telecodex 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -132
- package/dist/bot/commandSupport.js +3 -1
- package/dist/bot/createBot.js +59 -2
- package/dist/bot/handlers/projectHandlers.js +67 -8
- package/dist/bot/topicCleanup.js +80 -0
- package/dist/codex/sessionCatalog.js +215 -0
- package/dist/config.js +0 -1
- package/dist/runtime/appPaths.js +4 -1
- package/dist/runtime/bootstrap.js +11 -7
- package/dist/runtime/startTelecodex.js +5 -0
- package/dist/store/fileState.js +370 -0
- package/dist/store/legacyMigration.js +160 -0
- package/dist/store/projects.js +11 -33
- package/dist/store/sessions.js +138 -210
- package/package.json +2 -2
- package/dist/store/db.js +0 -267
package/README.md
CHANGED
|
@@ -1,179 +1,151 @@
|
|
|
1
1
|
# telecodex
|
|
2
2
|
|
|
3
|
-
Telegram
|
|
3
|
+
Use Telegram forum topics as a remote interface for local Codex.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Telegram private chat
|
|
9
|
-
-> one-time admin bootstrap
|
|
10
|
-
|
|
11
|
-
Telegram forum supergroup
|
|
12
|
-
-> one project per supergroup
|
|
13
|
-
-> one topic per Codex thread
|
|
14
|
-
|
|
15
|
-
Telegram
|
|
16
|
-
-> grammY bot + runner
|
|
17
|
-
-> CodexSdkRuntime
|
|
18
|
-
-> @openai/codex-sdk
|
|
19
|
-
-> local Codex login
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
The bot talks to local Codex through `@openai/codex-sdk`, which wraps the local
|
|
23
|
-
`codex` CLI. It does not depend on `codex app-server`.
|
|
24
|
-
|
|
25
|
-
## Runtime contract
|
|
26
|
-
|
|
27
|
-
- Telegram is treated as a remote task interface, not a clone of Codex Desktop.
|
|
28
|
-
- One topic maps to one Codex SDK thread.
|
|
29
|
-
- Each topic has at most one active SDK run.
|
|
30
|
-
- Follow-up messages during an active run are queued and processed in order.
|
|
31
|
-
- Text and image messages are mapped to Codex SDK input.
|
|
32
|
-
- A run immediately creates a normal Telegram status message; progress edits that message.
|
|
33
|
-
- telecodex does not use pinned messages for live state.
|
|
34
|
-
- While a run is pending, the bot sends Telegram `typing` activity so the chat does not look dead during long SDK gaps.
|
|
35
|
-
- `/status` is the source of truth for runtime state, active thread id, last SDK event, and queue depth.
|
|
5
|
+
`telecodex` connects a Telegram bot to your local `codex` CLI through the
|
|
6
|
+
official TypeScript SDK. It is meant for remote task execution, not as a full
|
|
7
|
+
clone of Codex Desktop.
|
|
36
8
|
|
|
37
9
|
## Requirements
|
|
38
10
|
|
|
39
|
-
- Node.js 24 or newer
|
|
40
|
-
- A local `codex` CLI installation available on `PATH
|
|
41
|
-
- A valid local Codex login
|
|
11
|
+
- Node.js 24 or newer
|
|
12
|
+
- A local `codex` CLI installation available on `PATH`
|
|
13
|
+
- A valid local Codex login
|
|
14
|
+
- A Telegram bot token
|
|
15
|
+
|
|
16
|
+
Check Codex login first:
|
|
42
17
|
|
|
43
18
|
```bash
|
|
44
19
|
codex login status
|
|
45
20
|
```
|
|
46
21
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
## Install from npm
|
|
22
|
+
## Install
|
|
50
23
|
|
|
51
24
|
```bash
|
|
52
25
|
npm install -g telecodex
|
|
53
26
|
telecodex
|
|
54
27
|
```
|
|
55
28
|
|
|
56
|
-
`telecodex`
|
|
57
|
-
|
|
29
|
+
Installing `telecodex` does not replace the separate `codex` CLI. The bot uses
|
|
30
|
+
your local Codex installation at runtime.
|
|
58
31
|
|
|
59
|
-
##
|
|
32
|
+
## First Launch
|
|
60
33
|
|
|
61
|
-
|
|
34
|
+
On first launch, `telecodex`:
|
|
62
35
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
36
|
+
1. Finds or asks for the local `codex` binary path.
|
|
37
|
+
2. Verifies local Codex login.
|
|
38
|
+
3. Prompts for a Telegram bot token if none is stored yet.
|
|
39
|
+
4. Generates a one-time bootstrap code if no Telegram admin is bound yet.
|
|
40
|
+
5. Waits for that code in a private Telegram chat with the bot.
|
|
66
41
|
|
|
67
|
-
|
|
42
|
+
The first successful sender becomes the admin for that bot instance.
|
|
68
43
|
|
|
69
|
-
|
|
70
|
-
npm run dev
|
|
71
|
-
```
|
|
44
|
+
Optional security override:
|
|
72
45
|
|
|
73
|
-
|
|
74
|
-
|
|
46
|
+
- `TELECODEX_ALLOW_PLAINTEXT_TOKEN_FALLBACK=1` allows storing the Telegram bot
|
|
47
|
+
token unencrypted in local state when the system keychain is unavailable.
|
|
48
|
+
This is disabled by default.
|
|
75
49
|
|
|
76
|
-
##
|
|
50
|
+
## How It Works
|
|
77
51
|
|
|
78
|
-
|
|
52
|
+
- One Telegram forum supergroup represents one project.
|
|
53
|
+
- One topic inside that supergroup represents one Codex thread.
|
|
54
|
+
- Work happens by sending normal messages inside the topic.
|
|
55
|
+
- While a run is active, follow-up messages are queued automatically.
|
|
56
|
+
- `/status` shows the current runtime state.
|
|
79
57
|
|
|
80
|
-
|
|
81
|
-
- Organization or user: `jiangege`
|
|
82
|
-
- Repository: `telecodex`
|
|
83
|
-
- Workflow filename: `publish.yml`
|
|
84
|
-
2. Bump the version locally:
|
|
58
|
+
Private chat is only for bootstrap and lightweight admin actions.
|
|
85
59
|
|
|
86
|
-
|
|
87
|
-
|
|
60
|
+
## Quick Start
|
|
61
|
+
|
|
62
|
+
Inside a Telegram forum supergroup:
|
|
63
|
+
|
|
64
|
+
1. Bind the group to a project root:
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
/project bind /absolute/path/to/project
|
|
88
68
|
```
|
|
89
69
|
|
|
90
|
-
|
|
70
|
+
2. Create a fresh topic for a new Codex thread:
|
|
91
71
|
|
|
92
|
-
```
|
|
93
|
-
|
|
72
|
+
```text
|
|
73
|
+
/thread new My Task
|
|
94
74
|
```
|
|
95
75
|
|
|
96
|
-
|
|
97
|
-
runs `npm run check`, runs `npm test`, and publishes the package to npm when the tag
|
|
98
|
-
matches the version in `package.json`.
|
|
76
|
+
3. Or resume an existing thread:
|
|
99
77
|
|
|
100
|
-
|
|
78
|
+
```text
|
|
79
|
+
/thread list
|
|
80
|
+
/thread resume <threadId>
|
|
81
|
+
```
|
|
101
82
|
|
|
102
|
-
|
|
83
|
+
4. Send normal messages in the topic to work with Codex.
|
|
103
84
|
|
|
104
|
-
|
|
105
|
-
2. Verifies Codex login.
|
|
106
|
-
3. Asks for a Telegram bot token if none is stored yet.
|
|
107
|
-
4. Generates a one-time bootstrap code if no Telegram admin is bound yet.
|
|
108
|
-
5. Waits for that code in a private chat. The first successful sender becomes the permanent admin for this bot instance.
|
|
85
|
+
## Commands
|
|
109
86
|
|
|
110
|
-
|
|
87
|
+
### General
|
|
111
88
|
|
|
112
|
-
|
|
89
|
+
- `/start` or `/help` - show usage help
|
|
90
|
+
- `/status` - show current state
|
|
91
|
+
- `/stop` - interrupt the active run in the current topic
|
|
113
92
|
|
|
114
|
-
|
|
93
|
+
### Admin
|
|
115
94
|
|
|
116
|
-
- `
|
|
95
|
+
- `/admin` - show admin binding and handoff state
|
|
96
|
+
- `/admin rebind` - issue a temporary handoff code
|
|
97
|
+
- `/admin cancel` - cancel a pending handoff
|
|
117
98
|
|
|
118
|
-
|
|
99
|
+
### Project
|
|
119
100
|
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
- Each topic in that supergroup is one Codex thread.
|
|
124
|
-
- Work happens by sending normal messages inside the topic.
|
|
125
|
-
- `/thread new <topic-name>` automatically creates a new topic; the first normal message inside it starts a fresh Codex thread.
|
|
126
|
-
- `/thread resume <threadId>` automatically creates a new topic and binds it to an existing thread id.
|
|
101
|
+
- `/project` - show the current project binding
|
|
102
|
+
- `/project bind <absolute-path>` - bind the current supergroup to a project root
|
|
103
|
+
- `/project unbind` - remove the project binding
|
|
127
104
|
|
|
128
|
-
|
|
105
|
+
### Threads
|
|
129
106
|
|
|
130
|
-
-
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
107
|
+
- `/thread` - show the current attached thread id in a topic
|
|
108
|
+
- `/thread list` - list saved Codex threads for the current project
|
|
109
|
+
- `/thread new <topic-name>` - create a fresh topic for a new thread
|
|
110
|
+
- `/thread resume <threadId>` - create a topic and bind it to an existing thread
|
|
134
111
|
|
|
135
|
-
|
|
112
|
+
### Session Configuration
|
|
136
113
|
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
114
|
+
- `/cwd <absolute-path>`
|
|
115
|
+
- `/mode read|write|danger|yolo`
|
|
116
|
+
- `/sandbox <read-only|workspace-write|danger-full-access>`
|
|
117
|
+
- `/approval <on-request|on-failure|never>`
|
|
118
|
+
- `/yolo on|off`
|
|
119
|
+
- `/model <model-id>`
|
|
120
|
+
- `/effort default|minimal|low|medium|high|xhigh`
|
|
121
|
+
- `/web default|disabled|cached|live`
|
|
122
|
+
- `/network on|off`
|
|
123
|
+
- `/gitcheck skip|enforce`
|
|
124
|
+
- `/adddir list|add <path-inside-project>|add-external <absolute-path>|drop <index>|clear`
|
|
125
|
+
- `/schema show|set <JSON object>|clear`
|
|
126
|
+
- `/codexconfig show|set <JSON object>|clear`
|
|
140
127
|
|
|
141
|
-
##
|
|
128
|
+
## Images
|
|
129
|
+
|
|
130
|
+
- Sending an image in a topic is supported.
|
|
131
|
+
- Telegram photos and image documents are downloaded locally and sent to Codex as
|
|
132
|
+
`local_image` input.
|
|
133
|
+
- Image output is not rendered inline in Telegram text messages.
|
|
134
|
+
|
|
135
|
+
## Storage
|
|
136
|
+
|
|
137
|
+
- Telegram bot token: stored in the system keychain when available
|
|
138
|
+
- Durable local state: `~/.telecodex/state/`
|
|
139
|
+
- Runtime logs: `~/.telecodex/logs/telecodex.log`
|
|
140
|
+
- Codex thread history: read from Codex session files under `$CODEX_HOME/sessions`
|
|
141
|
+
(or `~/.codex/sessions` by default)
|
|
142
|
+
|
|
143
|
+
If an older `~/.telecodex/state.sqlite` exists, telecodex imports it once into
|
|
144
|
+
the JSON state files and then removes the old SQLite files.
|
|
145
|
+
|
|
146
|
+
## Troubleshooting
|
|
147
|
+
|
|
148
|
+
- If startup reports a login problem, run `codex login`.
|
|
149
|
+
- If the bot appears idle for a long time, check `/status`.
|
|
150
|
+
- If you need logs, inspect `~/.telecodex/logs/telecodex.log`.
|
|
142
151
|
|
|
143
|
-
- `/start` or `/help` - show the current usage model.
|
|
144
|
-
- `/status` - in private chat shows global state; in a project topic shows project/thread runtime state.
|
|
145
|
-
- `/admin` - in private chat, show admin binding and handoff status.
|
|
146
|
-
- `/admin rebind` - in private chat, issue a time-limited handoff code for transferring control to another Telegram account.
|
|
147
|
-
- `/admin cancel` - in private chat, cancel a pending admin handoff code.
|
|
148
|
-
- `/project` - show the current supergroup's project binding.
|
|
149
|
-
- `/project bind <absolute-path>` - bind the current supergroup to a project root.
|
|
150
|
-
- `/project unbind` - remove the current supergroup's project binding.
|
|
151
|
-
- `/thread` - in a topic, show the current attached thread id.
|
|
152
|
-
- `/thread new <topic-name>` - create a new topic for a fresh Codex thread.
|
|
153
|
-
- `/thread resume <threadId>` - create a new topic and bind it to an existing Codex thread id.
|
|
154
|
-
- Normal text in a topic - send that message to the current Codex thread.
|
|
155
|
-
- `/stop` - interrupt the active SDK run.
|
|
156
|
-
- `/cwd <absolute-path>` - switch the topic working directory inside the current project root.
|
|
157
|
-
- `/mode read|write|danger|yolo` - switch runtime presets for the current topic.
|
|
158
|
-
- `/sandbox <read-only|workspace-write|danger-full-access>` - set sandbox explicitly for the current topic.
|
|
159
|
-
- `/approval <on-request|on-failure|never>` - set approval policy explicitly for the current topic.
|
|
160
|
-
- `/yolo on|off` - quick toggle for `danger-full-access + never` on the current topic.
|
|
161
|
-
- `/model <model-id>` - set model for the current topic.
|
|
162
|
-
- `/effort default|minimal|low|medium|high|xhigh` - set model reasoning effort for the current topic.
|
|
163
|
-
- `/web default|disabled|cached|live` - set Codex SDK web search mode.
|
|
164
|
-
- `/network on|off` - set workspace network access for Codex SDK runs.
|
|
165
|
-
- `/gitcheck skip|enforce` - control Codex SDK git repository checks.
|
|
166
|
-
- `/adddir list|add <path-inside-project>|add-external <absolute-path>|drop <index>|clear` - manage Codex SDK additional directories. `add` stays inside the project root; `add-external` is the explicit escape hatch.
|
|
167
|
-
- `/schema show|set <JSON object>|clear` - manage Codex SDK output schema for the current topic.
|
|
168
|
-
- `/codexconfig show|set <JSON object>|clear` - manage global non-auth Codex SDK config overrides for future runs.
|
|
169
|
-
- Image messages in a topic - download the Telegram image locally and send it as SDK `local_image` input.
|
|
170
|
-
|
|
171
|
-
## Notes
|
|
172
|
-
|
|
173
|
-
- Long polling is managed by `@grammyjs/runner`.
|
|
174
|
-
- Streaming updates are throttled before editing Telegram messages.
|
|
175
|
-
- Final answers are rendered from Markdown to Telegram-safe HTML.
|
|
176
|
-
- Project-scoped path checks resolve symlinks before enforcing the root boundary, so topic cwd changes cannot escape the bound project through symlink paths.
|
|
177
|
-
- Because the SDK run is in-process, a telecodex restart cannot resume a partially streamed Telegram turn; the topic is reset and the user is asked to resend.
|
|
178
|
-
- Authentication and provider switching remain owned by the local Codex installation; telecodex does not manage API keys or login state.
|
|
179
|
-
- Interactive terminal stdin bridging and native Codex approval UI are intentionally not part of the Telegram contract. For unattended remote work, use the topic's sandbox/approval preset deliberately.
|
|
@@ -47,6 +47,7 @@ export function formatHelpText(ctx, projects) {
|
|
|
47
47
|
"/project bind <absolute-path>",
|
|
48
48
|
"",
|
|
49
49
|
"Then manage threads in the group:",
|
|
50
|
+
"/thread list",
|
|
50
51
|
"/thread new <topic-name>",
|
|
51
52
|
"/thread resume <threadId>",
|
|
52
53
|
"",
|
|
@@ -80,6 +81,7 @@ export function formatHelpText(ctx, projects) {
|
|
|
80
81
|
"",
|
|
81
82
|
"/project show the project binding",
|
|
82
83
|
"/project bind <absolute-path> update the project root",
|
|
84
|
+
"/thread list show saved Codex threads already recorded for this project",
|
|
83
85
|
"/thread new <topic-name> create a new topic; the first message starts a new thread",
|
|
84
86
|
"/thread resume <threadId> create a topic bound to an existing thread",
|
|
85
87
|
"send a normal message inside a topic to the current thread",
|
|
@@ -135,7 +137,7 @@ export function formatProjectStatus(project) {
|
|
|
135
137
|
"Project status",
|
|
136
138
|
`project: ${project.name}`,
|
|
137
139
|
`root: ${project.cwd}`,
|
|
138
|
-
"This supergroup represents one project. Use /thread new or /thread resume to
|
|
140
|
+
"This supergroup represents one project. Use /thread list, /thread new, or /thread resume to manage topics.",
|
|
139
141
|
].join("\n");
|
|
140
142
|
}
|
|
141
143
|
export function ensureTopicSession(input) {
|
package/dist/bot/createBot.js
CHANGED
|
@@ -3,9 +3,10 @@ import { MessageBuffer } from "../telegram/messageBuffer.js";
|
|
|
3
3
|
import { authMiddleware } from "./auth.js";
|
|
4
4
|
import { recoverActiveTopicSessions } from "./inputService.js";
|
|
5
5
|
import { registerHandlers } from "./registerHandlers.js";
|
|
6
|
+
import { cleanupMissingTopicBindings } from "./topicCleanup.js";
|
|
6
7
|
export { handleUserText, refreshSessionIfActiveTurnIsStale } from "./inputService.js";
|
|
7
8
|
export function wireBot(input) {
|
|
8
|
-
const { bot, config, store, projects, codex, bootstrapCode, logger, onAdminBound } = input;
|
|
9
|
+
const { bot, config, store, projects, codex, threadCatalog, bootstrapCode, logger, onAdminBound } = input;
|
|
9
10
|
const buffers = new MessageBuffer(bot, config.updateIntervalMs, logger?.child("message-buffer"));
|
|
10
11
|
bot.use(authMiddleware({
|
|
11
12
|
store,
|
|
@@ -30,10 +31,24 @@ export function wireBot(input) {
|
|
|
30
31
|
store,
|
|
31
32
|
projects,
|
|
32
33
|
codex,
|
|
34
|
+
threadCatalog,
|
|
33
35
|
buffers,
|
|
34
36
|
...(logger ? { logger } : {}),
|
|
35
37
|
});
|
|
36
|
-
void
|
|
38
|
+
void (async () => {
|
|
39
|
+
try {
|
|
40
|
+
await syncBotCommands(bot, logger);
|
|
41
|
+
await cleanupMissingTopicBindings({
|
|
42
|
+
bot,
|
|
43
|
+
store,
|
|
44
|
+
...(logger ? { logger: logger.child("topic-cleanup") } : {}),
|
|
45
|
+
});
|
|
46
|
+
await recoverActiveTopicSessions(store, codex, buffers, bot, logger);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
logger?.error("startup topic reconciliation failed", error);
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
37
52
|
return {
|
|
38
53
|
bot,
|
|
39
54
|
buffers,
|
|
@@ -48,3 +63,45 @@ export function createBot(input) {
|
|
|
48
63
|
});
|
|
49
64
|
return bot;
|
|
50
65
|
}
|
|
66
|
+
async function syncBotCommands(bot, logger) {
|
|
67
|
+
try {
|
|
68
|
+
await bot.api.setMyCommands(privateCommands, {
|
|
69
|
+
scope: { type: "all_private_chats" },
|
|
70
|
+
});
|
|
71
|
+
await bot.api.setMyCommands(groupCommands, {
|
|
72
|
+
scope: { type: "all_group_chats" },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
logger?.warn("failed to sync telegram bot commands", {
|
|
77
|
+
error: error instanceof Error ? error.message : String(error),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const privateCommands = [
|
|
82
|
+
{ command: "start", description: "Show help" },
|
|
83
|
+
{ command: "help", description: "Show help" },
|
|
84
|
+
{ command: "status", description: "Show bot status" },
|
|
85
|
+
{ command: "admin", description: "Show or hand off admin access" },
|
|
86
|
+
];
|
|
87
|
+
const groupCommands = [
|
|
88
|
+
{ command: "help", description: "Show help" },
|
|
89
|
+
{ command: "status", description: "Show project or topic status" },
|
|
90
|
+
{ command: "project", description: "Show, bind, or unbind project" },
|
|
91
|
+
{ command: "thread", description: "List, resume, or create topics" },
|
|
92
|
+
{ command: "queue", description: "List, drop, or clear queued inputs" },
|
|
93
|
+
{ command: "stop", description: "Stop the active run" },
|
|
94
|
+
{ command: "cwd", description: "Show or set topic directory" },
|
|
95
|
+
{ command: "mode", description: "Switch preset mode" },
|
|
96
|
+
{ command: "sandbox", description: "Show or set sandbox mode" },
|
|
97
|
+
{ command: "approval", description: "Show or set approval mode" },
|
|
98
|
+
{ command: "yolo", description: "Enable or disable YOLO mode" },
|
|
99
|
+
{ command: "model", description: "Show or set model" },
|
|
100
|
+
{ command: "effort", description: "Show or set reasoning effort" },
|
|
101
|
+
{ command: "web", description: "Show or set web search" },
|
|
102
|
+
{ command: "network", description: "Show or set network access" },
|
|
103
|
+
{ command: "gitcheck", description: "Show or set git repo check" },
|
|
104
|
+
{ command: "adddir", description: "List or manage extra directories" },
|
|
105
|
+
{ command: "schema", description: "Show or set output schema" },
|
|
106
|
+
{ command: "codexconfig", description: "Show or set Codex config" },
|
|
107
|
+
];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, resolveExistingDirectory, } from "../commandSupport.js";
|
|
2
3
|
import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
|
|
3
4
|
const PROJECT_REQUIRED_MESSAGE = "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.";
|
|
@@ -82,12 +83,17 @@ export function registerProjectHandlers(deps) {
|
|
|
82
83
|
`queue: ${store.getQueuedInputCount(session.sessionKey)}`,
|
|
83
84
|
`cwd: ${session.cwd}`,
|
|
84
85
|
"Manage threads in this project:",
|
|
86
|
+
"/thread list",
|
|
85
87
|
"/thread resume <threadId>",
|
|
86
88
|
"/thread new <topic-name>",
|
|
87
89
|
].join("\n"));
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
90
|
-
await ctx.reply("Usage:\n/thread resume <threadId>\n/thread new <topic-name>");
|
|
92
|
+
await ctx.reply("Usage:\n/thread list\n/thread resume <threadId>\n/thread new <topic-name>");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (command === "list") {
|
|
96
|
+
await listProjectThreads(ctx, deps);
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
93
99
|
if (command === "resume") {
|
|
@@ -102,7 +108,7 @@ export function registerProjectHandlers(deps) {
|
|
|
102
108
|
await createFreshThreadTopic(ctx, deps, args);
|
|
103
109
|
return;
|
|
104
110
|
}
|
|
105
|
-
await ctx.reply("Usage:\n/thread resume <threadId>\n/thread new <topic-name>");
|
|
111
|
+
await ctx.reply("Usage:\n/thread list\n/thread resume <threadId>\n/thread new <topic-name>");
|
|
106
112
|
});
|
|
107
113
|
bot.on(["message:forum_topic_created", "message:forum_topic_edited"], async (ctx) => {
|
|
108
114
|
const threadId = ctx.message.message_thread_id;
|
|
@@ -119,13 +125,26 @@ export function registerProjectHandlers(deps) {
|
|
|
119
125
|
});
|
|
120
126
|
}
|
|
121
127
|
async function resumeThreadIntoTopic(ctx, deps, threadId) {
|
|
122
|
-
const { bot, config, store, projects, logger } = deps;
|
|
128
|
+
const { bot, config, store, projects, logger, threadCatalog } = deps;
|
|
123
129
|
const project = getProjectForContext(ctx, projects);
|
|
124
130
|
if (!project) {
|
|
125
131
|
await ctx.reply(PROJECT_REQUIRED_MESSAGE);
|
|
126
132
|
return;
|
|
127
133
|
}
|
|
128
|
-
const
|
|
134
|
+
const thread = await threadCatalog.findProjectThreadById({
|
|
135
|
+
projectRoot: project.cwd,
|
|
136
|
+
threadId,
|
|
137
|
+
});
|
|
138
|
+
if (!thread) {
|
|
139
|
+
await ctx.reply([
|
|
140
|
+
"Could not find a saved Codex thread with that id under this project.",
|
|
141
|
+
`project root: ${project.cwd}`,
|
|
142
|
+
`thread: ${threadId}`,
|
|
143
|
+
"Run /thread list to inspect the saved project threads first.",
|
|
144
|
+
].join("\n"));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const topicName = formatTopicName(thread.preview, `Resumed ${thread.id.slice(0, 8)}`);
|
|
129
148
|
const forumTopic = await bot.api.createForumTopic(ctx.chat.id, topicName);
|
|
130
149
|
const session = ensureTopicSession({
|
|
131
150
|
store,
|
|
@@ -134,24 +153,25 @@ async function resumeThreadIntoTopic(ctx, deps, threadId) {
|
|
|
134
153
|
chatId: ctx.chat.id,
|
|
135
154
|
messageThreadId: forumTopic.message_thread_id,
|
|
136
155
|
topicName: forumTopic.name,
|
|
137
|
-
threadId,
|
|
156
|
+
threadId: thread.id,
|
|
138
157
|
});
|
|
139
158
|
logger?.info("thread id bound into topic", {
|
|
140
159
|
...contextLogFields(ctx),
|
|
141
160
|
sessionKey: session.sessionKey,
|
|
142
|
-
threadId,
|
|
161
|
+
threadId: thread.id,
|
|
143
162
|
topicName: forumTopic.name,
|
|
144
163
|
});
|
|
145
164
|
await ctx.reply([
|
|
146
165
|
"Created a topic and bound it to the existing thread id.",
|
|
147
166
|
`topic: ${forumTopic.name}`,
|
|
148
167
|
`topic id: ${forumTopic.message_thread_id}`,
|
|
149
|
-
`thread: ${
|
|
168
|
+
`thread: ${thread.id}`,
|
|
169
|
+
`cwd: ${thread.cwd}`,
|
|
150
170
|
"Future messages in this topic will continue on that thread through the Codex SDK.",
|
|
151
171
|
].join("\n"));
|
|
152
172
|
await postTopicReadyMessage(bot, session, [
|
|
153
173
|
"This topic is now bound to an existing Codex thread id.",
|
|
154
|
-
`thread: ${
|
|
174
|
+
`thread: ${thread.id}`,
|
|
155
175
|
"Send a message to continue.",
|
|
156
176
|
].join("\n"));
|
|
157
177
|
}
|
|
@@ -190,3 +210,42 @@ async function createFreshThreadTopic(ctx, deps, requestedName) {
|
|
|
190
210
|
`model: ${session.model}`,
|
|
191
211
|
].join("\n"));
|
|
192
212
|
}
|
|
213
|
+
async function listProjectThreads(ctx, deps) {
|
|
214
|
+
const { projects, store, threadCatalog } = deps;
|
|
215
|
+
const project = getProjectForContext(ctx, projects);
|
|
216
|
+
if (!project) {
|
|
217
|
+
await ctx.reply(PROJECT_REQUIRED_MESSAGE);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const threads = await threadCatalog.listProjectThreads({
|
|
221
|
+
projectRoot: project.cwd,
|
|
222
|
+
limit: 8,
|
|
223
|
+
});
|
|
224
|
+
if (threads.length === 0) {
|
|
225
|
+
await ctx.reply([
|
|
226
|
+
"No saved Codex threads were found for this project yet.",
|
|
227
|
+
`project root: ${project.cwd}`,
|
|
228
|
+
].join("\n"));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const lines = [
|
|
232
|
+
`Saved Codex threads for ${project.name}:`,
|
|
233
|
+
...threads.flatMap((thread, index) => {
|
|
234
|
+
const relativeCwd = path.relative(project.cwd, thread.cwd) || ".";
|
|
235
|
+
const bound = store.getByThreadId(thread.id);
|
|
236
|
+
return [
|
|
237
|
+
`${index + 1}. ${thread.preview}`,
|
|
238
|
+
` id: ${thread.id}`,
|
|
239
|
+
` cwd: ${relativeCwd}`,
|
|
240
|
+
` updated: ${thread.updatedAt}`,
|
|
241
|
+
` source: ${thread.source ?? "unknown"}`,
|
|
242
|
+
...(bound
|
|
243
|
+
? [` bound: ${bound.telegramTopicName ?? bound.messageThreadId ?? bound.sessionKey}`]
|
|
244
|
+
: []),
|
|
245
|
+
];
|
|
246
|
+
}),
|
|
247
|
+
"",
|
|
248
|
+
"Resume one with /thread resume <threadId>",
|
|
249
|
+
];
|
|
250
|
+
await ctx.reply(lines.join("\n"));
|
|
251
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { GrammyError } from "grammy";
|
|
2
|
+
import { sendTypingAction } from "../telegram/delivery.js";
|
|
3
|
+
export async function cleanupMissingTopicBindings(input) {
|
|
4
|
+
const sessions = input.store.listTopicSessions();
|
|
5
|
+
const summary = {
|
|
6
|
+
total: sessions.length,
|
|
7
|
+
checked: 0,
|
|
8
|
+
kept: 0,
|
|
9
|
+
removed: 0,
|
|
10
|
+
skipped: 0,
|
|
11
|
+
failed: 0,
|
|
12
|
+
};
|
|
13
|
+
for (const session of sessions) {
|
|
14
|
+
const chatId = Number(session.chatId);
|
|
15
|
+
const messageThreadId = Number(session.messageThreadId);
|
|
16
|
+
if (!Number.isSafeInteger(chatId) || !Number.isSafeInteger(messageThreadId)) {
|
|
17
|
+
summary.skipped += 1;
|
|
18
|
+
input.logger?.warn("skipped topic binding cleanup for non-numeric telegram identifiers", {
|
|
19
|
+
sessionKey: session.sessionKey,
|
|
20
|
+
chatId: session.chatId,
|
|
21
|
+
messageThreadId: session.messageThreadId,
|
|
22
|
+
});
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
summary.checked += 1;
|
|
26
|
+
try {
|
|
27
|
+
await sendTypingAction(input.bot, {
|
|
28
|
+
chatId,
|
|
29
|
+
messageThreadId,
|
|
30
|
+
}, input.logger);
|
|
31
|
+
summary.kept += 1;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!isMissingTopicBindingError(error)) {
|
|
35
|
+
summary.failed += 1;
|
|
36
|
+
input.logger?.warn("topic binding cleanup probe failed", {
|
|
37
|
+
sessionKey: session.sessionKey,
|
|
38
|
+
chatId,
|
|
39
|
+
messageThreadId,
|
|
40
|
+
error,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
input.store.remove(session.sessionKey);
|
|
45
|
+
summary.removed += 1;
|
|
46
|
+
input.logger?.info("removed stale telegram topic binding", {
|
|
47
|
+
sessionKey: session.sessionKey,
|
|
48
|
+
chatId,
|
|
49
|
+
messageThreadId,
|
|
50
|
+
codexThreadId: session.codexThreadId,
|
|
51
|
+
topicName: session.telegramTopicName,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
input.logger?.info("topic binding cleanup finished", summary);
|
|
56
|
+
return summary;
|
|
57
|
+
}
|
|
58
|
+
export function isMissingTopicBindingError(error) {
|
|
59
|
+
const description = describeError(error);
|
|
60
|
+
if (!description)
|
|
61
|
+
return false;
|
|
62
|
+
return [
|
|
63
|
+
"message thread not found",
|
|
64
|
+
"message thread was not found",
|
|
65
|
+
"forum topic not found",
|
|
66
|
+
"topic not found",
|
|
67
|
+
"thread not found",
|
|
68
|
+
"topic deleted",
|
|
69
|
+
"topic_deleted",
|
|
70
|
+
].some((fragment) => description.includes(fragment));
|
|
71
|
+
}
|
|
72
|
+
function describeError(error) {
|
|
73
|
+
if (error instanceof GrammyError) {
|
|
74
|
+
return typeof error.description === "string" ? error.description.toLowerCase() : null;
|
|
75
|
+
}
|
|
76
|
+
if (error instanceof Error) {
|
|
77
|
+
return error.message.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
return typeof error === "string" ? error.toLowerCase() : null;
|
|
80
|
+
}
|