@unlinearity/cli-wechat-bridge 1.2.2 → 1.2.3

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,378 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
3
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
4
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
5
+ // must be released under AGPL-3.0-or-later with full source code; see
6
+ // LICENSE.txt. Network services built on it must offer source to users.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { tryClaimChannelInboundMessage } from "../../core/inbound-message-claims.js";
10
+ import { truncatePreview } from "../../core/text-utils.js";
11
+ import { TELEGRAM_INBOUND_ATTACHMENTS_DIR, TELEGRAM_INBOUND_MESSAGE_CLAIMS_DIR, } from "./telegram-config.js";
12
+ const TELEGRAM_API_BASE = "https://api.telegram.org";
13
+ const TELEGRAM_TEXT_CHUNK_CHARS = 3_900;
14
+ const TELEGRAM_LONG_POLL_TIMEOUT_S = 50;
15
+ const TELEGRAM_TYPING_REFRESH_MS = 4_000;
16
+ const TELEGRAM_DOWNLOAD_MAX_BYTES = 20 * 1024 * 1024;
17
+ export class TelegramTokenRejectedError extends Error {
18
+ constructor(description) {
19
+ super(`Telegram rejected the bot token: ${description}`);
20
+ this.name = "TelegramTokenRejectedError";
21
+ }
22
+ }
23
+ export class TelegramPollConflictError extends Error {
24
+ constructor() {
25
+ super("Another process is polling the same Telegram bot (409 conflict). Only one bridge per bot token may run.");
26
+ this.name = "TelegramPollConflictError";
27
+ }
28
+ }
29
+ function isRecord(value) {
30
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+ function splitText(text, maxChars = TELEGRAM_TEXT_CHUNK_CHARS) {
33
+ if (text.length <= maxChars) {
34
+ return [text];
35
+ }
36
+ const chunks = [];
37
+ let current = "";
38
+ for (const character of text) {
39
+ if (current && current.length + 1 > maxChars) {
40
+ chunks.push(current);
41
+ current = "";
42
+ }
43
+ current += character;
44
+ }
45
+ if (current) {
46
+ chunks.push(current);
47
+ }
48
+ return chunks;
49
+ }
50
+ function chatTypeOf(chat) {
51
+ return typeof chat.type === "string" ? chat.type : "private";
52
+ }
53
+ function inferAttachmentKind(fileName, mimeType) {
54
+ if (mimeType?.startsWith("image/"))
55
+ return "image";
56
+ if (mimeType?.startsWith("video/"))
57
+ return "video";
58
+ if (mimeType?.startsWith("audio/"))
59
+ return "voice";
60
+ const ext = path.extname(fileName).toLowerCase();
61
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext))
62
+ return "image";
63
+ if ([".mp4", ".mov", ".webm"].includes(ext))
64
+ return "video";
65
+ if ([".ogg", ".oga", ".mp3", ".m4a", ".opus"].includes(ext))
66
+ return "voice";
67
+ return "file";
68
+ }
69
+ /** Long-poll Bot API transport for the Telegram channel. */
70
+ export class TelegramTransport {
71
+ account;
72
+ logger;
73
+ claimsDir;
74
+ attachmentsDir;
75
+ fetchImpl;
76
+ handlers = null;
77
+ polling = false;
78
+ pollAbort = null;
79
+ updateOffset = 0;
80
+ typingTimers = new Map();
81
+ connectedNotified = false;
82
+ constructor(options) {
83
+ this.account = options.account;
84
+ this.logger = options.logger ?? {};
85
+ this.claimsDir = options.claimsDir ?? TELEGRAM_INBOUND_MESSAGE_CLAIMS_DIR;
86
+ this.attachmentsDir = options.attachmentsDir ?? TELEGRAM_INBOUND_ATTACHMENTS_DIR;
87
+ this.fetchImpl = options.fetchImpl ?? fetch;
88
+ }
89
+ setHandlers(handlers) {
90
+ this.handlers = handlers;
91
+ }
92
+ get isConnected() {
93
+ return this.polling;
94
+ }
95
+ async start() {
96
+ if (this.polling) {
97
+ return;
98
+ }
99
+ this.polling = true;
100
+ void this.runPollLoop();
101
+ }
102
+ async stop() {
103
+ this.polling = false;
104
+ this.pollAbort?.abort();
105
+ this.pollAbort = null;
106
+ this.endAllTyping();
107
+ }
108
+ apiUrl(method) {
109
+ return `${TELEGRAM_API_BASE}/bot${this.account.botToken}/${method}`;
110
+ }
111
+ async callApi(method, body, signal) {
112
+ const response = await this.fetchImpl(this.apiUrl(method), {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify(body),
116
+ signal,
117
+ });
118
+ const payload = (await response.json().catch(() => null));
119
+ if (!payload) {
120
+ throw new Error(`Telegram ${method} returned a non-JSON response (HTTP ${response.status}).`);
121
+ }
122
+ return payload;
123
+ }
124
+ async callApiExpectOk(method, body, signal) {
125
+ const payload = await this.callApi(method, body, signal);
126
+ if (!payload.ok) {
127
+ if (payload.error_code === 401) {
128
+ throw new TelegramTokenRejectedError(payload.description ?? "unauthorized");
129
+ }
130
+ if (payload.error_code === 409) {
131
+ throw new TelegramPollConflictError();
132
+ }
133
+ throw new Error(`Telegram ${method} failed: ${payload.description ?? `error ${payload.error_code ?? "unknown"}`}`);
134
+ }
135
+ return payload.result;
136
+ }
137
+ /** Validate the bot token and return the bot's username. */
138
+ async validateToken() {
139
+ const result = await this.callApiExpectOk("getMe", {});
140
+ if (!isRecord(result) || typeof result.username !== "string") {
141
+ throw new Error("Telegram getMe returned an unexpected payload.");
142
+ }
143
+ return result.username;
144
+ }
145
+ async runPollLoop() {
146
+ while (this.polling) {
147
+ this.pollAbort = new AbortController();
148
+ try {
149
+ const updates = await this.callApiExpectOk("getUpdates", {
150
+ offset: this.updateOffset,
151
+ timeout: TELEGRAM_LONG_POLL_TIMEOUT_S,
152
+ allowed_updates: ["message"],
153
+ }, this.pollAbort.signal);
154
+ if (!this.connectedNotified) {
155
+ this.connectedNotified = true;
156
+ await this.handlers?.onConnected?.();
157
+ }
158
+ if (!Array.isArray(updates)) {
159
+ continue;
160
+ }
161
+ for (const update of updates) {
162
+ if (!isRecord(update))
163
+ continue;
164
+ if (typeof update.update_id === "number") {
165
+ this.updateOffset = update.update_id + 1;
166
+ }
167
+ await this.handleUpdate(update);
168
+ }
169
+ }
170
+ catch (error) {
171
+ if (!this.polling) {
172
+ return;
173
+ }
174
+ if (error instanceof TelegramTokenRejectedError) {
175
+ this.polling = false;
176
+ this.logger.error?.(error.message);
177
+ await this.handlers?.onFatal?.(error);
178
+ return;
179
+ }
180
+ this.logger.error?.(`telegram_poll_error: ${error instanceof Error ? error.message : String(error)}`);
181
+ await new Promise((resolve) => setTimeout(resolve, 3_000));
182
+ }
183
+ }
184
+ }
185
+ async handleUpdate(update) {
186
+ const message = isRecord(update.message) ? update.message : null;
187
+ if (!message || !isRecord(message.chat) || !isRecord(message.from)) {
188
+ return;
189
+ }
190
+ const chatId = String(message.chat.id);
191
+ const senderId = String(message.from.id);
192
+ const chatType = chatTypeOf(message.chat);
193
+ const messageId = typeof message.message_id === "number"
194
+ ? String(message.message_id)
195
+ : `${chatId}-${Date.now()}`;
196
+ const text = (typeof message.text === "string" && message.text) ||
197
+ (typeof message.caption === "string" && message.caption) ||
198
+ "";
199
+ if (this.handlers?.onPreAuthMessage) {
200
+ await this.handlers.onPreAuthMessage({ chatId, userId: senderId, chatType, text });
201
+ }
202
+ if (senderId !== this.account.operatorUserId) {
203
+ await this.handlers?.onUnauthorized?.(senderId, chatType);
204
+ return;
205
+ }
206
+ const claimed = tryClaimChannelInboundMessage(`telegram:${chatId}:${messageId}`, {
207
+ claimsDir: this.claimsDir,
208
+ });
209
+ if (!claimed) {
210
+ return;
211
+ }
212
+ const attachments = await this.downloadAttachments(message);
213
+ if (!text && attachments.length === 0) {
214
+ return;
215
+ }
216
+ await this.handlers?.onMessage({
217
+ id: messageId,
218
+ conversation: {
219
+ channelId: "telegram",
220
+ conversationId: chatId,
221
+ recipientId: chatId,
222
+ metadata: { chatType },
223
+ },
224
+ senderId,
225
+ text,
226
+ attachments,
227
+ createdAt: new Date().toISOString(),
228
+ });
229
+ }
230
+ async downloadAttachments(message) {
231
+ const candidates = [];
232
+ if (Array.isArray(message.photo)) {
233
+ const largest = message.photo
234
+ .filter((entry) => isRecord(entry))
235
+ .sort((a, b) => (Number(b.width) || 0) - (Number(a.width) || 0))[0];
236
+ if (largest && typeof largest.file_id === "string") {
237
+ candidates.push({
238
+ fileId: largest.file_id,
239
+ fileName: `photo-${Date.now()}.jpg`,
240
+ kind: "image",
241
+ mimeType: "image/jpeg",
242
+ });
243
+ }
244
+ }
245
+ if (isRecord(message.document) && typeof message.document.file_id === "string") {
246
+ candidates.push({
247
+ fileId: message.document.file_id,
248
+ fileName: typeof message.document.file_name === "string" ? message.document.file_name : "document",
249
+ mimeType: typeof message.document.mime_type === "string" ? message.document.mime_type : undefined,
250
+ });
251
+ }
252
+ if (isRecord(message.video) && typeof message.video.file_id === "string") {
253
+ candidates.push({
254
+ fileId: message.video.file_id,
255
+ fileName: `video-${Date.now()}.mp4`,
256
+ kind: "video",
257
+ mimeType: "video/mp4",
258
+ });
259
+ }
260
+ if (isRecord(message.voice) && typeof message.voice.file_id === "string") {
261
+ candidates.push({
262
+ fileId: message.voice.file_id,
263
+ fileName: `voice-${Date.now()}.ogg`,
264
+ kind: "voice",
265
+ mimeType: "audio/ogg",
266
+ });
267
+ }
268
+ if (isRecord(message.audio) && typeof message.audio.file_id === "string") {
269
+ candidates.push({
270
+ fileId: message.audio.file_id,
271
+ fileName: typeof message.audio.file_name === "string"
272
+ ? message.audio.file_name
273
+ : `audio-${Date.now()}.mp3`,
274
+ kind: "voice",
275
+ });
276
+ }
277
+ const attachments = [];
278
+ for (const candidate of candidates) {
279
+ try {
280
+ const filePath = await this.downloadFile(candidate.fileId, candidate.fileName);
281
+ const kind = candidate.kind ?? inferAttachmentKind(candidate.fileName, candidate.mimeType);
282
+ const sizeBytes = fs.statSync(filePath).size;
283
+ attachments.push({
284
+ kind,
285
+ path: filePath,
286
+ fileName: candidate.fileName,
287
+ mimeType: candidate.mimeType,
288
+ sizeBytes,
289
+ });
290
+ }
291
+ catch (error) {
292
+ this.logger.error?.(`telegram_attachment_download_failed: ${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+ }
295
+ return attachments;
296
+ }
297
+ async downloadFile(fileId, fileName) {
298
+ const file = await this.callApiExpectOk("getFile", { file_id: fileId });
299
+ if (!isRecord(file) || typeof file.file_path !== "string") {
300
+ throw new Error("Telegram getFile returned no file_path.");
301
+ }
302
+ const url = `${TELEGRAM_API_BASE}/file/bot${this.account.botToken}/${file.file_path}`;
303
+ const response = await this.fetchImpl(url);
304
+ if (!response.ok) {
305
+ throw new Error(`Telegram file download failed with HTTP ${response.status}.`);
306
+ }
307
+ const buffer = Buffer.from(await response.arrayBuffer());
308
+ if (buffer.byteLength > TELEGRAM_DOWNLOAD_MAX_BYTES) {
309
+ throw new Error("Telegram file exceeds the 20 MB bot download limit.");
310
+ }
311
+ const dateDir = path.join(this.attachmentsDir, new Date().toISOString().slice(0, 10));
312
+ fs.mkdirSync(dateDir, { recursive: true });
313
+ const safeName = fileName.replace(/[/\\]/g, "_") || "attachment";
314
+ const target = path.join(dateDir, `${Date.now().toString(36)}-${safeName}`);
315
+ fs.writeFileSync(target, buffer);
316
+ return target;
317
+ }
318
+ async sendText(target, text, kind) {
319
+ const chatId = target.recipientId;
320
+ const chunks = splitText(text);
321
+ for (const chunk of chunks) {
322
+ if (!chunk)
323
+ continue;
324
+ await this.callApiExpectOk("sendMessage", {
325
+ chat_id: chatId,
326
+ text: chunk,
327
+ disable_web_page_preview: true,
328
+ });
329
+ }
330
+ this.logger.log?.(`telegram_send: kind=${kind} chat=${truncatePreview(chatId, 24)} chunks=${chunks.length} chars=${text.length}`);
331
+ return true;
332
+ }
333
+ async sendAttachment(target, attachment) {
334
+ const chatId = target.recipientId;
335
+ const fileName = attachment.fileName || path.basename(attachment.path);
336
+ const buffer = fs.readFileSync(attachment.path);
337
+ const form = new FormData();
338
+ form.append("chat_id", chatId);
339
+ const blob = new Blob([new Uint8Array(buffer)]);
340
+ const method = attachment.kind === "image" ? "sendPhoto" : "sendDocument";
341
+ form.append(attachment.kind === "image" ? "photo" : "document", blob, fileName);
342
+ const response = await this.fetchImpl(this.apiUrl(method), {
343
+ method: "POST",
344
+ body: form,
345
+ });
346
+ const payload = (await response.json().catch(() => null));
347
+ if (!payload?.ok) {
348
+ throw new Error(`Telegram ${method} failed: ${payload?.description ?? `HTTP ${response.status}`}`);
349
+ }
350
+ this.logger.log?.(`telegram_attachment_sent: chat=${truncatePreview(chatId, 24)} file=${fileName}`);
351
+ }
352
+ async beginTyping(recipientId) {
353
+ if (this.typingTimers.has(recipientId)) {
354
+ return;
355
+ }
356
+ const trigger = () => {
357
+ void this.callApiExpectOk("sendChatAction", {
358
+ chat_id: recipientId,
359
+ action: "typing",
360
+ }).catch(() => undefined);
361
+ };
362
+ trigger();
363
+ this.typingTimers.set(recipientId, setInterval(trigger, TELEGRAM_TYPING_REFRESH_MS));
364
+ }
365
+ async endTyping(recipientId) {
366
+ const timer = this.typingTimers.get(recipientId);
367
+ if (timer) {
368
+ clearInterval(timer);
369
+ this.typingTimers.delete(recipientId);
370
+ }
371
+ }
372
+ endAllTyping() {
373
+ for (const timer of this.typingTimers.values()) {
374
+ clearInterval(timer);
375
+ }
376
+ this.typingTimers.clear();
377
+ }
378
+ }