arisa 5.1.13 → 5.1.49

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 (49) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +2 -0
  3. package/package.json +8 -10
  4. package/src/core/agent/agent-manager.js +132 -70
  5. package/src/core/agent/pi-runtime.js +0 -8
  6. package/src/core/agent/system-shell-tool.js +13 -2
  7. package/src/core/artifacts/artifact-store.js +17 -18
  8. package/src/core/config/config-defaults.js +2 -2
  9. package/src/core/conversation/session-seed-store.js +85 -0
  10. package/src/core/tools/ipc-client.js +0 -2
  11. package/src/core/tools/official-tool-installer.js +78 -6
  12. package/src/core/tools/tool-dependencies.js +99 -0
  13. package/src/core/tools/tool-output-materializer.js +41 -0
  14. package/src/core/tools/tool-registry.js +145 -28
  15. package/src/official-tools.lock.json +209 -5
  16. package/src/runtime/arisa-capabilities.js +12 -1
  17. package/src/runtime/create-app.js +1 -0
  18. package/src/runtime/doctor.js +72 -23
  19. package/src/runtime/headless-tool-executor.js +2 -32
  20. package/src/runtime/paths.js +7 -1
  21. package/src/runtime/restart-receipt.js +90 -0
  22. package/src/runtime/tool-usage-report.js +25 -10
  23. package/src/transport/telegram/bot.js +403 -1015
  24. package/src/transport/telegram/chat-queue.js +132 -0
  25. package/src/transport/telegram/media.js +2 -2
  26. package/src/transport/telegram/model-callback.js +211 -0
  27. package/src/transport/telegram/model-controls.js +164 -0
  28. package/src/transport/telegram/prompt-builders.js +372 -0
  29. package/src/transport/telegram/task-dispatcher.js +94 -0
  30. package/src/transport/telegram/update-command.js +1 -1
  31. package/src/transport/telegram/workspace-group.js +83 -0
  32. package/test/agent-tool-policy.test.js +7 -1
  33. package/test/capabilities-security.test.js +21 -0
  34. package/test/context-and-task-bounds.test.js +33 -5
  35. package/test/doctor.test.js +57 -4
  36. package/test/model-selection.test.js +47 -1
  37. package/test/official-tool-dependencies.test.js +25 -0
  38. package/test/official-tool-installer.test.js +37 -9
  39. package/test/paths.test.js +4 -4
  40. package/test/restart-receipt.test.js +39 -0
  41. package/test/session-start-operational-notes.test.js +47 -0
  42. package/test/telegram-prompt-builders.test.js +33 -0
  43. package/test/telegram-task-dispatcher.test.js +102 -0
  44. package/test/telegram-workspace-group.test.js +76 -0
  45. package/test/tool-dependencies.test.js +53 -0
  46. package/test/tool-registry-run.test.js +81 -1
  47. package/test/tool-usage.test.js +26 -4
  48. package/test/topic-initialization.test.js +66 -0
  49. package/src/core/conversation/conversation-history-store.js +0 -142
@@ -1,25 +1,86 @@
1
1
  import { Bot, InputFile } from "grammy";
2
2
  import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
- import { captureIncomingArtifact, formatLocationText } from "./media.js";
4
+ import { captureIncomingArtifact } from "./media.js";
5
5
  import { buildDeviceCodeTelegramMessage } from "./device-code-message.js";
6
- import { buildEffortPicker, buildModelPicker, buildSpeedPicker, parseEffortPickerAction, parseModelPickerAction, parseSpeedPickerAction, reverseModelOrder } from "./model-picker.js";
7
6
  import { renderTelegramHtml } from "./text-format.js";
8
7
  import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
9
8
  import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
