package telegram import ( "encoding/json" "fmt" "path/filepath" "strings" "testing" "codex-telegram-bot/internal/store" ) func TestEscapeHTML(t *testing.T) { got := EscapeHTML(``) want := "<run & "test">" if got != want { t.Fatalf("EscapeHTML() = %q, want %q", got, want) } } func TestChunkText(t *testing.T) { text := strings.Repeat("a", 25) chunks := ChunkText(text, 10) if len(chunks) != 3 { t.Fatalf("got %d chunks", len(chunks)) } for _, chunk := range chunks { if len([]rune(chunk)) > 10 { t.Fatalf("chunk too long: %q", chunk) } } } func TestTelegramClientRedactsToken(t *testing.T) { client := NewClient("secret-token") err := client.redactError(fmt.Errorf("Post %q: context canceled", client.baseURL+"/sendMessage")) if strings.Contains(err.Error(), "secret-token") { t.Fatalf("token was not redacted: %v", err) } if !strings.Contains(err.Error(), "") { t.Fatalf("redacted token marker missing: %v", err) } } func TestApprovalCallbackData(t *testing.T) { data := ApprovalCallbackData(12, "accept") id, decision, ok := ParseApprovalCallbackData(data) if !ok || id != 12 || decision != "accept" { t.Fatalf("unexpected callback parse: id=%d decision=%s ok=%v", id, decision, ok) } if _, _, ok := ParseApprovalCallbackData("approval:12:unknown"); ok { t.Fatal("unknown decisions should be rejected") } } func TestApprovalCallbackDataForStructuredDecision(t *testing.T) { data := ApprovalCallbackData(12, "acceptWithExecpolicyAmendment") id, decision, ok := ParseApprovalCallbackData(data) if !ok || id != 12 || decision != "acceptWithExecpolicyAmendment" { t.Fatalf("unexpected structured callback parse: id=%d decision=%s ok=%v", id, decision, ok) } data = ApprovalCallbackData(12, "networkPolicy0") id, decision, ok = ParseApprovalCallbackData(data) if !ok || id != 12 || decision != "networkPolicy0" { t.Fatalf("unexpected network callback parse: id=%d decision=%s ok=%v", id, decision, ok) } if _, _, ok := ParseApprovalCallbackData("approval:12:networkPolicyx"); ok { t.Fatal("invalid network policy callback should be rejected") } } func TestApprovalMarkupHonorsAvailableDecisions(t *testing.T) { raw := json.RawMessage(`{"availableDecisions":["accept",{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","push"]}},"decline"]}`) markup := approvalMarkupForPayload(42, raw) var labels []string for _, row := range markup.InlineKeyboard { for _, button := range row { labels = append(labels, button.Text) } } joined := strings.Join(labels, "|") for _, want := range []string{"Approve", "Approve rule", "Deny", "Details"} { if !strings.Contains(joined, want) { t.Fatalf("approval markup missing %q in %#v", want, markup.InlineKeyboard) } } if strings.Contains(joined, "Cancel") { t.Fatalf("cancel should not be shown when Codex does not advertise it: %#v", markup.InlineKeyboard) } } func TestApprovalResponseForCommandStructuredDecision(t *testing.T) { approval := store.PendingApproval{ Kind: "item/commandExecution/requestApproval", PayloadJSON: `{"availableDecisions":[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","push"]}},{"applyNetworkPolicyAmendment":{"network_policy_amendment":{"action":"allow","host":"example.com"}}}]}`, } response, ok := approvalResponse(approval, "acceptWithExecpolicyAmendment").(map[string]any) if !ok { t.Fatal("structured command response should be a map") } decision, ok := response["decision"].(map[string]any) if !ok { t.Fatalf("decision should be structured: %#v", response["decision"]) } if _, ok := decision["acceptWithExecpolicyAmendment"]; !ok { t.Fatalf("missing execpolicy decision: %#v", decision) } response, ok = approvalResponse(approval, "networkPolicy0").(map[string]any) if !ok { t.Fatal("network command response should be a map") } decision, ok = response["decision"].(map[string]any) if !ok { t.Fatalf("network decision should be structured: %#v", response["decision"]) } if _, ok := decision["applyNetworkPolicyAmendment"]; !ok { t.Fatalf("missing network policy decision: %#v", decision) } } func TestApprovalResponseForPermissions(t *testing.T) { approval := store.PendingApproval{ Kind: "item/permissions/requestApproval", PayloadJSON: `{"permissions":{"network":{"enabled":true}}}`, } response, ok := approvalResponse(approval, "accept").(map[string]any) if !ok { t.Fatal("approval response should be a map") } if response["scope"] != "turn" { t.Fatalf("scope = %v, want turn", response["scope"]) } permissions, ok := response["permissions"].(map[string]any) if !ok { t.Fatal("permissions should be a map") } network, ok := permissions["network"].(map[string]any) if !ok || network["enabled"] != true { t.Fatalf("unexpected permissions: %#v", permissions) } denied, ok := approvalResponse(approval, "decline").(map[string]any) if !ok { t.Fatal("denied response should be a map") } deniedPermissions, ok := denied["permissions"].(map[string]any) if !ok || len(deniedPermissions) != 0 { t.Fatalf("denied permissions = %#v, want empty map", denied["permissions"]) } } func TestApprovalResponseForLegacyApproval(t *testing.T) { approval := store.PendingApproval{Kind: "execCommandApproval"} response, ok := approvalResponse(approval, "accept").(map[string]any) if !ok { t.Fatal("legacy response should be a map") } if response["decision"] != "approved" { t.Fatalf("legacy accept = %v, want approved", response["decision"]) } response, ok = approvalResponse(approval, "decline").(map[string]any) if !ok { t.Fatal("legacy decline response should be a map") } if response["decision"] != "denied" { t.Fatalf("legacy decline = %v, want denied", response["decision"]) } } func TestRenderLegacyApprovalDetails(t *testing.T) { raw := json.RawMessage(`{"conversationId":"thr_1","callId":"call_1","command":["git","remote","-v"],"cwd":"/workspace/project","reason":"Need remote details"}`) text := renderApprovalHTML("execCommandApproval", raw, "") for _, want := range []string{"Codex requests command approval", "Need remote details", "language-bash", "git", "CWD"} { if !strings.Contains(text, want) { t.Fatalf("legacy approval render missing %q in %q", want, text) } } } func TestEditReplyMarkupClearsInlineKeyboard(t *testing.T) { markup := editReplyMarkup(nil) if markup == nil { t.Fatal("nil edit markup would leave Telegram buttons unchanged") } raw, err := json.Marshal(markup) if err != nil { t.Fatal(err) } if string(raw) != `{"inline_keyboard":[]}` { t.Fatalf("clear markup JSON = %s", raw) } existing := approvalMarkup(7) if editReplyMarkup(existing) != existing { t.Fatal("non-nil markup should be preserved") } } func TestBotCommandsUseSingleThreadCommand(t *testing.T) { commands := botCommands() seen := map[string]bool{} for _, command := range commands { seen[command.Command] = true } if !seen["thread"] { t.Fatal("bot command list should include /thread") } for _, removed := range []string{"threads", "resume"} { if seen[removed] { t.Fatalf("bot command list should not include /%s", removed) } } } func TestParseCommand(t *testing.T) { name, args, ok := parseCommand("/thread@my_bot 123") if !ok || name != "thread" || len(args) != 1 || args[0] != "123" { t.Fatalf("unexpected command parse: %q %#v %v", name, args, ok) } } func TestPictureGenerationInstruction(t *testing.T) { instruction := pictureGenerationInstruction("generate a blue cube") for _, want := range []string{"Telegram /pic command", "built-in image generation", "generate a blue cube"} { if !strings.Contains(instruction, want) { t.Fatalf("instruction missing %q in %q", want, instruction) } } for _, unwanted := range []string{"/home", "repo/playground"} { if strings.Contains(instruction, unwanted) { t.Fatalf("instruction contains non-portable text %q: %q", unwanted, instruction) } } } func TestSplitAssistantMessageSegmentsWithPhotoDirective(t *testing.T) { photoPath := filepath.Join(string(filepath.Separator), "workspace", "photo.jpg") text := fmt.Sprintf("before\n\nafter", photoPath) segments := splitAssistantMessageSegments(text) if len(segments) != 3 { t.Fatalf("segments = %d, want 3: %#v", len(segments), segments) } if segments[0].Text != "before\n" || segments[0].Photo != nil { t.Fatalf("unexpected first segment: %#v", segments[0]) } if segments[1].Photo == nil || segments[1].Photo.Path != photoPath || segments[1].Photo.Caption != "hello" { t.Fatalf("unexpected photo segment: %#v", segments[1]) } if segments[2].Text != "after" || segments[2].Photo != nil { t.Fatalf("unexpected final segment: %#v", segments[2]) } } func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) { text := "" segments := splitAssistantMessageSegments(text) if len(segments) != 1 || segments[0].Text != text { t.Fatalf("invalid directive should stay text: %#v", segments) } } func TestSplitAssistantMessageSegmentsWithThreadDirectives(t *testing.T) { cwd := filepath.Join(string(filepath.Separator), "workspace", "project") text := fmt.Sprintf("\n", cwd) segments := splitAssistantMessageSegments(text) if len(segments) != 2 { t.Fatalf("segments = %d, want 2: %#v", len(segments), segments) } if segments[0].ThreadRename == nil || segments[0].ThreadRename.Title != "A Better Thread Title" { t.Fatalf("unexpected rename segment: %#v", segments[0]) } if segments[1].ThreadCWD == nil || segments[1].ThreadCWD.CWD != cwd { t.Fatalf("unexpected cwd segment: %#v", segments[1]) } } func TestRenderCodexCommandExecutionItem(t *testing.T) { output := "line 1\nline 2" exitCode := 0 item := codexThreadItemView{ Type: "commandExecution", Command: "go test ./...", CWD: "/workspace/project", AggregatedOutput: &output, ExitCode: &exitCode, } text := renderCodexItemCompleted(item) for _, want := range []string{"Tool call: command finished", "CWD: /workspace/project", "Command:
go test ./...
", "Output:
line 1\nline 2
", "Exit code: 0"} { if !strings.Contains(text, want) { t.Fatalf("rendered command item missing %q in %q", want, text) } } summary, _, _ := strings.Cut(text, "
") if strings.Contains(summary, "Exit code") { t.Fatalf("exit code should not be in summary: %q", summary) } cwdAt := strings.Index(text, "CWD:") commandAt := strings.Index(text, "Command:") outputAt := strings.Index(text, "Output:") exitAt := strings.Index(text, "Exit code:") if !(cwdAt >= 0 && commandAt > cwdAt && outputAt > commandAt && exitAt > outputAt) { t.Fatalf("command details order should be CWD, Command, Output, fields: %q", text) } if got := strings.Count(text, "
"); got != 4 { t.Fatalf("command details should use four quoted sections, got %d in %q", got, text) } } func TestFitHTMLMessageKeepsCodeBlockTagsBalanced(t *testing.T) { longOutput := strings.Repeat("0123456789abcdef\n", 600) text := SummaryRawHTMLSectionsLimited("Tool call: command finished", []string{"Output: " + CodeBlockHTML("text", longOutput)}, 900) if len([]rune(text)) > 900 { t.Fatalf("fitted message exceeds limit: %d", len([]rune(text))) } for _, tag := range []string{"
", "
", "
", "
", "", ""} { if !strings.Contains(text, tag) { t.Fatalf("fitted message missing %q in %q", tag, text) } } if strings.Count(text, "
") != strings.Count(text, "
") || strings.Count(text, "") || strings.Count(text, "
") != strings.Count(text, "
") { t.Fatalf("fitted message has unbalanced HTML tags: %q", text) } if strings.Contains(text, "Output:\n") { t.Fatalf("label should not be separated from code block by an immediate newline: %q", text) } } func TestRenderCodexStartedItems(t *testing.T) { text := renderCodexItemStarted(codexThreadItemView{Type: "webSearch", Query: "telegram bot api"}) if !strings.Contains(text, "web search started") || !strings.Contains(text, "telegram bot api") { t.Fatalf("unexpected web search render: %q", text) } } func TestRenderDynamicToolDetailsSelectsUsefulArguments(t *testing.T) { item := codexThreadItemView{ Type: "dynamicToolCall", Namespace: "functions", Tool: "exec_command", Status: "completed", Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`), } text := renderCodexItemCompleted(item) for _, want := range []string{"Tool: functions.exec_command", "cmd:", "language-bash", "go test ./..."} { if !strings.Contains(text, want) { t.Fatalf("rendered tool details missing %q in %q", want, text) } } if strings.Contains(text, "irrelevant") { t.Fatalf("rendered tool details should omit irrelevant argument JSON: %q", text) } } func TestRenderApprovalDetailsAvoidsRawJSONDump(t *testing.T) { raw := json.RawMessage(`{"command":"go test ./...","cwd":"/workspace/project","unused":{"nested":true}}`) text := renderApprovalHTML("item/commandExecution/requestApproval", raw, "") for _, want := range []string{"Codex requests command approval", "language-bash", "go test ./...", "CWD"} { if !strings.Contains(text, want) { t.Fatalf("approval render missing %q in %q", want, text) } } summary, _, _ := strings.Cut(text, "
") for _, unwanted := range []string{"go test ./...", "/workspace/project"} { if strings.Contains(summary, unwanted) { t.Fatalf("approval summary should not include %q in %q", unwanted, summary) } } if strings.Contains(text, "unused") { t.Fatalf("approval render should omit unused JSON: %q", text) } } func TestRenderApprovalDetailsSummarizesPolicyFields(t *testing.T) { raw := json.RawMessage(`{ "command":"git push gitea master", "cwd":"/workspace/project", "reason":"Need to publish changes", "proposedExecpolicyAmendment":["git","push","gitea","master"], "availableDecisions":[ {"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","push","gitea","master"]}}, "decline" ] }`) text := renderApprovalHTML("item/commandExecution/requestApproval", raw, "") for _, want := range []string{"Proposed command rule", "git push gitea master", "Available decisions", "Approve rule", "Deny"} { if !strings.Contains(text, want) { t.Fatalf("approval render missing concise field %q in %q", want, text) } } for _, unwanted := range []string{"execpolicy_amendment", "acceptWithExecpolicyAmendment", "availableDecisions"} { if strings.Contains(text, unwanted) { t.Fatalf("approval render still contains verbose field %q in %q", unwanted, text) } } if strings.Count(text, "Need to publish changes") != 1 { t.Fatalf("reason should only appear once in summary: %q", text) } } func TestCombinedCommandApprovalOmitsDuplicateCommandDetails(t *testing.T) { toolHTML := renderCodexItemStarted(codexThreadItemView{ Type: "commandExecution", Command: "go test ./...", CWD: "/workspace/project", }) approvalHTML := renderApprovalHTML("item/commandExecution/requestApproval", json.RawMessage(`{ "command":"go test ./...", "cwd":"/workspace/project", "reason":"Need to run tests" }`), "") text := combineToolApprovalHTML(toolHTML, approvalHTML) for _, want := range []string{"Tool call: command started", "Codex requests command approval", "Need to run tests"} { if !strings.Contains(text, want) { t.Fatalf("combined approval missing %q in %q", want, text) } } for _, duplicate := range []string{"CWD:", "Command:"} { if got := strings.Count(text, duplicate); got != 1 { t.Fatalf("%s count = %d, want 1 in %q", duplicate, got, text) } } } func TestApprovalOnlyToolMessageCanReceiveCompletionDetails(t *testing.T) { exitCode := 0 duration := int64(1234) output := "done" tool := toolMessageState{ approvalHTML: SummaryDetailsHTML("Codex requests command approval", "approval details"), } tool.toolHTML = renderCodexItemCompleted(codexThreadItemView{ Type: "commandExecution", Command: "go test ./...", CWD: "/workspace/project", ExitCode: &exitCode, DurationMs: &duration, AggregatedOutput: &output, }) text := tool.html() for _, want := range []string{"Tool call: command finished", "Exit code: 0", "Duration ms:", "1234", "Codex requests command approval"} { if !strings.Contains(text, want) { t.Fatalf("combined approval tool message missing %q in %q", want, text) } } } func TestToolMessageAddsEditedAtInsideDetails(t *testing.T) { tool := toolMessageState{ toolHTML: SummaryDetailsHTML("Tool call: command finished", "full output"), editedAt: "2026-05-21 12:34:56 UTC", } text := tool.html() summary, details, ok := strings.Cut(text, "
") if !ok { t.Fatalf("tool message should contain details quote: %q", text) } if strings.Contains(summary, "Edited at") { t.Fatalf("edited timestamp should not be in summary: %q", summary) } if !strings.Contains(details, "Edited at: 2026-05-21 12:34:56 UTC") { t.Fatalf("edited timestamp not placed inside details: %q", text) } } func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) { largeToolDetails := strings.Repeat("tool output line\n", 600) largeApprovalDetails := strings.Repeat("approval payload line\n", 600) tool := toolMessageState{ toolHTML: SummaryDetailsHTML("Tool call: command finished", largeToolDetails), approvalHTML: SummaryDetailsHTML("Codex requests command approval", largeApprovalDetails), editedAt: "2026-05-21 12:34:56 UTC", } text := tool.html() if len([]rune(text)) > TelegramHTMLMessageLimit { t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text))) } for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "Edited at: 2026-05-21 12:34:56 UTC", "...[truncated]"} { if !strings.Contains(text, want) { t.Fatalf("fitted tool message missing %q in %q", want, text) } } } func TestResumeCallbackData(t *testing.T) { threadID, ok := ParseResumeThreadCallbackData(ResumeThreadCallbackData(123)) if !ok || threadID != 123 { t.Fatalf("unexpected resume thread callback: id=%d ok=%v", threadID, ok) } page, ok := ParseResumePageCallbackData(ResumePageCallbackData(2)) if !ok || page != 2 { t.Fatalf("unexpected resume page callback: page=%d ok=%v", page, ok) } } func TestResumeThreadListText(t *testing.T) { threads := []store.Thread{{ID: 42, Title: "do xyz"}, {ID: 43, Title: "executed xxx command"}} text := resumeThreadListText(threads, 0) for _, want := range []string{"Thread ID 42: do xyz", "Thread ID 43: executed xxx command"} { if !strings.Contains(text, want) { t.Fatalf("resume list missing %q in %q", want, text) } } markup := resumeThreadMarkup(threads, 0, true) if len(markup.InlineKeyboard) != 2 || markup.InlineKeyboard[0][0].Text != "ID 42" || markup.InlineKeyboard[0][1].Text != "ID 43" { t.Fatalf("unexpected resume buttons: %#v", markup.InlineKeyboard) } firstID, ok := ParseResumeThreadCallbackData(markup.InlineKeyboard[0][0].CallbackData) if !ok || firstID != 42 { t.Fatalf("first resume button targets id=%d ok=%v", firstID, ok) } secondID, ok := ParseResumeThreadCallbackData(markup.InlineKeyboard[0][1].CallbackData) if !ok || secondID != 43 { t.Fatalf("second resume button targets id=%d ok=%v", secondID, ok) } } func TestModelEffortAndSandboxCallbackData(t *testing.T) { modelID := strings.Join([]string{"server", "model", "id"}, "-") data, ok := ModelCallbackData(modelID) if !ok { t.Fatal("model callback should fit") } model, ok := ParseModelCallbackData(data) if !ok || model != modelID { t.Fatalf("unexpected model callback parse: model=%q ok=%v", model, ok) } effortName := strings.Join([]string{"server", "effort"}, "-") effort, ok := ParseEffortCallbackData(EffortCallbackData(effortName)) if !ok || effort != effortName { t.Fatalf("unexpected effort callback parse: effort=%q ok=%v", effort, ok) } sandbox, ok := ParseSandboxCallbackData(SandboxCallbackData("workspace-write")) if !ok || sandbox != "workspace-write" { t.Fatalf("unexpected sandbox callback parse: sandbox=%q ok=%v", sandbox, ok) } if _, ok := ParseSandboxCallbackData(SandboxCallbackData("bad")); ok { t.Fatal("bad sandbox callback should not parse") } }