hermes: verify Telegram bot readiness
This commit is contained in:
parent
fd3be220f3
commit
33bdf694f0
@ -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"
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
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 {
|
||||
@ -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)
|
||||
@ -186,6 +195,7 @@ func (bot *telegramBot) run() {
|
||||
}(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandParts(text string) (string, []string) {
|
||||
|
||||
@ -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 bot—not 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='<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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user