arisa 5.1.49 → 5.1.60

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 (37) hide show
  1. package/AGENTS.md +0 -2
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +39 -489
  4. package/src/core/agent/agent-session-lifecycle.js +181 -0
  5. package/src/core/agent/pi-capability-tools.js +183 -0
  6. package/src/core/artifacts/artifact-store.js +73 -17
  7. package/src/core/capabilities/capability-service.js +340 -0
  8. package/src/core/tasks/task-routing.js +7 -0
  9. package/src/core/tasks/task-runner.js +53 -0
  10. package/src/core/tasks/task-store.js +316 -92
  11. package/src/core/tools/tool-output-materializer.js +5 -5
  12. package/src/official-tools.lock.json +7 -4
  13. package/src/runtime/arisa-capabilities.js +51 -242
  14. package/src/runtime/create-app.js +10 -1
  15. package/src/runtime/create-headless-app.js +5 -2
  16. package/src/transport/telegram/bot.js +112 -368
  17. package/src/transport/telegram/chat-queue.js +72 -6
  18. package/src/transport/telegram/prompt-builders.js +9 -0
  19. package/src/transport/telegram/task-dispatcher.js +73 -36
  20. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  21. package/src/transport/telegram/telegram-session-bridge.js +170 -0
  22. package/src/transport/telegram/telegram-tools-command.js +28 -0
  23. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  24. package/test/agent-session-lifecycle.test.js +58 -0
  25. package/test/artifact-store.test.js +38 -2
  26. package/test/capabilities-security.test.js +58 -0
  27. package/test/context-and-task-bounds.test.js +76 -1
  28. package/test/device-code-message.test.js +9 -0
  29. package/test/media-caption.test.js +1 -1
  30. package/test/pi-capability-tools.test.js +65 -0
  31. package/test/session-start-operational-notes.test.js +1 -1
  32. package/test/task-idempotency.test.js +40 -0
  33. package/test/task-routing.test.js +62 -0
  34. package/test/task-store.test.js +178 -6
  35. package/test/telegram-task-dispatcher.test.js +99 -23
  36. package/test/telegram-text-artifact.test.js +13 -2
  37. package/test/telegram-tools-command.test.js +47 -0
@@ -6,6 +6,8 @@ export function createChatStateStore() {
6
6
  processing: false,
7
7
  pendingPrompts: [],
8
8
  pendingPromptContexts: [],
9
+ pendingPromptReceipts: [],
10
+ pendingPromptCoalescible: [],
9
11
  continueAfterClose: false,
10
12
  historyRevision: 0,
11
13
  beforeNextPrompt: null,
@@ -29,20 +31,59 @@ export function createChatStateStore() {
29
31
  };
30
32
  }
31
33
 
