Bake in thread directives
This commit is contained in:
@@ -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:
|
||||
|
||||
`<!-- codex-thread-cwd {"cwd":"<absolute-cwd>"} -->`
|
||||
|
||||
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 `<rollout>.bak.<timestamp>` 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
`<!-- codex-thread-rename {"title":"<short title>"} -->`
|
||||
|
||||
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.
|
||||
|
||||
|
||||
10
.env.example
10
.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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,8 +24,6 @@
|
||||
*.test
|
||||
coverage.out
|
||||
|
||||
# Local scratch assets
|
||||
/codex-telegram-bot-profile.jpg
|
||||
|
||||
# Editor/OS noise
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
2
PLAN.md
2
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
221
internal/codexstate/state.go
Normal file
221
internal/codexstate/state.go
Normal file
@@ -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
|
||||
}
|
||||
72
internal/codexstate/state_test.go
Normal file
72
internal/codexstate/state_test.go
Normal file
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user