arisa 5.1.49 → 5.1.64

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 (56) hide show
  1. package/AGENTS.md +0 -2
  2. package/README.md +9 -0
  3. package/package.json +1 -1
  4. package/src/core/agent/agent-manager.js +49 -489
  5. package/src/core/agent/agent-session-lifecycle.js +181 -0
  6. package/src/core/agent/pi-capability-tools.js +183 -0
  7. package/src/core/artifacts/artifact-store.js +73 -17
  8. package/src/core/capabilities/capability-service.js +340 -0
  9. package/src/core/config/config-defaults.js +28 -1
  10. package/src/core/tasks/task-routing.js +7 -0
  11. package/src/core/tasks/task-runner.js +68 -0
  12. package/src/core/tasks/task-store.js +382 -92
  13. package/src/core/tools/tool-output-materializer.js +5 -5
  14. package/src/core/tools/tool-registry.js +20 -5
  15. package/src/core/tools/weighted-resource-governor.js +153 -0
  16. package/src/index.js +20 -0
  17. package/src/official-tools.lock.json +62 -45
  18. package/src/runtime/arisa-capabilities.js +51 -242
  19. package/src/runtime/create-app.js +11 -2
  20. package/src/runtime/create-headless-app.js +7 -4
  21. package/src/runtime/paths.js +4 -0
  22. package/src/runtime/service-manager.js +3 -1
  23. package/src/runtime/service-supervisor.js +98 -0
  24. package/src/transport/telegram/bot.js +186 -374
  25. package/src/transport/telegram/chat-queue.js +83 -6
  26. package/src/transport/telegram/prompt-builders.js +9 -0
  27. package/src/transport/telegram/reply-topic-routing.js +111 -0
  28. package/src/transport/telegram/task-dispatcher.js +96 -36
  29. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  30. package/src/transport/telegram/telegram-session-bridge.js +177 -0
  31. package/src/transport/telegram/telegram-tools-command.js +28 -0
  32. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  33. package/src/transport/telegram/workspace-topic-store.js +228 -0
  34. package/test/agent-session-lifecycle.test.js +58 -0
  35. package/test/artifact-store.test.js +38 -2
  36. package/test/capabilities-security.test.js +58 -0
  37. package/test/chat-queue.test.js +32 -0
  38. package/test/context-and-task-bounds.test.js +76 -1
  39. package/test/device-code-message.test.js +9 -0
  40. package/test/media-caption.test.js +1 -1
  41. package/test/model-selection.test.js +9 -1
  42. package/test/official-tool-dependencies.test.js +1 -1
  43. package/test/paths.test.js +8 -0
  44. package/test/pi-capability-tools.test.js +65 -0
  45. package/test/service-manager.test.js +48 -0
  46. package/test/session-start-operational-notes.test.js +1 -1
  47. package/test/task-idempotency.test.js +40 -0
  48. package/test/task-routing.test.js +62 -0
  49. package/test/task-store.test.js +231 -7
  50. package/test/telegram-reply-topic-routing.test.js +94 -0
  51. package/test/telegram-task-dispatcher.test.js +150 -23
  52. package/test/telegram-text-artifact.test.js +13 -2
  53. package/test/telegram-tools-command.test.js +47 -0
  54. package/test/telegram-workspace-topic-store.test.js +124 -0
  55. package/test/tool-registry-run.test.js +41 -0
  56. package/test/weighted-resource-governor.test.js +95 -0
@@ -1,15 +1,12 @@
1
- import { Bot, InputFile } from "grammy";
2
- import path from "node:path";
1
+ import { Bot } from "grammy";
3
2
  import { authorizeChat } from "./auth.js";
4
3
  import { captureIncomingArtifact } from "./media.js";
5
- import { buildDeviceCodeTelegramMessage } from "./device-code-message.js";
6
4
  import { renderTelegramHtml } from "./text-format.js";
7
- import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
8
- import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
5
+ import { buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue } from "../../core/agent/auth-flow.js";
6
+ import { createTelegramAuthController } from "./telegram-auth-controller.js";
9
7
  import { resolveChatSpeed } from "../../core/agent/model-selection.js";
10
8
  import { SessionSeedStore } from "../../core/conversation/session-seed-store.js";
11
9
  import { formatDoctorReport } from "../../runtime/doctor.js";
12
- import { formatToolUsageReport } from "../../runtime/tool-usage-report.js";
13
10
  import { ToolResourceNoteStore } from "../../core/tools/tool-resource-note-store.js";
14
11
  import { formatUpdateReport } from "../../runtime/update-manager.js";
15
12
  import { cancelRestartReceipt, deliverRestartReceipt, prepareRestartReceipt } from "../../runtime/restart-receipt.js";
@@ -17,7 +14,16 @@ import { buildUpdatePicker, createTelegramUpdateCallbackHandler } from "./update
17
14
  import { createTelegramModelControls } from "./model-controls.js";
18
15
  import { createTelegramModelCallbackHandler } from "./model-callback.js";
19
16
  import { createTelegramTaskDispatcher } from "./task-dispatcher.js";
