@goodandready/dsh-messenger-gateway 0.3.21 → 0.4.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 +1 -1
- package/cordis.patch.yml +1 -1
- package/lib/adapters/discord.js +9 -1
- package/lib/adapters/slack.js +7 -1
- package/lib/adapters/telegram-inbound.js +141 -0
- package/lib/adapters/telegram.js +42 -154
- package/lib/alerts.js +104 -63
- package/lib/api-health.js +38 -0
- package/lib/artifacts.js +118 -118
- package/lib/ask.js +203 -152
- package/lib/client.js +1660 -1312
- package/lib/commands.js +53 -0
- package/lib/config.js +104 -104
- package/lib/content-guard.js +14 -14
- package/lib/documents.js +1 -1
- package/lib/file-manager.js +27 -1
- package/lib/forum-mirror.js +105 -0
- package/lib/gateway-callbacks.js +187 -0
- package/lib/gateway-commands.js +584 -0
- package/lib/gateway-turn.js +285 -0
- package/lib/gateway.js +590 -1737
- package/lib/http.js +12 -1
- package/lib/index.js +48 -8
- package/lib/integrations.js +1 -1
- package/lib/locales/en.js +123 -114
- package/lib/locales/index.js +21 -21
- package/lib/locales/zh.js +123 -114
- package/lib/media.js +1 -9
- package/lib/models.js +6 -3
- package/lib/outbound.js +1 -1
- package/lib/pairing.js +8 -4
- package/lib/personas.js +8 -4
- package/lib/photos.js +1 -1
- package/lib/scheduler.js +14 -4
- package/lib/stream.js +115 -115
- package/lib/text.js +39 -39
- package/lib/topics.js +0 -14
- package/lib/tts.js +0 -10
- package/lib/updater.js +304 -0
- package/lib/voice-prefs.js +8 -4
- package/package.json +69 -65
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
2
|
+
import { ensureContentArray } from './content-guard.js'
|
|
3
|
+
import { getPersona } from './personas.js'
|
|
4
|
+
import { MESSENGER_RELAY_INSTRUCTION, stripReasoningPreamble, splitText } from './text.js'
|
|
5
|
+
import { formatInboundDocument, documentOnlyHint, parseDocument } from './documents.js'
|
|
6
|
+
import { t } from './locales/index.js'
|
|
7
|
+
import { processDiagramsAndTables } from './artifacts.js'
|
|
8
|
+
import { prepareTtsText, toTelegramVoiceFile } from './tts.js'
|
|
9
|
+
import { shouldSpeakReply } from './voice-prefs.js'
|
|
10
|
+
import { speakText } from './integrations.js'
|
|
11
|
+
import {
|
|
12
|
+
buildStreamPreview, formatProgressLine, createEditScheduler, startTypingHeartbeat
|
|
13
|
+
} from './stream.js'
|
|
14
|
+
|
|
15
|
+
const PLUGIN = 'dsh-messenger-gateway'
|
|
16
|
+
|
|
17
|
+
export function whenIdleWithTimeout(agent, timeoutMs, signal) {
|
|
18
|
+
const idle = agent.whenIdle()
|
|
19
|
+
if (!timeoutMs || timeoutMs <= 0) return idle
|
|
20
|
+
return Promise.race([
|
|
21
|
+
idle,
|
|
22
|
+
new Promise((_, reject) => {
|
|
23
|
+
const timer = setTimeout(() => reject(new Error('turn timeout (' + timeoutMs + 'ms)')), timeoutMs)
|
|
24
|
+
timer.unref?.()
|
|
25
|
+
signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
|
|
26
|
+
}),
|
|
27
|
+
])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function answerApproval(gw, chatKeyValue, req, next) {
|
|
31
|
+
try {
|
|
32
|
+
const chat = gw.chats.get(chatKeyValue)
|
|
33
|
+
if (!chat?.target) return next()
|
|
34
|
+
const tool = req.toolName || 'tool'
|
|
35
|
+
if (chat.sessionAllowlist?.has(tool)) {
|
|
36
|
+
return 'allowed-once'
|
|
37
|
+
}
|
|
38
|
+
const locale = gw.resolveLocale(chat.target)
|
|
39
|
+
const reason = req.reason ? `\n<i>${req.reason}</i>` : ''
|
|
40
|
+
let detail = ''
|
|
41
|
+
if (req.input && typeof req.input === 'object') {
|
|
42
|
+
try {
|
|
43
|
+
const jsonStr = JSON.stringify(req.input, null, 2)
|
|
44
|
+
detail = `\n<pre><code>${jsonStr.slice(0, 500)}</code></pre>`
|
|
45
|
+
} catch { /* safe JSON serialization fallback */ }
|
|
46
|
+
}
|
|
47
|
+
const text = t('ask.confirm_title', { tool, reason: reason + detail }, locale)
|
|
48
|
+
const buttons = [
|
|
49
|
+
[
|
|
50
|
+
{ id: 'allow_once', text: t('ask.allow_once', {}, locale) },
|
|
51
|
+
{ id: 'allow_session', text: t('ask.allow_session', {}, locale) },
|
|
52
|
+
{ id: 'deny', text: t('ask.deny', {}, locale) },
|
|
53
|
+
],
|
|
54
|
+
]
|
|
55
|
+
const result = await gw.messengerAsk(chat.target, { text, buttons }, 300_000)
|
|
56
|
+
if (result?.buttonId === 'allow_once' || result?.buttonId === 'allow') return 'allowed-once'
|
|
57
|
+
if (result?.buttonId === 'allow_session') {
|
|
58
|
+
if (!chat.sessionAllowlist) chat.sessionAllowlist = new Set()
|
|
59
|
+
chat.sessionAllowlist.add(tool)
|
|
60
|
+
return 'allowed-once'
|
|
61
|
+
}
|
|
62
|
+
if (result?.buttonId === 'deny') return 'rejected'
|
|
63
|
+
return next()
|
|
64
|
+
} catch {
|
|
65
|
+
return next()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function buildUserContent(gw, input, signal) {
|
|
70
|
+
const { text, attachments = [], replyText, steer, personaOverride } = input
|
|
71
|
+
const parts = []
|
|
72
|
+
parts.push(String(gw.config.agent?.instructionPrefix || MESSENGER_RELAY_INSTRUCTION))
|
|
73
|
+
const activePersonaId = personaOverride || gw.personas.getPersonaForChat(input.chatId, input.threadId)
|
|
74
|
+
const activePersona = getPersona(activePersonaId)
|
|
75
|
+
if (activePersona?.instruction) {
|
|
76
|
+
parts.push(`[Persona: ${activePersona.name} (${activePersona.icon})]\n${activePersona.instruction}`)
|
|
77
|
+
}
|
|
78
|
+
if (steer) parts.push('[Steer / addition to current turn: combine with previous instruction, do not restart from scratch]')
|
|
79
|
+
if (replyText?.trim()) parts.push(`[Replying to message: ${replyText.trim()}]`)
|
|
80
|
+
const blocks = []
|
|
81
|
+
for (const att of attachments) {
|
|
82
|
+
if (att.kind === 'photo' || (att.kind === 'sticker' && att.mime?.startsWith('image/'))) {
|
|
83
|
+
try {
|
|
84
|
+
const { ref } = await attachInboundPhoto(gw.ctx, att, {
|
|
85
|
+
signal,
|
|
86
|
+
maxBytes: Number(gw.config.media?.maxImageBytes) || 20 * 1024 * 1024,
|
|
87
|
+
})
|
|
88
|
+
blocks.push({ type: 'image', attachment: ref })
|
|
89
|
+
if (att.kind === 'sticker' && att.emoji) parts.push(`[Sticker ${att.emoji}]`)
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (signal?.aborted) throw err
|
|
92
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
93
|
+
parts.push(`[Failed to attach image: ${msg}]`)
|
|
94
|
+
}
|
|
95
|
+
} else if (att.kind === 'voice' || att.kind === 'audio') {
|
|
96
|
+
try {
|
|
97
|
+
const bytes = new Uint8Array(await readFile(att.path))
|
|
98
|
+
const transcript = await transcribeVoice(gw.baseUrl(), bytes, att.mime || 'audio/ogg', 'message', signal)
|
|
99
|
+
parts.push(transcript ? `[Voice message transcript: ${transcript}]` : '[Voice message (unrecognized)]')
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (signal?.aborted) throw err
|
|
102
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
103
|
+
gw.ctx.logger?.warn?.(`voice: ${msg}`)
|
|
104
|
+
parts.push(`[Voice message (dsh-voice unavailable: ${msg})]`)
|
|
105
|
+
}
|
|
106
|
+
} else if (att.kind === 'document' || att.kind === 'video' || att.kind === 'animation' || att.kind === 'sticker') {
|
|
107
|
+
let parsed = null
|
|
108
|
+
if (att.kind === 'document' && att.path) {
|
|
109
|
+
try {
|
|
110
|
+
const maxDocBytes = Number(gw.config.media?.maxTextInjectBytes) || 100 * 1024
|
|
111
|
+
parsed = await parseDocument(att.path, { maxBytes: maxDocBytes })
|
|
112
|
+
} catch (err) {
|
|
113
|
+
gw.logger?.debug?.('parseDocument fallback:', err?.message || err)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
parts.push(formatInboundDocument(att, parsed))
|
|
117
|
+
} else {
|
|
118
|
+
parts.push(`[File: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const photoHint = photoOnlyHint(attachments, text)
|
|
122
|
+
if (photoHint) parts.push(photoHint)
|
|
123
|
+
const docHint = documentOnlyHint(attachments, text)
|
|
124
|
+
if (docHint) parts.push(docHint)
|
|
125
|
+
if (text?.trim()) parts.push(text.trim())
|
|
126
|
+
const textBlock = parts.filter(Boolean).join('\n\n')
|
|
127
|
+
if (textBlock) blocks.unshift({ type: 'text', text: textBlock })
|
|
128
|
+
if (!blocks.length) blocks.push({ type: 'text', text: '(empty message)' })
|
|
129
|
+
return blocks
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
export async function runGatewayTurn(gw, chat, input, signal) {
|
|
134
|
+
const { reply, typing, startStream, startProgress, react, inboundWasVoice, userId } = input
|
|
135
|
+
chat.turnActive = true
|
|
136
|
+
const sessionId = chat.agent.session.id
|
|
137
|
+
const tg = gw.tg()
|
|
138
|
+
const streaming = tg.streaming === true && typeof startStream === 'function'
|
|
139
|
+
const progressEnabled = tg.progressEnabled !== false
|
|
140
|
+
const collector = { parts: [], lastText: '', streamText: '', toolName: '', images: [], reason: undefined, onStream: undefined }
|
|
141
|
+
gw.pending.set(sessionId, collector)
|
|
142
|
+
let stopTyping = () => {}
|
|
143
|
+
let stream = null
|
|
144
|
+
let scheduler = null
|
|
145
|
+
let progress = null
|
|
146
|
+
try {
|
|
147
|
+
if (signal.aborted) return
|
|
148
|
+
if (typeof react === 'function' && tg.reactionsEnabled !== false) {
|
|
149
|
+
react('👀').catch?.(() => {})
|
|
150
|
+
}
|
|
151
|
+
if (typeof typing === 'function') stopTyping = startTypingHeartbeat(typing, 4000)
|
|
152
|
+
if (streaming) {
|
|
153
|
+
try {
|
|
154
|
+
stream = await startStream()
|
|
155
|
+
scheduler = createEditScheduler((text) => stream.edit(text), Number(tg.streamEditIntervalMs) || 1200)
|
|
156
|
+
collector.onStream = (text, toolName) => {
|
|
157
|
+
if (text) stopTyping()
|
|
158
|
+
if (!progressEnabled && !text) return
|
|
159
|
+
scheduler.push(buildStreamPreview(text, progressEnabled ? toolName : ''))
|
|
160
|
+
}
|
|
161
|
+
if (progressEnabled) scheduler.push(buildStreamPreview('', ''))
|
|
162
|
+
} catch (e) {
|
|
163
|
+
gw.ctx.logger?.warn?.(`stream start: ${e.message}`)
|
|
164
|
+
stream = null
|
|
165
|
+
}
|
|
166
|
+
} else if (progressEnabled && typeof startProgress === 'function') {
|
|
167
|
+
try {
|
|
168
|
+
progress = await startProgress()
|
|
169
|
+
const editProgress = createEditScheduler((text) => progress.edit(text), 800)
|
|
170
|
+
collector.onStream = (_text, toolName) => {
|
|
171
|
+
editProgress.push(formatProgressLine(toolName))
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
gw.ctx.logger?.warn?.(`progress start: ${e.message}`)
|
|
175
|
+
progress = null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
const content = await gw.buildUserContent(input, signal)
|
|
181
|
+
chat.agent.followup(createUserMessage({
|
|
182
|
+
content: ensureContentArray(content),
|
|
183
|
+
source: { kind: 'user', plugin: PLUGIN, form: 'relay', origin: 'telegram' },
|
|
184
|
+
}))
|
|
185
|
+
const turnTimeoutMs = Number(gw.config.agent?.turnTimeoutMs) || 600_000
|
|
186
|
+
await whenIdleWithTimeout(chat.agent, turnTimeoutMs, signal)
|
|
187
|
+
if (signal.aborted) {
|
|
188
|
+
if (progress) try { await progress.remove() } catch (err) { gw.logger?.debug?.('progress.remove failed on abort:', err?.message || err) }
|
|
189
|
+
if (typeof react === 'function') react('').catch?.(() => {})
|
|
190
|
+
const stoppedMsg = t('msg.turn_stopped', {}, gw.resolveLocale(input))
|
|
191
|
+
if (stream) try { await stream.finalize(stoppedMsg) } catch (err) { gw.recordApiFailure('stream.finalize.stopped', err) }
|
|
192
|
+
else return reply(stoppedMsg)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const sessions = gw.ctx.get?.('sessions') || gw.ctx.sessions
|
|
196
|
+
await sessions?.flush?.(chat.agent.session)
|
|
197
|
+
if (progress) try { await progress.remove() } catch (err) { gw.logger?.debug?.('progress.remove failed after turn:', err?.message || err) }
|
|
198
|
+
progress = null
|
|
199
|
+
if (typeof react === 'function') react('').catch?.(() => {})
|
|
200
|
+
const rawAnswer = stripReasoningPreamble(stripImageUrls(collector.lastText || collector.streamText || collector.parts.join('\n\n')))
|
|
201
|
+
const processed = processDiagramsAndTables(rawAnswer, {
|
|
202
|
+
artifactPreviews: gw.tg().artifactPreviews !== false,
|
|
203
|
+
})
|
|
204
|
+
const answer = processed.text
|
|
205
|
+
if (collector.reason?.kind === 'error') {
|
|
206
|
+
const err = collector.reason.error
|
|
207
|
+
const msg = t('msg.agent_error', { code: err?.code || 'error', message: err?.message || 'unknown' }, gw.resolveLocale(input))
|
|
208
|
+
gw.sendAlert('error', {
|
|
209
|
+
code: err?.code || 'AGENT_ERROR',
|
|
210
|
+
message: err?.message || 'unknown',
|
|
211
|
+
sessionId,
|
|
212
|
+
chatId: input.chatId,
|
|
213
|
+
threadId: input.threadId,
|
|
214
|
+
}).catch(() => {})
|
|
215
|
+
if (stream) { await scheduler?.flush(); await stream.finalize(msg) }
|
|
216
|
+
else await reply(msg)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const files = await buildOutboundFiles(gw.ctx, gw.baseUrl(), collector, { signal, logger: gw.ctx.logger })
|
|
220
|
+
const allFiles = [...files, ...(processed.files || [])]
|
|
221
|
+
if (!answer && !allFiles.length) {
|
|
222
|
+
const noResp = t('msg.no_response', {}, gw.resolveLocale(input))
|
|
223
|
+
if (stream) { await scheduler?.flush(); await stream.finalize(noResp) }
|
|
224
|
+
else await reply(noResp)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
const maxLen = Number(gw.config.agent?.maxMessageLength) || 4000
|
|
228
|
+
const chunks = answer ? splitText(answer, maxLen) : ['']
|
|
229
|
+
if (stream) {
|
|
230
|
+
await scheduler?.flush()
|
|
231
|
+
await stream.finalize(chunks[0] || t('msg.no_response', {}, gw.resolveLocale(input)))
|
|
232
|
+
for (let i = 1; i < chunks.length; i++) await reply({ text: chunks[i] })
|
|
233
|
+
if (allFiles.length) await reply({ files: allFiles })
|
|
234
|
+
} else {
|
|
235
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
236
|
+
await reply({ text: chunks[i] || undefined, files: i === 0 ? allFiles : [] })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const chatTtsPref = gw.chatTts.get(chat.target?.chatId)
|
|
240
|
+
const speak = shouldSpeakReply({
|
|
241
|
+
globalTts: Boolean(gw.config.tts?.enabled),
|
|
242
|
+
voiceMode: gw.tg().voiceMode || 'mirror',
|
|
243
|
+
inboundWasVoice: Boolean(inboundWasVoice),
|
|
244
|
+
userPref: gw.voicePrefs.get(userId),
|
|
245
|
+
chatPref: chatTtsPref,
|
|
246
|
+
})
|
|
247
|
+
if (speak && !signal.aborted) {
|
|
248
|
+
const isVoiceSummary = gw.config.tts?.voiceSummary === true
|
|
249
|
+
const ttsText = prepareTtsText(answer, gw.config.tts?.maxChars, { voiceSummary: isVoiceSummary })
|
|
250
|
+
if (ttsText) {
|
|
251
|
+
try {
|
|
252
|
+
const spoken = await speakText(gw.baseUrl(), ttsText, signal)
|
|
253
|
+
const voiceFile = await toTelegramVoiceFile(spoken, { logger: gw.ctx.logger })
|
|
254
|
+
if (!signal.aborted && voiceFile) await reply({ files: [voiceFile] })
|
|
255
|
+
} catch (e) {
|
|
256
|
+
if (!signal?.aborted) gw.ctx.logger?.warn?.(`tts: ${e.message}`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (!signal?.aborted) {
|
|
262
|
+
gw.sendAlert('error', {
|
|
263
|
+
code: err?.code || 'EXCEPTION',
|
|
264
|
+
message: err?.message || String(err),
|
|
265
|
+
sessionId,
|
|
266
|
+
chatId: input.chatId,
|
|
267
|
+
threadId: input.threadId,
|
|
268
|
+
}).catch(() => {})
|
|
269
|
+
try {
|
|
270
|
+
const excMsg = t('msg.exception', { message: err.message }, gw.resolveLocale(input))
|
|
271
|
+
if (stream) await stream.finalize(excMsg)
|
|
272
|
+
else await reply(excMsg)
|
|
273
|
+
} catch (repErr) {
|
|
274
|
+
gw.recordApiFailure('reply.exception_turn', repErr)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
stopTyping()
|
|
279
|
+
if (progress) try { await progress.remove() } catch (err) { gw.logger?.debug?.('progress.remove failed in finally:', err?.message || err) }
|
|
280
|
+
if (typeof react === 'function') react('').catch?.(() => {})
|
|
281
|
+
chat.turnActive = false
|
|
282
|
+
chat.abort = undefined
|
|
283
|
+
gw.pending.delete(sessionId)
|
|
284
|
+
}
|
|
285
|
+
}
|