package telegram import ( "context" "database/sql" "encoding/json" "errors" "fmt" "html" "log" "mime" "os" "path/filepath" "strconv" "strings" "sync" "time" "codex-telegram-bot/internal/codexapp" "codex-telegram-bot/internal/codexstate" "codex-telegram-bot/internal/store" ) const ( telegramDownloadLimit = 20 * 1024 * 1024 resumeThreadPageSize = 8 telegramPhotoDirectiveStart = "" telegramPhotoCaptionLimit = 1024 pictureMediaGroupLimit = 10 ) type Bot struct { tg *Client store *store.Store codex *codexapp.Client logger *log.Logger uploadDir string codexHome string codexStateDB string defaultModel string defaultSandbox string pollTimeout time.Duration mu sync.Mutex outputs map[string]*outputState diffs map[string]string } type assistantMessageSegment struct { Text string Photo *assistantPhotoDirective ThreadRename *assistantThreadRenameDirective ThreadCWD *assistantThreadCWDDirective } type assistantPhotoDirective struct { Path string `json:"path"` Caption string `json:"caption,omitempty"` } type assistantThreadRenameDirective struct { Title string `json:"title"` } type assistantThreadCWDDirective struct { CWD string `json:"cwd"` } type outputState struct { turnID string chatID int64 assistant strings.Builder sentAny bool pictureRequest bool tools map[string]toolMessageState sentImages map[string]bool generatedImages []generatedImageOutput workingIndicatorOff context.CancelFunc } type generatedImageOutput struct { Path string } type toolMessageState struct { chatID int64 messageID int toolHTML string approvalHTML string approvalMarkup *InlineKeyboardMarkup editedAt string } type codexThreadItemView struct { Type string `json:"type"` ID string `json:"id"` Command string `json:"command"` CWD string `json:"cwd"` Status string `json:"status"` Tool string `json:"tool"` Server string `json:"server"` Namespace string `json:"namespace"` Query string `json:"query"` Path string `json:"path"` SavedPath string `json:"savedPath"` Text string `json:"text"` AggregatedOutput *string `json:"aggregatedOutput"` ExitCode *int `json:"exitCode"` DurationMs *int64 `json:"durationMs"` Success *bool `json:"success"` Arguments json.RawMessage `json:"arguments"` Changes []struct { Path string `json:"path"` } `json:"changes"` } func NewBot(tg *Client, st *store.Store, codex *codexapp.Client, uploadDir, codexHome, codexStateDB, defaultModel, defaultSandbox string, pollTimeout time.Duration, logger *log.Logger) *Bot { if logger == nil { logger = log.Default() } return &Bot{ tg: tg, store: st, codex: codex, logger: logger, uploadDir: uploadDir, codexHome: codexHome, codexStateDB: codexStateDB, defaultModel: defaultModel, defaultSandbox: defaultSandbox, pollTimeout: pollTimeout, outputs: make(map[string]*outputState), diffs: make(map[string]string), } } 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 { return err } if err := b.tg.SetMyCommands(ctx, botCommands()); err != nil { b.logger.Printf("set telegram commands: %v", err) } go b.handleCodexEvents(ctx) offset := 0 for ctx.Err() == nil { updates, err := b.tg.GetUpdates(ctx, offset, int(b.pollTimeout.Seconds())) if err != nil { if ctx.Err() != nil { break } b.logger.Printf("getUpdates: %v", err) time.Sleep(2 * time.Second) continue } for _, update := range updates { if update.UpdateID >= offset { offset = update.UpdateID + 1 } if err := b.handleUpdate(ctx, update); err != nil { b.logger.Printf("handle update %d: %v", update.UpdateID, 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: "Choose 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 { switch { case update.Message != nil: return b.handleMessage(ctx, update.Message) case update.CallbackQuery != nil: return b.handleCallback(ctx, update.CallbackQuery) default: return nil } } func (b *Bot) handleMessage(ctx context.Context, message *Message) error { if message.PinnedMessage != nil { return nil } if message.Chat.Type != "private" { if message.From != nil { _ = b.store.Audit(ctx, message.From.ID, "reject_non_private", message.Chat.Type) } _, err := b.tg.SendMessage(ctx, message.Chat.ID, "This bot only supports one-to-one chats.", SendMessageOptions{}) return err } if message.From == nil { return nil } allowed, err := b.store.IsAllowed(ctx, message.From.ID) if err != nil { return err } if !allowed { _ = b.store.Audit(ctx, message.From.ID, "reject_not_allowed", message.From.Username) _, err := b.tg.SendMessage(ctx, message.Chat.ID, "Access denied.", SendMessageOptions{}) return err } session, err := b.store.GetOrCreateSession(ctx, message.From.ID, b.defaultModel, b.defaultSandbox) if err != nil { return err } if handled, err := b.handleCommand(ctx, message, session); handled || err != nil { return err } return b.continueThread(ctx, message, session) } func (b *Bot) handleCommand(ctx context.Context, message *Message, session store.Session) (bool, error) { command, args, ok := parseCommand(message.Text) if !ok { return false, nil } userID := message.From.ID chatID := message.Chat.ID switch command { case "start", "help": return true, b.sendHelp(ctx, chatID) case "new": _, _, err := b.createNewThread(ctx, userID, chatID, session, true) return true, err case "thread": return true, b.threadCommand(ctx, userID, chatID, args) case "threads", "resume": return true, b.legacyThreadCommand(ctx, userID, chatID, args) case "rename": return true, b.renameThread(ctx, userID, chatID, session, args) case "fork": return true, b.forkThread(ctx, userID, chatID, session) case "archive": return true, b.archiveThread(ctx, userID, chatID, session, args) case "status": return true, b.sendStatus(ctx, userID, chatID, session) case "cancel": return true, b.cancelTurn(ctx, userID, chatID, session) case "workspaces": return true, b.sendWorkspaces(ctx, userID, chatID) case "workspace": return true, b.handleWorkspaceCommand(ctx, userID, chatID, session, args) case "model": return true, b.handleModelCommand(ctx, userID, chatID, session, args) case "sandbox": return true, b.handleSandboxCommand(ctx, userID, chatID, session, args) case "pic": return true, b.handlePictureCommand(ctx, userID, chatID, session, args) case "diff": return true, b.sendDiff(ctx, chatID, session) default: _, err := b.tg.SendMessage(ctx, chatID, "Unknown command. Use /help.", SendMessageOptions{}) return true, err } } func (b *Bot) sendHelp(ctx context.Context, chatID int64) error { text := strings.Join([]string{ "Codex Telegram Bot", "", "/new - start a new Codex thread", "/thread - list recent threads", "/thread ID - switch to a thread", "/rename TITLE or /rename ID TITLE - rename a thread", "/fork - fork the active thread", "/archive [ID] - archive a thread", "/status - show active settings", "/cancel - interrupt the active turn", "/workspaces - list workspaces", "/workspace [ID] - select workspace", "/model - choose model and reasoning effort", "/sandbox - choose sandbox", "/pic PROMPT - generate image(s) from a prompt", "/diff - show the latest streamed diff", "", "Plain text continues the active thread. Images are staged as local Codex image inputs; other files are staged and sent as paths.", }, "\n") return b.sendLong(ctx, chatID, text) } func (b *Bot) threadCommand(ctx context.Context, userID, chatID int64, args []string) error { if len(args) == 0 { return b.sendResumeChoices(ctx, userID, chatID, 0, 0) } if len(args) != 1 { _, err := b.tg.SendMessage(ctx, chatID, "Use /thread to choose a thread, or /thread ID to switch directly.", SendMessageOptions{}) return err } id, err := strconv.ParseInt(args[0], 10, 64) if err != nil { _, sendErr := b.tg.SendMessage(ctx, chatID, "Thread ID must be a number.", SendMessageOptions{}) return sendErr } 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 { if page < 0 { page = 0 } threads, err := b.store.ListThreadsPage(ctx, userID, false, resumeThreadPageSize+1, page*resumeThreadPageSize) if err != nil { return err } if len(threads) == 0 && page > 0 { return b.sendResumeChoices(ctx, userID, chatID, page-1, messageID) } if len(threads) == 0 { text := "No threads yet. Use /new." if messageID != 0 { _, err := b.tg.EditMessageText(ctx, chatID, messageID, text, EditMessageTextOptions{}) return err } _, err := b.tg.SendMessage(ctx, chatID, text, SendMessageOptions{}) return err } threads = b.syncThreadStates(ctx, threads) hasNext := len(threads) > resumeThreadPageSize if hasNext { threads = threads[:resumeThreadPageSize] } text := resumeThreadListText(threads, page) markup := resumeThreadMarkup(threads, page, hasNext) if messageID != 0 { _, err := b.tg.EditMessageText(ctx, chatID, messageID, EscapeHTML(text), EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: editReplyMarkup(markup)}) return err } _, err = b.tg.SendMessage(ctx, chatID, EscapeHTML(text), SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup}) return err } func (b *Bot) resumeThreadByID(ctx context.Context, userID, chatID int64, id int64, messageID int) error { thread, err := b.store.GetThreadByID(ctx, userID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { text := "Thread not found." if messageID != 0 { _, editErr := b.tg.EditMessageText(ctx, chatID, messageID, text, EditMessageTextOptions{}) return editErr } _, sendErr := b.tg.SendMessage(ctx, chatID, text, SendMessageOptions{}) return sendErr } return err } resumed, err := b.codex.ResumeThread(ctx, thread.CodexThreadID) if err != nil { return b.sendError(ctx, chatID, "Could not resume Codex thread", err) } thread, err = b.applyCodexThreadState(ctx, thread, resumed) if err != nil { return err } if err := b.store.SetActiveThread(ctx, userID, thread.ID); err != nil { return err } if err := b.store.SetSessionWorkspace(ctx, userID, thread.WorkspaceID); err != nil { return err } text := fmt.Sprintf("Active thread ID %d: %s", thread.ID, threadDisplayTitle(thread)) if messageID != 0 { _, err = b.tg.EditMessageText(ctx, chatID, messageID, EscapeHTML(text), EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: clearInlineKeyboardMarkup()}) return err } _, err = b.tg.SendMessage(ctx, chatID, EscapeHTML(text), SendMessageOptions{ParseMode: "HTML"}) return err } func (b *Bot) renameThread(ctx context.Context, userID, chatID int64, session store.Session, args []string) error { if len(args) == 0 { _, err := b.tg.SendMessage(ctx, chatID, "Use /rename TITLE for the active thread, or /rename THREAD_ID TITLE.", SendMessageOptions{}) return err } var thread store.Thread titleArgs := args var err error if len(args) > 1 { if id, parseErr := strconv.ParseInt(args[0], 10, 64); parseErr == nil { thread, err = b.store.GetThreadByID(ctx, userID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { _, sendErr := b.tg.SendMessage(ctx, chatID, "Thread not found.", SendMessageOptions{}) return sendErr } return err } titleArgs = args[1:] } } if thread.ID == 0 { thread, err = b.activeThread(ctx, userID, session) } if err != nil { return b.sendNoActiveThread(ctx, chatID, err) } title := normalizeThreadTitle(strings.Join(titleArgs, " ")) if title == "" { _, err := b.tg.SendMessage(ctx, chatID, "Thread title cannot be empty.", SendMessageOptions{}) return err } if err := b.codex.SetThreadName(ctx, thread.CodexThreadID, title); err != nil { return b.sendError(ctx, chatID, "Could not rename Codex thread", err) } if codexThread, readErr := b.codex.ReadThread(ctx, thread.CodexThreadID); readErr == nil { thread, err = b.applyCodexThreadState(ctx, thread, codexThread) if err != nil { return err } title = thread.Title } else { b.logger.Printf("read renamed thread %s: %v", thread.CodexThreadID, readErr) if err := b.store.SyncThreadTitle(ctx, userID, thread.ID, title); err != nil { return err } } _, err = b.tg.SendMessage(ctx, chatID, fmt.Sprintf("Renamed thread ID %d: %s", thread.ID, title), SendMessageOptions{}) return err } func (b *Bot) forkThread(ctx context.Context, userID, chatID int64, session store.Session) error { thread, err := b.activeThread(ctx, userID, session) if err != nil { return b.sendNoActiveThread(ctx, chatID, err) } forked, err := b.codex.ForkThread(ctx, thread.CodexThreadID) if err != nil { return b.sendError(ctx, chatID, "Could not fork Codex thread", err) } workspaceID := thread.WorkspaceID if workspace, ok, workspaceErr := b.workspaceForCodexCWD(ctx, forked.CWD); workspaceErr == nil && ok { workspaceID = workspace.ID } else if workspaceErr != nil { b.logger.Printf("sync fork cwd %s: %v", forked.CWD, workspaceErr) } title := codexThreadTitle(forked, "fork of ID "+strconv.FormatInt(thread.ID, 10)) local, err := b.store.CreateThread(ctx, userID, forked.ID, workspaceID, title) if err != nil { return err } if err := b.store.SetActiveThread(ctx, userID, local.ID); err != nil { return err } if err := b.store.SetSessionWorkspace(ctx, userID, local.WorkspaceID); err != nil { return err } _, err = b.tg.SendMessage(ctx, chatID, fmt.Sprintf("Forked active thread to #%d.", local.ID), SendMessageOptions{}) return err } func (b *Bot) archiveThread(ctx context.Context, userID, chatID int64, session store.Session, args []string) error { var thread store.Thread var err error if len(args) > 0 { id, parseErr := strconv.ParseInt(args[0], 10, 64) if parseErr != nil { _, sendErr := b.tg.SendMessage(ctx, chatID, "Thread ID must be a number.", SendMessageOptions{}) return sendErr } thread, err = b.store.GetThreadByID(ctx, userID, id) } else { thread, err = b.activeThread(ctx, userID, session) } if err != nil { return b.sendNoActiveThread(ctx, chatID, err) } if err := b.codex.ArchiveThread(ctx, thread.CodexThreadID); err != nil { return b.sendError(ctx, chatID, "Could not archive Codex thread", err) } if err := b.store.ArchiveThread(ctx, userID, thread.ID); err != nil { return err } _, err = b.tg.SendMessage(ctx, chatID, fmt.Sprintf("Archived thread #%d.", thread.ID), SendMessageOptions{}) return err } func (b *Bot) sendStatus(ctx context.Context, userID, chatID int64, session store.Session) error { workspace := "(none)" if session.ActiveWorkspaceID != 0 { if ws, err := b.store.GetWorkspaceByID(ctx, session.ActiveWorkspaceID); err == nil { workspace = fmt.Sprintf("%s (%s)", ws.Label, ws.Path) } } thread := "(none)" if session.ActiveThreadID != 0 { thread = fmt.Sprintf("ID %d", session.ActiveThreadID) if active, err := b.store.GetThreadByID(ctx, userID, session.ActiveThreadID); err == nil { if synced, syncErr := b.syncThreadState(ctx, active); syncErr == nil { active = synced } else { b.logger.Printf("sync status thread state %s: %v", active.CodexThreadID, syncErr) } if ws, wsErr := b.store.GetWorkspaceByID(ctx, active.WorkspaceID); wsErr == nil { workspace = fmt.Sprintf("%s (%s)", ws.Label, ws.Path) } thread = fmt.Sprintf("ID %d: %s", active.ID, threadDisplayTitle(active)) } } model := session.Model if model == "" { model = "(Codex default)" } turn := session.ActiveTurnID if turn == "" { turn = "(none)" } effort := session.ReasoningEffort if effort == "" { effort = "(model default)" } text := fmt.Sprintf("Workspace: %s\nThread: %s\nModel: %s\nReasoning effort: %s\nSandbox: %s\nActive turn: %s", workspace, thread, model, effort, session.Sandbox, turn) _ = userID return b.sendLong(ctx, chatID, text) } func (b *Bot) cancelTurn(ctx context.Context, userID, chatID int64, session store.Session) error { if session.ActiveTurnID == "" { _, err := b.tg.SendMessage(ctx, chatID, "No active turn to cancel.", SendMessageOptions{}) return err } thread, err := b.activeThread(ctx, userID, session) if err != nil { return b.sendNoActiveThread(ctx, chatID, err) } if err := b.codex.InterruptTurn(ctx, thread.CodexThreadID, session.ActiveTurnID); err != nil { return b.sendError(ctx, chatID, "Could not interrupt turn", err) } _, err = b.tg.SendMessage(ctx, chatID, "Cancellation requested.", SendMessageOptions{}) return err } func (b *Bot) sendWorkspaces(ctx context.Context, userID, chatID int64) error { if err := b.syncUserThreadStates(ctx, userID); err != nil { b.logger.Printf("sync workspaces for user %d: %v", userID, err) } workspaces, err := b.store.ListWorkspaces(ctx) if err != nil { return err } if len(workspaces) == 0 { _, err := b.tg.SendMessage(ctx, chatID, "No workspaces configured. Ask an admin to run scripts/add-workspace.", SendMessageOptions{}) return err } var lines []string lines = append(lines, "Workspaces:") for _, ws := range workspaces { marker := "" if ws.IsDefault { marker = " default" } lines = append(lines, fmt.Sprintf("#%d %s%s\n%s", ws.ID, ws.Label, marker, ws.Path)) } _, err = b.tg.SendMessage(ctx, chatID, EscapeHTML(strings.Join(lines, "\n")), SendMessageOptions{ ParseMode: "HTML", ReplyMarkup: workspaceMarkup(workspaces), }) return err } func (b *Bot) handleWorkspaceCommand(ctx context.Context, userID, chatID int64, session store.Session, args []string) error { if len(args) == 0 { return b.sendWorkspaces(ctx, userID, chatID) } id, err := strconv.ParseInt(args[0], 10, 64) if err != nil { _, sendErr := b.tg.SendMessage(ctx, chatID, "Workspace ID must be a number.", SendMessageOptions{}) return sendErr } return b.setWorkspace(ctx, userID, chatID, session, id) } func (b *Bot) setWorkspace(ctx context.Context, userID, chatID int64, session store.Session, workspaceID int64) error { workspace, err := b.store.GetWorkspaceByID(ctx, workspaceID) if err != nil { if errors.Is(err, sql.ErrNoRows) { _, sendErr := b.tg.SendMessage(ctx, chatID, "Workspace not found.", SendMessageOptions{}) return sendErr } return err } if err := b.store.SetSessionWorkspace(ctx, userID, workspace.ID); err != nil { return err } _ = session _, err = b.tg.SendMessage(ctx, chatID, "Workspace set to "+workspace.Label+".", SendMessageOptions{}) return err } func (b *Bot) handleModelCommand(ctx context.Context, userID, chatID int64, session store.Session, args []string) error { _ = userID if len(args) > 0 { _, err := b.tg.SendMessage(ctx, chatID, "Use /model and choose from the buttons.", SendMessageOptions{}) return err } return b.sendModelChoices(ctx, chatID, session) } func (b *Bot) sendModelChoices(ctx context.Context, chatID int64, session store.Session) error { models, err := b.codex.ListModels(ctx) if err != nil { return b.sendError(ctx, chatID, "Could not list Codex models", err) } if len(models) == 0 { _, err := b.tg.SendMessage(ctx, chatID, "Codex returned no available models.", SendMessageOptions{}) return err } model := sessionModelLabel(models, session.Model) text := settingsStatusText(model, session.ReasoningEffort) + "\nChoose a model:" message, err := b.tg.SendMessage(ctx, chatID, EscapeHTML(text), SendMessageOptions{ ParseMode: "HTML", ReplyMarkup: modelMarkup(models), }) if err != nil { return err } b.rememberSettingsMessage(ctx, session.TelegramUserID, chatID, message.MessageID) return nil } func (b *Bot) handleModelCallback(ctx context.Context, callback *CallbackQuery, modelID string) error { models, err := b.codex.ListModels(ctx) if err != nil { _ = b.tg.AnswerCallbackQuery(ctx, callback.ID, "Could not list models.") return b.sendError(ctx, callback.Message.Chat.ID, "Could not list Codex models", err) } var selected codexapp.Model found := false for _, model := range models { if model.ID == modelID { selected = model found = true break } } if !found { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Model is no longer available.") } if err := b.store.SetSessionModel(ctx, callback.From.ID, selected.ID); err != nil { return err } if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Model selected."); err != nil { return err } label := modelLabel(selected) if len(selected.SupportedReasoningEfforts) == 0 { text := settingsStatusText(label, "") + "\nNo reasoning effort choices are advertised for this model." message, err := b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, EscapeHTML(text), EditMessageTextOptions{ParseMode: "HTML"}) if err == nil { b.rememberSettingsMessage(ctx, callback.From.ID, callback.Message.Chat.ID, message.MessageID) } return err } text := settingsStatusText(label, "") + "\nChoose reasoning effort:" message, err := b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, EscapeHTML(text), EditMessageTextOptions{ ParseMode: "HTML", ReplyMarkup: effortMarkup(selected), }) if err == nil { b.rememberSettingsMessage(ctx, callback.From.ID, callback.Message.Chat.ID, message.MessageID) } return err } func (b *Bot) handleEffortCallback(ctx context.Context, callback *CallbackQuery, effort string) error { session, err := b.store.GetOrCreateSession(ctx, callback.From.ID, b.defaultModel, b.defaultSandbox) if err != nil { return err } if session.Model == "" { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Choose a model first.") } models, err := b.codex.ListModels(ctx) if err != nil { _ = b.tg.AnswerCallbackQuery(ctx, callback.ID, "Could not validate effort.") return b.sendError(ctx, callback.Message.Chat.ID, "Could not list Codex models", err) } var selected codexapp.Model found := false for _, model := range models { if model.ID == session.Model { selected = model found = true break } } if !found { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Selected model is no longer available.") } if !modelSupportsEffort(selected, effort) { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Effort is no longer available for this model.") } if err := b.store.SetSessionReasoningEffort(ctx, callback.From.ID, effort); err != nil { return err } if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Reasoning effort selected."); err != nil { return err } text := settingsStatusText(modelLabel(selected), effort) message, err := b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, EscapeHTML(text), EditMessageTextOptions{ParseMode: "HTML"}) if err == nil { b.rememberSettingsMessage(ctx, callback.From.ID, callback.Message.Chat.ID, message.MessageID) } return err } func (b *Bot) handleSandboxCommand(ctx context.Context, userID, chatID int64, session store.Session, args []string) error { _ = userID if len(args) > 0 { _, err := b.tg.SendMessage(ctx, chatID, "Use /sandbox and choose from the buttons.", SendMessageOptions{}) return err } return b.sendSandboxChoices(ctx, chatID, session) } func (b *Bot) sendSandboxChoices(ctx context.Context, chatID int64, session store.Session) error { text := sandboxStatusText(session.Sandbox) + "\nChoose sandbox:" message, err := b.tg.SendMessage(ctx, chatID, EscapeHTML(text), SendMessageOptions{ ParseMode: "HTML", ReplyMarkup: sandboxMarkup(session.Sandbox), }) if err != nil { return err } b.rememberSettingsMessage(ctx, session.TelegramUserID, chatID, message.MessageID) return nil } func (b *Bot) handleSandboxCallback(ctx context.Context, callback *CallbackQuery, sandbox string) error { normalized, err := codexapp.NormalizeSandbox(sandbox) if err != nil { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Unsupported sandbox.") } if err := b.store.SetSessionSandbox(ctx, callback.From.ID, normalized); err != nil { return err } if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Sandbox selected."); err != nil { return err } text := sandboxStatusText(normalized) message, err := b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, EscapeHTML(text), EditMessageTextOptions{ParseMode: "HTML"}) if err == nil { b.rememberSettingsMessage(ctx, callback.From.ID, callback.Message.Chat.ID, message.MessageID) } return err } func isPicturePath(path string) bool { switch strings.ToLower(filepath.Ext(path)) { case ".jpg", ".jpeg", ".png", ".webp", ".gif": return true default: return false } } func (b *Bot) sendDiff(ctx context.Context, chatID int64, session store.Session) error { if session.ActiveThreadID == 0 { _, err := b.tg.SendMessage(ctx, chatID, "No active thread.", SendMessageOptions{}) return err } thread, err := b.store.GetThreadByID(ctx, chatID, session.ActiveThreadID) if err != nil { _, sendErr := b.tg.SendMessage(ctx, chatID, "No active thread.", SendMessageOptions{}) return sendErr } b.mu.Lock() diff := b.diffs[thread.CodexThreadID] b.mu.Unlock() if diff == "" { _, err := b.tg.SendMessage(ctx, chatID, "No diff has been streamed for this thread.", SendMessageOptions{}) return err } return b.sendLong(ctx, chatID, diff) } func (b *Bot) continueThread(ctx context.Context, message *Message, session store.Session) error { userID := message.From.ID chatID := message.Chat.ID input, err := b.messageInput(ctx, userID, message) if err != nil { return b.sendError(ctx, chatID, "Could not stage Telegram input", err) } if len(input) == 0 { _, err := b.tg.SendMessage(ctx, chatID, "Send text, an image, or a document.", SendMessageOptions{}) return err } thread, _, err := b.ensureThread(ctx, userID, chatID, session) if err != nil { return err } if session.ActiveTurnID != "" { if b.hasOutputTurn(thread.CodexThreadID, session.ActiveTurnID) { 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 } b.clearStaleActiveTurn(ctx, userID, thread, session.ActiveTurnID) session.ActiveTurnID = "" } b.registerOutput(thread.CodexThreadID, "", chatID) turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input) if err != nil { b.clearOutput(thread.CodexThreadID) 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 { return err } _ = b.store.TouchThread(ctx, thread.CodexThreadID) return nil } func codexThreadTitle(thread codexapp.Thread, fallback string) string { if title := normalizeThreadTitle(thread.Name); title != "" { return title } if title := normalizeThreadTitle(thread.Preview); title != "" { return title } return normalizeThreadTitle(fallback) } func (b *Bot) applyCodexThreadState(ctx context.Context, thread store.Thread, codexThread codexapp.Thread) (store.Thread, error) { title := codexThreadTitle(codexThread, "") if title != thread.Title { if err := b.store.SyncThreadTitle(ctx, thread.TelegramUserID, thread.ID, title); err != nil { return thread, err } thread.Title = title } workspace, ok, err := b.workspaceForCodexCWD(ctx, codexThread.CWD) if err != nil { return thread, err } if ok && workspace.ID != thread.WorkspaceID { if err := b.store.SyncThreadWorkspace(ctx, thread.TelegramUserID, thread.ID, workspace.ID); err != nil { return thread, err } thread.WorkspaceID = workspace.ID } return thread, nil } func (b *Bot) workspaceForCodexCWD(ctx context.Context, cwd string) (store.Workspace, bool, error) { cwd = strings.TrimSpace(cwd) if cwd == "" { return store.Workspace{}, false, nil } clean, err := store.ValidateWorkspacePath(cwd) if err != nil { return store.Workspace{}, false, err } workspace, err := b.store.GetWorkspaceByPath(ctx, clean) if err == nil { return workspace, true, nil } if !errors.Is(err, sql.ErrNoRows) { return store.Workspace{}, false, err } label := filepath.Base(clean) workspace, err = b.store.AddWorkspace(ctx, clean, label, false) if err != nil { return store.Workspace{}, false, err } return workspace, true, nil } func (b *Bot) syncThreadState(ctx context.Context, thread store.Thread) (store.Thread, error) { if thread.CodexThreadID == "" { return thread, nil } codexThread, err := b.codex.ReadThread(ctx, thread.CodexThreadID) if err != nil { return thread, err } return b.applyCodexThreadState(ctx, thread, codexThread) } func (b *Bot) syncThreadStates(ctx context.Context, threads []store.Thread) []store.Thread { for i := range threads { synced, err := b.syncThreadState(ctx, threads[i]) if err != nil { b.logger.Printf("sync thread state %s: %v", threads[i].CodexThreadID, err) continue } threads[i] = synced } return threads } func (b *Bot) syncUserThreadStates(ctx context.Context, userID int64) error { threads, err := b.store.ListThreadsPage(ctx, userID, false, 200, 0) if err != nil { return err } b.syncThreadStates(ctx, threads) return nil } func (b *Bot) createNewThread(ctx context.Context, userID, chatID int64, session store.Session, announce bool) (store.Thread, store.Workspace, error) { workspace, err := b.resolveWorkspace(ctx, userID, session) if err != nil { return store.Thread{}, store.Workspace{}, b.sendWorkspaceMissing(ctx, chatID) } codexThread, err := b.codex.StartThread(ctx, workspace.Path, session.Model, session.Sandbox) if err != nil { return store.Thread{}, store.Workspace{}, b.sendError(ctx, chatID, "Could not start Codex thread", err) } threadWorkspace := workspace if codexWorkspace, ok, workspaceErr := b.workspaceForCodexCWD(ctx, codexThread.CWD); workspaceErr == nil && ok { threadWorkspace = codexWorkspace } else if workspaceErr != nil { b.logger.Printf("sync new thread cwd %s: %v", codexThread.CWD, workspaceErr) } title := codexThreadTitle(codexThread, threadWorkspace.Label) thread, err := b.store.CreateThread(ctx, userID, codexThread.ID, threadWorkspace.ID, title) if err != nil { return store.Thread{}, store.Workspace{}, err } if err := b.store.SetActiveThread(ctx, userID, thread.ID); err != nil { return store.Thread{}, store.Workspace{}, err } if err := b.store.SetSessionWorkspace(ctx, userID, thread.WorkspaceID); err != nil { return store.Thread{}, store.Workspace{}, err } if announce { _, err = b.tg.SendMessage(ctx, chatID, fmt.Sprintf("New thread #%d in %s.", thread.ID, threadWorkspace.Label), SendMessageOptions{}) } return thread, threadWorkspace, err } func (b *Bot) ensureThread(ctx context.Context, userID, chatID int64, session store.Session) (store.Thread, store.Workspace, error) { if session.ActiveThreadID != 0 { thread, err := b.store.GetThreadByID(ctx, userID, session.ActiveThreadID) if err == nil && !thread.Archived { if synced, syncErr := b.syncThreadState(ctx, thread); syncErr == nil { thread = synced } else { b.logger.Printf("sync active thread state %s: %v", thread.CodexThreadID, syncErr) } workspace, err := b.store.GetWorkspaceByID(ctx, thread.WorkspaceID) return thread, workspace, err } } return b.createNewThread(ctx, userID, chatID, session, true) } func (b *Bot) ensureThreadForPicture(ctx context.Context, userID, chatID int64, session store.Session) (store.Thread, store.Workspace, error) { if session.ActiveThreadID != 0 { return b.ensureThread(ctx, userID, chatID, session) } return b.createNewThread(ctx, userID, chatID, session, false) } func (b *Bot) handlePictureCommand(ctx context.Context, userID, chatID int64, session store.Session, args []string) error { prompt := strings.TrimSpace(strings.Join(args, " ")) if prompt == "" { _, err := b.tg.SendMessage(ctx, chatID, "Use /pic PROMPT to generate image(s).", SendMessageOptions{}) return err } thread, _, err := b.ensureThreadForPicture(ctx, userID, chatID, session) if err != nil { 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)}} b.registerPictureOutput(thread.CodexThreadID, "", chatID) turn, err := b.codex.StartTurn(ctx, thread.CodexThreadID, "", session.Model, session.ReasoningEffort, session.Sandbox, input) if err != nil { b.clearOutput(thread.CodexThreadID) 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 { return err } _ = b.store.TouchThread(ctx, thread.CodexThreadID) return nil } func pictureGenerationInstruction(prompt string) string { return strings.Join([]string{ "You are handling a Telegram /pic command.", "Use only the built-in image generation capability to create image(s) from the user prompt below.", "Do not browse the web, run shell commands, call MCP tools, edit files, or ask follow-up questions.", "Avoid extra explanatory text; the Telegram bot will send generated image files automatically.", "", "User image prompt:", strings.TrimSpace(prompt), }, "\n") } func (b *Bot) activeThread(ctx context.Context, userID int64, session store.Session) (store.Thread, error) { if session.ActiveThreadID == 0 { return store.Thread{}, sql.ErrNoRows } return b.store.GetThreadByID(ctx, userID, session.ActiveThreadID) } func (b *Bot) resolveWorkspace(ctx context.Context, userID int64, session store.Session) (store.Workspace, error) { if session.ActiveWorkspaceID != 0 { return b.store.GetWorkspaceByID(ctx, session.ActiveWorkspaceID) } workspace, err := b.store.DefaultWorkspace(ctx) if err != nil { return store.Workspace{}, err } if err := b.store.SetSessionWorkspace(ctx, userID, workspace.ID); err != nil { return store.Workspace{}, err } return workspace, nil } func (b *Bot) messageInput(ctx context.Context, userID int64, message *Message) ([]codexapp.InputItem, error) { var input []codexapp.InputItem text := strings.TrimSpace(message.Text) if text == "" { text = strings.TrimSpace(message.Caption) } if text != "" { input = append(input, codexapp.InputItem{Type: "text", Text: text}) } if len(message.Photo) > 0 { photo := largestPhoto(message.Photo) path, err := b.stageFile(ctx, userID, photo.FileID, "photo.jpg", "image/jpeg", photo.FileSize) if err != nil { return nil, err } input = append(input, codexapp.InputItem{Type: "localImage", Path: path}) } if message.Document != nil { path, err := b.stageFile(ctx, userID, message.Document.FileID, message.Document.FileName, message.Document.MimeType, message.Document.FileSize) if err != nil { return nil, err } if isImage(message.Document.MimeType, message.Document.FileName) { input = append(input, codexapp.InputItem{Type: "localImage", Path: path}) } else { input = append(input, codexapp.InputItem{Type: "text", Text: "User uploaded a file staged at: " + path}) } } return input, nil } func (b *Bot) stageFile(ctx context.Context, userID int64, fileID, filename, mimeType string, size int64) (string, error) { if size > telegramDownloadLimit { return "", fmt.Errorf("file exceeds Telegram cloud download limit of 20 MB") } file, err := b.tg.GetFile(ctx, fileID) if err != nil { return "", err } if file.FileSize > telegramDownloadLimit { return "", fmt.Errorf("file exceeds Telegram cloud download limit of 20 MB") } if file.FilePath == "" { return "", errors.New("telegram did not return a file path") } data, err := b.tg.DownloadFilePath(ctx, file.FilePath) if err != nil { return "", err } if len(data) > telegramDownloadLimit { return "", fmt.Errorf("file exceeds Telegram cloud download limit of 20 MB") } if filename == "" { filename = filepath.Base(file.FilePath) } if filename == "." || filename == string(filepath.Separator) || filename == "" { filename = fileID if ext := extensionForMime(mimeType); ext != "" { filename += ext } } if err := os.MkdirAll(b.uploadDir, 0o755); err != nil { return "", err } name := fmt.Sprintf("%d_%d_%s", userID, time.Now().UnixNano(), safeFilename(filename)) path := filepath.Join(b.uploadDir, name) if err := os.WriteFile(path, data, 0o644); err != nil { return "", err } return path, nil } func (b *Bot) handleCallback(ctx context.Context, callback *CallbackQuery) error { if callback.Message == nil { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Unsupported callback.") } if callback.Message.Chat.Type != "private" { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Use a private chat.") } allowed, err := b.store.IsAllowed(ctx, callback.From.ID) if err != nil { return err } if !allowed { _ = b.store.Audit(ctx, callback.From.ID, "reject_callback_not_allowed", callback.From.Username) return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Access denied.") } session, err := b.store.GetOrCreateSession(ctx, callback.From.ID, b.defaultModel, b.defaultSandbox) if err != nil { return err } if workspaceID, ok := ParseWorkspaceCallbackData(callback.Data); ok { if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, ""); err != nil { return err } return b.setWorkspace(ctx, callback.From.ID, callback.Message.Chat.ID, session, workspaceID) } if resumeID, ok := ParseResumeThreadCallbackData(callback.Data); ok { if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Thread selected."); err != nil { return err } return b.resumeThreadByID(ctx, callback.From.ID, callback.Message.Chat.ID, resumeID, callback.Message.MessageID) } if resumePage, ok := ParseResumePageCallbackData(callback.Data); ok { if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, ""); err != nil { return err } return b.sendResumeChoices(ctx, callback.From.ID, callback.Message.Chat.ID, resumePage, callback.Message.MessageID) } if modelID, ok := ParseModelCallbackData(callback.Data); ok { return b.handleModelCallback(ctx, callback, modelID) } if effort, ok := ParseEffortCallbackData(callback.Data); ok { return b.handleEffortCallback(ctx, callback, effort) } if sandbox, ok := ParseSandboxCallbackData(callback.Data); ok { return b.handleSandboxCallback(ctx, callback, sandbox) } if approvalID, decision, ok := ParseApprovalCallbackData(callback.Data); ok { return b.handleApprovalCallback(ctx, callback, approvalID, decision) } return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Unknown action.") } func (b *Bot) handleApprovalCallback(ctx context.Context, callback *CallbackQuery, approvalID int64, decision string) error { approval, err := b.store.GetPendingApproval(ctx, callback.From.ID, approvalID) if err != nil { _ = b.tg.AnswerCallbackQuery(ctx, callback.ID, "Approval not found.") return nil } if decision == "details" { if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Details sent."); err != nil { return err } return b.sendHTML(ctx, callback.Message.Chat.ID, renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), "")) } if approval.Status != "pending" { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Already resolved.") } requestID, err := codexapp.ParseRequestIDKey(approval.CodexRequestID) if err != nil { return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Invalid request id.") } if err := b.codex.RespondServerRequest(ctx, requestID, approvalResponse(approval, decision)); err != nil { _ = b.tg.AnswerCallbackQuery(ctx, callback.ID, "Could not answer Codex.") return b.sendError(ctx, callback.Message.Chat.ID, "Could not answer Codex approval", err) } if err := b.store.ResolvePendingApproval(ctx, callback.From.ID, approval.ID, decision); err != nil { return err } if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Sent to Codex."); err != nil { return err } updated := b.resolveApprovalMessageHTML(approval, decision) _, err = b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, updated, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: clearInlineKeyboardMarkup()}) return ignoreTelegramMessageNotModified(err) } func (b *Bot) handleCodexEvents(ctx context.Context) { for { select { case <-ctx.Done(): return case event := <-b.codex.Events(): if event.Err != nil { b.logger.Printf("codex event %s: %v", event.Method, event.Err) if event.Method == "connection/closed" { b.failActiveOutputs(ctx, "Codex connection closed: "+event.Err.Error()) } continue } if event.ServerRequest { if err := b.handleCodexServerRequest(ctx, event); err != nil { b.logger.Printf("server request %s: %v", event.Method, err) if event.ID != nil { _ = b.codex.RespondServerRequestError(ctx, *event.ID, -32603, err.Error()) } } continue } if err := b.handleCodexNotification(ctx, event); err != nil { b.logger.Printf("notification %s: %v", event.Method, err) } } } } func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) { var item codexThreadItemView if len(raw) == 0 { return item, nil } if err := json.Unmarshal(raw, &item); err != nil { return item, err } return item, nil } func renderGuardianReviewHTML(title string, raw json.RawMessage) string { var params map[string]any if err := json.Unmarshal(raw, ¶ms); err != nil { return SummaryDetailsHTML(title, string(raw)) } sections := renderSelectedArgumentDetailsHTML(params, []string{"action", "review", "decisionSource", "targetItemId", "reviewId"}) return SummaryRawHTMLSectionsLimited(title, sections, TelegramHTMLMessageLimit) } func renderCodexItemStarted(item codexThreadItemView) string { switch item.Type { case "commandExecution": return SummaryRawHTMLSectionsLimited("Tool call: command started", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit) case "fileChange": return "Tool call: file change started" case "mcpToolCall": return joinNonEmpty("Tool call: MCP started", "Tool: "+toolDisplayName(item.Server, item.Tool)) case "dynamicToolCall": return joinNonEmpty("Tool call: started", "Tool: "+toolDisplayName(item.Namespace, item.Tool)) case "webSearch": return joinNonEmpty("Tool call: web search started", "Query: "+item.Query) case "imageView": return joinNonEmpty("Tool call: image view", "Path: "+item.Path) case "imageGeneration": return "Tool call: image generation started" case "collabAgentToolCall": return joinNonEmpty("Tool call: agent started", "Tool: "+item.Tool) default: return "" } } func renderCodexItemCompleted(item codexThreadItemView) string { switch item.Type { case "commandExecution": return SummaryRawHTMLSectionsLimited("Tool call: command finished", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit) case "fileChange": return joinNonEmpty("Tool call: file change finished", fmt.Sprintf("Changed files: %d", len(item.Changes)), "Status: "+item.Status) case "mcpToolCall": summary := joinNonEmpty("Tool call: MCP finished", "Tool: "+toolDisplayName(item.Server, item.Tool), "Status: "+item.Status) return SummaryDetailsRawHTMLLimited(summary, renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit) case "dynamicToolCall": status := item.Status if item.Success != nil { status = fmt.Sprintf("success=%t", *item.Success) } summary := joinNonEmpty("Tool call: finished", "Tool: "+toolDisplayName(item.Namespace, item.Tool), "Status: "+status) return SummaryDetailsRawHTMLLimited(summary, renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit) case "webSearch": return joinNonEmpty("Tool call: web search finished", "Query: "+item.Query) case "imageView": return joinNonEmpty("Tool call: image view finished", "Path: "+item.Path) case "imageGeneration": return joinNonEmpty("Tool call: image generation finished", "Status: "+item.Status, "Saved path: "+item.SavedPath) case "collabAgentToolCall": return joinNonEmpty("Tool call: agent finished", "Tool: "+item.Tool, "Status: "+item.Status) default: return "" } } func commandStartedDetailsHTML(item codexThreadItemView) string { return commandExecutionDetailsHTML(item, "") } 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 != "" { sections = append(sections, FieldHTML("CWD", cwd)) } if command := strings.TrimSpace(item.Command); command != "" { sections = append(sections, "Command: "+CodeBlockHTML("bash", command)) } if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" { sections = append(sections, "Output: "+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 { var parts []string appendField := func(label, value string) { if html := FieldHTML(label, value); html != "" { parts = append(parts, html) } } appendBool := func(label string, value *bool) { if value != nil { appendField(label, strconv.FormatBool(*value)) } } switch item.Type { case "commandExecution": parts = append(parts, commandExecutionDetailsHTML(item, "")) case "fileChange": appendField("Status", item.Status) for _, change := range item.Changes { appendField("Changed", change.Path) } case "mcpToolCall": appendField("Tool", toolDisplayName(item.Server, item.Tool)) appendField("Status", item.Status) parts = append(parts, renderArgumentsDetailsHTML(item.Arguments)...) case "dynamicToolCall": appendField("Tool", toolDisplayName(item.Namespace, item.Tool)) appendField("Status", item.Status) appendBool("Success", item.Success) parts = append(parts, renderArgumentsDetailsHTML(item.Arguments)...) case "webSearch": appendField("Query", item.Query) case "imageView": appendField("Path", item.Path) case "imageGeneration": appendField("Status", item.Status) appendField("Saved path", item.SavedPath) case "collabAgentToolCall": appendField("Tool", item.Tool) appendField("Status", item.Status) default: appendField("Type", item.Type) appendField("Status", item.Status) parts = append(parts, renderArgumentsDetailsHTML(item.Arguments)...) } return strings.Join(nonEmptyHTML(parts), "\n") } func renderArgumentsDetailsHTML(raw json.RawMessage) []string { if len(raw) == 0 || string(raw) == "null" { return nil } var value any if err := json.Unmarshal(raw, &value); err != nil { return []string{"Arguments", CodeBlockHTML("json", string(raw))} } object, ok := value.(map[string]any) if !ok { return []string{"Arguments", CodeBlockHTML("json", compactPrettyJSON(value))} } parts := renderSelectedArgumentDetailsHTML(object, preferredArgumentKeys(object)) if len(parts) == 0 && len(object) > 0 { parts = append(parts, "Arguments", CodeBlockHTML("json", compactPrettyJSON(object))) } return parts } func renderSelectedArgumentDetailsHTML(object map[string]any, keys []string) []string { var parts []string for _, key := range keys { part := renderArgumentFieldHTML(key, object[key]) if strings.TrimSpace(part) != "" { parts = append(parts, part) } } return parts } func preferredArgumentKeys(object map[string]any) []string { preferred := []string{"cmd", "command", "script", "code", "content", "input", "query", "path", "file", "filename", "cwd", "args", "config", "patch"} seen := make(map[string]bool, len(object)) var keys []string for _, key := range preferred { if _, ok := object[key]; ok { keys = append(keys, key) seen[key] = true } } for key := range object { if !seen[key] && isMeaningfulArgumentKey(key) { keys = append(keys, key) } } return keys } func isMeaningfulArgumentKey(key string) bool { key = strings.ToLower(key) for _, part := range []string{"command", "cmd", "code", "content", "path", "file", "query", "input", "config", "patch", "cwd"} { if strings.Contains(key, part) { return true } } return false } func renderArgumentFieldHTML(key string, value any) string { label := argumentLabel(key) text, complex := argumentValueText(value) if strings.TrimSpace(text) == "" { return "" } if complex || shouldUseCodeBlock(key, text) { return "" + EscapeHTML(label) + ": " + CodeBlockHTML(languageForArgument(key, text), text) } return FieldHTML(label, text) } func argumentLabel(key string) string { key = strings.TrimSpace(key) if key == "" { return "Argument" } switch strings.ToLower(key) { case "cwd": return "CWD" case "cmd": return "cmd" } label := strings.ReplaceAll(key, "_", " ") return strings.ToUpper(label[:1]) + label[1:] } func argumentValueText(value any) (string, bool) { switch v := value.(type) { case string: return v, false case float64: return strconv.FormatFloat(v, 'f', -1, 64), false case bool: return strconv.FormatBool(v), false case nil: return "", false default: return compactPrettyJSON(v), true } } func shouldUseCodeBlock(key, text string) bool { key = strings.ToLower(key) if strings.Contains(text, "\n") { return true } for _, marker := range []string{"command", "cmd", "script", "code", "content", "config", "patch"} { if strings.Contains(key, marker) { return true } } return false } func languageForArgument(key, text string) string { key = strings.ToLower(key) switch { case strings.Contains(key, "command") || strings.Contains(key, "cmd") || strings.Contains(key, "script"): return "bash" case strings.Contains(key, "config") || looksLikeJSON(text): return "json" case strings.Contains(key, "patch"): return "diff" case strings.Contains(key, "code") || strings.Contains(key, "content"): return languageForContent(text) default: return "text" } } func languageForContent(text string) string { trimmed := strings.TrimSpace(text) switch { case looksLikeJSON(trimmed): return "json" case strings.HasPrefix(trimmed, "package ") || strings.Contains(trimmed, "func "): return "go" case strings.Contains(trimmed, "#!/bin/sh") || strings.Contains(trimmed, "#!/usr/bin/env bash"): return "bash" case strings.Contains(trimmed, "apiVersion:") || strings.Contains(trimmed, "kind:"): return "yaml" default: return "text" } } func looksLikeJSON(text string) bool { text = strings.TrimSpace(text) return (strings.HasPrefix(text, "{") && strings.HasSuffix(text, "}")) || (strings.HasPrefix(text, "[") && strings.HasSuffix(text, "]")) } func compactPrettyJSON(value any) string { data, err := json.MarshalIndent(value, "", " ") if err != nil { return fmt.Sprint(value) } return string(data) } func nonEmptyHTML(parts []string) []string { out := make([]string, 0, len(parts)) for _, part := range parts { if strings.TrimSpace(part) != "" { out = append(out, part) } } return out } func joinNonEmpty(lines ...string) string { out := make([]string, 0, len(lines)) for _, line := range lines { if strings.TrimSpace(line) != "" { out = append(out, line) } } return strings.Join(out, "\n") } func toolDisplayName(namespace, tool string) string { if namespace == "" { return tool } if tool == "" { return namespace } return namespace + "." + tool } func truncateForStatus(text string) string { runes := []rune(text) if len(runes) <= 1200 { return text } return string(runes[:1200]) + "\n..." } func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event) error { switch event.Method { case "error": var params struct { ThreadID string `json:"threadId"` TurnID string `json:"turnId"` WillRetry bool `json:"willRetry"` Error struct { Message string `json:"message"` AdditionalDetails string `json:"additionalDetails"` } `json:"error"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } if params.ThreadID != "" && !params.WillRetry { if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil { _ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.TurnID) } if !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { return nil } message := "Codex error" if params.Error.Message != "" { message += ": " + params.Error.Message } if params.Error.AdditionalDetails != "" { message += "\n" + params.Error.AdditionalDetails } if err := b.flushAssistantMessage(ctx, params.ThreadID); err != nil { return err } if err := b.sendOutputBlock(ctx, params.ThreadID, message); err != nil { return err } b.clearOutput(params.ThreadID) return nil } case "item/started": var params struct { ThreadID string `json:"threadId"` TurnID string `json:"turnId"` Item json.RawMessage `json:"item"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { return nil } item, err := parseCodexThreadItem(params.Item) if err != nil { return err } if item.Type == "agentMessage" && b.hasAssistantText(params.ThreadID) { return b.flushAssistantMessage(ctx, params.ThreadID) } if b.shouldSuppressPictureToolMessage(params.ThreadID, item) { return nil } return b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemStarted(item)) case "item/agentMessage/delta": var params struct { ThreadID string `json:"threadId"` TurnID string `json:"turnId"` Delta string `json:"delta"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } if params.ThreadID != "" && params.Delta != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { return b.appendAssistantDelta(ctx, params.ThreadID, params.Delta) } case "item/completed": var params struct { ThreadID string `json:"threadId"` TurnID string `json:"turnId"` Item json.RawMessage `json:"item"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { return nil } item, err := parseCodexThreadItem(params.Item) if err != nil { return err } if item.Type == "agentMessage" { if item.Text != "" && !b.hasAssistantText(params.ThreadID) { if err := b.appendAssistantDelta(ctx, params.ThreadID, item.Text); err != nil { return err } } return b.flushAssistantMessage(ctx, params.ThreadID) } if b.queuePictureImageOutput(params.ThreadID, item) { return nil } 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 "item/guardianApprovalReview/started", "item/guardianApprovalReview/completed": var params struct { ThreadID string `json:"threadId"` TurnID string `json:"turnId"` ReviewID string `json:"reviewId"` TargetItemID *string `json:"targetItemId"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } targetItemID := "" if params.TargetItemID != nil { targetItemID = *params.TargetItemID } b.logger.Printf("codex guardian approval review: method=%s thread=%s turn=%s review=%s target=%s", event.Method, params.ThreadID, params.TurnID, params.ReviewID, targetItemID) if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { title := "Codex approval auto-review started" if event.Method == "item/guardianApprovalReview/completed" { title = "Codex approval auto-review completed" } return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderGuardianReviewHTML(title, event.Params)) } case "guardianWarning": var params struct { ThreadID string `json:"threadId"` Message string `json:"message"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } b.logger.Printf("codex guardian warning: thread=%s message=%q", params.ThreadID, truncateForStatus(params.Message)) if params.ThreadID != "" && b.hasOutputThread(params.ThreadID) { return b.sendOutputBlock(ctx, params.ThreadID, "Codex warning: "+params.Message) } case "turn/diff/updated": var params struct { ThreadID string `json:"threadId"` TurnID string `json:"turnId"` Diff string `json:"diff"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) { b.mu.Lock() b.diffs[params.ThreadID] = params.Diff b.mu.Unlock() } case "turn/completed": var params struct { ThreadID string `json:"threadId"` Turn struct { ID string `json:"id"` Status string `json:"status"` } `json:"turn"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } if params.ThreadID != "" { if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil { _ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.Turn.ID) _ = b.store.TouchThread(ctx, params.ThreadID) } if !b.shouldHandleOutputEvent(params.ThreadID, params.Turn.ID) { return nil } return b.completeTurnOutput(ctx, params.ThreadID) } case "thread/name/updated": var params struct { ThreadID string `json:"threadId"` ThreadName *string `json:"threadName"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } if params.ThreadID != "" { title := "" if params.ThreadName != nil { title = normalizeThreadTitle(*params.ThreadName) } return b.store.SyncThreadTitleByCodexID(ctx, params.ThreadID, title) } case "thread/settings/updated": var params struct { ThreadID string `json:"threadId"` ThreadSettings struct { CWD string `json:"cwd"` } `json:"threadSettings"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } if params.ThreadID != "" { return b.syncThreadWorkspaceFromCWD(ctx, params.ThreadID, params.ThreadSettings.CWD) } case "serverRequest/resolved": var params struct { ThreadID string `json:"threadId"` RequestID string `json:"requestId"` } _ = json.Unmarshal(event.Params, ¶ms) } return nil } func (b *Bot) syncThreadWorkspaceFromCWD(ctx context.Context, codexThreadID, cwd string) error { thread, err := b.store.GetThreadByCodexID(ctx, codexThreadID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil } return err } workspace, ok, err := b.workspaceForCodexCWD(ctx, cwd) if err != nil || !ok { return err } if workspace.ID == thread.WorkspaceID { return nil } if err := b.store.SyncThreadWorkspace(ctx, thread.TelegramUserID, thread.ID, workspace.ID); err != nil { return err } session, err := b.store.GetSession(ctx, thread.TelegramUserID) if err != nil { return err } if session.ActiveThreadID == thread.ID { return b.store.SetSessionWorkspace(ctx, thread.TelegramUserID, workspace.ID) } return nil } func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event) error { if event.ID == nil { return errors.New("server request missing id") } switch event.Method { case "item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval": case "execCommandApproval", "applyPatchApproval": default: return fmt.Errorf("unsupported Codex server request: %s", event.Method) } var params struct { ThreadID string `json:"threadId"` ConversationID string `json:"conversationId"` TurnID string `json:"turnId"` ItemID string `json:"itemId"` CallID string `json:"callId"` ApprovalID string `json:"approvalId"` Reason string `json:"reason"` } if err := json.Unmarshal(event.Params, ¶ms); err != nil { return err } threadID := firstNonEmpty(params.ThreadID, params.ConversationID) if threadID == "" { return errors.New("approval request missing threadId") } itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID) b.logger.Printf("codex approval request: method=%s request=%s thread=%s turn=%s item=%s approval=%s call=%s", event.Method, event.ID.Key(), threadID, params.TurnID, params.ItemID, params.ApprovalID, params.CallID) thread, err := b.store.GetThreadByCodexID(ctx, threadID) if err != nil { return err } b.logger.Printf("codex approval thread mapped: request=%s telegram_user=%d", event.ID.Key(), thread.TelegramUserID) pretty, _ := json.MarshalIndent(json.RawMessage(event.Params), "", " ") if len(pretty) == 0 { pretty = event.Params } kind := event.Method approval, err := b.store.UpsertPendingApproval(ctx, store.PendingApproval{ TelegramUserID: thread.TelegramUserID, CodexRequestID: event.ID.Key(), CodexThreadID: threadID, TurnID: params.TurnID, ItemID: itemID, Kind: kind, PayloadJSON: string(pretty), }) if err != nil { return err } b.logger.Printf("codex approval stored: request=%s approval_id=%d status=%s item=%s", event.ID.Key(), approval.ID, approval.Status, approval.ItemID) if approval.Status != "pending" { return nil } text := renderApprovalHTML(kind, event.Params, "") markup := approvalMarkupForPayload(approval.ID, event.Params) b.logger.Printf("codex approval render complete: request=%s approval_id=%d text_runes=%d", event.ID.Key(), approval.ID, len([]rune(text))) msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, params.TurnID, itemID, text, markup) if err != nil { return err } b.logger.Printf("codex approval telegram sent: request=%s approval_id=%d chat=%d message=%d", event.ID.Key(), approval.ID, msg.Chat.ID, msg.MessageID) return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID) } func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func (b *Bot) newOutputState(chatID int64, turnID string) *outputState { return &outputState{ turnID: turnID, chatID: chatID, tools: make(map[string]toolMessageState), sentImages: make(map[string]bool), workingIndicatorOff: b.startWorkingIndicator(chatID), } } func (b *Bot) registerOutput(threadID, turnID string, chatID int64) { b.mu.Lock() defer b.mu.Unlock() if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil { state.workingIndicatorOff() } b.outputs[threadID] = b.newOutputState(chatID, turnID) } func (b *Bot) registerPictureOutput(threadID, turnID string, chatID int64) { b.registerOutput(threadID, turnID, chatID) b.mu.Lock() defer b.mu.Unlock() if state := b.outputs[threadID]; state != nil { state.pictureRequest = true } } 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) hasOutputThread(threadID string) bool { b.mu.Lock() defer b.mu.Unlock() return b.outputs[threadID] != nil } 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) { b.mu.Lock() state := b.outputs[threadID] delete(b.outputs, threadID) b.mu.Unlock() if state != nil && state.workingIndicatorOff != nil { state.workingIndicatorOff() } } func (b *Bot) startWorkingIndicator(chatID int64) context.CancelFunc { ctx, cancel := context.WithCancel(context.Background()) draftID := time.Now().UnixNano() go func() { useDraft := true sendDraft := func() bool { return b.tg.SendMessageDraft(ctx, chatID, draftID, "") == nil } sendTyping := func() { if err := b.tg.SendChatAction(ctx, chatID, "typing"); err != nil && ctx.Err() == nil { b.logger.Printf("send typing action: %v", err) } } if !sendDraft() { useDraft = false sendTyping() } draftTicker := time.NewTicker(25 * time.Second) typingTicker := time.NewTicker(4 * time.Second) defer draftTicker.Stop() defer typingTicker.Stop() for { select { case <-ctx.Done(): return case <-draftTicker.C: if useDraft && !sendDraft() { useDraft = false sendTyping() } case <-typingTicker.C: if !useDraft { sendTyping() } } } }() return cancel } func (b *Bot) hasAssistantText(threadID string) bool { b.mu.Lock() defer b.mu.Unlock() state := b.outputs[threadID] return state != nil && state.assistant.Len() > 0 } 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() threadIDs := make([]string, 0, len(b.outputs)) for threadID := range b.outputs { threadIDs = append(threadIDs, threadID) } b.mu.Unlock() for _, threadID := range threadIDs { if err := b.flushAssistantMessage(ctx, threadID); err != nil { b.logger.Printf("flush failed output %s: %v", threadID, err) } if err := b.sendOutputBlock(ctx, threadID, message); err != nil { b.logger.Printf("send failed output %s: %v", threadID, err) } b.clearOutput(threadID) } } func (b *Bot) shouldSuppressPictureToolMessage(threadID string, item codexThreadItemView) bool { b.mu.Lock() defer b.mu.Unlock() state := b.outputs[threadID] return state != nil && state.pictureRequest && item.Type == "imageGeneration" } func (b *Bot) queuePictureImageOutput(threadID string, item codexThreadItemView) bool { if item.Type != "imageGeneration" { return false } b.mu.Lock() defer b.mu.Unlock() state := b.outputs[threadID] if state == nil || !state.pictureRequest { return false } path := strings.TrimSpace(item.SavedPath) if path == "" { return true } if state.sentImages == nil { state.sentImages = make(map[string]bool) } if state.sentImages[path] { return true } state.sentImages[path] = true state.generatedImages = append(state.generatedImages, generatedImageOutput{Path: path}) return true } func (b *Bot) sendImageOutput(ctx context.Context, threadID string, item codexThreadItemView) error { if item.Type != "imageGeneration" || strings.TrimSpace(item.SavedPath) == "" { return nil } path := strings.TrimSpace(item.SavedPath) if !b.markImageOutputPending(threadID, path) { return nil } data, err := os.ReadFile(path) if err != nil { b.logger.Printf("read generated image %s: %v", path, err) return nil } chatID, err := b.outputChatID(ctx, threadID) if err != nil { return nil } caption := "Generated image" if item.Status != "" { caption += ": " + item.Status } if _, err := b.tg.SendPhotoBytes(ctx, chatID, path, data, caption); err != nil { b.logger.Printf("send generated image %s: %v", path, err) return nil } b.markOutputSent(threadID) return nil } func (b *Bot) markImageOutputPending(threadID, path string) bool { b.mu.Lock() defer b.mu.Unlock() state := b.outputs[threadID] if state == nil { return false } if state.sentImages == nil { state.sentImages = make(map[string]bool) } if state.sentImages[path] { return false } state.sentImages[path] = true return true } func (b *Bot) sendOutputBlock(ctx context.Context, threadID, block string) error { block = strings.TrimSpace(block) if block == "" { return nil } if err := b.flushAssistantMessage(ctx, threadID); err != nil { return err } chatID, err := b.outputChatID(ctx, threadID) if err != nil { return nil } if err := b.sendLong(ctx, chatID, block); err != nil { return err } b.markOutputSent(threadID) return nil } func (b *Bot) sendOutputHTMLBlock(ctx context.Context, threadID, htmlText string) error { htmlText = strings.TrimSpace(htmlText) if htmlText == "" { return nil } if err := b.flushAssistantMessage(ctx, threadID); err != nil { return err } chatID, err := b.outputChatID(ctx, threadID) if err != nil { return nil } if err := b.sendHTML(ctx, chatID, htmlText); err != nil { return err } b.markOutputSent(threadID) return nil } func (s toolMessageState) html() string { return FitHTMLMessage(addEditedAtLine(combineToolApprovalHTML(s.toolHTML, s.approvalHTML), s.editedAt), TelegramHTMLMessageLimit) } func combineToolApprovalHTML(toolHTML, approvalHTML string) string { toolHTML = strings.TrimSpace(toolHTML) approvalHTML = strings.TrimSpace(approvalHTML) switch { case toolHTML == "": return approvalHTML case approvalHTML == "": return toolHTML default: return toolHTML + "\n\n" + compactApprovalHTMLForTool(toolHTML, approvalHTML) } } func compactApprovalHTMLForTool(toolHTML, approvalHTML string) string { return removeDuplicateLabeledQuotes(approvalHTML, labelsPresentInToolHTML(toolHTML)) } func labelsPresentInToolHTML(toolHTML string) map[string]bool { labels := make(map[string]bool) for _, label := range []string{"CWD", "Command"} { if strings.Contains(toolHTML, ""+EscapeHTML(label)+":") { labels[label] = true } } return labels } func removeDuplicateLabeledQuotes(htmlText string, labels map[string]bool) string { if len(labels) == 0 || strings.TrimSpace(htmlText) == "" { return htmlText } const open = "
" const close = "
" var out strings.Builder searchFrom := 0 for { start := strings.Index(htmlText[searchFrom:], open) if start < 0 { out.WriteString(htmlText[searchFrom:]) break } start += searchFrom contentStart := start + len(open) end := strings.Index(htmlText[contentStart:], close) if end < 0 { out.WriteString(htmlText[searchFrom:]) break } end += contentStart content := htmlText[contentStart:end] label := quoteLabel(content) if labels[label] { out.WriteString(strings.TrimRight(htmlText[searchFrom:start], "\n")) searchFrom = end + len(close) for strings.HasPrefix(htmlText[searchFrom:], "\n") { searchFrom++ } continue } out.WriteString(htmlText[searchFrom : end+len(close)]) searchFrom = end + len(close) } return strings.TrimSpace(out.String()) } func quoteLabel(content string) string { const boldOpen = "" const labelClose = ":" content = strings.TrimSpace(content) if !strings.HasPrefix(content, boldOpen) { return "" } end := strings.Index(content, labelClose) if end < 0 { return "" } return html.UnescapeString(content[len(boldOpen):end]) } func addEditedAtLine(htmlText, editedAt string) string { htmlText = strings.TrimSpace(htmlText) if htmlText == "" || editedAt == "" { return htmlText } line := FieldHTML("Edited at", editedAt) if strings.Contains(htmlText, line) { return htmlText } for _, marker := range []string{"\n\nCodex requests command approval", "\n\nCodex requests file change approval", "\n\nCodex requests permission approval", "\n\nCodex approval requested"} { if before, after, ok := strings.Cut(htmlText, marker); ok { return addEditedAtToToolHTML(before, line) + marker + after } } return addEditedAtToToolHTML(htmlText, line) } func addEditedAtToToolHTML(htmlText, line string) string { const open = "
" const close = "
" 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, "Exit code:") || strings.Contains(content, "Duration ms:") { 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 { return time.Now().UTC().Format("2006-01-02 15:04:05 MST") } func (b *Bot) upsertToolMessage(ctx context.Context, threadID, turnID, itemID, htmlText string) error { htmlText = strings.TrimSpace(htmlText) if htmlText == "" { return nil } if !b.hasOutputTurn(threadID, turnID) { return nil } if itemID == "" { return b.sendOutputHTMLBlock(ctx, threadID, htmlText) } if err := b.flushAssistantMessage(ctx, threadID); err != nil { return err } chatID, err := b.outputChatID(ctx, threadID) if err != nil { return nil } b.mu.Lock() state := b.outputs[threadID] if state != nil && state.tools == nil { state.tools = make(map[string]toolMessageState) } if state != nil { tool, ok := state.tools[itemID] if ok && tool.messageID != 0 { tool.toolHTML = htmlText tool.editedAt = editedAtTimestamp() state.tools[itemID] = tool combined := tool.html() msgChatID := tool.chatID msgID := tool.messageID markup := tool.approvalMarkup b.mu.Unlock() _, err := b.tg.EditMessageText(ctx, msgChatID, msgID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: editReplyMarkup(markup)}) if err := ignoreTelegramMessageNotModified(err); err != nil { return err } b.markOutputSent(threadID) return nil } } b.mu.Unlock() msg, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil) if err != nil { return err } b.mu.Lock() state = b.outputs[threadID] if state != nil { if state.tools == nil { state.tools = make(map[string]toolMessageState) } state.tools[itemID] = toolMessageState{chatID: msg.Chat.ID, messageID: msg.MessageID, toolHTML: htmlText} } b.mu.Unlock() b.markOutputSent(threadID) return nil } func (b *Bot) upsertApprovalMessage(ctx context.Context, chatID int64, threadID, turnID, itemID, approvalHTML string, markup *InlineKeyboardMarkup) (Message, error) { approvalHTML = strings.TrimSpace(approvalHTML) if approvalHTML == "" { return Message{}, errors.New("approval message is empty") } b.logger.Printf("codex approval ui upsert start: thread=%s turn=%s item=%s chat=%d text_runes=%d", threadID, turnID, itemID, chatID, len([]rune(approvalHTML))) trackedTurn := threadID != "" && itemID != "" && b.hasOutputTurn(threadID, turnID) if !trackedTurn { b.logger.Printf("codex approval ui direct send start: thread=%s turn=%s item=%s chat=%d", threadID, turnID, itemID, chatID) msg, err := b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup}) if err != nil { b.logger.Printf("codex approval ui direct send failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, err) return Message{}, err } b.logger.Printf("codex approval ui direct send done: thread=%s turn=%s item=%s chat=%d message=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID) return msg, nil } b.logger.Printf("codex approval ui flush assistant start: thread=%s turn=%s item=%s", threadID, turnID, itemID) if err := b.flushAssistantMessage(ctx, threadID); err != nil { b.logger.Printf("codex approval ui flush assistant failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, err) return Message{}, err } b.logger.Printf("codex approval ui flush assistant done: thread=%s turn=%s item=%s", threadID, turnID, itemID) trackedChatID, err := b.outputChatID(ctx, threadID) if err != nil { b.logger.Printf("codex approval ui output state missing; direct send start: thread=%s turn=%s item=%s chat=%d err=%v", threadID, turnID, itemID, chatID, err) msg, sendErr := b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup}) if sendErr != nil { b.logger.Printf("codex approval ui output-state direct send failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, sendErr) return Message{}, sendErr } b.logger.Printf("codex approval ui output-state direct send done: thread=%s turn=%s item=%s chat=%d message=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID) return msg, nil } chatID = trackedChatID b.mu.Lock() state := b.outputs[threadID] if state != nil && state.tools == nil { state.tools = make(map[string]toolMessageState) } if state != nil { tool, ok := state.tools[itemID] if ok && tool.messageID != 0 { tool.approvalHTML = approvalHTML tool.approvalMarkup = markup tool.editedAt = editedAtTimestamp() state.tools[itemID] = tool combined := tool.html() msg := Message{MessageID: tool.messageID, Chat: Chat{ID: tool.chatID}} b.mu.Unlock() b.logger.Printf("codex approval ui edit start: thread=%s turn=%s item=%s chat=%d message=%d text_runes=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID, len([]rune(combined))) _, err := b.tg.EditMessageText(ctx, msg.Chat.ID, msg.MessageID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: editReplyMarkup(markup)}) if err := ignoreTelegramMessageNotModified(err); err != nil { b.clearToolApproval(threadID, itemID) b.logger.Printf("codex approval ui edit failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, err) return Message{}, err } b.logger.Printf("codex approval ui edit done: thread=%s turn=%s item=%s chat=%d message=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID) b.markOutputSent(threadID) return msg, nil } } b.mu.Unlock() b.logger.Printf("codex approval ui new combined message start: thread=%s turn=%s item=%s chat=%d", threadID, turnID, itemID, chatID) msg, err := b.sendHTMLMessage(ctx, chatID, approvalHTML, markup) if err != nil { b.logger.Printf("codex approval ui new combined message failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, err) return Message{}, err } b.mu.Lock() state = b.outputs[threadID] if state != nil { if state.tools == nil { state.tools = make(map[string]toolMessageState) } state.tools[itemID] = toolMessageState{chatID: msg.Chat.ID, messageID: msg.MessageID, approvalHTML: approvalHTML, approvalMarkup: markup} } b.mu.Unlock() b.logger.Printf("codex approval ui new combined message done: thread=%s turn=%s item=%s chat=%d message=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID) b.markOutputSent(threadID) return msg, nil } func (b *Bot) clearToolApproval(threadID, itemID string) { b.mu.Lock() defer b.mu.Unlock() if state := b.outputs[threadID]; state != nil { if tool, ok := state.tools[itemID]; ok { tool.approvalHTML = "" tool.approvalMarkup = nil state.tools[itemID] = tool } } } func (b *Bot) resolveApprovalMessageHTML(approval store.PendingApproval, decision string) string { approvalHTML := renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), approvalStatusLine(decision)) if approval.ItemID == "" { return approvalHTML } b.mu.Lock() defer b.mu.Unlock() state := b.outputs[approval.CodexThreadID] if state == nil { return approvalHTML } tool, ok := state.tools[approval.ItemID] if !ok || tool.messageID == 0 || tool.messageID != approval.MessageID { return approvalHTML } tool.approvalHTML = approvalHTML tool.approvalMarkup = nil tool.editedAt = editedAtTimestamp() state.tools[approval.ItemID] = tool return tool.html() } func ignoreTelegramMessageNotModified(err error) error { if err != nil && strings.Contains(err.Error(), "message is not modified") { return nil } return err } func splitAssistantMessageSegments(text string) []assistantMessageSegment { var segments []assistantMessageSegment var visible strings.Builder flushVisible := func() { if visible.Len() == 0 { return } segments = append(segments, assistantMessageSegment{Text: visible.String()}) visible.Reset() } for _, line := range strings.SplitAfter(text, "\n") { body := strings.TrimSuffix(line, "\n") body = strings.TrimSuffix(body, "\r") if directive, ok := parseAssistantPhotoDirectiveLine(body); ok { flushVisible() segments = append(segments, assistantMessageSegment{Photo: &directive}) continue } if directive, ok := parseAssistantThreadRenameDirectiveLine(body); ok { flushVisible() segments = append(segments, assistantMessageSegment{ThreadRename: &directive}) continue } if directive, ok := parseAssistantThreadCWDDirectiveLine(body); ok { flushVisible() segments = append(segments, assistantMessageSegment{ThreadCWD: &directive}) continue } visible.WriteString(line) } flushVisible() return segments } func parseAssistantThreadRenameDirectiveLine(line string) (assistantThreadRenameDirective, bool) { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, telegramThreadRenameDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) { return assistantThreadRenameDirective{}, false } raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramThreadRenameDirectiveStart), telegramDirectiveEnd) raw = strings.TrimSpace(raw) var directive assistantThreadRenameDirective if err := json.Unmarshal([]byte(raw), &directive); err != nil { return assistantThreadRenameDirective{}, false } directive.Title = normalizeThreadTitle(directive.Title) return directive, true } func parseAssistantThreadCWDDirectiveLine(line string) (assistantThreadCWDDirective, bool) { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, telegramThreadCWDDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) { return assistantThreadCWDDirective{}, false } raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramThreadCWDDirectiveStart), telegramDirectiveEnd) raw = strings.TrimSpace(raw) var directive assistantThreadCWDDirective if err := json.Unmarshal([]byte(raw), &directive); err != nil { return assistantThreadCWDDirective{}, false } directive.CWD = strings.TrimSpace(directive.CWD) return directive, true } func parseAssistantPhotoDirectiveLine(line string) (assistantPhotoDirective, bool) { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, telegramPhotoDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) { return assistantPhotoDirective{}, false } raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramPhotoDirectiveStart), telegramDirectiveEnd) raw = strings.TrimSpace(raw) var directive assistantPhotoDirective if err := json.Unmarshal([]byte(raw), &directive); err != nil { return assistantPhotoDirective{}, false } directive.Path = strings.TrimSpace(directive.Path) directive.Caption = strings.TrimSpace(directive.Caption) return directive, true } func (b *Bot) sendAssistantText(ctx context.Context, threadID string, chatID int64, text string) error { for _, segment := range splitAssistantMessageSegments(text) { if segment.Text != "" && strings.TrimSpace(segment.Text) != "" { if err := b.sendLong(ctx, chatID, segment.Text); err != nil { return err } } if segment.Photo != nil { if err := b.sendAssistantPhoto(ctx, chatID, *segment.Photo); err != nil { b.logger.Printf("send assistant photo: %v", err) if sendErr := b.sendLong(ctx, chatID, "Could not send photo: "+err.Error()); sendErr != nil { return sendErr } } } if segment.ThreadRename != nil { if err := b.applyAssistantThreadRename(ctx, threadID, *segment.ThreadRename); err != nil { b.logger.Printf("apply assistant thread rename: %v", err) if sendErr := b.sendLong(ctx, chatID, "Could not rename thread: "+err.Error()); sendErr != nil { return sendErr } } } if segment.ThreadCWD != nil { if err := b.applyAssistantThreadCWD(ctx, threadID, *segment.ThreadCWD); err != nil { b.logger.Printf("apply assistant thread cwd: %v", err) if sendErr := b.sendLong(ctx, chatID, "Could not change thread cwd: "+err.Error()); sendErr != nil { return sendErr } } } } return nil } func (b *Bot) applyAssistantThreadRename(ctx context.Context, threadID string, directive assistantThreadRenameDirective) error { title := normalizeThreadTitle(directive.Title) if title == "" { return errors.New("thread title cannot be empty") } if err := b.codex.SetThreadName(ctx, threadID, title); err != nil { return err } return b.store.SyncThreadTitleByCodexID(ctx, threadID, title) } func (b *Bot) applyAssistantThreadCWD(ctx context.Context, threadID string, directive assistantThreadCWDDirective) error { cwd, err := store.ValidateWorkspacePath(directive.CWD) if err != nil { return err } if _, err := codexstate.SetThreadCWD(ctx, b.codexHome, b.codexStateDB, threadID, cwd); err != nil { return err } workspace, ok, err := b.workspaceForCodexCWD(ctx, cwd) if err != nil { return err } if !ok { return nil } thread, err := b.store.GetThreadByCodexID(ctx, threadID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil } return err } if err := b.store.SyncThreadWorkspace(ctx, thread.TelegramUserID, thread.ID, workspace.ID); err != nil { return err } return b.store.SetSessionWorkspace(ctx, thread.TelegramUserID, workspace.ID) } func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive assistantPhotoDirective) error { path := strings.TrimSpace(directive.Path) if path == "" { return errors.New("photo directive is missing a path") } if !filepath.IsAbs(path) { return fmt.Errorf("photo path must be absolute: %s", path) } if !isPicturePath(path) { return fmt.Errorf("unsupported photo type: %s", filepath.Base(path)) } data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read %s: %v", filepath.Base(path), err) } caption := truncateTelegramPhotoCaption(directive.Caption) if _, err := b.tg.SendPhotoBytes(ctx, chatID, path, data, caption); err != nil { return fmt.Errorf("send %s: %v", filepath.Base(path), err) } return nil } func truncateTelegramPhotoCaption(caption string) string { runes := []rune(caption) if len(runes) <= telegramPhotoCaptionLimit { return caption } if telegramPhotoCaptionLimit <= 3 { return string(runes[:telegramPhotoCaptionLimit]) } return string(runes[:telegramPhotoCaptionLimit-3]) + "..." } func (b *Bot) appendAssistantDelta(ctx context.Context, threadID, delta string) error { if delta == "" { return nil } if _, err := b.outputChatID(ctx, threadID); err != nil { return nil } b.mu.Lock() state := b.outputs[threadID] if state != nil { _, _ = state.assistant.WriteString(delta) } b.mu.Unlock() return nil } func (b *Bot) flushAssistantMessage(ctx context.Context, threadID string) error { b.mu.Lock() state := b.outputs[threadID] if state == nil || state.assistant.Len() == 0 { b.mu.Unlock() return nil } chatID := state.chatID text := state.assistant.String() pictureRequest := state.pictureRequest state.assistant.Reset() b.mu.Unlock() if pictureRequest { return nil } if err := b.sendAssistantText(ctx, threadID, chatID, text); err != nil { return err } b.markOutputSent(threadID) return nil } func (b *Bot) completeTurnOutput(ctx context.Context, threadID string) error { if err := b.flushAssistantMessage(ctx, threadID); err != nil { return err } b.mu.Lock() state := b.outputs[threadID] if state == nil { b.mu.Unlock() return nil } chatID := state.chatID sentAny := state.sentAny pictureRequest := state.pictureRequest generatedImages := append([]generatedImageOutput(nil), state.generatedImages...) workingIndicatorOff := state.workingIndicatorOff delete(b.outputs, threadID) b.mu.Unlock() if workingIndicatorOff != nil { workingIndicatorOff() } if pictureRequest { if len(generatedImages) == 0 { _, err := b.tg.SendMessage(ctx, chatID, "No image was generated.", SendMessageOptions{}) return err } return b.sendGeneratedImageOutputs(ctx, chatID, generatedImages) } if !sentAny { _, err := b.tg.SendMessage(ctx, chatID, "Done.", SendMessageOptions{}) return err } return nil } func (b *Bot) sendGeneratedImageOutputs(ctx context.Context, chatID int64, images []generatedImageOutput) error { uploads := make([]PhotoUpload, 0, len(images)) for _, image := range images { path := strings.TrimSpace(image.Path) if path == "" { continue } data, err := os.ReadFile(path) if err != nil { b.logger.Printf("read generated image %s: %v", path, err) continue } uploads = append(uploads, PhotoUpload{Filename: path, Data: data}) } if len(uploads) == 0 { _, err := b.tg.SendMessage(ctx, chatID, "Generated image file was not readable by the bot.", SendMessageOptions{}) return err } for len(uploads) > 0 { count := len(uploads) if count > pictureMediaGroupLimit { count = pictureMediaGroupLimit } if _, err := b.tg.SendPhotoGroupBytes(ctx, chatID, uploads[:count]); err != nil { return err } uploads = uploads[count:] } return nil } func (b *Bot) outputChatID(ctx context.Context, threadID string) (int64, error) { b.mu.Lock() state := b.outputs[threadID] if state != nil { chatID := state.chatID b.mu.Unlock() return chatID, nil } b.mu.Unlock() return 0, sql.ErrNoRows } func (b *Bot) markOutputSent(threadID string) { b.mu.Lock() defer b.mu.Unlock() if state := b.outputs[threadID]; state != nil { state.sentAny = true } } func (b *Bot) sendLong(ctx context.Context, chatID int64, text string) error { for _, chunk := range ChunkText(text, TelegramHTMLMessageLimit) { if err := b.sendHTML(ctx, chatID, EscapeHTML(chunk)); err != nil { return err } } return nil } func (b *Bot) sendHTML(ctx context.Context, chatID int64, htmlText string) error { _, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil) return err } func (b *Bot) sendHTMLMessage(ctx context.Context, chatID int64, htmlText string, markup *InlineKeyboardMarkup) (Message, error) { return b.tg.SendMessage(ctx, chatID, htmlText, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup}) } func (b *Bot) sendError(ctx context.Context, chatID int64, prefix string, err error) error { _, sendErr := b.tg.SendMessage(ctx, chatID, EscapeHTML(prefix+": "+err.Error()), SendMessageOptions{ParseMode: "HTML"}) return sendErr } func (b *Bot) sendWorkspaceMissing(ctx context.Context, chatID int64) error { _, err := b.tg.SendMessage(ctx, chatID, "No workspace configured. Ask an admin to run scripts/add-workspace, then use /workspace.", SendMessageOptions{}) return err } func (b *Bot) sendNoActiveThread(ctx context.Context, chatID int64, err error) error { if errors.Is(err, sql.ErrNoRows) { _, sendErr := b.tg.SendMessage(ctx, chatID, "No active thread. Use /new.", SendMessageOptions{}) return sendErr } return err } func parseCommand(text string) (string, []string, bool) { text = strings.TrimSpace(text) if !strings.HasPrefix(text, "/") { return "", nil, false } fields := strings.Fields(text) if len(fields) == 0 { return "", nil, false } name := strings.TrimPrefix(fields[0], "/") if before, _, ok := strings.Cut(name, "@"); ok { name = before } return strings.ToLower(name), fields[1:], true } func resumeThreadListText(threads []store.Thread, page int) string { lines := []string{fmt.Sprintf("Threads (page %d):", page+1), ""} for _, thread := range threads { lines = append(lines, fmt.Sprintf("Thread ID %d: %s", thread.ID, threadDisplayTitle(thread))) } lines = append(lines, "", "Choose a button below, or use /thread THREAD_ID directly.") return strings.Join(lines, "\n") } func resumeThreadMarkup(threads []store.Thread, page int, hasNext bool) *InlineKeyboardMarkup { keyboard := make([][]InlineKeyboardButton, 0, 4) for _, thread := range threads { button := InlineKeyboardButton{ Text: fmt.Sprintf("ID %d", thread.ID), CallbackData: ResumeThreadCallbackData(thread.ID), } if len(keyboard) == 0 || len(keyboard[len(keyboard)-1]) >= 4 { keyboard = append(keyboard, []InlineKeyboardButton{button}) continue } keyboard[len(keyboard)-1] = append(keyboard[len(keyboard)-1], button) } var nav []InlineKeyboardButton if page > 0 { nav = append(nav, InlineKeyboardButton{Text: "Prev", CallbackData: ResumePageCallbackData(page - 1)}) } if hasNext { nav = append(nav, InlineKeyboardButton{Text: "Next", CallbackData: ResumePageCallbackData(page + 1)}) } if len(nav) > 0 { keyboard = append(keyboard, nav) } return &InlineKeyboardMarkup{InlineKeyboard: keyboard} } func normalizeThreadTitle(title string) string { title = strings.Join(strings.Fields(title), " ") runes := []rune(title) if len(runes) > 80 { title = string(runes[:80]) } return title } func threadDisplayTitle(thread store.Thread) string { title := strings.Join(strings.Fields(thread.Title), " ") if title == "" { title = thread.CodexThreadID } runes := []rune(title) if len(runes) > 90 { title = string(runes[:90]) + "..." } return title } func workspaceMarkup(workspaces []store.Workspace) *InlineKeyboardMarkup { keyboard := make([][]InlineKeyboardButton, 0, len(workspaces)) for _, ws := range workspaces { text := ws.Label if ws.IsDefault { text += " default" } keyboard = append(keyboard, []InlineKeyboardButton{{ Text: text, CallbackData: WorkspaceCallbackData(ws.ID), }}) } return &InlineKeyboardMarkup{InlineKeyboard: keyboard} } func modelMarkup(models []codexapp.Model) *InlineKeyboardMarkup { keyboard := make([][]InlineKeyboardButton, 0, len(models)) for _, model := range models { callbackData, ok := ModelCallbackData(model.ID) if !ok { continue } keyboard = append(keyboard, []InlineKeyboardButton{{ Text: modelLabel(model), CallbackData: callbackData, }}) } return &InlineKeyboardMarkup{InlineKeyboard: keyboard} } func effortMarkup(model codexapp.Model) *InlineKeyboardMarkup { keyboard := make([][]InlineKeyboardButton, 0, len(model.SupportedReasoningEfforts)) for _, option := range model.SupportedReasoningEfforts { label := option.ReasoningEffort if option.ReasoningEffort == model.DefaultReasoningEffort { label += " default" } keyboard = append(keyboard, []InlineKeyboardButton{{ Text: label, CallbackData: EffortCallbackData(option.ReasoningEffort), }}) } return &InlineKeyboardMarkup{InlineKeyboard: keyboard} } func sandboxMarkup(current string) *InlineKeyboardMarkup { options := []struct { Value string Label string }{ {Value: "read-only", Label: "Read only"}, {Value: "workspace-write", Label: "Workspace write"}, {Value: "danger-full-access", Label: "Danger full access"}, } keyboard := make([][]InlineKeyboardButton, 0, len(options)) for _, option := range options { label := option.Label if option.Value == current { label += " current" } keyboard = append(keyboard, []InlineKeyboardButton{{ Text: label, CallbackData: SandboxCallbackData(option.Value), }}) } return &InlineKeyboardMarkup{InlineKeyboard: keyboard} } func sandboxStatusText(sandbox string) string { if strings.TrimSpace(sandbox) == "" { sandbox = "(Codex default)" } return "Current sandbox: " + sandbox } func modelSupportsEffort(model codexapp.Model, effort string) bool { for _, option := range model.SupportedReasoningEfforts { if option.ReasoningEffort == effort { return true } } return false } func modelLabel(model codexapp.Model) string { label := model.DisplayName if label == "" { label = model.ID } if model.IsDefault { label += " default" } return label } func sessionModelLabel(models []codexapp.Model, modelID string) string { if modelID == "" { return "(Codex default)" } for _, model := range models { if model.ID == modelID { return modelLabel(model) } } return modelID } func settingsStatusText(model string, effort string) string { if effort == "" { effort = "(model default)" } return fmt.Sprintf("Current model: %s\nCurrent reasoning effort: %s", model, effort) } func (b *Bot) rememberSettingsMessage(ctx context.Context, userID int64, chatID int64, messageID int) { if messageID == 0 { return } if err := b.store.SetSessionSettingsMessage(ctx, userID, chatID, messageID); err != nil { b.logger.Printf("settings message store: %v", err) return } if err := b.tg.PinChatMessage(ctx, chatID, messageID, true); err != nil { b.logger.Printf("settings message pin: %v", err) } } func clearInlineKeyboardMarkup() *InlineKeyboardMarkup { return &InlineKeyboardMarkup{InlineKeyboard: [][]InlineKeyboardButton{}} } func editReplyMarkup(markup *InlineKeyboardMarkup) *InlineKeyboardMarkup { if markup != nil { return markup } return clearInlineKeyboardMarkup() } type approvalDecisionOption struct { Key string Label string Approves bool } func approvalMarkup(id int64) *InlineKeyboardMarkup { return approvalMarkupForOptions(id, nil) } func approvalMarkupForPayload(id int64, raw json.RawMessage) *InlineKeyboardMarkup { return approvalMarkupForOptions(id, approvalDecisionOptions(raw)) } func approvalDecisionOptions(raw json.RawMessage) []approvalDecisionOption { var params struct { AvailableDecisions []json.RawMessage `json:"availableDecisions"` } if err := json.Unmarshal(raw, ¶ms); 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 { if isLegacyApprovalKind(approval.Kind) { 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" { return map[string]any{"decision": decision} } scope := "turn" if decision == "acceptForSession" { scope = "session" } permissions := map[string]any{} if decision == "accept" || decision == "acceptForSession" { var params struct { Permissions map[string]any `json:"permissions"` } _ = json.Unmarshal([]byte(approval.PayloadJSON), ¶ms) if params.Permissions != nil { permissions = params.Permissions } } return map[string]any{ "permissions": permissions, "scope": scope, } } 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, ¶ms); 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, ¶ms); 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 { switch kind { case "execCommandApproval", "applyPatchApproval": return true default: return false } } func legacyApprovalDecision(decision string) string { switch decision { case "accept": return "approved" case "acceptForSession": return "approved_for_session" case "decline": return "denied" case "cancel": return "abort" default: return decision } } func renderApprovalHTML(kind string, raw json.RawMessage, status string) string { title := "Codex approval requested" if strings.Contains(kind, "commandExecution") || kind == "execCommandApproval" { title = "Codex requests command approval" } if strings.Contains(kind, "fileChange") || kind == "applyPatchApproval" { title = "Codex requests file change approval" } if strings.Contains(kind, "permissions") { title = "Codex requests permission approval" } var params map[string]any _ = json.Unmarshal(raw, ¶ms) lines := []string{title} if reason, _ := params["reason"].(string); reason != "" { lines = append(lines, "", reason) } summary := strings.Join(lines, "\n") sections := renderApprovalDetailSectionsHTML(kind, raw) limit := TelegramHTMLMessageLimit if status != "" { limit -= len([]rune(status)) + 1 } text := SummaryRawHTMLSectionsLimited(summary, sections, limit) if status != "" { text += "\n" + EscapeHTML(status) } return text } 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 if err := json.Unmarshal(raw, ¶ms); err != nil { return []string{CodeBlockHTML("json", string(raw))} } parts := renderApprovalPayloadDetailsHTML(raw, params) if len(parts) == 0 { return []string{CodeBlockHTML("json", prettyJSON(raw))} } return nonEmptyHTML(parts) } func renderApprovalPayloadDetailsHTML(raw json.RawMessage, params map[string]any) []string { var parts []string appendPart := func(part string) { if strings.TrimSpace(part) != "" { parts = append(parts, part) } } appendPart(renderApprovalFieldHTML("cwd", params["cwd"])) appendPart(renderApprovalFieldHTML("command", params["command"])) appendPart(renderApprovalFieldHTML("parsedCmd", params["parsedCmd"])) appendPart(renderApprovalFieldHTML("additionalPermissions", params["additionalPermissions"])) appendPart(renderNetworkApprovalContextHTML(params["networkApprovalContext"])) appendPart(renderExecpolicyAmendmentHTML(params["proposedExecpolicyAmendment"])) appendPart(renderNetworkPolicyAmendmentsHTML(params["proposedNetworkPolicyAmendments"])) appendPart(renderAvailableDecisionsHTML(raw)) appendPart(renderApprovalFieldHTML("grantRoot", params["grantRoot"])) appendPart(renderApprovalFieldHTML("permissions", params["permissions"])) appendPart(renderApprovalFieldHTML("fileChanges", params["fileChanges"])) return parts } func renderApprovalFieldHTML(key string, value any) string { if strings.EqualFold(key, "reason") { return "" } return renderArgumentFieldHTML(key, value) } func renderExecpolicyAmendmentHTML(value any) string { items := stringSliceValue(value) if len(items) == 0 { return "" } return "Proposed command rule: " + CodeBlockHTML("bash", strings.Join(items, " ")) } func renderNetworkPolicyAmendmentsHTML(value any) string { entries, ok := value.([]any) if !ok || len(entries) == 0 { return "" } var lines []string for _, entry := range entries { object, ok := entry.(map[string]any) if !ok { continue } action := strings.TrimSpace(fmt.Sprint(object["action"])) host := strings.TrimSpace(fmt.Sprint(object["host"])) if action == "" && host == "" { continue } lines = append(lines, strings.TrimSpace(strings.Join(nonEmptyStrings(action, host), " "))) } if len(lines) == 0 { return "" } return FieldHTML("Proposed network rules", strings.Join(lines, "\n")) } func renderNetworkApprovalContextHTML(value any) string { object, ok := value.(map[string]any) if !ok || len(object) == 0 { return "" } var fields []string for _, key := range []string{"host", "port", "protocol", "url"} { if text := strings.TrimSpace(fmt.Sprint(object[key])); text != "" && text != "" { fields = append(fields, key+"="+text) } } if len(fields) == 0 { return renderArgumentFieldHTML("networkApprovalContext", value) } return FieldHTML("Network request", strings.Join(fields, " ")) } func renderAvailableDecisionsHTML(raw json.RawMessage) string { options := approvalDecisionOptions(raw) if len(options) == 0 { return "" } seen := make(map[string]bool, len(options)) var labels []string for _, option := range options { label := strings.TrimSpace(option.Label) if label == "" || seen[label] { continue } labels = append(labels, label) seen[label] = true } if len(labels) == 0 { return "" } return FieldHTML("Available decisions", strings.Join(labels, ", ")) } func stringSliceValue(value any) []string { switch v := value.(type) { case []string: return nonEmptyStrings(v...) case []any: var out []string for _, item := range v { text := strings.TrimSpace(fmt.Sprint(item)) if text != "" && text != "" { out = append(out, text) } } return out default: return nil } } func nonEmptyStrings(values ...string) []string { var out []string for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { out = append(out, trimmed) } } return out } func approvalStatusLine(decision string) string { if strings.HasPrefix(decision, "networkPolicy") { return "Applied network rule." } switch decision { case "accept": return "Approved." case "acceptForSession": return "Approved for this session." case "acceptWithExecpolicyAmendment": return "Approved and saved command rule." case "decline": return "Disapproved." case "cancel": return "Canceled." default: return "Resolved: " + decision + "." } } func prettyJSON(raw json.RawMessage) string { if len(raw) == 0 { return "" } var value any if err := json.Unmarshal(raw, &value); err != nil { return string(raw) } data, err := json.MarshalIndent(value, "", " ") if err != nil { return string(raw) } return string(data) } func largestPhoto(photos []PhotoSize) PhotoSize { best := photos[0] bestScore := best.Width * best.Height for _, photo := range photos[1:] { score := photo.Width * photo.Height if score > bestScore { best = photo bestScore = score } } return best } func isImage(mimeType, filename string) bool { if strings.HasPrefix(strings.ToLower(mimeType), "image/") { return true } switch strings.ToLower(filepath.Ext(filename)) { case ".jpg", ".jpeg", ".png", ".gif", ".webp": return true default: return false } } func extensionForMime(mimeType string) string { extensions, err := mime.ExtensionsByType(mimeType) if err != nil || len(extensions) == 0 { return "" } return extensions[0] } func safeFilename(filename string) string { base := filepath.Base(filename) var builder strings.Builder for _, r := range base { switch { case r >= 'a' && r <= 'z': builder.WriteRune(r) case r >= 'A' && r <= 'Z': builder.WriteRune(r) case r >= '0' && r <= '9': builder.WriteRune(r) case r == '.', r == '-', r == '_': builder.WriteRune(r) default: builder.WriteByte('_') } } if builder.Len() == 0 { return "upload" } return builder.String() }