Files
codex-telegram-bot/internal/telegram/bot.go
Codex c00ffb42f2 Route Codex approvals to Telegram
Force app-server turns to use the user approval reviewer so command approvals surface in the bot on Codex 0.134.

Add focused protocol logs for approval requests and guardian review events to diagnose silent approval stalls.
2026-05-28 09:57:43 +00:00

3353 lines
104 KiB
Go

package telegram
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"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 = "<!-- telegram-photo "
telegramThreadRenameDirectiveStart = "<!-- codex-thread-rename "
telegramThreadCWDDirectiveStart = "<!-- codex-thread-cwd "
telegramDirectiveEnd = " -->"
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: "Show or set sandbox"},
{Command: "pic", Description: "Generate images"},
{Command: "diff", Description: "Show latest diff"},
{Command: "help", Description: "Show help"},
}
}
func (b *Bot) handleUpdate(ctx context.Context, update Update) error {
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 [read-only|workspace-write|danger-full-access] - show or set 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 {
if len(args) == 0 {
_, err := b.tg.SendMessage(ctx, chatID, "Current sandbox: "+session.Sandbox, SendMessageOptions{})
return err
}
sandbox, err := codexapp.NormalizeSandbox(args[0])
if err != nil {
_, sendErr := b.tg.SendMessage(ctx, chatID, "Use one of: read-only, workspace-write, danger-full-access.", SendMessageOptions{})
return sendErr
}
if err := b.store.SetSessionSandbox(ctx, userID, sandbox); err != nil {
return err
}
_, err = b.tg.SendMessage(ctx, chatID, "Sandbox set to "+sandbox+".", SendMessageOptions{})
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 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, &params); 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, "<b>Command:</b> "+CodeBlockHTML("bash", command))
}
if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" {
sections = append(sections, "<b>Output:</b> "+CodeBlockHTML("text", *item.AggregatedOutput))
}
var fields []string
if item.ExitCode != nil {
fields = append(fields, FieldHTML("Exit code", strconv.Itoa(*item.ExitCode)))
}
if item.DurationMs != nil {
fields = append(fields, FieldHTML("Duration ms", strconv.FormatInt(*item.DurationMs, 10)))
}
if editedAt != "" {
fields = append(fields, FieldHTML("Edited at", editedAt))
}
if len(fields) > 0 {
sections = append(sections, strings.Join(nonEmptyHTML(fields), "\n"))
}
return nonEmptyHTML(sections)
}
func renderCodexItemDetailsHTML(item codexThreadItemView) string {
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{"<b>Arguments</b>", CodeBlockHTML("json", string(raw))}
}
object, ok := value.(map[string]any)
if !ok {
return []string{"<b>Arguments</b>", CodeBlockHTML("json", compactPrettyJSON(value))}
}
parts := renderSelectedArgumentDetailsHTML(object, preferredArgumentKeys(object))
if len(parts) == 0 && len(object) > 0 {
parts = append(parts, "<b>Arguments</b>", 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 "<b>" + EscapeHTML(label) + ":</b> " + 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params)
}
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, &params); 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
}
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
}
if approval.Status != "pending" {
return nil
}
text := renderApprovalHTML(kind, event.Params, "")
markup := approvalMarkupForPayload(approval.ID, event.Params)
msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, params.TurnID, itemID, text, markup)
if err != nil {
return err
}
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" + approvalHTML
}
}
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 = "<blockquote expandable>"
const close = "</blockquote>"
searchFrom := 0
for {
start := strings.Index(htmlText[searchFrom:], open)
if start < 0 {
break
}
start += searchFrom
contentStart := start + len(open)
end := strings.Index(htmlText[contentStart:], close)
if end < 0 {
break
}
end += contentStart
content := htmlText[contentStart:end]
if strings.Contains(content, "<b>Exit code:</b>") || strings.Contains(content, "<b>Duration ms:</b>") {
insert := strings.TrimRight(content, "\n") + "\n" + line
return htmlText[:contentStart] + insert + htmlText[end:]
}
searchFrom = end + len(close)
}
return htmlText + "\n" + ExpandableQuoteRawHTML(line)
}
func editedAtTimestamp() string {
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")
}
if threadID == "" || itemID == "" || !b.hasOutputTurn(threadID, turnID) {
return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
}
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
return Message{}, err
}
trackedChatID, err := b.outputChatID(ctx, threadID)
if err != nil {
return b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
}
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()
_, 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("edit tool approval message %s/%s: %v", threadID, itemID, err)
return Message{}, err
}
b.markOutputSent(threadID)
return msg, nil
}
}
b.mu.Unlock()
msg, err := b.sendHTMLMessage(ctx, chatID, approvalHTML, markup)
if err != nil {
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.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 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, &params); err != nil || params.AvailableDecisions == nil {
return nil
}
seen := map[string]bool{}
var options []approvalDecisionOption
networkIndex := 0
for _, rawDecision := range params.AvailableDecisions {
if option, ok := stringApprovalDecisionOption(rawDecision); ok {
if !seen[option.Key] {
options = append(options, option)
seen[option.Key] = true
}
continue
}
if option, ok := structuredApprovalDecisionOption(rawDecision, networkIndex); ok {
if strings.HasPrefix(option.Key, "networkPolicy") {
networkIndex++
}
if !seen[option.Key] {
options = append(options, option)
seen[option.Key] = true
}
}
}
return options
}
func stringApprovalDecisionOption(raw json.RawMessage) (approvalDecisionOption, bool) {
var decision string
if err := json.Unmarshal(raw, &decision); err != nil {
return approvalDecisionOption{}, false
}
switch decision {
case "accept":
return approvalDecisionOption{Key: decision, Label: "Approve", Approves: true}, true
case "acceptForSession":
return approvalDecisionOption{Key: decision, Label: "Approve session", Approves: true}, true
case "decline":
return approvalDecisionOption{Key: decision, Label: "Deny"}, true
case "cancel":
return approvalDecisionOption{Key: decision, Label: "Cancel"}, true
default:
return approvalDecisionOption{}, false
}
}
func structuredApprovalDecisionOption(raw json.RawMessage, networkIndex int) (approvalDecisionOption, bool) {
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return approvalDecisionOption{}, false
}
if _, ok := object["acceptWithExecpolicyAmendment"]; ok {
return approvalDecisionOption{Key: "acceptWithExecpolicyAmendment", Label: "Approve rule", Approves: true}, true
}
if rawNetwork, ok := object["applyNetworkPolicyAmendment"]; ok {
label := "Apply network rule"
approves := true
var payload struct {
NetworkPolicyAmendment struct {
Action string `json:"action"`
Host string `json:"host"`
} `json:"network_policy_amendment"`
}
if err := json.Unmarshal(rawNetwork, &payload); err == nil {
host := strings.TrimSpace(payload.NetworkPolicyAmendment.Host)
switch payload.NetworkPolicyAmendment.Action {
case "allow":
label = "Allow network"
if host != "" {
label = "Allow " + host
}
case "deny":
label = "Deny network"
approves = false
if host != "" {
label = "Deny " + host
}
}
}
return approvalDecisionOption{Key: fmt.Sprintf("networkPolicy%d", networkIndex), Label: label, Approves: approves}, true
}
return approvalDecisionOption{}, false
}
func approvalMarkupForOptions(id int64, options []approvalDecisionOption) *InlineKeyboardMarkup {
if len(options) == 0 {
options = []approvalDecisionOption{
{Key: "accept", Label: "Approve", Approves: true},
{Key: "decline", Label: "Deny"},
{Key: "cancel", Label: "Cancel"},
}
}
var approveRow []InlineKeyboardButton
var denyRow []InlineKeyboardButton
for _, option := range options {
if option.Key == "" || option.Label == "" {
continue
}
button := InlineKeyboardButton{Text: truncateButtonLabel(option.Label), CallbackData: ApprovalCallbackData(id, option.Key)}
if option.Approves {
approveRow = append(approveRow, button)
} else {
denyRow = append(denyRow, button)
}
}
keyboard := make([][]InlineKeyboardButton, 0, 3)
if len(approveRow) > 0 {
keyboard = append(keyboard, approveRow)
}
if len(denyRow) > 0 {
keyboard = append(keyboard, denyRow)
}
keyboard = append(keyboard, []InlineKeyboardButton{{Text: "Details", CallbackData: ApprovalCallbackData(id, "details")}})
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
}
func truncateButtonLabel(label string) string {
const maxRunes = 48
runes := []rune(label)
if len(runes) <= maxRunes {
return label
}
return string(runes[:maxRunes-3]) + "..."
}
func approvalResponse(approval store.PendingApproval, decision string) any {
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), &params)
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, &params); err != nil {
return nil, false
}
seen := 0
for _, rawDecision := range params.AvailableDecisions {
var object map[string]json.RawMessage
if err := json.Unmarshal(rawDecision, &object); err != nil {
continue
}
payload, ok := object[key]
if !ok {
continue
}
if seen != index {
seen++
continue
}
var value map[string]any
if err := json.Unmarshal(payload, &value); err != nil {
return nil, false
}
return map[string]any{key: value}, true
}
return nil, false
}
func fallbackExecpolicyDecision(raw json.RawMessage) (any, bool) {
var params struct {
ProposedExecpolicyAmendment []string `json:"proposedExecpolicyAmendment"`
}
if err := json.Unmarshal(raw, &params); err != nil || len(params.ProposedExecpolicyAmendment) == 0 {
return nil, false
}
return map[string]any{
"acceptWithExecpolicyAmendment": map[string]any{
"execpolicy_amendment": params.ProposedExecpolicyAmendment,
},
}, true
}
func isLegacyApprovalKind(kind string) bool {
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, &params)
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, &params); err != nil {
return []string{CodeBlockHTML("json", string(raw))}
}
parts := renderSelectedArgumentDetailsHTML(params, []string{"cwd", "command", "additionalPermissions", "networkApprovalContext", "proposedExecpolicyAmendment", "proposedNetworkPolicyAmendments", "availableDecisions", "grantRoot", "permissions", "fileChanges", "parsedCmd", "reason"})
if len(parts) == 0 {
return []string{CodeBlockHTML("json", prettyJSON(raw))}
}
return nonEmptyHTML(parts)
}
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()
}