@goodandready/dsh-messenger-gateway 0.3.22 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/gateway.js CHANGED
@@ -7,55 +7,28 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent'
7
7
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
8
8
  import { SessionId } from '@deepseek-ai/dsh-session'
9
9
  import createAdapters from './adapters/index.js'
10
- import { transcribeVoice, speakText } from './integrations.js'
10
+ import { transcribeVoice } from './integrations.js'
11
11
  import { attachInboundPhoto, photoOnlyHint } from './photos.js'
12
- import { formatInboundDocument, documentOnlyHint, parseDocument } from './documents.js'
13
- import { listFiles, getFileForDownload, formatFileSize } from './file-manager.js'
14
- import { collectAssistantParts, buildOutboundFiles, stripImageUrls } from './outbound.js'
15
- import { assistantText, splitText, stripReasoningPreamble, MESSENGER_RELAY_INSTRUCTION } from './text.js'
16
12
  import { chatKey, sessionKey } from './topics.js'
17
- import { listHomes, resolveNamedHome, upsertHome, normalizeHomeName } from './homes.js'
18
- import { createVoicePrefs, shouldSpeakReply } from './voice-prefs.js'
19
- import { prepareTtsText, toTelegramVoiceFile } from './tts.js'
20
- import {
21
- makeAskToken, buildInlineKeyboard, buildMultiSelectKeyboard, indexCallbacks, releaseCallbacks,
22
- parseCallbackData, parseAskCallback, targetMatchesAsk, rejectPendingAsk, REMOVE_KEYBOARD,
23
- } from './ask.js'
24
- import { processDiagramsAndTables } from './artifacts.js'
25
- import { createPersonaStore, getPersona, listPersonas, BUILTIN_PERSONAS } from './personas.js'
26
- import { exportSessionToMarkdown, rewindSession } from './session-ops.js'
13
+ import { resolveNamedHome } from './homes.js'
14
+ import { createVoicePrefs } from './voice-prefs.js'
15
+ import { executeMessengerAsk, releaseCallbacks, rejectPendingAsk } from './ask.js'
16
+ import { createPersonaStore, BUILTIN_PERSONAS } from './personas.js'
27
17
  import { formatAlertMessage, resolveAlertTarget } from './alerts.js'
28
- import { createScheduler, parseRelativeTime, formatRemaining } from './scheduler.js'
29
- import { listModelCatalog, buildProvidersKeyboard, buildModelsKeyboard, getStoredModelSelection } from './models.js'
30
- import { buildQuickActionsKeyboard, REMOVE_REPLY_KEYBOARD } from './adapters/telegram.js'
18
+ import { createScheduler } from './scheduler.js'
19
+ import { getStoredModelSelection } from './models.js'
31
20
  import { createPairingStore } from './pairing.js'
32
- import {
33
- extractTextDelta, extractToolName, buildStreamPreview, formatProgressLine,
34
- createEditScheduler, startTypingHeartbeat,
35
- } from './stream.js'
36
21
  import { isTopicGoneError } from './telegram-errors.js'
37
22
  import { ensureContentArray } from './content-guard.js'
38
- import { mergeDynamicCommands } from './commands.js'
23
+ import { ApiHealthTracker } from './api-health.js'
39
24
  import { t } from './locales/index.js'
40
- import { getUpdateStatus, runDirectUpdate } from './updater.js'
41
-
42
-
43
- function whenIdleWithTimeout(agent, timeoutMs, signal) {
44
- const idle = agent.whenIdle()
45
- if (!timeoutMs || timeoutMs <= 0) return idle
46
- return Promise.race([
47
- idle,
48
- new Promise((_, reject) => {
49
- const timer = setTimeout(() => reject(new Error(`turn timeout (${timeoutMs}ms)`)), timeoutMs)
50
- timer.unref?.()
51
- signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
52
- }),
53
- ])
54
- }
55
-
56
- function releaseChatTurn(chat) {
57
- chat.busy = Promise.resolve()
58
- }
25
+ import { handleGatewayCommand, releaseChatTurn } from './gateway-commands.js'
26
+ import { handleGatewayCallback } from './gateway-callbacks.js'
27
+ import { runGatewayTurn, buildUserContent, answerApproval, whenIdleWithTimeout } from './gateway-turn.js'
28
+ import {
29
+ collectDynamicSkills, syncTelegramCommands,
30
+ mirrorSessionToForumTopic, relayTurnToForumMirror,
31
+ } from './forum-mirror.js'
59
32
 
60
33
  const PLUGIN = 'dsh-messenger-gateway'
61
34
 
@@ -75,13 +48,14 @@ export class Gateway {
75
48
  this.disposeListener = undefined
76
49
  this.idleTimer = undefined
77
50
  this.callbackIndex = new Map()
51
+ this.logger = ctx?.logger || console
78
52
  const home = process.env.DSH_HOME || join(homedir(), '.dsh')
79
- this.pairing = createPairingStore(join(home, 'messenger-gateway', 'pairing.json'))
80
- this.voicePrefs = createVoicePrefs(join(home, 'messenger-gateway', 'voice-prefs.json'))
81
- this.chatTts = createVoicePrefs(join(home, 'messenger-gateway', 'chat-tts.json'))
82
- this.muted = createVoicePrefs(join(home, 'messenger-gateway', 'muted.json'))
83
- this.personas = createPersonaStore(join(home, 'messenger-gateway', 'personas.json'))
84
- this.chatLocales = createVoicePrefs(join(home, 'messenger-gateway', 'chat-locales.json'))
53
+ this.pairing = createPairingStore(join(home, 'messenger-gateway', 'pairing.json'), { logger: this.logger })
54
+ this.voicePrefs = createVoicePrefs(join(home, 'messenger-gateway', 'voice-prefs.json'), { logger: this.logger })
55
+ this.chatTts = createVoicePrefs(join(home, 'messenger-gateway', 'chat-tts.json'), { logger: this.logger })
56
+ this.muted = createVoicePrefs(join(home, 'messenger-gateway', 'muted.json'), { logger: this.logger })
57
+ this.personas = createPersonaStore(join(home, 'messenger-gateway', 'personas.json'), { logger: this.logger })
58
+ this.chatLocales = createVoicePrefs(join(home, 'messenger-gateway', 'chat-locales.json'), { logger: this.logger })
85
59
  this.scheduler = createScheduler(join(home, 'messenger-gateway', 'scheduled.json'), async (task) => {
86
60
  const target = {
87
61
  platform: task.platform || 'telegram',
@@ -104,8 +78,25 @@ export class Gateway {
104
78
  const text = t('remind.prefix', { text: task.text }, locale)
105
79
  await this.sendToMessenger(target, { text })
106
80
  }
107
- })
81
+ }, { logger: this.ctx?.logger || console })
108
82
  this.stats = { sent: 0, errors: 0, startedAt: Date.now() }
83
+ this.apiHealth = new ApiHealthTracker({ logger: this.logger })
84
+ }
85
+
86
+ get consecutiveApiFailures() {
87
+ return this.apiHealth.consecutiveFailures
88
+ }
89
+
90
+ get lastApiError() {
91
+ return this.apiHealth.lastError
92
+ }
93
+
94
+ recordApiFailure(op, err) {
95
+ this.apiHealth.recordFailure(op, err)
96
+ }
97
+
98
+ recordApiSuccess() {
99
+ this.apiHealth.recordSuccess()
109
100
  }
110
101
 
