@llblab/pi-telegram 0.2.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.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Regression tests for the Telegram replies domain
3
+ * Covers preview decisions, rendered-message delivery, and plain or markdown reply sending in one suite
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+
9
+ import {
10
+ buildTelegramPreviewFinalText,
11
+ buildTelegramPreviewFlushText,
12
+ buildTelegramReplyTransport,
13
+ clearTelegramPreview,
14
+ editTelegramRenderedMessage,
15
+ finalizeTelegramMarkdownPreview,
16
+ finalizeTelegramPreview,
17
+ flushTelegramPreview,
18
+ sendTelegramMarkdownReply,
19
+ sendTelegramPlainReply,
20
+ sendTelegramRenderedChunks,
21
+ shouldUseTelegramDraftPreview,
22
+ } from "../lib/replies.ts";
23
+
24
+ function createPreviewRuntimeHarness(state?: {
25
+ mode: "draft" | "message";
26
+ draftId?: number;
27
+ messageId?: number;
28
+ pendingText: string;
29
+ lastSentText: string;
30
+ flushTimer?: ReturnType<typeof setTimeout>;
31
+ }) {
32
+ let previewState = state;
33
+ let draftSupport: "unknown" | "supported" | "unsupported" = "unknown";
34
+ let nextDraftId = 10;
35
+ const events: string[] = [];
36
+ return {
37
+ events,
38
+ getState: () => previewState,
39
+ getDraftSupport: () => draftSupport,
40
+ setDraftSupport: (support: "unknown" | "supported" | "unsupported") => {
41
+ draftSupport = support;
42
+ },
43
+ deps: {
44
+ getState: () => previewState,
45
+ setState: (nextState: typeof previewState) => {
46
+ previewState = nextState;
47
+ },
48
+ clearScheduledFlush: (nextState: NonNullable<typeof previewState>) => {
49
+ if (!nextState.flushTimer) return;
50
+ clearTimeout(nextState.flushTimer);
51
+ nextState.flushTimer = undefined;
52
+ events.push("clear-timer");
53
+ },
54
+ maxMessageLength: 5,
55
+ renderPreviewText: (markdown: string) => markdown.replaceAll("*", ""),
56
+ getDraftSupport: () => draftSupport,
57
+ setDraftSupport: (support: "unknown" | "supported" | "unsupported") => {
58
+ draftSupport = support;
59
+ },
60
+ allocateDraftId: () => nextDraftId++,
61
+ sendDraft: async (chatId: number, draftId: number, text: string) => {
62
+ events.push(`draft:${chatId}:${draftId}:${text}`);
63
+ },
64
+ sendMessage: async (chatId: number, text: string) => {
65
+ events.push(`send:${chatId}:${text}`);
66
+ return { message_id: 77 };
67
+ },
68
+ editMessageText: async (
69
+ chatId: number,
70
+ messageId: number,
71
+ text: string,
72
+ ) => {
73
+ events.push(`edit:${chatId}:${messageId}:${text}`);
74
+ },
75
+ renderTelegramMessage: (text: string, options?: { mode?: string }) => [
76
+ { text: `${options?.mode ?? "plain"}:${text}` },
77
+ ],
78
+ sendRenderedChunks: async (
79
+ chatId: number,
80
+ chunks: Array<{ text: string }>,
81
+ ) => {
82
+ events.push(
83
+ `render-send:${chatId}:${chunks.map((chunk) => chunk.text).join("|")}`,
84
+ );
85
+ return 88;
86
+ },
87
+ editRenderedMessage: async (
88
+ chatId: number,
89
+ messageId: number,
90
+ chunks: Array<{ text: string }>,
91
+ ) => {
92
+ events.push(
93
+ `render-edit:${chatId}:${messageId}:${chunks.map((chunk) => chunk.text).join("|")}`,
94
+ );
95
+ return messageId;
96
+ },
97
+ },
98
+ };
99
+ }
100
+
101
+ test("Reply previews build flush text only when the preview changed", () => {
102
+ assert.equal(
103
+ buildTelegramPreviewFlushText({
104
+ state: {
105
+ mode: "draft",
106
+ pendingText: "**hello**",
107
+ lastSentText: "",
108
+ },
109
+ maxMessageLength: 4096,
110
+ renderPreviewText: (markdown) => markdown.replaceAll("*", ""),
111
+ }),
112
+ "hello",
113
+ );
114
+ assert.equal(
115
+ buildTelegramPreviewFlushText({
116
+ state: {
117
+ mode: "draft",
118
+ pendingText: "**hello**",
119
+ lastSentText: "hello",
120
+ },
121
+ maxMessageLength: 4096,
122
+ renderPreviewText: (markdown) => markdown.replaceAll("*", ""),
123
+ }),
124
+ undefined,
125
+ );
126
+ });
127
+
128
+ test("Reply previews truncate long flush text and compute final text fallback", () => {
129
+ assert.equal(
130
+ buildTelegramPreviewFlushText({
131
+ state: {
132
+ mode: "message",
133
+ pendingText: "abcdef",
134
+ lastSentText: "",
135
+ },
136
+ maxMessageLength: 3,
137
+ renderPreviewText: (markdown) => markdown,
138
+ }),
139
+ "abc",
140
+ );
141
+ assert.equal(
142
+ buildTelegramPreviewFinalText({
143
+ mode: "message",
144
+ pendingText: " ",
145
+ lastSentText: "saved",
146
+ }),
147
+ "saved",
148
+ );
149
+ assert.equal(
150
+ buildTelegramPreviewFinalText({
151
+ mode: "message",
152
+ pendingText: " ",
153
+ lastSentText: " ",
154
+ }),
155
+ undefined,
156
+ );
157
+ });
158
+
159
+ test("Reply previews use drafts unless support is explicitly disabled", () => {
160
+ assert.equal(
161
+ shouldUseTelegramDraftPreview({ draftSupport: "unknown" }),
162
+ true,
163
+ );
164
+ assert.equal(
165
+ shouldUseTelegramDraftPreview({ draftSupport: "supported" }),
166
+ true,
167
+ );
168
+ assert.equal(
169
+ shouldUseTelegramDraftPreview({ draftSupport: "unsupported" }),
170
+ false,
171
+ );
172
+ });
173
+
174
+ test("Reply preview runtime prefers draft updates and can clear draft previews", async () => {
175
+ const harness = createPreviewRuntimeHarness({
176
+ mode: "draft",
177
+ pendingText: "**hello**",
178
+ lastSentText: "",
179
+ flushTimer: setTimeout(() => {}, 1000),
180
+ });
181
+ await flushTelegramPreview(7, harness.deps);
182
+ assert.deepEqual(harness.events, ["draft:7:10:hello"]);
183
+ assert.equal(harness.getState()?.mode, "draft");
184
+ assert.equal(harness.getState()?.draftId, 10);
185
+ assert.equal(harness.getState()?.lastSentText, "hello");
186
+ assert.equal(harness.getDraftSupport(), "supported");
187
+ await clearTelegramPreview(7, harness.deps);
188
+ assert.deepEqual(harness.events, ["draft:7:10:hello", "draft:7:10:"]);
189
+ assert.equal(harness.getState(), undefined);
190
+ });
191
+
192
+ test("Reply preview runtime falls back to editable messages when draft delivery fails", async () => {
193
+ const harness = createPreviewRuntimeHarness({
194
+ mode: "draft",
195
+ pendingText: "abcdef",
196
+ lastSentText: "",
197
+ });
198
+ harness.deps.sendDraft = async () => {
199
+ throw new Error("draft unsupported");
200
+ };
201
+ await flushTelegramPreview(7, harness.deps);
202
+ assert.deepEqual(harness.events, ["send:7:abcde"]);
203
+ assert.equal(harness.getState()?.mode, "message");
204
+ assert.equal(harness.getState()?.messageId, 77);
205
+ assert.equal(harness.getDraftSupport(), "unsupported");
206
+ });
207
+
208
+ test("Reply preview runtime finalizes plain and markdown previews", async () => {
209
+ const plainHarness = createPreviewRuntimeHarness({
210
+ mode: "message",
211
+ messageId: 44,
212
+ pendingText: "done",
213
+ lastSentText: "",
214
+ });
215
+ plainHarness.setDraftSupport("unsupported");
216
+ assert.equal(await finalizeTelegramPreview(7, plainHarness.deps), true);
217
+ assert.deepEqual(plainHarness.events, ["edit:7:44:done"]);
218
+ assert.equal(plainHarness.getState(), undefined);
219
+ const markdownHarness = createPreviewRuntimeHarness({
220
+ mode: "message",
221
+ messageId: 55,
222
+ pendingText: "done",
223
+ lastSentText: "",
224
+ });
225
+ markdownHarness.setDraftSupport("unsupported");
226
+ assert.equal(
227
+ await finalizeTelegramMarkdownPreview(7, "**done**", markdownHarness.deps),
228
+ true,
229
+ );
230
+ assert.deepEqual(markdownHarness.events, [
231
+ "edit:7:55:done",
232
+ "render-edit:7:55:markdown:**done**",
233
+ ]);
234
+ assert.equal(markdownHarness.getState(), undefined);
235
+ });
236
+
237
+ test("Reply transport forwards send and edit operations through delivery helpers", async () => {
238
+ const events: string[] = [];
239
+ const transport = buildTelegramReplyTransport({
240
+ sendMessage: async (body) => {
241
+ events.push(`send:${body.chat_id}:${body.text}`);
242
+ return { message_id: 5 };
243
+ },
244
+ editMessage: async (body) => {
245
+ events.push(`edit:${body.chat_id}:${body.message_id}:${body.text}`);
246
+ },
247
+ });
248
+ assert.equal(await transport.sendRenderedChunks(7, [{ text: "one" }]), 5);
249
+ assert.equal(await transport.editRenderedMessage(7, 9, [{ text: "two" }]), 9);
250
+ assert.deepEqual(events, ["send:7:one", "edit:7:9:two"]);
251
+ });
252
+
253
+ test("Reply delivery sends chunks and applies reply markup only to the last chunk", async () => {
254
+ const sentBodies: Array<Record<string, unknown>> = [];
255
+ const messageId = await sendTelegramRenderedChunks(
256
+ 7,
257
+ [{ text: "one" }, { text: "two", parseMode: "HTML" }],
258
+ {
259
+ sendMessage: async (body) => {
260
+ sentBodies.push(body);
261
+ return { message_id: sentBodies.length };
262
+ },
263
+ editMessage: async () => {},
264
+ },
265
+ {
266
+ replyMarkup: {
267
+ inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
268
+ },
269
+ },
270
+ );
271
+ assert.equal(messageId, 2);
272
+ assert.deepEqual(sentBodies, [
273
+ { chat_id: 7, text: "one", parse_mode: undefined, reply_markup: undefined },
274
+ {
275
+ chat_id: 7,
276
+ text: "two",
277
+ parse_mode: "HTML",
278
+ reply_markup: {
279
+ inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
280
+ },
281
+ },
282
+ ]);
283
+ });
284
+
285
+ test("Reply delivery edits the first chunk and sends remaining chunks separately", async () => {
286
+ const editedBodies: Array<Record<string, unknown>> = [];
287
+ const sentBodies: Array<Record<string, unknown>> = [];
288
+ const result = await editTelegramRenderedMessage(
289
+ 7,
290
+ 99,
291
+ [{ text: "first", parseMode: "HTML" }, { text: "second" }],
292
+ {
293
+ sendMessage: async (body) => {
294
+ sentBodies.push(body);
295
+ return { message_id: 123 };
296
+ },
297
+ editMessage: async (body) => {
298
+ editedBodies.push(body);
299
+ },
300
+ },
301
+ {
302
+ replyMarkup: {
303
+ inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
304
+ },
305
+ },
306
+ );
307
+ assert.equal(result, 123);
308
+ assert.deepEqual(editedBodies, [
309
+ {
310
+ chat_id: 7,
311
+ message_id: 99,
312
+ text: "first",
313
+ parse_mode: "HTML",
314
+ reply_markup: undefined,
315
+ },
316
+ ]);
317
+ assert.deepEqual(sentBodies, [
318
+ {
319
+ chat_id: 7,
320
+ text: "second",
321
+ parse_mode: undefined,
322
+ reply_markup: {
323
+ inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
324
+ },
325
+ },
326
+ ]);
327
+ });
328
+
329
+ test("Reply runtime sends plain replies using the requested parse mode", async () => {
330
+ const sent: string[] = [];
331
+ const messageId = await sendTelegramPlainReply(
332
+ "hello",
333
+ {
334
+ renderTelegramMessage: (_text, options) => [
335
+ { text: options?.mode === "html" ? "html" : "plain" },
336
+ ],
337
+ sendRenderedChunks: async (chunks) => {
338
+ sent.push(chunks[0]?.text ?? "");
339
+ return 7;
340
+ },
341
+ },
342
+ { parseMode: "HTML" },
343
+ );
344
+ assert.equal(messageId, 7);
345
+ assert.deepEqual(sent, ["html"]);
346
+ });
347
+
348
+ test("Reply runtime falls back to plain delivery when markdown rendering yields no chunks", async () => {
349
+ const calls: Array<string> = [];
350
+ const messageId = await sendTelegramMarkdownReply("hello", {
351
+ renderTelegramMessage: (_text, options) => {
352
+ if (options?.mode === "markdown") return [];
353
+ return [{ text: options?.mode ?? "plain" }];
354
+ },
355
+ sendRenderedChunks: async (chunks) => {
356
+ calls.push(chunks[0]?.text ?? "");
357
+ return 9;
358
+ },
359
+ });
360
+ assert.equal(messageId, 9);
361
+ assert.deepEqual(calls, ["plain"]);
362
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Regression tests for the Telegram turn-building domain
3
+ * Covers queue-summary formatting, prompt construction, and prompt-turn assembly from messages and downloaded files
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+
9
+ import {
10
+ buildTelegramPromptTurn,
11
+ buildTelegramTurnPrompt,
12
+ formatTelegramTurnStatusSummary,
13
+ truncateTelegramQueueSummary,
14
+ } from "../lib/turns.ts";
15
+
16
+ test("Turn helpers truncate queue summaries predictably", () => {
17
+ assert.equal(
18
+ truncateTelegramQueueSummary("one two three four"),
19
+ "one two three four",
20
+ );
21
+ assert.equal(
22
+ truncateTelegramQueueSummary("one two three four five six"),
23
+ "one two three four five…",
24
+ );
25
+ assert.equal(truncateTelegramQueueSummary(" "), "");
26
+ });
27
+
28
+ test("Turn helpers build prompt text with history and attachments", () => {
29
+ const prompt = buildTelegramTurnPrompt({
30
+ telegramPrefix: "[telegram]",
31
+ rawText: "current message",
32
+ files: [{ path: "/tmp/demo.png", fileName: "demo.png", isImage: true }],
33
+ historyTurns: [{ historyText: "older message" }],
34
+ });
35
+ assert.match(prompt, /^\[telegram\]/);
36
+ assert.match(
37
+ prompt,
38
+ /Earlier Telegram messages arrived after an aborted turn/,
39
+ );
40
+ assert.match(prompt, /1\. older message/);
41
+ assert.match(prompt, /Current Telegram message:\ncurrent message/);
42
+ assert.match(
43
+ prompt,
44
+ /Telegram attachments were saved locally:\n- \/tmp\/demo.png/,
45
+ );
46
+ });
47
+
48
+ test("Turn helpers summarize text and attachment-only turns", () => {
49
+ assert.equal(
50
+ formatTelegramTurnStatusSummary("hello there from telegram", []),
51
+ "hello there from telegram",
52
+ );
53
+ assert.equal(
54
+ formatTelegramTurnStatusSummary("", [
55
+ {
56
+ path: "/tmp/report-final-version.txt",
57
+ fileName: "report-final-version.txt",
58
+ isImage: false,
59
+ },
60
+ ]),
61
+ "📎 report-final-version.txt",
62
+ );
63
+ assert.equal(
64
+ formatTelegramTurnStatusSummary("", [
65
+ { path: "/tmp/a.txt", fileName: "a.txt", isImage: false },
66
+ { path: "/tmp/b.txt", fileName: "b.txt", isImage: false },
67
+ ]),
68
+ "📎 2 attachments",
69
+ );
70
+ });
71
+
72
+ test("Turn helpers assemble prompt turns with text, ids, history, and image payloads", async () => {
73
+ const turn = await buildTelegramPromptTurn({
74
+ telegramPrefix: "[telegram]",
75
+ messages: [
76
+ { message_id: 10, chat: { id: 99 } },
77
+ { message_id: 11, chat: { id: 99 } },
78
+ ],
79
+ historyTurns: [
80
+ {
81
+ kind: "prompt",
82
+ chatId: 99,
83
+ replyToMessageId: 1,
84
+ sourceMessageIds: [1],
85
+ queueOrder: 1,
86
+ queueLane: "default",
87
+ laneOrder: 1,
88
+ queuedAttachments: [],
89
+ content: [{ type: "text", text: "ignored" }],
90
+ historyText: "older message",
91
+ statusSummary: "older",
92
+ },
93
+ ],
94
+ queueOrder: 7,
95
+ rawText: "current message",
96
+ files: [
97
+ {
98
+ path: "/tmp/demo.png",
99
+ fileName: "demo.png",
100
+ isImage: true,
101
+ mimeType: "image/png",
102
+ },
103
+ {
104
+ path: "/tmp/report.txt",
105
+ fileName: "report.txt",
106
+ isImage: false,
107
+ },
108
+ ],
109
+ readBinaryFile: async () => new Uint8Array([1, 2, 3]),
110
+ inferImageMimeType: () => undefined,
111
+ });
112
+ assert.equal(turn.chatId, 99);
113
+ assert.equal(turn.replyToMessageId, 10);
114
+ assert.deepEqual(turn.sourceMessageIds, [10, 11]);
115
+ assert.equal(turn.queueOrder, 7);
116
+ assert.equal(turn.statusSummary, "current message");
117
+ assert.equal(
118
+ turn.historyText,
119
+ "current message\nAttachments:\n- /tmp/demo.png\n- /tmp/report.txt",
120
+ );
121
+ assert.equal(turn.content.length, 2);
122
+ assert.equal(turn.content[0]?.type, "text");
123
+ assert.match(
124
+ (turn.content[0] as { type: "text"; text: string }).text,
125
+ /Earlier Telegram messages arrived after an aborted turn/,
126
+ );
127
+ assert.deepEqual(turn.content[1], {
128
+ type: "image",
129
+ data: Buffer.from([1, 2, 3]).toString("base64"),
130
+ mimeType: "image/png",
131
+ });
132
+ });