@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.
package/lib/updates.ts ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Telegram updates domain helpers
3
+ * Owns update extraction, authorization, classification, execution planning, and runtime execution for Telegram updates
4
+ */
5
+
6
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
7
+
8
+ // --- Extraction ---
9
+
10
+ export interface TelegramReactionTypeEmojiLike {
11
+ type: "emoji";
12
+ emoji: string;
13
+ }
14
+
15
+ export interface TelegramReactionTypeNonEmojiLike {
16
+ type: string;
17
+ }
18
+
19
+ export type TelegramReactionTypeLike =
20
+ | TelegramReactionTypeEmojiLike
21
+ | TelegramReactionTypeNonEmojiLike;
22
+
23
+ export interface TelegramUpdateLike {
24
+ deleted_business_messages?: { message_ids?: unknown };
25
+ _: string;
26
+ messages?: unknown;
27
+ }
28
+
29
+ function isTelegramMessageIdList(value: unknown): value is number[] {
30
+ return Array.isArray(value) && value.every((item) => Number.isInteger(item));
31
+ }
32
+
33
+ export function normalizeTelegramReactionEmoji(emoji: string): string {
34
+ return emoji.replace(/\uFE0F/g, "");
35
+ }
36
+
37
+ export function collectTelegramReactionEmojis(
38
+ reactions: TelegramReactionTypeLike[],
39
+ ): Set<string> {
40
+ return new Set(
41
+ reactions
42
+ .filter(
43
+ (reaction): reaction is TelegramReactionTypeEmojiLike =>
44
+ reaction.type === "emoji",
45
+ )
46
+ .map((reaction) => normalizeTelegramReactionEmoji(reaction.emoji)),
47
+ );
48
+ }
49
+
50
+ export function extractDeletedTelegramMessageIds(
51
+ update: TelegramUpdateLike,
52
+ ): number[] {
53
+ const deletedBusinessMessageIds =
54
+ update.deleted_business_messages?.message_ids;
55
+ if (isTelegramMessageIdList(deletedBusinessMessageIds)) {
56
+ return deletedBusinessMessageIds;
57
+ }
58
+ if (
59
+ update._ === "updateDeleteMessages" &&
60
+ isTelegramMessageIdList(update.messages)
61
+ ) {
62
+ return update.messages;
63
+ }
64
+ return [];
65
+ }
66
+
67
+ // --- Routing ---
68
+
69
+ export interface TelegramUserLike {
70
+ id: number;
71
+ is_bot: boolean;
72
+ }
73
+
74
+ export interface TelegramChatLike {
75
+ id?: number;
76
+ type: string;
77
+ }
78
+
79
+ export interface TelegramMessageLike {
80
+ chat: TelegramChatLike;
81
+ from?: TelegramUserLike;
82
+ message_id?: number;
83
+ }
84
+
85
+ export interface TelegramCallbackQueryLike {
86
+ id?: string;
87
+ from: TelegramUserLike;
88
+ message?: TelegramMessageLike;
89
+ }
90
+
91
+ export interface TelegramUpdateRoutingLike {
92
+ message?: TelegramMessageLike;
93
+ edited_message?: TelegramMessageLike;
94
+ callback_query?: TelegramCallbackQueryLike;
95
+ }
96
+
97
+ export type TelegramAuthorizationState =
98
+ | { kind: "pair"; userId: number }
99
+ | { kind: "allow" }
100
+ | { kind: "deny" };
101
+
102
+ export function getTelegramAuthorizationState(
103
+ userId: number,
104
+ allowedUserId?: number,
105
+ ): TelegramAuthorizationState {
106
+ if (allowedUserId === undefined) {
107
+ return { kind: "pair", userId };
108
+ }
109
+ if (userId === allowedUserId) {
110
+ return { kind: "allow" };
111
+ }
112
+ return { kind: "deny" };
113
+ }
114
+
115
+ export function getAuthorizedTelegramCallbackQuery(
116
+ update: TelegramUpdateRoutingLike,
117
+ ): TelegramCallbackQueryLike | undefined {
118
+ const query = update.callback_query;
119
+ if (!query) return undefined;
120
+ const message = query.message;
121
+ if (!message || message.chat.type !== "private" || query.from.is_bot) {
122
+ return undefined;
123
+ }
124
+ return query;
125
+ }
126
+
127
+ export function getAuthorizedTelegramMessage(
128
+ update: TelegramUpdateRoutingLike,
129
+ ): TelegramMessageLike | undefined {
130
+ const message = update.message || update.edited_message;
131
+ if (
132
+ !message ||
133
+ message.chat.type !== "private" ||
134
+ !message.from ||
135
+ message.from.is_bot
136
+ ) {
137
+ return undefined;
138
+ }
139
+ return message;
140
+ }
141
+
142
+ // --- Flow ---
143
+
144
+ export interface TelegramMessageReactionUpdatedLike {
145
+ chat: { type: string };
146
+ user?: TelegramUserLike;
147
+ }
148
+
149
+ export interface TelegramUpdateFlowLike
150
+ extends TelegramUpdateRoutingLike, TelegramUpdateLike {
151
+ message_reaction?: TelegramMessageReactionUpdatedLike;
152
+ }
153
+
154
+ export type TelegramUpdateFlowAction =
155
+ | { kind: "ignore" }
156
+ | { kind: "deleted"; messageIds: number[] }
157
+ | { kind: "reaction"; reactionUpdate: TelegramMessageReactionUpdatedLike }
158
+ | {
159
+ kind: "callback";
160
+ query: TelegramCallbackQueryLike;
161
+ authorization: TelegramAuthorizationState;
162
+ }
163
+ | {
164
+ kind: "message";
165
+ message: TelegramMessageLike & { from: TelegramUserLike };
166
+ authorization: TelegramAuthorizationState;
167
+ };
168
+
169
+ export function buildTelegramUpdateFlowAction(
170
+ update: TelegramUpdateFlowLike,
171
+ allowedUserId?: number,
172
+ ): TelegramUpdateFlowAction {
173
+ const deletedMessageIds = extractDeletedTelegramMessageIds(update);
174
+ if (deletedMessageIds.length > 0) {
175
+ return { kind: "deleted", messageIds: deletedMessageIds };
176
+ }
177
+ if (update.message_reaction) {
178
+ return { kind: "reaction", reactionUpdate: update.message_reaction };
179
+ }
180
+ const query = getAuthorizedTelegramCallbackQuery(update);
181
+ if (query) {
182
+ return {
183
+ kind: "callback",
184
+ query,
185
+ authorization: getTelegramAuthorizationState(
186
+ query.from.id,
187
+ allowedUserId,
188
+ ),
189
+ };
190
+ }
191
+ const message = getAuthorizedTelegramMessage(update);
192
+ if (message?.from) {
193
+ return {
194
+ kind: "message",
195
+ message: message as TelegramMessageLike & { from: TelegramUserLike },
196
+ authorization: getTelegramAuthorizationState(
197
+ message.from.id,
198
+ allowedUserId,
199
+ ),
200
+ };
201
+ }
202
+ return { kind: "ignore" };
203
+ }
204
+
205
+ // --- Execution Planning ---
206
+
207
+ export type TelegramUpdateExecutionPlan =
208
+ | { kind: "ignore" }
209
+ | { kind: "deleted"; messageIds: number[] }
210
+ | {
211
+ kind: "reaction";
212
+ reactionUpdate: NonNullable<TelegramUpdateFlowLike["message_reaction"]>;
213
+ }
214
+ | {
215
+ kind: "callback";
216
+ query: TelegramCallbackQueryLike;
217
+ shouldPair: boolean;
218
+ shouldDeny: boolean;
219
+ }
220
+ | {
221
+ kind: "message";
222
+ message: TelegramMessageLike & { from: TelegramUserLike };
223
+ shouldPair: boolean;
224
+ shouldNotifyPaired: boolean;
225
+ shouldDeny: boolean;
226
+ };
227
+
228
+ export function buildTelegramUpdateExecutionPlan(
229
+ action: TelegramUpdateFlowAction,
230
+ ): TelegramUpdateExecutionPlan {
231
+ switch (action.kind) {
232
+ case "ignore":
233
+ return { kind: "ignore" };
234
+ case "deleted":
235
+ return { kind: "deleted", messageIds: action.messageIds };
236
+ case "reaction":
237
+ return { kind: "reaction", reactionUpdate: action.reactionUpdate };
238
+ case "callback":
239
+ return {
240
+ kind: "callback",
241
+ query: action.query,
242
+ shouldPair: action.authorization.kind === "pair",
243
+ shouldDeny: action.authorization.kind === "deny",
244
+ };
245
+ case "message":
246
+ return {
247
+ kind: "message",
248
+ message: action.message,
249
+ shouldPair: action.authorization.kind === "pair",
250
+ shouldNotifyPaired: action.authorization.kind === "pair",
251
+ shouldDeny: action.authorization.kind === "deny",
252
+ };
253
+ }
254
+ }
255
+
256
+ export function buildTelegramUpdateExecutionPlanFromUpdate(
257
+ update: TelegramUpdateFlowLike,
258
+ allowedUserId?: number,
259
+ ): TelegramUpdateExecutionPlan {
260
+ return buildTelegramUpdateExecutionPlan(
261
+ buildTelegramUpdateFlowAction(update, allowedUserId),
262
+ );
263
+ }
264
+
265
+ // --- Runtime ---
266
+
267
+ export interface TelegramUpdateRuntimeDeps {
268
+ ctx: ExtensionContext;
269
+ removePendingMediaGroupMessages: (messageIds: number[]) => void;
270
+ removeQueuedTelegramTurnsByMessageIds: (
271
+ messageIds: number[],
272
+ ctx: ExtensionContext,
273
+ ) => number;
274
+ handleAuthorizedTelegramReactionUpdate: (
275
+ reactionUpdate: NonNullable<
276
+ Extract<
277
+ TelegramUpdateExecutionPlan,
278
+ { kind: "reaction" }
279
+ >["reactionUpdate"]
280
+ >,
281
+ ctx: ExtensionContext,
282
+ ) => Promise<void>;
283
+ pairTelegramUserIfNeeded: (
284
+ userId: number,
285
+ ctx: ExtensionContext,
286
+ ) => Promise<boolean>;
287
+ answerCallbackQuery: (
288
+ callbackQueryId: string,
289
+ text?: string,
290
+ ) => Promise<void>;
291
+ handleAuthorizedTelegramCallbackQuery: (
292
+ query: Extract<TelegramUpdateExecutionPlan, { kind: "callback" }>["query"],
293
+ ctx: ExtensionContext,
294
+ ) => Promise<void>;
295
+ sendTextReply: (
296
+ chatId: number,
297
+ replyToMessageId: number,
298
+ text: string,
299
+ ) => Promise<number | undefined>;
300
+ handleAuthorizedTelegramMessage: (
301
+ message: Extract<
302
+ TelegramUpdateExecutionPlan,
303
+ { kind: "message" }
304
+ >["message"],
305
+ ctx: ExtensionContext,
306
+ ) => Promise<void>;
307
+ }
308
+
309
+ function getTelegramCallbackQueryId(
310
+ query: TelegramCallbackQueryLike,
311
+ ): string | undefined {
312
+ return typeof query.id === "string" ? query.id : undefined;
313
+ }
314
+
315
+ function getTelegramMessageReplyTarget(
316
+ message: TelegramMessageLike,
317
+ ): { chatId: number; messageId: number } | undefined {
318
+ if (
319
+ typeof message.chat.id !== "number" ||
320
+ typeof message.message_id !== "number"
321
+ ) {
322
+ return undefined;
323
+ }
324
+ return {
325
+ chatId: message.chat.id,
326
+ messageId: message.message_id,
327
+ };
328
+ }
329
+
330
+ export async function executeTelegramUpdate(
331
+ update: TelegramUpdateFlowLike,
332
+ allowedUserId: number | undefined,
333
+ deps: TelegramUpdateRuntimeDeps,
334
+ ): Promise<void> {
335
+ await executeTelegramUpdatePlan(
336
+ buildTelegramUpdateExecutionPlanFromUpdate(update, allowedUserId),
337
+ deps,
338
+ );
339
+ }
340
+
341
+ export async function executeTelegramUpdatePlan(
342
+ plan: TelegramUpdateExecutionPlan,
343
+ deps: TelegramUpdateRuntimeDeps,
344
+ ): Promise<void> {
345
+ if (plan.kind === "ignore") return;
346
+ if (plan.kind === "deleted") {
347
+ deps.removePendingMediaGroupMessages(plan.messageIds);
348
+ deps.removeQueuedTelegramTurnsByMessageIds(plan.messageIds, deps.ctx);
349
+ return;
350
+ }
351
+ if (plan.kind === "reaction") {
352
+ await deps.handleAuthorizedTelegramReactionUpdate(
353
+ plan.reactionUpdate,
354
+ deps.ctx,
355
+ );
356
+ return;
357
+ }
358
+ if (plan.kind === "callback") {
359
+ if (plan.shouldPair) {
360
+ await deps.pairTelegramUserIfNeeded(plan.query.from.id, deps.ctx);
361
+ }
362
+ if (plan.shouldDeny) {
363
+ const callbackQueryId = getTelegramCallbackQueryId(plan.query);
364
+ if (callbackQueryId) {
365
+ await deps.answerCallbackQuery(
366
+ callbackQueryId,
367
+ "This bot is not authorized for your account.",
368
+ );
369
+ }
370
+ return;
371
+ }
372
+ await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx);
373
+ return;
374
+ }
375
+ const pairedNow = plan.shouldPair
376
+ ? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx)
377
+ : false;
378
+ const replyTarget = getTelegramMessageReplyTarget(plan.message);
379
+ if (pairedNow && plan.shouldNotifyPaired && replyTarget) {
380
+ await deps.sendTextReply(
381
+ replyTarget.chatId,
382
+ replyTarget.messageId,
383
+ "Telegram bridge paired with this account.",
384
+ );
385
+ }
386
+ if (plan.shouldDeny) {
387
+ if (replyTarget) {
388
+ await deps.sendTextReply(
389
+ replyTarget.chatId,
390
+ replyTarget.messageId,
391
+ "This bot is not authorized for your account.",
392
+ );
393
+ }
394
+ return;
395
+ }
396
+ await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx);
397
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@llblab/pi-telegram",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "description": "Telegram DM bridge extension for pi",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "telegram",
11
+ "bot",
12
+ "extension"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/llblab/pi-telegram.git"
18
+ },
19
+ "homepage": "https://github.com/llblab/pi-telegram",
20
+ "bugs": {
21
+ "url": "https://github.com/llblab/pi-telegram/issues"
22
+ },
23
+ "scripts": {
24
+ "test": "node --experimental-strip-types --test tests/*.test.ts"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./index.ts"
32
+ ]
33
+ },
34
+ "peerDependencies": {
35
+ "@mariozechner/pi-ai": "*",
36
+ "@mariozechner/pi-agent-core": "*",
37
+ "@mariozechner/pi-coding-agent": "*",
38
+ "@sinclair/typebox": "*"
39
+ }
40
+ }
package/screenshot.png ADDED
Binary file
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Regression tests for Telegram API and config helpers
3
+ * Verifies config persistence and direct helper behavior around missing tokens and callback-query failures
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import { mkdtemp, readFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import test from "node:test";
11
+
12
+ import {
13
+ answerTelegramCallbackQuery,
14
+ callTelegram,
15
+ createTelegramApiClient,
16
+ downloadTelegramFile,
17
+ readTelegramConfig,
18
+ writeTelegramConfig,
19
+ } from "../lib/api.ts";
20
+
21
+ test("Telegram config helpers persist and reload config", async () => {
22
+ const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-config-"));
23
+ const configPath = join(agentDir, "telegram.json");
24
+ const config = {
25
+ botToken: "123:abc",
26
+ botUsername: "demo_bot",
27
+ allowedUserId: 42,
28
+ };
29
+ await writeTelegramConfig(agentDir, configPath, config);
30
+ const reloaded = await readTelegramConfig(configPath);
31
+ assert.deepEqual(reloaded, config);
32
+ const raw = await readFile(configPath, "utf8");
33
+ assert.match(raw, /demo_bot/);
34
+ });
35
+
36
+ test("Telegram API helpers reject missing bot token for direct calls", async () => {
37
+ await assert.rejects(() => callTelegram(undefined, "getMe", {}), {
38
+ message: "Telegram bot token is not configured",
39
+ });
40
+ await assert.rejects(
41
+ () =>
42
+ downloadTelegramFile(
43
+ undefined,
44
+ "file-id",
45
+ "demo.txt",
46
+ join(tmpdir(), "pi-telegram-missing-token"),
47
+ ),
48
+ {
49
+ message: "Telegram bot token is not configured",
50
+ },
51
+ );
52
+ });
53
+
54
+ test("answerTelegramCallbackQuery ignores Telegram API failures", async () => {
55
+ const originalFetch = globalThis.fetch;
56
+ globalThis.fetch = (async () => {
57
+ throw new Error("network down");
58
+ }) as typeof fetch;
59
+ try {
60
+ await assert.doesNotReject(() =>
61
+ answerTelegramCallbackQuery("123:abc", "callback-id", "ok"),
62
+ );
63
+ } finally {
64
+ globalThis.fetch = originalFetch;
65
+ }
66
+ });
67
+
68
+ test("Telegram API client resolves bot tokens lazily for wrapped calls", async () => {
69
+ const originalFetch = globalThis.fetch;
70
+ const calls: string[] = [];
71
+ let botToken = "123:abc";
72
+ globalThis.fetch = (async (input) => {
73
+ calls.push(typeof input === "string" ? input : input.toString());
74
+ return {
75
+ ok: true,
76
+ json: async () => ({ ok: true, result: true }),
77
+ } as Response;
78
+ }) as typeof fetch;
79
+ try {
80
+ const client = createTelegramApiClient(() => botToken);
81
+ await client.call("sendChatAction", { chat_id: 1, action: "typing" });
82
+ botToken = "456:def";
83
+ await client.answerCallbackQuery("cb-1", "ok");
84
+ assert.match(calls[0] ?? "", /bot123:abc\/sendChatAction$/);
85
+ assert.match(calls[1] ?? "", /bot456:def\/answerCallbackQuery$/);
86
+ } finally {
87
+ globalThis.fetch = originalFetch;
88
+ }
89
+ });
@@ -0,0 +1,132 @@
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
+ });