111
102
  resolveLocale(input) {
@@ -136,7 +127,7 @@ export class Gateway {
136
127
  const chat = await this.getOrCreateChat(key, input)
137
128
  const turnInput = { ...input, attachments: [], inboundWasVoice: false }
138
129
  chat.turnActive = true
139
- try { chat.abort?.abort?.() } catch {}
130
+ try { chat.abort?.abort?.() } catch { /* safe best-effort abort */ }
140
131
  chat.abort = new AbortController()
141
132
  const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
142
133
  chat.busy = run.catch(() => {})
@@ -262,7 +253,7 @@ export class Gateway {
262
253
  if (this.scheduler) this.scheduler.stop()
263
254
  if (this.disposeListener) this.disposeListener()
264
255
  if (this.idleTimer) clearInterval(this.idleTimer)
265
- for (const a of this.adapterList) { try { a.stop() } catch {} }
256
+ for (const a of this.adapterList) { try { a.stop() } catch { /* safe best-effort stop */ } }
266
257
  this.adapterList = []
267
258
  this.adapters.clear()
268
259
  for (const chat of this.chats.values()) chat.dispose().catch(() => {})
@@ -293,8 +284,10 @@ export class Gateway {
293
284
  try {
294
285
  await adapter.sendTo(resolved.chatId, payload, { threadId: resolved.threadId })
295
286
  this.stats.sent++
287
+ this.recordApiSuccess()
296
288
  } catch (err) {
297
289
  this.stats.errors++
290
+ this.recordApiFailure('messengerSend', err)
298
291
  if (isTopicGoneError(err)) {
299
292
  this.logger?.warn?.(`messengerSend: topic gone for ${resolved.platform}:${resolved.chatId}:${resolved.threadId} — the chat/topic was deleted; skipping delivery`)
300
293
  return
@@ -304,55 +297,10 @@ export class Gateway {
304
297
  }
305
298
 
306
299
  async messengerAsk(target, payload, timeoutMs = 300_000) {
307
- const adapter = this.getAdapter(target.platform)
308
- if (!adapter) throw new Error(`adapter ${target.platform} unavailable`)
309
- const token = makeAskToken()
310
- const isMulti = payload.mode === 'multi' || (Array.isArray(payload.options) && payload.options.length > 0)
311
- let replyMarkup
312
- let callbackKeys
313
- let selectedSet
314
- let page = 0
315
- const pageSize = Number(payload.pageSize) || 6
316
-
317
- if (isMulti) {
318
- selectedSet = new Set(Array.isArray(payload.selected) ? payload.selected : [])
319
- const kb = buildMultiSelectKeyboard(token, payload.options || payload.buttons, selectedSet, page, pageSize, payload)
320
- replyMarkup = kb.replyMarkup
321
- callbackKeys = kb.callbackKeys
322
- } else {
323
- const kb = buildInlineKeyboard(token, payload.buttons || [])
324
- replyMarkup = kb.replyMarkup
325
- callbackKeys = kb.callbackKeys
326
- }
327
-
328
- indexCallbacks(this.callbackIndex, callbackKeys, token)
329
- try {
330
- await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
331
- } catch (err) {
332
- // Never leave stale callback keys pointing at an unresolvable ask.
333
- releaseCallbacks(this.callbackIndex, callbackKeys)
334
- if (isTopicGoneError(err)) {
335
- this.logger?.warn?.(`messengerAsk: topic gone for ${target.platform}:${target.chatId}:${target.threadId} — ask aborted (chat/topic deleted)`)
336
- throw new Error('messenger.ask: chat or topic was deleted')
337
- }
338
- throw err
339
- }
340
- return new Promise((resolve, reject) => {
341
- const timer = setTimeout(() => {
342
- this.pendingAsks.delete(token)
343
- releaseCallbacks(this.callbackIndex, callbackKeys)
344
- reject(new Error('messenger.ask timed out'))
345
- }, timeoutMs)
346
- timer.unref?.()
347
- this.pendingAsks.set(token, {
348
- resolve, reject, timer, target, callbackKeys, isMulti,
349
- options: payload.options || payload.buttons,
350
- selected: selectedSet,
351
- page, pageSize, payload,
352
- })
353
- })
300
+ return executeMessengerAsk(this, target, payload, timeoutMs)
354
301
  }
355
302
 
303
+
356
304
  async messengerProgress(target, payload) {
357
305
  await this.messengerSend(target, { text: payload.text })
358
306
  }
@@ -375,170 +323,7 @@ export class Gateway {
375
323
  }
376
324
 
377
325
  async handleCallback(cb) {
378
- if (cb.userId && !this.isUserAllowed(cb.userId)) {
379
- try { await cb.answer(t('msg.not_allowed', {}, 'en')) } catch {}
380
- return
381
- }
382
- const indexed = this.callbackIndex.get(cb.data)
383
- const { token, buttonId } = parseCallbackData(cb.data)
384
- const askToken = indexed || token
385
- if (askToken && this.pendingAsks.has(askToken)) {
386
- const pending = this.pendingAsks.get(askToken)
387
- if (!targetMatchesAsk(pending, cb)) {
388
- await cb.answer(t('ask.other_chat', {}, 'en'))
389
- return
390
- }
391
- const action = parseAskCallback(cb.data)
392
- if (pending.isMulti) {
393
- if (action.kind === 'toggle') {
394
- if (pending.selected.has(action.id)) pending.selected.delete(action.id)
395
- else pending.selected.add(action.id)
396
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
397
- const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
398
- pending.callbackKeys = nextKb.callbackKeys
399
- indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
400
- try {
401
- if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
402
- else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
403
- } catch {}
404
- await cb.answer(pending.selected.has(action.id) ? 'Selected' : 'Deselected')
405
- return
406
- }
407
- if (action.kind === 'page') {
408
- pending.page = action.page
409
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
410
- const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
411
- pending.callbackKeys = nextKb.callbackKeys
412
- indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
413
- try {
414
- if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
415
- else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
416
- } catch {}
417
- await cb.answer()
418
- return
419
- }
420
- if (action.kind === 'done') {
421
- this.pendingAsks.delete(askToken)
422
- clearTimeout(pending.timer)
423
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
424
- await cb.answer('OK')
425
- try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch {}
426
- pending.resolve({ buttonId: 'done', selected: Array.from(pending.selected), data: cb.data })
427
- return
428
- }
429
- if (action.kind === 'cancel') {
430
- this.pendingAsks.delete(askToken)
431
- clearTimeout(pending.timer)
432
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
433
- await cb.answer('Cancelled')
434
- try { await cb.editMessage(cb.message?.text || 'Cancelled', REMOVE_KEYBOARD) } catch {}
435
- pending.resolve({ buttonId: 'cancel', selected: [], data: cb.data })
436
- return
437
- }
438
- }
439
- this.pendingAsks.delete(askToken)
440
- clearTimeout(pending.timer)
441
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
442
- await cb.answer('OK')
443
- try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch {}
444
- pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
445
- return
446
- }
447
-
448
- // Model picker interactive flow
449
- if (cb.data?.startsWith('mdl:')) {
450
- const parts = cb.data.split(':')
451
- const sub = parts[1]
452
- // Step 2: Selected provider -> show its models (10 per page)
453
- if (sub === 'p') {
454
- const providerId = parts.slice(2).join(':')
455
- const current = this.resolveAgentModel()
456
- const catalog = await listModelCatalog(this.ctx, current)
457
- const models = catalog.modelsByProvider.get(providerId) || []
458
- if (!models.length) {
459
- await cb.answer(t('model.no_models', {}, 'en'))
460
- return
461
- }
462
- const kb = buildModelsKeyboard(providerId, models, current.model, 0)
463
- await cb.answer()
464
- const text = [
465
- `🤖 <b>Provider:</b> <code>${providerId}</code>`,
466
- `Select model (page ${kb.page + 1}/${kb.totalPages}):`,
467
- ].join('\n')
468
- try {
469
- if (cb.editMessage) await cb.editMessage(text, kb)
470
- } catch {}
471
- return
472
- }
473
- // Pagination for models
474
- if (sub === 'pg') {
475
- const page = parseInt(parts[parts.length - 1], 10) || 0
476
- const providerId = parts.slice(2, -1).join(':')
477
- const current = this.resolveAgentModel()
478
- const catalog = await listModelCatalog(this.ctx, current)
479
- const models = catalog.modelsByProvider.get(providerId) || []
480
- const kb = buildModelsKeyboard(providerId, models, current.model, page)
481
- await cb.answer()
482
- const text = [
483
- `🤖 <b>Provider:</b> <code>${providerId}</code>`,
484
- `Select model (page ${kb.page + 1}/${kb.totalPages}):`,
485
- ].join('\n')
486
- try {
487
- if (cb.editMessage) await cb.editMessage(text, kb)
488
- } catch {}
489
- return
490
- }
491
- // Back to providers
492
- if (sub === 'back') {
493
- const current = this.resolveAgentModel()
494
- const catalog = await listModelCatalog(this.ctx, current)
495
- const kb = buildProvidersKeyboard(catalog.providers, current)
496
- await cb.answer()
497
- const text = [
498
- '🤖 <b>Choose Provider:</b>',
499
- `Current: <code>${current.provider}/${current.model}</code>`,
500
- ].join('\n')
501
- try {
502
- if (cb.editMessage) await cb.editMessage(text, kb)
503
- } catch {}
504
- return
505
- }
506
- // Select model
507
- if (sub === 's') {
508
- const key = parts[2]
509
- const stored = getStoredModelSelection(key)
510
- if (!stored) {
511
- await cb.answer(t('ask.expired', {}, 'en'))
512
- return
513
- }
514
- const { provider, model } = stored
515
- try {
516
- const adm = this.ctx.get('agentDefaultModel')
517
- if (adm?.saveSelection) {
518
- await adm.saveSelection({ provider, model })
519
- }
520
- this.config.agent = { ...this.config.agent, provider, model }
521
- try {
522
- await this.hooks?.persistAgentModel?.({ provider, model })
523
- } catch (e) {
524
- this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
525
- }
526
- await cb.answer(t('ask.chose', { choice: model }, 'en'))
527
- try {
528
- if (cb.editMessage) await cb.editMessage(t('model.switched', { provider, model }, 'en'), REMOVE_KEYBOARD)
529
- } catch {}
530
- } catch (err) {
531
- await cb.answer(`Error: ${err.message}`)
532
- }
533
- return
534
- }
535
- if (sub === 'cur') {
536
- await cb.answer()
537
- return
538
- }
539
- }
540
-
541
- await cb.answer()
326
+ return handleGatewayCallback(this, cb)
542
327
  }
543
328
 
544
329
  sessionKeyFor(input) {
@@ -607,13 +392,13 @@ export class Gateway {
607
392
  source: { kind: 'user', plugin: PLUGIN, form: 'steer', origin: 'telegram' },
608
393
  }))
609
394
  chat.lastUsed = Date.now()
610
- try { await reply(t('msg.steer_added', {}, this.resolveLocale(input))) } catch {}
395
+ try { await reply(t('msg.steer_added', {}, this.resolveLocale(input))) } catch (err) { this.recordApiFailure('reply.steer_added', err) }
611
396
  return
612
397
  }
613
398
 
614
399
  // Mark busy BEFORE yielding to the poll loop, otherwise steer/stop never see an active turn.
615
400
  chat.turnActive = true
616
- try { chat.abort?.abort?.() } catch {}
401
+ try { chat.abort?.abort?.() } catch { /* safe best-effort abort */ }
617
402
  chat.abort = new AbortController()
618
403
  const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
619
404
  chat.busy = run.catch(() => {})
@@ -634,755 +419,28 @@ export class Gateway {
634
419
  } catch (err) {
635
420
  const msg = err instanceof Error ? err.message : String(err)
636
421
  this.ctx.logger?.warn?.(`dsh-messenger-gateway: message: ${msg}`)
637
- try { await reply(t('msg.exception', { message: msg }, this.resolveLocale(input))) } catch {}
422
+ try { await reply(t('msg.exception', { message: msg }, this.resolveLocale(input))) } catch (repErr) { this.recordApiFailure('reply.exception', repErr) }
638
423
  }
639
424
  }
640
425
 
641
426
  async handleCommand(key, text, input) {
642
- const parts = text.split(/\s+/)
643
- const cmd = parts[0].toLowerCase().split('@')[0]
644
- const { reply, userId, chatId, threadId = 0, platform } = input
645
- const locale = this.resolveLocale(input)
646
-
647
- if (cmd === '/start') {
648
- return reply(t('msg.start', {}, locale), { replyMarkup: REMOVE_REPLY_KEYBOARD })
649
- }
650
-
651
- if (cmd === '/help') {
652
- return reply([
653
- '📖 <b>Messenger Gateway Help:</b>',
654
- '',
655
- '💬 <b>Session & Chat:</b>',
656
- '• /help — show this command reference',
657
- '• /new — start fresh session',
658
- '• /stop — interrupt current response',
659
- '• /model — interactive model selector (/model list)',
660
- '• /role [name] — switch persona or role (/role list)',
661
- '• /bind [role] — bind persona to topic / chat',
662
- '• /preset [name] — bind preset to topic / chat',
663
- '• /lang [en|zh] — switch user language',
664
- '• /rewind [N] — rewind last N turns',
665
- '• /fork — fork session into new branch',
666
- '• /export — export history to Markdown',
667
- '',
668
- '🛠️ <b>Tools, Files & Cron:</b>',
669
- '• /skills / /tools — list active tools & skills',
670
- '• /files [dir] — workspace file explorer',
671
- '• /get <path> — download file from workspace',
672
- '• /remind <time> <text> — set reminder (/remind 10m check deploy)',
673
- '• /cron <interval> <prompt> — recurring autonomous task',
674
- '',
675
- '⚙️ <b>Settings & Stats:</b>',
676
- '• /status — gateway and active model status',
677
- '• /top — system resource usage (RAM, uptime)',
678
- '• /keyboard on|off — quick action keyboard',
679
- '• /voice on|off|status — voice replies preference',
680
- '• /tts on|off|status — speech synthesis in this chat',
681
- '• /mute / /unmute — mute notifications in this chat',
682
- '',
683
- '🔒 <b>Access & Channels:</b>',
684
- '• /whoami — your messenger user ID',
685
- '• /pair CODE — approve pairing code',
686
- '• /sethome [name] — set home notification channel',
687
- '• /setalert — set alert channel',
688
- ].join('\n'))
689
- }
690
-
691
- if (cmd === '/lang' || cmd === '/language') {
692
- const sub = parts[1]?.toLowerCase()
693
- if (sub === 'en' || sub === 'zh') {
694
- this.chatLocales.set(chatId, sub)
695
- return reply(sub === 'zh' ? '语言已切换为中文 (zh)' : 'Language switched to English (en)')
696
- }
697
- const cur = this.chatLocales.get(chatId) || this.config?.defaultLocale || 'en'
698
- return reply(`Current language: <b>${cur}</b>\nSwitch: <code>/lang en</code> or <code>/lang zh</code>`)
699
- }
700
-
701
- if (cmd === '/bind') {
702
- const targetRole = parts[1]?.toLowerCase()
703
- if (!targetRole || targetRole === 'list') {
704
- const cur = this.personas.getPersonaForChat(chatId, threadId)
705
- return reply(`${t('persona.title', {}, locale)}\nCurrent bound role: <b>${cur}</b>\nUsage: <code>/bind &lt;role&gt;</code> (or /bind reset)`)
706
- }
707
- if (targetRole === 'reset' || targetRole === 'default') {
708
- this.personas.set(chatId, 'default', threadId)
709
- return reply(t('persona.reset', {}, locale))
710
- }
711
- const persona = getPersona(targetRole)
712
- if (!persona) return reply(t('persona.unknown', { target: targetRole }, locale))
713
- this.personas.set(chatId, targetRole, threadId)
714
- return reply(t('persona.bound_topic', { kind: 'role', name: `${persona.icon} ${persona.name}` }, locale))
715
- }
716
-
717
- if (cmd === '/preset') {
718
- const presetName = parts[1]
719
- if (!presetName || presetName === 'list') {
720
- const cur = this.personas.getPreset(chatId, threadId) || '(none)'
721
- return reply(`🎭 <b>Presets:</b>\nCurrent topic preset: <code>${cur}</code>\nUsage: <code>/preset &lt;name&gt;</code> or <code>/preset reset</code>`)
722
- }
723
- if (presetName === 'reset' || presetName === 'clear') {
724
- this.personas.setPreset(chatId, threadId, null)
725
- return reply('Preset cleared for this topic.')
726
- }
727
- this.personas.setPreset(chatId, threadId, presetName)
728
- return reply(t('persona.bound_topic', { kind: 'preset', name: presetName }, locale))
729
- }
730
-
731
- if (cmd === '/cron') {
732
- const sub = parts[1]?.toLowerCase()
733
- if (sub === 'list') {
734
- const list = await this.scheduler.listRecurring(chatId)
735
- if (!list.length) return reply(t('cron.none', {}, locale))
736
- const lines = [t('cron.list_title', {}, locale)]
737
- for (const task of list) {
738
- const left = formatRemaining(task.dueAt - Date.now(), locale)
739
- lines.push(`• <code>${task.id}</code> (every ${formatRemaining(task.intervalMs, locale)}, next in ${left}): ${task.prompt || task.text}`)
740
- }
741
- lines.push('\nCancel: <code>/cron cancel ID</code>')
742
- return reply(lines.join('\n'))
743
- }
744
- if (sub === 'cancel') {
745
- const targetId = parts[2]
746
- if (!targetId) return reply('Specify cron task ID: <code>/cron cancel ID</code>')
747
- const ok = await this.scheduler.cancel(targetId, chatId)
748
- return reply(ok ? t('cron.cancelled', { id: targetId }, locale) : `Task not found: <code>${targetId}</code>`)
749
- }
750
- const specArg = parts[1]
751
- const promptArg = parts.slice(2).join(' ')
752
- const ms = parseRelativeTime(specArg)
753
- if (!ms || !promptArg) {
754
- return reply('⏱️ <b>Autonomous Cron Tasks:</b>\nCreate: <code>/cron &lt;interval&gt; &lt;prompt&gt;</code>\nExample: <code>/cron 1h check server logs</code>\nList: <code>/cron list</code>\nCancel: <code>/cron cancel ID</code>')
755
- }
756
- const task = await this.scheduler.schedule({
757
- platform,
758
- chatId,
759
- threadId,
760
- userId,
761
- text: promptArg,
762
- prompt: promptArg,
763
- dueAt: Date.now() + ms,
764
- recurring: true,
765
- intervalMs: ms,
766
- })
767
- return reply(t('cron.scheduled', { id: task.id, schedule: specArg, prompt: promptArg }, locale))
768
- }
769
-
770
- if (cmd === '/update') {
771
- if (!this.isUserAllowed(userId)) {
772
- return reply(t('msg.not_allowed', {}, locale))
773
- }
774
- const sub = parts[1]?.toLowerCase()
775
- const manifestPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json')
776
- const updaterOpts = {
777
- manifestPath,
778
- packageName: '@goodandready/dsh-messenger-gateway',
779
- }
780
- if (sub === 'now' || sub === 'install') {
781
- await reply(t('update.installing', {}, locale))
782
- try {
783
- const res = await runDirectUpdate(updaterOpts)
784
- if (!res.updated) {
785
- return reply(t('update.already_latest', { version: res.currentVersion }, locale))
786
- }
787
- return reply(t('update.success', { version: res.updatedVersion }, locale))
788
- } catch (err) {
789
- return reply(t('update.failed', { error: err.message }, locale))
790
- }
791
- }
792
- try {
793
- const st = await getUpdateStatus(updaterOpts)
794
- if (st.updateAvailable) {
795
- return reply(t('update.available', { current: st.currentVersion, latest: st.latestVersion }, locale))
796
- }
797
- return reply(t('update.current', { version: st.currentVersion }, locale))
798
- } catch (err) {
799
- return reply(t('update.check_failed', { error: err.message }, locale))
800
- }
801
- }
802
-
803
- if (cmd === '/role' || cmd === '/persona') {
804
- const targetRole = parts[1]?.toLowerCase()
805
- if (!targetRole || targetRole === 'list') {
806
- const currentId = this.personas.getPersonaForChat(chatId, threadId)
807
- const lines = [
808
- t('persona.title', {}, locale),
809
- '',
810
- ...listPersonas().map((p) => {
811
- const isCurrent = p.id === currentId ? ' (active)' : ''
812
- return `${p.icon} <b>${p.id}</b> — ${p.name}: ${p.description}${isCurrent}`
813
- }),
814
- '',
815
- t('persona.usage', {}, locale),
816
- ]
817
- return reply(lines.join('\n'))
818
- }
819
- if (targetRole === 'reset' || targetRole === 'default') {
820
- this.personas.set(chatId, 'default', threadId)
821
- return reply(t('persona.reset', {}, locale))
822
- }
823
- const persona = getPersona(targetRole)
824
- if (!persona) {
825
- return reply(t('persona.unknown', { target: targetRole }, locale))
826
- }
827
- this.personas.set(chatId, persona.id, threadId)
828
- return reply(t('persona.switched', { icon: persona.icon, name: persona.name, description: persona.description }, locale))
829
- }
830
-
831
- if (cmd === '/skills' || cmd === '/tools') {
832
- const tools = this.ctx.get?.('tools') || this.ctx.tools
833
- const toolsList = []
834
- if (tools?.tools) {
835
- for (const [name, tDef] of tools.tools.entries()) {
836
- toolsList.push(`• <b>${name}</b>: ${tDef.description || '(no description)'}`)
837
- }
838
- }
839
- if (!toolsList.length) {
840
- return reply('🛠️ <b>Agent Tools:</b>\n(no tools registered)')
841
- }
842
- return reply([
843
- '🛠️ <b>Active Tools & Skills:</b>',
844
- '',
845
- ...toolsList,
846
- ].join('\n'))
847
- }
848
-
849
- if (cmd === '/export') {
850
- const chat = this.chats.get(key)
851
- if (!chat?.agent?.session) {
852
- return reply(t('msg.no_active_session', {}, locale))
853
- }
854
- try {
855
- const { filename, buffer, messagesCount } = exportSessionToMarkdown(chat.agent.session)
856
- if (!messagesCount) {
857
- return reply(t('export.empty', {}, locale))
858
- }
859
- const file = {
860
- name: filename,
861
- mime: 'text/markdown',
862
- kind: 'document',
863
- bytes: buffer,
864
- }
865
- return reply({ text: t('export.title', { count: messagesCount }, locale), files: [file] })
866
- } catch (err) {
867
- return reply(`Export error: ${err.message}`)
868
- }
869
- }
870
-
871
- if (cmd === '/rewind') {
872
- const chat = this.chats.get(key)
873
- if (!chat?.agent?.session) {
874
- return reply(t('msg.no_active_session', {}, locale))
875
- }
876
- const count = Number(parts[1]) || 1
877
- const res = rewindSession(chat.agent.session, count)
878
- if (!res.removed) {
879
- return reply('No turns to rewind in session history.')
880
- }
881
- const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
882
- try { await sessions?.flush(chat.agent.session) } catch {}
883
- return reply(`⏪ Rewound ${res.removed} messages. Remaining in context: ${res.remaining}.`)
884
- }
885
-
886
- if (cmd === '/fork') {
887
- const chat = this.chats.get(key)
888
- if (!chat?.agent?.session) {
889
- return reply(t('msg.no_active_session', {}, locale))
890
- }
891
- try {
892
- const oldSession = chat.agent.session
893
- const oldMessages = Array.isArray(oldSession.messages)
894
- ? oldSession.messages.map(m => ({ ...m, content: ensureContentArray(m.content) }))
895
- : []
896
- const newChat = await this.createChat(key, input)
897
- if (newChat.agent?.session && oldMessages.length) {
898
- newChat.agent.session.messages = oldMessages
899
- const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
900
- try { await sessions?.flush(newChat.agent.session) } catch {}
901
- }
902
- this.chats.set(key, newChat)
903
- return reply(`🔀 Forked session!\nOld session: ${oldSession.id}\nNew session: ${newChat.agent.session.id}\nContext preserved (${oldMessages.length} messages).`)
904
- } catch (err) {
905
- return reply(`Fork error: ${err.message}`)
906
- }
907
- }
908
-
909
- if (cmd === '/files') {
910
- const subPath = parts.slice(1).join(' ').trim() || '.'
911
- const agentCwd = this.config.agent?.cwd || process.cwd()
912
- const res = await listFiles(agentCwd, subPath)
913
- if (!res.ok) return reply(`❌ ${res.error}`)
914
- return reply(res.formattedText)
915
- }
916
-
917
- if (cmd === '/get') {
918
- const targetRel = parts.slice(1).join(' ').trim()
919
- if (!targetRel) {
920
- return reply('Specify file path to download: <code>/get &lt;path&gt;</code>\nBrowse: <code>/files</code>')
921
- }
922
- const agentCwd = this.config.agent?.cwd || process.cwd()
923
- const maxDocBytes = Number(this.config.media?.maxDocBytes) || 50 * 1024 * 1024
924
- const res = await getFileForDownload(agentCwd, targetRel, maxDocBytes)
925
- if (!res.ok) return reply(`❌ ${res.error}`)
926
- const file = {
927
- name: res.name,
928
- mime: res.mime,
929
- kind: 'document',
930
- bytes: res.bytes,
931
- dataBase64: res.bytes.toString('base64'),
932
- }
933
- return reply({ text: `📄 File: <b>${res.name}</b> (${formatFileSize(res.size)})`, files: [file] })
934
- }
935
-
936
- if (cmd === '/new') {
937
- const chat = this.chats.get(key)
938
- if (chat) {
939
- if (chat.abort) chat.abort.abort()
940
- chat.pendingMedia = []
941
- this.sessionToChat.delete(String(chat.agent.session.id))
942
- this.chats.delete(key)
943
- await chat.dispose()
944
- return reply('Session reset.')
945
- }
946
- return reply(t('msg.no_active_session', {}, locale))
947
- }
948
-
949
- if (cmd === '/whoami') {
950
- const lines = [`User ID: ${userId}`]
951
- if (chatId) lines.push(`chatId: ${chatId}`)
952
- if (threadId) lines.push(`threadId: ${threadId}`)
953
- return reply(lines.join('\n'))
954
- }
955
-
956
- if (cmd === '/stop') {
957
- const chat = this.chats.get(key)
958
- if (chat?.turnActive || chat?.abort) {
959
- try { chat.abort?.abort() } catch {}
960
- releaseChatTurn(chat)
961
- chat.turnActive = false
962
- return reply(t('msg.turn_stopped', {}, locale))
963
- }
964
- return reply('Nothing to stop.')
965
- }
966
-
967
- if (cmd === '/status') {
968
- let modelLine = 'model: (not set)'
969
- try {
970
- const sel = this.resolveAgentModel()
971
- modelLine = `model: ${sel.provider}/${sel.model}`
972
- } catch (e) {
973
- modelLine = `model: ${e.message}`
974
- }
975
- const home = this.resolveHomeTarget(platform || 'telegram')
976
- const homeLine = home
977
- ? `home: chat ${home.chatId}${home.threadId ? ` topic ${home.threadId}` : ''}`
978
- : 'home: (not set)'
979
- const pending = this.pairing.listPending().length
980
- const up = Math.max(0, Math.round((Date.now() - this.stats.startedAt) / 1000))
981
- const hh = String(Math.floor(up / 3600)).padStart(2, '0')
982
- const mm = String(Math.floor((up % 3600) / 60)).padStart(2, '0')
983
- const ss = String(up % 60).padStart(2, '0')
984
- return reply([
985
- 'Messenger gateway',
986
- `adapters: ${[...this.adapters.keys()].join(', ') || '(none)'}`,
987
- `active chats: ${this.chats.size}`,
988
- modelLine,
989
- homeLine,
990
- `pairing pending: ${pending}`,
991
- `transport: ${this.tg().transport || 'poll'}`,
992
- `sessionScope: ${this.config.agent?.sessionScope || 'user'}`,
993
- `delivered: ${this.stats.sent}`,
994
- `errors: ${this.stats.errors}`,
995
- `polling conflict: ${this.tgAdapter?.pollingConflict ? 'yes' : 'no'}`,
996
- `uptime: ${hh}:${mm}:${ss}`,
997
- ].join('\n'))
998
- }
999
-
1000
- if (cmd === '/model') {
1001
- if (parts.length >= 3) {
1002
- if (!this.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
1003
- const provider = parts[1]
1004
- const model = parts.slice(2).join(' ')
1005
- try {
1006
- const adm = this.ctx.get('agentDefaultModel')
1007
- if (adm?.saveSelection) {
1008
- await adm.saveSelection({ provider, model })
1009
- }
1010
- this.config.agent = { ...this.config.agent, provider, model }
1011
- try {
1012
- await this.hooks?.persistAgentModel?.({ provider, model })
1013
- } catch (e) {
1014
- this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
1015
- }
1016
- return reply(t('model.switched', { provider, model }, locale))
1017
- } catch (e) {
1018
- return reply(`Failed to switch model: ${e.message}`)
1019
- }
1020
- }
1021
- try {
1022
- const current = this.resolveAgentModel()
1023
- const catalog = await listModelCatalog(this.ctx, current)
1024
- if (!catalog.providers.length) {
1025
- return reply(`Current model: <code>${current.provider}/${current.model}</code>\nSwitch: <code>/model &lt;provider&gt; &lt;model&gt;</code>`)
1026
- }
1027
- const kb = buildProvidersKeyboard(catalog.providers, current)
1028
- return reply([
1029
- t('model.title', {}, locale),
1030
- t('model.current', { current: `${current.provider}/${current.model}` }, locale),
1031
- ].join('\n'), {
1032
- replyMarkup: kb,
1033
- })
1034
- } catch (e) {
1035
- return reply(e.message)
1036
- }
1037
- }
1038
-
1039
- if (cmd === '/pair') {
1040
- if (!this.isUserAllowed(userId)) return reply('Only users in allowlist can approve /pair.')
1041
- const code = parts[1]
1042
- if (!code) return reply('Usage: /pair CODE')
1043
- const res = this.pairing.approveCode(code, userId)
1044
- if (!res.ok) return reply(`Failed: ${res.error}`)
1045
- const merged = this.effectiveAllowedIds()
1046
- for (const a of this.adapterList) a.setAllowedUserIds?.(merged)
1047
- try { await this.hooks?.persistAllowedUserIds?.(merged) } catch (e) {
1048
- this.ctx.logger?.warn?.(`persist allowlist: ${e.message}`)
1049
- }
1050
- return reply(`Approved user ID ${res.userId}${res.username ? ` (@${res.username})` : ''}.`)
1051
- }
1052
-
1053
- if (cmd === '/sethome') {
1054
- if (!this.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
1055
- const name = normalizeHomeName(parts[1] || 'default') || 'default'
1056
- try {
1057
- const nextTg = upsertHome(this.tg(), { name, chatId, threadId })
1058
- await this.hooks?.persistHomes?.(nextTg)
1059
- this.config.telegram = nextTg
1060
- return reply(`Home "${name}": chat ${chatId}${threadId ? ` topic ${threadId}` : ''}`)
1061
- } catch (e) {
1062
- return reply(`Failed to save home: ${e.message}`)
1063
- }
1064
- }
1065
-
1066
- if (cmd === '/home') {
1067
- const homes = listHomes(this.tg())
1068
- if (!homes.length) return reply('Home is not set. /sethome or /sethome <name>')
1069
- return reply(['Homes:', ...homes.map((h) => `• ${h.name}: chat ${h.chatId}${h.threadId ? ` topic ${h.threadId}` : ''}`)].join('\n'))
1070
- }
1071
-
1072
- if (cmd === '/setalert') {
1073
- if (!this.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
1074
- const nextTg = {
1075
- ...this.tg(),
1076
- alerts: {
1077
- ...(this.tg().alerts || {}),
1078
- enabled: true,
1079
- chatId,
1080
- threadId: threadId || 0,
1081
- },
1082
- }
1083
- this.config.telegram = nextTg
1084
- try { await this.hooks?.persistHomes?.(nextTg) } catch {}
1085
- return reply(`🔔 This chat assigned as alert channel (chat: ${chatId}${threadId ? `, topic: ${threadId}` : ''}).`)
1086
- }
1087
-
1088
- if (cmd === '/alert') {
1089
- const sub = parts[1]?.toLowerCase()
1090
- if (sub === 'test') {
1091
- const target = resolveAlertTarget(this)
1092
- if (!target) return reply('Alert channel not configured. Configure: /setalert')
1093
- await this.sendAlert('status', { title: 'Test Alert', details: `Sent by user ID ${userId}` })
1094
- return reply('Test alert sent to alert channel.')
1095
- }
1096
- const target = resolveAlertTarget(this)
1097
- const alertsCfg = this.tg().alerts || {}
1098
- return reply([
1099
- '🔔 <b>Alert Channel:</b>',
1100
- `Status: ${alertsCfg.enabled ? 'enabled' : 'disabled'}`,
1101
- `Chat: ${target ? `${target.chatId}${target.threadId ? ` (topic: ${target.threadId})` : ''}` : '(not assigned)'}`,
1102
- `Events: ${(alertsCfg.events || ['error', 'pairing']).join(', ')}`,
1103
- '',
1104
- 'Commands:',
1105
- '/setalert — assign current chat as alert channel',
1106
- '/alert test — send test alert',
1107
- ].join('\n'))
1108
- }
1109
-
1110
- if (cmd === '/remind') {
1111
- const sub = parts[1]?.toLowerCase()
1112
- if (sub === 'list') {
1113
- const active = await this.scheduler.list(chatId)
1114
- if (!active.length) return reply('No active reminders for this chat.')
1115
- const lines = [
1116
- '⏰ <b>Active Reminders:</b>',
1117
- '',
1118
- ...active.map((tItem) => {
1119
- const left = formatRemaining(tItem.dueAt - Date.now(), locale)
1120
- return `• <code>${tItem.id}</code> (in ${left}): ${tItem.text}`
1121
- }),
1122
- '',
1123
- 'Cancel: <code>/remind cancel ID</code>',
1124
- ]
1125
- return reply(lines.join('\n'))
1126
- }
1127
- if (sub === 'cancel') {
1128
- const targetId = parts[2]?.trim()
1129
- if (!targetId) return reply('Specify reminder ID: <code>/remind cancel ID</code>')
1130
- const ok = await this.scheduler.cancel(targetId, chatId)
1131
- return reply(ok ? `✅ Reminder <code>${targetId}</code> cancelled.` : `❌ Reminder <code>${targetId}</code> not found.`)
1132
- }
1133
- const timeArg = parts[1]
1134
- const textArg = parts.slice(2).join(' ').trim()
1135
- const delayMs = parseRelativeTime(timeArg)
1136
- if (!delayMs || !textArg) {
1137
- return reply([
1138
- '⏰ <b>Reminders:</b>',
1139
- 'Create: <code>/remind &lt;time&gt; &lt;text&gt;</code>',
1140
- 'Examples: <code>/remind 10m Call colleague</code>, <code>/remind 2h Check deploy</code>',
1141
- 'List: <code>/remind list</code>',
1142
- 'Cancel: <code>/remind cancel ID</code>',
1143
- ].join('\n'))
1144
- }
1145
- const dueAt = Date.now() + delayMs
1146
- const task = await this.scheduler.schedule({
1147
- platform: platform || 'telegram',
1148
- chatId,
1149
- threadId: threadId || 0,
1150
- userId,
1151
- text: textArg,
1152
- dueAt,
1153
- })
1154
- const left = formatRemaining(delayMs, locale)
1155
- return reply(t('remind.scheduled', { time: new Date(dueAt).toLocaleTimeString(), duration: left, text: textArg }, locale))
1156
- }
1157
-
1158
- if (cmd === '/voice') {
1159
- const sub = String(parts[1] || 'status').toLowerCase()
1160
- if (sub === 'summary') {
1161
- const val = parts[2]?.toLowerCase()
1162
- if (val === 'on' || val === 'off') {
1163
- if (!this.config.tts) this.config.tts = {}
1164
- this.config.tts.voiceSummary = val === 'on'
1165
- return reply(`Voice summary (TL;DR): ${val === 'on' ? 'enabled' : 'disabled'}`)
1166
- }
1167
- const state = this.config.tts?.voiceSummary ? 'on' : 'off'
1168
- return reply(`Voice summary (TL;DR): ${state}\nToggle: <code>/voice summary on|off</code>`)
1169
- }
1170
- if (sub === 'on' || sub === 'off') {
1171
- this.voicePrefs.set(userId, sub === 'on')
1172
- return reply(sub === 'on' ? 'Voice replies: on (for you)' : 'Voice replies: off (for you)')
1173
- }
1174
- const pref = this.voicePrefs.get(userId)
1175
- const mode = this.tg().voiceMode || 'mirror'
1176
- const prefLine = pref === null ? 'not set (/voice on|off)' : (pref ? 'on' : 'off')
1177
- const summaryState = this.config.tts?.voiceSummary ? 'on' : 'off'
1178
- return reply(`voiceMode=${mode}\nyour /voice: ${prefLine}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}\nvoice summary: ${summaryState}`)
1179
- }
1180
-
1181
- if (cmd === '/topic') {
1182
- const topicName = parts.slice(1).join(' ').trim()
1183
- if (!topicName) {
1184
- return reply('Usage: <code>/topic &lt;name&gt;</code>\nCreates a new topic in supergroup with an isolated session.')
1185
- }
1186
- const tgAdapter = this.getAdapter('telegram')
1187
- if (!tgAdapter?.createForumTopic) {
1188
- return reply('Topic creation is available only in Telegram.')
1189
- }
1190
- try {
1191
- const res = await tgAdapter.createForumTopic(chatId, topicName)
1192
- const newThreadId = res?.message_thread_id
1193
- await reply(`🎯 Created new topic <b>«${topicName}»</b> (ID: <code>${newThreadId}</code>).\nSwitch to the topic to continue working!`)
1194
- if (newThreadId) {
1195
- await tgAdapter.sendTo(chatId, {
1196
- text: `👋 Hello! This is an isolated session for task <b>«${topicName}»</b>.\nHow can I help?`,
1197
- }, { threadId: newThreadId })
1198
- }
1199
- return
1200
- } catch (err) {
1201
- return reply(`Failed to create topic: ${err.message}\n(Ensure the bot is group administrator with Manage Topics permission)`)
1202
- }
1203
- }
1204
-
1205
- if (cmd === '/top') {
1206
- const mem = process.memoryUsage()
1207
- const rssMb = (mem.rss / 1024 / 1024).toFixed(1)
1208
- const heapMb = (mem.heapUsed / 1024 / 1024).toFixed(1)
1209
- const sec = Math.floor((Date.now() - this.stats.startedAt) / 1000)
1210
- const hh = String(Math.floor(sec / 3600)).padStart(2, '0')
1211
- const mm = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
1212
- const ss = String(sec % 60).padStart(2, '0')
1213
- let activeReminders = 0
1214
- try { activeReminders = (await this.scheduler.list()).length } catch {}
1215
- let currentModel = 'not set'
1216
- try {
1217
- const m = this.resolveAgentModel()
1218
- currentModel = `${m.provider}/${m.model}`
1219
- } catch {}
1220
-
1221
- return reply([
1222
- '📊 <b>DSH System & Resources:</b>',
1223
- `• <b>Memory (RSS):</b> ${rssMb} MB`,
1224
- `• <b>Heap:</b> ${heapMb} MB`,
1225
- `• <b>Uptime:</b> ${hh}:${mm}:${ss}`,
1226
- `• <b>Active chats:</b> ${this.chats.size}`,
1227
- `• <b>Queued reminders:</b> ${activeReminders}`,
1228
- `• <b>Active model:</b> <code>${currentModel}</code>`,
1229
- `• <b>Messages sent:</b> ${this.stats.sent}`,
1230
- `• <b>Errors:</b> ${this.stats.errors}`,
1231
- ].join('\n'))
1232
- }
1233
-
1234
- if (cmd === '/keyboard') {
1235
- const sub = String(parts[1] || '').toLowerCase()
1236
- const tgAdapter = this.getAdapter('telegram')
1237
- if (sub === 'on') {
1238
- if (tgAdapter) tgAdapter.quickActions = true
1239
- return reply('Quick action keyboard enabled.', {
1240
- replyMarkup: buildQuickActionsKeyboard(),
1241
- })
1242
- }
1243
- if (sub === 'off') {
1244
- if (tgAdapter) tgAdapter.quickActions = false
1245
- return reply('Quick action keyboard disabled.', {
1246
- replyMarkup: REMOVE_REPLY_KEYBOARD,
1247
- })
1248
- }
1249
- const curState = tgAdapter?.quickActions ? 'enabled' : 'disabled'
1250
- return reply([
1251
- '⌨️ <b>Quick Action Keyboard:</b>',
1252
- `Current state: <b>${curState}</b>`,
1253
- '',
1254
- 'Commands:',
1255
- '<code>/keyboard on</code> — show buttons',
1256
- '<code>/keyboard off</code> — hide buttons',
1257
- ].join('\n'))
1258
- }
1259
-
1260
- if (cmd === '/tts') {
1261
- const sub = String(parts[1] || 'status').toLowerCase()
1262
- if (sub === 'on' || sub === 'off') {
1263
- this.chatTts.set(chatId, sub === 'on')
1264
- return reply(sub === 'on' ? 'Speech in this chat: on' : 'Speech in this chat: off')
1265
- }
1266
- const cur = this.chatTts.get(chatId)
1267
- const line = cur === null ? 'not set (/tts on|off)' : (cur ? 'on' : 'off')
1268
- return reply(`Speech in this chat: ${line}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}`)
1269
- }
1270
-
1271
- if (cmd === '/mute') {
1272
- this.setMuted(chatId, true)
1273
- return reply(t('msg.muted_on', {}, locale))
1274
- }
1275
-
1276
- if (cmd === '/unmute') {
1277
- this.setMuted(chatId, false)
1278
- return reply(t('msg.muted_off', {}, locale))
1279
- }
1280
-
1281
- return reply(t('msg.unknown_command', { cmd }, locale))
427
+ return handleGatewayCommand(this, key, text, input)
1282
428
  }
1283
429
 
1284
430
  collectDynamicSkills() {
1285
- const skills = []
1286
- try {
1287
- const skillsService = this.ctx.get?.('skills') || this.ctx.skills
1288
- const allSkills = skillsService?.list?.() || []
1289
- for (const s of allSkills) {
1290
- if (!s || !s.name) continue
1291
- if (s.userInvocable === false) continue
1292
- skills.push({
1293
- name: String(s.name),
1294
- description: String(s.description || s.title || `Skill ${s.name}`).slice(0, 256),
1295
- })
1296
- }
1297
- } catch (err) {
1298
- this.ctx.logger?.debug?.(`Failed to collect dynamic skills: ${err?.message || err}`)
1299
- }
1300
- return skills
431
+ return collectDynamicSkills(this.ctx)
1301
432
  }
1302
433
 
1303
434
  async syncTelegramCommands() {
1304
- const tgAdapter = this.adapters.get('telegram')
1305
- if (!tgAdapter || typeof tgAdapter.registerCommands !== 'function') return
1306
- const baseCommands = this.config.telegram?.commands || []
1307
- const dynamicSkills = this.collectDynamicSkills()
1308
- const merged = mergeDynamicCommands(baseCommands, dynamicSkills, 100)
1309
- try {
1310
- await tgAdapter.registerCommands(merged)
1311
- this.ctx.logger?.info?.(`Synced ${merged.length} Telegram commands (including ${dynamicSkills.length} dynamic skills)`)
1312
- } catch (err) {
1313
- this.ctx.logger?.warn?.(`Failed to sync Telegram commands: ${err?.message || err}`)
1314
- }
435
+ return syncTelegramCommands(this)
1315
436
  }
1316
437
 
1317
438
  async mirrorSessionToForumTopic(session) {
1318
- if (!session || !session.id) return
1319
- const sessionId = String(session.id)
1320
- if (sessionId.startsWith('msgw-')) return
1321
- if (this.sessionToThread.has(sessionId)) return
1322
-
1323
- const tgCfg = this.config.telegram || {}
1324
- if (!tgCfg.forumMirrorEnabled || !tgCfg.forumMirrorChatId) return
1325
-
1326
- const tgAdapter = this.adapters.get('telegram')
1327
- if (!tgAdapter || typeof tgAdapter.createForumTopic !== 'function') return
1328
-
1329
- const forumChatId = tgCfg.forumMirrorChatId
1330
- const title = String(session.title || session.meta?.name || `Session ${sessionId.slice(0, 8)}`).slice(0, 120)
1331
-
1332
- try {
1333
- const topic = await tgAdapter.createForumTopic(forumChatId, title)
1334
- const threadId = topic?.message_thread_id
1335
- if (!threadId) return
1336
-
1337
- const threadKey = `${forumChatId}:${threadId}`
1338
- this.sessionToThread.set(sessionId, { chatId: forumChatId, threadId })
1339
- this.threadToSession.set(threadKey, sessionId)
1340
-
1341
- const text = t('mirror.created', { sessionId, title }, 'en')
1342
- await tgAdapter.sendTo(forumChatId, { text }, { threadId })
1343
- this.ctx.logger?.info?.(`Mirrored session ${sessionId} to Telegram forum topic ${threadId} in ${forumChatId}`)
1344
- } catch (err) {
1345
- this.ctx.logger?.warn?.(`Failed to mirror session ${sessionId} to forum topic: ${err?.message || err}`)
1346
- }
439
+ return mirrorSessionToForumTopic(this, session)
1347
440
  }
1348
441
 
1349
442
  async relayTurnToForumMirror(session, event) {
1350
- if (!session || !session.id) return
1351
- const sessionId = String(session.id)
1352
- const threadInfo = this.sessionToThread.get(sessionId)
1353
- if (!threadInfo) return
1354
- if (this.pending.has(sessionId)) return
1355
-
1356
- const tgAdapter = this.adapters.get('telegram')
1357
- if (!tgAdapter) return
1358
-
1359
- const messages = Array.isArray(session.messages)
1360
- ? session.messages
1361
- : (Array.isArray(session.history) ? session.history : [])
1362
-
1363
- for (let i = messages.length - 1; i >= 0; i--) {
1364
- const msg = messages[i]
1365
- const role = msg.role || (msg.type === 'user' ? 'user' : 'assistant')
1366
- if (role === 'assistant') {
1367
- let text = ''
1368
- if (typeof msg.content === 'string') text = msg.content
1369
- else if (Array.isArray(msg.content)) {
1370
- text = msg.content
1371
- .map((p) => (typeof p === 'string' ? p : (p?.text || '')))
1372
- .filter(Boolean)
1373
- .join('\n')
1374
- } else if (msg.text) text = msg.text
1375
- const clean = stripReasoningPreamble(stripImageUrls(text)).trim()
1376
- if (clean) {
1377
- const maxLen = Number(this.config.agent?.maxMessageLength) || 4000
1378
- const chunks = splitText(clean, maxLen)
1379
- for (const chunk of chunks) {
1380
- await tgAdapter.sendTo(threadInfo.chatId, { text: chunk }, { threadId: threadInfo.threadId })
1381
- }
1382
- }
1383
- break
1384
- }
1385
- }
443
+ return relayTurnToForumMirror(this, session, event)
1386
444
  }
1387
445
 
1388
446
  async getOrCreateChat(key, input) {
@@ -1445,255 +503,15 @@ export class Gateway {
1445
503
  }
1446
504
 
1447
505
  async answerApproval(chatKeyValue, req, next) {
1448
- try {
1449
- const chat = this.chats.get(chatKeyValue)
1450
- if (!chat?.target) return next()
1451
- const tool = req.toolName || 'tool'
1452
- if (chat.sessionAllowlist?.has(tool)) {
1453
- return 'allowed-once'
1454
- }
1455
- const locale = this.resolveLocale(chat.target)
1456
- const reason = req.reason ? `\n<i>${req.reason}</i>` : ''
1457
- let detail = ''
1458
- if (req.input && typeof req.input === 'object') {
1459
- try {
1460
- const jsonStr = JSON.stringify(req.input, null, 2)
1461
- detail = `\n<pre><code>${jsonStr.slice(0, 500)}</code></pre>`
1462
- } catch {}
1463
- }
1464
- const text = t('ask.confirm_title', { tool, reason: reason + detail }, locale)
1465
- const buttons = [
1466
- [
1467
- { id: 'allow_once', text: t('ask.allow_once', {}, locale) },
1468
- { id: 'allow_session', text: t('ask.allow_session', {}, locale) },
1469
- { id: 'deny', text: t('ask.deny', {}, locale) },
1470
- ],
1471
- ]
1472
- const result = await this.messengerAsk(chat.target, { text, buttons }, 300_000)
1473
- if (result?.buttonId === 'allow_once' || result?.buttonId === 'allow') return 'allowed-once'
1474
- if (result?.buttonId === 'allow_session') {
1475
- if (!chat.sessionAllowlist) chat.sessionAllowlist = new Set()
1476
- chat.sessionAllowlist.add(tool)
1477
- return 'allowed-once'
1478
- }
1479
- if (result?.buttonId === 'deny') return 'rejected'
1480
- return next()
1481
- } catch {
1482
- return next()
1483
- }
506
+ return answerApproval(this, chatKeyValue, req, next)
1484
507
  }
1485
508
 
1486
509
  async buildUserContent(input, signal) {
1487
- const { text, attachments = [], replyText, steer, personaOverride } = input
1488
- const parts = []
1489
- parts.push(String(this.config.agent?.instructionPrefix || MESSENGER_RELAY_INSTRUCTION))
1490
- const activePersonaId = personaOverride || this.personas.getPersonaForChat(input.chatId, input.threadId)
1491
- const activePersona = getPersona(activePersonaId)
1492
- if (activePersona?.instruction) {
1493
- parts.push(`[Persona: ${activePersona.name} (${activePersona.icon})]\n${activePersona.instruction}`)
1494
- }
1495
- if (steer) parts.push('[Steer / addition to current turn: combine with previous instruction, do not restart from scratch]')
1496
- if (replyText?.trim()) parts.push(`[Replying to message: ${replyText.trim()}]`)
1497
- const blocks = []
1498
- for (const att of attachments) {
1499
- if (att.kind === 'photo' || (att.kind === 'sticker' && att.mime?.startsWith('image/'))) {
1500
- try {
1501
- const { ref } = await attachInboundPhoto(this.ctx, att, {
1502
- signal,
1503
- maxBytes: Number(this.config.media?.maxImageBytes) || 20 * 1024 * 1024,
1504
- })
1505
- blocks.push({ type: 'image', attachment: ref })
1506
- if (att.kind === 'sticker' && att.emoji) parts.push(`[Sticker ${att.emoji}]`)
1507
- } catch (err) {
1508
- if (signal?.aborted) throw err
1509
- const msg = err instanceof Error ? err.message : String(err)
1510
- parts.push(`[Failed to attach image: ${msg}]`)
1511
- }
1512
- } else if (att.kind === 'voice' || att.kind === 'audio') {
1513
- try {
1514
- const bytes = new Uint8Array(await readFile(att.path))
1515
- const transcript = await transcribeVoice(this.baseUrl(), bytes, att.mime || 'audio/ogg', 'message', signal)
1516
- parts.push(transcript ? `[Voice message transcript: ${transcript}]` : '[Voice message (unrecognized)]')
1517
- } catch (err) {
1518
- if (signal?.aborted) throw err
1519
- const msg = err instanceof Error ? err.message : String(err)
1520
- this.ctx.logger?.warn?.(`voice: ${msg}`)
1521
- parts.push(`[Voice message (dsh-voice unavailable: ${msg})]`)
1522
- }
1523
- } else if (att.kind === 'document' || att.kind === 'video' || att.kind === 'animation' || att.kind === 'sticker') {
1524
- let parsed = null
1525
- if (att.kind === 'document' && att.path) {
1526
- try {
1527
- const maxDocBytes = Number(this.config.media?.maxTextInjectBytes) || 100 * 1024
1528
- parsed = await parseDocument(att.path, { maxBytes: maxDocBytes })
1529
- } catch {}
1530
- }
1531
- parts.push(formatInboundDocument(att, parsed))
1532
- } else {
1533
- parts.push(`[File: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
1534
- }
1535
- }
1536
- const photoHint = photoOnlyHint(attachments, text)
1537
- if (photoHint) parts.push(photoHint)
1538
- const docHint = documentOnlyHint(attachments, text)
1539
- if (docHint) parts.push(docHint)
1540
- if (text?.trim()) parts.push(text.trim())
1541
- const textBlock = parts.filter(Boolean).join('\n\n')
1542
- if (textBlock) blocks.unshift({ type: 'text', text: textBlock })
1543
- if (!blocks.length) blocks.push({ type: 'text', text: '(empty message)' })
1544
- return blocks
510
+ return buildUserContent(this, input, signal)
1545
511
  }
1546
512
 
1547
513
  async runTurn(chat, input, signal) {
1548
- const { reply, typing, startStream, startProgress, react, inboundWasVoice, userId } = input
1549
- chat.turnActive = true
1550
- const sessionId = chat.agent.session.id
1551
- const tg = this.tg()
1552
- const streaming = tg.streaming === true && typeof startStream === 'function'
1553
- const progressEnabled = tg.progressEnabled !== false
1554
- const collector = { parts: [], lastText: '', streamText: '', toolName: '', images: [], reason: undefined, onStream: undefined }
1555
- this.pending.set(sessionId, collector)
1556
- let stopTyping = () => {}
1557
- let stream = null
1558
- let scheduler = null
1559
- let progress = null
1560
- try {
1561
- if (signal.aborted) return
1562
- if (typeof react === 'function' && tg.reactionsEnabled !== false) {
1563
- react('👀').catch?.(() => {})
1564
- }
1565
- if (typeof typing === 'function') stopTyping = startTypingHeartbeat(typing, 4000)
1566
- if (streaming) {
1567
- try {
1568
- stream = await startStream()
1569
- scheduler = createEditScheduler((text) => stream.edit(text), Number(tg.streamEditIntervalMs) || 1200)
1570
- collector.onStream = (text, toolName) => {
1571
- if (text) stopTyping()
1572
- if (!progressEnabled && !text) return
1573
- scheduler.push(buildStreamPreview(text, progressEnabled ? toolName : ''))
1574
- }
1575
- if (progressEnabled) scheduler.push(buildStreamPreview('', ''))
1576
- } catch (e) {
1577
- this.ctx.logger?.warn?.(`stream start: ${e.message}`)
1578
- stream = null
1579
- }
1580
- } else if (progressEnabled && typeof startProgress === 'function') {
1581
- try {
1582
- progress = await startProgress()
1583
- const editProgress = createEditScheduler((text) => progress.edit(text), 800)
1584
- collector.onStream = (_text, toolName) => {
1585
- editProgress.push(formatProgressLine(toolName))
1586
- }
1587
- } catch (e) {
1588
- this.ctx.logger?.warn?.(`progress start: ${e.message}`)
1589
- progress = null
1590
- }
1591
- }
1592
-
1593
-
1594
- const content = await this.buildUserContent(input, signal)
1595
- chat.agent.followup(createUserMessage({
1596
- content: ensureContentArray(content),
1597
- source: { kind: 'user', plugin: PLUGIN, form: 'relay', origin: 'telegram' },
1598
- }))
1599
- const turnTimeoutMs = Number(this.config.agent?.turnTimeoutMs) || 600_000
1600
- await whenIdleWithTimeout(chat.agent, turnTimeoutMs, signal)
1601
- if (signal.aborted) {
1602
- if (progress) try { await progress.remove() } catch {}
1603
- if (typeof react === 'function') react('').catch?.(() => {})
1604
- const stoppedMsg = t('msg.turn_stopped', {}, this.resolveLocale(input))
1605
- if (stream) try { await stream.finalize(stoppedMsg) } catch {}
1606
- else return reply(stoppedMsg)
1607
- return
1608
- }
1609
- const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
1610
- await sessions?.flush?.(chat.agent.session)
1611
- if (progress) try { await progress.remove() } catch {}
1612
- progress = null
1613
- if (typeof react === 'function') react('').catch?.(() => {})
1614
- const rawAnswer = stripReasoningPreamble(stripImageUrls(collector.lastText || collector.streamText || collector.parts.join('\n\n')))
1615
- const processed = processDiagramsAndTables(rawAnswer, {
1616
- artifactPreviews: this.tg().artifactPreviews !== false,
1617
- })
1618
- const answer = processed.text
1619
- if (collector.reason?.kind === 'error') {
1620
- const err = collector.reason.error
1621
- const msg = t('msg.agent_error', { code: err?.code || 'error', message: err?.message || 'unknown' }, this.resolveLocale(input))
1622
- this.sendAlert('error', {
1623
- code: err?.code || 'AGENT_ERROR',
1624
- message: err?.message || 'unknown',
1625
- sessionId,
1626
- chatId: input.chatId,
1627
- threadId: input.threadId,
1628
- }).catch(() => {})
1629
- if (stream) { await scheduler?.flush(); await stream.finalize(msg) }
1630
- else await reply(msg)
1631
- return
1632
- }
1633
- const files = await buildOutboundFiles(this.ctx, this.baseUrl(), collector, { signal, logger: this.ctx.logger })
1634
- const allFiles = [...files, ...(processed.files || [])]
1635
- if (!answer && !allFiles.length) {
1636
- const noResp = t('msg.no_response', {}, this.resolveLocale(input))
1637
- if (stream) { await scheduler?.flush(); await stream.finalize(noResp) }
1638
- else await reply(noResp)
1639
- return
1640
- }
1641
- const maxLen = Number(this.config.agent?.maxMessageLength) || 4000
1642
- const chunks = answer ? splitText(answer, maxLen) : ['']
1643
- if (stream) {
1644
- await scheduler?.flush()
1645
- await stream.finalize(chunks[0] || t('msg.no_response', {}, this.resolveLocale(input)))
1646
- for (let i = 1; i < chunks.length; i++) await reply({ text: chunks[i] })
1647
- if (allFiles.length) await reply({ files: allFiles })
1648
- } else {
1649
- for (let i = 0; i < chunks.length; i++) {
1650
- await reply({ text: chunks[i] || undefined, files: i === 0 ? allFiles : [] })
1651
- }
1652
- }
1653
- const chatTtsPref = this.chatTts.get(chat.target?.chatId)
1654
- const speak = shouldSpeakReply({
1655
- globalTts: Boolean(this.config.tts?.enabled),
1656
- voiceMode: this.tg().voiceMode || 'mirror',
1657
- inboundWasVoice: Boolean(inboundWasVoice),
1658
- userPref: this.voicePrefs.get(userId),
1659
- chatPref: chatTtsPref,
1660
- })
1661
- if (speak && !signal.aborted) {
1662
- const isVoiceSummary = this.config.tts?.voiceSummary === true
1663
- const ttsText = prepareTtsText(answer, this.config.tts?.maxChars, { voiceSummary: isVoiceSummary })
1664
- if (ttsText) {
1665
- try {
1666
- const spoken = await speakText(this.baseUrl(), ttsText, signal)
1667
- const voiceFile = await toTelegramVoiceFile(spoken, { logger: this.ctx.logger })
1668
- if (!signal.aborted && voiceFile) await reply({ files: [voiceFile] })
1669
- } catch (e) {
1670
- if (!signal?.aborted) this.ctx.logger?.warn?.(`tts: ${e.message}`)
1671
- }
1672
- }
1673
- }
1674
- } catch (err) {
1675
- if (!signal?.aborted) {
1676
- this.sendAlert('error', {
1677
- code: err?.code || 'EXCEPTION',
1678
- message: err?.message || String(err),
1679
- sessionId,
1680
- chatId: input.chatId,
1681
- threadId: input.threadId,
1682
- }).catch(() => {})
1683
- try {
1684
- const excMsg = t('msg.exception', { message: err.message }, this.resolveLocale(input))
1685
- if (stream) await stream.finalize(excMsg)
1686
- else await reply(excMsg)
1687
- } catch {}
1688
- }
1689
- } finally {
1690
- stopTyping()
1691
- if (progress) try { await progress.remove() } catch {}
1692
- if (typeof react === 'function') react('').catch?.(() => {})
1693
- chat.turnActive = false
1694
- chat.abort = undefined
1695
- this.pending.delete(sessionId)
1696
- }
514
+ return runGatewayTurn(this, chat, input, signal)
1697
515
  }
1698
516
 
1699
517
  reapIdle() {