arisa 5.1.49 → 5.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +0 -2
  2. package/README.md +9 -0
  3. package/package.json +1 -1
  4. package/src/core/agent/agent-manager.js +49 -489
  5. package/src/core/agent/agent-session-lifecycle.js +181 -0
  6. package/src/core/agent/pi-capability-tools.js +183 -0
  7. package/src/core/artifacts/artifact-store.js +73 -17
  8. package/src/core/capabilities/capability-service.js +340 -0
  9. package/src/core/config/config-defaults.js +28 -1
  10. package/src/core/tasks/task-routing.js +7 -0
  11. package/src/core/tasks/task-runner.js +68 -0
  12. package/src/core/tasks/task-store.js +382 -92
  13. package/src/core/tools/tool-output-materializer.js +5 -5
  14. package/src/core/tools/tool-registry.js +20 -5
  15. package/src/core/tools/weighted-resource-governor.js +153 -0
  16. package/src/index.js +20 -0
  17. package/src/official-tools.lock.json +62 -45
  18. package/src/runtime/arisa-capabilities.js +51 -242
  19. package/src/runtime/create-app.js +11 -2
  20. package/src/runtime/create-headless-app.js +7 -4
  21. package/src/runtime/paths.js +4 -0
  22. package/src/runtime/service-manager.js +3 -1
  23. package/src/runtime/service-supervisor.js +98 -0
  24. package/src/transport/telegram/bot.js +186 -374
  25. package/src/transport/telegram/chat-queue.js +83 -6
  26. package/src/transport/telegram/prompt-builders.js +9 -0
  27. package/src/transport/telegram/reply-topic-routing.js +111 -0
  28. package/src/transport/telegram/task-dispatcher.js +96 -36
  29. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  30. package/src/transport/telegram/telegram-session-bridge.js +177 -0
  31. package/src/transport/telegram/telegram-tools-command.js +28 -0
  32. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  33. package/src/transport/telegram/workspace-topic-store.js +228 -0
  34. package/test/agent-session-lifecycle.test.js +58 -0
  35. package/test/artifact-store.test.js +38 -2
  36. package/test/capabilities-security.test.js +58 -0
  37. package/test/chat-queue.test.js +32 -0
  38. package/test/context-and-task-bounds.test.js +76 -1
  39. package/test/device-code-message.test.js +9 -0
  40. package/test/media-caption.test.js +1 -1
  41. package/test/model-selection.test.js +9 -1
  42. package/test/official-tool-dependencies.test.js +1 -1
  43. package/test/paths.test.js +8 -0
  44. package/test/pi-capability-tools.test.js +65 -0
  45. package/test/service-manager.test.js +48 -0
  46. package/test/session-start-operational-notes.test.js +1 -1
  47. package/test/task-idempotency.test.js +40 -0
  48. package/test/task-routing.test.js +62 -0
  49. package/test/task-store.test.js +231 -7
  50. package/test/telegram-reply-topic-routing.test.js +94 -0
  51. package/test/telegram-task-dispatcher.test.js +150 -23
  52. package/test/telegram-text-artifact.test.js +13 -2
  53. package/test/telegram-tools-command.test.js +47 -0
  54. package/test/telegram-workspace-topic-store.test.js +124 -0
  55. package/test/tool-registry-run.test.js +41 -0
  56. package/test/weighted-resource-governor.test.js +95 -0
@@ -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,69 @@ export function createChatStateStore() {
29
31
  };
30
32
  }
31
33
 
