@llblab/pi-telegram 0.2.10 → 0.4.0

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 (47) hide show
  1. package/README.md +52 -19
  2. package/docs/README.md +2 -3
  3. package/docs/architecture.md +62 -31
  4. package/docs/locks.md +136 -0
  5. package/index.ts +323 -1880
  6. package/lib/api.ts +396 -60
  7. package/lib/attachments.ts +128 -16
  8. package/lib/commands.ts +648 -14
  9. package/lib/config.ts +169 -0
  10. package/lib/handlers.ts +474 -0
  11. package/lib/locks.ts +306 -0
  12. package/lib/media.ts +196 -46
  13. package/lib/menu.ts +920 -338
  14. package/lib/model.ts +647 -0
  15. package/lib/pi.ts +90 -0
  16. package/lib/polling.ts +240 -14
  17. package/lib/preview.ts +420 -25
  18. package/lib/queue.ts +1137 -110
  19. package/lib/registration.ts +214 -31
  20. package/lib/rendering.ts +560 -366
  21. package/lib/replies.ts +198 -8
  22. package/lib/routing.ts +217 -0
  23. package/lib/runtime.ts +475 -0
  24. package/lib/setup.ts +143 -1
  25. package/lib/status.ts +432 -13
  26. package/lib/turns.ts +217 -36
  27. package/lib/updates.ts +340 -109
  28. package/package.json +18 -3
  29. package/AGENTS.md +0 -91
  30. package/BACKLOG.md +0 -5
  31. package/CHANGELOG.md +0 -34
  32. package/lib/model-switch.ts +0 -62
  33. package/lib/types.ts +0 -137
  34. package/tests/api.test.ts +0 -331
  35. package/tests/attachments.test.ts +0 -132
  36. package/tests/commands.test.ts +0 -85
  37. package/tests/config.test.ts +0 -80
  38. package/tests/media.test.ts +0 -166
  39. package/tests/menu.test.ts +0 -676
  40. package/tests/polling.test.ts +0 -202
  41. package/tests/preview.test.ts +0 -480
  42. package/tests/queue.test.ts +0 -3245
  43. package/tests/registration.test.ts +0 -268
  44. package/tests/rendering.test.ts +0 -526
  45. package/tests/replies.test.ts +0 -142
  46. package/tests/turns.test.ts +0 -247
  47. package/tests/updates.test.ts +0 -416
