@goodandready/dsh-messenger-gateway 0.1.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/LICENSE +21 -0
- package/README.md +35 -0
- package/cordis.patch.yml +4 -0
- package/docs/architecture/2026-08-23-messenger-gateway-design.md +30 -0
- package/docs/deployment/0.1.0-install.md +28 -0
- package/docs/testing/0.1.0-smoke.md +12 -0
- package/lib/adapters/discord.js +6 -0
- package/lib/adapters/index.js +24 -0
- package/lib/adapters/telegram.js +210 -0
- package/lib/ask.js +63 -0
- package/lib/client.js +96 -0
- package/lib/documents.js +16 -0
- package/lib/gateway.js +321 -0
- package/lib/http.js +18 -0
- package/lib/index.js +174 -0
- package/lib/integrations.js +40 -0
- package/lib/media.js +68 -0
- package/lib/messenger-api.js +225 -0
- package/lib/outbound.js +112 -0
- package/lib/photos.js +30 -0
- package/lib/text.js +22 -0
- package/lib/topics.js +27 -0
- package/lib/tts.js +40 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GooDAnDReaDY
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @goodandready/dsh-messenger-gateway
|
|
2
|
+
|
|
3
|
+
Messenger transport for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
|
|
4
|
+
|
|
5
|
+
## 0.1.0 scope (Telegram)
|
|
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`
|
|
14
|
+
|
|
15
|
+
Discord adapter is a stub; full Discord support comes after 0.1.0.
|
|
16
|
+
|
|
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
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
dsh plugin --profile web add @goodandready/dsh-messenger-gateway
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Settings → **Messenger gateway** → enable Telegram, paste bot token, set allowed user IDs.
|
|
32
|
+
|
|
33
|
+
See `docs/deployment/0.1.0-install.md` for staging smoke and prod swap vs legacy hub-media.
|
|
34
|
+
|
|
35
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# dsh-messenger-gateway — versioning
|
|
2
|
+
|
|
3
|
+
## Правило версий
|
|
4
|
+
|
|
5
|
+
| Версия | Когда |
|
|
6
|
+
|--------|--------|
|
|
7
|
+
| **0.0.1, 0.0.2, 0.0.3…** | Каждая отдельная фича (один bump = одна фича) |
|
|
8
|
+
| **0.1.0** | Финальный релиз согласованного scope (замена hub на prod) |
|
|
9
|
+
| **1.0.0** | Когда-нибудь потом, не планируем сейчас |
|
|
10
|
+
|
|
11
|
+
## Журнал 0.0.x
|
|
12
|
+
|
|
13
|
+
| Ver | Фича | Статус |
|
|
14
|
+
|-----|------|--------|
|
|
15
|
+
| 0.0.1 | Скелет: Telegram text, Settings, команды | done |
|
|
16
|
+
| 0.0.2 | Черновик: media, outbound, messenger HTTP, topics, TTS toggle, /stop | done (unit tests) |
|
|
17
|
+
| 0.0.3 | Voice inbound via dsh-voice (signal, errors, unit test) | done (E2E smoke — с ботом) |
|
|
18
|
+
| 0.0.4 | Photo inbound + vision-bridge (attachInboundPhoto, maxImageBytes, photoOnlyHint) | done |
|
|
19
|
+
| 0.0.5 | Documents inbound (formatInboundDocument, documentOnlyHint) | done |
|
|
20
|
+
| 0.0.6 | Outbound tool attachments (nested images, URL fetch, stripImageUrls) | done |
|
|
21
|
+
| 0.0.7 | messenger API (HTTP + ctx.provide messenger, validation, schema) | done |
|
|
22
|
+
| 0.0.8 | Inline keyboards ask (lib/ask.js, cleanup, chat guard) | done |
|
|
23
|
+
| 0.0.9 | Forum topics (threadId sessions + outbound message_thread_id) | done |
|
|
24
|
+
| 0.0.10 | TTS replies (prepareTtsText, AbortSignal, maxChars UI) | done |
|
|
25
|
+
| 0.1.0 | Final Telegram scope + prod swap vs hub-media | done |
|
|
26
|
+
| … | Discord | after 0.1.0 |
|
|
27
|
+
|
|
28
|
+
## Scope 0.1.0 (финал этой линии)
|
|
29
|
+
|
|
30
|
+
Всё из roadmap п.4 для Telegram, без Discord (Discord — после 0.1.0 или отдельной веткой 0.0.x).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# 0.1.0 install and prod swap
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
Profile bundles must include:
|
|
6
|
+
|
|
7
|
+
- `dsh-voice`
|
|
8
|
+
- `dsh-tts` (optional)
|
|
9
|
+
- `dsh-vision-bridge`
|
|
10
|
+
|
|
11
|
+
## Staging smoke
|
|
12
|
+
|
|
13
|
+
1. Backup profile `package.json`.
|
|
14
|
+
2. `dsh plugin --profile <staging-profile> add file:/opt/plugins-dev/dsh-messenger-gateway`
|
|
15
|
+
or `add @goodandready/dsh-messenger-gateway@0.1.0` after npm publish.
|
|
16
|
+
3. Restart Harness; verify client.js 200 and `/dsh-messenger-gateway/status`.
|
|
17
|
+
4. Run `docs/testing/0.1.0-smoke.md`.
|
|
18
|
+
|
|
19
|
+
## RELEASE → npm
|
|
20
|
+
|
|
21
|
+
Copy to RELEASE/dhsplugins, `npm test`, `npm publish`.
|
|
22
|
+
|
|
23
|
+
## Prod swap (user approval only)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
dsh plugin --profile web remove dsh-im-hub-media
|
|
27
|
+
dsh plugin --profile web add @goodandready/dsh-messenger-gateway@0.1.0
|
|
28
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 0.1.0 manual smoke
|
|
2
|
+
|
|
3
|
+
- [ ] `/start`, text reply
|
|
4
|
+
- [ ] Voice → dsh-voice transcript
|
|
5
|
+
- [ ] Photo → vision-bridge
|
|
6
|
+
- [ ] Document metadata in prompt
|
|
7
|
+
- [ ] Outbound tool image
|
|
8
|
+
- [ ] messenger HTTP send/ask/progress
|
|
9
|
+
- [ ] Inline keyboard ask
|
|
10
|
+
- [ ] Forum topic session
|
|
11
|
+
- [ ] TTS voice reply (or log warn if down)
|
|
12
|
+
- [ ] `/new` `/stop` `/whoami`
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { TelegramAdapter } from './telegram.js'
|
|
2
|
+
|
|
3
|
+
export default function createAdapters(deps) {
|
|
4
|
+
const { config, onMessage, onCallback, logger } = deps
|
|
5
|
+
const list = []
|
|
6
|
+
const tg = config.telegram || {}
|
|
7
|
+
if (config.enabled !== false && tg.enabled && String(tg.botToken || '').trim()) {
|
|
8
|
+
list.push(new TelegramAdapter({
|
|
9
|
+
botToken: tg.botToken,
|
|
10
|
+
allowedUserIds: tg.allowedUserIds,
|
|
11
|
+
timeoutSeconds: tg.pollTimeoutSeconds,
|
|
12
|
+
pollIntervalMs: tg.pollIntervalMs,
|
|
13
|
+
media: config.media,
|
|
14
|
+
onMessage,
|
|
15
|
+
onCallback,
|
|
16
|
+
logger,
|
|
17
|
+
}))
|
|
18
|
+
}
|
|
19
|
+
const dc = config.discord || {}
|
|
20
|
+
if (config.enabled !== false && dc.enabled) {
|
|
21
|
+
logger?.warn?.('dsh-messenger-gateway: discord adapter is not implemented yet')
|
|
22
|
+
}
|
|
23
|
+
return list
|
|
24
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import {
|
|
3
|
+
IMAGE_EXT_TO_MIME, VIDEO_EXT_TO_MIME, basename, cacheName, classifyDocument,
|
|
4
|
+
extOf, mediaKindOf, saveToCache, safeName,
|
|
5
|
+
} from '../media.js'
|
|
6
|
+
import { splitText } from '../text.js'
|
|
7
|
+
import { normalizeThreadId, telegramThreadParams } from '../topics.js'
|
|
8
|
+
|
|
9
|
+
const API = 'https://api.telegram.org'
|
|
10
|
+
const TELEGRAM_MAX = 4096
|
|
11
|
+
|
|
12
|
+
export class TelegramAdapter {
|
|
13
|
+
constructor(opts) {
|
|
14
|
+
this.name = 'telegram'
|
|
15
|
+
this.token = String(opts.botToken || '').trim()
|
|
16
|
+
this.allowedUserIds = (opts.allowedUserIds || []).map(Number).filter((n) => Number.isFinite(n))
|
|
17
|
+
this.timeoutSeconds = Number(opts.timeoutSeconds) || 50
|
|
18
|
+
this.pollIntervalMs = Number(opts.pollIntervalMs) || 500
|
|
19
|
+
this.media = opts.media || {}
|
|
20
|
+
this.onMessage = opts.onMessage
|
|
21
|
+
this.onCallback = opts.onCallback
|
|
22
|
+
this.logger = opts.logger
|
|
23
|
+
this.offset = 0
|
|
24
|
+
this.stopped = false
|
|
25
|
+
this.pollTimer = undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async call(method, params = {}) {
|
|
29
|
+
const res = await fetch(`${API}/bot${this.token}/${method}`, {
|
|
30
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params),
|
|
31
|
+
})
|
|
32
|
+
const json = await res.json().catch(() => ({}))
|
|
33
|
+
if (!res.ok || json.ok === false) throw new Error(`telegram ${method}: ${json.description || res.status}`)
|
|
34
|
+
return json.result
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async callMultipart(method, form) {
|
|
38
|
+
const res = await fetch(`${API}/bot${this.token}/${method}`, { method: 'POST', body: form })
|
|
39
|
+
const json = await res.json().catch(() => ({}))
|
|
40
|
+
if (!res.ok || json.ok === false) throw new Error(`telegram ${method}: ${json.description || res.status}`)
|
|
41
|
+
return json.result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getFile(fileId) { return this.call('getFile', { file_id: fileId }) }
|
|
45
|
+
|
|
46
|
+
async downloadFile(filePath) {
|
|
47
|
+
const res = await fetch(`${API}/file/bot${this.token}/${filePath}`)
|
|
48
|
+
if (!res.ok) throw new Error(`telegram download HTTP ${res.status}`)
|
|
49
|
+
return new Uint8Array(await res.arrayBuffer())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async start() {
|
|
53
|
+
if (!this.token) throw new Error('telegram bot token is empty')
|
|
54
|
+
this.stopped = false
|
|
55
|
+
this.poll()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop() {
|
|
59
|
+
this.stopped = true
|
|
60
|
+
if (this.pollTimer) clearTimeout(this.pollTimer)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
schedulePoll() {
|
|
64
|
+
if (this.stopped) return
|
|
65
|
+
this.pollTimer = setTimeout(() => this.poll(), this.pollIntervalMs)
|
|
66
|
+
this.pollTimer.unref?.()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async poll() {
|
|
70
|
+
if (this.stopped) return
|
|
71
|
+
try {
|
|
72
|
+
const updates = await this.call('getUpdates', {
|
|
73
|
+
timeout: this.timeoutSeconds,
|
|
74
|
+
offset: this.offset,
|
|
75
|
+
allowed_updates: ['message', 'callback_query'],
|
|
76
|
+
})
|
|
77
|
+
for (const update of updates || []) {
|
|
78
|
+
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
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (!this.stopped) this.logger?.warn?.(`poll: ${e.message}`)
|
|
93
|
+
}
|
|
94
|
+
this.schedulePoll()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
wrapCallback(cq) {
|
|
98
|
+
const chatId = cq.message?.chat?.id
|
|
99
|
+
const messageId = cq.message?.message_id
|
|
100
|
+
return {
|
|
101
|
+
platform: 'telegram', chatId, threadId: cq.message?.message_thread_id || 0, userId: cq.from?.id, data: cq.data, callbackQueryId: cq.id,
|
|
102
|
+
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
|
+
}),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
allowed(userId) {
|
|
110
|
+
return this.allowedUserIds.length === 0 || this.allowedUserIds.includes(Number(userId))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async handleMessage(msg) {
|
|
114
|
+
const chatId = msg.chat.id
|
|
115
|
+
const userId = msg.from?.id ?? chatId
|
|
116
|
+
if (!this.allowed(userId)) return
|
|
117
|
+
const threadId = msg.message_thread_id || 0
|
|
118
|
+
const cacheDir = this.media.cacheDir
|
|
119
|
+
const maxDocBytes = this.media.maxDocBytes ?? 20 * 1024 * 1024
|
|
120
|
+
const maxTextInjectBytes = this.media.maxTextInjectBytes ?? 100 * 1024
|
|
121
|
+
let text = msg.text ?? msg.caption ?? ''
|
|
122
|
+
const attachments = []
|
|
123
|
+
const replyText = msg.reply_to_message ? (msg.reply_to_message.text ?? msg.reply_to_message.caption ?? '') : ''
|
|
124
|
+
|
|
125
|
+
if (msg.photo?.length) {
|
|
126
|
+
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)
|
|
129
|
+
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' })
|
|
131
|
+
}
|
|
132
|
+
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' })
|
|
136
|
+
}
|
|
137
|
+
if (msg.audio) {
|
|
138
|
+
const file = await this.getFile(msg.audio.file_id)
|
|
139
|
+
const bytes = await this.downloadFile(file.file_path)
|
|
140
|
+
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' })
|
|
142
|
+
}
|
|
143
|
+
if (msg.document) {
|
|
144
|
+
const doc = msg.document
|
|
145
|
+
const ext = extOf(doc.file_name, doc.mime_type)
|
|
146
|
+
const kind = classifyDocument(ext, doc.mime_type)
|
|
147
|
+
if (doc.file_size > maxDocBytes) {
|
|
148
|
+
text = `${text}\n[Document too large: ${doc.file_name || 'file'}]`.trim()
|
|
149
|
+
} else if (kind === 'unsupported') {
|
|
150
|
+
text = `${text}\n[Unsupported document type: ${doc.file_name || ext}]`.trim()
|
|
151
|
+
} 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)
|
|
155
|
+
if (kind === 'image') attachments.push({ kind: 'photo', path, mime: IMAGE_EXT_TO_MIME[ext] || doc.mime_type || 'image/jpeg', name: doc.file_name })
|
|
156
|
+
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)) {
|
|
158
|
+
const body = new TextDecoder('utf-8', { fatal: false }).decode(bytes).slice(0, maxTextInjectBytes)
|
|
159
|
+
text = `${text}\n\n[Document ${doc.file_name}]\n${body}`.trim()
|
|
160
|
+
} else attachments.push({ kind: 'document', path, mime: doc.mime_type || 'application/octet-stream', name: doc.file_name || basename(path) })
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const reply = async (payload) => this.sendReply(chatId, msg.message_id, payload, threadId)
|
|
165
|
+
const typing = async () => { try { await this.call('sendChatAction', { chat_id: chatId, action: 'typing', ...telegramThreadParams(threadId) }) } catch {} }
|
|
166
|
+
|
|
167
|
+
await this.onMessage({
|
|
168
|
+
platform: 'telegram', chatId, userId, threadId, text, attachments, replyText, messageId: msg.message_id, reply, typing,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async sendMedia(chatId, file, threadId = 0) {
|
|
173
|
+
const form = new FormData()
|
|
174
|
+
form.append('chat_id', String(chatId))
|
|
175
|
+
const thread = telegramThreadParams(threadId)
|
|
176
|
+
if (thread.message_thread_id) form.append('message_thread_id', String(thread.message_thread_id))
|
|
177
|
+
const blob = new Blob([file.bytes])
|
|
178
|
+
const name = safeName(file.name || 'file')
|
|
179
|
+
if (file.kind === 'photo') { form.append('photo', blob, name); return this.callMultipart('sendPhoto', form) }
|
|
180
|
+
if (file.kind === 'voice') { form.append('voice', blob, name); return this.callMultipart('sendVoice', form) }
|
|
181
|
+
if (file.kind === 'audio') { form.append('audio', blob, name); return this.callMultipart('sendAudio', form) }
|
|
182
|
+
form.append('document', blob, name)
|
|
183
|
+
return this.callMultipart('sendDocument', form)
|
|
184
|
+
}
|
|
185
|
+
|
|
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)) {
|
|
196
|
+
await this.call('sendMessage', {
|
|
197
|
+
chat_id: chatId,
|
|
198
|
+
text: chunk,
|
|
199
|
+
reply_to_message_id: replyTo,
|
|
200
|
+
reply_markup: replyMarkup,
|
|
201
|
+
...telegramThreadParams(threadId),
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async sendTo(chatId, payload, opts = {}) {
|
|
208
|
+
return this.sendReply(chatId, undefined, payload, normalizeThreadId(opts.threadId))
|
|
209
|
+
}
|
|
210
|
+
}
|
package/lib/ask.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { normalizeThreadId } from './topics.js'
|
|
3
|
+
|
|
4
|
+
export const TELEGRAM_CALLBACK_DATA_MAX = 64
|
|
5
|
+
|
|
6
|
+
export function makeAskToken() {
|
|
7
|
+
return randomUUID().replace(/-/g, '').slice(0, 12)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildCallbackData(token, buttonId) {
|
|
11
|
+
const data = `${token}:${buttonId}`
|
|
12
|
+
if (data.length > TELEGRAM_CALLBACK_DATA_MAX) {
|
|
13
|
+
const err = new Error(`callback_data too long (${data.length} > ${TELEGRAM_CALLBACK_DATA_MAX})`)
|
|
14
|
+
err.status = 400
|
|
15
|
+
throw err
|
|
16
|
+
}
|
|
17
|
+
return data
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseCallbackData(data) {
|
|
21
|
+
const raw = String(data || '')
|
|
22
|
+
const idx = raw.indexOf(':')
|
|
23
|
+
if (idx <= 0) return { token: undefined, buttonId: raw }
|
|
24
|
+
return { token: raw.slice(0, idx), buttonId: raw.slice(idx + 1) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildInlineKeyboard(token, buttons) {
|
|
28
|
+
const callbackKeys = []
|
|
29
|
+
const rows = (buttons || []).map((row) => row.map((btn) => {
|
|
30
|
+
const callback_data = buildCallbackData(token, btn.id)
|
|
31
|
+
callbackKeys.push(callback_data)
|
|
32
|
+
return { text: btn.text, callback_data }
|
|
33
|
+
}))
|
|
34
|
+
return {
|
|
35
|
+
replyMarkup: rows.length ? { inline_keyboard: rows } : undefined,
|
|
36
|
+
callbackKeys,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function indexCallbacks(callbackIndex, keys, token) {
|
|
41
|
+
for (const key of keys || []) callbackIndex.set(key, token)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function releaseCallbacks(callbackIndex, keys) {
|
|
45
|
+
for (const key of keys || []) callbackIndex.delete(key)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function targetMatchesAsk(pending, cb) {
|
|
49
|
+
const target = pending?.target
|
|
50
|
+
if (!target) return false
|
|
51
|
+
if (cb?.platform && target.platform && cb.platform !== target.platform) return false
|
|
52
|
+
if (cb?.chatId !== undefined && String(target.chatId) !== String(cb.chatId)) return false
|
|
53
|
+
if (normalizeThreadId(target.threadId) !== normalizeThreadId(cb?.threadId)) return false
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function rejectPendingAsk(pending, err) {
|
|
58
|
+
if (!pending) return
|
|
59
|
+
clearTimeout(pending.timer)
|
|
60
|
+
pending.reject(err)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const REMOVE_KEYBOARD = { inline_keyboard: [] }
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@goodandready/dsh-messenger-gateway',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} }
|
|
5
|
+
var exports = module.exports
|
|
6
|
+
const React = require('react')
|
|
7
|
+
|
|
8
|
+
function idsToText(ids) { return (Array.isArray(ids) ? ids : []).map(String).filter(Boolean).join(', ') }
|
|
9
|
+
function textToIds(text) {
|
|
10
|
+
return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter(Boolean).map(Number).filter((n) => Number.isFinite(n))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function Section() {
|
|
14
|
+
const [cfg, setCfg] = React.useState(null)
|
|
15
|
+
const [token, setToken] = React.useState('')
|
|
16
|
+
const [allowText, setAllowText] = React.useState('')
|
|
17
|
+
const [err, setErr] = React.useState('')
|
|
18
|
+
const [busy, setBusy] = React.useState(false)
|
|
19
|
+
|
|
20
|
+
const load = React.useCallback(async () => {
|
|
21
|
+
const res = await fetch('/dsh-messenger-gateway/config', { credentials: 'same-origin' })
|
|
22
|
+
const data = await res.json()
|
|
23
|
+
if (!res.ok || !data.ok) throw new Error(data.error || res.status)
|
|
24
|
+
setCfg(data.config)
|
|
25
|
+
setAllowText(idsToText(data.config?.telegram?.allowedUserIds))
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => { load().catch((e) => setErr(String(e.message || e))) }, [load])
|
|
29
|
+
|
|
30
|
+
const save = async (patch) => {
|
|
31
|
+
setBusy(true); setErr('')
|
|
32
|
+
try {
|
|
33
|
+
const body = {
|
|
34
|
+
config: {
|
|
35
|
+
...cfg,
|
|
36
|
+
...patch,
|
|
37
|
+
telegram: { ...cfg.telegram, ...(patch.telegram || {}) },
|
|
38
|
+
tts: { ...cfg.tts, ...(patch.tts || {}) },
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
if (token.trim()) body.config.telegram = { ...body.config.telegram, botToken: token.trim() }
|
|
42
|
+
body.config.telegram.allowedUserIds = textToIds(allowText)
|
|
43
|
+
const res = await fetch('/dsh-messenger-gateway/config', {
|
|
44
|
+
method: 'PUT', credentials: 'same-origin',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
46
|
+
})
|
|
47
|
+
const data = await res.json()
|
|
48
|
+
if (!res.ok || !data.ok) throw new Error(data.error || res.status)
|
|
49
|
+
setCfg(data.config); setToken('')
|
|
50
|
+
setAllowText(idsToText(data.config?.telegram?.allowedUserIds))
|
|
51
|
+
} catch (e) { setErr(String(e.message || e)) } finally { setBusy(false) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!cfg) return React.createElement('div', null, err || 'Loading…')
|
|
55
|
+
|
|
56
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 720 } },
|
|
57
|
+
React.createElement('div', { style: { fontWeight: 600 } }, 'Messenger gateway'),
|
|
58
|
+
React.createElement('div', { style: { fontSize: 12, opacity: 0.75 } }, 'Telegram: text, voice (dsh-voice), photos, documents. Optional voice replies via dsh-tts.'),
|
|
59
|
+
React.createElement('label', null, 'Bot token (write-only)'),
|
|
60
|
+
React.createElement('input', { type: 'password', value: token, placeholder: cfg.telegram?.botTokenConfigured ? '••••••••' : 'BotFather token', onChange: (e) => setToken(e.target.value) }),
|
|
61
|
+
React.createElement('label', null, 'Allowed user ids (comma-separated, empty = all)'),
|
|
62
|
+
React.createElement('textarea', { value: allowText, onChange: (e) => setAllowText(e.target.value), rows: 2 }),
|
|
63
|
+
React.createElement('label', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
64
|
+
React.createElement('input', { type: 'checkbox', checked: !!cfg.tts?.enabled, onChange: (e) => save({ tts: { enabled: e.target.checked } }) }),
|
|
65
|
+
'Speak agent replies (dsh-tts)',
|
|
66
|
+
),
|
|
67
|
+
cfg.tts?.enabled ? React.createElement('label', null,
|
|
68
|
+
'Max spoken chars',
|
|
69
|
+
React.createElement('input', {
|
|
70
|
+
type: 'number', min: 100, max: 8000,
|
|
71
|
+
value: cfg.tts?.maxChars ?? 4000,
|
|
72
|
+
onChange: (e) => setCfg({ ...cfg, tts: { ...cfg.tts, maxChars: Number(e.target.value) || 4000 } }),
|
|
73
|
+
}),
|
|
74
|
+
) : null,
|
|
75
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
|
|
76
|
+
React.createElement('button', { disabled: busy, onClick: () => save({ telegram: { enabled: !cfg.telegram.enabled } }) }, cfg.telegram.enabled ? 'Disable Telegram' : 'Enable Telegram'),
|
|
77
|
+
React.createElement('button', { disabled: busy, onClick: () => save({}) }, 'Save'),
|
|
78
|
+
),
|
|
79
|
+
err ? React.createElement('div', { style: { color: 'crimson', fontSize: 12 } }, err) : null,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function apply(ctx) {
|
|
84
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
85
|
+
name: 'settings.section',
|
|
86
|
+
id: '@goodandready/dsh-messenger-gateway',
|
|
87
|
+
order: 36,
|
|
88
|
+
label: () => 'Messenger gateway',
|
|
89
|
+
}, Section))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
exports.apply = apply
|
|
93
|
+
exports.inject = ['slots']
|
|
94
|
+
return module.exports
|
|
95
|
+
},
|
|
96
|
+
})
|
package/lib/documents.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Format a cached inbound document/video path for the agent (fs tools). */
|
|
2
|
+
export function formatInboundDocument(att) {
|
|
3
|
+
const kind = att?.kind || 'document'
|
|
4
|
+
const label = kind === 'video' ? 'Видео' : 'Документ'
|
|
5
|
+
const name = att?.name ? ` ${att.name}` : ''
|
|
6
|
+
const mime = att?.mime ? ` (${att.mime})` : ''
|
|
7
|
+
return `[${label}${name}${mime}]\nПуть: ${att.path}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function documentOnlyHint(attachments, userText) {
|
|
11
|
+
if (String(userText || '').trim()) return ''
|
|
12
|
+
const docs = attachments.filter((a) => a.kind === 'document' || a.kind === 'video')
|
|
13
|
+
if (!docs.length) return ''
|
|
14
|
+
if (docs.length === 1) return '[Пользователь отправил файл]'
|
|
15
|
+
return `[Пользователь отправил ${docs.length} файла]`
|
|
16
|
+
}
|