arisa 4.1.8 → 4.1.12
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.
- package/AGENTS.md +5 -1
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +47 -52
- package/src/transport/telegram/bot.js +3 -3
- package/test/artifact-store.test.js +99 -0
- package/test/auth-flow.test.js +40 -0
- package/test/auth.test.js +101 -0
- package/test/capabilities-security.test.js +249 -0
- package/test/media-caption.test.js +15 -0
- package/test/normalize-for-reasoning.test.js +173 -0
- package/test/paths.test.js +54 -0
- package/test/task-store.test.js +119 -0
- package/test/text-format.test.js +61 -0
- package/test/tool-config.test.js +72 -0
- package/test/tool-registry-run.test.js +136 -0
- package/test/tool-result.test.js +127 -0
package/AGENTS.md
CHANGED
|
@@ -121,7 +121,9 @@ To run a pipe, the agent should:
|
|
|
121
121
|
Example manual pipe:
|
|
122
122
|
1. `run_tool(openai-transcribe, artifact audio)`
|
|
123
123
|
2. take the returned text `artifactId`
|
|
124
|
-
3. `run_tool(openai-tts, artifact text)` or `
|
|
124
|
+
3. `run_tool(openai-tts, artifact text)` to generate audio, then `send_artifact(artifactId)` to deliver it (or `run_tool(openai-tts, artifact text, deliver: true)` to generate and send in one step)
|
|
125
|
+
|
|
126
|
+
Delivery is generic: any `run_tool` output that produces a file becomes an artifact, and `send_artifact(artifactId)` delivers it to the chat. The delivery method and filename are derived from the artifact (its `delivery` hint, `kind`, and stored name); internal local paths are never exposed. No caption is sent by default: the filename already appears on the attachment, so it is never duplicated into the caption text (which would let Telegram autolink a filename like `example.md` as a URL). A caption is shown only when an explicit one is passed. Tools declare their delivery intent by returning `delivery: { method }` in their output; they do not deliver to the transport themselves. As a shortcut, `run_tool` accepts `deliver: true` to generate and deliver in a single step; use it only when the user should receive the file now, not for intermediate pipe steps.
|
|
125
127
|
|
|
126
128
|
## Async event queue flow
|
|
127
129
|
Beyond time-based scheduling, tools can drive an event queue that wakes the agent only when there is something to evaluate. Everything goes through the `asyncTask` (single) or `asyncTasks` (array) field the pipeline already supports; no new Pi tools are needed. The 1s poller drains tasks by `kind`:
|
|
@@ -195,6 +197,8 @@ Tools may declare skills in `tool.manifest.json`:
|
|
|
195
197
|
|
|
196
198
|
The tool registry resolves these from the installed skills directory and injects them into the tool request as `skills`. `list_tools` exposes the hints and `tool_help` shows their resolution status. Skills are guidance for the agent/tool; they are not separate runtime dependencies.
|
|
197
199
|
|
|
200
|
+
If a tool executes deterministic logic over skill content, bundle the required skill assets inside the tool package and treat injected `skills` only as an optional override. A catalog tool must still work when the hinted skill is not installed on the target Arisa instance.
|
|
201
|
+
|
|
198
202
|
## Safety
|
|
199
203
|
- Prefer tool manifests and CLI help over assumptions.
|
|
200
204
|
- Keep config and runtime data inside the user runtime area through the path helpers above.
|
package/package.json
CHANGED
|
@@ -19,7 +19,7 @@ const arisaToolNames = [
|
|
|
19
19
|
"list_scheduled_tasks",
|
|
20
20
|
"cancel_scheduled_task",
|
|
21
21
|
"cancel_all_scheduled_tasks",
|
|
22
|
-
"
|
|
22
|
+
"send_artifact"
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
function isLocalBaseUrl(value) {
|
|
@@ -55,30 +55,28 @@ async function promptAndThrowOnAssistantError(session, prompt) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
if (pattern.endsWith("/*")) return mimeType.startsWith(`${pattern.slice(0, -2)}/`);
|
|
62
|
-
return false;
|
|
58
|
+
function inferDeliveryMethod(artifact) {
|
|
59
|
+
if (artifact.kind === "audio" || (artifact.mimeType || "").startsWith("audio/")) return "audio";
|
|
60
|
+
return "document";
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
function
|
|
66
|
-
|
|
63
|
+
function containsAbsolutePath(value) {
|
|
64
|
+
if (typeof value !== "string") return false;
|
|
65
|
+
return /(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(value);
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
function
|
|
70
|
-
|
|
68
|
+
export function resolveMediaCaption(caption) {
|
|
69
|
+
if (caption && !containsAbsolutePath(caption)) return caption;
|
|
70
|
+
return undefined;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.filter(toolProducesAudio);
|
|
81
|
-
return tools.find(looksLikeTextToSpeechTool) || tools[0] || null;
|
|
73
|
+
async function deliverArtifactToChat({ artifact, telegram, caption, method, logger }) {
|
|
74
|
+
const resolvedMethod = method || artifact.metadata?.delivery?.method || inferDeliveryMethod(artifact);
|
|
75
|
+
const fileName = path.basename(artifact.path);
|
|
76
|
+
const resolvedCaption = resolveMediaCaption(caption);
|
|
77
|
+
logger?.log("agent", `deliver artifact ${artifact.id} as ${resolvedMethod}`);
|
|
78
|
+
await telegram.sendMedia(artifact.path, { method: resolvedMethod, caption: resolvedCaption, filename: fileName });
|
|
79
|
+
return { method: resolvedMethod, fileName, artifactId: artifact.id };
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
async function assertDirectory(dir, label) {
|
|
@@ -238,7 +236,7 @@ export class AgentManager {
|
|
|
238
236
|
kind: result.output.kind || "file",
|
|
239
237
|
mimeType: result.output.mimeType || "application/octet-stream",
|
|
240
238
|
source: { type: "tool", toolName: name },
|
|
241
|
-
metadata: { tool: name }
|
|
239
|
+
metadata: { tool: name, delivery: result.output.delivery }
|
|
242
240
|
});
|
|
243
241
|
result.output.artifactId = generated.id;
|
|
244
242
|
await unlink(result.output.filePath).catch(() => {});
|
|
@@ -337,12 +335,13 @@ export class AgentManager {
|
|
|
337
335
|
defineTool({
|
|
338
336
|
name: "run_tool",
|
|
339
337
|
label: "Run tool",
|
|
340
|
-
description: "Run a CLI tool using text input or an artifactId. Inspect the returned status/resolution fields. If a tool reports missing config, ask the user naturally, use set_tool_config, and retry.",
|
|
338
|
+
description: "Run a CLI tool using text input or an artifactId. Inspect the returned status/resolution fields. If a tool reports missing config, ask the user naturally, use set_tool_config, and retry. Set `deliver: true` to also send the generated file to the chat in one step (only when you want the user to receive it now, not for intermediate pipe steps).",
|
|
341
339
|
parameters: Type.Object({
|
|
342
340
|
name: Type.String(),
|
|
343
341
|
artifactId: Type.Optional(Type.String()),
|
|
344
342
|
text: Type.Optional(Type.String()),
|
|
345
|
-
args: Type.Optional(Type.Record(Type.String(), Type.String()))
|
|
343
|
+
args: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
344
|
+
deliver: Type.Optional(Type.Boolean())
|
|
346
345
|
}),
|
|
347
346
|
execute: async (_id, params) => {
|
|
348
347
|
let artifact = null;
|
|
@@ -362,6 +361,13 @@ export class AgentManager {
|
|
|
362
361
|
chatId
|
|
363
362
|
});
|
|
364
363
|
|
|
364
|
+
if (params.deliver && result.output?.artifactId) {
|
|
365
|
+
const generated = await chatArtifactStore.get(result.output.artifactId);
|
|
366
|
+
if (generated?.path) {
|
|
367
|
+
result.sent = await deliverArtifactToChat({ artifact: generated, telegram, logger: this.logger });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
365
371
|
return {
|
|
366
372
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
367
373
|
details: result
|
|
@@ -417,12 +423,12 @@ export class AgentManager {
|
|
|
417
423
|
}
|
|
418
424
|
}),
|
|
419
425
|
defineTool({
|
|
420
|
-
name: "
|
|
421
|
-
label: "Send
|
|
422
|
-
description: "
|
|
426
|
+
name: "send_artifact",
|
|
427
|
+
label: "Send artifact",
|
|
428
|
+
description: "Deliver an existing chat artifact to the current Telegram chat. Pass the `artifactId` returned by run_tool or from an inbound file. The delivery method and filename are derived from the artifact (its delivery hint, kind, and stored name); internal local paths are never exposed. No caption is shown by default, since the filename already appears on the attachment; set `caption` only to add a separate visible label, or `method` to override the delivery method. The artifact is not deleted.",
|
|
423
429
|
parameters: Type.Object({
|
|
424
|
-
|
|
425
|
-
|
|
430
|
+
artifactId: Type.String(),
|
|
431
|
+
caption: Type.Optional(Type.String()),
|
|
426
432
|
method: Type.Optional(Type.Union([
|
|
427
433
|
Type.Literal("voice"),
|
|
428
434
|
Type.Literal("audio"),
|
|
@@ -430,36 +436,25 @@ export class AgentManager {
|
|
|
430
436
|
]))
|
|
431
437
|
}),
|
|
432
438
|
execute: async (_id, params) => {
|
|
433
|
-
await
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
: selectMediaReplyTool(this.toolRegistry);
|
|
437
|
-
if (!selectedTool) {
|
|
438
|
-
const result = {
|
|
439
|
-
ok: false,
|
|
440
|
-
status: "failed",
|
|
441
|
-
error: params.toolName
|
|
442
|
-
? `Tool not found: ${params.toolName}`
|
|
443
|
-
: "No registered text-to-speech tool can generate an audio reply."
|
|
444
|
-
};
|
|
439
|
+
const artifact = await chatArtifactStore.get(params.artifactId);
|
|
440
|
+
if (!artifact) {
|
|
441
|
+
const result = { ok: false, status: "failed", error: `Artifact not found: ${params.artifactId}` };
|
|
445
442
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
|
|
446
443
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const result = await this.toolRegistry.run({
|
|
450
|
-
name: toolName,
|
|
451
|
-
request: { text: params.text, args: {} },
|
|
452
|
-
chatId
|
|
453
|
-
});
|
|
454
|
-
if (!result.ok || !result.output?.filePath) {
|
|
444
|
+
if (!artifact.path) {
|
|
445
|
+
const result = { ok: false, status: "failed", error: `Artifact ${params.artifactId} has no file to deliver.` };
|
|
455
446
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
|
|
456
447
|
}
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
448
|
+
const sent = await deliverArtifactToChat({
|
|
449
|
+
artifact,
|
|
450
|
+
telegram,
|
|
451
|
+
caption: params.caption,
|
|
452
|
+
method: params.method,
|
|
453
|
+
logger: this.logger
|
|
454
|
+
});
|
|
460
455
|
return {
|
|
461
|
-
content: [{ type: "text", text: `Media sent to Telegram as ${method}.` }],
|
|
462
|
-
details: {
|
|
456
|
+
content: [{ type: "text", text: `Media sent to Telegram as ${sent.method}.` }],
|
|
457
|
+
details: { ok: true, sent }
|
|
463
458
|
};
|
|
464
459
|
}
|
|
465
460
|
})
|
|
@@ -77,7 +77,7 @@ function buildPrompt({ ctx, artifact, transcript, toolResult }) {
|
|
|
77
77
|
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.`);
|
|
78
78
|
parts.push(`If you need an Arisa modular CLI tool, use list_tools/tool_help/run_tool.`);
|
|
79
79
|
parts.push(`If a tool config is missing, ask the user naturally and then use set_tool_config.`);
|
|
80
|
-
parts.push(`
|
|
80
|
+
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).`);
|
|
81
81
|
return parts.join("\n");
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -426,9 +426,9 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
|
|
|
426
426
|
|
|
427
427
|
function createTelegramSessionBridge(chatId) {
|
|
428
428
|
return {
|
|
429
|
-
sendMedia: async (filePath, { method = "audio", caption } = {}) => {
|
|
429
|
+
sendMedia: async (filePath, { method = "audio", caption, filename } = {}) => {
|
|
430
430
|
logger?.log("telegram", `sending ${method} reply for chat ${chatId}`);
|
|
431
|
-
const input = new InputFile(filePath);
|
|
431
|
+
const input = new InputFile(filePath, filename || undefined);
|
|
432
432
|
if (method === "voice") return bot.api.sendVoice(chatId, input, { caption });
|
|
433
433
|
if (method === "document") return bot.api.sendDocument(chatId, input, { caption });
|
|
434
434
|
return bot.api.sendAudio(chatId, input, { caption });
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-artifact-store-home-"));
|
|
8
|
+
process.env.HOME = homeDir;
|
|
9
|
+
process.env.USERPROFILE = homeDir;
|
|
10
|
+
|
|
11
|
+
const { ArtifactStore } = await import("../src/core/artifacts/artifact-store.js");
|
|
12
|
+
const { arisaHomeDir } = await import("../src/runtime/paths.js");
|
|
13
|
+
|
|
14
|
+
async function resetHome() {
|
|
15
|
+
await rm(arisaHomeDir, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("creates, persists, reads, and lists text artifacts by recency", async () => {
|
|
19
|
+
await resetHome();
|
|
20
|
+
const store = new ArtifactStore();
|
|
21
|
+
const chatStore = store.forChat("chat-1");
|
|
22
|
+
|
|
23
|
+
const first = await chatStore.createText({
|
|
24
|
+
text: "first",
|
|
25
|
+
source: { type: "telegram" },
|
|
26
|
+
metadata: { index: 1 }
|
|
27
|
+
});
|
|
28
|
+
const second = await chatStore.createText({
|
|
29
|
+
text: "second",
|
|
30
|
+
mimeType: "text/markdown",
|
|
31
|
+
source: { type: "tool", toolName: "writer" },
|
|
32
|
+
metadata: { index: 2 }
|
|
33
|
+
});
|
|
34
|
+
const third = await chatStore.createText({
|
|
35
|
+
text: "third",
|
|
36
|
+
source: { type: "tool", toolName: "writer" }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.equal(first.chatId, "chat-1");
|
|
40
|
+
assert.equal(first.kind, "text");
|
|
41
|
+
assert.equal(first.mimeType, "text/plain");
|
|
42
|
+
assert.deepEqual(await chatStore.get(second.id), second);
|
|
43
|
+
assert.deepEqual((await chatStore.listRecent(2)).map((artifact) => artifact.id), [third.id, second.id]);
|
|
44
|
+
|
|
45
|
+
const reloadedStore = new ArtifactStore().forChat("chat-1");
|
|
46
|
+
assert.deepEqual(await reloadedStore.get(first.id), first);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("copies file artifacts into the chat artifact directory", async () => {
|
|
50
|
+
await resetHome();
|
|
51
|
+
const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
|
|
52
|
+
const originalPath = path.join(originalDir, "voice.ogg");
|
|
53
|
+
await writeFile(originalPath, "audio-bytes", "utf8");
|
|
54
|
+
|
|
55
|
+
const artifact = await new ArtifactStore().forChat("chat-1").createFromFile({
|
|
56
|
+
originalPath,
|
|
57
|
+
fileName: "voice.ogg",
|
|
58
|
+
kind: "audio",
|
|
59
|
+
mimeType: "audio/ogg",
|
|
60
|
+
source: { type: "telegram", fileId: "telegram-file" },
|
|
61
|
+
metadata: { duration: 3 }
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(artifact.chatId, "chat-1");
|
|
65
|
+
assert.equal(artifact.kind, "audio");
|
|
66
|
+
assert.equal(artifact.mimeType, "audio/ogg");
|
|
67
|
+
assert.equal(path.basename(artifact.path), "voice.ogg");
|
|
68
|
+
assert.equal(await readFile(artifact.path, "utf8"), "audio-bytes");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("creates generated file artifacts", async () => {
|
|
72
|
+
await resetHome();
|
|
73
|
+
const artifact = await new ArtifactStore().forChat("chat-1").createGeneratedFile({
|
|
74
|
+
fileName: "reply.md",
|
|
75
|
+
content: "# Hello\n",
|
|
76
|
+
kind: "document",
|
|
77
|
+
mimeType: "text/markdown",
|
|
78
|
+
source: { type: "assistant" }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(await readFile(artifact.path, "utf8"), "# Hello\n");
|
|
82
|
+
assert.equal(artifact.kind, "document");
|
|
83
|
+
assert.equal(artifact.mimeType, "text/markdown");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("keeps artifact indexes isolated by chat", async () => {
|
|
87
|
+
await resetHome();
|
|
88
|
+
const store = new ArtifactStore();
|
|
89
|
+
const chatA = store.forChat("chat-a");
|
|
90
|
+
const chatB = store.forChat("chat-b");
|
|
91
|
+
|
|
92
|
+
const aArtifact = await chatA.createText({ text: "for A", source: { type: "test" } });
|
|
93
|
+
const bArtifact = await chatB.createText({ text: "for B", source: { type: "test" } });
|
|
94
|
+
|
|
95
|
+
assert.deepEqual((await chatA.listRecent()).map((artifact) => artifact.id), [aArtifact.id]);
|
|
96
|
+
assert.deepEqual((await chatB.listRecent()).map((artifact) => artifact.id), [bArtifact.id]);
|
|
97
|
+
assert.equal(await chatA.get(bArtifact.id), null);
|
|
98
|
+
assert.equal(await chatB.get(aArtifact.id), null);
|
|
99
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { getErrorMessage, getPiAuthIssue } from "../src/core/agent/auth-flow.js";
|
|
4
|
+
|
|
5
|
+
test("extracts messages from Error instances and other thrown values", () => {
|
|
6
|
+
assert.equal(getErrorMessage(new Error("boom")), "boom");
|
|
7
|
+
assert.equal(getErrorMessage("plain failure"), "plain failure");
|
|
8
|
+
assert.equal(getErrorMessage(404), "404");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("classifies invalidated Pi authentication tokens", () => {
|
|
12
|
+
for (const error of [
|
|
13
|
+
new Error("authentication token has been invalidated"),
|
|
14
|
+
new Error("Token invalidated by provider"),
|
|
15
|
+
new Error("Please try signing in again"),
|
|
16
|
+
new Error("auth token expired")
|
|
17
|
+
]) {
|
|
18
|
+
assert.deepEqual(getPiAuthIssue(error), {
|
|
19
|
+
kind: "invalidated-token",
|
|
20
|
+
message: error.message
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("classifies missing Pi authentication", () => {
|
|
26
|
+
for (const error of [
|
|
27
|
+
new Error("No auth found for provider"),
|
|
28
|
+
new Error("authentication credentials are missing")
|
|
29
|
+
]) {
|
|
30
|
+
assert.deepEqual(getPiAuthIssue(error), {
|
|
31
|
+
kind: "missing-auth",
|
|
32
|
+
message: error.message
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("ignores unrelated Pi errors", () => {
|
|
38
|
+
assert.equal(getPiAuthIssue(new Error("model rate limit exceeded")), null);
|
|
39
|
+
assert.equal(getPiAuthIssue(new Error("")), null);
|
|
40
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { authorizeChat } from "../src/transport/telegram/auth.js";
|
|
4
|
+
|
|
5
|
+
function createConfig(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
telegram: {
|
|
8
|
+
authorizedChatIds: [],
|
|
9
|
+
maxChatIds: 2,
|
|
10
|
+
chatMeta: {},
|
|
11
|
+
...(overrides.telegram || {})
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createSaveConfigSpy() {
|
|
17
|
+
const calls = [];
|
|
18
|
+
const saveConfig = async (config) => {
|
|
19
|
+
calls.push(JSON.parse(JSON.stringify(config)));
|
|
20
|
+
};
|
|
21
|
+
return { saveConfig, calls };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("authorizes a new chat below the configured limit", async () => {
|
|
25
|
+
const config = createConfig();
|
|
26
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
27
|
+
|
|
28
|
+
const result = await authorizeChat({ config, chatId: 123, saveConfig });
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(result, { ok: true, firstTime: true });
|
|
31
|
+
assert.deepEqual(config.telegram.authorizedChatIds, [123]);
|
|
32
|
+
assert.equal(calls.length, 1);
|
|
33
|
+
assert.deepEqual(calls[0].telegram.authorizedChatIds, [123]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("keeps an authorized chat id unique", async () => {
|
|
37
|
+
const config = createConfig({
|
|
38
|
+
telegram: { authorizedChatIds: [123] }
|
|
39
|
+
});
|
|
40
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
41
|
+
|
|
42
|
+
const result = await authorizeChat({ config, chatId: 123, saveConfig });
|
|
43
|
+
|
|
44
|
+
assert.deepEqual(result, { ok: true, firstTime: false });
|
|
45
|
+
assert.deepEqual(config.telegram.authorizedChatIds, [123]);
|
|
46
|
+
assert.equal(calls.length, 0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rejects a new chat when the chat id limit is reached", async () => {
|
|
50
|
+
const config = createConfig({
|
|
51
|
+
telegram: { authorizedChatIds: [123, 456], maxChatIds: 2 }
|
|
52
|
+
});
|
|
53
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
54
|
+
|
|
55
|
+
const result = await authorizeChat({ config, chatId: 789, saveConfig });
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(result, { ok: false, reason: "max-chat-ids" });
|
|
58
|
+
assert.deepEqual(config.telegram.authorizedChatIds, [123, 456]);
|
|
59
|
+
assert.equal(calls.length, 0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("enforces the maxChatIds off-by-one boundary", async () => {
|
|
63
|
+
const config = createConfig({
|
|
64
|
+
telegram: { authorizedChatIds: [123], maxChatIds: 1 }
|
|
65
|
+
});
|
|
66
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
67
|
+
|
|
68
|
+
const result = await authorizeChat({ config, chatId: 456, saveConfig });
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(result, { ok: false, reason: "max-chat-ids" });
|
|
71
|
+
assert.deepEqual(config.telegram.authorizedChatIds, [123]);
|
|
72
|
+
assert.equal(calls.length, 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("merges chat metadata for an existing authorized chat and persists it", async () => {
|
|
76
|
+
const config = createConfig({
|
|
77
|
+
telegram: {
|
|
78
|
+
authorizedChatIds: [123],
|
|
79
|
+
chatMeta: {
|
|
80
|
+
123: { username: "old-name", firstName: "Ada" }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const { saveConfig, calls } = createSaveConfigSpy();
|
|
85
|
+
|
|
86
|
+
const result = await authorizeChat({
|
|
87
|
+
config,
|
|
88
|
+
chatId: 123,
|
|
89
|
+
saveConfig,
|
|
90
|
+
chatMeta: { username: "new-name", lastName: "Lovelace" }
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(result, { ok: true, firstTime: false });
|
|
94
|
+
assert.deepEqual(config.telegram.chatMeta[123], {
|
|
95
|
+
username: "new-name",
|
|
96
|
+
firstName: "Ada",
|
|
97
|
+
lastName: "Lovelace"
|
|
98
|
+
});
|
|
99
|
+
assert.equal(calls.length, 1);
|
|
100
|
+
assert.deepEqual(calls[0].telegram.chatMeta[123], config.telegram.chatMeta[123]);
|
|
101
|
+
});
|