sintbot 1.4.15 → 1.4.17

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.
@@ -0,0 +1,122 @@
1
+ 'use strict'
2
+ // 「这张模型卡能选什么」(;两家都改成实时清单)。bin 注入给 engine.modelCatalog,控制台同一份;core 只认返回形状:
3
+ // { ok, models: [{ id, name?, efforts?, defEffort? }] | null, aliases: [], aliasOf?: { 别名: 具体型号 }, efforts: [],
4
+ // recommended: { model, effort }, source: 'live' | 'saved' | 'builtin' }
5
+ // models === null ⇒ 这家没有可选清单(第三方端点):群里只能调深度,型号到后台改 —— 打错一个字下一轮就报错,
6
+ // 比让人在群里瞎试更稳。ok === false ⇒ 有清单但这会儿取不到(codex 探测失败、也没存过):型号这一项整条拒绝,不猜。
7
+ //
8
+ // 清单与名称一律取两家 CLI 的**实时数据**:
9
+ // Codex = app-server model/list(官方显示名 GPT-6.1-Sol、★ 默认型号、各型号支持与默认的深度);
10
+ // Claude 订阅 = Claude Code 初始化握手报的 /model 选单(claudemodels.probeModels:官方显示名、各型号支持的深度、
11
+ // default 指向的推荐型号;别名 opus / sonnet … 打进来存成它此刻指向的具体型号)。
12
+ // 都是 10 分钟缓存、过期先给旧的后台再刷(模型卡按钮回调要 3 秒内回包,现场探测要好几秒);每次拿到就存盘(cacheFile),
13
+ // 重启后 / 探测失败时先用存盘的(source 'saved')—— 不再退回写死在代码里、早已过时的候选。Claude 从没拿到过才用内置候选。
14
+ // 拿到的显示名登记进 core/modelname.js(群里回执、卡片、控制台都按官方名称显示)。
15
+ // recommended(没有「模型卡默认」):新绑的群、换到没选过的后端、模型卡「测试」预填 / 兜底用的推荐项 ——
16
+ // Codex = ★ 型号 + 它自带的默认深度;Claude 订阅 = 握手里 default 指向的型号 + xhigh(Claude Code 自己的默认档);
17
+ // 第三方 = 型号由模型卡定(空)+ xhigh。只用于预填与迁移,运行时不拿它悄悄兜底。
18
+
19
+ const fs = require('node:fs')
20
+ const path = require('node:path')
21
+ const { recommendEffort } = require('./core/modelcmd') // 推荐深度的口径只此一份(engine 的 /model 与模型卡同用)
22
+ const { setModelNames } = require('./core/modelname')
23
+
24
+ const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] // Claude Code 的五档(code.claude.com model-config)
25
+ // Codex 服务端真正接受的档(与控制台 RE_LEGAL 同一份口径):model/list 会过报(把 ultra 列进去),先过一道
26
+ const CODEX_LEGAL = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
27
+ const CLAUDE_ALIASES = ['opus', 'sonnet', 'haiku'] // 只在拿不到实时清单、退回内置候选时用(原样交给 CLI)
28
+ const CLAUDE_EFFORT = 'xhigh'
29
+ const TTL = 600_000
30
+
31
+ function createModelCatalog({ codexstatus, claudemodels, claudeBin = 'claude', cacheFile = null, now = () => Date.now(), log = () => {} } = {}) {
32
+ const live = { codex: null, claude: null } // { at, data }
33
+ const inflight = { codex: null, claude: null }
34
+ const fetchers = {
35
+ codex: async () => { const l = await codexstatus.listModels('codex'); return Array.isArray(l) && l.length ? l : null },
36
+ claude: async () => (claudemodels && typeof claudemodels.probeModels === 'function' ? claudemodels.probeModels(claudeBin) : null),
37
+ }
38
+ const register = (kind, data) => {
39
+ try {
40
+ if (kind === 'codex') setModelNames(data.map((m) => ({ id: m.model, name: m.name })))
41
+ else setModelNames(data.models)
42
+ } catch { /* 名称登记失败只影响显示 */ }
43
+ }
44
+ // 存盘的上次结果:at 记 0 ⇒ 第一次用到就后台再刷一次
45
+ if (cacheFile) {
46
+ try {
47
+ const saved = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
48
+ for (const k of ['codex', 'claude']) if (saved && saved[k] && saved[k].data) { live[k] = { at: 0, data: saved[k].data, saved: true }; register(k, saved[k].data) }
49
+ } catch { /* 没有 / 坏了就当没有 */ }
50
+ }
51
+ const persist = () => {
52
+ if (!cacheFile) return
53
+ try {
54
+ const out = {}
55
+ for (const k of ['codex', 'claude']) if (live[k]) out[k] = { at: live[k].at || now(), data: live[k].data }
56
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true })
57
+ const tmp = cacheFile + '.tmp'
58
+ fs.writeFileSync(tmp, JSON.stringify(out))
59
+ fs.renameSync(tmp, cacheFile)
60
+ } catch (e) { log('可选型号存盘失败: ' + (e && e.message)) }
61
+ }
62
+ const refresh = (kind) => {
63
+ if (!inflight[kind]) {
64
+ inflight[kind] = Promise.resolve().then(() => fetchers[kind]()).catch(() => null)
65
+ .then((data) => {
66
+ if (data) { live[kind] = { at: now(), data }; register(kind, data); persist() }
67
+ else log(`${kind === 'codex' ? 'codex' : 'Claude'} 可选型号探测失败 —— 沿用上次结果(如有)`)
68
+ })
69
+ .finally(() => { inflight[kind] = null })
70
+ }
71
+ return inflight[kind]
72
+ }
73
+ // 有结果就先用、过期了后台再刷;从没取到过(也没存盘)才等这一次
74
+ async function get(kind) {
75
+ const c = live[kind]
76
+ if (c) { if (now() - c.at >= TTL) refresh(kind); return c.data }
77
+ await refresh(kind)
78
+ return live[kind] ? live[kind].data : null
79
+ }
80
+ const sourceOf = (kind) => (live[kind] && live[kind].saved && !live[kind].at ? 'saved' : 'live')
81
+
82
+ async function catalog(name, provider) {
83
+ const p = provider || {}
84
+ if (p.runner === 'codex') {
85
+ const list = await get('codex')
86
+ if (!list) return { ok: false, models: [], aliases: [], efforts: CODEX_LEGAL.slice(1), recommended: { model: '', effort: '' } }
87
+ const models = list.map((m) => ({
88
+ id: m.model,
89
+ ...(m.name ? { name: m.name } : {}),
90
+ efforts: (m.efforts || []).filter((e) => CODEX_LEGAL.includes(e)),
91
+ ...(m.defEffort && CODEX_LEGAL.includes(m.defEffort) ? { defEffort: m.defEffort } : {}),
92
+ }))
93
+ const star = list.find((m) => m.isDefault) || list[0]
94
+ const cat = { ok: true, models, aliases: [], efforts: CODEX_LEGAL.slice(1), source: sourceOf('codex') }
95
+ cat.recommended = { model: star.model, effort: '' }
96
+ cat.recommended.effort = recommendEffort(cat, star.model)
97
+ return cat
98
+ }
99
+ if (!p.baseUrl) {
100
+ const d = await get('claude')
101
+ if (d && Array.isArray(d.models) && d.models.length) {
102
+ const models = d.models.map((m) => ({ id: m.id, name: m.name, ...(m.effort !== false && m.efforts && m.efforts.length ? { efforts: m.efforts.slice() } : {}) }))
103
+ const cat = { ok: true, models, aliases: [], aliasOf: { ...(d.aliasOf || {}) }, efforts: CLAUDE_EFFORTS.slice(), source: sourceOf('claude') }
104
+ cat.recommended = { model: d.recommended || models[0].id, effort: CLAUDE_EFFORT }
105
+ cat.recommended.effort = recommendEffort(cat, cat.recommended.model) // xhigh 不在这型号的档里才换
106
+ return cat
107
+ }
108
+ // 从没拿到过实时清单(没登录 / CLI 太老 / 超时)⇒ 内置候选 + 本机账号额外项 + 官方别名
109
+ let ids = []
110
+ try { ids = claudemodels.mergeModels(claudemodels.readExtraModels()).map((m) => m.id).filter(Boolean) } catch { ids = claudemodels.BUILTIN.map((m) => m.id).filter(Boolean) }
111
+ return { ok: true, models: ids.map((id) => ({ id })), aliases: CLAUDE_ALIASES.slice(), efforts: CLAUDE_EFFORTS.slice(), recommended: { model: ids[0] || '', effort: CLAUDE_EFFORT }, source: 'builtin' }
112
+ }
113
+ return { ok: true, models: null, aliases: [], efforts: CLAUDE_EFFORTS.slice(), recommended: { model: '', effort: CLAUDE_EFFORT } }
114
+ }
115
+ // 守护进程启动时预热(有哪家的卡才探哪家):第一张模型卡 / 第一次打开群卡就不用等探测
116
+ catalog.warm = (kinds = ['codex']) => Promise.all(kinds.filter((k) => fetchers[k]).map((k) => refresh(k)))
117
+ // 原始清单(控制台 /api/codex/models · /api/claude/models 用:要 isDefault / 说明这些字段)+ 它是不是实时的
118
+ catalog.raw = async (kind) => ({ data: fetchers[kind] ? await get(kind) : null, source: live[kind] ? sourceOf(kind) : 'none' })
119
+ return catalog
120
+ }
121
+
122
+ module.exports = { createModelCatalog, CLAUDE_EFFORTS, CODEX_LEGAL, CLAUDE_ALIASES }
@@ -569,22 +569,37 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
569
569
  // scope) is cached as null so we don't hammer the API, and callers fall back.
