@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.
@@ -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
+ }
@@ -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
+ }