grok-telegram-bot 2.4.0 → 2.5.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 (78) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +119 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +12 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +96 -15
  12. package/src/bot/bot.ts +122 -15
  13. package/src/bot/chat-controller.ts +52 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +159 -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 +207 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +101 -21
  23. package/src/bot/handlers/photo.ts +123 -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 +8 -5
  29. package/src/bot/menu/ephemeral.ts +13 -3
  30. package/src/bot/menu/keyboard.ts +53 -14
  31. package/src/bot/menu/refresh.ts +3 -1
  32. package/src/bot/menu/status-panel.ts +12 -6
  33. package/src/bot/permission-service.ts +19 -0
  34. package/src/bot/prompt-anchor.ts +300 -0
  35. package/src/bot/prompt-content.ts +3 -0
  36. package/src/bot/registry.ts +94 -1
  37. package/src/bot/scope.ts +94 -0
  38. package/src/bot/session-runtime.ts +647 -158
  39. package/src/bot/suggestions.ts +91 -31
  40. package/src/bot/telegram-actions.ts +440 -0
  41. package/src/bot/telegram-bots.ts +495 -0
  42. package/src/bot/telegram-io.ts +94 -10
  43. package/src/cli.ts +2 -0
  44. package/src/config.ts +201 -2
  45. package/src/forum/bind-path.ts +146 -0
  46. package/src/forum/manager.ts +651 -0
  47. package/src/forum/project-icon.ts +142 -0
  48. package/src/forum/thread.ts +16 -0
  49. package/src/forum/topic-store.ts +114 -0
  50. package/src/forum/types.ts +29 -0
  51. package/src/grok/client.ts +130 -28
  52. package/src/index.ts +205 -75
  53. package/src/projects/manager.ts +16 -3
  54. package/src/render/chunk.ts +17 -10
  55. package/src/render/hashtags.ts +5 -1
  56. package/src/render/session-comment.ts +64 -7
  57. package/src/render/telegram-bridge.ts +360 -0
  58. package/src/render/tool-call.ts +56 -37
  59. package/src/service/platform.ts +44 -7
  60. package/src/service/windows.ts +16 -4
  61. package/src/sessions/history.ts +50 -9
  62. package/src/sessions/process.ts +7 -0
  63. package/src/sessions/types.ts +2 -2
  64. package/src/stream/streamer.ts +17 -6
  65. package/scripts/analyze-jsonl.ts +0 -33
  66. package/scripts/delayed-restart.ps1 +0 -29
  67. package/scripts/probe-exit-response-shape.py +0 -77
  68. package/scripts/probe-plan-exit.py +0 -60
  69. package/scripts/probe-plan-exit2.py +0 -48
  70. package/scripts/probe-plan-fields.py +0 -41
  71. package/scripts/probe-plan-fields2.py +0 -58
  72. package/scripts/probe-plan-response-path.py +0 -48
  73. package/scripts/sample-claude-tooluse.ts +0 -21
  74. package/scripts/sample-kiro-events.ts +0 -31
  75. package/scripts/smoke-exit-plan.ts +0 -274
  76. package/scripts/smoke-exit-shapes.ts +0 -252
  77. package/scripts/smoke-import.mjs +0 -82
  78. package/scripts/smoke-import.ts +0 -73
