From 33bdf694f000be2adb63e5888199b024e9e330b6 Mon Sep 17 00:00:00 2001 From: jenkins Date: Thu, 13 Aug 2026 12:39:11 -0300 Subject: [PATCH] hermes: verify Telegram bot readiness --- services/hermes/chat-router.yaml | 2 +- services/hermes/router/main_test.go | 18 +++++++ services/hermes/router/telegram.go | 84 ++++++++++++++++------------- services/hermes/router/web.go | 49 +++++++++++------ 4 files changed, 99 insertions(+), 54 deletions(-) diff --git a/services/hermes/chat-router.yaml b/services/hermes/chat-router.yaml index 0b244a9c8..c27284e7e 100644 --- a/services/hermes/chat-router.yaml +++ b/services/hermes/chat-router.yaml @@ -20,7 +20,7 @@ spec: app: hermes-chat-router annotations: ai.bstein.dev/role: privacy-preserving-chat-tenant-router - ai.bstein.dev/config-rev: "20260813-telegram-activated-v1" + ai.bstein.dev/config-rev: "20260813-telegram-readiness-v1" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-pre-populate-only: "true" vault.hashicorp.com/agent-init-first: "true" diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go index 039d6ac1e..a17b020aa 100644 --- a/services/hermes/router/main_test.go +++ b/services/hermes/router/main_test.go @@ -374,6 +374,7 @@ func TestTelegramWebActionsRequireExplicitSameOriginHeader(t *testing.T) { if response.Code != http.StatusForbidden { t.Fatalf("got %d", response.Code) } + router.telegram.setStatus("BsteinAtlasHermesBot", true, "") request = httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`)) request.Header.Set("X-Forwarded-User", "subject") request.Header.Set("Content-Type", "application/json") @@ -384,3 +385,20 @@ func TestTelegramWebActionsRequireExplicitSameOriginHeader(t *testing.T) { t.Fatalf("got %d: %s", response.Code, response.Body.String()) } } + +func TestTelegramWebRejectsLinkWhileConfiguredBotIsNotReady(t *testing.T) { + router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" }) + if err != nil { + t.Fatal(err) + } + router.telegram = &telegramBot{} + request := httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`)) + request.Header.Set("X-Forwarded-User", "subject") + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Hermes-Action", "telegram-link") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("got %d: %s", response.Code, response.Body.String()) + } +} diff --git a/services/hermes/router/telegram.go b/services/hermes/router/telegram.go index e58fcde6f..cb5f2e76e 100644 --- a/services/hermes/router/telegram.go +++ b/services/hermes/router/telegram.go @@ -30,6 +30,8 @@ type telegramBot struct { agentClient *http.Client mu sync.RWMutex botUsername string + ready bool + lastError string workLimit chan struct{} slotLocks map[int]*sync.Mutex slotLocksMux sync.Mutex @@ -92,16 +94,20 @@ func newTelegramBot(config telegramConfig, router *tenantRouter) *telegramBot { } } -func (bot *telegramBot) username() string { +func (bot *telegramBot) status() (string, bool, string) { bot.mu.RLock() defer bot.mu.RUnlock() - return bot.botUsername + return bot.botUsername, bot.ready, bot.lastError } -func (bot *telegramBot) setUsername(username string) { +func (bot *telegramBot) setStatus(username string, ready bool, lastError string) { bot.mu.Lock() defer bot.mu.Unlock() - bot.botUsername = strings.TrimPrefix(strings.TrimSpace(username), "@") + if username != "" { + bot.botUsername = strings.TrimPrefix(strings.TrimSpace(username), "@") + } + bot.ready = ready + bot.lastError = strings.TrimSpace(lastError) } func (bot *telegramBot) slotLock(slot int) *sync.Mutex { @@ -150,40 +156,44 @@ func (bot *telegramBot) call(ctx context.Context, method string, values url.Valu func (bot *telegramBot) run() { for { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - var me struct { - Username string `json:"username"` + for { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + var me struct { + Username string `json:"username"` + } + err := bot.call(ctx, "getMe", url.Values{}, &me) + cancel() + if err == nil { + bot.setStatus(me.Username, true, "") + break + } + bot.setStatus("", false, "Telegram rejected the configured bot token or is temporarily unavailable.") + time.Sleep(10 * time.Second) } - err := bot.call(ctx, "getMe", url.Values{}, &me) - cancel() - if err == nil { - bot.setUsername(me.Username) - break - } - time.Sleep(10 * time.Second) - } - for { - offset := bot.router.telegramOffset() - ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second) - values := url.Values{ - "offset": {strconv.FormatInt(offset, 10)}, - "timeout": {"50"}, - "allowed_updates": {`["message"]`}, - } - var updates []telegramUpdate - err := bot.call(ctx, "getUpdates", values, &updates) - cancel() - if err != nil { - time.Sleep(3 * time.Second) - continue - } - for _, update := range updates { - _ = bot.router.setTelegramOffset(update.UpdateID + 1) - bot.workLimit <- struct{}{} - go func(update telegramUpdate) { - defer func() { <-bot.workLimit }() - bot.handleUpdate(update) - }(update) + for { + offset := bot.router.telegramOffset() + ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second) + values := url.Values{ + "offset": {strconv.FormatInt(offset, 10)}, + "timeout": {"50"}, + "allowed_updates": {`["message"]`}, + } + var updates []telegramUpdate + err := bot.call(ctx, "getUpdates", values, &updates) + cancel() + if err != nil { + bot.setStatus("", false, "Hermes lost contact with the configured Telegram bot.") + time.Sleep(3 * time.Second) + break + } + for _, update := range updates { + _ = bot.router.setTelegramOffset(update.UpdateID + 1) + bot.workLimit <- struct{}{} + go func(update telegramUpdate) { + defer func() { <-bot.workLimit }() + bot.handleUpdate(update) + }(update) + } } } } diff --git a/services/hermes/router/web.go b/services/hermes/router/web.go index 7eb81f032..b93beae60 100644 --- a/services/hermes/router/web.go +++ b/services/hermes/router/web.go @@ -17,13 +17,14 @@ const telegramPage = ` Hermes on Telegram - +
← Back to Hermes

