Treat app-server request IDs as connection-local by reopening reused approval rows when the thread, turn, or item context changes. Keep duplicate resolved approvals in the same context closed, and add focused approval-path diagnostics without changing the Telegram approval UI.
3381 lines
107 KiB
Go
3381 lines
107 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
|
|
}
|
|
b.logger.Printf("codex approval thread mapped: request=%s telegram_user=%d", event.ID.Key(), thread.TelegramUserID)
|
|
pretty, _ := json.MarshalIndent(json.RawMessage(event.Params), "", " ")
|
|
if len(pretty) == 0 {
|
|
pretty = event.Params
|
|
}
|
|
kind := event.Method
|
|
approval, err := b.store.UpsertPendingApproval(ctx, store.PendingApproval{
|
|
TelegramUserID: thread.TelegramUserID,
|
|
CodexRequestID: event.ID.Key(),
|
|
CodexThreadID: threadID,
|
|
TurnID: params.TurnID,
|
|
ItemID: itemID,
|
|
Kind: kind,
|
|
PayloadJSON: string(pretty),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.logger.Printf("codex approval stored: request=%s approval_id=%d status=%s item=%s", event.ID.Key(), approval.ID, approval.Status, approval.ItemID)
|
|
if approval.Status != "pending" {
|
|
return nil
|
|
}
|
|
text := renderApprovalHTML(kind, event.Params, "")
|
|
markup := approvalMarkupForPayload(approval.ID, event.Params)
|
|
b.logger.Printf("codex approval render complete: request=%s approval_id=%d text_runes=%d", event.ID.Key(), approval.ID, len([]rune(text)))
|
|
msg, err := b.upsertApprovalMessage(ctx, thread.TelegramUserID, threadID, params.TurnID, itemID, text, markup)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.logger.Printf("codex approval telegram sent: request=%s approval_id=%d chat=%d message=%d", event.ID.Key(), approval.ID, msg.Chat.ID, msg.MessageID)
|
|
return b.store.UpdatePendingApprovalMessage(ctx, approval.ID, msg.Chat.ID, msg.MessageID)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (b *Bot) newOutputState(chatID int64, turnID string) *outputState {
|
|
return &outputState{
|
|
turnID: turnID,
|
|
chatID: chatID,
|
|
tools: make(map[string]toolMessageState),
|
|
sentImages: make(map[string]bool),
|
|
workingIndicatorOff: b.startWorkingIndicator(chatID),
|
|
}
|
|
}
|
|
|
|
func (b *Bot) registerOutput(threadID, turnID string, chatID int64) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if state := b.outputs[threadID]; state != nil && state.workingIndicatorOff != nil {
|
|
state.workingIndicatorOff()
|
|
}
|
|
b.outputs[threadID] = b.newOutputState(chatID, turnID)
|
|
}
|
|
|
|
func (b *Bot) registerPictureOutput(threadID, turnID string, chatID int64) {
|
|
b.registerOutput(threadID, turnID, chatID)
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if state := b.outputs[threadID]; state != nil {
|
|
state.pictureRequest = true
|
|
}
|
|
}
|
|
|
|
func (b *Bot) setOutputTurnID(threadID, turnID string) {
|
|
if strings.TrimSpace(turnID) == "" {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if state := b.outputs[threadID]; state != nil {
|
|
state.turnID = turnID
|
|
}
|
|
}
|
|
|
|
func (b *Bot) hasOutputThread(threadID string) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.outputs[threadID] != nil
|
|
}
|
|
|
|
func (b *Bot) hasOutputTurn(threadID, turnID string) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
state := b.outputs[threadID]
|
|
return state != nil && sameTurn(state.turnID, turnID)
|
|
}
|
|
|
|
func (b *Bot) shouldHandleOutputEvent(threadID, turnID string) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
state := b.outputs[threadID]
|
|
if state == nil {
|
|
return false
|
|
}
|
|
if state.turnID == "" && strings.TrimSpace(turnID) != "" {
|
|
state.turnID = turnID
|
|
}
|
|
return sameTurn(state.turnID, turnID)
|
|
}
|
|
|
|
func sameTurn(ownedTurnID, eventTurnID string) bool {
|
|
ownedTurnID = strings.TrimSpace(ownedTurnID)
|
|
eventTurnID = strings.TrimSpace(eventTurnID)
|
|
return ownedTurnID == "" || eventTurnID == "" || ownedTurnID == eventTurnID
|
|
}
|
|
|
|
func (b *Bot) clearOutput(threadID string) {
|
|
b.mu.Lock()
|
|
state := b.outputs[threadID]
|
|
delete(b.outputs, threadID)
|
|
b.mu.Unlock()
|
|
if state != nil && state.workingIndicatorOff != nil {
|
|
state.workingIndicatorOff()
|
|
}
|
|
}
|
|
|
|
func (b *Bot) startWorkingIndicator(chatID int64) context.CancelFunc {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
draftID := time.Now().UnixNano()
|
|
go func() {
|
|
useDraft := true
|
|
sendDraft := func() bool {
|
|
return b.tg.SendMessageDraft(ctx, chatID, draftID, "") == nil
|
|
}
|
|
sendTyping := func() {
|
|
if err := b.tg.SendChatAction(ctx, chatID, "typing"); err != nil && ctx.Err() == nil {
|
|
b.logger.Printf("send typing action: %v", err)
|
|
}
|
|
}
|
|
|
|
if !sendDraft() {
|
|
useDraft = false
|
|
sendTyping()
|
|
}
|
|
|
|
draftTicker := time.NewTicker(25 * time.Second)
|
|
typingTicker := time.NewTicker(4 * time.Second)
|
|
defer draftTicker.Stop()
|
|
defer typingTicker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-draftTicker.C:
|
|
if useDraft && !sendDraft() {
|
|
useDraft = false
|
|
sendTyping()
|
|
}
|
|
case <-typingTicker.C:
|
|
if !useDraft {
|
|
sendTyping()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return cancel
|
|
}
|
|
|
|
func (b *Bot) hasAssistantText(threadID string) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
state := b.outputs[threadID]
|
|
return state != nil && state.assistant.Len() > 0
|
|
}
|
|
|
|
func (b *Bot) failActiveOutputs(ctx context.Context, message string) {
|
|
if err := b.store.ClearActiveTurns(ctx); err != nil {
|
|
b.logger.Printf("clear active turns after Codex disconnect: %v", err)
|
|
}
|
|
|
|
b.mu.Lock()
|
|
threadIDs := make([]string, 0, len(b.outputs))
|
|
for threadID := range b.outputs {
|
|
threadIDs = append(threadIDs, threadID)
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
for _, threadID := range threadIDs {
|
|
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
|
b.logger.Printf("flush failed output %s: %v", threadID, err)
|
|
}
|
|
if err := b.sendOutputBlock(ctx, threadID, message); err != nil {
|
|
b.logger.Printf("send failed output %s: %v", threadID, err)
|
|
}
|
|
b.clearOutput(threadID)
|
|
}
|
|
}
|
|
|
|
func (b *Bot) shouldSuppressPictureToolMessage(threadID string, item codexThreadItemView) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
state := b.outputs[threadID]
|
|
return state != nil && state.pictureRequest && item.Type == "imageGeneration"
|
|
}
|
|
|
|
func (b *Bot) queuePictureImageOutput(threadID string, item codexThreadItemView) bool {
|
|
if item.Type != "imageGeneration" {
|
|
return false
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
state := b.outputs[threadID]
|
|
if state == nil || !state.pictureRequest {
|
|
return false
|
|
}
|
|
path := strings.TrimSpace(item.SavedPath)
|
|
if path == "" {
|
|
return true
|
|
}
|
|
if state.sentImages == nil {
|
|
state.sentImages = make(map[string]bool)
|
|
}
|
|
if state.sentImages[path] {
|
|
return true
|
|
}
|
|
state.sentImages[path] = true
|
|
state.generatedImages = append(state.generatedImages, generatedImageOutput{Path: path})
|
|
return true
|
|
}
|
|
|
|
func (b *Bot) sendImageOutput(ctx context.Context, threadID string, item codexThreadItemView) error {
|
|
if item.Type != "imageGeneration" || strings.TrimSpace(item.SavedPath) == "" {
|
|
return nil
|
|
}
|
|
path := strings.TrimSpace(item.SavedPath)
|
|
if !b.markImageOutputPending(threadID, path) {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
b.logger.Printf("read generated image %s: %v", path, err)
|
|
return nil
|
|
}
|
|
chatID, err := b.outputChatID(ctx, threadID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
caption := "Generated image"
|
|
if item.Status != "" {
|
|
caption += ": " + item.Status
|
|
}
|
|
if _, err := b.tg.SendPhotoBytes(ctx, chatID, path, data, caption); err != nil {
|
|
b.logger.Printf("send generated image %s: %v", path, err)
|
|
return nil
|
|
}
|
|
b.markOutputSent(threadID)
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) markImageOutputPending(threadID, path string) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
state := b.outputs[threadID]
|
|
if state == nil {
|
|
return false
|
|
}
|
|
if state.sentImages == nil {
|
|
state.sentImages = make(map[string]bool)
|
|
}
|
|
if state.sentImages[path] {
|
|
return false
|
|
}
|
|
state.sentImages[path] = true
|
|
return true
|
|
}
|
|
|
|
func (b *Bot) sendOutputBlock(ctx context.Context, threadID, block string) error {
|
|
block = strings.TrimSpace(block)
|
|
if block == "" {
|
|
return nil
|
|
}
|
|
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
|
return err
|
|
}
|
|
chatID, err := b.outputChatID(ctx, threadID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if err := b.sendLong(ctx, chatID, block); err != nil {
|
|
return err
|
|
}
|
|
b.markOutputSent(threadID)
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) sendOutputHTMLBlock(ctx context.Context, threadID, htmlText string) error {
|
|
htmlText = strings.TrimSpace(htmlText)
|
|
if htmlText == "" {
|
|
return nil
|
|
}
|
|
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
|
return err
|
|
}
|
|
chatID, err := b.outputChatID(ctx, threadID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if err := b.sendHTML(ctx, chatID, htmlText); err != nil {
|
|
return err
|
|
}
|
|
b.markOutputSent(threadID)
|
|
return nil
|
|
}
|
|
|
|
func (s toolMessageState) html() string {
|
|
return FitHTMLMessage(addEditedAtLine(combineToolApprovalHTML(s.toolHTML, s.approvalHTML), s.editedAt), TelegramHTMLMessageLimit)
|
|
}
|
|
|
|
func combineToolApprovalHTML(toolHTML, approvalHTML string) string {
|
|
toolHTML = strings.TrimSpace(toolHTML)
|
|
approvalHTML = strings.TrimSpace(approvalHTML)
|
|
switch {
|
|
case toolHTML == "":
|
|
return approvalHTML
|
|
case approvalHTML == "":
|
|
return toolHTML
|
|
default:
|
|
return toolHTML + "\n\n" + 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")
|
|
}
|
|
b.logger.Printf("codex approval ui upsert start: thread=%s turn=%s item=%s chat=%d text_runes=%d", threadID, turnID, itemID, chatID, len([]rune(approvalHTML)))
|
|
trackedTurn := threadID != "" && itemID != "" && b.hasOutputTurn(threadID, turnID)
|
|
if !trackedTurn {
|
|
b.logger.Printf("codex approval ui direct send start: thread=%s turn=%s item=%s chat=%d", threadID, turnID, itemID, chatID)
|
|
msg, err := b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
|
|
if err != nil {
|
|
b.logger.Printf("codex approval ui direct send failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, err)
|
|
return Message{}, err
|
|
}
|
|
b.logger.Printf("codex approval ui direct send done: thread=%s turn=%s item=%s chat=%d message=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID)
|
|
return msg, nil
|
|
}
|
|
b.logger.Printf("codex approval ui flush assistant start: thread=%s turn=%s item=%s", threadID, turnID, itemID)
|
|
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
|
b.logger.Printf("codex approval ui flush assistant failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, err)
|
|
return Message{}, err
|
|
}
|
|
b.logger.Printf("codex approval ui flush assistant done: thread=%s turn=%s item=%s", threadID, turnID, itemID)
|
|
trackedChatID, err := b.outputChatID(ctx, threadID)
|
|
if err != nil {
|
|
b.logger.Printf("codex approval ui output state missing; direct send start: thread=%s turn=%s item=%s chat=%d err=%v", threadID, turnID, itemID, chatID, err)
|
|
msg, sendErr := b.tg.SendMessage(ctx, chatID, approvalHTML, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
|
|
if sendErr != nil {
|
|
b.logger.Printf("codex approval ui output-state direct send failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, sendErr)
|
|
return Message{}, sendErr
|
|
}
|
|
b.logger.Printf("codex approval ui output-state direct send done: thread=%s turn=%s item=%s chat=%d message=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID)
|
|
return msg, nil
|
|
}
|
|
chatID = trackedChatID
|
|
|
|
b.mu.Lock()
|
|
state := b.outputs[threadID]
|
|
if state != nil && state.tools == nil {
|
|
state.tools = make(map[string]toolMessageState)
|
|
}
|
|
if state != nil {
|
|
tool, ok := state.tools[itemID]
|
|
if ok && tool.messageID != 0 {
|
|
tool.approvalHTML = approvalHTML
|
|
tool.approvalMarkup = markup
|
|
tool.editedAt = editedAtTimestamp()
|
|
state.tools[itemID] = tool
|
|
combined := tool.html()
|
|
msg := Message{MessageID: tool.messageID, Chat: Chat{ID: tool.chatID}}
|
|
b.mu.Unlock()
|
|
b.logger.Printf("codex approval ui edit start: thread=%s turn=%s item=%s chat=%d message=%d text_runes=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID, len([]rune(combined)))
|
|
_, err := b.tg.EditMessageText(ctx, msg.Chat.ID, msg.MessageID, combined, EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: editReplyMarkup(markup)})
|
|
if err := ignoreTelegramMessageNotModified(err); err != nil {
|
|
b.clearToolApproval(threadID, itemID)
|
|
b.logger.Printf("codex approval ui edit failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, err)
|
|
return Message{}, err
|
|
}
|
|
b.logger.Printf("codex approval ui edit done: thread=%s turn=%s item=%s chat=%d message=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID)
|
|
b.markOutputSent(threadID)
|
|
return msg, nil
|
|
}
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
b.logger.Printf("codex approval ui new combined message start: thread=%s turn=%s item=%s chat=%d", threadID, turnID, itemID, chatID)
|
|
msg, err := b.sendHTMLMessage(ctx, chatID, approvalHTML, markup)
|
|
if err != nil {
|
|
b.logger.Printf("codex approval ui new combined message failed: thread=%s turn=%s item=%s err=%v", threadID, turnID, itemID, err)
|
|
return Message{}, err
|
|
}
|
|
b.mu.Lock()
|
|
state = b.outputs[threadID]
|
|
if state != nil {
|
|
if state.tools == nil {
|
|
state.tools = make(map[string]toolMessageState)
|
|
}
|
|
state.tools[itemID] = toolMessageState{chatID: msg.Chat.ID, messageID: msg.MessageID, approvalHTML: approvalHTML, approvalMarkup: markup}
|
|
}
|
|
b.mu.Unlock()
|
|
b.logger.Printf("codex approval ui new combined message done: thread=%s turn=%s item=%s chat=%d message=%d", threadID, turnID, itemID, msg.Chat.ID, msg.MessageID)
|
|
b.markOutputSent(threadID)
|
|
return msg, nil
|
|
}
|
|
|
|
func (b *Bot) clearToolApproval(threadID, itemID string) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if state := b.outputs[threadID]; state != nil {
|
|
if tool, ok := state.tools[itemID]; ok {
|
|
tool.approvalHTML = ""
|
|
tool.approvalMarkup = nil
|
|
state.tools[itemID] = tool
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Bot) resolveApprovalMessageHTML(approval store.PendingApproval, decision string) string {
|
|
approvalHTML := renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), approvalStatusLine(decision))
|
|
if approval.ItemID == "" {
|
|
return approvalHTML
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
state := b.outputs[approval.CodexThreadID]
|
|
if state == nil {
|
|
return approvalHTML
|
|
}
|
|
tool, ok := state.tools[approval.ItemID]
|
|
if !ok || tool.messageID == 0 || tool.messageID != approval.MessageID {
|
|
return approvalHTML
|
|
}
|
|
tool.approvalHTML = approvalHTML
|
|
tool.approvalMarkup = nil
|
|
tool.editedAt = editedAtTimestamp()
|
|
state.tools[approval.ItemID] = tool
|
|
return tool.html()
|
|
}
|
|
|
|
func ignoreTelegramMessageNotModified(err error) error {
|
|
if err != nil && strings.Contains(err.Error(), "message is not modified") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func splitAssistantMessageSegments(text string) []assistantMessageSegment {
|
|
var segments []assistantMessageSegment
|
|
var visible strings.Builder
|
|
flushVisible := func() {
|
|
if visible.Len() == 0 {
|
|
return
|
|
}
|
|
segments = append(segments, assistantMessageSegment{Text: visible.String()})
|
|
visible.Reset()
|
|
}
|
|
|
|
for _, line := range strings.SplitAfter(text, "\n") {
|
|
body := strings.TrimSuffix(line, "\n")
|
|
body = strings.TrimSuffix(body, "\r")
|
|
if directive, ok := parseAssistantPhotoDirectiveLine(body); ok {
|
|
flushVisible()
|
|
segments = append(segments, assistantMessageSegment{Photo: &directive})
|
|
continue
|
|
}
|
|
if directive, ok := parseAssistantThreadRenameDirectiveLine(body); ok {
|
|
flushVisible()
|
|
segments = append(segments, assistantMessageSegment{ThreadRename: &directive})
|
|
continue
|
|
}
|
|
if directive, ok := parseAssistantThreadCWDDirectiveLine(body); ok {
|
|
flushVisible()
|
|
segments = append(segments, assistantMessageSegment{ThreadCWD: &directive})
|
|
continue
|
|
}
|
|
visible.WriteString(line)
|
|
}
|
|
flushVisible()
|
|
return segments
|
|
}
|
|
|
|
func parseAssistantThreadRenameDirectiveLine(line string) (assistantThreadRenameDirective, bool) {
|
|
trimmed := strings.TrimSpace(line)
|
|
if !strings.HasPrefix(trimmed, telegramThreadRenameDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) {
|
|
return assistantThreadRenameDirective{}, false
|
|
}
|
|
raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramThreadRenameDirectiveStart), telegramDirectiveEnd)
|
|
raw = strings.TrimSpace(raw)
|
|
var directive assistantThreadRenameDirective
|
|
if err := json.Unmarshal([]byte(raw), &directive); err != nil {
|
|
return assistantThreadRenameDirective{}, false
|
|
}
|
|
directive.Title = normalizeThreadTitle(directive.Title)
|
|
return directive, true
|
|
}
|
|
|
|
func parseAssistantThreadCWDDirectiveLine(line string) (assistantThreadCWDDirective, bool) {
|
|
trimmed := strings.TrimSpace(line)
|
|
if !strings.HasPrefix(trimmed, telegramThreadCWDDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) {
|
|
return assistantThreadCWDDirective{}, false
|
|
}
|
|
raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramThreadCWDDirectiveStart), telegramDirectiveEnd)
|
|
raw = strings.TrimSpace(raw)
|
|
var directive assistantThreadCWDDirective
|
|
if err := json.Unmarshal([]byte(raw), &directive); err != nil {
|
|
return assistantThreadCWDDirective{}, false
|
|
}
|
|
directive.CWD = strings.TrimSpace(directive.CWD)
|
|
return directive, true
|
|
}
|
|
|
|
func parseAssistantPhotoDirectiveLine(line string) (assistantPhotoDirective, bool) {
|
|
trimmed := strings.TrimSpace(line)
|
|
if !strings.HasPrefix(trimmed, telegramPhotoDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) {
|
|
return assistantPhotoDirective{}, false
|
|
}
|
|
raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramPhotoDirectiveStart), telegramDirectiveEnd)
|
|
raw = strings.TrimSpace(raw)
|
|
var directive assistantPhotoDirective
|
|
if err := json.Unmarshal([]byte(raw), &directive); err != nil {
|
|
return assistantPhotoDirective{}, false
|
|
}
|
|
directive.Path = strings.TrimSpace(directive.Path)
|
|
directive.Caption = strings.TrimSpace(directive.Caption)
|
|
return directive, true
|
|
}
|
|
|
|
func (b *Bot) sendAssistantText(ctx context.Context, threadID string, chatID int64, text string) error {
|
|
for _, segment := range splitAssistantMessageSegments(text) {
|
|
if segment.Text != "" && strings.TrimSpace(segment.Text) != "" {
|
|
if err := b.sendLong(ctx, chatID, segment.Text); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if segment.Photo != nil {
|
|
if err := b.sendAssistantPhoto(ctx, chatID, *segment.Photo); err != nil {
|
|
b.logger.Printf("send assistant photo: %v", err)
|
|
if sendErr := b.sendLong(ctx, chatID, "Could not send photo: "+err.Error()); sendErr != nil {
|
|
return sendErr
|
|
}
|
|
}
|
|
}
|
|
if segment.ThreadRename != nil {
|
|
if err := b.applyAssistantThreadRename(ctx, threadID, *segment.ThreadRename); err != nil {
|
|
b.logger.Printf("apply assistant thread rename: %v", err)
|
|
if sendErr := b.sendLong(ctx, chatID, "Could not rename thread: "+err.Error()); sendErr != nil {
|
|
return sendErr
|
|
}
|
|
}
|
|
}
|
|
if segment.ThreadCWD != nil {
|
|
if err := b.applyAssistantThreadCWD(ctx, threadID, *segment.ThreadCWD); err != nil {
|
|
b.logger.Printf("apply assistant thread cwd: %v", err)
|
|
if sendErr := b.sendLong(ctx, chatID, "Could not change thread cwd: "+err.Error()); sendErr != nil {
|
|
return sendErr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) applyAssistantThreadRename(ctx context.Context, threadID string, directive assistantThreadRenameDirective) error {
|
|
title := normalizeThreadTitle(directive.Title)
|
|
if title == "" {
|
|
return errors.New("thread title cannot be empty")
|
|
}
|
|
if err := b.codex.SetThreadName(ctx, threadID, title); err != nil {
|
|
return err
|
|
}
|
|
return b.store.SyncThreadTitleByCodexID(ctx, threadID, title)
|
|
}
|
|
|
|
func (b *Bot) applyAssistantThreadCWD(ctx context.Context, threadID string, directive assistantThreadCWDDirective) error {
|
|
cwd, err := store.ValidateWorkspacePath(directive.CWD)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := codexstate.SetThreadCWD(ctx, b.codexHome, b.codexStateDB, threadID, cwd); err != nil {
|
|
return err
|
|
}
|
|
workspace, ok, err := b.workspaceForCodexCWD(ctx, cwd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return nil
|
|
}
|
|
thread, err := b.store.GetThreadByCodexID(ctx, threadID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if err := b.store.SyncThreadWorkspace(ctx, thread.TelegramUserID, thread.ID, workspace.ID); err != nil {
|
|
return err
|
|
}
|
|
return b.store.SetSessionWorkspace(ctx, thread.TelegramUserID, workspace.ID)
|
|
}
|
|
|
|
func (b *Bot) sendAssistantPhoto(ctx context.Context, chatID int64, directive assistantPhotoDirective) error {
|
|
path := strings.TrimSpace(directive.Path)
|
|
if path == "" {
|
|
return errors.New("photo directive is missing a path")
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("photo path must be absolute: %s", path)
|
|
}
|
|
if !isPicturePath(path) {
|
|
return fmt.Errorf("unsupported photo type: %s", filepath.Base(path))
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %v", filepath.Base(path), err)
|
|
}
|
|
caption := truncateTelegramPhotoCaption(directive.Caption)
|
|
if _, err := b.tg.SendPhotoBytes(ctx, chatID, path, data, caption); err != nil {
|
|
return fmt.Errorf("send %s: %v", filepath.Base(path), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func truncateTelegramPhotoCaption(caption string) string {
|
|
runes := []rune(caption)
|
|
if len(runes) <= telegramPhotoCaptionLimit {
|
|
return caption
|
|
}
|
|
if telegramPhotoCaptionLimit <= 3 {
|
|
return string(runes[:telegramPhotoCaptionLimit])
|
|
}
|
|
return string(runes[:telegramPhotoCaptionLimit-3]) + "..."
|
|
}
|
|
|
|
func (b *Bot) appendAssistantDelta(ctx context.Context, threadID, delta string) error {
|
|
if delta == "" {
|
|
return nil
|
|
}
|
|
if _, err := b.outputChatID(ctx, threadID); err != nil {
|
|
return nil
|
|
}
|
|
b.mu.Lock()
|
|
state := b.outputs[threadID]
|
|
if state != nil {
|
|
_, _ = state.assistant.WriteString(delta)
|
|
}
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) flushAssistantMessage(ctx context.Context, threadID string) error {
|
|
b.mu.Lock()
|
|
state := b.outputs[threadID]
|
|
if state == nil || state.assistant.Len() == 0 {
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
chatID := state.chatID
|
|
text := state.assistant.String()
|
|
pictureRequest := state.pictureRequest
|
|
state.assistant.Reset()
|
|
b.mu.Unlock()
|
|
|
|
if pictureRequest {
|
|
return nil
|
|
}
|
|
if err := b.sendAssistantText(ctx, threadID, chatID, text); err != nil {
|
|
return err
|
|
}
|
|
b.markOutputSent(threadID)
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) completeTurnOutput(ctx context.Context, threadID string) error {
|
|
if err := b.flushAssistantMessage(ctx, threadID); err != nil {
|
|
return err
|
|
}
|
|
b.mu.Lock()
|
|
state := b.outputs[threadID]
|
|
if state == nil {
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
chatID := state.chatID
|
|
sentAny := state.sentAny
|
|
pictureRequest := state.pictureRequest
|
|
generatedImages := append([]generatedImageOutput(nil), state.generatedImages...)
|
|
workingIndicatorOff := state.workingIndicatorOff
|
|
delete(b.outputs, threadID)
|
|
b.mu.Unlock()
|
|
if workingIndicatorOff != nil {
|
|
workingIndicatorOff()
|
|
}
|
|
|
|
if pictureRequest {
|
|
if len(generatedImages) == 0 {
|
|
_, err := b.tg.SendMessage(ctx, chatID, "No image was generated.", SendMessageOptions{})
|
|
return err
|
|
}
|
|
return b.sendGeneratedImageOutputs(ctx, chatID, generatedImages)
|
|
}
|
|
if !sentAny {
|
|
_, err := b.tg.SendMessage(ctx, chatID, "Done.", SendMessageOptions{})
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) sendGeneratedImageOutputs(ctx context.Context, chatID int64, images []generatedImageOutput) error {
|
|
uploads := make([]PhotoUpload, 0, len(images))
|
|
for _, image := range images {
|
|
path := strings.TrimSpace(image.Path)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
b.logger.Printf("read generated image %s: %v", path, err)
|
|
continue
|
|
}
|
|
uploads = append(uploads, PhotoUpload{Filename: path, Data: data})
|
|
}
|
|
if len(uploads) == 0 {
|
|
_, err := b.tg.SendMessage(ctx, chatID, "Generated image file was not readable by the bot.", SendMessageOptions{})
|
|
return err
|
|
}
|
|
for len(uploads) > 0 {
|
|
count := len(uploads)
|
|
if count > pictureMediaGroupLimit {
|
|
count = pictureMediaGroupLimit
|
|
}
|
|
if _, err := b.tg.SendPhotoGroupBytes(ctx, chatID, uploads[:count]); err != nil {
|
|
return err
|
|
}
|
|
uploads = uploads[count:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) outputChatID(ctx context.Context, threadID string) (int64, error) {
|
|
b.mu.Lock()
|
|
state := b.outputs[threadID]
|
|
if state != nil {
|
|
chatID := state.chatID
|
|
b.mu.Unlock()
|
|
return chatID, nil
|
|
}
|
|
b.mu.Unlock()
|
|
return 0, sql.ErrNoRows
|
|
}
|
|
|
|
func (b *Bot) markOutputSent(threadID string) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if state := b.outputs[threadID]; state != nil {
|
|
state.sentAny = true
|
|
}
|
|
}
|
|
|
|
func (b *Bot) sendLong(ctx context.Context, chatID int64, text string) error {
|
|
for _, chunk := range ChunkText(text, TelegramHTMLMessageLimit) {
|
|
if err := b.sendHTML(ctx, chatID, EscapeHTML(chunk)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Bot) sendHTML(ctx context.Context, chatID int64, htmlText string) error {
|
|
_, err := b.sendHTMLMessage(ctx, chatID, htmlText, nil)
|
|
return err
|
|
}
|
|
|
|
func (b *Bot) sendHTMLMessage(ctx context.Context, chatID int64, htmlText string, markup *InlineKeyboardMarkup) (Message, error) {
|
|
return b.tg.SendMessage(ctx, chatID, htmlText, SendMessageOptions{ParseMode: "HTML", ReplyMarkup: markup})
|
|
}
|
|
|
|
func (b *Bot) sendError(ctx context.Context, chatID int64, prefix string, err error) error {
|
|
_, sendErr := b.tg.SendMessage(ctx, chatID, EscapeHTML(prefix+": "+err.Error()), SendMessageOptions{ParseMode: "HTML"})
|
|
return sendErr
|
|
}
|
|
|
|
func (b *Bot) sendWorkspaceMissing(ctx context.Context, chatID int64) error {
|
|
_, err := b.tg.SendMessage(ctx, chatID, "No workspace configured. Ask an admin to run scripts/add-workspace, then use /workspace.", SendMessageOptions{})
|
|
return err
|
|
}
|
|
|
|
func (b *Bot) sendNoActiveThread(ctx context.Context, chatID int64, err error) error {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
_, sendErr := b.tg.SendMessage(ctx, chatID, "No active thread. Use /new.", SendMessageOptions{})
|
|
return sendErr
|
|
}
|
|
return err
|
|
}
|
|
|
|
func parseCommand(text string) (string, []string, bool) {
|
|
text = strings.TrimSpace(text)
|
|
if !strings.HasPrefix(text, "/") {
|
|
return "", nil, false
|
|
}
|
|
fields := strings.Fields(text)
|
|
if len(fields) == 0 {
|
|
return "", nil, false
|
|
}
|
|
name := strings.TrimPrefix(fields[0], "/")
|
|
if before, _, ok := strings.Cut(name, "@"); ok {
|
|
name = before
|
|
}
|
|
return strings.ToLower(name), fields[1:], true
|
|
}
|
|
|
|
func resumeThreadListText(threads []store.Thread, page int) string {
|
|
lines := []string{fmt.Sprintf("Threads (page %d):", page+1), ""}
|
|
for _, thread := range threads {
|
|
lines = append(lines, fmt.Sprintf("Thread ID %d: %s", thread.ID, threadDisplayTitle(thread)))
|
|
}
|
|
lines = append(lines, "", "Choose a button below, or use /thread THREAD_ID directly.")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func resumeThreadMarkup(threads []store.Thread, page int, hasNext bool) *InlineKeyboardMarkup {
|
|
keyboard := make([][]InlineKeyboardButton, 0, 4)
|
|
for _, thread := range threads {
|
|
button := InlineKeyboardButton{
|
|
Text: fmt.Sprintf("ID %d", thread.ID),
|
|
CallbackData: ResumeThreadCallbackData(thread.ID),
|
|
}
|
|
if len(keyboard) == 0 || len(keyboard[len(keyboard)-1]) >= 4 {
|
|
keyboard = append(keyboard, []InlineKeyboardButton{button})
|
|
continue
|
|
}
|
|
keyboard[len(keyboard)-1] = append(keyboard[len(keyboard)-1], button)
|
|
}
|
|
var nav []InlineKeyboardButton
|
|
if page > 0 {
|
|
nav = append(nav, InlineKeyboardButton{Text: "Prev", CallbackData: ResumePageCallbackData(page - 1)})
|
|
}
|
|
if hasNext {
|
|
nav = append(nav, InlineKeyboardButton{Text: "Next", CallbackData: ResumePageCallbackData(page + 1)})
|
|
}
|
|
if len(nav) > 0 {
|
|
keyboard = append(keyboard, nav)
|
|
}
|
|
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
|
}
|
|
|
|
func normalizeThreadTitle(title string) string {
|
|
title = strings.Join(strings.Fields(title), " ")
|
|
runes := []rune(title)
|
|
if len(runes) > 80 {
|
|
title = string(runes[:80])
|
|
}
|
|
return title
|
|
}
|
|
|
|
func threadDisplayTitle(thread store.Thread) string {
|
|
title := strings.Join(strings.Fields(thread.Title), " ")
|
|
if title == "" {
|
|
title = thread.CodexThreadID
|
|
}
|
|
runes := []rune(title)
|
|
if len(runes) > 90 {
|
|
title = string(runes[:90]) + "..."
|
|
}
|
|
return title
|
|
}
|
|
|
|
func workspaceMarkup(workspaces []store.Workspace) *InlineKeyboardMarkup {
|
|
keyboard := make([][]InlineKeyboardButton, 0, len(workspaces))
|
|
for _, ws := range workspaces {
|
|
text := ws.Label
|
|
if ws.IsDefault {
|
|
text += " default"
|
|
}
|
|
keyboard = append(keyboard, []InlineKeyboardButton{{
|
|
Text: text,
|
|
CallbackData: WorkspaceCallbackData(ws.ID),
|
|
}})
|
|
}
|
|
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
|
}
|
|
|
|
func modelMarkup(models []codexapp.Model) *InlineKeyboardMarkup {
|
|
keyboard := make([][]InlineKeyboardButton, 0, len(models))
|
|
for _, model := range models {
|
|
callbackData, ok := ModelCallbackData(model.ID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
keyboard = append(keyboard, []InlineKeyboardButton{{
|
|
Text: modelLabel(model),
|
|
CallbackData: callbackData,
|
|
}})
|
|
}
|
|
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
|
}
|
|
|
|
func effortMarkup(model codexapp.Model) *InlineKeyboardMarkup {
|
|
keyboard := make([][]InlineKeyboardButton, 0, len(model.SupportedReasoningEfforts))
|
|
for _, option := range model.SupportedReasoningEfforts {
|
|
label := option.ReasoningEffort
|
|
if option.ReasoningEffort == model.DefaultReasoningEffort {
|
|
label += " default"
|
|
}
|
|
keyboard = append(keyboard, []InlineKeyboardButton{{
|
|
Text: label,
|
|
CallbackData: EffortCallbackData(option.ReasoningEffort),
|
|
}})
|
|
}
|
|
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
|
}
|
|
|
|
func 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()
|
|
}
|