grok-telegram-bot 2.4.0 → 2.6.0

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.
Files changed (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
@@ -7,7 +7,7 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
7
7
  import type { RunningSession, SwitchResult } from "../chat-controller.js";
8
8
  import type { BotDeps } from "../deps.js";
9
9
  import type { HistoryEntry } from "../../sessions/types.js";
10
- import { jsonlMtimeMs, readFirstPrompt, readLastCardSummary } from "../../sessions/history.js";
10
+ import { jsonlMtimeMs, readFirstPrompt, readLastUserPrompt } from "../../sessions/history.js";
11
11
  import { progressBar } from "../../render/progress.js";
12
12
  import { refreshMenu } from "../menu/refresh.js";
13
13
  import { sendMarkdownDoc } from "../telegram-io.js";
@@ -51,47 +51,49 @@ function cleanPrompt(raw: string): string {
51
51
  /** Build a rich card (plain text, no MarkdownV2) + buttons for one controlled
52
52
  * session: Switch / History / Close.
53
53
  *
54
- * The comment line always answers "what is happening / what was solved":
55
- * busy → live current step (tool / thinking / working on …)
56
- * idle → last-turn outcome (assistant result + files), never bare import noise
54
+ * Comment:
55
+ * always last user prompt (≤250)
56
+ * busy — plus last AI agent thinking on the next line (≤250)
57
57
  */
58
58
  function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text: string; kb: InlineKeyboard } {
59
59
  const dot = s.foreground ? "\u25B6\uFE0F" : s.busy ? "\u{1F7E0}" : "\u26AA";
60
60
  const state = s.foreground ? "foreground" : s.busy ? "working" : "idle";
61
61
 
62
62
  let when = "new";
63
- let historySummary = "";
63
+ let lastUser = "";
64
64
  let firstPrompt = "";
65
65
  if (s.sessionId) {
66
66
  const path = deps.store.jsonlPath(s.sessionId);
67
67
  const mtime = jsonlMtimeMs(path);
68
68
  if (mtime) when = timeAgo(now - mtime);
69
- // Last assistant outcome beats first-prompt import-confirm noise.
70
- historySummary = readLastCardSummary(path);
69
+ lastUser = readLastUserPrompt(path, 40, 250);
71
70
  firstPrompt = cleanPrompt(readFirstPrompt(path));
72
71
  if (/session import complete/i.test(firstPrompt)) firstPrompt = "";
73
72
  }
74
73
 
75
74
  const diskComment = s.sessionId ? deps.store.get(s.sessionId)?.comment?.trim() : undefined;
76
- // Order: live runtime commentpersisted last-turn summary history tail → first prompt.
75
+ // Order: live runtime (user + thinking) → last user from history disk → first prompt.
77
76
  const comment =
78
77
  (s.comment && s.comment.trim()) ||
78
+ lastUser ||
79
79
  diskComment ||
80
- historySummary ||
81
80
  firstPrompt;
82
81
 
83
82
  const meta = [when, state];
84
83
  if (s.busy) meta.push("\u23F3");
85
84
  if (s.unread > 0) meta.push(`${s.unread} \u{1F4EC} unread`);
86
85
 
87
- const commentLabel = s.busy ? "\u23F3" : "\u{1F4AC}";
88
- const lines = [
89
- `${dot} ${s.projectName}`,
90
- comment
91
- ? `${commentLabel} ${trunc(comment, 200)}`
92
- : "\u{1F4AC} (no messages yet)",
93
- `\u{1F552} ${meta.join(" \u00B7 ")}`,
94
- ];
86
+ const lines = [`${dot} ${s.projectName}`];
87
+ if (comment) {
88
+ const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
89
+ parts.forEach((part, i) => {
90
+ const icon = i === 0 ? (s.busy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
91
+ lines.push(`${icon} ${trunc(part, 250)}`);
92
+ });
93
+ } else {
94
+ lines.push("\u{1F4AC} (no messages yet)");
95
+ }
96
+ lines.push(`\u{1F552} ${meta.join(" \u00B7 ")}`);
95
97
  if (s.progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(s.progress)}`);
96
98
  if (s.sessionId) lines.push(`\u{1F194} ${s.sessionId.slice(0, 8)}`);
97
99
 
@@ -108,14 +110,55 @@ function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text
108
110
 
109
111
  export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
110
112
  await deps.ephemeral.open(ctx);
111
- const list = dedupeBySession(deps.registry.controller(ctx.chat!.id).list());
113
+ const { resolveScope } = await import("../scope.js");
114
+ const scope = resolveScope(ctx, deps);
115
+ // Topic: only this topic's controlled sessions. Private: this chat + same-project forum sessions.
116
+ let list = dedupeBySession(scope.controller.list());
117
+ if (!scope.isForum) {
118
+ const path = scope.rt.cwd;
119
+ for (const fc of deps.registry.allForumControllers()) {
120
+ if (fc.fixedCwd && samePath(fc.fixedCwd, path)) {
121
+ list = dedupeBySession([...list, ...fc.list().map((s) => ({ ...s, projectName: `${s.projectName} \u00B7 topic` }))]);
122
+ }
123
+ }
124
+ } else {
125
+ // Also surface private-bot sessions for the same project path.
126
+ for (const chatId of deps.settings.chatIds()) {
127
+ if (chatId === scope.chatId) continue;
128
+ try {
129
+ const priv = deps.registry.controller(chatId);
130
+ for (const s of priv.list()) {
131
+ // Match by session cwd via store or name — use store meta if available.
132
+ if (s.sessionId) {
133
+ const meta = deps.store.get(s.sessionId);
134
+ if (meta?.cwd && samePath(meta.cwd, scope.rt.cwd)) {
135
+ list = dedupeBySession([
136
+ ...list,
137
+ { ...s, projectName: `${s.projectName} \u00B7 DM`, foreground: false },
138
+ ]);
139
+ }
140
+ }
141
+ }
142
+ } catch {
143
+ /* skip */
144
+ }
145
+ }
146
+ }
112
147
  if (list.length === 0) {
113
- await deps.ephemeral.reply(ctx, "No sessions controlled yet. Use \u{1F4C1} Project or /new to start one.");
148
+ await deps.ephemeral.reply(
149
+ ctx,
150
+ scope.isForum
151
+ ? "No sessions in this topic yet. Send a message or tap \u{1F195} New."
152
+ : "No sessions controlled yet. Use \u{1F4C1} Project or /new to start one.",
153
+ );
114
154
  return;
115
155
  }
116
156
  const now = Date.now();
117
157
  const shown = list.slice(0, CARD_LIMIT);
118
- await deps.ephemeral.reply(ctx, `\u{1F9ED} Sessions controlled by this chat (${list.length}) \u2014 tap \u{1F500} Switch on a card:`);
158
+ const header = scope.isForum
159
+ ? `\u{1F9ED} Running in topic **${scope.projectName ?? "topic"}** (${list.length})`
160
+ : `\u{1F9ED} Sessions controlled by this chat (${list.length}) \u2014 tap \u{1F500} Switch on a card:`;
161
+ await deps.ephemeral.reply(ctx, header);
119
162
  for (const s of shown) {
120
163
  const { text, kb } = buildRunningCard(s, deps, now);
121
164
  await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
@@ -125,6 +168,11 @@ export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
125
168
  }
126
169
  }
127
170
 
171
+ function samePath(a: string, b: string): boolean {
172
+ return a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() ===
173
+ b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
174
+ }
175
+
128
176
  /** Collapse any cards that share a session id (defensive — the controller
129
177
  * already prunes duplicate runtimes, but never show the same session twice). */
130
178
  function dedupeBySession(list: RunningSession[]): RunningSession[] {
@@ -139,14 +187,76 @@ function dedupeBySession(list: RunningSession[]): RunningSession[] {
139
187
 
140
188
  /** Switch the chat to a session and show its summary + unread. */
141
189
  export async function switchAndShow(ctx: Context, deps: BotDeps, sessionId: string): Promise<void> {
142
- const res = await deps.registry.controller(ctx.chat!.id).switchTo(sessionId);
190
+ const { resolveScope } = await import("../scope.js");
191
+ const scope = resolveScope(ctx, deps);
192
+ // Always bring the session into *this* scope's controller (never switch FG on
193
+ // another surface — that would dual-own one ACP session across controllers).
194
+ let res = await scope.controller.switchTo(sessionId);
195
+ if (!res) {
196
+ const meta = deps.store.get(sessionId);
197
+ let cwd = meta?.cwd;
198
+ let name = meta?.title || (cwd ? basenameSafe(cwd) : undefined);
199
+ if (!cwd) {
200
+ // Discover cwd from whatever controller currently lists it.
201
+ for (const c of [scope.controller, ...deps.registry.allForumControllers()]) {
202
+ const hit = c.list().find((s) => s.sessionId === sessionId);
203
+ if (hit) {
204
+ cwd = (c as { fixedCwd?: string }).fixedCwd || scope.rt.cwd;
205
+ name = hit.projectName || name;
206
+ break;
207
+ }
208
+ }
209
+ }
210
+ if (cwd) {
211
+ // Topic controllers are fixed-path — refuse foreign projects.
212
+ if (scope.controller.fixedCwd && !samePath(scope.controller.fixedCwd, cwd)) {
213
+ await ctx.reply(
214
+ "That session belongs to a different project than this topic.",
215
+ scope.threadExtra,
216
+ );
217
+ return;
218
+ }
219
+ // Drop dual ownership: release from any other controller first.
220
+ await releaseSessionElsewhere(deps, scope.controller, sessionId);
221
+ const hist = (await import("../../sessions/history.js")).readHistory(
222
+ deps.store.jsonlPath(sessionId),
223
+ );
224
+ await scope.controller.addAttach(sessionId, cwd, name || basenameSafe(cwd), hist);
225
+ res = await scope.controller.switchTo(sessionId);
226
+ }
227
+ }
143
228
  if (!res) {
144
- await ctx.reply("Session not found (it may have been closed).");
229
+ await ctx.reply("Session not found (it may have been closed).", scope.threadExtra);
145
230
  return;
146
231
  }
147
232
  await deliverSwitch(ctx, deps, res);
148
233
  }
149
234
 
235
+ /** Stop controlling a session on every controller except `keep`. */
236
+ async function releaseSessionElsewhere(
237
+ deps: BotDeps,
238
+ keep: import("../chat-controller.js").ChatController,
239
+ sessionId: string,
240
+ ): Promise<void> {
241
+ for (const c of deps.registry.allForumControllers()) {
242
+ if (c !== keep && c.findBySession(sessionId)) await c.close(sessionId);
243
+ }
244
+ for (const chatId of deps.settings.chatIds()) {
245
+ try {
246
+ const c = deps.registry.controller(chatId);
247
+ if (c !== keep && c.findBySession(sessionId)) await c.close(sessionId);
248
+ } catch {
249
+ /* skip */
250
+ }
251
+ }
252
+ }
253
+
254
+ function basenameSafe(p: string): string {
255
+ const n = p.replace(/\\/g, "/").replace(/\/+$/, "");
256
+ const i = n.lastIndexOf("/");
257
+ return i >= 0 ? n.slice(i + 1) : n;
258
+ }
259
+
150
260
  export function registerRunning(bot: Bot, deps: BotDeps): void {
151
261
  bot.command("running", (ctx) => showRunning(ctx, deps));
152
262
 
@@ -160,8 +270,24 @@ export function registerRunning(bot: Bot, deps: BotDeps): void {
160
270
 
161
271
  bot.callbackQuery(new RegExp(`^run:close:${UUID}$`), async (ctx) => {
162
272
  const id = ctx.match![1]!;
163
- await deps.registry.controller(ctx.chat!.id).close(id);
164
- await ctx.answerCallbackQuery({ text: "Closed" });
273
+ const { resolveScope } = await import("../scope.js");
274
+ const scope = resolveScope(ctx, deps);
275
+ let closed = await scope.controller.close(id);
276
+ // Card may show a session owned by the other surface — close on owner.
277
+ if (!closed) {
278
+ const fc = deps.registry.forumControllerForSession(id);
279
+ if (fc) closed = await fc.close(id);
280
+ }
281
+ if (!closed) {
282
+ for (const chatId of deps.settings.chatIds()) {
283
+ const c = deps.registry.controller(chatId);
284
+ if (c.findBySession(id)) {
285
+ closed = await c.close(id);
286
+ break;
287
+ }
288
+ }
289
+ }
290
+ await ctx.answerCallbackQuery({ text: closed ? "Closed" : "Not found" });
165
291
  await ctx.deleteMessage().catch(() => {}); // remove just this card
166
292
  });
167
293
  }
@@ -24,8 +24,8 @@ export interface SessionCardExtras {
24
24
  /** Latest task-completion % (0–100) for this session, if this chat runs it. */
25
25
  progress?: number;
26
26
  /**
27
- * Live step (while working) or chat summary (when idle). Overrides
28
- * `m.comment` when provided by the controlling chat runtime.
27
+ * Last user prompt (and, when busy, last AI thinking on a second line).
28
+ * Overrides `m.comment` when provided by the controlling chat runtime.
29
29
  */
30
30
  comment?: string;
31
31
  }
@@ -35,6 +35,8 @@ export interface SessionCard {
35
35
  keyboard: InlineKeyboard;
36
36
  }
37
37
 
38
+ const COMMENT_LINE_MAX = 250;
39
+
38
40
  /** Build the card body + buttons for one session. */
39
41
  export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}): SessionCard {
40
42
  const dot = m.active ? "\u{1F7E2}" : "\u26AA";
@@ -44,10 +46,16 @@ export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}):
44
46
 
45
47
  const lines = [`${dot} ${m.title}`, `\u{1F4C1} ${proj}`];
46
48
  if (m.cwd) lines.push(` ${m.cwd}`);
47
- // Always surface "what is happening / what was done" when we have it.
49
+ // Last user prompt always; second line = thinking while running.
48
50
  if (comment) {
49
- const icon = m.active || typeof extra.progress === "number" ? "\u23F3" : "\u{1F4AC}";
50
- lines.push(`${icon} ${comment.length > 160 ? comment.slice(0, 159) + "\u2026" : comment}`);
51
+ const busy = m.active || typeof extra.progress === "number";
52
+ const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
53
+ parts.forEach((part, i) => {
54
+ const clipped = part.length > COMMENT_LINE_MAX ? part.slice(0, COMMENT_LINE_MAX - 1) + "\u2026" : part;
55
+ // First line: user prompt; later lines (thinking): hourglass when busy.
56
+ const icon = i === 0 ? (busy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
57
+ lines.push(`${icon} ${clipped}`);
58
+ });
51
59
  }
52
60
  lines.push(`\u{1F552} updated ${relTime(m.updatedAt)} \u00B7 created ${relTime(m.createdAt)}`);
53
61
  const ctx = typeof extra.contextPct === "number" ? ` \u00B7 \u{1F9E0} ctx ${Math.round(extra.contextPct)}%` : "";
@@ -10,7 +10,7 @@
10
10
  import { type Bot, type Context, InlineKeyboard } from "grammy";
11
11
  import { basename } from "node:path";
12
12
  import type { BotDeps } from "../deps.js";
13
- import { readHistory, readLastCardSummary } from "../../sessions/history.js";
13
+ import { readHistory, readLastUserPrompt } from "../../sessions/history.js";
14
14
  import type { SessionMeta } from "../../sessions/types.js";
15
15
  import { refreshMenu } from "../menu/refresh.js";
16
16
  import { showHistory } from "./history.js";
@@ -22,16 +22,35 @@ const UUID = "([0-9a-fA-F-]{36})";
22
22
 
23
23
  export async function showSessions(ctx: Context, deps: BotDeps, query?: string): Promise<void> {
24
24
  const q = (query ?? "").trim().toLowerCase();
25
+ const { resolveScope } = await import("../scope.js");
26
+ const scope = resolveScope(ctx, deps);
25
27
  let metas = deps.store.list(q ? 400 : 200);
28
+ // In a forum topic, only show sessions for this project path (includes bot DMs).
29
+ if (scope.isForum && scope.projectPath) {
30
+ const want = scope.projectPath.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
31
+ metas = metas.filter((m) => (m.cwd || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === want);
32
+ }
26
33
  if (q) {
27
34
  metas = metas.filter((m) => `${m.title} ${m.cwd} ${m.sessionId}`.toLowerCase().includes(q));
28
35
  }
29
36
  if (metas.length === 0) {
30
37
  await deps.ephemeral.open(ctx);
31
- await deps.ephemeral.reply(ctx, q ? `No sessions match "${q}".` : "No saved sessions found in ~/.grok/sessions/cli.");
38
+ await deps.ephemeral.reply(
39
+ ctx,
40
+ q
41
+ ? `No sessions match "${q}".`
42
+ : scope.isForum
43
+ ? `No saved sessions for project **${scope.projectName}** yet.`
44
+ : "No saved sessions found in ~/.grok/sessions/cli.",
45
+ );
32
46
  return;
33
47
  }
34
- deps.menuCache.setSessions(ctx.chat!.id, metas, q ? `Sessions matching "${q}"` : "Recent sessions");
48
+ const heading = scope.isForum
49
+ ? `Sessions \u00B7 ${scope.projectName ?? "topic"}`
50
+ : q
51
+ ? `Sessions matching "${q}"`
52
+ : "Recent sessions";
53
+ deps.menuCache.setSessions(ctx.chat!.id, metas, heading);
35
54
  await renderSessionPage(ctx, deps, 0);
36
55
  }
37
56
 
@@ -50,15 +69,20 @@ async function renderSessionPage(ctx: Context, deps: BotDeps, page: number): Pro
50
69
  const pageStr = totalPages > 1 ? ` \u00B7 page ${p + 1}/${totalPages}` : "";
51
70
  await deps.ephemeral.reply(ctx, `\u{1F5C2} ${heading} \u2014 ${metas.length} total${liveStr}${pageStr}`);
52
71
 
72
+ const { resolveScope } = await import("../scope.js");
73
+ const scope = resolveScope(ctx, deps);
53
74
  for (const m of slice) {
54
75
  const contextPct = deps.acp.metadataFor(m.sessionId)?.contextUsagePercentage;
55
- const ctrl = deps.registry.controller(ctx.chat!.id);
56
- const progress = ctrl.progressFor(m.sessionId);
57
- // Live runtime → persisted comment → last assistant outcome from history.
76
+ const ctrl = scope.controller;
77
+ const progress =
78
+ ctrl.progressFor(m.sessionId) ??
79
+ deps.registry.forumControllerForSession(m.sessionId)?.progressFor(m.sessionId);
80
+ // Live runtime (user + thinking) → last user from history → disk comment.
58
81
  const comment =
59
82
  ctrl.commentFor(m.sessionId) ||
60
- m.comment ||
61
- readLastCardSummary(deps.store.jsonlPath(m.sessionId));
83
+ deps.registry.forumControllerForSession(m.sessionId)?.commentFor(m.sessionId) ||
84
+ readLastUserPrompt(deps.store.jsonlPath(m.sessionId)) ||
85
+ m.comment;
62
86
  const { text, keyboard } = buildSessionCard(m, {
63
87
  contextPct,
64
88
  selfPid: deps.acp.pid,
@@ -97,8 +121,12 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
97
121
  });
98
122
 
99
123
  bot.command("unwatch", async (ctx) => {
100
- const rt = deps.registry.get(ctx.chat.id);
101
- await ctx.reply(rt.stopWatch() ? "\u{1F6D1} Stopped watching." : "Not watching anything.");
124
+ const { resolveScope } = await import("../scope.js");
125
+ const scope = resolveScope(ctx, deps);
126
+ await ctx.reply(
127
+ scope.rt.stopWatch() ? "\u{1F6D1} Stopped watching." : "Not watching anything.",
128
+ scope.threadExtra,
129
+ );
102
130
  });
103
131
 
104
132
  bot.callbackQuery(new RegExp(`^sess:${UUID}$`), async (ctx) => {
@@ -110,19 +138,39 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
110
138
  }
111
139
  await ctx.answerCallbackQuery();
112
140
  await deps.ephemeral.clear(ctx.chat!.id); // remove the session cards
113
- const fgCwd = deps.registry.get(ctx.chat!.id).cwd;
141
+ const { resolveScope } = await import("../scope.js");
142
+ const scope = resolveScope(ctx, deps);
143
+ const fgCwd = scope.rt.cwd;
114
144
  const cwd = meta.cwd || fgCwd;
115
145
  const projectName = basename(meta.cwd || fgCwd) || "session";
146
+ if (scope.controller.fixedCwd) {
147
+ const a = scope.controller.fixedCwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
148
+ const b = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
149
+ if (a !== b) {
150
+ await ctx.reply("That session is for a different project than this topic.", scope.threadExtra);
151
+ return;
152
+ }
153
+ }
116
154
  const prior = readHistory(deps.store.jsonlPath(id), 24);
117
155
  try {
118
- const { result, alreadyControlled } = await deps.registry
119
- .controller(ctx.chat!.id)
120
- .addAttach(id, cwd, projectName, prior);
121
- await ctx.reply(alreadyControlled ? `\u{1F500} Switched to ${meta.title}` : connectMessage(result, meta));
156
+ // Avoid dual ownership across DM topic controllers.
157
+ for (const c of deps.registry.allForumControllers()) {
158
+ if (c !== scope.controller && c.findBySession(id)) await c.close(id);
159
+ }
160
+ const { result, alreadyControlled } = await scope.controller.addAttach(
161
+ id,
162
+ cwd,
163
+ projectName,
164
+ prior,
165
+ );
166
+ await ctx.reply(
167
+ alreadyControlled ? `\u{1F500} Switched to ${meta.title}` : connectMessage(result, meta),
168
+ scope.threadExtra,
169
+ );
122
170
  await refreshMenu(ctx, deps, `\u{1F4C2} ${meta.title}`);
123
171
  await showHistory(deps, ctx.chat!.id, id, meta);
124
172
  } catch (err) {
125
- await ctx.reply(`\u274C Could not connect: ${(err as Error).message}`);
173
+ await ctx.reply(`\u274C Could not connect: ${(err as Error).message}`, scope.threadExtra);
126
174
  }
127
175
  });
128
176
 
@@ -137,10 +185,12 @@ export function registerSessions(bot: Bot, deps: BotDeps): void {
137
185
  const id = ctx.match![1]!;
138
186
  await ctx.answerCallbackQuery();
139
187
  const meta = deps.store.get(id);
140
- const rt = deps.registry.get(ctx.chat!.id);
141
- rt.startWatch(deps.store.jsonlPath(id));
188
+ const { resolveScope } = await import("../scope.js");
189
+ const scope = resolveScope(ctx, deps);
190
+ scope.rt.startWatch(deps.store.jsonlPath(id));
142
191
  await ctx.reply(
143
192
  `\u{1F4E1} Watching live: ${meta?.title ?? id.slice(0, 8)}\nNew activity streams here. Send /unwatch to stop.`,
193
+ scope.threadExtra,
144
194
  );
145
195
  });
146
196
  }
@@ -14,8 +14,16 @@ import { extractReplyContext } from "../reply-context.js";
14
14
 
15
15
  const log = createLogger("voice");
16
16
 
17
+ type VoiceMediaKind = "voice" | "audio" | "video_note";
18
+
17
19
  export function registerVoice(bot: Bot, deps: BotDeps): void {
18
- const handle = async (ctx: Context, fileId: string, mime: string, name: string): Promise<void> => {
20
+ const handle = async (
21
+ ctx: Context,
22
+ fileId: string,
23
+ mime: string,
24
+ name: string,
25
+ mediaKind: VoiceMediaKind,
26
+ ): Promise<void> => {
19
27
  const chatId = ctx.chat!.id;
20
28
  if (deps.wizard.isActive(chatId)) {
21
29
  await ctx.reply("Finish or /cancel the task wizard before sending voice.");
@@ -36,11 +44,41 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
36
44
  await ctx.reply("\u{1F399} I couldn't make out any speech.");
37
45
  return;
38
46
  }
39
- await ctx.reply(`\u{1F399} \u201C${text}\u201D`);
40
- const rt = deps.registry.get(chatId);
47
+ const { resolveScope } = await import("../scope.js");
48
+ const { adoptUserPrompt } = await import("../prompt-anchor.js");
49
+ const scope = resolveScope(ctx, deps);
41
50
  const quoted = extractReplyContext(ctx);
42
- const outcome = await rt.submit(textPrompt(text, ctx.message?.message_id, quoted));
43
- if (outcome === "queued") await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.");
51
+ const userMsgId = ctx.message?.message_id;
52
+ const media =
53
+ mediaKind === "video_note"
54
+ ? [{ type: "video_note" as const, fileId }]
55
+ : mediaKind === "voice"
56
+ ? [{ type: "voice" as const, fileId }]
57
+ : [{ type: "audio" as const, fileId, fileName: name }];
58
+ const anchor = await adoptUserPrompt(deps.api, {
59
+ chatId,
60
+ text: `\u201C${text}\u201D`,
61
+ userMessageIds: userMsgId !== undefined ? [userMsgId] : [],
62
+ messageThreadId: scope.threadExtra.message_thread_id,
63
+ projectName: scope.rt.projectName,
64
+ prefix: "\u{1F399} Voice",
65
+ media,
66
+ });
67
+ const outcome = await scope.rt.submit(
68
+ textPrompt(text, anchor?.replyTo ?? userMsgId, quoted, {
69
+ promptId: anchor?.promptId,
70
+ }),
71
+ );
72
+ if (outcome === "queued") {
73
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
74
+ if (anchor?.replyTo !== undefined) {
75
+ extra.reply_parameters = {
76
+ message_id: anchor.replyTo,
77
+ allow_sending_without_reply: true,
78
+ };
79
+ }
80
+ await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.", extra);
81
+ }
44
82
  } catch (e) {
45
83
  log.warn("voice failed:", (e as Error).message);
46
84
  await ctx.reply(`\u274C Voice transcription failed: ${(e as Error).message}`);
@@ -48,7 +86,13 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
48
86
  };
49
87
 
50
88
  bot.on("message:voice", (ctx) =>
51
- handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"),
89
+ handle(
90
+ ctx,
91
+ ctx.message.voice.file_id,
92
+ ctx.message.voice.mime_type || "audio/ogg",
93
+ "voice.ogg",
94
+ "voice",
95
+ ),
52
96
  );
53
97
  bot.on("message:audio", (ctx) =>
54
98
  handle(
@@ -56,10 +100,11 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
56
100
  ctx.message.audio.file_id,
57
101
  ctx.message.audio.mime_type || "audio/mpeg",
58
102
  ctx.message.audio.file_name || "audio.mp3",
103
+ "audio",
59
104
  ),
60
105
  );
61
106
  bot.on("message:video_note", (ctx) =>
62
- handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"),
107
+ handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4", "video_note"),
63
108
  );
64
109
  }
65
110
 
@@ -129,6 +129,8 @@ export interface SendImagesOptions {
129
129
  max: number;
130
130
  /** Optional Telegram message id to thread replies under. */
131
131
  replyTo?: number;
132
+ /** Forum topic — required so agent images land in the right topic. */
133
+ messageThreadId?: number;
132
134
  }
133
135
 
134
136
  /** Send the valid, fresh, not-yet-sent images as documents. Returns how many were sent. */
@@ -139,10 +141,14 @@ export async function sendImages(
139
141
  opts: SendImagesOptions,
140
142
  ): Promise<number> {
141
143
  let sent = 0;
142
- const replyExtra =
143
- opts.replyTo !== undefined
144
- ? { reply_parameters: { message_id: opts.replyTo, allow_sending_without_reply: true } }
145
- : {};
144
+ const extra: Record<string, unknown> = {};
145
+ if (opts.replyTo !== undefined) {
146
+ extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
147
+ }
148
+ // Omit General (1) — Bot API rejects message_thread_id=1.
149
+ if (opts.messageThreadId !== undefined && opts.messageThreadId !== 1) {
150
+ extra.message_thread_id = opts.messageThreadId;
151
+ }
146
152
  for (const path of paths) {
147
153
  if (sent >= opts.max) break;
148
154
  if (opts.already.has(path)) continue;
@@ -161,7 +167,7 @@ export async function sendImages(
161
167
  const file = new InputFile(path, basename(path));
162
168
  await api.sendDocument(chatId, file, {
163
169
  caption: basename(path),
164
- ...replyExtra,
170
+ ...extra,
165
171
  });
166
172
  sent++;
167
173
  log.debug(`sent document ${path}`);