@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 +68 -19
- package/lib/adapters/index.js +12 -2
- package/lib/adapters/telegram.js +324 -47
- package/lib/client.js +471 -46
- package/lib/commands.js +30 -0
- package/lib/config.js +75 -0
- package/lib/documents.js +19 -5
- package/lib/gateway.js +476 -55
- 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-format.js +39 -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/README.md
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
# @goodandready/dsh-messenger-gateway
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Telegram messenger bridge for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Talk to your Harness agent from Telegram: text, voice, photos, documents, inline buttons, named homes, and optional spoken replies.
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
Then open **Settings → Plugins → 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
|
-
|
|
82
|
+
## License
|
|
34
83
|
|
|
35
84
|
MIT
|
package/lib/adapters/index.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/adapters/telegram.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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) =>
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
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,
|
|
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
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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:
|
|
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
|
}
|