570
570
  const nameCache = new Map() // openId -> name | null
571
571
  let nameScopeWarned = false
572
- async function senderNameOf(openId) {
572
+ let memberNameWarned = false
573
+ async function senderNameOf(openId, chatId = null) {
573
574
  if (!openId) return null
574
575
  if (nameCache.has(openId)) return nameCache.get(openId)
576
+ let name = null
575
577
  try {
576
578
  const r = await client.contact.user.get({ params: { user_id_type: 'open_id' }, path: { user_id: openId } })
577
- const name = (r && r.data && r.data.user && (r.data.user.name || r.data.user.nickname)) || null
578
- nameCache.set(openId, name)
579
- return name
579
+ name = (r && r.data && r.data.user && (r.data.user.name || r.data.user.nickname)) || null
580
580
  } catch (e) {
581
- nameCache.set(openId, null)
582
581
  if (!nameScopeWarned) {
583
582
  nameScopeWarned = true
584
583
  log('feishu: 拉取用户名失败(需要 contact:contact.base:readonly scope?),发送者标注回退为 open_id — ' + larkErrMsg(e))
585
584
  }
586
- return null
587
585
  }
586
+ // 通讯录查不到(缺 scope、或发送者不在应用的通讯录范围内 —— 基础版多人群常见)时,退到当前群的
587
+ // 成员列表:名字随成员一起返回,顺带把同群其他人也写进同一份缓存。
588
+ if (!name && chatId) {
589
+ try {
590
+ for (const m of await fetchChatMembers(client, chatId)) {
591
+ if (m.name && m.name !== m.openId && !nameCache.get(m.openId)) nameCache.set(m.openId, m.name)
592
+ }
593
+ name = nameCache.get(openId) || null
594
+ } catch (e) {
595
+ if (!memberNameWarned) {
596
+ memberNameWarned = true
597
+ log('feishu: 群成员列表取名失败 — ' + larkErrMsg(e))
598
+ }
599
+ }
600
+ }
601
+ nameCache.set(openId, name)
602
+ return name
588
603
  }
589
604
 
590
605
  async function sendText(chatId, content) {
@@ -737,7 +752,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
737
752
  let senderName = openId || 'user'
738
753
  if (openId) {
739
754
  if (nameCache.has(openId)) senderName = nameCache.get(openId) || openId
740
- else senderNameOf(openId).catch(() => {}) // background warm; does not reorder onMessage
755
+ else senderNameOf(openId, message.chat_id).catch(() => {}) // background warm; does not reorder onMessage
741
756
  }
742
757
  // Fire-and-forget into the engine (NFR1): a turn can run for minutes,
743
758
  // and whether the SDK serializes pending handlers is not contractual —
@@ -848,6 +863,23 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
848
863
  return sendText(chatId, content)
849
864
  },
850
865
 
866
+ // One interactive reply under a group command (the /model card). Like
867
+ // replyOnce it is best-effort and never retried; the caller falls back
868
+ // to the text reply when this does not confirm.
869
+ async replyCardOnce(replyCtx, card) {
870
+ const mid = replyCtx && replyCtx.messageId
871
+ if (typeof mid !== 'string' || !/^om_[A-Za-z0-9_-]{1,124}$/.test(mid) ||
872
+ !card || typeof card !== 'object') {
873
+ throw new TypeError('Feishu card reply needs exact message target and a card')
874
+ }
875
+ return client.im.message.reply({ path: { message_id: mid },
876
+ data: { msg_type: 'interactive', content: JSON.stringify(card) } })
877
+ },
878
+
879
+ // open_id → display name (cached; null when unknown). The model card's
880
+ // receipt says who changed it; a card callback only carries the open_id.
881
+ nameOf: (openId) => senderNameOf(openId),
882
+
851
883
  // 发一张静态富卡,返回 { msgId, cardId }(msgId=按钮/表情命中锚点;cardId=实体更新回填用)。
852
884
  // 文本已由调用方在构卡时逐字段 redact(卡是 JSON 不能整体脱敏),故这里不再包 redact。
853
885
  async sendCard(chatId, cardJson) {
@@ -0,0 +1,103 @@
1
+ 'use strict'
2
+ // 群里 /model 的「模型卡」。发 /model 不带参数 ⇒ 一张能点的卡:
3
+ // 后端一排按钮 · 型号一个下拉 · 思考深度一排按钮 + 「确认更新」「关闭」;确认后卡片收成一行回执(卡片本身就是回执,不另发消息);
4
+ // 10 分钟后过期翻只读(防止往上翻聊天记录时误点到几天前的老卡)。
5
+ // 纯函数、零依赖:飞书卡片 schema 2.0 的 JSON 形状只在此一处;样式与通知卡同一套(撑满宽 · 标题栏底色说类别 ·
6
+ // 注意事项与右下角小字用 notation 灰字;⚠ 2.0 不认 note 组件)。没有这种卡的平台(企微)由调用方退回文本回执。
7
+ // ⚠ 脱敏:型号 / 后端名来自配置与清单,没有用户输入;结果行里的人名来自平台,同样经 opts.redact。
8
+
9
+ const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
10
+ const DEFAULT_VALUE = '__default__' // 下拉里「跟随默认」那一项的值(选项值不能是空串)
11
+
12
+ const grey = (s, size = 'notation') => ({ tag: 'markdown', text_size: size, content: `<font color='grey'>${esc(s)}</font>` })
13
+
14
+ function button(label, { selected = false, value, disabled = false } = {}) {
15
+ const b = { tag: 'button', text: { tag: 'plain_text', content: label }, type: selected ? 'primary_filled' : 'default', size: 'small', width: 'default' }
16
+ if (disabled) b.disabled = true
17
+ if (value) b.behaviors = [{ type: 'callback', value }]
18
+ return b
19
+ }
20
+
21
+ // 一排按钮:flow 布局,窄屏自动折行
22
+ function buttonRow(buttons) {
23
+ return { tag: 'column_set', flex_mode: 'flow', horizontal_spacing: '6px', horizontal_align: 'left', columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })) }
24
+ }
25
+
26
+ /**
27
+ * 「先选、再确认」(点一下就生效容易点错):
28
+ * 点后端 / 选型号 / 点深度只改**卡上的草稿**(卡片重画选中状态,什么都没生效);草稿与当前不同时出一行「将改为 … —— 会不会开新对话」,
29
+ * 「确认更新」一次生效(只写一次、最多开一次新对话、审计一条),卡片收成一行回执;「关闭」什么都不改,卡片原地变成灰色「已关闭」。
30
+ * 草稿随每个回调值带回去(无状态,守护进程重启也不丢):{ a:'mc', f:'p'|'m'|'e'|'ok'|'x', v?, d:{p,m,e}, t }
31
+ * —— p 后端 · m 型号 · e 深度('' = 跟随默认);下拉的选中值在回调的 action.option 里。
32
+ * @param s {
33
+ * mode: 'edit' | 'receipt' | 'closed' | 'expired', current: 'claude · claude-opus-5 · xhigh'(当前生效的)
34
+ * failover: '', quota: '', —— 额度临时切换那一句 / 缓存额度(有才给)
35
+ * draft: { p, m, e }, —— edit:卡上正在选的
36
+ * backends: [{ name, selected }], model: { options: [{ value, label }], selected } | null(null = 这家型号到后台改),
37
+ * efforts: [{ value, label, selected }], —— label 里由调用方标「(默认)」
38
+ * preview: { to, note } | null, —— 草稿与当前不同才有;null ⇒「确认更新」灰着
39
+ * receipt: { line, before }, —— receipt:谁改成了什么 · 之前是什么
40
+ * issuedAt: 发卡 / 上次操作的时刻(写进回调值,10 分钟过期)
41
+ * }
42
+ */
43
+ function buildModelCard(s, { redact } = {}) {
44
+ const r = typeof redact === 'function' ? (x) => String(redact(String(x))) : (x) => String(x)
45
+ const mode = s.mode || (s.expired ? 'expired' : 'edit')
46
+ const t = s.issuedAt || 0
47
+ const d = { p: '', m: '', e: '', ...(s.draft || {}) }
48
+ const cb = (f, v) => ({ a: 'mc', f, ...(v !== undefined ? { v } : {}), d, t })
49
+ const els = []
50
+ const head = (title, template, tag, tagColor) => ({
51
+ title: { tag: 'plain_text', content: title },
52
+ subtitle: { tag: 'plain_text', content: r(s.current || '') },
53
+ template, text_tag_list: [{ tag: 'text_tag', text: { tag: 'plain_text', content: tag }, color: tagColor }],
54
+ padding: '12px 12px 12px 12px',
55
+ })
56
+ let header
57
+ if (mode === 'receipt') {
58
+ // 确认之后:收成一行回执 —— 控件全收起(卡片就此「关闭」),群里留一条谁改成了什么
59
+ header = head('模型已更新', 'green', '已更新', 'green')
60
+ const rc = s.receipt || {}
61
+ els.push({ tag: 'markdown', text_size: 'normal', content: esc(r(rc.line || '')) })
62
+ if (rc.before) els.push(grey(r(rc.before)))
63
+ } else if (mode === 'closed') {
64
+ header = head('本群模型', 'grey', '已关闭', 'neutral')
65
+ els.push(grey('已关闭 · 模型没变', 'normal'))
66
+ } else if (mode === 'expired') {
67
+ header = head('本群模型', 'grey', '已过期', 'neutral')
68
+ els.push(grey('这张卡已过期,没有改任何东西 —— 发 /model 重新打开', 'normal'))
69
+ } else {
70
+ header = head('本群模型', 'indigo', '模型', 'indigo')
71
+ const info = []
72
+ if (s.failover) info.push(`**额度切换** ${esc(s.failover)}`)
73
+ if (s.quota) info.push(`**额度** ${esc(s.quota)}`)
74
+ if (info.length) els.push({ tag: 'markdown', text_size: 'normal', content: info.join('\n') })
75
+ const backendBtns = buttonRow((s.backends || []).map((b) => button(b.name, { selected: b.selected, value: b.selected ? null : cb('p', b.name) })))
76
+ let modelCtl
77
+ if (s.model) {
78
+ modelCtl = {
79
+ tag: 'select_static', width: 'fill', type: 'default',
80
+ placeholder: { tag: 'plain_text', content: '选一个型号' },
81
+ initial_option: s.model.selected || DEFAULT_VALUE,
82
+ options: s.model.options.map((o) => ({ text: { tag: 'plain_text', content: o.label }, value: o.value || DEFAULT_VALUE })),
83
+ behaviors: [{ type: 'callback', value: cb('m') }],
84
+ }
85
+ } else modelCtl = grey('这个后端的型号在控制台改', 'normal')
86
+ const effortBtns = buttonRow((s.efforts || []).map((e) => button(e.label || e.value, { selected: e.selected, value: e.selected ? null : cb('e', e.value || DEFAULT_VALUE) })))
87
+ // 标签在上、控件在下(手机上五个深度按钮一排放得下)
88
+ for (const [label, ctl] of [['后端', backendBtns], ['型号', modelCtl], ['思考深度', effortBtns]]) els.push(grey(label), ctl)
89
+ els.push({ tag: 'hr' })
90
+ if (s.preview) {
91
+ els.push({ tag: 'markdown', text_size: 'normal', content: `**将改为** ${esc(s.preview.to)}` })
92
+ if (s.preview.note) els.push(grey(s.preview.note))
93
+ }
94
+ els.push(buttonRow([
95
+ button('确认更新', { selected: !!s.preview, value: s.preview ? cb('ok') : null, disabled: !s.preview }),
96
+ button('关闭', { value: { a: 'mc', f: 'x', t } }),
97
+ ]))
98
+ els.push(grey('选好后点「确认更新」才生效 · 换后端会开新对话,换型号或调深度接着当前对话 · 要等手头任务结束 · 10 分钟内有效 · 点不动时可直接发 /model high 这类文字'))
99
+ }
100
+ return { schema: '2.0', config: { width_mode: 'fill', update_multi: true }, header, body: { padding: '12px 12px 12px 12px', vertical_spacing: '8px', elements: els } }
101
+ }
102
+
103
+ module.exports = { buildModelCard, DEFAULT_VALUE }