arisa 5.1.24 → 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 (38) hide show
  1. package/package.json +2 -3
  2. package/src/core/agent/agent-manager.js +132 -70
  3. package/src/core/agent/pi-runtime.js +0 -8
  4. package/src/core/agent/system-shell-tool.js +13 -2
  5. package/src/core/artifacts/artifact-store.js +17 -18
  6. package/src/core/config/config-defaults.js +2 -2
  7. package/src/core/conversation/session-seed-store.js +85 -0
  8. package/src/core/tools/ipc-client.js +0 -2
  9. package/src/core/tools/tool-output-materializer.js +41 -0
  10. package/src/core/tools/tool-registry.js +96 -23
  11. package/src/official-tools.lock.json +145 -93
  12. package/src/runtime/doctor.js +1 -4
  13. package/src/runtime/headless-tool-executor.js +2 -32
  14. package/src/runtime/paths.js +7 -1
  15. package/src/runtime/restart-receipt.js +90 -0
  16. package/src/transport/telegram/bot.js +390 -1034
  17. package/src/transport/telegram/chat-queue.js +132 -0
  18. package/src/transport/telegram/media.js +2 -2
  19. package/src/transport/telegram/model-callback.js +211 -0
  20. package/src/transport/telegram/model-controls.js +164 -0
  21. package/src/transport/telegram/prompt-builders.js +372 -0
  22. package/src/transport/telegram/task-dispatcher.js +94 -0
  23. package/src/transport/telegram/update-command.js +1 -1
  24. package/src/transport/telegram/workspace-group.js +83 -0
  25. package/test/agent-tool-policy.test.js +7 -1
  26. package/test/context-and-task-bounds.test.js +9 -7
  27. package/test/doctor.test.js +2 -4
  28. package/test/model-selection.test.js +47 -1
  29. package/test/official-tool-dependencies.test.js +2 -0
  30. package/test/paths.test.js +4 -4
  31. package/test/restart-receipt.test.js +39 -0
  32. package/test/session-start-operational-notes.test.js +47 -0
  33. package/test/telegram-prompt-builders.test.js +33 -0
  34. package/test/telegram-task-dispatcher.test.js +102 -0
  35. package/test/telegram-workspace-group.test.js +76 -0
  36. package/test/tool-registry-run.test.js +62 -0
  37. package/test/topic-initialization.test.js +66 -0
  38. 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,348 +129,13 @@ 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
132
  export async function startTelegramTyping(ctx) {
439
- await ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => {});
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
140
  return () => clearInterval(timer);
444
141
  }
@@ -462,164 +159,43 @@ async function withTyping(ctx, work) {
462
159
  }
463
160
  }
464
161
 
465
- export function createChatStateStore() {
466
- const states = new Map();
467
-
468
- function reset(chatId) {
469
- const state = {
470
- processing: false,
471
- pendingPrompts: [],
472
- continueAfterClose: false,
473
- historyRevision: 0,
474
- beforeNextPrompt: null,
475
- activeSession: null,
476
- activeSteers: [],
477
- assistantMessages: new Map(),
478
- stopQueuedTyping: null
479
- };
480
- states.set(String(chatId), state);
481
- return state;
482
- }
483
-
484
- return {
485
- get(chatId) {
486
- const key = String(chatId);
487
- return states.get(key) || reset(key);
488
- },
489
- reset,
490
- anyProcessing() {
491
- return [...states.values()].some((state) => state.processing);
492
- }
493
- };
494
- }
495
-
496
- export function queueChatPrompt(chatState, prompt, { replace = false } = {}) {
497
- if (replace) chatState.pendingPrompts = [];
498
- chatState.pendingPrompts.push(prompt);
499
- }
500
-
501
- function takeQueuedPrompt(chatState) {
502
- return chatState.pendingPrompts.shift() || "";
503
- }
504
-
505
- export function resolveTelegramBusyMessageMode(config, chatId) {
506
- const chatMode = config.telegram?.chatMeta?.[String(chatId)]?.busyMessageMode;
507
- const mode = chatMode || config.telegram?.busyMessageMode;
508
- return mode === "steer" ? "steer" : "queue";
509
- }
510
-
511
- export async function routeBusyPrompt({ chatState, prompt, mode = "queue", replaceQueued = false }) {
512
- const session = chatState.activeSession;
513
- if (
514
- mode === "steer"
515
- && !replaceQueued
516
- && !chatState.continueAfterClose
517
- && !chatState.beforeNextPrompt
518
- && session?.isStreaming
519
- && typeof session.steer === "function"
520
- ) {
521
- try {
522
- await session.steer(prompt);
523
- chatState.activeSteers.push(prompt);
524
- return { disposition: "steered" };
525
- } catch (error) {
526
- queueChatPrompt(chatState, prompt);
527
- return { disposition: "queued", steerError: error };
528
- }
529
- }
530
-
531
- queueChatPrompt(chatState, prompt, { replace: replaceQueued });
532
- return { disposition: "queued" };
533
- }
534
-
535
- export async function drainChatPromptQueue({
536
- chatState,
537
- initialPrompt,
538
- initialCtx = null,
539
- processPrompt,
540
- onPromptFailure,
541
- onPromptInterrupted,
542
- beforeInitialPrompt
543
- }) {
544
- let currentPrompt = initialPrompt;
545
- let currentCtx = initialCtx;
546
-
547
- try {
548
- await beforeInitialPrompt?.();
549
- while (currentPrompt) {
550
- while (chatState.beforeNextPrompt) {
551
- const gate = chatState.beforeNextPrompt;
552
- await gate;
553
- if (chatState.beforeNextPrompt === gate) chatState.beforeNextPrompt = null;
554
- }
555
- if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
556
- currentPrompt = takeQueuedPrompt(chatState);
557
- chatState.continueAfterClose = false;
558
- currentCtx = null;
559
- }
560
- try {
561
- await processPrompt({ prompt: currentPrompt, ctx: currentCtx });
562
- } catch (error) {
563
- if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
564
- await onPromptInterrupted?.(error);
565
- } else {
566
- await onPromptFailure?.(error);
567
- throw error;
568
- }
569
- } finally {
570
- currentCtx = null;
571
- }
572
-
573
- currentPrompt = takeQueuedPrompt(chatState);
574
- chatState.continueAfterClose = false;
575
- }
576
- } finally {
577
- stopQueuedTelegramTyping(chatState);
578
- chatState.processing = false;
579
- chatState.activeSession = null;
580
- chatState.activeSteers = [];
581
- }
582
- }
583
-
584
- export async function closeModelPicker(ctx, { messageText, callbackText }) {
585
- await ctx.api.editMessageText(
586
- ctx.chat.id,
587
- ctx.callbackQuery.message.message_id,
588
- messageText
589
- );
590
- await ctx.answerCallbackQuery({ text: callbackText });
591
- }
162
+ export { closeModelPicker } from "./model-callback.js";
592
163
 
