package main import ( "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" ) func TestReadTelegramConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "telegram-config") if err := os.WriteFile(path, []byte("bot_token=123:abc\nrelay_key=relay-secret\n"), 0600); err != nil { t.Fatal(err) } config, err := readTelegramConfig(path) if err != nil { t.Fatal(err) } if config.BotToken != "123:abc" || config.RelayKey != "relay-secret" { t.Fatalf("unexpected config: %#v", config) } } func TestAskTenantUsesAuthenticatedNamedConversation(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.URL.Path != "/v1/responses" { t.Fatalf("unexpected path %s", request.URL.Path) } if request.Header.Get("Authorization") != "Bearer relay-secret" { t.Fatal("relay authentication was not set") } if request.Header.Get("X-Hermes-Session-Key") != "telegram-topic-cassandra" { t.Fatal("Telegram session scope was not set") } if request.Header.Get("X-Hermes-Conversation-Platform") != "telegram" || request.Header.Get("X-Hermes-Conversation-Title") != "Telegram ยท Cassandra" { t.Fatalf("Telegram origin metadata was not set: %#v", request.Header) } var payload map[string]any if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatal(err) } if payload["conversation"] != "telegram-topic-cassandra" || payload["input"] != "hello" || payload["truncation"] != "auto" { t.Fatalf("unexpected payload: %#v", payload) } writer.Header().Set("Content-Type", "application/json") _, _ = writer.Write([]byte(`{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello from Hermes"}]}]}`)) })) defer server.Close() router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" }) if err != nil { t.Fatal(err) } router.backendAPIURL = func(slot int) string { return server.URL } bot := newTelegramBot(telegramConfig{RelayKey: "relay-secret"}, router) answer, err := bot.askTenant(0, "hello", 42, activeTelegramTopic{ Conversation: "telegram-topic-cassandra", Label: "Cassandra", }) if err != nil { t.Fatal(err) } if answer != "Hello from Hermes" { t.Fatalf("unexpected answer %q", answer) } } func TestSplitTelegramTextStaysUnderTelegramLimit(t *testing.T) { chunks := splitTelegramText(strings.Repeat("word ", 2000)) if len(chunks) < 2 { t.Fatal("expected a long response to be split") } for _, chunk := range chunks { if len([]rune(chunk)) > 3900 { t.Fatalf("chunk exceeds limit: %d", len([]rune(chunk))) } } } func TestTelegramCallIncludesAPIDescription(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusConflict) _, _ = writer.Write([]byte(`{"ok":false,"error_code":409,"description":"Conflict: terminated by other getUpdates request"}`)) })) defer server.Close() bot := &telegramBot{ apiBase: server.URL, client: &http.Client{Timeout: time.Second}, } err := bot.call(t.Context(), "getUpdates", nil, nil) if err == nil || !strings.Contains(err.Error(), "terminated by other getUpdates request") { t.Fatalf("expected Telegram API description, got %v", err) } } func jpegInputFixture() []byte { return append( []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}, []byte("telegram-input-image")..., ) } func TestFetchTelegramPhotoStagesLargestImageWithoutTrustingPath(t *testing.T) { image := jpegInputFixture() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { switch request.URL.Path { case "/getFile": if err := request.ParseForm(); err != nil { t.Fatal(err) } if request.Form.Get("file_id") != "large" { t.Fatalf("unexpected file id %q", request.Form.Get("file_id")) } writer.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(writer).Encode(map[string]any{ "ok": true, "result": map[string]any{ "file_path": "photos/input.jpg", "file_size": len(image), }, }) case "/file/photos/input.jpg": writer.Header().Set("Content-Type", "application/octet-stream") _, _ = writer.Write(image) default: t.Fatalf("unexpected Telegram path %s", request.URL.Path) } })) defer server.Close() bot := &telegramBot{ apiBase: server.URL, fileBase: server.URL + "/file", client: &http.Client{Timeout: time.Second}, } media, err := bot.fetchTelegramPhoto([]telegramPhotoSize{ {FileID: "small", Width: 100, Height: 100, FileSize: 50}, {FileID: "large", Width: 800, Height: 600, FileSize: int64(len(image))}, }) if err != nil { t.Fatal(err) } defer os.Remove(media.Path) actual, err := os.ReadFile(media.Path) if err != nil { t.Fatal(err) } if media.MIME != "image/jpeg" || string(actual) != string(image) { t.Fatalf("unexpected staged image: mime=%q bytes=%x", media.MIME, actual) } info, err := os.Stat(media.Path) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0600 { t.Fatalf("staged image mode is %o", info.Mode().Perm()) } } func TestTelegramImageRequestStreamsMultimodalInput(t *testing.T) { image := jpegInputFixture() imagePath := filepath.Join(t.TempDir(), "input.jpg") if err := os.WriteFile(imagePath, image, 0600); err != nil { t.Fatal(err) } server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Header.Get("Authorization") != "Bearer relay-secret" { t.Fatal("relay authentication was not set") } body, err := io.ReadAll(request.Body) if err != nil { t.Fatal(err) } var payload struct { Input []struct { Role string `json:"role"` Content []struct { Type string `json:"type"` Text string `json:"text"` ImageURL string `json:"image_url"` } `json:"content"` } `json:"input"` Conversation string `json:"conversation"` Store bool `json:"store"` Truncation string `json:"truncation"` } if err := json.Unmarshal(body, &payload); err != nil { t.Fatalf("decode streamed request: %v\n%s", err, body) } if payload.Conversation != "telegram-topic-images" || !payload.Store || payload.Truncation != "auto" || len(payload.Input) != 1 || len(payload.Input[0].Content) != 2 { t.Fatalf("unexpected image payload: %#v", payload) } if payload.Input[0].Role != "user" || payload.Input[0].Content[0].Text != "What is this?" || payload.Input[0].Content[1].Type != "input_image" { t.Fatalf("unexpected image content: %#v", payload.Input[0]) } encoded := strings.TrimPrefix(payload.Input[0].Content[1].ImageURL, "data:image/jpeg;base64,") decoded, err := base64.StdEncoding.DecodeString(encoded) if err != nil || string(decoded) != string(image) { t.Fatalf("unexpected encoded image: err=%v bytes=%x", err, decoded) } writer.Header().Set("Content-Type", "application/json") _, _ = writer.Write([]byte(`{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"It is a test image."}]}]}`)) })) defer server.Close() router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(int) string { return "" }) if err != nil { t.Fatal(err) } router.backendAPIURL = func(int) string { return server.URL } bot := newTelegramBot(telegramConfig{RelayKey: "relay-secret"}, router) answer, err := bot.askTenantImage(0, "What is this?", telegramInputMedia{ MIME: "image/jpeg", Path: imagePath, }, 99, activeTelegramTopic{Conversation: "telegram-topic-images", Label: "Images"}) if err != nil { t.Fatal(err) } if answer != "It is a test image." { t.Fatalf("unexpected answer %q", answer) } } func TestTelegramTopicsPersistWithoutStoringTelegramIdentity(t *testing.T) { statePath := filepath.Join(t.TempDir(), "state.json") router, err := newTenantRouter(statePath, 1, func(int) string { return "" }) if err != nil { t.Fatal(err) } selected, err := router.selectTelegramTopic("123456789", " Cassandra repairs ") if err != nil { t.Fatal(err) } if selected.Label != "Cassandra repairs" || !strings.HasPrefix(selected.Conversation, "telegram-topic-") { t.Fatalf("unexpected selected topic: %#v", selected) } reloaded, err := newTenantRouter(statePath, 1, func(int) string { return "" }) if err != nil { t.Fatal(err) } if active := reloaded.activeTelegramTopic("123456789"); active != selected { t.Fatalf("topic did not persist: got %#v want %#v", active, selected) } content, err := os.ReadFile(statePath) if err != nil { t.Fatal(err) } if strings.Contains(string(content), "123456789") { t.Fatal("raw Telegram identity leaked into router state") } } func TestTelegramTopicValidationAndLegacyGeneralConversation(t *testing.T) { if telegramTopicConversation("general") != "telegram" { t.Fatal("general topic did not preserve the legacy conversation") } if _, err := normalizeTelegramTopicLabel(strings.Repeat("x", 49)); err == nil { t.Fatal("oversized topic name was accepted") } if _, err := normalizeTelegramTopicLabel("unsafe\nname"); err != nil { // Whitespace is deliberately normalized so a pasted line break is safe. t.Fatalf("normalizable whitespace was rejected: %v", err) } if first, second := telegramTopicID("Cassandra"), telegramTopicID("cassandra"); first != second { t.Fatalf("case-insensitive topic IDs diverged: %q %q", first, second) } } func TestTelegramTopicContinuityExpiresOnlyAfterIdleWeek(t *testing.T) { statePath := filepath.Join(t.TempDir(), "state.json") base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) now := base router, err := newTenantRouter(statePath, 1, func(int) string { return "" }) if err != nil { t.Fatal(err) } router.now = func() time.Time { return now } first, err := router.selectTelegramTopic("123", "Week plan") if err != nil { t.Fatal(err) } for _, elapsed := range []time.Duration{time.Hour, 24 * time.Hour} { now = base.Add(elapsed) resumed, touchErr := router.touchTelegramTopic("123") if touchErr != nil || resumed.Conversation != first.Conversation { t.Fatalf("topic changed after %s: %#v, %v", elapsed, resumed, touchErr) } } // Exactly seven idle days remains the same durable conversation. now = base.Add(8 * 24 * time.Hour) exactWeek, err := router.touchTelegramTopic("123") if err != nil || exactWeek.Conversation != first.Conversation { t.Fatalf("topic changed at exact idle week: %#v, %v", exactWeek, err) } reloaded, err := newTenantRouter(statePath, 1, func(int) string { return "" }) if err != nil { t.Fatal(err) } reloaded.now = func() time.Time { return now } if active := reloaded.activeTelegramTopic("123"); active != exactWeek { t.Fatalf("router restart changed topic: %#v != %#v", active, exactWeek) } now = now.Add(7*24*time.Hour + time.Second) rotated, err := reloaded.touchTelegramTopic("123") if err != nil { t.Fatal(err) } if rotated.Conversation == first.Conversation || !strings.HasSuffix(rotated.Conversation, "-g1") { t.Fatalf("expired topic did not rotate deterministically: %#v", rotated) } again, err := newTenantRouter(statePath, 1, func(int) string { return "" }) if err != nil { t.Fatal(err) } if active := again.activeTelegramTopic("123"); active != rotated { t.Fatalf("rotated identity changed after restart: %#v != %#v", active, rotated) } } func TestTelegramActivityRepeatsAndStops(t *testing.T) { var actions atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.URL.Path != "/sendChatAction" { t.Fatalf("unexpected Telegram method %s", request.URL.Path) } actions.Add(1) writer.Header().Set("Content-Type", "application/json") _, _ = writer.Write([]byte(`{"ok":true,"result":true}`)) })) defer server.Close() bot := &telegramBot{ apiBase: server.URL, client: &http.Client{Timeout: time.Second}, activityEvery: 5 * time.Millisecond, } stop := bot.startTelegramActivity(42) deadline := time.Now().Add(time.Second) for actions.Load() < 2 && time.Now().Before(deadline) { time.Sleep(time.Millisecond) } stop() stoppedAt := actions.Load() time.Sleep(20 * time.Millisecond) if stoppedAt < 2 || actions.Load() != stoppedAt { t.Fatalf("activity did not progress and stop: before=%d after=%d", stoppedAt, actions.Load()) } } func TestTelegramActivityStopCancelsInflightRequest(t *testing.T) { started := make(chan struct{}) release := make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { close(started) select { case <-request.Context().Done(): case <-release: } })) defer server.Close() bot := &telegramBot{ apiBase: server.URL, client: &http.Client{Timeout: time.Minute}, activityEvery: time.Hour, } stop := bot.startTelegramActivity(42) select { case <-started: case <-time.After(time.Second): t.Fatal("activity request did not start") } stopped := make(chan struct{}) go func() { stop() close(stopped) }() select { case <-stopped: case <-time.After(time.Second): t.Fatal("activity stop did not cancel the in-flight request") } close(release) } func TestTelegramImageEditPromptStatesTransportBoundary(t *testing.T) { withPhoto := telegramInputPrompt("Edit this photo into a watercolor", true) if !strings.Contains(withPhoto, "available for visual analysis") || !strings.Contains(withPhoto, "not stored as a generated-image artifact") { t.Fatalf("inbound attachment boundary missing: %q", withPhoto) } withoutPhoto := telegramInputPrompt("Edit this photo into a watercolor", false) if !strings.Contains(withoutPhoto, "no image is attached") || !strings.Contains(withoutPhoto, "image_edit_latest only if Hermes previously generated") { t.Fatalf("prior generated image boundary missing: %q", withoutPhoto) } for _, request := range []string{"Make the photo brighter", "Crop it closer"} { if got := telegramInputPrompt(request, false); !strings.Contains(got, "no image is attached") { t.Fatalf("edit variant omitted attachment boundary: %q", got) } } plain := telegramInputPrompt("Explain this photograph", true) if !strings.Contains(plain, "available for visual analysis") || !strings.Contains(plain, "User request: Explain this photograph") { t.Fatalf("photo analysis boundary missing: %q", plain) } } func TestTelegramInputRejectsUnsafePathsAndNonImages(t *testing.T) { for _, value := range []string{"", "../secret", "/absolute/image.jpg", `photos\image.jpg`, "photos/../../secret"} { if _, err := safeTelegramFilePath(value); err == nil { t.Errorf("unsafe path accepted: %q", value) } } if _, err := supportedTelegramImageType([]byte("not an image")); err == nil { t.Fatal("non-image input was accepted") } if _, err := largestTelegramPhoto([]telegramPhotoSize{{ FileID: "too-large", Width: 1, Height: 1, FileSize: telegramInputImageLimit + 1, }}); err == nil { t.Fatal("oversized Telegram image was accepted") } }