@0xmaxma/claude-gateway 1.8.0 → 1.8.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.
@@ -1198,6 +1198,62 @@ bot.command('model', async ctx => {
1198
1198
  }
1199
1199
  })
1200
1200
 
1201
+ /** Fetches current model + configured/live model lists. Shared by /models, models:back and models:more so all three agree on fallback behavior. */
1202
+ async function fetchModelMenuData(chatId: string): Promise<{
1203
+ currentModel: string
1204
+ configuredModels: Array<{ id: string; label: string }>
1205
+ liveModels: Array<{ id: string; label: string }>
1206
+ } | null> {
1207
+ if (!CALLBACK_URL_BASE) return null
1208
+ const [modelRes, modelsRes] = await Promise.all([
1209
+ fetch(CALLBACK_URL_BASE + '/command', {
1210
+ method: 'POST',
1211
+ headers: { 'Content-Type': 'application/json' },
1212
+ body: JSON.stringify({ command: 'get_model', chat_id: chatId }),
1213
+ }),
1214
+ fetch(CALLBACK_URL_BASE + '/command', {
1215
+ method: 'POST',
1216
+ headers: { 'Content-Type': 'application/json' },
1217
+ body: JSON.stringify({ command: 'get_models' }),
1218
+ }),
1219
+ ])
1220
+ if (!modelRes.ok || !modelsRes.ok) throw new Error('model request failed')
1221
+ const modelData = (await modelRes.json()) as { model?: string }
1222
+ const modelsData = (await modelsRes.json()) as {
1223
+ models?: unknown
1224
+ configuredModels?: unknown
1225
+ liveModels?: unknown
1226
+ }
1227
+ const currentModel = typeof modelData.model === 'string' ? modelData.model : ''
1228
+ const availableRaw = validModelRows(modelsData.models)
1229
+ const availableModels = availableRaw.length ? availableRaw : validModelRows(AVAILABLE_MODELS)
1230
+ const configuredModels = Array.isArray(modelsData.configuredModels)
1231
+ ? validModelRows(modelsData.configuredModels)
1232
+ : availableModels
1233
+ const liveModels = Array.isArray(modelsData.liveModels)
1234
+ ? validModelRows(modelsData.liveModels)
1235
+ : availableModels.filter(m => !configuredModels.some(c => c.id === m.id))
1236
+ return { currentModel, configuredModels, liveModels }
1237
+ }
1238
+
1239
+ /** Builds the root /models keyboard: one row per configured model, plus "More models..." when live-only models exist. */
1240
+ function buildModelsRootKeyboard(
1241
+ currentModel: string,
1242
+ configuredModels: Array<{ id: string; label: string }>,
1243
+ liveModels: Array<{ id: string; label: string }>,
1244
+ ): InlineKeyboard {
1245
+ const keyboard = new InlineKeyboard()
1246
+ for (const m of configuredModels) {
1247
+ const id = safeModelId(m.id)
1248
+ if (!id) continue
1249
+ const prefix = m.id === currentModel ? '\u2705 ' : ''
1250
+ keyboard.text(`${prefix}${m.label}`, `model:${id}`).row()
1251
+ }
1252
+ if (liveModels.length) keyboard.text('More models...', 'models:more:0').row()
1253
+ keyboard.text('Dismiss', 'models:dismiss')
1254
+ return keyboard
1255
+ }
1256
+
1201
1257
  // /models — show model selection keyboard (receiver mode only)
