{"repo":"NousResearch/hermes-agent","public_id":"41193","display_label":"Telegram update queue preservation on reconnect","summary":"These issues and PRs address Telegram gateway behavior around dropped or preserved pending updates during reconnects and startup, including related adapter interface fixes and configuration toggles for catch-up behavior.","subsystem":"gateway","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":3,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52844},"resolution":{"state":"partially_resolved","by":"pr-52844"},"freshness":{"state":"verified","last_verified_at":"2026-09-19T18:49:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20444444444444443},"fix_edges":[{"issue_number":41193,"pr_number":116134,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":46621,"pr_number":52844,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":46621,"pr_number":61767,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":52914,"pr_number":61767,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":53443,"pr_number":61767,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54679,"pr_number":61767,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":71811,"pr_number":116134,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":41193,"title":"feat: add env-var/config toggle for Telegram pending-updates catch-up on startup","url":null,"state":"closed"},{"kind":"issue","number":46621,"title":"Telegram gateway discards the server-side update queue on watcher reconnect after a prolonged outage (start_polling drop_pending_updates=True)","url":null,"state":"closed"},{"kind":"pr","number":41194,"title":"feat: add TELEGRAM_CATCHUP_ON_STARTUP env var toggle for pending-updates on gateway startup","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41193","number":41193},{"kind":"pr","value":"#116134","number":116134},{"kind":"pr","value":"#71811","number":71811}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":46656,"title":"fix: preserve queued Telegram updates on reconnect","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52844","number":52844},{"kind":"pr","value":"#46621","number":46621}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":46671,"title":"fix(telegram): preserve update queue on reconnect after outage (#46621)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52844","number":52844},{"kind":"pr","value":"#46621","number":46621}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":52844,"title":"fix(telegram): preserve Bot API update queue on watcher reconnect (#46621)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","gateway/platforms/base.py","gateway/platforms/bluebubbles.py","gateway/platforms/msgraph_webhook.py","gateway/platforms/signal.py","gateway/platforms/webhook.py","gateway/platforms/weixin.py","gateway/platforms/whatsapp_cloud.py","gateway/platforms/yuanbao.py","gateway/run.py","plugins/platforms/dingtalk/adapter.py","plugins/platforms/discord/adapter.py","plugins/platforms/email/adapter.py","plugins/platforms/feishu/adapter.py","plugins/platforms/google_chat/adapter.py","plugins/platforms/homeassistant/adapter.py","plugins/platforms/irc/adapter.py","plugins/platforms/line/adapter.py","plugins/platforms/matrix/adapter.py","plugins/platforms/mattermost/adapter.py","plugins/platforms/ntfy/adapter.py","plugins/platforms/photon/adapter.py","plugins/platforms/raft/adapter.py","plugins/platforms/simplex/adapter.py","plugins/platforms/slack/adapter.py","plugins/platforms/sms/adapter.py","plugins/platforms/teams/adapter.py","plugins/platforms/telegram/adapter.py","plugins/platforms/wecom/adapter.py","plugins/platforms/whatsapp/adapter.py","tests/gateway/relay/stub_connector.py","tests/gateway/relay/test_relay_going_idle.py","tests/gateway/restart_test_helpers.py","tests/gateway/test_active_session_text_merge.py","tests/gateway/test_base_topic_sessions.py","tests/gateway/test_cancel_background_drain.py","tests/gateway/test_command_bypass_active_session.py","tests/gateway/test_duplicate_reply_suppression.py","tests/gateway/test_ephemeral_reply.py","tests/gateway/test_interrupt_key_match.py","tests/gateway/test_keep_typing_timeout.py","tests/gateway/test_pending_drain_no_recursion.py","tests/gateway/test_pending_drain_race.py","tests/gateway/test_platform_base.py","tests/gateway/test_platform_reconnect.py","tests/gateway/test_platform_reconnect_fd_leak.py","tests/gateway/test_post_delivery_callback_chaining.py","tests/gateway/test_queue_consumption.py","tests/gateway/test_relay_capability_surface.py","tests/gateway/test_run_cleanup_progress.py","tests/gateway/test_run_progress_interrupt.py","tests/gateway/test_run_progress_topics.py","tests/gateway/test_runner_fatal_adapter.py","tests/gateway/test_runner_startup_failures.py","tests/gateway/test_send_multiple_images.py","tests/gateway/test_send_retry.py","tests/gateway/test_session_hygiene.py","tests/gateway/test_session_split_brain_11016.py","tests/gateway/test_status_command.py","tests/gateway/test_telegram_clarify_buttons.py","tests/gateway/test_telegram_conflict.py","tests/gateway/test_telegram_thread_fallback.py","tests/gateway/test_tool_response_drop_recovery.py","tests/gateway/test_tts_media_routing.py","tests/tools/test_send_message_tool.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -4372,7 +4372,7 @@ async def _sweep_orphaned_runs(self) -> None:\n     # BasePlatformAdapter interface\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Start the aiohttp web server.\"\"\"\n         if not AIOHTTP_AVAILABLE:\n             logger.warning(\"[%s] aiohttp not installed\", self.name)"]},{"file":"gateway/platforms/base.py","hunks":["@@ -2634,10 +2634,21 @@ def set_session_store(self, session_store: Any) -> None:\n         self._session_store = session_store\n     \n     @abstractmethod\n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"\n         Connect to the platform and start receiving messages.\n-        \n+\n+        Args:\n+            is_reconnect: False on a cold first boot (the gateway is\n+                starting this platform for the first time); True when the\n+                reconnect watcher is re-establishing a platform that was\n+                previously running and dropped after an outage. Adapters\n+                that buffer a server-side update queue (e.g. Telegram's Bot\n+                API) should preserve that queue when ``is_reconnect`` is\n+                True so messages sent during the outage are delivered rather\n+                than silently discarded. Adapters with no such queue may\n+                ignore the flag.\n+\n         Returns True if connection was successful.\n         \"\"\"\n         pass"]},{"file":"gateway/platforms/bluebubbles.py","hunks":["@@ -232,7 +232,7 @@ async def _api_post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:\n     # Lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if not self.server_url or not self.password:\n             [redacted:secret-assignment]\n                 \"[bluebubbles] BLUEBUBBLES_SERVER_URL and BLUEBUBBLES_PASSWORD are required\""]},{"file":"gateway/platforms/msgraph_webhook.py","hunks":["@@ -136,7 +136,7 @@ def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler])\n     def _source_allowlist_required_but_missing(self) -> bool:\n         return is_network_accessible(self._host) and not self._allowed_source_networks\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if self._client_state is None:\n             logger.error(\n                 \"[msgraph_webhook] Refusing to start without extra.client_state configured\""]},{"file":"gateway/platforms/signal.py","hunks":["@@ -338,7 +338,7 @@ def __init__(self, config: PlatformConfig):\n     # Lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to signal-cli daemon and start SSE listener.\"\"\"\n         if not self.http_url or not self.account:\n             logger.error(\"Signal: SIGNAL_HTTP_URL and SIGNAL_ACCOUNT are required\")"]},{"file":"gateway/platforms/webhook.py","hunks":["@@ -153,7 +153,7 @@ def __init__(self, config: PlatformConfig):\n     # Lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         # Load agent-created subscriptions before validating\n         self._reload_dynamic_routes()\n "]},{"file":"gateway/platforms/weixin.py","hunks":["@@ -1261,7 +1261,7 @@ def _coerce_list(value: Any) -> List[str]:\n             return [str(item).strip() for item in value if str(item).strip()]\n         return [str(value).strip()] if str(value).strip() else []\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if not check_weixin_requirements():\n             message = \"Weixin startup failed: aiohttp and cryptography are required\"\n             self._set_fatal_error(\"weixin_missing_dependency\", message, retryable=False)"]},{"file":"gateway/platforms/whatsapp_cloud.py","hunks":["@@ -347,7 +347,7 @@ def _is_dm_allowed(self, sender_id: str) -> bool:\n         return super()._is_dm_allowed(sender_id)\n \n     # ------------------------------------------------------------------ lifecycle\n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if not check_whatsapp_cloud_requirements():\n             self._set_fatal_error(\n                 \"whatsapp_cloud_deps_missing\","]},{"file":"gateway/platforms/yuanbao.py","hunks":["@@ -5114,7 +5114,7 @@ def enforces_own_access_policy(self) -> bool:\n         \"\"\"Yuanbao gates DM/group access at intake via dm_policy/group_policy.\"\"\"\n         return True\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to Yuanbao WS gateway and authenticate.\n \n         Delegates to ConnectionManager.open()."]},{"file":"gateway/run.py","hunks":["@@ -3086,13 +3086,24 @@ def _platform_connect_timeout_secs(self) -> float:\n                 return max(0.0, timeout)\n         return _PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT\n \n-    async def _connect_adapter_with_timeout(self, adapter, platform) -> bool:\n-        \"\"\"Connect an adapter without allowing one platform to block others.\"\"\"\n+    async def _connect_adapter_with_timeout(\n+        self, adapter, platform, *, is_reconnect: bool = False\n+    ) -> bool:\n+        \"\"\"Connect an adapter without allowing one platform to block others.\n+\n+        ``is_reconnect`` is forwarded to ``adapter.connect()`` so platform\n+        adapters can distinguish a cold first boot (drop any stale\n+        server-side queue) from a watcher reconnect after a prolonged outage\n+        (preserve the queue so messages sent during the outage are delivered\n+        rather than silently dropped — #46621).\n+        \"\"\"\n         timeout = self._platform_connect_timeout_secs()\n         if timeout <= 0:\n-            return await adapter.connect()\n+            return await adapter.connect(is_reconnect=is_reconnect)\n         try:\n-            return await asyncio.wait_for(adapter.connect(), timeout=timeout)\n+            return await asyncio.wait_for(\n+                adapter.connect(is_reconnect=is_reconnect), timeout=timeout\n+            )\n         except asyncio.TimeoutError as exc:\n             raise TimeoutError(\n                 f\"{platform.value} connect timed out after {timeout:g}s\"","@@ -6718,7 +6729,12 @@ async def _platform_reconnect_watcher(self) -> None:\n                     adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)\n                     adapter._busy_text_mode = self._busy_text_mode\n \n-                    success = await self._connect_adapter_with_timeout(adapter, platform)\n+                    # Reconnect after an outage: preserve the platform's\n+                    # server-side update queue so messages sent while the bot\n+                    # was offline are delivered rather than dropped (#46621).\n+                    success = await self._connect_adapter_with_timeout(\n+                        adapter, platform, is_reconnect=True\n+                    )\n                     if success:\n                         self.adapters[platform] = adapter\n                         self._sync_voice_mode_state_to_adapter(adapter)"]},{"file":"plugins/platforms/dingtalk/adapter.py","hunks":["@@ -239,7 +239,7 @@ def __init__(self, config: PlatformConfig):\n \n     # -- Connection lifecycle -----------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to DingTalk via Stream Mode.\"\"\"\n         if not DINGTALK_STREAM_AVAILABLE:\n             logger.warning("]},{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -859,7 +859,7 @@ async def _notify() -> None:\n \n         asyncio.create_task(_notify())\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to Discord and start receiving events.\"\"\"\n         if not DISCORD_AVAILABLE:\n             logger.error(\"[%s] discord.py not installed. Run: pip install discord.py\", self.name)"]},{"file":"plugins/platforms/email/adapter.py","hunks":["@@ -548,7 +548,7 @@ def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP:\n             # Retry with IPv4 only.\n             return _connect(ipv4_only=True)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to the IMAP server and start polling for new messages.\"\"\"\n         # Validate up front so a missing host surfaces as an actionable config\n         # error instead of IMAP4_SSL(\"\") raising the cryptic"]},{"file":"plugins/platforms/feishu/adapter.py","hunks":["@@ -1641,7 +1641,7 @@ def _build_event_handler(self) -> Any:\n             .build()\n         )\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to Feishu/Lark.\"\"\"\n         if not FEISHU_AVAILABLE:\n             logger.error(\"[Feishu] lark-oapi not installed\")"]},{"file":"plugins/platforms/google_chat/adapter.py","hunks":["@@ -761,7 +761,7 @@ async def _resolve_bot_user_id(self) -> Optional[str]:\n     # ------------------------------------------------------------------\n     # Connection lifecycle\n     # ------------------------------------------------------------------\n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Validate config, authenticate, start Pub/Sub pull, resolve bot id.\"\"\"\n         # First call into the heavy google-cloud stack — trigger the lazy\n         # import. ``_load_google_modules()`` is idempotent and rebinds the"]},{"file":"plugins/platforms/homeassistant/adapter.py","hunks":["@@ -98,7 +98,7 @@ def _next_id(self) -> int:\n     # Connection lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to HA WebSocket API and subscribe to events.\"\"\"\n         if not AIOHTTP_AVAILABLE:\n             logger.warning(\"[%s] aiohttp not installed. Run: pip install aiohttp\", self.name)"]},{"file":"plugins/platforms/irc/adapter.py","hunks":["@@ -152,7 +152,7 @@ def name(self) -> str:\n \n     # ── Connection lifecycle ──────────────────────────────────────────────\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to the IRC server, register, and join the channel.\"\"\"\n         if not self.server or not self.channel:\n             logger.error(\"IRC: server and channel must be configured\")"]},{"file":"plugins/platforms/line/adapter.py","hunks":["@@ -740,7 +740,7 @@ def __init__(self, config, **kwargs):\n     # Connection lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if not self.channel_access_token or not self.channel_secret:\n             [redacted:secret-assignment]\n                 \"config_missing\","]},{"file":"plugins/platforms/matrix/adapter.py","hunks":["@@ -1135,7 +1135,7 @@ async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:\n     # Required overrides\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to the Matrix homeserver and start syncing.\"\"\"\n         from mautrix.api import HTTPAPI\n         from mautrix.client import Client"]},{"file":"plugins/platforms/mattermost/adapter.py","hunks":["@@ -256,7 +256,7 @@ async def _upload_file(\n     # Required overrides\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to Mattermost and start the WebSocket listener.\"\"\"\n         import aiohttp\n "]},{"file":"plugins/platforms/ntfy/adapter.py","hunks":["@@ -183,7 +183,7 @@ def __init__(self, config: PlatformConfig):\n \n     # -- Connection lifecycle -----------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to ntfy by starting the streaming subscription task.\"\"\"\n         if not HTTPX_AVAILABLE:\n             logger.warning(\"[%s] httpx not installed. Run: pip install httpx\", self.name)"]},{"file":"plugins/platforms/photon/adapter.py","hunks":["@@ -334,7 +334,7 @@ def _clean_mention_text(self, text: str) -> str:\n \n     # -- Connection lifecycle ---------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if not HTTPX_AVAILABLE:\n             self._set_fatal_error(\n                 \"MISSING_DEP\", \"httpx not installed\", retryable=False"]},{"file":"plugins/platforms/raft/adapter.py","hunks":["@@ -468,7 +468,7 @@ def __init__(self, config: PlatformConfig):\n     def runtime_session(self) -> str:\n         return self._runtime_session\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if not self._bridge_token:\n             [redacted:secret-assignment] = secrets.token_hex(32)\n             logger.info(\"[raft] Auto-generated bridge token\")"]},{"file":"plugins/platforms/simplex/adapter.py","hunks":["@@ -208,7 +208,7 @@ def __init__(self, config: PlatformConfig, **kwargs):\n     # Lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to the simplex-chat daemon and start the WebSocket listener.\"\"\"\n         try:\n             import websockets  # noqa: F401"]},{"file":"plugins/platforms/slack/adapter.py","hunks":["@@ -829,7 +829,7 @@ async def _send_slash_ephemeral(\n         # Non-fatal — the user saw the initial ack already.\n         return SendResult(success=True, message_id=None)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to Slack via Socket Mode.\"\"\"\n         if not SLACK_AVAILABLE:\n             logger.error("]},{"file":"plugins/platforms/sms/adapter.py","hunks":["@@ -86,7 +86,7 @@ def _basic_auth_header(self) -> str:\n     # Required abstract methods\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         import aiohttp\n         from aiohttp import web\n "]},{"file":"plugins/platforms/teams/adapter.py","hunks":["@@ -709,7 +709,7 @@ def __init__(self, config: PlatformConfig):\n         # Used to send cards with the correct conversation type (personal/group/channel).\n         self._conv_refs: Dict[str, Any] = {}\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         # Lazy-install the Teams SDK on demand (parity with Slack/Discord/etc.),\n         # then re-check the module globals it rebinds.\n         check_teams_requirements()"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -2232,7 +2232,7 @@ async def _setup_dm_topics(self) -> None:\n                             self.name, topic_name, seed_err,\n                         )\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to Telegram via polling or webhook.\n \n         By default, uses long polling (outbound connection to Telegram).","@@ -2240,6 +2240,14 @@ async def connect(self) -> bool:\n         instead.  Webhook mode is useful for cloud deployments (Fly.io,\n         Railway) where inbound HTTP can wake a suspended machine.\n \n+        ``is_reconnect`` distinguishes a cold first boot (False — drop any\n+        stale Bot API queue) from a watcher reconnect after a prolonged\n+        outage (True — preserve the updates Telegram queued while the bot\n+        was offline, otherwise every message sent during the outage is\n+        silently lost). The in-process network-error ladder and the\n+        409-conflict handler already pass ``drop_pending_updates=False``\n+        for the same reason; bootstrap follows suit on the reconnect path.\n+\n         Env vars for webhook mode::\n \n             TELEGRAM_WEBHOOK_URL    Public HTTPS URL (e.g. https://app.fly.dev/telegram)","@@ -2476,7 +2484,11 @@ def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict:\n                     webhook_url=webhook_url,\n                     secret_token=[redacted:secret-assignment]\n                     allowed_updates=Update.ALL_TYPES,\n-                    drop_pending_updates=True,\n+                    # Webhooks are push-based — Telegram does not hold a\n+                    # server-side getUpdates queue, so this flag is a no-op\n+                    # in practice. Mirror the polling path's reconnect\n+                    # semantics for consistency.\n+                    drop_pending_updates=not is_reconnect,\n                 )\n                 self._webhook_mode = True\n                 logger.info(","@@ -2509,7 +2521,10 @@ def _polling_error_callback(error: Exception) -> None:\n \n                 await self._app.updater.start_polling(\n                     allowed_updates=Update.ALL_TYPES,\n-                    drop_pending_updates=True,\n+                    # On a cold first boot drop the stale Bot API queue; on a\n+                    # watcher reconnect after an outage preserve it so messages\n+                    # sent while the bot was offline are delivered (#46621).\n+                    drop_pending_updates=not is_reconnect,\n                     error_callback=_polling_error_callback,\n                 )\n             "]},{"file":"plugins/platforms/wecom/adapter.py","hunks":["@@ -198,7 +198,7 @@ def __init__(self, config: PlatformConfig):\n     # Connection lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"Connect to the WeCom AI Bot gateway.\"\"\"\n         if not AIOHTTP_AVAILABLE:\n             message = \"WeCom startup failed: aiohttp not installed\""]},{"file":"plugins/platforms/whatsapp/adapter.py","hunks":["@@ -411,7 +411,7 @@ def _coerce_float_extra(self, key: str, default: float) -> float:\n             return float(default)\n         return parsed\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         \"\"\"\n         Start the WhatsApp bridge.\n         "]},{"file":"tests/gateway/relay/stub_connector.py","hunks":["@@ -40,7 +40,7 @@ def __init__(self, descriptor: CapabilityDescriptor) -> None:\n         # absent/expired capability or a tenant mismatch on the connector side.\n         self.next_follow_up_result: Dict[str, Any] = {\"success\": True, \"message_id\": \"f1\"}\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         self.connected = True\n         return True\n "]},{"file":"tests/gateway/relay/test_relay_going_idle.py","hunks":["@@ -422,7 +422,7 @@ async def test_adapter_go_dormant_noop_on_stub_transport():\n     )\n \n     class _StubTransport:\n-        async def connect(self):\n+        async def connect(self, *, is_reconnect: bool = False):\n             return True\n \n         def set_inbound_handler(self, h):"]},{"file":"tests/gateway/restart_test_helpers.py","hunks":["@@ -15,7 +15,7 @@ def __init__(self):\n         self.sent: list[str] = []\n         self.sent_calls: list[tuple[str, str, object]] = []\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         return True\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_active_session_text_merge.py","hunks":["@@ -65,7 +65,7 @@ def _make_event(\n \n \n class _DummyAdapter(BasePlatformAdapter):  # type: ignore[misc]\n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         pass\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_base_topic_sessions.py","hunks":["@@ -20,7 +20,7 @@ def __init__(self):\n         self.typing = []\n         self.processing_hooks = []\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_cancel_background_drain.py","hunks":["@@ -19,7 +19,7 @@\n \n \n class _StubAdapter(BasePlatformAdapter):\n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         pass\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_command_bypass_active_session.py","hunks":["@@ -29,7 +29,7 @@\n class _StubAdapter(BasePlatformAdapter):\n     \"\"\"Concrete adapter with abstract methods stubbed out.\"\"\"\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         pass\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_duplicate_reply_suppression.py","hunks":["@@ -37,7 +37,7 @@ def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"fake\"), Platform.DISCORD)\n         self.sent = []\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         return True\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_ephemeral_reply.py","hunks":["@@ -39,7 +39,7 @@\n class _NoDeleteAdapter(BasePlatformAdapter):\n     \"\"\"Adapter that does NOT override delete_message (silent degrade).\"\"\"\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         pass\n \n     async def disconnect(self):","@@ -59,7 +59,7 @@ def __init__(self, *a, **kw):\n         super().__init__(*a, **kw)\n         self.deleted: list[tuple[str, str]] = []\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         pass\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_interrupt_key_match.py","hunks":["@@ -21,7 +21,7 @@ class StubAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"test\"), Platform.TELEGRAM)\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         return True\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_keep_typing_timeout.py","hunks":["@@ -38,7 +38,7 @@ class _StubAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"test\"), Platform.TELEGRAM)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_pending_drain_no_recursion.py","hunks":["@@ -35,7 +35,7 @@\n \n \n class _StubAdapter(BasePlatformAdapter):\n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         pass\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_pending_drain_race.py","hunks":["@@ -36,7 +36,7 @@\n \n \n class _StubAdapter(BasePlatformAdapter):\n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         pass\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -1276,7 +1276,7 @@ def _adapter(self):\n         \"\"\"Create a minimal adapter instance for testing static/instance methods.\"\"\"\n \n         class StubAdapter(BasePlatformAdapter):\n-            async def connect(self):\n+            async def connect(self, *, is_reconnect: bool = False):\n                 return True\n \n             async def disconnect(self):"]},{"file":"tests/gateway/test_platform_reconnect.py","hunks":["@@ -26,8 +26,12 @@ def __init__(\n         self._succeed = succeed\n         self._fatal_error = fatal_error\n         self._fatal_retryable = fatal_retryable\n+        # Records the is_reconnect value of every connect() call so tests can\n+        # assert that the watcher distinguishes reconnect from cold boot (#46621).\n+        self.connect_calls: list[bool] = []\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n+        self.connect_calls.append(is_reconnect)\n         if self._fatal_error:\n             self._set_fatal_error(\"test_error\", self._fatal_error, retryable=self._fatal_retryable)\n             return False","@@ -140,7 +144,7 @@ async def test_connect_adapter_timeout_raises_retryable_exception(self, monkeypa\n         runner = _make_runner()\n         adapter = StubAdapter()\n \n-        async def hang():\n+        async def hang(*, is_reconnect: bool = False):\n             await asyncio.sleep(60)\n             return True\n ","@@ -217,6 +221,59 @@ async def fake_sleep(n):\n         assert Platform.TELEGRAM not in runner._failed_platforms\n         assert Platform.TELEGRAM in runner.adapters\n \n+    @pytest.mark.asyncio\n+    async def test_reconnect_passes_is_reconnect_true(self):\n+        \"\"\"The watcher must connect with is_reconnect=True so adapters preserve\n+        their server-side update queue across an outage (#46621). Without this,\n+        bootstrap start_polling(drop_pending_updates=True) silently dropped every\n+        message queued while the bot was offline.\"\"\"\n+        runner = _make_runner()\n+        runner._sync_voice_mode_state_to_adapter = MagicMock()\n+\n+        runner._failed_platforms[Platform.TELEGRAM] = {\n+            \"config\": PlatformConfig(enabled=True, token=\"test\"),\n+            \"attempts\": 1,\n+            \"next_retry\": time.monotonic() - 1,\n+        }\n+\n+        succeed_adapter = StubAdapter(succeed=True)\n+        real_sleep = asyncio.sleep\n+\n+        with patch.object(runner, \"_create_adapter\", return_value=succeed_adapter):\n+            with patch(\"gateway.run.build_channel_directory\", create=True):\n+                runner._running = True\n+                call_count = 0\n+\n+                async def fake_sleep(n):\n+                    nonlocal call_count\n+                    call_count += 1\n+                    if call_count > 1:\n+                        runner._running = False\n+                    await real_sleep(0)\n+\n+                with patch(\"asyncio.sleep\", side_effect=fake_sleep):\n+                    await runner._platform_reconnect_watcher()\n+\n+        assert succeed_adapter.connect_calls == [True], (\n+            f\"watcher must pass is_reconnect=True; got {succeed_adapter.connect_calls!r}\"\n+        )\n+        assert Platform.TELEGRAM in runner.adapters\n+\n+    @pytest.mark.asyncio\n+    async def test_cold_connect_defaults_to_is_reconnect_false(self):\n+        \"\"\"The cold-start connect path (_connect_adapter_with_timeout with no\n+        is_reconnect arg) must default to False so a first boot still drops any\n+        stale queue (#46621).\"\"\"\n+        runner = _make_runner()\n+        adapter = StubAdapter(succeed=True)\n+\n+        success = await runner._connect_adapter_with_timeout(adapter, Platform.TELEGRAM)\n+\n+        assert success is True\n+        assert adapter.connect_calls == [False], (\n+            f\"cold-start must default to is_reconnect=False; got {adapter.connect_calls!r}\"\n+        )\n+\n     @pytest.mark.asyncio\n     async def test_reconnect_retries_resume_pending_for_platform(self):\n         \"\"\"A successful reconnect retries the startup auto-resume scoped to"]},{"file":"tests/gateway/test_platform_reconnect_fd_leak.py","hunks":["@@ -103,7 +103,7 @@ def __init__(self, *, succeed: bool = False, fatal_error: str | None = None,\n         self._fatal_retryable = fatal_retryable\n         self._raise_during_connect = raise_during_connect\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         if self._raise_during_connect:\n             raise RuntimeError(\"simulated connect exception\")\n         if self._fatal_error:","@@ -254,7 +254,7 @@ def __init__(self):\n                     Platform.TELEGRAM,\n                 )\n \n-            async def connect(self) -> bool:\n+            async def connect(self, *, is_reconnect: bool = False) -> bool:\n                 return True\n \n             async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_post_delivery_callback_chaining.py","hunks":["@@ -13,7 +13,7 @@\n \n \n class _MinAdapter(BasePlatformAdapter):\n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_queue_consumption.py","hunks":["@@ -27,7 +27,7 @@ class _StubAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"test\"), Platform.TELEGRAM)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_relay_capability_surface.py","hunks":["@@ -22,7 +22,7 @@\n class _MinAdapter(BasePlatformAdapter):\n     \"\"\"Smallest concrete adapter: implements exactly the abstract methods.\"\"\"\n \n-    async def connect(self):  # pragma: no cover - not called\n+    async def connect(self, *, is_reconnect: bool = False):  # pragma: no cover - not called\n         return True\n \n     async def disconnect(self):  # pragma: no cover - not called"]},{"file":"tests/gateway/test_run_cleanup_progress.py","hunks":["@@ -41,7 +41,7 @@ def __init__(self, platform=Platform.TELEGRAM):\n         self.edits = []\n         self.deleted = []\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_run_progress_interrupt.py","hunks":["@@ -28,7 +28,7 @@ def __init__(self, platform=Platform.TELEGRAM):\n         self.edits = []\n         self.typing = []\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_run_progress_topics.py","hunks":["@@ -22,7 +22,7 @@ def __init__(self, platform=Platform.TELEGRAM):\n         self.edits = []\n         self.typing = []\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_runner_fatal_adapter.py","hunks":["@@ -11,7 +11,7 @@ class _FatalAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"token\"), Platform.TELEGRAM)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         self._set_fatal_error(\n             \"telegram_token_lock\",\n             \"Another local Hermes gateway is already using this Telegram bot token.\",","@@ -33,7 +33,7 @@ class _RuntimeRetryableAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"token\"), Platform.WHATSAPP)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_runner_startup_failures.py","hunks":["@@ -12,7 +12,7 @@ class _RetryableFailureAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"***\"), Platform.TELEGRAM)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         self._set_fatal_error(\n             \"telegram_connect_error\",\n             \"Telegram startup failed: temporary DNS resolution failure.\",","@@ -34,7 +34,7 @@ class _DisabledAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=False, token=\"***\"), Platform.TELEGRAM)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         raise AssertionError(\"connect should not be called for disabled platforms\")\n \n     async def disconnect(self) -> None:","@@ -51,7 +51,7 @@ class _SuccessfulAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"***\"), Platform.DISCORD)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:","@@ -467,7 +467,7 @@ class _NonRetryableFailureAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"***\"), Platform.DISCORD)\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         self._set_fatal_error(\n             \"discord-bot-token_lock\",\n             \"Discord bot token already in use (PID 999). Stop the other gateway first.\","]},{"file":"tests/gateway/test_send_multiple_images.py","hunks":["@@ -41,7 +41,7 @@ def __init__(self):\n         self.sent_animations = []\n         self.sent_files = []\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         return True\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_send_retry.py","hunks":["@@ -35,7 +35,7 @@ async def send(self, chat_id, content, reply_to=None, metadata=None, **kwargs) -\n         self._send_calls.append((chat_id, content))\n         return self._next_result()\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_session_hygiene.py","hunks":["@@ -56,7 +56,7 @@ def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"), Platform.TELEGRAM)\n         self.sent = []\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_session_split_brain_11016.py","hunks":["@@ -37,7 +37,7 @@\n \n \n class _StubAdapter(BasePlatformAdapter):\n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         pass\n \n     async def disconnect(self):"]},{"file":"tests/gateway/test_status_command.py","hunks":["@@ -611,7 +611,7 @@ async def fake_handler(event):\n     class _ConcreteAdapter(BasePlatformAdapter):\n         platform = Platform.TELEGRAM\n \n-        async def connect(self): pass\n+        async def connect(self, *, is_reconnect: bool = False): pass\n         async def disconnect(self): pass\n         async def send(self, chat_id, content, **kwargs): pass\n         async def get_chat_info(self, chat_id): return {}","@@ -692,7 +692,7 @@ async def test_post_delivery_callback_generation_snapshot_happens_after_bind():\n     class _ConcreteAdapter(BasePlatformAdapter):\n         platform = Platform.TELEGRAM\n \n-        async def connect(self): pass\n+        async def connect(self, *, is_reconnect: bool = False): pass\n         async def disconnect(self): pass\n         async def send(self, chat_id, content, **kwargs): pass\n         async def get_chat_info(self, chat_id): return {}"]},{"file":"tests/gateway/test_telegram_clarify_buttons.py","hunks":["@@ -403,7 +403,7 @@ def __init__(self):\n                 # Skip base __init__ — we're not exercising it\n                 self.sent: list = []\n \n-            async def connect(self): pass\n+            async def connect(self, *, is_reconnect: bool = False): pass\n             async def disconnect(self): pass\n             async def send(self, chat_id, content, **kw):\n                 self.sent.append({\"chat_id\": chat_id, \"content\": content})","@@ -436,7 +436,7 @@ class _Stub(BasePlatformAdapter):\n             name = \"stub\"\n             def __init__(self):\n                 self.sent: list = []\n-            async def connect(self): pass\n+            async def connect(self, *, is_reconnect: bool = False): pass\n             async def disconnect(self): pass\n             async def send(self, chat_id, content, **kw):\n                 self.sent.append(content)"]},{"file":"tests/gateway/test_telegram_conflict.py","hunks":["@@ -425,3 +425,67 @@ def _boom():\n     except (asyncio.CancelledError, Exception):\n         pass\n     await _cancel_heartbeat(adapter)\n+\n+\n+def _build_polling_app(monkeypatch):\n+    \"\"\"Wire a mock PTB Application whose start_polling captures kwargs.\"\"\"\n+    captured = {}\n+\n+    async def fake_start_polling(**kwargs):\n+        captured.update(kwargs)\n+\n+    updater = SimpleNamespace(\n+        start_polling=AsyncMock(side_effect=fake_start_polling),\n+        stop=AsyncMock(),\n+        running=True,\n+    )\n+    bot = SimpleNamespace(set_my_commands=AsyncMock(), delete_webhook=AsyncMock())\n+    app = SimpleNamespace(\n+        bot=bot,\n+        updater=updater,\n+        add_handler=MagicMock(),\n+        initialize=AsyncMock(),\n+        start=AsyncMock(),\n+    )\n+    builder = MagicMock()\n+    builder.token.return_value = builder\n+    builder.request.return_value = builder\n+    builder.get_updates_request.return_value = builder\n+    builder.build.return_value = app\n+    monkeypatch.setattr(\n+        \"plugins.platforms.telegram.adapter.Application\",\n+        SimpleNamespace(builder=MagicMock(return_value=builder)),\n+    )\n+    monkeypatch.setattr(\n+        \"gateway.status.acquire_scoped_lock\",\n+        lambda scope, identity, metadata=None: (True, None),\n+    )\n+    monkeypatch.setattr(\"asyncio.sleep\", AsyncMock())\n+    return captured\n+\n+\n[redacted:email]\n+async def test_cold_connect_drops_pending_updates(monkeypatch):\n+    \"\"\"A cold first boot (is_reconnect=False) drops the stale Bot API queue.\"\"\"\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))\n+    captured = _build_polling_app(monkeypatch)\n+\n+    ok = await adapter.connect()  # default is_reconnect=False\n+\n+    assert ok is True\n+    assert captured[\"drop_pending_updates\"] is True\n+    await _cancel_heartbeat(adapter)\n+\n+\n[redacted:email]\n+async def test_reconnect_preserves_pending_updates(monkeypatch):\n+    \"\"\"A watcher reconnect (is_reconnect=True) preserves the queue Telegram\n+    accumulated during the outage — the core of #46621.\"\"\"\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))\n+    captured = _build_polling_app(monkeypatch)\n+\n+    ok = await adapter.connect(is_reconnect=True)\n+\n+    assert ok is True\n+    assert captured[\"drop_pending_updates\"] is False\n+    await _cancel_heartbeat(adapter)"]},{"file":"tests/gateway/test_telegram_thread_fallback.py","hunks":["@@ -1127,7 +1127,7 @@ async def test_base_send_image_fallback_preserves_metadata():\n     from gateway.platforms.base import BasePlatformAdapter\n \n     class _ConcreteBaseAdapter(BasePlatformAdapter):\n-        async def connect(self):\n+        async def connect(self, *, is_reconnect: bool = False):\n             return True\n \n         async def disconnect(self):"]},{"file":"tests/gateway/test_tool_response_drop_recovery.py","hunks":["@@ -40,7 +40,7 @@ def __init__(self, platform: Platform):\n         super().__init__(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"), platform)\n         self.sent: list[dict] = []\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n         return True\n \n     async def disconnect(self) -> None:"]},{"file":"tests/gateway/test_tts_media_routing.py","hunks":["@@ -22,7 +22,7 @@ class _MediaRoutingAdapter(BasePlatformAdapter):\n     def __init__(self):\n         super().__init__(PlatformConfig(enabled=True, token=\"test\"), Platform.TELEGRAM)\n \n-    async def connect(self):\n+    async def connect(self, *, is_reconnect: bool = False):\n         return True\n \n     async def disconnect(self):"]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -871,7 +871,7 @@ class FakeAdapter:\n             def __init__(self, _config):\n                 self.connected = False\n \n-            async def connect(self):\n+            async def connect(self, *, is_reconnect: bool = False):\n                 self.connected = True\n                 calls.append((\"connect\",))\n                 return True\n"]}]},{"kind":"pr","number":60831,"title":"fix(qqbot): accept is_reconnect kwarg in connect() to match base class","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"276542c729c10ff9d093760897f4c2d1256a79ce","number":0},{"kind":"commit","value":"8416545c423a1f0f88f1236fd999c03922318547","number":0},{"kind":"pr","value":"#61767","number":61767},{"kind":"pr","value":"#61767","number":61767},{"kind":"pr","value":"#52966","number":52966},{"kind":"pr","value":"#61767","number":61767}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":61767,"title":"fix(gateway): enforce reconnect contract across adapters","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/qqbot/adapter.py","infographic/gateway-reconnect-contract/infographic.png","plugins/platforms/wecom/callback_adapter.py","scripts/release.py","tests/gateway/test_adapter_connect_is_reconnect_contract.py","tests/gateway/test_qqbot.py"],"fix_hunks":[{"file":"gateway/platforms/qqbot/adapter.py","hunks":["@@ -278,8 +278,16 @@ def enforces_own_access_policy(self) -> bool:\n     # Connection lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n-        \"\"\"Authenticate, obtain gateway URL, and open the WebSocket.\"\"\"\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n+        \"\"\"\n+        Authenticate, obtain gateway URL, and open the WebSocket.\n+\n+        Args:\n+            is_reconnect: False on a cold first boot; True when the\n+                reconnect watcher is re-establishing this platform after\n+                an outage. QQBot has no server-side update queue so this\n+                flag is accepted for interface conformance only.\n+        \"\"\"\n         if not AIOHTTP_AVAILABLE:\n             message = \"QQ startup failed: aiohttp not installed\"\n             self._set_fatal_error(\"qq_missing_dependency\", message, retryable=True)"]},{"file":"plugins/platforms/wecom/callback_adapter.py","hunks":["@@ -115,7 +115,13 @@ def _normalize_apps(extra: Dict[str, Any]) -> List[Dict[str, Any]]:\n     # Lifecycle\n     # ------------------------------------------------------------------\n \n-    async def connect(self) -> bool:\n+    async def connect(self, *, is_reconnect: bool = False) -> bool:\n+        # ``is_reconnect`` is forwarded by GatewayRunner on every retry per\n+        # the BasePlatformAdapter.connect contract. Callback adapters have\n+        # no server-side queue to preserve, so the flag is accepted-and-\n+        # ignored — but the kwarg MUST be present or the reconnect watcher\n+        # dies with TypeError and the platform silently stays offline.\n+        del is_reconnect\n         if not self._apps:\n             logger.warning(\"[WecomCallback] No callback apps configured\")\n             return False"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,8 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"lemonwan\",  # PR #59430 sibling salvage (adapter reconnect contract guard)\n+    \"[redacted:email]\": \"luxuguang-leo\",  # PR #52966 salvage (QQBot reconnect contract)\n     \"[redacted:email]\": \"evelynburger\",  # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity)\n     \"[redacted:email]\": \"SiteupAgencia\",  # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578)\n     \"[redacted:email]\": \"rainbowgore\",  # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349)"]},{"file":"tests/gateway/test_adapter_connect_is_reconnect_contract.py","hunks":["@@ -0,0 +1,157 @@\n+\"\"\"Regression: every platform adapter's ``connect()`` must accept the\n+``is_reconnect`` keyword-only argument.\n+\n+The gateway reconnect watcher forwards ``is_reconnect=True`` to every\n+adapter on every retry (see ``GatewayRunner._call_adapter_connect`` in\n+``gateway/run.py``). An adapter whose ``connect()`` signature omits\n+``is_reconnect`` blows up on the first reconnect attempt with::\n+\n+    TypeError: <Foo>Adapter.connect() got an unexpected\n+               keyword argument 'is_reconnect'\n+\n+…and never recovers, leaving that platform silently disconnected until\n+the operator manually restarts the gateway. This exact bug shipped for\n+``QQAdapter`` and was only discovered after messages stopped flowing on\n+the QQ channel for hours.\n+\n+To prevent this class of bug from regressing, we statically parse every\n+``adapter.py`` under ``gateway/platforms/`` and ``plugins/platforms/``\n+and assert that its ``connect()`` method accepts an ``is_reconnect``\n+keyword. Doing this via AST (rather than importing) avoids pulling every\n+platform's optional third-party SDK (aiohttp, slack_sdk, telegram,\n+matrix-nio, etc.) into the test environment.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import ast\n+from pathlib import Path\n+\n+import pytest\n+\n+\n+REPO_ROOT = Path(__file__).resolve().parents[2]\n+\n+# Directories that hold platform adapters. Each entry is a directory\n+# whose immediate children are either ``adapter.py`` files or\n+# sub-packages that expose one.\n+ADAPTER_ROOTS = [\n+    REPO_ROOT / \"gateway\" / \"platforms\",\n+    REPO_ROOT / \"plugins\" / \"platforms\",\n+]\n+\n+\n+def _iter_adapter_files() -> list[Path]:\n+    \"\"\"Every ``*adapter*.py`` under the two adapter roots.\n+\n+    We intentionally cast a wide net (any ``adapter.py`` / ``*_adapter.py``\n+    inside these trees) so a new platform can't sneak in without the\n+    contract check firing.\n+    \"\"\"\n+    files: list[Path] = []\n+    for root in ADAPTER_ROOTS:\n+        if not root.is_dir():\n+            continue\n+        for path in root.rglob(\"*.py\"):\n+            if path.name == \"adapter.py\" or path.stem.endswith(\"_adapter\"):\n+                files.append(path)\n+    return sorted(files)\n+\n+\n+def _find_adapter_classes(module: ast.Module) -> list[ast.ClassDef]:\n+    \"\"\"Classes that look like a platform adapter.\n+\n+    Heuristic: any class whose name ends in ``Adapter`` and that defines\n+    an ``async def connect`` method. This catches every subclass of\n+    ``BasePlatformAdapter`` in the tree today (QQAdapter, TelegramAdapter,\n+    SlackAdapter, …) without importing the base class.\n+    \"\"\"\n+    hits: list[ast.ClassDef] = []\n+    for node in ast.walk(module):\n+        if not isinstance(node, ast.ClassDef):\n+            continue\n+        if not node.name.endswith(\"Adapter\"):\n+            continue\n+        has_connect = any(\n+            isinstance(item, ast.AsyncFunctionDef) and item.name == \"connect\"\n+            for item in node.body\n+        )\n+        if has_connect:\n+            hits.append(node)\n+    return hits\n+\n+\n+def _connect_accepts_is_reconnect(cls: ast.ClassDef) -> bool:\n+    \"\"\"True iff the class's own ``connect()`` accepts ``is_reconnect``.\n+\n+    Accepts the kwarg via:\n+    - keyword-only argument named ``is_reconnect`` (the canonical form\n+      used by ``BasePlatformAdapter``), OR\n+    - ``**kwargs`` catch-all (also safe — the kwarg is absorbed).\n+    \"\"\"\n+    for item in cls.body:\n+        if not (isinstance(item, ast.AsyncFunctionDef) and item.name == \"connect\"):\n+            continue\n+        args = item.args\n+        if any(a.arg == \"is_reconnect\" for a in args.kwonlyargs):\n+            return True\n+        if any(a.arg == \"is_reconnect\" for a in args.args):\n+            return True\n+        if args.kwarg is not None:  # **kwargs\n+            return True\n+        return False\n+    return False\n+\n+\n+ADAPTER_FILES = _iter_adapter_files()\n+\n+\n+def test_adapter_discovery_finds_platforms():\n+    \"\"\"Sanity: the discovery walker actually found a meaningful set of\n+    adapters. If this drops to a trivial number, the glob broke and the\n+    contract test below is silently passing on nothing.\n+    \"\"\"\n+    assert len(ADAPTER_FILES) >= 20, (\n+        f\"Expected to discover >=20 platform adapter files under \"\n+        f\"{[str(p) for p in ADAPTER_ROOTS]}, found {len(ADAPTER_FILES)}. \"\n+        f\"The discovery glob is likely broken.\"\n+    )\n+\n+\n[redacted:email](\n+    \"adapter_file\",\n+    ADAPTER_FILES,\n+    ids=lambda p: str(p.relative_to(REPO_ROOT)),\n+)\n+def test_adapter_connect_accepts_is_reconnect(adapter_file: Path):\n+    \"\"\"Every ``*Adapter.connect()`` must accept ``is_reconnect``.\n+\n+    This is the contract enforced by ``BasePlatformAdapter.connect`` and\n+    relied on by ``GatewayRunner._call_adapter_connect``. Violating it\n+    silently disables the affected platform after its first reconnect.\n+    \"\"\"\n+    source = adapter_file.read_text(encoding=\"utf-8\")\n+    try:\n+        tree = ast.parse(source, filename=str(adapter_file))\n+    except SyntaxError as exc:\n+        pytest.fail(f\"Could not parse {adapter_file}: {exc}\")\n+\n+    classes = _find_adapter_classes(tree)\n+    if not classes:\n+        pytest.skip(\n+            f\"{adapter_file.relative_to(REPO_ROOT)} has no *Adapter class \"\n+            f\"with an async connect() — nothing to check.\"\n+        )\n+\n+    offenders = [cls.name for cls in classes if not _connect_accepts_is_reconnect(cls)]\n+\n+    assert not offenders, (\n+        f\"{adapter_file.relative_to(REPO_ROOT)}: the following adapter \"\n+        f\"class(es) define `async def connect()` WITHOUT accepting the \"\n+        f\"`is_reconnect` kwarg: {offenders}. \"\n+        f\"Add `*, is_reconnect: bool = False` to the signature \"\n+        f\"(matching BasePlatformAdapter.connect). \"\n+        f\"The gateway reconnect watcher forwards this kwarg on every \"\n+        f\"retry — an adapter that rejects it silently disconnects after \"\n+        f\"the first outage.\"\n+    )"]},{"file":"tests/gateway/test_qqbot.py","hunks":["@@ -190,6 +190,21 @@ def test_connect_uses_redirect_guard_hook(self):\n         assert kwargs.get(\"follow_redirects\") is True\n         assert kwargs.get(\"event_hooks\", {}).get(\"response\") == [_ssrf_redirect_guard]\n \n+    def test_connect_accepts_is_reconnect_param(self):\n+        \"\"\"connect() must accept is_reconnect for interface conformance with\n+        the base adapter, which the reconnect watcher calls with\n+        is_reconnect=True.\"\"\"\n+        from gateway.platforms.qqbot import QQAdapter\n+\n+        adapter = QQAdapter(_make_config(app_id=\"a\", client_secret=\"b\"))\n+        adapter._ensure_token = [redacted:secret-assignment]\"stop after client init\"))\n+\n+        # Both forms must not raise TypeError.\n+        connected_default = asyncio.run(adapter.connect())\n+        connected_explicit = asyncio.run(adapter.connect(is_reconnect=True))\n+        assert connected_default is False\n+        assert connected_explicit is False\n+\n \n # ---------------------------------------------------------------------------\n # WebSocket proxy handling\n"]}]},{"kind":"pr","number":116134,"title":"Telegram gateway can keep the messages sent while it was offline (#71811, salvage #105899)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli-config.yaml.example","plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_cold_boot_queue.py","website/docs/user-guide/messaging/telegram.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}