20
- import { resolveTelegramWorkspaceRoute, topicSessionId } from "./workspace-group.js";
17
+ import { createTelegramSessionBridgeController } from "./telegram-session-bridge.js";
18
+ import { createTelegramToolsCommandHandler } from "./telegram-tools-command.js";
19
+ import { createTelegramWorkspaceController } from "./telegram-workspace-controller.js";
20
+ import { resolveTelegramWorkspaceRoute } from "./workspace-group.js";
21
+ import {
22
+ appendGeneralReplyRoutingInstruction,
23
+ isGeneralWorkspaceRoute,
24
+ routeGeneralWorkspaceReply
25
+ } from "./reply-topic-routing.js";
26
+ import { migrateLegacyReplyTopics, WorkspaceTopicStore } from "./workspace-topic-store.js";
21
27
  import {
22
28
  buildNewSessionPrompt,
23
29
  buildPrompt,
@@ -30,17 +36,18 @@ import {
30
36
  isSilentReply,
31
37
  normalizeIncomingArtifact,
32
38
  sanitizeSessionHandoff,
39
+ scheduledPromptSpeedOptions,
33
40
  shouldIncludeArtifactReference,
34
41
  withPromptSpeed
35
42
  } from "./prompt-builders.js";
36
43
  import {
37
44
  createChatStateStore,
45
+ createPromptExecutionReceipt,
38
46
  drainChatPromptQueue,
39
47
  queueChatPrompt,
40
48
  resolveTelegramBusyMessageMode,
41
49
  routeBusyPrompt
42
50
  } from "./chat-queue.js";
43
-
44
51
  export {
45
52
  createChatStateStore,
46
53
  drainChatPromptQueue,
@@ -56,6 +63,7 @@ export {
56
63
  collectText,
57
64
  isScheduledTaskPrompt,
58
65
  isSilentReply,
66
+ scheduledPromptSpeedOptions,
59
67
  shouldIncludeArtifactReference,
60
68
  withPromptSpeed
61
69
  } from "./prompt-builders.js";
@@ -73,6 +81,12 @@ export function isProcessableTelegramMessage(message = {}) {
73
81
  );
74
82
  }
75
83
 
84
+ export function resolveIncomingBusyMessageMode({ config, route, message }) {
85
+ if (route?.workspace) return "queue";
86
+ if (typeof message?.text !== "string") return "queue";
87
+ return resolveTelegramBusyMessageMode(config, route?.sessionId);
88
+ }
89
+
76
90
  export function buildTopicInitializationHandoff({ name, context }) {
77
91
  return [
78
92
  `Telegram topic: ${String(name || "").trim()}`,
@@ -166,12 +180,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
166
180
  const bot = new Bot(config.telegram.token);
167
181
  const perChatState = createChatStateStore();
168
182
  const sessionSeeds = new SessionSeedStore();
183
+ const workspaceTopics = new WorkspaceTopicStore();
169
184
  const notifiedPromptErrors = new WeakSet();
170
- const authRenewals = new Map();
171
- const workspaceRoutes = new WeakMap();
172
- const workspaceGateStates = new Map();
173
- let piAuthIssue = null;
174
185
  let taskTimer = null;
186
+ const { authorizeContext, contextRoute, registerRoute } = createTelegramWorkspaceController({
187
+ config,
188
+ api: bot.api,
189
+ saveConfig
190
+ });
175
191
 
176
192
  const requestRestartWithReceipt = async (ctx, reason = "Telegram restart") => {
177
193
  const route = contextRoute(ctx);
@@ -211,152 +227,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
211
227
  if (error instanceof Error) notifiedPromptErrors.add(error);
212
228
  }
213
229
 
214
- function rememberPiAuthIssue(error) {
215
- const issue = getPiAuthIssue(error);
216
- if (issue) piAuthIssue = issue;
217
- return issue;
218
- }
219
-
220
- async function notifyPiAuthIssueIfNeeded(chatId, error) {
221
- const issue = rememberPiAuthIssue(error);
222
- if (!issue) return false;
223
-
224
- try {
225
- await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue }));
226
- markPromptErrorNotified(error);
227
- return true;
228
- } catch (notifyError) {
229
- logger?.error("telegram", `auth issue notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
230
- return false;
231
- }
232
- }
233
-
234
- function selectTelegramLoginOption(options = []) {
235
- return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
236
- || options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
237
- || options[0]
238
- || null;
239
- }
240
-
241
- async function finishAuthRenewal(chatId, renewal) {
242
- try {
243
- await renewal.promise;
244
- await agentManager.validateAgent();
245
- agentManager.clearSessionCache(chatId);
246
- piAuthIssue = null;
247
- logger?.log("telegram", `Pi auth renewal completed for chat ${chatId}`);
248
- await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, verified: true }));
249
- } catch (error) {
250
- const issue = rememberPiAuthIssue(error) || { kind: "validation-failed", message: getErrorMessage(error) };
251
- piAuthIssue = issue;
252
- logger?.error("telegram", `Pi auth renewal failed for chat ${chatId}: ${getErrorMessage(error)}`);
253
- await bot.api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue })).catch((notifyError) => {
254
- logger?.error("telegram", `auth renewal failure notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
255
- });
256
- } finally {
257
- authRenewals.delete(chatKey(chatId));
258
- }
259
- }
260
-
261
- async function startAuthRenewal(chatId) {
262
- const key = chatKey(chatId);
263
- const existing = authRenewals.get(key);
264
- if (existing) {
265
- return { started: false, renewal: existing };
266
- }
267
-
268
- const renewal = createPiOAuthLogin({
269
- provider: config.pi.provider,
270
- onSelect: async ({ message, options }) => {
271
- const selected = selectTelegramLoginOption(options);
272
- if (!selected) return undefined;
273
- logger?.log("telegram", `Pi auth option for chat ${chatId}: ${selected.id}`);
274
- await bot.api.sendMessage(chatId, `${message}\nUsing: ${selected.label || selected.id}`);
275
- return selected.id;
276
- },
277
- onAuth: async ({ url, instructions }) => {
278
- await bot.api.sendMessage(chatId, [
279
- instructions || "Open this URL to continue Pi authentication:",
280
- url,
281
- "After login, paste the full redirect URL back here."
282
- ].join("\n"));
283
- },
284
- onDeviceCode: async ({ userCode, verificationUri, expiresInSeconds }) => {
285
- const payload = buildDeviceCodeTelegramMessage({ userCode, verificationUri, expiresInSeconds });
286
- const { text, ...options } = payload;
287
- await bot.api.sendMessage(chatId, text, options);
288
- },
289
- onPrompt: async ({ message, controller }) => {
290
- await bot.api.sendMessage(chatId, `${message}\nReply here with the value.`);
291
- return controller.waitForManualCode();
292
- },
293
- onProgress: (message) => {
294
- if (message) logger?.log("telegram", `Pi auth progress for chat ${chatId}: ${message}`);
295
- }
296
- });
297
-
298
- authRenewals.set(key, renewal);
299
- finishAuthRenewal(chatId, renewal);
300
- return { started: true, renewal };
301
- }
302
-
303
- async function submitAuthRenewalInput(ctx) {
304
- const renewal = authRenewals.get(chatKey(ctx.chat.id));
305
- const text = getIncomingMessageText(ctx.message).trim();
306
- if (!renewal || !renewal.manualInputRequested || !text) return false;
307
-
308
- if (!renewal.submitManualCode(text)) return false;
309
- await ctx.reply("Got it. Finishing Pi login now...");
310
- return true;
311
- }
312
-
313
- function getIncomingChatMeta(ctx) {
314
- return {
315
- languageCode: ctx.from?.language_code || "",
316
- username: ctx.from?.username || "",
317
- firstName: ctx.from?.first_name || "",
318
- lastName: ctx.from?.last_name || ""
319
- };
320
- }
321
-
322
- async function authorizeContext(ctx) {
323
- const route = await resolveTelegramWorkspaceRoute({ config, api: ctx.api, ctx });
324
- if (!route.workspace) {
325
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
326
- if (auth.ok) workspaceRoutes.set(ctx, route);
327
- return auth;
328
- }
329
-
330
- const gateKey = String(ctx.chat.id);
331
- const previous = workspaceGateStates.get(gateKey);
332
- if (!route.ok) {
333
- workspaceGateStates.set(gateKey, route.reason || "locked");
334
- if (previous !== (route.reason || "locked")) {
335
- await ctx.reply("Private workspace access is paused because this forum is no longer owner-only.").catch(() => {});
336
- }
337
- return { ok: false, reason: route.reason || "workspace-locked" };
338
- }
339
- if (!(config.telegram.authorizedChatIds || []).includes(route.ownerChatId)) {
340
- return { ok: false, reason: "owner-not-authorized" };
341
- }
342
- workspaceRoutes.set(ctx, route);
343
- workspaceGateStates.set(gateKey, "ready");
344
- if (previous && previous !== "ready") {
345
- await ctx.reply("Private workspace access restored.").catch(() => {});
346
- }
347
- return { ok: true, firstTime: false, workspace: true };
348
- }
349
-
350
- function contextRoute(ctx) {
351
- return workspaceRoutes.get(ctx) || {
352
- ok: true,
353
- workspace: false,
354
- sessionId: String(ctx.chat.id),
355
- scopeChatId: ctx.chat.id,
356
- transportChatId: ctx.chat.id,
357
- threadId: null
358
- };
359
- }
230
+ const authController = createTelegramAuthController({
231
+ config,
232
+ api: bot.api,
233
+ agentManager,
234
+ logger,
235
+ markPromptErrorNotified
236
+ });
360
237
 
