@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,268 @@
1
+ /**
2
+ * Regression tests for the Telegram registration domain
3
+ * Covers tool registration and command registration behavior without exercising the full extension runtime
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+
9
+ import telegramExtension from "../index.ts";
10
+ import {
11
+ registerTelegramAttachmentTool,
12
+ registerTelegramCommands,
13
+ registerTelegramLifecycleHooks,
14
+ } from "../lib/registration.ts";
15
+
16
+ function createRegistrationApiHarness() {
17
+ let tool: any;
18
+ const commands = new Map<string, any>();
19
+ const handlers = new Map<string, any>();
20
+ return {
21
+ tool: () => tool,
22
+ commands,
23
+ handlers,
24
+ api: {
25
+ on: (event: string, handler: unknown) => {
26
+ handlers.set(event, handler);
27
+ },
28
+ registerTool: (definition: unknown) => {
29
+ tool = definition;
30
+ },
31
+ registerCommand: (name: string, definition: unknown) => {
32
+ commands.set(name, definition);
33
+ },
34
+ } as never,
35
+ };
36
+ }
37
+
38
+ test("Registration registers the attachment tool and delegates queueing", async () => {
39
+ const harness = createRegistrationApiHarness();
40
+ const activeTurn = {
41
+ queuedAttachments: [],
42
+ } as unknown as {
43
+ queuedAttachments: Array<{ path: string; fileName: string }>;
44
+ } & ReturnType<
45
+ Parameters<typeof registerTelegramAttachmentTool>[1]["getActiveTurn"]
46
+ >;
47
+ registerTelegramAttachmentTool(harness.api, {
48
+ maxAttachmentsPerTurn: 2,
49
+ getActiveTurn: () => activeTurn,
50
+ statPath: async () => ({ isFile: () => true }),
51
+ });
52
+ const tool = harness.tool();
53
+ assert.equal(tool?.name, "telegram_attach");
54
+ const result = await tool.execute("tool-call", { paths: ["/tmp/report.md"] });
55
+ assert.deepEqual(activeTurn.queuedAttachments, [
56
+ { path: "/tmp/report.md", fileName: "report.md" },
57
+ ]);
58
+ assert.deepEqual(result.details.paths, ["/tmp/report.md"]);
59
+ });
60
+
61
+ test("Registration commands expose setup and status behaviors", async () => {
62
+ const harness = createRegistrationApiHarness();
63
+ const events: string[] = [];
64
+ registerTelegramCommands(harness.api, {
65
+ promptForConfig: async () => {
66
+ events.push("setup");
67
+ },
68
+ getStatusLines: () => ["bot: @demo", "polling: stopped"],
69
+ reloadConfig: async () => {
70
+ events.push("reload");
71
+ },
72
+ hasBotToken: () => false,
73
+ startPolling: async () => {
74
+ events.push("start");
75
+ },
76
+ stopPolling: async () => {
77
+ events.push("stop");
78
+ },
79
+ updateStatus: () => {
80
+ events.push("update-status");
81
+ },
82
+ });
83
+ const setupCommand = harness.commands.get("telegram-setup");
84
+ const statusCommand = harness.commands.get("telegram-status");
85
+ const notifications: string[] = [];
86
+ const ctx = {
87
+ ui: {
88
+ notify: (message: string) => {
89
+ notifications.push(message);
90
+ },
91
+ },
92
+ } as never;
93
+ await setupCommand.handler("", ctx);
94
+ await statusCommand.handler("", ctx);
95
+ assert.deepEqual(events, ["setup"]);
96
+ assert.deepEqual(notifications, ["bot: @demo | polling: stopped"]);
97
+ });
98
+
99
+ test("Registration connect and disconnect commands reload config and control polling", async () => {
100
+ const harness = createRegistrationApiHarness();
101
+ const events: string[] = [];
102
+ let hasToken = false;
103
+ registerTelegramCommands(harness.api, {
104
+ promptForConfig: async () => {
105
+ events.push("setup");
106
+ },
107
+ getStatusLines: () => [],
108
+ reloadConfig: async () => {
109
+ events.push("reload");
110
+ },
111
+ hasBotToken: () => hasToken,
112
+ startPolling: async () => {
113
+ events.push("start");
114
+ },
115
+ stopPolling: async () => {
116
+ events.push("stop");
117
+ },
118
+ updateStatus: () => {
119
+ events.push("update-status");
120
+ },
121
+ });
122
+ const connectCommand = harness.commands.get("telegram-connect");
123
+ const disconnectCommand = harness.commands.get("telegram-disconnect");
124
+ const ctx = { ui: { notify: () => {} } } as never;
125
+ await connectCommand.handler("", ctx);
126
+ hasToken = true;
127
+ await connectCommand.handler("", ctx);
128
+ await disconnectCommand.handler("", ctx);
129
+ assert.deepEqual(events, [
130
+ "reload",
131
+ "setup",
132
+ "reload",
133
+ "start",
134
+ "update-status",
135
+ "stop",
136
+ "update-status",
137
+ ]);
138
+ });
139
+
140
+ test("Registration lifecycle hooks are registered and delegate to the provided handlers", async () => {
141
+ const harness = createRegistrationApiHarness();
142
+ const events: string[] = [];
143
+ registerTelegramLifecycleHooks(harness.api, {
144
+ onSessionStart: async () => {
145
+ events.push("session-start");
146
+ },
147
+ onSessionShutdown: async () => {
148
+ events.push("session-shutdown");
149
+ },
150
+ onBeforeAgentStart: () => {
151
+ events.push("before-agent-start");
152
+ return { systemPrompt: "prompt" };
153
+ },
154
+ onModelSelect: () => {
155
+ events.push("model-select");
156
+ },
157
+ onAgentStart: async () => {
158
+ events.push("agent-start");
159
+ },
160
+ onToolExecutionStart: () => {
161
+ events.push("tool-start");
162
+ },
163
+ onToolExecutionEnd: () => {
164
+ events.push("tool-end");
165
+ },
166
+ onMessageStart: async () => {
167
+ events.push("message-start");
168
+ },
169
+ onMessageUpdate: async () => {
170
+ events.push("message-update");
171
+ },
172
+ onAgentEnd: async () => {
173
+ events.push("agent-end");
174
+ },
175
+ });
176
+ assert.deepEqual(
177
+ [...harness.handlers.keys()],
178
+ [
179
+ "session_start",
180
+ "session_shutdown",
181
+ "before_agent_start",
182
+ "model_select",
183
+ "agent_start",
184
+ "tool_execution_start",
185
+ "tool_execution_end",
186
+ "message_start",
187
+ "message_update",
188
+ "agent_end",
189
+ ],
190
+ );
191
+ const ctx = {} as never;
192
+ await harness.handlers.get("session_start")({}, ctx);
193
+ await harness.handlers.get("session_shutdown")({}, ctx);
194
+ const beforeAgentStartResult = await harness.handlers.get(
195
+ "before_agent_start",
196
+ )({}, ctx);
197
+ await harness.handlers.get("model_select")({}, ctx);
198
+ await harness.handlers.get("agent_start")({}, ctx);
199
+ await harness.handlers.get("tool_execution_start")({}, ctx);
200
+ await harness.handlers.get("tool_execution_end")({}, ctx);
201
+ await harness.handlers.get("message_start")({}, ctx);
202
+ await harness.handlers.get("message_update")({}, ctx);
203
+ await harness.handlers.get("agent_end")({}, ctx);
204
+ assert.deepEqual(beforeAgentStartResult, { systemPrompt: "prompt" });
205
+ assert.deepEqual(events, [
206
+ "session-start",
207
+ "session-shutdown",
208
+ "before-agent-start",
209
+ "model-select",
210
+ "agent-start",
211
+ "tool-start",
212
+ "tool-end",
213
+ "message-start",
214
+ "message-update",
215
+ "agent-end",
216
+ ]);
217
+ });
218
+
219
+ test("Extension entrypoint wires registration domains into the pi API", () => {
220
+ const harness = createRegistrationApiHarness();
221
+ telegramExtension(harness.api);
222
+ assert.equal(harness.tool()?.name, "telegram_attach");
223
+ assert.deepEqual(
224
+ [...harness.commands.keys()],
225
+ [
226
+ "telegram-setup",
227
+ "telegram-status",
228
+ "telegram-connect",
229
+ "telegram-disconnect",
230
+ ],
231
+ );
232
+ assert.deepEqual(
233
+ [...harness.handlers.keys()],
234
+ [
235
+ "session_start",
236
+ "session_shutdown",
237
+ "before_agent_start",
238
+ "model_select",
239
+ "agent_start",
240
+ "tool_execution_start",
241
+ "tool_execution_end",
242
+ "message_start",
243
+ "message_update",
244
+ "agent_end",
245
+ ],
246
+ );
247
+ });
248
+
249
+ test("Extension before-agent-start hook appends Telegram-specific system prompt guidance", async () => {
250
+ const harness = createRegistrationApiHarness();
251
+ telegramExtension(harness.api);
252
+ const handler = harness.handlers.get("before_agent_start");
253
+ const basePrompt = "System base";
254
+ const telegramResult = await handler(
255
+ { systemPrompt: basePrompt, prompt: "[telegram] hello" },
256
+ {} as never,
257
+ );
258
+ const localResult = await handler(
259
+ { systemPrompt: basePrompt, prompt: "hello" },
260
+ {} as never,
261
+ );
262
+ assert.match(
263
+ telegramResult.systemPrompt,
264
+ /current user message came from Telegram/,
265
+ );
266
+ assert.match(telegramResult.systemPrompt, /telegram_attach/);
267
+ assert.equal(localResult.systemPrompt.includes("came from Telegram"), false);
268
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Regression tests for Telegram markdown rendering helpers
3
+ * Covers nested lists, code blocks, tables, links, quotes, chunking, and other Telegram-specific render edge cases
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("Nested lists stay out of code blocks", () => {
12
+ const chunks = __telegramTestUtils.renderTelegramMessage(
13
+ "- Level 1\n - Level 2\n - Level 3 with **bold** text",
14
+ { mode: "markdown" },
15
+ );
16
+ assert.ok(chunks.length > 0);
17
+ assert.equal(
18
+ chunks.some((chunk) => chunk.text.includes("<pre><code>")),
19
+ false,
20
+ );
21
+ assert.equal(
22
+ chunks.some((chunk) =>
23
+ chunk.text.includes("<code>-</code> Level 3 with <b>bold</b> text"),
24
+ ),
25
+ true,
26
+ );
27
+ });
28
+
29
+ test("Fenced code blocks preserve literal markdown", () => {
30
+ const chunks = __telegramTestUtils.renderTelegramMessage(
31
+ '~~~ts\nconst value = "**raw**";\n~~~',
32
+ { mode: "markdown" },
33
+ );
34
+ assert.equal(chunks.length, 1);
35
+ assert.match(chunks[0]?.text ?? "", /<pre><code class="language-ts">/);
36
+ assert.match(chunks[0]?.text ?? "", /\*\*raw\*\*/);
37
+ });
38
+
39
+ test("Underscores inside words do not become italic", () => {
40
+ const chunks = __telegramTestUtils.renderTelegramMessage(
41
+ "Path: foo_bar_baz.txt and **bold**",
42
+ { mode: "markdown" },
43
+ );
44
+ assert.equal(chunks.length, 1);
45
+ assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
46
+ assert.match(chunks[0]?.text ?? "", /<b>bold<\/b>/);
47
+ });
48
+
49
+ test("Quoted nested lists stay in blockquote rendering", () => {
50
+ const chunks = __telegramTestUtils.renderTelegramMessage(
51
+ "> Quoted intro\n> - nested item\n> - deeper item",
52
+ { mode: "markdown" },
53
+ );
54
+ assert.equal(chunks.length, 1);
55
+ assert.match(chunks[0]?.text ?? "", /<blockquote>/);
56
+ assert.match(chunks[0]?.text ?? "", /nested item/);
57
+ assert.match(chunks[0]?.text ?? "", /<code>-<\/code> nested item/);
58
+ assert.equal((chunks[0]?.text ?? "").includes("<pre><code>"), false);
59
+ });
60
+
61
+ test("Numbered lists use monospace numeric markers", () => {
62
+ const chunks = __telegramTestUtils.renderTelegramMessage(
63
+ "1. first\n 2. second",
64
+ { mode: "markdown" },
65
+ );
66
+ assert.equal(chunks.length, 1);
67
+ assert.match(chunks[0]?.text ?? "", /<code>1\.<\/code> first/);
68
+ assert.match(chunks[0]?.text ?? "", /<code>2\.<\/code> second/);
69
+ });
70
+
71
+ test("Nested blockquotes flatten into one Telegram blockquote with indentation", () => {
72
+ const chunks = __telegramTestUtils.renderTelegramMessage(
73
+ "> outer\n>> inner\n>>> deepest",
74
+ { mode: "markdown" },
75
+ );
76
+ assert.equal(chunks.length, 1);
77
+ assert.equal((chunks[0]?.text.match(/<blockquote>/g) ?? []).length, 1);
78
+ assert.equal((chunks[0]?.text.match(/<\/blockquote>/g) ?? []).length, 1);
79
+ assert.match(chunks[0]?.text ?? "", /outer/);
80
+ assert.match(chunks[0]?.text ?? "", /\u00A0\u00A0inner/);
81
+ assert.match(chunks[0]?.text ?? "", /\u00A0\u00A0\u00A0\u00A0deepest/);
82
+ });
83
+
84
+ test("Markdown tables render as literal monospace blocks without outer side borders", () => {
85
+ const chunks = __telegramTestUtils.renderTelegramMessage(
86
+ "| Name | Value |\n| --- | --- |\n| **x** | `y` |",
87
+ { mode: "markdown" },
88
+ );
89
+ assert.equal(chunks.length, 1);
90
+ assert.match(chunks[0]?.text ?? "", /<pre><code class="language-markdown">/);
91
+ assert.equal((chunks[0]?.text ?? "").includes("<b>x</b>"), false);
92
+ assert.match(chunks[0]?.text ?? "", /Name\s+\|\s+Value/);
93
+ assert.match(chunks[0]?.text ?? "", /x\s+\|\s+y/);
94
+ assert.equal((chunks[0]?.text ?? "").includes("| Name |"), false);
95
+ assert.equal((chunks[0]?.text ?? "").includes("| x |"), false);
96
+ });
97
+
98
+ test("Links, code spans, and underscore-heavy text coexist safely", () => {
99
+ const chunks = __telegramTestUtils.renderTelegramMessage(
100
+ "See [docs](https://example.com), run `foo_bar()` and keep foo_bar.txt literal",
101
+ { mode: "markdown" },
102
+ );
103
+ assert.equal(chunks.length, 1);
104
+ assert.match(
105
+ chunks[0]?.text ?? "",
106
+ /<a href="https:\/\/example.com">docs<\/a>/,
107
+ );
108
+ assert.match(chunks[0]?.text ?? "", /<code>foo_bar\(\)<\/code>/);
109
+ assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
110
+ });
111
+
112
+ test("Long quoted blocks stay chunked with balanced blockquote tags", () => {
113
+ const markdown = Array.from(
114
+ { length: 500 },
115
+ (_, index) => `> quoted **${index}** line`,
116
+ ).join("\n");
117
+ const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
118
+ mode: "markdown",
119
+ });
120
+ assert.ok(chunks.length > 1);
121
+ for (const chunk of chunks) {
122
+ assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
123
+ assert.equal(
124
+ (chunk.text.match(/<blockquote>/g) ?? []).length,
125
+ (chunk.text.match(/<\/blockquote>/g) ?? []).length,
126
+ );
127
+ }
128
+ });
129
+
130
+ test("Long markdown replies stay chunked below Telegram limits", () => {
131
+ const markdown = Array.from(
132
+ { length: 600 },
133
+ (_, index) => `- item **${index}**`,
134
+ ).join("\n");
135
+ const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
136
+ mode: "markdown",
137
+ });
138
+ assert.ok(chunks.length > 1);
139
+ for (const chunk of chunks) {
140
+ assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
141
+ assert.equal(
142
+ (chunk.text.match(/<b>/g) ?? []).length,
143
+ (chunk.text.match(/<\/b>/g) ?? []).length,
144
+ );
145
+ }
146
+ });
147
+
148
+ test("Long mixed links and code spans stay chunked with balanced inline tags", () => {
149
+ const markdown = Array.from(
150
+ { length: 450 },
151
+ (_, index) =>
152
+ `Paragraph ${index}: see [docs ${index}](https://example.com/${index}), run \`code_${index}()\`, and keep foo_bar_${index}.txt literal`,
153
+ ).join("\n\n");
154
+ const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
155
+ mode: "markdown",
156
+ });
157
+ assert.ok(chunks.length > 1);
158
+ for (const chunk of chunks) {
159
+ assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
160
+ assert.equal(
161
+ (chunk.text.match(/<a /g) ?? []).length,
162
+ (chunk.text.match(/<\/a>/g) ?? []).length,
163
+ );
164
+ assert.equal(
165
+ (chunk.text.match(/<code>/g) ?? []).length,
166
+ (chunk.text.match(/<\/code>/g) ?? []).length,
167
+ );
168
+ assert.equal((chunk.text ?? "").includes("<i>bar</i>"), false);
169
+ }
170
+ });
171
+
172
+ test("Long multi-block markdown keeps quotes and code fences structurally balanced", () => {
173
+ const markdown = Array.from({ length: 120 }, (_, index) => {
174
+ return [
175
+ `## Section ${index}`,
176
+ `> quoted **${index}** line`,
177
+ `- item ${index}`,
178
+ "```ts",
179
+ `const value_${index} = \"**raw**\";`,
180
+ "```",
181
+ ].join("\n");
182
+ }).join("\n\n");
183
+ const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
184
+ mode: "markdown",
185
+ });
186
+ assert.ok(chunks.length > 1);
187
+ for (const chunk of chunks) {
188
+ assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
189
+ assert.equal(
190
+ (chunk.text.match(/<blockquote>/g) ?? []).length,
191
+ (chunk.text.match(/<\/blockquote>/g) ?? []).length,
192
+ );
193
+ assert.equal(
194
+ (chunk.text.match(/<pre><code/g) ?? []).length,
195
+ (chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
196
+ );
197
+ }
198
+ });
199
+
200
+ test("Chunked mixed block transitions keep quote and list structure balanced", () => {
201
+ const markdown = Array.from({ length: 260 }, (_, index) => {
202
+ return [
203
+ `> quoted **${index}** intro`,
204
+ `> continuation ${index}`,
205
+ `- item ${index}`,
206
+ `plain paragraph ${index} with [link](https://example.com/${index})`,
207
+ ].join("\n");
208
+ }).join("\n\n");
209
+ const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
210
+ mode: "markdown",
211
+ });
212
+ assert.ok(chunks.length > 1);
213
+ for (const chunk of chunks) {
214
+ assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
215
+ assert.equal(
216
+ (chunk.text.match(/<blockquote>/g) ?? []).length,
217
+ (chunk.text.match(/<\/blockquote>/g) ?? []).length,
218
+ );
219
+ assert.equal(
220
+ (chunk.text.match(/<a /g) ?? []).length,
221
+ (chunk.text.match(/<\/a>/g) ?? []).length,
222
+ );
223
+ }
224
+ });
225
+
226
+ test("Chunked code fence transitions keep code blocks closed before following prose", () => {
227
+ const markdown = Array.from({ length: 220 }, (_, index) => {
228
+ return [
229
+ "```ts",
230
+ `const block_${index} = \"value_${index}\";`,
231
+ "```",
232
+ `After code **${index}** and \`inline_${index}()\``,
233
+ ].join("\n");
234
+ }).join("\n\n");
235
+ const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
236
+ mode: "markdown",
237
+ });
238
+ assert.ok(chunks.length > 1);
239
+ for (const chunk of chunks) {
240
+ assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
241
+ assert.equal(
242
+ (chunk.text.match(/<pre><code/g) ?? []).length,
243
+ (chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
244
+ );
245
+ assert.equal(
246
+ (chunk.text.match(/<code(?: class="[^"]+")?>/g) ?? []).length,
247
+ (chunk.text.match(/<\/code>/g) ?? []).length,
248
+ );
249
+ }
250
+ });
251
+
252
+ test("Long inline formatting paragraphs stay balanced across chunk boundaries", () => {
253
+ const markdown = Array.from({ length: 500 }, (_, index) => {
254
+ return `Segment ${index} keeps **bold_${index}** with \`code_${index}()\`, [link_${index}](https://example.com/${index}), and foo_bar_${index}.txt literal.`;
255
+ }).join(" ");
256
+ const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
257
+ mode: "markdown",
258
+ });
259
+ assert.ok(chunks.length > 1);
260
+ for (const chunk of chunks) {
261
+ assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
262
+ assert.equal(
263
+ (chunk.text.match(/<b>/g) ?? []).length,
264
+ (chunk.text.match(/<\/b>/g) ?? []).length,
265
+ );
266
+ assert.equal(
267
+ (chunk.text.match(/<a /g) ?? []).length,
268
+ (chunk.text.match(/<\/a>/g) ?? []).length,
269
+ );
270
+ assert.equal(
271
+ (chunk.text.match(/<code>/g) ?? []).length,
272
+ (chunk.text.match(/<\/code>/g) ?? []).length,
273
+ );
274
+ assert.equal(chunk.text.includes("<i>bar</i>"), false);
275
+ }
276
+ });
277
+
278
+ test("Chunked list, code, quote, and prose cycles stay balanced across transitions", () => {
279
+ const markdown = Array.from({ length: 180 }, (_, index) => {
280
+ return [
281
+ `- list item **${index}**`,
282
+ "```ts",
283
+ `const cycle_${index} = \"value_${index}\";`,
284
+ "```",
285
+ `> quoted ${index} with [link](https://example.com/${index})`,
286
+ `Plain paragraph ${index} with \`inline_${index}()\``,
287
+ ].join("\n");
288
+ }).join("\n\n");
289
+ const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
290
+ mode: "markdown",
291
+ });
292
+ assert.ok(chunks.length > 1);
293
+ for (const chunk of chunks) {
294
+ assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
295
+ assert.equal(
296
+ (chunk.text.match(/<pre><code/g) ?? []).length,
297
+ (chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
298
+ );
299
+ assert.equal(
300
+ (chunk.text.match(/<blockquote>/g) ?? []).length,
301
+ (chunk.text.match(/<\/blockquote>/g) ?? []).length,
302
+ );
303
+ assert.equal(
304
+ (chunk.text.match(/<a /g) ?? []).length,
305
+ (chunk.text.match(/<\/a>/g) ?? []).length,
306
+ );
307
+ }
308
+ });