32
- export function queueChatPrompt(chatState, prompt, { replace = false, ctx = null } = {}) {
34
+ export function createPromptExecutionReceipt(onStart = null) {
35
+ let resolve;
36
+ let reject;
37
+ let started = false;
38
+ const promise = new Promise((resolvePromise, rejectPromise) => {
39
+ resolve = resolvePromise;
40
+ reject = rejectPromise;
41
+ });
42
+ return {
43
+ promise,
44
+ resolve,
45
+ reject,
46
+ start() {
47
+ if (started) return;
48
+ started = true;
49
+ onStart?.({ resolve, reject, promise });
50
+ }
51
+ };
52
+ }
53
+
54
+ function rejectQueuedReceipts(chatState, error) {
55
+ for (const receipt of chatState.pendingPromptReceipts || []) receipt?.reject(error);
56
+ }
57
+
58
+ const COALESCED_PROMPT_SEPARATOR = "\n\n--- next direct message ---\n\n";
59
+
60
+ function coalesceLastQueuedPrompt(chatState, prompt, ctx) {
61
+ const coalescible = chatState.pendingPromptCoalescible ||= [];
62
+ const index = chatState.pendingPrompts.length - 1;
63
+ if (index < 0 || !coalescible[index]) return false;
64
+ chatState.pendingPrompts[index] += `${COALESCED_PROMPT_SEPARATOR}${prompt}`;
65
+ (chatState.pendingPromptContexts ||= [])[index] = ctx;
66
+ return true;
67
+ }
68
+
69
+ export function queueChatPrompt(chatState, prompt, {
70
+ replace = false,
71
+ ctx = null,
72
+ receipt = null,
73
+ coalescible = false
74
+ } = {}) {
33
75
  chatState.pendingPromptContexts ||= [];
76
+ chatState.pendingPromptReceipts ||= [];
77
+ chatState.pendingPromptCoalescible ||= [];
34
78
  if (replace) {
79
+ rejectQueuedReceipts(chatState, Object.assign(new Error("Queued prompt was superseded"), { code: "PROMPT_SUPERSEDED" }));
35
80
  chatState.pendingPrompts = [];
36
81
  chatState.pendingPromptContexts = [];
82
+ chatState.pendingPromptReceipts = [];
83
+ chatState.pendingPromptCoalescible = [];
37
84
  }
38
85
  chatState.pendingPrompts.push(prompt);
39
86
  chatState.pendingPromptContexts.push(ctx);
87
+ chatState.pendingPromptReceipts.push(receipt);
88
+ chatState.pendingPromptCoalescible.push(coalescible);
40
89
  }
41
90
 
42
91
  function takeQueuedPrompt(chatState) {
92
+ (chatState.pendingPromptCoalescible ||= []).shift();
43
93
  return {
44
94
  prompt: chatState.pendingPrompts.shift() || "",
45
- ctx: (chatState.pendingPromptContexts ||= []).shift() || null
95
+ ctx: (chatState.pendingPromptContexts ||= []).shift() || null,
96
+ receipt: (chatState.pendingPromptReceipts ||= []).shift() || null
46
97
  };
47
98
  }
48
99
 
@@ -52,7 +103,19 @@ export function resolveTelegramBusyMessageMode(config, chatId) {
52
103
  return mode === "steer" ? "steer" : "queue";
53
104
  }
54
105
 
55
- export async function routeBusyPrompt({ chatState, prompt, mode = "queue", replaceQueued = false, ctx = null }) {
106
+ export async function routeBusyPrompt({
107
+ chatState,
108
+ prompt,
109
+ mode = "queue",
110
+ replaceQueued = false,
111
+ ctx = null,
112
+ receipt = null,
113
+ coalesceQueued = false
114
+ }) {
115
+ if (coalesceQueued && !replaceQueued && !receipt && coalesceLastQueuedPrompt(chatState, prompt, ctx)) {
116
+ return { disposition: "coalesced" };
117
+ }
118
+
56
119
  const session = chatState.activeSession;
57
120
  if (
58
121
  mode === "steer"
@@ -61,17 +124,23 @@ export async function routeBusyPrompt({ chatState, prompt, mode = "queue", repla
61
124
  && !chatState.beforeNextPrompt
62
125
  && session?.isStreaming
63
126
  && typeof session.steer === "function"
127
+ && !receipt
64
128
  ) {
65
129
  try {
66
130
  await session.steer(prompt);
67
131
  return { disposition: "steered" };
68
132
  } catch (error) {
69
- queueChatPrompt(chatState, prompt, { ctx });
133
+ queueChatPrompt(chatState, prompt, { ctx, receipt, coalescible: coalesceQueued });
70
134
  return { disposition: "queued", steerError: error };
71
135
  }
72
136
  }
73
137
 
74
- queueChatPrompt(chatState, prompt, { replace: replaceQueued, ctx });
138
+ queueChatPrompt(chatState, prompt, {
139
+ replace: replaceQueued,
140
+ ctx,
141
+ receipt,
142
+ coalescible: coalesceQueued
143
+ });
75
144
  return { disposition: "queued" };
76
145
  }
77
146
 
@@ -84,6 +153,7 @@ export async function drainChatPromptQueue({
84
153
  chatState,
85
154
  initialPrompt,
86
155
  initialCtx = null,
156
+ initialReceipt = null,
87
157
  processPrompt,
88
158
  onPromptFailure,
89
159
  onPromptInterrupted,
@@ -91,6 +161,7 @@ export async function drainChatPromptQueue({
91
161
  }) {
92
162
  let currentPrompt = initialPrompt;
93
163
  let currentCtx = initialCtx;
164
+ let currentReceipt = initialReceipt;
94
165
 
95
166
  try {
96
167
  await beforeInitialPrompt?.();
@@ -104,11 +175,15 @@ export async function drainChatPromptQueue({
104
175
  const queued = takeQueuedPrompt(chatState);
105
176
  currentPrompt = queued.prompt;
106
177
  currentCtx = queued.ctx;
178
+ currentReceipt = queued.receipt;
107
179
  chatState.continueAfterClose = false;
108
180
  }
109
181
  try {
110
- await processPrompt({ prompt: currentPrompt, ctx: currentCtx });
182
+ currentReceipt?.start?.();
183
+ await processPrompt({ prompt: currentPrompt, ctx: currentCtx, receipt: currentReceipt });
184
+ currentReceipt?.resolve({ status: "completed" });
111
185
  } catch (error) {
186
+ currentReceipt?.reject(error);
112
187
  if (chatState.continueAfterClose && chatState.pendingPrompts.length) {
113
188
  await onPromptInterrupted?.(error);
114
189
  } else {
@@ -117,11 +192,13 @@ export async function drainChatPromptQueue({
117
192
  }
118
193
  } finally {
119
194
  currentCtx = null;
195
+ currentReceipt = null;
120
196
  }
121
197
 
122
198
  const queued = takeQueuedPrompt(chatState);
123
199
  currentPrompt = queued.prompt;
124
200
  currentCtx = queued.ctx;
201
+ currentReceipt = queued.receipt;
125
202
  chatState.continueAfterClose = false;
126
203
  }
127
204
  } 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);
@@ -0,0 +1,111 @@
1
+ const replyTopicMarkerPattern = /\s*\[\[ARISA_REPLY_TOPIC:(\d+)\]\]\s*$/u;
2
+ const proposalMarkerPattern = /\s*\[\[ARISA_PROPOSE_TOPIC:([^\]\r\n]{1,80})\]\]\s*$/u;
3
+ const anyRoutingMarkerPattern = /\s*\[\[ARISA_(?:REPLY_TOPIC|PROPOSE_TOPIC):[^\]]*\]\]\s*$/u;
4
+
5
+ function singleLine(value, maxLength) {
6
+ return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
7
+ }
8
+
9
+ export function isGeneralWorkspaceRoute(route) {
10
+ return Boolean(route?.workspace
11
+ && route.threadId == null
12
+ && route.topicThreadId === route.generalTopicId);
13
+ }
14
+
15
+ function activeTopics(topics = []) {
16
+ return topics
17
+ .map((topic) => ({
18
+ threadId: Number(topic?.threadId),
19
+ name: singleLine(topic?.name, 80),
20
+ description: singleLine(topic?.description, 240)
21
+ }))
22
+ .filter((topic) => Number.isSafeInteger(topic.threadId) && topic.threadId > 0 && topic.name)
23
+ .sort((left, right) => left.threadId - right.threadId)
24
+ .slice(0, 32);
25
+ }
26
+
27
+ export function buildGeneralReplyRoutingInstruction(topics = [], recentProposals = []) {
28
+ const available = activeTopics(topics);
29
+ const options = available.length
30
+ ? available.map((topic) => [
31
+ `- ${topic.threadId}: ${topic.name}`,
32
+ topic.description ? ` — ${topic.description}` : ""
33
+ ].join(""))
34
+ : ["- No named topics are registered yet."];
35
+ const proposals = recentProposals
36
+ .map((proposal) => singleLine(proposal?.name, 80))
37
+ .filter(Boolean)
38
+ .slice(0, 12);
39
+ return [
40
+ "Telegram General-topic management instruction:",
41
+ "This applies only because the incoming message was written in General inside an owner workspace supergroup. It never applies to a private chat with the bot.",
42
+ "The original message must stay in General and the conversation remains in the General session.",
43
+ "If, and only if, your final response clearly belongs to exactly one registered topic below, append [[ARISA_REPLY_TOPIC:<id>]] as the final line. Do not mention this routing marker.",
44
+ "If the relationship is weak, ambiguous, or no topic fits, do not append a reply marker and the response will remain in General.",
45
+ "You may occasionally propose a new topic only when the General conversation shows a substantial theme recurring across multiple turns and no registered topic fits. Ask for explicit confirmation in the visible response and append [[ARISA_PROPOSE_TOPIC:<short name>]] as the final line. Never create a topic without explicit confirmation.",
46
+ proposals.length ? `Do not repeat these recent topic proposals: ${proposals.join(", ")}.` : null,
47
+ "Registered reply topics:",
48
+ ...options
49
+ ].filter(Boolean).join("\n");
50
+ }
51
+
52
+ export function appendGeneralReplyRoutingInstruction(prompt, topics = [], recentProposals = []) {
53
+ return `${String(prompt || "")}\n\n${buildGeneralReplyRoutingInstruction(topics, recentProposals)}`;
54
+ }
55
+
56
+ export function extractReplyTopicMetadata(text, topics = []) {
57
+ let cleanedText = String(text || "");
58
+ let requestedThreadId = null;
59
+ let proposal = "";
60
+ let changed = true;
61
+ while (changed) {
62
+ changed = false;
63
+ const replyMatch = cleanedText.match(replyTopicMarkerPattern);
64
+ if (replyMatch) {
65
+ requestedThreadId ??= Number(replyMatch[1]);
66
+ cleanedText = cleanedText.replace(replyTopicMarkerPattern, "");
67
+ changed = true;
68
+ continue;
69
+ }
70
+ const proposalMatch = cleanedText.match(proposalMarkerPattern);
71
+ if (proposalMatch) {
72
+ proposal ||= singleLine(proposalMatch[1], 80);
73
+ cleanedText = cleanedText.replace(proposalMarkerPattern, "");
74
+ changed = true;
75
+ continue;
76
+ }
77
+ if (anyRoutingMarkerPattern.test(cleanedText)) {
78
+ cleanedText = cleanedText.replace(anyRoutingMarkerPattern, "");
79
+ changed = true;
80
+ }
81
+ }
82
+
83
+ const available = activeTopics(topics);
84
+ const allowed = available.some((topic) => topic.threadId === requestedThreadId);
85
+ return {
86
+ text: cleanedText.trimEnd(),
87
+ threadId: allowed ? requestedThreadId : null,
88
+ proposal
89
+ };
90
+ }
91
+
92
+ export function routeGeneralWorkspaceReply({ route, text, topics = [] }) {
93
+ const generalWorkspaceRoute = isGeneralWorkspaceRoute(route);
94
+ const extracted = extractReplyTopicMetadata(text, generalWorkspaceRoute ? topics : []);
95
+ if (!generalWorkspaceRoute) {
96
+ return { text: extracted.text, route, topic: null, proposal: "" };
97
+ }
98
+
99
+ const topic = activeTopics(topics).find((item) => item.threadId === extracted.threadId) || null;
100
+ if (!topic) return { text: extracted.text, route, topic: null, proposal: extracted.proposal };
101
+ return {
102
+ text: extracted.text,
103
+ route: {
104
+ ...route,
105
+ threadId: topic.threadId,
106
+ topicThreadId: topic.threadId
107
+ },
108
+ topic,
109
+ proposal: ""
110
+ };
111
+ }
@@ -1,9 +1,55 @@
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 boundedTimeout(value, fallback) {
9
+ const parsed = Number(value);
10
+ return Number.isFinite(parsed) && parsed > 0
11
+ ? Math.min(Math.round(parsed), 60 * 60_000)
12
+ : fallback;
13
+ }
14
+
15
+ function requireChatId(task) {
16
+ const chatId = task.payload?.chatId;
17
+ if (chatId == null || chatId === "") {
18
+ throw new NonRetryableTaskError(`Task missing chatId: ${task.kind}`);
19
+ }
20
+ return chatId;
21
+ }
22
+
23
+ function safeErrorSummary(error) {
24
+ return errorMessage(error)
25
+ .replace(/(bearer\s+)[^\s]+/gi, "$1[redacted]")
26
+ .replace(/((?:api[_-]?key|token|secret|password)\s*[=:]\s*)[^\s,;]+/gi, "$1[redacted]")
27
+ .replace(/\s+/g, " ")
28
+ .trim()
29
+ .slice(0, 300) || "Unknown error";
30
+ }
31
+
32
+ function failureDestination(task) {
33
+ const destination = task.route?.transport === "telegram" ? task.route.destination : null;
34
+ return {
35
+ chatId: destination?.chatId || task.payload?.chatId,
36
+ threadId: destination?.threadId || null
37
+ };
38
+ }
39
+
40
+ function buildFailureNotice({ task, result, error }) {
41
+ const uncertain = result?.status === "outcome_uncertain" || result?.lastOutcome === "outcome_uncertain";
42
+ const recurring = result?.terminalFailure === true && result?.status === "pending";
43
+ const lines = [
44
+ uncertain ? "⚠️ Arisa task outcome is uncertain" : "⚠️ Arisa task failed",
45
+ `Task: ${task.kind || "unknown"} (${task.id})`,
46
+ `Error: ${safeErrorSummary(error)}`
47
+ ];
48
+ if (recurring) lines.push(`Next run: ${result.runAt}`);
49
+ else lines.push("No further retries are scheduled.");
50
+ return lines.join("\n");
51
+ }
52
+
7
53
  export function createTelegramTaskDispatcher({
8
54
  taskStore,
9
55
  sendMessage,
@@ -12,24 +58,26 @@ export function createTelegramTaskDispatcher({
12
58
  toolRegistry,
13
59
  resourceNotes,
14
60
  agentManager,
61
+ taskTimeouts = {},
15
62
  logger
16
63
  }) {
64
+ const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
65
+ const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
66
+
17
67
  async function dispatchAgentTask(task, chatId) {
18
- if (!task.payload.prompt) {
19
- await taskStore.fail(task.id, "agent_task missing prompt");
20
- return;
21
- }
68
+ if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
22
69
  logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
23
70
  await enqueueAsyncPrompt({
24
71
  chatId,
25
72
  prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
26
73
  label: `scheduled task ${task.id}`,
27
- telegramContext: task.payload.telegramContext
74
+ route: task.route,
75
+ timeoutMs: agentTimeoutMs
28
76
  });
29
- await taskStore.complete(task.id);
30
77
  }
31
78
 
32
79
  async function dispatchAgentEvent(task, chatId) {
80
+ if (!task.payload?.prompt) throw new NonRetryableTaskError("agent_event missing prompt");
33
81
  logger?.log("tasks", `agent event ${task.id} for chat ${chatId}`);
34
82
  const acknowledgement = String(task.payload?.acknowledgement || "").trim();
35
83
  if (acknowledgement) {
@@ -43,52 +91,64 @@ export function createTelegramTaskDispatcher({
43
91
  chatId,
44
92
  prompt: await buildAsyncEventPrompt(task, resourceNotes),
45
93
  label: `agent event ${task.id}`,
46
- telegramContext: task.payload.telegramContext
94
+ route: task.route,
95
+ timeoutMs: eventTimeoutMs
47
96
  });
48
- await taskStore.complete(task.id);
49
97
  }
50
98
 
51
99
  async function dispatchPollTool(task, chatId) {
52
100
  const toolName = task.payload?.toolName;
53
- if (!toolName) {
54
- await taskStore.fail(task.id, "poll_tool missing toolName");
55
- return;
56
- }
101
+ if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
57
102
  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)}`);
103
+ const result = await agentManager.runTool({
104
+ name: toolName,
105
+ request: { args: task.payload.args || {} },
106
+ chatId
107
+ });
108
+ if (result?.ok === false) {
109
+ const error = new Error(result.error || `poll_tool ${toolName} failed`);
110
+ if (result.status === "needs_config") error.retryable = false;
111
+ if (result.status === "outcome_uncertain") {
112
+ error.retryable = false;
113
+ error.outcomeUncertain = true;
114
+ }
115
+ throw error;
66
116
  }
67
- await taskStore.complete(task.id);
68
117
  }
69
118
 
70
119
  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
- }
120
+ const chatId = requireChatId(task);
76
121
  if (task.kind === "agent_task") return dispatchAgentTask(task, chatId);
77
122
  if (task.kind === "agent_event") return dispatchAgentEvent(task, chatId);
78
123
  if (task.kind === "poll_tool") return dispatchPollTool(task, chatId);
79
- await taskStore.fail(task.id, `Unsupported task: ${task.kind}`);
124
+ throw new NonRetryableTaskError(`Unsupported task: ${task.kind}`);
80
125
  }
81
126
 
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
- }
127
+ async function notifyTerminalFailure(details) {
128
+ const destination = failureDestination(details.task);
129
+ if (!destination.chatId) {
130
+ logger?.log("tasks", `task ${details.task.id} has no Telegram failure-notification destination`);
131
+ return;
90
132
  }
133
+ const options = destination.threadId ? { message_thread_id: destination.threadId } : undefined;
134
+ await sendMessage(destination.chatId, buildFailureNotice(details), options);
91
135
  }
92
136
 
93
- return { dispatchTask, dispatchDueTasks };
137
+ const runner = createTaskRunner({
138
+ taskStore,
139
+ dispatch: dispatchTask,
140
+ laneKey(task) {
141
+ if (task.kind === "poll_tool") {
142
+ return `poll:${task.payload?.chatId}:${task.payload?.toolName || task.id}`;
143
+ }
144
+ const destination = task.route?.transport === "telegram" ? task.route.destination : null;
145
+ if (!destination?.chatId || Number(destination.threadId) === 1) {
146
+ return `agent:${task.payload?.chatId}`;
147
+ }
148
+ return `agent:${destination.chatId}:${destination.threadId || 0}`;
149
+ },
150
+ onTerminalFailure: notifyTerminalFailure,
151
+ logger
152
+ });
153
+ return { dispatchTask, dispatchDueTasks: runner.dispatchDueTasks, runClaimedTask: runner.runClaimedTask };
94
154
  }
@@ -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
+ }