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.
3353 lines
104 KiB
Go
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, ¶ms); err != nil {
|
|
return SummaryDetailsHTML(title, string(raw))
|
|
}
|
|
sections := renderSelectedArgumentDetailsHTML(params, []string{"action", "review", "decisionSource", "targetItemId", "reviewId"})
|
|
return SummaryRawHTMLSectionsLimited(title, sections, TelegramHTMLMessageLimit)
|
|
}
|
|
|
|
func renderCodexItemStarted(item codexThreadItemView) string {
|
|
switch item.Type {
|
|
case "commandExecution":
|
|
return SummaryRawHTMLSectionsLimited("Tool call: command started", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit)
|
|
case "fileChange":
|
|
return "Tool call: file change started"
|
|
case "mcpToolCall":
|
|
return joinNonEmpty("Tool call: MCP started", "Tool: "+toolDisplayName(item.Server, item.Tool))
|
|
case "dynamicToolCall":
|
|
return joinNonEmpty("Tool call: started", "Tool: "+toolDisplayName(item.Namespace, item.Tool))
|
|
case "webSearch":
|
|
return joinNonEmpty("Tool call: web search started", "Query: "+item.Query)
|
|
case "imageView":
|
|
return joinNonEmpty("Tool call: image view", "Path: "+item.Path)
|
|
case "imageGeneration":
|
|
return "Tool call: image generation started"
|
|
case "collabAgentToolCall":
|
|
return joinNonEmpty("Tool call: agent started", "Tool: "+item.Tool)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func renderCodexItemCompleted(item codexThreadItemView) string {
|
|
switch item.Type {
|
|
case "commandExecution":
|
|
return SummaryRawHTMLSectionsLimited("Tool call: command finished", commandExecutionSectionsHTML(item, ""), TelegramHTMLMessageLimit)
|
|
case "fileChange":
|
|
return joinNonEmpty("Tool call: file change finished", fmt.Sprintf("Changed files: %d", len(item.Changes)), "Status: "+item.Status)
|
|
case "mcpToolCall":
|
|
summary := joinNonEmpty("Tool call: MCP finished", "Tool: "+toolDisplayName(item.Server, item.Tool), "Status: "+item.Status)
|
|
return SummaryDetailsRawHTMLLimited(summary, renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit)
|
|
case "dynamicToolCall":
|
|
status := item.Status
|
|
if item.Success != nil {
|
|
status = fmt.Sprintf("success=%t", *item.Success)
|
|
}
|
|
summary := joinNonEmpty("Tool call: finished", "Tool: "+toolDisplayName(item.Namespace, item.Tool), "Status: "+status)
|
|
return SummaryDetailsRawHTMLLimited(summary, renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit)
|
|
case "webSearch":
|
|
return joinNonEmpty("Tool call: web search finished", "Query: "+item.Query)
|
|
case "imageView":
|
|
return joinNonEmpty("Tool call: image view finished", "Path: "+item.Path)
|
|
case "imageGeneration":
|
|
return joinNonEmpty("Tool call: image generation finished", "Status: "+item.Status, "Saved path: "+item.SavedPath)
|
|
case "collabAgentToolCall":
|
|
return joinNonEmpty("Tool call: agent finished", "Tool: "+item.Tool, "Status: "+item.Status)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func commandStartedDetailsHTML(item codexThreadItemView) string {
|
|
return commandExecutionDetailsHTML(item, "")
|
|
}
|
|
|
|
func commandExecutionDetailsHTML(item codexThreadItemView, editedAt string) string {
|
|
return strings.Join(commandExecutionSectionsHTML(item, editedAt), "\n\n")
|
|
}
|
|
|
|
func commandExecutionSectionsHTML(item codexThreadItemView, editedAt string) []string {
|
|
var sections []string
|
|
if cwd := strings.TrimSpace(item.CWD); cwd != "" {
|
|
sections = append(sections, FieldHTML("CWD", cwd))
|
|
}
|
|
if command := strings.TrimSpace(item.Command); command != "" {
|
|
sections = append(sections, "<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, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
if params.ThreadID != "" && !params.WillRetry {
|
|
if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil {
|
|
_ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.TurnID)
|
|
}
|
|
if !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
return nil
|
|
}
|
|
message := "Codex error"
|
|
if params.Error.Message != "" {
|
|
message += ": " + params.Error.Message
|
|
}
|
|
if params.Error.AdditionalDetails != "" {
|
|
message += "\n" + params.Error.AdditionalDetails
|
|
}
|
|
if err := b.flushAssistantMessage(ctx, params.ThreadID); err != nil {
|
|
return err
|
|
}
|
|
if err := b.sendOutputBlock(ctx, params.ThreadID, message); err != nil {
|
|
return err
|
|
}
|
|
b.clearOutput(params.ThreadID)
|
|
return nil
|
|
}
|
|
case "item/started":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
TurnID string `json:"turnId"`
|
|
Item json.RawMessage `json:"item"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
return nil
|
|
}
|
|
item, err := parseCodexThreadItem(params.Item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item.Type == "agentMessage" && b.hasAssistantText(params.ThreadID) {
|
|
return b.flushAssistantMessage(ctx, params.ThreadID)
|
|
}
|
|
if b.shouldSuppressPictureToolMessage(params.ThreadID, item) {
|
|
return nil
|
|
}
|
|
return b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemStarted(item))
|
|
case "item/agentMessage/delta":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
TurnID string `json:"turnId"`
|
|
Delta string `json:"delta"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
if params.ThreadID != "" && params.Delta != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
return b.appendAssistantDelta(ctx, params.ThreadID, params.Delta)
|
|
}
|
|
case "item/completed":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
TurnID string `json:"turnId"`
|
|
Item json.RawMessage `json:"item"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
if params.ThreadID == "" || !b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
return nil
|
|
}
|
|
item, err := parseCodexThreadItem(params.Item)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item.Type == "agentMessage" {
|
|
if item.Text != "" && !b.hasAssistantText(params.ThreadID) {
|
|
if err := b.appendAssistantDelta(ctx, params.ThreadID, item.Text); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return b.flushAssistantMessage(ctx, params.ThreadID)
|
|
}
|
|
if b.queuePictureImageOutput(params.ThreadID, item) {
|
|
return nil
|
|
}
|
|
if err := b.upsertToolMessage(ctx, params.ThreadID, params.TurnID, item.ID, renderCodexItemCompleted(item)); err != nil {
|
|
return err
|
|
}
|
|
return b.sendImageOutput(ctx, params.ThreadID, item)
|
|
case "item/guardianApprovalReview/started", "item/guardianApprovalReview/completed":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
TurnID string `json:"turnId"`
|
|
ReviewID string `json:"reviewId"`
|
|
TargetItemID *string `json:"targetItemId"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
targetItemID := ""
|
|
if params.TargetItemID != nil {
|
|
targetItemID = *params.TargetItemID
|
|
}
|
|
b.logger.Printf("codex guardian approval review: method=%s thread=%s turn=%s review=%s target=%s", event.Method, params.ThreadID, params.TurnID, params.ReviewID, targetItemID)
|
|
if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
title := "Codex approval auto-review started"
|
|
if event.Method == "item/guardianApprovalReview/completed" {
|
|
title = "Codex approval auto-review completed"
|
|
}
|
|
return b.sendOutputHTMLBlock(ctx, params.ThreadID, renderGuardianReviewHTML(title, event.Params))
|
|
}
|
|
case "guardianWarning":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
b.logger.Printf("codex guardian warning: thread=%s message=%q", params.ThreadID, truncateForStatus(params.Message))
|
|
if params.ThreadID != "" && b.hasOutputThread(params.ThreadID) {
|
|
return b.sendOutputBlock(ctx, params.ThreadID, "Codex warning: "+params.Message)
|
|
}
|
|
case "turn/diff/updated":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
TurnID string `json:"turnId"`
|
|
Diff string `json:"diff"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
|
|
b.mu.Lock()
|
|
b.diffs[params.ThreadID] = params.Diff
|
|
b.mu.Unlock()
|
|
}
|
|
case "turn/completed":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
Turn struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"turn"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
if params.ThreadID != "" {
|
|
if thread, err := b.store.GetThreadByCodexID(ctx, params.ThreadID); err == nil {
|
|
_ = b.store.ClearActiveTurn(ctx, thread.TelegramUserID, params.Turn.ID)
|
|
_ = b.store.TouchThread(ctx, params.ThreadID)
|
|
}
|
|
if !b.shouldHandleOutputEvent(params.ThreadID, params.Turn.ID) {
|
|
return nil
|
|
}
|
|
return b.completeTurnOutput(ctx, params.ThreadID)
|
|
}
|
|
case "thread/name/updated":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
ThreadName *string `json:"threadName"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
if params.ThreadID != "" {
|
|
title := ""
|
|
if params.ThreadName != nil {
|
|
title = normalizeThreadTitle(*params.ThreadName)
|
|
}
|
|
return b.store.SyncThreadTitleByCodexID(ctx, params.ThreadID, title)
|
|
}
|
|
case "thread/settings/updated":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
ThreadSettings struct {
|
|
CWD string `json:"cwd"`
|
|
} `json:"threadSettings"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
if params.ThreadID != "" {
|
|
return b.syncThreadWorkspaceFromCWD(ctx, params.ThreadID, params.ThreadSettings.CWD)
|
|
}
|
|
case "serverRequest/resolved":
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
RequestID string `json:"requestId"`
|
|
}
|
|
_ = json.Unmarshal(event.Params, ¶ms)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) syncThreadWorkspaceFromCWD(ctx context.Context, codexThreadID, cwd string) error {
|
|
thread, err := b.store.GetThreadByCodexID(ctx, codexThreadID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
workspace, ok, err := b.workspaceForCodexCWD(ctx, cwd)
|
|
if err != nil || !ok {
|
|
return err
|
|
}
|
|
if workspace.ID == thread.WorkspaceID {
|
|
return nil
|
|
}
|
|
if err := b.store.SyncThreadWorkspace(ctx, thread.TelegramUserID, thread.ID, workspace.ID); err != nil {
|
|
return err
|
|
}
|
|
session, err := b.store.GetSession(ctx, thread.TelegramUserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if session.ActiveThreadID == thread.ID {
|
|
return b.store.SetSessionWorkspace(ctx, thread.TelegramUserID, workspace.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) handleCodexServerRequest(ctx context.Context, event codexapp.Event) error {
|
|
if event.ID == nil {
|
|
return errors.New("server request missing id")
|
|
}
|
|
switch event.Method {
|
|
case "item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval":
|
|
case "execCommandApproval", "applyPatchApproval":
|
|
default:
|
|
return fmt.Errorf("unsupported Codex server request: %s", event.Method)
|
|
}
|
|
var params struct {
|
|
ThreadID string `json:"threadId"`
|
|
ConversationID string `json:"conversationId"`
|
|
TurnID string `json:"turnId"`
|
|
ItemID string `json:"itemId"`
|
|
CallID string `json:"callId"`
|
|
ApprovalID string `json:"approvalId"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.Unmarshal(event.Params, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
threadID := firstNonEmpty(params.ThreadID, params.ConversationID)
|
|
if threadID == "" {
|
|
return errors.New("approval request missing threadId")
|
|
}
|
|
itemID := firstNonEmpty(params.ApprovalID, params.ItemID, params.CallID)
|
|
b.logger.Printf("codex approval request: method=%s request=%s thread=%s turn=%s item=%s approval=%s call=%s", event.Method, event.ID.Key(), threadID, params.TurnID, params.ItemID, params.ApprovalID, params.CallID)
|
|
thread, err := b.store.GetThreadByCodexID(ctx, threadID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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, ¶ms); err != nil || params.AvailableDecisions == nil {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var options []approvalDecisionOption
|
|
networkIndex := 0
|
|
for _, rawDecision := range params.AvailableDecisions {
|
|
if option, ok := stringApprovalDecisionOption(rawDecision); ok {
|
|
if !seen[option.Key] {
|
|
options = append(options, option)
|
|
seen[option.Key] = true
|
|
}
|
|
continue
|
|
}
|
|
if option, ok := structuredApprovalDecisionOption(rawDecision, networkIndex); ok {
|
|
if strings.HasPrefix(option.Key, "networkPolicy") {
|
|
networkIndex++
|
|
}
|
|
if !seen[option.Key] {
|
|
options = append(options, option)
|
|
seen[option.Key] = true
|
|
}
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
func stringApprovalDecisionOption(raw json.RawMessage) (approvalDecisionOption, bool) {
|
|
var decision string
|
|
if err := json.Unmarshal(raw, &decision); err != nil {
|
|
return approvalDecisionOption{}, false
|
|
}
|
|
switch decision {
|
|
case "accept":
|
|
return approvalDecisionOption{Key: decision, Label: "Approve", Approves: true}, true
|
|
case "acceptForSession":
|
|
return approvalDecisionOption{Key: decision, Label: "Approve session", Approves: true}, true
|
|
case "decline":
|
|
return approvalDecisionOption{Key: decision, Label: "Deny"}, true
|
|
case "cancel":
|
|
return approvalDecisionOption{Key: decision, Label: "Cancel"}, true
|
|
default:
|
|
return approvalDecisionOption{}, false
|
|
}
|
|
}
|
|
|
|
func structuredApprovalDecisionOption(raw json.RawMessage, networkIndex int) (approvalDecisionOption, bool) {
|
|
var object map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &object); err != nil {
|
|
return approvalDecisionOption{}, false
|
|
}
|
|
if _, ok := object["acceptWithExecpolicyAmendment"]; ok {
|
|
return approvalDecisionOption{Key: "acceptWithExecpolicyAmendment", Label: "Approve rule", Approves: true}, true
|
|
}
|
|
if rawNetwork, ok := object["applyNetworkPolicyAmendment"]; ok {
|
|
label := "Apply network rule"
|
|
approves := true
|
|
var payload struct {
|
|
NetworkPolicyAmendment struct {
|
|
Action string `json:"action"`
|
|
Host string `json:"host"`
|
|
} `json:"network_policy_amendment"`
|
|
}
|
|
if err := json.Unmarshal(rawNetwork, &payload); err == nil {
|
|
host := strings.TrimSpace(payload.NetworkPolicyAmendment.Host)
|
|
switch payload.NetworkPolicyAmendment.Action {
|
|
case "allow":
|
|
label = "Allow network"
|
|
if host != "" {
|
|
label = "Allow " + host
|
|
}
|
|
case "deny":
|
|
label = "Deny network"
|
|
approves = false
|
|
if host != "" {
|
|
label = "Deny " + host
|
|
}
|
|
}
|
|
}
|
|
return approvalDecisionOption{Key: fmt.Sprintf("networkPolicy%d", networkIndex), Label: label, Approves: approves}, true
|
|
}
|
|
return approvalDecisionOption{}, false
|
|
}
|
|
|
|
func approvalMarkupForOptions(id int64, options []approvalDecisionOption) *InlineKeyboardMarkup {
|
|
if len(options) == 0 {
|
|
options = []approvalDecisionOption{
|
|
{Key: "accept", Label: "Approve", Approves: true},
|
|
{Key: "decline", Label: "Deny"},
|
|
{Key: "cancel", Label: "Cancel"},
|
|
}
|
|
}
|
|
var approveRow []InlineKeyboardButton
|
|
var denyRow []InlineKeyboardButton
|
|
for _, option := range options {
|
|
if option.Key == "" || option.Label == "" {
|
|
continue
|
|
}
|
|
button := InlineKeyboardButton{Text: truncateButtonLabel(option.Label), CallbackData: ApprovalCallbackData(id, option.Key)}
|
|
if option.Approves {
|
|
approveRow = append(approveRow, button)
|
|
} else {
|
|
denyRow = append(denyRow, button)
|
|
}
|
|
}
|
|
keyboard := make([][]InlineKeyboardButton, 0, 3)
|
|
if len(approveRow) > 0 {
|
|
keyboard = append(keyboard, approveRow)
|
|
}
|
|
if len(denyRow) > 0 {
|
|
keyboard = append(keyboard, denyRow)
|
|
}
|
|
keyboard = append(keyboard, []InlineKeyboardButton{{Text: "Details", CallbackData: ApprovalCallbackData(id, "details")}})
|
|
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
|
}
|
|
|
|
func truncateButtonLabel(label string) string {
|
|
const maxRunes = 48
|
|
runes := []rune(label)
|
|
if len(runes) <= maxRunes {
|
|
return label
|
|
}
|
|
return string(runes[:maxRunes-3]) + "..."
|
|
}
|
|
|
|
func approvalResponse(approval store.PendingApproval, decision string) any {
|
|
if isLegacyApprovalKind(approval.Kind) {
|
|
return map[string]any{"decision": legacyApprovalDecision(decision)}
|
|
}
|
|
if approval.Kind == "item/commandExecution/requestApproval" {
|
|
if responseDecision, ok := commandApprovalResponseDecision(json.RawMessage(approval.PayloadJSON), decision); ok {
|
|
return map[string]any{"decision": responseDecision}
|
|
}
|
|
}
|
|
if approval.Kind != "item/permissions/requestApproval" {
|
|
return map[string]any{"decision": decision}
|
|
}
|
|
|
|
scope := "turn"
|
|
if decision == "acceptForSession" {
|
|
scope = "session"
|
|
}
|
|
permissions := map[string]any{}
|
|
if decision == "accept" || decision == "acceptForSession" {
|
|
var params struct {
|
|
Permissions map[string]any `json:"permissions"`
|
|
}
|
|
_ = json.Unmarshal([]byte(approval.PayloadJSON), ¶ms)
|
|
if params.Permissions != nil {
|
|
permissions = params.Permissions
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"permissions": permissions,
|
|
"scope": scope,
|
|
}
|
|
}
|
|
|
|
func commandApprovalResponseDecision(raw json.RawMessage, decision string) (any, bool) {
|
|
switch decision {
|
|
case "accept", "acceptForSession", "decline", "cancel":
|
|
return decision, true
|
|
case "acceptWithExecpolicyAmendment":
|
|
if value, ok := firstStructuredApprovalDecision(raw, "acceptWithExecpolicyAmendment", 0); ok {
|
|
return value, true
|
|
}
|
|
if value, ok := fallbackExecpolicyDecision(raw); ok {
|
|
return value, true
|
|
}
|
|
case "details":
|
|
return nil, false
|
|
default:
|
|
if strings.HasPrefix(decision, "networkPolicy") {
|
|
index, err := strconv.Atoi(strings.TrimPrefix(decision, "networkPolicy"))
|
|
if err == nil {
|
|
return firstStructuredApprovalDecision(raw, "applyNetworkPolicyAmendment", index)
|
|
}
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func firstStructuredApprovalDecision(raw json.RawMessage, key string, index int) (any, bool) {
|
|
if index < 0 {
|
|
return nil, false
|
|
}
|
|
var params struct {
|
|
AvailableDecisions []json.RawMessage `json:"availableDecisions"`
|
|
}
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
|
return nil, false
|
|
}
|
|
seen := 0
|
|
for _, rawDecision := range params.AvailableDecisions {
|
|
var object map[string]json.RawMessage
|
|
if err := json.Unmarshal(rawDecision, &object); err != nil {
|
|
continue
|
|
}
|
|
payload, ok := object[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if seen != index {
|
|
seen++
|
|
continue
|
|
}
|
|
var value map[string]any
|
|
if err := json.Unmarshal(payload, &value); err != nil {
|
|
return nil, false
|
|
}
|
|
return map[string]any{key: value}, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func fallbackExecpolicyDecision(raw json.RawMessage) (any, bool) {
|
|
var params struct {
|
|
ProposedExecpolicyAmendment []string `json:"proposedExecpolicyAmendment"`
|
|
}
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil || len(params.ProposedExecpolicyAmendment) == 0 {
|
|
return nil, false
|
|
}
|
|
return map[string]any{
|
|
"acceptWithExecpolicyAmendment": map[string]any{
|
|
"execpolicy_amendment": params.ProposedExecpolicyAmendment,
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func isLegacyApprovalKind(kind string) bool {
|
|
switch kind {
|
|
case "execCommandApproval", "applyPatchApproval":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func legacyApprovalDecision(decision string) string {
|
|
switch decision {
|
|
case "accept":
|
|
return "approved"
|
|
case "acceptForSession":
|
|
return "approved_for_session"
|
|
case "decline":
|
|
return "denied"
|
|
case "cancel":
|
|
return "abort"
|
|
default:
|
|
return decision
|
|
}
|
|
}
|
|
|
|
func renderApprovalHTML(kind string, raw json.RawMessage, status string) string {
|
|
title := "Codex approval requested"
|
|
if strings.Contains(kind, "commandExecution") || kind == "execCommandApproval" {
|
|
title = "Codex requests command approval"
|
|
}
|
|
if strings.Contains(kind, "fileChange") || kind == "applyPatchApproval" {
|
|
title = "Codex requests file change approval"
|
|
}
|
|
if strings.Contains(kind, "permissions") {
|
|
title = "Codex requests permission approval"
|
|
}
|
|
|
|
var params map[string]any
|
|
_ = json.Unmarshal(raw, ¶ms)
|
|
lines := []string{title}
|
|
if reason, _ := params["reason"].(string); reason != "" {
|
|
lines = append(lines, "", reason)
|
|
}
|
|
summary := strings.Join(lines, "\n")
|
|
sections := renderApprovalDetailSectionsHTML(kind, raw)
|
|
limit := TelegramHTMLMessageLimit
|
|
if status != "" {
|
|
limit -= len([]rune(status)) + 1
|
|
}
|
|
text := SummaryRawHTMLSectionsLimited(summary, sections, limit)
|
|
if status != "" {
|
|
text += "\n" + EscapeHTML(status)
|
|
}
|
|
return text
|
|
}
|
|
|
|
func renderApprovalDetailsHTML(kind string, raw json.RawMessage) string {
|
|
return strings.Join(renderApprovalDetailSectionsHTML(kind, raw), "\n")
|
|
}
|
|
|
|
func renderApprovalDetailSectionsHTML(kind string, raw json.RawMessage) []string {
|
|
var params map[string]any
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
|
return []string{CodeBlockHTML("json", string(raw))}
|
|
}
|
|
parts := 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()
|
|
}
|