593
164
  export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, doctor, checkUpdates, updateCore, updateTools, requestRestart, logger }) {
594
165
  const resourceNotes = new ToolResourceNoteStore();
595
166
  const bot = new Bot(config.telegram.token);
596
167
  const perChatState = createChatStateStore();
597
- const conversationHistory = new ConversationHistoryStore();
168
+ const sessionSeeds = new SessionSeedStore();
598
169
  const notifiedPromptErrors = new WeakSet();
599
170
  const authRenewals = new Map();
171
+ const workspaceRoutes = new WeakMap();
172
+ const workspaceGateStates = new Map();
600
173
  let piAuthIssue = null;
601
174
  let taskTimer = null;
602
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
+ };
603
189
  const handleRestartCommand = createTelegramRestartHandler({
604
- authorize: (ctx) => authorizeChat({
605
- config,
606
- chatId: ctx.chat.id,
607
- saveConfig,
608
- chatMeta: getIncomingChatMeta(ctx)
609
- }),
610
- requestRestart,
190
+ authorize: authorizeContext,
191
+ requestRestart: (ctx) => requestRestartWithReceipt(ctx, "Telegram /restart"),
611
192
  logger
612
193
  });
613
194
  const handleUpdateCallback = createTelegramUpdateCallbackHandler({
614
- authorize: (ctx) => authorizeChat({
615
- config,
616
- chatId: ctx.chat.id,
617
- saveConfig,
618
- chatMeta: getIncomingChatMeta(ctx)
619
- }),
195
+ authorize: authorizeContext,
620
196
  updateCore,
621
197
  updateTools,
622
- requestRestart,
198
+ requestRestart: (ctx) => requestRestartWithReceipt(ctx, "Telegram update restart"),
623
199
  logger
624
200
  });
625
201
 
@@ -743,174 +319,97 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
743
319
  };
744
320
  }
745
321
 
746
- function getChatState(chatId) {
747
- return perChatState.get(chatId);
748
- }
749
-
750
- async function getProviderModels(chatId) {
751
- const runtime = createPiRuntime({
752
- provider: config.pi.provider,
753
- apiKey: config.pi.apiKey
754
- });
755
- return reverseModelOrder(listProviderModels(config.pi.provider, runtime));
756
- }
757
-
758
- async function showModelPicker(ctx, page = 0) {
759
- const agentConfig = getAgentConfig(config);
760
- const picker = buildModelPicker({
761
- provider: agentConfig.provider,
762
- models: await getProviderModels(ctx.chat.id),
763
- selectedModelId: resolveChatModel(config, ctx.chat.id),
764
- selectedThinkingLevel: resolveChatThinkingLevel(config, ctx.chat.id),
765
- selectedSpeed: resolveChatSpeed(config, ctx.chat.id),
766
- page,
767
- pageSize: config.telegram.modelPickerPageSize
768
- });
769
- const extra = { reply_markup: picker.replyMarkup };
770
- const messageId = ctx.callbackQuery?.message?.message_id;
771
- if (messageId) {
772
- 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;
773
328
  }
774
- return ctx.reply(picker.text, extra);
775
- }
776
329
 
