Support Codex 0.134 approvals

Use available approval decisions from the app-server schema, preserve structured policy decisions in callbacks, and keep approval rendering aligned with normal tool-call output.

Also simplify thread commands, clear stale active turns more carefully, and update command/help docs.
This commit is contained in:
Codex
2026-05-28 09:39:40 +00:00
parent e9dd840111
commit 2b0da9f508
9 changed files with 813 additions and 147 deletions

View File

@@ -52,7 +52,7 @@ Before implementation, carefully read the official docs and check against the gu
- Telegram UX: - Telegram UX:
- One-to-one chats only; reject groups, supergroups, and channels. - One-to-one chats only; reject groups, supergroups, and channels.
- Allowlisted Telegram user IDs only. - Allowlisted Telegram user IDs only.
- Commands: `/start`, `/help`, `/new`, `/threads`, `/resume`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/diff`. - Commands: `/start`, `/help`, `/new`, `/thread`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/diff`.
- Plain text continues the active Codex thread, creating one if needed. - Plain text continues the active Codex thread, creating one if needed.
- Send assistant messages and rendered tool/status blocks as separate Telegram messages; chunk only when a single message exceeds Telegram limits. - Send assistant messages and rendered tool/status blocks as separate Telegram messages; chunk only when a single message exceeds Telegram limits.
- Send long output, logs, and diffs as chunked messages. - Send long output, logs, and diffs as chunked messages.
@@ -77,7 +77,7 @@ Any interactive related test that requires user action should be done properly -
- Integration tests: - Integration tests:
- Use a low/mini model in codex for testing. - Use a low/mini model in codex for testing.
- Verify initialize, thread start, turn start, streamed output, approval, and cancellation. - Verify initialize, thread start, turn start, streamed output, approval, and cancellation.
- Verify `/start`, `/new`, `/threads`, `/resume`, `/workspace`, `/cancel`, image input, and document staging. - Verify `/start`, `/new`, `/thread`, `/workspace`, `/cancel`, image input, and document staging.
- Verify non-allowlisted users are rejected and logged. - Verify non-allowlisted users are rejected and logged.
- Manual acceptance: - Manual acceptance:

View File

@@ -38,7 +38,7 @@ Docker Compose runs only the Go Telegram bot. Codex runs on the host through `co
The bot accepts one-to-one chats from allowlisted Telegram user IDs only. It rejects group, supergroup, and channel updates in code. The bot accepts one-to-one chats from allowlisted Telegram user IDs only. It rejects group, supergroup, and channel updates in code.
Supported commands: `/start`, `/help`, `/new`, `/threads`, `/resume`, `/rename`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/pic`, `/diff`. `/model` lists available Codex models as inline buttons, then shows reasoning-effort buttons for the selected model. Supported commands: `/start`, `/help`, `/new`, `/thread`, `/rename`, `/fork`, `/archive`, `/status`, `/cancel`, `/workspaces`, `/workspace`, `/model`, `/sandbox`, `/pic`, `/diff`. `/model` lists available Codex models as inline buttons, then shows reasoning-effort buttons for the selected model.
Plain text continues the active Codex thread and creates one if needed. `/pic PROMPT` starts a dedicated Codex image-generation turn and sends generated images back as Telegram photos. Telegram images are staged under `HOST_UPLOAD_DIR` and sent as `localImage` inputs. Other uploaded documents are staged and passed to Codex as host-visible file paths. Plain text continues the active Codex thread and creates one if needed. `/pic PROMPT` starts a dedicated Codex image-generation turn and sends generated images back as Telegram photos. Telegram images are staged under `HOST_UPLOAD_DIR` and sent as `localImage` inputs. Other uploaded documents are staged and passed to Codex as host-visible file paths.

View File

