@goodandready/dsh-messenger-gateway 0.4.10 → 0.4.12
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/ask.js +3 -1
- package/lib/client.js +16 -3
- package/lib/config.js +1 -0
- package/lib/documents.js +33 -7
- package/lib/gateway.js +46 -31
- package/lib/index.js +116 -72
- package/lib/messenger-api.js +23 -1
- package/lib/outbound.js +51 -6
- package/lib/personas.js +3 -4
- package/lib/scheduler.js +12 -4
- package/lib/settings-bridge.js +2 -2
- package/lib/storage-atomic.js +17 -0
- package/lib/stream.js +4 -0
- package/lib/updater.js +11 -6
- package/package.json +1 -1
package/lib/ask.js
CHANGED
|
@@ -56,7 +56,9 @@ export function buildInlineKeyboard(token, buttons) {
|
|
|
56
56
|
|
|
57
57
|
export function normalizeAskOptions(options = []) {
|
|
58
58
|
if (Array.isArray(options)) {
|
|
59
|
-
const flat =
|
|
59
|
+
const flat = typeof options.flat === 'function'
|
|
60
|
+
? options.flat()
|
|
61
|
+
: options.reduce((acc, item) => acc.concat(item), [])
|
|
60
62
|
return flat.map((opt, i) => {
|
|
61
63
|
if (typeof opt === 'string') return { id: String(i + 1), text: opt, selected: false }
|
|
62
64
|
const id = String(opt?.id ?? opt?.buttonId ?? i + 1).trim()
|
package/lib/client.js
CHANGED
|
@@ -17,6 +17,15 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
const ROW_ID = 'dsh-messenger-gateway'
|
|
18
18
|
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
|
|
19
19
|
|
|
20
|
+
function timeoutSignal(ms) {
|
|
21
|
+
if (typeof AbortSignal?.timeout === 'function') {
|
|
22
|
+
return AbortSignal.timeout(ms)
|
|
23
|
+
}
|
|
24
|
+
const controller = new AbortController()
|
|
25
|
+
setTimeout(() => controller.abort(new Error('TimeoutError')), ms)
|
|
26
|
+
return controller.signal
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
const en = {
|
|
21
30
|
title: 'Messenger gateway',
|
|
22
31
|
description: 'Telegram bot: text, voice, photos, documents, and interactive asks.',
|
|
@@ -593,7 +602,7 @@ window.__ModuleLoader__.load({
|
|
|
593
602
|
|
|
594
603
|
const loadStatus = React.useCallback(async () => {
|
|
595
604
|
try {
|
|
596
|
-
const res = await fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store' })
|
|
605
|
+
const res = await fetch(`${ROUTE_PREFIX}/status`, { cache: 'no-store', signal: timeoutSignal(10000) })
|
|
597
606
|
const data = await res.json().catch(() => ({}))
|
|
598
607
|
if (res.ok && data.ok) {
|
|
599
608
|
setStatus(data)
|
|
@@ -605,7 +614,7 @@ window.__ModuleLoader__.load({
|
|
|
605
614
|
|
|
606
615
|
const loadPairing = React.useCallback(async () => {
|
|
607
616
|
try {
|
|
608
|
-
const res = await fetch(`${ROUTE_PREFIX}/pairing`, { credentials: 'same-origin', cache: 'no-store' })
|
|
617
|
+
const res = await fetch(`${ROUTE_PREFIX}/pairing`, { credentials: 'same-origin', cache: 'no-store', signal: timeoutSignal(10000) })
|
|
609
618
|
const data = await res.json().catch(() => ({}))
|
|
610
619
|
if (res.ok && data.ok) setPending(Array.isArray(data.pending) ? data.pending : [])
|
|
611
620
|
} catch (err) {
|
|
@@ -626,7 +635,7 @@ window.__ModuleLoader__.load({
|
|
|
626
635
|
const loadUpdateStatus = React.useCallback(async () => {
|
|
627
636
|
setUpdateState((s) => ({ ...s, checking: true, error: '' }))
|
|
628
637
|
try {
|
|
629
|
-
const res = await fetch(`${ROUTE_PREFIX}/update`, { cache: 'no-store' })
|
|
638
|
+
const res = await fetch(`${ROUTE_PREFIX}/update`, { cache: 'no-store', signal: timeoutSignal(15000) })
|
|
630
639
|
const data = await res.json().catch(() => ({}))
|
|
631
640
|
if (res.ok && data.ok) {
|
|
632
641
|
setUpdateState((s) => ({
|
|
@@ -655,6 +664,7 @@ window.__ModuleLoader__.load({
|
|
|
655
664
|
'Content-Type': 'application/json',
|
|
656
665
|
'x-dsh-plugin-update': '1',
|
|
657
666
|
},
|
|
667
|
+
signal: timeoutSignal(60000),
|
|
658
668
|
})
|
|
659
669
|
const data = await res.json().catch(() => ({}))
|
|
660
670
|
if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
@@ -689,6 +699,7 @@ window.__ModuleLoader__.load({
|
|
|
689
699
|
credentials: 'same-origin',
|
|
690
700
|
headers: { 'Content-Type': 'application/json' },
|
|
691
701
|
body: JSON.stringify({ timeoutMs: 8000 }),
|
|
702
|
+
signal: timeoutSignal(15000),
|
|
692
703
|
})
|
|
693
704
|
const data = await res.json().catch(() => ({}))
|
|
694
705
|
if (!data.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
@@ -1348,6 +1359,7 @@ window.__ModuleLoader__.load({
|
|
|
1348
1359
|
credentials: 'same-origin',
|
|
1349
1360
|
headers: { 'Content-Type': 'application/json' },
|
|
1350
1361
|
body: JSON.stringify({ code: row.code }),
|
|
1362
|
+
signal: timeoutSignal(15000),
|
|
1351
1363
|
})
|
|
1352
1364
|
const data = await res.json()
|
|
1353
1365
|
if (!res.ok || !data.ok) throw new Error(data.error || res.status)
|
|
@@ -1376,6 +1388,7 @@ window.__ModuleLoader__.load({
|
|
|
1376
1388
|
credentials: 'same-origin',
|
|
1377
1389
|
headers: { 'Content-Type': 'application/json' },
|
|
1378
1390
|
body: JSON.stringify({ code: row.code }),
|
|
1391
|
+
signal: timeoutSignal(15000),
|
|
1379
1392
|
})
|
|
1380
1393
|
const data = await res.json()
|
|
1381
1394
|
if (!res.ok || !data.ok) throw new Error(data.error || res.status)
|
package/lib/config.js
CHANGED
|
@@ -9,6 +9,7 @@ const HomeEntry = z.object({
|
|
|
9
9
|
export const PluginConfig = z.object({
|
|
10
10
|
enabled: z.boolean().volatile().default(true),
|
|
11
11
|
internalBaseURL: z.string().volatile().default('http://127.0.0.1:3080'),
|
|
12
|
+
defaultLocale: z.union([z.const('en'), z.const('zh')]).default('en').description('Default language for bot messages and notifications (en, zh)'),
|
|
12
13
|
telegram: z.object({
|
|
13
14
|
enabled: z.boolean().default(false),
|
|
14
15
|
botToken: z.string().role('secret').default(''),
|
package/lib/documents.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { open } from 'node:fs/promises'
|
|
2
2
|
import { extname } from 'node:path'
|
|
3
3
|
import { inflateRawSync } from 'node:zlib'
|
|
4
4
|
|
|
@@ -132,7 +132,36 @@ export async function parseDocument(filePath, opts = {}) {
|
|
|
132
132
|
const ext = extname(filePath).toLowerCase()
|
|
133
133
|
|
|
134
134
|
try {
|
|
135
|
-
const
|
|
135
|
+
const handle = await open(filePath, 'r')
|
|
136
|
+
let rawBuffer
|
|
137
|
+
let truncated = false
|
|
138
|
+
let originalLength = 0
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const st = await handle.stat()
|
|
142
|
+
originalLength = st.size
|
|
143
|
+
|
|
144
|
+
if (TEXT_INJECT_EXTS.has(ext)) {
|
|
145
|
+
const toRead = Math.min(st.size, maxBytes)
|
|
146
|
+
const buf = Buffer.alloc(toRead)
|
|
147
|
+
const { bytesRead } = await handle.read(buf, 0, toRead, 0)
|
|
148
|
+
rawBuffer = buf.subarray(0, bytesRead)
|
|
149
|
+
truncated = st.size > toRead
|
|
150
|
+
} else if (ext === '.pdf' || ext === '.docx') {
|
|
151
|
+
const maxBinary = Math.max(maxBytes * 4, 10 * 1024 * 1024)
|
|
152
|
+
if (st.size > maxBinary) {
|
|
153
|
+
return { parsed: false, error: `File size (${st.size} bytes) exceeds safety parse limit` }
|
|
154
|
+
}
|
|
155
|
+
const buf = Buffer.alloc(st.size)
|
|
156
|
+
const { bytesRead } = await handle.read(buf, 0, st.size, 0)
|
|
157
|
+
rawBuffer = buf.subarray(0, bytesRead)
|
|
158
|
+
} else {
|
|
159
|
+
return { parsed: false, type: 'unsupported' }
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
await handle.close()
|
|
163
|
+
}
|
|
164
|
+
|
|
136
165
|
let text = ''
|
|
137
166
|
let parsedType = 'text'
|
|
138
167
|
|
|
@@ -145,15 +174,11 @@ export async function parseDocument(filePath, opts = {}) {
|
|
|
145
174
|
} else if (TEXT_INJECT_EXTS.has(ext)) {
|
|
146
175
|
parsedType = ext.slice(1)
|
|
147
176
|
text = rawBuffer.toString('utf8')
|
|
148
|
-
} else {
|
|
149
|
-
return { parsed: false, type: 'unsupported' }
|
|
150
177
|
}
|
|
151
178
|
|
|
152
179
|
text = text.trim()
|
|
153
180
|
if (!text) return { parsed: false, type: parsedType }
|
|
154
181
|
|
|
155
|
-
const originalLength = text.length
|
|
156
|
-
let truncated = false
|
|
157
182
|
if (text.length > maxBytes) {
|
|
158
183
|
text = text.slice(0, maxBytes)
|
|
159
184
|
truncated = true
|
|
@@ -164,9 +189,10 @@ export async function parseDocument(filePath, opts = {}) {
|
|
|
164
189
|
type: parsedType,
|
|
165
190
|
text,
|
|
166
191
|
truncated,
|
|
167
|
-
originalLength,
|
|
192
|
+
originalLength: originalLength || text.length,
|
|
168
193
|
}
|
|
169
194
|
} catch (err) {
|
|
170
195
|
return { parsed: false, error: err.message }
|
|
171
196
|
}
|
|
172
197
|
}
|
|
198
|
+
|
package/lib/gateway.js
CHANGED
|
@@ -182,40 +182,55 @@ export class Gateway {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
185
|
+
handleHostEvent(session, event) {
|
|
186
|
+
if (!session || !event) return
|
|
187
|
+
if (this.config.telegram?.forumMirrorEnabled) {
|
|
188
|
+
if (event.type === 'turn/start' || event.type === 'session/create') {
|
|
189
|
+
this.mirrorSessionToForumTopic(session).catch?.((e) => this.logger?.debug?.('mirror error:', e?.message || e))
|
|
190
|
+
} else if (event.type === 'turn/end') {
|
|
191
|
+
this.relayTurnToForumMirror(session, event).catch?.((e) => this.logger?.debug?.('relay mirror error:', e?.message || e))
|
|
193
192
|
}
|
|
193
|
+
}
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
} else if (event.type === 'tool/call') {
|
|
210
|
-
collector.toolName = extractToolName(event.data) || collector.toolName
|
|
211
|
-
collector.onStream?.(collector.streamText || '', collector.toolName)
|
|
212
|
-
} else if (event.type === 'tool/result') {
|
|
213
|
-
collector.toolName = ''
|
|
214
|
-
collector.onStream?.(collector.streamText || '', '')
|
|
215
|
-
} else if (event.type === 'turn/end') {
|
|
216
|
-
collector.reason = event.data.reason
|
|
195
|
+
const collector = this.pending.get(session.id)
|
|
196
|
+
if (!collector) return
|
|
197
|
+
if (event.type === 'assistant/message') {
|
|
198
|
+
const msg = event.data?.message || event.data
|
|
199
|
+
const text = assistantText(msg)
|
|
200
|
+
if (text) collector.lastText = text
|
|
201
|
+
const extra = collectAssistantParts(msg)
|
|
202
|
+
for (const img of extra.images) collector.images.push(img)
|
|
203
|
+
} else if (event.type === 'assistant/chunk') {
|
|
204
|
+
const delta = extractTextDelta(event.data?.chunk || event.data)
|
|
205
|
+
if (delta) {
|
|
206
|
+
collector.streamText = (collector.streamText || '') + delta
|
|
207
|
+
collector.onStream?.(collector.streamText, collector.toolName)
|
|
217
208
|
}
|
|
218
|
-
})
|
|
209
|
+
} else if (event.type === 'tool/call') {
|
|
210
|
+
collector.toolName = extractToolName(event.data) || collector.toolName
|
|
211
|
+
collector.onStream?.(collector.streamText || '', collector.toolName)
|
|
212
|
+
} else if (event.type === 'tool/result') {
|
|
213
|
+
collector.toolName = ''
|
|
214
|
+
collector.onStream?.(collector.streamText || '', '')
|
|
215
|
+
} else if (event.type === 'turn/end') {
|
|
216
|
+
collector.reason = event.data?.reason || event.data
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async start() {
|
|
221
|
+
const onHost = (session, event) => this.handleHostEvent(session, event)
|
|
222
|
+
const unsubs = [
|
|
223
|
+
this.ctx.on('session/event', onHost),
|
|
224
|
+
this.ctx.on('turn/end', (session, data) => onHost(session, { type: 'turn/end', data })),
|
|
225
|
+
this.ctx.on('turn/start', (session, data) => onHost(session, { type: 'turn/start', data })),
|
|
226
|
+
this.ctx.on('assistant/message', (session, message) => onHost(session, { type: 'assistant/message', data: { message } })),
|
|
227
|
+
this.ctx.on('assistant/chunk', (session, chunk) => onHost(session, { type: 'assistant/chunk', data: { chunk } })),
|
|
228
|
+
]
|
|
229
|
+
this.disposeListener = () => {
|
|
230
|
+
for (const un of unsubs) {
|
|
231
|
+
if (typeof un === 'function') un()
|
|
232
|
+
}
|
|
233
|
+
}
|
|
219
234
|
const adapters = createAdapters({
|
|
220
235
|
config: this.config,
|
|
221
236
|
onMessage: (input) => this.handleMessage(input),
|
package/lib/index.js
CHANGED
|
@@ -149,7 +149,57 @@ export function apply(ctx, config) {
|
|
|
149
149
|
const turnStarts = new Map()
|
|
150
150
|
const getWebServer = () => ctx.get?.('webServer') || ctx.webServer
|
|
151
151
|
|
|
152
|
+
let webhookRouteDispose = null
|
|
153
|
+
let currentWebhookPath = ''
|
|
154
|
+
|
|
155
|
+
const webhookPath = () => {
|
|
156
|
+
const p = String(source().telegram?.webhookPath || '').trim()
|
|
157
|
+
return p || '/dsh-messenger-gateway/telegram/webhook'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const syncWebhookRoute = () => {
|
|
161
|
+
const targetPath = webhookPath()
|
|
162
|
+
if (targetPath === currentWebhookPath && webhookRouteDispose) return
|
|
163
|
+
if (typeof webhookRouteDispose === 'function') {
|
|
164
|
+
try { webhookRouteDispose() } catch (e) { /* ignore */ }
|
|
165
|
+
webhookRouteDispose = null
|
|
166
|
+
}
|
|
167
|
+
currentWebhookPath = targetPath
|
|
168
|
+
const ws = getWebServer()
|
|
169
|
+
if (!ws?.register) return
|
|
170
|
+
webhookRouteDispose = ws.register({
|
|
171
|
+
kind: 'exact',
|
|
172
|
+
path: targetPath,
|
|
173
|
+
handler: async (req, res) => {
|
|
174
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
175
|
+
const cfg = source().telegram || {}
|
|
176
|
+
if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
|
|
177
|
+
const secret = String(cfg.webhookSecret || '').trim()
|
|
178
|
+
if (!secret) {
|
|
179
|
+
return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
|
|
180
|
+
}
|
|
181
|
+
const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
|
|
182
|
+
if (!timingSafeCompare(hdr, secret)) {
|
|
183
|
+
return writeJson(res, 403, { ok: false, error: 'bad secret' })
|
|
184
|
+
}
|
|
185
|
+
const gw = getGw()
|
|
186
|
+
const adapter = gw?.getAdapter?.('telegram')
|
|
187
|
+
if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
|
|
188
|
+
const parsed = await readJsonSafe(req, res, 2_097_152)
|
|
189
|
+
if (!parsed.ok) return
|
|
190
|
+
const update = parsed.data
|
|
191
|
+
try {
|
|
192
|
+
await adapter.handleWebhookUpdate(update)
|
|
193
|
+
writeJson(res, 200, { ok: true })
|
|
194
|
+
} catch (err) {
|
|
195
|
+
writeJson(res, 500, { ok: false, error: err.message })
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
152
201
|
const sync = () => {
|
|
202
|
+
syncWebhookRoute()
|
|
153
203
|
if (gateway) { gateway.stop(); gateway = undefined }
|
|
154
204
|
const effective = source()
|
|
155
205
|
if (effective.enabled === false) return
|
|
@@ -292,51 +342,69 @@ export function apply(ctx, config) {
|
|
|
292
342
|
const gw = getGw()
|
|
293
343
|
if (gw) gw.syncTelegramCommands().catch((err) => ctx.logger?.debug?.(`sync telegram commands on skills change: ${err?.message || err}`))
|
|
294
344
|
}
|
|
295
|
-
const
|
|
296
|
-
|
|
345
|
+
const unbind1 = ctx.on('skills/change', onSkillsChange)
|
|
346
|
+
const unbind2 = ctx.on('skills/updated', onSkillsChange)
|
|
347
|
+
const unbind3 = ctx.on('skills/update', onSkillsChange)
|
|
348
|
+
return () => {
|
|
349
|
+
if (typeof unbind1 === 'function') unbind1()
|
|
350
|
+
if (typeof unbind2 === 'function') unbind2()
|
|
351
|
+
if (typeof unbind3 === 'function') unbind3()
|
|
352
|
+
}
|
|
297
353
|
}, 'dsh-messenger-gateway: dynamic skills sync')
|
|
298
354
|
|
|
299
355
|
// Notify → Telegram home bridge (non-msgw sessions only)
|
|
300
|
-
ctx.effect(() =>
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
356
|
+
ctx.effect(() => {
|
|
357
|
+
const handleNotify = (session, event) => {
|
|
358
|
+
if (!session || !event) return
|
|
359
|
+
const cfg = source().telegram?.notifyBridge
|
|
360
|
+
if (!cfg?.enabled) return
|
|
361
|
+
if (event.type === 'turn/start') {
|
|
362
|
+
const now = Date.now()
|
|
363
|
+
if (turnStarts.size > 50) {
|
|
364
|
+
for (const [sid, ts] of turnStarts.entries()) {
|
|
365
|
+
if (now - ts > 7_200_000) turnStarts.delete(sid)
|
|
366
|
+
}
|
|
308
367
|
}
|
|
368
|
+
turnStarts.set(String(session.id), now)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if (event.type !== 'turn/end') return
|
|
372
|
+
const prefixes = cfg.excludeSessionPrefixes || ['msgw-']
|
|
373
|
+
if (isExcludedSession(session.id, prefixes)) return
|
|
374
|
+
const reason = event.data?.reason
|
|
375
|
+
const kind = reason?.kind === 'completed' ? 'task_done' : 'error'
|
|
376
|
+
const events = new Set(cfg.events?.length ? cfg.events : ['task_done', 'error'])
|
|
377
|
+
if (!events.has(kind)) return
|
|
378
|
+
const gw = getGw()
|
|
379
|
+
if (!gw) return
|
|
380
|
+
const started = turnStarts.get(String(session.id))
|
|
381
|
+
turnStarts.delete(String(session.id))
|
|
382
|
+
const dur = started ? Math.round((Date.now() - started) / 1000) : null
|
|
383
|
+
const title = kind === 'task_done' ? 'Task completed' : 'Agent error'
|
|
384
|
+
const lines = [
|
|
385
|
+
`【${title}】`,
|
|
386
|
+
`session: ${session.id}`,
|
|
387
|
+
]
|
|
388
|
+
if (dur != null) lines.push(`duration: ${dur}s`)
|
|
389
|
+
if (reason?.kind === 'error') {
|
|
390
|
+
lines.push(`error: ${reason.error?.message || reason.error?.code || 'unknown'}`)
|
|
309
391
|
}
|
|
310
|
-
|
|
311
|
-
|
|
392
|
+
const homeName = cfg.home || 'default'
|
|
393
|
+
const home = gw.messenger.home?.() || null
|
|
394
|
+
if (home && gw.isMuted?.(home.chatId)) return
|
|
395
|
+
gw.messengerSend({ platform: 'telegram', home: homeName }, { text: lines.join('\n') })
|
|
396
|
+
.catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
|
|
312
397
|
}
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (!gw) return
|
|
322
|
-
const started = turnStarts.get(String(session.id))
|
|
323
|
-
turnStarts.delete(String(session.id))
|
|
324
|
-
const dur = started ? Math.round((Date.now() - started) / 1000) : null
|
|
325
|
-
const title = kind === 'task_done' ? 'Task completed' : 'Agent error'
|
|
326
|
-
const lines = [
|
|
327
|
-
`【${title}】`,
|
|
328
|
-
`session: ${session.id}`,
|
|
329
|
-
]
|
|
330
|
-
if (dur != null) lines.push(`duration: ${dur}s`)
|
|
331
|
-
if (reason?.kind === 'error') {
|
|
332
|
-
lines.push(`error: ${reason.error?.message || reason.error?.code || 'unknown'}`)
|
|
398
|
+
|
|
399
|
+
const un1 = ctx.on('session/event', handleNotify)
|
|
400
|
+
const un2 = ctx.on('turn/end', (session, data) => handleNotify(session, { type: 'turn/end', data }))
|
|
401
|
+
const un3 = ctx.on('turn/start', (session, data) => handleNotify(session, { type: 'turn/start', data }))
|
|
402
|
+
return () => {
|
|
403
|
+
if (typeof un1 === 'function') un1()
|
|
404
|
+
if (typeof un2 === 'function') un2()
|
|
405
|
+
if (typeof un3 === 'function') un3()
|
|
333
406
|
}
|
|
334
|
-
|
|
335
|
-
const home = gw.messenger.home?.() || null
|
|
336
|
-
if (home && gw.isMuted?.(home.chatId)) return
|
|
337
|
-
gw.messengerSend({ platform: 'telegram', home: homeName }, { text: lines.join('\n') })
|
|
338
|
-
.catch((e) => ctx.logger?.warn?.(`notify bridge: ${e.message}`))
|
|
339
|
-
}), 'dsh-messenger-gateway: notify bridge')
|
|
407
|
+
}, 'dsh-messenger-gateway: notify bridge')
|
|
340
408
|
|
|
341
409
|
ctx.effect(() => getWebServer().register({
|
|
342
410
|
kind: 'exact', path: '/dsh-messenger-gateway/status',
|
|
@@ -538,39 +606,12 @@ export function apply(ctx, config) {
|
|
|
538
606
|
},
|
|
539
607
|
}), 'dsh-messenger-gateway: pairing reject')
|
|
540
608
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
kind: 'exact', path: webhookPath(),
|
|
548
|
-
handler: async (req, res) => {
|
|
549
|
-
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
550
|
-
const cfg = source().telegram || {}
|
|
551
|
-
if (cfg.transport !== 'webhook') return writeJson(res, 404, { ok: false, error: 'webhook transport disabled' })
|
|
552
|
-
const secret = String(cfg.webhookSecret || '').trim()
|
|
553
|
-
if (!secret) {
|
|
554
|
-
return writeJson(res, 403, { ok: false, error: 'webhook secret not configured' })
|
|
555
|
-
}
|
|
556
|
-
const hdr = String(req.headers['x-telegram-bot-api-secret-token'] || '')
|
|
557
|
-
if (!timingSafeCompare(hdr, secret)) {
|
|
558
|
-
return writeJson(res, 403, { ok: false, error: 'bad secret' })
|
|
559
|
-
}
|
|
560
|
-
const gw = getGw()
|
|
561
|
-
const adapter = gw?.getAdapter?.('telegram')
|
|
562
|
-
if (!adapter?.handleWebhookUpdate) return writeJson(res, 503, { ok: false, error: 'telegram adapter unavailable' })
|
|
563
|
-
const parsed = await readJsonSafe(req, res, 2_097_152)
|
|
564
|
-
if (!parsed.ok) return
|
|
565
|
-
const update = parsed.data
|
|
566
|
-
try {
|
|
567
|
-
await adapter.handleWebhookUpdate(update)
|
|
568
|
-
writeJson(res, 200, { ok: true })
|
|
569
|
-
} catch (err) {
|
|
570
|
-
writeJson(res, 500, { ok: false, error: err.message })
|
|
571
|
-
}
|
|
572
|
-
},
|
|
573
|
-
}), 'dsh-messenger-gateway: telegram webhook')
|
|
609
|
+
ctx.effect(() => () => {
|
|
610
|
+
if (typeof webhookRouteDispose === 'function') {
|
|
611
|
+
try { webhookRouteDispose() } catch (e) { /* ignore */ }
|
|
612
|
+
webhookRouteDispose = null
|
|
613
|
+
}
|
|
614
|
+
}, 'dsh-messenger-gateway: telegram webhook')
|
|
574
615
|
|
|
575
616
|
registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/send', 'send', source)
|
|
576
617
|
registerMessengerRoute(ctx, getGw, '/dsh-messenger-gateway/messenger/progress', 'progress', source)
|
|
@@ -583,6 +624,9 @@ export function apply(ctx, config) {
|
|
|
583
624
|
endpoint: '/dsh-messenger-gateway/update',
|
|
584
625
|
}), 'dsh-messenger-gateway: plugin updater')
|
|
585
626
|
|
|
586
|
-
ctx.on('dispose', () => {
|
|
627
|
+
ctx.on('dispose', () => {
|
|
628
|
+
if (gateway) gateway.stop()
|
|
629
|
+
if (typeof webhookRouteDispose === 'function') webhookRouteDispose()
|
|
630
|
+
})
|
|
587
631
|
sync()
|
|
588
632
|
}
|
package/lib/messenger-api.js
CHANGED
|
@@ -110,7 +110,29 @@ export function normalizeFiles(files) {
|
|
|
110
110
|
err.status = 400
|
|
111
111
|
throw err
|
|
112
112
|
}
|
|
113
|
-
if (file.bytes)
|
|
113
|
+
if (file.bytes !== undefined) {
|
|
114
|
+
let buf = null
|
|
115
|
+
if (Buffer.isBuffer(file.bytes) || file.bytes instanceof Uint8Array) {
|
|
116
|
+
buf = Buffer.from(file.bytes)
|
|
117
|
+
} else if (Array.isArray(file.bytes)) {
|
|
118
|
+
buf = Buffer.from(file.bytes)
|
|
119
|
+
} else if (file.bytes && typeof file.bytes === 'object' && file.bytes.type === 'Buffer' && Array.isArray(file.bytes.data)) {
|
|
120
|
+
buf = Buffer.from(file.bytes.data)
|
|
121
|
+
} else if (typeof file.bytes === 'string') {
|
|
122
|
+
buf = Buffer.from(file.bytes, 'utf8')
|
|
123
|
+
}
|
|
124
|
+
if (!buf) {
|
|
125
|
+
const err = new Error(`files[${i}].bytes must be a Buffer, Uint8Array, or array of bytes`)
|
|
126
|
+
err.status = 400
|
|
127
|
+
throw err
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
bytes: buf,
|
|
131
|
+
mime: file.mime || 'application/octet-stream',
|
|
132
|
+
kind: file.kind || 'document',
|
|
133
|
+
name: file.name || 'file',
|
|
134
|
+
}
|
|
135
|
+
}
|
|
114
136
|
if (typeof file.dataBase64 === 'string') {
|
|
115
137
|
return {
|
|
116
138
|
bytes: Buffer.from(file.dataBase64, 'base64'),
|
package/lib/outbound.js
CHANGED
|
@@ -72,12 +72,54 @@ export async function readAttachmentBytes(ctx, ref) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export async function fetchInternalImage(baseUrl, path, signal) {
|
|
75
|
+
export async function fetchInternalImage(baseUrl, path, signal, opts = {}) {
|
|
76
|
+
const { timeoutMs = 15_000, maxBytes = 20 * 1024 * 1024 } = opts
|
|
77
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
78
|
+
const effectiveSignal = signal
|
|
79
|
+
? (typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal)
|
|
80
|
+
: timeoutSignal
|
|
81
|
+
|
|
76
82
|
const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
|
|
77
|
-
const res = await fetch(url, { signal })
|
|
83
|
+
const res = await fetch(url, { signal: effectiveSignal })
|
|
78
84
|
if (!res.ok) throw new Error(`image fetch HTTP ${res.status}`)
|
|
79
|
-
|
|
80
|
-
const
|
|
85
|
+
|
|
86
|
+
const clHeader = res.headers?.get?.('content-length')
|
|
87
|
+
const cl = Number(clHeader)
|
|
88
|
+
if (Number.isFinite(cl) && cl > maxBytes) {
|
|
89
|
+
throw new Error(`image size (${cl} bytes) exceeds limit of ${maxBytes} bytes`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let bytes
|
|
93
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
94
|
+
const reader = res.body.getReader()
|
|
95
|
+
const chunks = []
|
|
96
|
+
let total = 0
|
|
97
|
+
while (true) {
|
|
98
|
+
const { done, value } = await reader.read()
|
|
99
|
+
if (done) break
|
|
100
|
+
total += value.length
|
|
101
|
+
if (total > maxBytes) {
|
|
102
|
+
reader.cancel().catch(() => {})
|
|
103
|
+
throw new Error(`image size exceeds limit of ${maxBytes} bytes`)
|
|
104
|
+
}
|
|
105
|
+
chunks.push(value)
|
|
106
|
+
}
|
|
107
|
+
const combined = new Uint8Array(total)
|
|
108
|
+
let offset = 0
|
|
109
|
+
for (const chunk of chunks) {
|
|
110
|
+
combined.set(chunk, offset)
|
|
111
|
+
offset += chunk.length
|
|
112
|
+
}
|
|
113
|
+
bytes = combined
|
|
114
|
+
} else {
|
|
115
|
+
const ab = await res.arrayBuffer()
|
|
116
|
+
if (ab.byteLength > maxBytes) {
|
|
117
|
+
throw new Error(`image size exceeds limit of ${maxBytes} bytes`)
|
|
118
|
+
}
|
|
119
|
+
bytes = new Uint8Array(ab)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const mime = res.headers?.get?.('content-type') || 'image/png'
|
|
81
123
|
const fromQuery = url.searchParams.get('id') || url.searchParams.get('name')
|
|
82
124
|
const name = fromQuery ? `image-${fromQuery.slice(0, 32)}.png` : 'image.png'
|
|
83
125
|
return { bytes, mime, kind: 'photo', name }
|
|
@@ -87,7 +129,7 @@ const DEFAULT_MAX_OUTBOUND_FILES = 10
|
|
|
87
129
|
|
|
88
130
|
/** Build Telegram-ready files from assistant image blocks and inline plugin URLs. */
|
|
89
131
|
export async function buildOutboundFiles(ctx, baseUrl, collector, opts = {}) {
|
|
90
|
-
const { signal, logger, maxFiles = DEFAULT_MAX_OUTBOUND_FILES } = opts
|
|
132
|
+
const { signal, logger, maxFiles = DEFAULT_MAX_OUTBOUND_FILES, maxImageBytes, imageTimeoutMs } = opts
|
|
91
133
|
const files = []
|
|
92
134
|
const refs = dedupeAttachmentRefs(collector.images)
|
|
93
135
|
for (const ref of refs) {
|
|
@@ -104,7 +146,10 @@ export async function buildOutboundFiles(ctx, baseUrl, collector, opts = {}) {
|
|
|
104
146
|
if (files.length >= maxFiles) break
|
|
105
147
|
if (signal?.aborted) break
|
|
106
148
|
try {
|
|
107
|
-
files.push(await fetchInternalImage(baseUrl, path, signal
|
|
149
|
+
files.push(await fetchInternalImage(baseUrl, path, signal, {
|
|
150
|
+
maxBytes: maxImageBytes,
|
|
151
|
+
timeoutMs: imageTimeoutMs,
|
|
152
|
+
}))
|
|
108
153
|
} catch (e) {
|
|
109
154
|
logger?.warn?.(`outbound url image: ${e.message}`)
|
|
110
155
|
}
|
package/lib/personas.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { writeJsonAtomicSync } from './storage-atomic.js'
|
|
3
3
|
|
|
4
4
|
export const BUILTIN_PERSONAS = {
|
|
5
5
|
default: {
|
|
@@ -87,8 +87,7 @@ export function createPersonaStore(filePath, { logger } = {}) {
|
|
|
87
87
|
|
|
88
88
|
function save() {
|
|
89
89
|
try {
|
|
90
|
-
|
|
91
|
-
writeFileSync(filePath, JSON.stringify(cache, null, 2), 'utf8')
|
|
90
|
+
writeJsonAtomicSync(filePath, cache)
|
|
92
91
|
} catch (err) {
|
|
93
92
|
logger?.warn?.('[dsh-messenger-gateway] personas save error:', err?.message || err)
|
|
94
93
|
}
|
package/lib/scheduler.js
CHANGED
|
@@ -42,10 +42,16 @@ export function createScheduler(filePath, onDue, options = {}) {
|
|
|
42
42
|
async function load() {
|
|
43
43
|
try {
|
|
44
44
|
const raw = await readFile(filePath, 'utf8')
|
|
45
|
-
|
|
45
|
+
const parsed = JSON.parse(raw)
|
|
46
|
+
if (Array.isArray(parsed)) {
|
|
47
|
+
tasks = parsed
|
|
48
|
+
} else {
|
|
49
|
+
logger?.warn?.(`[dsh-messenger-gateway] scheduler invalid tasks payload in ${filePath}: expected array, got ${typeof parsed}; resetting to empty list`)
|
|
50
|
+
tasks = []
|
|
51
|
+
}
|
|
46
52
|
} catch (err) {
|
|
47
53
|
if (err?.code !== 'ENOENT') {
|
|
48
|
-
logger?.
|
|
54
|
+
logger?.warn?.('[dsh-messenger-gateway] scheduler load error:', err?.message || err)
|
|
49
55
|
}
|
|
50
56
|
tasks = []
|
|
51
57
|
}
|
|
@@ -56,6 +62,7 @@ export function createScheduler(filePath, onDue, options = {}) {
|
|
|
56
62
|
|
|
57
63
|
async function save() {
|
|
58
64
|
if (!filePath) return
|
|
65
|
+
if (!Array.isArray(tasks)) tasks = []
|
|
59
66
|
try {
|
|
60
67
|
const now = Date.now()
|
|
61
68
|
tasks = tasks.filter((t) => {
|
|
@@ -71,6 +78,7 @@ export function createScheduler(filePath, onDue, options = {}) {
|
|
|
71
78
|
|
|
72
79
|
async function checkDue() {
|
|
73
80
|
if (!loaded) await load()
|
|
81
|
+
if (!Array.isArray(tasks)) tasks = []
|
|
74
82
|
const now = Date.now()
|
|
75
83
|
const due = tasks.filter((t) => t.status === 'pending' && t.dueAt <= now)
|
|
76
84
|
|
|
@@ -99,10 +107,10 @@ export function createScheduler(filePath, onDue, options = {}) {
|
|
|
99
107
|
function start(intervalMs = 5000) {
|
|
100
108
|
if (timer) clearInterval(timer)
|
|
101
109
|
timer = setInterval(() => {
|
|
102
|
-
checkDue().catch((err) => { logger?.
|
|
110
|
+
checkDue().catch((err) => { logger?.warn?.('[dsh-messenger-gateway] scheduler checkDue error:', err?.message || err) })
|
|
103
111
|
}, intervalMs)
|
|
104
112
|
timer.unref?.()
|
|
105
|
-
checkDue().catch((err) => { logger?.
|
|
113
|
+
checkDue().catch((err) => { logger?.warn?.('[dsh-messenger-gateway] scheduler checkDue error:', err?.message || err) })
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
function stop() {
|
package/lib/settings-bridge.js
CHANGED
|
@@ -78,7 +78,7 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
|
|
|
78
78
|
source = () => currentConfig
|
|
79
79
|
|
|
80
80
|
const persist = async (next) => {
|
|
81
|
-
const payload =
|
|
81
|
+
const payload = resolveConfig(next)
|
|
82
82
|
if (typeof service.replace === 'function') {
|
|
83
83
|
await service.replace(namespace, payload, getRevision())
|
|
84
84
|
} else if (typeof service.update === 'function') {
|
|
@@ -88,7 +88,7 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
|
|
|
88
88
|
} else {
|
|
89
89
|
throw new Error('settings service cannot persist configuration')
|
|
90
90
|
}
|
|
91
|
-
currentConfig =
|
|
91
|
+
currentConfig = payload
|
|
92
92
|
sync()
|
|
93
93
|
for (const cb of watchers) {
|
|
94
94
|
try { cb(currentConfig) } catch (err) { logger?.debug?.(`${namespace}: watcher error:`, err?.message || err) }
|
package/lib/storage-atomic.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { writeFile, rename, mkdir, unlink } from 'node:fs/promises'
|
|
2
|
+
import { writeFileSync, renameSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
2
3
|
import { dirname } from 'node:path'
|
|
3
4
|
import { randomUUID } from 'node:crypto'
|
|
4
5
|
|
|
@@ -18,4 +19,20 @@ export async function writeJsonAtomic(filePath, data) {
|
|
|
18
19
|
await unlink(tmpPath).catch(() => {})
|
|
19
20
|
throw err
|
|
20
21
|
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Synchronous atomic JSON write via temporary file and rename.
|
|
26
|
+
*/
|
|
27
|
+
export function writeJsonAtomicSync(filePath, data) {
|
|
28
|
+
const dir = dirname(filePath)
|
|
29
|
+
mkdirSync(dir, { recursive: true })
|
|
30
|
+
const tmpPath = `${filePath}.${randomUUID().slice(0, 8)}.tmp`
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
|
|
33
|
+
renameSync(tmpPath, filePath)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
try { unlinkSync(tmpPath) } catch { /* ignore */ }
|
|
36
|
+
throw err
|
|
37
|
+
}
|
|
21
38
|
}
|
package/lib/stream.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/** Helpers for Telegram streaming edits + progress. */
|
|
2
2
|
|
|
3
3
|
export function extractTextDelta(chunk) {
|
|
4
|
+
if (typeof chunk === 'string') return chunk
|
|
4
5
|
if (!chunk || typeof chunk !== 'object') return ''
|
|
5
6
|
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') return chunk.text
|
|
7
|
+
if (chunk.type === 'text' && typeof chunk.text === 'string') return chunk.text
|
|
8
|
+
if (!chunk.type && typeof chunk.text === 'string') return chunk.text
|
|
9
|
+
if (!chunk.type && typeof chunk.delta === 'string') return chunk.delta
|
|
6
10
|
return ''
|
|
7
11
|
}
|
|
8
12
|
|
package/lib/updater.js
CHANGED
|
@@ -181,15 +181,20 @@ export async function getUpdateStatus(options, target = runtime()) {
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
export function buildUpdateArgs(target, packageSpec, options = {}) {
|
|
185
|
+
return [
|
|
186
|
+
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
187
|
+
packageSpec,
|
|
188
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
|
|
184
192
|
export async function installExact(target, packageSpec, options = {}) {
|
|
185
193
|
if (target.cliEntry === undefined) throw new Error('Automatic update CLI entry is unavailable in this runtime.')
|
|
186
194
|
return new Promise((resolvePromise, reject) => {
|
|
187
|
-
const args =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
191
|
-
]
|
|
192
|
-
const child = spawn(process.execPath, args, {
|
|
195
|
+
const args = buildUpdateArgs(target, packageSpec, options)
|
|
196
|
+
const spawnFn = options.spawn ?? spawn
|
|
197
|
+
const child = spawnFn(process.execPath, args, {
|
|
193
198
|
cwd: target.profileDir,
|
|
194
199
|
windowsHide: true,
|
|
195
200
|
stdio: ['ignore', 'pipe', 'pipe'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-messenger-gateway",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.12",
|
|
4
4
|
"description": "Telegram messenger bridge for DeepSeek Harness: sessions, steer, homes, inline asks, notify bridge, and optional TTS voice notes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|