777
- async function showEffortPicker(ctx, { model, modelIndex, selectedThinkingLevel } = {}) {
778
- const agentConfig = getAgentConfig(config);
779
- const models = await getProviderModels(ctx.chat.id);
780
- const resolvedModel = model || models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
781
- if (!resolvedModel) {
782
- throw new Error(`Model not found for provider ${agentConfig.provider}`);
783
- }
784
- if (!modelSupportsThinking(resolvedModel)) {
785
- const text = `${resolvedModel.provider}/${resolvedModel.id} does not support effort levels.`;
786
- if (ctx.callbackQuery?.message?.message_id) {
787
- 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(() => {});
788
336
  }
789
- return ctx.reply(text);
337
+ return { ok: false, reason: route.reason || "workspace-locked" };
790
338
  }
791
- const levels = listModelThinkingLevels(resolvedModel);
792
- const picker = buildEffortPicker({
793
- provider: resolvedModel.provider,
794
- modelId: resolvedModel.id,
795
- levels,
796
- selectedThinkingLevel: selectedThinkingLevel
797
- ?? clampModelThinkingLevel(resolvedModel, resolveChatThinkingLevel(config, ctx.chat.id)),
798
- modelIndex
799
- });
800
- const extra = { reply_markup: picker.replyMarkup };
801
- const messageId = ctx.callbackQuery?.message?.message_id;
802
- if (messageId) {
803
- 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(() => {});
804
346
  }
805
- return ctx.reply(picker.text, extra);
347
+ return { ok: true, firstTime: false, workspace: true };
806
348
  }
807
349
 
808
- async function showSpeedPicker(ctx) {
809
- const agentConfig = getAgentConfig(config);
810
- const models = await getProviderModels(ctx.chat.id);
811
- const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
812
- if (!model) throw new Error(`Model not found for provider ${agentConfig.provider}`);
813
- if (!modelSupportsSpeed(model)) {
814
- const text = `${model.provider}/${model.id} does not support speed 1.5x.`;
815
- if (ctx.callbackQuery?.message?.message_id) {
816
- return ctx.api.editMessageText(ctx.chat.id, ctx.callbackQuery.message.message_id, text);
817
- }
818
- return ctx.reply(text);
819
- }
820
- const picker = buildSpeedPicker({
821
- provider: model.provider,
822
- modelId: model.id,
823
- speeds: MODEL_SPEEDS,
824
- selectedSpeed: resolveChatSpeed(config, ctx.chat.id)
825
- });
826
- const extra = { reply_markup: picker.replyMarkup };
827
- const messageId = ctx.callbackQuery?.message?.message_id;
828
- if (messageId) return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
829
- 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
+ };
830
359
  }
831
360
 
832
- async function persistChatModel(chatId, model, thinkingLevel) {
833
- const agentConfig = getAgentConfig(config);
834
- const key = chatKey(chatId);
835
- const hadSelections = Boolean(agentConfig.chatModels);
836
- const previousSelection = agentConfig.chatModels?.[key];
837
- const level = clampModelThinkingLevel(model, thinkingLevel ?? resolveChatThinkingLevel(config, chatId));
838
- const speed = clampModelSpeed(model, resolveChatSpeed(config, chatId));
839
- selectChatModel(config, chatId, model, { thinkingLevel: level, speed });
840
- try {
841
- await saveConfig(config);
842
- } catch (error) {
843
- if (previousSelection) {
844
- agentConfig.chatModels[key] = previousSelection;
845
- } else {
846
- delete agentConfig.chatModels[key];
847
- if (!hadSelections) delete agentConfig.chatModels;
848
- }
849
- throw error;
850
- }
851
- agentManager.resetSession(chatId);
852
- return level;
361
+ function getChatState(chatId) {
362
+ return perChatState.get(chatId);
853
363
  }
854
364
 
855
- async function persistChatEffort(chatId, model, thinkingLevel) {
856
- const agentConfig = getAgentConfig(config);
857
- const key = chatKey(chatId);
858
- const hadSelections = Boolean(agentConfig.chatModels);
859
- const previousSelection = agentConfig.chatModels?.[key];
860
- const level = clampModelThinkingLevel(model, thinkingLevel);
861
- selectChatThinkingLevel(config, chatId, level);
862
- try {
863
- await saveConfig(config);
864
- } catch (error) {
865
- if (previousSelection) {
866
- agentConfig.chatModels[key] = previousSelection;
867
- } else {
868
- delete agentConfig.chatModels[key];
869
- if (!hadSelections) delete agentConfig.chatModels;
870
- }
871
- throw error;
872
- }
873
- 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;
874
378
  }
875
379
 
876
- async function persistChatSpeed(chatId, model, speed) {
877
- const agentConfig = getAgentConfig(config);
878
- const key = chatKey(chatId);
879
- const hadSelections = Boolean(agentConfig.chatModels);
880
- const previousSelection = agentConfig.chatModels?.[key];
881
- const level = clampModelSpeed(model, speed);
882
- await agentManager.setModelSpeed(chatId, level);
883
- selectChatSpeed(config, chatId, level);
884
- try {
885
- await saveConfig(config);
886
- } catch (error) {
887
- if (previousSelection) {
888
- agentConfig.chatModels[key] = previousSelection;
889
- } else {
890
- delete agentConfig.chatModels[key];
891
- if (!hadSelections) delete agentConfig.chatModels;
892
- }
893
- agentManager.clearSessionCache(chatId);
894
- throw error;
895
- }
896
- 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}`);
897
392
  }
898
393
 
899
- async function buildIncomingPrompt(ctx) {
900
- const chatId = ctx.chat.id;
901
- logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${chatId}`);
902
- const chatArtifactStore = artifactStore.forChat(chatId);
903
- 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 });
904
398
  if (artifact) logger?.log("telegram", `captured artifact ${artifact.kind}${artifact.id ? ` ${artifact.id}` : ""}`);
