@poncho-ai/messaging 0.2.8 → 0.3.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/.turbo/turbo-build.log +5 -5
- package/.turbo/turbo-test.log +29 -0
- package/CHANGELOG.md +17 -0
- package/dist/index.d.ts +31 -1
- package/dist/index.js +453 -5
- package/package.json +3 -2
- package/src/adapters/telegram/index.ts +344 -0
- package/src/adapters/telegram/utils.ts +369 -0
- package/src/adapters/telegram/verify.ts +18 -0
- package/src/bridge.ts +19 -5
- package/src/index.ts +2 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type http from "node:http";
|
|
2
|
+
import type {
|
|
3
|
+
FileAttachment,
|
|
4
|
+
IncomingMessage as PonchoIncomingMessage,
|
|
5
|
+
IncomingMessageHandler,
|
|
6
|
+
MessagingAdapter,
|
|
7
|
+
RouteRegistrar,
|
|
8
|
+
ThreadRef,
|
|
9
|
+
} from "../../types.js";
|
|
10
|
+
import { verifyTelegramSecret } from "./verify.js";
|
|
11
|
+
import {
|
|
12
|
+
type TelegramMessage,
|
|
13
|
+
type TelegramUpdate,
|
|
14
|
+
downloadFile,
|
|
15
|
+
getFile,
|
|
16
|
+
getMe,
|
|
17
|
+
isBotMentioned,
|
|
18
|
+
sendChatAction,
|
|
19
|
+
sendDocument,
|
|
20
|
+
sendMessage,
|
|
21
|
+
sendPhoto,
|
|
22
|
+
splitMessage,
|
|
23
|
+
stripMention,
|
|
24
|
+
} from "./utils.js";
|
|
25
|
+
|
|
26
|
+
const TYPING_INTERVAL_MS = 4_000;
|
|
27
|
+
const NEW_COMMAND_RE = /^\/new(?:@(\S+))?$/i;
|
|
28
|
+
|
|
29
|
+
export interface TelegramAdapterOptions {
|
|
30
|
+
botTokenEnv?: string;
|
|
31
|
+
webhookSecretEnv?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const collectBody = (req: http.IncomingMessage): Promise<string> =>
|
|
35
|
+
new Promise((resolve, reject) => {
|
|
36
|
+
const chunks: Buffer[] = [];
|
|
37
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
38
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
39
|
+
req.on("error", reject);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export class TelegramAdapter implements MessagingAdapter {
|
|
43
|
+
readonly platform = "telegram" as const;
|
|
44
|
+
readonly autoReply = true;
|
|
45
|
+
readonly hasSentInCurrentRequest = false;
|
|
46
|
+
|
|
47
|
+
private botToken = "";
|
|
48
|
+
private webhookSecret = "";
|
|
49
|
+
private botUsername = "";
|
|
50
|
+
private botId = 0;
|
|
51
|
+
private readonly botTokenEnv: string;
|
|
52
|
+
private readonly webhookSecretEnv: string;
|
|
53
|
+
private handler: IncomingMessageHandler | undefined;
|
|
54
|
+
private readonly sessionCounters = new Map<string, number>();
|
|
55
|
+
private lastUpdateId = 0;
|
|
56
|
+
|
|
57
|
+
constructor(options: TelegramAdapterOptions = {}) {
|
|
58
|
+
this.botTokenEnv = options.botTokenEnv ?? "TELEGRAM_BOT_TOKEN";
|
|
59
|
+
this.webhookSecretEnv =
|
|
60
|
+
options.webhookSecretEnv ?? "TELEGRAM_WEBHOOK_SECRET";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
// MessagingAdapter implementation
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
async initialize(): Promise<void> {
|
|
68
|
+
this.botToken = process.env[this.botTokenEnv] ?? "";
|
|
69
|
+
this.webhookSecret = process.env[this.webhookSecretEnv] ?? "";
|
|
70
|
+
|
|
71
|
+
if (!this.botToken) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Telegram messaging: ${this.botTokenEnv} environment variable is not set`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const me = await getMe(this.botToken);
|
|
78
|
+
this.botUsername = me.username;
|
|
79
|
+
this.botId = me.id;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
onMessage(handler: IncomingMessageHandler): void {
|
|
83
|
+
this.handler = handler;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
registerRoutes(router: RouteRegistrar): void {
|
|
87
|
+
router("POST", "/api/messaging/telegram", (req, res) =>
|
|
88
|
+
this.handleRequest(req, res),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async sendReply(
|
|
93
|
+
threadRef: ThreadRef,
|
|
94
|
+
content: string,
|
|
95
|
+
options?: { files?: FileAttachment[] },
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const chatId = threadRef.channelId;
|
|
98
|
+
const replyTo = threadRef.messageId
|
|
99
|
+
? Number(threadRef.messageId)
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
102
|
+
if (content) {
|
|
103
|
+
const chunks = splitMessage(content);
|
|
104
|
+
for (const chunk of chunks) {
|
|
105
|
+
await sendMessage(this.botToken, chatId, chunk, {
|
|
106
|
+
reply_to_message_id: replyTo,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options?.files) {
|
|
112
|
+
for (const file of options.files) {
|
|
113
|
+
if (file.mediaType.startsWith("image/")) {
|
|
114
|
+
await sendPhoto(this.botToken, chatId, file.data, {
|
|
115
|
+
reply_to_message_id: replyTo,
|
|
116
|
+
filename: file.filename,
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
await sendDocument(this.botToken, chatId, file.data, {
|
|
120
|
+
reply_to_message_id: replyTo,
|
|
121
|
+
filename: file.filename,
|
|
122
|
+
mediaType: file.mediaType,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async indicateProcessing(
|
|
130
|
+
threadRef: ThreadRef,
|
|
131
|
+
): Promise<() => Promise<void>> {
|
|
132
|
+
const chatId = threadRef.channelId;
|
|
133
|
+
|
|
134
|
+
await sendChatAction(this.botToken, chatId, "typing");
|
|
135
|
+
|
|
136
|
+
const interval = setInterval(() => {
|
|
137
|
+
void sendChatAction(this.botToken, chatId, "typing").catch(() => {});
|
|
138
|
+
}, TYPING_INTERVAL_MS);
|
|
139
|
+
|
|
140
|
+
return async () => {
|
|
141
|
+
clearInterval(interval);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
146
|
+
// HTTP request handling
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
private async handleRequest(
|
|
150
|
+
req: http.IncomingMessage,
|
|
151
|
+
res: http.ServerResponse,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const rawBody = await collectBody(req);
|
|
154
|
+
|
|
155
|
+
// -- Secret verification ----------------------------------------------
|
|
156
|
+
if (this.webhookSecret) {
|
|
157
|
+
const headerSecret = req.headers[
|
|
158
|
+
"x-telegram-bot-api-secret-token"
|
|
159
|
+
] as string | undefined;
|
|
160
|
+
if (!verifyTelegramSecret(this.webhookSecret, headerSecret)) {
|
|
161
|
+
res.writeHead(401);
|
|
162
|
+
res.end("Invalid secret");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let payload: TelegramUpdate;
|
|
168
|
+
try {
|
|
169
|
+
payload = JSON.parse(rawBody) as TelegramUpdate;
|
|
170
|
+
} catch {
|
|
171
|
+
res.writeHead(400);
|
|
172
|
+
res.end("Invalid JSON");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -- Update deduplication -----------------------------------------------
|
|
177
|
+
if (payload.update_id <= this.lastUpdateId) {
|
|
178
|
+
res.writeHead(200);
|
|
179
|
+
res.end();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.lastUpdateId = payload.update_id;
|
|
183
|
+
|
|
184
|
+
const message = payload.message;
|
|
185
|
+
if (!message) {
|
|
186
|
+
res.writeHead(200);
|
|
187
|
+
res.end();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const text = message.text ?? message.caption ?? "";
|
|
192
|
+
const hasFiles = !!(message.photo || message.document);
|
|
193
|
+
|
|
194
|
+
if (!text && !hasFiles) {
|
|
195
|
+
res.writeHead(200);
|
|
196
|
+
res.end();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const chatId = String(message.chat.id);
|
|
201
|
+
const chatType = message.chat.type;
|
|
202
|
+
const isGroup = chatType === "group" || chatType === "supergroup";
|
|
203
|
+
const entities = message.entities ?? message.caption_entities;
|
|
204
|
+
|
|
205
|
+
// -- /new command -----------------------------------------------------
|
|
206
|
+
const newMatch = text.match(NEW_COMMAND_RE);
|
|
207
|
+
if (newMatch) {
|
|
208
|
+
const suffix = newMatch[1];
|
|
209
|
+
if (
|
|
210
|
+
isGroup &&
|
|
211
|
+
suffix &&
|
|
212
|
+
suffix.toLowerCase() !== this.botUsername.toLowerCase()
|
|
213
|
+
) {
|
|
214
|
+
res.writeHead(200);
|
|
215
|
+
res.end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const key = this.sessionKey(message);
|
|
220
|
+
const current = this.sessionCounters.get(key) ?? 0;
|
|
221
|
+
this.sessionCounters.set(key, current + 1);
|
|
222
|
+
|
|
223
|
+
res.writeHead(200);
|
|
224
|
+
res.end();
|
|
225
|
+
|
|
226
|
+
await sendMessage(
|
|
227
|
+
this.botToken,
|
|
228
|
+
chatId,
|
|
229
|
+
"Conversation reset. Send a new message to start fresh.",
|
|
230
|
+
{
|
|
231
|
+
reply_to_message_id: message.message_id,
|
|
232
|
+
message_thread_id: message.message_thread_id,
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// -- Group mention filter ---------------------------------------------
|
|
239
|
+
if (isGroup) {
|
|
240
|
+
if (!isBotMentioned(entities, this.botUsername, this.botId, text)) {
|
|
241
|
+
res.writeHead(200);
|
|
242
|
+
res.end();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const cleanText = isGroup
|
|
248
|
+
? stripMention(text, entities, this.botUsername, this.botId)
|
|
249
|
+
: text;
|
|
250
|
+
|
|
251
|
+
if (!cleanText && !hasFiles) {
|
|
252
|
+
res.writeHead(200);
|
|
253
|
+
res.end();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Acknowledge immediately so Telegram doesn't retry.
|
|
258
|
+
res.writeHead(200);
|
|
259
|
+
res.end();
|
|
260
|
+
|
|
261
|
+
if (!this.handler) return;
|
|
262
|
+
|
|
263
|
+
// -- File extraction --------------------------------------------------
|
|
264
|
+
const files = await this.extractFiles(message);
|
|
265
|
+
|
|
266
|
+
// -- Build thread ref -------------------------------------------------
|
|
267
|
+
const key = this.sessionKey(message);
|
|
268
|
+
const session = this.sessionCounters.get(key) ?? 0;
|
|
269
|
+
const topicId = message.message_thread_id;
|
|
270
|
+
const platformThreadId = topicId
|
|
271
|
+
? `${chatId}:${topicId}:${session}`
|
|
272
|
+
: `${chatId}:${session}`;
|
|
273
|
+
|
|
274
|
+
const userId = String(message.from?.id ?? "unknown");
|
|
275
|
+
const userName =
|
|
276
|
+
[message.from?.first_name, message.from?.last_name]
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.join(" ") || undefined;
|
|
279
|
+
|
|
280
|
+
const ponchoMessage: PonchoIncomingMessage = {
|
|
281
|
+
text: cleanText,
|
|
282
|
+
files: files.length > 0 ? files : undefined,
|
|
283
|
+
threadRef: {
|
|
284
|
+
platformThreadId,
|
|
285
|
+
channelId: chatId,
|
|
286
|
+
messageId: String(message.message_id),
|
|
287
|
+
},
|
|
288
|
+
sender: { id: userId, name: userName },
|
|
289
|
+
platform: "telegram",
|
|
290
|
+
raw: message,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
void this.handler(ponchoMessage).catch((err) => {
|
|
294
|
+
console.error(
|
|
295
|
+
"[telegram-adapter] unhandled message handler error",
|
|
296
|
+
err,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// -----------------------------------------------------------------------
|
|
302
|
+
// Helpers
|
|
303
|
+
// -----------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
private sessionKey(message: TelegramMessage): string {
|
|
306
|
+
const chatId = String(message.chat.id);
|
|
307
|
+
return message.message_thread_id
|
|
308
|
+
? `${chatId}:${message.message_thread_id}`
|
|
309
|
+
: chatId;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async extractFiles(
|
|
313
|
+
message: TelegramMessage,
|
|
314
|
+
): Promise<FileAttachment[]> {
|
|
315
|
+
const files: FileAttachment[] = [];
|
|
316
|
+
try {
|
|
317
|
+
if (message.photo && message.photo.length > 0) {
|
|
318
|
+
const largest = message.photo[message.photo.length - 1]!;
|
|
319
|
+
const filePath = await getFile(this.botToken, largest.file_id);
|
|
320
|
+
const { data } = await downloadFile(this.botToken, filePath);
|
|
321
|
+
files.push({ data, mediaType: "image/jpeg", filename: "photo.jpg" });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (message.document) {
|
|
325
|
+
const filePath = await getFile(
|
|
326
|
+
this.botToken,
|
|
327
|
+
message.document.file_id,
|
|
328
|
+
);
|
|
329
|
+
const downloaded = await downloadFile(this.botToken, filePath);
|
|
330
|
+
files.push({
|
|
331
|
+
data: downloaded.data,
|
|
332
|
+
mediaType: message.document.mime_type ?? downloaded.mediaType,
|
|
333
|
+
filename: message.document.file_name,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.warn(
|
|
338
|
+
"[telegram-adapter] failed to download file:",
|
|
339
|
+
err instanceof Error ? err.message : err,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return files;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
const TELEGRAM_API = "https://api.telegram.org";
|
|
2
|
+
const TELEGRAM_MAX_MESSAGE_LENGTH = 4096;
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Telegram Bot API object types (subset used by the adapter)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface TelegramEntity {
|
|
9
|
+
type: string;
|
|
10
|
+
offset: number;
|
|
11
|
+
length: number;
|
|
12
|
+
user?: { id: number; username?: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TelegramUser {
|
|
16
|
+
id: number;
|
|
17
|
+
is_bot: boolean;
|
|
18
|
+
first_name: string;
|
|
19
|
+
last_name?: string;
|
|
20
|
+
username?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TelegramChat {
|
|
24
|
+
id: number;
|
|
25
|
+
type: "private" | "group" | "supergroup" | "channel";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TelegramPhotoSize {
|
|
29
|
+
file_id: string;
|
|
30
|
+
file_unique_id: string;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
file_size?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TelegramDocument {
|
|
37
|
+
file_id: string;
|
|
38
|
+
file_unique_id: string;
|
|
39
|
+
file_name?: string;
|
|
40
|
+
mime_type?: string;
|
|
41
|
+
file_size?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TelegramMessage {
|
|
45
|
+
message_id: number;
|
|
46
|
+
from?: TelegramUser;
|
|
47
|
+
chat: TelegramChat;
|
|
48
|
+
date: number;
|
|
49
|
+
text?: string;
|
|
50
|
+
caption?: string;
|
|
51
|
+
entities?: TelegramEntity[];
|
|
52
|
+
caption_entities?: TelegramEntity[];
|
|
53
|
+
photo?: TelegramPhotoSize[];
|
|
54
|
+
document?: TelegramDocument;
|
|
55
|
+
message_thread_id?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TelegramUpdate {
|
|
59
|
+
update_id: number;
|
|
60
|
+
message?: TelegramMessage;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Low-level fetch helpers
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
interface TelegramApiResult {
|
|
68
|
+
ok: boolean;
|
|
69
|
+
result?: unknown;
|
|
70
|
+
description?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const telegramFetch = async (
|
|
74
|
+
token: string,
|
|
75
|
+
method: string,
|
|
76
|
+
body: Record<string, unknown>,
|
|
77
|
+
): Promise<TelegramApiResult> => {
|
|
78
|
+
const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
});
|
|
83
|
+
return (await res.json()) as TelegramApiResult;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const telegramUpload = async (
|
|
87
|
+
token: string,
|
|
88
|
+
method: string,
|
|
89
|
+
formData: FormData,
|
|
90
|
+
): Promise<TelegramApiResult> => {
|
|
91
|
+
const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: formData,
|
|
94
|
+
});
|
|
95
|
+
return (await res.json()) as TelegramApiResult;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Bot info
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
export const getMe = async (
|
|
103
|
+
token: string,
|
|
104
|
+
): Promise<{ id: number; username: string }> => {
|
|
105
|
+
const result = await telegramFetch(token, "getMe", {});
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
throw new Error(`Telegram getMe failed: ${result.description}`);
|
|
108
|
+
}
|
|
109
|
+
const user = result.result as TelegramUser;
|
|
110
|
+
return { id: user.id, username: user.username ?? "" };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// File download
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export const getFile = async (
|
|
118
|
+
token: string,
|
|
119
|
+
fileId: string,
|
|
120
|
+
): Promise<string> => {
|
|
121
|
+
const result = await telegramFetch(token, "getFile", { file_id: fileId });
|
|
122
|
+
if (!result.ok) {
|
|
123
|
+
throw new Error(`Telegram getFile failed: ${result.description}`);
|
|
124
|
+
}
|
|
125
|
+
const file = result.result as { file_id: string; file_path?: string };
|
|
126
|
+
if (!file.file_path) {
|
|
127
|
+
throw new Error("Telegram getFile: no file_path returned");
|
|
128
|
+
}
|
|
129
|
+
return file.file_path;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const EXTENSION_MEDIA_TYPES: Record<string, string> = {
|
|
133
|
+
jpg: "image/jpeg",
|
|
134
|
+
jpeg: "image/jpeg",
|
|
135
|
+
png: "image/png",
|
|
136
|
+
gif: "image/gif",
|
|
137
|
+
webp: "image/webp",
|
|
138
|
+
pdf: "application/pdf",
|
|
139
|
+
mp4: "video/mp4",
|
|
140
|
+
ogg: "audio/ogg",
|
|
141
|
+
mp3: "audio/mpeg",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const inferMediaType = (filePath: string, header: string | null): string => {
|
|
145
|
+
if (header && header !== "application/octet-stream") return header;
|
|
146
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
147
|
+
if (ext && EXTENSION_MEDIA_TYPES[ext]) return EXTENSION_MEDIA_TYPES[ext];
|
|
148
|
+
return header ?? "application/octet-stream";
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const downloadFile = async (
|
|
152
|
+
token: string,
|
|
153
|
+
filePath: string,
|
|
154
|
+
): Promise<{ data: string; mediaType: string }> => {
|
|
155
|
+
const url = `${TELEGRAM_API}/file/bot${token}/${filePath}`;
|
|
156
|
+
const res = await fetch(url);
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
throw new Error(`Telegram file download failed: ${res.status}`);
|
|
159
|
+
}
|
|
160
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
161
|
+
const mediaType = inferMediaType(filePath, res.headers.get("content-type"));
|
|
162
|
+
return { data: buffer.toString("base64"), mediaType };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Sending messages
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export const sendMessage = async (
|
|
170
|
+
token: string,
|
|
171
|
+
chatId: number | string,
|
|
172
|
+
text: string,
|
|
173
|
+
opts?: { reply_to_message_id?: number; message_thread_id?: number },
|
|
174
|
+
): Promise<void> => {
|
|
175
|
+
const body: Record<string, unknown> = { chat_id: chatId, text };
|
|
176
|
+
if (opts?.reply_to_message_id) {
|
|
177
|
+
body.reply_parameters = {
|
|
178
|
+
message_id: opts.reply_to_message_id,
|
|
179
|
+
allow_sending_without_reply: true,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (opts?.message_thread_id) {
|
|
183
|
+
body.message_thread_id = opts.message_thread_id;
|
|
184
|
+
}
|
|
185
|
+
const result = await telegramFetch(token, "sendMessage", body);
|
|
186
|
+
if (!result.ok) {
|
|
187
|
+
throw new Error(`Telegram sendMessage failed: ${result.description}`);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export const sendPhoto = async (
|
|
192
|
+
token: string,
|
|
193
|
+
chatId: number | string,
|
|
194
|
+
photoData: string,
|
|
195
|
+
opts?: {
|
|
196
|
+
caption?: string;
|
|
197
|
+
reply_to_message_id?: number;
|
|
198
|
+
message_thread_id?: number;
|
|
199
|
+
filename?: string;
|
|
200
|
+
},
|
|
201
|
+
): Promise<void> => {
|
|
202
|
+
const formData = new FormData();
|
|
203
|
+
formData.append("chat_id", String(chatId));
|
|
204
|
+
const blob = new Blob([Buffer.from(photoData, "base64")]);
|
|
205
|
+
formData.append("photo", blob, opts?.filename ?? "photo.jpg");
|
|
206
|
+
if (opts?.caption) formData.append("caption", opts.caption);
|
|
207
|
+
if (opts?.reply_to_message_id) {
|
|
208
|
+
formData.append(
|
|
209
|
+
"reply_parameters",
|
|
210
|
+
JSON.stringify({
|
|
211
|
+
message_id: opts.reply_to_message_id,
|
|
212
|
+
allow_sending_without_reply: true,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (opts?.message_thread_id) {
|
|
217
|
+
formData.append("message_thread_id", String(opts.message_thread_id));
|
|
218
|
+
}
|
|
219
|
+
const result = await telegramUpload(token, "sendPhoto", formData);
|
|
220
|
+
if (!result.ok) {
|
|
221
|
+
throw new Error(`Telegram sendPhoto failed: ${result.description}`);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const sendDocument = async (
|
|
226
|
+
token: string,
|
|
227
|
+
chatId: number | string,
|
|
228
|
+
docData: string,
|
|
229
|
+
opts?: {
|
|
230
|
+
caption?: string;
|
|
231
|
+
reply_to_message_id?: number;
|
|
232
|
+
message_thread_id?: number;
|
|
233
|
+
filename?: string;
|
|
234
|
+
mediaType?: string;
|
|
235
|
+
},
|
|
236
|
+
): Promise<void> => {
|
|
237
|
+
const formData = new FormData();
|
|
238
|
+
formData.append("chat_id", String(chatId));
|
|
239
|
+
const blob = new Blob([Buffer.from(docData, "base64")], {
|
|
240
|
+
type: opts?.mediaType,
|
|
241
|
+
});
|
|
242
|
+
formData.append("document", blob, opts?.filename ?? "file");
|
|
243
|
+
if (opts?.caption) formData.append("caption", opts.caption);
|
|
244
|
+
if (opts?.reply_to_message_id) {
|
|
245
|
+
formData.append(
|
|
246
|
+
"reply_parameters",
|
|
247
|
+
JSON.stringify({
|
|
248
|
+
message_id: opts.reply_to_message_id,
|
|
249
|
+
allow_sending_without_reply: true,
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (opts?.message_thread_id) {
|
|
254
|
+
formData.append("message_thread_id", String(opts.message_thread_id));
|
|
255
|
+
}
|
|
256
|
+
const result = await telegramUpload(token, "sendDocument", formData);
|
|
257
|
+
if (!result.ok) {
|
|
258
|
+
throw new Error(`Telegram sendDocument failed: ${result.description}`);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Typing indicator
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export const sendChatAction = async (
|
|
267
|
+
token: string,
|
|
268
|
+
chatId: number | string,
|
|
269
|
+
action: string,
|
|
270
|
+
): Promise<void> => {
|
|
271
|
+
await telegramFetch(token, "sendChatAction", {
|
|
272
|
+
chat_id: chatId,
|
|
273
|
+
action,
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Message splitting (same pattern as Slack, adapted for 4096 limit)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
export const splitMessage = (text: string): string[] => {
|
|
282
|
+
if (text.length <= TELEGRAM_MAX_MESSAGE_LENGTH) return [text];
|
|
283
|
+
|
|
284
|
+
const chunks: string[] = [];
|
|
285
|
+
let remaining = text;
|
|
286
|
+
|
|
287
|
+
while (remaining.length > 0) {
|
|
288
|
+
if (remaining.length <= TELEGRAM_MAX_MESSAGE_LENGTH) {
|
|
289
|
+
chunks.push(remaining);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let cutPoint = remaining.lastIndexOf(
|
|
294
|
+
"\n",
|
|
295
|
+
TELEGRAM_MAX_MESSAGE_LENGTH,
|
|
296
|
+
);
|
|
297
|
+
if (cutPoint <= 0) {
|
|
298
|
+
cutPoint = TELEGRAM_MAX_MESSAGE_LENGTH;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
chunks.push(remaining.slice(0, cutPoint));
|
|
302
|
+
remaining = remaining.slice(cutPoint).replace(/^\n/, "");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return chunks;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Mention detection & stripping
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Check whether the bot is mentioned in a message's entities.
|
|
314
|
+
* Works with both `@username` mentions and `text_mention` entities.
|
|
315
|
+
*/
|
|
316
|
+
export const isBotMentioned = (
|
|
317
|
+
entities: TelegramEntity[] | undefined,
|
|
318
|
+
botUsername: string,
|
|
319
|
+
botId: number,
|
|
320
|
+
text: string,
|
|
321
|
+
): boolean => {
|
|
322
|
+
if (!entities || entities.length === 0) return false;
|
|
323
|
+
const lower = botUsername.toLowerCase();
|
|
324
|
+
|
|
325
|
+
for (const entity of entities) {
|
|
326
|
+
if (entity.type === "mention") {
|
|
327
|
+
const mentioned = text.slice(entity.offset, entity.offset + entity.length);
|
|
328
|
+
if (mentioned.toLowerCase() === `@${lower}`) return true;
|
|
329
|
+
}
|
|
330
|
+
if (entity.type === "text_mention" && entity.user?.id === botId) {
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return false;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Remove the first bot mention from the message text, using entity
|
|
340
|
+
* offsets for accuracy. Falls back to regex if no entity matches.
|
|
341
|
+
*/
|
|
342
|
+
export const stripMention = (
|
|
343
|
+
text: string,
|
|
344
|
+
entities: TelegramEntity[] | undefined,
|
|
345
|
+
botUsername: string,
|
|
346
|
+
botId: number,
|
|
347
|
+
): string => {
|
|
348
|
+
if (!entities || entities.length === 0) return text.trim();
|
|
349
|
+
const lower = botUsername.toLowerCase();
|
|
350
|
+
|
|
351
|
+
for (const entity of entities) {
|
|
352
|
+
let match = false;
|
|
353
|
+
if (entity.type === "mention") {
|
|
354
|
+
const mentioned = text.slice(entity.offset, entity.offset + entity.length);
|
|
355
|
+
if (mentioned.toLowerCase() === `@${lower}`) match = true;
|
|
356
|
+
}
|
|
357
|
+
if (entity.type === "text_mention" && entity.user?.id === botId) {
|
|
358
|
+
match = true;
|
|
359
|
+
}
|
|
360
|
+
if (match) {
|
|
361
|
+
return (
|
|
362
|
+
text.slice(0, entity.offset) +
|
|
363
|
+
text.slice(entity.offset + entity.length)
|
|
364
|
+
).trim();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return text.trim();
|
|
369
|
+
};
|