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
@@ -8,96 +8,316 @@
8
8
  * rapid consecutive text messages per chat within a short debounce window
9
9
  * (`MESSAGE_BATCH_MS`) into a single prompt — one submission, one confirmation.
10
10
  *
11
- * While a turn is running, the combined message is queued and runs
12
- * automatically when the current turn finishes.
13
- * (Wizard input and menu-button text are intercepted by earlier handlers.)
11
+ * Project topics: user messages are replaced by a bot-owned prompt anchor
12
+ * (`#prompt_<id>`) so the chat shows immediate life while CLI/ACP starts.
13
+ *
14
+ * General (manager): user messages are KEPT; AI replies thread to the user
15
+ * message. Each message is a NEW session (parallel), unless the user replies
16
+ * to a bot message — then that session continues.
14
17
  */
15
18
  import type { Bot } from "grammy";
16
19
  import { textPrompt } from "../../app/types.js";
17
20
  import { createLogger } from "../../logger.js";
21
+ import {
22
+ batchKey,
23
+ forumThreadId,
24
+ isGeneralThread,
25
+ outboundThreadExtra,
26
+ } from "../../forum/thread.js";
18
27
  import type { BotDeps } from "../deps.js";
28
+ import { adoptUserPrompt, newPromptId } from "../prompt-anchor.js";
19
29
  import { extractReplyContext } from "../reply-context.js";
30
+ import { resolveForumRuntime } from "./forum.js";
31
+ import type { SessionRuntime } from "../session-runtime.js";
20
32
 
21
33
  const log = createLogger("message");
22
34
 
23
- /** A pending burst of text messages from one chat, awaiting coalescing. */
35
+ /** A pending burst of text messages from one chat/topic, awaiting coalescing. */
24
36
  interface TextBatch {
25
37
  parts: string[];
26
38
  ids: number[];
39
+ /** Forum topic thread id (undefined for private chats / General without id). */
40
+ threadId?: number;
27
41
  /** Reference content if the burst began as a reply to another message. */
28
42
  quoted?: string;
43
+ /** Telegram message_id the user is replying to (for session follow-up). */
44
+ replyToMessageId?: number;
45
+ /** Text of the replied-to message (for #sess_ recovery). */
46
+ replyToText?: string;
29
47
  timer: NodeJS.Timeout;
30
48
  }
31
49
 
32
50
  export function registerMessages(bot: Bot, deps: BotDeps): void {
33
- const batches = new Map<number, TextBatch>();
51
+ const batches = new Map<string, TextBatch>();
34
52
  const windowMs = deps.cfg.messageBatchMs;
35
53
 
36
- const arm = (chatId: number): NodeJS.Timeout =>
37
- setTimeout(() => void flush(deps, batches, chatId), windowMs);
54
+ const arm = (key: string): NodeJS.Timeout =>
55
+ setTimeout(() => void flush(deps, batches, key), windowMs);
38
56
 
39
57
  bot.on("message:text", async (ctx) => {
40
58
  const text = ctx.message.text;
41
59
  if (!text.trim()) return;
60
+ // Slash commands are handled by bot.command / menu — never batch as agent prompts.
61
+ if (!text.includes("\n") && text.startsWith("/")) return;
62
+
42
63
  const chatId = ctx.chat.id;
43
64
  const id = ctx.message.message_id;
65
+ const rawThreadId = ctx.message.message_thread_id;
66
+ const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
67
+ const threadId = isForum ? forumThreadId(rawThreadId) : rawThreadId;
44
68
  const quoted = extractReplyContext(ctx);
69
+ const replyToMessageId = ctx.message.reply_to_message?.message_id;
70
+ const replyToText =
71
+ ctx.message.reply_to_message?.text ?? ctx.message.reply_to_message?.caption;
72
+ // General: do not coalesce independent messages (parallel sessions).
73
+ // Still coalesce multi-part 4096 splits when reply chain is empty and rapid.
74
+ const key = isGeneralThread(threadId) && isForum
75
+ ? `${chatId}:${threadId ?? 1}:m${id}`
76
+ : batchKey(chatId, rawThreadId, isForum);
45
77
 
46
- const batch = batches.get(chatId);
78
+ const batch = batches.get(key);
47
79
  if (batch) {
48
80
  clearTimeout(batch.timer);
49
81
  batch.parts.push(text);
50
82
  batch.ids.push(id);
51
83
  if (quoted && !batch.quoted) batch.quoted = quoted;
52
- batch.timer = arm(chatId);
84
+ if (replyToMessageId !== undefined && batch.replyToMessageId === undefined) {
85
+ batch.replyToMessageId = replyToMessageId;
86
+ batch.replyToText = replyToText;
87
+ }
88
+ batch.timer = arm(key);
53
89
  return;
54
90
  }
55
- batches.set(chatId, { parts: [text], ids: [id], quoted, timer: arm(chatId) });
91
+ batches.set(key, {
92
+ parts: [text],
93
+ ids: [id],
94
+ threadId,
95
+ quoted,
96
+ replyToMessageId,
97
+ replyToText,
98
+ timer: arm(key),
99
+ });
56
100
  });
57
101
  }
