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 CHANGED
@@ -1,179 +1,151 @@
1
1
  # telecodex
2
2
 
3
- Telegram bridge for local Codex built on the official TypeScript SDK.
3
+ Use Telegram forum topics as a remote interface for local Codex.
4
4
 
5
- ## Model
6
-
7
- ```text
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
- - A Telegram bot token.
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` uses the local `codex` CLI at runtime, so installing this package
57
- does not replace the separate Codex CLI installation.
29
+ Installing `telecodex` does not replace the separate `codex` CLI. The bot uses
30
+ your local Codex installation at runtime.
58
31
 
59
- ## Local development
32
+ ## First Launch
60
33
 
61
- 1. Install dependencies:
34
+ On first launch, `telecodex`:
62
35
 
63
- ```bash
64
- npm install
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
- 2. Start it:
42
+ The first successful sender becomes the admin for that bot instance.
68
43
 
69
- ```bash
70
- npm run dev
71
- ```
44
+ Optional security override:
72
45
 
73
- For a production-style local install during development, `npm link` exposes the
74
- same `telecodex` command globally from the current checkout.
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
- ## Automated npm release
50
+ ## How It Works
77
51
 
78
- This repository is set up for npm trusted publishing from GitHub Actions.
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
- 1. On npm, open the `telecodex` package settings and configure a trusted publisher:
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
- ```bash
87
- npm version patch
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
- 3. Push the branch and tag:
70
+ 2. Create a fresh topic for a new Codex thread:
91
71
 
92
- ```bash
93
- git push origin main --follow-tags
72
+ ```text
73
+ /thread new My Task
94
74
  ```
95
75
 
96
- Pushing a `v*` tag runs `.github/workflows/publish.yml`, which installs dependencies,
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
- ## First launch
78
+ ```text
79
+ /thread list
80
+ /thread resume <threadId>
81
+ ```
101
82
 
102
- On first launch, `telecodex`:
83
+ 4. Send normal messages in the topic to work with Codex.
103
84
 
104
- 1. Finds or asks for the local `codex` binary path.
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
- Bootstrap codes are time-limited and attempt-limited. When a code expires or is exhausted, start telecodex again locally to issue a fresh one.
87
+ ### General
111
88
 
112
- There are no required environment variables in the normal startup path.
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
- Optional security override:
93
+ ### Admin
115
94
 
116
- - `TELECODEX_ALLOW_PLAINTEXT_TOKEN_FALLBACK=1` allows storing the Telegram bot token unencrypted in local state when the system keychain is unavailable. This is disabled by default.
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
- ## Working model
99
+ ### Project
119
100
 
120
- - Private chat is only for bootstrap and lightweight management.
121
- - One forum supergroup represents one project.
122
- - The project root is bound once with `/project bind <absolute-path>`.
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
- ## Stored state
105
+ ### Threads
129
106
 
130
- - Telegram bot token: stored in the system keychain when available. Plaintext local fallback is disabled by default and must be opted into explicitly.
131
- - Admin binding, project bindings, and topic/session state: stored in a local SQLite database under `~/.telecodex/`.
132
- - Runtime logs: written by `pino` to `~/.telecodex/logs/telecodex.log`.
133
- - Working directory: defaults to the directory where you ran `telecodex`.
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
- ## Logs
112
+ ### Session Configuration
136
113
 
137
- - Startup prints the active log file path.
138
- - Telegram middleware errors and message edit failures are appended to the log file.
139
- - When you need to debug a running instance later, inspect `~/.telecodex/logs/telecodex.log` first.
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
- ## Commands
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 create topics.",
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) {
@@ -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 recoverActiveTopicSessions(store, codex, buffers, bot, logger);
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 topicName = formatTopicName(`Resumed ${threadId.slice(0, 8)}`, "Resumed Thread");
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: ${threadId}`,
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: ${threadId}`,
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
+ }