905
- 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
+ });
906
405
  if (transcript) logger?.log("telegram", `media transcribed to artifact ${transcript.id}`);
907
- if (shouldNormalizeArtifactToText(artifact) && !transcript) {
908
- 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"}`);
909
408
  }
910
409
  return buildPrompt({ ctx, artifact, transcript, toolResult });
911
410
  }
912
411
 
913
- async function sendTextReply({ sendText, sendDocument, chatId, text }) {
412
+ async function sendTextReply({ sendText, sendDocument, chatId, artifactChatId = chatId, text }) {
914
413
  const maxInlineReplyLength = 3500;
915
414
 
916
415
  if (isSilentReply(text)) {
@@ -920,7 +419,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
920
419
 
921
420
  if (text.length > maxInlineReplyLength) {
922
421
  logger?.log("telegram", `sending long reply as markdown attachment for chat ${chatId}`);
923
- const chatArtifactStore = artifactStore.forChat(chatId);
422
+ const chatArtifactStore = artifactStore.forChat(artifactChatId);
924
423
  const artifact = await chatArtifactStore.createGeneratedFile({
925
424
  fileName: `reply-${Date.now()}.md`,
926
425
  content: text,
@@ -944,17 +443,82 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
944
443
  }
945
444
  }
946
445
 
947
- 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
+ };
948
491
  return {
949
492
  sendMedia: async (filePath, { method = "audio", caption, filename } = {}) => {
950
- logger?.log("telegram", `sending ${method} reply for chat ${chatId}`);
493
+ logger?.log("telegram", `sending ${method} reply for chat ${route.transportChatId}`);
951
494
  const input = new InputFile(filePath, filename || undefined);
952
- if (method === "voice") return bot.api.sendVoice(chatId, input, { caption });
953
- if (method === "document") return bot.api.sendDocument(chatId, input, { caption });
954
- if (method === "photo" || method === "image") return bot.api.sendPhoto(chatId, input, { caption });
955
- if (method === "video") return bot.api.sendVideo(chatId, input, { caption });
956
- return bot.api.sendAudio(chatId, input, { caption });
957
- }
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
958
522
  };
959
523
  }
960
524
 
@@ -966,7 +530,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
966
530
  : artifact.kind === "video" || artifact.mimeType?.startsWith("video/") ? "video"
967
531
  : "document");
968
532
  const safeCaption = caption && !/(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(caption) ? caption : undefined;
969
- 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, {
970
540
  method: resolvedMethod,
971
541
  caption: safeCaption,
972
542
  filename: path.basename(artifact.path)
@@ -975,54 +545,63 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
975
545
  });
976
546
 
977
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;
978
560
  const work = async () => {
979
- const { session, speedController } = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
980
- const historyRevision = getChatState(chatId).historyRevision;
981
- await conversationHistory.ensureSeed(chatId, {
982
- runtime: "pi",
983
- 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)
984
572
  });
985
573
  let text = "";
986
- let steeredPrompts = [];
987
- const chatState = getChatState(chatId);
574
+ const chatState = getChatState(sessionId);
988
575
  chatState.activeSession = session;
989
- chatState.activeSteers = [];
576
+ chatState.activeRoute = route;
990
577
  try {
991
578
  text = await withPromptSpeed({
992
579
  speedController,
993
580
  speed: isScheduledTaskPrompt(prompt) ? 1 : undefined,
994
- restoreSpeed: () => clampModelSpeed(session.model, resolveChatSpeed(config, chatId))
581
+ restoreSpeed: () => clampModelSpeed(session.model, resolveChatSpeed(config, sessionId))
995
582
  }, () => collectText(session, prompt, {
996
583
  logger,
997
- chatId,
584
+ chatId: sessionId,
998
585
  onSlowPrompt: () => bot.api.sendMessage(
999
- chatId,
1000
- "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()
1001
589
  )
1002
590
  }));
1003
591
  } catch (error) {
1004
- agentManager.resetSession(chatId);
592
+ agentManager.resetSession(sessionId);
1005
593
  throw error;
1006
594
  } finally {
1007
- steeredPrompts = [...chatState.activeSteers];
1008
595
  if (chatState.activeSession === session) chatState.activeSession = null;
1009
- chatState.activeSteers = [];
1010
- }
1011
- if (getChatState(chatId).historyRevision === historyRevision) {
1012
- const historyPrompt = steeredPrompts.length
1013
- ? [prompt, ...steeredPrompts.map((message) => `[Steering message]\n${message}`)].join("\n\n")
1014
- : prompt;
1015
- await conversationHistory.appendTurn(chatId, {
1016
- runtime: "pi",
1017
- prompt: historyPrompt,
1018
- response: text
1019
- });
596
+ chatState.activeRoute = null;
1020
597
  }
1021
598
  if (text) {
599
+ await createWorkspaceAccessGuard(route)();
1022
600
  await sendTextReply({
1023
- sendText: (message, extra) => bot.api.sendMessage(chatId, message, extra),
1024
- sendDocument: (file, extra) => bot.api.sendDocument(chatId, file, extra),
1025
- 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,
1026
605
  text
1027
606
  });
1028
607
  }
@@ -1036,11 +615,18 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1036
615
  const chatState = getChatState(chatId);
1037
616
 
1038
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
+ );
1039
624
  const routed = await routeBusyPrompt({
1040
625
  chatState,
1041
626
  prompt,
1042
- mode: busyMessageMode,
1043
- replaceQueued
627
+ mode: sameDelivery ? busyMessageMode : "queue",
628
+ replaceQueued,
629
+ ctx
1044
630
  });