10
- import { getAgentConfig, resolveChatModel, resolveChatSpeed, resolveChatThinkingLevel, selectChatModel, selectChatSpeed, selectChatThinkingLevel } from "../../core/agent/model-selection.js";
11
- import { clampModelThinkingLevel, createPiRuntime, listModelThinkingLevels, listProviderModels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
12
- import { clampModelSpeed, MODEL_SPEEDS, modelSupportsSpeed } from "../../core/agent/model-speed.js";
13
- import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
14
- import { formatPortableSessionHistory } from "../../core/agent/agent-manager.js";
15
- import { ConversationHistoryStore } from "../../core/conversation/conversation-history-store.js";
9
+ import { resolveChatSpeed } from "../../core/agent/model-selection.js";
10
+ import { SessionSeedStore } from "../../core/conversation/session-seed-store.js";
16
11
  import { formatDoctorReport } from "../../runtime/doctor.js";
17
12
  import { formatToolUsageReport } from "../../runtime/tool-usage-report.js";
18
13
  import { ToolResourceNoteStore } from "../../core/tools/tool-resource-note-store.js";
19
14
  import { formatUpdateReport } from "../../runtime/update-manager.js";
15
+ import { cancelRestartReceipt, deliverRestartReceipt, prepareRestartReceipt } from "../../runtime/restart-receipt.js";
20
16
  import { buildUpdatePicker, createTelegramUpdateCallbackHandler } from "./update-command.js";
17
+ import { createTelegramModelControls } from "./model-controls.js";
18
+ import { createTelegramModelCallbackHandler } from "./model-callback.js";
19
+ import { createTelegramTaskDispatcher } from "./task-dispatcher.js";
20
+ import { resolveTelegramWorkspaceRoute, topicSessionId } from "./workspace-group.js";
21
+ import {
22
+ buildNewSessionPrompt,
23
+ buildPrompt,
24
+ buildReactionPrompt,
25
+ buildSessionHandoffPrompt,
26
+ buildStartupMessage,
27
+ collectText,
28
+ getIncomingMessageText,
29
+ isScheduledTaskPrompt,
30
+ isSilentReply,
31
+ normalizeIncomingArtifact,
32
+ sanitizeSessionHandoff,
33
+ shouldIncludeArtifactReference,
34
+ withPromptSpeed
35
+ } from "./prompt-builders.js";
36
+ import {
37
+ createChatStateStore,
38
+ drainChatPromptQueue,
39
+ queueChatPrompt,
40
+ resolveTelegramBusyMessageMode,
41
+ routeBusyPrompt
42
+ } from "./chat-queue.js";
43
+
44
+ export {
45
+ createChatStateStore,
46
+ drainChatPromptQueue,
47
+ queueChatPrompt,
48
+ resolveTelegramBusyMessageMode,
49
+ routeBusyPrompt
50
+ } from "./chat-queue.js";
51
+
52
+ export {
53
+ buildAsyncTaskPrompt,
54
+ buildPrompt,
55
+ buildReactionPrompt,
56
+ collectText,
57
+ isScheduledTaskPrompt,
58
+ isSilentReply,
59
+ shouldIncludeArtifactReference,
60
+ withPromptSpeed
61
+ } from "./prompt-builders.js";
62
+
63
+ export function isProcessableTelegramMessage(message = {}) {
64
+ return Boolean(
65
+ String(message.text || "").trim()
66
+ || message.voice
67
+ || message.audio
68
+ || message.video
69
+ || message.document
70
+ || message.photo?.length
71
+ || message.location
72
+ || message.venue
73
+ );
74
+ }
21
75
 
22
- const slowPromptNoticeMs = 300_000;
76
+ export function buildTopicInitializationHandoff({ name, context }) {
77
+ return [
78
+ `Telegram topic: ${String(name || "").trim()}`,
79
+ "This topic has an isolated conversation session.",
80
+ "Use the following as background context. Do not repeat it unless the user asks.",
81
+ String(context || "").trim()
82
+ ].filter(Boolean).join("\n\n");
83
+ }
23
84
 
24
85
  export const telegramCommands = Object.freeze([
25
86
  { command: "new", description: "New chat context" },
@@ -51,7 +112,7 @@ export function createTelegramRestartHandler({ authorize, requestRestart, logger
51
112
  restartRequested = true;
52
113
  try {
53
114
  await ctx.reply("Arisa is restarting. I'll be back shortly.");
54
- const handoff = await requestRestart();
115
+ const handoff = await requestRestart(ctx);
55
116
  logger?.log("telegram", `restart handed off to process ${handoff.pid}`);
56
117
  } catch (error) {
57
118
  restartRequested = false;
@@ -61,35 +122,6 @@ export function createTelegramRestartHandler({ authorize, requestRestart, logger
61
122
  };
62
123
  }
63
124
 
64
- function quotedMessageSummary(message) {
65
- if (!message) return [];
66
-
67
- const fromName = message.from?.username
68
- ? `@${message.from.username}`
69
- : [message.from?.first_name, message.from?.last_name].filter(Boolean).join(" ") || "unknown";
70
-
71
- const parts = [
72
- `quotedMessageId: ${message.message_id}`,
73
- `quotedFrom: ${fromName}`
74
- ];
75
-
76
- if (message.text) parts.push(`quotedText: ${message.text}`);
77
- if (message.caption) parts.push(`quotedCaption: ${message.caption}`);
78
- if (message.voice) parts.push(`quotedKind: voice`);
79
- if (message.audio) parts.push(`quotedKind: audio`);
80
- if (message.photo?.length) parts.push(`quotedKind: image`);
81
- if (message.document) parts.push(`quotedKind: document`);
82
- if (message.video) parts.push(`quotedKind: video`);
83
- if (message.sticker) parts.push(`quotedKind: sticker`);
84
- if (message.location) parts.push(`quotedKind: location`, `quotedLocation: ${formatLocationText(message)}`);
85
-
86
- if (!message.text && !message.caption) {
87
- parts.push(`Important: this message replies to a Telegram message with no textual body available in the update. Use the quoted kind and metadata as context.`);
88
- }
89
-
90
- return parts;
91
- }
92
-
93
125
  function getTelegramCommand(ctx) {
94
126
  const text = ctx.message?.text || "";
95
127
  const entity = ctx.message?.entities?.[0];
@@ -97,513 +129,73 @@ function getTelegramCommand(ctx) {
97
129
  return text.slice(1, entity.length).split("@")[0].trim().toLowerCase();
98
130
  }
99
131
 
100
- function getIncomingMessageText(message) {
101
- return message?.text || message?.caption || formatLocationText(message) || "";
102
- }
103
-
104
- function telegramDisplayName(entity = {}) {
105
- if (entity.username) return `@${entity.username}`;
106
- return [entity.first_name, entity.last_name].filter(Boolean).join(" ") || entity.title || "unknown";
107
- }
108
-
109
- function forwardedMessageSummary(message) {
110
- const origin = message?.forward_origin;
111
- if (!origin) return [];
112
-
113
- const parts = ["forwarded: true", `forwardedOriginType: ${origin.type}`];
114
- if (origin.type === "user") parts.push(`forwardedFrom: ${telegramDisplayName(origin.sender_user)}`);
115
- if (origin.type === "hidden_user") parts.push(`forwardedFrom: ${origin.sender_user_name}`);
116
- if (origin.type === "chat" || origin.type === "channel") {
117
- parts.push(`forwardedFrom: ${telegramDisplayName(origin.chat)}`);
118
- }
119
- if (origin.type === "channel" && origin.message_id) parts.push(`forwardedMessageId: ${origin.message_id}`);
120
- if (origin.author_signature) parts.push(`forwardedAuthorSignature: ${origin.author_signature}`);
121
- if (origin.date) parts.push(`forwardedAt: ${new Date(origin.date * 1000).toISOString()}`);
122
- return parts;
123
- }
124
-
125
- function reactionLabel(reaction = {}) {
126
- if (reaction.type === "emoji") return reaction.emoji || "emoji";
127
- if (reaction.type === "custom_emoji") return `custom:${reaction.custom_emoji_id || "unknown"}`;
128
- if (reaction.type === "paid") return "paid";
129
- return reaction.type || "unknown";
130
- }
131
-
132
- function reactionDifference(left = [], right = []) {
133
- const remaining = right.map(reactionLabel);
134
- return left.map(reactionLabel).filter((label) => {
135
- const index = remaining.indexOf(label);
136
- if (index < 0) return true;
137
- remaining.splice(index, 1);
138
- return false;
139
- });
140
- }
141
-
142
- export function buildReactionPrompt({ reaction, reactedMessageText = "" }) {
143
- const oldReactions = reaction.old_reaction || [];
144
- const newReactions = reaction.new_reaction || [];
145
- const added = reactionDifference(newReactions, oldReactions);
146
- const removed = reactionDifference(oldReactions, newReactions);
147
- const actor = reaction.user || reaction.actor_chat || {};
148
- const actorId = reaction.user?.id || reaction.actor_chat?.id || "unknown";
149
-
150
- return [
151
- "Incoming Telegram reaction.",
152
- `chatId: ${reaction.chat.id}`,
153
- `userId: ${actorId}`,
154
- `username: ${reaction.user?.username || "(no username)"}`,
155
- `reactedMessageId: ${reaction.message_id}`,
156
- reactedMessageText ? `reactedMessageText: ${reactedMessageText}` : null,
157
- added.length ? `addedReactions: ${added.join(" ")}` : null,
158
- removed.length ? `removedReactions: ${removed.join(" ")}` : null,
159
- `currentReactions: ${newReactions.map(reactionLabel).join(" ") || "none"}`,
160
- `actor: ${telegramDisplayName(actor)}`,
161
- "Treat this as lightweight feedback on the referenced message. Respond only if the reaction clearly requests action; otherwise stay silent."
162
- ].filter(Boolean).join("\n");
163
- }
164
-
165
- function baseMimeType(mimeType = "") {
166
- return mimeType.split(";")[0].trim().toLowerCase();
167
- }
168
-
169
- function isInlineTextArtifact(artifact, messageText) {
170
- return artifact?.kind === "text"
171
- && baseMimeType(artifact.mimeType) === "text/plain"
172
- && typeof artifact.text === "string"
173
- && artifact.text === messageText;
174
- }
175
-
176
- export function shouldIncludeArtifactReference({ artifact, messageText = "" } = {}) {
177
- if (!artifact) return false;
178
- return !isInlineTextArtifact(artifact, messageText);
179
- }
180
-
181
- export function buildPrompt({ ctx, artifact, transcript, toolResult }) {
182
- const parts = [
183
- `Incoming Telegram message.`,
184
- `chatId: ${ctx.chat.id}`,
185
- `userId: ${ctx.from.id}`,
186
- `username: ${ctx.from.username || "(no username)"}`,
187
- `messageId: ${ctx.msg.message_id}`
188
- ];
189
-
190
- const messageText = getIncomingMessageText(ctx.message);
191
- if (messageText) parts.push(`text: ${messageText}`);
192
- parts.push(...forwardedMessageSummary(ctx.message));
193
- parts.push(...quotedMessageSummary(ctx.message?.reply_to_message));
194
- if (shouldIncludeArtifactReference({ artifact, messageText })) {
195
- if (artifact?.path) parts.push(`artifactPath: ${artifact.path}`);
196
- if (artifact?.id) parts.push(`artifactId: ${artifact.id}`);
197
- if (artifact?.mimeType) parts.push(`mimeType: ${artifact.mimeType}`);
198
- if (artifact?.kind) parts.push(`kind: ${artifact.kind}`);
199
- }
200
- if (transcript) {
201
- parts.push(`transcriptArtifactId: ${transcript.id}`);
202
- parts.push(`transcriptText: ${transcript.text}`);
203
- parts.push(`Important: the incoming media has already been transcribed. Use the transcript as the user message content. Do not answer with a raw transcription unless the user explicitly asked for one.`);
204
- }
205
- if (shouldNormalizeArtifactToText(artifact) && !transcript && toolResult) {
206
- parts.push(`mediaNormalizationResult: ${JSON.stringify(toolResult)}`);
207
- parts.push(`Important: pre-reasoning media normalization could not be completed, so you do not have a transcript for this audio/video message.`);
208
- }
209
-
210
- parts.push(`Use read/write/edit for file work in the active workspace, bash for bash-compatible commands, and system_shell for native system commands such as PowerShell on Windows.`);
211
- parts.push(`If you need an Arisa modular CLI tool, use list_tools/tool_help/run_tool.`);
212
- parts.push(`If a tool config is missing, ask the user naturally and then use set_tool_config.`);
213
- parts.push(`To deliver a file to the chat: run_tool with deliver:true to generate and send in one step, or send_artifact with an existing artifactId (e.g. an inbound file).`);
214
- return parts.join("\n");
215
- }
216
-
217
- function buildNewSessionPrompt(ctx) {
218
- return [
219
- "System event: /new requested.",
220
- "Session was reset.",
221
- `preferredTelegramLanguageCode: ${ctx.from?.language_code || "unknown"}`,
222
- "Reply with a brief, warm confirmation in the user's language."
223
- ].join("\n");
224
- }
225
-
226
- export function isScheduledTaskPrompt(prompt) {
227
- return String(prompt || "").startsWith("Scheduled task fired.\n");
228
- }
229
-
230
- export async function withPromptSpeed({ speedController, speed, restoreSpeed }, work) {
231
- if (!speedController || speed === undefined) return work();
232
- speedController.setSpeed(speed);
233
- try {
234
- return await work();
235
- } finally {
236
- speedController.setSpeed(restoreSpeed());
237
- }
238
- }
239
-
240
- export async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }) {
241
- const taskText = task.payload.prompt || "";
242
- const resourceId = String(task.source?.resourceId || task.payload?.resourceId || "").trim();
243
- const resourceNote = resourceId && task.source?.toolName
244
- ? await resourceNotes.get(task.payload.chatId, task.source.toolName, resourceId)
245
- : "";
246
- const parts = [
247
- "Scheduled task fired.",
248
- `taskId: ${task.id}`,
249
- `chatId: ${task.payload.chatId}`,
250
- resourceNote ? `resourceNote: ${resourceNote}` : null,
251
- taskText ? `text: ${taskText}` : null
252
- ];
253
-
254
- if (task.payload.artifactId) {
255
- const chatArtifactStore = artifactStore.forChat(task.payload.chatId);
256
- const artifact = await chatArtifactStore.get(task.payload.artifactId);
257
- if (artifact) {
258
- if (shouldIncludeArtifactReference({ artifact, messageText: taskText })) {
259
- parts.push(`artifactPath: ${artifact.path || ""}`);
260
- parts.push(`artifactId: ${artifact.id}`);
261
- parts.push(`mimeType: ${artifact.mimeType}`);
262
- parts.push(`kind: ${artifact.kind}`);
263
- }
264
-
265
- const { normalizedArtifact, toolResult } = await normalizeArtifactForReasoning({
266
- artifact,
267
- desiredMimeType: "text/plain",
268
- toolRegistry,
269
- chatArtifactStore,
270
- chatId: task.payload.chatId
271
- });
272
-
273
- if (normalizedArtifact) {
274
- logger?.log("tasks", `artifact ${artifact.id} normalized to ${normalizedArtifact.id}`);
275
- parts.push(`transcriptArtifactId: ${normalizedArtifact.id}`);
276
- parts.push(`transcriptText: ${normalizedArtifact.text}`);
277
- parts.push("Important: the attached media artifact has already been normalized for reasoning. Use the transcript as the message content.");
278
- } else if (shouldNormalizeArtifactToText(artifact) && toolResult) {
279
- parts.push(`mediaNormalizationResult: ${JSON.stringify(toolResult)}`);
280
- parts.push("Important: pre-reasoning media normalization could not be completed, so you do not have a transcript for this audio/video artifact.");
281
- }
282
- } else {
283
- parts.push(`artifactId: ${task.payload.artifactId}`);
284
- parts.push("Important: referenced artifact was not found.");
285
- }
286
- }
287
-
288
- parts.push("Treat this as a new request for the chat and fulfill it now.");
289
- parts.push("If needed, use read/write/edit, bash, system_shell, or Arisa modular tools via run_tool.");
290
- return parts.filter(Boolean).join("\n");
291
- }
292
-
293
- async function buildAsyncEventPrompt(task, resourceNotes) {
294
- const resourceId = String(task.source?.resourceId || task.payload?.resourceId || "").trim();
295
- const resourceNote = resourceId && task.source?.toolName
296
- ? await resourceNotes.get(task.payload.chatId, task.source.toolName, resourceId)
297
- : "";
298
- return [
299
- "External event arrived.",
300
- `taskId: ${task.id}`,
301
- `chatId: ${task.payload.chatId}`,
302
- resourceNote ? `resourceNote: ${resourceNote}` : null,
303
- task.payload.prompt ? `event: ${task.payload.prompt}` : null,
304
- "A polling checker detected this external event. Evaluate it and decide the next action.",
305
- "If it warrants no action, you may stay silent.",
306
- "If needed, use read/write/edit, bash, system_shell, or Arisa modular tools via run_tool."
307
- ].filter(Boolean).join("\n");
308
- }
309
-
310
- async function normalizeIncomingArtifact({ artifact, toolRegistry, chatArtifactStore, chatId }) {
311
- if (!artifact) return { transcript: null, toolResult: null };
312
- const { normalizedArtifact, toolResult } = await normalizeArtifactForReasoning({
313
- artifact,
314
- desiredMimeType: "text/plain",
315
- toolRegistry,
316
- chatArtifactStore,
317
- chatId
318
- });
319
- return { transcript: normalizedArtifact, toolResult };
320
- }
321
-
322
- function sessionEventLogMessage(event) {
323
- if (event.type === "tool_execution_start") {
324
- return `tool ${event.toolName} started`;
325
- }
326
- if (event.type === "tool_execution_end") {
327
- return `tool ${event.toolName} ${event.isError ? "failed" : "finished"}`;
328
- }
329
- if (event.type === "auto_retry_start") {
330
- return `auto retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms: ${event.errorMessage}`;
331
- }
332
- if (event.type === "auto_retry_end") {
333
- return event.success
334
- ? `auto retry succeeded after ${event.attempt} attempt(s)`
335
- : `auto retry failed after ${event.attempt} attempt(s): ${event.finalError || "unknown error"}`;
336
- }
337
- if (event.type === "compaction_start") {
338
- return `compaction started (${event.reason})`;
339
- }
340
- if (event.type === "compaction_end") {
341
- return `compaction ${event.aborted ? "aborted" : "finished"} (${event.reason})`;
342
- }
343
- if (event.type === "message_end" && event.message?.stopReason === "error") {
344
- return `assistant message ended with error: ${event.message.errorMessage || "unknown error"}`;
345
- }
346
- return "";
347
- }
348
-
349
- function buildStartupMessage(chatMeta = {}) {
350
- const languageCode = String(chatMeta.languageCode || "").toLowerCase();
351
- if (languageCode.startsWith("es")) return "Arisa esta en linea de nuevo.";
352
- if (languageCode.startsWith("pt")) return "Arisa esta online de novo.";
353
- return "Arisa is back online.";
354
- }
355
-
356
- export async function collectText(session, prompt, { logger, chatId, onSlowPrompt } = {}) {
357
- const assistantMessages = [];
358
- let assistantMessage = "";
359
- let assistantErrorMessage = "";
360
- let slowPromptTimer = null;
361
- const finishAssistantMessage = () => {
362
- if (assistantMessage && !isSilentReply(assistantMessage)) {
363
- assistantMessages.push(assistantMessage);
364
- }
365
- assistantMessage = "";
366
- };
367
- const unsubscribe = session.subscribe((event) => {
368
- if (event.arisaPromptScoped === false) return;
369
- if (event.type === "message_start" && event.message.role === "assistant") {
370
- finishAssistantMessage();
371
- }
372
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
373
- assistantMessage += event.assistantMessageEvent.delta;
374
- }
375
- if (event.type === "message_end" && event.message?.role === "assistant") {
376
- if (event.message.stopReason === "error") {
377
- assistantErrorMessage = event.message.errorMessage || "assistant message ended with error";
378
- } else if (event.message.stopReason !== "aborted") {
379
- // Auto-compaction and retry can emit a transient error before a successful continuation.
380
- assistantErrorMessage = "";
381
- }
382
- finishAssistantMessage();
383
- }
384
- const logMessage = sessionEventLogMessage(event);
385
- if (logMessage) logger?.log("agent", `chat ${chatId} ${logMessage}`);
386
- });
387
-
388
- if (onSlowPrompt) {
389
- slowPromptTimer = setTimeout(() => {
390
- logger?.log("telegram", `prompt for chat ${chatId} is still running after ${slowPromptNoticeMs}ms`);
391
- onSlowPrompt().catch((error) => {
392
- logger?.error("telegram", `slow prompt notice failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
393
- });
394
- }, slowPromptNoticeMs);
395
- }
396
-
397
- try {
398
- await session.prompt(prompt);
399
- } finally {
400
- if (slowPromptTimer) clearTimeout(slowPromptTimer);
401
- unsubscribe();
402
- }
403
-
404
- if (assistantErrorMessage) {
405
- throw new Error(assistantErrorMessage);
406
- }
407
-
408
- finishAssistantMessage();
409
- return assistantMessages.join("\n\n").trim();
410
- }
411
-
412
- export function isSilentReply(text) {
413
- return /^(?:NO_REPLY|No reply needed\.|No action needed\.)(?:\s+(?:NO_REPLY|No reply needed\.|No action needed\.))*$/.test(String(text || "").trim());
414
- }
415
-
416
- function buildSessionHandoffPrompt() {
417
- return [
418
- "Prepare a concise handoff for the next Arisa session.",
419
- "Review the entire active session, including any previous compaction summaries and the latest messages.",
420
- "Keep only durable context: current goals or projects, decisions, user preferences, unresolved tasks, and important facts needed to continue.",
421
- "Use at most 8 short bullets and at most 1600 characters.",
422
- "Exclude secrets, tokens, passwords, cookies, API keys, private file paths, full transcripts, and stale chatter.",
423
- "Do not take actions, call tools, send messages, or explain the process.",
424
- "Return only the handoff."
425
- ].join("\n");
426
- }
427
-
428
- function sanitizeSessionHandoff(text) {
429
- const sanitized = String(text || "")
430
- .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, "[redacted private key]")
431
- .replace(/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[opsu]_[A-Za-z0-9_-]{12,}|Bearer\s+[A-Za-z0-9._-]+)\b/gi, "[redacted credential]")
432
- .replace(/(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|client[_ -]?secret|password|cookie|secret)\s*[:=]\s*[^\s,;]+/gi, "[redacted credential]")
433
- .trim();
434
- if (sanitized.length <= 4000) return sanitized;
435
- return `${sanitized.slice(0, 3997).trim()}...`;
436
- }
437
-
438
- async function withTyping(ctx, work) {
439
- await ctx.api.sendChatAction(ctx.chat.id, "typing");
132
+ export async function startTelegramTyping(ctx) {
133
+ const options = ctx.message?.message_thread_id
134
+ ? { message_thread_id: ctx.message.message_thread_id }
135
+ : undefined;
136
+ await ctx.api.sendChatAction(ctx.chat.id, "typing", options).catch(() => {});
440
137
  const timer = setInterval(() => {
441
- ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => {});
138
+ ctx.api.sendChatAction(ctx.chat.id, "typing", options).catch(() => {});
442
139
  }, 4000);
443
-
444
- try {
445
- return await work();
446
- } finally {
447
- clearInterval(timer);
448
- }
449
- }
450
-
451
- export function createChatStateStore() {
452
- const states = new Map();
453
-
454
- function reset(chatId) {
455
- const state = {
456
- processing: false,
457
- pendingPrompts: [],
458
- continueAfterClose: false,
459
- historyRevision: 0,
460
- beforeNextPrompt: null,
461
- activeSession: null,
462
- activeSteers: [],
463
- assistantMessages: new Map()
464
- };
465
- states.set(String(chatId), state);
466
- return state;
467
- }
468
-
469
- return {
470
- get(chatId) {
471
- const key = String(chatId);
472
- return states.get(key) || reset(key);
473
- },
474
- reset,
475
- anyProcessing() {
476
- return [...states.values()].some((state) => state.processing);
477
- }
478
- };
479
- }
480
-
481
- export function queueChatPrompt(chatState, prompt, { replace = false } = {}) {
482
- if (replace) chatState.pendingPrompts = [];
483
- chatState.pendingPrompts.push(prompt);
140
+ return () => clearInterval(timer);
484
141
  }
485
142
 
486
- function takeQueuedPrompt(chatState) {
487
- return chatState.pendingPrompts.shift() || "";
143
+ export async function ensureQueuedTelegramTyping(chatState, ctx) {
144
+ if (chatState.stopQueuedTyping) return;
145
+ chatState.stopQueuedTyping = await startTelegramTyping(ctx);
488
146
  }
489
147
 
490
- export function resolveTelegramBusyMessageMode(config, chatId) {
491
- const chatMode = config.telegram?.chatMeta?.[String(chatId)]?.busyMessageMode;
492
- const mode = chatMode || config.telegram?.busyMessageMode;
493
- return mode === "steer" ? "steer" : "queue";
148
+ export function stopQueuedTelegramTyping(chatState) {
149
+ chatState.stopQueuedTyping?.();
150
+ chatState.stopQueuedTyping = null;
494
151
  }
495
152
 
496
- export async function routeBusyPrompt({ chatState, prompt, mode = "queue", replaceQueued = false }) {
497
- const session = chatState.activeSession;
498
- if (
499
- mode === "steer"
500
- && !replaceQueued
501
- && !chatState.continueAfterClose
502
- && !chatState.beforeNextPrompt
503
- && session?.isStreaming
504
- && typeof session.steer === "function"
505
- ) {
506
- try {
507
- await session.steer(prompt);
508
- chatState.activeSteers.push(prompt);
509
- return { disposition: "steered" };
510
- } catch (error) {
511
- queueChatPrompt(chatState, prompt);
512
- return { disposition: "queued", steerError: error };
513
- }
514
- }
515
-
516
- queueChatPrompt(chatState, prompt, { replace: replaceQueued });
517
- return { disposition: "queued" };
518
- }
519
-
520
- export async function drainChatPromptQueue({
521
- chatState,
522
- initialPrompt,
523
- initialCtx = null,
524
- processPrompt,
525
- onPromptFailure,
526
- onPromptInterrupted,
527
- beforeInitialPrompt
528
- }) {
529
- let currentPrompt = initialPrompt;
530
- let currentCtx = initialCtx;
531
-
153
+ async function withTyping(ctx, work) {
154
+ const stopTyping = await startTelegramTyping(ctx);
532
155
  try {
533
- await beforeInitialPrompt?.();
534
- while (currentPrompt) {
535
- while (chatState.beforeNextPrompt) {
536
- const gate = chatState.beforeNextPrompt;
537
- await gate;
538
- if (chatState.beforeNextPrompt === gate) chatState.beforeNextPrompt = null;
539
- }
540
- if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
541
- currentPrompt = takeQueuedPrompt(chatState);
542
- chatState.continueAfterClose = false;
543
- currentCtx = null;
544
- }
545
- try {
546
- await processPrompt({ prompt: currentPrompt, ctx: currentCtx });
547
- } catch (error) {
548
- if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
549
- await onPromptInterrupted?.(error);
550
- } else {
551
- await onPromptFailure?.(error);
552
- throw error;
553
- }
554
- } finally {
555
- currentCtx = null;
556
- }
557
-
558
- currentPrompt = takeQueuedPrompt(chatState);
559
- chatState.continueAfterClose = false;
560
- }
156
+ return await work();
561
157
  } finally {
562
- chatState.processing = false;
563
- chatState.activeSession = null;
564
- chatState.activeSteers = [];
158
+ stopTyping();
565
159
  }
566
160
  }
567
161
 
568
- export async function closeModelPicker(ctx, { messageText, callbackText }) {
569
- await ctx.api.editMessageText(
570
- ctx.chat.id,
571
- ctx.callbackQuery.message.message_id,
572
- messageText
573
- );
574
- await ctx.answerCallbackQuery({ text: callbackText });
575
- }
162
+ export { closeModelPicker } from "./model-callback.js";
576
163
 
577
164
  export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, doctor, checkUpdates, updateCore, updateTools, requestRestart, logger }) {
578
165
  const resourceNotes = new ToolResourceNoteStore();
579
166
  const bot = new Bot(config.telegram.token);
580
167
  const perChatState = createChatStateStore();
581
- const conversationHistory = new ConversationHistoryStore();
168
+ const sessionSeeds = new SessionSeedStore();
582
169
  const notifiedPromptErrors = new WeakSet();
583
170
  const authRenewals = new Map();
171
+ const workspaceRoutes = new WeakMap();
172
+ const workspaceGateStates = new Map();
584
173
  let piAuthIssue = null;
585
174
  let taskTimer = null;
586
175
 
176
+ const requestRestartWithReceipt = async (ctx, reason = "Telegram restart") => {
177
+ const route = contextRoute(ctx);
178
+ const receipt = await prepareRestartReceipt({
179
+ transportChatId: route.transportChatId,
180
+ threadId: route.threadId
181
+ }, { reason });
182
+ try {
183
+ return await requestRestart();
184
+ } catch (error) {
185
+ await cancelRestartReceipt(receipt.id).catch(() => {});
186
+ throw error;
187
+ }
188
+ };
587
189
  const handleRestartCommand = createTelegramRestartHandler({
588
- authorize: (ctx) => authorizeChat({
589
- config,
590
- chatId: ctx.chat.id,
591
- saveConfig,
592
- chatMeta: getIncomingChatMeta(ctx)
593
- }),
594
- requestRestart,
190
+ authorize: authorizeContext,
191
+ requestRestart: (ctx) => requestRestartWithReceipt(ctx, "Telegram /restart"),
595
192
  logger
596
193
  });
597
194
  const handleUpdateCallback = createTelegramUpdateCallbackHandler({
598
- authorize: (ctx) => authorizeChat({
599
- config,
600
- chatId: ctx.chat.id,
601
- saveConfig,
602
- chatMeta: getIncomingChatMeta(ctx)
603
- }),
195
+ authorize: authorizeContext,
604
196
  updateCore,
605
197
  updateTools,
606
- requestRestart,
198
+ requestRestart: (ctx) => requestRestartWithReceipt(ctx, "Telegram update restart"),
607
199
  logger
608
200
  });
609
201
 
@@ -727,174 +319,97 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
727
319
  };
728
320
  }
729
321
 
730
- function getChatState(chatId) {
731
- return perChatState.get(chatId);
732
- }
733
-
734
- async function getProviderModels(chatId) {
735
- const runtime = createPiRuntime({
736
- provider: config.pi.provider,
737
- apiKey: config.pi.apiKey
738
- });
739
- return reverseModelOrder(listProviderModels(config.pi.provider, runtime));
740
- }
741
-
742
- async function showModelPicker(ctx, page = 0) {
743
- const agentConfig = getAgentConfig(config);
744
- const picker = buildModelPicker({
745
- provider: agentConfig.provider,
746
- models: await getProviderModels(ctx.chat.id),
747
- selectedModelId: resolveChatModel(config, ctx.chat.id),
748
- selectedThinkingLevel: resolveChatThinkingLevel(config, ctx.chat.id),
749
- selectedSpeed: resolveChatSpeed(config, ctx.chat.id),
750
- page,
751
- pageSize: config.telegram.modelPickerPageSize
752
- });
753
- const extra = { reply_markup: picker.replyMarkup };
754
- const messageId = ctx.callbackQuery?.message?.message_id;
755
- if (messageId) {
756
- return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
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;
757
328
  }
758
- return ctx.reply(picker.text, extra);
759
- }
760
329
 
761
- async function showEffortPicker(ctx, { model, modelIndex, selectedThinkingLevel } = {}) {
762
- const agentConfig = getAgentConfig(config);
763
- const models = await getProviderModels(ctx.chat.id);
764
- const resolvedModel = model || models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
765
- if (!resolvedModel) {
766
- throw new Error(`Model not found for provider ${agentConfig.provider}`);
767
- }
768
- if (!modelSupportsThinking(resolvedModel)) {
769
- const text = `${resolvedModel.provider}/${resolvedModel.id} does not support effort levels.`;
770
- if (ctx.callbackQuery?.message?.message_id) {
771
- return ctx.api.editMessageText(ctx.chat.id, ctx.callbackQuery.message.message_id, text);
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(() => {});
772
336
  }
773
- return ctx.reply(text);
337
+ return { ok: false, reason: route.reason || "workspace-locked" };
774
338
  }
775
- const levels = listModelThinkingLevels(resolvedModel);
776
- const picker = buildEffortPicker({
777
- provider: resolvedModel.provider,
778
- modelId: resolvedModel.id,
779
- levels,
780
- selectedThinkingLevel: selectedThinkingLevel
781
- ?? clampModelThinkingLevel(resolvedModel, resolveChatThinkingLevel(config, ctx.chat.id)),
782
- modelIndex
783
- });
784
- const extra = { reply_markup: picker.replyMarkup };
785
- const messageId = ctx.callbackQuery?.message?.message_id;
786
- if (messageId) {
787
- return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
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(() => {});
788
346
  }
789
- return ctx.reply(picker.text, extra);
347
+ return { ok: true, firstTime: false, workspace: true };
790
348
  }
791
349
 
792
- async function showSpeedPicker(ctx) {
793
- const agentConfig = getAgentConfig(config);
794
- const models = await getProviderModels(ctx.chat.id);
795
- const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
796
- if (!model) throw new Error(`Model not found for provider ${agentConfig.provider}`);
797
- if (!modelSupportsSpeed(model)) {
798
- const text = `${model.provider}/${model.id} does not support speed 1.5x.`;
799
- if (ctx.callbackQuery?.message?.message_id) {
800
- return ctx.api.editMessageText(ctx.chat.id, ctx.callbackQuery.message.message_id, text);
801
- }
802
- return ctx.reply(text);
803
- }
804
- const picker = buildSpeedPicker({
805
- provider: model.provider,
806
- modelId: model.id,
807
- speeds: MODEL_SPEEDS,
808
- selectedSpeed: resolveChatSpeed(config, ctx.chat.id)
809
- });
810
- const extra = { reply_markup: picker.replyMarkup };
811
- const messageId = ctx.callbackQuery?.message?.message_id;
812
- if (messageId) return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
813
- return ctx.reply(picker.text, extra);
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
+ };
814
359
  }
815
360
 
816
- async function persistChatModel(chatId, model, thinkingLevel) {
817
- const agentConfig = getAgentConfig(config);
818
- const key = chatKey(chatId);
819
- const hadSelections = Boolean(agentConfig.chatModels);
820
- const previousSelection = agentConfig.chatModels?.[key];
821
- const level = clampModelThinkingLevel(model, thinkingLevel ?? resolveChatThinkingLevel(config, chatId));
822
- const speed = clampModelSpeed(model, resolveChatSpeed(config, chatId));
823
- selectChatModel(config, chatId, model, { thinkingLevel: level, speed });
824
- try {
825
- await saveConfig(config);
826
- } catch (error) {
827
- if (previousSelection) {
828
- agentConfig.chatModels[key] = previousSelection;
829
- } else {
830
- delete agentConfig.chatModels[key];
831
- if (!hadSelections) delete agentConfig.chatModels;
832
- }
833
- throw error;
834
- }
835
- agentManager.resetSession(chatId);
836
- return level;
361
+ function getChatState(chatId) {
362
+ return perChatState.get(chatId);
837
363
  }
838
364
 
839
- async function persistChatEffort(chatId, model, thinkingLevel) {
840
- const agentConfig = getAgentConfig(config);
841
- const key = chatKey(chatId);
842
- const hadSelections = Boolean(agentConfig.chatModels);
843
- const previousSelection = agentConfig.chatModels?.[key];
844
- const level = clampModelThinkingLevel(model, thinkingLevel);
845
- selectChatThinkingLevel(config, chatId, level);
846
- try {
847
- await saveConfig(config);
848
- } catch (error) {
849
- if (previousSelection) {
850
- agentConfig.chatModels[key] = previousSelection;
851
- } else {
852
- delete agentConfig.chatModels[key];
853
- if (!hadSelections) delete agentConfig.chatModels;
854
- }
855
- throw error;
856
- }
857
- return level;
365
+ const {
366
+ getProviderModels,
367
+ showModelPicker,
368
+ showEffortPicker,
369
+ showSpeedPicker,
370
+ persistChatModel,
371
+ persistChatEffort,
372
+ persistChatSpeed
373
+ } = createTelegramModelControls({ config, saveConfig, agentManager, contextRoute });
374
+
375
+ function modelSelectionFor(chatId) {
376
+ const selection = config.pi.chatModels?.[chatKey(chatId)];
377
+ return selection?.provider === config.pi.provider ? selection : null;
858
378
  }
859
379
 
860
- async function persistChatSpeed(chatId, model, speed) {
861
- const agentConfig = getAgentConfig(config);
862
- const key = chatKey(chatId);
863
- const hadSelections = Boolean(agentConfig.chatModels);
864
- const previousSelection = agentConfig.chatModels?.[key];
865
- const level = clampModelSpeed(model, speed);
866
- await agentManager.setModelSpeed(chatId, level);
867
- selectChatSpeed(config, chatId, level);
868
- try {
869
- await saveConfig(config);
870
- } catch (error) {
871
- if (previousSelection) {
872
- agentConfig.chatModels[key] = previousSelection;
873
- } else {
874
- delete agentConfig.chatModels[key];
875
- if (!hadSelections) delete agentConfig.chatModels;
876
- }
877
- agentManager.clearSessionCache(chatId);
878
- throw error;
879
- }
880
- return level;
380
+ async function ensureWorkspaceTopicModelSelection(route) {
381
+ if (!route.workspace || chatKey(route.sessionId) === chatKey(route.ownerChatId)) return;
382
+ if (modelSelectionFor(route.sessionId)) return;
383
+ const inherited = modelSelectionFor(route.scopeChatId) || modelSelectionFor(route.transportChatId);
384
+ if (!inherited) return;
385
+ config.pi.chatModels ||= {};
386
+ config.pi.chatModels[chatKey(route.sessionId)] = {
387
+ ...inherited,
388
+ sessionRevision: 0
389
+ };
390
+ await saveConfig(config);
391
+ logger?.log("telegram", `inherited model ${inherited.model} for workspace topic session ${route.sessionId}`);
881
392
  }
882
393
 
883
- async function buildIncomingPrompt(ctx) {
884
- const chatId = ctx.chat.id;
885
- logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${chatId}`);
886
- const chatArtifactStore = artifactStore.forChat(chatId);
887
- const artifact = await captureIncomingArtifact(ctx, artifactStore);
394
+ async function buildIncomingPrompt(ctx, route = contextRoute(ctx)) {
395
+ logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${route.transportChatId} session ${route.sessionId}`);
396
+ const chatArtifactStore = artifactStore.forChat(route.scopeChatId);
397
+ const artifact = await captureIncomingArtifact(ctx, artifactStore, { storageChatId: route.scopeChatId });
888
398
  if (artifact) logger?.log("telegram", `captured artifact ${artifact.kind}${artifact.id ? ` ${artifact.id}` : ""}`);
889
- const { transcript, toolResult } = await normalizeIncomingArtifact({ artifact, toolRegistry, chatArtifactStore, chatId });
399
+ const { transcript, toolResult, normalizationRequired } = await normalizeIncomingArtifact({
400
+ artifact,
401
+ toolRegistry,
402
+ chatArtifactStore,
403
+ chatId: route.scopeChatId
404
+ });
890
405
  if (transcript) logger?.log("telegram", `media transcribed to artifact ${transcript.id}`);
891
- if (shouldNormalizeArtifactToText(artifact) && !transcript) {
892
- logger?.log("telegram", `media normalization unavailable for chat ${ctx.chat.id}: ${toolResult?.error || toolResult?.missingConfig?.join(", ") || "unknown error"}`);
406
+ if (normalizationRequired && !transcript) {
407
+ logger?.log("telegram", `media normalization unavailable for chat ${route.transportChatId}: ${toolResult?.error || toolResult?.missingConfig?.join(", ") || "unknown error"}`);
893
408
  }
894
409
  return buildPrompt({ ctx, artifact, transcript, toolResult });
895
410
  }
896
411
 
897
- async function sendTextReply({ sendText, sendDocument, chatId, text }) {
412
+ async function sendTextReply({ sendText, sendDocument, chatId, artifactChatId = chatId, text }) {
898
413
  const maxInlineReplyLength = 3500;
899
414
 
900
415
  if (isSilentReply(text)) {
@@ -904,7 +419,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
904
419
 
905
420
  if (text.length > maxInlineReplyLength) {
906
421
  logger?.log("telegram", `sending long reply as markdown attachment for chat ${chatId}`);
907
- const chatArtifactStore = artifactStore.forChat(chatId);
422
+ const chatArtifactStore = artifactStore.forChat(artifactChatId);
908
423
  const artifact = await chatArtifactStore.createGeneratedFile({
909
424
  fileName: `reply-${Date.now()}.md`,
910
425
  content: text,
@@ -928,17 +443,82 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
928
443
  }
929
444
  }
930
445
 
931
- function createTelegramSessionBridge(chatId) {
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
+ };
460
+ }
461
+
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
+ };
932
491
  return {
933
492
  sendMedia: async (filePath, { method = "audio", caption, filename } = {}) => {
934
- logger?.log("telegram", `sending ${method} reply for chat ${chatId}`);
493
+ logger?.log("telegram", `sending ${method} reply for chat ${route.transportChatId}`);
935
494
  const input = new InputFile(filePath, filename || undefined);
936
- if (method === "voice") return bot.api.sendVoice(chatId, input, { caption });
937
- if (method === "document") return bot.api.sendDocument(chatId, input, { caption });
938
- if (method === "photo" || method === "image") return bot.api.sendPhoto(chatId, input, { caption });
939
- if (method === "video") return bot.api.sendVideo(chatId, input, { caption });
940
- return bot.api.sendAudio(chatId, input, { caption });
941
- }
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
942
522
  };
943
523
  }
944
524
 
@@ -950,7 +530,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
950
530
  : artifact.kind === "video" || artifact.mimeType?.startsWith("video/") ? "video"
951
531
  : "document");
952
532
  const safeCaption = caption && !/(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(caption) ? caption : undefined;
953
- await createTelegramSessionBridge(chatId).sendMedia(artifact.path, {
533
+ await createTelegramSessionBridge({
534
+ workspace: false,
535
+ sessionId: String(chatId),
536
+ scopeChatId: chatId,
537
+ transportChatId: chatId,
538
+ threadId: null
539
+ }).sendMedia(artifact.path, {
954
540
  method: resolvedMethod,
955
541
  caption: safeCaption,
956
542
  filename: path.basename(artifact.path)
@@ -959,54 +545,63 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
959
545
  });
960
546
 
961
547
  async function processPromptForChat({ chatId, prompt, ctx = null }) {
548
+ const route = ctx ? contextRoute(ctx) : {
549
+ workspace: false,
550
+ sessionId: String(chatId),
551
+ scopeChatId: chatId,
552
+ transportChatId: chatId,
553
+ threadId: null
554
+ };
555
+ const sessionId = route.sessionId;
556
+ const bridge = createTelegramSessionBridge(route);
557
+ const messageOptions = (extra = {}) => route.workspace && route.threadId
558
+ ? { ...extra, message_thread_id: route.threadId }
559
+ : extra;
962
560
  const work = async () => {
963
- const { session, speedController } = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
964
- const historyRevision = getChatState(chatId).historyRevision;
965
- await conversationHistory.ensureSeed(chatId, {
966
- runtime: "pi",
967
- history: formatPortableSessionHistory(session.messages)
561
+ await ensureWorkspaceTopicModelSelection(route);
562
+ if (route.workspace && route.threadId) {
563
+ const handoff = await sessionSeeds.consume(sessionId);
564
+ if (handoff) {
565
+ agentManager.resetSession(sessionId, { handoff });
566
+ await agentManager.waitForSessionClose(sessionId);
567
+ }
568
+ }
569
+ const { session, speedController } = await agentManager.getSessionContext(sessionId, bridge, {
570
+ scopeChatId: route.scopeChatId,
571
+ accessGuard: createWorkspaceAccessGuard(route)
968
572
  });
969
573
  let text = "";
970
- let steeredPrompts = [];
971
- const chatState = getChatState(chatId);
574
+ const chatState = getChatState(sessionId);
972
575
  chatState.activeSession = session;
973
- chatState.activeSteers = [];
576
+ chatState.activeRoute = route;
974
577
  try {
975
578
  text = await withPromptSpeed({
976
579
  speedController,
977
580
  speed: isScheduledTaskPrompt(prompt) ? 1 : undefined,
978
- restoreSpeed: () => clampModelSpeed(session.model, resolveChatSpeed(config, chatId))
581
+ restoreSpeed: () => clampModelSpeed(session.model, resolveChatSpeed(config, sessionId))
979
582
  }, () => collectText(session, prompt, {
980
583
  logger,
981
- chatId,
584
+ chatId: sessionId,
982
585
  onSlowPrompt: () => bot.api.sendMessage(
983
- chatId,
984
- "This is taking longer than 5 minutes, so I will keep the current session running instead of starting over. Send /new if you want to abandon it and start fresh."
586
+ route.transportChatId,
587
+ "This is taking longer than 5 minutes, so I will keep the current session running instead of starting over. Send /new if you want to abandon it and start fresh.",
588
+ messageOptions()
985
589
  )
986
590
  }));
987
591
  } catch (error) {
988
- agentManager.resetSession(chatId);
592
+ agentManager.resetSession(sessionId);
989
593
  throw error;
990
594
  } finally {
991
- steeredPrompts = [...chatState.activeSteers];
992
595
  if (chatState.activeSession === session) chatState.activeSession = null;
993
- chatState.activeSteers = [];
994
- }
995
- if (getChatState(chatId).historyRevision === historyRevision) {
996
- const historyPrompt = steeredPrompts.length
997
- ? [prompt, ...steeredPrompts.map((message) => `[Steering message]\n${message}`)].join("\n\n")
998
- : prompt;
999
- await conversationHistory.appendTurn(chatId, {
1000
- runtime: "pi",
1001
- prompt: historyPrompt,
1002
- response: text
1003
- });
596
+ chatState.activeRoute = null;
1004
597
  }
1005
598
  if (text) {
599
+ await createWorkspaceAccessGuard(route)();
1006
600
  await sendTextReply({
1007
- sendText: (message, extra) => bot.api.sendMessage(chatId, message, extra),
1008
- sendDocument: (file, extra) => bot.api.sendDocument(chatId, file, extra),
1009
- chatId,
601
+ sendText: (message, extra) => bot.api.sendMessage(route.transportChatId, message, messageOptions(extra)),
602
+ sendDocument: (file, extra) => bot.api.sendDocument(route.transportChatId, file, messageOptions(extra)),
603
+ chatId: sessionId,
604
+ artifactChatId: route.scopeChatId,
1010
605
  text
1011
606
  });
1012
607
  }
@@ -1020,11 +615,18 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1020
615
  const chatState = getChatState(chatId);
1021
616
 
1022
617
  if (chatState.processing) {
618
+ const incomingRoute = ctx ? contextRoute(ctx) : null;
619
+ const activeRoute = chatState.activeRoute;
620
+ const sameDelivery = !incomingRoute || !activeRoute || (
621
+ incomingRoute.transportChatId === activeRoute.transportChatId
622
+ && incomingRoute.threadId === activeRoute.threadId
623
+ );
1023
624
  const routed = await routeBusyPrompt({
1024
625
  chatState,
1025
626
  prompt,
1026
- mode: busyMessageMode,
1027
- replaceQueued
627
+ mode: sameDelivery ? busyMessageMode : "queue",
628
+ replaceQueued,
629
+ ctx
1028
630
  });
1029
631
  if (routed.disposition === "steered") {
1030
632
  logger?.log("telegram", `chat ${chatId} busy, steering ${label}`);
@@ -1066,24 +668,27 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1066
668
  }
1067
669
 
1068
670
  async function enqueueOrProcess(ctx) {
1069
- const chatState = getChatState(ctx.chat.id);
671
+ const route = contextRoute(ctx);
672
+ const chatState = getChatState(route.sessionId);
1070
673
 
1071
674
  if (chatState.processing) {
1072
- const incomingPrompt = await buildIncomingPrompt(ctx);
675
+ await ensureQueuedTelegramTyping(chatState, ctx);
676
+ const incomingPrompt = await buildIncomingPrompt(ctx, route);
1073
677
  const busyMessageMode = typeof ctx.message?.text === "string"
1074
- ? resolveTelegramBusyMessageMode(config, ctx.chat.id)
678
+ ? resolveTelegramBusyMessageMode(config, route.sessionId)
1075
679
  : "queue";
1076
680
  return enqueuePrompt({
1077
- chatId: ctx.chat.id,
681
+ chatId: route.sessionId,
1078
682
  prompt: incomingPrompt,
1079
683
  label: `message ${ctx.msg.message_id}`,
1080
- busyMessageMode
684
+ busyMessageMode,
685
+ ctx
1081
686
  });
1082
687
  }
1083
688
 
1084
- const incomingPrompt = await buildIncomingPrompt(ctx);
689
+ const incomingPrompt = await buildIncomingPrompt(ctx, route);
1085
690
  return enqueuePrompt({
1086
- chatId: ctx.chat.id,
691
+ chatId: route.sessionId,
1087
692
  prompt: incomingPrompt,
1088
693
  label: `message ${ctx.msg.message_id}`,
1089
694
  ctx
@@ -1100,6 +705,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1100
705
  logger?.log("telegram", `startup message failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
1101
706
  }
1102
707
  }
708
+ try {
709
+ const result = await deliverRestartReceipt((chatId, text, options) => bot.api.sendMessage(chatId, text, options));
710
+ if (result) logger?.log("telegram", `delivered restart receipt ${result.receipt.id}`);
711
+ } catch (error) {
712
+ logger?.log("telegram", `restart receipt delivery failed: ${error instanceof Error ? error.message : String(error)}`);
713
+ }
1103
714
  }
1104
715
 
1105
716
  function scheduleStartupMessages({ skipAgentStartupPrompts = false } = {}) {
@@ -1115,76 +726,48 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1115
726
  timer.unref?.();
1116
727
  }
1117
728
 
1118
- async function dispatchTask(task) {
1119
- const chatId = task.payload?.chatId;
1120
- if (!chatId) {
1121
- await taskStore.fail(task.id, `Task missing chatId: ${task.kind}`);
1122
- return;
1123
- }
1124
-
1125
- if (task.kind === "agent_task") {
1126
- if (!task.payload.prompt) {
1127
- await taskStore.fail(task.id, "agent_task missing prompt");
1128
- return;
1129
- }
1130
- logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
1131
- await enqueuePrompt({
1132
- chatId,
1133
- prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
1134
- label: `scheduled task ${task.id}`
1135
- });
1136
- await taskStore.complete(task.id);
1137
- return;
1138
- }
1139
-
1140
- if (task.kind === "agent_event") {
1141
- logger?.log("tasks", `agent event ${task.id} for chat ${chatId}`);
1142
- await enqueuePrompt({
1143
- chatId,
1144
- prompt: await buildAsyncEventPrompt(task, resourceNotes),
1145
- label: `agent event ${task.id}`
1146
- });
1147
- await taskStore.complete(task.id);
1148
- return;
1149
- }
1150
-
1151
- if (task.kind === "poll_tool") {
1152
- const toolName = task.payload?.toolName;
1153
- if (!toolName) {
1154
- await taskStore.fail(task.id, "poll_tool missing toolName");
1155
- return;
1156
- }
1157
- logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
1158
- try {
1159
- await agentManager.runTool({
1160
- name: toolName,
1161
- request: { args: task.payload.args || {} },
1162
- chatId
1163
- });
1164
- } catch (error) {
1165
- logger?.log("tasks", `poll_tool ${toolName} failed: ${error instanceof Error ? error.message : String(error)}`);
1166
- }
1167
- await taskStore.complete(task.id);
1168
- return;
729
+ async function enqueueAsyncPrompt({ chatId, prompt, label, telegramContext }) {
730
+ let ctx = { chat: { id: chatId }, api: bot.api };
731
+ if (telegramContext?.transportChatId && telegramContext?.messageThreadId) {
732
+ ctx = {
733
+ chat: { id: telegramContext.transportChatId, type: "supergroup", is_forum: true },
734
+ from: { id: chatId },
735
+ message: { message_thread_id: telegramContext.messageThreadId },
736
+ api: bot.api
737
+ };
738
+ const route = await resolveTelegramWorkspaceRoute({ config, api: bot.api, ctx });
739
+ if (!route.ok) throw new Error("Scheduled owner-workspace destination is unavailable.");
740
+ workspaceRoutes.set(ctx, route);
1169
741
  }
1170
-
1171
- await taskStore.fail(task.id, `Unsupported task: ${task.kind}`);
742
+ const route = contextRoute(ctx);
743
+ const chatState = getChatState(route.sessionId);
744
+ if (chatState.processing) await ensureQueuedTelegramTyping(chatState, ctx);
745
+ return enqueuePrompt({ chatId: route.sessionId, prompt, label, ctx });
1172
746
  }
1173
747
 
1174
- async function dispatchDueTasks() {
1175
- const tasks = await taskStore.claimDue(10);
1176
- for (const task of tasks) {
1177
- try {
1178
- await dispatchTask(task);
1179
- } catch (error) {
1180
- await taskStore.fail(task.id, error instanceof Error ? error.message : String(error));
1181
- }
1182
- }
1183
- }
748
+ const { dispatchDueTasks } = createTelegramTaskDispatcher({
749
+ taskStore,
750
+ sendMessage: (chatId, text) => bot.api.sendMessage(chatId, text),
751
+ enqueueAsyncPrompt,
752
+ artifactStore,
753
+ toolRegistry,
754
+ resourceNotes,
755
+ agentManager,
756
+ logger
757
+ });
1184
758
 
1185
- async function summarizeSessionBeforeReset(chatId) {
759
+ async function summarizeSessionBeforeReset(chatId, route = {
760
+ workspace: false,
761
+ sessionId: String(chatId),
762
+ scopeChatId: chatId,
763
+ transportChatId: chatId,
764
+ threadId: null
765
+ }) {
1186
766
  try {
1187
- const context = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
767
+ const context = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(route), {
768
+ scopeChatId: route.scopeChatId,
769
+ accessGuard: createWorkspaceAccessGuard(route)
770
+ });
1188
771
  const parentSession = context.session.sessionFile || "";
1189
772
  if (!context.session.messages.length) return { handoff: "", parentSession: "" };
1190
773
 
@@ -1197,19 +780,21 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1197
780
  }
1198
781
 
1199
782
  async function handleNewCommand(ctx) {
1200
- const chatState = getChatState(ctx.chat.id);
783
+ const route = contextRoute(ctx);
784
+ const sessionId = route.sessionId;
785
+ const chatState = getChatState(sessionId);
1201
786
  const wasProcessing = chatState.processing;
1202
787
  chatState.historyRevision += 1;
1203
788
  const commandRevision = chatState.historyRevision;
1204
789
  const prompt = buildNewSessionPrompt(ctx);
1205
790
 
1206
791
  if (wasProcessing) {
1207
- logger?.log("telegram", `chat ${ctx.chat.id} busy, queueing new-session command`);
1208
- queueChatPrompt(chatState, prompt, { replace: true });
792
+ logger?.log("telegram", `chat ${sessionId} busy, queueing new-session command`);
793
+ queueChatPrompt(chatState, prompt, { replace: true, ctx });
1209
794
  chatState.continueAfterClose = true;
1210
795
  const reset = (async () => {
1211
- await conversationHistory.reset(ctx.chat.id, { runtime: "pi" });
1212
- agentManager.resetSession(ctx.chat.id);
796
+ await sessionSeeds.clear(sessionId);
797
+ agentManager.resetSession(sessionId);
1213
798
  })();
1214
799
  chatState.beforeNextPrompt = reset;
1215
800
  try {
@@ -1221,21 +806,22 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1221
806
  }
1222
807
 
1223
808
  chatState.processing = true;
1224
- logger?.log("telegram", `processing new-session command in chat ${ctx.chat.id}`);
809
+ logger?.log("telegram", `processing new-session command in chat ${sessionId}`);
1225
810
  await processChatPromptQueue({
1226
- chatId: ctx.chat.id,
811
+ chatId: sessionId,
1227
812
  prompt,
1228
813
  label: "new-session command",
1229
814
  ctx,
1230
815
  beforeInitialPrompt: async () => {
1231
- const handoff = await withTyping(ctx, () => summarizeSessionBeforeReset(ctx.chat.id));
816
+ const handoff = await withTyping(ctx, () => summarizeSessionBeforeReset(sessionId, route));
1232
817
  if (chatState.historyRevision !== commandRevision) return;
1233
- await conversationHistory.reset(ctx.chat.id, {
1234
- runtime: "pi",
1235
- history: handoff.handoff
1236
- });
818
+ if (route.workspace && route.threadId) {
819
+ await sessionSeeds.set(sessionId, handoff.handoff);
820
+ } else {
821
+ await sessionSeeds.clear(sessionId);
822
+ }
1237
823
  if (chatState.historyRevision !== commandRevision) return;
1238
- agentManager.resetSession(ctx.chat.id, handoff);
824
+ agentManager.resetSession(sessionId, handoff);
1239
825
  }
1240
826
  });
1241
827
  }
@@ -1246,13 +832,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1246
832
  });
1247
833
 
1248
834
  bot.command("start", async (ctx) => {
1249
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
835
+ const auth = await authorizeContext(ctx);
1250
836
  if (!auth.ok) return;
1251
837
  return ctx.reply(auth.firstTime ? "This chat is now authorized for Arisa." : "Arisa is ready.");
1252
838
  });
1253
839
 
1254
840
  bot.command("new", async (ctx) => {
1255
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
841
+ const auth = await authorizeContext(ctx);
1256
842
  if (!auth.ok) return;
1257
843
  if (piAuthIssue) {
1258
844
  await ctx.reply(buildPiAuthRecoveryBlockedMessage({
@@ -1269,7 +855,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1269
855
  bot.command("restart", handleRestartCommand);
1270
856
 
1271
857
  bot.command("doctor", async (ctx) => {
1272
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
858
+ const auth = await authorizeContext(ctx);
1273
859
  if (!auth.ok) return;
1274
860
  const pending = await ctx.reply(renderTelegramHtml("```text\nRunning Arisa Doctor…\n```"), { parse_mode: "HTML" });
1275
861
  try {
@@ -1286,11 +872,11 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1286
872
  });
1287
873
 
1288
874
  bot.command("update", async (ctx) => {
1289
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
875
+ const auth = await authorizeContext(ctx);
1290
876
  if (!auth.ok) return;
1291
877
  const pending = await ctx.reply(renderTelegramHtml("```text\nChecking Arisa and official tool updates…\n```"), { parse_mode: "HTML" });
1292
878
  try {
1293
- const report = await checkUpdates(ctx.chat.id);
879
+ const report = await checkUpdates(contextRoute(ctx).scopeChatId);
1294
880
  const picker = buildUpdatePicker(report);
1295
881
  await ctx.api.editMessageText(
1296
882
  ctx.chat.id,
@@ -1305,31 +891,31 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1305
891
  });
1306
892
 
1307
893
  bot.command("tools", async (ctx) => {
1308
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
894
+ const auth = await authorizeContext(ctx);
1309
895
  if (!auth.ok) return;
1310
- await ctx.reply(renderTelegramHtml(formatToolUsageReport(await toolRegistry.usage(ctx.chat.id))), { parse_mode: "HTML" });
896
+ await ctx.reply(renderTelegramHtml(formatToolUsageReport(await toolRegistry.usage(contextRoute(ctx).scopeChatId))), { parse_mode: "HTML" });
1311
897
  });
1312
898
 
1313
899
  bot.command("model", async (ctx) => {
1314
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
900
+ const auth = await authorizeContext(ctx);
1315
901
  if (!auth.ok) return;
1316
902
  await showModelPicker(ctx);
1317
903
  });
1318
904
 
1319
905
  bot.command("effort", async (ctx) => {
1320
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
906
+ const auth = await authorizeContext(ctx);
1321
907
  if (!auth.ok) return;
1322
908
  await showEffortPicker(ctx);
1323
909
  });
1324
910
 
1325
911
  bot.command("speed", async (ctx) => {
1326
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
912
+ const auth = await authorizeContext(ctx);
1327
913
  if (!auth.ok) return;
1328
914
  await showSpeedPicker(ctx);
1329
915
  });
1330
916
 
1331
917
  bot.command("auth", async (ctx) => {
1332
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
918
+ const auth = await authorizeContext(ctx);
1333
919
  if (!auth.ok) return;
1334
920
 
1335
921
  const status = getPiAuthStatus(config, ctx.chat.id);
@@ -1361,222 +947,23 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1361
947
  }
1362
948
  });
1363
949
 
950
+ const handleModelCallback = createTelegramModelCallbackHandler({
951
+ config,
952
+ authorizeContext,
953
+ contextRoute,
954
+ getChatState,
955
+ getProviderModels,
956
+ showModelPicker,
957
+ showEffortPicker,
958
+ persistChatModel,
959
+ persistChatEffort,
960
+ persistChatSpeed,
961
+ logger
962
+ });
963
+
1364
964
  bot.on("callback_query:data", async (ctx, next) => {
1365
965
  if (await handleUpdateCallback(ctx)) return;
1366
- const modelAction = parseModelPickerAction(ctx.callbackQuery.data);
1367
- const effortAction = modelAction ? null : parseEffortPickerAction(ctx.callbackQuery.data);
1368
- const speedAction = modelAction || effortAction ? null : parseSpeedPickerAction(ctx.callbackQuery.data);
1369
- const action = modelAction || effortAction || speedAction;
1370
- if (!action) return next();
1371
- if (action.type === "noop") {
1372
- await ctx.answerCallbackQuery();
1373
- return;
1374
- }
1375
-
1376
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig });
1377
- if (!auth.ok) {
1378
- await ctx.answerCallbackQuery({ text: "This chat is not authorized.", show_alert: true });
1379
- return;
1380
- }
1381
-
1382
- try {
1383
- if (action.type === "page") {
1384
- await showModelPicker(ctx, action.value);
1385
- await ctx.answerCallbackQuery();
1386
- return;
1387
- }
1388
-
1389
- const models = await getProviderModels(ctx.chat.id);
1390
- const chatBusy = getChatState(ctx.chat.id).processing;
1391
-
1392
- if (action.type === "select") {
1393
- const model = models[action.value];
1394
- if (!model) {
1395
- await ctx.answerCallbackQuery({
1396
- text: "This model list is no longer current. Run /model again.",
1397
- show_alert: true
1398
- });
1399
- return;
1400
- }
1401
-
1402
- // Reasoning models open the effort picker only — no session reset yet.
1403
- if (modelSupportsThinking(model)) {
1404
- await showEffortPicker(ctx, {
1405
- model,
1406
- modelIndex: action.value,
1407
- selectedThinkingLevel: clampModelThinkingLevel(model, resolveChatThinkingLevel(config, ctx.chat.id))
1408
- });
1409
- await ctx.answerCallbackQuery({ text: `Choose effort for ${model.id}.` });
1410
- return;
1411
- }
1412
-
1413
- if (chatBusy) {
1414
- await ctx.answerCallbackQuery({
1415
- text: "Wait for the current response before changing models.",
1416
- show_alert: true
1417
- });
1418
- return;
1419
- }
1420
-
1421
- const currentModelId = resolveChatModel(config, ctx.chat.id);
1422
- const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
1423
- if (model.id === currentModelId && currentEffort === "off") {
1424
- await closeModelPicker(ctx, {
1425
- messageText: `Already using ${model.provider}/${model.id}.`,
1426
- callbackText: `Already using ${model.id}.`
1427
- });
1428
- return;
1429
- }
1430
-
1431
- await persistChatModel(ctx.chat.id, model, "off");
1432
- await ctx.api.editMessageText(
1433
- ctx.chat.id,
1434
- ctx.callbackQuery.message.message_id,
1435
- `Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
1436
- );
1437
- await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
1438
- return;
1439
- }
1440
-
1441
- if (action.type === "model-effort") {
1442
- const model = models[action.modelIndex];
1443
- if (!model) {
1444
- await ctx.answerCallbackQuery({
1445
- text: "This model list is no longer current. Run /model again.",
1446
- show_alert: true
1447
- });
1448
- return;
1449
- }
1450
- const levels = listModelThinkingLevels(model);
1451
- if (!levels.includes(action.level)) {
1452
- await ctx.answerCallbackQuery({
1453
- text: "That effort level is not available for this model.",
1454
- show_alert: true
1455
- });
1456
- return;
1457
- }
1458
-
1459
- const currentModelId = resolveChatModel(config, ctx.chat.id);
1460
- const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
1461
- if (model.id === currentModelId && action.level === currentEffort) {
1462
- await closeModelPicker(ctx, {
1463
- messageText: `Already using ${model.provider}/${model.id} (effort: ${action.level}).`,
1464
- callbackText: `Already using ${model.id} at ${action.level}.`
1465
- });
1466
- return;
1467
- }
1468
-
1469
- // Effort-only updates do not reset the session, so they are safe while busy.
1470
- if (model.id === currentModelId) {
1471
- await persistChatEffort(ctx.chat.id, model, action.level);
1472
- await ctx.api.editMessageText(
1473
- ctx.chat.id,
1474
- ctx.callbackQuery.message.message_id,
1475
- `Effort set to ${action.level} for ${model.provider}/${model.id}.`
1476
- );
1477
- await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
1478
- return;
1479
- }
1480
-
1481
- if (chatBusy) {
1482
- await ctx.answerCallbackQuery({
1483
- text: "Wait for the current response before changing models.",
1484
- show_alert: true
1485
- });
1486
- return;
1487
- }
1488
-
1489
- await persistChatModel(ctx.chat.id, model, action.level);
1490
- await ctx.api.editMessageText(
1491
- ctx.chat.id,
1492
- ctx.callbackQuery.message.message_id,
1493
- `Model changed to ${model.provider}/${model.id} (effort: ${action.level}).\nA new chat context will start with your next message.`
1494
- );
1495
- await ctx.answerCallbackQuery({ text: `Using ${model.id} / ${action.level}.` });
1496
- return;
1497
- }
1498
-
1499
- if (action.type === "effort") {
1500
- const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
1501
- if (!model) {
1502
- await ctx.answerCallbackQuery({
1503
- text: "Current model is unavailable. Run /model again.",
1504
- show_alert: true
1505
- });
1506
- return;
1507
- }
1508
- if (!modelSupportsThinking(model)) {
1509
- await ctx.answerCallbackQuery({
1510
- text: "This model does not support effort levels.",
1511
- show_alert: true
1512
- });
1513
- return;
1514
- }
1515
- const levels = listModelThinkingLevels(model);
1516
- if (!levels.includes(action.level)) {
1517
- await ctx.answerCallbackQuery({
1518
- text: "That effort level is not available for this model.",
1519
- show_alert: true
1520
- });
1521
- return;
1522
- }
1523
- const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
1524
- if (action.level === currentEffort) {
1525
- await closeModelPicker(ctx, {
1526
- messageText: `Already using effort ${action.level} for ${model.provider}/${model.id}.`,
1527
- callbackText: `Already using effort ${action.level}.`
1528
- });
1529
- return;
1530
- }
1531
- await persistChatEffort(ctx.chat.id, model, action.level);
1532
- await ctx.api.editMessageText(
1533
- ctx.chat.id,
1534
- ctx.callbackQuery.message.message_id,
1535
- `Effort set to ${action.level} for ${model.provider}/${model.id}.`
1536
- );
1537
- await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
1538
- return;
1539
- }
1540
-
1541
- if (action.type === "speed") {
1542
- const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
1543
- if (!model) {
1544
- await ctx.answerCallbackQuery({
1545
- text: "Current model is unavailable. Run /model again.",
1546
- show_alert: true
1547
- });
1548
- return;
1549
- }
1550
- if (!modelSupportsSpeed(model)) {
1551
- await ctx.answerCallbackQuery({
1552
- text: "This model does not support speed 1.5x.",
1553
- show_alert: true
1554
- });
1555
- return;
1556
- }
1557
- const currentSpeed = resolveChatSpeed(config, ctx.chat.id);
1558
- if (action.speed === currentSpeed) {
1559
- await closeModelPicker(ctx, {
1560
- messageText: `Already using speed ${action.speed.toFixed(1)}x for ${model.provider}/${model.id}.`,
1561
- callbackText: `Already using speed ${action.speed.toFixed(1)}x.`
1562
- });
1563
- return;
1564
- }
1565
- await persistChatSpeed(ctx.chat.id, model, action.speed);
1566
- await ctx.api.editMessageText(
1567
- ctx.chat.id,
1568
- ctx.callbackQuery.message.message_id,
1569
- `Speed set to ${action.speed.toFixed(1)}x for ${model.provider}/${model.id}.`
1570
- );
1571
- await ctx.answerCallbackQuery({ text: `Speed: ${action.speed.toFixed(1)}x.` });
1572
- }
1573
- } catch (error) {
1574
- logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
1575
- await ctx.answerCallbackQuery({
1576
- text: "Could not change the model, effort, or speed.",
1577
- show_alert: true
1578
- }).catch(() => {});
1579
- }
966
+ return handleModelCallback(ctx, next);
1580
967
  });
1581
968
 
1582
969
  bot.on("message_reaction", async (ctx) => {
@@ -1599,8 +986,9 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1599
986
  });
1600
987
 
1601
988
  bot.on("message", async (ctx) => {
1602
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
989
+ const auth = await authorizeContext(ctx);
1603
990
  if (!auth.ok) return;
991
+ if (!isProcessableTelegramMessage(ctx.message)) return;
1604
992
 
1605
993
  const command = getTelegramCommand(ctx);
1606
994
  if (command) return;