@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/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 }
|