@goodandready/dsh-messenger-gateway 0.4.0 → 0.4.1
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/adapters/telegram-inbound.js +141 -141
- package/lib/adapters/telegram.js +596 -596
- package/lib/alerts.js +105 -105
- package/lib/api-health.js +38 -38
- package/lib/client.js +38 -0
- package/lib/commands.js +126 -126
- package/lib/forum-mirror.js +105 -105
- package/lib/gateway-callbacks.js +187 -187
- package/lib/gateway-commands.js +584 -584
- package/lib/gateway-turn.js +285 -285
- package/lib/index.js +540 -540
- package/lib/scheduler.js +169 -169
- package/lib/telegram-errors.js +0 -0
- package/package.json +69 -69
package/lib/forum-mirror.js
CHANGED
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
import { mergeDynamicCommands } from './commands.js'
|
|
2
|
-
import { t } from './locales/index.js'
|
|
3
|
-
import { stripReasoningPreamble, splitText } from './text.js'
|
|
4
|
-
import { stripImageUrls } from './outbound.js'
|
|
5
|
-
|
|
6
|
-
export function collectDynamicSkills(ctx) {
|
|
7
|
-
const skills = []
|
|
8
|
-
try {
|
|
9
|
-
const skillsService = ctx.get?.('skills') || gw.ctx.skills
|
|
10
|
-
const allSkills = skillsService?.list?.() || []
|
|
11
|
-
for (const s of allSkills) {
|
|
12
|
-
if (!s || !s.name) continue
|
|
13
|
-
if (s.userInvocable === false) continue
|
|
14
|
-
skills.push({
|
|
15
|
-
name: String(s.name),
|
|
16
|
-
description: String(s.description || s.title || `Skill ${s.name}`).slice(0, 256),
|
|
17
|
-
})
|
|
18
|
-
}
|
|
19
|
-
} catch (err) {
|
|
20
|
-
ctx.logger?.debug?.(`Failed to collect dynamic skills: ${err?.message || err}`)
|
|
21
|
-
}
|
|
22
|
-
return skills
|
|
23
|
-
}
|
|
24
|
-
export async function syncTelegramCommands(gw) {
|
|
25
|
-
const tgAdapter = gw.adapters.get('telegram')
|
|
26
|
-
if (!tgAdapter || typeof tgAdapter.registerCommands !== 'function') return
|
|
27
|
-
const baseCommands = gw.config.telegram?.commands || []
|
|
28
|
-
const dynamicSkills = collectDynamicSkills(gw.ctx)
|
|
29
|
-
const merged = mergeDynamicCommands(baseCommands, dynamicSkills, 100)
|
|
30
|
-
try {
|
|
31
|
-
await tgAdapter.registerCommands(merged)
|
|
32
|
-
ctx.logger?.info?.(`Synced ${merged.length} Telegram commands (including ${dynamicSkills.length} dynamic skills)`)
|
|
33
|
-
} catch (err) {
|
|
34
|
-
ctx.logger?.warn?.(`Failed to sync Telegram commands: ${err?.message || err}`)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
export async function mirrorSessionToForumTopic(gw, session) {
|
|
38
|
-
if (!session || !session.id) return
|
|
39
|
-
const sessionId = String(session.id)
|
|
40
|
-
if (sessionId.startsWith('msgw-')) return
|
|
41
|
-
if (gw.sessionToThread.has(sessionId)) return
|
|
42
|
-
|
|
43
|
-
const tgCfg = gw.config.telegram || {}
|
|
44
|
-
if (!tgCfg.forumMirrorEnabled || !tgCfg.forumMirrorChatId) return
|
|
45
|
-
|
|
46
|
-
const tgAdapter = gw.adapters.get('telegram')
|
|
47
|
-
if (!tgAdapter || typeof tgAdapter.createForumTopic !== 'function') return
|
|
48
|
-
|
|
49
|
-
const forumChatId = tgCfg.forumMirrorChatId
|
|
50
|
-
const title = String(session.title || session.meta?.name || `Session ${sessionId.slice(0, 8)}`).slice(0, 120)
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const topic = await tgAdapter.createForumTopic(forumChatId, title)
|
|
54
|
-
const threadId = topic?.message_thread_id
|
|
55
|
-
if (!threadId) return
|
|
56
|
-
|
|
57
|
-
const threadKey = `${forumChatId}:${threadId}`
|
|
58
|
-
gw.sessionToThread.set(sessionId, { chatId: forumChatId, threadId })
|
|
59
|
-
gw.threadToSession.set(threadKey, sessionId)
|
|
60
|
-
|
|
61
|
-
const text = t('mirror.created', { sessionId, title }, 'en')
|
|
62
|
-
await tgAdapter.sendTo(forumChatId, { text }, { threadId })
|
|
63
|
-
ctx.logger?.info?.(`Mirrored session ${sessionId} to Telegram forum topic ${threadId} in ${forumChatId}`)
|
|
64
|
-
} catch (err) {
|
|
65
|
-
ctx.logger?.warn?.(`Failed to mirror session ${sessionId} to forum topic: ${err?.message || err}`)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
export async function relayTurnToForumMirror(gw, session, event) {
|
|
69
|
-
if (!session || !session.id) return
|
|
70
|
-
const sessionId = String(session.id)
|
|
71
|
-
const threadInfo = gw.sessionToThread.get(sessionId)
|
|
72
|
-
if (!threadInfo) return
|
|
73
|
-
if (gw.pending.has(sessionId)) return
|
|
74
|
-
|
|
75
|
-
const tgAdapter = gw.adapters.get('telegram')
|
|
76
|
-
if (!tgAdapter) return
|
|
77
|
-
|
|
78
|
-
const messages = Array.isArray(session.messages)
|
|
79
|
-
? session.messages
|
|
80
|
-
: (Array.isArray(session.history) ? session.history : [])
|
|
81
|
-
|
|
82
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
83
|
-
const msg = messages[i]
|
|
84
|
-
const role = msg.role || (msg.type === 'user' ? 'user' : 'assistant')
|
|
85
|
-
if (role === 'assistant') {
|
|
86
|
-
let text = ''
|
|
87
|
-
if (typeof msg.content === 'string') text = msg.content
|
|
88
|
-
else if (Array.isArray(msg.content)) {
|
|
89
|
-
text = msg.content
|
|
90
|
-
.map((p) => (typeof p === 'string' ? p : (p?.text || '')))
|
|
91
|
-
.filter(Boolean)
|
|
92
|
-
.join('\n')
|
|
93
|
-
} else if (msg.text) text = msg.text
|
|
94
|
-
const clean = stripReasoningPreamble(stripImageUrls(text)).trim()
|
|
95
|
-
if (clean) {
|
|
96
|
-
const maxLen = Number(gw.config.agent?.maxMessageLength) || 4000
|
|
97
|
-
const chunks = splitText(clean, maxLen)
|
|
98
|
-
for (const chunk of chunks) {
|
|
99
|
-
await tgAdapter.sendTo(threadInfo.chatId, { text: chunk }, { threadId: threadInfo.threadId })
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
break
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
1
|
+
import { mergeDynamicCommands } from './commands.js'
|
|
2
|
+
import { t } from './locales/index.js'
|
|
3
|
+
import { stripReasoningPreamble, splitText } from './text.js'
|
|
4
|
+
import { stripImageUrls } from './outbound.js'
|
|
5
|
+
|
|
6
|
+
export function collectDynamicSkills(ctx) {
|
|
7
|
+
const skills = []
|
|
8
|
+
try {
|
|
9
|
+
const skillsService = ctx.get?.('skills') || gw.ctx.skills
|
|
10
|
+
const allSkills = skillsService?.list?.() || []
|
|
11
|
+
for (const s of allSkills) {
|
|
12
|
+
if (!s || !s.name) continue
|
|
13
|
+
if (s.userInvocable === false) continue
|
|
14
|
+
skills.push({
|
|
15
|
+
name: String(s.name),
|
|
16
|
+
description: String(s.description || s.title || `Skill ${s.name}`).slice(0, 256),
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
} catch (err) {
|
|
20
|
+
ctx.logger?.debug?.(`Failed to collect dynamic skills: ${err?.message || err}`)
|
|
21
|
+
}
|
|
22
|
+
return skills
|
|
23
|
+
}
|
|
24
|
+
export async function syncTelegramCommands(gw) {
|
|
25
|
+
const tgAdapter = gw.adapters.get('telegram')
|
|
26
|
+
if (!tgAdapter || typeof tgAdapter.registerCommands !== 'function') return
|
|
27
|
+
const baseCommands = gw.config.telegram?.commands || []
|
|
28
|
+
const dynamicSkills = collectDynamicSkills(gw.ctx)
|
|
29
|
+
const merged = mergeDynamicCommands(baseCommands, dynamicSkills, 100)
|
|
30
|
+
try {
|
|
31
|
+
await tgAdapter.registerCommands(merged)
|
|
32
|
+
ctx.logger?.info?.(`Synced ${merged.length} Telegram commands (including ${dynamicSkills.length} dynamic skills)`)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
ctx.logger?.warn?.(`Failed to sync Telegram commands: ${err?.message || err}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function mirrorSessionToForumTopic(gw, session) {
|
|
38
|
+
if (!session || !session.id) return
|
|
39
|
+
const sessionId = String(session.id)
|
|
40
|
+
if (sessionId.startsWith('msgw-')) return
|
|
41
|
+
if (gw.sessionToThread.has(sessionId)) return
|
|
42
|
+
|
|
43
|
+
const tgCfg = gw.config.telegram || {}
|
|
44
|
+
if (!tgCfg.forumMirrorEnabled || !tgCfg.forumMirrorChatId) return
|
|
45
|
+
|
|
46
|
+
const tgAdapter = gw.adapters.get('telegram')
|
|
47
|
+
if (!tgAdapter || typeof tgAdapter.createForumTopic !== 'function') return
|
|
48
|
+
|
|
49
|
+
const forumChatId = tgCfg.forumMirrorChatId
|
|
50
|
+
const title = String(session.title || session.meta?.name || `Session ${sessionId.slice(0, 8)}`).slice(0, 120)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const topic = await tgAdapter.createForumTopic(forumChatId, title)
|
|
54
|
+
const threadId = topic?.message_thread_id
|
|
55
|
+
if (!threadId) return
|
|
56
|
+
|
|
57
|
+
const threadKey = `${forumChatId}:${threadId}`
|
|
58
|
+
gw.sessionToThread.set(sessionId, { chatId: forumChatId, threadId })
|
|
59
|
+
gw.threadToSession.set(threadKey, sessionId)
|
|
60
|
+
|
|
61
|
+
const text = t('mirror.created', { sessionId, title }, 'en')
|
|
62
|
+
await tgAdapter.sendTo(forumChatId, { text }, { threadId })
|
|
63
|
+
ctx.logger?.info?.(`Mirrored session ${sessionId} to Telegram forum topic ${threadId} in ${forumChatId}`)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
ctx.logger?.warn?.(`Failed to mirror session ${sessionId} to forum topic: ${err?.message || err}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function relayTurnToForumMirror(gw, session, event) {
|
|
69
|
+
if (!session || !session.id) return
|
|
70
|
+
const sessionId = String(session.id)
|
|
71
|
+
const threadInfo = gw.sessionToThread.get(sessionId)
|
|
72
|
+
if (!threadInfo) return
|
|
73
|
+
if (gw.pending.has(sessionId)) return
|
|
74
|
+
|
|
75
|
+
const tgAdapter = gw.adapters.get('telegram')
|
|
76
|
+
if (!tgAdapter) return
|
|
77
|
+
|
|
78
|
+
const messages = Array.isArray(session.messages)
|
|
79
|
+
? session.messages
|
|
80
|
+
: (Array.isArray(session.history) ? session.history : [])
|
|
81
|
+
|
|
82
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
83
|
+
const msg = messages[i]
|
|
84
|
+
const role = msg.role || (msg.type === 'user' ? 'user' : 'assistant')
|
|
85
|
+
if (role === 'assistant') {
|
|
86
|
+
let text = ''
|
|
87
|
+
if (typeof msg.content === 'string') text = msg.content
|
|
88
|
+
else if (Array.isArray(msg.content)) {
|
|
89
|
+
text = msg.content
|
|
90
|
+
.map((p) => (typeof p === 'string' ? p : (p?.text || '')))
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('\n')
|
|
93
|
+
} else if (msg.text) text = msg.text
|
|
94
|
+
const clean = stripReasoningPreamble(stripImageUrls(text)).trim()
|
|
95
|
+
if (clean) {
|
|
96
|
+
const maxLen = Number(gw.config.agent?.maxMessageLength) || 4000
|
|
97
|
+
const chunks = splitText(clean, maxLen)
|
|
98
|
+
for (const chunk of chunks) {
|
|
99
|
+
await tgAdapter.sendTo(threadInfo.chatId, { text: chunk }, { threadId: threadInfo.threadId })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
package/lib/gateway-callbacks.js
CHANGED
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
import { t } from './locales/index.js'
|
|
2
|
-
import {
|
|
3
|
-
parseCallbackData, parseAskCallback, targetMatchesAsk,
|
|
4
|
-
releaseCallbacks, buildMultiSelectKeyboard, indexCallbacks, REMOVE_KEYBOARD
|
|
5
|
-
} from './ask.js'
|
|
6
|
-
import {
|
|
7
|
-
listModelCatalog, buildProvidersKeyboard, buildModelsKeyboard, storeModelSelection
|
|
8
|
-
} from './models.js'
|
|
9
|
-
|
|
10
|
-
export async function handleGatewayCallback(gw, cb) {
|
|
11
|
-
if (cb.userId && !gw.isUserAllowed(cb.userId)) {
|
|
12
|
-
try { await cb.answer(t('msg.not_allowed', {}, 'en')) } catch (err) { gw.recordApiFailure('cb.answer.not_allowed', err) }
|
|
13
|
-
return
|
|
14
|
-
}
|
|
15
|
-
const indexed = gw.callbackIndex.get(cb.data)
|
|
16
|
-
const { token, buttonId } = parseCallbackData(cb.data)
|
|
17
|
-
const askToken = indexed || token
|
|
18
|
-
if (askToken && gw.pendingAsks.has(askToken)) {
|
|
19
|
-
const pending = gw.pendingAsks.get(askToken)
|
|
20
|
-
if (!targetMatchesAsk(pending, cb)) {
|
|
21
|
-
await cb.answer(t('ask.other_chat', {}, 'en'))
|
|
22
|
-
return
|
|
23
|
-
}
|
|
24
|
-
const action = parseAskCallback(cb.data)
|
|
25
|
-
if (pending.isMulti) {
|
|
26
|
-
if (action.kind === 'toggle') {
|
|
27
|
-
if (pending.selected.has(action.id)) pending.selected.delete(action.id)
|
|
28
|
-
else pending.selected.add(action.id)
|
|
29
|
-
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
30
|
-
const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
|
|
31
|
-
pending.callbackKeys = nextKb.callbackKeys
|
|
32
|
-
indexCallbacks(gw.callbackIndex, nextKb.callbackKeys, askToken)
|
|
33
|
-
try {
|
|
34
|
-
if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
|
|
35
|
-
else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
|
|
36
|
-
} catch (err) {
|
|
37
|
-
gw.recordApiFailure('cb.editMessage.ask_multi_toggle', err)
|
|
38
|
-
}
|
|
39
|
-
await cb.answer(pending.selected.has(action.id) ? 'Selected' : 'Deselected')
|
|
40
|
-
return
|
|
41
|
-
}
|
|
42
|
-
if (action.kind === 'page') {
|
|
43
|
-
pending.page = action.page
|
|
44
|
-
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
45
|
-
const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
|
|
46
|
-
pending.callbackKeys = nextKb.callbackKeys
|
|
47
|
-
indexCallbacks(gw.callbackIndex, nextKb.callbackKeys, askToken)
|
|
48
|
-
try {
|
|
49
|
-
if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
|
|
50
|
-
else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
|
|
51
|
-
} catch (err) {
|
|
52
|
-
gw.recordApiFailure('cb.editMessage.ask_multi_page', err)
|
|
53
|
-
}
|
|
54
|
-
await cb.answer()
|
|
55
|
-
return
|
|
56
|
-
}
|
|
57
|
-
if (action.kind === 'done') {
|
|
58
|
-
gw.pendingAsks.delete(askToken)
|
|
59
|
-
clearTimeout(pending.timer)
|
|
60
|
-
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
61
|
-
await cb.answer('OK')
|
|
62
|
-
try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_multi_done', err) }
|
|
63
|
-
pending.resolve({ buttonId: 'done', selected: Array.from(pending.selected), data: cb.data })
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
if (action.kind === 'cancel') {
|
|
67
|
-
gw.pendingAsks.delete(askToken)
|
|
68
|
-
clearTimeout(pending.timer)
|
|
69
|
-
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
70
|
-
await cb.answer('Cancelled')
|
|
71
|
-
try { await cb.editMessage(cb.message?.text || 'Cancelled', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_multi_cancel', err) }
|
|
72
|
-
pending.resolve({ buttonId: 'cancel', selected: [], data: cb.data })
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
gw.pendingAsks.delete(askToken)
|
|
77
|
-
clearTimeout(pending.timer)
|
|
78
|
-
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
79
|
-
await cb.answer('OK')
|
|
80
|
-
try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_single_done', err) }
|
|
81
|
-
pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Model picker interactive flow
|
|
86
|
-
if (cb.data?.startsWith('mdl:')) {
|
|
87
|
-
const parts = cb.data.split(':')
|
|
88
|
-
const sub = parts[1]
|
|
89
|
-
// Step 2: Selected provider -> show its models (10 per page)
|
|
90
|
-
if (sub === 'p') {
|
|
91
|
-
const providerId = parts.slice(2).join(':')
|
|
92
|
-
const current = gw.resolveAgentModel()
|
|
93
|
-
const catalog = await listModelCatalog(gw.ctx, current)
|
|
94
|
-
const models = catalog.modelsByProvider.get(providerId) || []
|
|
95
|
-
if (!models.length) {
|
|
96
|
-
await cb.answer(t('model.no_models', {}, 'en'))
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
const kb = buildModelsKeyboard(providerId, models, current.model, 0)
|
|
100
|
-
await cb.answer()
|
|
101
|
-
const text = [
|
|
102
|
-
`🤖 <b>Provider:</b> <code>${providerId}</code>`,
|
|
103
|
-
`Select model (page ${kb.page + 1}/${kb.totalPages}):`,
|
|
104
|
-
].join('\n')
|
|
105
|
-
try {
|
|
106
|
-
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
107
|
-
} catch (err) {
|
|
108
|
-
gw.recordApiFailure('cb.editMessage.model_picker_provider', err)
|
|
109
|
-
}
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
// Pagination for models
|
|
113
|
-
if (sub === 'pg') {
|
|
114
|
-
const page = parseInt(parts[parts.length - 1], 10) || 0
|
|
115
|
-
const providerId = parts.slice(2, -1).join(':')
|
|
116
|
-
const current = gw.resolveAgentModel()
|
|
117
|
-
const catalog = await listModelCatalog(gw.ctx, current)
|
|
118
|
-
const models = catalog.modelsByProvider.get(providerId) || []
|
|
119
|
-
const kb = buildModelsKeyboard(providerId, models, current.model, page)
|
|
120
|
-
await cb.answer()
|
|
121
|
-
const text = [
|
|
122
|
-
`🤖 <b>Provider:</b> <code>${providerId}</code>`,
|
|
123
|
-
`Select model (page ${kb.page + 1}/${kb.totalPages}):`,
|
|
124
|
-
].join('\n')
|
|
125
|
-
try {
|
|
126
|
-
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
127
|
-
} catch (err) {
|
|
128
|
-
gw.recordApiFailure('cb.editMessage.model_picker_page', err)
|
|
129
|
-
}
|
|
130
|
-
return
|
|
131
|
-
}
|
|
132
|
-
// Back to providers
|
|
133
|
-
if (sub === 'back') {
|
|
134
|
-
const current = gw.resolveAgentModel()
|
|
135
|
-
const catalog = await listModelCatalog(gw.ctx, current)
|
|
136
|
-
const kb = buildProvidersKeyboard(catalog.providers, current)
|
|
137
|
-
await cb.answer()
|
|
138
|
-
const text = [
|
|
139
|
-
'🤖 <b>Choose Provider:</b>',
|
|
140
|
-
`Current: <code>${current.provider}/${current.model}</code>`,
|
|
141
|
-
].join('\n')
|
|
142
|
-
try {
|
|
143
|
-
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
144
|
-
} catch (err) {
|
|
145
|
-
gw.recordApiFailure('cb.editMessage.model_picker_back', err)
|
|
146
|
-
}
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
// Select model
|
|
150
|
-
if (sub === 's') {
|
|
151
|
-
const key = parts[2]
|
|
152
|
-
const stored = getStoredModelSelection(key)
|
|
153
|
-
if (!stored) {
|
|
154
|
-
await cb.answer(t('ask.expired', {}, 'en'))
|
|
155
|
-
return
|
|
156
|
-
}
|
|
157
|
-
const { provider, model } = stored
|
|
158
|
-
try {
|
|
159
|
-
const adm = gw.ctx.get('agentDefaultModel')
|
|
160
|
-
if (adm?.saveSelection) {
|
|
161
|
-
await adm.saveSelection({ provider, model })
|
|
162
|
-
}
|
|
163
|
-
gw.config.agent = { ...gw.config.agent, provider, model }
|
|
164
|
-
try {
|
|
165
|
-
await gw.hooks?.persistAgentModel?.({ provider, model })
|
|
166
|
-
} catch (e) {
|
|
167
|
-
gw.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
|
|
168
|
-
}
|
|
169
|
-
await cb.answer(t('ask.chose', { choice: model }, 'en'))
|
|
170
|
-
try {
|
|
171
|
-
if (cb.editMessage) await cb.editMessage(t('model.switched', { provider, model }, 'en'), REMOVE_KEYBOARD)
|
|
172
|
-
} catch (err) {
|
|
173
|
-
gw.recordApiFailure('cb.editMessage.model_picker_switched', err)
|
|
174
|
-
}
|
|
175
|
-
} catch (err) {
|
|
176
|
-
await cb.answer(`Error: ${err.message}`)
|
|
177
|
-
}
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
if (sub === 'cur') {
|
|
181
|
-
await cb.answer()
|
|
182
|
-
return
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
await cb.answer()
|
|
187
|
-
}
|
|
1
|
+
import { t } from './locales/index.js'
|
|
2
|
+
import {
|
|
3
|
+
parseCallbackData, parseAskCallback, targetMatchesAsk,
|
|
4
|
+
releaseCallbacks, buildMultiSelectKeyboard, indexCallbacks, REMOVE_KEYBOARD
|
|
5
|
+
} from './ask.js'
|
|
6
|
+
import {
|
|
7
|
+
listModelCatalog, buildProvidersKeyboard, buildModelsKeyboard, storeModelSelection
|
|
8
|
+
} from './models.js'
|
|
9
|
+
|
|
10
|
+
export async function handleGatewayCallback(gw, cb) {
|
|
11
|
+
if (cb.userId && !gw.isUserAllowed(cb.userId)) {
|
|
12
|
+
try { await cb.answer(t('msg.not_allowed', {}, 'en')) } catch (err) { gw.recordApiFailure('cb.answer.not_allowed', err) }
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
const indexed = gw.callbackIndex.get(cb.data)
|
|
16
|
+
const { token, buttonId } = parseCallbackData(cb.data)
|
|
17
|
+
const askToken = indexed || token
|
|
18
|
+
if (askToken && gw.pendingAsks.has(askToken)) {
|
|
19
|
+
const pending = gw.pendingAsks.get(askToken)
|
|
20
|
+
if (!targetMatchesAsk(pending, cb)) {
|
|
21
|
+
await cb.answer(t('ask.other_chat', {}, 'en'))
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const action = parseAskCallback(cb.data)
|
|
25
|
+
if (pending.isMulti) {
|
|
26
|
+
if (action.kind === 'toggle') {
|
|
27
|
+
if (pending.selected.has(action.id)) pending.selected.delete(action.id)
|
|
28
|
+
else pending.selected.add(action.id)
|
|
29
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
30
|
+
const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
|
|
31
|
+
pending.callbackKeys = nextKb.callbackKeys
|
|
32
|
+
indexCallbacks(gw.callbackIndex, nextKb.callbackKeys, askToken)
|
|
33
|
+
try {
|
|
34
|
+
if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
|
|
35
|
+
else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
gw.recordApiFailure('cb.editMessage.ask_multi_toggle', err)
|
|
38
|
+
}
|
|
39
|
+
await cb.answer(pending.selected.has(action.id) ? 'Selected' : 'Deselected')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
if (action.kind === 'page') {
|
|
43
|
+
pending.page = action.page
|
|
44
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
45
|
+
const nextKb = buildMultiSelectKeyboard(askToken, pending.options, pending.selected, pending.page, pending.pageSize, pending.payload)
|
|
46
|
+
pending.callbackKeys = nextKb.callbackKeys
|
|
47
|
+
indexCallbacks(gw.callbackIndex, nextKb.callbackKeys, askToken)
|
|
48
|
+
try {
|
|
49
|
+
if (cb.editReplyMarkup) await cb.editReplyMarkup(nextKb.replyMarkup)
|
|
50
|
+
else await cb.editMessage(cb.message?.text || 'Selection', nextKb.replyMarkup)
|
|
51
|
+
} catch (err) {
|
|
52
|
+
gw.recordApiFailure('cb.editMessage.ask_multi_page', err)
|
|
53
|
+
}
|
|
54
|
+
await cb.answer()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (action.kind === 'done') {
|
|
58
|
+
gw.pendingAsks.delete(askToken)
|
|
59
|
+
clearTimeout(pending.timer)
|
|
60
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
61
|
+
await cb.answer('OK')
|
|
62
|
+
try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_multi_done', err) }
|
|
63
|
+
pending.resolve({ buttonId: 'done', selected: Array.from(pending.selected), data: cb.data })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (action.kind === 'cancel') {
|
|
67
|
+
gw.pendingAsks.delete(askToken)
|
|
68
|
+
clearTimeout(pending.timer)
|
|
69
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
70
|
+
await cb.answer('Cancelled')
|
|
71
|
+
try { await cb.editMessage(cb.message?.text || 'Cancelled', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_multi_cancel', err) }
|
|
72
|
+
pending.resolve({ buttonId: 'cancel', selected: [], data: cb.data })
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
gw.pendingAsks.delete(askToken)
|
|
77
|
+
clearTimeout(pending.timer)
|
|
78
|
+
releaseCallbacks(gw.callbackIndex, pending.callbackKeys)
|
|
79
|
+
await cb.answer('OK')
|
|
80
|
+
try { await cb.editMessage(cb.message?.text || 'Done', REMOVE_KEYBOARD) } catch (err) { gw.recordApiFailure('cb.editMessage.ask_single_done', err) }
|
|
81
|
+
pending.resolve({ buttonId: action.id || buttonId, data: cb.data })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Model picker interactive flow
|
|
86
|
+
if (cb.data?.startsWith('mdl:')) {
|
|
87
|
+
const parts = cb.data.split(':')
|
|
88
|
+
const sub = parts[1]
|
|
89
|
+
// Step 2: Selected provider -> show its models (10 per page)
|
|
90
|
+
if (sub === 'p') {
|
|
91
|
+
const providerId = parts.slice(2).join(':')
|
|
92
|
+
const current = gw.resolveAgentModel()
|
|
93
|
+
const catalog = await listModelCatalog(gw.ctx, current)
|
|
94
|
+
const models = catalog.modelsByProvider.get(providerId) || []
|
|
95
|
+
if (!models.length) {
|
|
96
|
+
await cb.answer(t('model.no_models', {}, 'en'))
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const kb = buildModelsKeyboard(providerId, models, current.model, 0)
|
|
100
|
+
await cb.answer()
|
|
101
|
+
const text = [
|
|
102
|
+
`🤖 <b>Provider:</b> <code>${providerId}</code>`,
|
|
103
|
+
`Select model (page ${kb.page + 1}/${kb.totalPages}):`,
|
|
104
|
+
].join('\n')
|
|
105
|
+
try {
|
|
106
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
gw.recordApiFailure('cb.editMessage.model_picker_provider', err)
|
|
109
|
+
}
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
// Pagination for models
|
|
113
|
+
if (sub === 'pg') {
|
|
114
|
+
const page = parseInt(parts[parts.length - 1], 10) || 0
|
|
115
|
+
const providerId = parts.slice(2, -1).join(':')
|
|
116
|
+
const current = gw.resolveAgentModel()
|
|
117
|
+
const catalog = await listModelCatalog(gw.ctx, current)
|
|
118
|
+
const models = catalog.modelsByProvider.get(providerId) || []
|
|
119
|
+
const kb = buildModelsKeyboard(providerId, models, current.model, page)
|
|
120
|
+
await cb.answer()
|
|
121
|
+
const text = [
|
|
122
|
+
`🤖 <b>Provider:</b> <code>${providerId}</code>`,
|
|
123
|
+
`Select model (page ${kb.page + 1}/${kb.totalPages}):`,
|
|
124
|
+
].join('\n')
|
|
125
|
+
try {
|
|
126
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
gw.recordApiFailure('cb.editMessage.model_picker_page', err)
|
|
129
|
+
}
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
// Back to providers
|
|
133
|
+
if (sub === 'back') {
|
|
134
|
+
const current = gw.resolveAgentModel()
|
|
135
|
+
const catalog = await listModelCatalog(gw.ctx, current)
|
|
136
|
+
const kb = buildProvidersKeyboard(catalog.providers, current)
|
|
137
|
+
await cb.answer()
|
|
138
|
+
const text = [
|
|
139
|
+
'🤖 <b>Choose Provider:</b>',
|
|
140
|
+
`Current: <code>${current.provider}/${current.model}</code>`,
|
|
141
|
+
].join('\n')
|
|
142
|
+
try {
|
|
143
|
+
if (cb.editMessage) await cb.editMessage(text, kb)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
gw.recordApiFailure('cb.editMessage.model_picker_back', err)
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
// Select model
|
|
150
|
+
if (sub === 's') {
|
|
151
|
+
const key = parts[2]
|
|
152
|
+
const stored = getStoredModelSelection(key)
|
|
153
|
+
if (!stored) {
|
|
154
|
+
await cb.answer(t('ask.expired', {}, 'en'))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
const { provider, model } = stored
|
|
158
|
+
try {
|
|
159
|
+
const adm = gw.ctx.get('agentDefaultModel')
|
|
160
|
+
if (adm?.saveSelection) {
|
|
161
|
+
await adm.saveSelection({ provider, model })
|
|
162
|
+
}
|
|
163
|
+
gw.config.agent = { ...gw.config.agent, provider, model }
|
|
164
|
+
try {
|
|
165
|
+
await gw.hooks?.persistAgentModel?.({ provider, model })
|
|
166
|
+
} catch (e) {
|
|
167
|
+
gw.ctx.logger?.warn?.(`persist agent model: ${e.message}`)
|
|
168
|
+
}
|
|
169
|
+
await cb.answer(t('ask.chose', { choice: model }, 'en'))
|
|
170
|
+
try {
|
|
171
|
+
if (cb.editMessage) await cb.editMessage(t('model.switched', { provider, model }, 'en'), REMOVE_KEYBOARD)
|
|
172
|
+
} catch (err) {
|
|
173
|
+
gw.recordApiFailure('cb.editMessage.model_picker_switched', err)
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
await cb.answer(`Error: ${err.message}`)
|
|
177
|
+
}
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (sub === 'cur') {
|
|
181
|
+
await cb.answer()
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await cb.answer()
|
|
187
|
+
}
|