@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/lib/gateway.js ADDED
@@ -0,0 +1,321 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { installModelSelection } from '@deepseek-ai/dsh-agent'
6
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
7
+ import { SessionId } from '@deepseek-ai/dsh-session'
8
+ import createAdapters from './adapters/index.js'
9
+ import { transcribeVoice, speakText } from './integrations.js'
10
+ import { attachInboundPhoto, photoOnlyHint } from './photos.js'
11
+ import { formatInboundDocument, documentOnlyHint } from './documents.js'
12
+ import { collectAssistantParts, buildOutboundFiles, stripImageUrls } from './outbound.js'
13
+ import { assistantText, splitText } from './text.js'
14
+ import { chatKey } from './topics.js'
15
+ import { prepareTtsText, voiceReplyFile } from './tts.js'
16
+ import {
17
+ makeAskToken, buildInlineKeyboard, indexCallbacks, releaseCallbacks,
18
+ parseCallbackData, targetMatchesAsk, rejectPendingAsk, REMOVE_KEYBOARD,
19
+ } from './ask.js'
20
+
21
+ const PLUGIN = 'dsh-messenger-gateway'
22
+
23
+ export class Gateway {
24
+ constructor(ctx, config) {
25
+ this.ctx = ctx
26
+ this.config = config
27
+ this.chats = new Map()
28
+ this.pending = new Map()
29
+ this.pendingAsks = new Map()
30
+ this.adapters = new Map()
31
+ this.adapterList = []
32
+ this.disposeListener = undefined
33
+ this.idleTimer = undefined
34
+ this.callbackIndex = new Map()
35
+ }
36
+
37
+ baseUrl() {
38
+ const raw = String(this.config.internalBaseURL || '').trim()
39
+ return raw || 'http://127.0.0.1:3080'
40
+ }
41
+
42
+ async start() {
43
+ this.disposeListener = this.ctx.on('session/event', (session, event) => {
44
+ const collector = this.pending.get(session.id)
45
+ if (!collector) return
46
+ if (event.type === 'assistant/message') {
47
+ const msg = event.data.message
48
+ const text = assistantText(msg)
49
+ if (text) collector.parts.push(text)
50
+ const extra = collectAssistantParts(msg)
51
+ for (const img of extra.images) collector.images.push(img)
52
+ } else if (event.type === 'turn/end') {
53
+ collector.reason = event.data.reason
54
+ }
55
+ })
56
+ const adapters = createAdapters({
57
+ config: this.config,
58
+ onMessage: (input) => this.handleMessage(input),
59
+ onCallback: (cb) => this.handleCallback(cb),
60
+ logger: this.ctx.logger,
61
+ })
62
+ for (const adapter of adapters) {
63
+ try {
64
+ await adapter.start()
65
+ this.adapterList.push(adapter)
66
+ this.adapters.set(adapter.name, adapter)
67
+ this.ctx.logger?.info?.(`dsh-messenger-gateway: ${adapter.name} started`)
68
+ } catch (err) {
69
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: ${adapter.name}: ${err.message}`)
70
+ }
71
+ }
72
+ const idleMs = Number(this.config.agent?.idleTimeoutMs) || 0
73
+ if (idleMs > 0) {
74
+ this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
75
+ this.idleTimer.unref?.()
76
+ }
77
+ }
78
+
79
+ stop() {
80
+ if (this.disposeListener) this.disposeListener()
81
+ if (this.idleTimer) clearInterval(this.idleTimer)
82
+ for (const a of this.adapterList) { try { a.stop() } catch {} }
83
+ this.adapterList = []
84
+ this.adapters.clear()
85
+ for (const chat of this.chats.values()) chat.dispose().catch(() => {})
86
+ this.chats.clear()
87
+ for (const pending of this.pendingAsks.values()) {
88
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
89
+ rejectPendingAsk(pending, new Error('gateway stopped'))
90
+ }
91
+ this.pending.clear()
92
+ this.pendingAsks.clear()
93
+ this.callbackIndex.clear()
94
+ }
95
+
96
+ getAdapter(platform) {
97
+ return this.adapters.get(platform)
98
+ }
99
+
100
+ async messengerSend(target, payload) {
101
+ const adapter = this.getAdapter(target.platform)
102
+ if (!adapter?.sendTo) throw new Error(`adapter ${target.platform} unavailable`)
103
+ await adapter.sendTo(target.chatId, payload, { threadId: target.threadId })
104
+ }
105
+
106
+ async messengerAsk(target, payload, timeoutMs = 300_000) {
107
+ const adapter = this.getAdapter(target.platform)
108
+ if (!adapter) throw new Error(`adapter ${target.platform} unavailable`)
109
+ const token = makeAskToken()
110
+ const { replyMarkup, callbackKeys } = buildInlineKeyboard(token, payload.buttons || [])
111
+ indexCallbacks(this.callbackIndex, callbackKeys, token)
112
+ await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
113
+ return new Promise((resolve, reject) => {
114
+ const timer = setTimeout(() => {
115
+ const pending = this.pendingAsks.get(token)
116
+ this.pendingAsks.delete(token)
117
+ releaseCallbacks(this.callbackIndex, callbackKeys)
118
+ reject(new Error('messenger.ask timed out'))
119
+ }, timeoutMs)
120
+ timer.unref?.()
121
+ this.pendingAsks.set(token, { resolve, reject, timer, target, callbackKeys })
122
+ })
123
+ }
124
+
125
+ async messengerProgress(target, payload) {
126
+ await this.messengerSend(target, { text: payload.text })
127
+ }
128
+
129
+ async handleCallback(cb) {
130
+ const indexed = this.callbackIndex.get(cb.data)
131
+ const { token, buttonId } = parseCallbackData(cb.data)
132
+ const askToken = indexed || token
133
+ if (askToken && this.pendingAsks.has(askToken)) {
134
+ const pending = this.pendingAsks.get(askToken)
135
+ if (!targetMatchesAsk(pending, cb)) {
136
+ await cb.answer('Кнопка для другого чата')
137
+ return
138
+ }
139
+ clearTimeout(pending.timer)
140
+ this.pendingAsks.delete(askToken)
141
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
142
+ await cb.answer('OK')
143
+ try {
144
+ await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD)
145
+ } catch {}
146
+ pending.resolve({ buttonId, data: cb.data })
147
+ return
148
+ }
149
+ await cb.answer()
150
+ }
151
+
152
+ async handleMessage(input) {
153
+ const { platform, chatId, userId, threadId = 0, text, attachments = [], replyText } = input
154
+ const key = chatKey(platform, chatId, threadId)
155
+ const body = String(text || '').trim()
156
+ const hasMedia = attachments.length > 0
157
+ if (!body && !hasMedia) return
158
+ if (body.startsWith('/')) { await this.handleCommand(key, body, input); return }
159
+ const chat = await this.getOrCreateChat(key)
160
+ if (chat.abort) chat.abort.abort()
161
+ chat.abort = new AbortController()
162
+ const run = chat.busy.then(() => this.runTurn(chat, input, chat.abort.signal))
163
+ chat.busy = run.catch(() => {})
164
+ await run
165
+ }
166
+
167
+ async handleCommand(key, text, input) {
168
+ const cmd = text.split(/\s+/)[0].toLowerCase()
169
+ const { reply, userId } = input
170
+ if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.')
171
+ if (cmd === '/help') return reply(['Команды:', '/help — справка', '/new — новая сессия', '/whoami — ваш id', '/stop — прервать текущий ответ'].join('\n'))
172
+ if (cmd === '/new') {
173
+ const chat = this.chats.get(key)
174
+ if (chat) { if (chat.abort) chat.abort.abort(); this.chats.delete(key); await chat.dispose(); return reply('Сессия сброшена.') }
175
+ return reply('Активной сессии нет.')
176
+ }
177
+ if (cmd === '/whoami') return reply(`Ваш id: ${userId}`)
178
+ if (cmd === '/stop') {
179
+ const chat = this.chats.get(key)
180
+ if (chat?.abort) { chat.abort.abort(); return reply('Прерывание отправлено.') }
181
+ return reply('Нечего прерывать.')
182
+ }
183
+ return reply(`Неизвестная команда ${cmd}. /help`)
184
+ }
185
+
186
+ async getOrCreateChat(key) {
187
+ let chat = this.chats.get(key)
188
+ if (!chat) { chat = await this.createChat(key); this.chats.set(key, chat) }
189
+ chat.lastUsed = Date.now()
190
+ return chat
191
+ }
192
+
193
+ async createChat(key) {
194
+ const selection = this.ctx.get('agentDefaultModel')?.currentSelection?.()
195
+ if (!selection) throw new Error('Выберите модель в Settings → Models')
196
+ const agentCfg = this.config.agent || {}
197
+ const provider = agentCfg.provider || selection.provider
198
+ const model = agentCfg.model || selection.model
199
+ const cwd = agentCfg.cwd || process.cwd()
200
+ const handle = await this.ctx.agents.create({
201
+ sessionId: SessionId(`msgw-${randomUUID()}`),
202
+ meta: { cwd },
203
+ agentOptions: { provider, model },
204
+ setup: (agentCtx) => installModelSelection(agentCtx, { current: { provider, model }, assembled: undefined }),
205
+ })
206
+ await handle.agent.whenIdle()
207
+ return { key, agent: handle.agent, dispose: handle.dispose, busy: Promise.resolve(), lastUsed: Date.now(), abort: undefined }
208
+ }
209
+
210
+ async buildUserContent(input, signal) {
211
+ const { text, attachments = [], replyText } = input
212
+ const parts = []
213
+ if (this.config.agent?.instructionPrefix) parts.push(String(this.config.agent.instructionPrefix))
214
+ if (replyText?.trim()) parts.push(`[Ответ на сообщение: ${replyText.trim()}]`)
215
+ const blocks = []
216
+ for (const att of attachments) {
217
+ if (att.kind === 'photo') {
218
+ try {
219
+ const { ref } = await attachInboundPhoto(this.ctx, att, {
220
+ signal,
221
+ maxBytes: Number(this.config.media?.maxImageBytes) || 20 * 1024 * 1024,
222
+ })
223
+ blocks.push({ type: 'image', attachment: ref })
224
+ } catch (err) {
225
+ if (signal?.aborted) throw err
226
+ const msg = err instanceof Error ? err.message : String(err)
227
+ parts.push(`[Не удалось приложить изображение: ${msg}]`)
228
+ }
229
+ } else if (att.kind === 'voice' || att.kind === 'audio') {
230
+ try {
231
+ const bytes = new Uint8Array(await readFile(att.path))
232
+ const transcript = await transcribeVoice(this.baseUrl(), bytes, att.mime || 'audio/ogg', 'message', signal)
233
+ parts.push(transcript ? `[Голосовое сообщение, расшифровка: ${transcript}]` : '[Голосовое сообщение (не удалось распознать)]')
234
+ } catch (err) {
235
+ if (signal?.aborted) throw err
236
+ const msg = err instanceof Error ? err.message : String(err)
237
+ this.ctx.logger?.warn?.(`voice: ${msg}`)
238
+ parts.push(`[Голосовое сообщение (dsh-voice недоступен: ${msg})]`)
239
+ }
240
+ } else if (att.kind === 'document' || att.kind === 'video') {
241
+ parts.push(formatInboundDocument(att))
242
+ } else {
243
+ parts.push(`[Файл: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
244
+ }
245
+ }
246
+ const photoHint = photoOnlyHint(attachments, text)
247
+ if (photoHint) parts.push(photoHint)
248
+ if (text?.trim()) parts.push(text.trim())
249
+ const textBlock = parts.filter(Boolean).join('\n\n')
250
+ if (textBlock) blocks.unshift({ type: 'text', text: textBlock })
251
+ if (!blocks.length) blocks.push({ type: 'text', text: '(пустое сообщение)' })
252
+ return blocks
253
+ }
254
+
255
+ async runTurn(chat, input, signal) {
256
+ const { reply, typing } = input
257
+ const sessionId = chat.agent.session.id
258
+ const collector = { parts: [], images: [], reason: undefined }
259
+ this.pending.set(sessionId, collector)
260
+ try {
261
+ if (signal.aborted) return
262
+ if (typing) await typing()
263
+ const content = await this.buildUserContent(input, signal)
264
+ chat.agent.followup(createUserMessage({
265
+ content,
266
+ source: { kind: 'plugin', plugin: PLUGIN, form: 'relay' },
267
+ }))
268
+ await chat.agent.whenIdle()
269
+ if (signal.aborted) return
270
+ await this.ctx.sessions.flush(chat.agent.session)
271
+ const answer = stripImageUrls(collector.parts.join('\n\n'))
272
+ if (collector.reason?.kind === 'error') {
273
+ const err = collector.reason.error
274
+ return reply(`Ошибка агента: ${err?.code || 'error'}: ${err?.message || 'unknown'}`)
275
+ }
276
+ const files = await buildOutboundFiles(this.ctx, this.baseUrl(), collector, { signal, logger: this.ctx.logger })
277
+ if (!answer && !files.length) return reply('(нет ответа)')
278
+ const maxLen = Number(this.config.agent?.maxMessageLength) || 4000
279
+ const chunks = answer ? splitText(answer, maxLen) : ['']
280
+ for (let i = 0; i < chunks.length; i++) {
281
+ await reply({ text: chunks[i] || undefined, files: i === 0 ? files : [] })
282
+ }
283
+ if (this.config.tts?.enabled && !signal.aborted) {
284
+ const ttsText = prepareTtsText(answer, this.config.tts?.maxChars)
285
+ if (ttsText) {
286
+ try {
287
+ const spoken = await speakText(this.baseUrl(), ttsText, signal)
288
+ if (!signal.aborted) await reply({ files: [voiceReplyFile(spoken)] })
289
+ } catch (e) {
290
+ if (!signal?.aborted) this.ctx.logger?.warn?.(`tts: ${e.message}`)
291
+ }
292
+ }
293
+ }
294
+ } catch (err) {
295
+ if (!signal?.aborted) {
296
+ try { await reply(`Сбой: ${err.message}`) } catch {}
297
+ }
298
+ } finally {
299
+ this.pending.delete(sessionId)
300
+ }
301
+ }
302
+
303
+ reapIdle() {
304
+ const timeout = Number(this.config.agent?.idleTimeoutMs) || 0
305
+ if (timeout <= 0) return
306
+ const now = Date.now()
307
+ for (const [key, chat] of this.chats) {
308
+ if (now - chat.lastUsed > timeout) { this.chats.delete(key); chat.dispose().catch(() => {}) }
309
+ }
310
+ }
311
+
312
+ get messenger() {
313
+ return {
314
+ adapters: () => [...this.adapters.keys()],
315
+ activeChats: () => this.chats.size,
316
+ send: (target, payload) => this.messengerSend(target, payload),
317
+ ask: (target, payload, timeoutMs) => this.messengerAsk(target, payload, timeoutMs),
318
+ progress: (target, payload) => this.messengerProgress(target, payload),
319
+ }
320
+ }
321
+ }
package/lib/http.js ADDED
@@ -0,0 +1,18 @@
1
+ export async function readBody(req) {
2
+ const chunks = []
3
+ for await (const chunk of req) chunks.push(chunk)
4
+ return Buffer.concat(chunks)
5
+ }
6
+
7
+ export function writeJson(res, status, body) {
8
+ res.statusCode = status
9
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
10
+ res.end(JSON.stringify(body))
11
+ }
12
+
13
+ export function isTrustedSettingsRequest(req) {
14
+ const origin = String(req.headers?.origin || '')
15
+ const host = String(req.headers?.host || '')
16
+ if (!origin || !host) return false
17
+ try { return new URL(origin).host === host } catch { return false }
18
+ }
package/lib/index.js ADDED
@@ -0,0 +1,174 @@
1
+ import z from '@deepseek-ai/schemastery'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
5
+ import { Gateway } from './gateway.js'
6
+ import { readBody, writeJson, isTrustedSettingsRequest } from './http.js'
7
+ import {
8
+ createMessengerService, dispatchMessenger, httpStatusForError,
9
+ messengerApiSchema, parseMessengerBody,
10
+ } from './messenger-api.js'
11
+
12
+ export const name = 'dsh-messenger-gateway'
13
+ export const inject = ['agentDefaultModel', 'agents', 'sessions', 'loader', 'settings', 'webServer', 'attachments']
14
+
15
+ export const SETTINGS_NAMESPACE = settingsNamespace('dsh-messenger-gateway')
16
+
17
+ export const Config = z.object({
18
+ enabled: z.boolean().default(true),
19
+ internalBaseURL: z.string().default('http://127.0.0.1:3080'),
20
+ telegram: z.object({
21
+ enabled: z.boolean().default(false),
22
+ botToken: z.string().role('secret').default(''),
23
+ allowedUserIds: z.array(z.number()).default([]),
24
+ pollTimeoutSeconds: z.number().default(50),
25
+ pollIntervalMs: z.number().default(500),
26
+ }),
27
+ discord: z.object({
28
+ enabled: z.boolean().default(false),
29
+ botToken: z.string().role('secret').default(''),
30
+ }),
31
+ media: z.object({
32
+ cacheDir: z.string().default(''),
33
+ maxDocBytes: z.number().default(20 * 1024 * 1024),
34
+ maxTextInjectBytes: z.number().default(100 * 1024),
35
+ maxImageBytes: z.number().default(20 * 1024 * 1024),
36
+ }),
37
+ tts: z.object({
38
+ enabled: z.boolean().default(false),
39
+ maxChars: z.number().default(4000),
40
+ }),
41
+ agent: z.object({
42
+ provider: z.string().default(''),
43
+ model: z.string().default(''),
44
+ cwd: z.string().default(''),
45
+ instructionPrefix: z.string().default(''),
46
+ maxMessageLength: z.number().default(4000),
47
+ idleTimeoutMs: z.number().default(3_600_000),
48
+ }),
49
+ })
50
+
51
+ function resolveConfig(raw) {
52
+ const cfg = Config(raw)
53
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
54
+ if (!cfg.media.cacheDir) {
55
+ cfg.media = { ...cfg.media, cacheDir: join(home, 'messenger-gateway', 'cache') }
56
+ }
57
+ return cfg
58
+ }
59
+
60
+ function publicConfig(cfg) {
61
+ return {
62
+ enabled: cfg.enabled,
63
+ internalBaseURL: cfg.internalBaseURL,
64
+ telegram: {
65
+ enabled: cfg.telegram.enabled,
66
+ allowedUserIds: cfg.telegram.allowedUserIds,
67
+ pollTimeoutSeconds: cfg.telegram.pollTimeoutSeconds,
68
+ pollIntervalMs: cfg.telegram.pollIntervalMs,
69
+ botTokenConfigured: Boolean(String(cfg.telegram.botToken || '').trim()),
70
+ },
71
+ discord: { enabled: cfg.discord.enabled, botTokenConfigured: Boolean(String(cfg.discord.botToken || '').trim()) },
72
+ media: { maxDocBytes: cfg.media.maxDocBytes, maxTextInjectBytes: cfg.media.maxTextInjectBytes, maxImageBytes: cfg.media.maxImageBytes },
73
+ tts: cfg.tts,
74
+ agent: cfg.agent,
75
+ }
76
+ }
77
+
78
+ function registerMessengerRoute(ctx, getGw, path, action) {
79
+ ctx.effect(() => ctx.webServer.register({
80
+ kind: 'exact', path,
81
+ handler: async (req, res) => {
82
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
83
+ let payload
84
+ try {
85
+ payload = parseMessengerBody((await readBody(req)).toString('utf8'))
86
+ } catch (err) {
87
+ return writeJson(res, httpStatusForError(err), { ok: false, error: err.message })
88
+ }
89
+ try {
90
+ const out = await dispatchMessenger(getGw(), action, payload)
91
+ writeJson(res, 200, out)
92
+ } catch (err) {
93
+ writeJson(res, httpStatusForError(err), { ok: false, error: err.message })
94
+ }
95
+ },
96
+ }), `dsh-messenger-gateway: ${action}`)
97
+ }
98
+
99
+ export function apply(ctx, config) {
100
+ const entry = resolveConfig(structuredClone(config || {}))
101
+ let gateway
102
+ let source = () => entry
103
+ let settingsApi
104
+
105
+ const sync = () => {
106
+ if (gateway) { gateway.stop(); gateway = undefined }
107
+ const effective = source()
108
+ if (effective.enabled === false) return
109
+ gateway = new Gateway(ctx, effective)
110
+ gateway.start().catch((err) => ctx.logger?.warn?.(`dsh-messenger-gateway: ${err.message}`))
111
+ }
112
+
113
+ if (ctx.settings?.register) {
114
+ settingsApi = ctx.settings.register(SETTINGS_NAMESPACE, Config, { base: config || {} })
115
+ source = () => resolveConfig(settingsApi.get() ?? config ?? {})
116
+ ctx.effect(() => settingsApi.watch(sync), 'dsh-messenger-gateway: settings')
117
+ }
118
+
119
+ const getGw = () => gateway
120
+
121
+ ctx.effect(() => ctx.provide('messenger', createMessengerService(getGw)), 'dsh-messenger-gateway: messenger service')
122
+
123
+ ctx.effect(() => ctx.webServer.register({
124
+ kind: 'exact', path: '/dsh-messenger-gateway/status',
125
+ handler: async (req, res) => {
126
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
127
+ const gw = getGw()
128
+ writeJson(res, 200, {
129
+ ok: true,
130
+ running: Boolean(gw),
131
+ adapters: gw?.messenger.adapters() || [],
132
+ activeChats: gw?.messenger.activeChats() || 0,
133
+ ttsEnabled: Boolean(source().tts?.enabled),
134
+ messengerService: 'messenger',
135
+ config: publicConfig(source()),
136
+ })
137
+ },
138
+ }), 'dsh-messenger-gateway: status')
139
+
140
+ ctx.effect(() => ctx.webServer.register({
141
+ kind: 'exact', path: '/dsh-messenger-gateway/messenger',
142
+ handler: async (req, res) => {
143
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
144
+ writeJson(res, 200, { ok: true, schema: messengerApiSchema })
145
+ },
146
+ }), 'dsh-messenger-gateway: messenger schema')
147
+
148
+ ctx.effect(() => ctx.webServer.register({
149
+ kind: 'exact', path: '/dsh-messenger-gateway/config',
150
+ handler: async (req, res) => {
151
+ if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
152
+ if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
153
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
154
+ if (!settingsApi) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
155
+ let payload
156
+ try { payload = JSON.parse((await readBody(req)).toString('utf8') || '{}') } catch { return writeJson(res, 400, { ok: false, error: 'invalid json' }) }
157
+ if (payload && typeof payload.config === 'object') payload = payload.config
158
+ try {
159
+ await settingsApi.replace(Config({ ...source(), ...payload }))
160
+ sync()
161
+ writeJson(res, 200, { ok: true, config: publicConfig(source()) })
162
+ } catch (err) {
163
+ writeJson(res, 400, { ok: false, error: err.message })
164
+ }
165
+ },
166
+ }), 'dsh-messenger-gateway: config')
167
+
168
+ registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send')
169
+ registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress')
170
+ registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/ask', 'ask')
171
+
172
+ ctx.on('dispose', () => { if (gateway) gateway.stop() })
173
+ sync()
174
+ }
@@ -0,0 +1,40 @@
1
+ export function formatApiError(data, status) {
2
+ const err = data?.error
3
+ const msg = typeof err === 'string' ? err
4
+ : (err?.message || err?.code || JSON.stringify(data).slice(0, 200))
5
+ return `${msg} (HTTP ${status})`
6
+ }
7
+
8
+ export async function postJson(baseUrl, path, body, signal) {
9
+ const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
10
+ const res = await fetch(url, {
11
+ method: 'POST',
12
+ headers: { 'Content-Type': 'application/json' },
13
+ body: JSON.stringify(body),
14
+ signal,
15
+ })
16
+ const data = await res.json().catch(() => ({}))
17
+ if (!res.ok || data.ok === false) {
18
+ throw new Error(`${path} failed: ${formatApiError(data, res.status)}`)
19
+ }
20
+ return data
21
+ }
22
+
23
+ /** Transcribe audio via dsh-voice message chain (Telegram voice/audio). */
24
+ export async function transcribeVoice(baseUrl, bytes, mimeType, mode = 'message', signal) {
25
+ const data = await postJson(baseUrl, '/dsh-voice/transcribe', {
26
+ dataBase64: Buffer.from(bytes).toString('base64'),
27
+ mimeType: mimeType || 'audio/ogg',
28
+ mode,
29
+ }, signal)
30
+ return String(data.text || '').trim()
31
+ }
32
+
33
+ export async function speakText(baseUrl, text, signal) {
34
+ const data = await postJson(baseUrl, '/dsh-tts/speak', { text }, signal)
35
+ return {
36
+ audio: Buffer.from(data.audioBase64, 'base64'),
37
+ mime: data.mime || 'audio/mpeg',
38
+ provider: data.provider,
39
+ }
40
+ }
package/lib/media.js ADDED
@@ -0,0 +1,68 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { basename, extname, join } from 'node:path'
3
+
4
+ export const TELEGRAM_MAX_DOC_BYTES = 20 * 1024 * 1024
5
+
6
+ export const SUPPORTED_DOCUMENT_TYPES = {
7
+ '.pdf': 'application/pdf', '.md': 'text/markdown', '.txt': 'text/plain', '.csv': 'text/csv',
8
+ '.log': 'text/plain', '.json': 'application/json', '.xml': 'application/xml',
9
+ '.yaml': 'application/yaml', '.yml': 'application/yaml', '.toml': 'application/toml',
10
+ '.ini': 'text/plain', '.cfg': 'text/plain', '.zip': 'application/zip',
11
+ '.doc': 'application/msword',
12
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
13
+ '.xls': 'application/vnd.ms-excel',
14
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
15
+ '.ppt': 'application/vnd.ms-powerpoint',
16
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
17
+ '.ts': 'text/plain', '.py': 'text/plain', '.sh': 'text/plain',
18
+ }
19
+
20
+ export const IMAGE_EXT_TO_MIME = {
21
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
22
+ }
23
+
24
+ export const VIDEO_EXT_TO_MIME = {
25
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
26
+ }
27
+
28
+ export function extOf(name, mime) {
29
+ let ext = name ? extname(name).toLowerCase() : ''
30
+ const m = String(mime || '').toLowerCase()
31
+ if (!ext && m) ext = Object.entries(IMAGE_EXT_TO_MIME).find(([, v]) => v === m)?.[0] || ''
32
+ return ext
33
+ }
34
+
35
+ export function classifyDocument(ext, mime) {
36
+ const m = String(mime || '').toLowerCase()
37
+ if (IMAGE_EXT_TO_MIME[ext] || m.startsWith('image/')) return 'image'
38
+ if (VIDEO_EXT_TO_MIME[ext] || Object.values(VIDEO_EXT_TO_MIME).includes(m)) return 'video'
39
+ if (SUPPORTED_DOCUMENT_TYPES[ext]) return 'doc'
40
+ return 'unsupported'
41
+ }
42
+
43
+ export function mediaKindOf(path) {
44
+ const ext = extname(path).toLowerCase()
45
+ if (IMAGE_EXT_TO_MIME[ext]) return 'photo'
46
+ if (['.ogg', '.opus', '.oga'].includes(ext)) return 'voice'
47
+ if (['.mp3', '.m4a', '.wav', '.flac'].includes(ext)) return 'audio'
48
+ return 'document'
49
+ }
50
+
51
+ export function safeName(name) {
52
+ return String(name || 'file').replace(/[^\w.\- ]/g, '_').slice(0, 120) || 'file'
53
+ }
54
+
55
+ export function saveToCache(cacheDir, name, bytes) {
56
+ mkdirSync(cacheDir, { recursive: true })
57
+ const path = join(cacheDir, name)
58
+ writeFileSync(path, bytes)
59
+ return path
60
+ }
61
+
62
+ export function cacheName(prefix, ext, name) {
63
+ const base = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
64
+ const display = name ? safeName(name) : ''
65
+ return display ? `${base}-${display}${ext || ''}` : `${base}${ext || ''}`
66
+ }
67
+
68
+ export { basename }