32
- export function queueChatPrompt(chatState, prompt, { replace = false, ctx = null } = {}) {
34
+ export function createPromptExecutionReceipt() {
35
+ let resolve;
36
+ let reject;
37
+ const promise = new Promise((resolvePromise, rejectPromise) => {
38
+ resolve = resolvePromise;
39
+ reject = rejectPromise;
40
+ });
41
+ return { promise, resolve, reject };
42
+ }
43
+
44
+ function rejectQueuedReceipts(chatState, error) {
45
+ for (const receipt of chatState.pendingPromptReceipts || []) receipt?.reject(error);
46
+ }
47
+
48
+ const COALESCED_PROMPT_SEPARATOR = "\n\n--- next direct message ---\n\n";
49
+
50
+ function coalesceLastQueuedPrompt(chatState, prompt, ctx) {
51
+ const coalescible = chatState.pendingPromptCoalescible ||= [];
52
+ const index = chatState.pendingPrompts.length - 1;
53
+ if (index < 0 || !coalescible[index]) return false;
54
+ chatState.pendingPrompts[index] += `${COALESCED_PROMPT_SEPARATOR}${prompt}`;
55
+ (chatState.pendingPromptContexts ||= [])[index] = ctx;
56
+ return true;
57
+ }
58
+
59
+ export function queueChatPrompt(chatState, prompt, {
60
+ replace = false,
61
+ ctx = null,
62
+ receipt = null,
63
+ coalescible = false
64
+ } = {}) {
33
65
  chatState.pendingPromptContexts ||= [];
66
+ chatState.pendingPromptReceipts ||= [];
67
+ chatState.pendingPromptCoalescible ||= [];
34
68
  if (replace) {
69
+ rejectQueuedReceipts(chatState, Object.assign(new Error("Queued prompt was superseded"), { code: "PROMPT_SUPERSEDED" }));
35
70
  chatState.pendingPrompts = [];
36
71
  chatState.pendingPromptContexts = [];
72
+ chatState.pendingPromptReceipts = [];
73
+ chatState.pendingPromptCoalescible = [];
37
74
  }
38
75
  chatState.pendingPrompts.push(prompt);
39
76
  chatState.pendingPromptContexts.push(ctx);
77
+ chatState.pendingPromptReceipts.push(receipt);
78
+ chatState.pendingPromptCoalescible.push(coalescible);
40
79
  }
41
80
 
42
81
  function takeQueuedPrompt(chatState) {
82
+ (chatState.pendingPromptCoalescible ||= []).shift();
43
83
  return {
44
84
  prompt: chatState.pendingPrompts.shift() || "",
45
- ctx: (chatState.pendingPromptContexts ||= []).shift() || null
85
+ ctx: (chatState.pendingPromptContexts ||= []).shift() || null,
86
+ receipt: (chatState.pendingPromptReceipts ||= []).shift() || null
46
87
  };
47
88
  }
48
89
 
@@ -52,7 +93,19 @@ export function resolveTelegramBusyMessageMode(config, chatId) {
52
93
  return mode === "steer" ? "steer" : "queue";
53
94
  }
54
95
 
55
- export async function routeBusyPrompt({ chatState, prompt, mode = "queue", replaceQueued = false, ctx = null }) {
96
+ export async function routeBusyPrompt({
97
+ chatState,
98
+ prompt,
99
+ mode = "queue",
100
+ replaceQueued = false,
101
+ ctx = null,
102
+ receipt = null,
103
+ coalesceQueued = false
104
+ }) {
105
+ if (coalesceQueued && !replaceQueued && !receipt && coalesceLastQueuedPrompt(chatState, prompt, ctx)) {
106
+ return { disposition: "coalesced" };
107
+ }
108
+
56
109
  const session = chatState.activeSession;
57
110
  if (
58
111
  mode === "steer"
@@ -61,17 +114,23 @@ export async function routeBusyPrompt({ chatState, prompt, mode = "queue", repla
61
114
  && !chatState.beforeNextPrompt
62
115
  && session?.isStreaming
63
116
  && typeof session.steer === "function"
117
+ && !receipt
64
118
  ) {
65
119
  try {
66
120
  await session.steer(prompt);
67
121
  return { disposition: "steered" };
68
122
  } catch (error) {
69
- queueChatPrompt(chatState, prompt, { ctx });
123
+ queueChatPrompt(chatState, prompt, { ctx, receipt, coalescible: coalesceQueued });
70
124
  return { disposition: "queued", steerError: error };
71
125
  }
72
126
  }
73
127
 
74
- queueChatPrompt(chatState, prompt, { replace: replaceQueued, ctx });
128
+ queueChatPrompt(chatState, prompt, {
129
+ replace: replaceQueued,
130
+ ctx,
131
+ receipt,
132
+ coalescible: coalesceQueued
133
+ });
75
134
  return { disposition: "queued" };
76
135
  }
77
136
 
@@ -84,6 +143,7 @@ export async function drainChatPromptQueue({
84
143
  chatState,
85
144
  initialPrompt,
86
145
  initialCtx = null,
146
+ initialReceipt = null,
87
147
  processPrompt,
88
148
  onPromptFailure,
89
149
  onPromptInterrupted,
@@ -91,6 +151,7 @@ export async function drainChatPromptQueue({
91
151
  }) {
92
152
  let currentPrompt = initialPrompt;
93
153
  let currentCtx = initialCtx;
154
+ let currentReceipt = initialReceipt;
94
155
 
95
156
  try {
96
157
  await beforeInitialPrompt?.();
@@ -104,11 +165,14 @@ export async function drainChatPromptQueue({
104
165
  const queued = takeQueuedPrompt(chatState);
105
166
  currentPrompt = queued.prompt;
106
167
  currentCtx = queued.ctx;
168
+ currentReceipt = queued.receipt;
107
169
  chatState.continueAfterClose = false;
108
170
  }
109
171
  try {
110
- await processPrompt({ prompt: currentPrompt, ctx: currentCtx });
172
+ await processPrompt({ prompt: currentPrompt, ctx: currentCtx, receipt: currentReceipt });
173
+ currentReceipt?.resolve({ status: "completed" });
111
174
  } catch (error) {
175
+ currentReceipt?.reject(error);
112
176
  if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
113
177
  await onPromptInterrupted?.(error);
114
178
  } else {
@@ -117,11 +181,13 @@ export async function drainChatPromptQueue({
117
181
  }
118
182
  } finally {
119
183
  currentCtx = null;
184
+ currentReceipt = null;
120
185
  }
121
186
 
122
187
  const queued = takeQueuedPrompt(chatState);
123
188
  currentPrompt = queued.prompt;
124
189
  currentCtx = queued.ctx;
190
+ currentReceipt = queued.receipt;
125
191
  chatState.continueAfterClose = false;
126
192
  }
127
193
  } finally {
@@ -1,5 +1,6 @@
1
1
  import { formatLocationText } from "./media.js";
2
2
  import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
3
+ import { clampModelSpeed } from "../../core/agent/model-speed.js";
3
4
 
4
5
  const slowPromptNoticeMs = 300_000;
5
6
 
@@ -169,6 +170,14 @@ export function isScheduledTaskPrompt(prompt) {
169
170
  return String(prompt || "").startsWith("Scheduled task fired.\n");
170
171
  }
171
172
 
173
+ export function scheduledPromptSpeedOptions({ prompt, session, speedController, configuredSpeed }) {
174
+ return {
175
+ speedController,
176
+ speed: isScheduledTaskPrompt(prompt) ? 1 : undefined,
177
+ restoreSpeed: () => clampModelSpeed(session.model, configuredSpeed)
178
+ };
179
+ }
180
+
172
181
  export async function withPromptSpeed({ speedController, speed, restoreSpeed }, work) {
173
182
  if (!speedController || speed === undefined) return work();
174
183
  speedController.setSpeed(speed);
@@ -1,9 +1,48 @@
1
+ import { NonRetryableTaskError, createTaskRunner } from "../../core/tasks/task-runner.js";
1
2
  import { buildAsyncEventPrompt, buildAsyncTaskPrompt } from "./prompt-builders.js";
2
3
 
3
4
  function errorMessage(error) {
4
5
  return error instanceof Error ? error.message : String(error);
5
6
  }
6
7
 
8
+ function requireChatId(task) {
9
+ const chatId = task.payload?.chatId;
10
+ if (chatId == null || chatId === "") {
11
+ throw new NonRetryableTaskError(`Task missing chatId: ${task.kind}`);
12
+ }
13
+ return chatId;
14
+ }
15
+
16
+ function safeErrorSummary(error) {
17
+ return errorMessage(error)
18
+ .replace(/(bearer\s+)[^\s]+/gi, "$1[redacted]")
19
+ .replace(/((?:api[_-]?key|token|secret|password)\s*[=:]\s*)[^\s,;]+/gi, "$1[redacted]")
20
+ .replace(/\s+/g, " ")
21
+ .trim()
22
+ .slice(0, 300) || "Unknown error";
23
+ }
24
+
25
+ function failureDestination(task) {
26
+ const destination = task.route?.transport === "telegram" ? task.route.destination : null;
27
+ return {
28
+ chatId: destination?.chatId || task.payload?.chatId,
29
+ threadId: destination?.threadId || null
30
+ };
31
+ }
32
+
33
+ function buildFailureNotice({ task, result, error }) {
34
+ const uncertain = result?.status === "outcome_uncertain";
35
+ const recurring = result?.terminalFailure === true && result?.status === "pending";
36
+ const lines = [
37
+ uncertain ? "⚠️ Arisa task outcome is uncertain" : "⚠️ Arisa task failed",
38
+ `Task: ${task.kind || "unknown"} (${task.id})`,
39
+ `Error: ${safeErrorSummary(error)}`
40
+ ];
41
+ if (recurring) lines.push(`Next run: ${result.runAt}`);
42
+ else lines.push("No further retries are scheduled.");
43
+ return lines.join("\n");
44
+ }
45
+
7
46
  export function createTelegramTaskDispatcher({
8
47
  taskStore,
9
48
  sendMessage,
@@ -15,21 +54,18 @@ export function createTelegramTaskDispatcher({
15
54
  logger
16
55
  }) {
17
56
  async function dispatchAgentTask(task, chatId) {
18
- if (!task.payload.prompt) {
19
- await taskStore.fail(task.id, "agent_task missing prompt");
20
- return;
21
- }
57
+ if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
22
58
  logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
23
59
  await enqueueAsyncPrompt({
24
60
  chatId,
25
61
  prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
26
62
  label: `scheduled task ${task.id}`,
27
- telegramContext: task.payload.telegramContext
63
+ route: task.route
28
64
  });
29
- await taskStore.complete(task.id);
30
65
  }
31
66
 
32
67
  async function dispatchAgentEvent(task, chatId) {
68
+ if (!task.payload?.prompt) throw new NonRetryableTaskError("agent_event missing prompt");
33
69
  logger?.log("tasks", `agent event ${task.id} for chat ${chatId}`);
34
70
  const acknowledgement = String(task.payload?.acknowledgement || "").trim();
35
71
  if (acknowledgement) {
@@ -43,52 +79,53 @@ export function createTelegramTaskDispatcher({
43
79
  chatId,
44
80
  prompt: await buildAsyncEventPrompt(task, resourceNotes),
45
81
  label: `agent event ${task.id}`,
46
- telegramContext: task.payload.telegramContext
82
+ route: task.route
47
83
  });
48
- await taskStore.complete(task.id);
49
84
  }
50
85
 
51
86
  async function dispatchPollTool(task, chatId) {
52
87
  const toolName = task.payload?.toolName;
53
- if (!toolName) {
54
- await taskStore.fail(task.id, "poll_tool missing toolName");
55
- return;
56
- }
88
+ if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
57
89
  logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
58
- try {
59
- await agentManager.runTool({
60
- name: toolName,
61
- request: { args: task.payload.args || {} },
62
- chatId
63
- });
64
- } catch (error) {
65
- logger?.log("tasks", `poll_tool ${toolName} failed: ${errorMessage(error)}`);
90
+ const result = await agentManager.runTool({
91
+ name: toolName,
92
+ request: { args: task.payload.args || {} },
93
+ chatId
94
+ });
95
+ if (result?.ok === false) {
96
+ const error = new Error(result.error || `poll_tool ${toolName} failed`);
97
+ if (result.status === "needs_config") error.retryable = false;
98
+ if (result.status === "outcome_uncertain") {
99
+ error.retryable = false;
100
+ error.outcomeUncertain = true;
101
+ }
102
+ throw error;
66
103
  }
67
- await taskStore.complete(task.id);
68
104
  }
69
105
 
70
106
  async function dispatchTask(task) {
71
- const chatId = task.payload?.chatId;
72
- if (!chatId) {
73
- await taskStore.fail(task.id, `Task missing chatId: ${task.kind}`);
74
- return;
75
- }
107
+ const chatId = requireChatId(task);
76
108
  if (task.kind === "agent_task") return dispatchAgentTask(task, chatId);
77
109
  if (task.kind === "agent_event") return dispatchAgentEvent(task, chatId);
78
110
  if (task.kind === "poll_tool") return dispatchPollTool(task, chatId);
79
- await taskStore.fail(task.id, `Unsupported task: ${task.kind}`);
111
+ throw new NonRetryableTaskError(`Unsupported task: ${task.kind}`);
80
112
  }
81
113
 
82
- async function dispatchDueTasks() {
83
- const tasks = await taskStore.claimDue(10);
84
- for (const task of tasks) {
85
- try {
86
- await dispatchTask(task);
87
- } catch (error) {
88
- await taskStore.fail(task.id, errorMessage(error));
89
- }
114
+ async function notifyTerminalFailure(details) {
115
+ const destination = failureDestination(details.task);
116
+ if (!destination.chatId) {
117
+ logger?.log("tasks", `task ${details.task.id} has no Telegram failure-notification destination`);
118
+ return;
90
119
  }
120
+ const options = destination.threadId ? { message_thread_id: destination.threadId } : undefined;
121
+ await sendMessage(destination.chatId, buildFailureNotice(details), options);
91
122
  }
92
123
 
93
- return { dispatchTask, dispatchDueTasks };
124
+ const runner = createTaskRunner({
125
+ taskStore,
126
+ dispatch: dispatchTask,
127
+ onTerminalFailure: notifyTerminalFailure,
128
+ logger
129
+ });
130
+ return { dispatchTask, dispatchDueTasks: runner.dispatchDueTasks, runClaimedTask: runner.runClaimedTask };
94
131
  }
@@ -0,0 +1,180 @@
1
+ import {
2
+ buildPiAuthRecoveryBlockedMessage,
3
+ buildPiAuthTelegramMessage,
4
+ getErrorMessage,
5
+ getPiAuthIssue,
6
+ getPiAuthStatus
7
+ } from "../../core/agent/auth-flow.js";
8
+ import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
9
+ import { buildDeviceCodeTelegramMessage } from "./device-code-message.js";
10
+ import { getIncomingMessageText } from "./prompt-builders.js";
11
+
12
+ function chatKey(chatId) {
13
+ return String(chatId);
14
+ }
15
+
16
+ export function selectTelegramLoginOption(options = []) {
17
+ return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
18
+ || options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
19
+ || options[0]
20
+ || null;
21
+ }
22
+
23
+ export function createTelegramAuthController({
24
+ config,
25
+ api,
26
+ agentManager,
27
+ logger,
28
+ markPromptErrorNotified = () => {}
29
+ }) {
30
+ const renewals = new Map();
31
+ let issue = null;
32
+
33
+ function rememberIssue(error) {
34
+ const detected = getPiAuthIssue(error);
35
+ if (detected) issue = detected;
36
+ return detected;
37
+ }
38
+
39
+ function rememberValidationFailure(error) {
40
+ const detected = rememberIssue(error) || {
41
+ kind: "validation-failed",
42
+ message: getErrorMessage(error)
43
+ };
44
+ issue = detected;
45
+ return detected;
46
+ }
47
+
48
+ async function notifyIssueIfNeeded(chatId, error) {
49
+ const detected = rememberIssue(error);
50
+ if (!detected) return false;
51
+
52
+ try {
53
+ await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue: detected }));
54
+ markPromptErrorNotified(error);
55
+ return true;
56
+ } catch (notifyError) {
57
+ logger?.error("telegram", `auth issue notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
58
+ return false;
59
+ }
60
+ }
61
+
62
+ async function finishRenewal(chatId, renewal) {
63
+ try {
64
+ await renewal.promise;
65
+ await agentManager.validateAgent();
66
+ agentManager.clearSessionCache(chatId);
67
+ issue = null;
68
+ logger?.log("telegram", `Pi auth renewal completed for chat ${chatId}`);
69
+ await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, verified: true }));
70
+ } catch (error) {
71
+ const detected = rememberValidationFailure(error);
72
+ logger?.error("telegram", `Pi auth renewal failed for chat ${chatId}: ${getErrorMessage(error)}`);
73
+ await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue: detected })).catch((notifyError) => {
74
+ logger?.error("telegram", `auth renewal failure notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
75
+ });
76
+ } finally {
77
+ renewals.delete(chatKey(chatId));
78
+ }
79
+ }
80
+
81
+ async function startRenewal(chatId) {
82
+ const key = chatKey(chatId);
83
+ const existing = renewals.get(key);
84
+ if (existing) return { started: false, renewal: existing };
85
+
86
+ const renewal = createPiOAuthLogin({
87
+ provider: config.pi.provider,
88
+ onSelect: async ({ message, options }) => {
89
+ const selected = selectTelegramLoginOption(options);
90
+ if (!selected) return undefined;
91
+ logger?.log("telegram", `Pi auth option for chat ${chatId}: ${selected.id}`);
92
+ await api.sendMessage(chatId, `${message}\nUsing: ${selected.label || selected.id}`);
93
+ return selected.id;
94
+ },
95
+ onAuth: async ({ url, instructions }) => {
96
+ await api.sendMessage(chatId, [
97
+ instructions || "Open this URL to continue Pi authentication:",
98
+ url,
99
+ "After login, paste the full redirect URL back here."
100
+ ].join("\n"));
101
+ },
102
+ onDeviceCode: async ({ userCode, verificationUri, expiresInSeconds }) => {
103
+ const payload = buildDeviceCodeTelegramMessage({ userCode, verificationUri, expiresInSeconds });
104
+ const { text, ...options } = payload;
105
+ await api.sendMessage(chatId, text, options);
106
+ },
107
+ onPrompt: async ({ message, controller }) => {
108
+ await api.sendMessage(chatId, `${message}\nReply here with the value.`);
109
+ return controller.waitForManualCode();
110
+ },
111
+ onProgress: (message) => {
112
+ if (message) logger?.log("telegram", `Pi auth progress for chat ${chatId}: ${message}`);
113
+ }
114
+ });
115
+
116
+ renewals.set(key, renewal);
117
+ finishRenewal(chatId, renewal);
118
+ return { started: true, renewal };
119
+ }
120
+
121
+ async function submitRenewalInput(ctx) {
122
+ const renewal = renewals.get(chatKey(ctx.chat.id));
123
+ const text = getIncomingMessageText(ctx.message).trim();
124
+ if (!renewal || !renewal.manualInputRequested || !text) return false;
125
+ if (!renewal.submitManualCode(text)) return false;
126
+ await ctx.reply("Got it. Finishing Pi login now...");
127
+ return true;
128
+ }
129
+
130
+ function buildBlockedMessage(chatId) {
131
+ return buildPiAuthRecoveryBlockedMessage({
132
+ config,
133
+ chatId,
134
+ issue,
135
+ renewalActive: renewals.has(chatKey(chatId))
136
+ });
137
+ }
138
+
139
+ async function handleCommand(ctx, { authorize, withTyping }) {
140
+ const authorization = await authorize(ctx);
141
+ if (!authorization.ok) return;
142
+
143
+ const status = getPiAuthStatus(config, ctx.chat.id);
144
+ if (status.hasApiKey || !status.supportsOAuth) {
145
+ await withTyping(ctx, async () => {
146
+ try {
147
+ await agentManager.validateAgent();
148
+ agentManager.clearSessionCache(ctx.chat.id);
149
+ issue = null;
150
+ await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
151
+ } catch (error) {
152
+ const detected = rememberValidationFailure(error);
153
+ await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
154
+ }
155
+ });
156
+ return;
157
+ }
158
+
159
+ try {
160
+ const { started } = await startRenewal(ctx.chat.id);
161
+ await ctx.reply(started
162
+ ? "Starting Pi login from Telegram..."
163
+ : "Pi login is already in progress. Paste the redirect URL or code here when you have it.");
164
+ } catch (error) {
165
+ const detected = rememberValidationFailure(error);
166
+ await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
167
+ }
168
+ }
169
+
170
+ return {
171
+ buildBlockedMessage,
172
+ getIssue: () => issue,
173
+ handleCommand,
174
+ hasActiveRenewal: (chatId) => renewals.has(chatKey(chatId)),
175
+ notifyIssueIfNeeded,
176
+ rememberIssue,
177
+ startRenewal,
178
+ submitRenewalInput
179
+ };
180
+ }
@@ -0,0 +1,170 @@
1
+ import path from "node:path";
2
+ import { InputFile } from "grammy";
3
+ import { cancelRestartReceipt, prepareRestartReceipt } from "../../runtime/restart-receipt.js";
4
+ import { isSilentReply } from "./prompt-builders.js";
5
+ import { renderTelegramHtml } from "./text-format.js";
6
+ import { resolveTelegramWorkspaceRoute, topicSessionId } from "./workspace-group.js";
7
+
8
+ function deliveryMethod(artifact, method) {
9
+ if (method) return method;
10
+ if (artifact.metadata?.delivery?.method) return artifact.metadata.delivery.method;
11
+ if (artifact.kind === "audio" || artifact.mimeType?.startsWith("audio/")) return "audio";
12
+ if (artifact.kind === "image" || artifact.mimeType?.startsWith("image/")) return "photo";
13
+ if (artifact.kind === "video" || artifact.mimeType?.startsWith("video/")) return "video";
14
+ return "document";
15
+ }
16
+
17
+ function safeCaption(caption) {
18
+ return caption && !/(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(caption) ? caption : undefined;
19
+ }
20
+
21
+ export function createTelegramSessionBridgeController({
22
+ config,
23
+ api,
24
+ agentManager,
25
+ artifactStore,
26
+ sessionSeeds,
27
+ getChatState,
28
+ buildTopicInitializationHandoff,
29
+ logger
30
+ }) {
31
+ function createWorkspaceAccessGuard(route) {
32
+ return async () => {
33
+ if (!route.workspace) return;
34
+ const current = await resolveTelegramWorkspaceRoute({
35
+ config,
36
+ api,
37
+ ctx: {
38
+ chat: { id: route.transportChatId, type: "supergroup", is_forum: true },
39
+ from: { id: route.ownerChatId },
40
+ message: { message_thread_id: route.threadId }
41
+ }
42
+ });
43
+ if (!current.ok) throw new Error("Owner-only workspace access is paused.");
44
+ };
45
+ }
46
+
47
+ function createSessionBridge(route) {
48
+ const messageOptions = (extra = {}) => route.workspace && route.threadId
49
+ ? { ...extra, message_thread_id: route.threadId }
50
+ : extra;
51
+ const initializeForumTopic = async ({ messageThreadId, name, context }) => {
52
+ if (!route.workspace) throw new Error("Telegram topic initialization is only available from the owner workspace forum.");
53
+ await createWorkspaceAccessGuard(route)();
54
+ const initializedSessionId = topicSessionId({
55
+ ownerChatId: route.ownerChatId,
56
+ groupChatId: route.transportChatId,
57
+ threadId: messageThreadId,
58
+ generalTopicId: route.generalTopicId
59
+ });
60
+ if (initializedSessionId === String(route.ownerChatId)) {
61
+ throw new Error("The General topic already uses the owner's private session and cannot be reinitialized here.");
62
+ }
63
+ const handoff = buildTopicInitializationHandoff({ name, context });
64
+ await sessionSeeds.set(initializedSessionId, handoff);
65
+ agentManager.resetSession(initializedSessionId, { handoff });
66
+ await agentManager.waitForSessionClose(initializedSessionId);
67
+ return {
68
+ ok: true,
69
+ chatId: route.transportChatId,
70
+ messageThreadId,
71
+ sessionId: initializedSessionId,
72
+ name,
73
+ initialized: true
74
+ };
75
+ };
76
+ return {
77
+ sendMedia: async (filePath, { method = "audio", caption, filename } = {}) => {
78
+ logger?.log("telegram", `sending ${method} reply for chat ${route.transportChatId}`);
79
+ const input = new InputFile(filePath, filename || undefined);
80
+ const options = messageOptions({ caption });
81
+ if (method === "voice") return api.sendVoice(route.transportChatId, input, options);
82
+ if (method === "document") return api.sendDocument(route.transportChatId, input, options);
83
+ if (method === "photo" || method === "image") return api.sendPhoto(route.transportChatId, input, options);
84
+ if (method === "video") return api.sendVideo(route.transportChatId, input, options);
85
+ return api.sendAudio(route.transportChatId, input, options);
86
+ },
87
+ createForumTopic: async (name, context) => {
88
+ if (!route.workspace) throw new Error("Telegram topic creation is only available from the owner workspace forum.");
89
+ await createWorkspaceAccessGuard(route)();
90
+ const topic = await api.createForumTopic(route.transportChatId, name);
91
+ return initializeForumTopic({
92
+ messageThreadId: topic.message_thread_id,
93
+ name: topic.name,
94
+ context
95
+ });
96
+ },
97
+ initializeForumTopic,
98
+ prepareRestartReceipt: (summary) => prepareRestartReceipt({
99
+ transportChatId: route.transportChatId,
100
+ threadId: route.threadId
101
+ }, { reason: String(summary || "Agent-requested restart").trim() }),
102
+ cancelRestartReceipt,
103
+ getTaskContext: () => route.workspace ? {
104
+ transport: "telegram",
105
+ destination: {
106
+ chatId: route.transportChatId,
107
+ threadId: route.topicThreadId
108
+ }
109
+ } : null
110
+ };
111
+ }
112
+
113
+ async function sendTextReply({ sendText, sendDocument, chatId, artifactChatId = chatId, text }) {
114
+ const maxInlineReplyLength = 3500;
115
+ if (isSilentReply(text)) {
116
+ logger?.log("telegram", `suppressing silent reply for chat ${chatId}`);
117
+ return;
118
+ }
119
+
120
+ if (text.length > maxInlineReplyLength) {
121
+ logger?.log("telegram", `sending long reply as markdown attachment for chat ${chatId}`);
122
+ const chatArtifactStore = artifactStore.forChat(artifactChatId);
123
+ const artifact = await chatArtifactStore.createGeneratedFile({
124
+ fileName: `reply-${Date.now()}.md`,
125
+ content: text,
126
+ kind: "document",
127
+ mimeType: "text/markdown",
128
+ source: { type: "assistant", chatId },
129
+ metadata: { delivery: "telegram-document" }
130
+ });
131
+ await sendDocument(new InputFile(artifact.path, path.basename(artifact.path)), {
132
+ caption: "Response attached as Markdown."
133
+ });
134
+ return;
135
+ }
136
+
137
+ logger?.log("telegram", `sending text reply for chat ${chatId}`);
138
+ const sent = await sendText(renderTelegramHtml(text), { parse_mode: "HTML" });
139
+ if (sent?.message_id) {
140
+ const messages = getChatState(chatId).assistantMessages;
141
+ messages.set(sent.message_id, text);
142
+ while (messages.size > 50) messages.delete(messages.keys().next().value);
143
+ }
144
+ }
145
+
146
+ function installArtifactDeliveryHandler() {
147
+ agentManager.setArtifactDeliveryHandler?.(async ({ chatId, artifact, caption, method }) => {
148
+ const resolvedMethod = deliveryMethod(artifact, method);
149
+ await createSessionBridge({
150
+ workspace: false,
151
+ sessionId: String(chatId),
152
+ scopeChatId: chatId,
153
+ transportChatId: chatId,
154
+ threadId: null
155
+ }).sendMedia(artifact.path, {
156
+ method: resolvedMethod,
157
+ caption: safeCaption(caption),
158
+ filename: path.basename(artifact.path)
159
+ });
160
+ return { ok: true, artifactId: artifact.id, method: resolvedMethod };
161
+ });
162
+ }
163
+
164
+ return {
165
+ createSessionBridge,
166
+ createWorkspaceAccessGuard,
167
+ installArtifactDeliveryHandler,
168
+ sendTextReply
169
+ };
170
+ }