Bake in thread directives

This commit is contained in:
Codex
2026-05-21 13:05:53 +00:00
parent 1a74c02173
commit d00cdd8e9f
17 changed files with 518 additions and 61 deletions

View File

@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
)
@@ -29,6 +30,20 @@ func NewClient(token string) *Client {
}
}
func (c *Client) redactError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s", c.redact(err.Error()))
}
func (c *Client) redact(text string) string {
if c.token == "" {
return text
}
return strings.ReplaceAll(text, c.token, "<telegram-token>")
}
func (c *Client) GetUpdates(ctx context.Context, offset int, timeoutSeconds int) ([]Update, error) {
params := map[string]any{
"offset": offset,
@@ -135,7 +150,7 @@ func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
return nil, c.redactError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
@@ -173,7 +188,7 @@ func (c *Client) SendPhotoBytes(ctx context.Context, chatID int64, filename stri
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return Message{}, err
return Message{}, c.redactError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
@@ -219,7 +234,7 @@ func (c *Client) SendDocumentBytes(ctx context.Context, chatID int64, filename s
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return Message{}, err
return Message{}, c.redactError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
@@ -248,7 +263,7 @@ func (c *Client) postJSON(ctx context.Context, method string, params any, result
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
return c.redactError(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {

View File

@@ -16,16 +16,19 @@ import (
"time"
"codex-telegram-bot/internal/codexapp"
"codex-telegram-bot/internal/codexstate"
"codex-telegram-bot/internal/store"
)
const (
telegramDownloadLimit = 20 * 1024 * 1024
resumeThreadPageSize = 8
commandSummaryLimit = 120
telegramPhotoDirectiveStart = "<!-- telegram-photo "
telegramPhotoDirectiveEnd = " -->"
telegramPhotoCaptionLimit = 1024
telegramDownloadLimit = 20 * 1024 * 1024
resumeThreadPageSize = 8
commandSummaryLimit = 120
telegramPhotoDirectiveStart = "<!-- telegram-photo "
telegramThreadRenameDirectiveStart = "<!-- codex-thread-rename "
telegramThreadCWDDirectiveStart = "<!-- codex-thread-cwd "
telegramDirectiveEnd = " -->"
telegramPhotoCaptionLimit = 1024
)
type Bot struct {
@@ -35,6 +38,8 @@ type Bot struct {
logger *log.Logger
uploadDir string
codexHome string
codexStateDB string
defaultModel string
defaultSandbox string
pollTimeout time.Duration
@@ -45,8 +50,10 @@ type Bot struct {
}
type assistantMessageSegment struct {
Text string
Photo *assistantPhotoDirective
Text string
Photo *assistantPhotoDirective
ThreadRename *assistantThreadRenameDirective
ThreadCWD *assistantThreadCWDDirective
}
type assistantPhotoDirective struct {
@@ -54,6 +61,14 @@ type assistantPhotoDirective struct {
Caption string `json:"caption,omitempty"`
}
type assistantThreadRenameDirective struct {
Title string `json:"title"`
}
type assistantThreadCWDDirective struct {
CWD string `json:"cwd"`
}
type outputState struct {
chatID int64
assistant strings.Builder
@@ -95,7 +110,7 @@ type codexThreadItemView struct {
} `json:"changes"`
}
func NewBot(tg *Client, st *store.Store, codex *codexapp.Client, uploadDir, defaultModel, defaultSandbox string, pollTimeout time.Duration, logger *log.Logger) *Bot {
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()
}
@@ -105,6 +120,8 @@ func NewBot(tg *Client, st *store.Store, codex *codexapp.Client, uploadDir, defa
codex: codex,
logger: logger,
uploadDir: uploadDir,
codexHome: codexHome,
codexStateDB: codexStateDB,
defaultModel: defaultModel,
defaultSandbox: defaultSandbox,
pollTimeout: pollTimeout,
@@ -1857,18 +1874,58 @@ func splitAssistantMessageSegments(text string) []assistantMessageSegment {
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, telegramPhotoDirectiveEnd) {
if !strings.HasPrefix(trimmed, telegramPhotoDirectiveStart) || !strings.HasSuffix(trimmed, telegramDirectiveEnd) {
return assistantPhotoDirective{}, false
}
raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramPhotoDirectiveStart), telegramPhotoDirectiveEnd)
raw := strings.TrimSuffix(strings.TrimPrefix(trimmed, telegramPhotoDirectiveStart), telegramDirectiveEnd)
raw = strings.TrimSpace(raw)
var directive assistantPhotoDirective
if err := json.Unmarshal([]byte(raw), &directive); err != nil {
@@ -1879,7 +1936,7 @@ func parseAssistantPhotoDirectiveLine(line string) (assistantPhotoDirective, boo
return directive, true
}
func (b *Bot) sendAssistantText(ctx context.Context, chatID int64, text string) error {
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 {
@@ -1894,10 +1951,65 @@ func (b *Bot) sendAssistantText(ctx context.Context, chatID int64, text string)
}
}
}
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 == "" {
@@ -1959,7 +2071,7 @@ func (b *Bot) flushAssistantMessage(ctx context.Context, threadID string) error
state.assistant.Reset()
b.mu.Unlock()
if err := b.sendAssistantText(ctx, chatID, text); err != nil {
if err := b.sendAssistantText(ctx, threadID, chatID, text); err != nil {
return err
}
b.markOutputSent(threadID)

View File

@@ -1,6 +1,8 @@
package telegram
import (
"fmt"
"path/filepath"
"strings"
"testing"
@@ -28,6 +30,17 @@ func TestChunkText(t *testing.T) {
}
}
func TestTelegramClientRedactsToken(t *testing.T) {
client := NewClient("secret-token")
err := client.redactError(fmt.Errorf("Post %q: context canceled", client.baseURL+"/sendMessage"))
if strings.Contains(err.Error(), "secret-token") {
t.Fatalf("token was not redacted: %v", err)
}
if !strings.Contains(err.Error(), "<telegram-token>") {
t.Fatalf("redacted token marker missing: %v", err)
}
}
func TestApprovalCallbackData(t *testing.T) {
data := ApprovalCallbackData(12, "accept")
id, decision, ok := ParseApprovalCallbackData(data)
@@ -78,7 +91,8 @@ func TestParseCommand(t *testing.T) {
}
func TestSplitAssistantMessageSegmentsWithPhotoDirective(t *testing.T) {
text := "before\n<!-- telegram-photo {\"path\":\"/tmp/photo.jpg\",\"caption\":\"hello\"} -->\nafter"
photoPath := filepath.Join(string(filepath.Separator), "workspace", "photo.jpg")
text := fmt.Sprintf("before\n<!-- telegram-photo {\"path\":%q,\"caption\":\"hello\"} -->\nafter", photoPath)
segments := splitAssistantMessageSegments(text)
if len(segments) != 3 {
t.Fatalf("segments = %d, want 3: %#v", len(segments), segments)
@@ -86,7 +100,7 @@ func TestSplitAssistantMessageSegmentsWithPhotoDirective(t *testing.T) {
if segments[0].Text != "before\n" || segments[0].Photo != nil {
t.Fatalf("unexpected first segment: %#v", segments[0])
}
if segments[1].Photo == nil || segments[1].Photo.Path != "/tmp/photo.jpg" || segments[1].Photo.Caption != "hello" {
if segments[1].Photo == nil || segments[1].Photo.Path != photoPath || segments[1].Photo.Caption != "hello" {
t.Fatalf("unexpected photo segment: %#v", segments[1])
}
if segments[2].Text != "after" || segments[2].Photo != nil {
@@ -102,6 +116,21 @@ func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) {
}
}
func TestSplitAssistantMessageSegmentsWithThreadDirectives(t *testing.T) {
cwd := filepath.Join(string(filepath.Separator), "workspace", "project")
text := fmt.Sprintf("<!-- codex-thread-rename {\"title\":\" A Better Thread Title \"} -->\n<!-- codex-thread-cwd {\"cwd\":%q} -->", cwd)
segments := splitAssistantMessageSegments(text)
if len(segments) != 2 {
t.Fatalf("segments = %d, want 2: %#v", len(segments), segments)
}
if segments[0].ThreadRename == nil || segments[0].ThreadRename.Title != "A Better Thread Title" {
t.Fatalf("unexpected rename segment: %#v", segments[0])
}
if segments[1].ThreadCWD == nil || segments[1].ThreadCWD.CWD != cwd {
t.Fatalf("unexpected cwd segment: %#v", segments[1])
}
}
func TestRenderCodexCommandExecutionItem(t *testing.T) {
output := "line 1\nline 2"
exitCode := 0