361
238
  function getChatState(chatId) {
362
239
  return perChatState.get(chatId);
@@ -406,145 +283,34 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
406
283
  if (normalizationRequired && !transcript) {
407
284
  logger?.log("telegram", `media normalization unavailable for chat ${route.transportChatId}: ${toolResult?.error || toolResult?.missingConfig?.join(", ") || "unknown error"}`);
408
285
  }
409
- return buildPrompt({ ctx, artifact, transcript, toolResult });
410
- }
411
-
412
- async function sendTextReply({ sendText, sendDocument, chatId, artifactChatId = chatId, text }) {
413
- const maxInlineReplyLength = 3500;
414
-
415
- if (isSilentReply(text)) {
416
- logger?.log("telegram", `suppressing silent reply for chat ${chatId}`);
417
- return;
418
- }
419
-
420
- if (text.length > maxInlineReplyLength) {
421
- logger?.log("telegram", `sending long reply as markdown attachment for chat ${chatId}`);
422
- const chatArtifactStore = artifactStore.forChat(artifactChatId);
423
- const artifact = await chatArtifactStore.createGeneratedFile({
424
- fileName: `reply-${Date.now()}.md`,
425
- content: text,
426
- kind: "document",
427
- mimeType: "text/markdown",
428
- source: { type: "assistant", chatId },
429
- metadata: { delivery: "telegram-document" }
430
- });
431
- await sendDocument(new InputFile(artifact.path, path.basename(artifact.path)), {
432
- caption: "Response attached as Markdown."
433
- });
434
- return;
435
- }
436
-
437
- logger?.log("telegram", `sending text reply for chat ${chatId}`);
438
- const sent = await sendText(renderTelegramHtml(text), { parse_mode: "HTML" });
439
- if (sent?.message_id) {
440
- const messages = getChatState(chatId).assistantMessages;
441
- messages.set(sent.message_id, text);
442
- while (messages.size > 50) messages.delete(messages.keys().next().value);
443
- }
444
- }
445
-
446
- function createWorkspaceAccessGuard(route) {
447
- return async () => {
448
- if (!route.workspace) return;
449
- const current = await resolveTelegramWorkspaceRoute({
450
- config,
451
- api: bot.api,
452
- ctx: {
453
- chat: { id: route.transportChatId, type: "supergroup", is_forum: true },
454
- from: { id: route.ownerChatId },
455
- message: { message_thread_id: route.threadId }
456
- }
457
- });
458
- if (!current.ok) throw new Error("Owner-only workspace access is paused.");
459
- };
286
+ const prompt = buildPrompt({ ctx, artifact, transcript, toolResult });
287
+ if (!isGeneralWorkspaceRoute(route)) return prompt;
288
+ const [topics, recentProposals] = await Promise.all([
289
+ workspaceTopics.listTopics(route.ownerChatId, route.transportChatId),
290
+ workspaceTopics.listRecentProposals(route.ownerChatId, route.transportChatId)
291
+ ]);
292
+ return appendGeneralReplyRoutingInstruction(prompt, topics, recentProposals);
460
293
  }