@@ -1,132 +0,0 @@
1
- /**
2
- * Regression tests for the Telegram attachments domain
3
- * Covers attachment queueing and attachment delivery behavior in one domain-level suite
4
- */
5
-
6
- import assert from "node:assert/strict";
7
- import test from "node:test";
8
-
9
- import {
10
- queueTelegramAttachments,
11
- sendQueuedTelegramAttachments,
12
- } from "../lib/attachments.ts";
13
-
14
- test("Attachment queueing adds files to the active Telegram turn", async () => {
15
- const activeTurn = {
16
- queuedAttachments: [],
17
- } as unknown as {
18
- queuedAttachments: Array<{ path: string; fileName: string }>;
19
- } & Parameters<typeof queueTelegramAttachments>[0]["activeTurn"];
20
- const result = await queueTelegramAttachments({
21
- activeTurn,
22
- paths: ["/tmp/demo.txt"],
23
- maxAttachmentsPerTurn: 2,
24
- statPath: async () => ({ isFile: () => true }),
25
- });
26
- assert.deepEqual(activeTurn.queuedAttachments, [
27
- { path: "/tmp/demo.txt", fileName: "demo.txt" },
28
- ]);
29
- assert.deepEqual(result.details.paths, ["/tmp/demo.txt"]);
30
- });
31
-
32
- test("Attachment queueing rejects missing turns, non-files, and full queues", async () => {
33
- await assert.rejects(
34
- () =>
35
- queueTelegramAttachments({
36
- activeTurn: undefined,
37
- paths: ["/tmp/demo.txt"],
38
- maxAttachmentsPerTurn: 1,
39
- statPath: async () => ({ isFile: () => true }),
40
- }),
41
- { message: /active Telegram turn/ },
42
- );
43
- await assert.rejects(
44
- () =>
45
- queueTelegramAttachments({
46
- activeTurn: { queuedAttachments: [] } as never,
47
- paths: ["/tmp/demo.txt"],
48
- maxAttachmentsPerTurn: 1,
49
- statPath: async () => ({ isFile: () => false }),
50
- }),
51
- { message: "Not a file: /tmp/demo.txt" },
52
- );
53
- await assert.rejects(
54
- () =>
55
- queueTelegramAttachments({
56
- activeTurn: {
57
- queuedAttachments: [{ path: "/tmp/a.txt", fileName: "a.txt" }],
58
- } as never,
59
- paths: ["/tmp/demo.txt"],
60
- maxAttachmentsPerTurn: 1,
61
- statPath: async () => ({ isFile: () => true }),
62
- }),
63
- { message: "Attachment limit reached (1)" },
64
- );
65
- });
66
-
67
- test("Attachment delivery chooses photo vs document methods from file paths", async () => {
68
- const sent: Array<string> = [];
69
- await sendQueuedTelegramAttachments(
70
- {
71
- kind: "prompt",
72
- chatId: 1,
73
- replyToMessageId: 2,
74
- sourceMessageIds: [],
75
- queueOrder: 1,
76
- queueLane: "default",
77
- laneOrder: 1,
78
- queuedAttachments: [
79
- { path: "/tmp/a.png", fileName: "a.png" },
80
- { path: "/tmp/b.txt", fileName: "b.txt" },
81
- ],
82
- content: [{ type: "text", text: "prompt" }],
83
- historyText: "history",
84
- statusSummary: "summary",
85
- },
86
- {
87
- sendMultipart: async (
88
- method,
89
- _fields,
90
- fileField,
91
- _filePath,
92
- fileName,
93
- ) => {
94
- sent.push(`${method}:${fileField}:${fileName}`);
95
- },
96
- sendTextReply: async () => undefined,
97
- },
98
- );
99
- assert.deepEqual(sent, [
100
- "sendPhoto:photo:a.png",
101
- "sendDocument:document:b.txt",
102
- ]);
103
- });
104
-
105
- test("Attachment delivery reports per-file failures via text replies", async () => {
106
- const replies: string[] = [];
107
- await sendQueuedTelegramAttachments(
108
- {
109
- kind: "prompt",
110
- chatId: 1,
111
- replyToMessageId: 2,
112
- sourceMessageIds: [],
113
- queueOrder: 1,
114
- queueLane: "default",
115
- laneOrder: 1,
116
- queuedAttachments: [{ path: "/tmp/a.png", fileName: "a.png" }],
117
- content: [{ type: "text", text: "prompt" }],
118
- historyText: "history",
119
- statusSummary: "summary",
120
- },
121
- {
122
- sendMultipart: async () => {
123
- throw new Error("upload failed");
124
- },
125
- sendTextReply: async (_chatId, _replyToMessageId, text) => {
126
- replies.push(text);
127
- return undefined;
128
- },
129
- },
130
- );
131
- assert.deepEqual(replies, ["Failed to send attachment a.png: upload failed"]);
132
- });
@@ -1,85 +0,0 @@
1
- /**
2
- * Regression tests for Telegram command helpers
3
- * Covers slash-command normalization, bot suffix stripping, arguments, and non-command input
4
- */
5
-
6
- import assert from "node:assert/strict";
7
- import test from "node:test";
8
-
9
- import {
10
- buildTelegramCommandAction,
11
- executeTelegramCommandAction,
12
- parseTelegramCommand,
13
- } from "../lib/commands.ts";
14
-
15
- test("Command helpers parse slash commands with args", () => {
16
- assert.deepEqual(parseTelegramCommand(" /Model@DemoBot claude opus "), {
17
- name: "model",
18
- args: "claude opus",
19
- });
20
- assert.deepEqual(parseTelegramCommand("/status"), {
21
- name: "status",
22
- args: "",
23
- });
24
- });
25
-
26
- test("Command helpers ignore non-command input and empty names", () => {
27
- assert.equal(parseTelegramCommand("hello /status"), undefined);
28
- assert.equal(parseTelegramCommand("/"), undefined);
29
- });
30
-
31
- test("Command helpers build command actions", () => {
32
- assert.deepEqual(buildTelegramCommandAction("stop"), { kind: "stop" });
33
- assert.deepEqual(buildTelegramCommandAction("compact"), { kind: "compact" });
34
- assert.deepEqual(buildTelegramCommandAction("status"), { kind: "status" });
35
- assert.deepEqual(buildTelegramCommandAction("model"), { kind: "model" });
36
- assert.deepEqual(buildTelegramCommandAction("help"), {
37
- kind: "help",
38
- commandName: "help",
39
- });
40
- assert.deepEqual(buildTelegramCommandAction("start"), {
41
- kind: "help",
42
- commandName: "start",
43
- });
44
- assert.deepEqual(buildTelegramCommandAction("unknown"), { kind: "ignore" });
45
- assert.deepEqual(buildTelegramCommandAction(undefined), { kind: "ignore" });
46
- });
47
-
48
- test("Command helpers execute command actions through provided handlers", async () => {
49
- const events: string[] = [];
50
- const deps = {
51
- handleStop: async () => {
52
- events.push("stop");
53
- },
54
- handleCompact: async () => {
55
- events.push("compact");
56
- },
57
- handleStatus: async () => {
58
- events.push("status");
59
- },
60
- handleModel: async () => {
61
- events.push("model");
62
- },
63
- handleHelp: async (_message: unknown, commandName: "help" | "start") => {
64
- events.push(`help:${commandName}`);
65
- },
66
- };
67
- assert.equal(
68
- await executeTelegramCommandAction({ kind: "ignore" }, {}, {}, deps),
69
- false,
70
- );
71
- assert.equal(
72
- await executeTelegramCommandAction({ kind: "stop" }, {}, {}, deps),
73
- true,
74
- );
75
- assert.equal(
76
- await executeTelegramCommandAction(
77
- { kind: "help", commandName: "start" },
78
- {},
79
- {},
80
- deps,
81
- ),
82
- true,
83
- );
84
- assert.deepEqual(events, ["stop", "help:start"]);
85
- });
@@ -1,80 +0,0 @@
1
- /**
2
- * Regression tests for Telegram setup prompt defaults
3
- * Covers token-prefill priority across stored config, environment variables, and placeholder fallback
4
- */
5
-
6
- import assert from "node:assert/strict";
7
- import test from "node:test";
8
-
9
- import { __telegramTestUtils } from "../index.ts";
10
-
11
- test("Bot token input prefers stored config over env vars", () => {
12
- const value = __telegramTestUtils.getTelegramBotTokenInputDefault(
13
- {
14
- TELEGRAM_KEY: "key-last",
15
- TELEGRAM_TOKEN: "token-third",
16
- TELEGRAM_BOT_KEY: "key-second",
17
- TELEGRAM_BOT_TOKEN: "token-first",
18
- },
19
- "stored-token",
20
- );
21
- assert.equal(value, "stored-token");
22
- });
23
-
24
- test("Bot token input prefers the first configured Telegram env var when no config exists", () => {
25
- const value = __telegramTestUtils.getTelegramBotTokenInputDefault({
26
- TELEGRAM_KEY: "key-last",
27
- TELEGRAM_TOKEN: "token-third",
28
- TELEGRAM_BOT_KEY: "key-second",
29
- TELEGRAM_BOT_TOKEN: "token-first",
30
- });
31
- assert.equal(value, "token-first");
32
- });
33
-
34
- test("Bot token prompt uses the editor when a real prefill exists", () => {
35
- const prompt = __telegramTestUtils.getTelegramBotTokenPromptSpec({
36
- TELEGRAM_BOT_TOKEN: "token-first",
37
- });
38
- assert.deepEqual(prompt, {
39
- method: "editor",
40
- value: "token-first",
41
- });
42
- });
43
-
44
- test("Bot token prompt shows stored config before env values", () => {
45
- const prompt = __telegramTestUtils.getTelegramBotTokenPromptSpec(
46
- {
47
- TELEGRAM_BOT_TOKEN: "token-first",
48
- },
49
- "stored-token",
50
- );
51
- assert.deepEqual(prompt, {
52
- method: "editor",
53
- value: "stored-token",
54
- });
55
- });
56
-
57
- test("Bot token input skips blank env vars and falls back to config", () => {
58
- const value = __telegramTestUtils.getTelegramBotTokenInputDefault(
59
- {
60
- TELEGRAM_BOT_TOKEN: " ",
61
- TELEGRAM_BOT_KEY: "",
62
- TELEGRAM_TOKEN: " ",
63
- },
64
- "stored-token",
65
- );
66
- assert.equal(value, "stored-token");
67
- });
68
-
69
- test("Bot token input falls back to placeholder when no value exists", () => {
70
- const value = __telegramTestUtils.getTelegramBotTokenInputDefault({});
71
- assert.equal(value, "123456:ABCDEF...");
72
- });
73
-
74
- test("Bot token prompt uses placeholder input when no prefill exists", () => {
75
- const prompt = __telegramTestUtils.getTelegramBotTokenPromptSpec({});
76
- assert.deepEqual(prompt, {
77
- method: "input",
78
- value: "123456:ABCDEF...",
79
- });
80
- });
@@ -1,166 +0,0 @@
1
- /**
2
- * Regression tests for Telegram media and text extraction helpers
3
- * Covers inbound file-info collection, text extraction, media groups, id collection, and history formatting
4
- */
5
-
6
- import assert from "node:assert/strict";
7
- import test from "node:test";
8
-
9
- import {
10
- collectTelegramFileInfos,
11
- collectTelegramMessageIds,
12
- extractFirstTelegramMessageText,
13
- extractTelegramMessagesText,
14
- formatTelegramHistoryText,
15
- getTelegramMediaGroupKey,
16
- guessMediaType,
17
- queueTelegramMediaGroupMessage,
18
- removePendingTelegramMediaGroupMessages,
19
- type TelegramMediaGroupState,
20
- } from "../lib/media.ts";
21
-
22
- test("Media helpers collect file infos across Telegram message variants", () => {
23
- const files = collectTelegramFileInfos([
24
- {
25
- message_id: 1,
26
- text: "hello",
27
- photo: [
28
- { file_id: "small", file_size: 1 },
29
- { file_id: "large", file_size: 10 },
30
- ],
31
- document: {
32
- file_id: "doc",
33
- file_name: "report.png",
34
- mime_type: "image/png",
35
- },
36
- voice: {
37
- file_id: "voice",
38
- mime_type: "audio/ogg",
39
- },
40
- sticker: {
41
- file_id: "sticker",
42
- },
43
- },
44
- ]);
45
- assert.deepEqual(
46
- files.map((file) => ({
47
- id: file.file_id,
48
- name: file.fileName,
49
- image: file.isImage,
50
- })),
51
- [
52
- { id: "large", name: "photo-1.jpg", image: true },
53
- { id: "doc", name: "report.png", image: true },
54
- { id: "voice", name: "voice-1.ogg", image: false },
55
- { id: "sticker", name: "sticker-1.webp", image: true },
56
- ],
57
- );
58
- });
59
-
60
- test("Media helpers extract text, ids, and history summaries", () => {
61
- const messages = [
62
- { message_id: 1, text: "first" },
63
- { message_id: 2, caption: "second" },
64
- { message_id: 2, text: "duplicate id" },
65
- ];
66
- assert.equal(
67
- extractTelegramMessagesText(messages),
68
- "first\n\nsecond\n\nduplicate id",
69
- );
70
- assert.equal(extractFirstTelegramMessageText(messages), "first");
71
- assert.deepEqual(collectTelegramMessageIds(messages), [1, 2]);
72
- assert.equal(
73
- formatTelegramHistoryText("hello", [{ path: "/tmp/demo.txt" }]),
74
- "hello\nAttachments:\n- /tmp/demo.txt",
75
- );
76
- });
77
-
78
- test("Media helpers infer outgoing image media types from file paths", () => {
79
- assert.equal(guessMediaType("/tmp/demo.png"), "image/png");
80
- assert.equal(guessMediaType("/tmp/demo.txt"), undefined);
81
- });
82
-
83
- test("Media helpers key messages by chat and media group", () => {
84
- assert.equal(
85
- getTelegramMediaGroupKey({
86
- message_id: 1,
87
- chat: { id: 7 },
88
- media_group_id: "album",
89
- }),
90
- "7:album",
91
- );
92
- assert.equal(
93
- getTelegramMediaGroupKey({ message_id: 1, chat: { id: 7 } }),
94
- undefined,
95
- );
96
- });
97
-
98
- test("Media helpers replace debounce timers and dispatch grouped messages", () => {
99
- const groups = new Map<
100
- string,
101
- TelegramMediaGroupState<{
102
- message_id: number;
103
- chat: { id: number };
104
- media_group_id?: string;
105
- }>
106
- >();
107
- const cleared: number[] = [];
108
- const callbacks: Array<() => void> = [];
109
- const dispatched: number[][] = [];
110
- let nextTimer = 1;
111
- const setTimer = (callback: () => void): ReturnType<typeof setTimeout> => {
112
- callbacks.push(callback);
113
- return nextTimer++ as unknown as ReturnType<typeof setTimeout>;
114
- };
115
- const clearTimer = (timer: ReturnType<typeof setTimeout>): void => {
116
- cleared.push(timer as unknown as number);
117
- };
118
- assert.equal(
119
- queueTelegramMediaGroupMessage({
120
- message: { message_id: 1, chat: { id: 7 }, media_group_id: "album" },
121
- groups,
122
- debounceMs: 100,
123
- setTimer,
124
- clearTimer,
125
- dispatchMessages: (messages) =>
126
- dispatched.push(messages.map((message) => message.message_id)),
127
- }),
128
- true,
129
- );
130
- queueTelegramMediaGroupMessage({
131
- message: { message_id: 2, chat: { id: 7 }, media_group_id: "album" },
132
- groups,
133
- debounceMs: 100,
134
- setTimer,
135
- clearTimer,
136
- dispatchMessages: (messages) =>
137
- dispatched.push(messages.map((message) => message.message_id)),
138
- });
139
- assert.deepEqual(cleared, [1]);
140
- callbacks.at(-1)?.();
141
- assert.deepEqual(dispatched, [[1, 2]]);
142
- assert.equal(groups.size, 0);
143
- });
144
-
145
- test("Media helpers remove pending groups by message id", () => {
146
- const groups = new Map<
147
- string,
148
- TelegramMediaGroupState<{ message_id: number; chat: { id: number } }>
149
- >();
150
- groups.set("7:album", {
151
- messages: [
152
- { message_id: 1, chat: { id: 7 } },
153
- { message_id: 2, chat: { id: 7 } },
154
- ],
155
- flushTimer: 10 as unknown as ReturnType<typeof setTimeout>,
156
- });
157
- const cleared: number[] = [];
158
- assert.equal(
159
- removePendingTelegramMediaGroupMessages(groups, [2], (timer) => {
160
- cleared.push(timer as unknown as number);
161
- }),
162
- 1,
163
- );
164
- assert.deepEqual(cleared, [10]);
165
- assert.equal(groups.size, 0);
166
- });