58
102
 
59
103
  /** Coalesce a chat's buffered parts into one prompt and submit it once. */
60
- async function flush(deps: BotDeps, batches: Map<number, TextBatch>, chatId: number): Promise<void> {
61
- const batch = batches.get(chatId);
104
+ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string): Promise<void> {
105
+ const batch = batches.get(key);
62
106
  if (!batch) return;
63
- batches.delete(chatId);
107
+ batches.delete(key);
64
108
 
65
- // Telegram splits at 4096 chars, almost always on a line boundary, so
66
- // rejoining with a newline reconstructs the original text faithfully.
67
109
  const combined = batch.parts.join("\n").trim();
68
110
  if (!combined) return;
69
111
 
70
- // A lone, single-line "/something" is an unknown-command typo — guide the
71
- // user instead of forwarding it to the agent. Split content never trips
72
- // this: it arrives as multiple parts, and multi-line text is never a command.
112
+ const [chatIdStr] = key.split(":");
113
+ const chatId = Number(chatIdStr);
114
+ const threadId = batch.threadId;
115
+
73
116
  if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
74
- await send(deps, chatId, "Unknown command. Type /help to see what I can do.");
75
117
  return;
76
118
  }
77
119
 
78
- const rt = deps.registry.get(chatId);
120
+ const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
121
+ const isGeneral = isForum && isGeneralThread(threadId);
122
+
123
+ let rt: SessionRuntime = deps.registry.get(chatId);
124
+
125
+ if (isForum && deps.forum) {
126
+ const resolved = await resolveForumRuntime(
127
+ deps,
128
+ deps.forum,
129
+ chatId,
130
+ threadId,
131
+ combined,
132
+ batch.ids[0]!,
133
+ );
134
+ if (resolved === "handled" || resolved === "ignore") return;
135
+
136
+ if (isGeneral) {
137
+ // ── General manager path ─────────────────────────────────────────
138
+ const controller = deps.registry.forumController(
139
+ chatId,
140
+ forumThreadId(threadId),
141
+ resolved.rt.cwd,
142
+ resolved.rt.projectName ?? "General",
143
+ );
144
+
145
+ // Reply → continue same session (map, #sess_ on controlled runtimes, or disk).
146
+ // Fresh message → new parallel session (does not queue behind other General work).
147
+ const continueRt = await controller.resolveContinueFromReply({
148
+ replyToMessageId: batch.replyToMessageId,
149
+ replyToText: batch.replyToText,
150
+ cwd: resolved.rt.cwd,
151
+ projectName: resolved.rt.projectName ?? "General",
152
+ });
153
+
154
+ const userMsgId = batch.ids[0]!;
155
+ const replyTo = userMsgId;
156
+ // Keep user message; reply to it. No overwrite / adopt delete.
157
+
158
+ // New session: post "Starting…" FIRST (before ACP session/new) so the user
159
+ // sees life immediately. runTurn later edits it to Thinking… then streams.
160
+ // Follow-up on existing session: skip Starting (runTurn posts Thinking…).
161
+ let seedMessageId: number | undefined;
162
+ if (!continueRt) {
163
+ seedMessageId = await sendStatus(
164
+ deps,
165
+ chatId,
166
+ "Starting\u2026",
167
+ threadId,
168
+ replyTo,
169
+ );
170
+ rt = await controller.addParallel(
171
+ resolved.rt.cwd,
172
+ resolved.rt.projectName ?? "General",
173
+ );
174
+ if (seedMessageId !== undefined && rt.sessionId) {
175
+ controller.bindTelegramMessage(seedMessageId, rt.sessionId);
176
+ }
177
+ } else {
178
+ rt = continueRt;
179
+ // FG for status panel; manager setForeground keeps busy siblings streaming.
180
+ if (rt.sessionId) await controller.switchTo(rt.sessionId).catch(() => {});
181
+ }
182
+ if (rt.sessionId) controller.bindTelegramMessage(userMsgId, rt.sessionId);
183
+
184
+ try {
185
+ const outcome = await rt.submit(
186
+ textPrompt(combined, replyTo, batch.quoted, {
187
+ promptId: newPromptId(),
188
+ seedMessageId,
189
+ }),
190
+ );
191
+ if (rt.sessionId) {
192
+ controller.bindTelegramMessage(userMsgId, rt.sessionId);
193
+ if (seedMessageId !== undefined) {
194
+ controller.bindTelegramMessage(seedMessageId, rt.sessionId);
195
+ }
196
+ }
197
+ // Never show "queued" spam for new parallel sessions; only if continuing
198
+ // the same session that is already busy.
199
+ if (outcome === "queued" && continueRt) {
200
+ await send(
201
+ deps,
202
+ chatId,
203
+ `\u{1F4E5} Got it — queued as a follow-up on that thread.`,
204
+ threadId,
205
+ replyTo,
206
+ );
207
+ }
208
+ } catch (err) {
209
+ log.warn(`general submit failed chat ${chatId}: ${(err as Error).message}`);
210
+ if (seedMessageId !== undefined) {
211
+ await editStatus(deps, chatId, seedMessageId, `\u274C Couldn't start: ${(err as Error).message}`);
212
+ } else {
213
+ await send(
214
+ deps,
215
+ chatId,
216
+ `\u274C Couldn't start: ${(err as Error).message}`,
217
+ threadId,
218
+ replyTo,
219
+ );
220
+ }
221
+ }
222
+ return;
223
+ }
224
+
225
+ // ── Project / AI Chat topics (existing behavior) ─────────────────
226
+ rt = resolved.rt;
227
+ if (!rt.sessionId && !rt.isBusy) {
228
+ try {
229
+ await rt.startNewSession(rt.cwd, rt.projectName);
230
+ } catch {
231
+ /* ensureSession on submit will retry */
232
+ }
233
+ }
234
+ }
235
+
79
236
  const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
