@goodandready/dsh-messenger-gateway 0.1.0 → 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/README.md CHANGED
@@ -1,26 +1,24 @@
1
1
  # @goodandready/dsh-messenger-gateway
2
2
 
3
- Messenger transport for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
3
+ Telegram messenger bridge for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
4
4
 
5
- ## 0.1.0 scope (Telegram)
5
+ Talk to your Harness agent from Telegram: text, voice, photos, documents, inline buttons, named homes, and optional spoken replies.
6
6
 
7
- - Long-poll Telegram bot
8
- - Text, voice (`dsh-voice`), photos (`attachments` + `dsh-vision-bridge`), documents
9
- - Outbound images from tool results
10
- - `messenger` service: HTTP `/messenger/send`, `/ask`, `/progress` and `ctx.provide('messenger')`
11
- - Forum topics = separate agent sessions (`message_thread_id`)
12
- - Optional spoken replies (`dsh-tts`)
13
- - Commands: `/start` `/help` `/new` `/whoami` `/stop`
7
+ ## Features
14
8
 
15
- Discord adapter is a stub; full Discord support comes after 0.1.0.
9
+ - Long-poll or webhook Telegram bot
10
+ - Allowlist + pairing codes
11
+ - Per-user or per-chat sessions (`sessionScope`)
12
+ - Forum topics as separate sessions
13
+ - Steer: follow-up messages while the agent is busy (instead of aborting)
14
+ - `/stop`, `/new`, `/model`, `/status`, `/voice`, `/sethome`, `/home`
15
+ - Agent tool `messenger_ask` (inline keyboard answers return to the agent)
16
+ - Named homes for outbound notify / `messenger.send`
17
+ - Optional notify bridge: web session events → Telegram home (excludes messenger sessions)
18
+ - Inbound voice → STT (`dsh-voice`), photos → vision (`dsh-vision-bridge`)
19
+ - Optional TTS replies (`dsh-tts`); mp3 is converted to OGG/Opus via `ffmpeg` for Telegram voice notes
16
20
 
17
- ## Profile dependencies
18
-
19
- Install these plugins in the same Harness profile (they are not npm dependencies):
20
-
21
- - `dsh-voice` — inbound voice transcription
22
- - `dsh-tts` — optional spoken replies
23
- - `dsh-vision-bridge` — inbound photo understanding
21
+ Discord is listed in settings as a placeholder only — the Discord adapter is not implemented yet.
24
22
 
25
23
  ## Install
26
24
 
