@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.
@@ -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
+ }