arisa 3.0.14 → 3.1.2

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.
@@ -15,6 +15,79 @@ async function exists(file) {
15
15
  }
16
16
  }
17
17
 
18
+ function normalizeString(value) {
19
+ if (typeof value !== "string") return "";
20
+ return value.trim();
21
+ }
22
+
23
+ function parseMaxChatIds(value, fallback = 1) {
24
+ const parsed = Number(value);
25
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
26
+ return parsed;
27
+ }
28
+
29
+ function buildConfig({ telegramApiKey, telegramMaxChatIds, provider, model, piApiKey }) {
30
+ return {
31
+ telegram: {
32
+ token: telegramApiKey,
33
+ maxChatIds: telegramMaxChatIds,
34
+ authorizedChatIds: [],
35
+ chatMeta: {}
36
+ },
37
+ pi: {
38
+ provider,
39
+ model,
40
+ apiKey: piApiKey
41
+ },
42
+ createdAt: new Date().toISOString()
43
+ };
44
+ }
45
+
46
+ function resolvePiDefaults(runtime, { provider: preferredProvider = "", model: preferredModel = "" } = {}) {
47
+ const providers = sortBootstrapProviders(listPiProviders(runtime));
48
+ if (!providers.length) {
49
+ throw new Error("No Pi providers are available for bootstrap.");
50
+ }
51
+
52
+ const preferredProviderValue = normalizeString(preferredProvider);
53
+ const providerExists = providers.some((item) => item.provider === preferredProviderValue);
54
+ if (preferredProviderValue && !providerExists) {
55
+ console.log(`Ignoring unknown Pi provider override: ${preferredProviderValue}`);
56
+ }
57
+
58
+ const selectedProvider = providerExists
59
+ ? preferredProviderValue
60
+ : providers[0].provider;
61
+
62
+ const models = sortBootstrapModels(selectedProvider, listProviderModels(selectedProvider, runtime));
63
+ if (!models.length) {
64
+ throw new Error(`No Pi models are available for provider ${selectedProvider}.`);
65
+ }
66
+
67
+ const preferredModelValue = normalizeString(preferredModel);
68
+ const modelExists = models.some((item) => item.id === preferredModelValue);
69
+ if (preferredModelValue && !modelExists) {
70
+ console.log(`Ignoring unknown Pi model override for ${selectedProvider}: ${preferredModelValue}`);
71
+ }
72
+
73
+ const selectedModel = modelExists ? preferredModelValue : models[0].id;
74
+ return { provider: selectedProvider, model: selectedModel };
75
+ }
76
+
77
+ function sortBootstrapProviders(providers) {
78
+ const preferredOrder = ["openai-codex"];
79
+ const positions = new Map(providers.map((provider, index) => [provider.provider, index]));
80
+
81
+ return [...providers].sort((a, b) => {
82
+ const aPref = preferredOrder.indexOf(a.provider);
83
+ const bPref = preferredOrder.indexOf(b.provider);
84
+ const aRank = aPref === -1 ? Number.MAX_SAFE_INTEGER : aPref;
85
+ const bRank = bPref === -1 ? Number.MAX_SAFE_INTEGER : bPref;
86
+ if (aRank !== bRank) return aRank - bRank;
87
+ return (positions.get(a.provider) || 0) - (positions.get(b.provider) || 0);
88
+ });
89
+ }
90
+
18
91
  function sortBootstrapModels(provider, models) {
19
92
  const preferred = {
20
93
  "openai-codex": ["gpt-5.4"]
@@ -49,7 +122,78 @@ async function maybeOpenExternal(url) {
49
122
  });
50
123
  }
51
124
 
