hermes: verify Telegram bot readiness
This commit is contained in:
parent
fd3be220f3
commit
33bdf694f0
@ -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"
|
||||||
|
|||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,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()
|
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()
|
||||||
|
if username != "" {
|
||||||
bot.botUsername = strings.TrimPrefix(strings.TrimSpace(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 {
|
||||||
bot.slotLocksMux.Lock()
|
bot.slotLocksMux.Lock()
|
||||||
@ -149,6 +155,7 @@ func (bot *telegramBot) call(ctx context.Context, method string, values url.Valu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (bot *telegramBot) run() {
|
func (bot *telegramBot) run() {
|
||||||
|
for {
|
||||||
for {
|
for {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
var me struct {
|
var me struct {
|
||||||
@ -157,9 +164,10 @@ func (bot *telegramBot) run() {
|
|||||||
err := bot.call(ctx, "getMe", url.Values{}, &me)
|
err := bot.call(ctx, "getMe", url.Values{}, &me)
|
||||||
cancel()
|
cancel()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
bot.setUsername(me.Username)
|
bot.setStatus(me.Username, true, "")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
bot.setStatus("", false, "Telegram rejected the configured bot token or is temporarily unavailable.")
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
@ -174,8 +182,9 @@ func (bot *telegramBot) run() {
|
|||||||
err := bot.call(ctx, "getUpdates", values, &updates)
|
err := bot.call(ctx, "getUpdates", values, &updates)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
bot.setStatus("", false, "Hermes lost contact with the configured Telegram bot.")
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
for _, update := range updates {
|
for _, update := range updates {
|
||||||
_ = bot.router.setTelegramOffset(update.UpdateID + 1)
|
_ = bot.router.setTelegramOffset(update.UpdateID + 1)
|
||||||
@ -187,6 +196,7 @@ func (bot *telegramBot) run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func commandParts(text string) (string, []string) {
|
func commandParts(text string) (string, []string) {
|
||||||
fields := strings.Fields(strings.TrimSpace(text))
|
fields := strings.Fields(strings.TrimSpace(text))
|
||||||
|
|||||||
@ -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 bot—not 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='<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))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user