@goodandready/dsh-messenger-gateway 0.3.8 → 0.3.10
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/README.md +4 -1
- package/lib/adapters/telegram.js +15 -5
- package/lib/commands.js +3 -1
- package/lib/config.js +2 -1
- package/lib/gateway.js +199 -5
- package/lib/index.js +1 -1
- package/lib/models.js +106 -0
- package/lib/tts.js +20 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,10 @@ Spoken replies use Telegram `sendVoice`. If TTS returns MP3 (or other non-Opus a
|
|
|
65
65
|
| `/whoami` | Your Telegram user id |
|
|
66
66
|
| `/new` | New agent session |
|
|
67
67
|
| `/stop` | Abort the current turn |
|
|
68
|
-
| `/model`
|
|
68
|
+
| `/model` | Interactive 2-step model picker (providers → models) or `/model <prov> <mod>` |
|
|
69
|
+
| `/status` | Gateway status and session counters |
|
|
70
|
+
| `/top` | Live server resources: RSS/Heap memory, uptime, active chats, reminders |
|
|
71
|
+
| `/topic <name>` | Create a new Telegram forum topic in supergroups and start an isolated session |
|
|
69
72
|
| `/role [name]` | Switch agent persona (`coder`, `architect`, `reviewer`, `writer`, `translator`, `concise`) |
|
|
70
73
|
| `/skills` `/tools` | List active agent tools and capabilities |
|
|
71
74
|
| `/fork` | Fork current session into a new independent session |
|
package/lib/adapters/telegram.js
CHANGED
|
@@ -23,7 +23,6 @@ export function buildQuickActionsKeyboard() {
|
|
|
23
23
|
[{ text: '🎙️ /voice' }, { text: '📊 /status' }],
|
|
24
24
|
],
|
|
25
25
|
resize_keyboard: true,
|
|
26
|
-
is_persistent: true,
|
|
27
26
|
}
|
|
28
27
|
}
|
|
29
28
|
|
|
@@ -47,7 +46,7 @@ export class TelegramAdapter {
|
|
|
47
46
|
this.groupsEnabled = opts.groupsEnabled !== false
|
|
48
47
|
this.groupRequireMention = opts.groupRequireMention !== false
|
|
49
48
|
this.reactionsEnabled = opts.reactionsEnabled !== false
|
|
50
|
-
this.quickActions = opts.quickActions
|
|
49
|
+
this.quickActions = opts.quickActions === true
|
|
51
50
|
this.artifactPreviews = opts.artifactPreviews !== false
|
|
52
51
|
this.transport = opts.transport === 'webhook' ? 'webhook' : 'poll'
|
|
53
52
|
this.statusIndicator = opts.statusIndicator === true
|
|
@@ -509,9 +508,11 @@ export class TelegramAdapter {
|
|
|
509
508
|
const { text: formatted, parseMode } = this.formatOutgoingText(text, payload)
|
|
510
509
|
const chunks = splitText(formatted, TELEGRAM_MAX)
|
|
511
510
|
const plainChunks = splitText(text, TELEGRAM_MAX)
|
|
512
|
-
const effectiveMarkup =
|
|
513
|
-
?
|
|
514
|
-
:
|
|
511
|
+
const effectiveMarkup = replyMarkup !== undefined
|
|
512
|
+
? replyMarkup
|
|
513
|
+
: (this.quickActions && !threadId
|
|
514
|
+
? buildQuickActionsKeyboard()
|
|
515
|
+
: (!threadId ? REMOVE_REPLY_KEYBOARD : undefined))
|
|
515
516
|
for (let i = 0; i < chunks.length; i++) {
|
|
516
517
|
const params = {
|
|
517
518
|
chat_id: chatId,
|
|
@@ -555,4 +556,13 @@ export class TelegramAdapter {
|
|
|
555
556
|
async sendTo(chatId, payload, opts = {}) {
|
|
556
557
|
return this.sendReply(chatId, undefined, payload, normalizeThreadId(opts.threadId))
|
|
557
558
|
}
|
|
559
|
+
|
|
560
|
+
async createForumTopic(chatId, name, opts = {}) {
|
|
561
|
+
return this.call('createForumTopic', {
|
|
562
|
+
chat_id: chatId,
|
|
563
|
+
name,
|
|
564
|
+
icon_color: opts.iconColor,
|
|
565
|
+
icon_custom_emoji_id: opts.iconCustomEmojiId,
|
|
566
|
+
})
|
|
567
|
+
}
|
|
558
568
|
}
|
package/lib/commands.js
CHANGED
|
@@ -7,7 +7,9 @@ export const DEFAULT_TELEGRAM_COMMANDS = [
|
|
|
7
7
|
{ command: 'pair', description: 'Одобрить код сопряжения: /pair CODE' },
|
|
8
8
|
{ command: 'sethome', description: 'Home: /sethome [name]' },
|
|
9
9
|
{ command: 'home', description: 'Список home-каналов' },
|
|
10
|
-
{ command: 'model', description: '
|
|
10
|
+
{ command: 'model', description: 'Интерактивный выбор или смена модели' },
|
|
11
|
+
{ command: 'top', description: 'Ресурсы сервера: CPU, RAM, uptime, сессии' },
|
|
12
|
+
{ command: 'topic', description: 'Создать новый форум-топик под задачу: /topic <имя>' },
|
|
11
13
|
{ command: 'status', description: 'Статус шлюза' },
|
|
12
14
|
{ command: 'setalert', description: 'Назначить этот чат каналом алертов' },
|
|
13
15
|
{ command: 'alert', description: 'Статус канала алертов: /alert [test]' },
|
package/lib/config.js
CHANGED
|
@@ -40,7 +40,7 @@ export const PluginConfig = z.object({
|
|
|
40
40
|
webhookPath: z.string().default('/dsh-messenger-gateway/telegram/webhook'),
|
|
41
41
|
voiceMode: z.union([z.const('mirror'), z.const('always'), z.const('off')]).default('mirror')
|
|
42
42
|
.description('mirror: TTS when inbound was voice; always/off override (per-user /voice wins)'),
|
|
43
|
-
quickActions: z.boolean().default(
|
|
43
|
+
quickActions: z.boolean().default(false).description('Show quick actions keyboard in Telegram (/new, /stop, /voice, /status)'),
|
|
44
44
|
artifactPreviews: z.boolean().default(true).description('Render diagrams and formatted tables as previews'),
|
|
45
45
|
notifyBridge: z.object({
|
|
46
46
|
enabled: z.boolean().default(false),
|
|
@@ -79,6 +79,7 @@ export const PluginConfig = z.object({
|
|
|
79
79
|
tts: z.object({
|
|
80
80
|
enabled: z.boolean().default(false),
|
|
81
81
|
maxChars: z.number().default(4000),
|
|
82
|
+
voiceSummary: z.boolean().default(false).description('Speak concise TL;DR summary while full text is sent to chat'),
|
|
82
83
|
}),
|
|
83
84
|
agent: z.object({
|
|
84
85
|
provider: z.string().default(''),
|
package/lib/gateway.js
CHANGED
|
@@ -25,6 +25,8 @@ import { createPersonaStore, getPersona, listPersonas, BUILTIN_PERSONAS } from '
|
|
|
25
25
|
import { exportSessionToMarkdown, rewindSession } from './session-ops.js'
|
|
26
26
|
import { formatAlertMessage, resolveAlertTarget } from './alerts.js'
|
|
27
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'
|
|
28
30
|
import { createPairingStore } from './pairing.js'
|
|
29
31
|
import {
|
|
30
32
|
extractTextDelta, extractToolName, buildStreamPreview, formatProgressLine,
|
|
@@ -359,6 +361,100 @@ export class Gateway {
|
|
|
359
361
|
pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
|
|
360
362
|
return
|
|
361
363
|
}
|
|
364
|
+
|
|
365
|
+
// Model picker interactive flow
|
|
366
|
+
if (cb.data?.startsWith('mdl:')) {
|
|
367
|
+
const parts = cb.data.split(':')
|
|
368
|
+
const sub = parts[1]
|
|
369
|
+
// Step 2: Selected provider -> show its models (10 per page)
|
|
370
|
+
if (sub === 'p') {
|
|
371
|
+
const providerId = parts[2]
|
|
372
|
+
const current = this.resolveAgentModel()
|
|
373
|
+
const catalog = await listModelCatalog(this.ctx, current)
|
|
374
|
+
const models = catalog.modelsByProvider.get(providerId) || []
|
|
375
|
+
if (!models.length) {
|
|
376
|
+
await cb.answer('Нет доступных моделей у этого провайдера')
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
const kb = buildModelsKeyboard(providerId, models, current.model, 0)
|
|
380
|
+
await cb.answer()
|
|
381
|
+
const text = [
|
|
382
|
+
`🤖 <b>Провайдер:</b> <code>${providerId}</code>`,
|
|
383
|
+
`Выберите модель (страница ${kb.page + 1}/${kb.totalPages}):`,
|
|
384
|
+
].join('\n')
|
|
385
|
+
try {
|
|
386
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
387
|
+
} catch {}
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
// Pagination for models
|
|
391
|
+
if (sub === 'pg') {
|
|
392
|
+
const providerId = parts[2]
|
|
393
|
+
const page = parseInt(parts[3], 10) || 0
|
|
394
|
+
const current = this.resolveAgentModel()
|
|
395
|
+
const catalog = await listModelCatalog(this.ctx, current)
|
|
396
|
+
const models = catalog.modelsByProvider.get(providerId) || []
|
|
397
|
+
const kb = buildModelsKeyboard(providerId, models, current.model, page)
|
|
398
|
+
await cb.answer()
|
|
399
|
+
const text = [
|
|
400
|
+
`🤖 <b>Провайдер:</b> <code>${providerId}</code>`,
|
|
401
|
+
`Выберите модель (страница ${kb.page + 1}/${kb.totalPages}):`,
|
|
402
|
+
].join('\n')
|
|
403
|
+
try {
|
|
404
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
405
|
+
} catch {}
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
// Back to providers
|
|
409
|
+
if (sub === 'back') {
|
|
410
|
+
const current = this.resolveAgentModel()
|
|
411
|
+
const catalog = await listModelCatalog(this.ctx, current)
|
|
412
|
+
const kb = buildProvidersKeyboard(catalog.providers, current)
|
|
413
|
+
await cb.answer()
|
|
414
|
+
const text = [
|
|
415
|
+
'🤖 <b>Выберите провайдера:</b>',
|
|
416
|
+
`Текущая: <code>${current.provider}/${current.model}</code>`,
|
|
417
|
+
].join('\n')
|
|
418
|
+
try {
|
|
419
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
420
|
+
} catch {}
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
// Select model
|
|
424
|
+
if (sub === 's') {
|
|
425
|
+
const key = parts[2]
|
|
426
|
+
const stored = getStoredModelSelection(key)
|
|
427
|
+
if (!stored) {
|
|
428
|
+
await cb.answer('Сессия выбора модели истекла, вызовите /model заново')
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
const { provider, model } = stored
|
|
432
|
+
try {
|
|
433
|
+
const adm = this.ctx.get('agentDefaultModel')
|
|
434
|
+
if (adm?.saveSelection) {
|
|
435
|
+
await adm.saveSelection({ provider, model })
|
|
436
|
+
}
|
|
437
|
+
this.config.agent = { ...this.config.agent, provider, model }
|
|
438
|
+
try {
|
|
439
|
+
await this.hooks?.persistAgentModel?.({ provider, model })
|
|
440
|
+
} catch (e) {
|
|
441
|
+
this.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
|
|
442
|
+
}
|
|
443
|
+
await cb.answer(`Выбрана ${model}`)
|
|
444
|
+
try {
|
|
445
|
+
if (cb.editMessage) await cb.editMessage(`✅ Модель успешно переключена на: <b>${provider}/${model}</b>`, REMOVE_KEYBOARD)
|
|
446
|
+
} catch {}
|
|
447
|
+
} catch (err) {
|
|
448
|
+
await cb.answer(`Ошибка: ${err.message}`)
|
|
449
|
+
}
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
if (sub === 'cur') {
|
|
453
|
+
await cb.answer()
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
362
458
|
await cb.answer()
|
|
363
459
|
}
|
|
364
460
|
|
|
@@ -462,7 +558,7 @@ export class Gateway {
|
|
|
462
558
|
const parts = text.split(/\s+/)
|
|
463
559
|
const cmd = parts[0].toLowerCase().split('@')[0]
|
|
464
560
|
const { reply, userId, chatId, threadId = 0, platform } = input
|
|
465
|
-
if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.')
|
|
561
|
+
if (cmd === '/start') return reply('Шлюз подключён. Пишите сообщение агенту. /help — команды.', { replyMarkup: REMOVE_REPLY_KEYBOARD })
|
|
466
562
|
if (cmd === '/help') {
|
|
467
563
|
return reply([
|
|
468
564
|
'Команды:',
|
|
@@ -705,8 +801,18 @@ export class Gateway {
|
|
|
705
801
|
}
|
|
706
802
|
}
|
|
707
803
|
try {
|
|
708
|
-
const
|
|
709
|
-
|
|
804
|
+
const current = this.resolveAgentModel()
|
|
805
|
+
const catalog = await listModelCatalog(this.ctx, current)
|
|
806
|
+
if (!catalog.providers.length) {
|
|
807
|
+
return reply(`Текущая модель: <code>${current.provider}/${current.model}</code>\nСмена: <code>/model <provider> <model></code>`)
|
|
808
|
+
}
|
|
809
|
+
const kb = buildProvidersKeyboard(catalog.providers, current)
|
|
810
|
+
return reply([
|
|
811
|
+
'🤖 <b>Выберите провайдера:</b>',
|
|
812
|
+
`Текущая модель: <code>${current.provider}/${current.model}</code>`,
|
|
813
|
+
].join('\n'), {
|
|
814
|
+
replyMarkup: kb,
|
|
815
|
+
})
|
|
710
816
|
} catch (e) {
|
|
711
817
|
return reply(e.message)
|
|
712
818
|
}
|
|
@@ -826,6 +932,16 @@ export class Gateway {
|
|
|
826
932
|
}
|
|
827
933
|
if (cmd === '/voice') {
|
|
828
934
|
const sub = String(parts[1] || 'status').toLowerCase()
|
|
935
|
+
if (sub === 'summary') {
|
|
936
|
+
const val = parts[2]?.toLowerCase()
|
|
937
|
+
if (val === 'on' || val === 'off') {
|
|
938
|
+
if (!this.config.tts) this.config.tts = {}
|
|
939
|
+
this.config.tts.voiceSummary = val === 'on'
|
|
940
|
+
return reply(`Голосовое саммари (TL;DR): ${val === 'on' ? 'включено' : 'выключено'}`)
|
|
941
|
+
}
|
|
942
|
+
const state = this.config.tts?.voiceSummary ? 'on' : 'off'
|
|
943
|
+
return reply(`Голосовое саммари (TL;DR): ${state}\nПереключение: <code>/voice summary on|off</code>`)
|
|
944
|
+
}
|
|
829
945
|
if (sub === 'on' || sub === 'off') {
|
|
830
946
|
this.voicePrefs.set(userId, sub === 'on')
|
|
831
947
|
return reply(sub === 'on' ? 'Голосовые ответы: on (для вас)' : 'Голосовые ответы: off (для вас)')
|
|
@@ -833,7 +949,84 @@ export class Gateway {
|
|
|
833
949
|
const pref = this.voicePrefs.get(userId)
|
|
834
950
|
const mode = this.tg().voiceMode || 'mirror'
|
|
835
951
|
const prefLine = pref === null ? 'не задан (/voice on|off)' : (pref ? 'on' : 'off')
|
|
836
|
-
|
|
952
|
+
const summaryState = this.config.tts?.voiceSummary ? 'on' : 'off'
|
|
953
|
+
return reply(`voiceMode=${mode}\nваш /voice: ${prefLine}\nglobal tts: ${this.config.tts?.enabled ? 'on' : 'off'}\nvoice summary: ${summaryState}`)
|
|
954
|
+
}
|
|
955
|
+
if (cmd === '/topic') {
|
|
956
|
+
const topicName = parts.slice(1).join(' ').trim()
|
|
957
|
+
if (!topicName) {
|
|
958
|
+
return reply('Использование: <code>/topic <название></code>\nСоздает новый топик в супергруппе и изолированную сессию под задачу.')
|
|
959
|
+
}
|
|
960
|
+
const tgAdapter = this.getAdapter('telegram')
|
|
961
|
+
if (!tgAdapter?.createForumTopic) {
|
|
962
|
+
return reply('Создание топиков доступно только в Telegram.')
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
const res = await tgAdapter.createForumTopic(chatId, topicName)
|
|
966
|
+
const newThreadId = res?.message_thread_id
|
|
967
|
+
await reply(`🎯 Создан новый топик <b>«${topicName}»</b> (ID: <code>${newThreadId}</code>).\nПерейдите в созданный топик для работы над задачей!`)
|
|
968
|
+
if (newThreadId) {
|
|
969
|
+
await tgAdapter.sendTo(chatId, {
|
|
970
|
+
text: `👋 Привет! Это изолированная сессия для задачи <b>«${topicName}»</b>.\nЧем могу помочь?`,
|
|
971
|
+
}, { threadId: newThreadId })
|
|
972
|
+
}
|
|
973
|
+
return
|
|
974
|
+
} catch (err) {
|
|
975
|
+
return reply(`Не удалось создать топик: ${err.message}\n(Убедитесь, что бот является администратором группы и включены темы/форумы)`)
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (cmd === '/top') {
|
|
979
|
+
const mem = process.memoryUsage()
|
|
980
|
+
const rssMb = (mem.rss / 1024 / 1024).toFixed(1)
|
|
981
|
+
const heapMb = (mem.heapUsed / 1024 / 1024).toFixed(1)
|
|
982
|
+
const sec = Math.floor((Date.now() - this.stats.startedAt) / 1000)
|
|
983
|
+
const hh = String(Math.floor(sec / 3600)).padStart(2, '0')
|
|
984
|
+
const mm = String(Math.floor((sec % 3600) / 60)).padStart(2, '0')
|
|
985
|
+
const ss = String(sec % 60).padStart(2, '0')
|
|
986
|
+
let activeReminders = 0
|
|
987
|
+
try { activeReminders = (await this.scheduler.list()).length } catch {}
|
|
988
|
+
let currentModel = 'не задана'
|
|
989
|
+
try {
|
|
990
|
+
const m = this.resolveAgentModel()
|
|
991
|
+
currentModel = `${m.provider}/${m.model}`
|
|
992
|
+
} catch {}
|
|
993
|
+
|
|
994
|
+
return reply([
|
|
995
|
+
'📊 <b>DSH System & Resources:</b>',
|
|
996
|
+
`• <b>Память (RSS):</b> ${rssMb} MB`,
|
|
997
|
+
`• <b>Heap:</b> ${heapMb} MB`,
|
|
998
|
+
`• <b>Аптайм:</b> ${hh}:${mm}:${ss}`,
|
|
999
|
+
`• <b>Активных чатов:</b> ${this.chats.size}`,
|
|
1000
|
+
`• <b>Напоминаний в очереди:</b> ${activeReminders}`,
|
|
1001
|
+
`• <b>Активная модель:</b> <code>${currentModel}</code>`,
|
|
1002
|
+
`• <b>Сообщений отправлено:</b> ${this.stats.sent}`,
|
|
1003
|
+
`• <b>Ошибок:</b> ${this.stats.errors}`,
|
|
1004
|
+
].join('\n'))
|
|
1005
|
+
}
|
|
1006
|
+
if (cmd === '/keyboard') {
|
|
1007
|
+
const sub = String(parts[1] || '').toLowerCase()
|
|
1008
|
+
const tgAdapter = this.getAdapter('telegram')
|
|
1009
|
+
if (sub === 'on') {
|
|
1010
|
+
if (tgAdapter) tgAdapter.quickActions = true
|
|
1011
|
+
return reply('Клавиатура быстрых действий включена.', {
|
|
1012
|
+
replyMarkup: buildQuickActionsKeyboard(),
|
|
1013
|
+
})
|
|
1014
|
+
}
|
|
1015
|
+
if (sub === 'off') {
|
|
1016
|
+
if (tgAdapter) tgAdapter.quickActions = false
|
|
1017
|
+
return reply('Клавиатура быстрых действий выключена.', {
|
|
1018
|
+
replyMarkup: REMOVE_REPLY_KEYBOARD,
|
|
1019
|
+
})
|
|
1020
|
+
}
|
|
1021
|
+
const curState = tgAdapter?.quickActions ? 'включена' : 'выключена'
|
|
1022
|
+
return reply([
|
|
1023
|
+
'⌨️ <b>Клавиатура быстрых действий:</b>',
|
|
1024
|
+
`Текущее состояние: <b>${curState}</b>`,
|
|
1025
|
+
'',
|
|
1026
|
+
'Команды:',
|
|
1027
|
+
'<code>/keyboard on</code> — показать кнопки',
|
|
1028
|
+
'<code>/keyboard off</code> — скрыть кнопки',
|
|
1029
|
+
].join('\n'))
|
|
837
1030
|
}
|
|
838
1031
|
if (cmd === '/tts') {
|
|
839
1032
|
const sub = String(parts[1] || 'status').toLowerCase()
|
|
@@ -1094,7 +1287,8 @@ export class Gateway {
|
|
|
1094
1287
|
chatPref: chatTtsPref,
|
|
1095
1288
|
})
|
|
1096
1289
|
if (speak && !signal.aborted) {
|
|
1097
|
-
const
|
|
1290
|
+
const isVoiceSummary = this.config.tts?.voiceSummary === true
|
|
1291
|
+
const ttsText = prepareTtsText(answer, this.config.tts?.maxChars, { voiceSummary: isVoiceSummary })
|
|
1098
1292
|
if (ttsText) {
|
|
1099
1293
|
try {
|
|
1100
1294
|
const spoken = await speakText(this.baseUrl(), ttsText, signal)
|
package/lib/index.js
CHANGED
|
@@ -326,7 +326,7 @@ export function apply(ctx, config) {
|
|
|
326
326
|
|
|
327
327
|
const dest = target || { platform, chatId, threadId, home }
|
|
328
328
|
try {
|
|
329
|
-
const result = await gw.messengerSend(dest, { text, files })
|
|
329
|
+
const result = await gw.messengerSend(dest, { text, files, replyMarkup: payload.replyMarkup })
|
|
330
330
|
writeJson(res, 200, { ok: true, sent: result })
|
|
331
331
|
} catch (err) {
|
|
332
332
|
writeJson(res, 500, { ok: false, error: err.message })
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const MODEL_PAGE_SIZE = 10
|
|
2
|
+
|
|
3
|
+
export async function listModelCatalog(ctx, fallback = {}) {
|
|
4
|
+
const result = {
|
|
5
|
+
providers: [],
|
|
6
|
+
modelsByProvider: new Map(),
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 1. Try ctx.llm if available
|
|
10
|
+
if (ctx?.llm?.listProviders) {
|
|
11
|
+
try {
|
|
12
|
+
const providers = await ctx.llm.listProviders()
|
|
13
|
+
for (const p of providers || []) {
|
|
14
|
+
const pId = p.id || p
|
|
15
|
+
result.providers.push({ id: pId, name: p.name || pId })
|
|
16
|
+
try {
|
|
17
|
+
const models = await ctx.llm.listModels(pId)
|
|
18
|
+
result.modelsByProvider.set(
|
|
19
|
+
pId,
|
|
20
|
+
(models || []).map((m) => ({ id: m.id || m, name: m.name || m.id || m }))
|
|
21
|
+
)
|
|
22
|
+
} catch {
|
|
23
|
+
result.modelsByProvider.set(pId, [])
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. If no providers from ctx.llm, check settings / fallback
|
|
30
|
+
if (!result.providers.length && fallback.provider) {
|
|
31
|
+
result.providers.push({ id: fallback.provider, name: fallback.provider })
|
|
32
|
+
if (fallback.model) {
|
|
33
|
+
result.modelsByProvider.set(fallback.provider, [{ id: fallback.model, name: fallback.model }])
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// In-memory registry for model picker callback tokens to avoid 64-byte Telegram limit
|
|
41
|
+
const modelIndexStore = new Map()
|
|
42
|
+
let modelIndexCounter = 0
|
|
43
|
+
|
|
44
|
+
export function storeModelSelection(provider, model) {
|
|
45
|
+
const key = String(++modelIndexCounter)
|
|
46
|
+
modelIndexStore.set(key, { provider, model, time: Date.now() })
|
|
47
|
+
// Cleanup entries older than 1 hour
|
|
48
|
+
if (modelIndexStore.size > 200) {
|
|
49
|
+
const cutoff = Date.now() - 3600000
|
|
50
|
+
for (const [k, v] of modelIndexStore.entries()) {
|
|
51
|
+
if (v.time < cutoff) modelIndexStore.delete(k)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return key
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getStoredModelSelection(key) {
|
|
58
|
+
return modelIndexStore.get(key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildProvidersKeyboard(providers, current = {}) {
|
|
62
|
+
const rows = []
|
|
63
|
+
for (const p of providers) {
|
|
64
|
+
const isCurrent = p.id === current.provider
|
|
65
|
+
rows.push([{
|
|
66
|
+
text: `${isCurrent ? '✅ ' : '🔹 '}${p.name || p.id}`,
|
|
67
|
+
callback_data: `mdl:p:${p.id}`,
|
|
68
|
+
}])
|
|
69
|
+
}
|
|
70
|
+
return { inline_keyboard: rows }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildModelsKeyboard(providerId, models, currentModel, page = 0, pageSize = MODEL_PAGE_SIZE) {
|
|
74
|
+
const totalPages = Math.ceil(models.length / pageSize) || 1
|
|
75
|
+
const curPage = Math.max(0, Math.min(page, totalPages - 1))
|
|
76
|
+
const start = curPage * pageSize
|
|
77
|
+
const pageModels = models.slice(start, start + pageSize)
|
|
78
|
+
|
|
79
|
+
const rows = []
|
|
80
|
+
for (const m of pageModels) {
|
|
81
|
+
const isCurrent = m.id === currentModel
|
|
82
|
+
const key = storeModelSelection(providerId, m.id)
|
|
83
|
+
rows.push([{
|
|
84
|
+
text: `${isCurrent ? '✅ ' : ''}${m.name || m.id}`,
|
|
85
|
+
callback_data: `mdl:s:${key}`,
|
|
86
|
+
}])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Navigation row
|
|
90
|
+
const navRow = []
|
|
91
|
+
if (curPage > 0) {
|
|
92
|
+
navRow.push({ text: '⬅️', callback_data: `mdl:pg:${providerId}:${curPage - 1}` })
|
|
93
|
+
}
|
|
94
|
+
if (totalPages > 1) {
|
|
95
|
+
navRow.push({ text: `${curPage + 1}/${totalPages}`, callback_data: `mdl:cur` })
|
|
96
|
+
}
|
|
97
|
+
if (curPage < totalPages - 1) {
|
|
98
|
+
navRow.push({ text: '➡️', callback_data: `mdl:pg:${providerId}:${curPage + 1}` })
|
|
99
|
+
}
|
|
100
|
+
if (navRow.length) rows.push(navRow)
|
|
101
|
+
|
|
102
|
+
// Back row
|
|
103
|
+
rows.push([{ text: '🔙 Назад к провайдерам', callback_data: 'mdl:back' }])
|
|
104
|
+
|
|
105
|
+
return { inline_keyboard: rows, page: curPage, totalPages }
|
|
106
|
+
}
|
package/lib/tts.js
CHANGED
|
@@ -19,8 +19,26 @@ export function stripMarkdownForSpeech(text) {
|
|
|
19
19
|
.trim()
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function
|
|
23
|
-
const stripped = stripMarkdownForSpeech(
|
|
22
|
+
export function prepareVoiceSummary(text, maxChars = 300) {
|
|
23
|
+
const stripped = stripMarkdownForSpeech(text)
|
|
24
|
+
if (!stripped) return ''
|
|
25
|
+
const sentences = stripped.split(/(?<=[.!?])\s+/)
|
|
26
|
+
let summary = ''
|
|
27
|
+
for (const s of sentences) {
|
|
28
|
+
if ((summary + ' ' + s).trim().length <= maxChars) {
|
|
29
|
+
summary = (summary ? summary + ' ' + s : s).trim()
|
|
30
|
+
} else {
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!summary) summary = stripped.slice(0, maxChars).trim()
|
|
35
|
+
return summary
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function prepareTtsText(answer, maxChars = DEFAULT_TTS_MAX_CHARS, opts = {}) {
|
|
39
|
+
const isSummary = opts.voiceSummary === true
|
|
40
|
+
const source = isSummary ? prepareVoiceSummary(answer, Math.min(Number(maxChars) || 300, 400)) : answer
|
|
41
|
+
const stripped = stripMarkdownForSpeech(source)
|
|
24
42
|
if (!stripped || stripped.length < 2) return ''
|
|
25
43
|
const limit = Math.max(1, Number(maxChars) || DEFAULT_TTS_MAX_CHARS)
|
|
26
44
|
return stripped.slice(0, limit)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-messenger-gateway",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"description": "Telegram messenger bridge for DeepSeek Harness: sessions, steer, homes, inline asks, notify bridge, and optional TTS voice notes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|