telecodex 0.1.0 → 0.1.2
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 +41 -4
- package/dist/bot/auth.js +62 -13
- package/dist/bot/commandSupport.js +23 -8
- package/dist/bot/createBot.js +16 -3
- package/dist/bot/handlers/operationalHandlers.js +52 -0
- package/dist/bot/handlers/projectHandlers.js +67 -8
- package/dist/bot/handlers/sessionConfigHandlers.js +27 -6
- package/dist/bot/topicCleanup.js +80 -0
- package/dist/cli.js +0 -0
- package/dist/codex/sessionCatalog.js +215 -0
- package/dist/config.js +0 -1
- package/dist/runtime/appPaths.js +4 -1
- package/dist/runtime/bindingCodes.js +5 -0
- package/dist/runtime/bootstrap.js +26 -17
- 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 +240 -207
- package/dist/telegram/renderer.js +40 -3
- package/package.json +2 -2
- package/dist/store/db.js +0 -267
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ The bot talks to local Codex through `@openai/codex-sdk`, which wraps the local
|
|
|
28
28
|
- One topic maps to one Codex SDK thread.
|
|
29
29
|
- Each topic has at most one active SDK run.
|
|
30
30
|
- Follow-up messages during an active run are queued and processed in order.
|
|
31
|
+
- The pending queue is in-memory only and is cleared on restart.
|
|
31
32
|
- Text and image messages are mapped to Codex SDK input.
|
|
32
33
|
- A run immediately creates a normal Telegram status message; progress edits that message.
|
|
33
34
|
- telecodex does not use pinned messages for live state.
|
|
@@ -73,6 +74,30 @@ npm run dev
|
|
|
73
74
|
For a production-style local install during development, `npm link` exposes the
|
|
74
75
|
same `telecodex` command globally from the current checkout.
|
|
75
76
|
|
|
77
|
+
## Automated npm release
|
|
78
|
+
|
|
79
|
+
This repository is set up for npm trusted publishing from GitHub Actions.
|
|
80
|
+
|
|
81
|
+
1. On npm, open the `telecodex` package settings and configure a trusted publisher:
|
|
82
|
+
- Organization or user: `jiangege`
|
|
83
|
+
- Repository: `telecodex`
|
|
84
|
+
- Workflow filename: `publish.yml`
|
|
85
|
+
2. Bump the version locally:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm version patch
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
3. Push the branch and tag:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
git push origin main --follow-tags
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Pushing a `v*` tag runs `.github/workflows/publish.yml`, which installs dependencies,
|
|
98
|
+
runs `npm run check`, runs `npm test`, and publishes the package to npm when the tag
|
|
99
|
+
matches the version in `package.json`.
|
|
100
|
+
|
|
76
101
|
## First launch
|
|
77
102
|
|
|
78
103
|
On first launch, `telecodex`:
|
|
@@ -83,6 +108,8 @@ On first launch, `telecodex`:
|
|
|
83
108
|
4. Generates a one-time bootstrap code if no Telegram admin is bound yet.
|
|
84
109
|
5. Waits for that code in a private chat. The first successful sender becomes the permanent admin for this bot instance.
|
|
85
110
|
|
|
111
|
+
Bootstrap codes are time-limited and attempt-limited. When a code expires or is exhausted, start telecodex again locally to issue a fresh one.
|
|
112
|
+
|
|
86
113
|
There are no required environment variables in the normal startup path.
|
|
87
114
|
|
|
88
115
|
Optional security override:
|
|
@@ -96,13 +123,18 @@ Optional security override:
|
|
|
96
123
|
- The project root is bound once with `/project bind <absolute-path>`.
|
|
97
124
|
- Each topic in that supergroup is one Codex thread.
|
|
98
125
|
- Work happens by sending normal messages inside the topic.
|
|
126
|
+
- `/thread list` shows the saved Codex threads already recorded for the bound project root or its subdirectories.
|
|
99
127
|
- `/thread new <topic-name>` automatically creates a new topic; the first normal message inside it starts a fresh Codex thread.
|
|
100
128
|
- `/thread resume <threadId>` automatically creates a new topic and binds it to an existing thread id.
|
|
129
|
+
- On startup, telecodex probes stored topic bindings once and removes bindings whose Telegram topics no longer exist.
|
|
130
|
+
- On first launch after upgrading from the old SQLite state format, telecodex imports the legacy state once and then deletes the old SQLite files (`state.sqlite` plus sidecars such as `-wal`/`-shm`).
|
|
101
131
|
|
|
102
132
|
## Stored state
|
|
103
133
|
|
|
104
134
|
- Telegram bot token: stored in the system keychain when available. Plaintext local fallback is disabled by default and must be opted into explicitly.
|
|
105
|
-
-
|
|
135
|
+
- Codex thread history: read directly from Codex session files under `$CODEX_HOME/sessions` (or `~/.codex/sessions` by default).
|
|
136
|
+
- Admin binding, project bindings, and durable Telegram topic configuration: stored as local JSON files under `~/.telecodex/state/`.
|
|
137
|
+
- Legacy upgrade path: if `~/.telecodex/state.sqlite` exists from an older telecodex version, it is imported once into the JSON state files and then cleaned up together with any SQLite sidecar files that remain.
|
|
106
138
|
- Runtime logs: written by `pino` to `~/.telecodex/logs/telecodex.log`.
|
|
107
139
|
- Working directory: defaults to the directory where you ran `telecodex`.
|
|
108
140
|
|
|
@@ -116,12 +148,16 @@ Optional security override:
|
|
|
116
148
|
|
|
117
149
|
- `/start` or `/help` - show the current usage model.
|
|
118
150
|
- `/status` - in private chat shows global state; in a project topic shows project/thread runtime state.
|
|
151
|
+
- `/admin` - in private chat, show admin binding and handoff status.
|
|
152
|
+
- `/admin rebind` - in private chat, issue a time-limited handoff code for transferring control to another Telegram account.
|
|
153
|
+
- `/admin cancel` - in private chat, cancel a pending admin handoff code.
|
|
119
154
|
- `/project` - show the current supergroup's project binding.
|
|
120
155
|
- `/project bind <absolute-path>` - bind the current supergroup to a project root.
|
|
121
156
|
- `/project unbind` - remove the current supergroup's project binding.
|
|
122
157
|
- `/thread` - in a topic, show the current attached thread id.
|
|
158
|
+
- `/thread list` - list saved Codex threads whose working directory is inside the current project root.
|
|
123
159
|
- `/thread new <topic-name>` - create a new topic for a fresh Codex thread.
|
|
124
|
-
- `/thread resume <threadId>` - create a new topic and bind it to an existing Codex thread id.
|
|
160
|
+
- `/thread resume <threadId>` - create a new topic and bind it to an existing saved Codex thread id from the current project.
|
|
125
161
|
- Normal text in a topic - send that message to the current Codex thread.
|
|
126
162
|
- `/stop` - interrupt the active SDK run.
|
|
127
163
|
- `/cwd <absolute-path>` - switch the topic working directory inside the current project root.
|
|
@@ -134,7 +170,7 @@ Optional security override:
|
|
|
134
170
|
- `/web default|disabled|cached|live` - set Codex SDK web search mode.
|
|
135
171
|
- `/network on|off` - set workspace network access for Codex SDK runs.
|
|
136
172
|
- `/gitcheck skip|enforce` - control Codex SDK git repository checks.
|
|
137
|
-
- `/adddir list|add <absolute-path>|drop <index>|clear` - manage Codex SDK additional directories.
|
|
173
|
+
- `/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.
|
|
138
174
|
- `/schema show|set <JSON object>|clear` - manage Codex SDK output schema for the current topic.
|
|
139
175
|
- `/codexconfig show|set <JSON object>|clear` - manage global non-auth Codex SDK config overrides for future runs.
|
|
140
176
|
- Image messages in a topic - download the Telegram image locally and send it as SDK `local_image` input.
|
|
@@ -144,6 +180,7 @@ Optional security override:
|
|
|
144
180
|
- Long polling is managed by `@grammyjs/runner`.
|
|
145
181
|
- Streaming updates are throttled before editing Telegram messages.
|
|
146
182
|
- Final answers are rendered from Markdown to Telegram-safe HTML.
|
|
147
|
-
-
|
|
183
|
+
- Project-scoped path checks resolve symlinks before enforcing the root boundary, so topic cwd changes cannot escape the bound project through symlink paths.
|
|
184
|
+
- Because the SDK run and pending queue are in-process, a telecodex restart cannot resume a partially streamed Telegram turn and clears queued follow-up messages.
|
|
148
185
|
- Authentication and provider switching remain owned by the local Codex installation; telecodex does not manage API keys or login state.
|
|
149
186
|
- 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.
|
package/dist/bot/auth.js
CHANGED
|
@@ -15,11 +15,30 @@ export function authMiddleware(input) {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
const authorizedUserId = input.store.getAuthorizedUserId();
|
|
18
|
+
const binding = input.store.getBindingCodeState();
|
|
19
|
+
const messageText = ctx.message?.text?.trim();
|
|
18
20
|
if (authorizedUserId != null) {
|
|
19
21
|
if (authorizedUserId === userId) {
|
|
20
22
|
await next();
|
|
21
23
|
return;
|
|
22
24
|
}
|
|
25
|
+
if (ctx.chat?.type === "private" && binding?.mode === "rebind" && messageText) {
|
|
26
|
+
const handled = await handleBindingCodeMessage({
|
|
27
|
+
ctx,
|
|
28
|
+
userId,
|
|
29
|
+
text: messageText,
|
|
30
|
+
binding,
|
|
31
|
+
store: input.store,
|
|
32
|
+
success: async () => {
|
|
33
|
+
input.store.rebindAuthorizedUserId(userId);
|
|
34
|
+
await ctx.reply("Admin handoff succeeded. This Telegram account is now authorized to use telecodex.");
|
|
35
|
+
},
|
|
36
|
+
mismatchLabel: "Admin handoff code did not match.",
|
|
37
|
+
exhaustedLabel: "Admin handoff code exhausted its attempt limit and was invalidated. Issue a new one from the currently authorized account.",
|
|
38
|
+
});
|
|
39
|
+
if (handled)
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
23
42
|
input.logger?.warn("telegram update denied because user is not authorized", {
|
|
24
43
|
chatId: ctx.chat?.id ?? null,
|
|
25
44
|
chatType: ctx.chat?.type ?? null,
|
|
@@ -30,29 +49,59 @@ export function authMiddleware(input) {
|
|
|
30
49
|
await deny(ctx, "Unauthorized.");
|
|
31
50
|
return;
|
|
32
51
|
}
|
|
33
|
-
if (!
|
|
34
|
-
await deny(ctx, "
|
|
52
|
+
if (!binding || binding.mode !== "bootstrap") {
|
|
53
|
+
await deny(ctx, "This bot is not initialized yet, or the binding code expired. Restart telecodex locally to issue a new binding code.");
|
|
35
54
|
return;
|
|
36
55
|
}
|
|
37
56
|
if (ctx.chat?.type !== "private") {
|
|
38
57
|
await deny(ctx, "Send the admin bootstrap code to the bot in a private chat first.");
|
|
39
58
|
return;
|
|
40
59
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
if (messageText) {
|
|
61
|
+
const handled = await handleBindingCodeMessage({
|
|
62
|
+
ctx,
|
|
63
|
+
userId,
|
|
64
|
+
text: messageText,
|
|
65
|
+
binding,
|
|
66
|
+
store: input.store,
|
|
67
|
+
success: async () => {
|
|
68
|
+
const claimedUserId = input.store.claimAuthorizedUserId(userId);
|
|
69
|
+
if (claimedUserId === userId) {
|
|
70
|
+
input.onAdminBound?.(userId);
|
|
71
|
+
await ctx.reply("Admin binding succeeded. Only this Telegram account can use this bot from now on.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await deny(ctx, "An admin account has already claimed this bot.");
|
|
75
|
+
},
|
|
76
|
+
mismatchLabel: "Binding code did not match.",
|
|
77
|
+
exhaustedLabel: "Binding code exhausted its attempt limit and was invalidated. Restart telecodex locally to issue a new one.",
|
|
78
|
+
});
|
|
79
|
+
if (handled)
|
|
80
|
+
return;
|
|
52
81
|
}
|
|
53
82
|
await ctx.reply("This bot is not initialized yet. Send the binding code shown in the startup logs to complete the one-time admin binding.");
|
|
54
83
|
};
|
|
55
84
|
}
|
|
85
|
+
async function handleBindingCodeMessage(input) {
|
|
86
|
+
if (input.text === input.binding.code) {
|
|
87
|
+
await input.success();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (input.text.startsWith("/")) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const attempt = input.store.recordBindingCodeFailure();
|
|
94
|
+
if (!attempt) {
|
|
95
|
+
await input.ctx.reply("The binding code is no longer active. Issue a new one and try again.");
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (attempt.exhausted) {
|
|
99
|
+
await input.ctx.reply(input.exhaustedLabel);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
await input.ctx.reply(`${input.mismatchLabel}\nRemaining attempts: ${attempt.remaining}`);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
56
105
|
async function deny(ctx, text) {
|
|
57
106
|
if (ctx.callbackQuery) {
|
|
58
107
|
await ctx.answerCallbackQuery({ text, show_alert: false });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { APPROVAL_POLICIES, MODE_PRESETS, REASONING_EFFORTS, SANDBOX_MODES, } from "../config.js";
|
|
4
4
|
import { makeSessionKey } from "../store/sessions.js";
|
|
@@ -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
|
"",
|
|
@@ -56,6 +57,7 @@ export function formatHelpText(ctx, projects) {
|
|
|
56
57
|
"/queue drop <id>",
|
|
57
58
|
"/queue clear",
|
|
58
59
|
"/stop",
|
|
60
|
+
"/admin",
|
|
59
61
|
"",
|
|
60
62
|
formatPrivateProjectSummary(projects),
|
|
61
63
|
].join("\n");
|
|
@@ -79,6 +81,7 @@ export function formatHelpText(ctx, projects) {
|
|
|
79
81
|
"",
|
|
80
82
|
"/project show the project binding",
|
|
81
83
|
"/project bind <absolute-path> update the project root",
|
|
84
|
+
"/thread list show saved Codex threads already recorded for this project",
|
|
82
85
|
"/thread new <topic-name> create a new topic; the first message starts a new thread",
|
|
83
86
|
"/thread resume <threadId> create a topic bound to an existing thread",
|
|
84
87
|
"send a normal message inside a topic to the current thread",
|
|
@@ -97,15 +100,17 @@ export function formatHelpText(ctx, projects) {
|
|
|
97
100
|
"/web default|disabled|cached|live",
|
|
98
101
|
"/network on|off",
|
|
99
102
|
"/gitcheck skip|enforce",
|
|
100
|
-
"/adddir list|add|drop|clear",
|
|
103
|
+
"/adddir list|add|add-external|drop|clear",
|
|
101
104
|
"/schema show|set|clear",
|
|
102
105
|
"/codexconfig show|set|clear",
|
|
103
106
|
].join("\n");
|
|
104
107
|
}
|
|
105
108
|
export function formatPrivateStatus(store, projects) {
|
|
109
|
+
const binding = store.getBindingCodeState();
|
|
106
110
|
return [
|
|
107
111
|
"telecodex admin",
|
|
108
112
|
`authorized telegram user id: ${store.getAuthorizedUserId() ?? "not bound"}`,
|
|
113
|
+
binding?.mode === "rebind" ? `pending handoff: active until ${binding.expiresAt}` : "pending handoff: none",
|
|
109
114
|
"",
|
|
110
115
|
formatPrivateProjectSummary(projects),
|
|
111
116
|
].join("\n");
|
|
@@ -132,7 +137,7 @@ export function formatProjectStatus(project) {
|
|
|
132
137
|
"Project status",
|
|
133
138
|
`project: ${project.name}`,
|
|
134
139
|
`root: ${project.cwd}`,
|
|
135
|
-
"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.",
|
|
136
141
|
].join("\n");
|
|
137
142
|
}
|
|
138
143
|
export function ensureTopicSession(input) {
|
|
@@ -199,11 +204,12 @@ export function resolveExistingDirectory(input) {
|
|
|
199
204
|
if (!stat.isDirectory()) {
|
|
200
205
|
throw new Error(`Not a directory: ${resolved}`);
|
|
201
206
|
}
|
|
202
|
-
return resolved;
|
|
207
|
+
return canonicalizeBoundaryPath(resolved);
|
|
203
208
|
}
|
|
204
209
|
export function assertProjectScopedPath(input, projectRoot) {
|
|
205
|
-
const resolved =
|
|
206
|
-
|
|
210
|
+
const resolved = resolveExistingDirectory(input);
|
|
211
|
+
const canonicalRoot = resolveExistingDirectory(projectRoot);
|
|
212
|
+
if (!isPathWithinRoot(resolved, canonicalRoot)) {
|
|
207
213
|
throw new Error(["Path must stay within the project root.", `project root: ${projectRoot}`, `input: ${resolved}`].join("\n"));
|
|
208
214
|
}
|
|
209
215
|
return resolved;
|
|
@@ -233,7 +239,16 @@ export function hasTopicContext(ctx) {
|
|
|
233
239
|
return ctx.message?.message_thread_id != null || ctx.callbackQuery?.message?.message_thread_id != null;
|
|
234
240
|
}
|
|
235
241
|
export function isPathWithinRoot(candidate, root) {
|
|
236
|
-
const resolvedCandidate =
|
|
237
|
-
const resolvedRoot =
|
|
242
|
+
const resolvedCandidate = canonicalizeBoundaryPath(candidate);
|
|
243
|
+
const resolvedRoot = canonicalizeBoundaryPath(root);
|
|
238
244
|
return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`);
|
|
239
245
|
}
|
|
246
|
+
function canonicalizeBoundaryPath(input) {
|
|
247
|
+
const resolved = path.resolve(input);
|
|
248
|
+
try {
|
|
249
|
+
return realpathSync.native(resolved);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return resolved;
|
|
253
|
+
}
|
|
254
|
+
}
|
package/dist/bot/createBot.js
CHANGED
|
@@ -3,12 +3,12 @@ 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
|
-
bootstrapCode,
|
|
12
12
|
store,
|
|
13
13
|
...(logger ? { logger: logger.child("auth") } : {}),
|
|
14
14
|
...(onAdminBound ? { onAdminBound } : {}),
|
|
@@ -31,10 +31,23 @@ export function wireBot(input) {
|
|
|
31
31
|
store,
|
|
32
32
|
projects,
|
|
33
33
|
codex,
|
|
34
|
+
threadCatalog,
|
|
34
35
|
buffers,
|
|
35
36
|
...(logger ? { logger } : {}),
|
|
36
37
|
});
|
|
37
|
-
void
|
|
38
|
+
void (async () => {
|
|
39
|
+
try {
|
|
40
|
+
await cleanupMissingTopicBindings({
|
|
41
|
+
bot,
|
|
42
|
+
store,
|
|
43
|
+
...(logger ? { logger: logger.child("topic-cleanup") } : {}),
|
|
44
|
+
});
|
|
45
|
+
await recoverActiveTopicSessions(store, codex, buffers, bot, logger);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
logger?.error("startup topic reconciliation failed", error);
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
38
51
|
return {
|
|
39
52
|
bot,
|
|
40
53
|
buffers,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { presetFromProfile } from "../../config.js";
|
|
2
|
+
import { generateBindingCode } from "../../runtime/bindingCodes.js";
|
|
2
3
|
import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
|
|
3
4
|
import { refreshSessionIfActiveTurnIsStale } from "../inputService.js";
|
|
4
5
|
import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, parseSubcommand, } from "../commandSupport.js";
|
|
@@ -8,6 +9,57 @@ export function registerOperationalHandlers(deps) {
|
|
|
8
9
|
bot.command(["start", "help"], async (ctx) => {
|
|
9
10
|
await ctx.reply(formatHelpText(ctx, projects));
|
|
10
11
|
});
|
|
12
|
+
bot.command("admin", async (ctx) => {
|
|
13
|
+
if (!isPrivateChat(ctx)) {
|
|
14
|
+
await ctx.reply("Use /admin in the bot private chat.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const authorizedUserId = store.getAuthorizedUserId();
|
|
18
|
+
if (authorizedUserId == null) {
|
|
19
|
+
await ctx.reply("Admin binding is not completed yet.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const { command } = parseSubcommand(ctx.match.trim());
|
|
23
|
+
const binding = store.getBindingCodeState();
|
|
24
|
+
if (!command || command === "status") {
|
|
25
|
+
await ctx.reply([
|
|
26
|
+
"Admin status",
|
|
27
|
+
`authorized telegram user id: ${authorizedUserId}`,
|
|
28
|
+
binding?.mode === "rebind"
|
|
29
|
+
? `pending handoff: active until ${formatIsoTimestamp(binding.expiresAt)} (${binding.maxAttempts - binding.attempts} attempts remaining)`
|
|
30
|
+
: "pending handoff: none",
|
|
31
|
+
"Usage: /admin | /admin rebind | /admin cancel",
|
|
32
|
+
].join("\n"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (command === "rebind") {
|
|
36
|
+
const next = store.issueBindingCode({
|
|
37
|
+
code: generateBindingCode("rebind"),
|
|
38
|
+
mode: "rebind",
|
|
39
|
+
issuedByUserId: authorizedUserId,
|
|
40
|
+
});
|
|
41
|
+
await ctx.reply([
|
|
42
|
+
"Admin handoff code created.",
|
|
43
|
+
`expires at: ${formatIsoTimestamp(next.expiresAt)}`,
|
|
44
|
+
`max failed attempts: ${next.maxAttempts}`,
|
|
45
|
+
"",
|
|
46
|
+
next.code,
|
|
47
|
+
"",
|
|
48
|
+
"Send this code from the target Telegram account in this bot's private chat to transfer control.",
|
|
49
|
+
].join("\n"));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (command === "cancel") {
|
|
53
|
+
if (binding?.mode !== "rebind") {
|
|
54
|
+
await ctx.reply("No pending admin handoff.");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
store.clearBindingCode();
|
|
58
|
+
await ctx.reply("Cancelled the pending admin handoff.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await ctx.reply("Usage: /admin | /admin rebind | /admin cancel");
|
|
62
|
+
});
|
|
11
63
|
bot.command("status", async (ctx) => {
|
|
12
64
|
if (isPrivateChat(ctx)) {
|
|
13
65
|
await ctx.reply(formatPrivateStatus(store, projects));
|
|
@@ -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
|
+
}
|
|
@@ -177,28 +177,29 @@ export function registerSessionConfigHandlers(deps) {
|
|
|
177
177
|
await ctx.reply(`Set git repo check: ${value}`);
|
|
178
178
|
});
|
|
179
179
|
bot.command("adddir", async (ctx) => {
|
|
180
|
+
const project = getProjectForContext(ctx, projects);
|
|
180
181
|
const session = getScopedSession(ctx, store, projects, config);
|
|
181
|
-
if (!session)
|
|
182
|
+
if (!project || !session)
|
|
182
183
|
return;
|
|
183
184
|
const [command, ...rest] = ctx.match.trim().split(/\s+/).filter(Boolean);
|
|
184
185
|
const args = rest.join(" ");
|
|
185
186
|
if (!command || command === "list") {
|
|
186
187
|
await ctx.reply(session.additionalDirectories.length === 0
|
|
187
|
-
? "additional directories: none\nUsage: /adddir add <absolute-path> | /adddir drop <index> | /adddir clear"
|
|
188
|
+
? "additional directories: none\nUsage: /adddir add <path-inside-project> | /adddir add-external <absolute-path> | /adddir drop <index> | /adddir clear"
|
|
188
189
|
: [
|
|
189
190
|
"additional directories:",
|
|
190
191
|
...session.additionalDirectories.map((directory, index) => `${index + 1}. ${directory}`),
|
|
191
|
-
"Usage: /adddir add <absolute-path> | /adddir drop <index> | /adddir clear",
|
|
192
|
+
"Usage: /adddir add <path-inside-project> | /adddir add-external <absolute-path> | /adddir drop <index> | /adddir clear",
|
|
192
193
|
].join("\n"));
|
|
193
194
|
return;
|
|
194
195
|
}
|
|
195
196
|
if (command === "add") {
|
|
196
197
|
if (!args) {
|
|
197
|
-
await ctx.reply("Usage: /adddir add <
|
|
198
|
+
await ctx.reply("Usage: /adddir add <path-inside-project>");
|
|
198
199
|
return;
|
|
199
200
|
}
|
|
200
201
|
try {
|
|
201
|
-
const directory =
|
|
202
|
+
const directory = assertProjectScopedPath(args, project.cwd);
|
|
202
203
|
const next = [...session.additionalDirectories.filter((entry) => entry !== directory), directory];
|
|
203
204
|
store.setAdditionalDirectories(session.sessionKey, next);
|
|
204
205
|
await ctx.reply(`Added additional directory:\n${directory}`);
|
|
@@ -208,6 +209,26 @@ export function registerSessionConfigHandlers(deps) {
|
|
|
208
209
|
}
|
|
209
210
|
return;
|
|
210
211
|
}
|
|
212
|
+
if (command === "add-external") {
|
|
213
|
+
if (!args) {
|
|
214
|
+
await ctx.reply("Usage: /adddir add-external <absolute-path>");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const directory = resolveExistingDirectory(args);
|
|
219
|
+
const next = [...session.additionalDirectories.filter((entry) => entry !== directory), directory];
|
|
220
|
+
store.setAdditionalDirectories(session.sessionKey, next);
|
|
221
|
+
await ctx.reply([
|
|
222
|
+
"Added external additional directory outside the project root.",
|
|
223
|
+
directory,
|
|
224
|
+
"Codex can now read files there during future runs.",
|
|
225
|
+
].join("\n"));
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
await ctx.reply(error instanceof Error ? error.message : String(error));
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
211
232
|
if (command === "drop") {
|
|
212
233
|
const index = Number(args);
|
|
213
234
|
if (!Number.isInteger(index) || index <= 0 || index > session.additionalDirectories.length) {
|
|
@@ -224,7 +245,7 @@ export function registerSessionConfigHandlers(deps) {
|
|
|
224
245
|
await ctx.reply("Cleared additional directories.");
|
|
225
246
|
return;
|
|
226
247
|
}
|
|
227
|
-
await ctx.reply("Usage: /adddir list | /adddir add <absolute-path> | /adddir drop <index> | /adddir clear");
|
|
248
|
+
await ctx.reply("Usage: /adddir list | /adddir add <path-inside-project> | /adddir add-external <absolute-path> | /adddir drop <index> | /adddir clear");
|
|
228
249
|
});
|
|
229
250
|
bot.command("schema", async (ctx) => {
|
|
230
251
|
const session = getScopedSession(ctx, store, projects, config);
|