@goodandready/dsh-messenger-gateway 0.1.0 → 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/README.md +68 -19
- package/lib/adapters/index.js +15 -2
- package/lib/adapters/telegram.js +375 -52
- package/lib/client.js +480 -46
- package/lib/commands.js +30 -0
- package/lib/config.js +78 -0
- package/lib/documents.js +19 -5
- package/lib/gateway.js +496 -56
- package/lib/groups.js +72 -0
- package/lib/homes.js +58 -0
- package/lib/index.js +261 -39
- package/lib/messenger-api.js +5 -5
- package/lib/pairing.js +110 -0
- package/lib/stream.js +64 -0
- package/lib/telegram-errors.js +41 -0
- package/lib/telegram-format.js +141 -0
- package/lib/text.js +18 -1
- package/lib/topics.js +23 -4
- package/lib/tts.js +82 -2
- package/lib/voice-prefs.js +44 -0
- package/package.json +15 -9
- package/docs/architecture/2026-08-23-messenger-gateway-design.md +0 -30
- package/docs/deployment/0.1.0-install.md +0 -28
- package/docs/testing/0.1.0-smoke.md +0 -12
package/lib/adapters/telegram.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import {
|
|
3
3
|
IMAGE_EXT_TO_MIME, VIDEO_EXT_TO_MIME, basename, cacheName, classifyDocument,
|
|
4
|
-
extOf,
|
|
4
|
+
extOf, saveToCache, safeName,
|
|
5
5
|
} from '../media.js'
|
|
6
|
+
import { TEXT_INJECT_EXTS } from '../documents.js'
|
|
6
7
|
import { splitText } from '../text.js'
|
|
8
|
+
import { prepareTelegramText } from '../telegram-format.js'
|
|
9
|
+
import { normalizeTelegramCommands } from '../commands.js'
|
|
7
10
|
import { normalizeThreadId, telegramThreadParams } from '../topics.js'
|
|
11
|
+
import {
|
|
12
|
+
shouldProcessTelegramMessage, stripBotCommandSuffix,
|
|
13
|
+
} from '../groups.js'
|
|
14
|
+
import { isResendSafeNetworkError, isPollingConflict } from '../telegram-errors.js'
|
|
8
15
|
|
|
9
16
|
const API = 'https://api.telegram.org'
|
|
10
17
|
const TELEGRAM_MAX = 4096
|
|
@@ -19,10 +26,32 @@ export class TelegramAdapter {
|
|
|
19
26
|
this.media = opts.media || {}
|
|
20
27
|
this.onMessage = opts.onMessage
|
|
21
28
|
this.onCallback = opts.onCallback
|
|
29
|
+
this.onUnauthorized = opts.onUnauthorized
|
|
30
|
+
this.isUserAllowed = opts.isUserAllowed
|
|
22
31
|
this.logger = opts.logger
|
|
32
|
+
this.commands = normalizeTelegramCommands(opts.commands)
|
|
33
|
+
this.textFormat = opts.textFormat === 'plain' ? 'plain' : 'html'
|
|
34
|
+
this.groupsEnabled = opts.groupsEnabled !== false
|
|
35
|
+
this.groupRequireMention = opts.groupRequireMention !== false
|
|
36
|
+
this.reactionsEnabled = opts.reactionsEnabled !== false
|
|
37
|
+
this.transport = opts.transport === 'webhook' ? 'webhook' : 'poll'
|
|
38
|
+
this.statusIndicator = opts.statusIndicator === true
|
|
39
|
+
this.statusOnline = String(opts.statusOnline || 'Online')
|
|
40
|
+
this.statusOffline = String(opts.statusOffline || 'Offline')
|
|
41
|
+
this.sendRetryMax = 2
|
|
42
|
+
this.sendRetryBaseMs = 400
|
|
43
|
+
this.pollingConflict = false
|
|
44
|
+
this.webhookUrl = String(opts.webhookUrl || '').trim()
|
|
45
|
+
this.webhookSecret = String(opts.webhookSecret || '').trim()
|
|
23
46
|
this.offset = 0
|
|
24
47
|
this.stopped = false
|
|
25
48
|
this.pollTimer = undefined
|
|
49
|
+
this.botId = 0
|
|
50
|
+
this.botUsername = ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setAllowedUserIds(ids) {
|
|
54
|
+
this.allowedUserIds = (ids || []).map(Number).filter((n) => Number.isFinite(n))
|
|
26
55
|
}
|
|
27
56
|
|
|
28
57
|
async call(method, params = {}) {
|
|
@@ -41,6 +70,24 @@ export class TelegramAdapter {
|
|
|
41
70
|
return json.result
|
|
42
71
|
}
|
|
43
72
|
|
|
73
|
+
// Retry a send only on resend-safe network errors (request never reached Telegram).
|
|
74
|
+
// Permanent errors (4xx/5xx) and ambiguous timeouts are not retried to avoid duplicates.
|
|
75
|
+
async sendWithRetry(method, params, { multipart = false } = {}) {
|
|
76
|
+
const fn = () => (multipart ? this.callMultipart(method, params) : this.call(method, params))
|
|
77
|
+
let lastErr
|
|
78
|
+
for (let attempt = 0; attempt <= this.sendRetryMax; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
return await fn()
|
|
81
|
+
} catch (err) {
|
|
82
|
+
lastErr = err
|
|
83
|
+
if (!isResendSafeNetworkError(err) || attempt >= this.sendRetryMax) throw err
|
|
84
|
+
this.logger?.warn?.(`telegram ${method} resend-safe network error (attempt ${attempt + 1}/${this.sendRetryMax}), retrying: ${err.message}`)
|
|
85
|
+
await new Promise((r) => setTimeout(r, this.sendRetryBaseMs * (attempt + 1)))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw lastErr
|
|
89
|
+
}
|
|
90
|
+
|
|
44
91
|
async getFile(fileId) { return this.call('getFile', { file_id: fileId }) }
|
|
45
92
|
|
|
46
93
|
async downloadFile(filePath) {
|
|
@@ -49,15 +96,63 @@ export class TelegramAdapter {
|
|
|
49
96
|
return new Uint8Array(await res.arrayBuffer())
|
|
50
97
|
}
|
|
51
98
|
|
|
99
|
+
async registerCommands() {
|
|
100
|
+
if (!this.commands.length) return
|
|
101
|
+
await this.call('setMyCommands', { commands: this.commands })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Bots have no presence dot; the short description is the closest surface.
|
|
105
|
+
// Opt-in only — it mutates the bot's global profile visible to all users.
|
|
106
|
+
async setStatusIndicator(text) {
|
|
107
|
+
if (!this.statusIndicator) return
|
|
108
|
+
try {
|
|
109
|
+
await this.call('setMyShortDescription', { short_description: String(text || '').slice(0, 120) })
|
|
110
|
+
} catch (err) {
|
|
111
|
+
this.logger?.warn?.(`telegram setMyShortDescription: ${err.message}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
52
115
|
async start() {
|
|
53
116
|
if (!this.token) throw new Error('telegram bot token is empty')
|
|
54
117
|
this.stopped = false
|
|
118
|
+
try {
|
|
119
|
+
const me = await this.call('getMe')
|
|
120
|
+
this.botId = Number(me.id) || 0
|
|
121
|
+
this.botUsername = String(me.username || '')
|
|
122
|
+
this.logger?.info?.(`dsh-messenger-gateway: telegram bot @${this.botUsername} (${this.botId})`)
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this.logger?.warn?.(`dsh-messenger-gateway: telegram getMe: ${err.message}`)
|
|
125
|
+
}
|
|
126
|
+
if (this.statusIndicator) await this.setStatusIndicator(this.statusOnline)
|
|
127
|
+
try {
|
|
128
|
+
await this.registerCommands()
|
|
129
|
+
this.logger?.info?.(`dsh-messenger-gateway: telegram commands registered (${this.commands.length})`)
|
|
130
|
+
} catch (err) {
|
|
131
|
+
this.logger?.warn?.(`dsh-messenger-gateway: telegram setMyCommands: ${err.message}`)
|
|
132
|
+
}
|
|
133
|
+
if (this.transport === 'webhook') {
|
|
134
|
+
if (!this.webhookUrl) throw new Error('telegram webhookUrl is required for webhook transport')
|
|
135
|
+
try {
|
|
136
|
+
await this.call('deleteWebhook', { drop_pending_updates: false })
|
|
137
|
+
} catch {}
|
|
138
|
+
const params = {
|
|
139
|
+
url: this.webhookUrl,
|
|
140
|
+
allowed_updates: ['message', 'callback_query'],
|
|
141
|
+
drop_pending_updates: false,
|
|
142
|
+
}
|
|
143
|
+
if (this.webhookSecret) params.secret_token = this.webhookSecret
|
|
144
|
+
await this.call('setWebhook', params)
|
|
145
|
+
this.logger?.info?.(`dsh-messenger-gateway: telegram webhook set → ${this.webhookUrl}`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
try { await this.call('deleteWebhook', { drop_pending_updates: false }) } catch {}
|
|
55
149
|
this.poll()
|
|
56
150
|
}
|
|
57
151
|
|
|
58
152
|
stop() {
|
|
59
153
|
this.stopped = true
|
|
60
154
|
if (this.pollTimer) clearTimeout(this.pollTimer)
|
|
155
|
+
if (this.statusIndicator) this.setStatusIndicator(this.statusOffline).catch(() => {})
|
|
61
156
|
}
|
|
62
157
|
|
|
63
158
|
schedulePoll() {
|
|
@@ -74,71 +169,201 @@ export class TelegramAdapter {
|
|
|
74
169
|
offset: this.offset,
|
|
75
170
|
allowed_updates: ['message', 'callback_query'],
|
|
76
171
|
})
|
|
172
|
+
this.pollingConflict = false
|
|
77
173
|
for (const update of updates || []) {
|
|
78
174
|
this.offset = Math.max(this.offset, update.update_id + 1)
|
|
79
|
-
|
|
80
|
-
try { await this.onCallback?.(this.wrapCallback(update.callback_query)) } catch (e) {
|
|
81
|
-
this.logger?.warn?.(`callback: ${e.message}`)
|
|
82
|
-
}
|
|
83
|
-
continue
|
|
84
|
-
}
|
|
85
|
-
const msg = update.message
|
|
86
|
-
if (!msg) continue
|
|
87
|
-
try { await this.handleMessage(msg) } catch (e) {
|
|
88
|
-
this.logger?.warn?.(`message: ${e.message}`)
|
|
89
|
-
}
|
|
175
|
+
await this.dispatchUpdate(update)
|
|
90
176
|
}
|
|
91
177
|
} catch (e) {
|
|
92
|
-
if (!this.stopped)
|
|
178
|
+
if (!this.stopped) {
|
|
179
|
+
if (isPollingConflict(e)) {
|
|
180
|
+
this.pollingConflict = true
|
|
181
|
+
this.logger?.error?.(`poll: TELEGRAM CONFLICT — another bot instance is polling the same token. Stop the duplicate instance. (${e.message})`)
|
|
182
|
+
} else {
|
|
183
|
+
this.logger?.warn?.(`poll: ${e.message}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
93
186
|
}
|
|
94
187
|
this.schedulePoll()
|
|
95
188
|
}
|
|
96
189
|
|
|
190
|
+
async dispatchUpdate(update) {
|
|
191
|
+
if (update.callback_query) {
|
|
192
|
+
try { await this.onCallback?.(this.wrapCallback(update.callback_query)) } catch (e) {
|
|
193
|
+
this.logger?.warn?.(`callback: ${e.message}`)
|
|
194
|
+
}
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
const msg = update.message
|
|
198
|
+
if (!msg) return
|
|
199
|
+
try { await this.handleMessage(msg) } catch (e) {
|
|
200
|
+
this.logger?.warn?.(`message: ${e.message}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** HTTP webhook entry (caller verifies secret). */
|
|
205
|
+
async handleWebhookUpdate(update) {
|
|
206
|
+
if (this.stopped) return
|
|
207
|
+
await this.dispatchUpdate(update)
|
|
208
|
+
}
|
|
209
|
+
|
|
97
210
|
wrapCallback(cq) {
|
|
98
211
|
const chatId = cq.message?.chat?.id
|
|
99
212
|
const messageId = cq.message?.message_id
|
|
100
213
|
return {
|
|
101
214
|
platform: 'telegram', chatId, threadId: cq.message?.message_thread_id || 0, userId: cq.from?.id, data: cq.data, callbackQueryId: cq.id,
|
|
215
|
+
message: cq.message,
|
|
102
216
|
answer: async (text) => this.call('answerCallbackQuery', { callback_query_id: cq.id, text: text || '' }),
|
|
103
|
-
editMessage: async (text, replyMarkup) =>
|
|
104
|
-
|
|
105
|
-
|
|
217
|
+
editMessage: async (text, replyMarkup) => {
|
|
218
|
+
const { text: formatted, parseMode } = this.formatOutgoingText(text)
|
|
219
|
+
const params = { chat_id: chatId, message_id: messageId, text: formatted, reply_markup: replyMarkup }
|
|
220
|
+
if (parseMode) params.parse_mode = parseMode
|
|
221
|
+
try {
|
|
222
|
+
return await this.call('editMessageText', params)
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (!parseMode) throw err
|
|
225
|
+
return this.call('editMessageText', { chat_id: chatId, message_id: messageId, text, reply_markup: replyMarkup })
|
|
226
|
+
}
|
|
227
|
+
},
|
|
106
228
|
}
|
|
107
229
|
}
|
|
108
230
|
|
|
109
231
|
allowed(userId) {
|
|
232
|
+
if (typeof this.isUserAllowed === 'function') return this.isUserAllowed(userId)
|
|
110
233
|
return this.allowedUserIds.length === 0 || this.allowedUserIds.includes(Number(userId))
|
|
111
234
|
}
|
|
112
235
|
|
|
236
|
+
async downloadByFileId(fileId, prefix, ext, name = '') {
|
|
237
|
+
const file = await this.getFile(fileId)
|
|
238
|
+
const bytes = await this.downloadFile(file.file_path)
|
|
239
|
+
const resolvedExt = ext || extOf(file.file_path, '') || ''
|
|
240
|
+
const path = saveToCache(this.media.cacheDir, cacheName(prefix, resolvedExt, name), bytes)
|
|
241
|
+
return { path, bytes, file }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async setReaction(chatId, messageId, emoji) {
|
|
245
|
+
if (!this.reactionsEnabled || !messageId) return
|
|
246
|
+
try {
|
|
247
|
+
await this.call('setMessageReaction', {
|
|
248
|
+
chat_id: chatId,
|
|
249
|
+
message_id: messageId,
|
|
250
|
+
reaction: emoji ? [{ type: 'emoji', emoji }] : [],
|
|
251
|
+
})
|
|
252
|
+
} catch (e) {
|
|
253
|
+
this.logger?.warn?.(`reaction: ${e.message}`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
113
257
|
async handleMessage(msg) {
|
|
114
258
|
const chatId = msg.chat.id
|
|
259
|
+
const chatType = msg.chat?.type || 'private'
|
|
115
260
|
const userId = msg.from?.id ?? chatId
|
|
116
|
-
|
|
261
|
+
let text = msg.text ?? msg.caption ?? ''
|
|
262
|
+
const entities = msg.entities || msg.caption_entities || []
|
|
263
|
+
const gate = shouldProcessTelegramMessage({
|
|
264
|
+
chatType,
|
|
265
|
+
text,
|
|
266
|
+
entities,
|
|
267
|
+
replyTo: msg.reply_to_message,
|
|
268
|
+
botId: this.botId,
|
|
269
|
+
botUsername: this.botUsername,
|
|
270
|
+
groupsEnabled: this.groupsEnabled,
|
|
271
|
+
requireMention: this.groupRequireMention,
|
|
272
|
+
})
|
|
273
|
+
if (!gate.ok) return
|
|
274
|
+
|
|
275
|
+
if (!this.allowed(userId)) {
|
|
276
|
+
if (chatType !== 'private') return
|
|
277
|
+
if (this.onUnauthorized) {
|
|
278
|
+
await this.onUnauthorized({
|
|
279
|
+
platform: 'telegram', chatId, userId, threadId: msg.message_thread_id || 0,
|
|
280
|
+
username: msg.from?.username || '',
|
|
281
|
+
reply: async (payload) => this.sendReply(chatId, msg.message_id, payload, msg.message_thread_id || 0),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
text = stripBotCommandSuffix(text, this.botUsername)
|
|
117
288
|
const threadId = msg.message_thread_id || 0
|
|
118
|
-
const cacheDir = this.media.cacheDir
|
|
119
289
|
const maxDocBytes = this.media.maxDocBytes ?? 20 * 1024 * 1024
|
|
120
290
|
const maxTextInjectBytes = this.media.maxTextInjectBytes ?? 100 * 1024
|
|
121
|
-
let text = msg.text ?? msg.caption ?? ''
|
|
122
291
|
const attachments = []
|
|
123
|
-
const
|
|
292
|
+
const replyMsg = msg.reply_to_message
|
|
293
|
+
let replyText = ''
|
|
294
|
+
if (replyMsg) {
|
|
295
|
+
const quoted = replyMsg.text ?? replyMsg.caption ?? ''
|
|
296
|
+
const quoteFrag = msg.quote?.text || ''
|
|
297
|
+
replyText = quoteFrag
|
|
298
|
+
? `${quoted}${quoted ? '\n' : ''}[цитата: ${quoteFrag}]`
|
|
299
|
+
: quoted
|
|
300
|
+
}
|
|
124
301
|
|
|
125
302
|
if (msg.photo?.length) {
|
|
126
303
|
const largest = msg.photo[msg.photo.length - 1]
|
|
127
|
-
const file = await this.
|
|
128
|
-
const bytes = await this.downloadFile(file.file_path)
|
|
304
|
+
const { path, file } = await this.downloadByFileId(largest.file_id, 'photo', extOf('', '') || '.jpg')
|
|
129
305
|
const ext = extOf(file.file_path, '') || '.jpg'
|
|
130
|
-
attachments.push({ kind: 'photo', path
|
|
306
|
+
attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || 'image/jpeg' })
|
|
307
|
+
}
|
|
308
|
+
if (msg.sticker) {
|
|
309
|
+
const st = msg.sticker
|
|
310
|
+
if (st.is_video) {
|
|
311
|
+
try {
|
|
312
|
+
const { path } = await this.downloadByFileId(st.file_id, 'sticker-video', '.webm', st.file_unique_id || '')
|
|
313
|
+
attachments.push({ kind: 'animation', path, mime: 'video/webm', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webm` })
|
|
314
|
+
if (st.emoji) text = `${text}\n[Видео-стикер ${st.emoji}]`.trim()
|
|
315
|
+
} catch (e) {
|
|
316
|
+
text = `${text}\n[Видео-стикер ${st.emoji || ''} (не скачан: ${e.message})]`.trim()
|
|
317
|
+
}
|
|
318
|
+
} else if (st.is_animated) {
|
|
319
|
+
try {
|
|
320
|
+
const { path } = await this.downloadByFileId(st.file_id, 'sticker-anim', '.tgs', st.file_unique_id || '')
|
|
321
|
+
attachments.push({ kind: 'document', path, mime: 'application/x-tgsticker', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.tgs` })
|
|
322
|
+
if (st.emoji) text = `${text}\n[Анимированный стикер ${st.emoji}]`.trim()
|
|
323
|
+
} catch (e) {
|
|
324
|
+
text = `${text}\n[Анимированный стикер ${st.emoji || ''} (не скачан: ${e.message})]`.trim()
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
const { path } = await this.downloadByFileId(st.file_id, 'sticker', '.webp', st.file_unique_id || '')
|
|
328
|
+
attachments.push({ kind: 'sticker', path, mime: 'image/webp', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webp` })
|
|
329
|
+
}
|
|
131
330
|
}
|
|
132
331
|
if (msg.voice) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
attachments.push({ kind: 'voice', path: saveToCache(cacheDir, cacheName('voice', '.ogg', ''), bytes), mime: 'audio/ogg' })
|
|
332
|
+
const { path } = await this.downloadByFileId(msg.voice.file_id, 'voice', '.ogg')
|
|
333
|
+
attachments.push({ kind: 'voice', path, mime: 'audio/ogg' })
|
|
136
334
|
}
|
|
137
335
|
if (msg.audio) {
|
|
138
|
-
const file = await this.getFile(msg.audio.file_id)
|
|
139
|
-
const bytes = await this.downloadFile(file.file_path)
|
|
140
336
|
const ext = extOf(msg.audio.file_name, msg.audio.mime_type) || '.mp3'
|
|
141
|
-
|
|
337
|
+
const { path } = await this.downloadByFileId(msg.audio.file_id, 'audio', ext, msg.audio.file_name || '')
|
|
338
|
+
attachments.push({ kind: 'audio', path, mime: msg.audio.mime_type || 'audio/mpeg' })
|
|
339
|
+
}
|
|
340
|
+
if (msg.video) {
|
|
341
|
+
const ext = extOf(msg.video.file_name, msg.video.mime_type) || '.mp4'
|
|
342
|
+
if ((msg.video.file_size || 0) > maxDocBytes) {
|
|
343
|
+
text = `${text}\n[Video too large]`.trim()
|
|
344
|
+
} else {
|
|
345
|
+
const { path } = await this.downloadByFileId(msg.video.file_id, 'video', ext, msg.video.file_name || '')
|
|
346
|
+
attachments.push({ kind: 'video', path, mime: msg.video.mime_type || VIDEO_EXT_TO_MIME[ext] || 'video/mp4', name: msg.video.file_name || basename(path) })
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (msg.video_note) {
|
|
350
|
+
const vn = msg.video_note
|
|
351
|
+
if ((vn.file_size || 0) > maxDocBytes) {
|
|
352
|
+
text = `${text}\n[Video note too large]`.trim()
|
|
353
|
+
} else {
|
|
354
|
+
const { path } = await this.downloadByFileId(vn.file_id, 'videonote', '.mp4')
|
|
355
|
+
attachments.push({ kind: 'video', path, mime: 'video/mp4', name: 'video_note.mp4' })
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (msg.animation) {
|
|
359
|
+
const an = msg.animation
|
|
360
|
+
const ext = extOf(an.file_name, an.mime_type) || '.mp4'
|
|
361
|
+
if ((an.file_size || 0) > maxDocBytes) {
|
|
362
|
+
text = `${text}\n[Animation too large]`.trim()
|
|
363
|
+
} else {
|
|
364
|
+
const { path } = await this.downloadByFileId(an.file_id, 'anim', ext, an.file_name || '')
|
|
365
|
+
attachments.push({ kind: 'animation', path, mime: an.mime_type || 'video/mp4', name: an.file_name || basename(path) })
|
|
366
|
+
}
|
|
142
367
|
}
|
|
143
368
|
if (msg.document) {
|
|
144
369
|
const doc = msg.document
|
|
@@ -147,26 +372,93 @@ export class TelegramAdapter {
|
|
|
147
372
|
if (doc.file_size > maxDocBytes) {
|
|
148
373
|
text = `${text}\n[Document too large: ${doc.file_name || 'file'}]`.trim()
|
|
149
374
|
} else if (kind === 'unsupported') {
|
|
150
|
-
|
|
375
|
+
const { path } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
|
|
376
|
+
attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
|
|
151
377
|
} else {
|
|
152
|
-
const
|
|
153
|
-
const bytes = await this.downloadFile(file.file_path)
|
|
154
|
-
const path = saveToCache(cacheDir, cacheName('doc', ext, doc.file_name || ''), bytes)
|
|
378
|
+
const { path, bytes } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
|
|
155
379
|
if (kind === 'image') attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || doc.mime_type || 'image/jpeg', name: doc.file_name })
|
|
156
380
|
else if (kind === 'video') attachments.push({ kind: 'video', path, mime: VIDEO_EXT_TO_MIME[ext] || doc.mime_type || 'video/mp4', name: doc.file_name })
|
|
157
|
-
else if (bytes.length <= maxTextInjectBytes &&
|
|
381
|
+
else if (bytes.length <= maxTextInjectBytes && TEXT_INJECT_EXTS.has(ext)) {
|
|
158
382
|
const body = new TextDecoder('utf-8', { fatal: false }).decode(bytes).slice(0, maxTextInjectBytes)
|
|
159
383
|
text = `${text}\n\n[Document ${doc.file_name}]\n${body}`.trim()
|
|
160
384
|
} else attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
|
|
161
385
|
}
|
|
162
386
|
}
|
|
163
387
|
|
|
164
|
-
const
|
|
388
|
+
const messageId = msg.message_id
|
|
389
|
+
const reply = async (payload) => this.sendReply(chatId, messageId, payload, threadId)
|
|
165
390
|
const typing = async () => { try { await this.call('sendChatAction', { chat_id: chatId, action: 'typing', ...telegramThreadParams(threadId) }) } catch {} }
|
|
391
|
+
const startStream = async () => this.startStreamMessage(chatId, messageId, threadId)
|
|
392
|
+
const startProgress = async () => this.startProgressMessage(chatId, messageId, threadId)
|
|
393
|
+
const react = async (emoji) => this.setReaction(chatId, messageId, emoji)
|
|
166
394
|
|
|
167
395
|
await this.onMessage({
|
|
168
|
-
platform: 'telegram', chatId, userId, threadId, text, attachments, replyText,
|
|
396
|
+
platform: 'telegram', chatId, userId, threadId, chatType, text, attachments, replyText,
|
|
397
|
+
messageId, reply, typing, startStream, startProgress, react,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async startProgressMessage(chatId, replyTo, threadId = 0) {
|
|
402
|
+
const result = await this.call('sendMessage', {
|
|
403
|
+
chat_id: chatId,
|
|
404
|
+
text: '⏳ Думаю…',
|
|
405
|
+
reply_to_message_id: replyTo,
|
|
406
|
+
...telegramThreadParams(threadId),
|
|
169
407
|
})
|
|
408
|
+
const messageId = result?.message_id
|
|
409
|
+
return {
|
|
410
|
+
messageId,
|
|
411
|
+
edit: async (text) => {
|
|
412
|
+
const plain = String(text || '⏳ Думаю…').slice(0, TELEGRAM_MAX)
|
|
413
|
+
try {
|
|
414
|
+
await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '⏳ Думаю…' })
|
|
415
|
+
} catch (err) {
|
|
416
|
+
const msg = String(err.message || '')
|
|
417
|
+
if (!msg.includes('message is not modified')) this.logger?.warn?.(`progress edit: ${err.message}`)
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
remove: async () => {
|
|
421
|
+
try { await this.call('deleteMessage', { chat_id: chatId, message_id: messageId }) } catch {}
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async startStreamMessage(chatId, replyTo, threadId = 0) {
|
|
427
|
+
const result = await this.call('sendMessage', {
|
|
428
|
+
chat_id: chatId,
|
|
429
|
+
text: '…',
|
|
430
|
+
reply_to_message_id: replyTo,
|
|
431
|
+
...telegramThreadParams(threadId),
|
|
432
|
+
})
|
|
433
|
+
const messageId = result?.message_id
|
|
434
|
+
return {
|
|
435
|
+
messageId,
|
|
436
|
+
edit: async (text) => {
|
|
437
|
+
const plain = String(text || '…').slice(0, TELEGRAM_MAX)
|
|
438
|
+
try {
|
|
439
|
+
await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '…' })
|
|
440
|
+
} catch (err) {
|
|
441
|
+
const msg = String(err.message || '')
|
|
442
|
+
if (!msg.includes('message is not modified')) throw err
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
finalize: async (text, payload = {}) => {
|
|
446
|
+
const { text: formatted, parseMode } = this.formatOutgoingText(String(text || ''), payload)
|
|
447
|
+
const chunk = splitText(formatted, TELEGRAM_MAX)[0] || '…'
|
|
448
|
+
const params = { chat_id: chatId, message_id: messageId, text: chunk }
|
|
449
|
+
if (parseMode) params.parse_mode = parseMode
|
|
450
|
+
try {
|
|
451
|
+
await this.call('editMessageText', params)
|
|
452
|
+
} catch (err) {
|
|
453
|
+
if (!parseMode) {
|
|
454
|
+
const msg = String(err.message || '')
|
|
455
|
+
if (!msg.includes('message is not modified')) throw err
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: splitText(String(text || ''), TELEGRAM_MAX)[0] || '…' })
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
}
|
|
170
462
|
}
|
|
171
463
|
|
|
172
464
|
async sendMedia(chatId, file, threadId = 0) {
|
|
@@ -176,34 +468,65 @@ export class TelegramAdapter {
|
|
|
176
468
|
if (thread.message_thread_id) form.append('message_thread_id', String(thread.message_thread_id))
|
|
177
469
|
const blob = new Blob([file.bytes])
|
|
178
470
|
const name = safeName(file.name || 'file')
|
|
179
|
-
if (file.kind === 'photo') { form.append('photo', blob, name); return this.
|
|
180
|
-
if (file.kind === 'voice') { form.append('voice', blob, name); return this.
|
|
181
|
-
if (file.kind === 'audio') { form.append('audio', blob, name); return this.
|
|
471
|
+
if (file.kind === 'photo') { form.append('photo', blob, name); return this.sendWithRetry('sendPhoto', form, { multipart: true }) }
|
|
472
|
+
if (file.kind === 'voice') { form.append('voice', blob, name); return this.sendWithRetry('sendVoice', form, { multipart: true }) }
|
|
473
|
+
if (file.kind === 'audio') { form.append('audio', blob, name); return this.sendWithRetry('sendAudio', form, { multipart: true }) }
|
|
474
|
+
if (file.kind === 'video') { form.append('video', blob, name); return this.sendWithRetry('sendVideo', form, { multipart: true }) }
|
|
182
475
|
form.append('document', blob, name)
|
|
183
|
-
return this.
|
|
476
|
+
return this.sendWithRetry('sendDocument', form, { multipart: true })
|
|
184
477
|
}
|
|
185
478
|
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
479
|
+
formatOutgoingText(text, payload = {}) {
|
|
480
|
+
const mode = payload.parseMode === 'HTML' ? 'html'
|
|
481
|
+
: payload.parseMode === 'plain' ? 'plain'
|
|
482
|
+
: this.textFormat
|
|
483
|
+
return prepareTelegramText(text, mode)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async sendFormattedMessage(chatId, replyTo, text, payload, threadId, replyMarkup) {
|
|
487
|
+
const { text: formatted, parseMode } = this.formatOutgoingText(text, payload)
|
|
488
|
+
const chunks = splitText(formatted, TELEGRAM_MAX)
|
|
489
|
+
const plainChunks = splitText(text, TELEGRAM_MAX)
|
|
490
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
491
|
+
const params = {
|
|
492
|
+
chat_id: chatId,
|
|
493
|
+
text: chunks[i],
|
|
494
|
+
reply_to_message_id: replyTo,
|
|
495
|
+
reply_markup: i === 0 ? replyMarkup : undefined,
|
|
496
|
+
...telegramThreadParams(threadId),
|
|
497
|
+
}
|
|
498
|
+
if (parseMode) params.parse_mode = parseMode
|
|
499
|
+
try {
|
|
500
|
+
await this.sendWithRetry('sendMessage', params)
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (!parseMode) throw err
|
|
503
|
+
this.logger?.warn?.(`telegram HTML send failed, fallback plain: ${err.message}`)
|
|
196
504
|
await this.call('sendMessage', {
|
|
197
505
|
chat_id: chatId,
|
|
198
|
-
text:
|
|
506
|
+
text: plainChunks[i] ?? chunks[i],
|
|
199
507
|
reply_to_message_id: replyTo,
|
|
200
|
-
reply_markup: replyMarkup,
|
|
508
|
+
reply_markup: i === 0 ? replyMarkup : undefined,
|
|
201
509
|
...telegramThreadParams(threadId),
|
|
202
510
|
})
|
|
203
511
|
}
|
|
204
512
|
}
|
|
205
513
|
}
|
|
206
514
|
|
|
515
|
+
async sendReply(chatId, replyTo, payload, threadId = 0) {
|
|
516
|
+
const body = typeof payload === 'string' ? { text: payload } : (payload || {})
|
|
517
|
+
const files = Array.isArray(body.files) ? body.files : []
|
|
518
|
+
const text = String(body.text || '')
|
|
519
|
+
const replyMarkup = body.replyMarkup
|
|
520
|
+
for (const file of files) {
|
|
521
|
+
try {
|
|
522
|
+
const bytes = file.bytes || (file.path ? await readFile(file.path) : null)
|
|
523
|
+
if (!bytes) continue
|
|
524
|
+
await this.sendMedia(chatId, { ...file, bytes }, threadId)
|
|
525
|
+
} catch (e) { this.logger?.warn?.(`send media: ${e.message}`) }
|
|
526
|
+
}
|
|
527
|
+
if (text) await this.sendFormattedMessage(chatId, replyTo, text, body, threadId, replyMarkup)
|
|
528
|
+
}
|
|
529
|
+
|
|
207
530
|
async sendTo(chatId, payload, opts = {}) {
|
|
208
531
|
return this.sendReply(chatId, undefined, payload, normalizeThreadId(opts.threadId))
|
|
209
532
|
}
|