@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 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
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: dsh-messenger-gateway
3
+ name: '@goodandready/dsh-messenger-gateway'
4
+ config: {}
@@ -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,6 @@
1
+ // Discord adapter — planned for a future release.
2
+ export class DiscordAdapter {
3
+ constructor() { this.name = 'discord' }
4
+ async start() { throw new Error('Discord adapter is not implemented yet') }
5
+ stop() {}
6
+ }
@@ -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
+ })
@@ -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
+ }