@goodandready/dsh-messenger-gateway 0.4.11 → 0.4.13

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/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/gateway.js CHANGED
@@ -182,40 +182,55 @@ export class Gateway {
182
182
  }
183
183
  }
184
184
 
185
- async start() {
186
- this.disposeListener = this.ctx.on('session/event', (session, event) => {
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))
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
- const collector = this.pending.get(session.id)
196
- if (!collector) return
197
- if (event.type === 'assistant/message') {
198
- const msg = event.data.message
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)
205
- if (delta) {
206
- collector.streamText = (collector.streamText || '') + delta
207
- collector.onStream?.(collector.streamText, collector.toolName)
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 unbind = ctx.on('skills/change', onSkillsChange)
296
- return () => { if (typeof unbind === 'function') unbind() }
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(() => ctx.on('session/event', (session, event) => {
301
- const cfg = source().telegram?.notifyBridge
302
- if (!cfg?.enabled) return
303
- if (event.type === 'turn/start') {
304
- const now = Date.now()
305
- if (turnStarts.size > 50) {
306
- for (const [sid, ts] of turnStarts.entries()) {
307
- if (now - ts > 7_200_000) turnStarts.delete(sid)
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
- turnStarts.set(String(session.id), now)
311
- return
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
- if (event.type !== 'turn/end') return
314
- const prefixes = cfg.excludeSessionPrefixes || ['msgw-']
315
- if (isExcludedSession(session.id, prefixes)) return
316
- const reason = event.data?.reason
317
- const kind = reason?.kind === 'completed' ? 'task_done' : 'error'
318
- const events = new Set(cfg.events?.length ? cfg.events : ['task_done', 'error'])
319
- if (!events.has(kind)) return
320
- const gw = getGw()
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
- const homeName = cfg.home || 'default'
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
- const webhookPath = () => {
542
- const p = String(source().telegram?.webhookPath || '').trim()
543
- return p || '/dsh-messenger-gateway/telegram/webhook'
544
- }
545
-
546
- ctx.effect(() => getWebServer().register({
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', () => { if (gateway) gateway.stop() })
627
+ ctx.on('dispose', () => {
628
+ if (gateway) gateway.stop()
629
+ if (typeof webhookRouteDispose === 'function') webhookRouteDispose()
630
+ })
587
631
  sync()
588
632
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-messenger-gateway",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
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",