Преглед изворни кода

Answer the outcome prompt by reacting to the Telegram message (#3129)

Thomansky пре 2 дана
родитељ
комит
1c316e2ad6
33 измењених фајлова са 2160 додато и 63 уклоњено
  1. 19 0
      backend/app/api/routes/notifications.py
  2. 41 0
      backend/app/core/database.py
  3. 12 0
      backend/app/main.py
  4. 41 0
      backend/app/models/notification.py
  5. 7 1
      backend/app/schemas/notification.py
  6. 164 53
      backend/app/services/notification_service.py
  7. 38 2
      backend/app/services/print_confirmation.py
  8. 393 0
      backend/app/services/telegram_reactions.py
  9. 940 0
      backend/tests/integration/test_telegram_reactions.py
  10. 9 1
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  11. 186 0
      frontend/src/__tests__/components/TelegramVerdictMode.test.tsx
  12. 39 0
      frontend/src/__tests__/i18n/sv-outcome-strings.test.ts
  13. 122 0
      frontend/src/__tests__/pages/ArchivesUnconfirmedFilter.test.tsx
  14. 5 0
      frontend/src/api/client.ts
  15. 24 1
      frontend/src/components/AddNotificationModal.tsx
  16. 6 0
      frontend/src/components/NotificationProviderCard.tsx
  17. 7 0
      frontend/src/i18n/locales/de.ts
  18. 7 0
      frontend/src/i18n/locales/en.ts
  19. 7 0
      frontend/src/i18n/locales/es.ts
  20. 7 0
      frontend/src/i18n/locales/fr.ts
  21. 7 0
      frontend/src/i18n/locales/it.ts
  22. 7 0
      frontend/src/i18n/locales/ja.ts
  23. 7 0
      frontend/src/i18n/locales/ko.ts
  24. 7 0
      frontend/src/i18n/locales/nl.ts
  25. 7 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 7 0
      frontend/src/i18n/locales/ru.ts
  27. 8 1
      frontend/src/i18n/locales/sv.ts
  28. 7 0
      frontend/src/i18n/locales/tr.ts
  29. 7 0
      frontend/src/i18n/locales/uk.ts
  30. 7 0
      frontend/src/i18n/locales/zh-CN.ts
  31. 7 0
      frontend/src/i18n/locales/zh-TW.ts
  32. 6 2
      frontend/src/pages/ArchivesPage.tsx
  33. 2 2
      frontend/src/utils/verdictSource.ts

+ 19 - 0
backend/app/api/routes/notifications.py

@@ -28,12 +28,25 @@ from backend.app.schemas.notification import (
     NotificationTestResponse,
 )
 from backend.app.services.notification_service import notification_service
+from backend.app.services.telegram_reactions import telegram_reaction_poller
 
 logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/notifications", tags=["notifications"])
 
 
+async def _resync_reaction_poller():
+    """Start/stop Telegram reaction polls after a provider changed (#3046).
+
+    Never fails the request: the provider row is already saved, and the
+    poller catches up on the next restart at worst.
+    """
+    try:
+        await telegram_reaction_poller.sync()
+    except Exception as e:
+        logger.warning("Telegram reaction poller resync failed: %s", e)
+
+
 def _provider_to_dict(provider: NotificationProvider) -> dict:
     """Convert a NotificationProvider model to a response dictionary."""
     return {
@@ -73,6 +86,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_plate_clear_required": provider.on_plate_clear_required,
         # Post-print outcome confirmation (#1898)
         "on_print_confirm_request": provider.on_print_confirm_request,
+        # Rows from before #3046 hold NULL here; "buttons" is what they did.
+        "telegram_verdict_mode": provider.telegram_verdict_mode or "buttons",
         # Bed cooled
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
@@ -169,6 +184,7 @@ async def create_notification_provider(
         on_plate_clear_required=provider_data.on_plate_clear_required,
         # Post-print outcome confirmation (#1898)
         on_print_confirm_request=provider_data.on_print_confirm_request,
+        telegram_verdict_mode=provider_data.telegram_verdict_mode,
         # Bed cooled
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete
@@ -201,6 +217,7 @@ async def create_notification_provider(
     await db.refresh(provider)
 
     logger.info("Created notification provider: %s (%s)", provider.name, provider.provider_type)
+    await _resync_reaction_poller()
 
     return _provider_to_dict(provider)
 
@@ -514,6 +531,7 @@ async def update_notification_provider(
     await db.refresh(provider)
 
     logger.info("Updated notification provider: %s", provider.name)
+    await _resync_reaction_poller()
 
     return _provider_to_dict(provider)
 
@@ -536,6 +554,7 @@ async def delete_notification_provider(
     await db.commit()
 
     logger.info("Deleted notification provider: %s", name)
+    await _resync_reaction_poller()
 
     return {"message": f"Notification provider '{name}' deleted"}
 

+ 41 - 0
backend/app/core/database.py

@@ -5072,6 +5072,47 @@ async def run_migrations(conn):
     # sending the verdict links as body text for every channel.
     await _migrate_confirm_prompt_body_template(conn)
 
+    # Migration: Telegram verdict-by-reaction (#3046). Per-provider mode plus
+    # the table of delivered prompts the reaction poller matches updates
+    # against. create_all covers fresh installs; this covers upgrades.
+    await _safe_execute(
+        conn, "ALTER TABLE notification_providers ADD COLUMN telegram_verdict_mode VARCHAR(16) DEFAULT 'buttons'"
+    )
+    await _safe_execute(
+        conn,
+        """
+        CREATE TABLE IF NOT EXISTS telegram_pending_verdicts (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            provider_id INTEGER NOT NULL REFERENCES notification_providers(id) ON DELETE CASCADE,
+            chat_id VARCHAR(64) NOT NULL,
+            message_id INTEGER NOT NULL,
+            archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
+            confirm_token VARCHAR(64),
+            has_caption BOOLEAN DEFAULT FALSE,
+            message_text TEXT,
+            created_at DATETIME
+        )
+        """
+        if is_sqlite()
+        else """
+        CREATE TABLE IF NOT EXISTS telegram_pending_verdicts (
+            id SERIAL PRIMARY KEY,
+            provider_id INTEGER NOT NULL REFERENCES notification_providers(id) ON DELETE CASCADE,
+            chat_id VARCHAR(64) NOT NULL,
+            message_id INTEGER NOT NULL,
+            archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
+            confirm_token VARCHAR(64),
+            has_caption BOOLEAN DEFAULT FALSE,
+            message_text TEXT,
+            created_at TIMESTAMP
+        )
+        """,
+    )
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_telegram_pending_verdicts_created_at ON telegram_pending_verdicts (created_at)",
+    )
+
     # Migration: rename the ha_sensor_alert template (#2824). "Home Assistant
     # Sensor Alert" was fine as a name while it was the only such template;
     # next to the new "Storage Location Sensor Alert" it no longer says which

+ 12 - 0
backend/app/main.py

@@ -148,6 +148,7 @@ from backend.app.services.spoolman_tracking import (
     store_print_data as _store_spoolman_print_data,
 )
 from backend.app.services.tasmota import tasmota_service
+from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.filament_types import printer_filament_type
@@ -3868,6 +3869,7 @@ async def dispatch_outcome_confirmation(
         good_url=good_url,
         reject_url=reject_url,
         confirm_url=confirm_url,
+        archive_id=archive_id,
     )
     return True
 
@@ -9852,6 +9854,15 @@ async def lifespan(app: FastAPI):
     # Start the notification digest scheduler
     notification_service.start_digest_scheduler()
 
+    # Start the Telegram reaction pollers (#3046), one per bot token used by a
+    # provider in reactions/both mode; the notification routes resync them.
+    # Never fatal: a bad provider row or a DB hiccup here costs reactions
+    # until the next provider save, not the whole startup.
+    try:
+        await telegram_reaction_poller.start()
+    except Exception as e:
+        logging.warning("Telegram reaction poller did not start: %s", e)
+
     # Start the GitHub backup scheduler
     await github_backup_service.start_scheduler()
 
@@ -9931,6 +9942,7 @@ async def lifespan(app: FastAPI):
     ha_sensor_manager.stop()
     location_ha_sensor_manager.stop()
     notification_service.stop_digest_scheduler()
+    await telegram_reaction_poller.aclose()
     github_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
     library_trash_service.stop_scheduler()

+ 41 - 0
backend/app/models/notification.py

@@ -105,6 +105,12 @@ class NotificationProvider(Base):
     # fires for prints where the user opted in per-job, so the provider-level
     # toggle exists to silence a channel, not to enable the feature.
     on_print_confirm_request = Column(Boolean, default=True)
+    # How a Telegram provider collects that verdict (#3046): inline URL
+    # buttons ("buttons", the original behaviour), a thumbs-up/down reaction
+    # on the message ("reactions"), or both. Reactions need no inbound
+    # connectivity — the poller fetches them — so they work without an
+    # external_url. Ignored for every other provider type.
+    telegram_verdict_mode = Column(String(16), default="buttons")
 
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
@@ -151,3 +157,38 @@ class NotificationProvider(Base):
     printer = relationship("Printer", back_populates="notification_providers")
     logs = relationship("NotificationLog", back_populates="provider", cascade="all, delete-orphan")
     digest_queue = relationship("NotificationDigestQueue", back_populates="provider", cascade="all, delete-orphan")
+    # ORM-level cascade like the two above: SQLite ships with foreign keys
+    # off, so the column's ON DELETE CASCADE alone would leave rows behind.
+    pending_verdicts = relationship("TelegramPendingVerdict", back_populates="provider", cascade="all, delete-orphan")
+
+
+class TelegramPendingVerdict(Base):
+    """A Telegram outcome prompt still waiting for a thumbs-up/down reaction (#3046).
+
+    One row per delivered ``print_confirm_request`` message on a provider in
+    "reactions" or "both" mode. The reaction poller matches incoming
+    ``message_reaction`` updates against (provider, chat, message) and deletes
+    the row once a verdict landed; rows older than a week are pruned.
+    """
+
+    __tablename__ = "telegram_pending_verdicts"
+
+    id = Column(Integer, primary_key=True, index=True)
+    provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
+    chat_id = Column(String(64), nullable=False)
+    message_id = Column(Integer, nullable=False)
+    archive_id = Column(Integer, ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=False)
+    # The archive's confirm_token when the prompt went out. A reaction counts
+    # only while that is still the archive's live token, i.e. exactly as long
+    # as the prompt's one-tap links would: a reprint clears the token, so an
+    # earlier run's message can no longer answer the new run.
+    confirm_token = Column(String(64), nullable=True)
+    # sendPhoto messages carry the prompt as a caption, which is edited with a
+    # different Bot API method than a plain text message.
+    has_caption = Column(Boolean, default=False)
+    # The Markdown we sent, so the confirmation edit can append to it — the
+    # reaction update does not carry the message body.
+    message_text = Column(Text, nullable=True)
+    created_at = Column(DateTime, default=datetime.utcnow, index=True)
+
+    provider = relationship("NotificationProvider", back_populates="pending_verdicts")

+ 7 - 1
backend/app/schemas/notification.py

@@ -1,7 +1,7 @@
 """Pydantic schemas for notification providers."""
 
 from datetime import datetime
-from typing import Any
+from typing import Any, Literal
 
 from pydantic import BaseModel, Field, field_validator, model_validator
 
@@ -87,6 +87,11 @@ class NotificationProviderBase(BaseModel):
         default=True,
         description="Notify with one-tap verdict links when a print that opted in asks for its outcome",
     )
+    # How a Telegram provider collects the verdict (#3046). Ignored elsewhere.
+    telegram_verdict_mode: Literal["buttons", "reactions", "both"] = Field(
+        default="buttons",
+        description="Telegram only: answer the outcome prompt via inline buttons, a thumbs reaction, or both",
+    )
 
     # Event triggers - Bed cooled
     on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
@@ -199,6 +204,7 @@ class NotificationProviderUpdate(BaseModel):
 
     # Event triggers - Post-print outcome confirmation (#1898)
     on_print_confirm_request: bool | None = None
+    telegram_verdict_mode: Literal["buttons", "reactions", "both"] | None = None
 
     # Event triggers - Bed cooled
     on_bed_cooled: bool | None = None

+ 164 - 53
backend/app/services/notification_service.py

@@ -17,7 +17,13 @@ import httpx
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider
+from backend.app.models.archive import PrintArchive
+from backend.app.models.notification import (
+    NotificationDigestQueue,
+    NotificationLog,
+    NotificationProvider,
+    TelegramPendingVerdict,
+)
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.services.print_confirmation import one_tap_url
 
@@ -30,6 +36,23 @@ logger = logging.getLogger(__name__)
 # bot signature for upstream WAFs.
 _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
 
+# Appended to a Telegram outcome prompt in reaction mode (#3046); the message
+# itself carries no buttons there, so the user needs telling how to answer.
+TELEGRAM_REACTION_HINT = "React with \U0001f44d or \U0001f44e to record the outcome."
+
+
+def telegram_markdown_escape(message: str) -> str:
+    """Escape underscores in the message body so Telegram Markdown parsing
+    doesn't break on job names like "A1_plate_8" or error codes like
+    "0300_0001". The title is already wrapped in *bold* markers, so only
+    escape after the first newline. Shared with the reaction poller, which
+    re-sends the stored body when it edits the prompt (#3046)."""
+    if "\n" in message:
+        title_part, body_part = message.split("\n", 1)
+        body_part = body_part.replace("_", "\\_")
+        return f"{title_part}\n{body_part}"
+    return message
+
 
 def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
     """Return True if ``response`` looks like a Cloudflare mitigation
@@ -557,11 +580,30 @@ class NotificationService:
         one-tap Good/Reject under the message. ``link_preview=False`` asks
         Telegram not to fetch the first URL in the text for a preview card.
         """
+        ok, status, _ = await self._send_telegram_message(
+            config, message, image_data=image_data, buttons=buttons, link_preview=link_preview
+        )
+        return ok, status
+
+    async def _send_telegram_message(
+        self,
+        config: dict,
+        message: str,
+        image_data: bytes | None = None,
+        buttons: list[dict] | None = None,
+        link_preview: bool = True,
+    ) -> tuple[bool, str, dict | None]:
+        """Send via Telegram and also hand back the Bot API ``result`` (the sent Message).
+
+        The outcome confirmation in reaction mode (#3046) needs the
+        ``message_id`` from it to recognise the reaction later; every other
+        caller goes through ``_send_telegram`` and ignores it.
+        """
         bot_token = config.get("bot_token", "").strip()
         chat_id = config.get("chat_id", "").strip()
 
         if not bot_token or not chat_id:
-            return False, "Bot token and chat ID are required"
+            return False, "Bot token and chat ID are required", None
 
         # Optional forum topic (#1518).  Telegram expects message_thread_id as an
         # integer in the JSON sendMessage body — a string 400s there even though
@@ -574,16 +616,9 @@ class NotificationService:
             try:
                 message_thread_id = int(thread_id_raw)
             except ValueError:
-                return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number"
+                return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number", None
 
-        # Escape underscores in the message body so Telegram Markdown
-        # parsing doesn't break on job names like "A1_plate_8" or error
-        # codes like "0300_0001".  The title is already wrapped in *bold*
-        # markers, so only escape after the first newline.
-        if "\n" in message:
-            title_part, body_part = message.split("\n", 1)
-            body_part = body_part.replace("_", "\\_")
-            message = f"{title_part}\n{body_part}"
+        message = telegram_markdown_escape(message)
 
         client = await self._get_client()
 
@@ -639,8 +674,81 @@ class NotificationService:
             failure = _failure(response)
 
         if failure:
-            return False, failure
-        return True, "Message sent successfully"
+            return False, failure, None
+        sent = response.json().get("result")
+        return True, "Message sent successfully", sent if isinstance(sent, dict) else None
+
+    async def _send_telegram_confirm_request(
+        self,
+        provider: NotificationProvider,
+        config: dict,
+        message: str,
+        db: AsyncSession | None,
+        image_data: bytes | None,
+        buttons: list[dict] | None,
+        archive_id: int | None,
+    ) -> tuple[bool, str]:
+        """Deliver the outcome prompt the way the provider's verdict mode asks (#3046).
+
+        "buttons" is the plain #1898 delivery. In "reactions" the inline
+        keyboard is dropped and the user answers with a thumbs-up/down on the
+        message itself; "both" keeps the keyboard as well. In either of those
+        the sent message is remembered in telegram_pending_verdicts, together
+        with the confirm token its links carry, so the reaction poller can map
+        the reaction back to the archive for as long as that token is live.
+        """
+        mode = provider.telegram_verdict_mode or "buttons"
+        # Telegram's servers GET the first URL in the text to build a preview
+        # card. An outcome prompt whose edited body still carries {good_url}
+        # would have that fetch answer the question before the operator saw
+        # it, so the preview is off for the prompt in every mode.
+        if mode == "buttons":
+            return await self._send_telegram(
+                config, message, image_data=image_data, buttons=buttons, link_preview=False
+            )
+
+        if mode == "reactions":
+            buttons = None
+        message = f"{message}\n\n{TELEGRAM_REACTION_HINT}"
+        ok, status, sent = await self._send_telegram_message(
+            config, message, image_data=image_data, buttons=buttons, link_preview=False
+        )
+        if not ok:
+            return ok, status
+
+        message_id = (sent or {}).get("message_id")
+        if not isinstance(message_id, int) or message_id <= 0:
+            logger.warning("Telegram did not return a message_id for the outcome prompt; reactions cannot be matched")
+            return ok, status
+        if db is None or archive_id is None:
+            return ok, status
+
+        try:
+            # dispatch_outcome_confirmation mints a live token right before
+            # sending. Without one the prompt's links are dead too, and a
+            # reaction must not outlive them, so there is nothing to remember.
+            archive = await db.get(PrintArchive, archive_id)
+            if archive is None or not archive.confirm_token or archive.confirm_token_used_at is not None:
+                logger.warning("Outcome prompt for archive %s went out without a live confirm token", archive_id)
+                return ok, status
+            db.add(
+                TelegramPendingVerdict(
+                    provider_id=provider.id,
+                    chat_id=str(((sent or {}).get("chat") or {}).get("id") or config.get("chat_id", "")).strip(),
+                    message_id=message_id,
+                    archive_id=archive_id,
+                    confirm_token=archive.confirm_token,
+                    has_caption=image_data is not None,
+                    message_text=message,
+                )
+            )
+            await db.commit()
+        except Exception as e:
+            # The prompt went out; losing the reaction mapping is a degraded
+            # outcome, not a failed notification.
+            logger.warning("Failed to record the Telegram outcome prompt for reactions: %s", e)
+            await db.rollback()
+        return ok, status
 
     async def _send_email(
         self,
@@ -1065,45 +1173,44 @@ class NotificationService:
                     config, title, message, image_data=image_data, url=supplement_url, url_title=supplement_url_title
                 )
             elif provider.provider_type == "telegram":
-                # Outcome confirmation (#1898): inline URL buttons under the
-                # message — one tap records the verdict via the capability
-                # link. Same absolute-URL requirement as the ntfy actions.
-                # Telegram has no way to POST, so this is the one affordance
-                # that opens a browser, and it is the one that gets the one-tap
-                # marker: the page submits itself only for a URL that came off
-                # a button. Telegram does not fetch inline-keyboard URLs and
-                # nothing else can read them, so the marker never reaches a
-                # scanner — which is the difference between this and trusting
-                # the User-Agent.
-                tg_buttons = None
-                _tg_good = (variables or {}).get("good_url")
-                _tg_reject = (variables or {}).get("reject_url")
-                if (
-                    event_type == "print_confirm_request"
-                    and _tg_good
-                    and _tg_reject
-                    and _tg_good.startswith("http")
-                    and _tg_reject.startswith("http")
-                ):
-                    tg_buttons = [
-                        {"text": "\U0001f44d Good", "url": one_tap_url(_tg_good)},
-                        {"text": "\U0001f44e Reject", "url": one_tap_url(_tg_reject)},
-                    ]
-                # Telegram's servers GET the first URL in the text to build a
-                # preview card. An outcome prompt whose edited body still
-                # carries {good_url} would have that fetch answer the question
-                # before the operator saw it, so the preview is off for this
-                # event. Only for this one, for the same reason as the Slack
-                # unfurl in _send_webhook: when the finish photo is too large to attach,
-                # the preview is how a {finish_photo_url} in a print_complete
-                # body still shows up as a photo in the chat.
-                return await self._send_telegram(
-                    config,
-                    f"*{title}*\n{message}",
-                    image_data=image_data,
-                    buttons=tg_buttons,
-                    link_preview=event_type != "print_confirm_request",
-                )
+                if event_type == "print_confirm_request":
+                    # Outcome confirmation (#1898): inline URL buttons under
+                    # the message — one tap records the verdict via the
+                    # capability link. Same absolute-URL requirement as the
+                    # ntfy actions. Telegram has no way to POST, so this is the
+                    # one affordance that opens a browser, and it is the one
+                    # that gets the one-tap marker: the page submits itself
+                    # only for a URL that came off a button. Telegram does not
+                    # fetch inline-keyboard URLs and nothing else can read
+                    # them, so the marker never reaches a scanner — which is
+                    # the difference between this and trusting the User-Agent.
+                    tg_buttons = None
+                    _tg_good = (variables or {}).get("good_url")
+                    _tg_reject = (variables or {}).get("reject_url")
+                    if _tg_good and _tg_reject and _tg_good.startswith("http") and _tg_reject.startswith("http"):
+                        tg_buttons = [
+                            {"text": "\U0001f44d Good", "url": one_tap_url(_tg_good)},
+                            {"text": "\U0001f44e Reject", "url": one_tap_url(_tg_reject)},
+                        ]
+                    # Telegram verdict mode (#3046): buttons, a reaction on
+                    # the message, or both. The link preview is off in all of
+                    # them (see _send_telegram_confirm_request).
+                    _archive_id = (variables or {}).get("archive_id")
+                    return await self._send_telegram_confirm_request(
+                        provider,
+                        config,
+                        f"*{title}*\n{message}",
+                        db,
+                        image_data,
+                        tg_buttons,
+                        _archive_id if isinstance(_archive_id, int) else None,
+                    )
+                # Every other event keeps Telegram's link preview, for the same
+                # reason as the Slack unfurl in _send_webhook: when the finish
+                # photo is too large to attach, the preview is how a
+                # {finish_photo_url} in a print_complete body still shows up as
+                # a photo in the chat.
+                return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
             elif provider.provider_type == "email":
                 # finish_photo_url is pulled from the rendered template variables
                 # so _send_email can detect whether the template referenced the
@@ -1503,6 +1610,7 @@ class NotificationService:
         good_url: str | None = None,
         reject_url: str | None = None,
         confirm_url: str | None = None,
+        archive_id: int | None = None,
     ):
         """Ask for a post-print outcome verdict (#1898).
 
@@ -1510,7 +1618,8 @@ class NotificationService:
         provider-level toggle exists to mute a channel, not to enable the
         feature. good_url / reject_url are the one-tap capability links
         (rendered as ntfy action buttons), confirm_url deep-links into the
-        archive's confirmation dialog in the web UI.
+        archive's confirmation dialog in the web UI. archive_id lets a Telegram
+        provider in reaction mode (#3046) tie the sent message to the archive.
         """
         providers = await self._get_providers_for_event(db, "on_print_confirm_request", printer_id)
         if not providers:
@@ -1529,6 +1638,8 @@ class NotificationService:
             variables["reject_url"] = reject_url
         if confirm_url:
             variables["confirm_url"] = confirm_url
+        if archive_id is not None:
+            variables["archive_id"] = archive_id
 
         image_data = None
         if archive_data:

+ 38 - 2
backend/app/services/print_confirmation.py

@@ -13,8 +13,8 @@ from backend.app.models.print_log import PrintLogEntry
 logger = logging.getLogger(__name__)
 
 # How a verdict reached the archive. 'reaction' is written by the Telegram
-# reaction handler (#3046), which lives on its own branch — listed here so the
-# vocabulary is complete and the UI can label it the day that lands.
+# reaction poller (#3046, services/telegram_reactions.py) through
+# apply_outcome_verdict below.
 VERDICT_SOURCES = ("dialog", "link", "plate_clear", "printer_card", "api", "reaction")
 
 # Link-preview unfurlers and mail-security scanners fetch every URL they find in
@@ -126,6 +126,42 @@ def retire_confirm_token(archive: PrintArchive) -> None:
         archive.confirm_token_used_at = datetime.now(timezone.utc)
 
 
+VERDICTS = ("good", "reject")
+
+
+async def apply_outcome_verdict(db: AsyncSession, archive: PrintArchive, verdict: str, *, source: str) -> bool:
+    """Record a verdict on an archive that has not been answered yet.
+
+    For verdict paths that arrive with nobody looking at the archive, like the
+    Telegram reaction poller (#3046). It writes what every verdict write in
+    this module writes: the archive's user_verdict with its provenance stamp,
+    the same value mirrored onto the latest PrintLogEntry (verdict-aware
+    statistics read the log, the #1444 mirror), and the one-tap capability
+    token spent.
+
+    First verdict wins. An archive that already carries one is left exactly
+    as it is and False is returned, so a late reaction cannot flip a decision
+    somebody made in the meantime. Deliberately does NOT commit.
+    """
+    if verdict not in VERDICTS:
+        raise ValueError(f"Verdict must be one of {VERDICTS}, got {verdict!r}")
+    if source not in VERDICT_SOURCES:
+        raise ValueError(f"Verdict source must be one of {VERDICT_SOURCES}, got {source!r}")
+    if archive.user_verdict is not None:
+        return False
+
+    archive.user_verdict = verdict
+    stamp_verdict(archive, source)
+    retire_confirm_token(archive)
+
+    latest_entry = await db.scalar(
+        select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
+    )
+    if latest_entry is not None:
+        latest_entry.user_verdict = verdict
+    return True
+
+
 async def resolve_pending_confirmation_as_good(db: AsyncSession, printer_id: int) -> int | None:
     """Mark the printer's latest pending-confirmation archive as good.
 

+ 393 - 0
backend/app/services/telegram_reactions.py

@@ -0,0 +1,393 @@
+"""Answer the post-print outcome prompt with a Telegram reaction (#3046).
+
+Reactions arrive as ``message_reaction`` updates, which the Bot API only
+hands out through ``getUpdates`` (long polling) or a webhook. Bambuddy
+polls: no inbound connectivity, no public URL, no certificate — the same
+outbound-only footing every other notification provider works from. One
+task per distinct bot token: several per-printer providers usually share a
+bot, and Telegram allows a single getUpdates consumer per bot (a second one
+gets a 409). The notifications routes resync the set whenever a provider
+changes.
+
+Reactions set by bots are never delivered by Telegram, and in groups the
+bot has to be an administrator to receive them at all — both documented Bot
+API behaviour, nothing to work around here.
+"""
+
+import asyncio
+import json
+import logging
+import time
+from datetime import timedelta
+
+import httpx
+from sqlalchemy import delete, select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.notification import NotificationProvider, TelegramPendingVerdict
+from backend.app.services.notification_service import _USER_AGENT, telegram_markdown_escape
+from backend.app.services.print_confirmation import apply_outcome_verdict
+from backend.app.utils.local_time import utcnow_naive
+
+logger = logging.getLogger(__name__)
+
+# Telegram holds a getUpdates call open for up to this long before answering
+# with an empty list. The HTTP read timeout below has to outlast it.
+GET_UPDATES_TIMEOUT = 50
+# A 409 means a webhook is set for the bot or a second poller is running; a
+# 401/404 means the bot token is wrong or the bot was deleted. None of them
+# clears by retrying, so the loop backs off well beyond the poll interval
+# instead of hammering the API every few seconds.
+CONFLICT_COOLDOWN = 300
+MAX_BACKOFF = 60
+PENDING_TTL = timedelta(days=7)
+PRUNE_INTERVAL = 3600
+
+REACTION_VERDICTS = {"\U0001f44d": "good", "\U0001f44e": "reject"}
+VERDICT_SUFFIX = {"good": "✅ marked as good", "reject": "❌ marked as reject"}
+
+
+def verdict_from_reaction(new_reaction: list) -> str | None:
+    """Map the reaction list of a message_reaction update to a verdict.
+
+    Only plain emoji reactions count; custom emoji and paid reactions are
+    ignored. The first thumbs in the list wins when several are set.
+    """
+    for reaction in new_reaction or []:
+        if not isinstance(reaction, dict) or reaction.get("type") != "emoji":
+            continue
+        verdict = REACTION_VERDICTS.get(reaction.get("emoji", ""))
+        if verdict:
+            return verdict
+    return None
+
+
+def _provider_reaction_token(provider: NotificationProvider) -> str | None:
+    """Bot token of a provider that should be polled, else None."""
+    if provider.provider_type != "telegram" or not provider.enabled:
+        return None
+    if (provider.telegram_verdict_mode or "buttons") == "buttons":
+        return None
+    config = json.loads(provider.config) if isinstance(provider.config, str) else (provider.config or {})
+    token = str(config.get("bot_token") or "").strip()
+    return token or None
+
+
+def _bot_label(bot_token: str) -> str:
+    """Log-safe name for a bot: the numeric id in front of the secret part."""
+    return f"bot {bot_token.split(':', 1)[0]}"
+
+
+def prompt_is_live(pending: TelegramPendingVerdict, archive: PrintArchive) -> bool:
+    """Whether a reaction on this prompt may still record a verdict.
+
+    Exactly as long as the prompt's one-tap links would: the token the
+    message went out with is still the archive's token and nothing has spent
+    it. A reprint clears the token and the next completion mints a new one,
+    so a reaction on an earlier run's message never answers a later run.
+    """
+    return (
+        bool(pending.confirm_token)
+        and pending.confirm_token == archive.confirm_token
+        and archive.confirm_token_used_at is None
+    )
+
+
+class TelegramReactionPoller:
+    def __init__(self):
+        # Everything is keyed by bot token, not provider: update_ids are a
+        # per-bot sequence and Telegram serves one getUpdates consumer per
+        # bot, so providers sharing a bot share one poll.
+        self._tasks: dict[str, asyncio.Task] = {}
+        # bot token -> ids of the providers whose prompts this poll answers.
+        self._providers: dict[str, set[int]] = {}
+        # bot token -> last update_id confirmed. Kept across task restarts
+        # within the process so a resync does not re-fetch handled updates.
+        self._offsets: dict[str, int] = {}
+        self._http_client: httpx.AsyncClient | None = None
+        self._session_factory = None
+
+    # ------------------------------------------------------------------
+    # Lifecycle
+    # ------------------------------------------------------------------
+
+    async def start(self):
+        await self.sync()
+
+    def stop(self) -> list[asyncio.Task]:
+        """Cancel every poll. Returns the cancelled tasks so aclose() can wait for them."""
+        tasks = list(self._tasks.values())
+        for task in tasks:
+            task.cancel()
+        if tasks:
+            logger.info("Telegram reaction poller stopped (%d bot(s))", len(tasks))
+        self._tasks.clear()
+        self._providers.clear()
+        return tasks
+
+    async def aclose(self):
+        """Shutdown: stop the polls, let them unwind, and release the HTTP client."""
+        tasks = self.stop()
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+        if self._http_client is not None and not self._http_client.is_closed:
+            await self._http_client.aclose()
+        self._http_client = None
+
+    async def sync(self):
+        """Reconcile the running tasks with the providers in the database.
+
+        Called at startup and after every provider create/update/delete.
+        Starts one task per bot token that at least one enabled provider in
+        reactions/both mode uses, re-points a running task at the providers
+        that now share its bot, and cancels the task of a bot no provider
+        wants any more (deleted, disabled, token changed, or switched back
+        to buttons). A running task never restarts on a provider edit alone.
+        """
+        async with self._sessions()() as db:
+            result = await db.execute(
+                select(NotificationProvider).where(NotificationProvider.provider_type == "telegram")
+            )
+            providers = list(result.scalars().all())
+
+        wanted: dict[str, set[int]] = {}
+        for provider in providers:
+            token = _provider_reaction_token(provider)
+            if token:
+                wanted.setdefault(token, set()).add(provider.id)
+
+        for token in list(self._tasks):
+            if token not in wanted:
+                self._tasks.pop(token).cancel()
+                self._providers.pop(token, None)
+                logger.info("Telegram reaction poll stopped for %s", _bot_label(token))
+
+        for token, provider_ids in wanted.items():
+            self._providers[token] = provider_ids
+            if token not in self._tasks:
+                self._tasks[token] = asyncio.create_task(
+                    self._run(token), name=f"telegram-reactions-{token.split(':', 1)[0]}"
+                )
+                logger.info(
+                    "Telegram reaction poll started for %s (provider(s) %s)",
+                    _bot_label(token),
+                    ", ".join(str(i) for i in sorted(provider_ids)),
+                )
+
+    def is_polling(self, provider_id: int) -> bool:
+        return any(provider_id in ids for token, ids in self._providers.items() if token in self._tasks)
+
+    # ------------------------------------------------------------------
+    # Plumbing
+    # ------------------------------------------------------------------
+
+    def _sessions(self):
+        if self._session_factory is not None:
+            return self._session_factory
+        from backend.app.core.database import async_session
+
+        return async_session
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        if self._http_client is None or self._http_client.is_closed:
+            self._http_client = httpx.AsyncClient(
+                timeout=httpx.Timeout(GET_UPDATES_TIMEOUT + 10.0, connect=5.0),
+                headers={"User-Agent": _USER_AGENT},
+            )
+        return self._http_client
+
+    async def _sleep(self, seconds: float):
+        await asyncio.sleep(seconds)
+
+    async def _run(self, bot_token: str):
+        backoff = 1.0
+        last_prune: float | None = None
+        while True:
+            try:
+                if last_prune is None or time.monotonic() - last_prune > PRUNE_INTERVAL:
+                    await self.prune_stale()
+                    last_prune = time.monotonic()
+                status = await self.poll_once(bot_token)
+            except asyncio.CancelledError:
+                raise
+            except Exception as e:
+                logger.warning("Telegram reaction poll failed for %s: %s", _bot_label(bot_token), e)
+                await self._sleep(backoff)
+                backoff = min(backoff * 2, MAX_BACKOFF)
+                continue
+            backoff = 1.0
+            if status != "ok":
+                await self._sleep(CONFLICT_COOLDOWN)
+
+    # ------------------------------------------------------------------
+    # One poll
+    # ------------------------------------------------------------------
+
+    async def poll_once(self, bot_token: str) -> str:
+        """One getUpdates round trip.
+
+        Returns "ok", or "conflict" / "rejected" for the permanent failures
+        (409 webhook or second poller; 401/404 bad or deleted bot token) that
+        are surfaced on the providers and cooled down instead of retried.
+        """
+        params: dict = {"timeout": GET_UPDATES_TIMEOUT, "allowed_updates": ["message_reaction"]}
+        offset = self._offsets.get(bot_token)
+        if offset is not None:
+            params["offset"] = offset + 1
+
+        client = await self._get_client()
+        response = await client.post(f"https://api.telegram.org/bot{bot_token}/getUpdates", json=params)
+
+        if response.status_code in (401, 404, 409):
+            description = ""
+            try:
+                description = response.json().get("description") or ""
+            except Exception:
+                pass
+            if response.status_code == 409:
+                status = "conflict"
+                message = (
+                    f"Telegram getUpdates conflict: {description or 'conflict'}. Reactions cannot be received "
+                    f"while a webhook is set for this bot or another poller is running"
+                )
+            else:
+                status = "rejected"
+                detail = f": {description}" if description else ""
+                message = (
+                    f"Telegram rejected the bot token (HTTP {response.status_code}{detail}). "
+                    f"Check the provider's bot token"
+                )
+            await self._record_error(bot_token, f"{message}; retrying in {CONFLICT_COOLDOWN // 60} minutes.")
+            return status
+        if response.status_code != 200:
+            raise RuntimeError(f"getUpdates HTTP {response.status_code}")
+        payload = response.json()
+        if not payload.get("ok"):
+            raise RuntimeError(f"getUpdates failed: {payload.get('description', 'unknown error')}")
+
+        for update in payload.get("result") or []:
+            update_id = update.get("update_id")
+            if isinstance(update_id, int):
+                self._offsets[bot_token] = max(self._offsets.get(bot_token, update_id), update_id)
+            reaction = update.get("message_reaction")
+            if not isinstance(reaction, dict):
+                continue
+            try:
+                await self._handle_reaction(bot_token, reaction)
+            except Exception as e:
+                logger.warning("Telegram reaction for %s could not be applied: %s", _bot_label(bot_token), e)
+        return "ok"
+
+    async def _record_error(self, bot_token: str, message: str):
+        """Surface a permanent poll failure on every provider that uses the bot."""
+        provider_ids = sorted(self._providers.get(bot_token, ()))
+        logger.error("%s (provider(s) %s): %s", _bot_label(bot_token), provider_ids or "-", message)
+        if not provider_ids:
+            return
+        try:
+            async with self._sessions()() as db:
+                result = await db.execute(select(NotificationProvider).where(NotificationProvider.id.in_(provider_ids)))
+                for provider in result.scalars().all():
+                    provider.last_error = message
+                    provider.last_error_at = utcnow_naive()
+                await db.commit()
+        except Exception as e:
+            logger.warning("Could not record the Telegram poll error on providers %s: %s", provider_ids, e)
+
+    async def _handle_reaction(self, bot_token: str, reaction: dict):
+        chat_id = str((reaction.get("chat") or {}).get("id", "")).strip()
+        message_id = reaction.get("message_id")
+        verdict = verdict_from_reaction(reaction.get("new_reaction") or [])
+        provider_ids = self._providers.get(bot_token)
+        if not chat_id or not isinstance(message_id, int) or verdict is None or not provider_ids:
+            return
+
+        async with self._sessions()() as db:
+            # message_ids are unique per chat, but two bots talking to the
+            # same person each see their own numbering, so the match stays
+            # scoped to the providers of this bot.
+            pending = await db.scalar(
+                select(TelegramPendingVerdict).where(
+                    TelegramPendingVerdict.provider_id.in_(sorted(provider_ids)),
+                    TelegramPendingVerdict.chat_id == chat_id,
+                    TelegramPendingVerdict.message_id == message_id,
+                )
+            )
+            if pending is None:
+                return
+
+            archive = await db.get(PrintArchive, pending.archive_id)
+            if archive is None or not prompt_is_live(pending, archive):
+                ignored = "the prompt belongs to an earlier run or was answered elsewhere"
+            elif not await apply_outcome_verdict(db, archive, verdict, source="reaction"):
+                ignored = "verdict already recorded"
+            else:
+                ignored = None
+            has_caption = bool(pending.has_caption)
+            text = pending.message_text
+            archive_id = pending.archive_id
+            # Whatever happened to the archive, this message is answered.
+            await db.delete(pending)
+            await db.commit()
+
+        if ignored:
+            logger.info("Telegram reaction on archive %s ignored: %s", archive_id, ignored)
+            return
+        logger.info("[#3046] Telegram reaction marked archive %s as '%s'", archive_id, verdict)
+        await self._confirm_on_message(bot_token, chat_id, message_id, has_caption, text, verdict)
+
+    async def _confirm_on_message(
+        self, bot_token: str, chat_id: str, message_id: int, has_caption: bool, text: str | None, verdict: str
+    ):
+        """Append the verdict to the prompt so the chat shows it was taken.
+
+        Editing also drops the inline keyboard in "both" mode — the links
+        are dead once a verdict landed. The link preview stays off, as on the
+        original send. Best effort: a failed edit is logged, the verdict
+        itself is already committed.
+        """
+        suffix = VERDICT_SUFFIX[verdict]
+        client = await self._get_client()
+        try:
+            if text and has_caption:
+                # A photo's caption gets no link preview of its own.
+                method = "editMessageCaption"
+                payload = {
+                    "chat_id": chat_id,
+                    "message_id": message_id,
+                    "caption": telegram_markdown_escape(f"{text}\n\n{suffix}"),
+                    "parse_mode": "Markdown",
+                }
+            elif text:
+                method = "editMessageText"
+                payload = {
+                    "chat_id": chat_id,
+                    "message_id": message_id,
+                    "text": telegram_markdown_escape(f"{text}\n\n{suffix}"),
+                    "parse_mode": "Markdown",
+                    "disable_web_page_preview": True,
+                }
+            else:
+                method = "sendMessage"
+                payload = {
+                    "chat_id": chat_id,
+                    "text": suffix,
+                    "reply_parameters": {"message_id": message_id},
+                    "disable_web_page_preview": True,
+                }
+            response = await client.post(f"https://api.telegram.org/bot{bot_token}/{method}", json=payload)
+            if response.status_code != 200 or not response.json().get("ok"):
+                logger.warning("Telegram %s failed after reaction verdict: HTTP %s", method, response.status_code)
+        except Exception as e:
+            logger.warning("Telegram confirmation edit failed: %s", e)
+
+    async def prune_stale(self):
+        """Forget prompts nobody reacted to within a week."""
+        async with self._sessions()() as db:
+            await db.execute(
+                delete(TelegramPendingVerdict).where(TelegramPendingVerdict.created_at < utcnow_naive() - PENDING_TTL)
+            )
+            await db.commit()
+
+
+telegram_reaction_poller = TelegramReactionPoller()

+ 940 - 0
backend/tests/integration/test_telegram_reactions.py

@@ -0,0 +1,940 @@
+"""Answering the outcome prompt with a Telegram reaction (#3046).
+
+The Bot API is stood in for at the httpx layer (the same ``_http_client``
+swap the forum-topic tests use), the database is the real test engine: the
+poller opens its own sessions, so the tests hand it the test session
+factory instead of the module-level one.
+"""
+
+from datetime import timedelta
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.notification import TelegramPendingVerdict
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.services.notification_service import TELEGRAM_REACTION_HINT, NotificationService, notification_service
+from backend.app.services.print_confirmation import apply_outcome_verdict, one_tap_url
+from backend.app.services.telegram_reactions import (
+    CONFLICT_COOLDOWN,
+    MAX_BACKOFF,
+    TelegramReactionPoller,
+    verdict_from_reaction,
+)
+from backend.app.utils.local_time import utcnow_naive
+
+BOT_TOKEN = "123456:AAbbCC"
+CHAT_ID = "-1002520100736"
+TELEGRAM_CONFIG = {"bot_token": BOT_TOKEN, "chat_id": CHAT_ID}
+THUMBS_UP = "\U0001f44d"
+THUMBS_DOWN = "\U0001f44e"
+
+
+class _FakeBotApi:
+    """Stand-in for httpx.AsyncClient with scripted responses, in call order."""
+
+    def __init__(self, responses=None):
+        self.is_closed = False
+        self.calls: list[dict] = []
+        self.responses = list(responses or [])
+
+    async def post(self, url, json=None, data=None, files=None):
+        self.calls.append({"url": url, "json": json, "data": data, "files": files})
+        if self.responses:
+            nxt = self.responses.pop(0)
+            if isinstance(nxt, Exception):
+                raise nxt
+            return nxt
+        return httpx.Response(200, json={"ok": True, "result": []})
+
+    async def aclose(self):
+        self.is_closed = True
+
+    def urls(self) -> list[str]:
+        return [c["url"].rsplit("/", 1)[-1] for c in self.calls]
+
+
+def _updates(*updates: dict) -> httpx.Response:
+    return httpx.Response(200, json={"ok": True, "result": list(updates)})
+
+
+def _reaction(update_id: int, message_id: int, emoji: str | None, chat_id: str = CHAT_ID, kind: str = "emoji") -> dict:
+    new_reaction = [] if emoji is None else [{"type": kind, "emoji": emoji}]
+    return {
+        "update_id": update_id,
+        "message_reaction": {
+            "chat": {"id": int(chat_id)},
+            "message_id": message_id,
+            "user": {"id": 42},
+            "date": 1_700_000_000,
+            "old_reaction": [],
+            "new_reaction": new_reaction,
+        },
+    }
+
+
+@pytest.fixture
+async def poller(test_engine):
+    p = TelegramReactionPoller()
+    p._session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    p._http_client = _FakeBotApi()
+    yield p
+    await p.aclose()
+
+
+def _track(poller: TelegramReactionPoller, *providers, token: str = BOT_TOKEN):
+    """What sync() would record for these providers, without starting a task."""
+    poller._providers.setdefault(token, set()).update(p.id for p in providers)
+
+
+@pytest.fixture
+def telegram_provider(notification_provider_factory):
+    async def _create(**kwargs):
+        defaults = {
+            "name": "Telegram",
+            "provider_type": "telegram",
+            "config": TELEGRAM_CONFIG,
+            "telegram_verdict_mode": "reactions",
+        }
+        defaults.update(kwargs)
+        return await notification_provider_factory(**defaults)
+
+    return _create
+
+
+@pytest.fixture
+def pending_factory(db_session):
+    async def _create(provider_id: int, archive_id: int, message_id: int = 777, **kwargs):
+        # A prompt only goes out with a live confirm token (the dispatch mints
+        # one), and the send records it on the row: do the same here.
+        if "confirm_token" not in kwargs:
+            archive = await db_session.get(PrintArchive, archive_id)
+            if archive.confirm_token is None:
+                archive.confirm_token = f"tok-{archive_id}-{message_id}"
+            kwargs["confirm_token"] = archive.confirm_token
+        defaults = {
+            "provider_id": provider_id,
+            "chat_id": CHAT_ID,
+            "message_id": message_id,
+            "archive_id": archive_id,
+            "has_caption": False,
+            "message_text": "*Outcome?*\nTest_Print on X1C",
+        }
+        defaults.update(kwargs)
+        row = TelegramPendingVerdict(**defaults)
+        db_session.add(row)
+        await db_session.commit()
+        await db_session.refresh(row)
+        return row
+
+    return _create
+
+
+async def _pending_count(db_session) -> int:
+    return len((await db_session.execute(select(TelegramPendingVerdict))).scalars().all())
+
+
+# ---------------------------------------------------------------------------
+# Shared verdict helper
+# ---------------------------------------------------------------------------
+
+
+class TestApplyOutcomeVerdict:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_first_verdict_wins_and_mirrors(self, db_session, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="tok")
+
+        assert await apply_outcome_verdict(db_session, archive, "reject", source="reaction") is True
+        await db_session.commit()
+        assert archive.user_verdict == "reject"
+        assert archive.user_verdict_source == "reaction"
+        assert archive.user_verdict_at is not None
+        # The capability is spent, not destroyed: the one-tap route can still
+        # tell whoever taps an old button that this print was answered.
+        assert archive.confirm_token == "tok"
+        assert archive.confirm_token_used_at is not None
+        entry = await db_session.scalar(
+            select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc())
+        )
+        assert entry.user_verdict == "reject"
+
+        # A later verdict from any path is a no-op.
+        assert await apply_outcome_verdict(db_session, archive, "good", source="reaction") is False
+        assert archive.user_verdict == "reject"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_unknown_verdict(self, db_session, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        with pytest.raises(ValueError):
+            await apply_outcome_verdict(db_session, archive, "meh", source="reaction")
+        with pytest.raises(ValueError):
+            await apply_outcome_verdict(db_session, archive, "good", source="carrier-pigeon")
+
+
+# ---------------------------------------------------------------------------
+# Sending the prompt
+# ---------------------------------------------------------------------------
+
+
+class _SendCapture:
+    def __init__(self, message_id=555):
+        self.is_closed = False
+        self.calls: list[dict] = []
+        self.message_id = message_id
+
+    async def post(self, url, data=None, files=None, json=None):
+        self.calls.append({"url": url, "data": data, "files": files, "json": json})
+        result = {"message_id": self.message_id, "chat": {"id": int(CHAT_ID)}}
+        return httpx.Response(200, json={"ok": True, "result": result})
+
+
+CONFIRM_VARIABLES = {"good_url": "http://bambuddy.local/good", "reject_url": "http://bambuddy.local/reject"}
+
+
+class TestSendingThePrompt:
+    async def _send(self, provider, db_session, archive_id, image_data=None):
+        service = NotificationService()
+        client = _SendCapture()
+        service._http_client = client
+        ok, _ = await service._send_to_provider(
+            provider,
+            "Outcome?",
+            "How did it go",
+            db_session,
+            image_data=image_data,
+            event_type="print_confirm_request",
+            variables={**CONFIRM_VARIABLES, "archive_id": archive_id},
+        )
+        assert ok
+        return client
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reactions_mode_drops_buttons_and_records_message(
+        self, db_session, printer_factory, archive_factory, telegram_provider
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="live-token")
+        provider = await telegram_provider(telegram_verdict_mode="reactions")
+
+        client = await self._send(provider, db_session, archive.id)
+
+        body = client.calls[0]["json"]
+        assert "reply_markup" not in body
+        assert TELEGRAM_REACTION_HINT in body["text"]
+
+        row = await db_session.scalar(select(TelegramPendingVerdict))
+        assert row is not None
+        assert row.provider_id == provider.id
+        assert row.archive_id == archive.id
+        assert row.message_id == 555
+        assert row.chat_id == CHAT_ID
+        assert row.confirm_token == "live-token", "the token the prompt's links carry"
+        assert row.has_caption is False
+        assert row.message_text and TELEGRAM_REACTION_HINT in row.message_text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_both_mode_keeps_buttons_and_records_message(
+        self, db_session, printer_factory, archive_factory, telegram_provider
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="live-token")
+        provider = await telegram_provider(telegram_verdict_mode="both")
+
+        client = await self._send(provider, db_session, archive.id, image_data=b"\x89PNG")
+
+        call = client.calls[0]
+        assert call["url"].endswith("/sendPhoto")
+        assert "inline_keyboard" in call["data"]["reply_markup"]
+        row = await db_session.scalar(select(TelegramPendingVerdict))
+        assert row is not None and row.has_caption is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_buttons_mode_is_unchanged(self, db_session, printer_factory, archive_factory, telegram_provider):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider(telegram_verdict_mode="buttons")
+
+        client = await self._send(provider, db_session, archive.id)
+
+        body = client.calls[0]["json"]
+        assert body["reply_markup"] == {
+            "inline_keyboard": [
+                [
+                    {"text": f"{THUMBS_UP} Good", "url": one_tap_url(CONFIRM_VARIABLES["good_url"])},
+                    {"text": f"{THUMBS_DOWN} Reject", "url": one_tap_url(CONFIRM_VARIABLES["reject_url"])},
+                ]
+            ]
+        }
+        assert TELEGRAM_REACTION_HINT not in body["text"]
+        assert await _pending_count(db_session) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_prompt_without_a_live_token_records_nothing(
+        self, db_session, printer_factory, archive_factory, telegram_provider
+    ):
+        """The prompt's links are dead without a live token, and a reaction
+        must not outlive them: nothing to remember."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id, confirm_requested=True, confirm_token="spent", confirm_token_used_at=utcnow_naive()
+        )
+        provider = await telegram_provider(telegram_verdict_mode="reactions")
+
+        await self._send(provider, db_session, archive.id)
+
+        assert await _pending_count(db_session) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_missing_message_id_records_nothing(
+        self, db_session, printer_factory, archive_factory, telegram_provider
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider(telegram_verdict_mode="reactions")
+
+        service = NotificationService()
+        client = _SendCapture(message_id=0)
+        service._http_client = client
+        ok, _ = await service._send_to_provider(
+            provider,
+            "Outcome?",
+            "How did it go",
+            db_session,
+            event_type="print_confirm_request",
+            variables={**CONFIRM_VARIABLES, "archive_id": archive.id},
+        )
+        assert ok
+        assert await _pending_count(db_session) == 0
+
+
+# ---------------------------------------------------------------------------
+# Poller
+# ---------------------------------------------------------------------------
+
+
+class TestVerdictFromReaction:
+    def test_thumbs_map_and_others_ignored(self):
+        assert verdict_from_reaction([{"type": "emoji", "emoji": THUMBS_UP}]) == "good"
+        assert verdict_from_reaction([{"type": "emoji", "emoji": THUMBS_DOWN}]) == "reject"
+        assert verdict_from_reaction([{"type": "emoji", "emoji": "❤"}]) is None
+        assert verdict_from_reaction([{"type": "custom_emoji", "custom_emoji_id": "1"}]) is None
+        assert verdict_from_reaction([]) is None
+        assert verdict_from_reaction([{"type": "emoji", "emoji": "❤"}, {"type": "emoji", "emoji": THUMBS_UP}]) == "good"
+
+
+class TestPoller:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_thumbs_up_marks_good_deletes_row_and_edits_message(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="tok")
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=777)
+        poller._http_client = _FakeBotApi([_updates(_reaction(10, 777, THUMBS_UP))])
+
+        assert await poller.poll_once(BOT_TOKEN) == "ok"
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "good"
+        assert archive.user_verdict_source == "reaction"
+        assert archive.confirm_token_used_at is not None
+        entry = await db_session.scalar(
+            select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc())
+        )
+        assert entry.user_verdict == "good"
+        assert await _pending_count(db_session) == 0
+
+        assert poller._http_client.urls() == ["getUpdates", "editMessageText"]
+        edit = poller._http_client.calls[1]["json"]
+        assert edit["chat_id"] == CHAT_ID and edit["message_id"] == 777
+        assert edit["text"].endswith("✅ marked as good")
+        assert "Test\\_Print" in edit["text"], "stored body is re-escaped for Markdown"
+        assert "reply_markup" not in edit
+        assert edit["disable_web_page_preview"] is True, "the edit keeps the preview off, like the send"
+        # The getUpdates that follows confirms the update we handled.
+        assert poller._offsets[BOT_TOKEN] == 10
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_getupdates_request_shape(self, poller, telegram_provider):
+        provider = await telegram_provider()
+        _track(poller, provider)
+        poller._offsets[BOT_TOKEN] = 41
+        await poller.poll_once(BOT_TOKEN)
+        call = poller._http_client.calls[0]
+        assert call["url"] == f"https://api.telegram.org/bot{BOT_TOKEN}/getUpdates"
+        assert call["json"] == {"timeout": 50, "allowed_updates": ["message_reaction"], "offset": 42}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_offset_belongs_to_the_bot_not_the_provider(self, poller, telegram_provider):
+        """update_ids are a per-bot sequence: after a provider is re-pointed at
+        another bot, the first getUpdates for it must not carry the old bot's
+        offset, or the new bot's lower ids are confirmed away unseen."""
+        provider = await telegram_provider()
+        _track(poller, provider)
+        poller._offsets[BOT_TOKEN] = 900_000_123
+
+        _track(poller, provider, token="999:newtoken")
+        await poller.poll_once("999:newtoken")
+
+        call = poller._http_client.calls[0]
+        assert call["url"].startswith("https://api.telegram.org/bot999:newtoken/")
+        assert "offset" not in call["json"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_thumbs_down_rejects(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=778)
+        poller._http_client = _FakeBotApi([_updates(_reaction(11, 778, THUMBS_DOWN))])
+
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "reject"
+        assert poller._http_client.calls[1]["json"]["text"].endswith("❌ marked as reject")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_caption_message_is_edited_with_editMessageCaption(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=779, has_caption=True)
+        poller._http_client = _FakeBotApi([_updates(_reaction(12, 779, THUMBS_UP))])
+
+        await poller.poll_once(BOT_TOKEN)
+
+        assert poller._http_client.urls() == ["getUpdates", "editMessageCaption"]
+        assert poller._http_client.calls[1]["json"]["caption"].endswith("✅ marked as good")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_message_is_ignored(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=780)
+        poller._http_client = _FakeBotApi(
+            [_updates(_reaction(13, 999, THUMBS_UP), _reaction(14, 780, THUMBS_UP, chat_id="-100999"))]
+        )
+
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert await _pending_count(db_session) == 1
+        assert poller._http_client.urls() == ["getUpdates"]
+        assert poller._offsets[BOT_TOKEN] == 14
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_other_emoji_and_removed_reaction_are_ignored(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=781)
+        poller._http_client = _FakeBotApi([_updates(_reaction(15, 781, "❤"), _reaction(16, 781, None))])
+
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert await _pending_count(db_session) == 1, "the prompt stays open for a later thumbs"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reaction_after_a_verdict_is_a_noop(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        """Someone already answered in the web UI: the late reaction neither
+        flips the verdict nor edits the message, but the row is retired."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, user_verdict="reject")
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=782)
+        poller._http_client = _FakeBotApi([_updates(_reaction(17, 782, THUMBS_UP))])
+
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "reject"
+        assert await _pending_count(db_session) == 0
+        assert poller._http_client.urls() == ["getUpdates"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_earlier_runs_prompt_cannot_answer_a_reprint(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, monkeypatch
+    ):
+        """Printing an archive again resets its verdict and confirm token, so
+        run 1's links stop working. Run 1's unanswered message has to stop with
+        them: a thumbs on it while run 2 prints records nothing, and run 2
+        still asks its own question, which its own message then answers."""
+        from backend.app.main import dispatch_outcome_confirmation
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        pending_columns = select(TelegramPendingVerdict.message_id, TelegramPendingVerdict.confirm_token)
+
+        # Run 1 completes and asks; nobody answers.
+        monkeypatch.setattr(notification_service, "_http_client", _SendCapture(message_id=901))
+        assert await dispatch_outcome_confirmation(db_session, printer.id, printer.name, {}, archive.id, None)
+        run1_token = archive.confirm_token
+        assert (await db_session.execute(pending_columns)).one() == (901, run1_token)
+
+        # Run 2 starts: the reprint reset in main.py (the expected_archive_id path).
+        archive.status = "printing"
+        archive.user_verdict = None
+        archive.user_verdict_source = None
+        archive.user_verdict_at = None
+        archive.confirm_token = None
+        archive.confirm_token_used_at = None
+        await db_session.commit()
+
+        # A thumbs-up on run 1's message while run 2 is printing.
+        poller._http_client = _FakeBotApi([_updates(_reaction(30, 901, THUMBS_UP))])
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert poller._http_client.urls() == ["getUpdates"], "the stale message is not edited"
+        assert await _pending_count(db_session) == 0
+
+        # Run 2 completes and asks with a token of its own.
+        archive.status = "completed"
+        await db_session.commit()
+        monkeypatch.setattr(notification_service, "_http_client", _SendCapture(message_id=902))
+        assert await dispatch_outcome_confirmation(db_session, printer.id, printer.name, {}, archive.id, None)
+        assert archive.confirm_token not in (None, run1_token)
+        assert (await db_session.execute(pending_columns)).one() == (902, archive.confirm_token)
+
+        # Its own message answers it.
+        poller._http_client = _FakeBotApi([_updates(_reaction(31, 902, THUMBS_DOWN))])
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "reject"
+        assert archive.user_verdict_source == "reaction"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_prompt_whose_link_was_used_is_retired(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        """A reaction stops working exactly when a link would: once the token
+        is spent, or replaced by a newer prompt's, the message is done."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, confirm_token="old")
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=785)
+        archive.confirm_token = "newer"
+        await db_session.commit()
+        poller._http_client = _FakeBotApi([_updates(_reaction(32, 785, THUMBS_UP))])
+
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert await _pending_count(db_session) == 0
+        assert poller._http_client.urls() == ["getUpdates"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_second_reaction_on_same_message_is_a_noop(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=783)
+        poller._http_client = _FakeBotApi(
+            [_updates(_reaction(18, 783, THUMBS_UP)), _updates(_reaction(19, 783, THUMBS_DOWN))]
+        )
+
+        await poller.poll_once(BOT_TOKEN)
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "good"
+        assert poller._http_client.urls() == ["getUpdates", "editMessageText", "getUpdates"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_failed_edit_does_not_lose_the_verdict(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=784)
+        poller._http_client = _FakeBotApi(
+            [_updates(_reaction(20, 784, THUMBS_UP)), httpx.ConnectError("telegram gone")]
+        )
+
+        assert await poller.poll_once(BOT_TOKEN) == "ok"
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict == "good"
+        assert await _pending_count(db_session) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_conflict_is_surfaced_and_cooled_down(self, poller, db_session, telegram_provider):
+        """409 = webhook set or a second poller: report it on the provider and
+        wait the cooldown instead of looping on the next getUpdates."""
+        provider = await telegram_provider()
+        _track(poller, provider)
+        conflict = httpx.Response(
+            409,
+            json={"ok": False, "error_code": 409, "description": "Conflict: terminated by other getUpdates request"},
+        )
+        poller._http_client = _FakeBotApi([conflict, conflict])
+
+        assert await poller.poll_once(BOT_TOKEN) == "conflict"
+
+        await db_session.refresh(provider)
+        assert "Conflict: terminated by other getUpdates request" in provider.last_error
+        assert "webhook" in provider.last_error
+        assert provider.last_error_at is not None
+
+        sleeps: list[float] = []
+
+        async def _sleep(seconds):
+            sleeps.append(seconds)
+            raise CancelledForTest
+
+        poller._sleep = _sleep
+        with pytest.raises(CancelledForTest):
+            await poller._run(BOT_TOKEN)
+        assert sleeps == [CONFLICT_COOLDOWN]
+        assert len(poller._http_client.calls) == 2, "one getUpdates per cooldown, not a tight loop"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bad_bot_token_is_surfaced_and_cooled_down(self, poller, db_session, telegram_provider):
+        """401/404 = wrong or deleted bot token: it never fixes itself, so it
+        lands on the provider card and waits the cooldown instead of a warning
+        every minute forever."""
+        provider = await telegram_provider()
+        _track(poller, provider)
+        unauthorized = httpx.Response(401, json={"ok": False, "error_code": 401, "description": "Unauthorized"})
+        poller._http_client = _FakeBotApi([unauthorized, unauthorized])
+
+        assert await poller.poll_once(BOT_TOKEN) == "rejected"
+
+        await db_session.refresh(provider)
+        assert "HTTP 401" in provider.last_error
+        assert "Unauthorized" in provider.last_error
+        assert "bot token" in provider.last_error
+        assert provider.last_error_at is not None
+
+        sleeps: list[float] = []
+
+        async def _sleep(seconds):
+            sleeps.append(seconds)
+            raise CancelledForTest
+
+        poller._sleep = _sleep
+        with pytest.raises(CancelledForTest):
+            await poller._run(BOT_TOKEN)
+        assert sleeps == [CONFLICT_COOLDOWN]
+        assert len(poller._http_client.calls) == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_conflict_is_recorded_on_every_provider_of_the_bot(self, poller, db_session, telegram_provider):
+        first = await telegram_provider(name="X1C")
+        second = await telegram_provider(name="P1S")
+        _track(poller, first, second)
+        conflict = httpx.Response(409, json={"ok": False, "error_code": 409, "description": "Conflict"})
+        poller._http_client = _FakeBotApi([conflict])
+
+        assert await poller.poll_once(BOT_TOKEN) == "conflict"
+
+        for provider in (first, second):
+            await db_session.refresh(provider)
+            assert provider.last_error and "Conflict" in provider.last_error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_network_errors_back_off_exponentially(self, poller, telegram_provider):
+        provider = await telegram_provider()
+        _track(poller, provider)
+        poller._http_client = _FakeBotApi([httpx.ConnectError("down")] * 10)
+        sleeps: list[float] = []
+
+        async def _sleep(seconds):
+            sleeps.append(seconds)
+            if len(sleeps) == 8:
+                raise CancelledForTest
+
+        poller._sleep = _sleep
+        with pytest.raises(CancelledForTest):
+            await poller._run(BOT_TOKEN)
+        assert sleeps == [1, 2, 4, 8, 16, 32, 60, 60]
+        assert max(sleeps) == MAX_BACKOFF
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_prune_drops_rows_older_than_a_week(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=1, created_at=utcnow_naive() - timedelta(days=8))
+        fresh = await pending_factory(provider.id, archive.id, message_id=2)
+
+        await poller.prune_stale()
+
+        rows = (await db_session.execute(select(TelegramPendingVerdict))).scalars().all()
+        assert [r.id for r in rows] == [fresh.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_follows_provider_mode_enabled_and_token(self, poller, db_session, telegram_provider):
+        never = _NeverAnswers()
+        poller._http_client = never
+        reactions = await telegram_provider(name="R", telegram_verdict_mode="reactions")
+        buttons = await telegram_provider(name="B", telegram_verdict_mode="buttons")
+        disabled = await telegram_provider(name="D", telegram_verdict_mode="both", enabled=False)
+
+        await poller.sync()
+        assert poller.is_polling(reactions.id)
+        assert not poller.is_polling(buttons.id)
+        assert not poller.is_polling(disabled.id)
+        assert set(poller._tasks) == {BOT_TOKEN}
+
+        # The bot keeps its single poll while any provider wants it; the
+        # provider set behind it follows the edits.
+        first_task = poller._tasks[BOT_TOKEN]
+        reactions.telegram_verdict_mode = "buttons"
+        buttons.telegram_verdict_mode = "both"
+        await db_session.commit()
+        await poller.sync()
+        assert not poller.is_polling(reactions.id)
+        assert poller.is_polling(buttons.id)
+        assert poller._tasks[BOT_TOKEN] is first_task
+        assert poller._providers[BOT_TOKEN] == {buttons.id}
+
+        # A token change moves the provider to a new bot: the old bot's poll
+        # stops because nobody uses it any more, a fresh one starts.
+        buttons.config = '{"bot_token": "999:newtoken", "chat_id": "1"}'
+        await db_session.commit()
+        await poller.sync()
+        assert first_task.cancelled() or first_task.cancelling()
+        assert set(poller._tasks) == {"999:newtoken"}
+        assert poller.is_polling(buttons.id)
+
+        await poller.aclose()
+        assert poller._tasks == {}
+        assert never.is_closed
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_providers_sharing_a_bot_share_one_poll(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        """The usual farm layout: one bot, one provider per printer. Telegram
+        serves a single getUpdates consumer per bot, so both providers ride
+        one task and a reaction to either prompt lands on its archive."""
+        poller._http_client = _NeverAnswers()
+        x1c = await printer_factory(name="X1C")
+        p1s = await printer_factory(name="P1S")
+        first = await telegram_provider(name="X1C", printer_id=x1c.id, config={"bot_token": BOT_TOKEN, "chat_id": "1"})
+        second = await telegram_provider(name="P1S", printer_id=p1s.id, config={"bot_token": BOT_TOKEN, "chat_id": "2"})
+
+        await poller.sync()
+        assert len(poller._tasks) == 1
+        assert poller.is_polling(first.id) and poller.is_polling(second.id)
+        poller.stop()
+        _track(poller, first, second)
+
+        archive_a = await archive_factory(x1c.id, confirm_requested=True)
+        archive_b = await archive_factory(p1s.id, confirm_requested=True)
+        await pending_factory(first.id, archive_a.id, message_id=100, chat_id="1")
+        await pending_factory(second.id, archive_b.id, message_id=100, chat_id="2")
+        poller._http_client = _FakeBotApi(
+            [_updates(_reaction(30, 100, THUMBS_DOWN, chat_id="2"), _reaction(31, 100, THUMBS_UP, chat_id="1"))]
+        )
+
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive_a)
+        await db_session.refresh(archive_b)
+        assert archive_a.user_verdict == "good"
+        assert archive_b.user_verdict == "reject"
+        assert await _pending_count(db_session) == 0
+        assert poller._http_client.urls() == ["getUpdates", "editMessageText", "editMessageText"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reaction_for_another_bots_provider_is_not_matched(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        """Two bots in private chat with the same person number their
+        messages independently, so a match is scoped to the bot's providers."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        other = await telegram_provider(name="other bot", config={"bot_token": "999:other", "chat_id": CHAT_ID})
+        mine = await telegram_provider(name="mine")
+        _track(poller, mine)
+        _track(poller, other, token="999:other")
+        await pending_factory(other.id, archive.id, message_id=5)
+        poller._http_client = _FakeBotApi([_updates(_reaction(40, 5, THUMBS_UP))])
+
+        await poller.poll_once(BOT_TOKEN)
+
+        await db_session.refresh(archive)
+        assert archive.user_verdict is None
+        assert await _pending_count(db_session) == 1
+
+
+class CancelledForTest(BaseException):
+    """Escape hatch out of the endless poll loop without tripping its handlers."""
+
+
+class _NeverAnswers:
+    """getUpdates that stays open forever, like a quiet chat would."""
+
+    def __init__(self):
+        self.is_closed = False
+
+    async def post(self, url, json=None, data=None, files=None):
+        import asyncio
+
+        await asyncio.Event().wait()
+
+    async def aclose(self):
+        self.is_closed = True
+
+
+# ---------------------------------------------------------------------------
+# Provider API round trip
+# ---------------------------------------------------------------------------
+
+
+class TestProviderApi:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_mode_round_trips_and_defaults_to_buttons(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.notifications.telegram_reaction_poller.sync", new=AsyncMock()) as sync:
+            created = await async_client.post(
+                "/api/v1/notifications/",
+                json={
+                    "name": "TG",
+                    "provider_type": "telegram",
+                    "config": TELEGRAM_CONFIG,
+                    "telegram_verdict_mode": "reactions",
+                },
+            )
+            assert created.status_code == 200, created.text
+            assert created.json()["telegram_verdict_mode"] == "reactions"
+            provider_id = created.json()["id"]
+            assert sync.await_count == 1
+
+            listed = await async_client.get("/api/v1/notifications/")
+            assert [p["telegram_verdict_mode"] for p in listed.json() if p["id"] == provider_id] == ["reactions"]
+
+            patched = await async_client.patch(
+                f"/api/v1/notifications/{provider_id}", json={"telegram_verdict_mode": "both"}
+            )
+            assert patched.status_code == 200
+            assert patched.json()["telegram_verdict_mode"] == "both"
+            assert sync.await_count == 2
+
+            invalid = await async_client.patch(
+                f"/api/v1/notifications/{provider_id}", json={"telegram_verdict_mode": "webhook"}
+            )
+            assert invalid.status_code == 422
+
+            other = await async_client.post(
+                "/api/v1/notifications/",
+                json={"name": "ntfy", "provider_type": "ntfy", "config": {"topic": "t"}},
+            )
+            assert other.json()["telegram_verdict_mode"] == "buttons"
+
+            deleted = await async_client.delete(f"/api/v1/notifications/{provider_id}")
+            assert deleted.status_code == 200
+            assert sync.await_count == 4
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_null_mode_reads_as_buttons(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        provider = await notification_provider_factory(name="Legacy")
+        # The ORM default fills a None at INSERT; a pre-#3046 row holds a real NULL.
+        await db_session.execute(
+            text("UPDATE notification_providers SET telegram_verdict_mode = NULL WHERE id = :id"), {"id": provider.id}
+        )
+        await db_session.commit()
+        stored = await db_session.scalar(
+            text("SELECT telegram_verdict_mode FROM notification_providers WHERE id = :id"), {"id": provider.id}
+        )
+        assert stored is None, "row under test must actually hold NULL"
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.status_code == 200
+        assert response.json()["telegram_verdict_mode"] == "buttons"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_provider_cascades_its_pending_rows(
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
+        archive_factory,
+        telegram_provider,
+        pending_factory,
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True)
+        provider = await telegram_provider()
+        await pending_factory(provider.id, archive.id)
+
+        with patch("backend.app.api.routes.notifications.telegram_reaction_poller.sync", new=AsyncMock()):
+            assert (await async_client.delete(f"/api/v1/notifications/{provider.id}")).status_code == 200
+
+        assert await _pending_count(db_session) == 0

+ 9 - 1
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -328,7 +328,15 @@ def test_no_user_supplied_host_provider_formats_the_body_inline():
     a webhook-prefix allowlist), so there is no trust boundary to cross.
     """
     src = inspect.getsource(ns).split("\n")
-    host_pinned = {"_send_callmebot", "_send_pushover", "_send_telegram", "_send_discord"}
+    # _send_telegram_message is the api.telegram.org call itself (#3046);
+    # _send_telegram wraps it.
+    host_pinned = {
+        "_send_callmebot",
+        "_send_pushover",
+        "_send_telegram",
+        "_send_telegram_message",
+        "_send_discord",
+    }
 
     current = None
     offenders = []

+ 186 - 0
frontend/src/__tests__/components/TelegramVerdictMode.test.tsx

@@ -0,0 +1,186 @@
+/**
+ * Telegram outcome verdict mode (#3046).
+ *
+ * Coverage:
+ * - AddNotificationModal shows the mode select only for Telegram providers.
+ * - The select pre-fills from provider.telegram_verdict_mode and the chosen
+ *   value is submitted; non-Telegram providers always submit "buttons".
+ * - NotificationProviderCard renders the reactions / both badge.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { AddNotificationModal } from '../../components/AddNotificationModal';
+import { NotificationProviderCard } from '../../components/NotificationProviderCard';
+import type { NotificationProvider } from '../../api/client';
+
+afterEach(() => {
+  server.resetHandlers();
+  vi.restoreAllMocks();
+});
+
+function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
+  return {
+    id: 1,
+    name: 'My Telegram',
+    provider_type: 'telegram',
+    enabled: true,
+    config: { bot_token: '123:abc', chat_id: '-100123' },
+    on_print_start: false,
+    on_print_complete: true,
+    on_print_failed: true,
+    on_print_stopped: true,
+    on_print_progress: false,
+    on_print_missing_spool_assignment: false,
+    on_billing_charge_failed: true,
+    on_printer_offline: false,
+    on_printer_error: false,
+    on_ai_failure_detection: false,
+    on_filament_low: false,
+    on_maintenance_due: false,
+    on_ams_humidity_high: false,
+    on_ams_temperature_high: false,
+    on_ams_drying_suspended: true,
+    on_ams_ht_humidity_high: false,
+    on_ams_ht_temperature_high: false,
+    on_plate_not_empty: true,
+    on_plate_clear_required: false,
+    on_print_confirm_request: true,
+    telegram_verdict_mode: 'buttons',
+    on_bed_cooled: false,
+    on_ha_sensor_alert: false,
+    on_location_ha_sensor_alert: false,
+    on_first_layer_complete: false,
+    on_queue_job_added: false,
+    on_queue_job_assigned: false,
+    on_queue_job_started: false,
+    on_queue_job_waiting: true,
+    on_queue_job_skipped: true,
+    on_queue_job_failed: true,
+    on_queue_completed: false,
+    on_stock_reorder_alert: false,
+    on_stock_break_alert: false,
+    quiet_hours_enabled: false,
+    quiet_hours_start: null,
+    quiet_hours_end: null,
+    daily_digest_enabled: false,
+    daily_digest_time: null,
+    printer_id: null,
+    last_success: null,
+    last_error: null,
+    last_error_at: null,
+    created_at: '2026-09-20T00:00:00Z',
+    updated_at: '2026-09-20T00:00:00Z',
+    ...overrides,
+  };
+}
+
+describe('AddNotificationModal — Telegram verdict mode (#3046)', () => {
+  it('shows the mode select for a Telegram provider, pre-filled from the provider', async () => {
+    render(
+      <AddNotificationModal provider={buildProvider({ telegram_verdict_mode: 'reactions' })} onClose={() => undefined} />,
+    );
+
+    const select = (await screen.findByLabelText('Outcome verdict via')) as HTMLSelectElement;
+    expect(select.value).toBe('reactions');
+    expect(screen.getByRole('option', { name: 'Inline buttons (link)' })).toBeInTheDocument();
+    expect(screen.getByRole('option', { name: 'Reaction (👍 / 👎)' })).toBeInTheDocument();
+    expect(screen.getByRole('option', { name: 'Buttons and reaction' })).toBeInTheDocument();
+    expect(screen.getByText(/Reactions need no inbound connectivity/)).toBeInTheDocument();
+  });
+
+  it('does not show the mode select for other provider types', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ provider_type: 'ntfy', config: { server: 'https://ntfy.sh', topic: 'x' } })}
+        onClose={() => undefined}
+      />,
+    );
+
+    await screen.findByDisplayValue('My Telegram');
+    expect(screen.queryByLabelText('Outcome verdict via')).not.toBeInTheDocument();
+  });
+
+  it('submits the chosen mode on save', async () => {
+    let captured: unknown = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = await request.json();
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
+
+    const select = await screen.findByLabelText('Outcome verdict via');
+    await user.selectOptions(select, 'both');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).toMatchObject({ provider_type: 'telegram', telegram_verdict_mode: 'both' });
+  });
+
+  it('always submits "buttons" for a non-Telegram provider', async () => {
+    let captured: unknown = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = await request.json();
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(
+      <AddNotificationModal
+        provider={buildProvider({
+          provider_type: 'ntfy',
+          config: { server: 'https://ntfy.sh', topic: 'x' },
+          telegram_verdict_mode: 'reactions',
+        })}
+        onClose={onClose}
+      />,
+    );
+
+    await screen.findByDisplayValue('My Telegram');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).toMatchObject({ provider_type: 'ntfy', telegram_verdict_mode: 'buttons' });
+  });
+});
+
+describe('NotificationProviderCard — Telegram verdict badge (#3046)', () => {
+  it('shows the Reactions badge in reactions mode', async () => {
+    render(<NotificationProviderCard provider={buildProvider({ telegram_verdict_mode: 'reactions' })} onEdit={vi.fn()} />);
+    expect(await screen.findByText('Reactions')).toBeInTheDocument();
+    expect(screen.queryByText('Buttons + reactions')).not.toBeInTheDocument();
+  });
+
+  it('shows the Buttons + reactions badge in both mode', async () => {
+    render(<NotificationProviderCard provider={buildProvider({ telegram_verdict_mode: 'both' })} onEdit={vi.fn()} />);
+    expect(await screen.findByText('Buttons + reactions')).toBeInTheDocument();
+  });
+
+  it('shows no mode badge in buttons mode or when the prompt event is muted', async () => {
+    const { unmount } = render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+    expect(await screen.findByText('Outcome Confirmation')).toBeInTheDocument();
+    expect(screen.queryByText('Reactions')).not.toBeInTheDocument();
+    unmount();
+
+    render(
+      <NotificationProviderCard
+        provider={buildProvider({ telegram_verdict_mode: 'reactions', on_print_confirm_request: false })}
+        onEdit={vi.fn()}
+      />,
+    );
+    expect(await screen.findByText('My Telegram')).toBeInTheDocument();
+    expect(screen.queryByText('Reactions')).not.toBeInTheDocument();
+  });
+});

+ 39 - 0
frontend/src/__tests__/i18n/sv-outcome-strings.test.ts

@@ -0,0 +1,39 @@
+/**
+ * Swedish strings for the outcome-confirmation stats (#1898).
+ *
+ * `kasserad` is a participle that agrees with what it describes: "1 kasserad",
+ * "2 kasserade". i18next cannot pluralise stats.rejectedPrintsCount for us --
+ * en.ts defines no _one/_other variants for the key, and the parity gate (rule
+ * 3 in scripts/check-i18n-parity.mjs) forbids a locale introducing an _one key
+ * that en lacks. So the Swedish sentence has to be number-neutral: the count
+ * goes where no word has to agree with it, the way ru and uk already write the
+ * same key.
+ *
+ * StatsPage renders this whenever rejected_prints > 0, so a count of exactly 1
+ * is the first thing anyone sees after their first rejected print.
+ */
+
+import { describe, it, expect } from 'vitest';
+import i18n from '../../i18n';
+
+const t = i18n.getFixedT('sv');
+
+describe('sv stats.rejectedPrintsCount', () => {
+  it('reads grammatically when exactly one print was rejected', () => {
+    const rendered = t('stats.rejectedPrintsCount', { rejected: 1 });
+
+    expect(rendered).toContain('1');
+    expect(rendered).not.toContain('{{');
+    // The regression: a plural participle sitting straight after the count.
+    expect(rendered).not.toMatch(/\b1\s+kasserade\b/i);
+  });
+
+  it('still reads grammatically for a plural count', () => {
+    const rendered = t('stats.rejectedPrintsCount', { rejected: 7 });
+
+    expect(rendered).toContain('7');
+    expect(rendered).not.toContain('{{');
+    // The mirror image: a singular participle sitting straight after the count.
+    expect(rendered).not.toMatch(/\b7\s+kasserad\b/i);
+  });
+});

+ 122 - 0
frontend/src/__tests__/pages/ArchivesUnconfirmedFilter.test.tsx

@@ -0,0 +1,122 @@
+/**
+ * The "Unconfirmed" archive filter (#1898) must agree with the rest of the
+ * feature about what "still waiting for a verdict" means.
+ *
+ * confirm_requested is copied onto the archive at dispatch, not at completion,
+ * and nothing ever clears it again. So a print that was dispatched with the
+ * outcome prompt enabled and then failed, was cancelled, or is still running
+ * keeps confirm_requested = true with user_verdict = null for good.
+ *
+ * Everything else in the feature gates on status === 'completed': the pending /
+ * rejected / good badges, the "Confirm Outcome" context-menu entry, and the
+ * backend's resolve_pending_confirmation_as_good. If the filter alone does not,
+ * those rows show up in the "Unconfirmed" view carrying no badge and no menu
+ * entry to answer them with, and they never leave it.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { ArchivesPage } from '../../pages/ArchivesPage';
+import { server } from '../mocks/server';
+
+/** Fields the grid card reads; only status/confirm_requested/user_verdict vary. */
+function archive(overrides: Record<string, unknown>) {
+  return {
+    id: 0,
+    filename: 'part.gcode.3mf',
+    print_name: 'Part',
+    printer_id: 1,
+    printer_name: 'X1 Carbon',
+    print_time_seconds: 3600,
+    filament_used_grams: 15,
+    status: 'completed',
+    started_at: '2026-09-01T10:00:00Z',
+    completed_at: '2026-09-01T11:00:00Z',
+    thumbnail_path: null,
+    notes: null,
+    rating: null,
+    project_id: null,
+    project_name: null,
+    project_color: null,
+    print_count: 1,
+    duplicate_count: 0,
+    duplicate_sequence: 0,
+    tags: '',
+    created_at: '2026-09-01T09:00:00Z',
+    updated_at: '2026-09-01T11:00:00Z',
+    has_f3d: false,
+    confirm_requested: false,
+    user_verdict: null,
+    ...overrides,
+  };
+}
+
+const archives = [
+  // The one and only row the filter is for.
+  archive({ id: 1, print_name: 'Waiting Benchy', status: 'completed', confirm_requested: true }),
+  // Asked for a verdict at dispatch, then failed / was cancelled / never finished.
+  archive({ id: 2, print_name: 'Failed Bracket', status: 'failed', confirm_requested: true }),
+  archive({ id: 3, print_name: 'Cancelled Gear', status: 'aborted', confirm_requested: true }),
+  archive({ id: 4, print_name: 'Running Cube', status: 'printing', confirm_requested: true }),
+  // Completed and already answered.
+  archive({ id: 5, print_name: 'Answered Vase', status: 'completed', confirm_requested: true, user_verdict: 'good' }),
+  // Completed, never asked.
+  archive({ id: 6, print_name: 'Quiet Clip', status: 'completed' }),
+];
+
+const FILTER_TITLE = 'Show only prints still waiting for their outcome verdict';
+
+describe('ArchivesPage unconfirmed-outcome filter', () => {
+  beforeEach(() => {
+    // Every filter on this page restores itself from localStorage; a leftover
+    // flag would decide the assertions below instead of the click.
+    localStorage.clear();
+    server.use(
+      http.get('/api/v1/archives/', () => HttpResponse.json(archives)),
+      http.get('/api/v1/archives/stats', () => HttpResponse.json({})),
+      http.get('/api/v1/archives/tags', () => HttpResponse.json([])),
+      http.get('/api/v1/archives/no-3mf-warning', () =>
+        HttpResponse.json({ has_fallback: false, reason: null }),
+      ),
+      http.get('/api/v1/printers/', () => HttpResponse.json([{ id: 1, name: 'X1 Carbon' }])),
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('lists only completed prints that are still waiting for a verdict', async () => {
+    render(<ArchivesPage />);
+
+    // Unfiltered first, so a page that rendered nothing can't pass vacuously.
+    expect(await screen.findByText('Waiting Benchy')).toBeInTheDocument();
+    expect(screen.getByText('Failed Bracket')).toBeInTheDocument();
+
+    fireEvent.click(screen.getByTitle(FILTER_TITLE));
+
+    expect(await screen.findByText('Waiting Benchy')).toBeInTheDocument();
+    // The regression: these carry confirm_requested = true and no verdict, but
+    // no badge and no menu entry can ever answer them.
+    expect(screen.queryByText('Failed Bracket')).not.toBeInTheDocument();
+    expect(screen.queryByText('Cancelled Gear')).not.toBeInTheDocument();
+    expect(screen.queryByText('Running Cube')).not.toBeInTheDocument();
+    // And the two that were never pending in the first place.
+    expect(screen.queryByText('Answered Vase')).not.toBeInTheDocument();
+    expect(screen.queryByText('Quiet Clip')).not.toBeInTheDocument();
+  });
+
+  it('leaves every archive alone while the filter is off', async () => {
+    render(<ArchivesPage />);
+
+    for (const name of [
+      'Waiting Benchy',
+      'Failed Bracket',
+      'Cancelled Gear',
+      'Running Cube',
+      'Answered Vase',
+      'Quiet Clip',
+    ]) {
+      expect(await screen.findByText(name)).toBeInTheDocument();
+    }
+  });
+});

+ 5 - 0
frontend/src/api/client.ts

@@ -2989,6 +2989,8 @@ export interface Filament {
 
 // Notification Provider types
 export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant' | 'bark';
+// How a Telegram provider collects the outcome verdict (#3046)
+export type TelegramVerdictMode = 'buttons' | 'reactions' | 'both';
 
 export interface NotificationProvider {
   id: number;
@@ -3022,6 +3024,7 @@ export interface NotificationProvider {
   on_plate_clear_required: boolean;
   // Post-print outcome confirmation (#1898)
   on_print_confirm_request: boolean;
+  telegram_verdict_mode: TelegramVerdictMode;
   // Bed cooled
   on_bed_cooled: boolean;
   on_ha_sensor_alert: boolean;
@@ -3089,6 +3092,7 @@ export interface NotificationProviderCreate {
   on_plate_clear_required?: boolean;
   // Post-print outcome confirmation (#1898)
   on_print_confirm_request?: boolean;
+  telegram_verdict_mode?: TelegramVerdictMode;
   // Bed cooled
   on_bed_cooled?: boolean;
   on_ha_sensor_alert?: boolean;
@@ -3149,6 +3153,7 @@ export interface NotificationProviderUpdate {
   on_plate_clear_required?: boolean;
   // Post-print outcome confirmation (#1898)
   on_print_confirm_request?: boolean;
+  telegram_verdict_mode?: TelegramVerdictMode;
   // Bed cooled
   on_bed_cooled?: boolean;
   on_ha_sensor_alert?: boolean;

+ 24 - 1
frontend/src/components/AddNotificationModal.tsx

@@ -3,7 +3,7 @@ import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
 import { api } from '../api/client';
-import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
+import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType, TelegramVerdictMode } from '../api/client';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 
@@ -48,6 +48,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   // Post-print outcome confirmation (#1898). Defaults ON — it only fires for
   // prints that opted in per-job, so the toggle exists to mute a channel.
   const [onPrintConfirmRequest, setOnPrintConfirmRequest] = useState(provider?.on_print_confirm_request ?? true);
+  // Telegram only (#3046): inline link buttons, a thumbs reaction, or both.
+  const [telegramVerdictMode, setTelegramVerdictMode] = useState<TelegramVerdictMode>(
+    provider?.telegram_verdict_mode ?? 'buttons'
+  );
   const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
   const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
   const [onLocationHaSensorAlert, setOnLocationHaSensorAlert] = useState(
@@ -210,6 +214,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_stock_break_alert: onStockBreakAlert,
       on_plate_clear_required: onPlateClearRequired,
       on_print_confirm_request: onPrintConfirmRequest,
+      telegram_verdict_mode: providerType === 'telegram' ? telegramVerdictMode : 'buttons',
       on_bed_cooled: onBedCooled,
       on_ha_sensor_alert: onHaSensorAlert,
       on_location_ha_sensor_alert: onLocationHaSensorAlert,
@@ -461,6 +466,24 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                 )}
               </div>
             ))}
+            {providerType === 'telegram' && (
+              <div>
+                <label htmlFor="telegram-verdict-mode" className="block text-sm text-bambu-gray mb-1">
+                  {t('notifications.telegramVerdictMode')}
+                </label>
+                <select
+                  id="telegram-verdict-mode"
+                  value={telegramVerdictMode}
+                  onChange={(e) => setTelegramVerdictMode(e.target.value as TelegramVerdictMode)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="buttons">{t('notifications.telegramVerdictModeButtons')}</option>
+                  <option value="reactions">{t('notifications.telegramVerdictModeReactions')}</option>
+                  <option value="both">{t('notifications.telegramVerdictModeBoth')}</option>
+                </select>
+                <p className="text-xs text-bambu-gray mt-1">{t('notifications.telegramVerdictModeHelp')}</p>
+              </div>
+            )}
           </div>
 
           {/* Test Button */}

+ 6 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -129,6 +129,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_print_confirm_request && (
               <span className="px-2 py-0.5 bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400 text-xs rounded">{t('notifications.printConfirmRequest')}</span>
             )}
+            {provider.provider_type === 'telegram' && provider.on_print_confirm_request && provider.telegram_verdict_mode === 'reactions' && (
+              <span className="px-2 py-0.5 bg-sky-100 dark:bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs rounded">{t('notifications.telegramVerdictBadgeReactions')}</span>
+            )}
+            {provider.provider_type === 'telegram' && provider.on_print_confirm_request && provider.telegram_verdict_mode === 'both' && (
+              <span className="px-2 py-0.5 bg-sky-100 dark:bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs rounded">{t('notifications.telegramVerdictBadgeBoth')}</span>
+            )}
             {provider.on_print_failed && (
               <span className="px-2 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 text-xs rounded">{t('notifications.failed')}</span>
             )}

+ 7 - 0
frontend/src/i18n/locales/de.ts

@@ -6468,6 +6468,13 @@ export default {
     telegramThreadId: 'Forum-Themen-ID',
     telegramThreadIdHelp: 'Optional. Sendet in ein einzelnes Thema einer Forum-Gruppe — die letzte Zahl im Themen-Link (t.me/c/.../25). Leer lassen für das allgemeine Thema.',
     telegramThreadIdInvalid: 'Die Forum-Themen-ID muss eine Zahl sein.',
+    telegramVerdictMode: 'Ergebnis-Urteil per',
+    telegramVerdictModeButtons: 'Inline-Buttons (Link)',
+    telegramVerdictModeReactions: 'Reaktion (👍 / 👎)',
+    telegramVerdictModeBoth: 'Buttons und Reaktion',
+    telegramVerdictModeHelp: 'Wie die Ergebnis-Abfrage beantwortet wird. Reaktionen brauchen keine eingehende Verbindung und keine externe URL — Bambuddy fragt sie beim Bot ab. Für Reaktionen einen Bot verwenden, den nur Bambuddy nutzt: Eine andere App, die denselben Bot abfragt (z. B. Home Assistant), bekommt ihre Nachrichten sonst nicht mehr, auch nicht nach dem Zurückschalten auf Buttons. In einer Gruppe muss der Bot Admin sein, um Reaktionen zu sehen.',
+    telegramVerdictBadgeReactions: 'Reaktionen',
+    telegramVerdictBadgeBoth: 'Buttons + Reaktionen',
     smtpServer: 'SMTP-Server',
     smtpPort: 'SMTP-Port',
     security: 'Sicherheit',

+ 7 - 0
frontend/src/i18n/locales/en.ts

@@ -6519,6 +6519,13 @@ export default {
     telegramThreadId: 'Forum Topic ID',
     telegramThreadIdHelp: 'Optional. Posts into a single topic of a forum group — the last number in the topic link (t.me/c/.../25). Leave empty for the General topic.',
     telegramThreadIdInvalid: 'Forum Topic ID must be a number.',
+    telegramVerdictMode: 'Outcome verdict via',
+    telegramVerdictModeButtons: 'Inline buttons (link)',
+    telegramVerdictModeReactions: 'Reaction (👍 / 👎)',
+    telegramVerdictModeBoth: 'Buttons and reaction',
+    telegramVerdictModeHelp: 'How the outcome prompt is answered. Reactions need no inbound connectivity or external URL — Bambuddy polls the bot for them. For reactions, use a bot only Bambuddy uses: another app polling the same bot (Home Assistant, for example) stops receiving its messages, and that stays so even after switching back to buttons. In a group the bot must be an admin to see reactions.',
+    telegramVerdictBadgeReactions: 'Reactions',
+    telegramVerdictBadgeBoth: 'Buttons + reactions',
     smtpServer: 'SMTP Server',
     smtpPort: 'SMTP Port',
     security: 'Security',

+ 7 - 0
frontend/src/i18n/locales/es.ts

@@ -6476,6 +6476,13 @@ export default {
     telegramThreadId: 'ID del tema del foro',
     telegramThreadIdHelp: 'Opcional. Envía a un único tema de un grupo de foro: el último número del enlace del tema (t.me/c/.../25). Déjalo vacío para el tema General.',
     telegramThreadIdInvalid: 'El ID del tema del foro debe ser un número.',
+    telegramVerdictMode: 'Veredicto del resultado mediante',
+    telegramVerdictModeButtons: 'Botones inline (enlace)',
+    telegramVerdictModeReactions: 'Reacción (👍 / 👎)',
+    telegramVerdictModeBoth: 'Botones y reacción',
+    telegramVerdictModeHelp: 'Cómo se responde a la consulta del resultado. Las reacciones no necesitan conectividad entrante ni URL externa: Bambuddy las consulta al bot. Para las reacciones, usa un bot que solo use Bambuddy: otra aplicación que consulte el mismo bot (por ejemplo, Home Assistant) deja de recibir sus mensajes, incluso después de volver a los botones. En un grupo, el bot debe ser administrador para ver las reacciones.',
+    telegramVerdictBadgeReactions: 'Reacciones',
+    telegramVerdictBadgeBoth: 'Botones + reacciones',
     smtpServer: 'Servidor SMTP',
     smtpPort: 'Puerto SMTP',
     security: 'Seguridad',

+ 7 - 0
frontend/src/i18n/locales/fr.ts

@@ -6456,6 +6456,13 @@ export default {
     telegramThreadId: 'ID du sujet de forum',
     telegramThreadIdHelp: 'Facultatif. Envoie dans un seul sujet d\'un groupe forum : le dernier nombre du lien du sujet (t.me/c/.../25). Laisser vide pour le sujet General.',
     telegramThreadIdInvalid: 'L\'ID du sujet de forum doit être un nombre.',
+    telegramVerdictMode: 'Verdict du résultat via',
+    telegramVerdictModeButtons: 'Boutons inline (lien)',
+    telegramVerdictModeReactions: 'Réaction (👍 / 👎)',
+    telegramVerdictModeBoth: 'Boutons et réaction',
+    telegramVerdictModeHelp: 'Comment répondre à la demande de résultat. Les réactions ne nécessitent ni connexion entrante ni URL externe : Bambuddy les récupère auprès du bot. Pour les réactions, utilisez un bot que seul Bambuddy utilise : une autre application qui interroge le même bot (Home Assistant, par exemple) ne reçoit plus ses messages, même après le retour aux boutons. Dans un groupe, le bot doit être administrateur pour voir les réactions.',
+    telegramVerdictBadgeReactions: 'Réactions',
+    telegramVerdictBadgeBoth: 'Boutons + réactions',
     smtpServer: 'Serveur SMTP',
     smtpPort: 'Port SMTP',
     security: 'Sécurité',

+ 7 - 0
frontend/src/i18n/locales/it.ts

@@ -6455,6 +6455,13 @@ export default {
     telegramThreadId: 'ID argomento forum',
     telegramThreadIdHelp: 'Opzionale. Invia in un singolo argomento di un gruppo forum: l\'ultimo numero nel link dell\'argomento (t.me/c/.../25). Lascia vuoto per l\'argomento Generale.',
     telegramThreadIdInvalid: 'L\'ID argomento forum deve essere un numero.',
+    telegramVerdictMode: 'Verdetto sull\'esito tramite',
+    telegramVerdictModeButtons: 'Pulsanti inline (link)',
+    telegramVerdictModeReactions: 'Reazione (👍 / 👎)',
+    telegramVerdictModeBoth: 'Pulsanti e reazione',
+    telegramVerdictModeHelp: 'Come viene data la risposta alla richiesta di esito. Le reazioni non richiedono connettività in ingresso né un URL esterno: Bambuddy le legge dal bot. Per le reazioni usa un bot usato solo da Bambuddy: un\'altra app che interroga lo stesso bot (ad esempio Home Assistant) smette di ricevere i suoi messaggi, anche dopo essere tornati ai pulsanti. In un gruppo il bot deve essere amministratore per vedere le reazioni.',
+    telegramVerdictBadgeReactions: 'Reazioni',
+    telegramVerdictBadgeBoth: 'Pulsanti + reazioni',
     smtpServer: 'Server SMTP',
     smtpPort: 'Porta SMTP',
     security: 'Sicurezza',

+ 7 - 0
frontend/src/i18n/locales/ja.ts

@@ -6469,6 +6469,13 @@ export default {
     telegramThreadId: 'フォーラムトピック ID',
     telegramThreadIdHelp: '任意。フォーラムグループ内の特定のトピックに送信します。トピックリンクの末尾の数字です (t.me/c/.../25)。空欄の場合は General トピックに送信されます。',
     telegramThreadIdInvalid: 'フォーラムトピック ID は数値で入力してください。',
+    telegramVerdictMode: '結果の判定方法',
+    telegramVerdictModeButtons: 'インラインボタン(リンク)',
+    telegramVerdictModeReactions: 'リアクション(👍 / 👎)',
+    telegramVerdictModeBoth: 'ボタンとリアクション',
+    telegramVerdictModeHelp: '結果確認にどう答えるか。リアクションは外部からの接続や外部 URL を必要とせず、Bambuddy がボットから取得します。リアクションには Bambuddy 専用のボットを使ってください。同じボットを読み取る別のアプリ(Home Assistant など)は自分のメッセージを受け取れなくなり、ボタンに戻した後もその状態が続きます。グループではリアクションを受け取るためにボットが管理者である必要があります。',
+    telegramVerdictBadgeReactions: 'リアクション',
+    telegramVerdictBadgeBoth: 'ボタン + リアクション',
     smtpServer: 'SMTPサーバー',
     smtpPort: 'SMTPポート',
     security: 'セキュリティ',

+ 7 - 0
frontend/src/i18n/locales/ko.ts

@@ -6176,6 +6176,13 @@ export default {
     telegramThreadId: '포럼 주제 ID',
     telegramThreadIdHelp: '선택 사항. 포럼 그룹의 특정 주제로 전송합니다. 주제 링크의 마지막 숫자입니다 (t.me/c/.../25). 비워 두면 General 주제로 전송됩니다.',
     telegramThreadIdInvalid: '포럼 주제 ID는 숫자여야 합니다.',
+    telegramVerdictMode: '결과 판정 방식',
+    telegramVerdictModeButtons: '인라인 버튼(링크)',
+    telegramVerdictModeReactions: '반응(👍 / 👎)',
+    telegramVerdictModeBoth: '버튼과 반응',
+    telegramVerdictModeHelp: '결과 확인 요청에 답하는 방식입니다. 반응은 수신 연결이나 외부 URL이 필요 없으며 Bambuddy가 봇에서 가져옵니다. 반응에는 Bambuddy만 사용하는 봇을 쓰세요. 같은 봇을 읽는 다른 앱(예: Home Assistant)은 자신의 메시지를 받지 못하게 되며, 버튼으로 되돌린 뒤에도 그대로입니다. 그룹에서는 봇이 관리자여야 반응을 볼 수 있습니다.',
+    telegramVerdictBadgeReactions: '반응',
+    telegramVerdictBadgeBoth: '버튼 + 반응',
     smtpServer: 'SMTP 서버',
     smtpPort: 'SMTP 포트',
     security: '보안',

+ 7 - 0
frontend/src/i18n/locales/nl.ts

@@ -6519,6 +6519,13 @@ export default {
     telegramThreadId: 'Forumtopic-ID',
     telegramThreadIdHelp: 'Optioneel. Plaatst in één specifiek topic van een forumgroep — het laatste nummer in de topiclink (t.me/c/.../25). Laat leeg voor het algemene topic.',
     telegramThreadIdInvalid: 'Forumtopic-ID moet een getal zijn.',
+    telegramVerdictMode: 'Resultaatoordeel via',
+    telegramVerdictModeButtons: 'Inline-knoppen (link)',
+    telegramVerdictModeReactions: 'Reactie (👍 / 👎)',
+    telegramVerdictModeBoth: 'Knoppen en reactie',
+    telegramVerdictModeHelp: 'Hoe de resultaatvraag wordt beantwoord. Reacties hebben geen inkomende verbinding of externe URL nodig — Bambuddy haalt ze bij de bot op. Gebruik voor reacties een bot die alleen Bambuddy gebruikt: een andere app die dezelfde bot uitleest (bijvoorbeeld Home Assistant) krijgt zijn berichten dan niet meer, ook niet nadat je terugschakelt naar knoppen. In een groep moet de bot beheerder zijn om reacties te zien.',
+    telegramVerdictBadgeReactions: 'Reacties',
+    telegramVerdictBadgeBoth: 'Knoppen + reacties',
     smtpServer: 'SMTP-server',
     smtpPort: 'SMTP-poort',
     security: 'Beveiliging',

+ 7 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -6455,6 +6455,13 @@ export default {
     telegramThreadId: 'ID do tópico do fórum',
     telegramThreadIdHelp: 'Opcional. Envia para um único tópico de um grupo de fórum: o último número no link do tópico (t.me/c/.../25). Deixe vazio para o tópico Geral.',
     telegramThreadIdInvalid: 'O ID do tópico do fórum deve ser um número.',
+    telegramVerdictMode: 'Veredito do resultado via',
+    telegramVerdictModeButtons: 'Botões inline (link)',
+    telegramVerdictModeReactions: 'Reação (👍 / 👎)',
+    telegramVerdictModeBoth: 'Botões e reação',
+    telegramVerdictModeHelp: 'Como a pergunta sobre o resultado é respondida. Reações não precisam de conexão de entrada nem de URL externa — o Bambuddy as consulta no bot. Para reações, use um bot usado só pelo Bambuddy: outro app que consulte o mesmo bot (o Home Assistant, por exemplo) deixa de receber suas mensagens, mesmo depois de voltar para os botões. Em um grupo, o bot precisa ser administrador para ver as reações.',
+    telegramVerdictBadgeReactions: 'Reações',
+    telegramVerdictBadgeBoth: 'Botões + reações',
     smtpServer: 'Servidor SMTP',
     smtpPort: 'Porta SMTP',
     security: 'Segurança',

+ 7 - 0
frontend/src/i18n/locales/ru.ts

@@ -6163,6 +6163,13 @@ export default {
     telegramThreadId: "ID темы форума",
     telegramThreadIdHelp: "Необязательно. Отправляет в конкретную тему форум-группы — последнее число в ссылке на тему (t.me/c/.../25). Оставьте пустым для темы General.",
     telegramThreadIdInvalid: "ID темы форума должен быть числом.",
+    telegramVerdictMode: "Оценка результата через",
+    telegramVerdictModeButtons: "Встроенные кнопки (ссылка)",
+    telegramVerdictModeReactions: "Реакция (👍 / 👎)",
+    telegramVerdictModeBoth: "Кнопки и реакция",
+    telegramVerdictModeHelp: "Как отвечать на запрос о результате. Реакциям не нужны ни входящее соединение, ни внешний URL — Bambuddy запрашивает их у бота. Для реакций используйте бота, которым пользуется только Bambuddy: другое приложение, опрашивающее того же бота (например, Home Assistant), перестаёт получать свои сообщения — даже после возврата к кнопкам. В группе бот должен быть администратором, чтобы видеть реакции.",
+    telegramVerdictBadgeReactions: "Реакции",
+    telegramVerdictBadgeBoth: "Кнопки + реакции",
     smtpServer: "SMTP-сервер",
     smtpPort: "Порт SMTP",
     security: "Защита соединения",

+ 8 - 1
frontend/src/i18n/locales/sv.ts

@@ -1722,7 +1722,7 @@ export default {
     failureReasons: 'Orsaker till fel',
     topFailureReasons: 'Vanligaste orsaker till fel',
     failedPrintsCount: '{{failed}} / {{total}} utskrifter misslyckades',
-    rejectedPrintsCount: '{{rejected}} kasserade efter slutförande',
+    rejectedPrintsCount: 'Kasserade efter slutförande: {{rejected}}',
     yieldRate: 'Utbyte: {{rate}}%',
     lastWeekRate: 'Förra veckan: {{rate}}%',
     // Actions
@@ -6517,6 +6517,13 @@ errors: {
     telegramThreadId: 'Forumämnes-ID',
     telegramThreadIdHelp: 'Valfritt. Skickar till ett enda ämne i en forumgrupp — det sista numret i ämneslänken (t.me/c/.../25). Lämna tomt för det allmänna ämnet.',
     telegramThreadIdInvalid: 'Forumämnes-ID måste vara ett nummer.',
+    telegramVerdictMode: 'Resultatbedömning via',
+    telegramVerdictModeButtons: 'Inline-knappar (länk)',
+    telegramVerdictModeReactions: 'Reaktion (👍 / 👎)',
+    telegramVerdictModeBoth: 'Knappar och reaktion',
+    telegramVerdictModeHelp: 'Hur resultatfrågan besvaras. Reaktioner kräver ingen inkommande anslutning och ingen extern URL — Bambuddy hämtar dem från boten. Använd för reaktioner en bot som bara Bambuddy använder: en annan app som läser samma bot (till exempel Home Assistant) får annars inte längre sina meddelanden, inte heller efter att du bytt tillbaka till knappar. I en grupp måste boten vara admin för att se reaktioner.',
+    telegramVerdictBadgeReactions: 'Reaktioner',
+    telegramVerdictBadgeBoth: 'Knappar + reaktioner',
     smtpServer: 'SMTP-server',
     smtpPort: 'SMTP-port',
     security: 'Säkerhet',

+ 7 - 0
frontend/src/i18n/locales/tr.ts

@@ -6414,6 +6414,13 @@ export default {
     telegramThreadId: 'Forum Konu Kimliği',
     telegramThreadIdHelp: 'İsteğe bağlı. Forum grubundaki tek bir konuya gönderir: konu bağlantısındaki son sayı (t.me/c/.../25). Genel konu için boş bırakın.',
     telegramThreadIdInvalid: 'Forum konu kimliği bir sayı olmalıdır.',
+    telegramVerdictMode: 'Sonuç kararı şu yolla',
+    telegramVerdictModeButtons: 'Satır içi düğmeler (bağlantı)',
+    telegramVerdictModeReactions: 'Tepki (👍 / 👎)',
+    telegramVerdictModeBoth: 'Düğmeler ve tepki',
+    telegramVerdictModeHelp: 'Sonuç sorusunun nasıl yanıtlanacağı. Tepkiler gelen bağlantı veya harici URL gerektirmez; Bambuddy bunları bottan alır. Tepkiler için yalnızca Bambuddy\'nin kullandığı bir bot kullanın: aynı botu okuyan başka bir uygulama (örneğin Home Assistant) kendi mesajlarını artık alamaz ve düğmelere geri döndükten sonra da bu durum sürer. Grupta tepkileri görebilmesi için botun yönetici olması gerekir.',
+    telegramVerdictBadgeReactions: 'Tepkiler',
+    telegramVerdictBadgeBoth: 'Düğmeler + tepkiler',
     smtpServer: 'SMTP Sunucusu',
     smtpPort: 'SMTP Portu',
     security: 'Güvenlik',

+ 7 - 0
frontend/src/i18n/locales/uk.ts

@@ -6509,6 +6509,13 @@ export default {
     telegramThreadId: "ID теми форуму",
     telegramThreadIdHelp: "Необов'язково. Надсилає в конкретну тему форум-групи — останнє число у посиланні на тему (t.me/c/.../25). Залиште порожнім для теми General.",
     telegramThreadIdInvalid: "ID теми форуму має бути числом.",
+    telegramVerdictMode: "Оцінка результату через",
+    telegramVerdictModeButtons: "Вбудовані кнопки (посилання)",
+    telegramVerdictModeReactions: "Реакція (👍 / 👎)",
+    telegramVerdictModeBoth: "Кнопки та реакція",
+    telegramVerdictModeHelp: "Як відповідати на запит про результат. Реакціям не потрібні ні вхідне зʼєднання, ні зовнішня URL-адреса — Bambuddy отримує їх від бота. Для реакцій використовуйте бота, яким користується лише Bambuddy: інший застосунок, що опитує того самого бота (наприклад, Home Assistant), перестає отримувати свої повідомлення — навіть після повернення до кнопок. У групі бот має бути адміністратором, щоб бачити реакції.",
+    telegramVerdictBadgeReactions: "Реакції",
+    telegramVerdictBadgeBoth: "Кнопки + реакції",
     smtpServer: "Сервер SMTP",
     smtpPort: "Порт SMTP",
     security: "Безпека",

+ 7 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -6454,6 +6454,13 @@ export default {
     telegramThreadId: '论坛话题 ID',
     telegramThreadIdHelp: '可选。发送到论坛群组中的指定话题,即话题链接末尾的数字 (t.me/c/.../25)。留空则发送到常规话题。',
     telegramThreadIdInvalid: '论坛话题 ID 必须是数字。',
+    telegramVerdictMode: '结果判定方式',
+    telegramVerdictModeButtons: '内联按钮(链接)',
+    telegramVerdictModeReactions: '表情回应(👍 / 👎)',
+    telegramVerdictModeBoth: '按钮和表情回应',
+    telegramVerdictModeHelp: '如何回答结果确认提示。表情回应不需要入站连接或外部 URL,由 Bambuddy 从机器人拉取。使用表情回应时,请使用仅供 Bambuddy 使用的机器人:读取同一机器人的其他应用(例如 Home Assistant)将收不到自己的消息,即使切换回按钮后也是如此。在群组中,机器人必须是管理员才能看到表情回应。',
+    telegramVerdictBadgeReactions: '表情回应',
+    telegramVerdictBadgeBoth: '按钮 + 表情回应',
     smtpServer: 'SMTP 服务器',
     smtpPort: 'SMTP 端口',
     security: '安全',

+ 7 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -6454,6 +6454,13 @@ export default {
     telegramThreadId: '論壇主題 ID',
     telegramThreadIdHelp: '選填。傳送到論壇群組中的指定主題,即主題連結結尾的數字 (t.me/c/.../25)。留空則傳送到一般主題。',
     telegramThreadIdInvalid: '論壇主題 ID 必須是數字。',
+    telegramVerdictMode: '結果判定方式',
+    telegramVerdictModeButtons: '內嵌按鈕(連結)',
+    telegramVerdictModeReactions: '表情回應(👍 / 👎)',
+    telegramVerdictModeBoth: '按鈕和表情回應',
+    telegramVerdictModeHelp: '如何回答結果確認提示。表情回應不需要入站連線或外部 URL,由 Bambuddy 從機器人拉取。使用表情回應時,請使用僅供 Bambuddy 使用的機器人:讀取同一機器人的其他應用程式(例如 Home Assistant)將收不到自己的訊息,即使切換回按鈕後也是如此。在群組中,機器人必須是管理員才能看到表情回應。',
+    telegramVerdictBadgeReactions: '表情回應',
+    telegramVerdictBadgeBoth: '按鈕 + 表情回應',
     smtpServer: 'SMTP 伺服器',
     smtpPort: 'SMTP 連接埠',
     security: '安全',

+ 6 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -3601,8 +3601,12 @@ export function ArchivesPage() {
       const matchesHideFailed = collection === 'failed' || !hideFailed || (a.status !== 'failed' && a.status !== 'aborted');
 
       // Unconfirmed-outcome filter (#1898): archives that asked for a verdict
-      // and are still waiting for one.
-      const matchesUnconfirmed = !filterUnconfirmed || (a.confirm_requested && a.user_verdict == null);
+      // and are still waiting for one. Completed prints only, like the badge
+      // and the menu entry — confirm_requested is copied on at dispatch and
+      // never cleared, so a print that failed or is still running would
+      // otherwise sit here forever with no way to answer it.
+      const matchesUnconfirmed = !filterUnconfirmed ||
+        (a.status === 'completed' && a.confirm_requested && a.user_verdict == null);
 
       // Hide duplicates filter (don't apply when viewing duplicates collection)
       const matchesHideDuplicates =

+ 2 - 2
frontend/src/utils/verdictSource.ts

@@ -3,8 +3,8 @@
 // clicking can say where it came from — the live case being the plate-clear
 // default answering a prompt before the operator reached the phone.
 //
-// 'reaction' is written by the Telegram reaction handler (#3046), which lands
-// separately; it is mapped here so it reads correctly the day it does.
+// 'reaction' is written when a thumbs-up/down on the Telegram prompt answers
+// it (#3046, the backend's telegram_reactions poller).
 
 import type { VerdictSource } from '../api/client';