@goodandready/dsh-messenger-gateway 0.4.5 → 0.4.7

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