From d00cdd8e9f1c7fc7e7cebc16d033dd5849c93010 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 13:05:53 +0000 Subject: [PATCH] Bake in thread directives --- .codex/skills/codex-thread-cwd/SKILL.md | 37 ++-- .codex/skills/thread-naming/SKILL.md | 12 +- .env.example | 10 +- .gitignore | 2 - Dockerfile | 2 +- PLAN.md | 2 +- README.md | 2 +- cmd/bot/main.go | 2 +- docker-compose.yml | 3 + internal/codexapp/client_test.go | 11 +- internal/codexstate/state.go | 221 ++++++++++++++++++++++++ internal/codexstate/state_test.go | 72 ++++++++ internal/config/config.go | 4 + internal/store/store_test.go | 5 +- internal/telegram/api.go | 23 ++- internal/telegram/bot.go | 138 +++++++++++++-- internal/telegram/render_test.go | 33 +++- 17 files changed, 518 insertions(+), 61 deletions(-) create mode 100644 internal/codexstate/state.go create mode 100644 internal/codexstate/state_test.go diff --git a/.codex/skills/codex-thread-cwd/SKILL.md b/.codex/skills/codex-thread-cwd/SKILL.md index bf250a4..17fc485 100644 --- a/.codex/skills/codex-thread-cwd/SKILL.md +++ b/.codex/skills/codex-thread-cwd/SKILL.md @@ -9,16 +9,20 @@ Use this skill to change a Codex thread's stored working directory (`cwd`) in Co ## Required Inputs -- Target `thread_id`, usually a Codex UUID. If the user refers to a client-specific current thread, inspect that client's state only to discover the Codex `thread_id`; do not edit that client state unless explicitly asked. -- Target absolute `cwd`. Do not assume a machine-specific path; derive it from the user's request, the current repo, or an explicit argument. +- Target absolute `cwd`. Derive it from the user's request, the current repo, or an explicit argument. Do not assume a machine-specific path. +- Target thread is normally the active Telegram/Codex thread unless the user specifies another one. -## Preferred Workflow +## Telegram Bot Directive -1. Resolve the target thread ID. - - For this repo's Telegram bot, read its configured SQLite DB only to discover `sessions.active_thread_id -> threads.codex_thread_id` for the relevant Telegram user. - - If ambiguous, list candidates and ask the user to choose. -2. Confirm the target cwd is absolute and exists, unless the user intentionally wants a path that does not exist yet. -3. Run the bundled script from the skill directory: +When working through this Telegram bot, emit exactly one hidden directive line in normal assistant output, outside code fences: + +`` + +The bot strips the directive, updates Codex-side cwd state, and syncs its local workspace tracking from the new Codex cwd. Do not run the bundled script or other tool calls when this directive is available. + +## Script Fallback + +Use the bundled script only outside the Telegram bot directive path, or when repairing state manually: ```bash python3 path/to/codex-thread-cwd/scripts/set_thread_cwd.py THREAD_ID /absolute/new/cwd @@ -26,23 +30,8 @@ python3 path/to/codex-thread-cwd/scripts/set_thread_cwd.py THREAD_ID /absolute/n Use `--codex-home`, `--state-db`, or `CODEX_HOME` when the Codex state is not under the current user's default Codex home. -4. Verify Codex-side state, preferably with app-server `thread/read` if a socket is available. DB-level verification from the script is acceptable when app-server is unavailable. -5. Trigger the dependent tool's normal sync path, such as asking a bot for `/workspace` or `/threads`. Do not manually insert or update downstream workspace rows unless the user explicitly asks for emergency repair. - ## Notes - The app-server schema exposes `cwd` on `thread/resume`, `thread/fork`, and `turn/start`; `thread/metadata/update` does not patch `cwd`. -- In observed behavior, `thread/resume` with `cwd` may return the original cwd and not mutate existing stored thread cwd. -- `turn/start.cwd` is schema-supported as an override for that turn and subsequent turns, but it starts a new turn. - The proven no-turn approach is to update Codex's own `state_*.sqlite` `threads.cwd` row and the first `session_meta.payload.cwd` line in the rollout JSONL. This is Codex-side state, not downstream client state. -- The script preserves a rollout backup by default; it writes `.bak.` unless `--no-backup` is passed. - -## Verification Examples - -Inspect Codex state directly: - -```bash -python3 path/to/codex-thread-cwd/scripts/set_thread_cwd.py THREAD_ID /absolute/new/cwd --verify-only -``` - -Read through app-server and check `result.thread.cwd` equals the requested path. +- The bot implementation performs that no-turn update when configured with access to Codex state. diff --git a/.codex/skills/thread-naming/SKILL.md b/.codex/skills/thread-naming/SKILL.md index 06e0fab..d1e2711 100644 --- a/.codex/skills/thread-naming/SKILL.md +++ b/.codex/skills/thread-naming/SKILL.md @@ -14,9 +14,17 @@ Keep thread titles short and useful for later resume lists. - Do not include dates, user names, punctuation-heavy labels, or generic words like "chat" unless needed. - Rename once the task goal is clear, and again only if the goal materially changes. -## How to Rename +## How to Rename in Telegram Bot Threads -- If the client exposes a thread rename or `thread/name/set` capability, use it with the concise title. +Emit exactly one hidden directive line in normal assistant output, outside code fences: + +`` + +The bot strips the directive, calls Codex thread rename, and syncs its local thread title. Do not use extra tool calls or Telegram commands when this directive is available. + +## Fallbacks + +- If the client exposes another direct thread rename capability, use it with the concise title. - If only Telegram commands are available, tell the user the exact `/rename TITLE` command to run. - If no rename mechanism is available, include a short suggested title in the final answer. diff --git a/.env.example b/.env.example index 36ee544..1aab987 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,13 @@ TELEGRAM_BOT_TOKEN= # Use absolute host paths. The socket is mounted at the same path in the container. -HOST_CODEX_SOCKET=/absolute/path/to/codex-telegram-bot/run/codex.sock -HOST_CODEX_RUN_DIR=/absolute/path/to/codex-telegram-bot/run -HOST_UPLOAD_DIR=/absolute/path/to/codex-telegram-bot/uploads +HOST_CODEX_SOCKET= +HOST_CODEX_RUN_DIR= +HOST_UPLOAD_DIR= + +# Codex home must be mounted at the same absolute path inside the container so rollout paths remain valid. +HOST_CODEX_HOME= +CODEX_STATE_DB= # Host-side SQLite directory mounted to /data in the container. DB_DIR=./data diff --git a/.gitignore b/.gitignore index 0a5d688..a44d553 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,6 @@ *.test coverage.out -# Local scratch assets -/codex-telegram-bot-profile.jpg # Editor/OS noise .DS_Store diff --git a/Dockerfile b/Dockerfile index 87e77ac..8bc4043 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN CGO_ENABLED=0 go build -o /out/codex-telegram-bot ./cmd/bot FROM alpine:3.20 -RUN adduser -D -h /home/bot bot +RUN adduser -D bot COPY --from=build /out/codex-telegram-bot /usr/local/bin/codex-telegram-bot USER bot diff --git a/PLAN.md b/PLAN.md index 8af1f4f..a9506be 100644 --- a/PLAN.md +++ b/PLAN.md @@ -47,7 +47,7 @@ Before implementation, carefully read the official docs and check against the gu - Started outside Compose with `scripts/start-codex-app-server`. - Default command: `codex app-server --listen unix://$HOST_CODEX_SOCKET`. - Uses host `~/.codex`, host workspaces, and host sandbox behavior. - - Socket defaults to an absolute path under the project, e.g. `/home/.../codex-telegram-bot/run/codex.sock`. + - Socket defaults to an absolute path under the project, e.g. `$PROJECT/run/codex.sock`. - Telegram UX: - One-to-one chats only; reject groups, supergroups, and channels. diff --git a/README.md b/README.md index d256f19..27c986f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Docker Compose runs only the Go Telegram bot. Codex runs on the host through `co ```sh scripts/allow-user 123456789 username - scripts/add-workspace /absolute/path/to/workspace "Workspace name" --default + scripts/add-workspace "$PWD" "Workspace name" --default ``` 4. Build the bot binary when source changes: diff --git a/cmd/bot/main.go b/cmd/bot/main.go index e821f4a..4516300 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -36,7 +36,7 @@ func main() { codex := codexapp.New(cfg.CodexSocketPath, cfg.AppVersion) defer codex.Close() - bot := telegram.NewBot(tg, st, codex, cfg.UploadDir, cfg.DefaultModel, cfg.DefaultSandbox, cfg.PollTimeout, logger) + bot := telegram.NewBot(tg, st, codex, cfg.UploadDir, cfg.CodexHome, cfg.CodexStateDB, cfg.DefaultModel, cfg.DefaultSandbox, cfg.PollTimeout, logger) logger.Printf("bot starting: db=%s codex_socket=%s upload_dir=%s", cfg.DatabasePath, cfg.CodexSocketPath, cfg.UploadDir) if err := bot.Run(ctx); err != nil && err != context.Canceled { logger.Fatalf("bot: %v", err) diff --git a/docker-compose.yml b/docker-compose.yml index a7e81c7..17104d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,11 @@ services: HOST_CODEX_SOCKET: ${HOST_CODEX_SOCKET:?set HOST_CODEX_SOCKET} HOST_UPLOAD_DIR: ${HOST_UPLOAD_DIR:?set HOST_UPLOAD_DIR} HOST_CODEX_RUN_DIR: ${HOST_CODEX_RUN_DIR:?set HOST_CODEX_RUN_DIR} + CODEX_HOME: ${HOST_CODEX_HOME:?set HOST_CODEX_HOME} + CODEX_STATE_DB: ${CODEX_STATE_DB:-} volumes: - ./bin/codex-telegram-bot:/app/codex-telegram-bot:ro - ${DB_DIR:-./data}:/data - ${HOST_CODEX_RUN_DIR:?set HOST_CODEX_RUN_DIR}:${HOST_CODEX_RUN_DIR:?set HOST_CODEX_RUN_DIR} - ${HOST_UPLOAD_DIR:?set HOST_UPLOAD_DIR}:${HOST_UPLOAD_DIR:?set HOST_UPLOAD_DIR} + - ${HOST_CODEX_HOME:?set HOST_CODEX_HOME}:${HOST_CODEX_HOME:?set HOST_CODEX_HOME} diff --git a/internal/codexapp/client_test.go b/internal/codexapp/client_test.go index a57e3a2..8dfe4ca 100644 --- a/internal/codexapp/client_test.go +++ b/internal/codexapp/client_test.go @@ -18,6 +18,7 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { defer cancel() socketPath := filepath.Join(t.TempDir(), "codex.sock") + projectCWD := filepath.Join(t.TempDir(), "project") serverDone := make(chan error, 1) ln, err := net.Listen("unix", socketPath) if err != nil { @@ -79,7 +80,7 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { if err := conn.WriteJSON(map[string]any{ "id": start["id"], "result": map[string]any{ - "cwd": "/tmp/project", + "cwd": projectCWD, "thread": map[string]any{"id": "thr_1", "preview": "test"}, }, }); err != nil { @@ -105,7 +106,7 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { if err := conn.WriteJSON(map[string]any{ "id": readThread["id"], "result": map[string]any{ - "thread": map[string]any{"id": "thr_1", "name": "Read title", "preview": "test", "cwd": "/tmp/project"}, + "thread": map[string]any{"id": "thr_1", "name": "Read title", "preview": "test", "cwd": projectCWD}, }, }); err != nil { serverDone <- err @@ -164,18 +165,18 @@ func TestClientWebSocketUnixJSONRPC(t *testing.T) { t.Fatal(ctx.Err()) } - thread, err := client.StartThread(ctx, "/tmp/project", "", "workspace-write") + thread, err := client.StartThread(ctx, projectCWD, "", "workspace-write") if err != nil { t.Fatal(err) } - if thread.ID != "thr_1" || thread.CWD != "/tmp/project" { + if thread.ID != "thr_1" || thread.CWD != projectCWD { t.Fatalf("unexpected thread: %+v", thread) } readThread, err := client.ReadThread(ctx, "thr_1") if err != nil { t.Fatal(err) } - if readThread.ID != "thr_1" || readThread.Name != "Read title" || readThread.CWD != "/tmp/project" { + if readThread.ID != "thr_1" || readThread.Name != "Read title" || readThread.CWD != projectCWD { t.Fatalf("unexpected read thread: %+v", readThread) } if err := client.SetThreadName(ctx, "thr_1", "Short title"); err != nil { diff --git a/internal/codexstate/state.go b/internal/codexstate/state.go new file mode 100644 index 0000000..678b7ee --- /dev/null +++ b/internal/codexstate/state.go @@ -0,0 +1,221 @@ +package codexstate + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type ThreadCWDUpdate struct { + StateDB string + Rollout string + Before string + After string +} + +func SetThreadCWD(ctx context.Context, codexHome, stateDB, threadID, cwd string) (ThreadCWDUpdate, error) { + threadID = strings.TrimSpace(threadID) + if threadID == "" { + return ThreadCWDUpdate{}, errors.New("thread id is required") + } + clean, err := validateCWD(cwd) + if err != nil { + return ThreadCWDUpdate{}, err + } + dbPath, err := findStateDB(codexHome, stateDB) + if err != nil { + return ThreadCWDUpdate{}, err + } + + row, err := readThreadState(ctx, dbPath, threadID) + if err != nil { + return ThreadCWDUpdate{}, err + } + if row.CWD == clean { + return ThreadCWDUpdate{StateDB: dbPath, Rollout: row.RolloutPath, Before: row.CWD, After: clean}, nil + } + if err := updateRolloutCWD(row.RolloutPath, threadID, clean); err != nil { + return ThreadCWDUpdate{}, err + } + if err := updateStateDBCWD(ctx, dbPath, threadID, clean); err != nil { + return ThreadCWDUpdate{}, err + } + return ThreadCWDUpdate{StateDB: dbPath, Rollout: row.RolloutPath, Before: row.CWD, After: clean}, nil +} + +type threadStateRow struct { + CWD string + RolloutPath string +} + +func validateCWD(cwd string) (string, error) { + cwd = strings.TrimSpace(cwd) + if cwd == "" { + return "", errors.New("cwd is required") + } + if strings.ContainsRune(cwd, 0) { + return "", errors.New("cwd contains a NUL byte") + } + if !filepath.IsAbs(cwd) { + return "", fmt.Errorf("cwd must be absolute: %s", cwd) + } + clean := filepath.Clean(cwd) + if clean == string(filepath.Separator) { + return "", errors.New("refusing to set cwd to filesystem root") + } + return clean, nil +} + +func findStateDB(codexHome, explicit string) (string, error) { + if strings.TrimSpace(explicit) != "" { + path := filepath.Clean(strings.TrimSpace(explicit)) + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("state DB %s: %w", path, err) + } + return path, nil + } + home := strings.TrimSpace(codexHome) + if home == "" { + if env := strings.TrimSpace(os.Getenv("CODEX_HOME")); env != "" { + home = env + } else if userHome, err := os.UserHomeDir(); err == nil { + home = filepath.Join(userHome, ".codex") + } + } + if home == "" { + return "", errors.New("CODEX_HOME is not configured") + } + matches, err := filepath.Glob(filepath.Join(filepath.Clean(home), "state_*.sqlite")) + if err != nil { + return "", err + } + if len(matches) == 0 { + return "", fmt.Errorf("no state_*.sqlite found under %s", filepath.Clean(home)) + } + best := matches[0] + bestInfo, _ := os.Stat(best) + for _, candidate := range matches[1:] { + info, err := os.Stat(candidate) + if err != nil { + continue + } + if bestInfo == nil || info.ModTime().After(bestInfo.ModTime()) { + best = candidate + bestInfo = info + } + } + return best, nil +} + +func readThreadState(ctx context.Context, dbPath, threadID string) (threadStateRow, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return threadStateRow{}, err + } + defer db.Close() + var row threadStateRow + err = db.QueryRowContext(ctx, "SELECT cwd, rollout_path FROM threads WHERE id = ?", threadID).Scan(&row.CWD, &row.RolloutPath) + if errors.Is(err, sql.ErrNoRows) { + return threadStateRow{}, fmt.Errorf("thread not found in Codex state DB: %s", threadID) + } + if err != nil { + return threadStateRow{}, err + } + row.RolloutPath = strings.TrimSpace(row.RolloutPath) + if row.RolloutPath == "" { + return threadStateRow{}, fmt.Errorf("thread %s has no rollout path", threadID) + } + return row, nil +} + +func updateStateDBCWD(ctx context.Context, dbPath, threadID, cwd string) error { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return err + } + defer db.Close() + nowMS := time.Now().UnixMilli() + result, err := db.ExecContext(ctx, "UPDATE threads SET cwd = ?, updated_at = ?, updated_at_ms = ? WHERE id = ?", cwd, nowMS/1000, nowMS, threadID) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed != 1 { + return fmt.Errorf("updated %d Codex thread rows, expected 1", changed) + } + return nil +} + +func updateRolloutCWD(path, threadID, cwd string) error { + path = filepath.Clean(strings.TrimSpace(path)) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("open rollout %s: %w", path, err) + } + info, err := os.Stat(path) + if err != nil { + return err + } + lines := strings.SplitAfter(string(data), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + return fmt.Errorf("rollout JSONL is empty: %s", path) + } + firstLine := strings.TrimRight(lines[0], "\r\n") + + var first map[string]any + if err := json.Unmarshal([]byte(firstLine), &first); err != nil { + return fmt.Errorf("parse first rollout line: %w", err) + } + payload, _ := first["payload"].(map[string]any) + if first["type"] != "session_meta" || payload == nil || payload["id"] != threadID { + return fmt.Errorf("first rollout line is not session_meta for %s", threadID) + } + payload["cwd"] = cwd + first["payload"] = payload + updatedFirst, err := json.Marshal(first) + if err != nil { + return err + } + lines[0] = string(updatedFirst) + "\n" + + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".") + if err != nil { + return err + } + tmpPath := tmp.Name() + ok := false + defer func() { + if !ok { + _ = os.Remove(tmpPath) + } + }() + for _, line := range lines { + if _, err := tmp.WriteString(line); err != nil { + _ = tmp.Close() + return err + } + } + if err := tmp.Chmod(info.Mode().Perm()); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + ok = true + return nil +} diff --git a/internal/codexstate/state_test.go b/internal/codexstate/state_test.go new file mode 100644 index 0000000..06418c2 --- /dev/null +++ b/internal/codexstate/state_test.go @@ -0,0 +1,72 @@ +package codexstate + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + _ "modernc.org/sqlite" +) + +func TestSetThreadCWD(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + rollout := filepath.Join(dir, "rollout.jsonl") + first := map[string]any{"type": "session_meta", "payload": map[string]any{"id": "thr_1", "cwd": "/old"}} + encoded, err := json.Marshal(first) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(rollout, []byte(string(encoded)+"\n{\"type\":\"other\"}\n"), 0o644); err != nil { + t.Fatal(err) + } + dbPath := filepath.Join(dir, "state_test.sqlite") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "CREATE TABLE threads (id TEXT PRIMARY KEY, cwd TEXT, rollout_path TEXT, updated_at INTEGER, updated_at_ms INTEGER)"); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO threads (id, cwd, rollout_path) VALUES (?, ?, ?)", "thr_1", "/old", rollout); err != nil { + t.Fatal(err) + } + _ = db.Close() + + result, err := SetThreadCWD(ctx, "", dbPath, "thr_1", "/new/path") + if err != nil { + t.Fatal(err) + } + if result.Before != "/old" || result.After != "/new/path" { + t.Fatalf("unexpected result: %+v", result) + } + + db, err = sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var cwd string + if err := db.QueryRowContext(ctx, "SELECT cwd FROM threads WHERE id = ?", "thr_1").Scan(&cwd); err != nil { + t.Fatal(err) + } + if cwd != "/new/path" { + t.Fatalf("db cwd = %q", cwd) + } + data, err := os.ReadFile(rollout) + if err != nil { + t.Fatal(err) + } + var updated map[string]any + if err := json.Unmarshal([]byte(strings.SplitN(string(data), "\n", 2)[0]), &updated); err != nil { + t.Fatal(err) + } + payload := updated["payload"].(map[string]any) + if payload["cwd"] != "/new/path" { + t.Fatalf("rollout cwd = %v", payload["cwd"]) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 954d386..1bfabcc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,8 @@ type Config struct { TelegramToken string DatabasePath string CodexSocketPath string + CodexHome string + CodexStateDB string UploadDir string DefaultModel string DefaultSandbox string @@ -29,6 +31,8 @@ func Load() (Config, error) { TelegramToken: os.Getenv("TELEGRAM_BOT_TOKEN"), DatabasePath: envOr("DB_PATH", filepath.Join(wd, "data", "bot.db")), CodexSocketPath: envOr("HOST_CODEX_SOCKET", filepath.Join(wd, "run", "codex.sock")), + CodexHome: os.Getenv("CODEX_HOME"), + CodexStateDB: os.Getenv("CODEX_STATE_DB"), UploadDir: envOr("HOST_UPLOAD_DIR", filepath.Join(wd, "uploads")), DefaultModel: os.Getenv("DEFAULT_MODEL"), DefaultSandbox: envOr("DEFAULT_SANDBOX", "workspace-write"), diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ca99599..f3dc28a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -199,11 +199,12 @@ func TestValidateWorkspacePath(t *testing.T) { if _, err := ValidateWorkspacePath("/"); err == nil { t.Fatal("filesystem root should be rejected") } - clean, err := ValidateWorkspacePath("/tmp/../tmp/project") + input := string(filepath.Separator) + filepath.Join("tmp", "..", "tmp", "project") + clean, err := ValidateWorkspacePath(input) if err != nil { t.Fatal(err) } - if clean != "/tmp/project" { + if clean != filepath.Join(string(filepath.Separator), "tmp", "project") { t.Fatalf("unexpected clean path: %s", clean) } } diff --git a/internal/telegram/api.go b/internal/telegram/api.go index 5351860..c79ce27 100644 --- a/internal/telegram/api.go +++ b/internal/telegram/api.go @@ -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, "") +} + 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 { diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index c16b603..547e62e 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -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 = "" - telegramPhotoCaptionLimit = 1024 + telegramDownloadLimit = 20 * 1024 * 1024 + resumeThreadPageSize = 8 + commandSummaryLimit = 120 + telegramPhotoDirectiveStart = "" + 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) diff --git a/internal/telegram/render_test.go b/internal/telegram/render_test.go index b5fd79d..06fd67c 100644 --- a/internal/telegram/render_test.go +++ b/internal/telegram/render_test.go @@ -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(), "") { + 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\nafter" + photoPath := filepath.Join(string(filepath.Separator), "workspace", "photo.jpg") + text := fmt.Sprintf("before\n\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("\n", 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