Hermes on Telegram

The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.

+

Account-link commands go only to the private chat with the Hermes bot—not to Hermes WebUI and not to BotFather.

Checking Telegram…

- + ` @@ -279,10 +280,18 @@ const bridgeJS = `(() => { operatorSetup.hidden = false; return; } + if (!payload.ready) { + status.textContent = payload.error || 'The shared Telegram bot token is not accepted by Telegram. The operator must update the token and roll out the router.'; + linkButton.hidden = true; + unlinkButton.hidden = true; + operatorSetup.hidden = false; + return; + } operatorSetup.hidden = true; linkButton.hidden = false; linkButton.disabled = false; - status.textContent = payload.linked ? 'Telegram is linked to this private account.' : 'Telegram is ready to link.'; + const botName = payload.bot_username ? '@' + payload.bot_username : 'the Hermes bot'; + status.textContent = payload.linked ? 'Telegram is linked to this private account through ' + botName + '.' : 'Telegram is ready. Link this account with ' + botName + '.'; unlinkButton.hidden = !payload.linked; } catch (_) { status.textContent = 'Telegram status is temporarily unavailable.'; } }; @@ -292,13 +301,15 @@ const bridgeJS = `(() => { result.hidden = false; result.replaceChildren(); const text = document.createElement('p'); - text.textContent = 'Send /link ' + payload.code + ' to the Hermes bot. This code expires at ' + new Date(payload.expires_at).toLocaleTimeString() + '.'; + const botName = payload.bot_username ? '@' + payload.bot_username : 'the Hermes bot'; + text.textContent = 'In a private Telegram chat with ' + botName + ', send /link ' + payload.code + '. Do not send it to BotFather or paste it into Hermes WebUI. This code expires at ' + new Date(payload.expires_at).toLocaleTimeString() + '.'; result.appendChild(text); if (payload.deep_link) { const anchor = document.createElement('a'); anchor.href = payload.deep_link; anchor.rel = 'noopener noreferrer'; - anchor.textContent = 'Open Telegram and link now'; + anchor.target = '_blank'; + anchor.textContent = 'Open ' + botName + ' in Telegram and link this account'; result.appendChild(anchor); } } catch (error) { status.textContent = error.message; } @@ -373,13 +384,17 @@ func (router *tenantRouter) serveTelegramWeb(writer http.ResponseWriter, request return true } username := "" + ready := false + lastError := "" if router.telegram != nil { - username = router.telegram.username() + username, ready, lastError = router.telegram.status() } writeJSON(writer, http.StatusOK, map[string]any{ "configured": router.telegram != nil, + "ready": ready, "linked": linked, "bot_username": username, + "error": lastError, }) return true case "/api/telegram/link": @@ -391,20 +406,22 @@ func (router *tenantRouter) serveTelegramWeb(writer http.ResponseWriter, request writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "Telegram bot token is not configured"}) return true } + username, ready, _ := router.telegram.status() + if !ready || username == "" { + writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "The configured Telegram bot is not active. Ask the operator to update its BotFather token and roll out the router."}) + return true + } code, expires, err := router.createLink(subject) if err != nil { writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) return true } - username := router.telegram.username() - deepLink := "" - if username != "" { - deepLink = fmt.Sprintf("https://t.me/%s?start=%s", url.PathEscape(username), url.QueryEscape(code)) - } + deepLink := fmt.Sprintf("https://t.me/%s?start=%s", url.PathEscape(username), url.QueryEscape(code)) writeJSON(writer, http.StatusOK, map[string]any{ - "code": code, - "expires_at": expires.Format(time.RFC3339), - "deep_link": deepLink, + "code": code, + "expires_at": expires.Format(time.RFC3339), + "deep_link": deepLink, + "bot_username": username, }) return true case "/api/telegram/unlink": @@ -434,8 +451,8 @@ func injectChatBridge(response *http.Response) error { _ = response.Body.Close() content := string(body) if !strings.Contains(content, "hermes-chat-bridge.js") { - content = strings.Replace(content, "", ``, 1) - content = strings.Replace(content, "", ``, 1) + content = strings.Replace(content, "", ``, 1) + content = strings.Replace(content, "", ``, 1) } response.Body = io.NopCloser(strings.NewReader(content)) response.ContentLength = int64(len(content))