atlas-iac/services/hermes/router/telegram_test.go
2026-08-17 14:39:21 +00:00

430 lines
15 KiB
Go

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