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.
- package/.env.example +38 -2
- package/CHANGELOG.md +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
package/src/bot/bot.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { Scheduler } from "../tasks/scheduler.js";
|
|
|
22
22
|
import { TaskStore } from "../tasks/store.js";
|
|
23
23
|
import { createAuthMiddleware } from "./auth.js";
|
|
24
24
|
import { isStaleCallbackError, safeCallbackMiddleware } from "./callback.js";
|
|
25
|
-
import { COMMANDS } from "./commands.js";
|
|
25
|
+
import { COMMANDS, GROUP_COMMANDS } from "./commands.js";
|
|
26
26
|
import { type BotDeps, MenuCache } from "./deps.js";
|
|
27
27
|
import { registerControl } from "./handlers/control.js";
|
|
28
28
|
import { registerDocuments } from "./handlers/document.js";
|
|
@@ -43,6 +43,7 @@ import { registerSystem } from "./handlers/system.js";
|
|
|
43
43
|
import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
|
|
44
44
|
import { registerUsage } from "./handlers/usage.js";
|
|
45
45
|
import { registerVoice } from "./handlers/voice.js";
|
|
46
|
+
import { registerForum } from "./handlers/forum.js";
|
|
46
47
|
import { StatusPanel } from "./menu/status-panel.js";
|
|
47
48
|
import { sendMarkdownDoc } from "./telegram-io.js";
|
|
48
49
|
import { Ephemeral } from "./menu/ephemeral.js";
|
|
@@ -50,6 +51,8 @@ import { BAR_LABELS } from "./menu/keyboard.js";
|
|
|
50
51
|
import { PermissionService } from "./permission-service.js";
|
|
51
52
|
import { RuntimeRegistry } from "./registry.js";
|
|
52
53
|
import { TaskWizard } from "./wizard/task-wizard.js";
|
|
54
|
+
import { ForumManager } from "../forum/manager.js";
|
|
55
|
+
import { TelegramBotService } from "./telegram-bots.js";
|
|
53
56
|
|
|
54
57
|
const log = createLogger("bot");
|
|
55
58
|
|
|
@@ -99,13 +102,81 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
99
102
|
const statusPanel = new StatusPanel(bot.api, settings, registry);
|
|
100
103
|
registry.setRefresher((chatId) => void statusPanel.refresh(chatId));
|
|
101
104
|
|
|
105
|
+
const projects = new ProjectManager(cfg.projectRoots);
|
|
106
|
+
const forum =
|
|
107
|
+
cfg.topicGroupId !== undefined
|
|
108
|
+
? new ForumManager(bot.api, cfg, projects)
|
|
109
|
+
: undefined;
|
|
110
|
+
|
|
111
|
+
// Sibling bots + memory/topic actions for the agent telegram JSON bridge.
|
|
112
|
+
const telegramBots = new TelegramBotService(bot.api, cfg);
|
|
113
|
+
telegramBots.attachToBot(bot);
|
|
114
|
+
registry.setBridge({
|
|
115
|
+
store,
|
|
116
|
+
forum,
|
|
117
|
+
bots: telegramBots,
|
|
118
|
+
// Cross-topic orchestration: General can create a topic and send_prompt there.
|
|
119
|
+
// sessionId resumes a specific Grok session (memory follow-up) instead of
|
|
120
|
+
// dumping into whatever is currently open in that topic.
|
|
121
|
+
submitTopicPrompt: async ({
|
|
122
|
+
threadId,
|
|
123
|
+
cwd,
|
|
124
|
+
projectName,
|
|
125
|
+
prompt,
|
|
126
|
+
newSession,
|
|
127
|
+
sessionId,
|
|
128
|
+
reportBack,
|
|
129
|
+
}) => {
|
|
130
|
+
if (cfg.topicGroupId === undefined) {
|
|
131
|
+
throw new Error("TOPIC_GROUP_ID unset");
|
|
132
|
+
}
|
|
133
|
+
const groupId = cfg.topicGroupId;
|
|
134
|
+
const controller = registry.forumController(groupId, threadId, cwd, projectName);
|
|
135
|
+
// Explicit resume wins over newSession / foreground.
|
|
136
|
+
if (sessionId) {
|
|
137
|
+
const sw = await controller.addResume(sessionId, cwd, projectName);
|
|
138
|
+
if (reportBack) sw.rt.setReportBack(reportBack);
|
|
139
|
+
const outcome = await sw.rt.submit(textPrompt(prompt));
|
|
140
|
+
return { outcome, sessionId: sw.rt.sessionId ?? sessionId };
|
|
141
|
+
}
|
|
142
|
+
if (newSession) {
|
|
143
|
+
const rt = await controller.addNew(cwd, projectName);
|
|
144
|
+
if (reportBack) rt.setReportBack(reportBack);
|
|
145
|
+
const outcome = await rt.submit(textPrompt(prompt));
|
|
146
|
+
return { outcome, sessionId: rt.sessionId };
|
|
147
|
+
}
|
|
148
|
+
const rt = controller.foreground();
|
|
149
|
+
if (reportBack) rt.setReportBack(reportBack);
|
|
150
|
+
const outcome = await rt.submit(textPrompt(prompt));
|
|
151
|
+
return { outcome, sessionId: rt.sessionId };
|
|
152
|
+
},
|
|
153
|
+
// Child topic Done → wake General manager with a WORK REPORT.
|
|
154
|
+
wakeManager: async ({ originChatId, originThreadId, prompt }) => {
|
|
155
|
+
if (cfg.topicGroupId === undefined) {
|
|
156
|
+
throw new Error("TOPIC_GROUP_ID unset");
|
|
157
|
+
}
|
|
158
|
+
const groupId = cfg.topicGroupId;
|
|
159
|
+
// origin is always General in manager mode; bind workspace.
|
|
160
|
+
const controller = registry.forumController(
|
|
161
|
+
originChatId || groupId,
|
|
162
|
+
originThreadId,
|
|
163
|
+
cfg.workspace,
|
|
164
|
+
"General",
|
|
165
|
+
);
|
|
166
|
+
const rt = controller.foreground();
|
|
167
|
+
await rt.submit(
|
|
168
|
+
textPrompt(prompt, undefined, undefined, { skipSelfRecheck: true }),
|
|
169
|
+
);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
102
173
|
const deps: BotDeps = {
|
|
103
174
|
api: bot.api,
|
|
104
175
|
cfg,
|
|
105
176
|
acp,
|
|
106
177
|
registry,
|
|
107
178
|
store,
|
|
108
|
-
projects
|
|
179
|
+
projects,
|
|
109
180
|
menuCache: new MenuCache(),
|
|
110
181
|
settings,
|
|
111
182
|
statusPanel,
|
|
@@ -121,6 +192,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
121
192
|
}),
|
|
122
193
|
usage: new UsageService(cfg.grokCliPath),
|
|
123
194
|
accounts: new AccountManager(cfg.dataDir),
|
|
195
|
+
forum,
|
|
124
196
|
};
|
|
125
197
|
|
|
126
198
|
// Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
|
|
@@ -135,6 +207,11 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
135
207
|
onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
|
|
136
208
|
});
|
|
137
209
|
acp.permissionHandler = (p) => permissions.handle(p);
|
|
210
|
+
// /stop and /cancel must cancel pending interactive permissions for that
|
|
211
|
+
// session only (ACP requires cancelled outcomes) — never kill the agent.
|
|
212
|
+
acp.onSessionCancel = (sessionId) => {
|
|
213
|
+
permissions.cancelForSession(sessionId);
|
|
214
|
+
};
|
|
138
215
|
|
|
139
216
|
// The bot pins/unpins the status panel, and Telegram emits a "pinned a
|
|
140
217
|
// message" service message for each pin. Delete those so the chat stays clean
|
|
@@ -146,14 +223,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
146
223
|
// handler forgets (prevents the loading spinner + unhandled 400 noise).
|
|
147
224
|
bot.use(safeCallbackMiddleware());
|
|
148
225
|
|
|
149
|
-
// Keep history clean:
|
|
150
|
-
//
|
|
226
|
+
// Keep history clean: delete the user's command (/…) and persistent-bar
|
|
227
|
+
// button taps INSTANTLY (before handlers) so slow ACP/CLI work never leaves
|
|
228
|
+
// the raw slash sitting in chat. Handlers post bot status messages instead.
|
|
229
|
+
// Plain prompts are adopted separately (see prompt-anchor.ts).
|
|
151
230
|
bot.on("message:text", async (ctx, next) => {
|
|
152
|
-
await next();
|
|
153
231
|
const text = ctx.message?.text ?? "";
|
|
154
232
|
if (text.startsWith("/") || BAR_LABELS.includes(text)) {
|
|
155
|
-
|
|
233
|
+
void ctx.deleteMessage().catch(() => {});
|
|
156
234
|
}
|
|
235
|
+
await next();
|
|
157
236
|
});
|
|
158
237
|
|
|
159
238
|
bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
|
|
@@ -187,27 +266,90 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
187
266
|
bot.callbackQuery(/^sug:(\d+):(\d+)$/, async (ctx) => {
|
|
188
267
|
const batchId = Number(ctx.match![1]);
|
|
189
268
|
const index = Number(ctx.match![2]);
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
269
|
+
const { resolveScope } = await import("./scope.js");
|
|
270
|
+
const { adoptUserPrompt } = await import("./prompt-anchor.js");
|
|
271
|
+
const { isGeneralThread } = await import("../forum/thread.js");
|
|
272
|
+
const scope = resolveScope(ctx, deps);
|
|
273
|
+
// General may have parallel sessions — find the runtime that owns this batch.
|
|
274
|
+
const hit =
|
|
275
|
+
scope.controller.takeSuggestionAnywhere(batchId, index) ??
|
|
276
|
+
(() => {
|
|
277
|
+
const t = scope.rt.takeSuggestion(batchId, index);
|
|
278
|
+
return t ? { rt: scope.rt, text: t } : undefined;
|
|
279
|
+
})();
|
|
280
|
+
if (!hit) {
|
|
193
281
|
await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
|
|
194
282
|
return;
|
|
195
283
|
}
|
|
284
|
+
const { rt, text } = hit;
|
|
196
285
|
await ctx.answerCallbackQuery({ text: "Sending\u2026" });
|
|
197
286
|
// Dim the keyboard so double-taps don't re-fire.
|
|
198
287
|
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
199
288
|
try {
|
|
200
|
-
const
|
|
289
|
+
const chatId = ctx.chat?.id;
|
|
290
|
+
const isGeneral = isGeneralThread(scope.threadId);
|
|
291
|
+
const sugMsgId = ctx.callbackQuery.message?.message_id;
|
|
292
|
+
// General: keep chat clean — no anchor overwrite; continue same session
|
|
293
|
+
// and reply to the message that carried the buttons.
|
|
294
|
+
if (isGeneral) {
|
|
295
|
+
if (rt.sessionId && sugMsgId !== undefined) {
|
|
296
|
+
scope.controller.bindTelegramMessage(sugMsgId, rt.sessionId);
|
|
297
|
+
}
|
|
298
|
+
const outcome = await rt.submit(
|
|
299
|
+
textPrompt(text, sugMsgId, undefined, { promptId: undefined }),
|
|
300
|
+
);
|
|
301
|
+
if (outcome === "queued") {
|
|
302
|
+
const extra: Record<string, unknown> = { ...scope.threadExtra };
|
|
303
|
+
if (sugMsgId !== undefined) {
|
|
304
|
+
extra.reply_parameters = {
|
|
305
|
+
message_id: sugMsgId,
|
|
306
|
+
allow_sending_without_reply: true,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
await deps.api
|
|
310
|
+
.sendMessage(chatId!, "\u{1F4E5} Queued on that thread.", extra)
|
|
311
|
+
.catch(() => {});
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const anchor =
|
|
316
|
+
chatId !== undefined
|
|
317
|
+
? await adoptUserPrompt(deps.api, {
|
|
318
|
+
chatId,
|
|
319
|
+
text,
|
|
320
|
+
userMessageIds: [],
|
|
321
|
+
messageThreadId: scope.threadExtra.message_thread_id,
|
|
322
|
+
projectName: rt.projectName,
|
|
323
|
+
prefix: "\u{1F4A1} Suggestion",
|
|
324
|
+
})
|
|
325
|
+
: undefined;
|
|
326
|
+
const outcome = await rt.submit(
|
|
327
|
+
textPrompt(text, anchor?.replyTo ?? ctx.callbackQuery.message?.message_id, undefined, {
|
|
328
|
+
promptId: anchor?.promptId,
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
201
331
|
if (outcome === "queued") {
|
|
202
|
-
|
|
332
|
+
const extra: Record<string, unknown> = { ...scope.threadExtra };
|
|
333
|
+
if (anchor?.replyTo !== undefined) {
|
|
334
|
+
extra.reply_parameters = {
|
|
335
|
+
message_id: anchor.replyTo,
|
|
336
|
+
allow_sending_without_reply: true,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
await ctx
|
|
340
|
+
.reply(`\u{1F4E5} Queued suggestion (position ${rt.queueLength}).`, extra)
|
|
341
|
+
.catch(() => {});
|
|
203
342
|
}
|
|
204
343
|
} catch (e) {
|
|
205
|
-
await ctx
|
|
344
|
+
await ctx
|
|
345
|
+
.reply(`\u274C Couldn't run suggestion: ${(e as Error).message}`, scope.threadExtra)
|
|
346
|
+
.catch(() => {});
|
|
206
347
|
}
|
|
207
348
|
});
|
|
208
349
|
|
|
209
350
|
registerMenu(bot, deps); // persistent-keyboard buttons (hears)
|
|
210
351
|
registerWizardInput(bot, deps); // wizard text input (before commands)
|
|
352
|
+
if (forum) registerForum(bot, deps, forum);
|
|
211
353
|
registerControl(bot, deps);
|
|
212
354
|
registerProjects(bot, deps);
|
|
213
355
|
registerSessions(bot, deps);
|
|
@@ -234,13 +376,40 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
234
376
|
log.debug("stale callback query:", err.error instanceof Error ? err.error.message : err.error);
|
|
235
377
|
return;
|
|
236
378
|
}
|
|
237
|
-
|
|
379
|
+
const e = err.error;
|
|
380
|
+
if (e instanceof Error) {
|
|
381
|
+
// Include Grammy error_code when present (429 / 403 / 409, etc.) for diagnosis.
|
|
382
|
+
const codeNum = (e as unknown as { error_code?: number }).error_code;
|
|
383
|
+
const code = typeof codeNum === "number" ? ` (code ${codeNum})` : "";
|
|
384
|
+
log.error(`unhandled bot error${code}:`, e.stack || e.message);
|
|
385
|
+
} else {
|
|
386
|
+
log.error("unhandled bot error:", e);
|
|
387
|
+
}
|
|
388
|
+
// Never rethrow — a middleware failure must not take down long polling.
|
|
238
389
|
});
|
|
239
390
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
391
|
+
// Scoped command menus: private = full sorted list; groups = short list with
|
|
392
|
+
// cancel/menu first (reply keyboard is unreliable in forum topics).
|
|
393
|
+
const registerCommands = async (
|
|
394
|
+
commands: typeof COMMANDS,
|
|
395
|
+
scope?: { type: string; chat_id?: number },
|
|
396
|
+
label = "default",
|
|
397
|
+
): Promise<void> => {
|
|
398
|
+
try {
|
|
399
|
+
await bot.api.setMyCommands(commands, scope ? { scope: scope as never } : undefined);
|
|
400
|
+
} catch (e) {
|
|
401
|
+
log.warn(`setMyCommands (${label}) failed:`, (e as Error).message);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
await registerCommands(COMMANDS, undefined, "default");
|
|
405
|
+
await registerCommands(COMMANDS, { type: "all_private_chats" }, "private");
|
|
406
|
+
await registerCommands(GROUP_COMMANDS, { type: "all_group_chats" }, "groups");
|
|
407
|
+
if (cfg.topicGroupId !== undefined) {
|
|
408
|
+
await registerCommands(
|
|
409
|
+
GROUP_COMMANDS,
|
|
410
|
+
{ type: "chat", chat_id: cfg.topicGroupId },
|
|
411
|
+
`chat:${cfg.topicGroupId}`,
|
|
412
|
+
);
|
|
244
413
|
}
|
|
245
414
|
|
|
246
415
|
const updater = new Updater({
|
|
@@ -278,5 +447,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
278
447
|
// Remove any navigation surface left over from before a restart.
|
|
279
448
|
void deps.ephemeral.cleanupAll().catch(() => {});
|
|
280
449
|
|
|
450
|
+
// Forum project topics: ensure AI Chat + optional catalog topics (best-effort).
|
|
451
|
+
if (forum) {
|
|
452
|
+
void forum.ensureSetup().catch((e) => {
|
|
453
|
+
log.warn(`forum setup failed: ${(e as Error).message}`);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
281
457
|
return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
|
|
282
458
|
}
|
|
@@ -23,10 +23,7 @@ export interface RunningSession {
|
|
|
23
23
|
unread: number;
|
|
24
24
|
/** Latest task-completion % (0–100) for this session, if known. */
|
|
25
25
|
progress?: number;
|
|
26
|
-
/**
|
|
27
|
-
* Card comment: live step while working, chat summary when idle
|
|
28
|
-
* ("what is happening / what was done").
|
|
29
|
-
*/
|
|
26
|
+
/** Card comment: last user prompt (+ last AI thinking while busy). */
|
|
30
27
|
comment?: string;
|
|
31
28
|
}
|
|
32
29
|
|
|
@@ -40,11 +37,37 @@ export interface SwitchResult {
|
|
|
40
37
|
alreadyForeground: boolean;
|
|
41
38
|
}
|
|
42
39
|
|
|
40
|
+
export interface ChatControllerOpts {
|
|
41
|
+
/** Forum topic thread — all runtimes post into this topic. */
|
|
42
|
+
messageThreadId?: number;
|
|
43
|
+
/** Settings key (`chatId` or `chatId:t{thread}`). Defaults to String(chatId). */
|
|
44
|
+
settingsKey?: string;
|
|
45
|
+
/** Fixed project path (forum topics): never switch away via project picker. */
|
|
46
|
+
fixedCwd?: string;
|
|
47
|
+
fixedProjectName?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Optional Telegram bridge services attached to every runtime in this chat. */
|
|
51
|
+
export type ChatBridgeServices = SessionRuntime["bridge"];
|
|
52
|
+
|
|
43
53
|
export class ChatController {
|
|
44
54
|
private readonly runtimes: SessionRuntime[] = [];
|
|
45
55
|
private fg: SessionRuntime | undefined;
|
|
46
56
|
private readonly lastRead = new Map<string, number>();
|
|
47
57
|
private restored = false;
|
|
58
|
+
readonly settingsKey: string;
|
|
59
|
+
readonly messageThreadId: number | undefined;
|
|
60
|
+
readonly fixedCwd: string | undefined;
|
|
61
|
+
readonly fixedProjectName: string | undefined;
|
|
62
|
+
|
|
63
|
+
/** Telegram bridge (forum / memory / sibling bots); set by the registry. */
|
|
64
|
+
bridge?: ChatBridgeServices;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* General manager: map Telegram message ids (user + bot) → session id so a
|
|
68
|
+
* reply-to continues the same ACP session instead of spawning a new one.
|
|
69
|
+
*/
|
|
70
|
+
private readonly telegramMsgSessions = new Map<number, string>();
|
|
48
71
|
|
|
49
72
|
constructor(
|
|
50
73
|
private readonly api: Api,
|
|
@@ -56,14 +79,22 @@ export class ChatController {
|
|
|
56
79
|
private readonly refresh: (chatId: number) => void,
|
|
57
80
|
private readonly notifyActivity: (busy: boolean) => void,
|
|
58
81
|
private readonly getRotator?: () => AccountRotator | undefined,
|
|
59
|
-
|
|
82
|
+
opts?: ChatControllerOpts,
|
|
83
|
+
) {
|
|
84
|
+
this.messageThreadId = opts?.messageThreadId;
|
|
85
|
+
this.settingsKey = opts?.settingsKey ?? String(chatId);
|
|
86
|
+
this.fixedCwd = opts?.fixedCwd;
|
|
87
|
+
this.fixedProjectName = opts?.fixedProjectName;
|
|
88
|
+
}
|
|
60
89
|
|
|
61
90
|
/** The current foreground runtime (created/restored lazily). */
|
|
62
91
|
foreground(): SessionRuntime {
|
|
63
92
|
this.ensureRestored();
|
|
64
93
|
if (!this.fg) {
|
|
65
|
-
const s = this.settings.
|
|
66
|
-
const
|
|
94
|
+
const s = this.settings.getKey(this.settingsKey);
|
|
95
|
+
const cwd = this.fixedCwd ?? s.projectPath ?? this.cfg.workspace;
|
|
96
|
+
const name = this.fixedProjectName ?? s.projectName;
|
|
97
|
+
const rt = this.create({ cwd, projectName: name, sessionId: s.sessionId });
|
|
67
98
|
this.runtimes.push(rt);
|
|
68
99
|
this.fg = rt;
|
|
69
100
|
}
|
|
@@ -103,6 +134,105 @@ export class ChatController {
|
|
|
103
134
|
return rt;
|
|
104
135
|
}
|
|
105
136
|
|
|
137
|
+
/**
|
|
138
|
+
* General manager: spawn a fresh session for one user message without
|
|
139
|
+
* killing an in-flight sibling's Telegram stream (parallel prompts).
|
|
140
|
+
*/
|
|
141
|
+
async addParallel(cwd: string, projectName?: string): Promise<SessionRuntime> {
|
|
142
|
+
return this.addNew(cwd, projectName);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Remember that a Telegram message belongs to a Grok session (reply routing). */
|
|
146
|
+
bindTelegramMessage(messageId: number, sessionId: string): void {
|
|
147
|
+
if (!messageId || !sessionId) return;
|
|
148
|
+
this.telegramMsgSessions.set(messageId, sessionId);
|
|
149
|
+
// Cap map growth (keep newest ~500).
|
|
150
|
+
if (this.telegramMsgSessions.size > 500) {
|
|
151
|
+
const drop = this.telegramMsgSessions.size - 500;
|
|
152
|
+
let i = 0;
|
|
153
|
+
for (const k of this.telegramMsgSessions.keys()) {
|
|
154
|
+
this.telegramMsgSessions.delete(k);
|
|
155
|
+
if (++i >= drop) break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Resolve a controlled runtime from a Telegram message id (reply-to). */
|
|
161
|
+
runtimeForTelegramMessage(messageId: number | undefined): SessionRuntime | undefined {
|
|
162
|
+
if (messageId === undefined) return undefined;
|
|
163
|
+
this.ensureRestored();
|
|
164
|
+
const sid = this.telegramMsgSessions.get(messageId);
|
|
165
|
+
if (sid) {
|
|
166
|
+
const byId = this.runtimes.find((r) => r.sessionId === sid);
|
|
167
|
+
if (byId) return byId;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve session from `#sess_xxxxxxxx` in a replied-to bot message body
|
|
174
|
+
* (works across process restarts when the in-memory map is cold).
|
|
175
|
+
*/
|
|
176
|
+
runtimeForSessionTag(text: string | undefined): SessionRuntime | undefined {
|
|
177
|
+
if (!text) return undefined;
|
|
178
|
+
this.ensureRestored();
|
|
179
|
+
const tag = parseSessTag(text);
|
|
180
|
+
if (!tag) return undefined;
|
|
181
|
+
return this.runtimes.find((r) => r.sessionId && sessionMatchesTag(r.sessionId, tag));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Cold-start / map-miss: resolve continue target from reply message id, #sess_
|
|
186
|
+
* tag on controlled runtimes, or disk session list (attach into this controller).
|
|
187
|
+
*/
|
|
188
|
+
async resolveContinueFromReply(opts: {
|
|
189
|
+
replyToMessageId?: number;
|
|
190
|
+
replyToText?: string;
|
|
191
|
+
cwd: string;
|
|
192
|
+
projectName?: string;
|
|
193
|
+
}): Promise<SessionRuntime | undefined> {
|
|
194
|
+
this.ensureRestored();
|
|
195
|
+
const byMsg = this.runtimeForTelegramMessage(opts.replyToMessageId);
|
|
196
|
+
if (byMsg) return byMsg;
|
|
197
|
+
const byTag = this.runtimeForSessionTag(opts.replyToText);
|
|
198
|
+
if (byTag) return byTag;
|
|
199
|
+
|
|
200
|
+
const tag = parseSessTag(opts.replyToText);
|
|
201
|
+
if (!tag) return undefined;
|
|
202
|
+
// Disk fallback: find full session id, then resume into this controller.
|
|
203
|
+
let metas;
|
|
204
|
+
try {
|
|
205
|
+
metas = this.store.list(80);
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
const meta = metas.find((m) => sessionMatchesTag(m.sessionId, tag));
|
|
210
|
+
if (!meta) return undefined;
|
|
211
|
+
try {
|
|
212
|
+
const sw = await this.addResume(
|
|
213
|
+
meta.sessionId,
|
|
214
|
+
meta.cwd || opts.cwd,
|
|
215
|
+
opts.projectName,
|
|
216
|
+
);
|
|
217
|
+
return sw.rt;
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Find any runtime that still holds this suggestion batch (General parallel). */
|
|
224
|
+
takeSuggestionAnywhere(
|
|
225
|
+
batchId: number,
|
|
226
|
+
index: number,
|
|
227
|
+
): { rt: SessionRuntime; text: string } | undefined {
|
|
228
|
+
this.ensureRestored();
|
|
229
|
+
for (const r of this.runtimes) {
|
|
230
|
+
const text = r.takeSuggestion(batchId, index);
|
|
231
|
+
if (text) return { rt: r, text };
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
106
236
|
/**
|
|
107
237
|
* Switch the chat to a project directory **without** waiting on ACP.
|
|
108
238
|
* - Reuses an existing controlled runtime for the same path when possible.
|
|
@@ -279,7 +409,7 @@ export class ChatController {
|
|
|
279
409
|
return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress;
|
|
280
410
|
}
|
|
281
411
|
|
|
282
|
-
/**
|
|
412
|
+
/** Last user prompt (+ thinking when busy) for a controlled session id. */
|
|
283
413
|
commentFor(sessionId?: string): string | undefined {
|
|
284
414
|
if (!sessionId) return undefined;
|
|
285
415
|
this.ensureRestored();
|
|
@@ -301,27 +431,31 @@ export class ChatController {
|
|
|
301
431
|
private ensureRestored(): void {
|
|
302
432
|
if (this.restored) return;
|
|
303
433
|
this.restored = true;
|
|
304
|
-
const s = this.settings.
|
|
434
|
+
const s = this.settings.getKey(this.settingsKey);
|
|
305
435
|
const seen = new Set<string>();
|
|
306
436
|
for (const cs of s.controlledSessions ?? []) {
|
|
307
437
|
if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
|
|
308
438
|
seen.add(cs.sessionId);
|
|
439
|
+
// Forum topics only restore sessions for the fixed project path.
|
|
440
|
+
if (this.fixedCwd && normPath(cs.projectPath) !== normPath(this.fixedCwd)) continue;
|
|
309
441
|
this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
|
|
310
442
|
}
|
|
311
443
|
// Lazy project switches persist projectPath without a sessionId. If the
|
|
312
444
|
// saved project is not among controlled sessions, recreate an unbound FG
|
|
313
445
|
// so a restart lands on the project the user last chose.
|
|
314
|
-
|
|
315
|
-
|
|
446
|
+
const homePath = this.fixedCwd ?? s.projectPath;
|
|
447
|
+
const homeName = this.fixedProjectName ?? s.projectName;
|
|
448
|
+
if (homePath) {
|
|
449
|
+
const key = normPath(homePath);
|
|
316
450
|
const hasProject = this.runtimes.some((r) => normPath(r.cwd) === key);
|
|
317
451
|
if (!hasProject) {
|
|
318
|
-
this.runtimes.push(this.create({ cwd:
|
|
452
|
+
this.runtimes.push(this.create({ cwd: homePath, projectName: homeName, sessionId: s.sessionId }));
|
|
319
453
|
}
|
|
320
454
|
}
|
|
321
455
|
if (this.runtimes.length > 0) {
|
|
322
456
|
let fg = this.runtimes.find((r) => r.sessionId && r.sessionId === s.foregroundSessionId);
|
|
323
|
-
if (!fg &&
|
|
324
|
-
const key = normPath(
|
|
457
|
+
if (!fg && homePath) {
|
|
458
|
+
const key = normPath(homePath);
|
|
325
459
|
fg = this.runtimes.find((r) => normPath(r.cwd) === key);
|
|
326
460
|
}
|
|
327
461
|
fg = fg ?? this.runtimes[0]!;
|
|
@@ -365,10 +499,15 @@ export class ChatController {
|
|
|
365
499
|
}
|
|
366
500
|
|
|
367
501
|
private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
|
|
368
|
-
const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings,
|
|
502
|
+
const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, {
|
|
503
|
+
...init,
|
|
504
|
+
messageThreadId: this.messageThreadId,
|
|
505
|
+
settingsKey: this.settingsKey,
|
|
506
|
+
});
|
|
369
507
|
rt.onStateChange = () => this.refresh(this.chatId);
|
|
370
508
|
rt.onActivity = (busy) => this.notifyActivity(busy);
|
|
371
509
|
rt.accountRotator = this.getRotator?.();
|
|
510
|
+
rt.bridge = this.bridge;
|
|
372
511
|
// A logical fork (auto-fork-on-error / lost-session recovery) swaps the
|
|
373
512
|
// runtime's session id in place — re-persist the controlled list with the
|
|
374
513
|
// new id and treat the fresh session as already-seen.
|
|
@@ -376,6 +515,9 @@ export class ChatController {
|
|
|
376
515
|
this.markSeen(rt);
|
|
377
516
|
this.persist();
|
|
378
517
|
};
|
|
518
|
+
rt.onTelegramMessageBound = (messageId, sessionId) => {
|
|
519
|
+
this.bindTelegramMessage(messageId, sessionId);
|
|
520
|
+
};
|
|
379
521
|
return rt;
|
|
380
522
|
}
|
|
381
523
|
|
|
@@ -415,15 +557,15 @@ export class ChatController {
|
|
|
415
557
|
seen.add(r.sessionId);
|
|
416
558
|
controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
|
|
417
559
|
}
|
|
418
|
-
this.settings.
|
|
560
|
+
this.settings.updateKey(this.settingsKey, {
|
|
419
561
|
controlledSessions: controlled,
|
|
420
562
|
foregroundSessionId: this.fg?.sessionId,
|
|
421
563
|
// Keep the single-session restore fields aligned with the foreground so
|
|
422
564
|
// the pinned status panel and a fresh restore never show a project that
|
|
423
565
|
// belongs to a different (previously-foreground) session.
|
|
424
566
|
sessionId: this.fg?.sessionId,
|
|
425
|
-
projectPath: this.fg?.cwd,
|
|
426
|
-
projectName: this.fg?.projectName,
|
|
567
|
+
projectPath: this.fixedCwd ?? this.fg?.cwd,
|
|
568
|
+
projectName: this.fixedProjectName ?? this.fg?.projectName,
|
|
427
569
|
});
|
|
428
570
|
}
|
|
429
571
|
}
|
|
@@ -432,3 +574,24 @@ export class ChatController {
|
|
|
432
574
|
function normPath(p: string): string {
|
|
433
575
|
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
434
576
|
}
|
|
577
|
+
|
|
578
|
+
/** Extract `#sess_xxxxxxxx` from bot message text/caption. */
|
|
579
|
+
function parseSessTag(text: string | undefined): string | undefined {
|
|
580
|
+
if (!text) return undefined;
|
|
581
|
+
const m = text.match(/#sess_([a-z0-9]{6,12})/i);
|
|
582
|
+
return m?.[1]?.toLowerCase();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Match full session UUID (or short form) to a #sess_ tag body. */
|
|
586
|
+
function sessionMatchesTag(sessionId: string, tag: string): boolean {
|
|
587
|
+
const compact = sessionId.replace(/-/g, "").toLowerCase();
|
|
588
|
+
const short = sessionId.slice(0, 8).replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
589
|
+
const t = tag.toLowerCase();
|
|
590
|
+
return (
|
|
591
|
+
short === t ||
|
|
592
|
+
short.startsWith(t) ||
|
|
593
|
+
t.startsWith(short) ||
|
|
594
|
+
compact.startsWith(t) ||
|
|
595
|
+
sessionId.toLowerCase().startsWith(t)
|
|
596
|
+
);
|
|
597
|
+
}
|