237
+ let replyTo: number | undefined = batch.ids[0];
80
238
  try {
81
- // Thread the reply to the prompt message (the user's message is left intact;
82
- // the agent's response + Done reply to it, and carry searchable hashtags).
83
- const outcome = await rt.submit(textPrompt(combined, batch.ids[0], batch.quoted));
239
+ const anchor = await adoptUserPrompt(deps.api, {
240
+ chatId,
241
+ text: combined,
242
+ userMessageIds: batch.ids,
243
+ messageThreadId: threadId,
244
+ projectName: rt.projectName,
245
+ prefix: "\u{1F4DD} Prompt",
246
+ });
247
+ replyTo = anchor?.replyTo ?? batch.ids[0];
248
+ const outcome = await rt.submit(
249
+ textPrompt(combined, replyTo, batch.quoted, { promptId: anchor?.promptId }),
250
+ );
84
251
  if (outcome === "queued") {
85
252
  await send(
86
253
  deps,
87
254
  chatId,
88
255
  `\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
256
+ threadId,
257
+ replyTo,
89
258
  );
90
259
  }
91
- // "ran": turn started; complexity is steered silently by the agent.
92
260
  } catch (err) {
93
261
  log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
94
- await send(deps, chatId, `\u274C Couldn't start your message: ${(err as Error).message}`);
262
+ await send(
263
+ deps,
264
+ chatId,
265
+ `\u274C Couldn't start your message: ${(err as Error).message}`,
266
+ threadId,
267
+ replyTo,
268
+ );
269
+ }
270
+ }
271
+
272
+ async function send(
273
+ deps: BotDeps,
274
+ chatId: number,
275
+ text: string,
276
+ threadId?: number,
277
+ replyTo?: number,
278
+ ): Promise<void> {
279
+ try {
280
+ const extra: Record<string, unknown> = { ...outboundThreadExtra(threadId) };
281
+ if (replyTo !== undefined) {
282
+ extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
283
+ }
284
+ await deps.api.sendMessage(chatId, text, extra);
285
+ } catch {
286
+ /* non-fatal */
287
+ }
288
+ }
289
+
290
+ /** Post status bubble; returns message_id for later edit (Starting… → Thinking…). */
291
+ async function sendStatus(
292
+ deps: BotDeps,
293
+ chatId: number,
294
+ text: string,
295
+ threadId?: number,
296
+ replyTo?: number,
297
+ ): Promise<number | undefined> {
298
+ try {
299
+ const extra: Record<string, unknown> = {
300
+ disable_notification: true,
301
+ ...outboundThreadExtra(threadId),
302
+ };
303
+ if (replyTo !== undefined) {
304
+ extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
305
+ }
306
+ const msg = await deps.api.sendMessage(chatId, text, extra);
307
+ return msg.message_id;
308
+ } catch {
309
+ return undefined;
95
310
  }
96
311
  }
97
312
 
98
- async function send(deps: BotDeps, chatId: number, text: string): Promise<void> {
313
+ async function editStatus(
314
+ deps: BotDeps,
315
+ chatId: number,
316
+ messageId: number,
317
+ text: string,
318
+ ): Promise<void> {
99
319
  try {
100
- await deps.api.sendMessage(chatId, text);
320
+ await deps.api.editMessageText(chatId, messageId, text);
101
321
  } catch {
102
322
  /* non-fatal */
103
323
  }
@@ -2,11 +2,15 @@
2
2
  * Photo & image-document handler. Downloads images (including multi-image
3
3
  * albums / media groups) and submits them to Grok as ACP image content blocks
4
4
  * alongside the caption text.
5
+ *
6
+ * User media messages are replaced by a bot prompt anchor (`#prompt_<id>`);
7
+ * the agent still receives the downloaded image bytes.
5
8
  */
6
9
  import type { Bot, Context } from "grammy";
7
10
  import type { PromptImage } from "../../app/types.js";
8
11
  import { createLogger } from "../../logger.js";
9
12
  import type { BotDeps } from "../deps.js";
13
+ import { type AdoptMediaItem, adoptUserPrompt } from "../prompt-anchor.js";
10
14
  import { extractReplyContext } from "../reply-context.js";
11
15
 
12
16
  const log = createLogger("photo");
@@ -15,19 +19,31 @@ const GROUP_DEBOUNCE_MS = 900;
15
19
  interface GroupBuffer {
16
20
  chatId: number;
17
21
  caption: string;
22
+ /** Agent-bound image bytes (may be shorter than media if a download failed). */
18
23
  images: PromptImage[];
19
- replyTo?: number;
24
+ /** Chat re-post descriptors (file_id + correct photo vs document kind). */
25
+ media: AdoptMediaItem[];
26
+ /** User Telegram message ids in this album (for delete after anchor). */
27
+ userMessageIds: number[];
20
28
  quoted?: string;
29
+ threadId?: number;
21
30
  timer: NodeJS.Timeout;
22
31
  }
23
32
 
24
33
  export function registerPhotos(bot: Bot, deps: BotDeps): void {
25
34
  const groups = new Map<string, GroupBuffer>();
26
35
 
27
- const onMedia = async (ctx: Context, image: PromptImage | undefined, caption: string): Promise<void> => {
28
- if (!image) return;
36
+ const onMedia = async (
37
+ ctx: Context,
38
+ image: PromptImage | undefined,
39
+ mediaItem: AdoptMediaItem | undefined,
40
+ caption: string,
41
+ ): Promise<void> => {
42
+ // Still re-post to chat when download fails — user must not lose the file.
43
+ if (!mediaItem?.fileId) return;
29
44
  const chatId = ctx.chat!.id;
30
- const replyTo = ctx.message?.message_id;
45
+ const msgId = ctx.message?.message_id;
46
+ const threadId = ctx.message?.message_thread_id;
31
47
  const quoted = extractReplyContext(ctx);
32
48
 
33
49
  // Don't hijack the task wizard.
@@ -36,9 +52,21 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
36
52
  return;
37
53
  }
38
54
 
55
+ const images = image ? [image] : [];
56
+ const media = [mediaItem];
57
+
39
58
  const groupId = ctx.message?.media_group_id;
40
59
  if (!groupId) {
41
- await submit(deps, chatId, caption, [image], replyTo, quoted);
60
+ await submit(
61
+ deps,
62
+ chatId,
63
+ caption,
64
+ images,
65
+ media,
66
+ msgId !== undefined ? [msgId] : [],
67
+ quoted,
68
+ threadId,
69
+ );
42
70
  return;
43
71
  }
44
72
 
@@ -46,17 +74,21 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
46
74
  const existing = groups.get(groupId);
47
75
  if (existing) {
48
76
  clearTimeout(existing.timer);
49
- existing.images.push(image);
77
+ if (image) existing.images.push(image);
78
+ existing.media.push(mediaItem);
50
79
  if (caption) existing.caption = caption;
51
80
  if (quoted && !existing.quoted) existing.quoted = quoted;
81
+ if (msgId !== undefined) existing.userMessageIds.push(msgId);
52
82
  existing.timer = setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS);
53
83
  } else {
54
84
  groups.set(groupId, {
55
85
  chatId,
56
86
  caption,
57
- images: [image],
58
- replyTo,
87
+ images,
88
+ media,
89
+ userMessageIds: msgId !== undefined ? [msgId] : [],
59
90
  quoted,
91
+ threadId,
60
92
  timer: setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS),
61
93
  });
62
94
  }
@@ -65,15 +97,33 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
65
97
  bot.on("message:photo", async (ctx) => {
66
98
  const photos = ctx.message.photo;
67
99
  const largest = photos[photos.length - 1];
68
- const image = largest ? await download(ctx, largest.file_id, "image/jpeg", deps.cfg.token) : undefined;
69
- await onMedia(ctx, image, ctx.message.caption ?? "");
100
+ const fileId = largest?.file_id;
101
+ const image = fileId
102
+ ? await download(ctx, fileId, "image/jpeg", deps.cfg.token)
103
+ : undefined;
104
+ await onMedia(
105
+ ctx,
106
+ image,
107
+ fileId ? { type: "photo", fileId } : undefined,
108
+ ctx.message.caption ?? "",
109
+ );
70
110
  });
71
111
 
72
112
  bot.on("message:document", async (ctx, next) => {
73
113
  const doc = ctx.message.document;
74
114
  if (!doc.mime_type?.startsWith("image/")) return next(); // let document-handler logic pass
75
115
  const image = await download(ctx, doc.file_id, doc.mime_type, deps.cfg.token);
76
- await onMedia(ctx, image, ctx.message.caption ?? "");
116
+ // Image-as-document must re-post as document — photo file_ids are a different kind.
117
+ await onMedia(
118
+ ctx,
119
+ image,
120
+ {
121
+ type: "document",
122
+ fileId: doc.file_id,
123
+ fileName: doc.file_name ?? undefined,
124
+ },
125
+ ctx.message.caption ?? "",
126
+ );
77
127
  });
78
128
  }
79
129
 
@@ -81,7 +131,16 @@ async function flush(groups: Map<string, GroupBuffer>, groupId: string, deps: Bo
81
131
  const buf = groups.get(groupId);
82
132
  if (!buf) return;
83
133
  groups.delete(groupId);
84
- await submit(deps, buf.chatId, buf.caption, buf.images, buf.replyTo, buf.quoted);
134
+ await submit(
135
+ deps,
136
+ buf.chatId,
137
+ buf.caption,
138
+ buf.images,
139
+ buf.media,
140
+ buf.userMessageIds,
141
+ buf.quoted,
142
+ buf.threadId,
143
+ );
85
144
  }
86
145
 
87
146
  async function submit(
@@ -89,15 +148,66 @@ async function submit(
89
148
  chatId: number,
90
149
  caption: string,
91
150
  images: PromptImage[],
92
- replyTo?: number,
151
+ media: AdoptMediaItem[],
152
+ userMessageIds: number[],
93
153
  quoted?: string,
154
+ threadId?: number,
94
155
  ): Promise<void> {
95
- const rt = deps.registry.get(chatId);
96
- const outcome = await rt.submit({ text: caption, images, replyTo, quotedText: quoted });
156
+ let rt = deps.registry.get(chatId);
157
+ if (deps.forum?.isActiveForumChat(chatId) && threadId !== undefined) {
158
+ const { forumThreadId } = await import("../../forum/thread.js");
159
+ const tid = forumThreadId(threadId);
160
+ const resolved = deps.forum.resolveCwd(tid);
161
+ if (resolved) {
162
+ rt = deps.registry.getForumTopic(chatId, tid, resolved.cwd, resolved.projectName);
163
+ }
164
+ }
165
+
166
+ const n = Math.max(images.length, media.length);
167
+ const label =
168
+ n === 1
169
+ ? "\u{1F4F7} Image"
170
+ : `\u{1F4F7} ${n} images`;
171
+ const body = caption.trim()
172
+ ? caption
173
+ : n === 1
174
+ ? "(image attached)"
175
+ : `(${n} images attached)`;
176
+
177
+ const anchor = await adoptUserPrompt(deps.api, {
178
+ chatId,
179
+ text: body,
180
+ userMessageIds,
181
+ messageThreadId: threadId,
182
+ projectName: rt.projectName,
183
+ prefix: label,
184
+ media,
185
+ });
186
+
187
+ // Agent gets whatever bytes we could download (may be empty if download failed).
188
+ const outcome = await rt.submit({
189
+ text: caption,
190
+ images,
191
+ replyTo: anchor?.replyTo ?? userMessageIds[0],
192
+ promptId: anchor?.promptId,
193
+ quotedText: quoted,
194
+ });
97
195
  if (outcome === "queued") {
196
+ // Omit General (1) — Bot API rejects message_thread_id=1.
197
+ const extra: Record<string, unknown> =
198
+ threadId !== undefined && threadId !== 1
199
+ ? { message_thread_id: threadId }
200
+ : {};
201
+ if (anchor?.replyTo !== undefined) {
202
+ extra.reply_parameters = {
203
+ message_id: anchor.replyTo,
204
+ allow_sending_without_reply: true,
205
+ };
206
+ }
98
207
  await deps.api.sendMessage(
99
208
  chatId,
100
- `\u{1F4E5} Queued ${images.length} image${images.length > 1 ? "s" : ""} \u2014 will run after the current task.`,
209
+ `\u{1F4E5} Queued ${n} image${n > 1 ? "s" : ""} \u2014 will run after the current task.`,
210
+ extra,
101
211
  );
102
212
  }
103
213
  }