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
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"

View File

@ -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())
}
}

View File

@ -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,17 +94,21 @@ 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()
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 {
bot.slotLocksMux.Lock()
@ -149,6 +155,7 @@ func (bot *telegramBot) call(ctx context.Context, method string, values url.Valu
}
func (bot *telegramBot) run() {
for {
for {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
var me struct {
@ -157,9 +164,10 @@ func (bot *telegramBot) run() {
err := bot.call(ctx, "getMe", url.Values{}, &me)
cancel()
if err == nil {
bot.setUsername(me.Username)
bot.setStatus(me.Username, true, "")
break
}
bot.setStatus("", false, "Telegram rejected the configured bot token or is temporarily unavailable.")
time.Sleep(10 * time.Second)
}
for {
@ -174,8 +182,9 @@ func (bot *telegramBot) run() {
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)
continue
break
}
for _, update := range updates {
_ = bot.router.setTelegramOffset(update.UpdateID + 1)
@ -187,6 +196,7 @@ func (bot *telegramBot) run() {
}
}
}
}
func commandParts(text string) (string, []string) {
fields := strings.Fields(strings.TrimSpace(text))

View File

@ -17,13 +17,14 @@ const telegramPage = `<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<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>
<body class="hermes-link-page">
<main class="hermes-link-card" data-telegram-page>
<a class="hermes-back" href="/"> Back to Hermes</a>
<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><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>
<div class="hermes-link-actions">
<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>
<p class="hermes-fine-print">Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.</p>
</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>
</html>`
@ -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,
"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, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260813-telegram-setup"></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, "</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-readiness-v1" defer></script></body>`, 1)
}
response.Body = io.NopCloser(strings.NewReader(content))
response.ContentLength = int64(len(content))