52
- async function runInternalPiLogin(provider, rl) {
125
+ function installAuthRelay(httpPort, setHttpRequestHandler) {
126
+ let authUrl = "";
127
+ let resolveRedirectUrl;
128
+ const redirectUrlPromise = new Promise((resolve) => {
129
+ resolveRedirectUrl = resolve;
130
+ });
131
+
132
+ const page = (body) => [
133
+ "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width'>",
134
+ "<title>Arisa Auth</title>",
135
+ "<style>body{font-family:system-ui,sans-serif;max-width:600px;margin:40px auto;padding:0 20px;line-height:1.6}",
136
+ "input[type=text]{width:100%;padding:8px;box-sizing:border-box;margin:8px 0}",
137
+ "button{padding:8px 24px;cursor:pointer}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style>",
138
+ "</head><body>",
139
+ body,
140
+ "</body></html>"
141
+ ].join("");
142
+
143
+ setHttpRequestHandler((req, res) => {
144
+ const parsed = new URL(req.url, `http://localhost:${httpPort}`);
145
+
146
+ if (req.method === "GET" && parsed.pathname === "/auth/callback" && parsed.searchParams.has("code")) {
147
+ const callbackUrl = `http://localhost:1455${parsed.pathname}${parsed.search}`;
148
+ resolveRedirectUrl(callbackUrl);
149
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
150
+ res.end(page("<h2>Authentication received</h2><p>You can close this page. Arisa is starting&hellip;</p>"));
151
+ return;
152
+ }
153
+
154
+ if (req.method === "GET" && parsed.pathname === "/") {
155
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
156
+ res.end(page([
157
+ "<h2>Arisa &mdash; Pi Authentication</h2>",
158
+ authUrl
159
+ ? `<p><strong>1.</strong> <a href="${authUrl}" target="_blank">Click here to log in with Pi</a></p>`
160
+ : "<p>Waiting for authentication URL&hellip;</p>",
161
+ "<p><strong>2.</strong> After login your browser will redirect to a <code>localhost</code> URL that won't load. That's expected.</p>",
162
+ "<p><strong>3.</strong> In your browser's address bar, replace <code>localhost:1455</code> with your server's domain and press Enter.</p>",
163
+ "<hr>",
164
+ "<p><em>Or paste the full redirect URL here:</em></p>",
165
+ '<form method="POST" action="/auth/relay">',
166
+ '<input type="text" name="url" placeholder="Paste the localhost redirect URL here&hellip;" required />',
167
+ "<button type='submit'>Submit</button>",
168
+ "</form>"
169
+ ].join("\n")));
170
+ return;
171
+ }
172
+
173
+ if (req.method === "POST" && parsed.pathname === "/auth/relay") {
174
+ let body = "";
175
+ req.on("data", (chunk) => { body += chunk; });
176
+ req.on("end", () => {
177
+ const url = (new URLSearchParams(body).get("url") || "").trim();
178
+ if (url) resolveRedirectUrl(url);
179
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
180
+ res.end(page("<h2>Authentication received</h2><p>You can close this page. Arisa is starting&hellip;</p>"));
181
+ });
182
+ return;
183
+ }
184
+
185
+ res.writeHead(200, { "Content-Type": "text/plain" });
186
+ res.end("ok");
187
+ });
188
+
189
+ return {
190
+ setAuthUrl(url) { authUrl = url; },
191
+ waitForRedirectUrl() { return redirectUrlPromise; },
192
+ uninstall() { setHttpRequestHandler(null); }
193
+ };
194
+ }
195
+
196
+ async function runInternalPiLogin(provider, { rl = null, authRelay = null } = {}) {
53
197
  const authStorage = AuthStorage.create();
54
198
  const selected = authStorage.getOAuthProviders().find((item) => item.id === provider);
55
199
  if (!selected) {
@@ -67,7 +211,15 @@ async function runInternalPiLogin(provider, rl) {
67
211
  onAuth: async ({ url, instructions }) => {
68
212
  console.log(`${instructions || "Open this URL to continue authentication:"}\n${url}\n`);
69
213
  await maybeOpenExternal(url);
70
- if (selected.usesCallbackServer) {
214
+ if (authRelay) {
215
+ authRelay.setAuthUrl(url);
216
+ console.log("Waiting for authentication via the web relay...");
217
+ const redirectUrl = await authRelay.waitForRedirectUrl();
218
+ if (redirectUrl && manualCodeResolve) {
219
+ manualCodeResolve(redirectUrl);
220
+ manualCodeResolve = undefined;
221
+ }
222
+ } else if (selected.usesCallbackServer && rl) {
71
223
  const pasted = (await rl.question("Paste the redirect URL here if the browser does not return automatically, or press Enter to keep waiting: ")).trim();
72
224
  if (pasted && manualCodeResolve) {
73
225
  manualCodeResolve(pasted);
@@ -81,6 +233,9 @@ async function runInternalPiLogin(provider, rl) {
81
233
  await maybeOpenExternal(verificationUri);
82
234
  },
83
235
  onPrompt: async ({ message }) => {
236
+ if (!rl) {
237
+ throw new Error(`Pi login for ${provider} requires interactive input: ${message}`);
238
+ }
84
239
  return (await rl.question(`${message} `)).trim();
85
240
  },
86
241
  onProgress: (message) => {
@@ -96,10 +251,54 @@ async function runInternalPiLogin(provider, rl) {
96
251
  });
97
252
  }
98
253
 
99
- export async function bootstrapIfNeeded({ force = false } = {}) {
254
+ export async function bootstrapIfNeeded({ force = false, cliConfigOverrides = {}, httpPort = 0, setHttpRequestHandler } = {}) {
100
255
  await ensureArisaHome();
101
256
  if (!force && await exists(configFile)) return;
102
257
 
258
+ const telegramApiKeyFromCli = normalizeString(cliConfigOverrides?.telegram?.token);
259
+ if (telegramApiKeyFromCli) {
260
+ const runtime = createPiRuntime();
261
+ const resolvedPi = resolvePiDefaults(runtime, cliConfigOverrides?.pi || {});
262
+ const telegramMaxChatIds = parseMaxChatIds(cliConfigOverrides?.telegram?.maxChatIds, 1);
263
+ const piApiKey = normalizeString(cliConfigOverrides?.pi?.apiKey);
264
+ if (!piApiKey && !hasProviderAuth(resolvedPi.provider, runtime)) {
265
+ if (!supportsProviderOAuth(resolvedPi.provider, runtime)) {
266
+ throw new Error(
267
+ `No auth found for ${resolvedPi.provider}. Provide --pi.apiKey for non-interactive bootstrap, or use a provider that supports OAuth.`
268
+ );
269
+ }
270
+ if (!httpPort || !setHttpRequestHandler) {
271
+ throw new Error(
272
+ `No auth found for ${resolvedPi.provider}. Auth relay requires an HTTP server on PORT. Provide --pi.apiKey or set the PORT environment variable.`
273
+ );
274
+ }
275
+ const authRelay = installAuthRelay(httpPort, setHttpRequestHandler);
276
+ console.log(`No existing Pi auth found for ${resolvedPi.provider}. Auth relay active on port ${httpPort}.`);
277
+ console.log(`Open your server URL in a browser to complete Pi authentication.\n`);
278
+ try {
279
+ await runInternalPiLogin(resolvedPi.provider, { authRelay });
280
+ } finally {
281
+ authRelay.uninstall();
282
+ }
283
+ if (!hasProviderAuth(resolvedPi.provider, createPiRuntime())) {
284
+ throw new Error(
285
+ `Pi login did not complete for ${resolvedPi.provider}. Retry or provide --pi.apiKey.`
286
+ );
287
+ }
288
+ console.log(`Detected Pi auth for ${resolvedPi.provider}. Continuing bootstrap.`);
289
+ }
290
+ const config = buildConfig({
291
+ telegramApiKey: telegramApiKeyFromCli,
292
+ telegramMaxChatIds,
293
+ provider: resolvedPi.provider,
294
+ model: resolvedPi.model,
295
+ piApiKey
296
+ });
297
+ await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
298
+ console.log(`\nConfig saved to ${configFile} (non-interactive bootstrap)\n`);
299
+ return;
300
+ }
301
+
103
302
  const rl = readline.createInterface({ input, output });
104
303
  const ask = async (label, fallback = "") => {
105
304
  const suffix = fallback ? ` (${fallback})` : "";
@@ -113,7 +312,7 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
113
312
  const telegramMaxChatIds = Number(await ask("Maximum authorized chat IDs", "1"));
114
313
 
115
314
  const runtime = createPiRuntime();
116
- const providers = listPiProviders(runtime);
315
+ const providers = sortBootstrapProviders(listPiProviders(runtime));
117
316
  console.log("\nAvailable Pi providers:");
118
317
  providers.forEach((item, index) => {
119
318
  const authLabel = item.authConfigured ? "auth: configured" : item.supportsOAuth ? "auth: login or API key" : "auth: API key";
@@ -153,7 +352,7 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
153
352
 
154
353
  console.log(`No existing Pi auth found for ${selectedProvider.provider}. Starting internal Pi login...`);
155
354
  try {
156
- await runInternalPiLogin(selectedProvider.provider, rl);
355
+ await runInternalPiLogin(selectedProvider.provider, { rl });
157
356
  } catch (error) {
158
357
  console.log(`Internal Pi login failed: ${error instanceof Error ? error.message : String(error)}`);
159
358
  }
@@ -166,20 +365,13 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
166
365
  console.log(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
167
366
  }
168
367
 
169
- const config = {
170
- telegram: {
171
- apiKey: telegramApiKey,
172
- maxChatIds: telegramMaxChatIds,
173
- authorizedChatIds: [],
174
- chatMeta: {}
175
- },
176
- pi: {
177
- provider: selectedProvider.provider,
178
- model: selectedModel.id,
179
- apiKey: piApiKey
180
- },
181
- createdAt: new Date().toISOString()
182
- };
368
+ const config = buildConfig({
369
+ telegramApiKey,
370
+ telegramMaxChatIds,
371
+ provider: selectedProvider.provider,
372
+ model: selectedModel.id,
373
+ piApiKey
374
+ });
183
375
 
184
376
  await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
185
377
  rl.close();
@@ -5,9 +5,51 @@ import { TaskStore } from "../core/tasks/task-store.js";
5
5
  import { AgentManager } from "../core/agent/agent-manager.js";
6
6
  import { createTelegramBot } from "../transport/telegram/bot.js";
7
7
 
8
- export async function createApp({ logger } = {}) {
8
+ function normalizeString(value) {
9
+ const text = String(value ?? "").trim();
10
+ return text ? text : "";
11
+ }
12
+
13
+ function splitModelOverride(modelOverride) {
14
+ const separatorIndex = modelOverride.indexOf("/");
15
+ if (separatorIndex <= 0 || separatorIndex === modelOverride.length - 1) {
16
+ return null;
17
+ }
18
+ return {
19
+ provider: modelOverride.slice(0, separatorIndex),
20
+ model: modelOverride.slice(separatorIndex + 1)
21
+ };
22
+ }
23
+
24
+ function applyRuntimeOverrides(config, runtimeOverrides) {
25
+ const providerOverride = normalizeString(runtimeOverrides?.pi?.provider);
26
+ const modelOverride = normalizeString(runtimeOverrides?.pi?.model);
27
+ if (!providerOverride && !modelOverride) return config;
28
+
29
+ const splitOverride = modelOverride ? splitModelOverride(modelOverride) : null;
30
+ const provider = providerOverride || splitOverride?.provider || config.pi.provider;
31
+ const model = splitOverride && (!providerOverride || providerOverride === splitOverride.provider)
32
+ ? splitOverride.model
33
+ : (modelOverride || config.pi.model);
34
+
35
+ return {
36
+ ...config,
37
+ pi: {
38
+ ...config.pi,
39
+ provider,
40
+ model
41
+ }
42
+ };
43
+ }
44
+
45
+ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpRequestHandler } = {}) {
9
46
  logger?.log("app", "loading config");
10
- const config = await loadConfig();
47
+ const persistedConfig = await loadConfig();
48
+ const config = applyRuntimeOverrides(persistedConfig, runtimeOverrides);
49
+ if (config.pi.provider !== persistedConfig.pi.provider || config.pi.model !== persistedConfig.pi.model) {
50
+ logger?.log("app", `applying runtime model override: ${persistedConfig.pi.provider}/${persistedConfig.pi.model} -> ${config.pi.provider}/${config.pi.model}`);
51
+ }
52
+
11
53
  const artifactStore = new ArtifactStore();
12
54
  const toolRegistry = new ToolRegistry({ logger });
13
55
  const taskStore = new TaskStore();
@@ -15,7 +57,7 @@ export async function createApp({ logger } = {}) {
15
57
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
16
58
 
17
59
  const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
18
- const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger });
60
+ const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler });
19
61
 
20
62
  return {
21
63
  async start() {
@@ -7,10 +7,25 @@ export const stateDir = path.join(arisaHomeDir, "state");
7
7
  export const configFile = path.join(stateDir, "config.json");
8
8
  export const servicePidFile = path.join(stateDir, "arisa.pid");
9
9
  export const serviceLogFile = path.join(stateDir, "arisa.log");
10
- export const artifactsDir = path.join(arisaHomeDir, "artifacts");
11
- export const artifactsIndexFile = path.join(stateDir, "artifacts.json");
12
10
  export const tasksFile = path.join(stateDir, "tasks.json");
13
11
  export const toolsDir = path.join(arisaHomeDir, "tools");
12
+ export const chatsDir = path.join(arisaHomeDir, "chats");
13
+
14
+ export function getChatDir(chatId) {
15
+ return path.join(chatsDir, String(chatId));
16
+ }
17
+
18
+ export function getChatArtifactsDir(chatId) {
19
+ return path.join(getChatDir(chatId), "artifacts");
20
+ }
21
+
22
+ export function getChatArtifactsIndexFile(chatId) {
23
+ return path.join(getChatDir(chatId), "state", "artifacts.json");
24
+ }
25
+
26
+ export function getChatPiSessionsDir(chatId) {
27
+ return path.join(getChatDir(chatId), "state", "pi-sessions");
28
+ }
14
29
 
15
30
  export function getToolDir(toolName) {
16
31
  return path.join(toolsDir, toolName);
@@ -20,6 +35,10 @@ export function getToolConfigPath(toolName) {
20
35
  return path.join(getToolDir(toolName), "config.js");
21
36
  }
22
37
 
38
+ export function getChatToolConfigPath(chatId, toolName) {
39
+ return path.join(getChatDir(chatId), "tools", toolName, "config.js");
40
+ }
41
+
23
42
  export function getToolRuntimeDir(toolName) {
24
43
  return getToolDir(toolName);
25
44
  }
@@ -32,9 +51,13 @@ export function getToolTmpDir(toolName) {
32
51
  return path.join(getToolRuntimeDir(toolName), "tmp");
33
52
  }
34
53
 
54
+ export function getChatToolTmpDir(chatId, toolName) {
55
+ return path.join(getChatDir(chatId), "tools", toolName, "tmp");
56
+ }
57
+
35
58
  export async function ensureArisaHome() {
36
59
  await mkdir(stateDir, { recursive: true });
37
- await mkdir(artifactsDir, { recursive: true });
38
60
  await mkdir(toolsDir, { recursive: true });
61
+ await mkdir(chatsDir, { recursive: true });
39
62
  }
40
63
 
@@ -36,7 +36,7 @@ export async function getServiceStatus() {
36
36
  return { running: true, pid };
37
37
  }
38
38
 
39
- export async function startService({ verbose = false } = {}) {
39
+ export async function startService({ verbose = false, cliArgs = [] } = {}) {
40
40
  await ensureArisaHome();
41
41
  const status = await getServiceStatus();
42
42
  if (status.running) {
@@ -44,7 +44,7 @@ export async function startService({ verbose = false } = {}) {
44
44
  }
45
45
 
46
46
  const logHandle = await open(serviceLogFile, "a");
47
- const args = [entryFile, "--service-runner"];
47
+ const args = [entryFile, "--service-runner", ...cliArgs];
48
48
  if (verbose) args.push("--verbose");
49
49
 
50
50
  const child = spawn(process.execPath, args, {
@@ -1,8 +1,9 @@
1
- import { Bot, InputFile } from "grammy";
1
+ import { Bot, InputFile, webhookCallback } from "grammy";
2
2
  import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
4
  import { captureIncomingArtifact } from "./media.js";
5
5
  import { renderTelegramHtml } from "./text-format.js";
6
+ import { normalizeArtifactForReasoning } from "../../core/artifacts/normalize-for-reasoning.js";
6
7
 
7
8
  function quotedMessageSummary(message) {
8
9
  if (!message) return [];
@@ -39,16 +40,21 @@ function getTelegramCommand(ctx) {
39
40
  return text.slice(1, entity.length).split("@")[0].trim().toLowerCase();
40
41
  }
41
42
 
43
+ function getIncomingMessageText(message) {
44
+ return message?.text || message?.caption || "";
45
+ }
46
+
42
47
  function buildPrompt({ ctx, artifact, transcript, toolResult }) {
43
48
  const parts = [
44
- `New Session..`,
49
+ `Incoming Telegram message.`,
45
50
  `chatId: ${ctx.chat.id}`,
46
51
  `userId: ${ctx.from.id}`,
47
52
  `username: ${ctx.from.username || "(no username)"}`,
48
53
  `messageId: ${ctx.msg.message_id}`
49
54
  ];
50
55
 
51
- if (ctx.message?.text) parts.push(`text: ${ctx.message.text}`);
56
+ const messageText = getIncomingMessageText(ctx.message);
57
+ if (messageText) parts.push(`text: ${messageText}`);
52
58
  parts.push(...quotedMessageSummary(ctx.message?.reply_to_message));
53
59
  if (artifact?.path) parts.push(`artifactPath: ${artifact.path}`);
54
60
  if (artifact?.id) parts.push(`artifactId: ${artifact.id}`);
@@ -79,43 +85,61 @@ function buildNewSessionPrompt(ctx) {
79
85
  ].join("\n");
80
86
  }
81
87
 
82
- function buildAsyncTaskPrompt(task) {
83
- return [
88
+ async function buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, logger }) {
89
+ const parts = [
84
90
  "Scheduled task fired.",
85
91
  `taskId: ${task.id}`,
86
92
  `chatId: ${task.payload.chatId}`,
87
- task.payload.prompt ? `text: ${task.payload.prompt}` : null,
88
- "Treat this as a new request for the chat and fulfill it now.",
89
- "If needed, use tools."
90
- ].filter(Boolean).join("\n");
91
- }
93
+ task.payload.prompt ? `text: ${task.payload.prompt}` : null
94
+ ];
92
95
 
93
- async function maybeTranscribeIncomingAudio({ artifact, toolRegistry, artifactStore }) {
94
- if (!artifact || artifact.kind !== "audio") return { transcript: null };
96
+ if (task.payload.artifactId) {
97
+ const chatArtifactStore = artifactStore.forChat(task.payload.chatId);
98
+ const artifact = await chatArtifactStore.get(task.payload.artifactId);
99
+ if (artifact) {
100
+ parts.push(`artifactPath: ${artifact.path || ""}`);
101
+ parts.push(`artifactId: ${artifact.id}`);
102
+ parts.push(`mimeType: ${artifact.mimeType}`);
103
+ parts.push(`kind: ${artifact.kind}`);
104
+
105
+ const { normalizedArtifact, toolResult } = await normalizeArtifactForReasoning({
106
+ artifact,
107
+ desiredMimeType: "text/plain",
108
+ toolRegistry,
109
+ chatArtifactStore,
110
+ chatId: task.payload.chatId
111
+ });
95
112
 
96
- const result = await toolRegistry.run({
97
- name: "openai-transcribe",
98
- request: {
99
- artifact,
100
- args: {}
113
+ if (normalizedArtifact) {
114
+ logger?.log("tasks", `artifact ${artifact.id} normalized to ${normalizedArtifact.id}`);
115
+ parts.push(`transcriptArtifactId: ${normalizedArtifact.id}`);
116
+ parts.push(`transcriptText: ${normalizedArtifact.text}`);
117
+ parts.push("Important: the attached audio artifact has already been normalized for reasoning. Use the transcript as the message content.");
118
+ } else if (artifact.kind === "audio" && toolResult) {
119
+ parts.push(`audioNormalizationResult: ${JSON.stringify(toolResult)}`);
120
+ parts.push("Important: pre-reasoning audio normalization could not be completed, so you do not have a transcript for this audio artifact.");
121
+ }
122
+ } else {
123
+ parts.push(`artifactId: ${task.payload.artifactId}`);
124
+ parts.push("Important: referenced artifact was not found.");
101
125
  }
102
- });
103
-
104
- if (!result.ok) {
105
- return { transcript: null, toolResult: result };
106
126
  }
107
127
 
108
- if (!result.output?.text) {
109
- return { transcript: null, toolResult: { ok: false, status: "failed", error: "Transcription returned no text." } };
110
- }
128
+ parts.push("Treat this as a new request for the chat and fulfill it now.");
129
+ parts.push("If needed, use tools.");
130
+ return parts.filter(Boolean).join("\n");
131
+ }
111
132
 
112
- const transcript = await artifactStore.createText({
113
- text: result.output.text,
114
- source: { type: "tool", toolName: "openai-transcribe" },
115
- metadata: { fromArtifactId: artifact.id, tool: "openai-transcribe" }
133
+ async function normalizeIncomingArtifact({ artifact, toolRegistry, chatArtifactStore, chatId }) {
134
+ if (!artifact) return { transcript: null, toolResult: null };
135
+ const { normalizedArtifact, toolResult } = await normalizeArtifactForReasoning({
136
+ artifact,
137
+ desiredMimeType: "text/plain",
138
+ toolRegistry,
139
+ chatArtifactStore,
140
+ chatId
116
141
  });
117
-
118
- return { transcript, toolResult: result };
142
+ return { transcript: normalizedArtifact, toolResult };
119
143
  }
120
144
 
121
145
  async function collectText(session, prompt) {
@@ -143,8 +167,8 @@ async function withTyping(ctx, work) {
143
167
  }
144
168
  }
145
169
 
146
- export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger }) {
147
- const bot = new Bot(config.telegram.apiKey);
170
+ export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler }) {
171
+ const bot = new Bot(config.telegram.token);
148
172
  const perChatState = new Map();
149
173
 
150
174
  function getIncomingChatMeta(ctx) {
@@ -164,10 +188,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
164
188
  }
165
189
 
166
190
  async function buildIncomingPrompt(ctx) {
167
- logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${ctx.chat.id}`);
191
+ const chatId = ctx.chat.id;
192
+ logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${chatId}`);
193
+ const chatArtifactStore = artifactStore.forChat(chatId);
168
194
  const artifact = await captureIncomingArtifact(ctx, artifactStore);
169
195
  if (artifact) logger?.log("telegram", `captured artifact ${artifact.kind}${artifact.id ? ` ${artifact.id}` : ""}`);
170
- const { transcript, toolResult } = await maybeTranscribeIncomingAudio({ artifact, toolRegistry, artifactStore });
196
+ const { transcript, toolResult } = await normalizeIncomingArtifact({ artifact, toolRegistry, chatArtifactStore, chatId });
171
197
  if (transcript) logger?.log("telegram", `audio transcribed to artifact ${transcript.id}`);
172
198
  if (artifact?.kind === "audio" && !transcript) {
173
199
  logger?.log("telegram", `audio normalization unavailable for chat ${ctx.chat.id}: ${toolResult?.error || toolResult?.missingConfig?.join(", ") || "unknown error"}`);
@@ -180,7 +206,8 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
180
206
 
181
207
  if (text.length > maxInlineReplyLength) {
182
208
  logger?.log("telegram", `sending long reply as markdown attachment for chat ${chatId}`);
183
- const artifact = await artifactStore.createGeneratedFile({
209
+ const chatArtifactStore = artifactStore.forChat(chatId);
210
+ const artifact = await chatArtifactStore.createGeneratedFile({
184
211
  fileName: `reply-${Date.now()}.md`,
185
212
  content: text,
186
213
  kind: "document",
@@ -365,7 +392,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
365
392
  logger?.log("tasks", `running task ${task.id} for chat ${task.payload.chatId}`);
366
393
  await enqueuePrompt({
367
394
  chatId: task.payload.chatId,
368
- prompt: buildAsyncTaskPrompt(task),
395
+ prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, logger }),
369
396
  label: `scheduled task ${task.id}`
370
397
  });
371
398
  await taskStore.complete(task.id);
@@ -374,8 +401,26 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
374
401
  }
375
402
  }
376
403
  }, 1000).unref();
377
- logger?.log("telegram", "bot polling started");
378
- await bot.start({ drop_pending_updates: true });
404
+ if (webhookUrl && setHttpRequestHandler) {
405
+ const webhookPath = `/telegram-${config.telegram.token.slice(-8)}`;
406
+ const handleUpdate = webhookCallback(bot, "http", {
407
+ timeoutMilliseconds: 60_000,
408
+ onTimeout: "return",
409
+ });
410
+ setHttpRequestHandler((req, res) => {
411
+ const parsed = new URL(req.url, "http://localhost");
412
+ if (req.method === "POST" && parsed.pathname === webhookPath) {
413
+ return handleUpdate(req, res);
414
+ }
415
+ res.writeHead(200, { "Content-Type": "text/plain" });
416
+ res.end("ok");
417
+ });
418
+ await bot.api.setWebhook(`${webhookUrl}${webhookPath}`);
419
+ logger?.log("telegram", `webhook mode: ${webhookUrl}${webhookPath}`);
420
+ } else {
421
+ logger?.log("telegram", "bot polling started");
422
+ await bot.start({ drop_pending_updates: true });
423
+ }
379
424
  }
380
425
  };
381
426
  }