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…
Create one-time link
@@ -45,7 +46,7 @@ vault kv patch -mount=kv atlas/hermes/chat-telegram bot_token='<BOTFATHER_TOK
Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.
-
+