@goodandready/dsh-messenger-gateway 0.3.21 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,584 @@
1
+ import { t } from './locales/index.js'
2
+ import { REMOVE_REPLY_KEYBOARD, buildQuickActionsKeyboard } from './adapters/telegram.js'
3
+ import { getPersona, listPersonas } from './personas.js'
4
+ import { parseRelativeTime, formatRemaining } from './scheduler.js'
5
+ import { getUpdateStatus, runDirectUpdate } from './updater.js'
6
+ import { exportSessionToMarkdown, rewindSession } from './session-ops.js'
7
+ import { handleFilesCommand, handleGetCommand } from './file-manager.js'
8
+ import { listModelCatalog, buildProvidersKeyboard } from './models.js'
9
+ import { normalizeHomeName, upsertHome, listHomes } from './homes.js'
10
+ import { handleSetAlertCommand, handleAlertCommand } from './alerts.js'
11
+ import { HELP_TEXT } from './commands.js'
12
+
13
+ export function releaseChatTurn(chat) {
14
+ chat.busy = Promise.resolve()
15
+ }
16
+
17
+ export async function handleGatewayCommand(gw, key, text, input) {
18
+ const parts = text.split(/\s+/)
19
+ const cmd = parts[0].toLowerCase().split('@')[0]
20
+ const { reply, userId, chatId, threadId = 0, platform } = input
21
+ const locale = gw.resolveLocale(input)
22
+
23
+ if (cmd === '/start') {
24
+ return reply(t('msg.start', {}, locale), { replyMarkup: REMOVE_REPLY_KEYBOARD })
25
+ }
26
+
27
+ if (cmd === '/help') {
28
+ return reply(HELP_TEXT)
29
+ }
30
+
31
+ if (cmd === '/lang' || cmd === '/language') {
32
+ const sub = parts[1]?.toLowerCase()
33
+ if (sub === 'en' || sub === 'zh') {
34
+ gw.chatLocales.set(chatId, sub)
35
+ return reply(sub === 'zh' ? '语言已切换为中文 (zh)' : 'Language switched to English (en)')
36
+ }
37
+ const cur = gw.chatLocales.get(chatId) || gw.config?.defaultLocale || 'en'
38
+ return reply(`Current language: <b>${cur}</b>\nSwitch: <code>/lang en</code> or <code>/lang zh</code>`)
39
+ }
40
+
41
+ if (cmd === '/bind') {
42
+ const targetRole = parts[1]?.toLowerCase()
43
+ if (!targetRole || targetRole === 'list') {
44
+ const cur = gw.personas.getPersonaForChat(chatId, threadId)
45
+ return reply(`${t('persona.title', {}, locale)}\nCurrent bound role: <b>${cur}</b>\nUsage: <code>/bind &lt;role&gt;</code> (or /bind reset)`)
46
+ }
47
+ if (targetRole === 'reset' || targetRole === 'default') {
48
+ gw.personas.set(chatId, 'default', threadId)
49
+ return reply(t('persona.reset', {}, locale))
50
+ }
51
+ const persona = getPersona(targetRole)
52
+ if (!persona) return reply(t('persona.unknown', { target: targetRole }, locale))
53
+ gw.personas.set(chatId, targetRole, threadId)
54
+ return reply(t('persona.bound_topic', { kind: 'role', name: `${persona.icon} ${persona.name}` }, locale))
55
+ }
56
+
57
+ if (cmd === '/preset') {
58
+ const presetName = parts[1]
59
+ if (!presetName || presetName === 'list') {
60
+ const cur = gw.personas.getPreset(chatId, threadId) || '(none)'
61
+ return reply(`🎭 <b>Presets:</b>\nCurrent topic preset: <code>${cur}</code>\nUsage: <code>/preset &lt;name&gt;</code> or <code>/preset reset</code>`)
62
+ }
63
+ if (presetName === 'reset' || presetName === 'clear') {
64
+ gw.personas.setPreset(chatId, threadId, null)
65
+ return reply('Preset cleared for this topic.')
66
+ }
67
+ gw.personas.setPreset(chatId, threadId, presetName)
68
+ return reply(t('persona.bound_topic', { kind: 'preset', name: presetName }, locale))
69
+ }
70
+
71
+ if (cmd === '/cron') {
72
+ const sub = parts[1]?.toLowerCase()
73
+ if (sub === 'list') {
74
+ const list = await gw.scheduler.listRecurring(chatId)
75
+ if (!list.length) return reply(t('cron.none', {}, locale))
76
+ const lines = [t('cron.list_title', {}, locale)]
77
+ for (const task of list) {
78
+ const left = formatRemaining(task.dueAt - Date.now(), locale)
79
+ lines.push(`• <code>${task.id}</code> (every ${formatRemaining(task.intervalMs, locale)}, next in ${left}): ${task.prompt || task.text}`)
80
+ }
81
+ lines.push('\nCancel: <code>/cron cancel ID</code>')
82
+ return reply(lines.join('\n'))
83
+ }
84
+ if (sub === 'cancel') {
85
+ const targetId = parts[2]
86
+ if (!targetId) return reply('Specify cron task ID: <code>/cron cancel ID</code>')
87
+ const ok = await gw.scheduler.cancel(targetId, chatId)
88
+ return reply(ok ? t('cron.cancelled', { id: targetId }, locale) : `Task not found: <code>${targetId}</code>`)
89
+ }
90
+ const specArg = parts[1]
91
+ const promptArg = parts.slice(2).join(' ')
92
+ const ms = parseRelativeTime(specArg)
93
+ if (!ms || !promptArg) {
94
+ 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>')
95
+ }
96
+ const task = await gw.scheduler.schedule({
97
+ platform,
98
+ chatId,
99
+ threadId,
100
+ userId,
101
+ text: promptArg,
102
+ prompt: promptArg,
103
+ dueAt: Date.now() + ms,
104
+ recurring: true,
105
+ intervalMs: ms,
106
+ })
107
+ return reply(t('cron.scheduled', { id: task.id, schedule: specArg, prompt: promptArg }, locale))
108
+ }
109
+
110
+ if (cmd === '/update') {
111
+ if (!gw.isUserAllowed(userId)) {
112
+ return reply(t('msg.not_allowed', {}, locale))
113
+ }
114
+ const sub = parts[1]?.toLowerCase()
115
+ const manifestPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json')
116
+ const updaterOpts = {
117
+ manifestPath,
118
+ packageName: '@goodandready/dsh-messenger-gateway',
119
+ }
120
+ if (sub === 'now' || sub === 'install') {
121
+ await reply(t('update.installing', {}, locale))
122
+ try {
123
+ const res = await runDirectUpdate(updaterOpts)
124
+ if (!res.updated) {
125
+ return reply(t('update.already_latest', { version: res.currentVersion }, locale))
126
+ }
127
+ return reply(t('update.success', { version: res.updatedVersion }, locale))
128
+ } catch (err) {
129
+ return reply(t('update.failed', { error: err.message }, locale))
130
+ }
131
+ }
132
+ try {
133
+ const st = await getUpdateStatus(updaterOpts)
134
+ if (st.updateAvailable) {
135
+ return reply(t('update.available', { current: st.currentVersion, latest: st.latestVersion }, locale))
136
+ }
137
+ return reply(t('update.current', { version: st.currentVersion }, locale))
138
+ } catch (err) {
139
+ return reply(t('update.check_failed', { error: err.message }, locale))
140
+ }
141
+ }
142
+
143
+ if (cmd === '/role' || cmd === '/persona') {
144
+ const targetRole = parts[1]?.toLowerCase()
145
+ if (!targetRole || targetRole === 'list') {
146
+ const currentId = gw.personas.getPersonaForChat(chatId, threadId)
147
+ const lines = [
148
+ t('persona.title', {}, locale),
149
+ '',
150
+ ...listPersonas().map((p) => {
151
+ const isCurrent = p.id === currentId ? ' (active)' : ''
152
+ return `${p.icon} <b>${p.id}</b> — ${p.name}: ${p.description}${isCurrent}`
153
+ }),
154
+ '',
155
+ t('persona.usage', {}, locale),
156
+ ]
157
+ return reply(lines.join('\n'))
158
+ }
159
+ if (targetRole === 'reset' || targetRole === 'default') {
160
+ gw.personas.set(chatId, 'default', threadId)
161
+ return reply(t('persona.reset', {}, locale))
162
+ }
163
+ const persona = getPersona(targetRole)
164
+ if (!persona) {
165
+ return reply(t('persona.unknown', { target: targetRole }, locale))
166
+ }
167
+ gw.personas.set(chatId, persona.id, threadId)
168
+ return reply(t('persona.switched', { icon: persona.icon, name: persona.name, description: persona.description }, locale))
169
+ }
170
+
171
+ if (cmd === '/skills' || cmd === '/tools') {
172
+ const tools = gw.ctx.get?.('tools') || gw.ctx.tools
173
+ const toolsList = []
174
+ if (tools?.tools) {
175
+ for (const [name, tDef] of tools.tools.entries()) {
176
+ toolsList.push(`• <b>${name}</b>: ${tDef.description || '(no description)'}`)
177
+ }
178
+ }
179
+ if (!toolsList.length) {
180
+ return reply('🛠️ <b>Agent Tools:</b>\n(no tools registered)')
181
+ }
182
+ return reply([
183
+ '🛠️ <b>Active Tools & Skills:</b>',
184
+ '',
185
+ ...toolsList,
186
+ ].join('\n'))
187
+ }
188
+
189
+ if (cmd === '/export') {
190
+ const chat = gw.chats.get(key)
191
+ if (!chat?.agent?.session) {
192
+ return reply(t('msg.no_active_session', {}, locale))
193
+ }
194
+ try {
195
+ const { filename, buffer, messagesCount } = exportSessionToMarkdown(chat.agent.session)
196
+ if (!messagesCount) {
197
+ return reply(t('export.empty', {}, locale))
198
+ }
199
+ const file = {
200
+ name: filename,
201
+ mime: 'text/markdown',
202
+ kind: 'document',
203
+ bytes: buffer,
204
+ }
205
+ return reply({ text: t('export.title', { count: messagesCount }, locale), files: [file] })
206
+ } catch (err) {
207
+ return reply(`Export error: ${err.message}`)
208
+ }
209
+ }
210
+
211
+ if (cmd === '/rewind') {
212
+ const chat = gw.chats.get(key)
213
+ if (!chat?.agent?.session) {
214
+ return reply(t('msg.no_active_session', {}, locale))
215
+ }
216
+ const count = Number(parts[1]) || 1
217
+ const res = rewindSession(chat.agent.session, count)
218
+ if (!res.removed) {
219
+ return reply('No turns to rewind in session history.')
220
+ }
221
+ const sessions = gw.ctx.get?.('sessions') || gw.ctx.sessions
222
+ try { await sessions?.flush(chat.agent.session) } catch (err) { gw.logger?.debug?.('rewind session flush error:', err?.message || err) }
223
+ return reply(`⏪ Rewound ${res.removed} messages. Remaining in context: ${res.remaining}.`)
224
+ }
225
+
226
+ if (cmd === '/fork') {
227
+ const chat = gw.chats.get(key)
228
+ if (!chat?.agent?.session) {
229
+ return reply(t('msg.no_active_session', {}, locale))
230
+ }
231
+ try {
232
+ const oldSession = chat.agent.session
233
+ const oldMessages = Array.isArray(oldSession.messages)
234
+ ? oldSession.messages.map(m => ({ ...m, content: ensureContentArray(m.content) }))
235
+ : []
236
+ const newChat = await gw.createChat(key, input)
237
+ if (newChat.agent?.session && oldMessages.length) {
238
+ newChat.agent.session.messages = oldMessages
239
+ const sessions = gw.ctx.get?.('sessions') || gw.ctx.sessions
240
+ try { await sessions?.flush(newChat.agent.session) } catch (err) { gw.logger?.debug?.('fork session flush error:', err?.message || err) }
241
+ }
242
+ gw.chats.set(key, newChat)
243
+ return reply(`🔀 Forked session!\nOld session: ${oldSession.id}\nNew session: ${newChat.agent.session.id}\nContext preserved (${oldMessages.length} messages).`)
244
+ } catch (err) {
245
+ return reply(`Fork error: ${err.message}`)
246
+ }
247
+ }
248
+
249
+ if (cmd === '/files') {
250
+ const subPath = parts.slice(1).join(' ').trim() || '.'
251
+ const agentCwd = gw.config.agent?.cwd || process.cwd()
252
+ const res = await handleFilesCommand(agentCwd, subPath)
253
+ return reply(res.text)
254
+ }
255
+
256
+ if (cmd === '/get') {
257
+ const targetRel = parts.slice(1).join(' ').trim()
258
+ const agentCwd = gw.config.agent?.cwd || process.cwd()
259
+ const maxDocBytes = Number(gw.config.media?.maxDocBytes) || 50 * 1024 * 1024
260
+ const res = await handleGetCommand(agentCwd, targetRel, maxDocBytes)
261
+ return reply(res.files ? { text: res.text, files: res.files } : res.text)
262
+ }
263
+
264
+
265
+ if (cmd === '/new') {
266
+ const chat = gw.chats.get(key)
267
+ if (chat) {
268
+ if (chat.abort) chat.abort.abort()
269
+ chat.pendingMedia = []
270
+ gw.sessionToChat.delete(String(chat.agent.session.id))
271
+ gw.chats.delete(key)
272
+ await chat.dispose()
273
+ return reply('Session reset.')
274
+ }
275
+ return reply(t('msg.no_active_session', {}, locale))
276
+ }
277
+
278
+ if (cmd === '/whoami') {
279
+ const lines = [`User ID: ${userId}`]
280
+ if (chatId) lines.push(`chatId: ${chatId}`)
281
+ if (threadId) lines.push(`threadId: ${threadId}`)
282
+ return reply(lines.join('\n'))
283
+ }
284
+
285
+ if (cmd === '/stop') {
286
+ const chat = gw.chats.get(key)
287
+ if (chat?.turnActive || chat?.abort) {
288
+ try { chat.abort?.abort() } catch { /* safe best-effort stop abort */ }
289
+ releaseChatTurn(chat)
290
+ chat.turnActive = false
291
+ return reply(t('msg.turn_stopped', {}, locale))
292
+ }
293
+ return reply('Nothing to stop.')
294
+ }
295
+
296
+ if (cmd === '/status') {
297
+ let modelLine = 'model: (not set)'
298
+ try {
299
+ const sel = gw.resolveAgentModel()
300
+ modelLine = `model: ${sel.provider}/${sel.model}`
301
+ } catch (e) {
302
+ modelLine = `model: ${e.message}`
303
+ }
304
+ const home = gw.resolveHomeTarget(platform || 'telegram')
305
+ const homeLine = home
306
+ ? `home: chat ${home.chatId}${home.threadId ? ` topic ${home.threadId}` : ''}`
307
+ : 'home: (not set)'
308
+ const pending = gw.pairing.listPending().length
309
+ const up = Math.max(0, Math.round((Date.now() - gw.stats.startedAt) / 1000))
310
+ const hh = String(Math.floor(up / 3600)).padStart(2, '0')
311
+ const mm = String(Math.floor((up % 3600) / 60)).padStart(2, '0')
312
+ const ss = String(up % 60).padStart(2, '0')
313
+ return reply([
314
+ 'Messenger gateway',
315
+ `adapters: ${[...gw.adapters.keys()].join(', ') || '(none)'}`,
316
+ `active chats: ${gw.chats.size}`,
317
+ modelLine,
318
+ homeLine,
319
+ `pairing pending: ${pending}`,
320
+ `transport: ${gw.tg().transport || 'poll'}`,
321
+ `sessionScope: ${gw.config.agent?.sessionScope || 'user'}`,
322
+ `delivered: ${gw.stats.sent}`,
323
+ `errors: ${gw.stats.errors}`,
324
+ `polling conflict: ${gw.tgAdapter?.pollingConflict ? 'yes' : 'no'}`,
325
+ `uptime: ${hh}:${mm}:${ss}`,
326
+ ].join('\n'))
327
+ }
328
+
329
+ if (cmd === '/model') {
330
+ if (parts.length >= 3) {
331
+ if (!gw.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
332
+ const provider = parts[1]
333
+ const model = parts.slice(2).join(' ')
334
+ try {
335
+ const adm = gw.ctx.get('agentDefaultModel')
336
+ if (adm?.saveSelection) {
337
+ await adm.saveSelection({ provider, model })
338
+ }
339
+ gw.config.agent = { ...gw.config.agent, provider, model }
340
+ try {
341
+ await gw.hooks?.persistAgentModel?.({ provider, model })
342
+ } catch (e) {
343
+ gw.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
344
+ }
345
+ return reply(t('model.switched', { provider, model }, locale))
346
+ } catch (e) {
347
+ return reply(`Failed to switch model: ${e.message}`)
348
+ }
349
+ }
350
+ try {
351
+ const current = gw.resolveAgentModel()
352
+ const catalog = await listModelCatalog(gw.ctx, current)
353
+ if (!catalog.providers.length) {
354
+ return reply(`Current model: <code>${current.provider}/${current.model}</code>\nSwitch: <code>/model &lt;provider&gt; &lt;model&gt;</code>`)
355
+ }
356
+ const kb = buildProvidersKeyboard(catalog.providers, current)
357
+ return reply([
358
+ t('model.title', {}, locale),
359
+ t('model.current', { current: `${current.provider}/${current.model}` }, locale),
360
+ ].join('\n'), {
361
+ replyMarkup: kb,
362
+ })
363
+ } catch (e) {
364
+ return reply(e.message)
365
+ }
366
+ }
367
+
368
+ if (cmd === '/pair') {
369
+ if (!gw.isUserAllowed(userId)) return reply('Only users in allowlist can approve /pair.')
370
+ const code = parts[1]
371
+ if (!code) return reply('Usage: /pair CODE')
372
+ const res = gw.pairing.approveCode(code, userId)
373
+ if (!res.ok) return reply(`Failed: ${res.error}`)
374
+ const merged = gw.effectiveAllowedIds()
375
+ for (const a of gw.adapterList) a.setAllowedUserIds?.(merged)
376
+ try { await gw.hooks?.persistAllowedUserIds?.(merged) } catch (e) {
377
+ gw.ctx.logger?.warn?.(`persist allowlist: ${e.message}`)
378
+ }
379
+ return reply(`Approved user ID ${res.userId}${res.username ? ` (@${res.username})` : ''}.`)
380
+ }
381
+
382
+ if (cmd === '/sethome') {
383
+ if (!gw.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
384
+ const name = normalizeHomeName(parts[1] || 'default') || 'default'
385
+ try {
386
+ const nextTg = upsertHome(gw.tg(), { name, chatId, threadId })
387
+ await gw.hooks?.persistHomes?.(nextTg)
388
+ gw.config.telegram = nextTg
389
+ return reply(`Home "${name}": chat ${chatId}${threadId ? ` topic ${threadId}` : ''}`)
390
+ } catch (e) {
391
+ return reply(`Failed to save home: ${e.message}`)
392
+ }
393
+ }
394
+
395
+ if (cmd === '/home') {
396
+ const homes = listHomes(gw.tg())
397
+ if (!homes.length) return reply('Home is not set. /sethome or /sethome <name>')
398
+ return reply(['Homes:', ...homes.map((h) => `• ${h.name}: chat ${h.chatId}${h.threadId ? ` topic ${h.threadId}` : ''}`)].join('\n'))
399
+ }
400
+
401
+ if (cmd === '/setalert') {
402
+ return handleSetAlertCommand(gw, input, locale)
403
+ }
404
+
405
+ if (cmd === '/alert') {
406
+ return handleAlertCommand(gw, input, parts)
407
+ }
408
+
409
+
410
+ if (cmd === '/remind') {
411
+ const sub = parts[1]?.toLowerCase()
412
+ if (sub === 'list') {
413
+ const active = await gw.scheduler.list(chatId)
414
+ if (!active.length) return reply('No active reminders for this chat.')
415
+ const lines = [
416
+ '⏰ <b>Active Reminders:</b>',
417
+ '',
418
+ ...active.map((tItem) => {
419
+ const left = formatRemaining(tItem.dueAt - Date.now(), locale)
420
+ return `• <code>${tItem.id}</code> (in ${left}): ${tItem.text}`
421
+ }),
422
+ '',
423
+ 'Cancel: <code>/remind cancel ID</code>',
424
+ ]
425
+ return reply(lines.join('\n'))
426
+ }
427
+ if (sub === 'cancel') {
428
+ const targetId = parts[2]?.trim()
429
+ if (!targetId) return reply('Specify reminder ID: <code>/remind cancel ID</code>')
430
+ const ok = await gw.scheduler.cancel(targetId, chatId)
431
+ return reply(ok ? `✅ Reminder <code>${targetId}</code> cancelled.` : `❌ Reminder <code>${targetId}</code> not found.`)
432
+ }
433
+ const timeArg = parts[1]
434
+ const textArg = parts.slice(2).join(' ').trim()
435
+ const delayMs = parseRelativeTime(timeArg)
436
+ if (!delayMs || !textArg) {
437
+ return reply([
438
+ '⏰ <b>Reminders:</b>',
439
+ 'Create: <code>/remind &lt;time&gt; &lt;text&gt;</code>',
440
+ 'Examples: <code>/remind 10m Call colleague</code>, <code>/remind 2h Check deploy</code>',
441
+ 'List: <code>/remind list</code>',
442
+ 'Cancel: <code>/remind cancel ID</code>',
443
+ ].join('\n'))
444
+ }
445
+ const dueAt = Date.now() + delayMs
446
+ const task = await gw.scheduler.schedule({
447
+ platform: platform || 'telegram',
448
+ chatId,
449
+ threadId: threadId || 0,
450
+ userId,
451
+ text: textArg,
452
+ dueAt,
453
+ })
454
+ const left = formatRemaining(delayMs, locale)
455
+ return reply(t('remind.scheduled', { time: new Date(dueAt).toLocaleTimeString(), duration: left, text: textArg }, locale))
456
+ }
457
+
458
+ if (cmd === '/voice') {
459
+ const sub = String(parts[1] || 'status').toLowerCase()
460
+ if (sub === 'summary') {
461
+ const val = parts[2]?.toLowerCase()
462
+ if (val === 'on' || val === 'off') {
463
+ if (!gw.config.tts) gw.config.tts = {}
464
+ gw.config.tts.voiceSummary = val === 'on'
465
+ return reply(`Voice summary (TL;DR): ${val === 'on' ? 'enabled' : 'disabled'}`)
466
+ }
467
+ const state = gw.config.tts?.voiceSummary ? 'on' : 'off'
468
+ return reply(`Voice summary (TL;DR): ${state}\nToggle: <code>/voice summary on|off</code>`)
469
+ }
470
+ if (sub === 'on' || sub === 'off') {
471
+ gw.voicePrefs.set(userId, sub === 'on')
472
+ return reply(sub === 'on' ? 'Voice replies: on (for you)' : 'Voice replies: off (for you)')
473
+ }
474
+ const pref = gw.voicePrefs.get(userId)
475
+ const mode = gw.tg().voiceMode || 'mirror'
476
+ const prefLine = pref === null ? 'not set (/voice on|off)' : (pref ? 'on' : 'off')
477
+ const summaryState = gw.config.tts?.voiceSummary ? 'on' : 'off'
478
+ return reply(`voiceMode=${mode}\nyour /voice: ${prefLine}\nglobal tts: ${gw.config.tts?.enabled ? 'on' : 'off'}\nvoice summary: ${summaryState}`)
479
+ }
480
+
481
+ if (cmd === '/topic') {
482
+ const topicName = parts.slice(1).join(' ').trim()
483
+ if (!topicName) {
484
+ return reply('Usage: <code>/topic &lt;name&gt;</code>\nCreates a new topic in supergroup with an isolated session.')
485
+ }
486
+ const tgAdapter = gw.getAdapter('telegram')
487
+ if (!tgAdapter?.createForumTopic) {
488
+ return reply('Topic creation is available only in Telegram.')
489
+ }
490
+ try {
491
+ const res = await tgAdapter.createForumTopic(chatId, topicName)
492
+ const newThreadId = res?.message_thread_id
493
+ await reply(`🎯 Created new topic <b>«${topicName}»</b> (ID: <code>${newThreadId}</code>).\nSwitch to the topic to continue working!`)
494
+ if (newThreadId) {
495
+ await tgAdapter.sendTo(chatId, {
496
+ text: `👋 Hello! This is an isolated session for task <b>«${topicName}»</b>.\nHow can I help?`,
497
+ }, { threadId: newThreadId })
498
+ }
499
+ return
500
+ } catch (err) {
501
+ return reply(`Failed to create topic: ${err.message}\n(Ensure the bot is group administrator with Manage Topics permission)`)
502
+ }
503
+ }
504
+
505
+ if (cmd === '/top') {
506
+ const mem = process.memoryUsage()
507
+ const rssMb = (mem.rss / 1024 / 1024).toFixed(1)
508
+ const heapMb = (mem.heapUsed / 1024 / 1024).toFixed(1)
509
+ const sec = Math.floor((Date.now() - gw.stats.startedAt) / 1000)
510
+ const hh = String(Math.floor(sec / 3600)).padStart(2, '0')
511
+ const mm = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
512
+ const ss = String(sec % 60).padStart(2, '0')
513
+ let activeReminders = 0
514
+ try { activeReminders = (await gw.scheduler.list()).length } catch (err) { gw.logger?.debug?.('scheduler.list error in /top:', err?.message || err) }
515
+ let currentModel = 'not set'
516
+ try {
517
+ const m = gw.resolveAgentModel()
518
+ currentModel = `${m.provider}/${m.model}`
519
+ } catch (err) {
520
+ gw.logger?.debug?.('resolveAgentModel error in /top:', err?.message || err)
521
+ }
522
+
523
+ return reply([
524
+ '📊 <b>DSH System & Resources:</b>',
525
+ `• <b>Memory (RSS):</b> ${rssMb} MB`,
526
+ `• <b>Heap:</b> ${heapMb} MB`,
527
+ `• <b>Uptime:</b> ${hh}:${mm}:${ss}`,
528
+ `• <b>Active chats:</b> ${gw.chats.size}`,
529
+ `• <b>Queued reminders:</b> ${activeReminders}`,
530
+ `• <b>Active model:</b> <code>${currentModel}</code>`,
531
+ `• <b>Messages sent:</b> ${gw.stats.sent}`,
532
+ `• <b>Errors:</b> ${gw.stats.errors}`,
533
+ ].join('\n'))
534
+ }
535
+
536
+ if (cmd === '/keyboard') {
537
+ const sub = String(parts[1] || '').toLowerCase()
538
+ const tgAdapter = gw.getAdapter('telegram')
539
+ if (sub === 'on') {
540
+ if (tgAdapter) tgAdapter.quickActions = true
541
+ return reply('Quick action keyboard enabled.', {
542
+ replyMarkup: buildQuickActionsKeyboard(),
543
+ })
544
+ }
545
+ if (sub === 'off') {
546
+ if (tgAdapter) tgAdapter.quickActions = false
547
+ return reply('Quick action keyboard disabled.', {
548
+ replyMarkup: REMOVE_REPLY_KEYBOARD,
549
+ })
550
+ }
551
+ const curState = tgAdapter?.quickActions ? 'enabled' : 'disabled'
552
+ return reply([
553
+ '⌨️ <b>Quick Action Keyboard:</b>',
554
+ `Current state: <b>${curState}</b>`,
555
+ '',
556
+ 'Commands:',
557
+ '<code>/keyboard on</code> — show buttons',
558
+ '<code>/keyboard off</code> — hide buttons',
559
+ ].join('\n'))
560
+ }
561
+
562
+ if (cmd === '/tts') {
563
+ const sub = String(parts[1] || 'status').toLowerCase()
564
+ if (sub === 'on' || sub === 'off') {
565
+ gw.chatTts.set(chatId, sub === 'on')
566
+ return reply(sub === 'on' ? 'Speech in this chat: on' : 'Speech in this chat: off')
567
+ }
568
+ const cur = gw.chatTts.get(chatId)
569
+ const line = cur === null ? 'not set (/tts on|off)' : (cur ? 'on' : 'off')
570
+ return reply(`Speech in this chat: ${line}\nglobal tts: ${gw.config.tts?.enabled ? 'on' : 'off'}`)
571
+ }
572
+
573
+ if (cmd === '/mute') {
574
+ gw.setMuted(chatId, true)
575
+ return reply(t('msg.muted_on', {}, locale))
576
+ }
577
+
578
+ if (cmd === '/unmute') {
579
+ gw.setMuted(chatId, false)
580
+ return reply(t('msg.muted_off', {}, locale))
581
+ }
582
+
583
+ return reply(t('msg.unknown_command', { cmd }, locale))
584
+ }