telecodex 0.1.3 → 0.1.5

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.
@@ -1,31 +1,33 @@
1
1
  import path from "node:path";
2
- import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, resolveExistingDirectory, } from "../commandSupport.js";
2
+ import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, requireScopedSession, resolveExistingDirectory, } from "../commandSupport.js";
3
3
  import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
4
+ import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
5
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
4
6
  const PROJECT_REQUIRED_MESSAGE = "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.";
5
7
  export function registerProjectHandlers(deps) {
6
8
  const { bot, config, store, projects, logger } = deps;
7
- bot.command("project", async (ctx) => {
9
+ bot.command("project", wrapUserFacingHandler("project", logger, async (ctx) => {
8
10
  const { command, args } = parseSubcommand(ctx.match.trim());
9
11
  if (isPrivateChat(ctx)) {
10
12
  if (!command || command === "list" || command === "status") {
11
- await ctx.reply(formatPrivateProjectList(projects));
13
+ await replyNotice(ctx, formatPrivateProjectList(projects));
12
14
  return;
13
15
  }
14
- await ctx.reply("Use /project bind inside a supergroup with topics enabled. Private chat is only for admin entry points.");
16
+ await replyNotice(ctx, "Use /project bind inside a supergroup with topics enabled. Private chat is only for admin entry points.");
15
17
  return;
16
18
  }
17
19
  if (!isSupergroupChat(ctx)) {
18
- await ctx.reply("Use telecodex inside a supergroup with forum topics enabled.");
20
+ await replyNotice(ctx, "Use telecodex inside a supergroup with forum topics enabled.");
19
21
  return;
20
22
  }
21
23
  if (!command || command === "status") {
22
24
  const project = getProjectForContext(ctx, projects);
23
- await ctx.reply(project ? formatProjectStatus(project) : PROJECT_REQUIRED_MESSAGE);
25
+ await replyNotice(ctx, project ? formatProjectStatus(project) : PROJECT_REQUIRED_MESSAGE);
24
26
  return;
25
27
  }
26
28
  if (command === "bind") {
27
29
  if (!args) {
28
- await ctx.reply("Usage: /project bind <absolute-path>");
30
+ await replyUsage(ctx, "/project bind <absolute-path>");
29
31
  return;
30
32
  }
31
33
  try {
@@ -39,19 +41,21 @@ export function registerProjectHandlers(deps) {
39
41
  project: project.name,
40
42
  cwd: project.cwd,
41
43
  });
42
- const session = getScopedSession(ctx, store, projects, config, { requireTopic: false });
44
+ const session = await requireScopedSession(ctx, store, projects, config, { requireTopic: false });
43
45
  if (session && session.cwd !== project.cwd) {
44
46
  store.setCwd(session.sessionKey, project.cwd);
45
47
  }
46
- await ctx.reply([
47
- "Project binding updated.",
48
- `project: ${project.name}`,
49
- `root: ${project.cwd}`,
50
- "This supergroup now represents one project, and each topic maps to one Codex thread.",
51
- ].join("\n"));
48
+ await replyDocument(ctx, {
49
+ title: "Project binding updated.",
50
+ fields: [
51
+ codeField("project", project.name),
52
+ codeField("root", project.cwd),
53
+ ],
54
+ footer: "This supergroup now represents one project, and each topic maps to one Codex thread.",
55
+ });
52
56
  }
53
57
  catch (error) {
54
- await ctx.reply(error instanceof Error ? error.message : String(error));
58
+ await replyError(ctx, error instanceof Error ? error.message : String(error));
55
59
  }
56
60
  return;
57
61
  }
@@ -60,36 +64,36 @@ export function registerProjectHandlers(deps) {
60
64
  ...contextLogFields(ctx),
61
65
  });
62
66
  projects.remove(String(ctx.chat.id));
63
- await ctx.reply("Removed the project binding for this supergroup.");
67
+ await replyNotice(ctx, "Removed the project binding for this supergroup.");
64
68
  return;
65
69
  }
66
- await ctx.reply("Usage:\n/project\n/project bind <absolute-path>\n/project unbind");
67
- });
68
- bot.command("thread", async (ctx) => {
70
+ await replyUsage(ctx, ["/project", "/project bind <absolute-path>", "/project unbind"]);
71
+ }));
72
+ bot.command("thread", wrapUserFacingHandler("thread", logger, async (ctx) => {
69
73
  if (isPrivateChat(ctx)) {
70
- await ctx.reply("The thread command is only available inside project supergroups.");
74
+ await replyNotice(ctx, "The thread command is only available inside project supergroups.");
71
75
  return;
72
76
  }
73
77
  const { command, args } = parseSubcommand(ctx.match.trim());
74
78
  if (!command) {
75
79
  if (hasTopicContext(ctx)) {
76
- const session = getScopedSession(ctx, store, projects, config);
80
+ const session = await requireScopedSession(ctx, store, projects, config);
77
81
  if (!session)
78
82
  return;
79
- await ctx.reply([
80
- `Current thread: ${session.codexThreadId ?? "not created"}`,
81
- `state: ${formatSessionRuntimeStatus(session.runtimeStatus)}`,
82
- `state detail: ${session.runtimeStatusDetail ?? "none"}`,
83
- `queue: ${store.getQueuedInputCount(session.sessionKey)}`,
84
- `cwd: ${session.cwd}`,
85
- "Manage threads in this project:",
86
- "/thread list",
87
- "/thread resume <threadId>",
88
- "/thread new <topic-name>",
89
- ].join("\n"));
83
+ await replyDocument(ctx, {
84
+ title: "Current thread",
85
+ fields: [
86
+ codeField("thread", session.codexThreadId ?? "not created"),
87
+ textField("state", formatSessionRuntimeStatus(session.runtimeStatus)),
88
+ textField("state detail", session.runtimeStatusDetail ?? "none"),
89
+ textField("queue", store.getQueuedInputCount(session.sessionKey)),
90
+ codeField("cwd", session.cwd),
91
+ ],
92
+ footer: ["Manage threads in this project:", "/thread list", "/thread resume <threadId>", "/thread new <topic-name>"],
93
+ });
90
94
  return;
91
95
  }
92
- await ctx.reply("Usage:\n/thread list\n/thread resume <threadId>\n/thread new <topic-name>");
96
+ await replyUsage(ctx, ["/thread list", "/thread resume <threadId>", "/thread new <topic-name>"]);
93
97
  return;
94
98
  }
95
99
  if (command === "list") {
@@ -98,7 +102,7 @@ export function registerProjectHandlers(deps) {
98
102
  }
99
103
  if (command === "resume") {
100
104
  if (!args) {
101
- await ctx.reply("Usage: /thread resume <threadId>");
105
+ await replyUsage(ctx, "/thread resume <threadId>");
102
106
  return;
103
107
  }
104
108
  await resumeThreadIntoTopic(ctx, deps, args);
@@ -108,8 +112,8 @@ export function registerProjectHandlers(deps) {
108
112
  await createFreshThreadTopic(ctx, deps, args);
109
113
  return;
110
114
  }
111
- await ctx.reply("Usage:\n/thread list\n/thread resume <threadId>\n/thread new <topic-name>");
112
- });
115
+ await replyUsage(ctx, ["/thread list", "/thread resume <threadId>", "/thread new <topic-name>"]);
116
+ }));
113
117
  bot.on(["message:forum_topic_created", "message:forum_topic_edited"], async (ctx) => {
114
118
  const threadId = ctx.message.message_thread_id;
115
119
  if (threadId == null)
@@ -128,7 +132,7 @@ async function resumeThreadIntoTopic(ctx, deps, threadId) {
128
132
  const { bot, config, store, projects, logger, threadCatalog } = deps;
129
133
  const project = getProjectForContext(ctx, projects);
130
134
  if (!project) {
131
- await ctx.reply(PROJECT_REQUIRED_MESSAGE);
135
+ await replyNotice(ctx, PROJECT_REQUIRED_MESSAGE);
132
136
  return;
133
137
  }
134
138
  const thread = await threadCatalog.findProjectThreadById({
@@ -136,12 +140,14 @@ async function resumeThreadIntoTopic(ctx, deps, threadId) {
136
140
  threadId,
137
141
  });
138
142
  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"));
143
+ await replyDocument(ctx, {
144
+ title: "Could not find a saved Codex thread with that id under this project.",
145
+ fields: [
146
+ codeField("project root", project.cwd),
147
+ codeField("thread", threadId),
148
+ ],
149
+ footer: "Run /thread list to inspect the saved project threads first.",
150
+ });
145
151
  return;
146
152
  }
147
153
  const topicName = formatTopicName(thread.preview, `Resumed ${thread.id.slice(0, 8)}`);
@@ -161,14 +167,16 @@ async function resumeThreadIntoTopic(ctx, deps, threadId) {
161
167
  threadId: thread.id,
162
168
  topicName: forumTopic.name,
163
169
  });
164
- await ctx.reply([
165
- "Created a topic and bound it to the existing thread id.",
166
- `topic: ${forumTopic.name}`,
167
- `topic id: ${forumTopic.message_thread_id}`,
168
- `thread: ${thread.id}`,
169
- `cwd: ${thread.cwd}`,
170
- "Future messages in this topic will continue on that thread through the Codex SDK.",
171
- ].join("\n"));
170
+ await replyDocument(ctx, {
171
+ title: "Created a topic and bound it to the existing thread id.",
172
+ fields: [
173
+ textField("topic", forumTopic.name),
174
+ textField("topic id", forumTopic.message_thread_id),
175
+ codeField("thread", thread.id),
176
+ codeField("cwd", thread.cwd),
177
+ ],
178
+ footer: "Future messages in this topic will continue on that thread through the Codex SDK.",
179
+ });
172
180
  await postTopicReadyMessage(bot, session, [
173
181
  "This topic is now bound to an existing Codex thread id.",
174
182
  `thread: ${thread.id}`,
@@ -179,7 +187,7 @@ async function createFreshThreadTopic(ctx, deps, requestedName) {
179
187
  const { bot, config, store, projects, logger } = deps;
180
188
  const project = getProjectForContext(ctx, projects);
181
189
  if (!project) {
182
- await ctx.reply(PROJECT_REQUIRED_MESSAGE);
190
+ await replyNotice(ctx, PROJECT_REQUIRED_MESSAGE);
183
191
  return;
184
192
  }
185
193
  const topicName = formatTopicName(requestedName, "New Thread");
@@ -197,12 +205,14 @@ async function createFreshThreadTopic(ctx, deps, requestedName) {
197
205
  sessionKey: session.sessionKey,
198
206
  topicName: forumTopic.name,
199
207
  });
200
- await ctx.reply([
201
- "Created a new topic.",
202
- `topic: ${forumTopic.name}`,
203
- `topic id: ${forumTopic.message_thread_id}`,
204
- "Your first normal message will start a new Codex SDK thread.",
205
- ].join("\n"));
208
+ await replyDocument(ctx, {
209
+ title: "Created a new topic.",
210
+ fields: [
211
+ textField("topic", forumTopic.name),
212
+ textField("topic id", forumTopic.message_thread_id),
213
+ ],
214
+ footer: "Your first normal message will start a new Codex SDK thread.",
215
+ });
206
216
  await postTopicReadyMessage(bot, session, [
207
217
  "New topic created.",
208
218
  "Send a message to start a new Codex thread.",
@@ -214,7 +224,7 @@ async function listProjectThreads(ctx, deps) {
214
224
  const { projects, store, threadCatalog } = deps;
215
225
  const project = getProjectForContext(ctx, projects);
216
226
  if (!project) {
217
- await ctx.reply(PROJECT_REQUIRED_MESSAGE);
227
+ await replyNotice(ctx, PROJECT_REQUIRED_MESSAGE);
218
228
  return;
219
229
  }
220
230
  const threads = await threadCatalog.listProjectThreads({
@@ -222,30 +232,35 @@ async function listProjectThreads(ctx, deps) {
222
232
  limit: 8,
223
233
  });
224
234
  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"));
235
+ await replyDocument(ctx, {
236
+ title: "No saved Codex threads were found for this project yet.",
237
+ fields: [codeField("project root", project.cwd)],
238
+ });
229
239
  return;
230
240
  }
231
- const lines = [
232
- `Saved Codex threads for ${project.name}:`,
233
- ...threads.flatMap((thread, index) => {
241
+ await replyDocument(ctx, {
242
+ title: "Saved Codex threads",
243
+ fields: [
244
+ codeField("project", project.name),
245
+ codeField("root", project.cwd),
246
+ ],
247
+ sections: threads.map((thread, index) => {
234
248
  const relativeCwd = path.relative(project.cwd, thread.cwd) || ".";
235
249
  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
- ];
250
+ return {
251
+ title: `${index + 1}. ${thread.preview}`,
252
+ fields: [
253
+ codeField("id", thread.id),
254
+ codeField("resume", `/thread resume ${thread.id}`),
255
+ codeField("cwd", relativeCwd),
256
+ textField("updated", thread.updatedAt),
257
+ textField("source", thread.source ?? "unknown"),
258
+ ...(bound
259
+ ? [textField("bound", bound.telegramTopicName ?? bound.messageThreadId ?? bound.sessionKey)]
260
+ : []),
261
+ ],
262
+ };
246
263
  }),
247
- "",
248
- "Resume one with /thread resume <threadId>",
249
- ];
250
- await ctx.reply(lines.join("\n"));
264
+ footer: "Copy an id or resume command from the code-formatted fields above.",
265
+ });
251
266
  }