@goodandready/dsh-messenger-gateway 0.3.20 → 0.3.21

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
@@ -35,6 +35,7 @@ import {
35
35
  import { isTopicGoneError } from './telegram-errors.js'
36
36
  import { ensureContentArray } from './content-guard.js'
37
37
  import { mergeDynamicCommands } from './commands.js'
38
+ import { t } from './locales/index.js'
38
39
 
39
40
 
40
41
  function whenIdleWithTimeout(agent, timeoutMs, signal) {
@@ -78,18 +79,74 @@ export class Gateway {
78
79
  this.chatTts = createVoicePrefs(join(home, 'messenger-gateway', 'chat-tts.json'))
79
80
  this.muted = createVoicePrefs(join(home, 'messenger-gateway', 'muted.json'))
80
81
  this.personas = createPersonaStore(join(home, 'messenger-gateway', 'personas.json'))
82
+ this.chatLocales = createVoicePrefs(join(home, 'messenger-gateway', 'chat-locales.json'))
81
83
  this.scheduler = createScheduler(join(home, 'messenger-gateway', 'scheduled.json'), async (task) => {
82
84
  const target = {
83
85
  platform: task.platform || 'telegram',
84
86
  chatId: task.chatId,
85
87
  threadId: task.threadId || 0,
86
88
  }
87
- const text = `⏰ <b>[Напоминание]</b>\n${task.text}`
88
- await this.sendToMessenger(target, { text })
89
+ const locale = this.resolveLocale({ chatId: task.chatId })
90
+ if (task.prompt || task.action === 'prompt') {
91
+ const promptText = task.prompt || task.text
92
+ try {
93
+ await this.dispatchAutonomousPrompt(target, promptText, locale)
94
+ } catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err)
96
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: cron prompt error: ${msg}`)
97
+ await this.sendToMessenger(target, {
98
+ text: `⚠️ <b>[Cron Error]</b>\n${msg}`,
99
+ }).catch(() => {})
100
+ }
101
+ } else {
102
+ const text = t('remind.prefix', { text: task.text }, locale)
103
+ await this.sendToMessenger(target, { text })
104
+ }
89
105
  })
90
106
  this.stats = { sent: 0, errors: 0, startedAt: Date.now() }
91
107
  }
92
108
 
109
+ resolveLocale(input) {
110
+ if (input?.locale) return input.locale
111
+ const chatId = input?.chatId
112
+ if (chatId && this.chatLocales?.get(chatId)) return this.chatLocales.get(chatId)
113
+ if (input?.languageCode) {
114
+ const code = String(input.languageCode).toLowerCase()
115
+ if (code.startsWith('zh')) return 'zh'
116
+ if (code.startsWith('en')) return 'en'
117
+ }
118
+ return this.config?.defaultLocale || 'en'
119
+ }
120
+
121
+ async dispatchAutonomousPrompt(target, promptText, locale = 'en') {
122
+ const key = this.sessionKeyFor(target)
123
+ const reply = async (payload) => {
124
+ await this.sendToMessenger(target, typeof payload === 'string' ? { text: payload } : payload)
125
+ }
126
+ const input = {
127
+ platform: target.platform || 'telegram',
128
+ chatId: target.chatId,
129
+ threadId: target.threadId || 0,
130
+ text: promptText,
131
+ reply,
132
+ locale,
133
+ }
134
+ const chat = await this.getOrCreateChat(key, input)
135
+ const turnInput = { ...input, attachments: [], inboundWasVoice: false }
136
+ chat.turnActive = true
137
+ try { chat.abort?.abort?.() } catch {}
138
+ chat.abort = new AbortController()
139
+ const run = chat.busy.then(() => this.runTurn(chat, turnInput, chat.abort.signal))
140
+ chat.busy = run.catch(() => {})
141
+ run.catch((err) => {
142
+ const msg = err instanceof Error ? err.message : String(err)
143
+ this.ctx.logger?.warn?.(`dsh-messenger-gateway: cron turn: ${msg}`)
144
+ chat.turnActive = false
145
+ chat.abort = undefined
146
+ })
147
+ return run
148
+ }
149
+
93
150
  isMuted(chatId) { return this.muted.get(chatId) === true }
94
151
  setMuted(chatId, on) { return this.muted.set(chatId, on) }
95
152
 
@@ -300,23 +357,24 @@ export class Gateway {
300
357
 
301
358
  async handleUnauthorized(input) {
302
359
  const { reply, userId, username } = input
360
+ const locale = this.resolveLocale(input)
303
361
  if (!this.tg().pairingEnabled) {
304
- await reply('Доступ закрыт. Попросите владельца добавить ваш id в allowlist.')
362
+ await reply(t('msg.not_allowed', {}, locale))
305
363
  return
306
364
  }
307
365
  try {
308
366
  const { code } = this.pairing.requestCode(userId, { username })
309
- await reply(`Нет доступа.\nВаш id: ${userId}\nКод сопряжения: ${code}\n\nВладелец должен отправить боту:\n/pair ${code}`)
367
+ await reply(t('msg.pairing_requested', { userId, code }, locale))
310
368
  this.sendAlert('pairing', { userId, username, code }).catch(() => {})
311
369
  } catch (err) {
312
- if (err.code === 'RATE_LIMIT') await reply('Код уже выдан. Подождите или попросите владельца /pair.')
313
- else await reply(`Не удалось выдать код: ${err.message}`)
370
+ if (err.code === 'RATE_LIMIT') await reply(t('msg.pairing_rate_limit', {}, locale))
371
+ else await reply(t('msg.exception', { message: err.message }, locale))
314
372
  }
315
373
  }
316
374
 
317
375
  async handleCallback(cb) {
318
376
  if (cb.userId && !this.isUserAllowed(cb.userId)) {
319
- try { await cb.answer('Нет доступа.') } catch {}
377
+ try { await cb.answer(t('msg.not_allowed', {}, 'en')) } catch {}
320
378
  return
321
379
  }
322
380
  const indexed = this.callbackIndex.get(cb.data)
@@ -325,7 +383,7 @@ export class Gateway {
325
383
  if (askToken && this.pendingAsks.has(askToken)) {
326
384
  const pending = this.pendingAsks.get(askToken)
327
385
  if (!targetMatchesAsk(pending, cb)) {
328
- await cb.answer('Кнопка для другого чата')
386
+ await cb.answer(t('ask.other_chat', {}, 'en'))
329
387
  return
330
388
  }
331
389
  const action = parseAskCallback(cb.data)
@@ -339,9 +397,9 @@ export class Gateway {
339
397
  indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
340
398
  try {
341
399
  if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
342
- else await cb.editMessage(cb.message?.text || 'Выбор', nextKb.replyMarkup)
400
+ else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
343
401
  } catch {}
344
- await cb.answer(pending.selected.has(action.id) ? 'Выбрано' : 'Снято')
402
+ await cb.answer(pending.selected.has(action.id) ? 'Selected' : 'Deselected')
345
403
  return
346
404
  }
347
405
  if (action.kind === 'page') {
@@ -352,7 +410,7 @@ export class Gateway {
352
410
  indexCallbacks(this.callbackIndex, nextKb.callbackKeys, askToken)
353
411
  try {
354
412
  if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
355
- else await cb.editMessage(cb.message?.text || 'Выбор', nextKb.replyMarkup)
413
+ else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
356
414
  } catch {}
357
415
  await cb.answer()
358
416
  return
@@ -362,7 +420,7 @@ export class Gateway {
362
420
  clearTimeout(pending.timer)
363
421
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
364
422
  await cb.answer('OK')
365
- try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
423
+ try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch {}
366
424
  pending.resolve({ buttonId: 'done', selected: Array.from(pending.selected), data: cb.data })
367
425
  return
368
426
  }
@@ -370,8 +428,8 @@ export class Gateway {
370
428
  this.pendingAsks.delete(askToken)
371
429
  clearTimeout(pending.timer)
372
430
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
373
- await cb.answer('Отменено')
374
- try { await cb.editMessage(cb.message?.text || 'Отменено', REMOVE_KEYBOARD) } catch {}
431
+ await cb.answer('Cancelled')
432
+ try { await cb.editMessage(cb.message?.text || 'Cancelled', REMOVE_KEYBOARD) } catch {}
375
433
  pending.resolve({ buttonId: 'cancel', selected: [], data: cb.data })
376
434
  return
377
435
  }
@@ -380,7 +438,7 @@ export class Gateway {
380
438
  clearTimeout(pending.timer)
381
439
  releaseCallbacks(this.callbackIndex, pending.callbackKeys)
382
440
  await cb.answer('OK')
383
- try { await cb.editMessage(cb.message?.text || 'Выбрано', REMOVE_KEYBOARD) } catch {}
441
+ try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch {}
384
442
  pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
385
443
  return
386
444
  }
@@ -396,14 +454,14 @@ export class Gateway {
396
454
  const catalog = await listModelCatalog(this.ctx, current)
397
455
  const models = catalog.modelsByProvider.get(providerId) || []
398
456
  if (!models.length) {
399
- await cb.answer('Нет доступных моделей у этого провайдера')
457
+ await cb.answer(t('model.no_models', {}, 'en'))
400
458
  return
401
459
  }
402
460
  const kb = buildModelsKeyboard(providerId, models, current.model, 0)
403
461
  await cb.answer()
404
462
  const text = [
405
- `🤖 <b>Провайдер:</b> <code>${providerId}</code>`,
406
- `Выберите модель (страница ${kb.page + 1}/${kb.totalPages}):`,
463
+ `🤖 <b>Provider:</b> <code>${providerId}</code>`,
464
+ `Select model (page ${kb.page + 1}/${kb.totalPages}):`,
407
465
  ].join('\n')
408
466
  try {
409
467
  if (cb.editMessage) await cb.editMessage(text, kb)
@@ -420,8 +478,8 @@ export class Gateway {
420
478
  const kb = buildModelsKeyboard(providerId, models, current.model, page)
421
479
  await cb.answer()
422
480
  const text = [
423
- `🤖 <b>Провайдер:</b> <code>${providerId}</code>`,
424
- `Выберите модель (страница ${kb.page + 1}/${kb.totalPages}):`,
481
+ `🤖 <b>Provider:</b> <code>${providerId}</code>`,
482
+ `Select model (page ${kb.page + 1}/${kb.totalPages}):`,
425
483
  ].join('\n')
426
484
  try {
427
485
  if (cb.editMessage) await cb.editMessage(text, kb)
@@ -435,8 +493,8 @@ export class Gateway {
435
493
  const kb = buildProvidersKeyboard(catalog.providers, current)
436
494
  await cb.answer()
437
495
  const text = [
438
- '🤖 <b>Выберите провайдера:</b>',
439
- `Текущая: <code>${current.provider}/${current.model}</code>`,
496
+ '🤖 <b>Choose Provider:</b>',
497
+ `Current: <code>${current.provider}/${current.model}</code>`,
440
498
  ].join('\n')
441
499
  try {
442
500
  if (cb.editMessage) await cb.editMessage(text, kb)
@@ -448,7 +506,7 @@ export class Gateway {
448
506
  const key = parts[2]
449
507
  const stored = getStoredModelSelection(key)
450
508
  if (!stored) {
451
- await cb.answer('Сессия выбора модели истекла, вызовите /model заново')
509
+ await cb.answer(t('ask.expired', {}, 'en'))
452
510
  return
453
511
  }
454
512
  const { provider, model } = stored
@@ -463,12 +521,12 @@ export class Gateway {
463
521
  } catch (e) {
464
522
  this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
465
523
  }
466
- await cb.answer(`Выбрана ${model}`)
524
+ await cb.answer(t('ask.chose', { choice: model }, 'en'))
467
525
  try {
468
- if (cb.editMessage) await cb.editMessage(`✅ Модель успешно переключена на: <b>${provider}/${model}</b>`, REMOVE_KEYBOARD)
526
+ if (cb.editMessage) await cb.editMessage(t('model.switched', { provider, model }, 'en'), REMOVE_KEYBOARD)
469
527
  } catch {}
470
528
  } catch (err) {
471
- await cb.answer(`Ошибка: ${err.message}`)
529
+ await cb.answer(`Error: ${err.message}`)
472
530
  }
473
531
  return
474
532
  }
@@ -512,9 +570,10 @@ export class Gateway {
512
570
  if (incomingPhotoOnly && photoOnlyMode === 'prompt') {
513
571
  chat.pendingMedia = [...(chat.pendingMedia || []), ...attachments]
514
572
  const n = chat.pendingMedia.length
573
+ const locale = this.resolveLocale(input)
515
574
  const msg = n === 1
516
- ? 'Медиа получено. Напишите вопрос — например: «что на фото?»'
517
- : `Получено ${n} вложений. Напишите вопрос.`
575
+ ? t('photo.received_one', {}, locale)
576
+ : t('photo.received_many', { count: n }, locale)
518
577
  return reply(msg)
519
578
  }
520
579
  if (chat.pendingMedia?.length) {
@@ -535,7 +594,7 @@ export class Gateway {
535
594
 
536
595
  // Hermes-like steer: while a turn is running, inject followup instead of abort+restart
537
596
  if (this.isChatBusy(chat)) {
538
- const steerText = body || (hasMedia ? '(дополнение: медиа)' : '')
597
+ const steerText = body || (hasMedia ? '(steer: media)' : '')
539
598
  const content = await this.buildUserContent({
540
599
  ...turnInput,
541
600
  text: steerText,
@@ -546,7 +605,7 @@ export class Gateway {
546
605
  source: { kind: 'user', plugin: PLUGIN, form: 'steer', origin: 'telegram' },
547
606
  }))
548
607
  chat.lastUsed = Date.now()
549
- try { await reply('↪️ Добавлено к текущему ответу') } catch {}
608
+ try { await reply(t('msg.steer_added', {}, this.resolveLocale(input))) } catch {}
550
609
  return
551
610
  }
552
611
 
@@ -573,103 +632,194 @@ export class Gateway {
573
632
  } catch (err) {
574
633
  const msg = err instanceof Error ? err.message : String(err)
575
634
  this.ctx.logger?.warn?.(`dsh-messenger-gateway: message: ${msg}`)
576
- try { await reply(`Ошибка: ${msg}`) } catch {}
635
+ try { await reply(t('msg.exception', { message: msg }, this.resolveLocale(input))) } catch {}
577
636
  }
578
637
  }
579
638
 
580
639
  async handleCommand(key, text, input) {
581
-
582
640
  const parts = text.split(/\s+/)
583
641
  const cmd = parts[0].toLowerCase().split('@')[0]
584
642
  const { reply, userId, chatId, threadId = 0, platform } = input
585
- if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.', { replyMarkup: REMOVE_REPLY_KEYBOARD })
643
+ const locale = this.resolveLocale(input)
644
+
645
+ if (cmd === '/start') {
646
+ return reply(t('msg.start', {}, locale), { replyMarkup: REMOVE_REPLY_KEYBOARD })
647
+ }
648
+
586
649
  if (cmd === '/help') {
587
650
  return reply([
588
- '📖 <b>Команды бота:</b>',
651
+ '📖 <b>Messenger Gateway Help:</b>',
589
652
  '',
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',
653
+ '💬 <b>Session & Chat:</b>',
654
+ '• /help — show this command reference',
655
+ '• /new — start fresh session',
656
+ '• /stop — interrupt current response',
657
+ '• /model — interactive model selector (/model list)',
658
+ '• /role [name] — switch persona or role (/role list)',
659
+ '• /bind [role] — bind persona to topic / chat',
660
+ '• /preset [name] bind preset to topic / chat',
661
+ '• /lang [en|zh] switch user language',
662
+ '• /rewind [N] — rewind last N turns',
663
+ '• /fork — fork session into new branch',
664
+ '• /export — export history to Markdown',
599
665
  '',
600
- '🛠️ <b>Инструменты и файлы:</b>',
601
- '• /skills / /tools — список активных инструментов',
602
- '• /files [dir] — проводник рабочей папки',
603
- '• /get <path> — скачать файл в Telegram',
604
- '• /remind <время> <текст>напоминание (/remind 10m текст)',
666
+ '🛠️ <b>Tools, Files & Cron:</b>',
667
+ '• /skills / /tools — list active tools & skills',
668
+ '• /files [dir] — workspace file explorer',
669
+ '• /get <path> — download file from workspace',
670
+ '• /remind <time> <text>set reminder (/remind 10m check deploy)',
671
+ '• /cron <interval> <prompt> — recurring autonomous task',
605
672
  '',
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 — заглушить уведомления в этот чат',
673
+ '⚙️ <b>Settings & Stats:</b>',
674
+ '• /status — gateway and active model status',
675
+ '• /top — system resource usage (RAM, uptime)',
676
+ '• /keyboard on|off — quick action keyboard',
677
+ '• /voice on|off|status — voice replies preference',
678
+ '• /tts on|off|status — speech synthesis in this chat',
679
+ '• /mute / /unmute — mute notifications in this chat',
613
680
  '',
614
- '🔒 <b>Доступ и каналы:</b>',
615
- '• /whoami — ваш Telegram id',
616
- '• /pair CODE — одобрить код сопряжения',
617
- '• /sethome [name] — привязать домашний чат',
618
- '• /setalert — назначить канал алертов',
681
+ '🔒 <b>Access & Channels:</b>',
682
+ '• /whoami — your messenger user ID',
683
+ '• /pair CODE — approve pairing code',
684
+ '• /sethome [name] — set home notification channel',
685
+ '• /setalert — set alert channel',
619
686
  ].join('\n'))
620
687
  }
688
+
689
+ if (cmd === '/lang' || cmd === '/language') {
690
+ const sub = parts[1]?.toLowerCase()
691
+ if (sub === 'en' || sub === 'zh') {
692
+ this.chatLocales.set(chatId, sub)
693
+ return reply(sub === 'zh' ? '语言已切换为中文 (zh)' : 'Language switched to English (en)')
694
+ }
695
+ const cur = this.chatLocales.get(chatId) || this.config?.defaultLocale || 'en'
696
+ return reply(`Current language: <b>${cur}</b>\nSwitch: <code>/lang en</code> or <code>/lang zh</code>`)
697
+ }
698
+
699
+ if (cmd === '/bind') {
700
+ const targetRole = parts[1]?.toLowerCase()
701
+ if (!targetRole || targetRole === 'list') {
702
+ const cur = this.personas.getPersonaForChat(chatId, threadId)
703
+ return reply(`${t('persona.title', {}, locale)}\nCurrent bound role: <b>${cur}</b>\nUsage: <code>/bind &lt;role&gt;</code> (or /bind reset)`)
704
+ }
705
+ if (targetRole === 'reset' || targetRole === 'default') {
706
+ this.personas.set(chatId, 'default', threadId)
707
+ return reply(t('persona.reset', {}, locale))
708
+ }
709
+ const persona = getPersona(targetRole)
710
+ if (!persona) return reply(t('persona.unknown', { target: targetRole }, locale))
711
+ this.personas.set(chatId, targetRole, threadId)
712
+ return reply(t('persona.bound_topic', { kind: 'role', name: `${persona.icon} ${persona.name}` }, locale))
713
+ }
714
+
715
+ if (cmd === '/preset') {
716
+ const presetName = parts[1]
717
+ if (!presetName || presetName === 'list') {
718
+ const cur = this.personas.getPreset(chatId, threadId) || '(none)'
719
+ return reply(`🎭 <b>Presets:</b>\nCurrent topic preset: <code>${cur}</code>\nUsage: <code>/preset &lt;name&gt;</code> or <code>/preset reset</code>`)
720
+ }
721
+ if (presetName === 'reset' || presetName === 'clear') {
722
+ this.personas.setPreset(chatId, threadId, null)
723
+ return reply('Preset cleared for this topic.')
724
+ }
725
+ this.personas.setPreset(chatId, threadId, presetName)
726
+ return reply(t('persona.bound_topic', { kind: 'preset', name: presetName }, locale))
727
+ }
728
+
729
+ if (cmd === '/cron') {
730
+ const sub = parts[1]?.toLowerCase()
731
+ if (sub === 'list') {
732
+ const list = await this.scheduler.listRecurring(chatId)
733
+ if (!list.length) return reply(t('cron.none', {}, locale))
734
+ const lines = [t('cron.list_title', {}, locale)]
735
+ for (const task of list) {
736
+ const left = formatRemaining(task.dueAt - Date.now(), locale)
737
+ lines.push(`• <code>${task.id}</code> (every ${formatRemaining(task.intervalMs, locale)}, next in ${left}): ${task.prompt || task.text}`)
738
+ }
739
+ lines.push('\nCancel: <code>/cron cancel ID</code>')
740
+ return reply(lines.join('\n'))
741
+ }
742
+ if (sub === 'cancel') {
743
+ const targetId = parts[2]
744
+ if (!targetId) return reply('Specify cron task ID: <code>/cron cancel ID</code>')
745
+ const ok = await this.scheduler.cancel(targetId, chatId)
746
+ return reply(ok ? t('cron.cancelled', { id: targetId }, locale) : `Task not found: <code>${targetId}</code>`)
747
+ }
748
+ const specArg = parts[1]
749
+ const promptArg = parts.slice(2).join(' ')
750
+ const ms = parseRelativeTime(specArg)
751
+ if (!ms || !promptArg) {
752
+ 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>')
753
+ }
754
+ const task = await this.scheduler.schedule({
755
+ platform,
756
+ chatId,
757
+ threadId,
758
+ userId,
759
+ text: promptArg,
760
+ prompt: promptArg,
761
+ dueAt: Date.now() + ms,
762
+ recurring: true,
763
+ intervalMs: ms,
764
+ })
765
+ return reply(t('cron.scheduled', { id: task.id, schedule: specArg, prompt: promptArg }, locale))
766
+ }
767
+
621
768
  if (cmd === '/role' || cmd === '/persona') {
622
769
  const targetRole = parts[1]?.toLowerCase()
623
770
  if (!targetRole || targetRole === 'list') {
624
- const currentId = this.personas.get(chatId)
771
+ const currentId = this.personas.getPersonaForChat(chatId, threadId)
625
772
  const lines = [
626
- '🎭 <b>Доступные роли и персоны:</b>',
773
+ t('persona.title', {}, locale),
627
774
  '',
628
775
  ...listPersonas().map((p) => {
629
- const isCurrent = p.id === currentId ? ' (активна)' : ''
776
+ const isCurrent = p.id === currentId ? ' (active)' : ''
630
777
  return `${p.icon} <b>${p.id}</b> — ${p.name}: ${p.description}${isCurrent}`
631
778
  }),
632
779
  '',
633
- 'Смена роли: <code>/role coder</code> (или /role reset)',
780
+ t('persona.usage', {}, locale),
634
781
  ]
635
782
  return reply(lines.join('\n'))
636
783
  }
637
784
  if (targetRole === 'reset' || targetRole === 'default') {
638
- this.personas.set(chatId, 'default')
639
- return reply('🎭 Роль сброшена на стандартную (Default).')
785
+ this.personas.set(chatId, 'default', threadId)
786
+ return reply(t('persona.reset', {}, locale))
640
787
  }
641
788
  const persona = getPersona(targetRole)
642
789
  if (!persona) {
643
- return reply(`Неизвестная роль "${targetRole}". Список: /role list`)
790
+ return reply(t('persona.unknown', { target: targetRole }, locale))
644
791
  }
645
- this.personas.set(chatId, persona.id)
646
- return reply(`🎭 Роль изменена на: ${persona.icon} <b>${persona.name}</b>\n${persona.description}`)
792
+ this.personas.set(chatId, persona.id, threadId)
793
+ return reply(t('persona.switched', { icon: persona.icon, name: persona.name, description: persona.description }, locale))
647
794
  }
795
+
648
796
  if (cmd === '/skills' || cmd === '/tools') {
649
797
  const tools = this.ctx.get?.('tools') || this.ctx.tools
798
+ const toolsList = []
650
799
  if (tools?.tools) {
651
- for (const [name, t] of tools.tools.entries()) {
652
- toolsList.push(`• <b>${name}</b>: ${t.description || '(нет описания)'}`)
800
+ for (const [name, tDef] of tools.tools.entries()) {
801
+ toolsList.push(`• <b>${name}</b>: ${tDef.description || '(no description)'}`)
653
802
  }
654
803
  }
655
804
  if (!toolsList.length) {
656
- return reply('🛠️ <b>Инструменты агента:</b>\n(нет зарегистрированных инструментов)')
805
+ return reply('🛠️ <b>Agent Tools:</b>\n(no tools registered)')
657
806
  }
658
807
  return reply([
659
- '🛠️ <b>Активные инструменты и скиллы:</b>',
808
+ '🛠️ <b>Active Tools & Skills:</b>',
660
809
  '',
661
810
  ...toolsList,
662
811
  ].join('\n'))
663
812
  }
813
+
664
814
  if (cmd === '/export') {
665
815
  const chat = this.chats.get(key)
666
816
  if (!chat?.agent?.session) {
667
- return reply('Нет активной сессии для экспорта.')
817
+ return reply(t('msg.no_active_session', {}, locale))
668
818
  }
669
819
  try {
670
820
  const { filename, buffer, messagesCount } = exportSessionToMarkdown(chat.agent.session)
671
821
  if (!messagesCount) {
672
- return reply('Сессия пуста, нет сообщений для экспорта.')
822
+ return reply(t('export.empty', {}, locale))
673
823
  }
674
824
  const file = {
675
825
  name: filename,
@@ -677,29 +827,31 @@ export class Gateway {
677
827
  kind: 'document',
678
828
  bytes: buffer,
679
829
  }
680
- return reply({ text: `📄 Экспорт диалога (${messagesCount} сообщений):`, files: [file] })
830
+ return reply({ text: t('export.title', { count: messagesCount }, locale), files: [file] })
681
831
  } catch (err) {
682
- return reply(`Ошибка экспорта: ${err.message}`)
832
+ return reply(`Export error: ${err.message}`)
683
833
  }
684
834
  }
835
+
685
836
  if (cmd === '/rewind') {
686
837
  const chat = this.chats.get(key)
687
838
  if (!chat?.agent?.session) {
688
- return reply('Нет активной сессии для отката.')
839
+ return reply(t('msg.no_active_session', {}, locale))
689
840
  }
690
841
  const count = Number(parts[1]) || 1
691
842
  const res = rewindSession(chat.agent.session, count)
692
843
  if (!res.removed) {
693
- return reply('В истории сессии нет сообщений для отката.')
844
+ return reply('No turns to rewind in session history.')
694
845
  }
695
846
  const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
696
847
  try { await sessions?.flush(chat.agent.session) } catch {}
697
- return reply(`⏪ Откачено сообщений: ${res.removed}. Осталось в контексте: ${res.remaining}.`)
848
+ return reply(`⏪ Rewound ${res.removed} messages. Remaining in context: ${res.remaining}.`)
698
849
  }
850
+
699
851
  if (cmd === '/fork') {
700
852
  const chat = this.chats.get(key)
701
853
  if (!chat?.agent?.session) {
702
- return reply('Нет активной сессии для форка.')
854
+ return reply(t('msg.no_active_session', {}, locale))
703
855
  }
704
856
  try {
705
857
  const oldSession = chat.agent.session
@@ -713,11 +865,12 @@ export class Gateway {
713
865
  try { await sessions?.flush(newChat.agent.session) } catch {}
714
866
  }
715
867
  this.chats.set(key, newChat)
716
- return reply(`🔀 Создан форк сессии!\nСтарая сессия: ${oldSession.id}\nНовая активная сессия: ${newChat.agent.session.id}\nКонтекст сохранён (${oldMessages.length} сообщений).`)
868
+ return reply(`🔀 Forked session!\nOld session: ${oldSession.id}\nNew session: ${newChat.agent.session.id}\nContext preserved (${oldMessages.length} messages).`)
717
869
  } catch (err) {
718
- return reply(`Ошибка форка: ${err.message}`)
870
+ return reply(`Fork error: ${err.message}`)
719
871
  }
720
872
  }
873
+
721
874
  if (cmd === '/files') {
722
875
  const subPath = parts.slice(1).join(' ').trim() || '.'
723
876
  const agentCwd = this.config.agent?.cwd || process.cwd()
@@ -725,10 +878,11 @@ export class Gateway {
725
878
  if (!res.ok) return reply(`❌ ${res.error}`)
726
879
  return reply(res.formattedText)
727
880
  }
881
+
728
882
  if (cmd === '/get') {
729
883
  const targetRel = parts.slice(1).join(' ').trim()
730
884
  if (!targetRel) {
731
- return reply('Укажите имя файла для скачивания: <code>/get <файл></code>\nСписок: <code>/files</code>')
885
+ return reply('Specify file path to download: <code>/get &lt;path&gt;</code>\nBrowse: <code>/files</code>')
732
886
  }
733
887
  const agentCwd = this.config.agent?.cwd || process.cwd()
734
888
  const maxDocBytes = Number(this.config.media?.maxDocBytes) || 50 * 1024 * 1024
@@ -741,8 +895,9 @@ export class Gateway {
741
895
  bytes: res.bytes,
742
896
  dataBase64: res.bytes.toString('base64'),
743
897
  }
744
- return reply({ text: `📄 Файл: <b>${res.name}</b> (${formatFileSize(res.size)})`, files: [file] })
898
+ return reply({ text: `📄 File: <b>${res.name}</b> (${formatFileSize(res.size)})`, files: [file] })
745
899
  }
900
+
746
901
  if (cmd === '/new') {
747
902
  const chat = this.chats.get(key)
748
903
  if (chat) {
@@ -751,38 +906,41 @@ export class Gateway {
751
906
  this.sessionToChat.delete(String(chat.agent.session.id))
752
907
  this.chats.delete(key)
753
908
  await chat.dispose()
754
- return reply('Сессия сброшена.')
909
+ return reply('Session reset.')
755
910
  }
756
- return reply('Активной сессии нет.')
911
+ return reply(t('msg.no_active_session', {}, locale))
757
912
  }
913
+
758
914
  if (cmd === '/whoami') {
759
- const lines = [`Ваш id: ${userId}`]
915
+ const lines = [`User ID: ${userId}`]
760
916
  if (chatId) lines.push(`chatId: ${chatId}`)
761
917
  if (threadId) lines.push(`threadId: ${threadId}`)
762
918
  return reply(lines.join('\n'))
763
919
  }
920
+
764
921
  if (cmd === '/stop') {
765
922
  const chat = this.chats.get(key)
766
923
  if (chat?.turnActive || chat?.abort) {
767
924
  try { chat.abort?.abort() } catch {}
768
925
  releaseChatTurn(chat)
769
926
  chat.turnActive = false
770
- return reply('Прерывание отправлено.')
927
+ return reply(t('msg.turn_stopped', {}, locale))
771
928
  }
772
- return reply('Нечего прерывать.')
929
+ return reply('Nothing to stop.')
773
930
  }
931
+
774
932
  if (cmd === '/status') {
775
- let modelLine = 'модель: (не задана)'
933
+ let modelLine = 'model: (not set)'
776
934
  try {
777
935
  const sel = this.resolveAgentModel()
778
- modelLine = `модель: ${sel.provider}/${sel.model}`
936
+ modelLine = `model: ${sel.provider}/${sel.model}`
779
937
  } catch (e) {
780
- modelLine = `модель: ${e.message}`
938
+ modelLine = `model: ${e.message}`
781
939
  }
782
940
  const home = this.resolveHomeTarget(platform || 'telegram')
783
941
  const homeLine = home
784
942
  ? `home: chat ${home.chatId}${home.threadId ? ` topic ${home.threadId}` : ''}`
785
- : 'home: не задан'
943
+ : 'home: (not set)'
786
944
  const pending = this.pairing.listPending().length
787
945
  const up = Math.max(0, Math.round((Date.now() - this.stats.startedAt) / 1000))
788
946
  const hh = String(Math.floor(up / 3600)).padStart(2, '0')
@@ -790,22 +948,23 @@ export class Gateway {
790
948
  const ss = String(up % 60).padStart(2, '0')
791
949
  return reply([
792
950
  'Messenger gateway',
793
- `адаптеры: ${[...this.adapters.keys()].join(', ') || '(нет)'}`,
794
- `активных чатов: ${this.chats.size}`,
951
+ `adapters: ${[...this.adapters.keys()].join(', ') || '(none)'}`,
952
+ `active chats: ${this.chats.size}`,
795
953
  modelLine,
796
954
  homeLine,
797
955
  `pairing pending: ${pending}`,
798
956
  `transport: ${this.tg().transport || 'poll'}`,
799
957
  `sessionScope: ${this.config.agent?.sessionScope || 'user'}`,
800
- `доставлено: ${this.stats.sent}`,
801
- `ошибок: ${this.stats.errors}`,
802
- `polling conflict: ${this.tgAdapter?.pollingConflict ? 'да' : 'нет'}`,
958
+ `delivered: ${this.stats.sent}`,
959
+ `errors: ${this.stats.errors}`,
960
+ `polling conflict: ${this.tgAdapter?.pollingConflict ? 'yes' : 'no'}`,
803
961
  `uptime: ${hh}:${mm}:${ss}`,
804
962
  ].join('\n'))
805
963
  }
964
+
806
965
  if (cmd === '/model') {
807
966
  if (parts.length >= 3) {
808
- if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
967
+ if (!this.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
809
968
  const provider = parts[1]
810
969
  const model = parts.slice(2).join(' ')
811
970
  try {
@@ -819,21 +978,21 @@ export class Gateway {
819
978
  } catch (e) {
820
979
  this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
821
980
  }
822
- return reply(`Модель сохранена: ${provider}/${model}`)
981
+ return reply(t('model.switched', { provider, model }, locale))
823
982
  } catch (e) {
824
- return reply(`Не удалось сменить модель: ${e.message}`)
983
+ return reply(`Failed to switch model: ${e.message}`)
825
984
  }
826
985
  }
827
986
  try {
828
987
  const current = this.resolveAgentModel()
829
988
  const catalog = await listModelCatalog(this.ctx, current)
830
989
  if (!catalog.providers.length) {
831
- return reply(`Текущая модель: <code>${current.provider}/${current.model}</code>\nСмена: <code>/model &lt;provider&gt; &lt;model&gt;</code>`)
990
+ return reply(`Current model: <code>${current.provider}/${current.model}</code>\nSwitch: <code>/model &lt;provider&gt; &lt;model&gt;</code>`)
832
991
  }
833
992
  const kb = buildProvidersKeyboard(catalog.providers, current)
834
993
  return reply([
835
- '🤖 <b>Выберите провайдера:</b>',
836
- `Текущая модель: <code>${current.provider}/${current.model}</code>`,
994
+ t('model.title', {}, locale),
995
+ t('model.current', { current: `${current.provider}/${current.model}` }, locale),
837
996
  ].join('\n'), {
838
997
  replyMarkup: kb,
839
998
  })
@@ -841,38 +1000,42 @@ export class Gateway {
841
1000
  return reply(e.message)
842
1001
  }
843
1002
  }
1003
+
844
1004
  if (cmd === '/pair') {
845
- if (!this.isUserAllowed(userId)) return reply('Только пользователи из allowlist могут одобрять /pair.')
1005
+ if (!this.isUserAllowed(userId)) return reply('Only users in allowlist can approve /pair.')
846
1006
  const code = parts[1]
847
- if (!code) return reply('Использование: /pair CODE')
1007
+ if (!code) return reply('Usage: /pair CODE')
848
1008
  const res = this.pairing.approveCode(code, userId)
849
- if (!res.ok) return reply(`Не удалось: ${res.error}`)
1009
+ if (!res.ok) return reply(`Failed: ${res.error}`)
850
1010
  const merged = this.effectiveAllowedIds()
851
1011
  for (const a of this.adapterList) a.setAllowedUserIds?.(merged)
852
1012
  try { await this.hooks?.persistAllowedUserIds?.(merged) } catch (e) {
853
1013
  this.ctx.logger?.warn?.(`persist allowlist: ${e.message}`)
854
1014
  }
855
- return reply(`Одобрен user id ${res.userId}${res.username ? ` (@${res.username})` : ''}.`)
1015
+ return reply(`Approved user ID ${res.userId}${res.username ? ` (@${res.username})` : ''}.`)
856
1016
  }
1017
+
857
1018
  if (cmd === '/sethome') {
858
- if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
1019
+ if (!this.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
859
1020
  const name = normalizeHomeName(parts[1] || 'default') || 'default'
860
1021
  try {
861
1022
  const nextTg = upsertHome(this.tg(), { name, chatId, threadId })
862
1023
  await this.hooks?.persistHomes?.(nextTg)
863
1024
  this.config.telegram = nextTg
864
- return reply(`Home «${name}»: chat ${chatId}${threadId ? ` topic ${threadId}` : ''}`)
1025
+ return reply(`Home "${name}": chat ${chatId}${threadId ? ` topic ${threadId}` : ''}`)
865
1026
  } catch (e) {
866
- return reply(`Не удалось сохранить home: ${e.message}`)
1027
+ return reply(`Failed to save home: ${e.message}`)
867
1028
  }
868
1029
  }
1030
+
869
1031
  if (cmd === '/home') {
870
1032
  const homes = listHomes(this.tg())
871
- if (!homes.length) return reply('Home не задан. /sethome или /sethome name')
1033
+ if (!homes.length) return reply('Home is not set. /sethome or /sethome <name>')
872
1034
  return reply(['Homes:', ...homes.map((h) => `• ${h.name}: chat ${h.chatId}${h.threadId ? ` topic ${h.threadId}` : ''}`)].join('\n'))
873
1035
  }
1036
+
874
1037
  if (cmd === '/setalert') {
875
- if (!this.isUserAllowed(userId)) return reply('Нет доступа.')
1038
+ if (!this.isUserAllowed(userId)) return reply(t('msg.not_allowed', {}, locale))
876
1039
  const nextTg = {
877
1040
  ...this.tg(),
878
1041
  alerts: {
@@ -884,62 +1047,64 @@ export class Gateway {
884
1047
  }
885
1048
  this.config.telegram = nextTg
886
1049
  try { await this.hooks?.persistHomes?.(nextTg) } catch {}
887
- return reply(`🔔 Этот чат назначен каналом алертов (chat: ${chatId}${threadId ? `, topic: ${threadId}` : ''}).`)
1050
+ return reply(`🔔 This chat assigned as alert channel (chat: ${chatId}${threadId ? `, topic: ${threadId}` : ''}).`)
888
1051
  }
1052
+
889
1053
  if (cmd === '/alert') {
890
1054
  const sub = parts[1]?.toLowerCase()
891
1055
  if (sub === 'test') {
892
1056
  const target = resolveAlertTarget(this)
893
- if (!target) return reply('Канал алертов не настроен или выключен. Назначить: /setalert')
894
- await this.sendAlert('status', { title: 'Тестовый алерт', details: `Отправлен пользователем id ${userId}` })
895
- return reply('Тестовый алерт отправлен в канал алертов.')
1057
+ if (!target) return reply('Alert channel not configured. Configure: /setalert')
1058
+ await this.sendAlert('status', { title: 'Test Alert', details: `Sent by user ID ${userId}` })
1059
+ return reply('Test alert sent to alert channel.')
896
1060
  }
897
1061
  const target = resolveAlertTarget(this)
898
1062
  const alertsCfg = this.tg().alerts || {}
899
1063
  return reply([
900
- '🔔 <b>Канал алертов:</b>',
901
- `Статус: ${alertsCfg.enabled ? 'включен' : 'выключен'}`,
902
- `Чат: ${target ? `${target.chatId}${target.threadId ? ` (topic: ${target.threadId})` : ''}` : '(не назначен)'}`,
903
- `События: ${(alertsCfg.events || ['error', 'pairing']).join(', ')}`,
1064
+ '🔔 <b>Alert Channel:</b>',
1065
+ `Status: ${alertsCfg.enabled ? 'enabled' : 'disabled'}`,
1066
+ `Chat: ${target ? `${target.chatId}${target.threadId ? ` (topic: ${target.threadId})` : ''}` : '(not assigned)'}`,
1067
+ `Events: ${(alertsCfg.events || ['error', 'pairing']).join(', ')}`,
904
1068
  '',
905
- 'Команды:',
906
- '/setalert — назначить текущий чат каналом алертов',
907
- '/alert test — отправить тестовый алерт',
1069
+ 'Commands:',
1070
+ '/setalert — assign current chat as alert channel',
1071
+ '/alert test — send test alert',
908
1072
  ].join('\n'))
909
1073
  }
1074
+
910
1075
  if (cmd === '/remind') {
911
1076
  const sub = parts[1]?.toLowerCase()
912
1077
  if (sub === 'list') {
913
1078
  const active = await this.scheduler.list(chatId)
914
- if (!active.length) return reply('Нет активных напоминаний для этого чата.')
1079
+ if (!active.length) return reply('No active reminders for this chat.')
915
1080
  const lines = [
916
- '⏰ <b>Активные напоминания:</b>',
1081
+ '⏰ <b>Active Reminders:</b>',
917
1082
  '',
918
- ...active.map((t) => {
919
- const left = formatRemaining(t.dueAt - Date.now())
920
- return `• <code>${t.id}</code> (через ${left}): ${t.text}`
1083
+ ...active.map((tItem) => {
1084
+ const left = formatRemaining(tItem.dueAt - Date.now(), locale)
1085
+ return `• <code>${tItem.id}</code> (in ${left}): ${tItem.text}`
921
1086
  }),
922
1087
  '',
923
- 'Отмена: <code>/remind cancel ID</code>',
1088
+ 'Cancel: <code>/remind cancel ID</code>',
924
1089
  ]
925
1090
  return reply(lines.join('\n'))
926
1091
  }
927
1092
  if (sub === 'cancel') {
928
1093
  const targetId = parts[2]?.trim()
929
- if (!targetId) return reply('Укажите ID напоминания: <code>/remind cancel ID</code>')
1094
+ if (!targetId) return reply('Specify reminder ID: <code>/remind cancel ID</code>')
930
1095
  const ok = await this.scheduler.cancel(targetId, chatId)
931
- return reply(ok ? `✅ Напоминание <code>${targetId}</code> отменено.` : `❌ Напоминание с ID <code>${targetId}</code> не найдено.`)
1096
+ return reply(ok ? `✅ Reminder <code>${targetId}</code> cancelled.` : `❌ Reminder <code>${targetId}</code> not found.`)
932
1097
  }
933
1098
  const timeArg = parts[1]
934
1099
  const textArg = parts.slice(2).join(' ').trim()
935
1100
  const delayMs = parseRelativeTime(timeArg)
936
1101
  if (!delayMs || !textArg) {
937
1102
  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>',
1103
+ '⏰ <b>Reminders:</b>',
1104
+ 'Create: <code>/remind &lt;time&gt; &lt;text&gt;</code>',
1105
+ 'Examples: <code>/remind 10m Call colleague</code>, <code>/remind 2h Check deploy</code>',
1106
+ 'List: <code>/remind list</code>',
1107
+ 'Cancel: <code>/remind cancel ID</code>',
943
1108
  ].join('\n'))
944
1109
  }
945
1110
  const dueAt = Date.now() + delayMs
@@ -951,9 +1116,10 @@ export class Gateway {
951
1116
  text: textArg,
952
1117
  dueAt,
953
1118
  })
954
- const left = formatRemaining(delayMs)
955
- return reply(`⏰ Напоминание установлено на <b>через ${left}</b> (ID: <code>${task.id}</code>):\n«${textArg}»`)
1119
+ const left = formatRemaining(delayMs, locale)
1120
+ return reply(t('remind.scheduled', { time: new Date(dueAt).toLocaleTimeString(), duration: left, text: textArg }, locale))
956
1121
  }
1122
+
957
1123
  if (cmd === '/voice') {
958
1124
  const sub = String(parts[1] || 'status').toLowerCase()
959
1125
  if (sub === 'summary') {
@@ -961,44 +1127,46 @@ export class Gateway {
961
1127
  if (val === 'on' || val === 'off') {
962
1128
  if (!this.config.tts) this.config.tts = {}
963
1129
  this.config.tts.voiceSummary = val === 'on'
964
- return reply(`Голосовое саммари (TL;DR): ${val === 'on' ? 'включено' : 'выключено'}`)
1130
+ return reply(`Voice summary (TL;DR): ${val === 'on' ? 'enabled' : 'disabled'}`)
965
1131
  }
966
1132
  const state = this.config.tts?.voiceSummary ? 'on' : 'off'
967
- return reply(`Голосовое саммари (TL;DR): ${state}\nПереключение: <code>/voice summary on|off</code>`)
1133
+ return reply(`Voice summary (TL;DR): ${state}\nToggle: <code>/voice summary on|off</code>`)
968
1134
  }
969
1135
  if (sub === 'on' || sub === 'off') {
970
1136
  this.voicePrefs.set(userId, sub === 'on')
971
- return reply(sub === 'on' ? 'Голосовые ответы: on (для вас)' : 'Голосовые ответы: off (для вас)')
1137
+ return reply(sub === 'on' ? 'Voice replies: on (for you)' : 'Voice replies: off (for you)')
972
1138
  }
973
1139
  const pref = this.voicePrefs.get(userId)
974
1140
  const mode = this.tg().voiceMode || 'mirror'
975
- const prefLine = pref === null ? 'не задан (/voice on|off)' : (pref ? 'on' : 'off')
1141
+ const prefLine = pref === null ? 'not set (/voice on|off)' : (pref ? 'on' : 'off')
976
1142
  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}`)
1143
+ return reply(`voiceMode=${mode}\nyour /voice: ${prefLine}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}\nvoice summary: ${summaryState}`)
978
1144
  }
1145
+
979
1146
  if (cmd === '/topic') {
980
1147
  const topicName = parts.slice(1).join(' ').trim()
981
1148
  if (!topicName) {
982
- return reply('Использование: <code>/topic &lt;название&gt;</code>\nСоздает новый топик в супергруппе и изолированную сессию под задачу.')
1149
+ return reply('Usage: <code>/topic &lt;name&gt;</code>\nCreates a new topic in supergroup with an isolated session.')
983
1150
  }
984
1151
  const tgAdapter = this.getAdapter('telegram')
985
1152
  if (!tgAdapter?.createForumTopic) {
986
- return reply('Создание топиков доступно только в Telegram.')
1153
+ return reply('Topic creation is available only in Telegram.')
987
1154
  }
988
1155
  try {
989
1156
  const res = await tgAdapter.createForumTopic(chatId, topicName)
990
1157
  const newThreadId = res?.message_thread_id
991
- await reply(`🎯 Создан новый топик <b>«${topicName}»</b> (ID: <code>${newThreadId}</code>).\nПерейдите в созданный топик для работы над задачей!`)
1158
+ await reply(`🎯 Created new topic <b>«${topicName}»</b> (ID: <code>${newThreadId}</code>).\nSwitch to the topic to continue working!`)
992
1159
  if (newThreadId) {
993
1160
  await tgAdapter.sendTo(chatId, {
994
- text: `👋 Привет! Это изолированная сессия для задачи <b>«${topicName}»</b>.\nЧем могу помочь?`,
1161
+ text: `👋 Hello! This is an isolated session for task <b>«${topicName}»</b>.\nHow can I help?`,
995
1162
  }, { threadId: newThreadId })
996
1163
  }
997
1164
  return
998
1165
  } catch (err) {
999
- return reply(`Не удалось создать топик: ${err.message}\n(Убедитесь, что бот является администратором группы и включены темы/форумы)`)
1166
+ return reply(`Failed to create topic: ${err.message}\n(Ensure the bot is group administrator with Manage Topics permission)`)
1000
1167
  }
1001
1168
  }
1169
+
1002
1170
  if (cmd === '/top') {
1003
1171
  const mem = process.memoryUsage()
1004
1172
  const rssMb = (mem.rss / 1024 / 1024).toFixed(1)
@@ -1009,7 +1177,7 @@ export class Gateway {
1009
1177
  const ss = String(sec % 60).padStart(2, '0')
1010
1178
  let activeReminders = 0
1011
1179
  try { activeReminders = (await this.scheduler.list()).length } catch {}
1012
- let currentModel = 'не задана'
1180
+ let currentModel = 'not set'
1013
1181
  try {
1014
1182
  const m = this.resolveAgentModel()
1015
1183
  currentModel = `${m.provider}/${m.model}`
@@ -1017,60 +1185,65 @@ export class Gateway {
1017
1185
 
1018
1186
  return reply([
1019
1187
  '📊 <b>DSH System & Resources:</b>',
1020
- `• <b>Память (RSS):</b> ${rssMb} MB`,
1188
+ `• <b>Memory (RSS):</b> ${rssMb} MB`,
1021
1189
  `• <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}`,
1190
+ `• <b>Uptime:</b> ${hh}:${mm}:${ss}`,
1191
+ `• <b>Active chats:</b> ${this.chats.size}`,
1192
+ `• <b>Queued reminders:</b> ${activeReminders}`,
1193
+ `• <b>Active model:</b> <code>${currentModel}</code>`,
1194
+ `• <b>Messages sent:</b> ${this.stats.sent}`,
1195
+ `• <b>Errors:</b> ${this.stats.errors}`,
1028
1196
  ].join('\n'))
1029
1197
  }
1198
+
1030
1199
  if (cmd === '/keyboard') {
1031
1200
  const sub = String(parts[1] || '').toLowerCase()
1032
1201
  const tgAdapter = this.getAdapter('telegram')
1033
1202
  if (sub === 'on') {
1034
1203
  if (tgAdapter) tgAdapter.quickActions = true
1035
- return reply('Клавиатура быстрых действий включена.', {
1204
+ return reply('Quick action keyboard enabled.', {
1036
1205
  replyMarkup: buildQuickActionsKeyboard(),
1037
1206
  })
1038
1207
  }
1039
1208
  if (sub === 'off') {
1040
1209
  if (tgAdapter) tgAdapter.quickActions = false
1041
- return reply('Клавиатура быстрых действий выключена.', {
1210
+ return reply('Quick action keyboard disabled.', {
1042
1211
  replyMarkup: REMOVE_REPLY_KEYBOARD,
1043
1212
  })
1044
1213
  }
1045
- const curState = tgAdapter?.quickActions ? 'включена' : 'выключена'
1214
+ const curState = tgAdapter?.quickActions ? 'enabled' : 'disabled'
1046
1215
  return reply([
1047
- '⌨️ <b>Клавиатура быстрых действий:</b>',
1048
- `Текущее состояние: <b>${curState}</b>`,
1216
+ '⌨️ <b>Quick Action Keyboard:</b>',
1217
+ `Current state: <b>${curState}</b>`,
1049
1218
  '',
1050
- 'Команды:',
1051
- '<code>/keyboard on</code> — показать кнопки',
1052
- '<code>/keyboard off</code> — скрыть кнопки',
1219
+ 'Commands:',
1220
+ '<code>/keyboard on</code> — show buttons',
1221
+ '<code>/keyboard off</code> — hide buttons',
1053
1222
  ].join('\n'))
1054
1223
  }
1224
+
1055
1225
  if (cmd === '/tts') {
1056
1226
  const sub = String(parts[1] || 'status').toLowerCase()
1057
1227
  if (sub === 'on' || sub === 'off') {
1058
1228
  this.chatTts.set(chatId, sub === 'on')
1059
- return reply(sub === 'on' ? 'Озвучка в этом чате: on' : 'Озвучка в этом чате: off')
1229
+ return reply(sub === 'on' ? 'Speech in this chat: on' : 'Speech in this chat: off')
1060
1230
  }
1061
1231
  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'}`)
1232
+ const line = cur === null ? 'not set (/tts on|off)' : (cur ? 'on' : 'off')
1233
+ return reply(`Speech in this chat: ${line}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}`)
1064
1234
  }
1235
+
1065
1236
  if (cmd === '/mute') {
1066
1237
  this.setMuted(chatId, true)
1067
- return reply('Уведомления в этот чат: выключены (/unmute)')
1238
+ return reply(t('msg.muted_on', {}, locale))
1068
1239
  }
1240
+
1069
1241
  if (cmd === '/unmute') {
1070
1242
  this.setMuted(chatId, false)
1071
- return reply('Уведомления в этот чат: включены')
1243
+ return reply(t('msg.muted_off', {}, locale))
1072
1244
  }
1073
- return reply(`Неизвестная команда ${cmd}. /help`)
1245
+
1246
+ return reply(t('msg.unknown_command', { cmd }, locale))
1074
1247
  }
1075
1248
 
1076
1249
  collectDynamicSkills() {
@@ -1130,7 +1303,7 @@ export class Gateway {
1130
1303
  this.sessionToThread.set(sessionId, { chatId: forumChatId, threadId })
1131
1304
  this.threadToSession.set(threadKey, sessionId)
1132
1305
 
1133
- const text = `🪞 <b>[Зеркало сессии DSH]</b>\nСессия: <code>${sessionId}</code>\nЗаголовок: <b>${title}</b>\nСообщения из этой темы будут направляться в диалог DSH.`
1306
+ const text = t('mirror.created', { sessionId, title }, 'en')
1134
1307
  await tgAdapter.sendTo(forumChatId, { text }, { threadId })
1135
1308
  this.ctx.logger?.info?.(`Mirrored session ${sessionId} to Telegram forum topic ${threadId} in ${forumChatId}`)
1136
1309
  } catch (err) {
@@ -1197,7 +1370,7 @@ export class Gateway {
1197
1370
  if (provider && model) return { provider, model }
1198
1371
  const selection = this.ctx.get('agentDefaultModel')?.currentSelection?.()
1199
1372
  if (!selection?.provider || !selection?.model) {
1200
- throw new Error('Выберите модель в Settings Models (или укажите agent.provider/model в профиле)')
1373
+ throw new Error('Please select a model in Settings -> Models (or set agent.provider/model in profile)')
1201
1374
  }
1202
1375
  return { provider: provider || selection.provider, model: model || selection.model }
1203
1376
  }
@@ -1224,6 +1397,7 @@ export class Gateway {
1224
1397
  const chat = {
1225
1398
  key, agent: handle.agent, dispose: handle.dispose, busy: Promise.resolve(),
1226
1399
  lastUsed: Date.now(), abort: undefined, pendingMedia: [], turnActive: false,
1400
+ sessionAllowlist: new Set(),
1227
1401
  target: input ? { platform: input.platform, chatId: input.chatId, threadId: input.threadId || 0 } : undefined,
1228
1402
  }
1229
1403
  this.sessionToChat.set(String(handle.agent.session.id), key)
@@ -1240,12 +1414,33 @@ export class Gateway {
1240
1414
  const chat = this.chats.get(chatKeyValue)
1241
1415
  if (!chat?.target) return next()
1242
1416
  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'
1417
+ if (chat.sessionAllowlist?.has(tool)) {
1418
+ return 'allowed-once'
1419
+ }
1420
+ const locale = this.resolveLocale(chat.target)
1421
+ const reason = req.reason ? `\n<i>${req.reason}</i>` : ''
1422
+ let detail = ''
1423
+ if (req.input && typeof req.input === 'object') {
1424
+ try {
1425
+ const jsonStr = JSON.stringify(req.input, null, 2)
1426
+ detail = `\n<pre><code>${jsonStr.slice(0, 500)}</code></pre>`
1427
+ } catch {}
1428
+ }
1429
+ const text = t('ask.confirm_title', { tool, reason: reason + detail }, locale)
1430
+ const buttons = [
1431
+ [
1432
+ { id: 'allow_once', text: t('ask.allow_once', {}, locale) },
1433
+ { id: 'allow_session', text: t('ask.allow_session', {}, locale) },
1434
+ { id: 'deny', text: t('ask.deny', {}, locale) },
1435
+ ],
1436
+ ]
1437
+ const result = await this.messengerAsk(chat.target, { text, buttons }, 300_000)
1438
+ if (result?.buttonId === 'allow_once' || result?.buttonId === 'allow') return 'allowed-once'
1439
+ if (result?.buttonId === 'allow_session') {
1440
+ if (!chat.sessionAllowlist) chat.sessionAllowlist = new Set()
1441
+ chat.sessionAllowlist.add(tool)
1442
+ return 'allowed-once'
1443
+ }
1249
1444
  if (result?.buttonId === 'deny') return 'rejected'
1250
1445
  return next()
1251
1446
  } catch {
@@ -1257,13 +1452,13 @@ export class Gateway {
1257
1452
  const { text, attachments = [], replyText, steer, personaOverride } = input
1258
1453
  const parts = []
1259
1454
  parts.push(String(this.config.agent?.instructionPrefix || MESSENGER_RELAY_INSTRUCTION))
1260
- const activePersonaId = personaOverride || this.personas.get(input.chatId)
1455
+ const activePersonaId = personaOverride || this.personas.getPersonaForChat(input.chatId, input.threadId)
1261
1456
  const activePersona = getPersona(activePersonaId)
1262
1457
  if (activePersona?.instruction) {
1263
1458
  parts.push(`[Persona: ${activePersona.name} (${activePersona.icon})]\n${activePersona.instruction}`)
1264
1459
  }
1265
- if (steer) parts.push('[Steer / дополнение к текущему ходу учти вместе с предыдущим запросом, не начинай ответ заново с нуля]')
1266
- if (replyText?.trim()) parts.push(`[Ответ на сообщение: ${replyText.trim()}]`)
1460
+ if (steer) parts.push('[Steer / addition to current turn: combine with previous instruction, do not restart from scratch]')
1461
+ if (replyText?.trim()) parts.push(`[Replying to message: ${replyText.trim()}]`)
1267
1462
  const blocks = []
1268
1463
  for (const att of attachments) {
1269
1464
  if (att.kind === 'photo' || (att.kind === 'sticker' && att.mime?.startsWith('image/'))) {
@@ -1273,22 +1468,22 @@ export class Gateway {
1273
1468
  maxBytes: Number(this.config.media?.maxImageBytes) || 20 * 1024 * 1024,
1274
1469
  })
1275
1470
  blocks.push({ type: 'image', attachment: ref })
1276
- if (att.kind === 'sticker' && att.emoji) parts.push(`[Стикер ${att.emoji}]`)
1471
+ if (att.kind === 'sticker' && att.emoji) parts.push(`[Sticker ${att.emoji}]`)
1277
1472
  } catch (err) {
1278
1473
  if (signal?.aborted) throw err
1279
1474
  const msg = err instanceof Error ? err.message : String(err)
1280
- parts.push(`[Не удалось приложить изображение: ${msg}]`)
1475
+ parts.push(`[Failed to attach image: ${msg}]`)
1281
1476
  }
1282
1477
  } else if (att.kind === 'voice' || att.kind === 'audio') {
1283
1478
  try {
1284
1479
  const bytes = new Uint8Array(await readFile(att.path))
1285
1480
  const transcript = await transcribeVoice(this.baseUrl(), bytes, att.mime || 'audio/ogg', 'message', signal)
1286
- parts.push(transcript ? `[Голосовое сообщение, расшифровка: ${transcript}]` : '[Голосовое сообщение (не удалось распознать)]')
1481
+ parts.push(transcript ? `[Voice message transcript: ${transcript}]` : '[Voice message (unrecognized)]')
1287
1482
  } catch (err) {
1288
1483
  if (signal?.aborted) throw err
1289
1484
  const msg = err instanceof Error ? err.message : String(err)
1290
1485
  this.ctx.logger?.warn?.(`voice: ${msg}`)
1291
- parts.push(`[Голосовое сообщение (dsh-voice недоступен: ${msg})]`)
1486
+ parts.push(`[Voice message (dsh-voice unavailable: ${msg})]`)
1292
1487
  }
1293
1488
  } else if (att.kind === 'document' || att.kind === 'video' || att.kind === 'animation' || att.kind === 'sticker') {
1294
1489
  let parsed = null
@@ -1300,7 +1495,7 @@ export class Gateway {
1300
1495
  }
1301
1496
  parts.push(formatInboundDocument(att, parsed))
1302
1497
  } else {
1303
- parts.push(`[Файл: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
1498
+ parts.push(`[File: ${att.path}${att.name ? ` (${att.name})` : ''}]`)
1304
1499
  }
1305
1500
  }
1306
1501
  const photoHint = photoOnlyHint(attachments, text)
@@ -1310,7 +1505,7 @@ export class Gateway {
1310
1505
  if (text?.trim()) parts.push(text.trim())
1311
1506
  const textBlock = parts.filter(Boolean).join('\n\n')
1312
1507
  if (textBlock) blocks.unshift({ type: 'text', text: textBlock })
1313
- if (!blocks.length) blocks.push({ type: 'text', text: '(пустое сообщение)' })
1508
+ if (!blocks.length) blocks.push({ type: 'text', text: '(empty message)' })
1314
1509
  return blocks
1315
1510
  }
1316
1511
 
@@ -1371,8 +1566,9 @@ export class Gateway {
1371
1566
  if (signal.aborted) {
1372
1567
  if (progress) try { await progress.remove() } catch {}
1373
1568
  if (typeof react === 'function') react('').catch?.(() => {})
1374
- if (stream) try { await stream.finalize('Прервано.') } catch {}
1375
- else return reply('Прервано.')
1569
+ const stoppedMsg = t('msg.turn_stopped', {}, this.resolveLocale(input))
1570
+ if (stream) try { await stream.finalize(stoppedMsg) } catch {}
1571
+ else return reply(stoppedMsg)
1376
1572
  return
1377
1573
  }
1378
1574
  const sessions = this.ctx.get?.('sessions') || this.ctx.sessions
@@ -1387,7 +1583,7 @@ export class Gateway {
1387
1583
  const answer = processed.text
1388
1584
  if (collector.reason?.kind === 'error') {
1389
1585
  const err = collector.reason.error
1390
- const msg = `Ошибка агента: ${err?.code || 'error'}: ${err?.message || 'unknown'}`
1586
+ const msg = t('msg.agent_error', { code: err?.code || 'error', message: err?.message || 'unknown' }, this.resolveLocale(input))
1391
1587
  this.sendAlert('error', {
1392
1588
  code: err?.code || 'AGENT_ERROR',
1393
1589
  message: err?.message || 'unknown',
@@ -1402,15 +1598,16 @@ export class Gateway {
1402
1598
  const files = await buildOutboundFiles(this.ctx, this.baseUrl(), collector, { signal, logger: this.ctx.logger })
1403
1599
  const allFiles = [...files, ...(processed.files || [])]
1404
1600
  if (!answer && !allFiles.length) {
1405
- if (stream) { await scheduler?.flush(); await stream.finalize('(нет ответа)') }
1406
- else await reply('(нет ответа)')
1601
+ const noResp = t('msg.no_response', {}, this.resolveLocale(input))
1602
+ if (stream) { await scheduler?.flush(); await stream.finalize(noResp) }
1603
+ else await reply(noResp)
1407
1604
  return
1408
1605
  }
1409
1606
  const maxLen = Number(this.config.agent?.maxMessageLength) || 4000
1410
1607
  const chunks = answer ? splitText(answer, maxLen) : ['']
1411
1608
  if (stream) {
1412
1609
  await scheduler?.flush()
1413
- await stream.finalize(chunks[0] || '(нет ответа)')
1610
+ await stream.finalize(chunks[0] || t('msg.no_response', {}, this.resolveLocale(input)))
1414
1611
  for (let i = 1; i < chunks.length; i++) await reply({ text: chunks[i] })
1415
1612
  if (allFiles.length) await reply({ files: allFiles })
1416
1613
  } else {
@@ -1449,8 +1646,9 @@ export class Gateway {
1449
1646
  threadId: input.threadId,
1450
1647
  }).catch(() => {})
1451
1648
  try {
1452
- if (stream) await stream.finalize(`Сбой: ${err.message}`)
1453
- else await reply(`Сбой: ${err.message}`)
1649
+ const excMsg = t('msg.exception', { message: err.message }, this.resolveLocale(input))
1650
+ if (stream) await stream.finalize(excMsg)
1651
+ else await reply(excMsg)
1454
1652
  } catch {}
1455
1653
  }
1456
1654
  } finally {