@@ -44,6 +44,12 @@ type Session struct {
UpdatedAt string UpdatedAt string
} }
type ActiveTurn struct {
TelegramUserID int64
CodexThreadID string
TurnID string
}
type Thread struct { type Thread struct {
ID int64 ID int64
TelegramUserID int64 TelegramUserID int64
@@ -338,11 +344,42 @@ func (s *Store) SetActiveTurn(ctx context.Context, telegramUserID int64, turnID
return err return err
} }
func (s *Store) ClearActiveTurn(ctx context.Context, telegramUserID int64, turnID string) error {
if strings.TrimSpace(turnID) == "" {
return s.SetActiveTurn(ctx, telegramUserID, "")
}
_, err := s.db.ExecContext(ctx, "UPDATE sessions SET active_turn_id = '', updated_at = datetime('now') WHERE telegram_user_id = ? AND active_turn_id = ?", telegramUserID, turnID)
return err
}
func (s *Store) ClearActiveTurns(ctx context.Context) error { func (s *Store) ClearActiveTurns(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, "UPDATE sessions SET active_turn_id = '', updated_at = datetime('now') WHERE active_turn_id <> ''") _, err := s.db.ExecContext(ctx, "UPDATE sessions SET active_turn_id = '', updated_at = datetime('now') WHERE active_turn_id <> ''")
return err return err
} }
func (s *Store) ListActiveTurns(ctx context.Context) ([]ActiveTurn, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT s.telegram_user_id, t.codex_thread_id, s.active_turn_id
FROM sessions s
JOIN threads t ON t.id = s.active_thread_id AND t.telegram_user_id = s.telegram_user_id
WHERE s.active_turn_id <> ''
ORDER BY s.updated_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var turns []ActiveTurn
for rows.Next() {
var turn ActiveTurn
if err := rows.Scan(&turn.TelegramUserID, &turn.CodexThreadID, &turn.TurnID); err != nil {
return nil, err
}
turns = append(turns, turn)
}
return turns, rows.Err()
}
func (s *Store) CreateThread(ctx context.Context, telegramUserID int64, codexThreadID string, workspaceID int64, title string) (Thread, error) { func (s *Store) CreateThread(ctx context.Context, telegramUserID int64, codexThreadID string, workspaceID int64, title string) (Thread, error) {
result, err := s.db.ExecContext(ctx, ` result, err := s.db.ExecContext(ctx, `
INSERT INTO threads (telegram_user_id, codex_thread_id, workspace_id, title) INSERT INTO threads (telegram_user_id, codex_thread_id, workspace_id, title)

View File

@@ -72,9 +72,33 @@ func TestStoreUsersWorkspacesSessions(t *testing.T) {
if session.SettingsChatID != 1001 || session.SettingsMessageID != 2002 { if session.SettingsChatID != 1001 || session.SettingsMessageID != 2002 {
t.Fatalf("settings message not saved: %+v", session) t.Fatalf("settings message not saved: %+v", session)
} }
thread, err := st.CreateThread(ctx, 42, "codex-thread-123", ws.ID, "test thread")
if err != nil {
t.Fatal(err)
}
if err := st.SetActiveThread(ctx, 42, thread.ID); err != nil {
t.Fatal(err)
}
if err := st.SetActiveTurn(ctx, 42, "turn-123"); err != nil { if err := st.SetActiveTurn(ctx, 42, "turn-123"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
turns, err := st.ListActiveTurns(ctx)
if err != nil {
t.Fatal(err)
}
if len(turns) != 1 || turns[0].TelegramUserID != 42 || turns[0].CodexThreadID != "codex-thread-123" || turns[0].TurnID != "turn-123" {
t.Fatalf("active turns not listed: %+v", turns)
}
if err := st.ClearActiveTurn(ctx, 42, "other-turn"); err != nil {
t.Fatal(err)
}
session, err = st.GetSession(ctx, 42)
if err != nil {
t.Fatal(err)
}
if session.ActiveTurnID != "turn-123" {
t.Fatalf("wrong turn cleared: %+v", session)
}
if err := st.ClearActiveTurns(ctx); err != nil { if err := st.ClearActiveTurns(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -50,6 +50,11 @@ func (c *Client) redact(text string) string {
return strings.ReplaceAll(text, c.token, "<telegram-token>") return strings.ReplaceAll(text, c.token, "<telegram-token>")
} }
func (c *Client) SetMyCommands(ctx context.Context, commands []BotCommand) error {
var ok bool
return c.postJSON(ctx, "setMyCommands", map[string]any{"commands": commands}, &ok)
}
func (c *Client) GetUpdates(ctx context.Context, offset int, timeoutSeconds int) ([]Update, error) { func (c *Client) GetUpdates(ctx context.Context, offset int, timeoutSeconds int) ([]Update, error) {
params := map[string]any{ params := map[string]any{
"offset": offset, "offset": offset,

View File

@@ -70,6 +70,7 @@ type assistantThreadCWDDirective struct {
} }
type outputState struct { type outputState struct {
turnID string
chatID int64 chatID int64
assistant strings.Builder assistant strings.Builder
sentAny bool sentAny bool
@@ -137,9 +138,15 @@ func NewBot(tg *Client, st *store.Store, codex *codexapp.Client, uploadDir, code
} }
func (b *Bot) Run(ctx context.Context) error { func (b *Bot) Run(ctx context.Context) error {
if err := b.interruptStoredActiveTurns(ctx); err != nil {
return err
}
if err := b.store.ClearActiveTurns(ctx); err != nil { if err := b.store.ClearActiveTurns(ctx); err != nil {
return err return err
} }
if err := b.tg.SetMyCommands(ctx, botCommands()); err != nil {
b.logger.Printf("set telegram commands: %v", err)
}
go b.handleCodexEvents(ctx) go b.handleCodexEvents(ctx)
offset := 0 offset := 0
@@ -165,6 +172,60 @@ func (b *Bot) Run(ctx context.Context) error {
return ctx.Err() return ctx.Err()
} }
func (b *Bot) interruptStoredActiveTurns(ctx context.Context) error {
turns, err := b.store.ListActiveTurns(ctx)
if err != nil {
return err
}
if len(turns) == 0 {
return nil
}
interruptCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
for _, turn := range turns {
if strings.TrimSpace(turn.CodexThreadID) == "" || strings.TrimSpace(turn.TurnID) == "" {
continue
}
if err := b.codex.InterruptTurn(interruptCtx, turn.CodexThreadID, turn.TurnID); err != nil {
b.logger.Printf("interrupt stale active turn %s/%s: %v", turn.CodexThreadID, turn.TurnID, err)
}
}
return nil
}
func (b *Bot) clearStaleActiveTurn(ctx context.Context, userID int64, thread store.Thread, turnID string) {
turnID = strings.TrimSpace(turnID)
if turnID == "" {
return
}
interruptCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
if err := b.codex.InterruptTurn(interruptCtx, thread.CodexThreadID, turnID); err != nil {
b.logger.Printf("interrupt stale active turn %s/%s: %v", thread.CodexThreadID, turnID, err)
}
cancel()
if err := b.store.ClearActiveTurn(ctx, userID, turnID); err != nil {
b.logger.Printf("clear stale active turn %s for user %d: %v", turnID, userID, err)
}
}
func botCommands() []BotCommand {
return []BotCommand{
{Command: "new", Description: "Start a new thread"},
{Command: "thread", Description: "List or switch threads"},
{Command: "rename", Description: "Rename a thread"},
{Command: "fork", Description: "Fork the active thread"},
{Command: "archive", Description: "Archive a thread"},
{Command: "status", Description: "Show active settings"},
{Command: "cancel", Description: "Interrupt the active turn"},
{Command: "workspace", Description: "Select workspace"},
{Command: "model", Description: "Choose model"},
{Command: "sandbox", Description: "Show or set sandbox"},
{Command: "pic", Description: "Generate images"},
{Command: "diff", Description: "Show latest diff"},
{Command: "help", Description: "Show help"},
}
}
func (b *Bot) handleUpdate(ctx context.Context, update Update) error { func (b *Bot) handleUpdate(ctx context.Context, update Update) error {
switch { switch {
case update.Message != nil: case update.Message != nil:
@@ -224,10 +285,10 @@ func (b *Bot) handleCommand(ctx context.Context, message *Message, session store
case "new": case "new":
_, _, err := b.createNewThread(ctx, userID, chatID, session, true) _, _, err := b.createNewThread(ctx, userID, chatID, session, true)
return true, err return true, err
case "thread", "threads": case "thread":
return true, b.sendThreads(ctx, userID, chatID) return true, b.threadCommand(ctx, userID, chatID, args)
case "resume": case "threads", "resume":
return true, b.resumeThread(ctx, userID, chatID, args) return true, b.legacyThreadCommand(ctx, userID, chatID, args)
case "rename": case "rename":
return true, b.renameThread(ctx, userID, chatID, session, args) return true, b.renameThread(ctx, userID, chatID, session, args)
case "fork": case "fork":
@@ -261,9 +322,8 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error {
"Codex Telegram Bot", "Codex Telegram Bot",
"", "",
"/new - start a new Codex thread", "/new - start a new Codex thread",
"/thread or /threads - list recent threads", "/thread - list recent threads",
"/resume - choose a recent thread", "/thread ID - switch to a thread",
"/resume ID - resume a thread",
"/rename TITLE or /rename ID TITLE - rename a thread", "/rename TITLE or /rename ID TITLE - rename a thread",
"/fork - fork the active thread", "/fork - fork the active thread",
"/archive [ID] - archive a thread", "/archive [ID] - archive a thread",
@@ -281,16 +341,12 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error {
return b.sendLong(ctx, chatID, text) return b.sendLong(ctx, chatID, text)
} }
func (b *Bot) sendThreads(ctx context.Context, userID, chatID int64) error { func (b *Bot) threadCommand(ctx context.Context, userID, chatID int64, args []string) error {
return b.sendResumeChoices(ctx, userID, chatID, 0, 0)
}
func (b *Bot) resumeThread(ctx context.Context, userID, chatID int64, args []string) error {
if len(args) == 0 { if len(args) == 0 {
return b.sendResumeChoices(ctx, userID, chatID, 0, 0) return b.sendResumeChoices(ctx, userID, chatID, 0, 0)
} }
if len(args) != 1 { if len(args) != 1 {
_, err := b.tg.SendMessage(ctx, chatID, "Use /resume to choose a thread, or /resume ID to resume directly.", SendMessageOptions{}) _, err := b.tg.SendMessage(ctx, chatID, "Use /thread to choose a thread, or /thread ID to switch directly.", SendMessageOptions{})
return err return err
} }
id, err := strconv.ParseInt(args[0], 10, 64) id, err := strconv.ParseInt(args[0], 10, 64)
@@ -301,6 +357,13 @@ func (b *Bot) resumeThread(ctx context.Context, userID, chatID int64, args []str
return b.resumeThreadByID(ctx, userID, chatID, id, 0) return b.resumeThreadByID(ctx, userID, chatID, id, 0)
} }
func (b *Bot) legacyThreadCommand(ctx context.Context, userID, chatID int64, args []string) error {
if len(args) == 0 {
return b.sendResumeChoices(ctx, userID, chatID, 0, 0)
}
return b.threadCommand(ctx, userID, chatID, args)
}
func (b *Bot) sendResumeChoices(ctx context.Context, userID, chatID int64, page int, messageID int) error { func (b *Bot) sendResumeChoices(ctx context.Context, userID, chatID int64, page int, messageID int) error {
if page < 0 { if page < 0 {
page = 0 page = 0
@@ -775,19 +838,24 @@ func (b *Bot) continueThread(ctx context.Context, message *Message, session stor
return err return err
} }
if session.ActiveTurnID != "" { if session.ActiveTurnID != "" {
if err := b.codex.SteerTurn(ctx, thread.CodexThreadID, session.ActiveTurnID, input); err != nil { if b.hasOutputTurn(thread.CodexThreadID, session.ActiveTurnID) {
return b.sendError(ctx, chatID, "Could not append to active turn", err) if err := b.codex.SteerTurn(ctx, thread.CodexThreadID, session.ActiveTurnID, input); err != nil {
return b.sendError(ctx, chatID, "Could not append to active turn", err)
}
_, err := b.tg.SendMessage(ctx, chatID, "Added to the running turn.", SendMessageOptions{})
return err
} }
_, err := b.tg.SendMessage(ctx, chatID, "Added to the running turn.", SendMessageOptions{}) b.clearStaleActiveTurn(ctx, userID, thread, session.ActiveTurnID)
return err session.ActiveTurnID = ""
} }
b.registerOutput(thread.CodexThreadID, chatID) b.registerOutput(thread.CodexThreadID, "", chatID)
turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input) turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input)
if err != nil { if err != nil {
b.clearOutput(thread.CodexThreadID) b.clearOutput(thread.CodexThreadID)
return b.sendError(ctx, chatID, "Codex turn failed", err) return b.sendError(ctx, chatID, "Codex turn failed", err)
} }
b.setOutputTurnID(thread.CodexThreadID, turn.ID)
if err := b.store.SetActiveTurn(ctx, userID, turn.ID); err != nil { if err := b.store.SetActiveTurn(ctx, userID, turn.ID); err != nil {
return err return err
} }
@@ -943,21 +1011,26 @@ func (b *Bot) handlePictureCommand(ctx context.Context, userID, chatID int64, se
_, err := b.tg.SendMessage(ctx, chatID, "Use /pic PROMPT to generate image(s).", SendMessageOptions{}) _, err := b.tg.SendMessage(ctx, chatID, "Use /pic PROMPT to generate image(s).", SendMessageOptions{})
return err return err
} }
if session.ActiveTurnID != "" {
_, err := b.tg.SendMessage(ctx, chatID, "A Codex turn is already running. Use /cancel first, or wait for it to finish.", SendMessageOptions{})
return err
}
thread, _, err := b.ensureThreadForPicture(ctx, userID, chatID, session) thread, _, err := b.ensureThreadForPicture(ctx, userID, chatID, session)
if err != nil { if err != nil {
return err return err
} }
if session.ActiveTurnID != "" {
if b.hasOutputTurn(thread.CodexThreadID, session.ActiveTurnID) {
_, err := b.tg.SendMessage(ctx, chatID, "A Codex turn is already running. Use /cancel first, or wait for it to finish.", SendMessageOptions{})
return err
}
b.clearStaleActiveTurn(ctx, userID, thread, session.ActiveTurnID)
session.ActiveTurnID = ""
}
input := []codexapp.InputItem{{Type: "text", Text: pictureGenerationInstruction(prompt)}} input := []codexapp.InputItem{{Type: "text", Text: pictureGenerationInstruction(prompt)}}
b.registerPictureOutput(thread.CodexThreadID, chatID) b.registerPictureOutput(thread.CodexThreadID, "", chatID)
turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input) turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input)
if err != nil { if err != nil {
b.clearOutput(thread.CodexThreadID) b.clearOutput(thread.CodexThreadID)
return b.sendError(ctx, chatID, "Codex image generation failed", err) return b.sendError(ctx, chatID, "Codex image generation failed", err)
} }
b.setOutputTurnID(thread.CodexThreadID, turn.ID)
if err := b.store.SetActiveTurn(ctx, userID, turn.ID); err != nil { if err := b.store.SetActiveTurn(ctx, userID, turn.ID); err != nil {
return err return err
} }
@@ -1196,7 +1269,7 @@ func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) {
func renderCodexItemStarted(item codexThreadItemView) string { func renderCodexItemStarted(item codexThreadItemView) string {
switch item.Type { switch item.Type {
case "commandExecution": case "commandExecution":
return SummaryDetailsRawHTMLLimited("Tool call: command started", commandStartedDetailsHTML(item), TelegramHTMLMessageLimit) return SummaryRawHTMLSectionsLimited("Tool call: command started", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit)
case "fileChange": case "fileChange":
return "Tool call: file change started" return "Tool call: file change started"
case "mcpToolCall": case "mcpToolCall":
@@ -1219,11 +1292,7 @@ func renderCodexItemStarted(item codexThreadItemView) string {
func renderCodexItemCompleted(item codexThreadItemView) string { func renderCodexItemCompleted(item codexThreadItemView) string {
switch item.Type { switch item.Type {
case "commandExecution": case "commandExecution":
status := "" return SummaryRawHTMLSectionsLimited("Tool call: command finished", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit)
if item.ExitCode != nil {
status = fmt.Sprintf("Exit code: %d", *item.ExitCode)
}
return SummaryDetailsRawHTMLLimited(joinNonEmpty("Tool call: command finished", status), renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit)
case "fileChange": case "fileChange":
return joinNonEmpty("Tool call: file change finished", fmt.Sprintf("Changed files: %d", len(item.Changes)), "Status: "+item.Status) return joinNonEmpty("Tool call: file change finished", fmt.Sprintf("Changed files: %d", len(item.Changes)), "Status: "+item.Status)
case "mcpToolCall": case "mcpToolCall":
@@ -1250,14 +1319,38 @@ func renderCodexItemCompleted(item codexThreadItemView) string {
} }
func commandStartedDetailsHTML(item codexThreadItemView) string { func commandStartedDetailsHTML(item codexThreadItemView) string {
var parts []string return commandExecutionDetailsHTML(item, "")
if command := strings.TrimSpace(item.Command); command != "" { }
parts = append(parts, "<b>Command</b>", CodeBlockHTML("bash", command))
} func commandExecutionDetailsHTML(item codexThreadItemView, editedAt string) string {
return strings.Join(commandExecutionSectionsHTML(item, editedAt), "\n\n")
}
func commandExecutionSectionsHTML(item codexThreadItemView, editedAt string) []string {
var sections []string
if cwd := strings.TrimSpace(item.CWD); cwd != "" { if cwd := strings.TrimSpace(item.CWD); cwd != "" {
parts = append(parts, FieldHTML("CWD", cwd)) sections = append(sections, FieldHTML("CWD", cwd))
} }
return strings.Join(parts, "\n") if command := strings.TrimSpace(item.Command); command != "" {
sections = append(sections, "<b>Command:</b> "+CodeBlockHTML("bash", command))
}
if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" {
sections = append(sections, "<b>Output:</b> "+CodeBlockHTML("text", *item.AggregatedOutput))
}
var fields []string
if item.ExitCode != nil {
fields = append(fields, FieldHTML("Exit code", strconv.Itoa(*item.ExitCode)))
}
if item.DurationMs != nil {
fields = append(fields, FieldHTML("Duration ms", strconv.FormatInt(*item.DurationMs, 10)))
}
if editedAt != "" {
fields = append(fields, FieldHTML("Edited at", editedAt))
}
if len(fields) > 0 {
sections = append(sections, strings.Join(nonEmptyHTML(fields), "\n"))
}
return nonEmptyHTML(sections)
} }
func renderCodexItemDetailsHTML(item codexThreadItemView) string { func renderCodexItemDetailsHTML(item codexThreadItemView) string {
@@ -1267,16 +1360,6 @@ func renderCodexItemDetailsHTML(item codexThreadItemView) string {
parts = append(parts, html) parts = append(parts, html)
} }
} }
appendInt := func(label string, value *int) {
if value != nil {
appendField(label, strconv.Itoa(*value))
}
}
appendInt64 := func(label string, value *int64) {
if value != nil {
appendField(label, strconv.FormatInt(*value, 10))
}
}
appendBool := func(label string, value *bool) { appendBool := func(label string, value *bool) {
if value != nil { if value != nil {
appendField(label, strconv.FormatBool(*value)) appendField(label, strconv.FormatBool(*value))
@@ -1285,15 +1368,7 @@ func renderCodexItemDetailsHTML(item codexThreadItemView) string {
switch item.Type { switch item.Type {
case "commandExecution": case "commandExecution":
if command := strings.TrimSpace(item.Command); command != "" { parts = append(parts, commandExecutionDetailsHTML(item, ""))
parts = append(parts, "<b>Command</b>", CodeBlockHTML("bash", command))
}
appendField("CWD", item.CWD)
appendInt("Exit code", item.ExitCode)
appendInt64("Duration ms", item.DurationMs)
if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" {
parts = append(parts, "<b>Output</b>", CodeBlockHTML("text", *item.AggregatedOutput))
}
case "fileChange": case "fileChange":
appendField("Status", item.Status) appendField("Status", item.Status)
for _, change := range item.Changes { for _, change := range item.Changes {
@@ -1392,7 +1467,7 @@ func renderArgumentFieldHTML(key string, value any) string {
return "" return ""
} }
if complex || shouldUseCodeBlock(key, text) { if complex || shouldUseCodeBlock(key, text) {
return "<b>" + EscapeHTML(label) + "</b>\n" + CodeBlockHTML(languageForArgument(key, text), text) return "<b>" + EscapeHTML(label) + ":</b> " + CodeBlockHTML(languageForArgument(key, text), text)
} }
return FieldHTML(label, text) return FieldHTML(label, text)
} }
@@ -1540,7 +1615,10 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
} }
if params.ThreadID != "" && !params.WillRetry { if params.ThreadID != "" && !params.WillRetry {
if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil { if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil {
_ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "") _ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.TurnID)
}
if !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
return nil
} }
message := "Codex error" message := "Codex error"
if params.Error.Message != "" { if params.Error.Message != "" {
@@ -1561,48 +1639,55 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
case "item/started": case "item/started":
var params struct { var params struct {
ThreadID string `json:"threadId"` ThreadID string `json:"threadId"`
TurnID string `json:"turnId"`
Item json.RawMessage `json:"item"` Item json.RawMessage `json:"item"`
} }
if err := json.Unmarshal(event.Params, &params); err != nil { if err := json.Unmarshal(event.Params, &params); err != nil {
return err return err
} }
if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
return nil
}
item, err := parseCodexThreadItem(params.Item) item, err := parseCodexThreadItem(params.Item)
if err != nil { if err != nil {
return err return err
} }
if params.ThreadID != "" && item.Type == "agentMessage" && b.hasAssistantText(params.ThreadID) { if item.Type == "agentMessage" && b.hasAssistantText(params.ThreadID) {
return b.flushAssistantMessage(ctx, params.ThreadID) return b.flushAssistantMessage(ctx, params.ThreadID)
} }
if params.ThreadID != "" { if b.shouldSuppressPictureToolMessage(params.ThreadID, item) {
if b.shouldSuppressPictureToolMessage(params.ThreadID, item) { return nil
return nil
}
return b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemStarted(item))
} }
return b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemStarted(item))
case "item/agentMessage/delta": case "item/agentMessage/delta":
var params struct { var params struct {
ThreadID string `json:"threadId"` ThreadID string `json:"threadId"`
TurnID string `json:"turnId"`
Delta string `json:"delta"` Delta string `json:"delta"`
} }
if err := json.Unmarshal(event.Params, &params); err != nil { if err := json.Unmarshal(event.Params, &params); err != nil {
return err return err
} }
if params.ThreadID != "" && params.Delta != "" { if params.ThreadID != "" && params.Delta != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
return b.appendAssistantDelta(ctx, params.ThreadID, params.Delta) return b.appendAssistantDelta(ctx, params.ThreadID, params.Delta)
} }
case "item/completed": case "item/completed":
var params struct { var params struct {
ThreadID string `json:"threadId"` ThreadID string `json:"threadId"`
TurnID string `json:"turnId"`
Item json.RawMessage `json:"item"` Item json.RawMessage `json:"item"`
} }
if err := json.Unmarshal(event.Params, &params); err != nil { if err := json.Unmarshal(event.Params, &params); err != nil {
return err return err
} }
if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
return nil
}
item, err := parseCodexThreadItem(params.Item) item, err := parseCodexThreadItem(params.Item)
if err != nil { if err != nil {
return err return err
} }
if params.ThreadID != "" && item.Type == "agentMessage" { if item.Type == "agentMessage" {
if item.Text != "" && !b.hasAssistantText(params.ThreadID) { if item.Text != "" && !b.hasAssistantText(params.ThreadID) {
if err := b.appendAssistantDelta(ctx, params.ThreadID, item.Text); err != nil { if err := b.appendAssistantDelta(ctx, params.ThreadID, item.Text); err != nil {
return err return err
@@ -1610,24 +1695,23 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
} }
return b.flushAssistantMessage(ctx, params.ThreadID) return b.flushAssistantMessage(ctx, params.ThreadID)
} }
if params.ThreadID != "" { if b.queuePictureImageOutput(params.ThreadID, item) {
if b.queuePictureImageOutput(params.ThreadID, item) { return nil
return nil
}
if err := b.upsertToolMessage(ctx, params.ThreadID, item.ID, renderCodexItemCompleted(item)); err != nil {
return err
}
return b.sendImageOutput(ctx, params.ThreadID, item)
} }
if err := b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemCompleted(item)); err != nil {
return err
}
return b.sendImageOutput(ctx, params.ThreadID, item)
case "turn/diff/updated": case "turn/diff/updated":
var params struct { var params struct {
ThreadID string `json:"threadId"` ThreadID string `json:"threadId"`
TurnID string `json:"turnId"`
Diff string `json:"diff"` Diff string `json:"diff"`
} }
if err := json.Unmarshal(event.Params, &params); err != nil { if err := json.Unmarshal(event.Params, &params); err != nil {
return err return err
} }
if params.ThreadID != "" { if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
b.mu.Lock() b.mu.Lock()
b.diffs[params.ThreadID] = params.Diff b.diffs[params.ThreadID] = params.Diff
b.mu.Unlock() b.mu.Unlock()
@@ -1645,9 +1729,12 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
} }
if params.ThreadID != "" { if params.ThreadID != "" {
if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil { if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil {
_ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "") _ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.Turn.ID)
_ = b.store.TouchThread(ctx, params.ThreadID) _ = b.store.TouchThread(ctx, params.ThreadID)
} }
if !b.shouldHandleOutputEvent(params.ThreadID, params.Turn.ID) {
return nil
}
return b.completeTurnOutput(ctx, params.ThreadID) return b.completeTurnOutput(ctx, params.ThreadID)
} }
case "thread/name/updated": case "thread/name/updated":
@@ -1742,7 +1829,7 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
if threadID == "" { if threadID == "" {
return errors.New("approval request missing threadId") return errors.New("approval request missing threadId")
} }
itemID := firstNonEmpty(params.ItemID, params.ApprovalID, params.CallID) itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID)
thread, err := b.store.GetThreadByCodexID(ctx, threadID) thread, err := b.store.GetThreadByCodexID(ctx, threadID)
if err != nil { if err != nil {
return err return err
@@ -1768,8 +1855,8 @@ func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event
return nil return nil
} }
text := renderApprovalHTML(kind, event.Params, "") text := renderApprovalHTML(kind, event.Params, "")
markup := approvalMarkup(approval.ID) markup := approvalMarkupForPayload(approval.ID, event.Params)
msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, itemID, text, markup) msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, params.TurnID, itemID, text, markup)
if err != nil { if err != nil {
return err return err
} }
@@ -1785,8 +1872,9 @@ func firstNonEmpty(values ...string) string {
return "" return ""
} }
func (b *Bot) newOutputState(chatID int64) *outputState { func (b *Bot) newOutputState(chatID int64, turnID string) *outputState {
return &outputState{ return &outputState{
turnID: turnID,
chatID: chatID, chatID: chatID,
tools: make(map[string]toolMessageState), tools: make(map[string]toolMessageState),
sentImages: make(map[string]bool), sentImages: make(map[string]bool),
@@ -1794,17 +1882,17 @@ func (b *Bot) newOutputState(chatID int64) *outputState {
} }
} }
func (b *Bot) registerOutput(threadID string, chatID int64) { func (b *Bot) registerOutput(threadID, turnID string, chatID int64) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil { if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil {
state.workingIndicatorOff() state.workingIndicatorOff()
} }
b.outputs[threadID] = b.newOutputState(chatID) b.outputs[threadID] = b.newOutputState(chatID, turnID)
} }
func (b *Bot) registerPictureOutput(threadID string, chatID int64) { func (b *Bot) registerPictureOutput(threadID, turnID string, chatID int64) {
b.registerOutput(threadID, chatID) b.registerOutput(threadID, turnID, chatID)
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
if state := b.outputs[threadID]; state != nil { if state := b.outputs[threadID]; state != nil {
@@ -1812,6 +1900,43 @@ func (b *Bot) registerPictureOutput(threadID string, chatID int64) {
} }
} }
func (b *Bot) setOutputTurnID(threadID, turnID string) {
if strings.TrimSpace(turnID) == "" {
return
}
b.mu.Lock()
defer b.mu.Unlock()
if state := b.outputs[threadID]; state != nil {
state.turnID = turnID
}
}
func (b *Bot) hasOutputTurn(threadID, turnID string) bool {
b.mu.Lock()
defer b.mu.Unlock()
state := b.outputs[threadID]
return state != nil && sameTurn(state.turnID, turnID)
}
func (b *Bot) shouldHandleOutputEvent(threadID, turnID string) bool {
b.mu.Lock()
defer b.mu.Unlock()
state := b.outputs[threadID]
if state == nil {
return false
}
if state.turnID == "" && strings.TrimSpace(turnID) != "" {
state.turnID = turnID
}
return sameTurn(state.turnID, turnID)
}
func sameTurn(ownedTurnID, eventTurnID string) bool {
ownedTurnID = strings.TrimSpace(ownedTurnID)
eventTurnID = strings.TrimSpace(eventTurnID)
return ownedTurnID == "" || eventTurnID == "" || ownedTurnID == eventTurnID
}
func (b *Bot) clearOutput(threadID string) { func (b *Bot) clearOutput(threadID string) {
b.mu.Lock() b.mu.Lock()
state := b.outputs[threadID] state := b.outputs[threadID]
@@ -1872,6 +1997,10 @@ func (b *Bot) hasAssistantText(threadID string) bool {
} }
func (b *Bot) failActiveOutputs(ctx context.Context, message string) { func (b *Bot) failActiveOutputs(ctx context.Context, message string) {
if err := b.store.ClearActiveTurns(ctx); err != nil {
b.logger.Printf("clear active turns after Codex disconnect: %v", err)
}
b.mu.Lock() b.mu.Lock()
threadIDs := make([]string, 0, len(b.outputs)) threadIDs := make([]string, 0, len(b.outputs))
for threadID := range b.outputs { for threadID := range b.outputs {
@@ -1880,9 +2009,6 @@ func (b *Bot) failActiveOutputs(ctx context.Context, message string) {
b.mu.Unlock() b.mu.Unlock()
for _, threadID := range threadIDs { for _, threadID := range threadIDs {
if thread, err := b.store.GetThreadByCodexID(ctx, threadID); err == nil {
_ = b.store.SetActiveTurn(ctx, thread.TelegramUserID, "")
}
if err := b.flushAssistantMessage(ctx, threadID); err != nil { if err := b.flushAssistantMessage(ctx, threadID); err != nil {
b.logger.Printf("flush failed output %s: %v", threadID, err) b.logger.Printf("flush failed output %s: %v", threadID, err)
} }
@@ -2031,28 +2157,56 @@ func addEditedAtLine(htmlText, editedAt string) string {
if htmlText == "" || editedAt == "" { if htmlText == "" || editedAt == "" {
return htmlText return htmlText
} }
line := EscapeHTML("Edited at: " + editedAt) line := FieldHTML("Edited at", editedAt)
quoteIndex := strings.Index(htmlText, "<blockquote expandable>") if strings.Contains(htmlText, line) {
if quoteIndex < 0 { return htmlText
return htmlText + "\n" + line
} }
summary := strings.TrimRight(htmlText[:quoteIndex], "\n") for _, marker := range []string{"\n\nCodex requests command approval", "\n\nCodex requests file change approval", "\n\nCodex requests permission approval", "\n\nCodex approval requested"} {
details := strings.TrimLeft(htmlText[quoteIndex:], "\n") if before, after, ok := strings.Cut(htmlText, marker); ok {
if summary == "" { return addEditedAtToToolHTML(before, line) + marker + after
return line + "\n" + details }
} }
return summary + "\n" + line + "\n" + details return addEditedAtToToolHTML(htmlText, line)
}
func addEditedAtToToolHTML(htmlText, line string) string {
const open = "<blockquote expandable>"
const close = "</blockquote>"
searchFrom := 0
for {
start := strings.Index(htmlText[searchFrom:], open)
if start < 0 {
break
}
start += searchFrom
contentStart := start + len(open)
end := strings.Index(htmlText[contentStart:], close)
if end < 0 {
break
}
end += contentStart
content := htmlText[contentStart:end]
if strings.Contains(content, "<b>Exit code:</b>") || strings.Contains(content, "<b>Duration ms:</b>") {
insert := strings.TrimRight(content, "\n") + "\n" + line
return htmlText[:contentStart] + insert + htmlText[end:]
}
searchFrom = end + len(close)
}
return htmlText + "\n" + ExpandableQuoteRawHTML(line)
} }
func editedAtTimestamp() string { func editedAtTimestamp() string {
return time.Now().UTC().Format("2006-01-02 15:04:05 MST") return time.Now().UTC().Format("2006-01-02 15:04:05 MST")
} }
func (b *Bot) upsertToolMessage(ctx context.Context, threadID, itemID, htmlText string) error { func (b *Bot) upsertToolMessage(ctx context.Context, threadID, turnID, itemID, htmlText string) error {
htmlText = strings.TrimSpace(htmlText) htmlText = strings.TrimSpace(htmlText)
if htmlText == "" { if htmlText == "" {
return nil return nil
} }
if !b.hasOutputTurn(threadID, turnID) {
return nil
}
if itemID == "" { if itemID == "" {
return b.sendOutputHTMLBlock(ctx, threadID, htmlText) return b.sendOutputHTMLBlock(ctx, threadID, htmlText)
} }
@@ -2107,21 +2261,22 @@ func (b *Bot) upsertToolMessage(ctx context.Context, threadID, itemID, htmlText
return nil return nil
} }
func (b *Bot) upsertApprovalMessage(ctx context.Context, chatID int64, threadID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, error) { func (b *Bot) upsertApprovalMessage(ctx context.Context, chatID int64, threadID, turnID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, error) {
approvalHTML = strings.TrimSpace(approvalHTML) approvalHTML = strings.TrimSpace(approvalHTML)
if approvalHTML == "" { if approvalHTML == "" {
return Message{}, errors.New("approval message is empty") return Message{}, errors.New("approval message is empty")
} }
if threadID == "" || itemID == "" { if threadID == "" || itemID == "" || !b.hasOutputTurn(threadID, turnID) {
return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup}) return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
} }
if err := b.flushAssistantMessage(ctx, threadID); err != nil { if err := b.flushAssistantMessage(ctx, threadID); err != nil {
return Message{}, err return Message{}, err
} }
chatID, err := b.outputChatID(ctx, threadID) trackedChatID, err := b.outputChatID(ctx, threadID)
if err != nil { if err != nil {
return Message{}, err return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
} }
chatID = trackedChatID
b.mu.Lock() b.mu.Lock()
state := b.outputs[threadID] state := b.outputs[threadID]
@@ -2510,13 +2665,7 @@ func (b *Bot) outputChatID(ctx context.Context, threadID string) (int64, error)
return chatID, nil return chatID, nil
} }
b.mu.Unlock() b.mu.Unlock()
return 0, sql.ErrNoRows
thread, err := b.store.GetThreadByCodexID(ctx, threadID)
if err != nil {
return 0, err
}
b.registerOutput(threadID, thread.TelegramUserID)
return thread.TelegramUserID, nil
} }
func (b *Bot) markOutputSent(threadID string) { func (b *Bot) markOutputSent(threadID string) {
@@ -2580,11 +2729,11 @@ func parseCommand(text string) (string, []string, bool) {
} }
func resumeThreadListText(threads []store.Thread, page int) string { func resumeThreadListText(threads []store.Thread, page int) string {
lines := []string{fmt.Sprintf("Resume a thread (page %d):", page+1), ""} lines := []string{fmt.Sprintf("Threads (page %d):", page+1), ""}
for _, thread := range threads { for _, thread := range threads {
lines = append(lines, fmt.Sprintf("Thread ID %d: %s", thread.ID, threadDisplayTitle(thread))) lines = append(lines, fmt.Sprintf("Thread ID %d: %s", thread.ID, threadDisplayTitle(thread)))
} }
lines = append(lines, "", "Choose a button below, or use /resume THREAD_ID directly.") lines = append(lines, "", "Choose a button below, or use /thread THREAD_ID directly.")
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
@@ -2743,23 +2892,158 @@ func editReplyMarkup(markup *InlineKeyboardMarkup) *InlineKeyboardMarkup {
return clearInlineKeyboardMarkup() return clearInlineKeyboardMarkup()
} }
type approvalDecisionOption struct {
Key string
Label string
Approves bool
}
func approvalMarkup(id int64) *InlineKeyboardMarkup { func approvalMarkup(id int64) *InlineKeyboardMarkup {
return &InlineKeyboardMarkup{InlineKeyboard: [][]InlineKeyboardButton{ return approvalMarkupForOptions(id, nil)
{ }
{Text: "Approve", CallbackData: ApprovalCallbackData(id, "accept")},
{Text: "Deny", CallbackData: ApprovalCallbackData(id, "decline")}, func approvalMarkupForPayload(id int64, raw json.RawMessage) *InlineKeyboardMarkup {
}, return approvalMarkupForOptions(id, approvalDecisionOptions(raw))
{ }
{Text: "Details", CallbackData: ApprovalCallbackData(id, "details")},
{Text: "Cancel", CallbackData: ApprovalCallbackData(id, "cancel")}, func approvalDecisionOptions(raw json.RawMessage) []approvalDecisionOption {
}, var params struct {
}} AvailableDecisions []json.RawMessage `json:"availableDecisions"`
}
if err := json.Unmarshal(raw, &params); err != nil || params.AvailableDecisions == nil {
return nil
}
seen := map[string]bool{}
var options []approvalDecisionOption
networkIndex := 0
for _, rawDecision := range params.AvailableDecisions {
if option, ok := stringApprovalDecisionOption(rawDecision); ok {
if !seen[option.Key] {
options = append(options, option)
seen[option.Key] = true
}
continue
}
if option, ok := structuredApprovalDecisionOption(rawDecision, networkIndex); ok {
if strings.HasPrefix(option.Key, "networkPolicy") {
networkIndex++
}
if !seen[option.Key] {
options = append(options, option)
seen[option.Key] = true
}
}
}
return options
}
func stringApprovalDecisionOption(raw json.RawMessage) (approvalDecisionOption, bool) {
var decision string
if err := json.Unmarshal(raw, &decision); err != nil {
return approvalDecisionOption{}, false
}
switch decision {
case "accept":
return approvalDecisionOption{Key: decision, Label: "Approve", Approves: true}, true
case "acceptForSession":
return approvalDecisionOption{Key: decision, Label: "Approve session", Approves: true}, true
case "decline":
return approvalDecisionOption{Key: decision, Label: "Deny"}, true
case "cancel":
return approvalDecisionOption{Key: decision, Label: "Cancel"}, true
default:
return approvalDecisionOption{}, false
}
}
func structuredApprovalDecisionOption(raw json.RawMessage, networkIndex int) (approvalDecisionOption, bool) {
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return approvalDecisionOption{}, false
}
if _, ok := object["acceptWithExecpolicyAmendment"]; ok {
return approvalDecisionOption{Key: "acceptWithExecpolicyAmendment", Label: "Approve rule", Approves: true}, true
}
if rawNetwork, ok := object["applyNetworkPolicyAmendment"]; ok {
label := "Apply network rule"
approves := true
var payload struct {
NetworkPolicyAmendment struct {
Action string `json:"action"`
Host string `json:"host"`
} `json:"network_policy_amendment"`
}
if err := json.Unmarshal(rawNetwork, &payload); err == nil {
host := strings.TrimSpace(payload.NetworkPolicyAmendment.Host)
switch payload.NetworkPolicyAmendment.Action {
case "allow":
label = "Allow network"
if host != "" {
label = "Allow " + host
}
case "deny":
label = "Deny network"
approves = false
if host != "" {
label = "Deny " + host
}
}
}
return approvalDecisionOption{Key: fmt.Sprintf("networkPolicy%d", networkIndex), Label: label, Approves: approves}, true
}
return approvalDecisionOption{}, false
}
func approvalMarkupForOptions(id int64, options []approvalDecisionOption) *InlineKeyboardMarkup {
if len(options) == 0 {
options = []approvalDecisionOption{
{Key: "accept", Label: "Approve", Approves: true},
{Key: "decline", Label: "Deny"},
{Key: "cancel", Label: "Cancel"},
}
}
var approveRow []InlineKeyboardButton
var denyRow []InlineKeyboardButton
for _, option := range options {
if option.Key == "" || option.Label == "" {
continue
}
button := InlineKeyboardButton{Text: truncateButtonLabel(option.Label), CallbackData: ApprovalCallbackData(id, option.Key)}
if option.Approves {
approveRow = append(approveRow, button)
} else {
denyRow = append(denyRow, button)
}
}
keyboard := make([][]InlineKeyboardButton, 0, 3)
if len(approveRow) > 0 {
keyboard = append(keyboard, approveRow)
}
if len(denyRow) > 0 {
keyboard = append(keyboard, denyRow)
}
keyboard = append(keyboard, []InlineKeyboardButton{{Text: "Details", CallbackData: ApprovalCallbackData(id, "details")}})
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
}
func truncateButtonLabel(label string) string {
const maxRunes = 48
runes := []rune(label)
if len(runes) <= maxRunes {
return label
}
return string(runes[:maxRunes-3]) + "..."
} }
func approvalResponse(approval store.PendingApproval, decision string) any { func approvalResponse(approval store.PendingApproval, decision string) any {
if isLegacyApprovalKind(approval.Kind) { if isLegacyApprovalKind(approval.Kind) {
return map[string]any{"decision": legacyApprovalDecision(decision)} return map[string]any{"decision": legacyApprovalDecision(decision)}
} }
if approval.Kind == "item/commandExecution/requestApproval" {
if responseDecision, ok := commandApprovalResponseDecision(json.RawMessage(approval.PayloadJSON), decision); ok {
return map[string]any{"decision": responseDecision}
}
}
if approval.Kind != "item/permissions/requestApproval" { if approval.Kind != "item/permissions/requestApproval" {
return map[string]any{"decision": decision} return map[string]any{"decision": decision}
} }
@@ -2784,6 +3068,77 @@ func approvalResponse(approval store.PendingApproval, decision string) any {
} }
} }
func commandApprovalResponseDecision(raw json.RawMessage, decision string) (any, bool) {
switch decision {
case "accept", "acceptForSession", "decline", "cancel":
return decision, true
case "acceptWithExecpolicyAmendment":
if value, ok := firstStructuredApprovalDecision(raw, "acceptWithExecpolicyAmendment", 0); ok {
return value, true
}
if value, ok := fallbackExecpolicyDecision(raw); ok {
return value, true
}
case "details":
return nil, false
default:
if strings.HasPrefix(decision, "networkPolicy") {
index, err := strconv.Atoi(strings.TrimPrefix(decision, "networkPolicy"))
if err == nil {
return firstStructuredApprovalDecision(raw, "applyNetworkPolicyAmendment", index)
}
}
}
return nil, false
}
func firstStructuredApprovalDecision(raw json.RawMessage, key string, index int) (any, bool) {
if index < 0 {
return nil, false
}
var params struct {
AvailableDecisions []json.RawMessage `json:"availableDecisions"`
}
if err := json.Unmarshal(raw, &params); err != nil {
return nil, false
}
seen := 0
for _, rawDecision := range params.AvailableDecisions {
var object map[string]json.RawMessage
if err := json.Unmarshal(rawDecision, &object); err != nil {
continue
}
payload, ok := object[key]
if !ok {
continue
}
if seen != index {
seen++
continue
}
var value map[string]any
if err := json.Unmarshal(payload, &value); err != nil {
return nil, false
}
return map[string]any{key: value}, true
}
return nil, false
}
func fallbackExecpolicyDecision(raw json.RawMessage) (any, bool) {
var params struct {
ProposedExecpolicyAmendment []string `json:"proposedExecpolicyAmendment"`
}
if err := json.Unmarshal(raw, &params); err != nil || len(params.ProposedExecpolicyAmendment) == 0 {
return nil, false
}
return map[string]any{
"acceptWithExecpolicyAmendment": map[string]any{
"execpolicy_amendment": params.ProposedExecpolicyAmendment,
},
}, true
}
func isLegacyApprovalKind(kind string) bool { func isLegacyApprovalKind(kind string) bool {
switch kind { switch kind {
case "execCommandApproval", "applyPatchApproval": case "execCommandApproval", "applyPatchApproval":
@@ -2827,12 +3182,12 @@ func renderApprovalHTML(kind string, raw json.RawMessage, status string) string
lines = append(lines, "", reason) lines = append(lines, "", reason)
} }
summary := strings.Join(lines, "\n") summary := strings.Join(lines, "\n")
details := renderApprovalDetailsHTML(kind, raw) sections := renderApprovalDetailSectionsHTML(kind, raw)
limit := TelegramHTMLMessageLimit limit := TelegramHTMLMessageLimit
if status != "" { if status != "" {
limit -= len([]rune(status)) + 1 limit -= len([]rune(status)) + 1
} }
text := SummaryDetailsRawHTMLLimited(summary, details, limit) text := SummaryRawHTMLSectionsLimited(summary, sections, limit)
if status != "" { if status != "" {
text += "\n" + EscapeHTML(status) text += "\n" + EscapeHTML(status)
} }
@@ -2840,23 +3195,32 @@ func renderApprovalHTML(kind string, raw json.RawMessage, status string) string
} }
func renderApprovalDetailsHTML(kind string, raw json.RawMessage) string { func renderApprovalDetailsHTML(kind string, raw json.RawMessage) string {
return strings.Join(renderApprovalDetailSectionsHTML(kind, raw), "\n")
}
func renderApprovalDetailSectionsHTML(kind string, raw json.RawMessage) []string {
var params map[string]any var params map[string]any
if err := json.Unmarshal(raw, &params); err != nil { if err := json.Unmarshal(raw, &params); err != nil {
return CodeBlockHTML("json", string(raw)) return []string{CodeBlockHTML("json", string(raw))}
} }
parts := renderSelectedArgumentDetailsHTML(params, []string{"command", "cwd", "grantRoot", "permissions", "fileChanges", "parsedCmd", "reason"}) parts := renderSelectedArgumentDetailsHTML(params, []string{"cwd", "command", "additionalPermissions", "networkApprovalContext", "proposedExecpolicyAmendment", "proposedNetworkPolicyAmendments", "availableDecisions", "grantRoot", "permissions", "fileChanges", "parsedCmd", "reason"})
if len(parts) == 0 { if len(parts) == 0 {
return CodeBlockHTML("json", prettyJSON(raw)) return []string{CodeBlockHTML("json", prettyJSON(raw))}
} }
return strings.Join(nonEmptyHTML(parts), "\n") return nonEmptyHTML(parts)
} }
func approvalStatusLine(decision string) string { func approvalStatusLine(decision string) string {
if strings.HasPrefix(decision, "networkPolicy") {
return "Applied network rule."
}
switch decision { switch decision {
case "accept": case "accept":
return "Approved." return "Approved."
case "acceptForSession": case "acceptForSession":
return "Approved for this session." return "Approved for this session."
case "acceptWithExecpolicyAmendment":
return "Approved and saved command rule."
case "decline": case "decline":
return "Disapproved." return "Disapproved."
case "cancel": case "cancel":

View File

@@ -87,6 +87,26 @@ func SummaryDetailsRawHTMLLimited(summary, detailsHTML string, limit int) string
return SummaryDetailsRawHTML(summary, EscapeHTML(suffix)) return SummaryDetailsRawHTML(summary, EscapeHTML(suffix))
} }
func SummaryRawHTMLSections(summary string, sections []string) string {
summary = strings.TrimSpace(summary)
sections = nonEmptyHTML(sections)
var parts []string
if summary != "" {
parts = append(parts, EscapeHTML(summary))
}
for _, section := range sections {
parts = append(parts, ExpandableQuoteRawHTML(section))
}
return strings.Join(parts, "\n")
}
func SummaryRawHTMLSectionsLimited(summary string, sections []string, limit int) string {
if limit <= 0 {
limit = TelegramHTMLMessageLimit
}
return FitHTMLMessage(SummaryRawHTMLSections(summary, sections), limit)
}
func CodeBlockHTML(language, text string) string { func CodeBlockHTML(language, text string) string {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if text == "" { if text == "" {
@@ -197,7 +217,16 @@ func FitHTMLMessage(htmlText string, limit int) string {
return truncateHTMLText(htmlText, limit) return truncateHTMLText(htmlText, limit)
} }
contentRunes := []rune(strings.TrimSpace(html.UnescapeString(content))) prefix, body, codeLanguage, codeBlock := splitSafeQuotePrefix(content)
contentRunes := []rune(strings.TrimSpace(html.UnescapeString(body)))
if len(contentRunes) == 0 {
replacement := prefix + EscapeHTML(truncatedQuote)
if replacement == content {
return summaryOnlyHTML(htmlText, limit)
}
htmlText = htmlText[:contentStart] + replacement + htmlText[contentEnd:]
continue
}
over := len([]rune(htmlText)) - limit over := len([]rune(htmlText)) - limit
keep := len(contentRunes) - over - 80 keep := len(contentRunes) - over - 80
if keep < 0 { if keep < 0 {
@@ -210,7 +239,10 @@ func FitHTMLMessage(htmlText string, limit int) string {
if keep > 0 { if keep > 0 {
replacementText = strings.TrimSpace(string(contentRunes[:keep])) + "\n" + truncatedQuote replacementText = strings.TrimSpace(string(contentRunes[:keep])) + "\n" + truncatedQuote
} }
replacement := EscapeHTML(replacementText) replacement := prefix + EscapeHTML(replacementText)
if codeBlock {
replacement = prefix + CodeBlockHTML(codeLanguage, replacementText)
}
if replacement == content { if replacement == content {
return summaryOnlyHTML(htmlText, limit) return summaryOnlyHTML(htmlText, limit)
} }
@@ -219,6 +251,81 @@ func FitHTMLMessage(htmlText string, limit int) string {
return htmlText return htmlText
} }
func splitSafeQuotePrefix(content string) (prefix, body, codeLanguage string, codeBlock bool) {
content = strings.TrimSpace(content)
prefix, body = splitLeadingBoldLabel(content)
body = strings.TrimSpace(body)
if language, code, ok := splitSingleCodeBlock(body); ok {
return prefix, code, language, true
}
return prefix, body, "", false
}
func splitLeadingBoldLabel(content string) (string, string) {
if !strings.HasPrefix(content, "<b>") {
return "", content
}
end := strings.Index(content, "</b>")
if end < 0 {
return "", content
}
labelEnd := end + len("</b>")
label := content[:labelEnd]
if !strings.HasSuffix(label, ":</b>") {
return "", content
}
afterLabel := content[labelEnd:]
if strings.HasPrefix(afterLabel, " <pre>") {
return label + " ", strings.TrimLeft(afterLabel, " ")
}
if strings.HasPrefix(afterLabel, "\n<pre>") {
return label + " ", strings.TrimLeft(afterLabel, "\n")
}
if strings.HasPrefix(afterLabel, " ") {
lineEnd := strings.Index(afterLabel, "\n")
if lineEnd < 0 {
return content + "\n", ""
}
prefixEnd := labelEnd + lineEnd + 1
return content[:prefixEnd], strings.TrimLeft(content[prefixEnd:], "\n")
}
rest := strings.TrimLeft(afterLabel, " \n")
return label + " ", rest
}
func splitSingleCodeBlock(content string) (string, string, bool) {
const preOpen = "<pre>"
const preClose = "</pre>"
const codeClose = "</code>"
if !strings.HasPrefix(content, preOpen+"<code ") || !strings.HasSuffix(content, codeClose+preClose) {
return "", "", false
}
codeStart := len(preOpen)
tagEnd := strings.Index(content[codeStart:], ">")
if tagEnd < 0 {
return "", "", false
}
tagEnd += codeStart
tag := content[codeStart : tagEnd+1]
const classPrefix = `class="language-`
classStart := strings.Index(tag, classPrefix)
if classStart < 0 {
return "", "", false
}
classStart += len(classPrefix)
classEnd := strings.Index(tag[classStart:], `"`)
if classEnd < 0 {
return "", "", false
}
language := tag[classStart : classStart+classEnd]
bodyStart := tagEnd + 1
bodyEnd := len(content) - len(codeClose+preClose)
if bodyEnd < bodyStart {
return "", "", false
}
return safeCodeLanguage(language), html.UnescapeString(content[bodyStart:bodyEnd]), true
}
func largestBlockquoteContent(htmlText, open, close string) (int, int, string) { func largestBlockquoteContent(htmlText, open, close string) (int, int, string) {
bestStart := -1 bestStart := -1
bestEnd := -1 bestEnd := -1
@@ -270,15 +377,19 @@ func truncateHTMLText(htmlText string, limit int) string {
limit = TelegramHTMLMessageLimit limit = TelegramHTMLMessageLimit
} }
suffix := "\n...[truncated]" suffix := "\n...[truncated]"
runes := []rune(htmlText) if len([]rune(htmlText)) <= limit {
if len(runes) <= limit {
return htmlText return htmlText
} }
plain := stripSimpleHTML(htmlText)
runes := []rune(plain)
keep := limit - len([]rune(suffix)) keep := limit - len([]rune(suffix))
if keep < 0 { if keep < 0 {
keep = 0 keep = 0
} }
return string(runes[:keep]) + suffix if keep > len(runes) {
keep = len(runes)
}
return EscapeHTML(strings.TrimSpace(string(runes[:keep]))) + EscapeHTML(suffix)
} }
func ChunkText(text string, max int) []string { func ChunkText(text string, max int) []string {
@@ -321,9 +432,13 @@ func ParseApprovalCallbackData(data string) (int64, string, bool) {
return 0, "", false return 0, "", false
} }
switch parts[2] { switch parts[2] {
case "accept", "acceptForSession", "decline", "cancel", "details": case "accept", "acceptForSession", "acceptWithExecpolicyAmendment", "decline", "cancel", "details":
return id, parts[2], true return id, parts[2], true
default: default:
if strings.HasPrefix(parts[2], "networkPolicy") {
index, err := strconv.Atoi(strings.TrimPrefix(parts[2], "networkPolicy"))
return id, parts[2], err == nil && index >= 0
}
return 0, "", false return 0, "", false
} }
} }

View File

@@ -53,6 +53,72 @@ func TestApprovalCallbackData(t *testing.T) {
} }
} }
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) { func TestApprovalResponseForPermissions(t *testing.T) {
approval := store.PendingApproval{ approval := store.PendingApproval{
Kind: "item/permissions/requestApproval", Kind: "item/permissions/requestApproval",
@@ -131,9 +197,25 @@ func TestEditReplyMarkupClearsInlineKeyboard(t *testing.T) {
} }
} }
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) { func TestParseCommand(t *testing.T) {
name, args, ok := parseCommand("/resume@my_bot 123") name, args, ok := parseCommand("/thread@my_bot 123")
if !ok || name != "resume" || len(args) != 1 || args[0] != "123" { if !ok || name != "thread" || len(args) != 1 || args[0] != "123" {
t.Fatalf("unexpected command parse: %q %#v %v", name, args, ok) t.Fatalf("unexpected command parse: %q %#v %v", name, args, ok)
} }
} }
@@ -204,15 +286,43 @@ func TestRenderCodexCommandExecutionItem(t *testing.T) {
ExitCode: &exitCode, ExitCode: &exitCode,
} }
text := renderCodexItemCompleted(item) text := renderCodexItemCompleted(item)
for _, want := range []string{"Tool call: command finished", "<b>Command</b>", "<pre><code class=\"language-bash\">go test ./...</code></pre>", "Exit code: 0", "<pre><code class=\"language-text\">line 1\nline 2</code></pre>"} { for _, want := range []string{"Tool call: command finished", "<b>CWD:</b> /workspace/project", "<b>Command:</b> <pre><code class=\"language-bash\">go test ./...</code></pre>", "<b>Output:</b> <pre><code class=\"language-text\">line 1\nline 2</code></pre>", "<b>Exit code:</b> 0"} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("rendered command item missing %q in %q", want, text) t.Fatalf("rendered command item missing %q in %q", want, text)
} }
} }
commandAt := strings.Index(text, "<b>Command</b>") summary, _, _ := strings.Cut(text, "<blockquote expandable>")
if strings.Contains(summary, "Exit code") {
t.Fatalf("exit code should not be in summary: %q", summary)
}
cwdAt := strings.Index(text, "<b>CWD:</b>") cwdAt := strings.Index(text, "<b>CWD:</b>")
if cwdAt >= 0 && commandAt > cwdAt { commandAt := strings.Index(text, "<b>Command:</b>")
t.Fatalf("command label should render before CWD to avoid Telegram attaching it to the CWD line: %q", text) outputAt := strings.Index(text, "<b>Output:</b>")
exitAt := strings.Index(text, "<b>Exit code:</b>")
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, "<blockquote expandable>"); 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{"<b>Output:</b> " + CodeBlockHTML("text", longOutput)}, 900)
if len([]rune(text)) > 900 {
t.Fatalf("fitted message exceeds limit: %d", len([]rune(text)))
}
for _, tag := range []string{"<blockquote expandable>", "</blockquote>", "<pre>", "</pre>", "<code class=\"language-text\">", "</code>"} {
if !strings.Contains(text, tag) {
t.Fatalf("fitted message missing %q in %q", tag, text)
}
}
if strings.Count(text, "<pre>") != strings.Count(text, "</pre>") || strings.Count(text, "<code") != strings.Count(text, "</code>") || strings.Count(text, "<blockquote expandable>") != strings.Count(text, "</blockquote>") {
t.Fatalf("fitted message has unbalanced HTML tags: %q", text)
}
if strings.Contains(text, "<b>Output:</b>\n") {
t.Fatalf("label should not be separated from code block by an immediate newline: %q", text)
} }
} }
@@ -232,7 +342,7 @@ func TestRenderDynamicToolDetailsSelectsUsefulArguments(t *testing.T) {
Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`), Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`),
} }
text := renderCodexItemCompleted(item) text := renderCodexItemCompleted(item)
for _, want := range []string{"Tool: functions.exec_command", "<b>cmd</b>", "language-bash", "go test ./..."} { for _, want := range []string{"Tool: functions.exec_command", "<b>cmd:</b>", "language-bash", "go test ./..."} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("rendered tool details missing %q in %q", want, text) t.Fatalf("rendered tool details missing %q in %q", want, text)
} }
@@ -277,22 +387,28 @@ func TestApprovalOnlyToolMessageCanReceiveCompletionDetails(t *testing.T) {
AggregatedOutput: &output, AggregatedOutput: &output,
}) })
text := tool.html() text := tool.html()
for _, want := range []string{"Tool call: command finished", "Exit code: 0", "Duration ms", "1234", "Codex requests command approval"} { for _, want := range []string{"Tool call: command finished", "<b>Exit code:</b> 0", "<b>Duration ms:</b>", "1234", "Codex requests command approval"} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("combined approval tool message missing %q in %q", want, text) t.Fatalf("combined approval tool message missing %q in %q", want, text)
} }
} }
} }
func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) { func TestToolMessageAddsEditedAtInsideDetails(t *testing.T) {
tool := toolMessageState{ tool := toolMessageState{
toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"), toolHTML: SummaryDetailsHTML("Tool call: command finished", "full output"),
editedAt: "2026-05-21 12:34:56 UTC", editedAt: "2026-05-21 12:34:56 UTC",
} }
text := tool.html() text := tool.html()
want := "Command: go test ./...\nEdited at: 2026-05-21 12:34:56 UTC\n<blockquote expandable>" summary, details, ok := strings.Cut(text, "<blockquote expandable>")
if !strings.Contains(text, want) { if !ok {
t.Fatalf("edited timestamp not placed before details: %q", text) 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, "<b>Edited at:</b> 2026-05-21 12:34:56 UTC") {
t.Fatalf("edited timestamp not placed inside details: %q", text)
} }
} }
@@ -308,7 +424,7 @@ func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) {
if len([]rune(text)) > TelegramHTMLMessageLimit { if len([]rune(text)) > TelegramHTMLMessageLimit {
t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text))) 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]"} { for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "<b>Edited at:</b> 2026-05-21 12:34:56 UTC", "...[truncated]"} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("fitted tool message missing %q in %q", want, text) t.Fatalf("fitted tool message missing %q in %q", want, text)
} }

View File

@@ -74,3 +74,8 @@ type EditMessageTextOptions struct {
ParseMode string `json:"parse_mode,omitempty"` ParseMode string `json:"parse_mode,omitempty"`
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
} }
type BotCommand struct {
Command string `json:"command"`
Description string `json:"description"`
}