telecodex 0.1.3 → 0.1.4

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