@goodandready/dsh-messenger-gateway 0.3.20 → 0.3.22

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
@@ -1,1539 +1,1772 @@
1
- import { readFile } from 'node:fs/promises'
2
- import { randomUUID } from 'node:crypto'
3
- import { join } from 'node:path'
4
- import { homedir } from 'node:os'
5
- import { installModelSelection } from '@deepseek-ai/dsh-agent'
6
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
7
- import { SessionId } from '@deepseek-ai/dsh-session'
8
- import createAdapters from './adapters/index.js'
9
- import { transcribeVoice, speakText } from './integrations.js'
10
- import { attachInboundPhoto, photoOnlyHint } from './photos.js'
11
- import { formatInboundDocument, documentOnlyHint, parseDocument } from './documents.js'
12
- import { listFiles, getFileForDownload, formatFileSize } from './file-manager.js'
13
- import { collectAssistantParts, buildOutboundFiles, stripImageUrls } from './outbound.js'
14
- import { assistantText, splitText, stripReasoningPreamble, MESSENGER_RELAY_INSTRUCTION } from './text.js'
15
- import { chatKey, sessionKey } from './topics.js'
16
- import { listHomes, resolveNamedHome, upsertHome, normalizeHomeName } from './homes.js'
17
- import { createVoicePrefs, shouldSpeakReply } from './voice-prefs.js'
18
- import { prepareTtsText, toTelegramVoiceFile } from './tts.js'
19
- import {
20
- makeAskToken, buildInlineKeyboard, buildMultiSelectKeyboard, indexCallbacks, releaseCallbacks,
21
- parseCallbackData, parseAskCallback, targetMatchesAsk, rejectPendingAsk, REMOVE_KEYBOARD,
22
- } from './ask.js'
23
- import { processDiagramsAndTables } from './artifacts.js'
24
- import { createPersonaStore, getPersona, listPersonas, BUILTIN_PERSONAS } from './personas.js'
25
- import { exportSessionToMarkdown, rewindSession } from './session-ops.js'
26
- import { formatAlertMessage, resolveAlertTarget } from './alerts.js'
27
- import { createScheduler, parseRelativeTime, formatRemaining } from './scheduler.js'
28
- import { listModelCatalog, buildProvidersKeyboard, buildModelsKeyboard, getStoredModelSelection } from './models.js'
29
- import { buildQuickActionsKeyboard, REMOVE_REPLY_KEYBOARD } from './adapters/telegram.js'
30
- import { createPairingStore } from './pairing.js'
31
- import {
32
- extractTextDelta, extractToolName, buildStreamPreview, formatProgressLine,
33
- createEditScheduler, startTypingHeartbeat,
34
- } from './stream.js'
35
- import { isTopicGoneError } from './telegram-errors.js'
36
- import { ensureContentArray } from './content-guard.js'
37
- import { mergeDynamicCommands } from './commands.js'
38
-
39
-
40
- function whenIdleWithTimeout(agent, timeoutMs, signal) {
41
- const idle = agent.whenIdle()
42
- if (!timeoutMs || timeoutMs <= 0) return idle
43
- return Promise.race([
44
- idle,
45
- new Promise((_, reject) => {
46
- const timer = setTimeout(() => reject(new Error(`turn timeout (${timeoutMs}ms)`)), timeoutMs)
47
- timer.unref?.()
48
- signal?.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
49
- }),
50
- ])
51
- }
52
-
53
- function releaseChatTurn(chat) {
54
- chat.busy = Promise.resolve()
55
- }
56
-
57
- const PLUGIN = 'dsh-messenger-gateway'
58
-
59
- export class Gateway {
60
- constructor(ctx, config, hooks = {}) {
61
- this.ctx = ctx
62
- this.config = config
63
- this.hooks = hooks
64
- this.chats = new Map()
65
- this.sessionToChat = new Map()
66
- this.sessionToThread = new Map()
67
- this.threadToSession = new Map()
68
- this.pending = new Map()
69
- this.pendingAsks = new Map()
70
- this.adapters = new Map()
71
- this.adapterList = []
72
- this.disposeListener = undefined
73
- this.idleTimer = undefined
74
- this.callbackIndex = new Map()
75
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
76
- this.pairing = createPairingStore(join(home, 'messenger-gateway', 'pairing.json'))
77
- this.voicePrefs = createVoicePrefs(join(home, 'messenger-gateway', 'voice-prefs.json'))
78
- this.chatTts = createVoicePrefs(join(home, 'messenger-gateway', 'chat-tts.json'))
79
- this.muted = createVoicePrefs(join(home, 'messenger-gateway', 'muted.json'))
80
- this.personas = createPersonaStore(join(home, 'messenger-gateway', 'personas.json'))
81
- this.scheduler = createScheduler(join(home, 'messenger-gateway', 'scheduled.json'), async (task) => {
82
- const target = {
83
- platform: task.platform || 'telegram',
84
- chatId: task.chatId,
85
- threadId: task.threadId || 0,
86
- }
87
- const text = `⏰ <b>[Напоминание]</b>\n${task.text}`
88
- await this.sendToMessenger(target, { text })
89
- })
90
- this.stats = { sent: 0, errors: 0, startedAt: Date.now() }
91
- }
92
-
93
- isMuted(chatId) { return this.muted.get(chatId) === true }
94
- setMuted(chatId, on) { return this.muted.set(chatId, on) }
95
-
96
- baseUrl() {
97
- const raw = String(this.config.internalBaseURL || '').trim()
98
- return raw || 'http://127.0.0.1:3080'
99
- }
100
-
101
- tg() { return this.config.telegram || {} }
102
-
103
- effectiveAllowedIds() {
104
- const fromConfig = (this.tg().allowedUserIds || []).map(Number).filter(Number.isFinite)
105
- const fromPairing = this.pairing.listApproved()
106
- return [...new Set([...fromConfig, ...fromPairing])]
107
- }
108
-
109
- isUserAllowed(userId) {
110
- const ids = this.effectiveAllowedIds()
111
- return ids.length === 0 || ids.includes(Number(userId))
112
- }
113
-
114
- resolveHomeTarget(platform = 'telegram', name) {
115
- const tg = this.tg()
116
- const home = resolveNamedHome(tg, name)
117
- if (!home) return null
118
- const out = { platform, chatId: home.chatId, homeName: home.name }
119
- if (home.threadId > 0) out.threadId = home.threadId
120
- return out
121
- }
122
-
123
- async sendAlert(type, payload = {}) {
124
- try {
125
- const target = resolveAlertTarget(this)
126
- if (!target) return
127
- const allowedEvents = this.config.telegram?.alerts?.events || ['error', 'pairing']
128
- if (type !== 'status' && !allowedEvents.includes(type)) return
129
-
130
- const text = formatAlertMessage(type, payload)
131
- await this.sendToMessenger(target, { text })
132
- } catch (err) {
133
- this.ctx.logger?.warn?.(`sendAlert (${type}): ${err.message}`)
134
- }
135
- }
136
-
137
- async start() {
138
- this.disposeListener = this.ctx.on('session/event', (session, event) => {
139
- if (this.config.telegram?.forumMirrorEnabled) {
140
- if (event.type === 'turn/start' || event.type === 'session/create') {
141
- this.mirrorSessionToForumTopic(session).catch?.(() => {})
142
- } else if (event.type === 'turn/end') {
143
- this.relayTurnToForumMirror(session, event).catch?.(() => {})
144
- }
145
- }
146
-
147
- const collector = this.pending.get(session.id)
148
- if (!collector) return
149
- if (event.type === 'assistant/message') {
150
- const msg = event.data.message
151
- const text = assistantText(msg)
152
- if (text) collector.lastText = text
153
- const extra = collectAssistantParts(msg)
154
- for (const img of extra.images) collector.images.push(img)
155
- } else if (event.type === 'assistant/chunk') {
156
- const delta = extractTextDelta(event.data?.chunk)
157
- if (delta) {
158
- collector.streamText = (collector.streamText || '') + delta
159
- collector.onStream?.(collector.streamText, collector.toolName)
160
- }
161
- } else if (event.type === 'tool/call') {
162
- collector.toolName = extractToolName(event.data) || collector.toolName
163
- collector.onStream?.(collector.streamText || '', collector.toolName)
164
- } else if (event.type === 'tool/result') {
165
- collector.toolName = ''
166
- collector.onStream?.(collector.streamText || '', '')
167
- } else if (event.type === 'turn/end') {
168
- collector.reason = event.data.reason
169
- }
170
- })
171
- const adapters = createAdapters({
172
- config: this.config,
173
- onMessage: (input) => this.handleMessage(input),
174
- onCallback: (cb) => this.handleCallback(cb),
175
- onUnauthorized: (input) => this.handleUnauthorized(input),
176
- isUserAllowed: (id) => this.isUserAllowed(id),
177
- logger: this.ctx.logger,
178
- })
179
- for (const adapter of adapters) {
180
- try {
181
- await adapter.start()
182
- this.adapterList.push(adapter)
183
- this.adapters.set(adapter.name, adapter)
184
- if (adapter.name === 'telegram') this.tgAdapter = adapter
185
- this.ctx.logger?.info?.(`dsh-messenger-gateway: ${adapter.name} started`)
186
- } catch (err) {
187
- this.ctx.logger?.warn?.(`dsh-messenger-gateway: ${adapter.name}: ${err.message}`)
188
- }
189
- }
190
- if (this.adapters.has('telegram')) {
191
- await this.syncTelegramCommands().catch((err) => {
192
- this.ctx.logger?.warn?.(`Initial syncTelegramCommands: ${err?.message || err}`)
193
- })
194
- }
195
- const rawIdle = Number(this.config.agent?.idleTimeoutMs)
196
- const idleMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle : 86_400_000
197
- this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
198
- this.idleTimer.unref?.()
199
- this.scheduler.start()
200
- }
201
-
202
- stop() {
203
- if (this.scheduler) this.scheduler.stop()
204
- if (this.disposeListener) this.disposeListener()
205
- if (this.idleTimer) clearInterval(this.idleTimer)
206
- for (const a of this.adapterList) { try { a.stop() } catch {} }
207
- this.adapterList = []
208
- this.adapters.clear()
209
- for (const chat of this.chats.values()) chat.dispose().catch(() => {})
210
- this.chats.clear()
211
- this.sessionToChat.clear()
212
- this.sessionToThread.clear()
213
- this.threadToSession.clear()
214
- for (const pending of this.pendingAsks.values()) {
215
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
216
- rejectPendingAsk(pending, new Error('gateway stopped'))
217
- }
218
- this.pending.clear()
219
- this.pendingAsks.clear()
220
- this.callbackIndex.clear()
221
- }
222
-
223
- getAdapter(platform) { return this.adapters.get(platform) }
224
-
225
- async messengerSend(target, payload) {
226
- let resolved = target
227
- if (!resolved?.chatId && resolved?.platform) {
228
- const home = this.resolveHomeTarget(resolved.platform, resolved.home || resolved.name)
229
- if (!home) throw new Error('target.chatId required (or set telegram home channel)')
230
- resolved = { ...home, ...resolved, chatId: home.chatId, threadId: resolved.threadId ?? home.threadId }
231
- }
232
- const adapter = this.getAdapter(resolved.platform)
233
- if (!adapter?.sendTo) throw new Error(`adapter ${resolved.platform} unavailable`)
234
- try {
235
- await adapter.sendTo(resolved.chatId, payload, { threadId: resolved.threadId })
236
- this.stats.sent++
237
- } catch (err) {
238
- this.stats.errors++
239
- if (isTopicGoneError(err)) {
240
- this.logger?.warn?.(`messengerSend: topic gone for ${resolved.platform}:${resolved.chatId}:${resolved.threadId} — the chat/topic was deleted; skipping delivery`)
241
- return
242
- }
243
- throw err
244
- }
245
- }
246
-
247
- async messengerAsk(target, payload, timeoutMs = 300_000) {
248
- const adapter = this.getAdapter(target.platform)
249
- if (!adapter) throw new Error(`adapter ${target.platform} unavailable`)
250
- const token = makeAskToken()
251
- const isMulti = payload.mode === 'multi' || (Array.isArray(payload.options) && payload.options.length > 0)
252
- let replyMarkup
253
- let callbackKeys
254
- let selectedSet
255
- let page = 0
256
- const pageSize = Number(payload.pageSize) || 6
257
-
258
- if (isMulti) {
259
- selectedSet = new Set(Array.isArray(payload.selected) ? payload.selected : [])
260
- const kb = buildMultiSelectKeyboard(token, payload.options || payload.buttons, selectedSet, page, pageSize, payload)
261
- replyMarkup = kb.replyMarkup
262
- callbackKeys = kb.callbackKeys
263
- } else {
264
- const kb = buildInlineKeyboard(token, payload.buttons || [])
265
- replyMarkup = kb.replyMarkup
266
- callbackKeys = kb.callbackKeys
267
- }
268
-
269
- indexCallbacks(this.callbackIndex, callbackKeys, token)
270
- try {
271
- await adapter.sendTo(target.chatId, { text: payload.text, replyMarkup }, { threadId: target.threadId })
272
- } catch (err) {
273
- // Never leave stale callback keys pointing at an unresolvable ask.
274
- releaseCallbacks(this.callbackIndex, callbackKeys)
275
- if (isTopicGoneError(err)) {
276
- this.logger?.warn?.(`messengerAsk: topic gone for ${target.platform}:${target.chatId}:${target.threadId} — ask aborted (chat/topic deleted)`)
277
- throw new Error('messenger.ask: chat or topic was deleted')
278
- }
279
- throw err
280
- }
281
- return new Promise((resolve, reject) => {
282
- const timer = setTimeout(() => {
283
- this.pendingAsks.delete(token)
284
- releaseCallbacks(this.callbackIndex, callbackKeys)
285
- reject(new Error('messenger.ask timed out'))
286
- }, timeoutMs)
287
- timer.unref?.()
288
- this.pendingAsks.set(token, {
289
- resolve, reject, timer, target, callbackKeys, isMulti,
290
- options: payload.options || payload.buttons,
291
- selected: selectedSet,
292
- page, pageSize, payload,
293
- })
294
- })
295
- }
296
-
297
- async messengerProgress(target, payload) {
298
- await this.messengerSend(target, { text: payload.text })
299
- }
300
-
301
- async handleUnauthorized(input) {
302
- const { reply, userId, username } = input
303
- if (!this.tg().pairingEnabled) {
304
- await reply('Доступ закрыт. Попросите владельца добавить ваш id в allowlist.')
305
- return
306
- }
307
- try {
308
- const { code } = this.pairing.requestCode(userId, { username })
309
- await reply(`Нет доступа.\nВаш id: ${userId}\nКод сопряжения: ${code}\n\nВладелец должен отправить боту:\n/pair ${code}`)
310
- this.sendAlert('pairing', { userId, username, code }).catch(() => {})
311
- } catch (err) {
312
- if (err.code === 'RATE_LIMIT') await reply('Код уже выдан. Подождите или попросите владельца /pair.')
313
- else await reply(`Не удалось выдать код: ${err.message}`)
314
- }
315
- }
316
-
317
- async handleCallback(cb) {
318
- if (cb.userId && !this.isUserAllowed(cb.userId)) {
319
- try { await cb.answer('Нет доступа.') } catch {}
320
- return
321
- }
322
- const indexed = this.callbackIndex.get(cb.data)
323
- const { token, buttonId } = parseCallbackData(cb.data)
324
- const askToken = indexed || token
325
- if (askToken && this.pendingAsks.has(askToken)) {
326
- const pending = this.pendingAsks.get(askToken)
327
- if (!targetMatchesAsk(pending, cb)) {
328
- await cb.answer('Кнопка для другого чата')
329
- return
330
- }
331
- const action = parseAskCallback(cb.data)
332
- if (pending.isMulti) {
333
- if (action.kind === 'toggle') {
334
- if (pending.selected.has(action.id)) pending.selected.delete(action.id)
335
- else pending.selected.add(action.id)
336
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
337
- const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
338
- pending.callbackKeys = nextKb.callbackKeys
339
- indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
340
- try {
341
- if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
342
- else await cb.editMessage(cb.message?.text || 'Выбор', nextKb.replyMarkup)
343
- } catch {}
344
- await cb.answer(pending.selected.has(action.id) ? 'Выбрано' : 'Снято')
345
- return
346
- }
347
- if (action.kind === 'page') {
348
- pending.page = action.page
349
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
350
- const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
351
- pending.callbackKeys = nextKb.callbackKeys
352
- indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
353
- try {
354
- if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
355
- else await cb.editMessage(cb.message?.text || 'Выбор', nextKb.replyMarkup)
356
- } catch {}
357
- await cb.answer()
358
- return
359
- }
360
- if (action.kind === 'done') {
361
- this.pendingAsks.delete(askToken)
362
- clearTimeout(pending.timer)
363
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
364
- await cb.answer('OK')
365
- try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
366
- pending.resolve({ buttonId: 'done', selected: Array.from(pending.selected), data: cb.data })
367
- return
368
- }
369
- if (action.kind === 'cancel') {
370
- this.pendingAsks.delete(askToken)
371
- clearTimeout(pending.timer)
372
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
373
- await cb.answer('Отменено')
374
- try { await cb.editMessage(cb.message?.text || 'Отменено', REMOVE_KEYBOARD) } catch {}
375
- pending.resolve({ buttonId: 'cancel', selected: [], data: cb.data })
376
- return
377
- }
378
- }
379
- this.pendingAsks.delete(askToken)
380
- clearTimeout(pending.timer)
381
- releaseCallbacks(this.callbackIndex, pending.callbackKeys)
382
- await cb.answer('OK')
383
- try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
384
- pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
385
- return
386
- }
387
-
388
- // Model picker interactive flow
389
- if (cb.data?.startsWith('mdl:')) {
390
- const parts = cb.data.split(':')
391
- const sub = parts[1]
392
- // Step 2: Selected provider -> show its models (10 per page)
393
- if (sub === 'p') {
394
- const providerId = parts.slice(2).join(':')
395
- const current = this.resolveAgentModel()
396
- const catalog = await listModelCatalog(this.ctx, current)
397
- const models = catalog.modelsByProvider.get(providerId) || []
398
- if (!models.length) {
399
- await cb.answer('Нет доступных моделей у этого провайдера')
400
- return
401
- }
402
- const kb = buildModelsKeyboard(providerId, models, current.model, 0)
403
- await cb.answer()
404
- const text = [
405
- `🤖 <b>Провайдер:</b> <code>${providerId}</code>`,
406
- `Выберите модель (страница ${kb.page + 1}/${kb.totalPages}):`,
407
- ].join('\n')
408
- try {
409
- if (cb.editMessage) await cb.editMessage(text, kb)
410
- } catch {}
411
- return
412
- }
413
- // Pagination for models
414
- if (sub === 'pg') {
415
- const page = parseInt(parts[parts.length - 1], 10) || 0
416
- const providerId = parts.slice(2, -1).join(':')
417
- const current = this.resolveAgentModel()
418
- const catalog = await listModelCatalog(this.ctx, current)
419
- const models = catalog.modelsByProvider.get(providerId) || []
420
- const kb = buildModelsKeyboard(providerId, models, current.model, page)
421
- await cb.answer()
422
- const text = [
423
- `🤖 <b>Провайдер:</b> <code>${providerId}</code>`,
424
- `Выберите модель (страница ${kb.page + 1}/${kb.totalPages}):`,
425
- ].join('\n')
426
- try {
427
- if (cb.editMessage) await cb.editMessage(text, kb)
428
- } catch {}
429
- return
430
- }
431
- // Back to providers
432
- if (sub === 'back') {
433
- const current = this.resolveAgentModel()
434
- const catalog = await listModelCatalog(this.ctx, current)
435
- const kb = buildProvidersKeyboard(catalog.providers, current)
436
- await cb.answer()
437
- const text = [
438
- '🤖 <b>Выберите провайдера:</b>',
439
- `Текущая: <code>${current.provider}/${current.model}</code>`,
440
- ].join('\n')
441
- try {
442
- if (cb.editMessage) await cb.editMessage(text, kb)
443
- } catch {}
444
- return
445
- }
446
- // Select model
447
- if (sub === 's') {
448
- const key = parts[2]
449
- const stored = getStoredModelSelection(key)
450
- if (!stored) {
451
- await cb.answer('Сессия выбора модели истекла, вызовите /model заново')
452
- return
453
- }
454
- const { provider, model } = stored
455
- try {
456
- const adm = this.ctx.get('agentDefaultModel')
457
- if (adm?.saveSelection) {
458
- await adm.saveSelection({ provider, model })
459
- }
460
- this.config.agent = { ...this.config.agent, provider, model }
461
- try {
462
- await this.hooks?.persistAgentModel?.({ provider, model })
463
- } catch (e) {
464
- this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
465
- }
466
- await cb.answer(`Выбрана ${model}`)
467
- try {
468
- if (cb.editMessage) await cb.editMessage(`✅ Модель успешно переключена на: <b>${provider}/${model}</b>`, REMOVE_KEYBOARD)
469
- } catch {}
470
- } catch (err) {
471
- await cb.answer(`Ошибка: ${err.message}`)
472
- }
473
- return
474
- }
475
- if (sub === 'cur') {
476
- await cb.answer()
477
- return
478
- }
479
- }
480
-
481
- await cb.answer()
482
- }
483
-
484
- sessionKeyFor(input) {
485
- const scope = this.config.agent?.sessionScope || 'user'
486
- return sessionKey({
487
- platform: input.platform,
488
- chatId: input.chatId,
489
- threadId: input.threadId || 0,
490
- userId: input.userId,
491
- chatType: input.chatType,
492
- scope,
493
- })
494
- }
495
-
496
- isChatBusy(chat) {
497
- return Boolean(chat?.turnActive)
498
- }
499
-
500
- async handleMessage(input) {
501
- const { platform, chatId, threadId = 0, text, reply } = input
502
- const key = this.sessionKeyFor(input)
503
- const body = String(text || '').trim()
504
- let attachments = [...(input.attachments || [])]
505
- const hasMedia = attachments.length > 0
506
- if (!body && !hasMedia) return
507
- if (body.startsWith('/')) { await this.handleCommand(key, body, input); return }
508
- try {
509
- const chat = await this.getOrCreateChat(key, input)
510
- const photoOnlyMode = this.config.agent?.photoOnlyMode ?? 'prompt'
511
- const incomingPhotoOnly = hasMedia && attachments.every((a) => a.kind === 'photo' || a.kind === 'sticker') && !body
512
- if (incomingPhotoOnly && photoOnlyMode === 'prompt') {
513
- chat.pendingMedia = [...(chat.pendingMedia || []), ...attachments]
514
- const n = chat.pendingMedia.length
515
- const msg = n === 1
516
- ? 'Медиа получено. Напишите вопрос — например: «что на фото?»'
517
- : `Получено ${n} вложений. Напишите вопрос.`
518
- return reply(msg)
519
- }
520
- if (chat.pendingMedia?.length) {
521
- attachments = [...chat.pendingMedia, ...attachments]
522
- chat.pendingMedia = []
523
- }
524
- const inboundWasVoice = attachments.some((a) => a.kind === 'voice' || a.kind === 'audio')
525
- let personaOverride
526
- for (const [pId] of Object.entries(BUILTIN_PERSONAS)) {
527
- if (pId === 'default') continue
528
- const tag = `@${pId}`
529
- if (body.toLowerCase().includes(tag)) {
530
- personaOverride = pId
531
- break
532
- }
533
- }
534
- const turnInput = { ...input, text: body, attachments, inboundWasVoice, personaOverride }
535
-
536
- // Hermes-like steer: while a turn is running, inject followup instead of abort+restart
537
- if (this.isChatBusy(chat)) {
538
- const steerText = body || (hasMedia ? '(дополнение: медиа)' : '')
539
- const content = await this.buildUserContent({
540
- ...turnInput,
541
- text: steerText,
542
- steer: true,
543
- }, undefined)
544
- chat.agent.followup(createUserMessage({
545
- content: ensureContentArray(content),
546
- source: { kind: 'user', plugin: PLUGIN, form: 'steer', origin: 'telegram' },
547
- }))
548
- chat.lastUsed = Date.now()
549
- try { await reply('↪️ Добавлено к текущему ответу') } catch {}
550
- return
551
- }
552
-
553
- // Mark busy BEFORE yielding to the poll loop, otherwise steer/stop never see an active turn.
554
- chat.turnActive = true
555
- try { chat.abort?.abort?.() } catch {}
556
- chat.abort = new AbortController()
557
- const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
558
- chat.busy = run.catch(() => {})
559
- // Do NOT await: Telegram poll is sequential; awaiting blocked steer and /stop until the turn finished.
560
- run.catch((err) => {
561
- const msg = err instanceof Error ? err.message : String(err)
562
- this.ctx.logger?.warn?.(`dsh-messenger-gateway: background turn: ${msg}`)
563
- this.sendAlert('error', {
564
- code: err?.code || 'BACKGROUND_ERROR',
565
- message: msg,
566
- sessionId: chat.agent?.session?.id,
567
- chatId: input.chatId,
568
- threadId: input.threadId,
569
- }).catch(() => {})
570
- chat.turnActive = false
571
- chat.abort = undefined
572
- })
573
- } catch (err) {
574
- const msg = err instanceof Error ? err.message : String(err)
575
- this.ctx.logger?.warn?.(`dsh-messenger-gateway: message: ${msg}`)
576
- try { await reply(`Ошибка: ${msg}`) } catch {}
577
- }
578
- }
579
-
580
- async handleCommand(key, text, input) {
581
-
582
- const parts = text.split(/\s+/)
583
- const cmd = parts[0].toLowerCase().split('@')[0]
584
- const { reply, userId, chatId, threadId = 0, platform } = input
585
- if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.', { replyMarkup: REMOVE_REPLY_KEYBOARD })
586
- if (cmd === '/help') {
587
- return reply([
588
- '📖 <b>Команды бота:</b>',
589
- '',
590
- '💬 <b>Диалог:</b>',
591
- '• /help — эта справка',
592
- '• /new — новая сессия',
593
- '• /stop — прервать текущий ответ',
594
- '• /model — интерактивный выбор модели (/model list)',
595
- '• /role [name] — персоны и роли агента (/role list)',
596
- '• /rewind [N] — откат последних N ходов',
597
- '• /fork — форк сессии в новую ветку',
598
- '• /export — выгрузка истории в Markdown',
599
- '',
600
- '🛠️ <b>Инструменты и файлы:</b>',
601
- '• /skills / /tools — список активных инструментов',
602
- '• /files [dir] — проводник рабочей папки',
603
- '• /get <path> — скачать файл в Telegram',
604
- '• /remind <время> <текст> — напоминание (/remind 10m текст)',
605
- '',
606
- '⚙️ <b>Настройки:</b>',
607
- '• /status — статус шлюза и модели',
608
- '• /top — системные ресурсы (RAM, uptime)',
609
- '• /keyboard on|off — быстрые кнопки',
610
- '• /voice on|off|status — голосовые ответы',
611
- '• /tts on|off|status — озвучка в этом чате',
612
- '• /mute / /unmute — заглушить уведомления в этот чат',
613
- '',
614
- '🔒 <b>Доступ и каналы:</b>',
615
- '• /whoami — ваш Telegram id',
616
- '• /pair CODE — одобрить код сопряжения',
617
- '• /sethome [name] — привязать домашний чат',
618
- '• /setalert — назначить канал алертов',
619
- ].join('\n'))
620
- }
621
- if (cmd === '/role' || cmd === '/persona') {
622
- const targetRole = parts[1]?.toLowerCase()
623
- if (!targetRole || targetRole === 'list') {
624
- const currentId = this.personas.get(chatId)
625
- const lines = [
626
- '🎭 <b>Доступные роли и персоны:</b>',
627
- '',
628
- ...listPersonas().map((p) => {
629
- const isCurrent = p.id === currentId ? ' (активна)' : ''
630
- return `${p.icon} <b>${p.id}</b> — ${p.name}: ${p.description}${isCurrent}`
631
- }),
632
- '',
633
- 'Смена роли: <code>/role coder</code> (или /role reset)',
634
- ]
635
- return reply(lines.join('\n'))
636
- }
637
- if (targetRole === 'reset' || targetRole === 'default') {
638
- this.personas.set(chatId, 'default')
639
- return reply('🎭 Роль сброшена на стандартную (Default).')
640
- }
641
- const persona = getPersona(targetRole)
642
- if (!persona) {
643
- return reply(`Неизвестная роль "${targetRole}". Список: /role list`)
644
- }
645
- this.personas.set(chatId, persona.id)
646
- return reply(`🎭 Роль изменена на: ${persona.icon} <b>${persona.name}</b>\n${persona.description}`)
647
- }
648
- if (cmd === '/skills' || cmd === '/tools') {
649
- const tools = this.ctx.get?.('tools') || this.ctx.tools
650
- if (tools?.tools) {
651
- for (const [name, t] of tools.tools.entries()) {
652
- toolsList.push(`• <b>${name}</b>: ${t.description || '(нет описания)'}`)
653
- }
654
- }
655
- if (!toolsList.length) {
656
- return reply('🛠️ <b>Инструменты агента:</b>\n(нет зарегистрированных инструментов)')
657
- }
658
- return reply([
659
- '🛠️ <b>Активные инструменты и скиллы:</b>',
660
- '',
661
- ...toolsList,
662
- ].join('\n'))
663
- }
664
- if (cmd === '/export') {
665
- const chat = this.chats.get(key)
666
- if (!chat?.agent?.session) {
667
- return reply('Нет активной сессии для экспорта.')
668
- }
669
- try {
670
- const { filename, buffer, messagesCount } = exportSessionToMarkdown(chat.agent.session)
671
- if (!messagesCount) {
672
- return reply('Сессия пуста, нет сообщений для экспорта.')
673
- }
674
- const file = {
675
- name: filename,
676
- mime: 'text/markdown',
677
- kind: 'document',
678
- bytes: buffer,
679
- }
680
- return reply({ text: `📄 Экспорт диалога (${messagesCount} сообщений):`, files: [file] })
681
- } catch (err) {
682
- return reply(`Ошибка экспорта: ${err.message}`)
683
- }
684
- }
685
- if (cmd === '/rewind') {
686
- const chat = this.chats.get(key)
687
- if (!chat?.agent?.session) {
688
- return reply('Нет активной сессии для отката.')
689
- }
690
- const count = Number(parts[1]) || 1
691
- const res = rewindSession(chat.agent.session, count)
692
- if (!res.removed) {
693
- return reply('В истории сессии нет сообщений для отката.')
694
- }
695
- const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
696
- try { await sessions?.flush(chat.agent.session) } catch {}
697
- return reply(`⏪ Откачено сообщений: ${res.removed}. Осталось в контексте: ${res.remaining}.`)
698
- }
699
- if (cmd === '/fork') {
700
- const chat = this.chats.get(key)
701
- if (!chat?.agent?.session) {
702
- return reply('Нет активной сессии для форка.')
703
- }
704
- try {
705
- const oldSession = chat.agent.session
706
- const oldMessages = Array.isArray(oldSession.messages)
707
- ? oldSession.messages.map(m => ({ ...m, content: ensureContentArray(m.content) }))
708
- : []
709
- const newChat = await this.createChat(key, input)
710
- if (newChat.agent?.session && oldMessages.length) {
711
- newChat.agent.session.messages = oldMessages
712
- const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
713
- try { await sessions?.flush(newChat.agent.session) } catch {}
714
- }
715
- this.chats.set(key, newChat)
716
- return reply(`🔀 Создан форк сессии!\nСтарая сессия: ${oldSession.id}\nНовая активная сессия: ${newChat.agent.session.id}\nКонтекст сохранён (${oldMessages.length} сообщений).`)
717
- } catch (err) {
718
- return reply(`Ошибка форка: ${err.message}`)
719
- }
720
- }
721
- if (cmd === '/files') {
722
- const subPath = parts.slice(1).join(' ').trim() || '.'
723
- const agentCwd = this.config.agent?.cwd || process.cwd()
724
- const res = await listFiles(agentCwd, subPath)
725
- if (!res.ok) return reply(`❌ ${res.error}`)
726
- return reply(res.formattedText)
727
- }
728
- if (cmd === '/get') {
729
- const targetRel = parts.slice(1).join(' ').trim()
730
- if (!targetRel) {
731
- return reply('Укажите имя файла для скачивания: <code>/get <файл></code>\nСписок: <code>/files</code>')
732
- }
733
- const agentCwd = this.config.agent?.cwd || process.cwd()
734
- const maxDocBytes = Number(this.config.media?.maxDocBytes) || 50 * 1024 * 1024
735
- const res = await getFileForDownload(agentCwd, targetRel, maxDocBytes)
736
- if (!res.ok) return reply(`❌ ${res.error}`)
737
- const file = {
738
- name: res.name,
739
- mime: res.mime,
740
- kind: 'document',
741
- bytes: res.bytes,
742
- dataBase64: res.bytes.toString('base64'),
743
- }
744
- return reply({ text: `📄 Файл: <b>${res.name}</b> (${formatFileSize(res.size)})`, files: [file] })
745
- }
746
- if (cmd === '/new') {
747
- const chat = this.chats.get(key)
748
- if (chat) {
749
- if (chat.abort) chat.abort.abort()
750
- chat.pendingMedia = []
751
- this.sessionToChat.delete(String(chat.agent.session.id))
752
- this.chats.delete(key)
753
- await chat.dispose()
754
- return reply('Сессия сброшена.')
755
- }
756
- return reply('Активной сессии нет.')
757
- }
758
- if (cmd === '/whoami') {
759
- const lines = [`Ваш id: ${userId}`]
760
- if (chatId) lines.push(`chatId: ${chatId}`)
761
- if (threadId) lines.push(`threadId: ${threadId}`)
762
- return reply(lines.join('\n'))
763
- }
764
- if (cmd === '/stop') {
765
- const chat = this.chats.get(key)
766
- if (chat?.turnActive || chat?.abort) {
767
- try { chat.abort?.abort() } catch {}
768
- releaseChatTurn(chat)
769
- chat.turnActive = false
770
- return reply('Прерывание отправлено.')
771
- }
772
- return reply('Нечего прерывать.')
773
- }
774
- if (cmd === '/status') {
775
- let modelLine = 'модель: (не задана)'
776
- try {
777
- const sel = this.resolveAgentModel()
778
- modelLine = `модель: ${sel.provider}/${sel.model}`
779
- } catch (e) {
780
- modelLine = `модель: ${e.message}`
781
- }
782
- const home = this.resolveHomeTarget(platform || 'telegram')
783
- const homeLine = home
784
- ? `home: chat ${home.chatId}${home.threadId ? ` topic ${home.threadId}` : ''}`
785
- : 'home: не задан'
786
- const pending = this.pairing.listPending().length
787
- const up = Math.max(0, Math.round((Date.now() - this.stats.startedAt) / 1000))
788
- const hh = String(Math.floor(up / 3600)).padStart(2, '0')
789
- const mm = String(Math.floor((up % 3600) / 60)).padStart(2, '0')
790
- const ss = String(up % 60).padStart(2, '0')
791
- return reply([
792
- 'Messenger gateway',
793
- `адаптеры: ${[...this.adapters.keys()].join(', ') || '(нет)'}`,
794
- `активных чатов: ${this.chats.size}`,
795
- modelLine,
796
- homeLine,
797
- `pairing pending: ${pending}`,
798
- `transport: ${this.tg().transport || 'poll'}`,
799
- `sessionScope: ${this.config.agent?.sessionScope || 'user'}`,
800
- `доставлено: ${this.stats.sent}`,
801
- `ошибок: ${this.stats.errors}`,
802
- `polling conflict: ${this.tgAdapter?.pollingConflict ? 'да' : 'нет'}`,
803
- `uptime: ${hh}:${mm}:${ss}`,
804
- ].join('\n'))
805
- }
806
- if (cmd === '/model') {
807
- if (parts.length >= 3) {
808
- if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
809
- const provider = parts[1]
810
- const model = parts.slice(2).join(' ')
811
- try {
812
- const adm = this.ctx.get('agentDefaultModel')
813
- if (adm?.saveSelection) {
814
- await adm.saveSelection({ provider, model })
815
- }
816
- this.config.agent = { ...this.config.agent, provider, model }
817
- try {
818
- await this.hooks?.persistAgentModel?.({ provider, model })
819
- } catch (e) {
820
- this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
821
- }
822
- return reply(`Модель сохранена: ${provider}/${model}`)
823
- } catch (e) {
824
- return reply(`Не удалось сменить модель: ${e.message}`)
825
- }
826
- }
827
- try {
828
- const current = this.resolveAgentModel()
829
- const catalog = await listModelCatalog(this.ctx, current)
830
- if (!catalog.providers.length) {
831
- return reply(`Текущая модель: <code>${current.provider}/${current.model}</code>\nСмена: <code>/model &lt;provider&gt; &lt;model&gt;</code>`)
832
- }
833
- const kb = buildProvidersKeyboard(catalog.providers, current)
834
- return reply([
835
- '🤖 <b>Выберите провайдера:</b>',
836
- `Текущая модель: <code>${current.provider}/${current.model}</code>`,
837
- ].join('\n'), {
838
- replyMarkup: kb,
839
- })
840
- } catch (e) {
841
- return reply(e.message)
842
- }
843
- }
844
- if (cmd === '/pair') {
845
- if (!this.isUserAllowed(userId)) return reply('Только пользователи из allowlist могут одобрять /pair.')
846
- const code = parts[1]
847
- if (!code) return reply('Использование: /pair CODE')
848
- const res = this.pairing.approveCode(code, userId)
849
- if (!res.ok) return reply(`Не удалось: ${res.error}`)
850
- const merged = this.effectiveAllowedIds()
851
- for (const a of this.adapterList) a.setAllowedUserIds?.(merged)
852
- try { await this.hooks?.persistAllowedUserIds?.(merged) } catch (e) {
853
- this.ctx.logger?.warn?.(`persist allowlist: ${e.message}`)
854
- }
855
- return reply(`Одобрен user id ${res.userId}${res.username ? ` (@${res.username})` : ''}.`)
856
- }
857
- if (cmd === '/sethome') {
858
- if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
859
- const name = normalizeHomeName(parts[1] || 'default') || 'default'
860
- try {
861
- const nextTg = upsertHome(this.tg(), { name, chatId, threadId })
862
- await this.hooks?.persistHomes?.(nextTg)
863
- this.config.telegram = nextTg
864
- return reply(`Home «${name}»: chat ${chatId}${threadId ? ` topic ${threadId}` : ''}`)
865
- } catch (e) {
866
- return reply(`Не удалось сохранить home: ${e.message}`)
867
- }
868
- }
869
- if (cmd === '/home') {
870
- const homes = listHomes(this.tg())
871
- if (!homes.length) return reply('Home не задан. /sethome или /sethome name')
872
- return reply(['Homes:', ...homes.map((h) => `• ${h.name}: chat ${h.chatId}${h.threadId ? ` topic ${h.threadId}` : ''}`)].join('\n'))
873
- }
874
- if (cmd === '/setalert') {
875
- if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
876
- const nextTg = {
877
- ...this.tg(),
878
- alerts: {
879
- ...(this.tg().alerts || {}),
880
- enabled: true,
881
- chatId,
882
- threadId: threadId || 0,
883
- },
884
- }
885
- this.config.telegram = nextTg
886
- try { await this.hooks?.persistHomes?.(nextTg) } catch {}
887
- return reply(`🔔 Этот чат назначен каналом алертов (chat: ${chatId}${threadId ? `, topic: ${threadId}` : ''}).`)
888
- }
889
- if (cmd === '/alert') {
890
- const sub = parts[1]?.toLowerCase()
891
- if (sub === 'test') {
892
- const target = resolveAlertTarget(this)
893
- if (!target) return reply('Канал алертов не настроен или выключен. Назначить: /setalert')
894
- await this.sendAlert('status', { title: 'Тестовый алерт', details: `Отправлен пользователем id ${userId}` })
895
- return reply('Тестовый алерт отправлен в канал алертов.')
896
- }
897
- const target = resolveAlertTarget(this)
898
- const alertsCfg = this.tg().alerts || {}
899
- return reply([
900
- '🔔 <b>Канал алертов:</b>',
901
- `Статус: ${alertsCfg.enabled ? 'включен' : 'выключен'}`,
902
- `Чат: ${target ? `${target.chatId}${target.threadId ? ` (topic: ${target.threadId})` : ''}` : '(не назначен)'}`,
903
- `События: ${(alertsCfg.events || ['error', 'pairing']).join(', ')}`,
904
- '',
905
- 'Команды:',
906
- '/setalert — назначить текущий чат каналом алертов',
907
- '/alert test — отправить тестовый алерт',
908
- ].join('\n'))
909
- }
910
- if (cmd === '/remind') {
911
- const sub = parts[1]?.toLowerCase()
912
- if (sub === 'list') {
913
- const active = await this.scheduler.list(chatId)
914
- if (!active.length) return reply('Нет активных напоминаний для этого чата.')
915
- const lines = [
916
- '⏰ <b>Активные напоминания:</b>',
917
- '',
918
- ...active.map((t) => {
919
- const left = formatRemaining(t.dueAt - Date.now())
920
- return `• <code>${t.id}</code> (через ${left}): ${t.text}`
921
- }),
922
- '',
923
- 'Отмена: <code>/remind cancel ID</code>',
924
- ]
925
- return reply(lines.join('\n'))
926
- }
927
- if (sub === 'cancel') {
928
- const targetId = parts[2]?.trim()
929
- if (!targetId) return reply('Укажите ID напоминания: <code>/remind cancel ID</code>')
930
- const ok = await this.scheduler.cancel(targetId, chatId)
931
- return reply(ok ? `✅ Напоминание <code>${targetId}</code> отменено.` : `❌ Напоминание с ID <code>${targetId}</code> не найдено.`)
932
- }
933
- const timeArg = parts[1]
934
- const textArg = parts.slice(2).join(' ').trim()
935
- const delayMs = parseRelativeTime(timeArg)
936
- if (!delayMs || !textArg) {
937
- return reply([
938
- '⏰ <b>Напоминания:</b>',
939
- 'Создать: <code>/remind &lt;время&gt; &lt;текст&gt;</code>',
940
- 'Примеры: <code>/remind 10m Позвонить</code>, <code>/remind 2h Проверить деплой</code>',
941
- 'Список: <code>/remind list</code>',
942
- 'Отмена: <code>/remind cancel ID</code>',
943
- ].join('\n'))
944
- }
945
- const dueAt = Date.now() + delayMs
946
- const task = await this.scheduler.schedule({
947
- platform: platform || 'telegram',
948
- chatId,
949
- threadId: threadId || 0,
950
- userId,
951
- text: textArg,
952
- dueAt,
953
- })
954
- const left = formatRemaining(delayMs)
955
- return reply(`⏰ Напоминание установлено на <b>через ${left}</b> (ID: <code>${task.id}</code>):\n«${textArg}»`)
956
- }
957
- if (cmd === '/voice') {
958
- const sub = String(parts[1] || 'status').toLowerCase()
959
- if (sub === 'summary') {
960
- const val = parts[2]?.toLowerCase()
961
- if (val === 'on' || val === 'off') {
962
- if (!this.config.tts) this.config.tts = {}
963
- this.config.tts.voiceSummary = val === 'on'
964
- return reply(`Голосовое саммари (TL;DR): ${val === 'on' ? 'включено' : 'выключено'}`)
965
- }
966
- const state = this.config.tts?.voiceSummary ? 'on' : 'off'
967
- return reply(`Голосовое саммари (TL;DR): ${state}\nПереключение: <code>/voice summary on|off</code>`)
968
- }
969
- if (sub === 'on' || sub === 'off') {
970
- this.voicePrefs.set(userId, sub === 'on')
971
- return reply(sub === 'on' ? 'Голосовые ответы: on (для вас)' : 'Голосовые ответы: off (для вас)')
972
- }
973
- const pref = this.voicePrefs.get(userId)
974
- const mode = this.tg().voiceMode || 'mirror'
975
- const prefLine = pref === null ? 'не задан (/voice on|off)' : (pref ? 'on' : 'off')
976
- const summaryState = this.config.tts?.voiceSummary ? 'on' : 'off'
977
- return reply(`voiceMode=${mode}\nваш /voice: ${prefLine}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}\nvoice summary: ${summaryState}`)
978
- }
979
- if (cmd === '/topic') {
980
- const topicName = parts.slice(1).join(' ').trim()
981
- if (!topicName) {
982
- return reply('Использование: <code>/topic &lt;название&gt;</code>\nСоздает новый топик в супергруппе и изолированную сессию под задачу.')
983
- }
984
- const tgAdapter = this.getAdapter('telegram')
985
- if (!tgAdapter?.createForumTopic) {
986
- return reply('Создание топиков доступно только в Telegram.')
987
- }
988
- try {
989
- const res = await tgAdapter.createForumTopic(chatId, topicName)
990
- const newThreadId = res?.message_thread_id
991
- await reply(`🎯 Создан новый топик <b>«${topicName}»</b> (ID: <code>${newThreadId}</code>).\nПерейдите в созданный топик для работы над задачей!`)
992
- if (newThreadId) {
993
- await tgAdapter.sendTo(chatId, {
994
- text: `👋 Привет! Это изолированная сессия для задачи <b>«${topicName}»</b>.\nЧем могу помочь?`,
995
- }, { threadId: newThreadId })
996
- }
997
- return
998
- } catch (err) {
999
- return reply(`Не удалось создать топик: ${err.message}\n(Убедитесь, что бот является администратором группы и включены темы/форумы)`)
1000
- }
1001
- }
1002
- if (cmd === '/top') {
1003
- const mem = process.memoryUsage()
1004
- const rssMb = (mem.rss / 1024 / 1024).toFixed(1)
1005
- const heapMb = (mem.heapUsed / 1024 / 1024).toFixed(1)
1006
- const sec = Math.floor((Date.now() - this.stats.startedAt) / 1000)
1007
- const hh = String(Math.floor(sec / 3600)).padStart(2, '0')
1008
- const mm = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
1009
- const ss = String(sec % 60).padStart(2, '0')
1010
- let activeReminders = 0
1011
- try { activeReminders = (await this.scheduler.list()).length } catch {}
1012
- let currentModel = 'не задана'
1013
- try {
1014
- const m = this.resolveAgentModel()
1015
- currentModel = `${m.provider}/${m.model}`
1016
- } catch {}
1017
-
1018
- return reply([
1019
- '📊 <b>DSH System & Resources:</b>',
1020
- `• <b>Память (RSS):</b> ${rssMb} MB`,
1021
- `• <b>Heap:</b> ${heapMb} MB`,
1022
- `• <b>Аптайм:</b> ${hh}:${mm}:${ss}`,
1023
- `• <b>Активных чатов:</b> ${this.chats.size}`,
1024
- `• <b>Напоминаний в очереди:</b> ${activeReminders}`,
1025
- `• <b>Активная модель:</b> <code>${currentModel}</code>`,
1026
- `• <b>Сообщений отправлено:</b> ${this.stats.sent}`,
1027
- `• <b>Ошибок:</b> ${this.stats.errors}`,
1028
- ].join('\n'))
1029
- }
1030
- if (cmd === '/keyboard') {
1031
- const sub = String(parts[1] || '').toLowerCase()
1032
- const tgAdapter = this.getAdapter('telegram')
1033
- if (sub === 'on') {
1034
- if (tgAdapter) tgAdapter.quickActions = true
1035
- return reply('Клавиатура быстрых действий включена.', {
1036
- replyMarkup: buildQuickActionsKeyboard(),
1037
- })
1038
- }
1039
- if (sub === 'off') {
1040
- if (tgAdapter) tgAdapter.quickActions = false
1041
- return reply('Клавиатура быстрых действий выключена.', {
1042
- replyMarkup: REMOVE_REPLY_KEYBOARD,
1043
- })
1044
- }
1045
- const curState = tgAdapter?.quickActions ? 'включена' : 'выключена'
1046
- return reply([
1047
- '⌨️ <b>Клавиатура быстрых действий:</b>',
1048
- `Текущее состояние: <b>${curState}</b>`,
1049
- '',
1050
- 'Команды:',
1051
- '<code>/keyboard on</code> — показать кнопки',
1052
- '<code>/keyboard off</code> — скрыть кнопки',
1053
- ].join('\n'))
1054
- }
1055
- if (cmd === '/tts') {
1056
- const sub = String(parts[1] || 'status').toLowerCase()
1057
- if (sub === 'on' || sub === 'off') {
1058
- this.chatTts.set(chatId, sub === 'on')
1059
- return reply(sub === 'on' ? 'Озвучка в этом чате: on' : 'Озвучка в этом чате: off')
1060
- }
1061
- const cur = this.chatTts.get(chatId)
1062
- const line = cur === null ? 'не задан (/tts on|off)' : (cur ? 'on' : 'off')
1063
- return reply(`Озвучка в этом чате: ${line}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}`)
1064
- }
1065
- if (cmd === '/mute') {
1066
- this.setMuted(chatId, true)
1067
- return reply('Уведомления в этот чат: выключены (/unmute)')
1068
- }
1069
- if (cmd === '/unmute') {
1070
- this.setMuted(chatId, false)
1071
- return reply('Уведомления в этот чат: включены')
1072
- }
1073
- return reply(`Неизвестная команда ${cmd}. /help`)
1074
- }
1075
-
1076
- collectDynamicSkills() {
1077
- const skills = []
1078
- try {
1079
- const skillsService = this.ctx.get?.('skills') || this.ctx.skills
1080
- const allSkills = skillsService?.list?.() || []
1081
- for (const s of allSkills) {
1082
- if (!s || !s.name) continue
1083
- if (s.userInvocable === false) continue
1084
- skills.push({
1085
- name: String(s.name),
1086
- description: String(s.description || s.title || `Skill ${s.name}`).slice(0, 256),
1087
- })
1088
- }
1089
- } catch (err) {
1090
- this.ctx.logger?.debug?.(`Failed to collect dynamic skills: ${err?.message || err}`)
1091
- }
1092
- return skills
1093
- }
1094
-
1095
- async syncTelegramCommands() {
1096
- const tgAdapter = this.adapters.get('telegram')
1097
- if (!tgAdapter || typeof tgAdapter.registerCommands !== 'function') return
1098
- const baseCommands = this.config.telegram?.commands || []
1099
- const dynamicSkills = this.collectDynamicSkills()
1100
- const merged = mergeDynamicCommands(baseCommands, dynamicSkills, 100)
1101
- try {
1102
- await tgAdapter.registerCommands(merged)
1103
- this.ctx.logger?.info?.(`Synced ${merged.length} Telegram commands (including ${dynamicSkills.length} dynamic skills)`)
1104
- } catch (err) {
1105
- this.ctx.logger?.warn?.(`Failed to sync Telegram commands: ${err?.message || err}`)
1106
- }
1107
- }
1108
-
1109
- async mirrorSessionToForumTopic(session) {
1110
- if (!session || !session.id) return
1111
- const sessionId = String(session.id)
1112
- if (sessionId.startsWith('msgw-')) return
1113
- if (this.sessionToThread.has(sessionId)) return
1114
-
1115
- const tgCfg = this.config.telegram || {}
1116
- if (!tgCfg.forumMirrorEnabled || !tgCfg.forumMirrorChatId) return
1117
-
1118
- const tgAdapter = this.adapters.get('telegram')
1119
- if (!tgAdapter || typeof tgAdapter.createForumTopic !== 'function') return
1120
-
1121
- const forumChatId = tgCfg.forumMirrorChatId
1122
- const title = String(session.title || session.meta?.name || `Session ${sessionId.slice(0, 8)}`).slice(0, 120)
1123
-
1124
- try {
1125
- const topic = await tgAdapter.createForumTopic(forumChatId, title)
1126
- const threadId = topic?.message_thread_id
1127
- if (!threadId) return
1128
-
1129
- const threadKey = `${forumChatId}:${threadId}`
1130
- this.sessionToThread.set(sessionId, { chatId: forumChatId, threadId })
1131
- this.threadToSession.set(threadKey, sessionId)
1132
-
1133
- const text = `🪞 <b>[Зеркало сессии DSH]</b>\nСессия: <code>${sessionId}</code>\nЗаголовок: <b>${title}</b>\nСообщения из этой темы будут направляться в диалог DSH.`
1134
- await tgAdapter.sendTo(forumChatId, { text }, { threadId })
1135
- this.ctx.logger?.info?.(`Mirrored session ${sessionId} to Telegram forum topic ${threadId} in ${forumChatId}`)
1136
- } catch (err) {
1137
- this.ctx.logger?.warn?.(`Failed to mirror session ${sessionId} to forum topic: ${err?.message || err}`)
1138
- }
1139
- }
1140
-
1141
- async relayTurnToForumMirror(session, event) {
1142
- if (!session || !session.id) return
1143
- const sessionId = String(session.id)
1144
- const threadInfo = this.sessionToThread.get(sessionId)
1145
- if (!threadInfo) return
1146
- if (this.pending.has(sessionId)) return
1147
-
1148
- const tgAdapter = this.adapters.get('telegram')
1149
- if (!tgAdapter) return
1150
-
1151
- const messages = Array.isArray(session.messages)
1152
- ? session.messages
1153
- : (Array.isArray(session.history) ? session.history : [])
1154
-
1155
- for (let i = messages.length - 1; i >= 0; i--) {
1156
- const msg = messages[i]
1157
- const role = msg.role || (msg.type === 'user' ? 'user' : 'assistant')
1158
- if (role === 'assistant') {
1159
- let text = ''
1160
- if (typeof msg.content === 'string') text = msg.content
1161
- else if (Array.isArray(msg.content)) {
1162
- text = msg.content
1163
- .map((p) => (typeof p === 'string' ? p : (p?.text || '')))
1164
- .filter(Boolean)
1165
- .join('\n')
1166
- } else if (msg.text) text = msg.text
1167
- const clean = stripReasoningPreamble(stripImageUrls(text)).trim()
1168
- if (clean) {
1169
- const maxLen = Number(this.config.agent?.maxMessageLength) || 4000
1170
- const chunks = splitText(clean, maxLen)
1171
- for (const chunk of chunks) {
1172
- await tgAdapter.sendTo(threadInfo.chatId, { text: chunk }, { threadId: threadInfo.threadId })
1173
- }
1174
- }
1175
- break
1176
- }
1177
- }
1178
- }
1179
-
1180
- async getOrCreateChat(key, input) {
1181
- let chat = this.chats.get(key)
1182
- if (!chat) {
1183
- const threadKey = `${input.chatId}:${input.threadId || 0}`
1184
- const existingSessionId = this.threadToSession.get(threadKey)
1185
- chat = await this.createChat(key, input, existingSessionId)
1186
- this.chats.set(key, chat)
1187
- }
1188
- chat.lastUsed = Date.now()
1189
- chat.target = { platform: input.platform, chatId: input.chatId, threadId: input.threadId || 0 }
1190
- return chat
1191
- }
1192
-
1193
- resolveAgentModel() {
1194
- const agentCfg = this.config.agent || {}
1195
- let provider = String(agentCfg.provider || '').trim()
1196
- let model = String(agentCfg.model || '').trim()
1197
- if (provider && model) return { provider, model }
1198
- const selection = this.ctx.get('agentDefaultModel')?.currentSelection?.()
1199
- if (!selection?.provider || !selection?.model) {
1200
- throw new Error('Выберите модель в Settings → Models (или укажите agent.provider/model в профиле)')
1201
- }
1202
- return { provider: provider || selection.provider, model: model || selection.model }
1203
- }
1204
-
1205
- async createChat(key, input, existingSessionId) {
1206
- const { provider, model } = this.resolveAgentModel()
1207
- const agentCfg = this.config.agent || {}
1208
- const cwd = agentCfg.cwd || process.cwd()
1209
- const self = this
1210
- const agents = this.ctx.get?.('agents') || this.ctx.agents
1211
- const targetSessionId = existingSessionId ? SessionId(existingSessionId) : SessionId(`msgw-${randomUUID()}`)
1212
- const handle = await agents.create({
1213
- sessionId: targetSessionId,
1214
- meta: { cwd },
1215
- agentOptions: { provider, model },
1216
- setup: (agentCtx) => {
1217
- installModelSelection(agentCtx, { current: { provider, model }, assembled: undefined })
1218
- if (self.tg().approvalsEnabled !== false) {
1219
- agentCtx.on('approval/request', (req, next) => self.answerApproval(key, req, next))
1220
- }
1221
- },
1222
- })
1223
- await handle.agent.whenIdle()
1224
- const chat = {
1225
- key, agent: handle.agent, dispose: handle.dispose, busy: Promise.resolve(),
1226
- lastUsed: Date.now(), abort: undefined, pendingMedia: [], turnActive: false,
1227
- target: input ? { platform: input.platform, chatId: input.chatId, threadId: input.threadId || 0 } : undefined,
1228
- }
1229
- this.sessionToChat.set(String(handle.agent.session.id), key)
1230
- if (existingSessionId && input) {
1231
- const threadKey = `${input.chatId}:${input.threadId || 0}`
1232
- this.threadToSession.set(threadKey, existingSessionId)
1233
- this.sessionToThread.set(existingSessionId, { chatId: input.chatId, threadId: input.threadId || 0 })
1234
- }
1235
- return chat
1236
- }
1237
-
1238
- async answerApproval(chatKeyValue, req, next) {
1239
- try {
1240
- const chat = this.chats.get(chatKeyValue)
1241
- if (!chat?.target) return next()
1242
- const tool = req.toolName || 'tool'
1243
- const reason = req.reason ? `\n${req.reason}` : ''
1244
- const result = await this.messengerAsk(chat.target, {
1245
- text: `⚠️ Нужно подтверждение\nИнструмент: ${tool}${reason}`,
1246
- buttons: [[{ id: 'allow', text: '✅ Разрешить' }, { id: 'deny', text: '❌ Отклонить' }]],
1247
- }, 300_000)
1248
- if (result?.buttonId === 'allow') return 'allowed-once'
1249
- if (result?.buttonId === 'deny') return 'rejected'
1250
- return next()
1251
- } catch {
1252
- return next()
1253
- }
1254
- }
1255
-
1256
- async buildUserContent(input, signal) {
1257
- const { text, attachments = [], replyText, steer, personaOverride } = input
1258
- const parts = []
1259
- parts.push(String(this.config.agent?.instructionPrefix || MESSENGER_RELAY_INSTRUCTION))
1260
- const activePersonaId = personaOverride || this.personas.get(input.chatId)
1261
- const activePersona = getPersona(activePersonaId)
1262
- if (activePersona?.instruction) {
1263
- parts.push(`[Persona: ${activePersona.name} (${activePersona.icon})]\n${activePersona.instruction}`)
1264
- }
1265
- if (steer) parts.push('[Steer / дополнение к текущему ходу — учти вместе с предыдущим запросом, не начинай ответ заново с нуля]')
1266
- if (replyText?.trim()) parts.push(`[Ответ на сообщение: ${replyText.trim()}]`)
1267
- const blocks = []
1268
- for (const att of attachments) {
1269
- if (att.kind === 'photo' || (att.kind === 'sticker' && att.mime?.startsWith('image/'))) {
1270
- try {
1271
- const { ref } = await attachInboundPhoto(this.ctx, att, {
1272
- signal,
1273
- maxBytes: Number(this.config.media?.maxImageBytes) || 20 * 1024 * 1024,
1274
- })
1275
- blocks.push({ type: 'image', attachment: ref })
1276
- if (att.kind === 'sticker' && att.emoji) parts.push(`[Стикер ${att.emoji}]`)
1277
- } catch (err) {
1278
- if (signal?.aborted) throw err
1279
- const msg = err instanceof Error ? err.message : String(err)
1280
- parts.push(`[Не удалось приложить изображение: ${msg}]`)
1281
- }
1282
- } else if (att.kind === 'voice' || att.kind === 'audio') {
1283
- try {
1284
- const bytes = new Uint8Array(await readFile(att.path))
1285
- const transcript = await transcribeVoice(this.baseUrl(), bytes, att.mime || 'audio/ogg', 'message', signal)
1286
- parts.push(transcript ? `[Голосовое сообщение, расшифровка: ${transcript}]` : '[Голосовое сообщение (не удалось распознать)]')
1287
- } catch (err) {
1288
- if (signal?.aborted) throw err
1289
- const msg = err instanceof Error ? err.message : String(err)
1290
- this.ctx.logger?.warn?.(`voice: ${msg}`)
1291
- parts.push(`[Голосовое сообщение (dsh-voice недоступен: ${msg})]`)
1292
- }
1293
- } else if (att.kind === 'document' || att.kind === 'video' || att.kind === 'animation' || att.kind === 'sticker') {
1294
- let parsed = null
1295
- if (att.kind === 'document' && att.path) {
1296
- try {
1297
- const maxDocBytes = Number(this.config.media?.maxTextInjectBytes) || 100 * 1024
1298
- parsed = await parseDocument(att.path, { maxBytes: maxDocBytes })
1299
- } catch {}
1300
- }
1301
- parts.push(formatInboundDocument(att, parsed))
1302
- } else {
1303
- parts.push(`[Файл: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
1304
- }
1305
- }
1306
- const photoHint = photoOnlyHint(attachments, text)
1307
- if (photoHint) parts.push(photoHint)
1308
- const docHint = documentOnlyHint(attachments, text)
1309
- if (docHint) parts.push(docHint)
1310
- if (text?.trim()) parts.push(text.trim())
1311
- const textBlock = parts.filter(Boolean).join('\n\n')
1312
- if (textBlock) blocks.unshift({ type: 'text', text: textBlock })
1313
- if (!blocks.length) blocks.push({ type: 'text', text: '(пустое сообщение)' })
1314
- return blocks
1315
- }
1316
-
1317
- async runTurn(chat, input, signal) {
1318
- const { reply, typing, startStream, startProgress, react, inboundWasVoice, userId } = input
1319
- chat.turnActive = true
1320
- const sessionId = chat.agent.session.id
1321
- const tg = this.tg()
1322
- const streaming = tg.streaming === true && typeof startStream === 'function'
1323
- const progressEnabled = tg.progressEnabled !== false
1324
- const collector = { parts: [], lastText: '', streamText: '', toolName: '', images: [], reason: undefined, onStream: undefined }
1325
- this.pending.set(sessionId, collector)
1326
- let stopTyping = () => {}
1327
- let stream = null
1328
- let scheduler = null
1329
- let progress = null
1330
- try {
1331
- if (signal.aborted) return
1332
- if (typeof react === 'function' && tg.reactionsEnabled !== false) {
1333
- react('👀').catch?.(() => {})
1334
- }
1335
- if (typeof typing === 'function') stopTyping = startTypingHeartbeat(typing, 4000)
1336
- if (streaming) {
1337
- try {
1338
- stream = await startStream()
1339
- scheduler = createEditScheduler((text) => stream.edit(text), Number(tg.streamEditIntervalMs) || 1200)
1340
- collector.onStream = (text, toolName) => {
1341
- if (text) stopTyping()
1342
- if (!progressEnabled && !text) return
1343
- scheduler.push(buildStreamPreview(text, progressEnabled ? toolName : ''))
1344
- }
1345
- if (progressEnabled) scheduler.push(buildStreamPreview('', ''))
1346
- } catch (e) {
1347
- this.ctx.logger?.warn?.(`stream start: ${e.message}`)
1348
- stream = null
1349
- }
1350
- } else if (progressEnabled && typeof startProgress === 'function') {
1351
- try {
1352
- progress = await startProgress()
1353
- const editProgress = createEditScheduler((text) => progress.edit(text), 800)
1354
- collector.onStream = (_text, toolName) => {
1355
- editProgress.push(formatProgressLine(toolName))
1356
- }
1357
- } catch (e) {
1358
- this.ctx.logger?.warn?.(`progress start: ${e.message}`)
1359
- progress = null
1360
- }
1361
- }
1362
-
1363
-
1364
- const content = await this.buildUserContent(input, signal)
1365
- chat.agent.followup(createUserMessage({
1366
- content: ensureContentArray(content),
1367
- source: { kind: 'user', plugin: PLUGIN, form: 'relay', origin: 'telegram' },
1368
- }))
1369
- const turnTimeoutMs = Number(this.config.agent?.turnTimeoutMs) || 600_000
1370
- await whenIdleWithTimeout(chat.agent, turnTimeoutMs, signal)
1371
- if (signal.aborted) {
1372
- if (progress) try { await progress.remove() } catch {}
1373
- if (typeof react === 'function') react('').catch?.(() => {})
1374
- if (stream) try { await stream.finalize('Прервано.') } catch {}
1375
- else return reply('Прервано.')
1376
- return
1377
- }
1378
- const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
1379
- await sessions?.flush?.(chat.agent.session)
1380
- if (progress) try { await progress.remove() } catch {}
1381
- progress = null
1382
- if (typeof react === 'function') react('').catch?.(() => {})
1383
- const rawAnswer = stripReasoningPreamble(stripImageUrls(collector.lastText || collector.streamText || collector.parts.join('\n\n')))
1384
- const processed = processDiagramsAndTables(rawAnswer, {
1385
- artifactPreviews: this.tg().artifactPreviews !== false,
1386
- })
1387
- const answer = processed.text
1388
- if (collector.reason?.kind === 'error') {
1389
- const err = collector.reason.error
1390
- const msg = `Ошибка агента: ${err?.code || 'error'}: ${err?.message || 'unknown'}`
1391
- this.sendAlert('error', {
1392
- code: err?.code || 'AGENT_ERROR',
1393
- message: err?.message || 'unknown',
1394
- sessionId,
1395
- chatId: input.chatId,
1396
- threadId: input.threadId,
1397
- }).catch(() => {})
1398
- if (stream) { await scheduler?.flush(); await stream.finalize(msg) }
1399
- else await reply(msg)
1400
- return
1401
- }
1402
- const files = await buildOutboundFiles(this.ctx, this.baseUrl(), collector, { signal, logger: this.ctx.logger })
1403
- const allFiles = [...files, ...(processed.files || [])]
1404
- if (!answer && !allFiles.length) {
1405
- if (stream) { await scheduler?.flush(); await stream.finalize('(нет ответа)') }
1406
- else await reply('(нет ответа)')
1407
- return
1408
- }
1409
- const maxLen = Number(this.config.agent?.maxMessageLength) || 4000
1410
- const chunks = answer ? splitText(answer, maxLen) : ['']
1411
- if (stream) {
1412
- await scheduler?.flush()
1413
- await stream.finalize(chunks[0] || '(нет ответа)')
1414
- for (let i = 1; i < chunks.length; i++) await reply({ text: chunks[i] })
1415
- if (allFiles.length) await reply({ files: allFiles })
1416
- } else {
1417
- for (let i = 0; i < chunks.length; i++) {
1418
- await reply({ text: chunks[i] || undefined, files: i === 0 ? allFiles : [] })
1419
- }
1420
- }
1421
- const chatTtsPref = this.chatTts.get(chat.target?.chatId)
1422
- const speak = shouldSpeakReply({
1423
- globalTts: Boolean(this.config.tts?.enabled),
1424
- voiceMode: this.tg().voiceMode || 'mirror',
1425
- inboundWasVoice: Boolean(inboundWasVoice),
1426
- userPref: this.voicePrefs.get(userId),
1427
- chatPref: chatTtsPref,
1428
- })
1429
- if (speak && !signal.aborted) {
1430
- const isVoiceSummary = this.config.tts?.voiceSummary === true
1431
- const ttsText = prepareTtsText(answer, this.config.tts?.maxChars, { voiceSummary: isVoiceSummary })
1432
- if (ttsText) {
1433
- try {
1434
- const spoken = await speakText(this.baseUrl(), ttsText, signal)
1435
- const voiceFile = await toTelegramVoiceFile(spoken, { logger: this.ctx.logger })
1436
- if (!signal.aborted && voiceFile) await reply({ files: [voiceFile] })
1437
- } catch (e) {
1438
- if (!signal?.aborted) this.ctx.logger?.warn?.(`tts: ${e.message}`)
1439
- }
1440
- }
1441
- }
1442
- } catch (err) {
1443
- if (!signal?.aborted) {
1444
- this.sendAlert('error', {
1445
- code: err?.code || 'EXCEPTION',
1446
- message: err?.message || String(err),
1447
- sessionId,
1448
- chatId: input.chatId,
1449
- threadId: input.threadId,
1450
- }).catch(() => {})
1451
- try {
1452
- if (stream) await stream.finalize(`Сбой: ${err.message}`)
1453
- else await reply(`Сбой: ${err.message}`)
1454
- } catch {}
1455
- }
1456
- } finally {
1457
- stopTyping()
1458
- if (progress) try { await progress.remove() } catch {}
1459
- if (typeof react === 'function') react('').catch?.(() => {})
1460
- chat.turnActive = false
1461
- chat.abort = undefined
1462
- this.pending.delete(sessionId)
1463
- }
1464
- }
1465
-
1466
- reapIdle() {
1467
- const rawTimeout = Number(this.config.agent?.idleTimeoutMs)
1468
- const timeout = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 86_400_000
1469
- const now = Date.now()
1470
- for (const [key, chat] of this.chats) {
1471
- if (!chat.turnActive && now - chat.lastUsed > timeout) {
1472
- if (chat.agent?.session?.id) {
1473
- this.sessionToChat.delete(String(chat.agent.session.id))
1474
- }
1475
- this.chats.delete(key)
1476
- chat.dispose().catch(() => {})
1477
- }
1478
- }
1479
- }
1480
-
1481
- async approvePairingCode(code, actorUserId = 0) {
1482
- const res = this.pairing.approveCode(code, actorUserId)
1483
- if (!res.ok) return res
1484
- const merged = this.effectiveAllowedIds()
1485
- for (const a of this.adapterList) a.setAllowedUserIds?.(merged)
1486
- try { await this.hooks?.persistAllowedUserIds?.(merged) } catch (e) {
1487
- this.ctx.logger?.warn?.(`persist allowlist: ${e.message}`)
1488
- }
1489
- return { ...res, allowedUserIds: merged }
1490
- }
1491
-
1492
- rejectPairingCode(code) {
1493
- return this.pairing.rejectCode(code)
1494
- }
1495
-
1496
- async probeTelegram(timeoutMs = 10000) {
1497
- const adapter = this.getAdapter('telegram')
1498
- if (!adapter) {
1499
- return { ok: false, error: 'Telegram adapter not initialized' }
1500
- }
1501
- if (typeof adapter.probeHealth === 'function') {
1502
- return adapter.probeHealth(timeoutMs)
1503
- }
1504
- return { ok: false, error: 'probeHealth not implemented on adapter' }
1505
- }
1506
-
1507
- getBotInfo() {
1508
- const adapter = this.getAdapter('telegram')
1509
- return {
1510
- botId: adapter?.botId || 0,
1511
- botUsername: adapter?.botUsername || '',
1512
- pollingConflict: Boolean(adapter?.pollingConflict),
1513
- }
1514
- }
1515
-
1516
- async messengerAskFromAgent(agent, payload, timeoutMs) {
1517
- const sessionId = String(agent?.session?.id || '')
1518
- const key = this.sessionToChat.get(sessionId)
1519
- const chat = key ? this.chats.get(key) : null
1520
- if (!chat?.target) throw new Error('messenger_ask: no telegram chat for this agent session')
1521
- return this.messengerAsk(chat.target, payload, timeoutMs)
1522
- }
1523
-
1524
- get messenger() {
1525
- return {
1526
- adapters: () => [...this.adapters.keys()],
1527
- activeChats: () => this.chats.size,
1528
- home: (name) => this.resolveHomeTarget('telegram', name),
1529
- homes: () => listHomes(this.tg()),
1530
- pairingPending: () => this.pairing.listPending(),
1531
- pairingApproved: () => this.pairing.listApproved(),
1532
- send: (target, payload) => this.messengerSend(target, payload),
1533
- ask: (target, payload, timeoutMs) => this.messengerAsk(target, payload, timeoutMs),
1534
- progress: (target, payload) => this.messengerProgress(target, payload),
1535
- probeTelegram: (timeoutMs) => this.probeTelegram(timeoutMs),
1536
- getBotInfo: () => this.getBotInfo(),
1537
- }
1538
- }
1539
- }
1
+ import { readFile } from 'node:fs/promises'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { join, dirname, resolve } from 'node:path'
4
+ import { homedir } from 'node:os'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { installModelSelection } from '@deepseek-ai/dsh-agent'
7
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
8
+ import { SessionId } from '@deepseek-ai/dsh-session'
9
+ import createAdapters from './adapters/index.js'
10
+ import { transcribeVoice, speakText } from './integrations.js'
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
+ 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'
27
+ 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'
31
+ import { createPairingStore } from './pairing.js'
32
+ import {
33
+ extractTextDelta, extractToolName, buildStreamPreview, formatProgressLine,
34
+ createEditScheduler, startTypingHeartbeat,
35
+ } from './stream.js'
36
+ import { isTopicGoneError } from './telegram-errors.js'
37
+ import { ensureContentArray } from './content-guard.js'
38
+ import { mergeDynamicCommands } from './commands.js'
39
+ 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
+ }
59
+
60
+ const PLUGIN = 'dsh-messenger-gateway'
61
+
62
+ export class Gateway {
63
+ constructor(ctx, config, hooks = {}) {
64
+ this.ctx = ctx
65
+ this.config = config
66
+ this.hooks = hooks
67
+ this.chats = new Map()
68
+ this.sessionToChat = new Map()
69
+ this.sessionToThread = new Map()
70
+ this.threadToSession = new Map()
71
+ this.pending = new Map()
72
+ this.pendingAsks = new Map()
73
+ this.adapters = new Map()
74
+ this.adapterList = []
75
+ this.disposeListener = undefined
76
+ this.idleTimer = undefined
77
+ this.callbackIndex = new Map()
78
+ 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'))
85
+ this.scheduler = createScheduler(join(home, 'messenger-gateway', 'scheduled.json'), async (task) => {
86
+ const target = {
87
+ platform: task.platform || 'telegram',
88
+ chatId: task.chatId,
89
+ threadId: task.threadId || 0,
90
+ }
91
+ const locale = this.resolveLocale({ chatId: task.chatId })
92
+ if (task.prompt || task.action === 'prompt') {
93
+ const promptText = task.prompt || task.text
94
+ try {
95
+ await this.dispatchAutonomousPrompt(target, promptText, locale)
96
+ } catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err)
98
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: cron prompt error: ${msg}`)
99
+ await this.sendToMessenger(target, {
100
+ text: `⚠️ <b>[Cron Error]</b>\n${msg}`,
101
+ }).catch(() => {})
102
+ }
103
+ } else {
104
+ const text = t('remind.prefix', { text: task.text }, locale)
105
+ await this.sendToMessenger(target, { text })
106
+ }
107
+ })
108
+ this.stats = { sent: 0, errors: 0, startedAt: Date.now() }
109
+ }
110
+
111
+ resolveLocale(input) {
112
+ if (input?.locale) return input.locale
113
+ const chatId = input?.chatId
114
+ if (chatId && this.chatLocales?.get(chatId)) return this.chatLocales.get(chatId)
115
+ if (input?.languageCode) {
116
+ const code = String(input.languageCode).toLowerCase()
117
+ if (code.startsWith('zh')) return 'zh'
118
+ if (code.startsWith('en')) return 'en'
119
+ }
120
+ return this.config?.defaultLocale || 'en'
121
+ }
122
+
123
+ async dispatchAutonomousPrompt(target, promptText, locale = 'en') {
124
+ const key = this.sessionKeyFor(target)
125
+ const reply = async (payload) => {
126
+ await this.sendToMessenger(target, typeof payload === 'string' ? { text: payload } : payload)
127
+ }
128
+ const input = {
129
+ platform: target.platform || 'telegram',
130
+ chatId: target.chatId,
131
+ threadId: target.threadId || 0,
132
+ text: promptText,
133
+ reply,
134
+ locale,
135
+ }
136
+ const chat = await this.getOrCreateChat(key, input)
137
+ const turnInput = { ...input, attachments: [], inboundWasVoice: false }
138
+ chat.turnActive = true
139
+ try { chat.abort?.abort?.() } catch {}
140
+ chat.abort = new AbortController()
141
+ const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
142
+ chat.busy = run.catch(() => {})
143
+ run.catch((err) => {
144
+ const msg = err instanceof Error ? err.message : String(err)
145
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: cron turn: ${msg}`)
146
+ chat.turnActive = false
147
+ chat.abort = undefined
148
+ })
149
+ return run
150
+ }
151
+
152
+ isMuted(chatId) { return this.muted.get(chatId) === true }
153
+ setMuted(chatId, on) { return this.muted.set(chatId, on) }
154
+
155
+ baseUrl() {
156
+ const raw = String(this.config.internalBaseURL || '').trim()
157
+ return raw || 'http://127.0.0.1:3080'
158
+ }
159
+
160
+ tg() { return this.config.telegram || {} }
161
+
162
+ effectiveAllowedIds() {
163
+ const fromConfig = (this.tg().allowedUserIds || []).map(Number).filter(Number.isFinite)
164
+ const fromPairing = this.pairing.listApproved()
165
+ return [...new Set([...fromConfig, ...fromPairing])]
166
+ }
167
+
168
+ isUserAllowed(userId) {
169
+ const ids = this.effectiveAllowedIds()
170
+ return ids.length === 0 || ids.includes(Number(userId))
171
+ }
172
+
173
+ resolveHomeTarget(platform = 'telegram', name) {
174
+ const tg = this.tg()
175
+ const home = resolveNamedHome(tg, name)
176
+ if (!home) return null
177
+ const out = { platform, chatId: home.chatId, homeName: home.name }
178
+ if (home.threadId > 0) out.threadId = home.threadId
179
+ return out
180
+ }
181
+
182
+ async sendAlert(type, payload = {}) {
183
+ try {
184
+ const target = resolveAlertTarget(this)
185
+ if (!target) return
186
+ const allowedEvents = this.config.telegram?.alerts?.events || ['error', 'pairing']
187
+ if (type !== 'status' && !allowedEvents.includes(type)) return
188
+
189
+ const text = formatAlertMessage(type, payload)
190
+ await this.sendToMessenger(target, { text })
191
+ } catch (err) {
192
+ this.ctx.logger?.warn?.(`sendAlert (${type}): ${err.message}`)
193
+ }
194
+ }
195
+
196
+ async start() {
197
+ this.disposeListener = this.ctx.on('session/event', (session, event) => {
198
+ if (this.config.telegram?.forumMirrorEnabled) {
199
+ if (event.type === 'turn/start' || event.type === 'session/create') {
200
+ this.mirrorSessionToForumTopic(session).catch?.(() => {})
201
+ } else if (event.type === 'turn/end') {
202
+ this.relayTurnToForumMirror(session, event).catch?.(() => {})
203
+ }
204
+ }
205
+
206
+ const collector = this.pending.get(session.id)
207
+ if (!collector) return
208
+ if (event.type === 'assistant/message') {
209
+ const msg = event.data.message
210
+ const text = assistantText(msg)
211
+ if (text) collector.lastText = text
212
+ const extra = collectAssistantParts(msg)
213
+ for (const img of extra.images) collector.images.push(img)
214
+ } else if (event.type === 'assistant/chunk') {
215
+ const delta = extractTextDelta(event.data?.chunk)
216
+ if (delta) {
217
+ collector.streamText = (collector.streamText || '') + delta
218
+ collector.onStream?.(collector.streamText, collector.toolName)
219
+ }
220
+ } else if (event.type === 'tool/call') {
221
+ collector.toolName = extractToolName(event.data) || collector.toolName
222
+ collector.onStream?.(collector.streamText || '', collector.toolName)
223
+ } else if (event.type === 'tool/result') {
224
+ collector.toolName = ''
225
+ collector.onStream?.(collector.streamText || '', '')
226
+ } else if (event.type === 'turn/end') {
227
+ collector.reason = event.data.reason
228
+ }
229
+ })
230
+ const adapters = createAdapters({
231
+ config: this.config,
232
+ onMessage: (input) => this.handleMessage(input),
233
+ onCallback: (cb) => this.handleCallback(cb),
234
+ onUnauthorized: (input) => this.handleUnauthorized(input),
235
+ isUserAllowed: (id) => this.isUserAllowed(id),
236
+ logger: this.ctx.logger,
237
+ })
238
+ for (const adapter of adapters) {
239
+ try {
240
+ await adapter.start()
241
+ this.adapterList.push(adapter)
242
+ this.adapters.set(adapter.name, adapter)
243
+ if (adapter.name === 'telegram') this.tgAdapter = adapter
244
+ this.ctx.logger?.info?.(`dsh-messenger-gateway: ${adapter.name} started`)
245
+ } catch (err) {
246
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: ${adapter.name}: ${err.message}`)
247
+ }
248
+ }
249
+ if (this.adapters.has('telegram')) {
250
+ await this.syncTelegramCommands().catch((err) => {
251
+ this.ctx.logger?.warn?.(`Initial syncTelegramCommands: ${err?.message || err}`)
252
+ })
253
+ }
254
+ const rawIdle = Number(this.config.agent?.idleTimeoutMs)
255
+ const idleMs = Number.isFinite(rawIdle) && rawIdle > 0 ? rawIdle : 86_400_000
256
+ this.idleTimer = setInterval(() => this.reapIdle(), Math.min(idleMs, 60_000))
257
+ this.idleTimer.unref?.()
258
+ this.scheduler.start()
259
+ }
260
+
261
+ stop() {
262
+ if (this.scheduler) this.scheduler.stop()
263
+ if (this.disposeListener) this.disposeListener()
264
+ if (this.idleTimer) clearInterval(this.idleTimer)
265
+ for (const a of this.adapterList) { try { a.stop() } catch {} }
266
+ this.adapterList = []
267
+ this.adapters.clear()
268
+ for (const chat of this.chats.values()) chat.dispose().catch(() => {})
269
+ this.chats.clear()
270
+ this.sessionToChat.clear()
271
+ this.sessionToThread.clear()
272
+ this.threadToSession.clear()
273
+ for (const pending of this.pendingAsks.values()) {
274
+ releaseCallbacks(this.callbackIndex, pending.callbackKeys)
275
+ rejectPendingAsk(pending, new Error('gateway stopped'))
276
+ }
277
+ this.pending.clear()
278
+ this.pendingAsks.clear()
279
+ this.callbackIndex.clear()
280
+ }
281
+
282
+ getAdapter(platform) { return this.adapters.get(platform) }
283
+
284
+ async messengerSend(target, payload) {
285
+ let resolved = target
286
+ if (!resolved?.chatId && resolved?.platform) {
287
+ const home = this.resolveHomeTarget(resolved.platform, resolved.home || resolved.name)
288
+ if (!home) throw new Error('target.chatId required (or set telegram home channel)')
289
+ resolved = { ...home, ...resolved, chatId: home.chatId, threadId: resolved.threadId ?? home.threadId }
290
+ }
291
+ const adapter = this.getAdapter(resolved.platform)
292
+ if (!adapter?.sendTo) throw new Error(`adapter ${resolved.platform} unavailable`)
293
+ try {
294
+ await adapter.sendTo(resolved.chatId, payload, { threadId: resolved.threadId })
295
+ this.stats.sent++
296
+ } catch (err) {
297
+ this.stats.errors++
298
+ if (isTopicGoneError(err)) {
299
+ this.logger?.warn?.(`messengerSend: topic gone for ${resolved.platform}:${resolved.chatId}:${resolved.threadId} — the chat/topic was deleted; skipping delivery`)
300
+ return
301
+ }
302
+ throw err
303
+ }
304
+ }
305
+
306
+ 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
+ })
354
+ }
355
+
356
+ async messengerProgress(target, payload) {
357
+ await this.messengerSend(target, { text: payload.text })
358
+ }
359
+
360
+ async handleUnauthorized(input) {
361
+ const { reply, userId, username } = input
362
+ const locale = this.resolveLocale(input)
363
+ if (!this.tg().pairingEnabled) {
364
+ await reply(t('msg.not_allowed', {}, locale))
365
+ return
366
+ }
367
+ try {
368
+ const { code } = this.pairing.requestCode(userId, { username })
369
+ await reply(t('msg.pairing_requested', { userId, code }, locale))
370
+ this.sendAlert('pairing', { userId, username, code }).catch(() => {})
371
+ } catch (err) {
372
+ if (err.code === 'RATE_LIMIT') await reply(t('msg.pairing_rate_limit', {}, locale))
373
+ else await reply(t('msg.exception', { message: err.message }, locale))
374
+ }
375
+ }
376
+
377
+ 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()
542
+ }
543
+
544
+ sessionKeyFor(input) {
545
+ const scope = this.config.agent?.sessionScope || 'user'
546
+ return sessionKey({
547
+ platform: input.platform,
548
+ chatId: input.chatId,
549
+ threadId: input.threadId || 0,
550
+ userId: input.userId,
551
+ chatType: input.chatType,
552
+ scope,
553
+ })
554
+ }
555
+
556
+ isChatBusy(chat) {
557
+ return Boolean(chat?.turnActive)
558
+ }
559
+
560
+ async handleMessage(input) {
561
+ const { platform, chatId, threadId = 0, text, reply } = input
562
+ const key = this.sessionKeyFor(input)
563
+ const body = String(text || '').trim()
564
+ let attachments = [...(input.attachments || [])]
565
+ const hasMedia = attachments.length > 0
566
+ if (!body && !hasMedia) return
567
+ if (body.startsWith('/')) { await this.handleCommand(key, body, input); return }
568
+ try {
569
+ const chat = await this.getOrCreateChat(key, input)
570
+ const photoOnlyMode = this.config.agent?.photoOnlyMode ?? 'prompt'
571
+ const incomingPhotoOnly = hasMedia && attachments.every((a) => a.kind === 'photo' || a.kind === 'sticker') && !body
572
+ if (incomingPhotoOnly && photoOnlyMode === 'prompt') {
573
+ chat.pendingMedia = [...(chat.pendingMedia || []), ...attachments]
574
+ const n = chat.pendingMedia.length
575
+ const locale = this.resolveLocale(input)
576
+ const msg = n === 1
577
+ ? t('photo.received_one', {}, locale)
578
+ : t('photo.received_many', { count: n }, locale)
579
+ return reply(msg)
580
+ }
581
+ if (chat.pendingMedia?.length) {
582
+ attachments = [...chat.pendingMedia, ...attachments]
583
+ chat.pendingMedia = []
584
+ }
585
+ const inboundWasVoice = attachments.some((a) => a.kind === 'voice' || a.kind === 'audio')
586
+ let personaOverride
587
+ for (const [pId] of Object.entries(BUILTIN_PERSONAS)) {
588
+ if (pId === 'default') continue
589
+ const tag = `@${pId}`
590
+ if (body.toLowerCase().includes(tag)) {
591
+ personaOverride = pId
592
+ break
593
+ }
594
+ }
595
+ const turnInput = { ...input, text: body, attachments, inboundWasVoice, personaOverride }
596
+
597
+ // Hermes-like steer: while a turn is running, inject followup instead of abort+restart
598
+ if (this.isChatBusy(chat)) {
599
+ const steerText = body || (hasMedia ? '(steer: media)' : '')
600
+ const content = await this.buildUserContent({
601
+ ...turnInput,
602
+ text: steerText,
603
+ steer: true,
604
+ }, undefined)
605
+ chat.agent.followup(createUserMessage({
606
+ content: ensureContentArray(content),
607
+ source: { kind: 'user', plugin: PLUGIN, form: 'steer', origin: 'telegram' },
608
+ }))
609
+ chat.lastUsed = Date.now()
610
+ try { await reply(t('msg.steer_added', {}, this.resolveLocale(input))) } catch {}
611
+ return
612
+ }
613
+
614
+ // Mark busy BEFORE yielding to the poll loop, otherwise steer/stop never see an active turn.
615
+ chat.turnActive = true
616
+ try { chat.abort?.abort?.() } catch {}
617
+ chat.abort = new AbortController()
618
+ const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
619
+ chat.busy = run.catch(() => {})
620
+ // Do NOT await: Telegram poll is sequential; awaiting blocked steer and /stop until the turn finished.
621
+ run.catch((err) => {
622
+ const msg = err instanceof Error ? err.message : String(err)
623
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: background turn: ${msg}`)
624
+ this.sendAlert('error', {
625
+ code: err?.code || 'BACKGROUND_ERROR',
626
+ message: msg,
627
+ sessionId: chat.agent?.session?.id,
628
+ chatId: input.chatId,
629
+ threadId: input.threadId,
630
+ }).catch(() => {})
631
+ chat.turnActive = false
632
+ chat.abort = undefined
633
+ })
634
+ } catch (err) {
635
+ const msg = err instanceof Error ? err.message : String(err)
636
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: message: ${msg}`)
637
+ try { await reply(t('msg.exception', { message: msg }, this.resolveLocale(input))) } catch {}
638
+ }
639
+ }
640
+
641
+ 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))
1282
+ }
1283
+
1284
+ 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
1301
+ }
1302
+
1303
+ 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
+ }
1315
+ }
1316
+
1317
+ 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
+ }
1347
+ }
1348
+
1349
+ 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
+ }
1386
+ }
1387
+
1388
+ async getOrCreateChat(key, input) {
1389
+ let chat = this.chats.get(key)
1390
+ if (!chat) {
1391
+ const threadKey = `${input.chatId}:${input.threadId || 0}`
1392
+ const existingSessionId = this.threadToSession.get(threadKey)
1393
+ chat = await this.createChat(key, input, existingSessionId)
1394
+ this.chats.set(key, chat)
1395
+ }
1396
+ chat.lastUsed = Date.now()
1397
+ chat.target = { platform: input.platform, chatId: input.chatId, threadId: input.threadId || 0 }
1398
+ return chat
1399
+ }
1400
+
1401
+ resolveAgentModel() {
1402
+ const agentCfg = this.config.agent || {}
1403
+ let provider = String(agentCfg.provider || '').trim()
1404
+ let model = String(agentCfg.model || '').trim()
1405
+ if (provider && model) return { provider, model }
1406
+ const selection = this.ctx.get('agentDefaultModel')?.currentSelection?.()
1407
+ if (!selection?.provider || !selection?.model) {
1408
+ throw new Error('Please select a model in Settings -> Models (or set agent.provider/model in profile)')
1409
+ }
1410
+ return { provider: provider || selection.provider, model: model || selection.model }
1411
+ }
1412
+
1413
+ async createChat(key, input, existingSessionId) {
1414
+ const { provider, model } = this.resolveAgentModel()
1415
+ const agentCfg = this.config.agent || {}
1416
+ const cwd = agentCfg.cwd || process.cwd()
1417
+ const self = this
1418
+ const agents = this.ctx.get?.('agents') || this.ctx.agents
1419
+ const targetSessionId = existingSessionId ? SessionId(existingSessionId) : SessionId(`msgw-${randomUUID()}`)
1420
+ const handle = await agents.create({
1421
+ sessionId: targetSessionId,
1422
+ meta: { cwd },
1423
+ agentOptions: { provider, model },
1424
+ setup: (agentCtx) => {
1425
+ installModelSelection(agentCtx, { current: { provider, model }, assembled: undefined })
1426
+ if (self.tg().approvalsEnabled !== false) {
1427
+ agentCtx.on('approval/request', (req, next) => self.answerApproval(key, req, next))
1428
+ }
1429
+ },
1430
+ })
1431
+ await handle.agent.whenIdle()
1432
+ const chat = {
1433
+ key, agent: handle.agent, dispose: handle.dispose, busy: Promise.resolve(),
1434
+ lastUsed: Date.now(), abort: undefined, pendingMedia: [], turnActive: false,
1435
+ sessionAllowlist: new Set(),
1436
+ target: input ? { platform: input.platform, chatId: input.chatId, threadId: input.threadId || 0 } : undefined,
1437
+ }
1438
+ this.sessionToChat.set(String(handle.agent.session.id), key)
1439
+ if (existingSessionId && input) {
1440
+ const threadKey = `${input.chatId}:${input.threadId || 0}`
1441
+ this.threadToSession.set(threadKey, existingSessionId)
1442
+ this.sessionToThread.set(existingSessionId, { chatId: input.chatId, threadId: input.threadId || 0 })
1443
+ }
1444
+ return chat
1445
+ }
1446
+
1447
+ 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
+ }
1484
+ }
1485
+
1486
+ 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
1545
+ }
1546
+
1547
+ 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
+ }
1697
+ }
1698
+
1699
+ reapIdle() {
1700
+ const rawTimeout = Number(this.config.agent?.idleTimeoutMs)
1701
+ const timeout = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 86_400_000
1702
+ const now = Date.now()
1703
+ for (const [key, chat] of this.chats) {
1704
+ if (!chat.turnActive && now - chat.lastUsed > timeout) {
1705
+ if (chat.agent?.session?.id) {
1706
+ this.sessionToChat.delete(String(chat.agent.session.id))
1707
+ }
1708
+ this.chats.delete(key)
1709
+ chat.dispose().catch(() => {})
1710
+ }
1711
+ }
1712
+ }
1713
+
1714
+ async approvePairingCode(code, actorUserId = 0) {
1715
+ const res = this.pairing.approveCode(code, actorUserId)
1716
+ if (!res.ok) return res
1717
+ const merged = this.effectiveAllowedIds()
1718
+ for (const a of this.adapterList) a.setAllowedUserIds?.(merged)
1719
+ try { await this.hooks?.persistAllowedUserIds?.(merged) } catch (e) {
1720
+ this.ctx.logger?.warn?.(`persist allowlist: ${e.message}`)
1721
+ }
1722
+ return { ...res, allowedUserIds: merged }
1723
+ }
1724
+
1725
+ rejectPairingCode(code) {
1726
+ return this.pairing.rejectCode(code)
1727
+ }
1728
+
1729
+ async probeTelegram(timeoutMs = 10000) {
1730
+ const adapter = this.getAdapter('telegram')
1731
+ if (!adapter) {
1732
+ return { ok: false, error: 'Telegram adapter not initialized' }
1733
+ }
1734
+ if (typeof adapter.probeHealth === 'function') {
1735
+ return adapter.probeHealth(timeoutMs)
1736
+ }
1737
+ return { ok: false, error: 'probeHealth not implemented on adapter' }
1738
+ }
1739
+
1740
+ getBotInfo() {
1741
+ const adapter = this.getAdapter('telegram')
1742
+ return {
1743
+ botId: adapter?.botId || 0,
1744
+ botUsername: adapter?.botUsername || '',
1745
+ pollingConflict: Boolean(adapter?.pollingConflict),
1746
+ }
1747
+ }
1748
+
1749
+ async messengerAskFromAgent(agent, payload, timeoutMs) {
1750
+ const sessionId = String(agent?.session?.id || '')
1751
+ const key = this.sessionToChat.get(sessionId)
1752
+ const chat = key ? this.chats.get(key) : null
1753
+ if (!chat?.target) throw new Error('messenger_ask: no telegram chat for this agent session')
1754
+ return this.messengerAsk(chat.target, payload, timeoutMs)
1755
+ }
1756
+
1757
+ get messenger() {
1758
+ return {
1759
+ adapters: () => [...this.adapters.keys()],
1760
+ activeChats: () => this.chats.size,
1761
+ home: (name) => this.resolveHomeTarget('telegram', name),
1762
+ homes: () => listHomes(this.tg()),
1763
+ pairingPending: () => this.pairing.listPending(),
1764
+ pairingApproved: () => this.pairing.listApproved(),
1765
+ send: (target, payload) => this.messengerSend(target, payload),
1766
+ ask: (target, payload, timeoutMs) => this.messengerAsk(target, payload, timeoutMs),
1767
+ progress: (target, payload) => this.messengerProgress(target, payload),
1768
+ probeTelegram: (timeoutMs) => this.probeTelegram(timeoutMs),
1769
+ getBotInfo: () => this.getBotInfo(),
1770
+ }
1771
+ }
1772
+ }