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
@@ -4,8 +4,10 @@
4
4
  * Self-recheck (once per real user turn, when enabled):
5
5
  * 1. Hard-skip if no files were modified.
6
6
  * 2. Quiet meta ask: AI refuses (simple / pure build / nothing to re-verify)
7
- * or writes a focused recheck prompt.
8
- * 3. That prompt is queued as the one-shot SELF-RECHECK turn.
7
+ * or writes a complete recheck brief (bugs + production gaps + finish-all +
8
+ * per-bug checklist).
9
+ * 3. That prompt is queued as the one-shot SELF-RECHECK turn (compose always
10
+ * injects finish-all / per-bug rules when the body omitted them).
9
11
  * 4. Then Done + suggestions.
10
12
  *
11
13
  * After Done, the bot quietly asks for 1–3 short next steps as JSON with a
@@ -30,21 +32,36 @@ export const SELF_RECHECK_MARKER = "SELF-RECHECK (automatic quality pass";
30
32
  export const DEFAULT_SELF_RECHECK_PROMPT = [
31
33
  `${SELF_RECHECK_MARKER} — once only).`,
32
34
  "Do a rigorous self-review of the work just completed for the user request below.",
35
+ "You are a bugs/logic finder AND a production-completeness pass for the SAME feature.",
33
36
  "",
34
- "You are both a bugs/logic finder AND a completeness checker for related follow-ups.",
35
- "Look for:",
36
- "1) Bugs, regressions, incomplete steps, wrong assumptions, missing verification, edge cases.",
37
- "2) Logical gaps the user would still need — e.g. if they asked for password reset and you",
38
- " shipped the happy path only, also cover brute-force protection, rate limits, token expiry,",
39
- " email enumeration, CSRF, audit logging, and similar security/ops follow-through that a",
40
- " solid implementation of THAT feature should include (not a random new product idea).",
37
+ "A) Bugs & regressions",
38
+ "1) Incomplete steps, wrong assumptions, missing verification, edge cases, broken paths.",
39
+ "2) Logic mismatches (UI vs backend, state vs render, happy path vs error path).",
40
+ "",
41
+ "B) Production-related functionality (same domain only — not a new product idea)",
42
+ "Continue development toward an ideal production version of WHAT WAS BUILT:",
43
+ "- Game with water → waves/physics/collision where missing; entity that can die → death state; etc.",
44
+ "- Auth form → rate limits, lockout, CSRF, secure tokens, validation, error handling.",
45
+ "- API write → input validation, authz, idempotency/errors, logging where expected.",
46
+ "- UI control → empty/loading/error states, accessibility basics tied to the control.",
47
+ "Only add what a solid implementation of THIS feature should already include.",
48
+ "",
49
+ "C) Finish-all — no honest leftovers",
50
+ "- Do NOT leave half-done work with 'still need…', 'remaining…', 'TODO for user',",
51
+ " incomplete honest footers, or progress stuck low because work is unfinished.",
52
+ "- Finish critical gaps now so Done does not rely on follow-up suggestion buttons",
53
+ " for unfinished must-have work from this request.",
54
+ "- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
55
+ "",
56
+ "D) Per-bug recheck checklist (REQUIRED at the end of this pass)",
57
+ "1) List every plausible bug/edge case for THESE changes (race, null, authz, wrong path,",
58
+ " regression, missing test, off-by-one, bad default, security hole, etc.).",
59
+ "2) For each item: verify with tools or fix it. Briefly note only real findings.",
60
+ "3) Prefer {progress: 100%} only when this checklist is done and nothing critical remains.",
41
61
  "",
42
62
  "Rules:",
43
- "- Prefer fixing real problems with tools when needed; do not invent unrelated features.",
44
- "- If something is unfinished relative to the user request (or a tightly related follow-up",
45
- " that leaves the feature incomplete/insecure), finish or fix it now.",
63
+ "- Prefer fixing real problems with tools; do not invent unrelated features.",
46
64
  "- If everything checks out, briefly confirm what you verified (no long essay).",
47
- "- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
48
65
  "- End with an honest {progress: N%} marker for this recheck pass.",
49
66
  "",
50
67
  "USER'S REQUEST:",
@@ -54,6 +71,19 @@ export const DEFAULT_SELF_RECHECK_PROMPT = [
54
71
  "{{DONE}}",
55
72
  ].join("\n");
56
73
 
74
+ /** Shared rules appended when wrapping a short AI-written recheck body. */
75
+ export const SELF_RECHECK_COMPOSE_RULES = [
76
+ "Rules:",
77
+ "- Fix real bugs and logic mismatches with tools when needed; no unrelated features.",
78
+ "- Production-related completeness for the SAME feature (e.g. water→waves, auth→rate limits,",
79
+ " API writes→validation/errors). Close gaps a solid production build of this feature needs.",
80
+ "- Finish-all: do NOT leave 'still need…', 'remaining…', incomplete honest footers, or",
81
+ " half-done work that would force critical follow-up buttons. Finish those gaps now.",
82
+ "- End with a per-bug recheck: list plausible bugs for these changes and verify/fix each.",
83
+ "- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
84
+ "- End with an honest {progress: N%} marker for this recheck pass (100 only when checklist done).",
85
+ ].join("\n");
86
+
57
87
  export interface Suggestion {
58
88
  /** Plain follow-up the user would type / the bot will submit. */
59
89
  text: string;
@@ -231,9 +261,20 @@ export function buildSelfRecheckDecisionPrompt(
231
261
  "- Non-trivial code/config changed and edge cases, regressions, or incomplete",
232
262
  " follow-through are plausible (auth, multi-file logic, concurrency, data paths).",
233
263
  "- A focused second pass with tools could catch real bugs or finish related gaps.",
264
+ "- The first pass left honest leftovers ('still need…', unfinished production logic,",
265
+ " incomplete feature pieces) that a recheck should finish instead of the user.",
234
266
  "",
235
- "If needed=true, write a focused recheck prompt: imperative instructions for a one-shot",
236
- "second pass that may use tools to fix REAL issues. Do not invent unrelated features.",
267
+ "If needed=true, write prompt = a COMPLETE recheck brief for a one-shot agent pass.",
268
+ "The prompt MUST include ALL of the following sections (imperative, concrete, domain-specific):",
269
+ "1) Bugs/logic: what to re-read, verify, and fix for THIS change set.",
270
+ "2) Production-related functionality: continue to ideal production for the SAME feature",
271
+ " (examples: game water→waves; auth→rate limits/lockout/CSRF; API→validation/errors;",
272
+ " UI→empty/loading/error). Not unrelated product ideas.",
273
+ "3) Finish-all: explicitly order the agent to finish incomplete work and NOT leave",
274
+ " 'still need…', 'remaining…', incomplete honest footers, or half-done bottoms.",
275
+ "4) Per-bug recheck (at the BOTTOM of the prompt): list every plausible bug/edge case",
276
+ " for these changes and instruct the agent to verify or fix EACH one with tools.",
277
+ "Do not invent unrelated features. Keep the prompt actionable (not vague).",
237
278
  "If needed=false, give a short reason.",
238
279
  "",
239
280
  "USER'S REQUEST:",
@@ -248,7 +289,7 @@ export function buildSelfRecheckDecisionPrompt(
248
289
  "Reply with ONLY one JSON object (no markdown fences, no commentary):",
249
290
  '{"needed":false,"reason":"short reason"}',
250
291
  "or",
251
- '{"needed":true,"prompt":"focused recheck / fix instructions for the agent"}',
292
+ '{"needed":true,"prompt":"complete recheck brief with bugs + production gaps + finish-all + per-bug checklist"}',
252
293
  ].join("\n");
253
294
  }
254
295
 
@@ -372,6 +413,26 @@ export function buildSelfRecheckPrompt(
372
413
  return ensureSelfRecheckMarker(out);
373
414
  }
374
415
 
416
+ /**
417
+ * True when the recheck body already encodes finish-all + per-bug expectations
418
+ * (default template or a strong AI brief). Used to avoid double-appending rules.
419
+ */
420
+ export function hasSelfRecheckFinishRules(text: string): boolean {
421
+ const t = text.toLowerCase();
422
+ const finish =
423
+ t.includes("still need") || t.includes("finish-all") || t.includes("finish all");
424
+ const bugs = t.includes("per-bug") || t.includes("plausible bug");
425
+ return finish && bugs;
426
+ }
427
+
428
+ /** Append SHARED compose rules when the body omitted finish-all / per-bug language. */
429
+ export function ensureSelfRecheckComposeRules(text: string): string {
430
+ const body = text.trim();
431
+ if (!body) return SELF_RECHECK_COMPOSE_RULES;
432
+ if (hasSelfRecheckFinishRules(body)) return body;
433
+ return `${body}\n\n${SELF_RECHECK_COMPOSE_RULES}`;
434
+ }
435
+
375
436
  /**
376
437
  * Turn an AI-written recheck body into a full one-shot turn (marker + context).
377
438
  */
@@ -396,23 +457,22 @@ export function composeSelfRecheckTurn(
396
457
  isSelfRecheckPrompt(body) ||
397
458
  (/USER'S REQUEST:/i.test(body) && /WHAT WAS JUST DONE/i.test(body));
398
459
  if (looksComplete) {
399
- return ensureSelfRecheckMarker(body);
460
+ // Still inject finish-all / per-bug rules if a "complete-looking" AI brief
461
+ // only copied headers and omitted production/finish-all/per-bug sections.
462
+ return ensureSelfRecheckMarker(ensureSelfRecheckComposeRules(body));
400
463
  }
401
464
  return ensureSelfRecheckMarker(
402
- [
403
- body,
404
- "",
405
- "Rules:",
406
- "- Prefer fixing real problems with tools when needed; do not invent unrelated features.",
407
- "- Do NOT ask the user questions. Do NOT call enter_plan_mode unless truly necessary.",
408
- "- End with an honest {progress: N%} marker for this recheck pass.",
409
- "",
410
- "USER'S REQUEST:",
411
- user,
412
- "",
413
- "WHAT WAS JUST DONE (summary):",
414
- did,
415
- ].join("\n"),
465
+ ensureSelfRecheckComposeRules(
466
+ [
467
+ body,
468
+ "",
469
+ "USER'S REQUEST:",
470
+ user,
471
+ "",
472
+ "WHAT WAS JUST DONE (summary):",
473
+ did,
474
+ ].join("\n"),
475
+ ),
416
476
  );
417
477
  }
418
478
 
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Execute agent-requested Telegram bridge actions and produce results +
3
+ * user-facing status lines.
4
+ */
5
+ import type { Api } from "grammy";
6
+ import type { AppConfig } from "../config.js";
7
+ import type { ForumManager } from "../forum/manager.js";
8
+ import type { ForumTopicBinding } from "../forum/types.js";
9
+ import { FORUM_GENERAL_THREAD_ID } from "../forum/thread.js";
10
+ import type { SessionStore } from "../sessions/store.js";
11
+ import { createLogger } from "../logger.js";
12
+ import { searchGroupMemory } from "./group-memory.js";
13
+ import type { TelegramBotService } from "./telegram-bots.js";
14
+ import type { TelegramAction } from "../render/telegram-bridge.js";
15
+
16
+ const log = createLogger("telegram-actions");
17
+
18
+ /** Cross-topic prompt injection (implemented by bot.ts via registry). */
19
+ export type SubmitTopicPromptFn = (opts: {
20
+ threadId: number;
21
+ cwd: string;
22
+ projectName: string;
23
+ prompt: string;
24
+ newSession?: boolean;
25
+ }) => Promise<{ outcome: "ran" | "queued"; sessionId?: string }>;
26
+
27
+ export interface TelegramActionContext {
28
+ api: Api;
29
+ cfg: AppConfig;
30
+ chatId: number;
31
+ messageThreadId?: number;
32
+ forum?: ForumManager;
33
+ store: SessionStore;
34
+ bots: TelegramBotService;
35
+ /** Dispatch a prompt into another forum topic's session. */
36
+ submitTopicPrompt?: SubmitTopicPromptFn;
37
+ }
38
+
39
+ export interface TelegramActionResult {
40
+ action: string;
41
+ ok: boolean;
42
+ /** Machine-readable payload for the agent. */
43
+ data?: unknown;
44
+ error?: string;
45
+ /** Short line to show the user (optional). */
46
+ userNote?: string;
47
+ }
48
+
49
+ /** Run actions sequentially; bot_command may await a sibling bot. */
50
+ export async function executeTelegramActions(
51
+ actions: TelegramAction[],
52
+ ctx: TelegramActionContext,
53
+ ): Promise<TelegramActionResult[]> {
54
+ const results: TelegramActionResult[] = [];
55
+ for (const action of actions) {
56
+ try {
57
+ results.push(await runOne(action, ctx));
58
+ } catch (e) {
59
+ const msg = (e as Error).message ?? String(e);
60
+ log.warn(`action ${action.action} failed: ${msg}`);
61
+ results.push({ action: action.action, ok: false, error: msg });
62
+ }
63
+ }
64
+ return results;
65
+ }
66
+
67
+ async function runOne(
68
+ action: TelegramAction,
69
+ ctx: TelegramActionContext,
70
+ ): Promise<TelegramActionResult> {
71
+ switch (action.action) {
72
+ case "create_topic":
73
+ return createTopic(action, ctx);
74
+ case "set_path":
75
+ return setPath(action, ctx);
76
+ case "send_prompt":
77
+ return sendPrompt(action, ctx);
78
+ case "search_memory":
79
+ return searchMemory(action, ctx);
80
+ case "list_bots":
81
+ return listBots(ctx);
82
+ case "bot_command":
83
+ return botCommand(action, ctx);
84
+ default:
85
+ return { action: "unknown", ok: false, error: "unsupported action" };
86
+ }
87
+ }
88
+
89
+ async function createTopic(
90
+ action: Extract<TelegramAction, { action: "create_topic" }>,
91
+ ctx: TelegramActionContext,
92
+ ): Promise<TelegramActionResult> {
93
+ const forum = ctx.forum;
94
+ if (!forum?.isReady) {
95
+ return {
96
+ action: "create_topic",
97
+ ok: false,
98
+ error:
99
+ "Forum topics not available (TOPIC_GROUP_ID unset, bot not admin, or Topics disabled)",
100
+ };
101
+ }
102
+ const created = await forum.createBoundTopic(action.name, action.path);
103
+ if (!created.ok) {
104
+ return { action: "create_topic", ok: false, error: created.error };
105
+ }
106
+ const b = created.binding;
107
+ return {
108
+ action: "create_topic",
109
+ ok: true,
110
+ data: {
111
+ threadId: b.threadId,
112
+ name: b.name,
113
+ projectPath: b.projectPath,
114
+ kind: b.kind,
115
+ },
116
+ userNote: b.projectPath
117
+ ? `\u{1F4CC} Created topic **${b.name}** (#${b.threadId}) \u2192 \`${b.projectPath}\``
118
+ : `\u{1F4CC} Created topic **${b.name}** (#${b.threadId}) (unbound — use set_path)`,
119
+ };
120
+ }
121
+
122
+ function setPath(
123
+ action: Extract<TelegramAction, { action: "set_path" }>,
124
+ ctx: TelegramActionContext,
125
+ ): TelegramActionResult {
126
+ const forum = ctx.forum;
127
+ if (!forum?.isReady) {
128
+ return {
129
+ action: "set_path",
130
+ ok: false,
131
+ error: "Forum topics not available",
132
+ };
133
+ }
134
+ const resolved = resolveTopicRef(forum, action.topic, ctx.cfg);
135
+ if (!resolved.ok) {
136
+ return { action: "set_path", ok: false, error: resolved.error };
137
+ }
138
+ const { binding } = resolved;
139
+ if (binding.threadId === FORUM_GENERAL_THREAD_ID) {
140
+ return {
141
+ action: "set_path",
142
+ ok: false,
143
+ error: "Cannot rebind the General topic (always GROK_WORKSPACE)",
144
+ };
145
+ }
146
+ if (binding.kind === "ai_chat") {
147
+ return {
148
+ action: "set_path",
149
+ ok: false,
150
+ error: "Cannot rebind the AI Chat topic (always GROK_WORKSPACE)",
151
+ };
152
+ }
153
+
154
+ // Agent set_path: create missing absolute folders (new project flow).
155
+ const result = forum.tryBindPath(binding.threadId, action.path, { createIfMissing: true });
156
+ if (!result.ok) {
157
+ return { action: "set_path", ok: false, error: result.error };
158
+ }
159
+ const b = result.binding;
160
+ const createdNote = result.created ? " (folder created)" : "";
161
+ return {
162
+ action: "set_path",
163
+ ok: true,
164
+ data: {
165
+ threadId: b.threadId,
166
+ name: b.name,
167
+ projectPath: b.projectPath,
168
+ kind: b.kind,
169
+ created: !!result.created,
170
+ },
171
+ userNote: `\u{1F4C1} Topic **${b.name}** (#${b.threadId}) \u2192 \`${b.projectPath}\`${createdNote}`,
172
+ };
173
+ }
174
+
175
+ async function sendPrompt(
176
+ action: Extract<TelegramAction, { action: "send_prompt" }>,
177
+ ctx: TelegramActionContext,
178
+ ): Promise<TelegramActionResult> {
179
+ const forum = ctx.forum;
180
+ if (!forum?.isReady) {
181
+ return {
182
+ action: "send_prompt",
183
+ ok: false,
184
+ error: "Forum topics not available",
185
+ };
186
+ }
187
+ if (!ctx.submitTopicPrompt) {
188
+ return {
189
+ action: "send_prompt",
190
+ ok: false,
191
+ error: "Cross-topic prompt dispatch is not wired",
192
+ };
193
+ }
194
+
195
+ const resolved = resolveTopicRef(forum, action.topic, ctx.cfg);
196
+ if (!resolved.ok) {
197
+ return { action: "send_prompt", ok: false, error: resolved.error };
198
+ }
199
+ const { binding } = resolved;
200
+ const cwd = binding.projectPath;
201
+ if (!cwd) {
202
+ return {
203
+ action: "send_prompt",
204
+ ok: false,
205
+ error: `Topic **${binding.name}** (#${binding.threadId}) has no project path — use set_path or create_topic with path first`,
206
+ };
207
+ }
208
+
209
+ const projectName =
210
+ binding.kind === "ai_chat"
211
+ ? ctx.cfg.topicAiChatName
212
+ : binding.kind === "general"
213
+ ? "General"
214
+ : binding.name;
215
+
216
+ // Visible injection in the *target* topic so humans see the orchestrated prompt.
217
+ // Plain text (no Markdown) so agent prompts with * _ ` cannot break the send.
218
+ const preview =
219
+ action.prompt.length > 1500 ? action.prompt.slice(0, 1499) + "\u2026" : action.prompt;
220
+ await ctx.api
221
+ .sendMessage(
222
+ forum.groupId,
223
+ `\u{1F4E8} Prompt from bridge${action.newSession ? " (new session)" : ""}\n\n${preview}`,
224
+ { message_thread_id: binding.threadId },
225
+ )
226
+ .catch((e) => {
227
+ log.debug(`send_prompt announce failed: ${(e as Error).message}`);
228
+ });
229
+
230
+ try {
231
+ const res = await ctx.submitTopicPrompt({
232
+ threadId: binding.threadId,
233
+ cwd,
234
+ projectName,
235
+ prompt: action.prompt,
236
+ newSession: action.newSession,
237
+ });
238
+ return {
239
+ action: "send_prompt",
240
+ ok: true,
241
+ data: {
242
+ threadId: binding.threadId,
243
+ name: binding.name,
244
+ projectPath: cwd,
245
+ outcome: res.outcome,
246
+ sessionId: res.sessionId,
247
+ newSession: !!action.newSession,
248
+ promptPreview: action.prompt.slice(0, 200),
249
+ },
250
+ userNote: `\u{1F4E8} Prompt ${res.outcome} in **${binding.name}** (#${binding.threadId})`,
251
+ };
252
+ } catch (e) {
253
+ return {
254
+ action: "send_prompt",
255
+ ok: false,
256
+ error: (e as Error).message ?? String(e),
257
+ data: { threadId: binding.threadId, name: binding.name, projectPath: cwd },
258
+ };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Resolve a topic ref: numeric / #id, "general", "ai chat", or exact title.
264
+ */
265
+ export function resolveTopicRef(
266
+ forum: ForumManager,
267
+ ref: string,
268
+ cfg: AppConfig,
269
+ ): { ok: true; binding: ForumTopicBinding } | { ok: false; error: string } {
270
+ const raw = ref.trim();
271
+ if (!raw) return { ok: false, error: "Empty topic reference" };
272
+
273
+ // #123 or plain digits
274
+ const idMatch = /^#?(\d+)$/.exec(raw);
275
+ if (idMatch) {
276
+ const threadId = Number(idMatch[1]);
277
+ const b = forum.store.get(threadId);
278
+ if (!b) {
279
+ // General (1) may not be in store yet — synthesize workspace bind.
280
+ if (threadId === FORUM_GENERAL_THREAD_ID) {
281
+ const bound = forum.store.bindProject(threadId, cfg.workspace, "General", "general");
282
+ return { ok: true, binding: bound };
283
+ }
284
+ return { ok: false, error: `No topic mapped for thread #${threadId}` };
285
+ }
286
+ return { ok: true, binding: b };
287
+ }
288
+
289
+ const key = raw.toLowerCase();
290
+ if (key === "general" || key === "general topic") {
291
+ let b = forum.store.get(FORUM_GENERAL_THREAD_ID);
292
+ if (!b?.projectPath) {
293
+ b = forum.store.bindProject(FORUM_GENERAL_THREAD_ID, cfg.workspace, "General", "general");
294
+ }
295
+ return { ok: true, binding: b };
296
+ }
297
+
298
+ const aiName = (cfg.topicAiChatName || "AI Chat").toLowerCase();
299
+ if (key === "ai chat" || key === "ai_chat" || key === aiName) {
300
+ const ai = forum.store.findAiChat();
301
+ if (ai) return { ok: true, binding: ai };
302
+ // Fallback: ensure workspace topic by name match
303
+ const byName = forum.store.all().find((t) => t.name.toLowerCase() === aiName);
304
+ if (byName) return { ok: true, binding: byName };
305
+ return { ok: false, error: "AI Chat topic not found — run /forum_setup" };
306
+ }
307
+
308
+ // Exact title match (case-insensitive)
309
+ const hits = forum.store.all().filter((t) => t.name.toLowerCase() === key);
310
+ if (hits.length === 1) return { ok: true, binding: hits[0]! };
311
+ if (hits.length > 1) {
312
+ return {
313
+ ok: false,
314
+ error: `Multiple topics named "${raw}" (${hits.map((h) => "#" + h.threadId).join(", ")}). Use #threadId.`,
315
+ };
316
+ }
317
+ return {
318
+ ok: false,
319
+ error: `Topic not found: "${raw}". Use exact title, #threadId, "general", or "ai chat".`,
320
+ };
321
+ }
322
+
323
+ function searchMemory(
324
+ action: Extract<TelegramAction, { action: "search_memory" }>,
325
+ ctx: TelegramActionContext,
326
+ ): TelegramActionResult {
327
+ const topics = ctx.forum?.isReady ? ctx.forum.store.all() : undefined;
328
+ const hits = searchGroupMemory({
329
+ query: action.query,
330
+ limit: action.limit,
331
+ sessionsDir: ctx.cfg.sessionsDir,
332
+ store: ctx.store,
333
+ topics,
334
+ });
335
+ return {
336
+ action: "search_memory",
337
+ ok: true,
338
+ data: { query: action.query, hits },
339
+ userNote:
340
+ hits.length > 0
341
+ ? `\u{1F50D} Memory search: ${hits.length} hit(s) for \u201c${action.query.slice(0, 60)}\u201d`
342
+ : `\u{1F50D} Memory search: no hits for \u201c${action.query.slice(0, 60)}\u201d`,
343
+ };
344
+ }
345
+
346
+ async function listBots(ctx: TelegramActionContext): Promise<TelegramActionResult> {
347
+ if (ctx.cfg.allowedTelegramBots.length === 0) {
348
+ return {
349
+ action: "list_bots",
350
+ ok: true,
351
+ data: { bots: [], note: "ALLOWED_TELEGRAM_BOTS is empty" },
352
+ userNote: "\u{1F916} No sibling bots configured (ALLOWED_TELEGRAM_BOTS)",
353
+ };
354
+ }
355
+ const bots = await ctx.bots.listBots(true);
356
+ const catalog = Object.fromEntries(
357
+ bots.map((b) => [
358
+ b.username,
359
+ b.commands.map((c) =>
360
+ c.description ? `/${c.command} — ${c.description}` : `/${c.command}`,
361
+ ),
362
+ ]),
363
+ );
364
+ return {
365
+ action: "list_bots",
366
+ ok: true,
367
+ data: {
368
+ bots,
369
+ commands: catalog,
370
+ usage:
371
+ "Call bot_command with bot=username (no @), command without leading slash, optional args. " +
372
+ "Treat replies like MCP tool results. Unknown/timeout commands return ok=false — continue the session; do not treat them as Done.",
373
+ },
374
+ userNote: `\u{1F916} Sibling bots: ${bots
375
+ .map((b) => {
376
+ const cmds = b.commands.length
377
+ ? ` (${b.commands.map((c) => "/" + c.command).join(", ")})`
378
+ : "";
379
+ return "@" + b.username + cmds;
380
+ })
381
+ .join(", ") || "(none)"}`,
382
+ };
383
+ }
384
+
385
+ async function botCommand(
386
+ action: Extract<TelegramAction, { action: "bot_command" }>,
387
+ ctx: TelegramActionContext,
388
+ ): Promise<TelegramActionResult> {
389
+ // Prefer the configured forum group so sibling bots (group-scoped) see the command.
390
+ const chatId = ctx.cfg.topicGroupId ?? ctx.chatId;
391
+ const messageThreadId =
392
+ ctx.cfg.topicGroupId !== undefined && chatId === ctx.cfg.topicGroupId
393
+ ? ctx.messageThreadId
394
+ : undefined;
395
+
396
+ const res = await ctx.bots.invokeCommand({
397
+ bot: action.bot,
398
+ command: action.command,
399
+ args: action.args,
400
+ chatId,
401
+ messageThreadId,
402
+ });
403
+
404
+ if (!res.ok) {
405
+ // Timeout / dead bot / bad command: session continues via bridge results.
406
+ // Never surface as a successful Done.
407
+ const kind = res.kind;
408
+ const icon = kind === "timeout" ? "\u23F1\uFE0F" : "\u26A0\uFE0F";
409
+ return {
410
+ action: "bot_command",
411
+ ok: false,
412
+ error: res.error,
413
+ data: {
414
+ bot: action.bot,
415
+ command: action.command,
416
+ args: action.args,
417
+ kind,
418
+ sessionContinues: true,
419
+ partialReply: res.partialReply,
420
+ },
421
+ userNote: `${icon} Waiting for @${action.bot} /${action.command} failed: ${res.error} (session continues)`,
422
+ };
423
+ }
424
+
425
+ const partialNote = res.partial
426
+ ? " \u2014 partial (hard timeout; stream may be incomplete)"
427
+ : "";
428
+ return {
429
+ action: "bot_command",
430
+ ok: true,
431
+ data: {
432
+ bot: action.bot,
433
+ command: action.command,
434
+ args: action.args,
435
+ reply: res.reply,
436
+ partial: res.partial,
437
+ },
438
+ userNote: `\u{1F916} @${action.bot} /${action.command} \u2014 reply ready (${res.reply.length} chars)${partialNote}`,
439
+ };
440
+ }