@llblab/pi-telegram 0.2.8 → 0.2.10
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 +2 -1
- package/CHANGELOG.md +15 -0
- package/README.md +16 -13
- package/docs/architecture.md +26 -16
- package/index.ts +199 -251
- package/lib/api.ts +277 -42
- package/lib/commands.ts +87 -0
- package/lib/media.ts +70 -1
- package/lib/polling.ts +25 -5
- package/lib/preview.ts +239 -0
- package/lib/rendering.ts +686 -49
- package/lib/replies.ts +2 -181
- package/lib/turns.ts +86 -0
- package/lib/types.ts +137 -0
- package/lib/updates.ts +64 -2
- package/package.json +1 -1
- package/tests/api.test.ts +243 -1
- package/tests/commands.test.ts +85 -0
- package/tests/media.test.ts +90 -1
- package/tests/menu.test.ts +46 -15
- package/tests/polling.test.ts +73 -0
- package/tests/preview.test.ts +480 -0
- package/tests/queue.test.ts +3 -0
- package/tests/rendering.test.ts +175 -2
- package/tests/replies.test.ts +2 -222
- package/tests/turns.test.ts +115 -0
- package/tests/updates.test.ts +72 -7
package/tests/api.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
|
-
import { mkdtemp, readFile } from "node:fs/promises";
|
|
7
|
+
import { mkdir, mkdtemp, readFile, readdir, utimes, writeFile } from "node:fs/promises";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import test from "node:test";
|
|
@@ -12,6 +12,8 @@ import test from "node:test";
|
|
|
12
12
|
import {
|
|
13
13
|
answerTelegramCallbackQuery,
|
|
14
14
|
callTelegram,
|
|
15
|
+
callTelegramMultipart,
|
|
16
|
+
cleanupTelegramTempFiles,
|
|
15
17
|
createTelegramApiClient,
|
|
16
18
|
downloadTelegramFile,
|
|
17
19
|
readTelegramConfig,
|
|
@@ -33,6 +35,21 @@ test("Telegram config helpers persist and reload config", async () => {
|
|
|
33
35
|
assert.match(raw, /demo_bot/);
|
|
34
36
|
});
|
|
35
37
|
|
|
38
|
+
test("Telegram temp cleanup removes only stale files", async () => {
|
|
39
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-cleanup-"));
|
|
40
|
+
const oldFile = join(tempDir, "old.txt");
|
|
41
|
+
const freshFile = join(tempDir, "fresh.txt");
|
|
42
|
+
const nestedDir = join(tempDir, "nested");
|
|
43
|
+
await writeFile(oldFile, "old", "utf8");
|
|
44
|
+
await writeFile(freshFile, "fresh", "utf8");
|
|
45
|
+
await mkdir(nestedDir);
|
|
46
|
+
await writeFile(join(nestedDir, "keep.txt"), "keep", "utf8");
|
|
47
|
+
await utimes(oldFile, new Date(1_000), new Date(1_000));
|
|
48
|
+
await utimes(freshFile, new Date(10_000), new Date(10_000));
|
|
49
|
+
assert.equal(await cleanupTelegramTempFiles(tempDir, 5_000, 11_000), 1);
|
|
50
|
+
assert.deepEqual((await readdir(tempDir)).sort(), ["fresh.txt", "nested"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
36
53
|
test("Telegram API helpers reject missing bot token for direct calls", async () => {
|
|
37
54
|
await assert.rejects(() => callTelegram(undefined, "getMe", {}), {
|
|
38
55
|
message: "Telegram bot token is not configured",
|
|
@@ -51,6 +68,231 @@ test("Telegram API helpers reject missing bot token for direct calls", async ()
|
|
|
51
68
|
);
|
|
52
69
|
});
|
|
53
70
|
|
|
71
|
+
test("Telegram API helpers include HTTP status details for failed responses", async () => {
|
|
72
|
+
const originalFetch = globalThis.fetch;
|
|
73
|
+
globalThis.fetch = (async () => {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 429,
|
|
77
|
+
text: async () => JSON.stringify({ ok: false, description: "Too Many Requests" }),
|
|
78
|
+
} as Response;
|
|
79
|
+
}) as typeof fetch;
|
|
80
|
+
try {
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
() => callTelegram("123:abc", "sendMessage", {}, { maxAttempts: 1 }),
|
|
83
|
+
{
|
|
84
|
+
message: "Telegram API sendMessage failed: HTTP 429: Too Many Requests",
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
} finally {
|
|
88
|
+
globalThis.fetch = originalFetch;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("Telegram API helpers retry 429 and 5xx responses", async () => {
|
|
93
|
+
const originalFetch = globalThis.fetch;
|
|
94
|
+
const sleeps: number[] = [];
|
|
95
|
+
let calls = 0;
|
|
96
|
+
globalThis.fetch = (async () => {
|
|
97
|
+
calls += 1;
|
|
98
|
+
if (calls === 1) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
status: 429,
|
|
102
|
+
headers: new Headers({ "retry-after": "2" }),
|
|
103
|
+
text: async () => JSON.stringify({ ok: false, description: "Too Many Requests" }),
|
|
104
|
+
} as Response;
|
|
105
|
+
}
|
|
106
|
+
if (calls === 2) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
status: 502,
|
|
110
|
+
text: async () => JSON.stringify({ ok: false, description: "Bad Gateway" }),
|
|
111
|
+
} as Response;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
status: 200,
|
|
116
|
+
text: async () => JSON.stringify({ ok: true, result: "sent" }),
|
|
117
|
+
} as Response;
|
|
118
|
+
}) as typeof fetch;
|
|
119
|
+
try {
|
|
120
|
+
const result = await callTelegram<string>("123:abc", "sendMessage", {}, {
|
|
121
|
+
retryBaseDelayMs: 10,
|
|
122
|
+
sleep: async (ms) => {
|
|
123
|
+
sleeps.push(ms);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
assert.equal(result, "sent");
|
|
127
|
+
assert.equal(calls, 3);
|
|
128
|
+
assert.deepEqual(sleeps, [2000, 20]);
|
|
129
|
+
} finally {
|
|
130
|
+
globalThis.fetch = originalFetch;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("Telegram multipart API rebuilds forms for retryable responses", async () => {
|
|
135
|
+
const originalFetch = globalThis.fetch;
|
|
136
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-upload-"));
|
|
137
|
+
const filePath = join(tempDir, "demo.txt");
|
|
138
|
+
await writeFile(filePath, "hello", "utf8");
|
|
139
|
+
const contentTypes: string[] = [];
|
|
140
|
+
let calls = 0;
|
|
141
|
+
globalThis.fetch = (async (_input, init) => {
|
|
142
|
+
calls += 1;
|
|
143
|
+
contentTypes.push((init?.body as FormData).get("document") instanceof Blob ? "blob" : "missing");
|
|
144
|
+
if (calls === 1) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
status: 500,
|
|
148
|
+
text: async () => JSON.stringify({ ok: false, description: "Server Error" }),
|
|
149
|
+
} as Response;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
status: 200,
|
|
154
|
+
text: async () => JSON.stringify({ ok: true, result: true }),
|
|
155
|
+
} as Response;
|
|
156
|
+
}) as typeof fetch;
|
|
157
|
+
try {
|
|
158
|
+
assert.equal(
|
|
159
|
+
await callTelegramMultipart<boolean>(
|
|
160
|
+
"123:abc",
|
|
161
|
+
"sendDocument",
|
|
162
|
+
{ chat_id: "1" },
|
|
163
|
+
"document",
|
|
164
|
+
filePath,
|
|
165
|
+
"demo.txt",
|
|
166
|
+
{ retryBaseDelayMs: 0, sleep: async () => {} },
|
|
167
|
+
),
|
|
168
|
+
true,
|
|
169
|
+
);
|
|
170
|
+
assert.equal(calls, 2);
|
|
171
|
+
assert.deepEqual(contentTypes, ["blob", "blob"]);
|
|
172
|
+
} finally {
|
|
173
|
+
globalThis.fetch = originalFetch;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("Telegram file downloads use unique sanitized temp file names", async () => {
|
|
178
|
+
const originalFetch = globalThis.fetch;
|
|
179
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-download-"));
|
|
180
|
+
globalThis.fetch = (async (input) => {
|
|
181
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
182
|
+
if (url.includes("/getFile")) {
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
status: 200,
|
|
186
|
+
text: async () =>
|
|
187
|
+
JSON.stringify({ ok: true, result: { file_path: "files/demo" } }),
|
|
188
|
+
} as Response;
|
|
189
|
+
}
|
|
190
|
+
return new Response("hello", { status: 200 });
|
|
191
|
+
}) as typeof fetch;
|
|
192
|
+
try {
|
|
193
|
+
const path = await downloadTelegramFile(
|
|
194
|
+
"123:abc",
|
|
195
|
+
"file-id",
|
|
196
|
+
"bad name?.txt",
|
|
197
|
+
tempDir,
|
|
198
|
+
);
|
|
199
|
+
assert.match(path, /[0-9a-f-]{36}-bad_name_\.txt$/);
|
|
200
|
+
assert.equal(await readFile(path, "utf8"), "hello");
|
|
201
|
+
} finally {
|
|
202
|
+
globalThis.fetch = originalFetch;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("Telegram file downloads reject files above configured limits", async () => {
|
|
207
|
+
const originalFetch = globalThis.fetch;
|
|
208
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-download-limit-"));
|
|
209
|
+
let calls = 0;
|
|
210
|
+
globalThis.fetch = (async (input) => {
|
|
211
|
+
calls += 1;
|
|
212
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
213
|
+
if (url.includes("/getFile")) {
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
status: 200,
|
|
217
|
+
text: async () =>
|
|
218
|
+
JSON.stringify({
|
|
219
|
+
ok: true,
|
|
220
|
+
result: { file_path: "files/demo", file_size: 10 },
|
|
221
|
+
}),
|
|
222
|
+
} as Response;
|
|
223
|
+
}
|
|
224
|
+
return new Response("too large", { status: 200 });
|
|
225
|
+
}) as typeof fetch;
|
|
226
|
+
try {
|
|
227
|
+
await assert.rejects(
|
|
228
|
+
() =>
|
|
229
|
+
downloadTelegramFile(
|
|
230
|
+
"123:abc",
|
|
231
|
+
"file-id",
|
|
232
|
+
"demo.txt",
|
|
233
|
+
tempDir,
|
|
234
|
+
{ maxFileSizeBytes: 5 },
|
|
235
|
+
),
|
|
236
|
+
{ message: "Telegram file exceeds size limit (10 bytes > 5 bytes)" },
|
|
237
|
+
);
|
|
238
|
+
assert.equal(calls, 1);
|
|
239
|
+
assert.deepEqual(await readdir(tempDir), []);
|
|
240
|
+
} finally {
|
|
241
|
+
globalThis.fetch = originalFetch;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("Telegram streaming downloads remove partial files after limit failures", async () => {
|
|
246
|
+
const originalFetch = globalThis.fetch;
|
|
247
|
+
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-download-partial-"));
|
|
248
|
+
globalThis.fetch = (async (input) => {
|
|
249
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
250
|
+
if (url.includes("/getFile")) {
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
status: 200,
|
|
254
|
+
text: async () =>
|
|
255
|
+
JSON.stringify({ ok: true, result: { file_path: "files/demo" } }),
|
|
256
|
+
} as Response;
|
|
257
|
+
}
|
|
258
|
+
return new Response("too large", { status: 200 });
|
|
259
|
+
}) as typeof fetch;
|
|
260
|
+
try {
|
|
261
|
+
await assert.rejects(
|
|
262
|
+
() =>
|
|
263
|
+
downloadTelegramFile(
|
|
264
|
+
"123:abc",
|
|
265
|
+
"file-id",
|
|
266
|
+
"demo.txt",
|
|
267
|
+
tempDir,
|
|
268
|
+
{ maxFileSizeBytes: 5 },
|
|
269
|
+
),
|
|
270
|
+
{ message: "Telegram file exceeds size limit (9 bytes > 5 bytes)" },
|
|
271
|
+
);
|
|
272
|
+
assert.deepEqual(await readdir(tempDir), []);
|
|
273
|
+
} finally {
|
|
274
|
+
globalThis.fetch = originalFetch;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("Telegram API helpers reject malformed successful responses", async () => {
|
|
279
|
+
const originalFetch = globalThis.fetch;
|
|
280
|
+
globalThis.fetch = (async () => {
|
|
281
|
+
return {
|
|
282
|
+
ok: true,
|
|
283
|
+
status: 200,
|
|
284
|
+
text: async () => "not json",
|
|
285
|
+
} as Response;
|
|
286
|
+
}) as typeof fetch;
|
|
287
|
+
try {
|
|
288
|
+
await assert.rejects(() => callTelegram("123:abc", "getMe", {}), {
|
|
289
|
+
message: "Telegram API getMe returned invalid JSON",
|
|
290
|
+
});
|
|
291
|
+
} finally {
|
|
292
|
+
globalThis.fetch = originalFetch;
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
54
296
|
test("answerTelegramCallbackQuery ignores Telegram API failures", async () => {
|
|
55
297
|
const originalFetch = globalThis.fetch;
|
|
56
298
|
globalThis.fetch = (async () => {
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
});
|
package/tests/media.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Regression tests for Telegram media and text extraction helpers
|
|
3
|
-
* Covers inbound file-info collection, text extraction, id collection, and history formatting
|
|
3
|
+
* Covers inbound file-info collection, text extraction, media groups, id collection, and history formatting
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import assert from "node:assert/strict";
|
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
extractFirstTelegramMessageText,
|
|
13
13
|
extractTelegramMessagesText,
|
|
14
14
|
formatTelegramHistoryText,
|
|
15
|
+
getTelegramMediaGroupKey,
|
|
15
16
|
guessMediaType,
|
|
17
|
+
queueTelegramMediaGroupMessage,
|
|
18
|
+
removePendingTelegramMediaGroupMessages,
|
|
19
|
+
type TelegramMediaGroupState,
|
|
16
20
|
} from "../lib/media.ts";
|
|
17
21
|
|
|
18
22
|
test("Media helpers collect file infos across Telegram message variants", () => {
|
|
@@ -75,3 +79,88 @@ test("Media helpers infer outgoing image media types from file paths", () => {
|
|
|
75
79
|
assert.equal(guessMediaType("/tmp/demo.png"), "image/png");
|
|
76
80
|
assert.equal(guessMediaType("/tmp/demo.txt"), undefined);
|
|
77
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
|
+
});
|
package/tests/menu.test.ts
CHANGED
|
@@ -144,7 +144,9 @@ test("Menu helpers apply menu mutations and resolve model selections", () => {
|
|
|
144
144
|
assert.equal(state.page, 2);
|
|
145
145
|
assert.equal(applyTelegramModelPageSelection(state, "2"), "unchanged");
|
|
146
146
|
assert.equal(applyTelegramModelPageSelection(state, "bad"), "invalid");
|
|
147
|
-
assert.deepEqual(getTelegramModelSelection(state, "bad"), {
|
|
147
|
+
assert.deepEqual(getTelegramModelSelection(state, "bad"), {
|
|
148
|
+
kind: "invalid",
|
|
149
|
+
});
|
|
148
150
|
assert.deepEqual(getTelegramModelSelection(state, "9"), { kind: "missing" });
|
|
149
151
|
assert.equal(getTelegramModelSelection(state, "0").kind, "selected");
|
|
150
152
|
});
|
|
@@ -174,7 +176,11 @@ test("Menu helpers derive normalized menu pages without mutating state", () => {
|
|
|
174
176
|
|
|
175
177
|
test("Menu helpers build model callback plans for paging, selection, and restart modes", () => {
|
|
176
178
|
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
|
177
|
-
const modelB = {
|
|
179
|
+
const modelB = {
|
|
180
|
+
provider: "anthropic",
|
|
181
|
+
id: "claude-3",
|
|
182
|
+
reasoning: false,
|
|
183
|
+
} as const;
|
|
178
184
|
const state = {
|
|
179
185
|
chatId: 1,
|
|
180
186
|
messageId: 2,
|
|
@@ -227,7 +233,8 @@ test("Menu helpers build model callback plans for paging, selection, and restart
|
|
|
227
233
|
kind: "switch-model",
|
|
228
234
|
selection: state.allModels[1],
|
|
229
235
|
mode: "restart-after-tool",
|
|
230
|
-
callbackText:
|
|
236
|
+
callbackText:
|
|
237
|
+
"Switched to claude-3. Restarting after the current tool finishes…",
|
|
231
238
|
},
|
|
232
239
|
);
|
|
233
240
|
assert.deepEqual(
|
|
@@ -254,14 +261,19 @@ test("Menu helpers route callback entry states before action handlers", async ()
|
|
|
254
261
|
events.push(`answer:${text ?? ""}`);
|
|
255
262
|
},
|
|
256
263
|
});
|
|
257
|
-
await handleTelegramMenuCallbackEntry(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
264
|
+
await handleTelegramMenuCallbackEntry(
|
|
265
|
+
"callback-2",
|
|
266
|
+
"status:model",
|
|
267
|
+
undefined,
|
|
268
|
+
{
|
|
269
|
+
handleStatusAction: async () => false,
|
|
270
|
+
handleThinkingAction: async () => false,
|
|
271
|
+
handleModelAction: async () => false,
|
|
272
|
+
answerCallbackQuery: async (_id, text) => {
|
|
273
|
+
events.push(`answer:${text ?? ""}`);
|
|
274
|
+
},
|
|
263
275
|
},
|
|
264
|
-
|
|
276
|
+
);
|
|
265
277
|
await handleTelegramMenuCallbackEntry(
|
|
266
278
|
"callback-3",
|
|
267
279
|
"status:model",
|
|
@@ -296,7 +308,11 @@ test("Menu helpers route callback entry states before action handlers", async ()
|
|
|
296
308
|
test("Menu helpers execute model callback actions across update, switch, and restart paths", async () => {
|
|
297
309
|
const events: string[] = [];
|
|
298
310
|
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
|
299
|
-
const modelB = {
|
|
311
|
+
const modelB = {
|
|
312
|
+
provider: "anthropic",
|
|
313
|
+
id: "claude-3",
|
|
314
|
+
reasoning: false,
|
|
315
|
+
} as const;
|
|
300
316
|
const state = {
|
|
301
317
|
chatId: 1,
|
|
302
318
|
messageId: 2,
|
|
@@ -530,8 +546,14 @@ test("Menu helpers build pure render payloads before transport", () => {
|
|
|
530
546
|
allModels: [{ model: modelA }],
|
|
531
547
|
mode: "status" as const,
|
|
532
548
|
} as unknown as TelegramModelMenuState;
|
|
533
|
-
const modelPayload = buildTelegramModelMenuRenderPayload(
|
|
534
|
-
|
|
549
|
+
const modelPayload = buildTelegramModelMenuRenderPayload(
|
|
550
|
+
state,
|
|
551
|
+
modelA as never,
|
|
552
|
+
);
|
|
553
|
+
const thinkingPayload = buildTelegramThinkingMenuRenderPayload(
|
|
554
|
+
modelA as never,
|
|
555
|
+
"medium",
|
|
556
|
+
);
|
|
535
557
|
const statusPayload = buildTelegramStatusMenuRenderPayload(
|
|
536
558
|
"<b>Status</b>",
|
|
537
559
|
modelA as never,
|
|
@@ -580,7 +602,12 @@ test("Menu helpers update and send interactive menu messages", async () => {
|
|
|
580
602
|
},
|
|
581
603
|
};
|
|
582
604
|
await updateTelegramModelMenuMessage(state, modelA as never, deps);
|
|
583
|
-
await updateTelegramThinkingMenuMessage(
|
|
605
|
+
await updateTelegramThinkingMenuMessage(
|
|
606
|
+
state,
|
|
607
|
+
modelA as never,
|
|
608
|
+
"medium",
|
|
609
|
+
deps,
|
|
610
|
+
);
|
|
584
611
|
await updateTelegramStatusMessage(
|
|
585
612
|
state,
|
|
586
613
|
"<b>Status</b>",
|
|
@@ -595,7 +622,11 @@ test("Menu helpers update and send interactive menu messages", async () => {
|
|
|
595
622
|
"medium",
|
|
596
623
|
deps,
|
|
597
624
|
);
|
|
598
|
-
const sentModelId = await sendTelegramModelMenuMessage(
|
|
625
|
+
const sentModelId = await sendTelegramModelMenuMessage(
|
|
626
|
+
state,
|
|
627
|
+
modelA as never,
|
|
628
|
+
deps,
|
|
629
|
+
);
|
|
599
630
|
assert.equal(sentStatusId, 99);
|
|
600
631
|
assert.equal(sentModelId, 99);
|
|
601
632
|
assert.equal(events[0], "edit:1:2:html:<b>Choose a model:</b>");
|
package/tests/polling.test.ts
CHANGED
|
@@ -93,6 +93,79 @@ test("Poll loop initializes lastUpdateId and processes updates", async () => {
|
|
|
93
93
|
assert.equal(persistCount, 3);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test("Poll loop persists long-poll offsets only after handling updates", async () => {
|
|
97
|
+
const config = { botToken: "123:abc", lastUpdateId: 5 };
|
|
98
|
+
const handled: number[] = [];
|
|
99
|
+
const persisted: number[] = [];
|
|
100
|
+
let calls = 0;
|
|
101
|
+
await runTelegramPollLoop({
|
|
102
|
+
ctx: {} as never,
|
|
103
|
+
signal: new AbortController().signal,
|
|
104
|
+
config,
|
|
105
|
+
deleteWebhook: async () => {},
|
|
106
|
+
getUpdates: async () => {
|
|
107
|
+
calls += 1;
|
|
108
|
+
if (calls === 1) return [{ update_id: 6 }];
|
|
109
|
+
throw new DOMException("stop", "AbortError");
|
|
110
|
+
},
|
|
111
|
+
persistConfig: async () => {
|
|
112
|
+
persisted.push(config.lastUpdateId ?? -1);
|
|
113
|
+
},
|
|
114
|
+
handleUpdate: async (update) => {
|
|
115
|
+
handled.push(update.update_id);
|
|
116
|
+
throw new Error("handler failed");
|
|
117
|
+
},
|
|
118
|
+
onErrorStatus: () => {},
|
|
119
|
+
onStatusReset: () => {},
|
|
120
|
+
sleep: async () => {},
|
|
121
|
+
});
|
|
122
|
+
assert.deepEqual(handled, [6]);
|
|
123
|
+
assert.equal(config.lastUpdateId, 5);
|
|
124
|
+
assert.deepEqual(persisted, []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("Poll loop skips repeatedly failing updates after the configured threshold", async () => {
|
|
128
|
+
const config = { botToken: "123:abc", lastUpdateId: 5 };
|
|
129
|
+
const persisted: number[] = [];
|
|
130
|
+
const statusMessages: string[] = [];
|
|
131
|
+
let calls = 0;
|
|
132
|
+
await runTelegramPollLoop({
|
|
133
|
+
ctx: {} as never,
|
|
134
|
+
signal: new AbortController().signal,
|
|
135
|
+
config,
|
|
136
|
+
maxUpdateFailures: 2,
|
|
137
|
+
deleteWebhook: async () => {},
|
|
138
|
+
getUpdates: async () => {
|
|
139
|
+
calls += 1;
|
|
140
|
+
if (calls <= 2) return [{ update_id: 6 }];
|
|
141
|
+
throw new DOMException("stop", "AbortError");
|
|
142
|
+
},
|
|
143
|
+
persistConfig: async () => {
|
|
144
|
+
persisted.push(config.lastUpdateId ?? -1);
|
|
145
|
+
},
|
|
146
|
+
handleUpdate: async () => {
|
|
147
|
+
throw new Error("handler failed");
|
|
148
|
+
},
|
|
149
|
+
onErrorStatus: (message) => {
|
|
150
|
+
statusMessages.push(message);
|
|
151
|
+
},
|
|
152
|
+
onStatusReset: () => {
|
|
153
|
+
statusMessages.push("reset");
|
|
154
|
+
},
|
|
155
|
+
sleep: async (ms) => {
|
|
156
|
+
statusMessages.push(`sleep:${ms}`);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
assert.equal(config.lastUpdateId, 6);
|
|
160
|
+
assert.deepEqual(persisted, [6]);
|
|
161
|
+
assert.deepEqual(statusMessages, [
|
|
162
|
+
"handler failed",
|
|
163
|
+
"sleep:3000",
|
|
164
|
+
"reset",
|
|
165
|
+
"skipping Telegram update 6 after 2 failures: handler failed",
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
96
169
|
test("Poll loop reports retryable errors and sleeps before retrying", async () => {
|
|
97
170
|
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
|
98
171
|
const statusMessages: string[] = [];
|