461
294
 
462
- function createTelegramSessionBridge(route) {
463
- const messageOptions = (extra = {}) => route.workspace && route.threadId
464
- ? { ...extra, message_thread_id: route.threadId }
465
- : extra;
466
- const initializeForumTopic = async ({ messageThreadId, name, context }) => {
467
- if (!route.workspace) throw new Error("Telegram topic initialization is only available from the owner workspace forum.");
468
- await createWorkspaceAccessGuard(route)();
469
- const initializedSessionId = topicSessionId({
470
- ownerChatId: route.ownerChatId,
471
- groupChatId: route.transportChatId,
472
- threadId: messageThreadId,
473
- generalTopicId: route.generalTopicId
474
- });
475
- if (initializedSessionId === String(route.ownerChatId)) {
476
- throw new Error("The General topic already uses the owner's private session and cannot be reinitialized here.");
477
- }
478
- const handoff = buildTopicInitializationHandoff({ name, context });
479
- await sessionSeeds.set(initializedSessionId, handoff);
480
- agentManager.resetSession(initializedSessionId, { handoff });
481
- await agentManager.waitForSessionClose(initializedSessionId);
482
- return {
483
- ok: true,
484
- chatId: route.transportChatId,
485
- messageThreadId,
486
- sessionId: initializedSessionId,
487
- name,
488
- initialized: true
489
- };
490
- };
491
- return {
492
- sendMedia: async (filePath, { method = "audio", caption, filename } = {}) => {
493
- logger?.log("telegram", `sending ${method} reply for chat ${route.transportChatId}`);
494
- const input = new InputFile(filePath, filename || undefined);
495
- const options = messageOptions({ caption });
496
- if (method === "voice") return bot.api.sendVoice(route.transportChatId, input, options);
497
- if (method === "document") return bot.api.sendDocument(route.transportChatId, input, options);
498
- if (method === "photo" || method === "image") return bot.api.sendPhoto(route.transportChatId, input, options);
499
- if (method === "video") return bot.api.sendVideo(route.transportChatId, input, options);
500
- return bot.api.sendAudio(route.transportChatId, input, options);
501
- },
502
- createForumTopic: async (name, context) => {
503
- if (!route.workspace) throw new Error("Telegram topic creation is only available from the owner workspace forum.");
504
- await createWorkspaceAccessGuard(route)();
505
- const topic = await bot.api.createForumTopic(route.transportChatId, name);
506
- return initializeForumTopic({
507
- messageThreadId: topic.message_thread_id,
508
- name: topic.name,
509
- context
510
- });
511
- },
512
- initializeForumTopic,
513
- prepareRestartReceipt: (summary) => prepareRestartReceipt({
514
- transportChatId: route.transportChatId,
515
- threadId: route.threadId
516
- }, { reason: String(summary || "Agent-requested restart").trim() }),
517
- cancelRestartReceipt,
518
- getTaskContext: () => route.workspace ? {
519
- transportChatId: route.transportChatId,
520
- messageThreadId: route.topicThreadId
521
- } : null
522
- };
523
- }
524
-
525
- agentManager.setArtifactDeliveryHandler?.(async ({ chatId, artifact, caption, method }) => {
526
- const resolvedMethod = method
527
- || artifact.metadata?.delivery?.method
528
- || (artifact.kind === "audio" || artifact.mimeType?.startsWith("audio/") ? "audio"
529
- : artifact.kind === "image" || artifact.mimeType?.startsWith("image/") ? "photo"
530
- : artifact.kind === "video" || artifact.mimeType?.startsWith("video/") ? "video"
531
- : "document");
532
- const safeCaption = caption && !/(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(caption) ? caption : undefined;
533
- await createTelegramSessionBridge({
534
- workspace: false,
535
- sessionId: String(chatId),
536
- scopeChatId: chatId,
537
- transportChatId: chatId,
538
- threadId: null
539
- }).sendMedia(artifact.path, {
540
- method: resolvedMethod,
541
- caption: safeCaption,
542
- filename: path.basename(artifact.path)
543
- });
544
- return { ok: true, artifactId: artifact.id, method: resolvedMethod };
295
+ const {
296
+ createSessionBridge: createTelegramSessionBridge,
297
+ createWorkspaceAccessGuard,
298
+ installArtifactDeliveryHandler,
299
+ sendTextReply
300
+ } = createTelegramSessionBridgeController({
301
+ config,
302
+ api: bot.api,
303
+ agentManager,
304
+ artifactStore,
305
+ sessionSeeds,
306
+ workspaceTopics,
307
+ getChatState,
308
+ buildTopicInitializationHandoff,
309
+ logger
545
310
  });
311
+ installArtifactDeliveryHandler();
546
312
 
547
- async function processPromptForChat({ chatId, prompt, ctx = null }) {
313
+ async function processPromptForChat({ chatId, prompt, ctx = null, executionReceipt = null }) {
548
314
  const route = ctx ? contextRoute(ctx) : {
549
315
  workspace: false,
550
316
  sessionId: String(chatId),
@@ -575,11 +341,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
575
341
  chatState.activeSession = session;
576
342
  chatState.activeRoute = route;
577
343
  try {
578
- text = await withPromptSpeed({
344
+ text = await withPromptSpeed(scheduledPromptSpeedOptions({
345
+ prompt,
346
+ session,
579
347
  speedController,
580
- speed: isScheduledTaskPrompt(prompt) ? 1 : undefined,
581
- restoreSpeed: () => clampModelSpeed(session.model, resolveChatSpeed(config, sessionId))
582
- }, () => collectText(session, prompt, {
348
+ configuredSpeed: resolveChatSpeed(config, sessionId)
349
+ }), () => collectText(session, prompt, {
583
350
  logger,
584
351
  chatId: sessionId,
585
352
  onSlowPrompt: () => bot.api.sendMessage(
@@ -590,19 +357,40 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
590
357
  }));
591
358
  } catch (error) {
592
359
  agentManager.resetSession(sessionId);
360
+ if (error && typeof error === "object") {
361
+ error.retryable = false;
362
+ error.outcomeUncertain = true;
363
+ }
593
364
  throw error;
594
365
  } finally {
595
366
  if (chatState.activeSession === session) chatState.activeSession = null;
596
367
  chatState.activeRoute = null;
597
368
  }
369
+ executionReceipt?.resolve({ status: "executed" });
598
370
  if (text) {
599
- await createWorkspaceAccessGuard(route)();
371
+ const topics = route.workspace && route.ownerChatId
372
+ ? await workspaceTopics.listTopics(route.ownerChatId, route.transportChatId)
373
+ : [];
374
+ const routedReply = routeGeneralWorkspaceReply({ route, text, topics });
375
+ if (routedReply.proposal) {
376
+ await workspaceTopics.recordProposal(route.ownerChatId, route.transportChatId, routedReply.proposal);
377
+ logger?.log("telegram", `recorded topic proposal ${routedReply.proposal} for workspace ${route.transportChatId}`);
378
+ }
379
+ if (!routedReply.text) return;
380
+ const deliveryRoute = routedReply.route;
381
+ const deliveryOptions = (extra = {}) => deliveryRoute.workspace && deliveryRoute.threadId
382
+ ? { ...extra, message_thread_id: deliveryRoute.threadId }
383
+ : extra;
384
+ await createWorkspaceAccessGuard(deliveryRoute)();
385
+ if (routedReply.topic) {
386
+ logger?.log("telegram", `routing General reply to topic ${routedReply.topic.threadId} (${routedReply.topic.name})`);
387
+ }
600
388
  await sendTextReply({
601
- sendText: (message, extra) => bot.api.sendMessage(route.transportChatId, message, messageOptions(extra)),
602
- sendDocument: (file, extra) => bot.api.sendDocument(route.transportChatId, file, messageOptions(extra)),
389
+ sendText: (message, extra) => bot.api.sendMessage(deliveryRoute.transportChatId, message, deliveryOptions(extra)),
390
+ sendDocument: (file, extra) => bot.api.sendDocument(deliveryRoute.transportChatId, file, deliveryOptions(extra)),
603
391
  chatId: sessionId,
604
- artifactChatId: route.scopeChatId,
605
- text
392
+ artifactChatId: deliveryRoute.scopeChatId,
393
+ text: routedReply.text
606
394
  });
607
395
  }
608
396
  };
@@ -611,8 +399,19 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
611
399
  return work();
612
400
  }
613
401
 
614
- async function enqueuePrompt({ chatId, prompt, label, ctx = null, replaceQueued = false, busyMessageMode = "queue" }) {
402
+ async function enqueuePrompt({
403
+ chatId,
404
+ prompt,
405
+ label,
406
+ ctx = null,
407
+ replaceQueued = false,
408
+ busyMessageMode = "queue",
409
+ waitForExecution = false,
410
+ onExecutionStart = null,
411
+ coalesceQueued = false
412
+ }) {
615
413
  const chatState = getChatState(chatId);
414
+ const receipt = waitForExecution ? createPromptExecutionReceipt(onExecutionStart) : null;
616
415
 
617
416
  if (chatState.processing) {
618
417
  const incomingRoute = ctx ? contextRoute(ctx) : null;
@@ -626,10 +425,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
626
425
  prompt,
627
426
  mode: sameDelivery ? busyMessageMode : "queue",
628
427
  replaceQueued,
629
- ctx
428
+ ctx,
429
+ receipt,
430
+ coalesceQueued
630
431
  });
631
432
  if (routed.disposition === "steered") {
632
433
  logger?.log("telegram", `chat ${chatId} busy, steering ${label}`);
434
+ } else if (routed.disposition === "coalesced") {
435
+ logger?.log("telegram", `chat ${chatId} busy, coalescing ${label} into pending direct turn`);
633
436
  } else {
634
437
  logger?.log("telegram", `chat ${chatId} busy, queueing ${label}`);
635
438
  if (routed.steerError) {
@@ -637,24 +440,28 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
637
440
  }
638
441
  }
639
442
  if (replaceQueued) chatState.continueAfterClose = true;
640
- return;
443
+ return receipt ? receipt.promise : undefined;
641
444
  }
642
445
 
643
446
  chatState.processing = true;
644
447
  logger?.log("telegram", `processing ${label} in chat ${chatId}`);
645
- return processChatPromptQueue({ chatId, prompt, label, ctx });
448
+ const draining = processChatPromptQueue({ chatId, prompt, label, ctx, initialReceipt: receipt });
449
+ if (!receipt) return draining;
450
+ draining.catch(() => {});
451
+ return receipt.promise;
646
452
  }
647
453
 
648
- function processChatPromptQueue({ chatId, prompt, label, ctx = null, beforeInitialPrompt }) {
454
+ function processChatPromptQueue({ chatId, prompt, label, ctx = null, beforeInitialPrompt, initialReceipt = null }) {
649
455
  const chatState = getChatState(chatId);
650
456
  return drainChatPromptQueue({
651
457
  chatState,
652
458
  initialPrompt: prompt,
653
459
  initialCtx: ctx,
460
+ initialReceipt,
654
461
  beforeInitialPrompt,
655
- processPrompt: ({ prompt: currentPrompt, ctx: currentCtx }) => {
462
+ processPrompt: ({ prompt: currentPrompt, ctx: currentCtx, receipt }) => {
656
463
  logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
657
- return processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx });
464
+ return processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx, executionReceipt: receipt });
658
465
  },
659
466
  onPromptInterrupted: (error) => {
660
467
  logger?.log("telegram", `${label} interrupted by queued /new for chat ${chatId}: ${getErrorMessage(error)}`);
@@ -662,7 +469,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
662
469
  onPromptFailure: async (error) => {
663
470
  const message = getErrorMessage(error);
664
471
  logger?.error("telegram", `${label} failed for chat ${chatId}: ${message}`);
665
- await notifyPiAuthIssueIfNeeded(chatId, error);
472
+ await authController.notifyIssueIfNeeded(chatId, error);
666
473
  }
667
474
  });
668
475
  }
@@ -674,14 +481,17 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
674
481
  if (chatState.processing) {
675
482
  await ensureQueuedTelegramTyping(chatState, ctx);
676
483
  const incomingPrompt = await buildIncomingPrompt(ctx, route);
677
- const busyMessageMode = typeof ctx.message?.text === "string"
678
- ? resolveTelegramBusyMessageMode(config, route.sessionId)
679
- : "queue";
484
+ const busyMessageMode = resolveIncomingBusyMessageMode({
485
+ config,
486
+ route,
487
+ message: ctx.message
488
+ });
680
489
  return enqueuePrompt({
681
490
  chatId: route.sessionId,
682
491
  prompt: incomingPrompt,
683
492
  label: `message ${ctx.msg.message_id}`,
684
493
  busyMessageMode,
494
+ coalesceQueued: busyMessageMode === "steer" && typeof ctx.message?.text === "string",
685
495
  ctx
686
496
  });
687
497
  }
@@ -695,6 +505,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
695
505
  });
696
506
  }
697
507
 
508
+ async function migrateConfiguredReplyTopics() {
509
+ const migratedGroups = await migrateLegacyReplyTopics(config, workspaceTopics);
510
+ if (!migratedGroups) return;
511
+ await saveConfig(config);
512
+ logger?.log("telegram", `migrated configured reply topics for ${migratedGroups} workspace group(s)`);
513
+ }
514
+
698
515
  async function sendStartupMessages() {
699
516
  for (const chatId of config.telegram.authorizedChatIds || []) {
700
517
  try {
@@ -726,33 +543,60 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
726
543
  timer.unref?.();
727
544
  }
728
545
 
729
- async function enqueueAsyncPrompt({ chatId, prompt, label, telegramContext }) {
546
+ async function enqueueAsyncPrompt({ chatId, prompt, label, route: taskRoute, timeoutMs }) {
730
547
  let ctx = { chat: { id: chatId }, api: bot.api };
731
- if (telegramContext?.transportChatId && telegramContext?.messageThreadId) {
548
+ const destination = taskRoute?.transport === "telegram" ? taskRoute.destination : null;
549
+ if (destination?.chatId && destination?.threadId) {
732
550
  ctx = {
733
- chat: { id: telegramContext.transportChatId, type: "supergroup", is_forum: true },
551
+ chat: { id: destination.chatId, type: "supergroup", is_forum: true },
734
552
  from: { id: chatId },
735
- message: { message_thread_id: telegramContext.messageThreadId },
553
+ message: { message_thread_id: destination.threadId },
736
554
  api: bot.api
737
555
  };
738
556
  const route = await resolveTelegramWorkspaceRoute({ config, api: bot.api, ctx });
739
557
  if (!route.ok) throw new Error("Scheduled owner-workspace destination is unavailable.");
740
- workspaceRoutes.set(ctx, route);
558
+ registerRoute(ctx, route);
741
559
  }
742
560
  const route = contextRoute(ctx);
743
561
  const chatState = getChatState(route.sessionId);
744
562
  if (chatState.processing) await ensureQueuedTelegramTyping(chatState, ctx);
745
- return enqueuePrompt({ chatId: route.sessionId, prompt, label, ctx });
563
+ let timer = null;
564
+ const execution = enqueuePrompt({
565
+ chatId: route.sessionId,
566
+ prompt,
567
+ label,
568
+ ctx,
569
+ waitForExecution: true,
570
+ onExecutionStart: ({ reject }) => {
571
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return;
572
+ timer = setTimeout(() => {
573
+ const error = new Error(`${label} exceeded its ${timeoutMs}ms execution deadline`);
574
+ error.retryable = false;
575
+ error.outcomeUncertain = true;
576
+ reject(error);
577
+ agentManager.abortSession(route.sessionId).catch((abortError) => {
578
+ logger?.error("tasks", `${label} abort failed: ${getErrorMessage(abortError)}`);
579
+ });
580
+ }, timeoutMs);
581
+ timer.unref?.();
582
+ }
583
+ });
584
+ execution.then(
585
+ () => { if (timer) clearTimeout(timer); },
586
+ () => { if (timer) clearTimeout(timer); }
587
+ );
588
+ return execution;
746
589
  }
747
590
 
748
591
  const { dispatchDueTasks } = createTelegramTaskDispatcher({
749
592
  taskStore,
750
- sendMessage: (chatId, text) => bot.api.sendMessage(chatId, text),
593
+ sendMessage: (chatId, text, options) => bot.api.sendMessage(chatId, text, options),
751
594
  enqueueAsyncPrompt,
752
595
  artifactStore,
753
596
  toolRegistry,
754
597
  resourceNotes,
755
598
  agentManager,
599
+ taskTimeouts: config.tasks,
756
600
  logger
757
601
  });
758
602
 
@@ -840,13 +684,8 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
840
684
  bot.command("new", async (ctx) => {
841
685
  const auth = await authorizeContext(ctx);
842
686
  if (!auth.ok) return;
843
- if (piAuthIssue) {
844
- await ctx.reply(buildPiAuthRecoveryBlockedMessage({
845
- config,
846
- chatId: ctx.chat.id,
847
- issue: piAuthIssue,
848
- renewalActive: authRenewals.has(chatKey(ctx.chat.id))
849
- }));
687
+ if (authController.getIssue()) {
688
+ await ctx.reply(authController.buildBlockedMessage(ctx.chat.id));
850
689
  return;
851
690
  }
852
691
  await handleNewCommand(ctx);
@@ -890,11 +729,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
890
729
  }
891
730
  });
892
731
 
893
- bot.command("tools", async (ctx) => {
894
- const auth = await authorizeContext(ctx);
895
- if (!auth.ok) return;
896
- await ctx.reply(renderTelegramHtml(formatToolUsageReport(await toolRegistry.usage(contextRoute(ctx).scopeChatId))), { parse_mode: "HTML" });
897
- });
732
+ bot.command("tools", createTelegramToolsCommandHandler({
733
+ authorize: authorizeContext,
734
+ contextRoute,
735
+ toolRegistry,
736
+ withTyping,
737
+ logger
738
+ }));
898
739
 
899
740
  bot.command("model", async (ctx) => {
900
741
  const auth = await authorizeContext(ctx);
@@ -914,38 +755,10 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
914
755
  await showSpeedPicker(ctx);
915
756
  });
916
757
 
917
- bot.command("auth", async (ctx) => {
918
- const auth = await authorizeContext(ctx);
919
- if (!auth.ok) return;
920
-
921
- const status = getPiAuthStatus(config, ctx.chat.id);
922
- if (status.hasApiKey || !status.supportsOAuth) {
923
- await withTyping(ctx, async () => {
924
- try {
925
- await agentManager.validateAgent();
926
- agentManager.clearSessionCache(ctx.chat.id);
927
- piAuthIssue = null;
928
- await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
929
- } catch (error) {
930
- const issue = rememberPiAuthIssue(error) || { kind: "validation-failed", message: getErrorMessage(error) };
931
- piAuthIssue = issue;
932
- await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue }));
933
- }
934
- });
935
- return;
936
- }
937
-
938
- try {
939
- const { started } = await startAuthRenewal(ctx.chat.id);
940
- await ctx.reply(started
941
- ? "Starting Pi login from Telegram..."
942
- : "Pi login is already in progress. Paste the redirect URL or code here when you have it.");
943
- } catch (error) {
944
- const issue = rememberPiAuthIssue(error) || { kind: "validation-failed", message: getErrorMessage(error) };
945
- piAuthIssue = issue;
946
- await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue }));
947
- }
948
- });
758
+ bot.command("auth", (ctx) => authController.handleCommand(ctx, {
759
+ authorize: authorizeContext,
760
+ withTyping
761
+ }));
949
762
 
950
763
  const handleModelCallback = createTelegramModelCallbackHandler({
951
764
  config,
@@ -970,7 +783,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
970
783
  const reaction = ctx.messageReaction;
971
784
  const chatId = reaction.chat.id;
972
785
  const auth = await authorizeChat({ config, chatId, saveConfig });
973
- if (!auth.ok || piAuthIssue) return;
786
+ if (!auth.ok || authController.getIssue()) return;
974
787
 
975
788
  const reactedMessageText = getChatState(chatId).assistantMessages.get(reaction.message_id) || "";
976
789
  const prompt = buildReactionPrompt({ reaction, reactedMessageText });
@@ -988,20 +801,18 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
988
801
  bot.on("message", async (ctx) => {
989
802
  const auth = await authorizeContext(ctx);
990
803
  if (!auth.ok) return;
804
+ await workspaceTopics.observeMessage(contextRoute(ctx), ctx.message).catch((error) => {
805
+ logger?.error("telegram", `workspace topic observation failed: ${getErrorMessage(error)}`);
806
+ });
991
807
  if (!isProcessableTelegramMessage(ctx.message)) return;
992
808
 
993
809
  const command = getTelegramCommand(ctx);
994
810
  if (command) return;
995
811
 
996
- if (await submitAuthRenewalInput(ctx)) return;
812
+ if (await authController.submitRenewalInput(ctx)) return;
997
813
 
998
- if (piAuthIssue) {
999
- await ctx.reply(buildPiAuthRecoveryBlockedMessage({
1000
- config,
1001
- chatId: ctx.chat.id,
1002
- issue: piAuthIssue,
1003
- renewalActive: authRenewals.has(chatKey(ctx.chat.id))
1004
- }));
814
+ if (authController.getIssue()) {
815
+ await ctx.reply(authController.buildBlockedMessage(ctx.chat.id));
1005
816
  return;
1006
817
  }
1007
818
 
@@ -1021,6 +832,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1021
832
  return {
1022
833
  async start({ skipAgentStartupPrompts = false } = {}) {
1023
834
  config.telegram.chatMeta ||= {};
835
+ await migrateConfiguredReplyTopics();
1024
836
  await bot.api.setMyCommands(telegramCommands);
1025
837
  if (!taskTimer) {
1026
838
  taskTimer = setInterval(() => {
@@ -1047,7 +859,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1047
859
  async notifyPiAuthIssue(error) {
1048
860
  let notified = false;
1049
861
  for (const chatId of config.telegram.authorizedChatIds || []) {
1050
- notified = await notifyPiAuthIssueIfNeeded(chatId, error) || notified;
862
+ notified = await authController.notifyIssueIfNeeded(chatId, error) || notified;
1051
863
  }
1052
864
  return notified;
1053
865
  }