@@ -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,11 @@ 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
+ if (opts.messageThreadId !== undefined) extra.message_thread_id = opts.messageThreadId;
146
149
  for (const path of paths) {
147
150
  if (sent >= opts.max) break;
148
151
  if (opts.already.has(path)) continue;
@@ -161,7 +164,7 @@ export async function sendImages(
161
164
  const file = new InputFile(path, basename(path));
162
165
  await api.sendDocument(chatId, file, {
163
166
  caption: basename(path),
164
- ...replyExtra,
167
+ ...extra,
165
168
  });
166
169
  sent++;
167
170
  log.debug(`sent document ${path}`);
@@ -95,11 +95,21 @@ export class Ephemeral {
95
95
  async reply(ctx: Context, text: string, extra: Record<string, unknown> = {}): Promise<number | undefined> {
96
96
  const chatId = ctx.chat?.id;
97
97
  if (chatId === undefined) return undefined;
98
+ // Stay in the forum topic when the trigger message was in a thread.
99
+ const msg = ctx.message ?? ctx.callbackQuery?.message;
100
+ const threadId =
101
+ msg && "message_thread_id" in msg
102
+ ? (msg as { message_thread_id?: number }).message_thread_id
103
+ : undefined;
104
+ const merged =
105
+ threadId !== undefined && extra.message_thread_id === undefined
106
+ ? { ...extra, message_thread_id: threadId }
107
+ : extra;
98
108
  return this.serialize(chatId, async () => {
99
109
  try {
100
- const msg = await ctx.reply(text, extra);
101
- this.remember(chatId, msg.message_id);
102
- return msg.message_id;
110
+ const m = await ctx.reply(text, merged);
111
+ this.remember(chatId, m.message_id);
112
+ return m.message_id;
103
113
  } catch {
104
114
  return undefined;
105
115
  }
@@ -1,32 +1,71 @@
1
1
  /**
2
2
  * Menu surfaces:
3
- * - a tiny PERSISTENT bar (☰ Menu · 🧭 Running · ⏹ Stop) — minimal footprint;
4
- * - a full, organized INLINE menu opened on demand (and hideable).
5
- * Live state (project/model/reasoning/context) lives in the pinned panel,
6
- * so the bar stays clean. (Agent picker removed Grok has no useful headless
7
- * agent switch; plan mode is entered automatically when the agent judges the task complex.)
3
+ * - PERSISTENT bar (reply keyboard): ☰ Menu · 🆕 New session / 🧭 Running · ⏹ Stop
4
+ * primary place for New session in private chats (not the inline Menu message).
5
+ * - INLINE menu message (opened via ☰ Menu or /menu) settings & navigation.
6
+ * Forum topics: reply keyboards are unreliable, so New stays on the topic inline menu.
7
+ * Live state lives in the pinned status panel so the bar stays clean.
8
8
  */
9
9
  import { InlineKeyboard, Keyboard } from "grammy";
10
10
 
11
11
  export const MENU_BTN = "\u2630 Menu"; // ☰
12
+ /** Persistent bar + forum topic control — brand-new session (same as /new). */
13
+ export const NEW_BTN = "\u{1F195} New session";
12
14
  export const RUNNING_BTN = "\u{1F9ED} Running";
13
15
  export const STOP_BTN = "\u23F9 Stop";
14
- export const BAR_LABELS = [MENU_BTN, RUNNING_BTN, STOP_BTN];
16
+ export const BAR_LABELS = [MENU_BTN, NEW_BTN, RUNNING_BTN, STOP_BTN];
15
17
 
16
- /** The always-visible compact bar. */
18
+ /** The always-visible compact bar (private chats; best-effort in groups). */
17
19
  export function compactKeyboard(): Keyboard {
18
- return new Keyboard().text(MENU_BTN).text(RUNNING_BTN).text(STOP_BTN).resized().persistent();
20
+ return new Keyboard()
21
+ .text(MENU_BTN)
22
+ .text(NEW_BTN)
23
+ .row()
24
+ .text(RUNNING_BTN)
25
+ .text(STOP_BTN)
26
+ .resized()
27
+ .persistent();
19
28
  }
20
29
 
21
30
  /** The full, grouped inline menu (opened via ☰ Menu or /menu). */
22
- export function mainMenuInline(state: { model: string; reasoning: string }): InlineKeyboard {
31
+ export function mainMenuInline(state: {
32
+ model: string;
33
+ reasoning: string;
34
+ /** Forum topic scope — hide project switch; label topic sessions. */
35
+ forumTopic?: { name: string; account?: string };
36
+ }): InlineKeyboard {
23
37
  const t = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "\u2026" : s);
24
- return new InlineKeyboard()
25
- .text("\u{1F4C1} Project", "m:project")
26
- .text("\u{1F195} New", "m:new")
38
+ const kb = new InlineKeyboard();
39
+
40
+ if (state.forumTopic) {
41
+ // Groups/topics: control first (no reliable reply-keyboard bar). New session here.
42
+ kb.text("\u23F9 Stop", "m:stop")
43
+ .text("\u{1F9ED} Running", "m:running")
44
+ .row()
45
+ .text(NEW_BTN, "m:new")
46
+ .text("\u{1F5C2} Sessions", "m:sessions")
47
+ .row()
48
+ .text(`\u{1F4C1} ${t(state.forumTopic.name, 28)}`, "m:topicinfo")
49
+ .row()
50
+ .text("\u{1F4E5} Import session", "m:import")
51
+ .row()
52
+ .text(`\u{1F9E9} Model \u00B7 ${t(state.model, 24)}`, "m:model")
53
+ .row()
54
+ .text(`\u{1F9E0} Reasoning \u00B7 ${t(state.reasoning, 24)}`, "m:reasoning")
55
+ .row();
56
+ if (state.forumTopic.account) {
57
+ kb.text(`\u{1F465} Account \u00B7 ${t(state.forumTopic.account, 20)}`, "m:accounts").row();
58
+ }
59
+ kb.text("\u{1F4CA} Status", "m:status").text("\u2716 Close", "m:close");
60
+ return kb;
61
+ }
62
+
63
+ // Private chat: New session is on the persistent bar + /new — not on this message.
64
+ kb.text("\u{1F4C1} Project", "m:project")
65
+ .text("\u{1F5C2} Sessions", "m:sessions")
27
66
  .row()
67
+ .text("\u23F9 Stop", "m:stop")
28
68
  .text("\u{1F9ED} Running", "m:running")
29
- .text("\u{1F5C2} Sessions", "m:sessions")
30
69
  .row()
31
70
  .text("\u{1F4E5} Import session", "m:import")
32
71
  .row()
@@ -41,10 +80,10 @@ export function mainMenuInline(state: { model: string; reasoning: string }): Inl
41
80
  .text("\u{1F465} Accounts", "m:accounts")
42
81
  .row()
43
82
  .text("\u{1F9E9} MCP", "m:mcp")
44
- .text("\u23F9 Stop", "m:stop")
45
83
  .text("\u{1F6D1} Kill all", "m:killall")
46
84
  .row()
47
85
  .text("\u2328\uFE0F Show bar", "m:showbar")
48
86
  .text("\u{1F648} Hide bar", "m:hidebar")
49
87
  .text("\u2716 Close", "m:close");
88
+ return kb;
50
89
  }
@@ -4,10 +4,12 @@
4
4
  */
5
5
  import type { Context } from "grammy";
6
6
  import type { BotDeps } from "../deps.js";
7
+ import { resolveScope } from "../scope.js";
7
8
  import { compactKeyboard } from "./keyboard.js";
8
9
 
9
10
  export async function refreshMenu(ctx: Context, deps: BotDeps, text: string): Promise<void> {
10
11
  const chatId = ctx.chat!.id;
11
- await ctx.reply(text, { reply_markup: compactKeyboard() });
12
+ const scope = resolveScope(ctx, deps);
13
+ await ctx.reply(text, { reply_markup: compactKeyboard(), ...scope.threadExtra });
12
14
  await deps.statusPanel.refresh(chatId);
13
15
  }
@@ -65,13 +65,19 @@ export class StatusPanel {
65
65
  if (subagents) activity.push(`\u{1F465} ${subagents}`);
66
66
  lines.push(activity.join(SEP));
67
67
 
68
- // 3b) What is happening now / last summary (same source as Running cards).
69
- // Prefer plan one-liner over a redundant tool-step when a plan is active.
70
- const comment = rt.planSummary && rt.isBusy ? undefined : rt.cardComment;
68
+ // 3b) Last user prompt (+ thinking while busy) same source as Running cards.
69
+ // When a plan board is already shown above, still surface the user prompt;
70
+ // skip only a pure thinking second line if plan is active (less noise).
71
+ const comment = rt.cardComment;
71
72
  if (comment) {
72
- const icon = rt.isBusy ? "\u23F3" : "\u{1F4AC}";
73
- const shown = comment.length > 120 ? `${comment.slice(0, 119)}\u2026` : comment;
74
- lines.push(`${icon} ${shown}`);
73
+ const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
74
+ const hideThinking = !!(rt.planSummary && rt.isBusy);
75
+ parts.forEach((part, i) => {
76
+ if (hideThinking && i > 0) return;
77
+ const icon = i === 0 ? (rt.isBusy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
78
+ const shown = part.length > 250 ? `${part.slice(0, 249)}\u2026` : part;
79
+ lines.push(`${icon} ${shown}`);
80
+ });
75
81
  }
76
82
 
77
83
  // 4) Where: project | session | context usage.
@@ -161,6 +161,25 @@ export class PermissionService {
161
161
  return this.pending.get(reqId)?.sessionId;
162
162
  }
163
163
 
164
+ /**
165
+ * ACP: when a session is cancelled, all pending permission requests for that
166
+ * session must complete with `cancelled`. Returns how many were cancelled.
167
+ * Does not touch other sessions.
168
+ */
169
+ cancelForSession(sessionId: string): number {
170
+ let n = 0;
171
+ for (const [reqId, p] of [...this.pending.entries()]) {
172
+ if (p.sessionId !== sessionId) continue;
173
+ clearTimeout(p.timer);
174
+ this.pending.delete(reqId);
175
+ void this.finishPrompt(p, "\u{1F510} (cancelled — turn stopped)");
176
+ p.resolve({ outcome: { outcome: "cancelled" } });
177
+ n++;
178
+ }
179
+ if (n > 0) log.info(`cancelled ${n} pending permission(s) for session ${sessionId.slice(0, 8)}`);
180
+ return n;
181
+ }
182
+
164
183
  /** Unpin (if pinned) and optionally rewrite the prompt message. */
165
184
  private async finishPrompt(p: Pending, text?: string): Promise<void> {
166
185
  if (p.messageId !== undefined && text) {
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Prompt anchors & command acks — instant bot feedback while the CLI/ACP warms up.
3
+ *
4
+ * When the user sends a prompt we:
5
+ * 1. post a bot-owned message with the prompt text + `#prompt_<id>`
6
+ * (and re-attach any photos/files/voice so media is not lost when the
7
+ * user's original is deleted)
8
+ * 2. best-effort delete the user's original message(s)
9
+ * 3. thread every AI reply (and Done/error) to that bot message
10
+ *
11
+ * Commands use {@link ackCommand}: delete the slash message immediately and
12
+ * post a short status so the chat never looks dead during slow handlers.
13
+ */
14
+ import type { Api, Context } from "grammy";
15
+ import { InputMediaBuilder } from "grammy";
16
+ import { createLogger } from "../logger.js";
17
+ import { tagSafe } from "../render/hashtags.js";
18
+
19
+ const log = createLogger("prompt-anchor");
20
+
21
+ /** Telegram hard cap for text messages; leave room for prefix + tags. */
22
+ const BODY_BUDGET = 3800;
23
+ /** Telegram caption hard cap on photo/document/audio/video. */
24
+ const CAPTION_BUDGET = 1024;
25
+
26
+ export type AdoptMediaItem =
27
+ | { type: "photo"; fileId: string }
28
+ | { type: "document"; fileId: string; fileName?: string }
29
+ | { type: "voice"; fileId: string }
30
+ | { type: "audio"; fileId: string; fileName?: string }
31
+ | { type: "video"; fileId: string }
32
+ | { type: "video_note"; fileId: string };
33
+
34
+ export interface AdoptPromptOpts {
35
+ chatId: number;
36
+ /** User prompt body to echo on the anchor (not sent to the agent twice — agent gets original text). */
37
+ text: string;
38
+ /** User Telegram message ids to delete after the anchor is posted. */
39
+ userMessageIds: number[];
40
+ messageThreadId?: number;
41
+ projectName?: string;
42
+ /** Leading emoji/label, e.g. "📝", "📷", "🎤". */
43
+ prefix?: string;
44
+ /**
45
+ * Media from the user's message(s), re-posted via Telegram file_id so the
46
+ * chat keeps images/files after the original is deleted. Same bot may reuse
47
+ * file_ids it received.
48
+ */
49
+ media?: AdoptMediaItem[];
50
+ }
51
+
52
+ export interface PromptAnchor {
53
+ /** Bot message id — use as PromptInput.replyTo. */
54
+ replyTo: number;
55
+ /** Short id for `#prompt_<id>` footers. */
56
+ promptId: string;
57
+ }
58
+
59
+ /** Short unique id safe for Telegram hashtag bodies. */
60
+ export function newPromptId(): string {
61
+ const t = Date.now().toString(36);
62
+ const r = Math.random().toString(36).slice(2, 6);
63
+ return tagSafe(`${t}${r}`);
64
+ }
65
+
66
+ export function formatPromptTag(promptId: string): string {
67
+ return `#prompt_${tagSafe(promptId)}`;
68
+ }
69
+
70
+ /** Build the plain-text body of a prompt-anchor message (hashtags stay tappable). */
71
+ export function formatPromptAnchorBody(
72
+ text: string,
73
+ promptId: string,
74
+ opts?: { prefix?: string; projectName?: string },
75
+ ): string {
76
+ const prefix = (opts?.prefix ?? "\u{1F4DD}").trim();
77
+ const body = truncateBody(text.trim() || "(empty)", BODY_BUDGET);
78
+ const tags = [formatPromptTag(promptId)];
79
+ if (opts?.projectName?.trim()) {
80
+ tags.push(`#proj_${tagSafe(opts.projectName)}`);
81
+ }
82
+ return `${prefix}\n${body}\n\n${tags.join(" ")}`;
83
+ }
84
+
85
+ /**
86
+ * Fit anchor body into a media caption (1024). Prefer keeping the trailing
87
+ * hashtag line so `#prompt_` stays searchable — never slice tags off the end.
88
+ */
89
+ export function fitCaption(body: string, max = CAPTION_BUDGET): string {
90
+ if (body.length <= max) return body;
91
+ const lines = body.split("\n");
92
+ const last = (lines[lines.length - 1] ?? "").trim();
93
+ const tags = last.includes("#prompt_") ? last : "";
94
+ if (tags) {
95
+ // Reserve room for "\n…\n\n" + tags; never clip the tag line.
96
+ const sep = "\n\u2026\n\n";
97
+ const budget = max - tags.length - sep.length;
98
+ if (budget > 40) {
99
+ const cutAt = body.lastIndexOf(tags);
100
+ const rawHead = (cutAt > 0 ? body.slice(0, cutAt) : body).replace(/\s+$/, "");
101
+ const head = rawHead.length > budget ? rawHead.slice(0, budget) : rawHead;
102
+ return `${head}${sep}${tags}`;
103
+ }
104
+ // Tags alone almost fill the caption — keep tags only.
105
+ return tags.length <= max ? tags : tags.slice(0, max - 1) + "\u2026";
106
+ }
107
+ return body.slice(0, max - 1) + "\u2026";
108
+ }
109
+
110
+ /**
111
+ * Post a bot-owned prompt message (with media when provided), delete the user's
112
+ * original(s), return ids for threading + tagging. On send failure returns
113
+ * undefined (caller falls back).
114
+ */
115
+ export async function adoptUserPrompt(
116
+ api: Api,
117
+ opts: AdoptPromptOpts,
118
+ ): Promise<PromptAnchor | undefined> {
119
+ const promptId = newPromptId();
120
+ const body = formatPromptAnchorBody(opts.text, promptId, {
121
+ prefix: opts.prefix,
122
+ projectName: opts.projectName,
123
+ });
124
+ const threadExtra: Record<string, unknown> = {
125
+ disable_notification: true,
126
+ };
127
+ if (opts.messageThreadId !== undefined) {
128
+ threadExtra.message_thread_id = opts.messageThreadId;
129
+ }
130
+
131
+ let replyTo: number;
132
+ try {
133
+ const media = (opts.media ?? []).filter((m) => !!m.fileId);
134
+ if (media.length > 0) {
135
+ replyTo = await sendAnchorWithMedia(api, opts.chatId, media, body, threadExtra);
136
+ } else {
137
+ const msg = await api.sendMessage(opts.chatId, body, threadExtra);
138
+ replyTo = msg.message_id;
139
+ }
140
+ } catch (err) {
141
+ log.warn(`anchor send failed chat=${opts.chatId}: ${(err as Error).message}`);
142
+ // Fallback: text-only anchor so threading still works if media re-post fails.
143
+ try {
144
+ const msg = await api.sendMessage(opts.chatId, body, threadExtra);
145
+ replyTo = msg.message_id;
146
+ } catch (err2) {
147
+ log.warn(`anchor text fallback failed chat=${opts.chatId}: ${(err2 as Error).message}`);
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ await deleteUserMessages(api, opts.chatId, opts.userMessageIds);
153
+ return { replyTo, promptId };
154
+ }
155
+
156
+ /** Re-post user media with caption/tags so files remain in chat history. */
157
+ async function sendAnchorWithMedia(
158
+ api: Api,
159
+ chatId: number,
160
+ media: AdoptMediaItem[],
161
+ body: string,
162
+ threadExtra: Record<string, unknown>,
163
+ ): Promise<number> {
164
+ const caption = fitCaption(body);
165
+ const needsFollowUp = body.length > CAPTION_BUDGET;
166
+
167
+ // Photo album: one media group (caption on first only).
168
+ if (media.length > 1 && media.every((m) => m.type === "photo")) {
169
+ const group = media.map((m, i) =>
170
+ i === 0
171
+ ? InputMediaBuilder.photo(m.fileId, { caption })
172
+ : InputMediaBuilder.photo(m.fileId),
173
+ );
174
+ const msgs = await api.sendMediaGroup(chatId, group, threadExtra);
175
+ const firstId = msgs[0]?.message_id;
176
+ if (firstId === undefined) throw new Error("sendMediaGroup returned no messages");
177
+ if (needsFollowUp) {
178
+ await api
179
+ .sendMessage(chatId, body, {
180
+ ...threadExtra,
181
+ reply_parameters: { message_id: firstId, allow_sending_without_reply: true },
182
+ })
183
+ .catch(() => {});
184
+ }
185
+ return firstId;
186
+ }
187
+
188
+ // Single (or primary) media item; extra photos sent as a follow-up group.
189
+ const primary = media[0]!;
190
+ const restPhotos = media.slice(1).filter((m): m is Extract<AdoptMediaItem, { type: "photo" }> => m.type === "photo");
191
+
192
+ let replyTo: number;
193
+ const capOpts = { caption, ...threadExtra };
194
+
195
+ switch (primary.type) {
196
+ case "photo": {
197
+ const msg = await api.sendPhoto(chatId, primary.fileId, capOpts);
198
+ replyTo = msg.message_id;
199
+ break;
200
+ }
201
+ case "document": {
202
+ const msg = await api.sendDocument(chatId, primary.fileId, capOpts);
203
+ replyTo = msg.message_id;
204
+ break;
205
+ }
206
+ case "voice": {
207
+ const msg = await api.sendVoice(chatId, primary.fileId, capOpts);
208
+ replyTo = msg.message_id;
209
+ break;
210
+ }
211
+ case "audio": {
212
+ const msg = await api.sendAudio(chatId, primary.fileId, capOpts);
213
+ replyTo = msg.message_id;
214
+ break;
215
+ }
216
+ case "video": {
217
+ const msg = await api.sendVideo(chatId, primary.fileId, capOpts);
218
+ replyTo = msg.message_id;
219
+ break;
220
+ }
221
+ case "video_note": {
222
+ // video_note has no caption — post note then text anchor as reply.
223
+ const msg = await api.sendVideoNote(chatId, primary.fileId, threadExtra);
224
+ replyTo = msg.message_id;
225
+ await api
226
+ .sendMessage(chatId, body, {
227
+ ...threadExtra,
228
+ reply_parameters: { message_id: replyTo, allow_sending_without_reply: true },
229
+ })
230
+ .catch(() => {});
231
+ return replyTo;
232
+ }
233
+ default: {
234
+ const msg = await api.sendMessage(chatId, body, threadExtra);
235
+ return msg.message_id;
236
+ }
237
+ }
238
+
239
+ if (restPhotos.length > 0) {
240
+ const group = restPhotos.map((m) => InputMediaBuilder.photo(m.fileId));
241
+ await api.sendMediaGroup(chatId, group, threadExtra).catch((e) => {
242
+ log.debug(`extra photo group failed: ${(e as Error).message}`);
243
+ });
244
+ }
245
+
246
+ if (needsFollowUp) {
247
+ await api
248
+ .sendMessage(chatId, body, {
249
+ ...threadExtra,
250
+ reply_parameters: { message_id: replyTo, allow_sending_without_reply: true },
251
+ })
252
+ .catch(() => {});
253
+ }
254
+
255
+ return replyTo;
256
+ }
257
+
258
+ /** Best-effort delete of user messages (private chats may refuse). */
259
+ export async function deleteUserMessages(
260
+ api: Api,
261
+ chatId: number,
262
+ messageIds: number[],
263
+ ): Promise<void> {
264
+ const unique = [...new Set(messageIds.filter((id) => Number.isFinite(id) && id > 0))];
265
+ await Promise.all(
266
+ unique.map(async (id) => {
267
+ try {
268
+ await api.deleteMessage(chatId, id);
269
+ } catch {
270
+ /* no rights / already gone / too old — non-fatal */
271
+ }
272
+ }),
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Instant command feedback: delete the user's command and post a bot status.
278
+ * Fire-and-forget delete so slow work never waits on Telegram cleanup.
279
+ */
280
+ export async function ackCommand(
281
+ ctx: Context,
282
+ text: string,
283
+ extra: Record<string, unknown> = {},
284
+ ): Promise<number | undefined> {
285
+ void ctx.deleteMessage().catch(() => {});
286
+ try {
287
+ const msg = await ctx.reply(text, extra);
288
+ return msg.message_id;
289
+ } catch (err) {
290
+ log.debug(`ackCommand reply failed: ${(err as Error).message}`);
291
+ return undefined;
292
+ }
293
+ }
294
+
295
+ function truncateBody(text: string, max: number): string {
296
+ if (text.length <= max) return text;
297
+ const head = Math.floor(max * 0.7);
298
+ const tail = max - head - 5;
299
+ return `${text.slice(0, head)}\n\u2026\n${text.slice(-tail)}`;
300
+ }
@@ -77,6 +77,9 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
77
77
  images: inputs.flatMap((i) => i.images),
78
78
  resourceLinks: inputs.flatMap((i) => i.resourceLinks ?? []),
79
79
  replyTo: inputs.find((i) => i.replyTo !== undefined)?.replyTo,
80
+ // First prompt's id wins (same rule as replyTo) so merged queue turns keep
81
+ // one searchable #prompt_ tag threaded to the first anchor.
82
+ promptId: inputs.find((i) => i.promptId)?.promptId,
80
83
  quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
81
84
  skipSelfRecheck: skipSelfRecheck || undefined,
82
85
  };