@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.
- package/lib/gateway-turn.js +288 -288
- package/lib/gateway.js +8 -8
- package/lib/http.js +73 -10
- package/lib/index.js +87 -46
- package/lib/messenger-api.js +1 -0
- package/lib/scheduler.js +169 -169
- package/lib/settings-bridge.js +111 -0
- package/lib/stream.js +8 -5
- package/lib/telegram-errors.js +0 -0
- package/package.json +10 -8
package/lib/gateway-turn.js
CHANGED
|
@@ -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 {
|
|
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
|
}
|