diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f848a4ad5c0..fe873fd622b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3748,6 +3748,29 @@ async def get_telegram_onboarding_status(pairing_id: str): ) +def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]: + """Best-effort gateway restart after saving Telegram QR onboarding. + + The QR flow naturally pulls users into Telegram on another device. If the + saved token waits on a separate dashboard restart click, Hermes appears + broken from the chat side. Keep the config save authoritative, but report + restart failures so the UI can fall back to the existing manual banner. + """ + try: + proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart") + except Exception as exc: + _log.exception("Failed to auto-restart gateway after Telegram onboarding") + return { + "restart_started": False, + "restart_error": str(exc), + } + return { + "restart_started": True, + "restart_action": "gateway-restart", + "restart_pid": proc.pid, + } + + @app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply") async def apply_telegram_onboarding( pairing_id: str, body: TelegramOnboardingApply @@ -3802,11 +3825,14 @@ async def apply_telegram_onboarding( with _telegram_onboarding_lock: _telegram_onboarding_pairings.pop(pairing_id, None) + restart_result = _restart_gateway_after_telegram_onboarding() + return { "ok": True, "platform": "telegram", "bot_username": bot_username, - "needs_restart": True, + "needs_restart": not restart_result["restart_started"], + **restart_result, } diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1ccd4704ca1..e0925439bb5 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1399,6 +1399,16 @@ class TestWebServerEndpoints: } monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + restart_calls = [] + + class FakeRestartProc: + pid = 4242 + + def fake_spawn_action(subcommand, name): + restart_calls.append((subcommand, name)) + return FakeRestartProc() + + monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action) start = self.client.post("/api/messaging/telegram/onboarding/start", json={}) assert start.status_code == 200 @@ -1420,8 +1430,73 @@ class TestWebServerEndpoints: "ok": True, "platform": "telegram", "bot_username": "hermes_pair_ready_bot", - "needs_restart": True, + "needs_restart": False, + "restart_started": True, + "restart_action": "gateway-restart", + "restart_pid": 4242, } + assert restart_calls == [(["gateway", "restart"], "gateway-restart")] + env = load_env() + assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET" + assert env["TELEGRAM_ALLOWED_USERS"] == "123456789" + assert load_config()["platforms"]["telegram"]["enabled"] is True + + def test_telegram_onboarding_apply_reports_restart_failure_after_save( + self, monkeypatch + ): + import hermes_cli.web_server as ws + from hermes_cli.config import load_config, load_env + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + def fake_request(method, path, *, body=None, bearer_token=None): + if method == "POST": + return { + "pairing_id": "pair-restart-fails", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair_restart_fails_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + assert method == "GET" + assert path == "/v1/telegram/pairings/pair-restart-fails" + assert bearer_token == "poll-secret" + return { + "status": "ready", + "bot_username": "hermes_pair_restart_fails_bot", + "owner_user_id": 123456789, + "token": "123456:SECRET", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + def fail_spawn_action(subcommand, name): + assert subcommand == ["gateway", "restart"] + assert name == "gateway-restart" + raise RuntimeError("supervisor unavailable") + + monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action) + + start = self.client.post("/api/messaging/telegram/onboarding/start", json={}) + assert start.status_code == 200 + ready = self.client.get("/api/messaging/telegram/onboarding/pair-restart-fails") + assert ready.status_code == 200 + assert ready.json()["status"] == "ready" + + applied = self.client.post( + "/api/messaging/telegram/onboarding/pair-restart-fails/apply", + json={"allowed_user_ids": ["123456789"]}, + ) + + assert applied.status_code == 200 + applied_data = applied.json() + assert applied_data["ok"] is True + assert applied_data["needs_restart"] is True + assert applied_data["restart_started"] is False + assert "supervisor unavailable" in applied_data["restart_error"] + assert "token" not in applied_data env = load_env() assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET" assert env["TELEGRAM_ALLOWED_USERS"] == "123456789" diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c38a72bc40f..8385c300ac7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1475,7 +1475,11 @@ export interface TelegramOnboardingApplyResponse { ok: boolean; platform: "telegram"; bot_username?: string; - needs_restart: true; + needs_restart: boolean; + restart_started?: boolean; + restart_action?: string; + restart_pid?: number | null; + restart_error?: string; } export interface SessionMessage { diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 98c9b7a77cb..07ad159ceee 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -608,19 +608,28 @@ function TelegramOnboardingPanel({ setPhase("applying"); setError(""); try { - await api.applyTelegramOnboarding(setup.pairing_id, { + const result = await api.applyTelegramOnboarding(setup.pairing_id, { allowed_user_ids: allowedIds, }); resetSetup(); - showToast("Telegram saved", "success"); - try { - await api.restartGateway(); - showToast("Gateway restarting…", "success"); + if (result.restart_started) { + showToast("Telegram saved; gateway restarting…", "success"); setRestartNeeded(false); setTimeout(() => void onChanged(), 4000); - } catch (restartError) { + } else if (result.restart_started === undefined && result.needs_restart) { + try { + await api.restartGateway(); + showToast("Telegram saved; gateway restarting…", "success"); + setRestartNeeded(false); + setTimeout(() => void onChanged(), 4000); + } catch (restartError) { + onRestartNeeded(); + showToast(`Telegram saved; gateway restart failed: ${restartError}`, "error"); + } + } else { onRestartNeeded(); - showToast(`Telegram saved; restart failed: ${restartError}`, "error"); + const detail = result.restart_error ? `: ${result.restart_error}` : ""; + showToast(`Telegram saved; gateway restart failed${detail}`, "error"); } await onChanged(); } catch (applyError) {