hermes: verify Telegram bot readiness

This commit is contained in:
jenkins 2026-08-13 12:39:11 -03:00
parent fd3be220f3
commit 33bdf694f0
4 changed files with 99 additions and 54 deletions

View File

@ -20,7 +20,7 @@ spec:
app: hermes-chat-router app: hermes-chat-router
annotations: annotations:
ai.bstein.dev/role: privacy-preserving-chat-tenant-router 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-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true" vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-init-first: "true" vault.hashicorp.com/agent-init-first: "true"

View File

@ -374,6 +374,7 @@ func TestTelegramWebActionsRequireExplicitSameOriginHeader(t *testing.T) {
if response.Code != http.StatusForbidden { if response.Code != http.StatusForbidden {
t.Fatalf("got %d", response.Code) t.Fatalf("got %d", response.Code)
} }
router.telegram.setStatus("BsteinAtlasHermesBot", true, "")
request = httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`)) request = httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
request.Header.Set("X-Forwarded-User", "subject") request.Header.Set("X-Forwarded-User", "subject")
request.Header.Set("Content-Type", "application/json") 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()) 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())
}
}

View File

@ -30,6 +30,8 @@ type telegramBot struct {
agentClient *http.Client agentClient *http.Client
mu sync.RWMutex mu sync.RWMutex
botUsername string botUsername string
ready bool
lastError string
workLimit chan struct{} workLimit chan struct{}
slotLocks map[int]*sync.Mutex slotLocks map[int]*sync.Mutex
slotLocksMux 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() bot.mu.RLock()
defer bot.mu.RUnlock() 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() bot.mu.Lock()
defer bot.mu.Unlock() 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 { 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() { func (bot *telegramBot) run() {
for { for {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) for {
var me struct { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
Username string `json:"username"` 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) for {
cancel() offset := bot.router.telegramOffset()
if err == nil { ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second)
bot.setUsername(me.Username) values := url.Values{
break "offset": {strconv.FormatInt(offset, 10)},
} "timeout": {"50"},
time.Sleep(10 * time.Second) "allowed_updates": {`["message"]`},
} }
for { var updates []telegramUpdate
offset := bot.router.telegramOffset() err := bot.call(ctx, "getUpdates", values, &updates)
ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second) cancel()
values := url.Values{ if err != nil {
"offset": {strconv.FormatInt(offset, 10)}, bot.setStatus("", false, "Hermes lost contact with the configured Telegram bot.")
"timeout": {"50"}, time.Sleep(3 * time.Second)
"allowed_updates": {`["message"]`}, break
} }
var updates []telegramUpdate for _, update := range updates {
err := bot.call(ctx, "getUpdates", values, &updates) _ = bot.router.setTelegramOffset(update.UpdateID + 1)
cancel() bot.workLimit <- struct{}{}
if err != nil { go func(update telegramUpdate) {
time.Sleep(3 * time.Second) defer func() { <-bot.workLimit }()
continue bot.handleUpdate(update)
} }(update)
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)
} }
} }
} }

View File

@ -17,13 +17,14 @@ const telegramPage = `<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hermes on Telegram</title> <title>Hermes on Telegram</title>
<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-operator-v2"> <link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-readiness-v1">
</head> </head>
<body class="hermes-link-page"> <body class="hermes-link-page">
<main class="hermes-link-card" data-telegram-page> <main class="hermes-link-card" data-telegram-page>
<a class="hermes-back" href="/"> Back to Hermes</a> <a class="hermes-back" href="/"> Back to Hermes</a>
<h1>Hermes on Telegram</h1> <h1>Hermes on Telegram</h1>
<p>The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.</p> <p>The operator configures one shared Hermes bot. Link your own Telegram account once so direct messages use this Keycloak account's isolated Hermes tenant.</p>
<p><strong>Account-link commands go only to the private chat with the Hermes botnot to Hermes WebUI and not to BotFather.</strong></p>
<p id="telegram-status">Checking Telegram</p> <p id="telegram-status">Checking Telegram</p>
<div class="hermes-link-actions"> <div class="hermes-link-actions">
<button id="telegram-link" type="button">Create one-time link</button> <button id="telegram-link" type="button">Create one-time link</button>
@ -45,7 +46,7 @@ vault kv patch -mount=kv atlas/hermes/chat-telegram bot_token='&lt;BOTFATHER_TOK
</section> </section>
<p class="hermes-fine-print">Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.</p> <p class="hermes-fine-print">Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.</p>
</main> </main>
<script src="/hermes-chat-bridge.js?v=20260813-telegram-operator-v2" defer></script> <script src="/hermes-chat-bridge.js?v=20260813-telegram-readiness-v1" defer></script>
</body> </body>
</html>` </html>`
@ -279,10 +280,18 @@ const bridgeJS = `(() => {
operatorSetup.hidden = false; operatorSetup.hidden = false;
return; 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; operatorSetup.hidden = true;
linkButton.hidden = false; linkButton.hidden = false;
linkButton.disabled = 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; unlinkButton.hidden = !payload.linked;
} catch (_) { status.textContent = 'Telegram status is temporarily unavailable.'; } } catch (_) { status.textContent = 'Telegram status is temporarily unavailable.'; }
}; };
@ -292,13 +301,15 @@ const bridgeJS = `(() => {
result.hidden = false; result.hidden = false;
result.replaceChildren(); result.replaceChildren();
const text = document.createElement('p'); 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); result.appendChild(text);
if (payload.deep_link) { if (payload.deep_link) {
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = payload.deep_link; anchor.href = payload.deep_link;
anchor.rel = 'noopener noreferrer'; 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); result.appendChild(anchor);
} }
} catch (error) { status.textContent = error.message; } } catch (error) { status.textContent = error.message; }
@ -373,13 +384,17 @@ func (router *tenantRouter) serveTelegramWeb(writer http.ResponseWriter, request
return true return true
} }
username := "" username := ""
ready := false
lastError := ""
if router.telegram != nil { if router.telegram != nil {
username = router.telegram.username() username, ready, lastError = router.telegram.status()
} }
writeJSON(writer, http.StatusOK, map[string]any{ writeJSON(writer, http.StatusOK, map[string]any{
"configured": router.telegram != nil, "configured": router.telegram != nil,
"ready": ready,
"linked": linked, "linked": linked,
"bot_username": username, "bot_username": username,
"error": lastError,
}) })
return true return true
case "/api/telegram/link": 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"}) writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "Telegram bot token is not configured"})
return true 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) code, expires, err := router.createLink(subject)
if err != nil { if err != nil {
writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return true return true
} }
username := router.telegram.username() deepLink := fmt.Sprintf("https://t.me/%s?start=%s", url.PathEscape(username), url.QueryEscape(code))
deepLink := ""
if username != "" {
deepLink = fmt.Sprintf("https://t.me/%s?start=%s", url.PathEscape(username), url.QueryEscape(code))
}
writeJSON(writer, http.StatusOK, map[string]any{ writeJSON(writer, http.StatusOK, map[string]any{
"code": code, "code": code,
"expires_at": expires.Format(time.RFC3339), "expires_at": expires.Format(time.RFC3339),
"deep_link": deepLink, "deep_link": deepLink,
"bot_username": username,
}) })
return true return true
case "/api/telegram/unlink": case "/api/telegram/unlink":
@ -434,8 +451,8 @@ func injectChatBridge(response *http.Response) error {
_ = response.Body.Close() _ = response.Body.Close()
content := string(body) content := string(body)
if !strings.Contains(content, "hermes-chat-bridge.js") { if !strings.Contains(content, "hermes-chat-bridge.js") {
content = strings.Replace(content, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-setup"></head>`, 1) content = strings.Replace(content, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-readiness-v1"></head>`, 1)
content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260813-telegram-setup" defer></script></body>`, 1) content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260813-telegram-readiness-v1" defer></script></body>`, 1)
} }
response.Body = io.NopCloser(strings.NewReader(content)) response.Body = io.NopCloser(strings.NewReader(content))
response.ContentLength = int64(len(content)) response.ContentLength = int64(len(content))