1045
631
  if (routed.disposition === "steered") {
1046
632
  logger?.log("telegram", `chat ${chatId} busy, steering ${label}`);
@@ -1082,25 +668,27 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1082
668
  }
1083
669
 
1084
670
  async function enqueueOrProcess(ctx) {
1085
- const chatState = getChatState(ctx.chat.id);
671
+ const route = contextRoute(ctx);
672
+ const chatState = getChatState(route.sessionId);
1086
673
 
1087
674
  if (chatState.processing) {
1088
675
  await ensureQueuedTelegramTyping(chatState, ctx);
1089
- const incomingPrompt = await buildIncomingPrompt(ctx);
676
+ const incomingPrompt = await buildIncomingPrompt(ctx, route);
1090
677
  const busyMessageMode = typeof ctx.message?.text === "string"
1091
- ? resolveTelegramBusyMessageMode(config, ctx.chat.id)
678
+ ? resolveTelegramBusyMessageMode(config, route.sessionId)
1092
679
  : "queue";
1093
680
  return enqueuePrompt({
1094
- chatId: ctx.chat.id,
681
+ chatId: route.sessionId,
1095
682
  prompt: incomingPrompt,
1096
683
  label: `message ${ctx.msg.message_id}`,
1097
- busyMessageMode
684
+ busyMessageMode,
685
+ ctx
1098
686
  });
1099
687
  }
1100
688
 
1101
- const incomingPrompt = await buildIncomingPrompt(ctx);
689
+ const incomingPrompt = await buildIncomingPrompt(ctx, route);
1102
690
  return enqueuePrompt({
1103
- chatId: ctx.chat.id,
691
+ chatId: route.sessionId,
1104
692
  prompt: incomingPrompt,
1105
693
  label: `message ${ctx.msg.message_id}`,
1106
694
  ctx
@@ -1117,6 +705,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1117
705
  logger?.log("telegram", `startup message failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
1118
706
  }
1119
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
+ }
1120
714
  }
1121
715
 
1122
716
  function scheduleStartupMessages({ skipAgentStartupPrompts = false } = {}) {
@@ -1132,91 +726,48 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1132
726
  timer.unref?.();
1133
727
  }
1134
728
 
1135
- async function enqueueAsyncPrompt({ chatId, prompt, label }) {
1136
- const ctx = { chat: { id: chatId }, api: bot.api };
1137
- const chatState = getChatState(chatId);
1138
- if (chatState.processing) await ensureQueuedTelegramTyping(chatState, ctx);
1139
- return enqueuePrompt({ chatId, prompt, label, ctx });
1140
- }
1141
-
1142
- async function dispatchTask(task) {
1143
- const chatId = task.payload?.chatId;
1144
- if (!chatId) {
1145
- await taskStore.fail(task.id, `Task missing chatId: ${task.kind}`);
1146
- return;
1147
- }
1148
-
1149
- if (task.kind === "agent_task") {
1150
- if (!task.payload.prompt) {
1151
- await taskStore.fail(task.id, "agent_task missing prompt");
1152
- return;
1153
- }
1154
- logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
1155
- await enqueueAsyncPrompt({
1156
- chatId,
1157
- prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
1158
- label: `scheduled task ${task.id}`
1159
- });
1160
- await taskStore.complete(task.id);
1161
- 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);
1162
741
  }
1163
-
1164
- if (task.kind === "agent_event") {
1165
- logger?.log("tasks", `agent event ${task.id} for chat ${chatId}`);
1166
- const acknowledgement = String(task.payload?.acknowledgement || "").trim();
1167
- if (acknowledgement) {
1168
- try {
1169
- await bot.api.sendMessage(chatId, acknowledgement);
1170
- } catch (error) {
1171
- logger?.log("telegram", `agent event acknowledgement failed for chat ${chatId}: ${error instanceof Error ? error.message : String(error)}`);
1172
- }
1173
- }
1174
- await enqueueAsyncPrompt({
1175
- chatId,
1176
- prompt: await buildAsyncEventPrompt(task, resourceNotes),
1177
- label: `agent event ${task.id}`
1178
- });
1179
- await taskStore.complete(task.id);
1180
- return;
1181
- }
1182
-
1183
- if (task.kind === "poll_tool") {
1184
- const toolName = task.payload?.toolName;
1185
- if (!toolName) {
1186
- await taskStore.fail(task.id, "poll_tool missing toolName");
1187
- return;
1188
- }
1189
- logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
1190
- try {
1191
- await agentManager.runTool({
1192
- name: toolName,
1193
- request: { args: task.payload.args || {} },
1194
- chatId
1195
- });
1196
- } catch (error) {
1197
- logger?.log("tasks", `poll_tool ${toolName} failed: ${error instanceof Error ? error.message : String(error)}`);
1198
- }
1199
- await taskStore.complete(task.id);
1200
- return;
1201
- }
1202
-
1203
- 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 });
1204
746
  }
1205
747
 
1206
- async function dispatchDueTasks() {
1207
- const tasks = await taskStore.claimDue(10);
1208
- for (const task of tasks) {
1209
- try {
1210
- await dispatchTask(task);
1211
- } catch (error) {
1212
- await taskStore.fail(task.id, error instanceof Error ? error.message : String(error));
1213
- }
1214
- }
1215
- }
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
+ });
1216
758
 
1217
- 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
+ }) {
1218
766
  try {
1219
- 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
+ });
1220
771
  const parentSession = context.session.sessionFile || "";
1221
772
  if (!context.session.messages.length) return { handoff: "", parentSession: "" };
1222
773
 
@@ -1229,19 +780,21 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1229
780
  }
1230
781
 
1231
782
  async function handleNewCommand(ctx) {
1232
- const chatState = getChatState(ctx.chat.id);
783
+ const route = contextRoute(ctx);
784
+ const sessionId = route.sessionId;
785
+ const chatState = getChatState(sessionId);
1233
786
  const wasProcessing = chatState.processing;
1234
787
  chatState.historyRevision += 1;
1235
788
  const commandRevision = chatState.historyRevision;
1236
789
  const prompt = buildNewSessionPrompt(ctx);
1237
790
 
1238
791
  if (wasProcessing) {
1239
- logger?.log("telegram", `chat ${ctx.chat.id} busy, queueing new-session command`);
1240
- queueChatPrompt(chatState, prompt, { replace: true });
792
+ logger?.log("telegram", `chat ${sessionId} busy, queueing new-session command`);
793
+ queueChatPrompt(chatState, prompt, { replace: true, ctx });
1241
794
  chatState.continueAfterClose = true;
1242
795
  const reset = (async () => {
1243
- await conversationHistory.reset(ctx.chat.id, { runtime: "pi" });
1244
- agentManager.resetSession(ctx.chat.id);
796
+ await sessionSeeds.clear(sessionId);
797
+ agentManager.resetSession(sessionId);
1245
798
  })();
1246
799
  chatState.beforeNextPrompt = reset;
1247
800
  try {
@@ -1253,21 +806,22 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1253
806
  }
1254
807
 
1255
808
  chatState.processing = true;
1256
- logger?.log("telegram", `processing new-session command in chat ${ctx.chat.id}`);
809
+ logger?.log("telegram", `processing new-session command in chat ${sessionId}`);
1257
810
  await processChatPromptQueue({
1258
- chatId: ctx.chat.id,
811
+ chatId: sessionId,
1259
812
  prompt,
1260
813
  label: "new-session command",
1261
814
  ctx,
1262
815
  beforeInitialPrompt: async () => {
1263
- const handoff = await withTyping(ctx, () => summarizeSessionBeforeReset(ctx.chat.id));
816
+ const handoff = await withTyping(ctx, () => summarizeSessionBeforeReset(sessionId, route));
1264
817
  if (chatState.historyRevision !== commandRevision) return;
1265
- await conversationHistory.reset(ctx.chat.id, {
1266
- runtime: "pi",
1267
- history: handoff.handoff
1268
- });
818
+ if (route.workspace && route.threadId) {
819
+ await sessionSeeds.set(sessionId, handoff.handoff);
820
+ } else {
821
+ await sessionSeeds.clear(sessionId);
822
+ }
1269
823
  if (chatState.historyRevision !== commandRevision) return;
1270
- agentManager.resetSession(ctx.chat.id, handoff);
824
+ agentManager.resetSession(sessionId, handoff);
1271
825
  }
1272
826
  });
1273
827
  }
@@ -1278,13 +832,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1278
832
  });
1279
833
 
1280
834
  bot.command("start", async (ctx) => {
1281
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
835
+ const auth = await authorizeContext(ctx);
1282
836
  if (!auth.ok) return;
1283
837
  return ctx.reply(auth.firstTime ? "This chat is now authorized for Arisa." : "Arisa is ready.");
1284
838
  });
1285
839
 
1286
840
  bot.command("new", async (ctx) => {
1287
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
841
+ const auth = await authorizeContext(ctx);
1288
842
  if (!auth.ok) return;
1289
843
  if (piAuthIssue) {
1290
844
  await ctx.reply(buildPiAuthRecoveryBlockedMessage({
@@ -1301,7 +855,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1301
855
  bot.command("restart", handleRestartCommand);
1302
856
 
1303
857
  bot.command("doctor", async (ctx) => {
1304
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
858
+ const auth = await authorizeContext(ctx);
1305
859
  if (!auth.ok) return;
1306
860
  const pending = await ctx.reply(renderTelegramHtml("```text\nRunning Arisa Doctor…\n```"), { parse_mode: "HTML" });
1307
861
  try {
@@ -1318,11 +872,11 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1318
872
  });
1319
873
 
1320
874
  bot.command("update", async (ctx) => {
1321
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
875
+ const auth = await authorizeContext(ctx);
1322
876
  if (!auth.ok) return;
1323
877
  const pending = await ctx.reply(renderTelegramHtml("```text\nChecking Arisa and official tool updates…\n```"), { parse_mode: "HTML" });
1324
878
  try {
1325
- const report = await checkUpdates(ctx.chat.id);
879
+ const report = await checkUpdates(contextRoute(ctx).scopeChatId);
1326
880
  const picker = buildUpdatePicker(report);
1327
881
  await ctx.api.editMessageText(
1328
882
  ctx.chat.id,
@@ -1337,31 +891,31 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1337
891
  });
1338
892
 
1339
893
  bot.command("tools", async (ctx) => {
1340
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
894
+ const auth = await authorizeContext(ctx);
1341
895
  if (!auth.ok) return;
1342
- 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" });
1343
897
  });
1344
898
 
1345
899
  bot.command("model", async (ctx) => {
1346
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
900
+ const auth = await authorizeContext(ctx);
1347
901
  if (!auth.ok) return;
1348
902
  await showModelPicker(ctx);
1349
903
  });
1350
904
 
1351
905
  bot.command("effort", async (ctx) => {
1352
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
906
+ const auth = await authorizeContext(ctx);
1353
907
  if (!auth.ok) return;
1354
908
  await showEffortPicker(ctx);
1355
909
  });
1356
910
 
1357
911
  bot.command("speed", async (ctx) => {
1358
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
912
+ const auth = await authorizeContext(ctx);
1359
913
  if (!auth.ok) return;
1360
914
  await showSpeedPicker(ctx);
1361
915
  });
1362
916
 
1363
917
  bot.command("auth", async (ctx) => {
1364
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
918
+ const auth = await authorizeContext(ctx);
1365
919
  if (!auth.ok) return;
1366
920
 
1367
921
  const status = getPiAuthStatus(config, ctx.chat.id);
@@ -1393,222 +947,23 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1393
947
  }
1394
948
  });
1395
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
+
1396
964
  bot.on("callback_query:data", async (ctx, next) => {
1397
965
  if (await handleUpdateCallback(ctx)) return;
1398
- const modelAction = parseModelPickerAction(ctx.callbackQuery.data);
1399
- const effortAction = modelAction ? null : parseEffortPickerAction(ctx.callbackQuery.data);
1400
- const speedAction = modelAction || effortAction ? null : parseSpeedPickerAction(ctx.callbackQuery.data);
1401
- const action = modelAction || effortAction || speedAction;
1402
- if (!action) return next();
1403
- if (action.type === "noop") {
1404
- await ctx.answerCallbackQuery();
1405
- return;
1406
- }
1407
-
1408
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig });
1409
- if (!auth.ok) {
1410
- await ctx.answerCallbackQuery({ text: "This chat is not authorized.", show_alert: true });
1411
- return;
1412
- }
1413
-
1414
- try {
1415
- if (action.type === "page") {
1416
- await showModelPicker(ctx, action.value);
1417
- await ctx.answerCallbackQuery();
1418
- return;
1419
- }
1420
-
1421
- const models = await getProviderModels(ctx.chat.id);
1422
- const chatBusy = getChatState(ctx.chat.id).processing;
1423
-
1424
- if (action.type === "select") {
1425
- const model = models[action.value];
1426
- if (!model) {
1427
- await ctx.answerCallbackQuery({
1428
- text: "This model list is no longer current. Run /model again.",
1429
- show_alert: true
1430
- });
1431
- return;
1432
- }
1433
-
1434
- // Reasoning models open the effort picker only — no session reset yet.
1435
- if (modelSupportsThinking(model)) {
1436
- await showEffortPicker(ctx, {
1437
- model,
1438
- modelIndex: action.value,
1439
- selectedThinkingLevel: clampModelThinkingLevel(model, resolveChatThinkingLevel(config, ctx.chat.id))
1440
- });
1441
- await ctx.answerCallbackQuery({ text: `Choose effort for ${model.id}.` });
1442
- return;
1443
- }
1444
-
1445
- if (chatBusy) {
1446
- await ctx.answerCallbackQuery({
1447
- text: "Wait for the current response before changing models.",
1448
- show_alert: true
1449
- });
1450
- return;
1451
- }
1452
-
1453
- const currentModelId = resolveChatModel(config, ctx.chat.id);
1454
- const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
1455
- if (model.id === currentModelId && currentEffort === "off") {
1456
- await closeModelPicker(ctx, {
1457
- messageText: `Already using ${model.provider}/${model.id}.`,
1458
- callbackText: `Already using ${model.id}.`
1459
- });
1460
- return;
1461
- }
1462
-
1463
- await persistChatModel(ctx.chat.id, model, "off");
1464
- await ctx.api.editMessageText(
1465
- ctx.chat.id,
1466
- ctx.callbackQuery.message.message_id,
1467
- `Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
1468
- );
1469
- await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
1470
- return;
1471
- }
1472
-
1473
- if (action.type === "model-effort") {
1474
- const model = models[action.modelIndex];
1475
- if (!model) {
1476
- await ctx.answerCallbackQuery({
1477
- text: "This model list is no longer current. Run /model again.",
1478
- show_alert: true
1479
- });
1480
- return;
1481
- }
1482
- const levels = listModelThinkingLevels(model);
1483
- if (!levels.includes(action.level)) {
1484
- await ctx.answerCallbackQuery({
1485
- text: "That effort level is not available for this model.",
1486
- show_alert: true
1487
- });
1488
- return;
1489
- }
1490
-
1491
- const currentModelId = resolveChatModel(config, ctx.chat.id);
1492
- const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
1493
- if (model.id === currentModelId && action.level === currentEffort) {
1494
- await closeModelPicker(ctx, {
1495
- messageText: `Already using ${model.provider}/${model.id} (effort: ${action.level}).`,
1496
- callbackText: `Already using ${model.id} at ${action.level}.`
1497
- });
1498
- return;
1499
- }
1500
-
1501
- // Effort-only updates do not reset the session, so they are safe while busy.
1502
- if (model.id === currentModelId) {
1503
- await persistChatEffort(ctx.chat.id, model, action.level);
1504
- await ctx.api.editMessageText(
1505
- ctx.chat.id,
1506
- ctx.callbackQuery.message.message_id,
1507
- `Effort set to ${action.level} for ${model.provider}/${model.id}.`
1508
- );
1509
- await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
1510
- return;
1511
- }
1512
-
1513
- if (chatBusy) {
1514
- await ctx.answerCallbackQuery({
1515
- text: "Wait for the current response before changing models.",
1516
- show_alert: true
1517
- });
1518
- return;
1519
- }
1520
-
1521
- await persistChatModel(ctx.chat.id, model, action.level);
1522
- await ctx.api.editMessageText(
1523
- ctx.chat.id,
1524
- ctx.callbackQuery.message.message_id,
1525
- `Model changed to ${model.provider}/${model.id} (effort: ${action.level}).\nA new chat context will start with your next message.`
1526
- );
1527
- await ctx.answerCallbackQuery({ text: `Using ${model.id} / ${action.level}.` });
1528
- return;
1529
- }
1530
-
1531
- if (action.type === "effort") {
1532
- const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
1533
- if (!model) {
1534
- await ctx.answerCallbackQuery({
1535
- text: "Current model is unavailable. Run /model again.",
1536
- show_alert: true
1537
- });
1538
- return;
1539
- }
1540
- if (!modelSupportsThinking(model)) {
1541
- await ctx.answerCallbackQuery({
1542
- text: "This model does not support effort levels.",
1543
- show_alert: true
1544
- });
1545
- return;
1546
- }
1547
- const levels = listModelThinkingLevels(model);
1548
- if (!levels.includes(action.level)) {
1549
- await ctx.answerCallbackQuery({
1550
- text: "That effort level is not available for this model.",
1551
- show_alert: true
1552
- });
1553
- return;
1554
- }
1555
- const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
1556
- if (action.level === currentEffort) {
1557
- await closeModelPicker(ctx, {
1558
- messageText: `Already using effort ${action.level} for ${model.provider}/${model.id}.`,
1559
- callbackText: `Already using effort ${action.level}.`
1560
- });
1561
- return;
1562
- }
1563
- await persistChatEffort(ctx.chat.id, model, action.level);
1564
- await ctx.api.editMessageText(
1565
- ctx.chat.id,
1566
- ctx.callbackQuery.message.message_id,
1567
- `Effort set to ${action.level} for ${model.provider}/${model.id}.`
1568
- );
1569
- await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
1570
- return;
1571
- }
1572
-
1573
- if (action.type === "speed") {
1574
- const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
1575
- if (!model) {
1576
- await ctx.answerCallbackQuery({
1577
- text: "Current model is unavailable. Run /model again.",
1578
- show_alert: true
1579
- });
1580
- return;
1581
- }
1582
- if (!modelSupportsSpeed(model)) {
1583
- await ctx.answerCallbackQuery({
1584
- text: "This model does not support speed 1.5x.",
1585
- show_alert: true
1586
- });
1587
- return;
1588
- }
1589
- const currentSpeed = resolveChatSpeed(config, ctx.chat.id);
1590
- if (action.speed === currentSpeed) {
1591
- await closeModelPicker(ctx, {
1592
- messageText: `Already using speed ${action.speed.toFixed(1)}x for ${model.provider}/${model.id}.`,
1593
- callbackText: `Already using speed ${action.speed.toFixed(1)}x.`
1594
- });
1595
- return;
1596
- }
1597
- await persistChatSpeed(ctx.chat.id, model, action.speed);
1598
- await ctx.api.editMessageText(
1599
- ctx.chat.id,
1600
- ctx.callbackQuery.message.message_id,
1601
- `Speed set to ${action.speed.toFixed(1)}x for ${model.provider}/${model.id}.`
1602
- );
1603
- await ctx.answerCallbackQuery({ text: `Speed: ${action.speed.toFixed(1)}x.` });
1604
- }
1605
- } catch (error) {
1606
- logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
1607
- await ctx.answerCallbackQuery({
1608
- text: "Could not change the model, effort, or speed.",
1609
- show_alert: true
1610
- }).catch(() => {});
1611
- }
966
+ return handleModelCallback(ctx, next);
1612
967
  });
1613
968
 
1614
969
  bot.on("message_reaction", async (ctx) => {
@@ -1631,8 +986,9 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
1631
986
  });
1632
987
 
1633
988
  bot.on("message", async (ctx) => {
1634
- const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
989
+ const auth = await authorizeContext(ctx);
1635
990
  if (!auth.ok) return;
991
+ if (!isProcessableTelegramMessage(ctx.message)) return;
1636
992
 
1637
993
  const command = getTelegramCommand(ctx);
1638
994
  if (command) return;