@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/api.ts ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Telegram API and config persistence helpers
3
+ * Wraps bot API calls, file downloads, and local config reads and writes for the bridge runtime
4
+ */
5
+
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+
9
+ export interface TelegramConfig {
10
+ botToken?: string;
11
+ botUsername?: string;
12
+ botId?: number;
13
+ allowedUserId?: number;
14
+ lastUpdateId?: number;
15
+ }
16
+
17
+ interface TelegramApiResponse<T> {
18
+ ok: boolean;
19
+ result?: T;
20
+ description?: string;
21
+ error_code?: number;
22
+ }
23
+
24
+ interface TelegramGetFileResult {
25
+ file_path: string;
26
+ }
27
+
28
+ export interface TelegramApiClient {
29
+ call: <TResponse>(
30
+ method: string,
31
+ body: Record<string, unknown>,
32
+ options?: { signal?: AbortSignal },
33
+ ) => Promise<TResponse>;
34
+ callMultipart: <TResponse>(
35
+ method: string,
36
+ fields: Record<string, string>,
37
+ fileField: string,
38
+ filePath: string,
39
+ fileName: string,
40
+ options?: { signal?: AbortSignal },
41
+ ) => Promise<TResponse>;
42
+ downloadFile: (
43
+ fileId: string,
44
+ suggestedName: string,
45
+ tempDir: string,
46
+ ) => Promise<string>;
47
+ answerCallbackQuery: (
48
+ callbackQueryId: string,
49
+ text?: string,
50
+ ) => Promise<void>;
51
+ }
52
+
53
+ function sanitizeFileName(name: string): string {
54
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
55
+ }
56
+
57
+ export async function readTelegramConfig(
58
+ configPath: string,
59
+ ): Promise<TelegramConfig> {
60
+ try {
61
+ const content = await readFile(configPath, "utf8");
62
+ return JSON.parse(content) as TelegramConfig;
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ export async function writeTelegramConfig(
69
+ agentDir: string,
70
+ configPath: string,
71
+ config: TelegramConfig,
72
+ ): Promise<void> {
73
+ await mkdir(agentDir, { recursive: true });
74
+ await writeFile(
75
+ configPath,
76
+ JSON.stringify(config, null, "\t") + "\n",
77
+ "utf8",
78
+ );
79
+ }
80
+
81
+ export async function callTelegram<TResponse>(
82
+ botToken: string | undefined,
83
+ method: string,
84
+ body: Record<string, unknown>,
85
+ options?: { signal?: AbortSignal },
86
+ ): Promise<TResponse> {
87
+ if (!botToken) {
88
+ throw new Error("Telegram bot token is not configured");
89
+ }
90
+ const response = await fetch(
91
+ `https://api.telegram.org/bot${botToken}/${method}`,
92
+ {
93
+ method: "POST",
94
+ headers: { "content-type": "application/json" },
95
+ body: JSON.stringify(body),
96
+ signal: options?.signal,
97
+ },
98
+ );
99
+ const data = (await response.json()) as TelegramApiResponse<TResponse>;
100
+ if (!data.ok || data.result === undefined) {
101
+ throw new Error(data.description || `Telegram API ${method} failed`);
102
+ }
103
+ return data.result;
104
+ }
105
+
106
+ export async function callTelegramMultipart<TResponse>(
107
+ botToken: string | undefined,
108
+ method: string,
109
+ fields: Record<string, string>,
110
+ fileField: string,
111
+ filePath: string,
112
+ fileName: string,
113
+ options?: { signal?: AbortSignal },
114
+ ): Promise<TResponse> {
115
+ if (!botToken) {
116
+ throw new Error("Telegram bot token is not configured");
117
+ }
118
+ const form = new FormData();
119
+ for (const [key, value] of Object.entries(fields)) {
120
+ form.set(key, value);
121
+ }
122
+ const buffer = await readFile(filePath);
123
+ form.set(fileField, new Blob([buffer]), fileName);
124
+ const response = await fetch(
125
+ `https://api.telegram.org/bot${botToken}/${method}`,
126
+ {
127
+ method: "POST",
128
+ body: form,
129
+ signal: options?.signal,
130
+ },
131
+ );
132
+ const data = (await response.json()) as TelegramApiResponse<TResponse>;
133
+ if (!data.ok || data.result === undefined) {
134
+ throw new Error(data.description || `Telegram API ${method} failed`);
135
+ }
136
+ return data.result;
137
+ }
138
+
139
+ export async function downloadTelegramFile(
140
+ botToken: string | undefined,
141
+ fileId: string,
142
+ suggestedName: string,
143
+ tempDir: string,
144
+ ): Promise<string> {
145
+ if (!botToken) {
146
+ throw new Error("Telegram bot token is not configured");
147
+ }
148
+ const file = await callTelegram<TelegramGetFileResult>(botToken, "getFile", {
149
+ file_id: fileId,
150
+ });
151
+ await mkdir(tempDir, { recursive: true });
152
+ const targetPath = join(
153
+ tempDir,
154
+ `${Date.now()}-${sanitizeFileName(suggestedName)}`,
155
+ );
156
+ const response = await fetch(
157
+ `https://api.telegram.org/file/bot${botToken}/${file.file_path}`,
158
+ );
159
+ if (!response.ok) {
160
+ throw new Error(`Failed to download Telegram file: ${response.status}`);
161
+ }
162
+ const arrayBuffer = await response.arrayBuffer();
163
+ await writeFile(targetPath, Buffer.from(arrayBuffer));
164
+ return targetPath;
165
+ }
166
+
167
+ export async function answerTelegramCallbackQuery(
168
+ botToken: string | undefined,
169
+ callbackQueryId: string,
170
+ text?: string,
171
+ ): Promise<void> {
172
+ try {
173
+ await callTelegram<boolean>(
174
+ botToken,
175
+ "answerCallbackQuery",
176
+ text
177
+ ? { callback_query_id: callbackQueryId, text }
178
+ : { callback_query_id: callbackQueryId },
179
+ );
180
+ } catch {
181
+ // ignore
182
+ }
183
+ }
184
+
185
+ export function createTelegramApiClient(
186
+ getBotToken: () => string | undefined,
187
+ ): TelegramApiClient {
188
+ return {
189
+ call: async (method, body, options) => {
190
+ return callTelegram(getBotToken(), method, body, options);
191
+ },
192
+ callMultipart: async (
193
+ method,
194
+ fields,
195
+ fileField,
196
+ filePath,
197
+ fileName,
198
+ options,
199
+ ) => {
200
+ return callTelegramMultipart(
201
+ getBotToken(),
202
+ method,
203
+ fields,
204
+ fileField,
205
+ filePath,
206
+ fileName,
207
+ options,
208
+ );
209
+ },
210
+ downloadFile: async (fileId, suggestedName, tempDir) => {
211
+ return downloadTelegramFile(
212
+ getBotToken(),
213
+ fileId,
214
+ suggestedName,
215
+ tempDir,
216
+ );
217
+ },
218
+ answerCallbackQuery: async (callbackQueryId, text) => {
219
+ await answerTelegramCallbackQuery(getBotToken(), callbackQueryId, text);
220
+ },
221
+ };
222
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Telegram attachment domain helpers
3
+ * Owns attachment queueing and attachment delivery so Telegram file output stays in one domain module
4
+ */
5
+
6
+ import { basename } from "node:path";
7
+
8
+ import { guessMediaType } from "./media.ts";
9
+ import type { PendingTelegramTurn } from "./queue.ts";
10
+
11
+ export interface TelegramAttachmentToolResult {
12
+ content: Array<{ type: "text"; text: string }>;
13
+ details: { paths: string[] };
14
+ }
15
+
16
+ export interface TelegramQueuedAttachmentDeliveryDeps {
17
+ sendMultipart: (
18
+ method: string,
19
+ fields: Record<string, string>,
20
+ fileField: string,
21
+ filePath: string,
22
+ fileName: string,
23
+ ) => Promise<unknown>;
24
+ sendTextReply: (
25
+ chatId: number,
26
+ replyToMessageId: number,
27
+ text: string,
28
+ ) => Promise<unknown>;
29
+ }
30
+
31
+ export async function queueTelegramAttachments(options: {
32
+ activeTurn: PendingTelegramTurn | undefined;
33
+ paths: string[];
34
+ maxAttachmentsPerTurn: number;
35
+ statPath: (path: string) => Promise<{ isFile(): boolean }>;
36
+ }): Promise<TelegramAttachmentToolResult> {
37
+ if (!options.activeTurn) {
38
+ throw new Error(
39
+ "telegram_attach can only be used while replying to an active Telegram turn",
40
+ );
41
+ }
42
+ const added: string[] = [];
43
+ for (const inputPath of options.paths) {
44
+ const stats = await options.statPath(inputPath);
45
+ if (!stats.isFile()) {
46
+ throw new Error(`Not a file: ${inputPath}`);
47
+ }
48
+ if (
49
+ options.activeTurn.queuedAttachments.length >=
50
+ options.maxAttachmentsPerTurn
51
+ ) {
52
+ throw new Error(
53
+ `Attachment limit reached (${options.maxAttachmentsPerTurn})`,
54
+ );
55
+ }
56
+ options.activeTurn.queuedAttachments.push({
57
+ path: inputPath,
58
+ fileName: basename(inputPath),
59
+ });
60
+ added.push(inputPath);
61
+ }
62
+ return {
63
+ content: [
64
+ {
65
+ type: "text",
66
+ text: `Queued ${added.length} Telegram attachment(s).`,
67
+ },
68
+ ],
69
+ details: { paths: added },
70
+ };
71
+ }
72
+
73
+ export async function sendQueuedTelegramAttachments(
74
+ turn: PendingTelegramTurn,
75
+ deps: TelegramQueuedAttachmentDeliveryDeps,
76
+ ): Promise<void> {
77
+ for (const attachment of turn.queuedAttachments) {
78
+ try {
79
+ const mediaType = guessMediaType(attachment.path);
80
+ const method = mediaType ? "sendPhoto" : "sendDocument";
81
+ const fieldName = mediaType ? "photo" : "document";
82
+ await deps.sendMultipart(
83
+ method,
84
+ { chat_id: String(turn.chatId) },
85
+ fieldName,
86
+ attachment.path,
87
+ attachment.fileName,
88
+ );
89
+ } catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ await deps.sendTextReply(
92
+ turn.chatId,
93
+ turn.replyToMessageId,
94
+ `Failed to send attachment ${attachment.fileName}: ${message}`,
95
+ );
96
+ }
97
+ }
98
+ }
package/lib/media.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Telegram media and text extraction helpers
3
+ * Normalizes inbound Telegram messages into reusable file, text, id, and history metadata
4
+ */
5
+
6
+ export interface TelegramPhotoSizeLike {
7
+ file_id: string;
8
+ file_size?: number;
9
+ }
10
+
11
+ export interface TelegramDocumentLike {
12
+ file_id: string;
13
+ file_name?: string;
14
+ mime_type?: string;
15
+ }
16
+
17
+ export interface TelegramVideoLike {
18
+ file_id: string;
19
+ file_name?: string;
20
+ mime_type?: string;
21
+ }
22
+
23
+ export interface TelegramAudioLike {
24
+ file_id: string;
25
+ file_name?: string;
26
+ mime_type?: string;
27
+ }
28
+
29
+ export interface TelegramVoiceLike {
30
+ file_id: string;
31
+ mime_type?: string;
32
+ }
33
+
34
+ export interface TelegramAnimationLike {
35
+ file_id: string;
36
+ file_name?: string;
37
+ mime_type?: string;
38
+ }
39
+
40
+ export interface TelegramStickerLike {
41
+ file_id: string;
42
+ }
43
+
44
+ export interface TelegramMessageLike {
45
+ message_id: number;
46
+ text?: string;
47
+ caption?: string;
48
+ photo?: TelegramPhotoSizeLike[];
49
+ document?: TelegramDocumentLike;
50
+ video?: TelegramVideoLike;
51
+ audio?: TelegramAudioLike;
52
+ voice?: TelegramVoiceLike;
53
+ animation?: TelegramAnimationLike;
54
+ sticker?: TelegramStickerLike;
55
+ }
56
+
57
+ export interface TelegramFileInfo {
58
+ file_id: string;
59
+ fileName: string;
60
+ mimeType?: string;
61
+ isImage: boolean;
62
+ }
63
+
64
+ export interface DownloadedTelegramFileLike {
65
+ path: string;
66
+ }
67
+
68
+ export function guessExtensionFromMime(
69
+ mimeType: string | undefined,
70
+ fallback: string,
71
+ ): string {
72
+ if (!mimeType) return fallback;
73
+ const normalized = mimeType.toLowerCase();
74
+ if (normalized === "image/jpeg") return ".jpg";
75
+ if (normalized === "image/png") return ".png";
76
+ if (normalized === "image/webp") return ".webp";
77
+ if (normalized === "image/gif") return ".gif";
78
+ if (normalized === "audio/ogg") return ".ogg";
79
+ if (normalized === "audio/mpeg") return ".mp3";
80
+ if (normalized === "audio/wav") return ".wav";
81
+ if (normalized === "video/mp4") return ".mp4";
82
+ if (normalized === "application/pdf") return ".pdf";
83
+ return fallback;
84
+ }
85
+
86
+ export function guessMediaType(path: string): string | undefined {
87
+ const normalized = path.toLowerCase();
88
+ if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) {
89
+ return "image/jpeg";
90
+ }
91
+ if (normalized.endsWith(".png")) return "image/png";
92
+ if (normalized.endsWith(".webp")) return "image/webp";
93
+ if (normalized.endsWith(".gif")) return "image/gif";
94
+ return undefined;
95
+ }
96
+
97
+ export function isImageMimeType(mimeType: string | undefined): boolean {
98
+ return mimeType?.toLowerCase().startsWith("image/") ?? false;
99
+ }
100
+
101
+ export function extractTelegramMessageText(
102
+ message: TelegramMessageLike,
103
+ ): string {
104
+ return (message.text || message.caption || "").trim();
105
+ }
106
+
107
+ export function extractTelegramMessagesText(
108
+ messages: TelegramMessageLike[],
109
+ ): string {
110
+ return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
111
+ }
112
+
113
+ export function extractFirstTelegramMessageText(
114
+ messages: TelegramMessageLike[],
115
+ ): string {
116
+ return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
117
+ }
118
+
119
+ export function collectTelegramMessageIds(
120
+ messages: TelegramMessageLike[],
121
+ ): number[] {
122
+ return [...new Set(messages.map((message) => message.message_id))];
123
+ }
124
+
125
+ export function formatTelegramHistoryText(
126
+ rawText: string,
127
+ files: DownloadedTelegramFileLike[],
128
+ ): string {
129
+ let summary = rawText.length > 0 ? rawText : "(no text)";
130
+ if (files.length > 0) {
131
+ summary += `\nAttachments:`;
132
+ for (const file of files) {
133
+ summary += `\n- ${file.path}`;
134
+ }
135
+ }
136
+ return summary;
137
+ }
138
+
139
+ export function collectTelegramFileInfos(
140
+ messages: TelegramMessageLike[],
141
+ ): TelegramFileInfo[] {
142
+ const files: TelegramFileInfo[] = [];
143
+ for (const message of messages) {
144
+ if (Array.isArray(message.photo) && message.photo.length > 0) {
145
+ const photo = [...message.photo]
146
+ .sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
147
+ .pop();
148
+ if (photo) {
149
+ files.push({
150
+ file_id: photo.file_id,
151
+ fileName: `photo-${message.message_id}.jpg`,
152
+ mimeType: "image/jpeg",
153
+ isImage: true,
154
+ });
155
+ }
156
+ }
157
+ if (message.document) {
158
+ const fileName =
159
+ message.document.file_name ||
160
+ `document-${message.message_id}${guessExtensionFromMime(
161
+ message.document.mime_type,
162
+ "",
163
+ )}`;
164
+ files.push({
165
+ file_id: message.document.file_id,
166
+ fileName,
167
+ mimeType: message.document.mime_type,
168
+ isImage: isImageMimeType(message.document.mime_type),
169
+ });
170
+ }
171
+ if (message.video) {
172
+ const fileName =
173
+ message.video.file_name ||
174
+ `video-${message.message_id}${guessExtensionFromMime(
175
+ message.video.mime_type,
176
+ ".mp4",
177
+ )}`;
178
+ files.push({
179
+ file_id: message.video.file_id,
180
+ fileName,
181
+ mimeType: message.video.mime_type,
182
+ isImage: false,
183
+ });
184
+ }
185
+ if (message.audio) {
186
+ const fileName =
187
+ message.audio.file_name ||
188
+ `audio-${message.message_id}${guessExtensionFromMime(
189
+ message.audio.mime_type,
190
+ ".mp3",
191
+ )}`;
192
+ files.push({
193
+ file_id: message.audio.file_id,
194
+ fileName,
195
+ mimeType: message.audio.mime_type,
196
+ isImage: false,
197
+ });
198
+ }
199
+ if (message.voice) {
200
+ files.push({
201
+ file_id: message.voice.file_id,
202
+ fileName: `voice-${message.message_id}${guessExtensionFromMime(
203
+ message.voice.mime_type,
204
+ ".ogg",
205
+ )}`,
206
+ mimeType: message.voice.mime_type,
207
+ isImage: false,
208
+ });
209
+ }
210
+ if (message.animation) {
211
+ const fileName =
212
+ message.animation.file_name ||
213
+ `animation-${message.message_id}${guessExtensionFromMime(
214
+ message.animation.mime_type,
215
+ ".mp4",
216
+ )}`;
217
+ files.push({
218
+ file_id: message.animation.file_id,
219
+ fileName,
220
+ mimeType: message.animation.mime_type,
221
+ isImage: false,
222
+ });
223
+ }
224
+ if (message.sticker) {
225
+ files.push({
226
+ file_id: message.sticker.file_id,
227
+ fileName: `sticker-${message.message_id}.webp`,
228
+ mimeType: "image/webp",
229
+ isImage: true,
230
+ });
231
+ }
232
+ }
233
+ return files;
234
+ }