1202
1258
  bot.command('models', async ctx => {
1203
1259
  if (ctx.chat?.type !== 'private') return
@@ -1206,31 +1262,10 @@ bot.command('models', async ctx => {
1206
1262
  if (!CALLBACK_URL_BASE) return
1207
1263
 
1208
1264
  try {
1209
- const [modelRes, modelsRes] = await Promise.all([
1210
- fetch(CALLBACK_URL_BASE + '/command', {
1211
- method: 'POST',
1212
- headers: { 'Content-Type': 'application/json' },
1213
- body: JSON.stringify({ command: 'get_model', chat_id: String(ctx.chat.id) }),
1214
- }),
1215
- fetch(CALLBACK_URL_BASE + '/command', {
1216
- method: 'POST',
1217
- headers: { 'Content-Type': 'application/json' },
1218
- body: JSON.stringify({ command: 'get_models' }),
1219
- }),
1220
- ])
1221
- const modelData = (await modelRes.json()) as { model?: string }
1222
- const modelsData = (await modelsRes.json()) as { models?: { id: string; label: string }[] }
1223
- const currentModel = modelData.model ?? ''
1224
- const availableModels = modelsData.models ?? AVAILABLE_MODELS
1225
-
1226
- const keyboard = new InlineKeyboard()
1227
- for (const m of availableModels) {
1228
- const prefix = m.id === currentModel ? '\u2705 ' : ''
1229
- keyboard.text(`${prefix}${m.label}`, `model:${m.id}`).row()
1230
- }
1231
- keyboard.text('Dismiss', 'models:dismiss')
1232
-
1233
- await ctx.reply(`Current model: ${currentModel}\nSelect a model:`, {
1265
+ const menu = await fetchModelMenuData(String(ctx.chat.id))
1266
+ if (!menu) return
1267
+ const keyboard = buildModelsRootKeyboard(menu.currentModel, menu.configuredModels, menu.liveModels)
1268
+ await ctx.reply(`Current model: ${menu.currentModel}\nSelect a model:`, {
1234
1269
  reply_markup: keyboard,
1235
1270
  })
1236
1271
  } catch (err) {
@@ -1403,6 +1438,26 @@ function isCallbackAuthorized(ctx: Context): boolean {
1403
1438
  return access.allowFrom.includes(String(ctx.from?.id ?? ''))
1404
1439
  }
1405
1440
 
1441
+ /** Telegram callback_data is capped at 64 UTF-8 bytes; reject untrusted ids safely. */
1442
+ function safeModelId(value: unknown): string | null {
1443
+ if (typeof value !== 'string') return null
1444
+ const id = value.trim()
1445
+ return id && Buffer.byteLength(`model:${id}`, 'utf8') <= 64 ? id : null
1446
+ }
1447
+
1448
+ function validModelRows(value: unknown): Array<{ id: string; label: string }> {
1449
+ if (!Array.isArray(value)) return []
1450
+ const rows: Array<{ id: string; label: string }> = []
1451
+ for (const item of value) {
1452
+ if (typeof item !== 'object' || item === null) continue
1453
+ const row = item as { id?: unknown; model_id?: unknown; label?: unknown; display_name?: unknown; name?: unknown }
1454
+ const id = safeModelId(row.id) ?? safeModelId(row.model_id)
1455
+ const labelValue = [row.label, row.display_name, row.name].find(v => typeof v === 'string' && v.trim())
1456
+ if (id && typeof labelValue === 'string') rows.push({ id, label: labelValue.trim() })
1457
+ }
1458
+ return rows
1459
+ }
1460
+
1406
1461
  // Inline-button handler for permission requests. Callback data is
1407
1462
  // `perm:allow:<id>`, `perm:deny:<id>`, or `perm:more:<id>`.
1408
1463
  // Security mirrors the text-reply path: allowFrom must contain the sender.
@@ -1489,6 +1544,72 @@ bot.on('callback_query:data', async ctx => {
1489
1544
  return
1490
1545
  }
1491
1546
 
1547
+ const moreMatch = /^models:more:(\d+)$/.exec(data)
1548
+ if (moreMatch) {
1549
+ if (!isCallbackAuthorized(ctx)) {
1550
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1551
+ return
1552
+ }
1553
+ if (!CALLBACK_URL_BASE) {
1554
+ await ctx.answerCallbackQuery({ text: 'Not available.' }).catch(() => {})
1555
+ return
1556
+ }
1557
+ try {
1558
+ const chatId = String(ctx.callbackQuery.message?.chat.id ?? ctx.from.id)
1559
+ const menu = await fetchModelMenuData(chatId)
1560
+ if (!menu) {
1561
+ await ctx.answerCallbackQuery({ text: 'Not available.' }).catch(() => {})
1562
+ return
1563
+ }
1564
+ const { currentModel, liveModels } = menu
1565
+ const page = Number(moreMatch[1])
1566
+ const pageSize = 16
1567
+ const totalPages = Math.max(1, Math.ceil(liveModels.length / pageSize))
1568
+ const safePage = Math.min(page, totalPages - 1)
1569
+ const keyboard = new InlineKeyboard()
1570
+ for (const [index, m] of liveModels.slice(safePage * pageSize, (safePage + 1) * pageSize).entries()) {
1571
+ const modelCallback = safeModelId(m.id)
1572
+ if (!modelCallback) continue
1573
+ keyboard.text(`${m.id === currentModel ? '\u2705 ' : ''}${m.id}`, `model:${modelCallback}`)
1574
+ if (index % 2 === 1) keyboard.row()
1575
+ }
1576
+ if (liveModels.slice(safePage * pageSize, (safePage + 1) * pageSize).length % 2 === 1) keyboard.row()
1577
+ keyboard.text('«', safePage === 0 ? 'models:back' : `models:more:${safePage - 1}`)
1578
+ if (safePage < totalPages - 1) keyboard.text('»', `models:more:${safePage + 1}`)
1579
+ keyboard.row().text('Dismiss', 'models:dismiss')
1580
+ await ctx.answerCallbackQuery().catch(() => {})
1581
+ await ctx.editMessageText(`More models (live) — page ${safePage + 1}/${totalPages}:`, { reply_markup: keyboard })
1582
+ } catch {
1583
+ await ctx.answerCallbackQuery({ text: 'Request failed' }).catch(() => {})
1584
+ }
1585
+ return
1586
+ }
1587
+
1588
+ if (data === 'models:back') {
1589
+ if (!isCallbackAuthorized(ctx)) {
1590
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
1591
+ return
1592
+ }
1593
+ if (!CALLBACK_URL_BASE) {
1594
+ await ctx.answerCallbackQuery({ text: 'Not available.' }).catch(() => {})
1595
+ return
1596
+ }
1597
+ try {
1598
+ const chatId = String(ctx.callbackQuery.message?.chat.id ?? ctx.from.id)
1599
+ const menu = await fetchModelMenuData(chatId)
1600
+ if (!menu) {
1601
+ await ctx.answerCallbackQuery({ text: 'Not available.' }).catch(() => {})
1602
+ return
1603
+ }
1604
+ const keyboard = buildModelsRootKeyboard(menu.currentModel, menu.configuredModels, menu.liveModels)
1605
+ await ctx.answerCallbackQuery().catch(() => {})
1606
+ await ctx.editMessageText(`Current model: ${menu.currentModel}\nSelect a model:`, { reply_markup: keyboard })
1607
+ } catch {
1608
+ await ctx.answerCallbackQuery({ text: 'Request failed' }).catch(() => {})
1609
+ }
1610
+ return
1611
+ }
1612
+
1492
1613
  // Handle model selection callback: model:<model_id>
1493
1614
  const modelMatch = /^model:(.+)$/.exec(data)
1494
1615
  if (modelMatch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",