@@ -28,8 +26,59 @@ Install these plugins in the same Harness profile (they are not npm dependencies
28
26
  dsh plugin --profile web add @goodandready/dsh-messenger-gateway
29
27
  ```
30
28
 
31
- Settings **Messenger gateway** enable Telegram, paste bot token, set allowed user IDs.
29
+ Then open **SettingsPlugins Messenger gateway**:
30
+
31
+ 1. Enable Telegram
32
+ 2. Paste the BotFather token (write-only; leave blank to keep the current token)
33
+ 3. Set allowed Telegram user IDs (or use pairing)
34
+
35
+ ### Optional companion plugins (same profile)
36
+
37
+ | Plugin | Role |
38
+ |--------|------|
39
+ | `@goodandready/dsh-voice` | Transcribe inbound voice messages |
40
+ | `@goodandready/dsh-tts` | Speak agent replies |
41
+ | `@goodandready/dsh-vision-bridge` | Describe inbound photos |
42
+
43
+ They are **not** npm dependencies of this package — install them separately if you want those features.
44
+
45
+ ### Voice notes
46
+
47
+ Spoken replies use Telegram `sendVoice`. If TTS returns MP3 (or other non-Opus audio), the gateway runs `ffmpeg` (`libopus`) to produce OGG. If `ffmpeg` is missing or conversion fails, the audio is sent as a regular audio file instead of a voice note.
48
+
49
+ ## Commands (Telegram)
50
+
51
+ | Command | Description |
52
+ |---------|-------------|
53
+ | `/start` `/help` | Help |
54
+ | `/whoami` | Your Telegram user id |
55
+ | `/new` | New agent session |
56
+ | `/stop` | Abort the current turn |
57
+ | `/model` `/status` | Model / gateway status |
58
+ | `/voice on\|off` | Per-user spoken replies |
59
+ | `/sethome [name]` | Bind current chat/topic as a named home |
60
+ | `/home` | List homes |
61
+
62
+ ## Agent tools & HTTP
63
+
64
+ - Tool: `messenger_ask` — ask the user with inline buttons; choice is fed back into the turn
65
+ - HTTP (when enabled): `/messenger/send`, `/messenger/ask`, `/messenger/progress`
66
+ - Cordis service: `ctx.messenger` for other plugins
67
+
68
+ ## Configuration notes
69
+
70
+ - `sessionScope`: `user` (default) or `chat` — how group chats isolate sessions
71
+ - `voiceMode`: `mirror` / `always` / `off` — when to speak replies (also `/voice`)
72
+ - `tts.enabled` / `tts.maxChars` — TTS gate and length cap
73
+ - `notifyBridge` — forward non-messenger web session events to a home
74
+ - Bot token is a DSH secret field — never commit it
75
+
76
+ ## Requirements
77
+
78
+ - DeepSeek Harness web (or compatible) profile
79
+ - Node.js matching your Harness install
80
+ - For voice notes from non-Opus TTS: `ffmpeg` on the host `PATH`
32
81
 
33
- See `docs/deployment/0.1.0-install.md` for staging smoke and prod swap vs legacy hub-media.
82
+ ## License
34
83
 
35
84
  MIT
@@ -1,7 +1,7 @@
1
1
  import { TelegramAdapter } from './telegram.js'
2
2
 
3
3
  export default function createAdapters(deps) {
4
- const { config, onMessage, onCallback, logger } = deps
4
+ const { config, onMessage, onCallback, onUnauthorized, isUserAllowed, logger } = deps
5
5
  const list = []
6
6
  const tg = config.telegram || {}
7
7
  if (config.enabled !== false && tg.enabled && String(tg.botToken || '').trim()) {
@@ -10,9 +10,19 @@ export default function createAdapters(deps) {
10
10
  allowedUserIds: tg.allowedUserIds,
11
11
  timeoutSeconds: tg.pollTimeoutSeconds,
12
12
  pollIntervalMs: tg.pollIntervalMs,
13
+ commands: tg.commands,
14
+ textFormat: tg.textFormat,
15
+ groupsEnabled: tg.groupsEnabled,
16
+ groupRequireMention: tg.groupRequireMention,
17
+ reactionsEnabled: tg.reactionsEnabled,
18
+ transport: tg.transport,
19
+ webhookUrl: tg.webhookUrl,
20
+ webhookSecret: tg.webhookSecret,
13
21
  media: config.media,
14
22
  onMessage,
15
23
  onCallback,
24
+ onUnauthorized,
25
+ isUserAllowed,
16
26
  logger,
17
27
  }))
18
28
  }
@@ -21,4 +31,4 @@ export default function createAdapters(deps) {
21
31
  logger?.warn?.('dsh-messenger-gateway: discord adapter is not implemented yet')
22
32
  }
23
33
  return list
24
- }
34
+ }
@@ -1,10 +1,16 @@
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, mediaKindOf, saveToCache, safeName,
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'
8
14
 
9
15
  const API = 'https://api.telegram.org'
10
16
  const TELEGRAM_MAX = 4096
@@ -19,10 +25,26 @@ export class TelegramAdapter {
19
25
  this.media = opts.media || {}
20
26
  this.onMessage = opts.onMessage
21
27
  this.onCallback = opts.onCallback
28
+ this.onUnauthorized = opts.onUnauthorized
29
+ this.isUserAllowed = opts.isUserAllowed
22
30
  this.logger = opts.logger
31
+ this.commands = normalizeTelegramCommands(opts.commands)
32
+ this.textFormat = opts.textFormat === 'plain' ? 'plain' : 'html'
33
+ this.groupsEnabled = opts.groupsEnabled !== false
34
+ this.groupRequireMention = opts.groupRequireMention !== false
35
+ this.reactionsEnabled = opts.reactionsEnabled !== false
36
+ this.transport = opts.transport === 'webhook' ? 'webhook' : 'poll'
37
+ this.webhookUrl = String(opts.webhookUrl || '').trim()
38
+ this.webhookSecret = String(opts.webhookSecret || '').trim()
23
39
  this.offset = 0
24
40
  this.stopped = false
25
41
  this.pollTimer = undefined
42
+ this.botId = 0
43
+ this.botUsername = ''
44
+ }
45
+
46
+ setAllowedUserIds(ids) {
47
+ this.allowedUserIds = (ids || []).map(Number).filter((n) => Number.isFinite(n))
26
48
  }
27
49
 
28
50
  async call(method, params = {}) {
@@ -49,9 +71,44 @@ export class TelegramAdapter {
49
71
  return new Uint8Array(await res.arrayBuffer())
50
72
  }
51
73
 
74
+ async registerCommands() {
75
+ if (!this.commands.length) return
76
+ await this.call('setMyCommands', { commands: this.commands })
77
+ }
78
+
52
79
  async start() {
53
80
  if (!this.token) throw new Error('telegram bot token is empty')
54
81
  this.stopped = false
82
+ try {
83
+ const me = await this.call('getMe')
84
+ this.botId = Number(me.id) || 0
85
+ this.botUsername = String(me.username || '')
86
+ this.logger?.info?.(`dsh-messenger-gateway: telegram bot @${this.botUsername} (${this.botId})`)
87
+ } catch (err) {
88
+ this.logger?.warn?.(`dsh-messenger-gateway: telegram getMe: ${err.message}`)
89
+ }
90
+ try {
91
+ await this.registerCommands()
92
+ this.logger?.info?.(`dsh-messenger-gateway: telegram commands registered (${this.commands.length})`)
93
+ } catch (err) {
94
+ this.logger?.warn?.(`dsh-messenger-gateway: telegram setMyCommands: ${err.message}`)
95
+ }
96
+ if (this.transport === 'webhook') {
97
+ if (!this.webhookUrl) throw new Error('telegram webhookUrl is required for webhook transport')
98
+ try {
99
+ await this.call('deleteWebhook', { drop_pending_updates: false })
100
+ } catch {}
101
+ const params = {
102
+ url: this.webhookUrl,
103
+ allowed_updates: ['message', 'callback_query'],
104
+ drop_pending_updates: false,
105
+ }
106
+ if (this.webhookSecret) params.secret_token = this.webhookSecret
107
+ await this.call('setWebhook', params)
108
+ this.logger?.info?.(`dsh-messenger-gateway: telegram webhook set → ${this.webhookUrl}`)
109
+ return
110
+ }
111
+ try { await this.call('deleteWebhook', { drop_pending_updates: false }) } catch {}
55
112
  this.poll()
56
113
  }
57
114
 
@@ -76,17 +133,7 @@ export class TelegramAdapter {
76
133
  })
77
134
  for (const update of updates || []) {
78
135
  this.offset = Math.max(this.offset, update.update_id + 1)
79
- if (update.callback_query) {
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
- }
136
+ await this.dispatchUpdate(update)
90
137
  }
91
138
  } catch (e) {
92
139
  if (!this.stopped) this.logger?.warn?.(`poll: ${e.message}`)
@@ -94,51 +141,183 @@ export class TelegramAdapter {
94
141
  this.schedulePoll()
95
142
  }
96
143
 
144
+ async dispatchUpdate(update) {
145
+ if (update.callback_query) {
146
+ try { await this.onCallback?.(this.wrapCallback(update.callback_query)) } catch (e) {
147
+ this.logger?.warn?.(`callback: ${e.message}`)
148
+ }
149
+ return
150
+ }
151
+ const msg = update.message
152
+ if (!msg) return
153
+ try { await this.handleMessage(msg) } catch (e) {
154
+ this.logger?.warn?.(`message: ${e.message}`)
155
+ }
156
+ }
157
+
158
+ /** HTTP webhook entry (caller verifies secret). */
159
+ async handleWebhookUpdate(update) {
160
+ if (this.stopped) return
161
+ await this.dispatchUpdate(update)
162
+ }
163
+
97
164
  wrapCallback(cq) {
98
165
  const chatId = cq.message?.chat?.id
99
166
  const messageId = cq.message?.message_id
100
167
  return {
101
168
  platform: 'telegram', chatId, threadId: cq.message?.message_thread_id || 0, userId: cq.from?.id, data: cq.data, callbackQueryId: cq.id,
169
+ message: cq.message,
102
170
  answer: async (text) => this.call('answerCallbackQuery', { callback_query_id: cq.id, text: text || '' }),
103
- editMessage: async (text, replyMarkup) => this.call('editMessageText', {
104
- chat_id: chatId, message_id: messageId, text, reply_markup: replyMarkup,
105
- }),
171
+ editMessage: async (text, replyMarkup) => {
172
+ const { text: formatted, parseMode } = this.formatOutgoingText(text)
173
+ const params = { chat_id: chatId, message_id: messageId, text: formatted, reply_markup: replyMarkup }
174
+ if (parseMode) params.parse_mode = parseMode
175
+ try {
176
+ return await this.call('editMessageText', params)
177
+ } catch (err) {
178
+ if (!parseMode) throw err
179
+ return this.call('editMessageText', { chat_id: chatId, message_id: messageId, text, reply_markup: replyMarkup })
180
+ }
181
+ },
106
182
  }
107
183
  }
108
184
 
109
185
  allowed(userId) {
186
+ if (typeof this.isUserAllowed === 'function') return this.isUserAllowed(userId)
110
187
  return this.allowedUserIds.length === 0 || this.allowedUserIds.includes(Number(userId))
111
188
  }
112
189
 
190
+ async downloadByFileId(fileId, prefix, ext, name = '') {
191
+ const file = await this.getFile(fileId)
192
+ const bytes = await this.downloadFile(file.file_path)
193
+ const resolvedExt = ext || extOf(file.file_path, '') || ''
194
+ const path = saveToCache(this.media.cacheDir, cacheName(prefix, resolvedExt, name), bytes)
195
+ return { path, bytes, file }
196
+ }
197
+
198
+ async setReaction(chatId, messageId, emoji) {
199
+ if (!this.reactionsEnabled || !messageId) return
200
+ try {
201
+ await this.call('setMessageReaction', {
202
+ chat_id: chatId,
203
+ message_id: messageId,
204
+ reaction: emoji ? [{ type: 'emoji', emoji }] : [],
205
+ })
206
+ } catch (e) {
207
+ this.logger?.warn?.(`reaction: ${e.message}`)
208
+ }
209
+ }
210
+
113
211
  async handleMessage(msg) {
114
212
  const chatId = msg.chat.id
213
+ const chatType = msg.chat?.type || 'private'
115
214
  const userId = msg.from?.id ?? chatId
116
- if (!this.allowed(userId)) return
215
+ let text = msg.text ?? msg.caption ?? ''
216
+ const entities = msg.entities || msg.caption_entities || []
217
+ const gate = shouldProcessTelegramMessage({
218
+ chatType,
219
+ text,
220
+ entities,
221
+ replyTo: msg.reply_to_message,
222
+ botId: this.botId,
223
+ botUsername: this.botUsername,
224
+ groupsEnabled: this.groupsEnabled,
225
+ requireMention: this.groupRequireMention,
226
+ })
227
+ if (!gate.ok) return
228
+
229
+ if (!this.allowed(userId)) {
230
+ if (chatType !== 'private') return
231
+ if (this.onUnauthorized) {
232
+ await this.onUnauthorized({
233
+ platform: 'telegram', chatId, userId, threadId: msg.message_thread_id || 0,
234
+ username: msg.from?.username || '',
235
+ reply: async (payload) => this.sendReply(chatId, msg.message_id, payload, msg.message_thread_id || 0),
236
+ })
237
+ }
238
+ return
239
+ }
240
+
241
+ text = stripBotCommandSuffix(text, this.botUsername)
117
242
  const threadId = msg.message_thread_id || 0
118
- const cacheDir = this.media.cacheDir
119
243
  const maxDocBytes = this.media.maxDocBytes ?? 20 * 1024 * 1024
120
244
  const maxTextInjectBytes = this.media.maxTextInjectBytes ?? 100 * 1024
121
- let text = msg.text ?? msg.caption ?? ''
122
245
  const attachments = []
123
- const replyText = msg.reply_to_message ? (msg.reply_to_message.text ?? msg.reply_to_message.caption ?? '') : ''
246
+ const replyMsg = msg.reply_to_message
247
+ let replyText = ''
248
+ if (replyMsg) {
249
+ const quoted = replyMsg.text ?? replyMsg.caption ?? ''
250
+ const quoteFrag = msg.quote?.text || ''
251
+ replyText = quoteFrag
252
+ ? `${quoted}${quoted ? '\n' : ''}[цитата: ${quoteFrag}]`
253
+ : quoted
254
+ }
124
255
 
125
256
  if (msg.photo?.length) {
126
257
  const largest = msg.photo[msg.photo.length - 1]
127
- const file = await this.getFile(largest.file_id)
128
- const bytes = await this.downloadFile(file.file_path)
258
+ const { path, file } = await this.downloadByFileId(largest.file_id, 'photo', extOf('', '') || '.jpg')
129
259
  const ext = extOf(file.file_path, '') || '.jpg'
130
- attachments.push({ kind: 'photo', path: saveToCache(cacheDir, cacheName('photo', ext, ''), bytes), mime: IMAGE_EXT_TO_MIME[ext] || 'image/jpeg' })
260
+ attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || 'image/jpeg' })
261
+ }
262
+ if (msg.sticker) {
263
+ const st = msg.sticker
264
+ if (st.is_video) {
265
+ try {
266
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker-video', '.webm', st.file_unique_id || '')
267
+ attachments.push({ kind: 'animation', path, mime: 'video/webm', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webm` })
268
+ if (st.emoji) text = `${text}\n[Видео-стикер ${st.emoji}]`.trim()
269
+ } catch (e) {
270
+ text = `${text}\n[Видео-стикер ${st.emoji || ''} (не скачан: ${e.message})]`.trim()
271
+ }
272
+ } else if (st.is_animated) {
273
+ try {
274
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker-anim', '.tgs', st.file_unique_id || '')
275
+ attachments.push({ kind: 'document', path, mime: 'application/x-tgsticker', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.tgs` })
276
+ if (st.emoji) text = `${text}\n[Анимированный стикер ${st.emoji}]`.trim()
277
+ } catch (e) {
278
+ text = `${text}\n[Анимированный стикер ${st.emoji || ''} (не скачан: ${e.message})]`.trim()
279
+ }
280
+ } else {
281
+ const { path } = await this.downloadByFileId(st.file_id, 'sticker', '.webp', st.file_unique_id || '')
282
+ attachments.push({ kind: 'sticker', path, mime: 'image/webp', emoji: st.emoji || '', name: `sticker${st.emoji || ''}.webp` })
283
+ }
131
284
  }
132
285
  if (msg.voice) {
133
- const file = await this.getFile(msg.voice.file_id)
134
- const bytes = await this.downloadFile(file.file_path)
135
- attachments.push({ kind: 'voice', path: saveToCache(cacheDir, cacheName('voice', '.ogg', ''), bytes), mime: 'audio/ogg' })
286
+ const { path } = await this.downloadByFileId(msg.voice.file_id, 'voice', '.ogg')
287
+ attachments.push({ kind: 'voice', path, mime: 'audio/ogg' })
136
288
  }
137
289
  if (msg.audio) {
138
- const file = await this.getFile(msg.audio.file_id)
139
- const bytes = await this.downloadFile(file.file_path)
140
290
  const ext = extOf(msg.audio.file_name, msg.audio.mime_type) || '.mp3'
141
- attachments.push({ kind: 'audio', path: saveToCache(cacheDir, cacheName('audio', ext, msg.audio.file_name || ''), bytes), mime: msg.audio.mime_type || 'audio/mpeg' })
291
+ const { path } = await this.downloadByFileId(msg.audio.file_id, 'audio', ext, msg.audio.file_name || '')
292
+ attachments.push({ kind: 'audio', path, mime: msg.audio.mime_type || 'audio/mpeg' })
293
+ }
294
+ if (msg.video) {
295
+ const ext = extOf(msg.video.file_name, msg.video.mime_type) || '.mp4'
296
+ if ((msg.video.file_size || 0) > maxDocBytes) {
297
+ text = `${text}\n[Video too large]`.trim()
298
+ } else {
299
+ const { path } = await this.downloadByFileId(msg.video.file_id, 'video', ext, msg.video.file_name || '')
300
+ attachments.push({ kind: 'video', path, mime: msg.video.mime_type || VIDEO_EXT_TO_MIME[ext] || 'video/mp4', name: msg.video.file_name || basename(path) })
301
+ }
302
+ }
303
+ if (msg.video_note) {
304
+ const vn = msg.video_note
305
+ if ((vn.file_size || 0) > maxDocBytes) {
306
+ text = `${text}\n[Video note too large]`.trim()
307
+ } else {
308
+ const { path } = await this.downloadByFileId(vn.file_id, 'videonote', '.mp4')
309
+ attachments.push({ kind: 'video', path, mime: 'video/mp4', name: 'video_note.mp4' })
310
+ }
311
+ }
312
+ if (msg.animation) {
313
+ const an = msg.animation
314
+ const ext = extOf(an.file_name, an.mime_type) || '.mp4'
315
+ if ((an.file_size || 0) > maxDocBytes) {
316
+ text = `${text}\n[Animation too large]`.trim()
317
+ } else {
318
+ const { path } = await this.downloadByFileId(an.file_id, 'anim', ext, an.file_name || '')
319
+ attachments.push({ kind: 'animation', path, mime: an.mime_type || 'video/mp4', name: an.file_name || basename(path) })
320
+ }
142
321
  }
143
322
  if (msg.document) {
144
323
  const doc = msg.document
@@ -147,28 +326,95 @@ export class TelegramAdapter {
147
326
  if (doc.file_size > maxDocBytes) {
148
327
  text = `${text}\n[Document too large: ${doc.file_name || 'file'}]`.trim()
149
328
  } else if (kind === 'unsupported') {
150
- text = `${text}\n[Unsupported document type: ${doc.file_name || ext}]`.trim()
329
+ const { path } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
330
+ attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
151
331
  } else {
152
- const file = await this.getFile(doc.file_id)
153
- const bytes = await this.downloadFile(file.file_path)
154
- const path = saveToCache(cacheDir, cacheName('doc', ext, doc.file_name || ''), bytes)
332
+ const { path, bytes } = await this.downloadByFileId(doc.file_id, 'doc', ext, doc.file_name || '')
155
333
  if (kind === 'image') attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || doc.mime_type || 'image/jpeg', name: doc.file_name })
156
334
  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 && ['.md', '.txt', '.csv', '.log', '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.ts', '.py', '.sh'].includes(ext)) {
335
+ else if (bytes.length <= maxTextInjectBytes && TEXT_INJECT_EXTS.has(ext)) {
158
336
  const body = new TextDecoder('utf-8', { fatal: false }).decode(bytes).slice(0, maxTextInjectBytes)
159
337
  text = `${text}\n\n[Document ${doc.file_name}]\n${body}`.trim()
160
338
  } else attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
161
339
  }
162
340
  }
163
341
 
164
- const reply = async (payload) => this.sendReply(chatId, msg.message_id, payload, threadId)
342
+ const messageId = msg.message_id
343
+ const reply = async (payload) => this.sendReply(chatId, messageId, payload, threadId)
165
344
  const typing = async () => { try { await this.call('sendChatAction', { chat_id: chatId, action: 'typing', ...telegramThreadParams(threadId) }) } catch {} }
345
+ const startStream = async () => this.startStreamMessage(chatId, messageId, threadId)
346
+ const startProgress = async () => this.startProgressMessage(chatId, messageId, threadId)
347
+ const react = async (emoji) => this.setReaction(chatId, messageId, emoji)
166
348
 
167
349
  await this.onMessage({
168
- platform: 'telegram', chatId, userId, threadId, text, attachments, replyText, messageId: msg.message_id, reply, typing,
350
+ platform: 'telegram', chatId, userId, threadId, chatType, text, attachments, replyText,
351
+ messageId, reply, typing, startStream, startProgress, react,
169
352
  })
170
353
  }
171
354
 
355
+ async startProgressMessage(chatId, replyTo, threadId = 0) {
356
+ const result = await this.call('sendMessage', {
357
+ chat_id: chatId,
358
+ text: '⏳ Думаю…',
359
+ reply_to_message_id: replyTo,
360
+ ...telegramThreadParams(threadId),
361
+ })
362
+ const messageId = result?.message_id
363
+ return {
364
+ messageId,
365
+ edit: async (text) => {
366
+ const plain = String(text || '⏳ Думаю…').slice(0, TELEGRAM_MAX)
367
+ try {
368
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '⏳ Думаю…' })
369
+ } catch (err) {
370
+ const msg = String(err.message || '')
371
+ if (!msg.includes('message is not modified')) this.logger?.warn?.(`progress edit: ${err.message}`)
372
+ }
373
+ },
374
+ remove: async () => {
375
+ try { await this.call('deleteMessage', { chat_id: chatId, message_id: messageId }) } catch {}
376
+ },
377
+ }
378
+ }
379
+
380
+ async startStreamMessage(chatId, replyTo, threadId = 0) {
381
+ const result = await this.call('sendMessage', {
382
+ chat_id: chatId,
383
+ text: '…',
384
+ reply_to_message_id: replyTo,
385
+ ...telegramThreadParams(threadId),
386
+ })
387
+ const messageId = result?.message_id
388
+ return {
389
+ messageId,
390
+ edit: async (text) => {
391
+ const plain = String(text || '…').slice(0, TELEGRAM_MAX)
392
+ try {
393
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: plain || '…' })
394
+ } catch (err) {
395
+ const msg = String(err.message || '')
396
+ if (!msg.includes('message is not modified')) throw err
397
+ }
398
+ },
399
+ finalize: async (text, payload = {}) => {
400
+ const { text: formatted, parseMode } = this.formatOutgoingText(String(text || ''), payload)
401
+ const chunk = splitText(formatted, TELEGRAM_MAX)[0] || '…'
402
+ const params = { chat_id: chatId, message_id: messageId, text: chunk }
403
+ if (parseMode) params.parse_mode = parseMode
404
+ try {
405
+ await this.call('editMessageText', params)
406
+ } catch (err) {
407
+ if (!parseMode) {
408
+ const msg = String(err.message || '')
409
+ if (!msg.includes('message is not modified')) throw err
410
+ return
411
+ }
412
+ await this.call('editMessageText', { chat_id: chatId, message_id: messageId, text: splitText(String(text || ''), TELEGRAM_MAX)[0] || '…' })
413
+ }
414
+ },
415
+ }
416
+ }
417
+
172
418
  async sendMedia(chatId, file, threadId = 0) {
173
419
  const form = new FormData()
174
420
  form.append('chat_id', String(chatId))
@@ -179,31 +425,62 @@ export class TelegramAdapter {
179
425
  if (file.kind === 'photo') { form.append('photo', blob, name); return this.callMultipart('sendPhoto', form) }
180
426
  if (file.kind === 'voice') { form.append('voice', blob, name); return this.callMultipart('sendVoice', form) }
181
427
  if (file.kind === 'audio') { form.append('audio', blob, name); return this.callMultipart('sendAudio', form) }
428
+ if (file.kind === 'video') { form.append('video', blob, name); return this.callMultipart('sendVideo', form) }
182
429
  form.append('document', blob, name)
183
430
  return this.callMultipart('sendDocument', form)
184
431
  }
185
432
 
186
- async sendReply(chatId, replyTo, payload, threadId = 0) {
187
- const body = typeof payload === 'string' ? { text: payload } : (payload || {})
188
- const files = Array.isArray(body.files) ? body.files : []
189
- const text = String(body.text || '')
190
- const replyMarkup = body.replyMarkup
191
- for (const file of files) {
192
- try { await this.sendMedia(chatId, file, threadId) } catch (e) { this.logger?.warn?.(`send media: ${e.message}`) }
193
- }
194
- if (text) {
195
- for (const chunk of splitText(text, TELEGRAM_MAX)) {
433
+ formatOutgoingText(text, payload = {}) {
434
+ const mode = payload.parseMode === 'HTML' ? 'html'
435
+ : payload.parseMode === 'plain' ? 'plain'
436
+ : this.textFormat
437
+ return prepareTelegramText(text, mode)
438
+ }
439
+
440
+ async sendFormattedMessage(chatId, replyTo, text, payload, threadId, replyMarkup) {
441
+ const { text: formatted, parseMode } = this.formatOutgoingText(text, payload)
442
+ const chunks = splitText(formatted, TELEGRAM_MAX)
443
+ const plainChunks = splitText(text, TELEGRAM_MAX)
444
+ for (let i = 0; i < chunks.length; i++) {
445
+ const params = {
446
+ chat_id: chatId,
447
+ text: chunks[i],
448
+ reply_to_message_id: replyTo,
449
+ reply_markup: i === 0 ? replyMarkup : undefined,
450
+ ...telegramThreadParams(threadId),
451
+ }
452
+ if (parseMode) params.parse_mode = parseMode
453
+ try {
454
+ await this.call('sendMessage', params)
455
+ } catch (err) {
456
+ if (!parseMode) throw err
457
+ this.logger?.warn?.(`telegram HTML send failed, fallback plain: ${err.message}`)
196
458
  await this.call('sendMessage', {
197
459
  chat_id: chatId,
198
- text: chunk,
460
+ text: plainChunks[i] ?? chunks[i],
199
461
  reply_to_message_id: replyTo,
200
- reply_markup: replyMarkup,
462
+ reply_markup: i === 0 ? replyMarkup : undefined,
201
463
  ...telegramThreadParams(threadId),
202
464
  })
203
465
  }
204
466
  }
205
467
  }
206
468
 
469
+ async sendReply(chatId, replyTo, payload, threadId = 0) {
470
+ const body = typeof payload === 'string' ? { text: payload } : (payload || {})
471
+ const files = Array.isArray(body.files) ? body.files : []
472
+ const text = String(body.text || '')
473
+ const replyMarkup = body.replyMarkup
474
+ for (const file of files) {
475
+ try {
476
+ const bytes = file.bytes || (file.path ? await readFile(file.path) : null)
477
+ if (!bytes) continue
478
+ await this.sendMedia(chatId, { ...file, bytes }, threadId)
479
+ } catch (e) { this.logger?.warn?.(`send media: ${e.message}`) }
480
+ }
481
+ if (text) await this.sendFormattedMessage(chatId, replyTo, text, body, threadId, replyMarkup)
482
+ }
483
+
207
484
  async sendTo(chatId, payload, opts = {}) {
208
485
  return this.sendReply(chatId, undefined, payload, normalizeThreadId(opts.threadId))
209
486
  }