sintbot 1.3.0 → 1.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.
Files changed (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
@@ -0,0 +1,2371 @@
1
+ // sbot console — a local control panel served by Node's http(除「接管浏览器」经 src/cdp.js 用 `ws` 连 CDP 外零依赖).
2
+ // Binds to 127.0.0.1 only; REST for actions, SSE for live push. Reads/writes the
3
+ // same config file the CLI uses (raw form, so `env:VAR` refs survive), redacts
4
+ // secrets on the way out. Shares health.js / feishu helpers with the CLI so the
5
+ // two stay in sync.
6
+
7
+ const http = require('node:http')
8
+ const crypto = require('node:crypto')
9
+ const fs = require('node:fs')
10
+ const os = require('node:os')
11
+ const path = require('node:path')
12
+ const { loadConfig, applyProxyEnv, deriveNoProxy, DEFAULTS, resolveSecret } = require('../config')
13
+ const verify = require('../core/verify') // 凭据最近一次验证的记录:模型测试 / 平台预检写,概览读
14
+ const projectsroot = require('../projectsroot') // 项目根目录围栏:目录浏览 / 保存绑定 / 风格提炼三处同一份判据
15
+ const { atomicWriteJSON } = require('../core/store')
16
+ const { createLedger } = require('../core/ledger')
17
+ const { normalizeTz, isValidTz, tzOffsetMin, tzParts } = require('../core/time')
18
+ // Runner dispatch (Claude vs Codex by provider.runner) — so 测试/试跑 run codex
19
+ // providers on the Codex CLI, not Claude. Same module the daemon/CLI use.
20
+ const { runTurn, probeVersion } = require('../agent/runner')
21
+ const codexstatus = require('../agent/codexstatus') // codex 订阅登录态 + 用量(卡片「验证登录」)
22
+ const claudestatus = require('../agent/claudestatus') // Claude 订阅登录态(卡片「检测登录」· doctor 同源)
23
+ const claudemodels = require('../agent/claudemodels') // Claude 模型候选(仅登录后给)
24
+ const claudeusage = require('../agent/claudeusage') // Claude 订阅额度(oauth/usage,cc-hud 同源;v0.11.3)
25
+ const codexAgent = require('../agent/codexcode') // codex --version 探针(概览健康,仅配了 codex provider 时)
26
+ const { globalPlugins, codexPlugins, projectPlugins, mcpReachability } = require('../plugins')
27
+ const gitrepo = require('../gitrepo') // 群项目 ↔ git 远端关联(「仓库地址」:init + origin,幂等)+ ls-remote 连通性探测
28
+ const design = require('../design') // 设计风格档:风格库(内置预设 + 自定义)与各群当前风格档的可视化卡片
29
+ const preview = require('../preview') // 群预览服务:运行中的预览要在概览里看得见、能一键停(那个挂了一周的服务就是因为没人看得见)
30
+ const { createIssues, summarize, fingerprint } = require('../core/issues') // 群内反馈本:同理 —— 只躺在文件里没人看,和消失没区别
31
+ const leftover = require('../leftover') // 遗留进程:对话轮次结束后仍在跑的后台 shell —— 同一条道理,那条每 5 秒打线上接口的循环跑了 35 天就是因为没处看见
32
+ const { withFileLock } = require('../core/store') // 反馈本改状态要与 CLI/群命令共用同一把跨进程锁
33
+ const { redact } = require('../core/redact') // 出站脱敏:预览停止通知里含 agent 提供的 label
34
+ const { hashPassword, verifyPassword } = require('./auth')
35
+ const feishulogin = require('./feishulogin') // 控制台「用飞书登录」:OAuth 授权码流程的纯逻辑(公网入口;只放行管理员名单)
36
+ const { createSessionStore } = require('./sessions') // 登录会话落盘 + 30 天过期(守护进程重启不再把人登出)
37
+ const { listWecomSessions, wecomAuthShow } = require('../platform/wecom')
38
+ const { createWecomCli } = require('../platform/wecomcli')
39
+ const { guessBotName } = require('../platform/wecomname') // 机器人名从群历史的 @ 里统计(企微没有取机器人信息的接口) // 企业微信:群候选 = 机器人被 @ 过的群(控制台「绑定群」用)
40
+ const { platformsConfigured, chatPlatform, inferChatPlatform, PLATFORMS } = require('../config') // 双平台并存:哪些平台配齐了 · 某群属于哪个平台(与 daemon 路由器同一个函数)
41
+ const { feishuClient, fetchBotOpenId, fetchAppIdentity, fetchChats, createChat, fetchChatMembers, addChatMembers, deleteChat, sendChatText, larkErrMsg, feishuPrecheck, fetchAdminCandidates, resolveContactToOpenId, fetchOrgUsers } = require('../platform/feishu')
42
+ const { slackPrecheck, fetchSlackChannels, sendSlackText } = require('../platform/slack') // Slack:控制台侧只用这三个助手(收发在 daemon 里)
43
+ const { getUsage, refreshUsage } = require('../usage')
44
+
45
+ // 反馈本默认落点(与 CLI / 群命令同一个文件 —— 三个入口一本账,不许各记各的)
46
+ const DEFAULT_ISSUES_FILE = () => path.join(os.homedir(), '.sbot', 'issues.jsonl')
47
+ // 登录会话落点(与反馈本同目录;注入式只为可测)
48
+ const DEFAULT_SESSIONS_FILE = () => path.join(os.homedir(), '.sbot', 'ui-sessions.json')
49
+
50
+ const CONSOLE_HTML = path.join(__dirname, 'console.html')
51
+ const BROWSER_HTML = path.join(__dirname, 'browser.html') // 接管浏览器页(自包含单文件,群成员凭一次性链接也能进)
52
+ const takeover = require('./takeover') // 接管链接的票据与 cookie(纯逻辑)
53
+ const dashboard = require('./dashboard') // 「需要你处理」收件箱 + 活动流的账本回填(纯逻辑)
54
+ const { createBrowserRoutes } = require('./browserroutes') // /api/browser/* 的实现;这里只裁决谁能进
55
+ const PKG_VERSION = (() => { try { return 'v' + require('../../package.json').version } catch { return '' } })() // 顶栏版本徽章
56
+ let changelogCache = null // 随包分发的版本历史,进程内读一次
57
+ const readChangelog = () => {
58
+ if (changelogCache) return changelogCache
59
+ try { changelogCache = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'CHANGELOG.json'), 'utf8')) } catch { changelogCache = [] }
60
+ return Array.isArray(changelogCache) ? changelogCache : (changelogCache = [])
61
+ }
62
+ // 版本历史的英文本(CHANGELOG.en.json,按 version 与中文条目对应,可以缺版本 —— 缺了页面回退中文)。
63
+ // 没这个文件 / 文件坏了都当「一条英文都没有」,整卡照旧出中文,不是错误。
64
+ let changelogEnCache = null
65
+ const readChangelogEn = () => {
66
+ if (changelogEnCache) return changelogEnCache
67
+ try { changelogEnCache = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'CHANGELOG.en.json'), 'utf8')) } catch { changelogEnCache = [] }
68
+ return Array.isArray(changelogEnCache) ? changelogEnCache : (changelogEnCache = [])
69
+ }
70
+ const isEnvRef = (v) => typeof v === 'string' && v.startsWith('env:')
71
+ // 目录浏览—— 只在**项目根目录**之内一层层列。围栏判据(哪些根 / realpath 比较)全在
72
+ // src/projectsroot.js,与保存绑定、风格提炼、doctor 同一份;这里只管列目录。
73
+ // `roots` = 有效根目录数组(字符串也认,单测传单个根)。target 在某个根之内就列它,否则**夹回**:
74
+ // 单根 ⇒ 根本身;多根 ⇒ 先出「选一个根」的列表(cwd 为空,rootList=true,前端据此不画新建行)。
75
+ // 只列目录;点开头目录与 node_modules 不列(那些不是项目,只是噪音);符号链接不列(Dirent.isDirectory
76
+ // 对链接为 false)—— 但手填一个穿过链接的路径同样过不了 realpath 围栏,链接指到根外就夹回。
77
+ // createMissing:默认根不存在就建(围栏得有个实在的底);配置里指定的根**不建** —— 挂载盘没挂时把目录
78
+ // 建在挂载点上是事故不是帮忙,那种情况 doctor 会报 fail。
79
+ function listDirs(target, roots, { createMissing = false } = {}) {
80
+ roots = Array.isArray(roots) ? roots : (roots ? [roots] : [])
81
+ if (!roots.length) throw new Error('项目根目录不可用:配置里的 projectsRoots 没有一条有效')
82
+ let root = null, cwd = null
83
+ if (target) {
84
+ root = projectsroot.withinRoots(target, roots)
85
+ if (root) cwd = path.resolve(target)
86
+ }
87
+ if (!cwd) {
88
+ if (roots.length > 1) return { cwd: '', root: '', roots, items: roots.map((r) => ({ name: r, path: r })), atRoot: true, rootList: true }
89
+ root = roots[0]; cwd = root
90
+ }
91
+ if (createMissing && !fs.existsSync(root)) { try { fs.mkdirSync(root, { recursive: true }) } catch { /* 下面 readdir 把真实错误报出来 */ } }
92
+ let names
93
+ try { names = fs.readdirSync(cwd, { withFileTypes: true }) }
94
+ catch (e) { throw new Error('无法读取目录: ' + cwd + '(' + (e && e.code) + ')') }
95
+ const items = []
96
+ for (const d of names) {
97
+ if (d.name.startsWith('.') || d.name === 'node_modules') continue
98
+ try { if (!d.isDirectory()) continue } catch { continue }
99
+ items.push({ name: d.name, path: path.join(cwd, d.name) })
100
+ }
101
+ items.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
102
+ const atRoot = projectsroot.norm(projectsroot.realpathLenient(cwd)) === projectsroot.norm(projectsroot.realpathLenient(root))
103
+ return { cwd, items, root, roots, atRoot }
104
+ }
105
+ // 从目录浏览器新建项目文件夹 —— 同样围在根目录之内。`name` 只能是单段安全名(无分隔符 / 穿越 / 非法字符);
106
+ // `parent` 不在任何根内就夹回:单根 ⇒ 根;多根 ⇒ 报错让人先进入某个根(不替他猜建在哪)。返回新目录,浏览器直接进入。
107
+ function makeDir(parent, name, roots, { createMissing = false } = {}) {
108
+ roots = Array.isArray(roots) ? roots : (roots ? [roots] : [])
109
+ if (!roots.length) throw new Error('项目根目录不可用:配置里的 projectsRoots 没有一条有效')
110
+ name = String(name || '').trim()
111
+ if (!name) throw new Error('文件夹名必填')
112
+ if (/[\\/]/.test(name) || name === '.' || name === '..' || /[:*?"<>|]/.test(name)) throw new Error('文件夹名含非法字符(不能有 / \\ : * ? " < > |)')
113
+ if (name.startsWith('.')) throw new Error('文件夹名不能以 . 开头(选择器不列这类目录,建了就找不回来)')
114
+ let base = parent && projectsroot.withinRoots(parent, roots) ? path.resolve(parent) : null
115
+ if (!base) {
116
+ if (roots.length > 1) throw new Error('先进入某个项目根目录再新建')
117
+ base = roots[0]
118
+ }
119
+ // 只补建**根本身**(与 listDirs 同口径),不替人把不存在的多级父目录一路建出来
120
+ if (createMissing && projectsroot.norm(base) === projectsroot.norm(roots[0]) && !fs.existsSync(base)) { try { fs.mkdirSync(base, { recursive: true }) } catch { /* 下面 mkdir 把真实错误报出来 */ } }
121
+ const dir = path.join(base, name)
122
+ if (!projectsroot.withinRoots(dir, roots)) throw new Error('超出项目根目录范围')
123
+ if (fs.existsSync(dir)) throw new Error('该文件夹已存在')
124
+ fs.mkdirSync(dir, { recursive: false })
125
+ return { path: dir, parent: base, name }
126
+ }
127
+ const keyStatus = (k) => (!k ? 'none' : isEnvRef(k) ? 'env' : 'set')
128
+ const resolvedKey = (pr) => (pr.apiKey ? (isEnvRef(pr.apiKey) ? (process.env[pr.apiKey.slice(4)] || '') : pr.apiKey) : '')
129
+ const isConfigured = (pr) => !pr.baseUrl || !!resolvedKey(pr) // logged-in, or has a resolvable key
130
+ // The default provider = the first configured, non-hidden one in list order (so
131
+ // "drag to the top" = set default). Falls back to the current one if none fit.
132
+ const pickDefault = (providers) => { for (const [n, pr] of Object.entries(providers)) if (!pr.hidden && isConfigured(pr)) return n; return null }
133
+ const fixDefault = (raw) => { const d = raw.providers && raw.providers[raw.defaultProvider]; if (!d || d.hidden || !isConfigured(d)) { const n = pickDefault(raw.providers || {}); if (n) raw.defaultProvider = n } }
134
+ // Admins (可多个) — the operators auto-invited into console-created groups. Migrates
135
+ // the legacy single feishu.adminOpenId/adminName into a one-element list so old
136
+ // configs keep working. Returns [{openId, name}].
137
+ const adminList = (fe) => {
138
+ if (!fe) return []
139
+ if (Array.isArray(fe.admins)) return fe.admins.filter((a) => a && a.openId).map((a) => ({ openId: a.openId, name: a.name || '' }))
140
+ return fe.adminOpenId ? [{ openId: fe.adminOpenId, name: fe.adminName || '' }] : []
141
+ }
142
+ // 应用身份 → 落配置的展示缓存。取名短字段是因为它纯给前端渲染;`descState`/`descHint` 保留,
143
+ // 缺权限时前端要照实说「描述为什么没有 / 怎么开」,而不是留一片空白让人以为应用没填描述。
144
+ const identityCache = (id) => ({
145
+ name: id.appName || '', desc: id.description || '', avatar: id.avatarUrl || '',
146
+ status: id.activateText || '', statusOk: !!id.activateOk,
147
+ descState: id.descState || '', descHint: id.descHint || '', at: Date.now(),
148
+ })
149
+
150
+ // 安装命令的实际执行串。Windows:cmd.exe 与多数 CLI 按 OEM 码页(zh-CN 为 GBK)输出,
151
+ // Node exec 默认按 UTF-8 解码 → 中文报错乱码(如"'X' 不是内部或外部命令")。先把会话码页
152
+ // 切到 UTF-8(65001)再跑,输出即可正确解码;`>nul` 吞掉 chcp 自身回显。非 Windows 原样。
153
+ function execShellCommand(cmd, platform = process.platform) {
154
+ return platform === 'win32' ? `chcp 65001>nul & ${cmd}` : cmd
155
+ }
156
+
157
+ // 飞书 WS 连接健康度 → 概览健康行的状态/文案(watchdog snapshot,见 wswatchdog.js)。
158
+ // connected=绿;connecting/reconnecting=黄(自愈中);failed/其它=红。
159
+ function connHealthStatus(c) {
160
+ if (!c) return 'warn'
161
+ if (c.state === 'connected') return 'ok'
162
+ if (c.state === 'connecting' || c.state === 'reconnecting') return 'warn'
163
+ return 'fail' // failed / stopped
164
+ }
165
+ // 连接健康字典 → 概览健康行(一行一个平台)。⚠ 兼容旧形状:单个快照(带 state 字段)按飞书算 —— 单测与旧 daemon 都这么给。
166
+ const CONN_LABEL = { feishu: '飞书连接', wecom: '企业微信连接', slack: 'Slack 连接' }
167
+ function connHealthRows(conn) {
168
+ if (!conn) return []
169
+ if (typeof conn.state === 'string') return [{ status: connHealthStatus(conn), label: CONN_LABEL.feishu, detail: connHealthDetail(conn) }]
170
+ return Object.entries(conn).filter(([k]) => CONN_LABEL[k]).map(([k, c]) => ({ status: connHealthStatus(c), label: CONN_LABEL[k], detail: connHealthDetail(c) }))
171
+ }
172
+ function connHealthDetail(c) {
173
+ if (!c) return ''
174
+ const label = { connected: '已连接', connecting: '连接中', reconnecting: '重连中', failed: '连接失败', stopped: '已停止' }[c.state] || c.state
175
+ const bits = [label]
176
+ if (c.reconnects) bits.push(`重连 ${c.reconnects}`)
177
+ if (c.rebuilds) bits.push(`重建 ${c.rebuilds}`) // 看门狗强制重建次数——非零=SDK 曾放弃
178
+ if (c.state !== 'connected' && c.lastError) bits.push(String(c.lastError).slice(0, 60))
179
+ return bits.join(' · ')
180
+ }
181
+
182
+ // 本机非环回 IPv4(局域网访问地址,控制台「本机访问地址」卡用)
183
+ function lanIPv4() {
184
+ const out = []
185
+ const ifs = os.networkInterfaces()
186
+ for (const name of Object.keys(ifs)) for (const ni of ifs[name] || []) if (ni.family === 'IPv4' && !ni.internal) out.push(ni.address)
187
+ return out
188
+ }
189
+
190
+ // control (optional, only in `sbot start --ui`) lets the console drive the live
191
+ // daemon: switch a group's model, clear its session, stop its turn — in real time.
192
+ // 「配了访问密码吗」在配置读不出来时怎么判(纯函数,可测)。
193
+ // ⚠ 只绑环回:退成「没配」—— 让本机还能打开控制台去修坏掉的配置(fail-safe 往能修的方向倒)。
194
+ // ⚠ 对外绑定:退成「配了」—— 否则一份写坏的配置会让页面给任何局域网访客注入免密 token(2026-09-07 通读
195
+ // 逮到;安全判定读不到信息时必须拒绝而不是放行)。此时本机浏览器仍免密(localBypass 按 TCP 源地址判,
196
+ // 与这里无关),LAN 访客会看到登录框、而 /api/login 因读不到密码返回 400 —— 修好配置即恢复。
197
+ function authConfiguredFrom(getAuth, loopbackBind) {
198
+ try { return !!getAuth() } catch { return !loopbackBind }
199
+ }
200
+
201
+ // 首次绑定群时发进群的那句。⚠ 这是群消息不是控制台:机器上的绝对路径不能进群(用户面零机器路径,
202
+ // 出过同形状的一次违规),只说项目目录名。
203
+ // 企微群只收 @ 机器人的消息(平台边界),「直接发消息」对它是错指引 ⇒ 按平台措辞。
204
+ // ⚠ 命令前缀按平台传:Slack 把 `/` 开头的输入当成它自己的斜杠命令吃掉(真机验过),写死 `/help`
205
+ // 等于第一句话就教错用法。
206
+ function bindNoticeText(workdir, platform = 'feishu', cmd = '/help') {
207
+ return `✅ 本群已绑定项目「${path.basename(String(workdir || '')) || '(未命名)'}」\n${platform === 'wecom' ? '@我 派单(只有 @ 我的消息我才收得到)' : '直接发消息或 @我 派单'};${cmd} 查看命令`
208
+ }
209
+
210
+ function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, onHostChange = null, control = null, updater = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null, setupMode = null, cronRecoveryEnabled = false, runtimePolicy = null, cronTools = null } = {}) {
211
+ // Legacy scheduling rules are injected by its compatibility wrapper only.
212
+ // The durable console cannot acquire those rules through this base module.
213
+ const unsupportedCron = () => { throw new Error('当前控制台未接入定时任务') }
214
+ const { normalizeCron = unsupportedCron, normalizeAt = unsupportedCron,
215
+ describeTask = () => '当前版本未接入', expandAt = unsupportedCron,
216
+ qualifyFuture = unsupportedCron } = cronTools || {}
217
+ // wecomExec:企业微信命令行工具的执行器注入(只为可测;生产走默认 execFile)
218
+ // verifyFile:凭据验证记录的落点注入(只为可测;生产 = ~/.sbot/verify.json)
219
+ const vopts = verifyFile ? { file: verifyFile } : undefined
220
+ const WECOM_NO_API = '企业微信这边机器人没有建群 / 解散 / 拉人的接口 —— 请在企业微信里建群、把机器人加进群并 @ 它一次,然后回到这里选择那个群'
221
+ // 某个群属于哪个平台:**与 daemon 路由器用同一个函数**(config.chatPlatform,在配置文件原文上判:群写了 platform 就认 ·
222
+ // id 形态 · 只配了一个平台就是它),这里只多一步「判不出按飞书」—— 两处各写一份必漂移,所以不重写。
223
+ const chatPlatformOf = (raw, chatId) => chatPlatform(raw, chatId) || 'feishu'
224
+ // 反馈本落点。注入式**只为可测**:单测若写默认路径就会污染本机真实的反馈本(而那本账
225
+ // 是生产数据)。daemon 不传 ⇒ 走默认,与 CLI / 群命令同一个文件。
226
+ const issuesFile = () => issuesPath || DEFAULT_ISSUES_FILE()
227
+ // Localhost binding alone is NOT a security boundary — cross-site POSTs (no-cors
228
+ // text/plain skips preflight) and DNS rebinding both reach 127.0.0.1. Every /api/*
229
+ // request must carry a per-process token (header, or ?token= for EventSource),
230
+ // and every request must present an allowed Host.
231
+ //
232
+ // Two token flavors: `token` is the loopback convenience token, auto-injected into
233
+ // the served page when no password is set. `sessions` holds tokens minted by a
234
+ // successful /api/login — the path used when a password IS set (any LAN exposure
235
+ // requires one; bin fail-closes otherwise). Auth check accepts either.
236
+ const token = crypto.randomBytes(16).toString('hex')
237
+ // 登录签发的会话令牌:落盘 + 30 天过期(此前随进程存亡 ⇒ 每次发版重启都要重输密码)。
238
+ // 注入 sessionsPath 只为可测:单测写默认路径会污染本机真实会话。
239
+ const sessions = createSessionStore({ file: sessionsPath || DEFAULT_SESSIONS_FILE(), log })
240
+ // 「用飞书登录」:一次性 state(防 CSRF)+ fetch 注入(单测用假飞书)+ 就绪判据(三样齐才亮入口)
241
+ const oauthStates = feishulogin.createStateStore()
242
+ const doFetch = fetchImpl || globalThis.fetch
243
+ // 凭证的 env: 引用用 config.js 同一个解析器(CLI 与控制台同源,别各写一份)
244
+ const resolveRef = (v) => resolveSecret(v) || ''
245
+ // 会话与配置对齐:会话活得比进程久,**名单里删掉的人、换过的密码**必须让旧会话立刻失效,否则被删的人最多还能
246
+ // 再用 30 天(对抗审查 🔴)。判据来源 = 配置文件本身;按 mtime 缓存,只有配置真变了才重读一次,不给每个请求加
247
+ // 一次 JSON.parse。配置读坏时不动会话(修好后下一次自然对齐)。
248
+ let sessSyncMtime = -1
249
+ const syncSessionsWithConfig = () => {
250
+ let mt = 0; try { mt = fs.statSync(cfgFile()).mtimeMs } catch { mt = 0 }
251
+ if (mt === sessSyncMtime) return
252
+ sessSyncMtime = mt
253
+ let raw; try { raw = readRaw() } catch { return }
254
+ const admins = adminList(raw.feishu || {}); const authRef = String((raw.ui || {}).auth || '').slice(0, 16)
255
+ const n = sessions.revokeWhere((m) => (m.via === 'feishu' && !feishulogin.adminMatch(admins, m.openId)) || (m.via === 'password' && (!authRef || m.authRef !== authRef)))
256
+ if (n) log(`ui: ${n} 个登录会话因管理员名单或密码变更而失效`)
257
+ }
258
+ const feishuLoginInfo = () => {
259
+ // 配置读坏要出声(v0.11.36/71 的教训):不能吞成「未填公网地址」那种会误导人的缺项
260
+ let raw; try { raw = readRaw() } catch { return { ready: false, why: '配置文件损坏,先修好它', publicUrl: '', redirectUri: '', appId: '', appSecret: '', admins: [] } }
261
+ const fe = raw.feishu || {}; const ui = raw.ui || {}
262
+ const publicUrl = feishulogin.normalizePublicUrl(ui.publicUrl)
263
+ const appId = resolveRef(fe.appId), appSecret = resolveRef(fe.appSecret), admins = adminList(fe)
264
+ const r = feishulogin.feishuLoginReadiness({ publicUrl, appId, appSecret, admins })
265
+ const enabled = ui.publicEnabled !== false // 未出现这个字段的旧配置保持原行为
266
+ return { ready: enabled && r.ready, configured: r.ready, enabled, why: !enabled ? '公网登录已关闭' : r.why,
267
+ publicUrl: publicUrl || '', redirectUri: publicUrl ? feishulogin.redirectUri(publicUrl) : '', appId, appSecret, admins }
268
+ }
269
+ // Host 白名单:环回时只认 loopback(防 DNS-rebinding);对外(--host 非环回)额外放行私网段
270
+ // IPv4(10./192.168./172.16-31./169.254.)。攻击者域名不匹配私网 IP 模式 → rebinding 仍挡住。
271
+ let activeHost = String(host)
272
+ let loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(activeHost)
273
+ const hostPattern = () => loopbackBind
274
+ ? /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
275
+ : /^(127\.0\.0\.1|localhost|\[::1\]|10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})(:\d+)?$/i
276
+ let HOST_OK = hostPattern()
277
+ const authed = (req, url) => { const t = req.headers['x-sbot-token'] || url.searchParams.get('token'); return (!!t && t === token) || sessions.has(t) }
278
+ // 是否已配访问密码(配了 ⇒ 页面不发 token,走登录)。config 读坏时的裁决见 authConfiguredFrom。
279
+ const authConfigured = () => authConfiguredFrom(() => (readRaw().ui || {}).auth, loopbackBind)
280
+ // 该请求是否来自本机 loopback。remoteAddress 是 TCP 对端真实地址,LAN 访客伪造不了
281
+ // loopback 源 IP(TCP 三次握手挡住源地址欺骗),故可信。
282
+ const isLoopbackReq = (req) => {
283
+ const a = String((req.socket && req.socket.remoteAddress) || '')
284
+ return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1'
285
+ }
286
+
287
+ const cfgFile = () => configPath || path.join(process.cwd(), 'sbot.config.json')
288
+ const readRaw = () => {
289
+ const p = cfgFile()
290
+ if (!fs.existsSync(p)) return {}
291
+ // A corrupted config must SURFACE, never be silently replaced — returning {}
292
+ // here would let the next writeRaw wipe the whole file (FR11.1).
293
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')) }
294
+ catch (e) { throw new Error(`配置文件损坏(${e.message}),已拒绝读写 — 请先手工修复 ${p}`) }
295
+ }
296
+ const writeRaw = (obj) => {
297
+ // Durable hosts validate synchronously before changing the configuration;
298
+ // their commit hook must never be swallowed as a successful legacy reload.
299
+ if (runtimePolicy) runtimePolicy.validateConfig(obj)
300
+ // A model/project change resets the group session before the file says so.
301
+ if (runtimePolicy && runtimePolicy.beforeWrite) runtimePolicy.beforeWrite(obj)
302
+ atomicWriteJSON(cfgFile(), obj)
303
+ if (runtimePolicy) runtimePolicy.applyConfig(obj)
304
+ else if (onConfigChange) { try { onConfigChange() } catch (e) { log('reload hook error: ' + (e && e.message)) } }
305
+ }
306
+ // 群内通知统一出口:暂停群不打扰(暂停语义=完全静默,配置变更通知也不例外 —— 2026-07-22
307
+ // 真机踩过:暂停群仍收到「模型已更新」;operator 在控制台自己改的,群成员恢复后清单会重注入,
308
+ // 静默无损)。唯 force:true 豁免 —— 暂停/恢复动作本身的那条告知必须送达。
309
+ const notifyGroup = (chatId, text, { force } = {}) => {
310
+ if (!control || !control.notifyGroup || !chatId) return
311
+ if (!force) {
312
+ try { const g = (readRaw().groups || {})[chatId]; if (g && g.paused) return } catch { /* 配置读不了则照发,通知不因此丢 */ }
313
+ }
314
+ control.notifyGroup(chatId, text)
315
+ }
316
+ const sseClients = new Set()
317
+
318
+ // Recent-activity ring buffer (下一步 ②「活动重同步」): SSE has no replay, so any
319
+ // turn event lost to a browser disconnect / late-open / reload is gone — hence the
320
+ // "打开即空列表 / 卡住 / 缺失" symptoms. The daemon keeps the last N turn events here
321
+ // (upsert by turn id: the 'done' supersedes its 'start'), and the console fetches
322
+ // this via /api/activity on load AND on every SSE (re)connect to fill the gap with
323
+ // the REAL done data (not just the synthetic '✓' self-heal). In-memory only: after a
324
+ // daemon restart it starts empty (no live activity yet anyway; the durable audit
325
+ // trail is the turn ledger, ~/.sbot/turns.jsonl).
326
+ const ACT_MAX = 30 // console shows ≤10, holds 20 — 30 gives margin after a gap
327
+ const activityBuf = [] // raw turn events, oldest→newest, latest-per-id
328
+ const recordActivity = (ev) => {
329
+ if (!ev || !ev.id) return
330
+ const i = activityBuf.findIndex((e) => e.id === ev.id)
331
+ if (i >= 0) activityBuf[i] = ev // same turn: 'done' replaces 'start' in place (keeps order)
332
+ else { activityBuf.push(ev); if (activityBuf.length > ACT_MAX) activityBuf.shift() }
333
+ }
334
+ const recentActivity = () => runtimePolicy ? runtimePolicy.getActivity() : activityBuf.slice().reverse() // newest-first for the console
335
+ // 账本落点注入**只为可测**(同反馈本):单测写默认路径会读到本机真实账本。
336
+ const ledgerFile = () => ledgerPath || path.join(os.homedir(), '.sbot', 'turns.jsonl')
337
+ const ledgerTail = dashboard.createLedgerTail(ledgerFile()) // 按文件大小与修改时刻缓存:账本只在一轮结束时才变
338
+ // 启动即用账本最近的轮次垫底:此前 daemon 一重启(含每次发版)概览的活动流就是空的,
339
+ // 要等下一轮才有东西看。id 与引擎同形,之后真实事件进来只会原地覆盖、不会重复。
340
+ try {
341
+ let groupsRaw = {}
342
+ try { groupsRaw = readRaw().groups || {} } catch {}
343
+ for (const ev of dashboard.ledgerActivity(ledgerTail.rows(), groupsRaw, 20)) recordActivity(ev)
344
+ } catch { /* 回填是锦上添花,读不了就空着 */ }
345
+ const inboxDismiss = dashboard.createDismissStore({ file: inboxDismissPath || path.join(os.homedir(), '.sbot', 'inbox-dismissed.json'), log })
346
+ let lastHealth = null // 最近一次 buildState 的健康检查,给只算收件箱的 /api/inbox 用(那里不重跑探针)
347
+ // 「需要你处理」:从 state 各区取数(反馈本 / 预览 / 残留进程 / 健康)+ 账本 + 接管票,不再各算一遍
348
+ const buildInboxFor = ({ issues, previews, leftovers, health }, raw) => {
349
+ try {
350
+ const update = updater ? { ...updater.status(), announce: !raw || !raw.ui || raw.ui.announceUpdates !== false } : null
351
+ return dashboard.buildInbox({
352
+ ledgerRows: ledgerTail.rows(),
353
+ issues, previews, leftovers, health,
354
+ update,
355
+ takeovers: takeoverStore.pending(),
356
+ groups: (raw && raw.groups) || {},
357
+ dismissed: inboxDismiss.set(),
358
+ })
359
+ } catch (e) { log('inbox: 汇总失败(忽略): ' + (e && e.message)); return [] }
360
+ }
361
+
362
+ // Disband hardening: server-enforced two-phase with a one-time
363
+ // token — a bare &confirm=1 must never skip the in-group notice/cool-down.
364
+ // Phase 1 issues {token, readyAt(+30s when other users exist), expiresAt(+10min)};
365
+ // phase 2 must present the token, after readyAt, before expiresAt. Dangerous
366
+ // operations are audited to ~/.sbot/audit.jsonl.
367
+ const disbandPending = new Map() // chatId -> { token, readyAt, expiresAt, users, names }
368
+ let codexStatusCache = null // { at, login, usage } — codex 订阅登录态+额度缓存(供 /api/state 回填 + verify 60s 去抖)
369
+ let claudeStatusCache = null // { at, login } — Claude 订阅登录态(`claude auth status --json`),同上回填
370
+ let codexModelsCache = null // { at, val } — codex model/list 探测缓存(10 分钟)
371
+ // 审计本落点。注入 auditPath 只为可测:单测里的「飞书登录成功」若写进真实审计本,出事时排查者 grep 到的就是假记录。
372
+ const audit = createLedger(auditPath || path.join(os.homedir(), '.sbot', 'audit.jsonl'), log)
373
+
374
+ // /api/state runs after every console mutation — cache the version probe so
375
+ // each click doesn't spawn a `claude --version` child.
376
+ let probeCache = null // { val, ts }
377
+ const probeCached = async (bin) => {
378
+ if (probeCache && Date.now() - probeCache.ts < 60_000) return probeCache.val
379
+ const val = await probeVersion(bin)
380
+ probeCache = { val, ts: Date.now() }
381
+ return val
382
+ }
383
+ // Same 60s cache for the codex CLI probe — only run when a codex provider exists.
384
+ let codexProbeCache = null
385
+ const probeCodexCached = async () => {
386
+ if (codexProbeCache && Date.now() - codexProbeCache.ts < 60_000) return codexProbeCache.val
387
+ const val = await codexAgent.probeVersion()
388
+ codexProbeCache = { val, ts: Date.now() }
389
+ return val
390
+ }
391
+
392
+ // 运行中的群预览服务(`sbot preview`)。**必须在概览里看得见** —— v0.8.1 那个 agent 私装的
393
+ // 静态服务在局域网上挂了一周才被人肉发现,「operator 看不见」正是本技能要治的三个软肋之一。
394
+ // 纯读 ~/.sbot/preview/ 的记录文件(不 spawn、不探网),每个 API 响应都跑得起。
395
+ const previewsState = (raw) => {
396
+ try {
397
+ const t = Date.now()
398
+ // 只列 **running**:'starting' 期间端口还没绑定,列出来等于给一个可能打不开的 URL
399
+ return preview.listRecords(preview.previewDir()).filter((r) => r.status === 'running' && preview.isLive(r)).map((r) => ({
400
+ id: r.id, chatId: r.chatId, name: (raw.groups && raw.groups[r.chatId] && raw.groups[r.chatId].name) || r.chatId,
401
+ label: r.label || r.entry, port: r.port, entry: r.entry,
402
+ serveRoot: preview.displayRoot(r), // 展示用相对路径(与 CLI/doctor 同一份实现)
403
+ single: !!r.single, // 只服务 entry 这一个文件(文件形态)
404
+ served: preview.displayServed(r), // 实际服务到的位置:single ⇒ 到文件,目录 ⇒ 到目录
405
+ projectRoot: !!r.projectRoot, // 服务整个项目目录 ⇒ 前端标风险色
406
+ upMs: t - (r.startedAt || t), lastReqMs: r.lastReqAt ? t - r.lastReqAt : null, reqCount: r.reqCount || 0,
407
+ idleLeftMs: r.idleMs > 0 ? Math.max(0, r.idleMs - (t - (r.lastReqAt || r.startedAt || t))) : null,
408
+ url: preview.buildUrl(r, preview.hostForUrl(r, lanIPv4())),
409
+ }))
410
+ } catch { return [] }
411
+ }
412
+
413
+ // 群内反馈本(群里 /issue 提的)。**未处理的必须在概览里看得见** —— 与上面的预览服务
414
+ // 同一条道理:这本账存在的全部意义是「反馈不会不声不响地消失」,而它要是只躺在本机
415
+ // 文件里没人看,和消失没区别。纯读一个小 JSONL(不 spawn、不探网),与 previews 同量级。
416
+ // ⚠ open 封顶 50 条:正常一天几条,封顶是防「这里有一天变成拖垮每个 API 响应的地方」。
417
+ const issuesState = (raw) => {
418
+ try {
419
+ const book = createIssues(issuesFile(), { host: raw.instance || undefined })
420
+ const all = book.list({ all: true })
421
+ const open = all.filter((i) => i.status === 'open').reverse() // 新的在上:反馈按「最近发生什么」读
422
+ const closed = all.filter((i) => i.status === 'closed').reverse()
423
+ const trim = (i) => ({ id: i.id, ts: i.ts, chatId: i.chatId, group: i.group, sender: i.sender, text: i.text, version: i.version, note: i.note, fp: fingerprint(i.turn) })
424
+ return { host: book.host, open: open.slice(0, 50).map(trim), closed: closed.slice(0, 5).map(trim), openCount: open.length, closedCount: closed.length }
425
+ } catch { return { host: '', open: [], closed: [], openCount: 0, closedCount: 0 } }
426
+ }
427
+
428
+ // 遗留进程:agent 在对话轮次里用后台方式起的 shell,轮次结束后挂到 pid 1 下继续跑。
429
+ // **必须在概览里看得见** —— 与预览服务 / 反馈本同一条道理:2026-10-09 在生产机翻出一条每 5 秒
430
+ // 打线上云函数的循环,零 CPU、零报警地跑了 35 天。走 30 秒 TTL 缓存(每次 /api/state 都问,ps 一次
431
+ // 几十毫秒,不必每次都跑)。⚠ **不下发完整 command**:它带 shell 快照路径、/tmp 落点等机器布局
432
+ // (零工具名约束),页面只需要 summary(agent 真正写的那条命令)+ 年龄 + pid。
433
+ // supported:false(Windows 没有 ps 也没有这套快照)⇒ rows 为空,前端一个字不出现。
434
+ const leftoversState = async () => {
435
+ try {
436
+ const r = await leftover.cachedList()
437
+ return { supported: !!r.supported, rows: (r.rows || []).map((x) => ({ pid: x.pid, ageSec: x.ageSec, age: x.age, summary: x.summary })), error: r.error || null }
438
+ } catch { return { supported: false, rows: [], error: null } }
439
+ }
440
+
441
+ // 只算收件箱(轮次结束、忽略之后控制台来取这一块):不走 buildState —— 那里有 CLI 探针、
442
+ // 逐群读项目插件与设计卡,一轮一次全量太重。健康检查借上一次 buildState 的结果(概览每分钟刷新一次)。
443
+ const inboxOnly = async () => {
444
+ const raw = readRaw()
445
+ return buildInboxFor({ issues: issuesState(raw), previews: previewsState(raw), leftovers: await leftoversState(), health: lastHealth }, raw)
446
+ }
447
+
448
+ async function buildState() {
449
+ const cfg = loadConfig(configPath)
450
+ const raw = readRaw()
451
+ // ⚠ 必须与 loadConfig 同一口径:**内置默认 + 文件**,文件覆盖同名。
452
+ // 原先是 `raw.providers || cfg.providers` —— 只要文件里写了 providers 键,内置默认就整块
453
+ // 消失,而 daemon 那边照样 merge ⇒ **控制台少显示一个真实可用的模型**(2026-09-01 撞到:
454
+ // 给临时配置加了 codex,claude 当场从模型库消失,而 daemon 仍然认它)。
455
+ // 「显示的 ≠ 实际生效的」是这类界面最贵的一种错。
456
+ const provSrc = { ...DEFAULTS.providers, ...(raw.providers || {}) }
457
+ // 一次性任务过期判断:at(群时区本地串)< 当前群时区时间串(YYYY-MM-DD HH:mm 字典序=时间序)
458
+ const nowTzStr = (() => { try { const p = tzParts(Date.now(), normalizeTz(raw.timezone)); return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}` } catch { return '' } })()
459
+ const cronExpired = (at) => { try { return !!at && normalizeAt(at) < nowTzStr } catch { return false } }
460
+ const providers = Object.entries(provSrc)
461
+ .filter(([, p]) => !runtimePolicy || runtimePolicy.providerSupported(p))
462
+ .map(([name, p]) => ({
463
+ name,
464
+ model: p.model || '',
465
+ runner: p.runner || '', // 'codex' → GPT via Codex CLI (subscription login, no key); else Claude Code
466
+ baseUrl: p.baseUrl || '',
467
+ loggedIn: !p.baseUrl,
468
+ keyStatus: keyStatus(p.apiKey),
469
+ keyRef: isEnvRef(p.apiKey) ? p.apiKey.slice(4) : '',
470
+ toolQuality: p.toolQuality || '',
471
+ effort: (p.env && p.env.CLAUDE_CODE_EFFORT_LEVEL) || '',
472
+ reasoningEffort: p.reasoningEffort || '', // codex-only: -c model_reasoning_effort
473
+ vision: p.vision !== false, // false = 纯文本模型 ⇒ 图片走 strip-proxy 降级
474
+ hidden: !!p.hidden,
475
+ configured: isConfigured(p), // usable: logged-in, or has a resolvable key
476
+ }))
477
+ const groups = Object.entries(raw.groups || {})
478
+ .filter(([id]) => id !== 'oc_example_chat_id')
479
+ // repo:群项目关联的 git 远端(可空)。repoState 走纯 fs 的 .git/config 读取(quickLinkState)
480
+ // —— 群卡标签要反映真实关联(origin 被手改/删掉时不能还绿着),但这里每个 API 响应都跑一遍,
481
+ // per-group spawn git 会把控制台拖垮,所以只读文件、不 spawn。
482
+ .map(([chatId, g]) => ({ chatId, name: g.name || '', workdir: g.workdir || '', repo: g.repo || '', platform: chatPlatformOf(raw, chatId) /* 群属于哪个平台:配置写了就认,没写按 id 形态 / 唯一平台推断(与 config.normalizeGroupPlatforms 同口径) */, repoState: gitrepo.quickLinkState(g.workdir, g.repo), board: g.board && g.board.url ? { url: g.board.url, createdAt: g.board.createdAt || null } : null, provider: g.provider || '', effectiveProvider: (control && control.providerOf && control.providerOf(chatId)) || g.provider || raw.defaultProvider || cfg.defaultProvider, codexSteer: g.codexSteer !== false, model: g.model || '', allowedTools: g.allowedTools || [], disallowedTools: g.disallowedTools || [], permissionMode: g.permissionMode || '', paused: g.paused === true, plugins: g.plugins || null,
483
+ // 项目级插件/技能(workdir 自带声明,随项目恒加载)—— 群卡摘要行不点开也看得见。
484
+ // projectPlugins 纯 fs 读且各错各兜(readJson/scanSkills 都 catch),与同行的 projectStyleCard 同量级。
485
+ // ⚠ workdir 必须先判空:空串会让 path.join 退化成 daemon cwd 相对读(实验证实能读到别的项目的
486
+ // .mcp.json)—— 无 workdir 的群就是没有项目声明,不能拿 cwd 凑。
487
+ proj: (() => { if (!g.workdir) return { mcp: [], skills: [] }; try { const pp = projectPlugins(g.workdir); return { mcp: pp.mcp.map((m) => m.name), skills: pp.skills.map((s) => s.name) } } catch { return { mcp: [], skills: [] } } })(),
488
+ redactPii: g.redactPii !== false, // false = 数据明文群;true = 默认遮盖
489
+ preview: g.preview || null, styles: design.projectStyleCards(g.workdir),
490
+ // 图标那一行:与「设计风格」平级的第二个选择(2026-08-25)。用哪套图标系统是**项目**的
491
+ // 事、住在 design/icons.md ——「换风格就换图标」是要拆掉的那个绑定。
492
+ icons: g.workdir ? design.projectIconCard(g.workdir) : null, crons: (Array.isArray(g.crons) ? g.crons : []).map((c) => ({ ...c, human: describeTask(c, nowTzStr), expired: cronExpired(c.at) })), exists: g.workdir ? fs.existsSync(g.workdir) : false }))
493
+ // Console overview health — deliberately minimal: backend CLI 探针 + "有无可用模型/群"。
494
+ // Codex CLI 行仅在配了 codex provider 时出现(与 doctor 一致);`sbot doctor` 保留全量。
495
+ const v = runtimePolicy ? null : await probeCached(cfg.agent.bin)
496
+ const hasCodex = providers.some((p) => !p.hidden && p.runner === 'codex')
497
+ const cv = hasCodex ? await probeCodexCached() : null
498
+ // 「可用模型」= 能用的,不只是配了的(1.0.3;此前没登录的 Claude 订阅、试跑失败的 key 都算在内)。
499
+ // key 型看最近一次验证:失败的不算,还没验证的先算上;Claude 订阅与 codex 看本机登录态,探不出来(没装 / 超时)不扣。
500
+ // 两个登录探测都带缓存(claude 60s · codex 60s),概览每分钟刷新也不会多 spawn。
501
+ const live = providers.filter((p) => !p.hidden && p.configured)
502
+ const subLogin = live.some((p) => !p.baseUrl && p.runner !== 'codex') ? await claudestatus.cachedLoginStatus(cfg.agent.bin).catch(() => null) : null
503
+ const cxLogin = live.some((p) => p.runner === 'codex') ? await codexstatus.cachedLoginStatus('codex').catch(() => null) : null
504
+ const loggedOut = (st) => !!(st && st.ok && st.loggedIn === false)
505
+ const unusable = (p) => {
506
+ if (p.baseUrl) { const v = verify.lookup(verify.providerFp(cfg.providers[p.name] || {}), vopts); return !!(v && !v.ok) }
507
+ return loggedOut(p.runner === 'codex' ? cxLogin : subLogin)
508
+ }
509
+ const badProv = live.filter(unusable).map((p) => p.name)
510
+ const usableProv = live.length - badProv.length
511
+ const usableGroups = groups.filter((g) => g.exists).length
512
+ // 各平台连接健康度(仅 daemon 运行时有):路由器出 { feishu: 快照, wecom: 快照 } 字典 → 概览一行一个平台 + 完整块给前端。
513
+ const conn = getConnHealth ? getConnHealth() : null
514
+ const connRows = connHealthRows(conn)
515
+ const rows = [
516
+ ...(v ? [{ status: v.ok ? 'ok' : 'fail', label: 'Claude CLI', detail: v.ok ? v.version : v.error }] : []),
517
+ ...(cv ? [{ status: cv.ok ? 'ok' : 'fail', label: 'Codex CLI', detail: cv.ok ? `${cv.version} · GPT 订阅` : cv.error }] : []),
518
+ { status: !usableProv ? 'fail' : badProv.length ? 'warn' : 'ok', label: '可用模型',
519
+ detail: (usableProv ? `${usableProv} 个能用${hasCodex ? '(含 Codex)' : ''}` : '无可用模型 — 去「模型 · 库」页添加 / 测试')
520
+ + (badProv.length ? `;${badProv.join('、')} 没登录或验证失败` : '') },
521
+ { status: usableGroups ? 'ok' : 'warn', label: '可用群 / 项目', detail: usableGroups ? `${usableGroups} 个已绑定` : '无绑定群/项目 — 去「群·项目」页创建' },
522
+ ...connRows,
523
+ // 平台凭据:最近一次预检失败才出一行(通过 / 没预检过不占地方;daemon 在跑时上面的连接行已经说明了连没连上)
524
+ ...[['feishu', '飞书凭据', verify.feishuFp(cfg.feishu)], ['slack', 'Slack 凭据', verify.slackFp(cfg.slack)]].flatMap(([, label, fp]) => {
525
+ const v = fp ? verify.lookup(fp, vopts) : null
526
+ return v && !v.ok ? [{ status: 'fail', label, detail: `上次预检失败:${v.reason} — 去「设置」页改凭据后再预检` }] : []
527
+ }),
528
+ ]
529
+ const health = { rows, fails: rows.filter((r) => r.status === 'fail').length, warns: rows.filter((r) => r.status === 'warn').length }
530
+ lastHealth = health
531
+ const feishu = raw.feishu ? {
532
+ appId: isEnvRef(raw.feishu.appId) ? '' : (raw.feishu.appId || ''),
533
+ appIdRef: isEnvRef(raw.feishu.appId) ? raw.feishu.appId.slice(4) : '',
534
+ appSecretStatus: keyStatus(raw.feishu.appSecret),
535
+ botOpenId: String(raw.feishu.botOpenId || '').startsWith('ou_x') ? '' : (raw.feishu.botOpenId || ''),
536
+ identity: raw.feishu.identity || null, // 应用名称/描述/头像/状态 — 预检或「刷新」时落盘的缓存
537
+ admins: adminList(raw.feishu), // 可多个;兼容旧的单 adminOpenId
538
+ onlyWhenMentioned: !!raw.feishu.onlyWhenMentioned,
539
+ } : null
540
+ // 企业微信机器人(设置页那张卡):凭据只给形状不给值(botId 是 env: 引用时留空并给引用名,secret 只给 none/env/set);
541
+ // names 是 operator 手填的 userid → 显示名映射,原样回给表单。cli 字段不进控制台(机器布局,配置文件里改)。
542
+ const wecom = raw.wecom ? {
543
+ botId: isEnvRef(raw.wecom.botId) ? '' : (raw.wecom.botId || ''),
544
+ botIdRef: isEnvRef(raw.wecom.botId) ? raw.wecom.botId.slice(4) : '',
545
+ secretStatus: keyStatus(raw.wecom.secret),
546
+ botName: raw.wecom.botName || '',
547
+ welcome: raw.wecom.welcome || '',
548
+ names: (raw.wecom.names && typeof raw.wecom.names === 'object' && !Array.isArray(raw.wecom.names)) ? raw.wecom.names : {},
549
+ cliOff: raw.wecom.cli === false,
550
+ keywords: Array.isArray(raw.wecom.keywords) ? raw.wecom.keywords.map(String) : null, // 点名词;null = 用默认(机器人 / 助手)
551
+ pollSec: raw.wecom.pollSec === undefined || raw.wecom.pollSec === null || raw.wecom.pollSec === '' ? 0 : Number(raw.wecom.pollSec), // 「不 @ 也回复」轮询间隔(默认 0 = 关,产品决定)
552
+ followSec: raw.wecom.followSec === undefined || raw.wecom.followSec === null || raw.wecom.followSec === '' ? 300 : Number(raw.wecom.followSec), // 跟进窗口(默认 300;0 全喂)
553
+ // 第二道门(便宜模型门控):配置里允许写成模型名字串(手写配置的简写)或 { provider, model? };表单是「下拉 + 型号框」两个控件,
554
+ // 要一个稳定形状才好回填,所以这里统一成 { provider, model }(没型号给空串);没配 = null(下拉落到「不问」)
555
+ gatekeeper: raw.wecom.gatekeeper ? (typeof raw.wecom.gatekeeper === 'string' ? { provider: raw.wecom.gatekeeper, model: '' } : { provider: String(raw.wecom.gatekeeper.provider || ''), model: String(raw.wecom.gatekeeper.model || '') }) : null,
556
+ } : null
557
+ const fli = feishuLoginInfo() // 公网地址 + 飞书登录就绪态(设置页要显示回调地址与缺哪样)
558
+ // Slack 应用块(与 feishu 平级)。⚠ token 一律**只回状态不回明文** —— xoxb 拿到就能以 bot
559
+ // 身份发消息,比飞书的 appId 敏感得多;界面只需要知道"配没配"。
560
+ const slack = raw.slack ? {
561
+ botTokenStatus: keyStatus(raw.slack.botToken),
562
+ appTokenStatus: keyStatus(raw.slack.appToken),
563
+ botUserId: raw.slack.botUserId || '',
564
+ identity: raw.slack.identity || null, // 预检时落盘:bot 名 / workspace 名(与飞书 identity 同惯例)
565
+ onlyWhenMentioned: !!raw.slack.onlyWhenMentioned,
566
+ replyInThread: raw.slack.replyInThread !== false,
567
+ commandAlias: raw.slack.commandAlias || '!',
568
+ } : null
569
+ const state = {
570
+ configPath: cfg._path,
571
+ health,
572
+ providers,
573
+ defaultProvider: raw.defaultProvider || cfg.defaultProvider,
574
+ groups,
575
+ // 项目根目录:绑群弹窗的路径提示用 projectsRoots(生效值);设置页那张卡用 projectsRootsInfo
576
+ //(每条根在不在 · 配置原文 · 被忽略的值与原因 · 哪些群在根外)。与围栏同一份判据。
577
+ ...(() => {
578
+ const pr = projectsroot.resolveProjectsRoots(raw)
579
+ const outside = groups.filter((g) => g.workdir && g.exists && !projectsroot.withinRoots(g.workdir, pr.roots)).map((g) => ({ chatId: g.chatId, name: g.name, workdir: g.workdir }))
580
+ const configured = Array.isArray(raw.projectsRoots) ? raw.projectsRoots.map(String) : (typeof raw.projectsRoots === 'string' ? [raw.projectsRoots] : null)
581
+ return { projectsRoots: pr.roots, projectsRootsInfo: { roots: pr.roots.map((r) => ({ root: r, exists: fs.existsSync(r) })), fromConfig: pr.fromConfig, dropped: pr.dropped, configured, outside } }
582
+ })(),
583
+
584
+ // 图标系统的可选项(两条,约 120 字节)。⚠ 随心跳下发而不是让前端另拉一次:群卡上那个
585
+ // 下拉要在**每次重画群卡时**就绪,单独拉会有「先画出来只剩『未选』、拉回来才补上」的空窗,
586
+ // 而那种空窗看起来和「这个项目没选」一模一样(与图标包没装要如实标是同一条理由)。
587
+ iconSystems: design.iconSystemList(),
588
+ feishu,
589
+ wecom,
590
+ slack,
591
+ platforms: platformsConfigured(raw), // 双平台并存:哪些平台配齐了凭据(env: 引用按已配算)—— 弹窗要不要显示平台切换 · 概览连接行 · 群卡徽标都看它
592
+ agent: {
593
+ permissionMode: cfg.agent.permissionMode,
594
+ maxConcurrent: cfg.agent.maxConcurrent,
595
+ maxTurnTimeMins: cfg.agent.maxTurnTimeMins,
596
+ autocompactTokens: cfg.agent.autocompactTokens, // 长会话自动压缩阈值(默认 40 万经 config 层合并)
597
+ ackDelayMs: cfg.agent.ackDelayMs, // 回执等待毫秒(默认 25s 经 config 层合并;界面按秒显示)
598
+ },
599
+ proxy: raw.proxy && raw.proxy.http ? { http: raw.proxy.http, noProxy: raw.proxy.noProxy || '' } : null,
600
+ live: getLiveState ? getLiveState() : { running: false },
601
+ // 守护进程还没接平台、只开着控制台(cmdStart 配置模式):supervised = 由服务托管,接上平台后会自己重启连上
602
+ setupMode: setupMode ? { supervised: !!setupMode.supervised } : null,
603
+ conn, // 各平台连接健康度字典 { feishu, wecom }(daemon 运行时);未运行为 null
604
+ previews: previewsState(raw),
605
+
606
+ issues: issuesState(raw),
607
+
608
+ leftovers: await leftoversState(),
609
+
610
+ codexStatus: codexStatusCache ? { login: codexStatusCache.login, usage: codexStatusCache.usage } : null, // 前端加载回填,codex 卡片刷新不丢
611
+ claudeStatus: claudeStatusCache ? claudeStatusCache.login : null, // 同上,Claude 订阅登录态
612
+ // 服务器时区/时间(顶栏时钟):tz/offsetMin = bot 工作时区(cfg.timezone,默认北京)——
613
+ // 群里/prompt 时间戳用的就是这个口径,运维看到的钟要和群里一致;hostTz 是主机系统时区
614
+ // (不同才在悬浮提示里补充)。前端用 now 校准一次时差后本地走秒,不轮询。
615
+ time: (() => {
616
+ const tz = normalizeTz(cfg.timezone)
617
+ let hostTz = ''
618
+ try { hostTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '' } catch {}
619
+ return { tz, offsetMin: tzOffsetMin(tz), hostTz, hostOffsetMin: -new Date().getTimezoneOffset(), now: Date.now() }
620
+ })(),
621
+ timezone: raw.timezone || '', // 设置页编辑用(空 = 未显式配置,跑默认北京)
622
+ brand: raw.brand || '', // 机器人自称(设置页编辑;空 = SintBot)
623
+ // 本进程环境里的代理(装服务时从终端带进来的 https_proxy 等;带认证的只说「带认证」):设置页据此说明「服务里已有代理」
624
+ envProxy: require('../core/proxy').proxyDescribe(process.env),
625
+ // 本机访问地址(控制台「设置」页展示):本机 URL 恒有;对外绑定时列出局域网 URL。
626
+ net: (() => {
627
+ const addr = server.address()
628
+ const port = addr && typeof addr === 'object' ? addr.port : null
629
+ return {
630
+ lan: !loopbackBind,
631
+ host: activeHost,
632
+ port,
633
+ local: port ? `http://127.0.0.1:${port}` : null,
634
+ lanUrls: (!loopbackBind && port) ? lanIPv4().map((ip) => `http://${ip}:${port}`) : [],
635
+ authOn: authConfigured(),
636
+ publicUrl: fli.publicUrl, // 公网地址(飞书登录回调的根;设置页可填)
637
+ publicEnabled: fli.enabled,
638
+ publicReady: fli.configured,
639
+ feishuLogin: { ready: fli.ready, why: fli.why, redirectUri: fli.redirectUri },
640
+ }
641
+ })(),
642
+ update: updater ? { ...updater.status(), announce: !raw.ui || raw.ui.announceUpdates !== false } : null,
643
+ }
644
+ state.inbox = buildInboxFor(state, raw)
645
+ if (runtimePolicy) runtimePolicy.decorateState(state)
646
+ return state
647
+ }
648
+
649
+ async function testProvider(name, prompt) {
650
+ const cfg = loadConfig(configPath); applyProxyEnv(cfg)
651
+ const provider = cfg.providers[name]
652
+ if (!provider) return { ok: false, error: `unknown provider '${name}'` }
653
+ if (provider.baseUrl && !provider.apiKey) return { ok: false, error: 'no apiKey' }
654
+ const ac = new AbortController()
655
+ // codex 跑整轮 GPT(冷启动 + reasoning)远慢于 Anthropic 的快问快答;给更长的测试超时。
656
+ // 中止现在真生效(codexcode 强制收尾),超时到点能掐断,不会再干等到自然结束。
657
+ const timer = setTimeout(() => ac.abort(), provider.runner === 'codex' ? 180000 : 60000)
658
+ const t0 = Date.now()
659
+ try {
660
+ const { result } = await runTurn({
661
+ bin: cfg.agent.bin,
662
+ prompt: prompt || verify.TRIAL_PROMPT,
663
+ provider,
664
+ model: provider.model,
665
+ permissionMode: cfg.agent.permissionMode,
666
+ signal: ac.signal, // a one-line reply — needs no tools, so no allow list
667
+ })
668
+ const ok = !(result && result.isError)
669
+ verify.recordTurn(provider, ok, (result && result.text) || '', vopts) // doctor / 概览据此报「能用」或「key 不对」
670
+ return { ok, model: provider.model, reply: (result && result.text) || '(no text)', ms: Date.now() - t0 }
671
+ } catch (e) {
672
+ verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
673
+ return { ok: false, error: (e && e.message) || String(e), ms: Date.now() - t0 }
674
+ } finally { clearTimeout(timer) }
675
+ }
676
+
677
+ // ── request handling ──────────────────────────────────────────────────────
678
+ const sendJSON = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)) }
679
+ const body = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy() }); req.on('end', () => { try { resolve(b ? JSON.parse(b) : {}) } catch { resolve({}) } }) })
680
+
681
+ // 安装命令执行(操作台专用,等价一个开放终端):白名单 env(剔 provider 密钥,npx postinstall
682
+ // 等捞不到 key)+ Windows UTF-8 码页(execShellCommand,防中文乱码)+ audit(命令/结果各一行)。
683
+ async function runInstallCommand(command, cwd) {
684
+ const t0 = Date.now()
685
+ audit.append({ kind: 'plugin-exec', command, cwd, ts: t0 })
686
+ const { exec } = require('node:child_process')
687
+ const SAFE_ENV = ['PATH', 'PATHEXT', 'SystemRoot', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'SHELL', 'LANG', 'LANGUAGE', 'LC_ALL', 'TERM', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']
688
+ const childEnv = {}
689
+ for (const k of SAFE_ENV) if (process.env[k] !== undefined) childEnv[k] = process.env[k]
690
+ return new Promise((resolve) => {
691
+ exec(execShellCommand(command), { cwd, timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024, windowsHide: true, env: childEnv }, (err, stdout, stderr) => {
692
+ const out = ((stdout || '') + (stderr ? '\n' + stderr : '')).trim()
693
+ const ok = !err
694
+ audit.append({ kind: 'plugin-exec-result', ok, ms: Date.now() - t0, exitCode: err ? (err.code ?? null) : 0, outputTail: (out || '').slice(-500), ts: Date.now() })
695
+ resolve({ ok, output: (out.length > 8000 ? '…' + out.slice(-8000) : out) || (err ? String(err.message || err) : '(无输出)') })
696
+ })
697
+ })
698
+ }
699
+
700
+ // 接管浏览器(src/ui/browserroutes.js + takeover.js):一次性链接的票据在这一层签发与核验,/api/browser/* 的实现在路由对象里。
701
+ // 三种人:管理员(令牌)· 群成员(接管 cookie,只认 /api/browser/* 且不能签票)· 本机环回进程(群 agent 跑命令签票)。
702
+ const takeoverStore = takeover.createTakeoverStore({ onClaimed: onTakeoverClaim, onExpired: onTakeoverExpire, onEnded: onTakeoverEnd, ...(takeoverNow ? { now: takeoverNow } : {}) }) // takeoverNow 只给测试拨钟
703
+ const browserRoutes = createBrowserRoutes({
704
+ readRaw, audit, log, cdp: cdpImpl || require('../cdp'), sendJSON, body,
705
+ takeover: { ...takeover, mint: (o) => takeoverStore.mint(o), consume: (k, o) => takeoverStore.consume(k, o), attach: (id, m) => takeoverStore.attach(id, m), revokeSession: (sid) => takeoverStore.revokeSession(sid) },
706
+ publicBase: () => feishuLoginInfo().publicUrl || '',
707
+ // 只绑环回时局域网地址根本连不上(对抗审查 🟡4)⇒ 不给,让签票退成 127.0.0.1 并标 reach:'local'
708
+ lanBase: () => { const a = server.address(); const ip = lanIPv4()[0]; return !loopbackBind && a && ip ? `http://${ip}:${a.port}` : '' },
709
+ localBase: () => { const a = server.address(); return a ? `http://127.0.0.1:${a.port}` : '' },
710
+ secureFor: (req) => String(req.headers['x-forwarded-proto'] || '') === 'https',
711
+ })
712
+ const server = http.createServer(async (req, res) => {
713
+ const url = new URL(req.url, 'http://localhost')
714
+ const p = url.pathname
715
+ try {
716
+ if (!HOST_OK.test(req.headers.host || '')) { res.writeHead(403); return res.end('forbidden') }
717
+ // 三个不需要令牌的入口:密码登录、飞书登录的起跳与回调 —— 其余 /api/* 一律先验令牌。
718
+ if (p.startsWith('/api/')) syncSessionsWithConfig() // 名单 / 密码变了,旧会话在这一步就失效
719
+ const openPath = p === '/api/login' || (req.method === 'GET' && (p === feishulogin.START_PATH || p === feishulogin.CALLBACK_PATH)) || (p === '/api/browser/claim' && req.method === 'POST') // claim 的凭证就是票本身
720
+ // 接管浏览器的两扇小门:① 接管 cookie 只对 /api/browser/*(签票接口除外)有效 —— 群成员到不了控制台其它接口;
721
+ // ② 签票接口另认「本机环回且不带反代头」的请求(群 agent 跑 sbot browser link 时没有令牌)。伪造反代头只会让自己失去这条资格,方向安全。
722
+ const takeoverSid = takeover.cookieValue(req.headers.cookie)
723
+ const takeoverSess = takeoverSid ? takeoverStore.session(takeoverSid) : null
724
+ const isMintPath = p === '/api/browser/link' || p === '/api/browser/link/attach'
725
+ const viaTakeover = p.startsWith('/api/browser/') && !isMintPath && !!takeoverSess
726
+ const localNoProxy = isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
727
+ // ⚠ 还要带 X-Sbot-Local 头:自定义头逼出 CORS 预检,而控制台不答预检 ⇒ 本机网页的跨站 POST(no-cors text/plain)到不了签票
728
+ // (文件头钉的不变量:环回绑定不是安全边界)。本机进程(群 agent 的 sbot browser link)加个头不费事。对抗审查 🟡3。
729
+ const viaLocalMint = isMintPath && localNoProxy && String(req.headers['x-sbot-local'] || '') === '1'
730
+ if (p.startsWith('/api/') && !openPath && !authed(req, url) && !viaTakeover && !viaLocalMint) return sendJSON(res, 401, { error: 'unauthorized — 重新打开控制台页面' })
731
+ if (runtimePolicy && p.startsWith('/api/') && !runtimePolicy.allows(req.method, p)) {
732
+ return sendJSON(res, 409, { error: runtimePolicy.capabilities.reason, code: 'C2_CONSOLE_UNSUPPORTED' })
733
+ }
734
+ if (!cronTools && p.startsWith('/api/groups/cron/')) {
735
+ return sendJSON(res, 409, { error: '当前控制台未接入定时任务', code: 'C2_CONSOLE_UNSUPPORTED' })
736
+ }
737
+ // 登出:作废本次会话令牌(落盘,重启后也不再认)
738
+ if (p === '/api/logout' && req.method === 'POST') { sessions.remove(String(req.headers['x-sbot-token'] || '')); return sendJSON(res, 200, { ok: true }) }
739
+ // 退出所有设备:吊销这台机器签发过的全部会话(自己这枚也在内)
740
+ if (p === '/api/logout/all' && req.method === 'POST') { const n = sessions.clear() + takeoverStore.revokeAll(); browserRoutes.endTakeoverStreams(); audit.append({ kind: 'ui-logout-all', n, ip: (req.socket && req.socket.remoteAddress) || '', ts: Date.now() }); return sendJSON(res, 200, { ok: true, n }) }
741
+ // 「用飞书登录」起跳:三样齐才起跳(否则说清缺哪样),签一次性 state,302 到飞书授权页。
742
+ if (p === feishulogin.START_PATH && req.method === 'GET') {
743
+ const info = feishuLoginInfo()
744
+ if (!info.ready) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(feishulogin.loginResultHtml({ ok: false, message: `飞书登录还没配好:${info.why}` })) }
745
+ const state = oauthStates.issue()
746
+ res.writeHead(302, { Location: feishulogin.buildAuthorizeUrl({ appId: info.appId, redirectUri: info.redirectUri, state }), 'Cache-Control': 'no-store' })
747
+ return res.end()
748
+ }
749
+ // 回调:state 一次性校验 → 换 token → 取 open_id → 对照管理员名单 → 签会话 → 落地页把令牌写进浏览器再回首页。
750
+ // 每一步失败都只说人话、都进审计;名单外的人授权成功也进不来(审计记 ok:false 与是谁)。
751
+ if (p === feishulogin.CALLBACK_PATH && req.method === 'GET') {
752
+ const ip = (req.socket && req.socket.remoteAddress) || ''
753
+ const xff = String(req.headers['x-forwarded-for'] || '') // 经隧道后 ip 恒为本机地址,真实来源只在这个头里(只记录)
754
+ const page = (code, o) => { res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(feishulogin.loginResultHtml(o)) }
755
+ const state = url.searchParams.get('state') || ''; const code = url.searchParams.get('code') || ''
756
+ if (!oauthStates.consume(state)) { audit.append({ kind: 'ui-login', ok: false, via: 'feishu', why: 'bad-state', ip, xff, ts: Date.now() }); return page(400, { ok: false, message: '这次登录已过期,或不是从登录页发起的。请回到登录页重试' }) }
757
+ const info = feishuLoginInfo()
758
+ if (!info.ready || !code) { audit.append({ kind: 'ui-login', ok: false, via: 'feishu', why: info.ready ? 'no-code' : 'not-ready', ip, xff, ts: Date.now() }); return page(400, { ok: false, message: info.ready ? '飞书没有返回授权码,请重试' : `飞书登录还没配好:${info.why}` }) }
759
+ try {
760
+ const { accessToken } = await feishulogin.exchangeCode({ fetchImpl: doFetch, appId: info.appId, appSecret: info.appSecret, code, redirectUri: info.redirectUri })
761
+ const who = await feishulogin.fetchUserInfo({ fetchImpl: doFetch, accessToken })
762
+ const admin = feishulogin.adminMatch(info.admins, who.openId)
763
+ if (!admin) {
764
+ audit.append({ kind: 'ui-login', ok: false, via: 'feishu', why: 'not-admin', who: who.name, openId: who.openId, ip, xff, ts: Date.now() })
765
+ log(`ui: 飞书登录被拒 —— ${who.name || who.openId} 不在管理员名单里`)
766
+ return page(403, { ok: false, message: `${who.name ? who.name + ',' : ''}你的飞书账号不在这台机器的管理员名单里。请管理员在「设置 → 飞书应用」把你加为管理员后再试` })
767
+ }
768
+ const name = admin.name || who.name
769
+ const token = sessions.add({ via: 'feishu', who: name, ip, xff, openId: who.openId })
770
+ audit.append({ kind: 'ui-login', ok: true, via: 'feishu', who: name, openId: who.openId, ip, xff, ts: Date.now() })
771
+ return page(200, { ok: true, token, name })
772
+ } catch (e) {
773
+ audit.append({ kind: 'ui-login', ok: false, via: 'feishu', why: 'exchange', ip, xff, ts: Date.now() })
774
+ log('ui: 飞书登录失败 — ' + (e && e.message))
775
+ return page(502, { ok: false, message: (e && e.message) || '飞书登录失败' })
776
+ }
777
+ }
778
+ // Login: verify password → mint a session token (reuses the header-token pipeline,
779
+ // so no cookie ⇒ no CSRF). Fixed 400ms delay + audit on failure keeps online
780
+ // brute-force impractical for a decent passphrase. Only reachable when a password set.
781
+ if (p === '/api/login' && req.method === 'POST') {
782
+ const stored = (() => { try { return (readRaw().ui || {}).auth || null } catch { return null } })()
783
+ const ip = (req.socket && req.socket.remoteAddress) || ''
784
+ if (!stored) return sendJSON(res, 400, { error: '未设置访问密码' })
785
+ const { password } = await body(req)
786
+ if (verifyPassword(String(password || ''), stored)) {
787
+ const xff = String(req.headers['x-forwarded-for'] || '') // 经反代时的真实来源(只记录,不做任何裁决)
788
+ const sess = sessions.add({ via: 'password', ip, xff, authRef: String(stored).slice(0, 16) })
789
+ audit.append({ kind: 'ui-login', ok: true, via: 'password', ip, xff, ts: Date.now() })
790
+ return sendJSON(res, 200, { token: sess })
791
+ }
792
+ audit.append({ kind: 'ui-login', ok: false, ip, ts: Date.now() })
793
+ await new Promise((r) => setTimeout(r, 400))
794
+ return sendJSON(res, 401, { error: '密码错误' })
795
+ }
796
+ if (req.method === 'GET' && (p === '/' || p === '/index.html')) {
797
+ // The page is the token delivery channel. Deliver the token (免密) when it's safe
798
+ // without a password:
799
+ // - no password set, OR
800
+ // - a password exists only because we're bound to a LAN address, and THIS request
801
+ // came in over loopback (same machine). Pure-local access must not be gated by
802
+ // the LAN password — the "127.0.0.1 免密" invariant (FR9.4a) should survive
803
+ // exposing the console to the LAN.
804
+ // A password on a loopback-ONLY bind is a deliberate local lock, so it still prompts.
805
+ // Any request that must authenticate gets an empty token → the login form.
806
+ // ⚠ 再加一道:带着反代头(X-Forwarded-*)的请求一律不算本机 —— 本机浏览器不会带,nginx 一定带。
807
+ // 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
808
+ const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
809
+ const injectTok = (authConfigured() && !localBypass) ? '' : token
810
+ return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : ''))))
811
+ }
812
+ // 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
813
+ // 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
814
+ // 与首页同一套免密 / 令牌规则(管理员从控制台进来);既没令牌也没有效 cookie 又没票的人看到的是「去登录」。
815
+ if (req.method === 'GET' && p === '/browser') {
816
+ const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
817
+ const injectTok = (authConfigured() && !localBypass) ? '' : token
818
+ const viaCookie = !!takeoverSess
819
+ return fs.readFile(BROWSER_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('browser.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html.replace(/__SBOT_TOKEN__/g, injectTok).replace(/__SBOT_TAKEOVER__/g, viaCookie ? '1' : ''))))
820
+ }
821
+ if (p.startsWith('/api/browser/')) {
822
+ const ip = (req.socket && req.socket.remoteAddress) || ''
823
+ const handled = await browserRoutes.handle(req, res, url, { p, authedAdmin: authed(req, url), takeoverSid: viaTakeover ? takeoverSid : '', takeoverTarget: viaTakeover ? (takeoverSess.targetId || '') : '', takeoverExp: viaTakeover ? (takeoverSess.exp || 0) : 0, localMint: viaLocalMint, ip, xff: String(req.headers['x-forwarded-for'] || '') })
824
+ if (handled) return
825
+ }
826
+ if (p === '/api/events') {
827
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
828
+ res.write('event: hello\ndata: {}\n\n')
829
+ sseClients.add(res)
830
+ req.on('close', () => sseClients.delete(res))
831
+ return
832
+ }
833
+ if (p === '/api/state' && req.method === 'GET') return sendJSON(res, 200, await buildState())
834
+ if (p === '/api/inbox' && req.method === 'GET') return sendJSON(res, 200, { inbox: await inboxOnly() })
835
+ // Recent activity for the console to seed/merge on load + SSE reconnect (下一步 ②).
836
+ // Lightweight + kept off /api/state so mutations don't re-ship it every write.
837
+ if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, { activity: recentActivity() })
838
+ // 版本历史(设置页「版本历史」卡):CHANGELOG.json 随包分发 = 单一事实源,发版时更新
839
+ if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
840
+ // 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
841
+ if (p === '/api/changelog-en' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelogEn() })
842
+ if (p === '/api/update/check' && req.method === 'POST') {
843
+ if (!updater) return sendJSON(res, 503, { error: '版本检查暂不可用' })
844
+ return sendJSON(res, 200, await updater.check())
845
+ }
846
+ if (p === '/api/update/start' && req.method === 'POST') {
847
+ if (!updater) return sendJSON(res, 503, { error: '版本升级暂不可用' })
848
+ const { version, announce } = await body(req)
849
+ try { return sendJSON(res, 202, updater.startUpgrade({ version, announce })) }
850
+ catch (e) { return sendJSON(res, 409, { error: e.message, update: updater.status() }) }
851
+ }
852
+ if (p === '/api/update/ignore' && req.method === 'POST') {
853
+ if (!updater) return sendJSON(res, 503, { error: '版本升级暂不可用' })
854
+ const { version } = await body(req)
855
+ try { return sendJSON(res, 200, updater.ignore(version)) }
856
+ catch (e) { return sendJSON(res, 400, { error: e.message }) }
857
+ }
858
+ if (p === '/api/ui/update-settings' && req.method === 'POST') {
859
+ const patch = await body(req)
860
+ if (typeof patch.announce !== 'boolean') return sendJSON(res, 400, { error: '请提供升级播报偏好' })
861
+ const raw = readRaw(); raw.ui = raw.ui || {}
862
+ raw.ui.announceUpdates = patch.announce
863
+ delete raw.ui.autoUpdateCheck
864
+ writeRaw(raw)
865
+ return sendJSON(res, 200, await buildState())
866
+ }
867
+ // codex 可用模型(codex 面板「模型」下拉):app-server model/list 实时探测(当前
868
+ // 账号真实可用集,含各模型支持的推理强度),失败回退内置预设;探测要 spawn 进程
869
+ // +触网 → 10 分钟缓存。
870
+ if (p === '/api/codex/models' && req.method === 'GET') {
871
+ if (codexModelsCache && Date.now() - codexModelsCache.at < 600_000) return sendJSON(res, 200, codexModelsCache.val)
872
+ const live = await codexstatus.listModels('codex')
873
+ const val = live ? { source: 'live', models: live } : { source: 'preset', models: codexstatus.PRESET_MODELS }
874
+ codexModelsCache = { at: Date.now(), val }
875
+ return sendJSON(res, 200, val)
876
+ }
877
+ if (p === '/api/usage' && req.method === 'GET') { const cfg = loadConfig(configPath); return sendJSON(res, 200, await getUsage(cfg.providers)) }
878
+ // 模型卡「额度」行的 ↻:只刷这一个 provider 且绕过两层缓存(见 usage.js refreshUsage)。
879
+ // 分工 = 整页 /api/usage 走 5 分钟缓存,手点才现拉;codex 走自家 /api/codex/verify 的 force。
880
+ if (p === '/api/usage/refresh' && req.method === 'POST') {
881
+ const { name } = await body(req)
882
+ const cfg = loadConfig(configPath)
883
+ const prov = cfg.providers && cfg.providers[name]
884
+ if (!prov) return sendJSON(res, 400, { error: `未知模型 '${name}'` })
885
+ return sendJSON(res, 200, { name, usage: await refreshUsage(name, prov) })
886
+ }
887
+
888
+ if (p === '/api/provider-key' && req.method === 'POST') {
889
+ const { name, apiKey } = await body(req)
890
+ const raw = readRaw(); if (!raw.providers || !raw.providers[name]) return sendJSON(res, 404, { error: 'no such provider' })
891
+ if (apiKey) raw.providers[name].apiKey = apiKey
892
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
893
+ }
894
+ if (p === '/api/default-provider' && req.method === 'POST') {
895
+ const { name } = await body(req); const raw = readRaw()
896
+ if (!(raw.providers || {})[name]) return sendJSON(res, 404, { error: 'no such provider' })
897
+ raw.defaultProvider = name; writeRaw(raw); return sendJSON(res, 200, await buildState())
898
+ }
899
+ // upsert a provider (add or edit — the ⚙ config panel + 添加模型 both use this)
900
+ if (p === '/api/providers/add' && req.method === 'POST') {
901
+ const { name, baseUrl, model, apiKey, env, toolQuality, effort, reasoningEffort, hidden, vision, modelClear, runner } = await body(req)
902
+ if (!name || !/^[\w.-]+$/.test(name)) return sendJSON(res, 400, { error: '名称必填,且只用字母/数字/._-' })
903
+ const raw = readRaw(); raw.providers = raw.providers || {}
904
+ // 编辑前快照:model / 推理强度变了要联动受影响群(通知 + model 变更重置会话)
905
+ const prev = raw.providers[name] ? { model: raw.providers[name].model || '', re: raw.providers[name].reasoningEffort || '' } : null
906
+ const pr = { ...(raw.providers[name] || {}) }
907
+ if (baseUrl !== undefined) pr.baseUrl = baseUrl || undefined
908
+ if (model) pr.model = model
909
+ // 「默认(跟随 Claude Code)」= 不指定型号 ⇒ spawn 时不设 ANTHROPIC_MODEL,用 CLI 自己的默认。
910
+ // 空 model 与「没传 model」要分开,所以用显式的 modelClear,而不是 `model !== undefined`
911
+ // ——后者会让别处只传部分字段的保存请求把型号误清掉。
912
+ else if (modelClear) delete pr.model
913
+ if (apiKey) pr.apiKey = apiKey
914
+ if (toolQuality !== undefined) { if (toolQuality) pr.toolQuality = toolQuality; else delete pr.toolQuality }
915
+ if (env && typeof env === 'object') pr.env = { ...(pr.env || {}), ...env }
916
+ if (effort !== undefined) { pr.env = pr.env || {}; if (effort) pr.env.CLAUDE_CODE_EFFORT_LEVEL = effort; else delete pr.env.CLAUDE_CODE_EFFORT_LEVEL; if (!Object.keys(pr.env).length) delete pr.env }
917
+ // codex-only reasoning-effort (`-c model_reasoning_effort`) — 档位集合随模型演进
918
+ // (model/list 实测 gpt-5.6 系已到 xhigh/max/ultra),故按形状校验而非枚举白名单;
919
+ // 传参走 argv 数组无 shell 展开,无注入面。坏形状清空(回模型默认),不炸轮。
920
+ if (reasoningEffort !== undefined) { if (typeof reasoningEffort === 'string' && /^[a-z]{2,16}$/.test(reasoningEffort)) pr.reasoningEffort = reasoningEffort; else delete pr.reasoningEffort }
921
+ if (hidden !== undefined) { if (hidden) pr.hidden = true; else delete pr.hidden }
922
+ // 识图能力:只存"没有"这一种情况(vision:false ⇒ spawn 时把 claude 挂到本地
923
+ // strip-proxy,图片块降级成占位文本);有识图=默认透传,不写字段免得配置长毛。
924
+ // 此前只能手改配置文件 —— 控制台建的第三方 provider 因此漏标,文本模型撞图片就 400 崩整轮。
925
+ if (vision !== undefined) { if (vision === false) pr.vision = false; else delete pr.vision }
926
+ // 后端选择(GPT 走 Codex CLI)。⚠ **白名单,不是原样存** —— 这个字段决定 spawn 哪个
927
+ // 可执行文件(src/agent/runner.js 的派发),存进去一个随便的字符串等于给自己开一道门。
928
+ // 不传 ⇒ 保持原值(编辑已有 codex provider 的常态,别把它降级成 claude 后端)。
929
+ if (runner !== undefined) { if (runner === 'codex') pr.runner = 'codex'; else delete pr.runner }
930
+ raw.providers[name] = pr
931
+ fixDefault(raw); writeRaw(raw)
932
+ // 变更联动(仅编辑已有 provider 时):受影响群 = 用着它(显式或经 defaultProvider)
933
+ // 且无 per-group model 覆盖的群。model 变 = 换脑 → 按「切模型即重置」的既有拍板,
934
+ // 重置这些群会话 + 群内一行通知;仅推理强度变 → 通知不重置。daemon 未运行(无
935
+ // control/platform)⇒ 只落盘,前端据 live 提示"下次启动生效"。
936
+ const modelChanged = prev && prev.model !== (pr.model || '')
937
+ const reChanged = prev && prev.re !== (pr.reasoningEffort || '')
938
+ const affected = (prev && (modelChanged || reChanged))
939
+ // 用着它 = 实际在用(daemon 在线时问它;额度临时切换中的群按备用算),daemon 不在线才退回配置
940
+ ? Object.keys(raw.groups || {}).filter((id) => { const g = raw.groups[id]; const cur = (control && control.providerOf && control.providerOf(id)) || g.provider || raw.defaultProvider; return id !== 'oc_example_chat_id' && !g.model && cur === name })
941
+ : []
942
+ const live = !!(control && control.notifyGroup)
943
+ if (live && affected.length) {
944
+ for (const chatId of affected) {
945
+ try {
946
+ if (modelChanged) {
947
+ if (control.clearSession) control.clearSession(chatId)
948
+ notifyGroup(chatId, `🔄 本群模型已更新:${name}${pr.model ? ' · ' + pr.model : ''}(会话已重置,下条消息生效)`)
949
+ } else {
950
+ notifyGroup(chatId, `⚙️ 本群模型 ${name} 推理强度已调为 ${pr.reasoningEffort || '默认'}(会话保留,下条消息生效)`)
951
+ }
952
+ } catch (e) { log('providerChange notify: ' + (e && e.message)) }
953
+ }
954
+ }
955
+ return sendJSON(res, 200, { ...(await buildState()), providerChange: { modelChanged: !!modelChanged, reChanged: !!reChanged, affected: affected.length, live } })
956
+ }
957
+ if (p === '/api/providers' && req.method === 'DELETE') {
958
+ const name = url.searchParams.get('name'); const raw = readRaw()
959
+ // 占用守卫:还有群显式用着它就不许删(否则这些群下一条消息直接报错)
960
+ const users = Object.keys(raw.groups || {}).filter((id) => id !== 'oc_example_chat_id' && raw.groups[id].provider === name).map((id) => raw.groups[id].name || id.slice(-8))
961
+ if (users.length) return sendJSON(res, 400, { error: `「${name}」正被 ${users.length} 个群使用(${users.slice(0, 3).join('、')}${users.length > 3 ? '…' : ''})— 先在「群·项目」页把它们切到其它模型` })
962
+ // ⚠ 内置默认(DEFAULTS.providers)不在配置文件里,删不掉也删不得 —— 不明说的话
963
+ // 界面上就是「点了移除,它还在」,而那看起来和 bug 一模一样。
964
+ if (DEFAULTS.providers[name] && !(raw.providers && raw.providers[name])) {
965
+ return sendJSON(res, 400, { error: `「${name}」是内置默认模型,移不掉 —— 不想让它出现在列表里就用「隐藏」` })
966
+ }
967
+ if (raw.providers) delete raw.providers[name]
968
+ fixDefault(raw); writeRaw(raw); return sendJSON(res, 200, await buildState())
969
+ }
970
+ if (p === '/api/providers/hidden' && req.method === 'POST') {
971
+ const { name, hidden } = await body(req); const raw = readRaw()
972
+ if (!raw.providers || !raw.providers[name]) return sendJSON(res, 404, { error: 'no such provider' })
973
+ if (hidden) raw.providers[name].hidden = true; else delete raw.providers[name].hidden
974
+ fixDefault(raw); writeRaw(raw); return sendJSON(res, 200, await buildState())
975
+ }
976
+ if (p === '/api/providers/order' && req.method === 'POST') {
977
+ const { order } = await body(req); const raw = readRaw()
978
+ if (Array.isArray(order) && raw.providers) {
979
+ const next = {}
980
+ for (const n of order) if (raw.providers[n]) next[n] = raw.providers[n]
981
+ for (const [n, v] of Object.entries(raw.providers)) if (!(n in next)) next[n] = v // keep any not listed
982
+ raw.providers = next
983
+ fixDefault(raw) // ordering doesn't set default; just keep the invisible fallback valid
984
+ writeRaw(raw)
985
+ }
986
+ return sendJSON(res, 200, await buildState())
987
+ }
988
+ if (p === '/api/groups/order' && req.method === 'POST') {
989
+ // display order = config key order (buildState walks Object.entries);
990
+ // cosmetic only — keys unchanged, engine routing unaffected
991
+ const { order } = await body(req); const raw = readRaw()
992
+ if (Array.isArray(order) && raw.groups) {
993
+ const next = {}
994
+ for (const id of order) if (raw.groups[id]) next[id] = raw.groups[id]
995
+ for (const [id, v] of Object.entries(raw.groups)) if (!(id in next)) next[id] = v // keep any not listed
996
+ raw.groups = next
997
+ writeRaw(raw)
998
+ }
999
+ return sendJSON(res, 200, await buildState())
1000
+ }
1001
+ if (p === '/api/provider-settings' && req.method === 'POST') {
1002
+ const { name, toolQuality, effort } = await body(req); const raw = readRaw()
1003
+ if (!raw.providers || !raw.providers[name]) return sendJSON(res, 404, { error: 'no such provider' })
1004
+ const pr = raw.providers[name]
1005
+ if (toolQuality !== undefined) { if (toolQuality) pr.toolQuality = toolQuality; else delete pr.toolQuality }
1006
+ if (effort !== undefined) {
1007
+ pr.env = pr.env || {}
1008
+ if (effort) pr.env.CLAUDE_CODE_EFFORT_LEVEL = effort; else delete pr.env.CLAUDE_CODE_EFFORT_LEVEL
1009
+ if (!Object.keys(pr.env).length) delete pr.env
1010
+ }
1011
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1012
+ }
1013
+ // 反馈本改状态(标记已处理 / 放回未处理)。与 CLI `sbot issue close` 同口径:
1014
+ // 同一把跨进程锁、同一条回群告知 —— 提了没下文,下次就没人提了。
1015
+ // ⚠ 出站必过 redact:notifyGroup 直通 platform.send **不脱敏**(engine 是自己 redact
1016
+ // 完才调它的),而反馈正文里粘一段带 key 的日志是很常见的事。
1017
+ // 向群里插一句:operator 从概览页直接给某个群发一条**会驱动一轮**的消息。
1018
+ // 由来:在此之前只能拿定时任务 `--do` 当"现在说句话"用 —— 要算时间、要等触发、还留残迹。
1019
+ // ⚠ 是否显示到群里由 operator 选:显示 ⇒ 群成员看得见来龙去脉;不显示 ⇒ 群里凭空多出一轮,
1020
+ // 改动没争议时无所谓,一旦有争议就会被读成「它自己乱改」。默认显示 —— 安全的一边是可见。
1021
+ // ⚠ 注入给 agent 的是**原文**,发进群的才过 redact:注入侧脱敏会改坏 operator 的指令本身。
1022
+ if (p === '/api/groups/say' && req.method === 'POST') {
1023
+ const b0 = await body(req)
1024
+ const chatId = String(b0 && b0.chatId != null ? b0.chatId : '')
1025
+ const show = b0 && b0.show
1026
+ // reply:它的回复要不要进群。两个开关**平级** —— 四种组合都可达,其中「都不勾」
1027
+ // (群里彻底无痕)恰恰是最有用的一种:后台维护动作不该在群里刷屏。
1028
+ const wantReply = !(b0 && b0.reply === false)
1029
+ const t = String(b0 && b0.text != null ? b0.text : '').trim()
1030
+ if (!t) return sendJSON(res, 400, { error: '内容不能为空' })
1031
+ if (t.length > 2000) return sendJSON(res, 400, { error: `内容过长(${t.length} 字,上限 2000)` })
1032
+ const raw = readRaw()
1033
+ const gCfg = raw.groups && raw.groups[chatId]
1034
+ if (!gCfg) return sendJSON(res, 404, { error: '没有这个群项目' })
1035
+ const visible = show !== false
1036
+ const pii = !(gCfg.redactPii === false)
1037
+ // ⚠ 群里看到的必须**标明是谁说的**。平台上 bot 只能以自己的身份发,不加标记的话
1038
+ // 这句话读起来就是「机器人在自言自语」—— 而它其实是人下的指令,后面紧跟着的
1039
+ // 回复才是机器人的。加【管理员】之后,群里读到的是「有人问 → 它答」,与真人对话同形。
1040
+ // 体裁按群消息规矩:纯文本不渲染 markdown,层次只能用【】与「· 」。
1041
+ const shownText = visible ? `【管理员】${redact(t, { pii })}` : null
1042
+ // say 会先等群里那行「【管理员】…」发出去再注入(见 src/consolesay.js),所以要 await;最多多等一次发送
1043
+ // requestId:前端每次提交生成的 32 位十六进制幂等键。静默注入(群里无可见那行字)
1044
+ // 时它是唯一跨重启稳定的锚点 —— 带了就能让重复提交落到同一笔持久收据上;没带只是
1045
+ // 退回现状(不给持久身份),不改变行为。格式不对一律当没带,不拿它去拼身份。
1046
+ const requestId = typeof (b0 && b0.requestId) === 'string' &&
1047
+ /^[0-9a-f]{32}$/.test(b0.requestId) ? b0.requestId : undefined
1048
+ const r = (control && control.say)
1049
+ ? await control.say(chatId, t, shownText, !wantReply,
1050
+ ...(requestId ? [{ requestId }] : []))
1051
+ : { ok: false, reason: 'no-daemon' }
1052
+ // 审计留痕。⚠ 只留经 redact 的摘要不留全文:audit 是长期留存的,而插话内容由人手输、
1053
+ // 什么都可能带进来;要追责有摘要 + 时间 + 群就够了。
1054
+ // where = 这句落在哪(起了一轮 / 加入当前任务 / 排队);守护进程侧给不出时为 null,前端退回旧说法
1055
+ const where = (r && r.where && typeof r.where.state === 'string') ? r.where : null
1056
+ audit.append({ kind: 'group-say', chatId, show: visible, reply: wantReply, ok: !!r.ok, reason: r.reason || null, where: where ? where.state : null, chars: t.length, preview: redact(t, { pii }).slice(0, 80), ts: Date.now() })
1057
+ // 说不清就别说成功:与 issues 那条 canNotify 同一条纪律 —— 「让人以为已经插进去了」
1058
+ // 比直接报错更糟,因为他会等一个永远不来的回应。
1059
+ if (!r.ok) {
1060
+ const why = { 'no-daemon': '守护进程没在跑(纯配置模式)—— 插话需要它在线', paused: '这个群项目已暂停,先恢复再插话', unbound: '没有这个群项目', 'inject-failed': '注入失败(守护进程侧报错,看日志)' }[r.reason] || '插话失败'
1061
+ return sendJSON(res, 409, { error: why })
1062
+ }
1063
+ return sendJSON(res, 200, Object.assign(await buildState(), { sayInjected: { chatId, show: visible, reply: wantReply, where } }))
1064
+ }
1065
+
1066
+ if (p === '/api/issues/status' && req.method === 'POST') {
1067
+ const { id, op, note } = await body(req)
1068
+ if (op !== 'close' && op !== 'reopen') return sendJSON(res, 400, { error: "op 只能是 'close' 或 'reopen'" })
1069
+ const raw = readRaw()
1070
+ const book = createIssues(issuesFile(), { host: raw.instance || undefined, withFileLock })
1071
+ const it = book.setStatus(id, op, note)
1072
+ if (!it) return sendJSON(res, 404, { error: `#${id} 不存在,或本来就是${op === 'close' ? '已处理' : '未处理'}状态` })
1073
+ // 能不能真的告知到群,要**先算清楚再告诉前端**:纯配置模式(无 daemon)notifyGroup 是
1074
+ // 静默 no-op、暂停群按语义跳过 —— 这两种情况下前端若照样弹「已告知群里」就是在说谎,
1075
+ // 而「让人以为已经告知了」正是这本账要消灭的那类失信。
1076
+ const gCfg = raw.groups && raw.groups[it.chatId]
1077
+ const canNotify = op === 'close' && !!it.chatId && !!(control && control.notifyGroup) && !(gCfg && gCfg.paused)
1078
+ if (canNotify) {
1079
+ const pii = !(gCfg && gCfg.redactPii === false)
1080
+ const body2 = `✅ 你提的 #${it.id} 已处理\n「${summarize(it, { width: 40 })}」` + (it.note ? `\n${it.note}` : '')
1081
+ notifyGroup(it.chatId, redact(body2, { pii }))
1082
+ }
1083
+ // issueNotified 是响应级瞬态字段(不属于 state),前端读完即删,不进状态签名
1084
+ return sendJSON(res, 200, Object.assign(await buildState(), { issueNotified: canNotify }))
1085
+ }
1086
+ if (p === '/api/proxy' && req.method === 'POST') {
1087
+ const { http: httpUrl, enabled } = await body(req); const raw = readRaw()
1088
+ if (enabled && httpUrl && !/^https?:\/\/\S+$/i.test(String(httpUrl))) return sendJSON(res, 400, { error: '代理地址要像 http://127.0.0.1:7890' })
1089
+ if (enabled && httpUrl) raw.proxy = { http: httpUrl, https: httpUrl, noProxy: deriveNoProxy(raw.providers) }
1090
+ else delete raw.proxy
1091
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1092
+ }
1093
+ if (p === '/api/agent' && req.method === 'POST') {
1094
+ const patch = await body(req); const raw = readRaw(); raw.agent = raw.agent || {}
1095
+ for (const k of ['permissionMode', 'maxConcurrent', 'maxTurnTimeMins']) if (k in patch) raw.agent[k] = patch[k]
1096
+ // 机器人自称(顶层 brand):空或默认名就不落盘,和安装向导 --full 的写法一致
1097
+ if ('brand' in patch) { const b = String(patch.brand || '').trim().slice(0, 40); if (b && b !== 'SintBot') raw.brand = b; else delete raw.brand }
1098
+ // 长会话自动压缩阈值:0 = 关,否则限 10万–100万(CLI 旗子的文档区间;区间外它会拒参数,
1099
+ // 每轮必死 ⇒ 存前就挡住,别让坏值落盘)。
1100
+ if ('autocompactTokens' in patch) {
1101
+ const n = Number(patch.autocompactTokens)
1102
+ if (!Number.isInteger(n) || n < 0 || (n > 0 && (n < 100_000 || n > 1_000_000))) {
1103
+ return sendJSON(res, 400, { error: '压缩阈值须为 0(关闭)或 100000–1000000 之间的整数(token 数)' })
1104
+ }
1105
+ raw.agent.autocompactTokens = n
1106
+ }
1107
+ // 回执等待:非负整数毫秒即可,0 = 不发。不设上限 —— 大于单轮超时只是永不触发,无害。
1108
+ if ('ackDelayMs' in patch) {
1109
+ const n = Number(patch.ackDelayMs)
1110
+ if (!Number.isInteger(n) || n < 0) return sendJSON(res, 400, { error: '回执等待须为不小于 0 的整数(毫秒;0 = 不发)' })
1111
+ raw.agent.ackDelayMs = n
1112
+ }
1113
+ // 时区是顶层配置(不在 agent 块):存前验 IANA 名;清空 = 回默认(Asia/Shanghai)
1114
+ if ('timezone' in patch) {
1115
+ const tz = String(patch.timezone || '').trim()
1116
+ if (tz && !isValidTz(tz)) return sendJSON(res, 400, { error: `无效时区名「${tz}」— 用 IANA 名,如 Asia/Shanghai、Asia/Tokyo、UTC` })
1117
+ if (tz) raw.timezone = tz; else delete raw.timezone
1118
+ }
1119
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1120
+ }
1121
+ // 公网地址(飞书登录回调的根):只认 https://host[:port],存归一后的值;空 = 关掉飞书登录入口。
1122
+ if (p === '/api/ui/public-url' && req.method === 'POST') {
1123
+ const { publicUrl } = await body(req); const raw = readRaw(); raw.ui = raw.ui || {}
1124
+ const t = String(publicUrl || '').trim()
1125
+ if (t) { const n = feishulogin.normalizePublicUrl(t); if (!n) return sendJSON(res, 400, { error: '公网地址要写成 https://域名(可带端口),不带路径' }); raw.ui.publicUrl = n } else delete raw.ui.publicUrl
1126
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1127
+ }
1128
+ if (p === '/api/ui/network' && req.method === 'POST') {
1129
+ const patch = await body(req); const raw = readRaw(); raw.ui = raw.ui || {}
1130
+ const previousHost = activeHost
1131
+ let nextHost = activeHost
1132
+ if (typeof patch.lanEnabled === 'boolean') {
1133
+ if (patch.lanEnabled && patch.password) {
1134
+ const password = String(patch.password)
1135
+ if (password.length < 6) return sendJSON(res, 400, { error: '访问密码至少 6 位' })
1136
+ raw.ui.auth = hashPassword(password)
1137
+ }
1138
+ if (patch.lanEnabled && !raw.ui.auth) return sendJSON(res, 400, { error: '开启局域网访问前要先设置访问密码' })
1139
+ if (patch.lanEnabled !== !loopbackBind) {
1140
+ nextHost = patch.lanEnabled ? '0.0.0.0' : '127.0.0.1'
1141
+ raw.ui.host = nextHost
1142
+ }
1143
+ }
1144
+ if (typeof patch.publicEnabled === 'boolean') {
1145
+ const wasEnabled = raw.ui.publicEnabled !== false
1146
+ if (patch.publicEnabled) {
1147
+ const fe = raw.feishu || {}; const publicUrl = feishulogin.normalizePublicUrl(raw.ui.publicUrl)
1148
+ const ready = feishulogin.feishuLoginReadiness({ publicUrl, appId: resolveRef(fe.appId), appSecret: resolveRef(fe.appSecret), admins: adminList(fe) })
1149
+ if (!ready.ready) return sendJSON(res, 400, { error: `公网登录尚未就绪:${ready.why}` })
1150
+ }
1151
+ raw.ui.publicEnabled = patch.publicEnabled
1152
+ if (wasEnabled && !patch.publicEnabled) {
1153
+ const n = sessions.revokeWhere((session) => session && session.via === 'feishu')
1154
+ if (n) audit.append({ kind: 'ui-public-access-revoked', sessions: n, ts: Date.now() })
1155
+ }
1156
+ }
1157
+ writeRaw(raw)
1158
+ audit.append({ kind: 'ui-network-settings', lan: nextHost !== '127.0.0.1', publicEnabled: raw.ui.publicEnabled !== false,
1159
+ hostChanged: nextHost !== previousHost, ts: Date.now() })
1160
+ if (nextHost !== previousHost) {
1161
+ const responseSocket = res.socket
1162
+ let rebindStarted = false
1163
+ const startRebind = () => {
1164
+ if (rebindStarted) return
1165
+ rebindStarted = true
1166
+ clearTimeout(rebindFallback)
1167
+ setImmediate(() => rebindHost(nextHost).catch((e) => {
1168
+ try {
1169
+ const latest = readRaw(); latest.ui = latest.ui || {}; latest.ui.host = previousHost
1170
+ writeRaw(latest)
1171
+ } catch (rollbackErr) { log('ui host rollback failed: ' + (rollbackErr && rollbackErr.message)) }
1172
+ audit.append({ kind: 'ui-network-rebind', ok: false, from: nextHost, to: previousHost, ts: Date.now() })
1173
+ log('ui host change failed: ' + (e && e.message))
1174
+ }))
1175
+ }
1176
+ // Wait for the switching response's connection to close before closing
1177
+ // the listener; a fixed delay can still reset the response under load.
1178
+ const rebindFallback = setTimeout(startRebind, 1000)
1179
+ if (rebindFallback.unref) rebindFallback.unref()
1180
+ if (responseSocket && !responseSocket.destroyed) responseSocket.once('close', startRebind)
1181
+ res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8', Connection: 'close' })
1182
+ res.end(JSON.stringify({ ok: true, rebinding: true, lan: nextHost !== '127.0.0.1' }), () => {
1183
+ if (!responseSocket || responseSocket.destroyed) startRebind()
1184
+ })
1185
+ return
1186
+ }
1187
+ return sendJSON(res, 200, await buildState())
1188
+ }
1189
+ if (p === '/api/feishu' && req.method === 'POST') {
1190
+ const { appId, appSecret, botOpenId, adminOpenId, adminName, onlyWhenMentioned } = await body(req); const raw = readRaw()
1191
+ raw.feishu = raw.feishu || { onlyWhenMentioned: false }
1192
+ // Only overwrite on a non-empty value (symmetric with appSecret below): when appId is
1193
+ // an `env:VAR` ref, buildState renders the field blank, so a plain 保存 posts appId:'' —
1194
+ // `!= null` would silently wipe the env ref and break creds. Empty appId has no valid use.
1195
+ // 换了应用 ⇒ 缓存的身份属于上一个应用,留着会让顶栏和身份条显示错的应用名(比缺失更糟)。
1196
+ // 作废即可,不必在保存时联网重取:下一次「预检 / 刷新身份」会填上,期间显示「尚未获取」。
1197
+ if (appId && raw.feishu.appId && appId !== raw.feishu.appId) delete raw.feishu.identity
1198
+ if (appId) raw.feishu.appId = appId
1199
+ if (appSecret) raw.feishu.appSecret = appSecret
1200
+ // manual botOpenId entry must persist too, not just the auto-fetch path (审查 P1)
1201
+ if (typeof botOpenId === 'string') raw.feishu.botOpenId = botOpenId.trim()
1202
+ // operator/admin — auto-invited into every console-created chat
1203
+ if (typeof adminOpenId === 'string') raw.feishu.adminOpenId = adminOpenId.trim()
1204
+ if (typeof adminName === 'string') raw.feishu.adminName = adminName.trim()
1205
+ if (onlyWhenMentioned != null) raw.feishu.onlyWhenMentioned = !!onlyWhenMentioned
1206
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1207
+ }
1208
+ // ── Slack 应用(与上面飞书那组平级)────────────────────────────────────────
1209
+ // ⚠ 两个 token 都是**完整凭据**:xoxb 能以 bot 身份发消息,xapp 能开长连接。
1210
+ // 因此只写不读 —— buildState 只回 status,任何路径都不把明文送回浏览器。
1211
+ if (p === '/api/slack' && req.method === 'POST') {
1212
+ const { botToken, appToken, botUserId, onlyWhenMentioned, replyInThread, commandAlias } = await body(req)
1213
+ const raw = readRaw()
1214
+ raw.slack = raw.slack || {}
1215
+ // 与飞书那组同惯例:空值不覆盖(界面上密钥框恒显示为空,一次「保存」不该把已配的抹掉)
1216
+ if (botToken) {
1217
+ if (raw.slack.botToken && botToken !== raw.slack.botToken) delete raw.slack.identity // 换了 app ⇒ 缓存的身份属于上一个
1218
+ raw.slack.botToken = botToken.trim()
1219
+ }
1220
+ if (appToken) raw.slack.appToken = appToken.trim()
1221
+ if (typeof botUserId === 'string') raw.slack.botUserId = botUserId.trim()
1222
+ if (onlyWhenMentioned != null) raw.slack.onlyWhenMentioned = !!onlyWhenMentioned
1223
+ if (replyInThread != null) raw.slack.replyInThread = !!replyInThread
1224
+ if (typeof commandAlias === 'string' && commandAlias.trim()) raw.slack.commandAlias = commandAlias.trim().slice(0, 2)
1225
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1226
+ }
1227
+ // 一次把「能不能用」查清楚:凭据 / scope / Socket Mode / 已加入的频道。
1228
+ // ⭐ scope 那条最要紧 —— 缺 scope 的典型症状是**事件静默不来**,不主动对照只能靠猜。
1229
+ if (p === '/api/slack/precheck' && req.method === 'POST') {
1230
+ const raw = readRaw(); const sl = raw.slack || {}
1231
+ const bt = isEnvRef(sl.botToken) ? (process.env[String(sl.botToken).slice(4)] || '') : sl.botToken
1232
+ const at = isEnvRef(sl.appToken) ? (process.env[String(sl.appToken).slice(4)] || '') : sl.appToken
1233
+ if (!bt || !at) return sendJSON(res, 400, { error: '先填 xoxb- 与 xapp- 两个 token(env: 引用要求该变量在 daemon 环境里存在)' })
1234
+ try {
1235
+ const r = await slackPrecheck(bt, at)
1236
+ verify.recordPrecheck('slack', { botToken: bt, appToken: at }, r.checks, vopts)
1237
+ if (r.identity) { raw.slack.identity = r.identity; if (r.botUserId) raw.slack.botUserId = r.botUserId; writeRaw(raw) }
1238
+ return sendJSON(res, 200, r)
1239
+ } catch (e) { return sendJSON(res, 502, { error: (e && e.message) || String(e) }) }
1240
+ }
1241
+ // 绑群下拉的数据源:**bot 已加入的**频道(没 /invite 的频道绑了也收不到消息)
1242
+ if (p === '/api/slack/channels' && req.method === 'GET') {
1243
+ const raw = readRaw(); const sl = raw.slack || {}
1244
+ const bt = isEnvRef(sl.botToken) ? (process.env[String(sl.botToken).slice(4)] || '') : sl.botToken
1245
+ if (!bt) return sendJSON(res, 400, { error: '先配 Slack 的 xoxb- token' })
1246
+ try { return sendJSON(res, 200, { channels: await fetchSlackChannels(bt) }) }
1247
+ catch (e) { return sendJSON(res, 502, { error: (e && e.code) === 'missing_scope' ? '缺 channels:read / groups:read —— 改 manifest 后要重新安装' : ((e && e.message) || String(e)) }) }
1248
+ }
1249
+ // 应用身份(名称 / 描述 / 头像 / 启用状态)—— 只刷这一块,不跑整套预检。
1250
+ // **落盘缓存而不是每次渲染现拉**:控制台 60s 自动刷新 + 每次切页签都会重画,现拉就是
1251
+ // 每分钟一次飞书调用,而月调用量是这个租户的硬约束(2026-07 已撞过线)。与 adminName
1252
+ // 同惯例:远端来的展示数据落配置,点「预检 / 刷新」时才更新。
1253
+ if (p === '/api/feishu/appinfo' && req.method === 'POST') {
1254
+ const raw = readRaw(); const fe = raw.feishu || {}
1255
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先填 appId/appSecret(明文)' })
1256
+ try {
1257
+ const id = await fetchAppIdentity(feishuClient(fe.appId, fe.appSecret), fe.appId)
1258
+ raw.feishu.identity = identityCache(id)
1259
+ if (id.openId && id.openId !== raw.feishu.botOpenId) raw.feishu.botOpenId = id.openId
1260
+ writeRaw(raw)
1261
+ return sendJSON(res, 200, { identity: raw.feishu.identity, botOpenId: raw.feishu.botOpenId || '' })
1262
+ } catch (e) { return sendJSON(res, 502, { error: larkErrMsg(e) }) }
1263
+ }
1264
+ if (p === '/api/feishu/botid' && req.method === 'POST') {
1265
+ const raw = readRaw(); const fe = raw.feishu || {}
1266
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先填 appId/appSecret(明文)' })
1267
+ try { const oid = await fetchBotOpenId(feishuClient(fe.appId, fe.appSecret)); if (!oid) return sendJSON(res, 502, { error: '未取到 open_id' }); raw.feishu.botOpenId = oid; writeRaw(raw); return sendJSON(res, 200, { botOpenId: oid }) }
1268
+ catch (e) { return sendJSON(res, 502, { error: larkErrMsg(e) }) }
1269
+ }
1270
+ // One-pass Feishu readiness checklist: probe creds / bot identity /
1271
+ // scopes and return ✓/✗ rows so the setup card shows what to fix in one look.
1272
+ // Persists the botOpenId it discovers (same as /botid) so the field auto-fills.
1273
+ if (p === '/api/feishu/precheck' && req.method === 'POST') {
1274
+ const raw = readRaw(); const fe = raw.feishu || {}
1275
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先填 appId/appSecret(明文)' })
1276
+ try {
1277
+ const r = await feishuPrecheck(feishuClient(fe.appId, fe.appSecret), { appId: fe.appId })
1278
+ verify.recordPrecheck('feishu', fe, r.checks, vopts)
1279
+ let dirty = false
1280
+ if (r.botOpenId && r.botOpenId !== raw.feishu.botOpenId) { raw.feishu.botOpenId = r.botOpenId; dirty = true }
1281
+ if (r.identity) { raw.feishu.identity = identityCache(r.identity); dirty = true }
1282
+ if (dirty) writeRaw(raw)
1283
+ return sendJSON(res, 200, { ...r, identity: raw.feishu.identity || null })
1284
+ } catch (e) { return sendJSON(res, 502, { error: larkErrMsg(e) }) }
1285
+ }
1286
+ // Admin candidates: non-bot members across the bot's chats → the
1287
+ // 「就是我」picker, so adminOpenId is chosen not hand-copied. Needs the bot in
1288
+ // at least one chat (the one manual step: 把 bot 拉进任意群).
1289
+ if (p === '/api/feishu/admin-candidates' && req.method === 'GET') {
1290
+ const raw = readRaw(); const fe = raw.feishu || {}
1291
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先填 appId/appSecret(明文)' })
1292
+ try {
1293
+ const client = feishuClient(fe.appId, fe.appSecret)
1294
+ const botOpenId = fe.botOpenId || (await fetchBotOpenId(client).catch(() => null))
1295
+ const candidates = await fetchAdminCandidates(client, botOpenId)
1296
+ return sendJSON(res, 200, { candidates })
1297
+ } catch (e) { return sendJSON(res, 502, { error: larkErrMsg(e) }) }
1298
+ }
1299
+ // Org contacts — enumerate the whole address book (dept-tree walk) so the admin
1300
+ // picker can choose anyone, not just group members. Needs contact:department.
1301
+ // base:readonly + a user-read scope; a scope error surfaces via larkErrMsg.
1302
+ if (p === '/api/feishu/org-users' && req.method === 'GET') {
1303
+ const raw = readRaw(); const fe = raw.feishu || {}
1304
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先填 appId/appSecret(明文)' })
1305
+ try { return sendJSON(res, 200, await fetchOrgUsers(feishuClient(fe.appId, fe.appSecret))) }
1306
+ catch (e) { return sendJSON(res, 502, { error: '拉取通讯录失败 — 需在开放平台开通 contact:department.base:readonly + 用户读权限并发版 · ' + larkErrMsg(e) }) }
1307
+ }
1308
+ // Resolve an admin's open_id from their email OR mobile — the way to
1309
+ // add an admin before the bot is in any group (no member candidates yet). Needs
1310
+ // the contact:user.id:readonly scope; a non-Feishu contact resolves to openId=null.
1311
+ if (p === '/api/feishu/resolve-contact' && req.method === 'GET') {
1312
+ const q = (url.searchParams.get('q') || '').trim()
1313
+ if (!q) return sendJSON(res, 400, { error: '邮箱或手机号必填' })
1314
+ const raw = readRaw(); const fe = raw.feishu || {}
1315
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先填 appId/appSecret(明文)' })
1316
+ try { return sendJSON(res, 200, await resolveContactToOpenId(feishuClient(fe.appId, fe.appSecret), q)) }
1317
+ catch (e) { return sendJSON(res, 502, { error: larkErrMsg(e) }) }
1318
+ }
1319
+ // Save the admins list (可多个). Replaces the whole list; migrates away from the
1320
+ // legacy single adminOpenId/adminName. Each entry is {openId, name}.
1321
+ if (p === '/api/feishu/admins' && req.method === 'POST') {
1322
+ const { admins } = await body(req); const raw = readRaw(); raw.feishu = raw.feishu || { onlyWhenMentioned: false }
1323
+ const seen = new Set()
1324
+ raw.feishu.admins = (Array.isArray(admins) ? admins : [])
1325
+ .filter((a) => a && typeof a.openId === 'string' && a.openId.trim())
1326
+ .map((a) => ({ openId: a.openId.trim(), name: String(a.name || '').trim() }))
1327
+ .filter((a) => (seen.has(a.openId) ? false : (seen.add(a.openId), true))) // dedup by openId
1328
+ delete raw.feishu.adminOpenId; delete raw.feishu.adminName // migrate off the single-admin fields
1329
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1330
+ }
1331
+ // 企业微信机器人凭据与选项(设置页「企业微信机器人」卡)。写回规矩与飞书 appSecret 完全一致:填了写明文、支持 env: 引用、
1332
+ // 空值不覆盖(botId 是 env: 引用时表单渲染为空,回传空串不能把引用冲掉)。names:{ userid: 显示名 },整体替换(表单是全量编辑)。
1333
+ if (p === '/api/wecom' && req.method === 'POST') {
1334
+ const { botId, secret, botName, welcome, names, pollSec, followSec, gatekeeper, keywords, cliOff } = await body(req); const raw = readRaw()
1335
+ raw.wecom = raw.wecom || {}
1336
+ // 点名词:数组或「逗号 / 顿号 / 空白」分隔的字串;空 = 回默认(删字段)。只认 1~20 个、每个 ≤ 20 字
1337
+ if (keywords !== undefined) {
1338
+ const arr = (Array.isArray(keywords) ? keywords : String(keywords || '').split(/[,,、\s]+/)).map((k) => String(k || '').trim()).filter(Boolean)
1339
+ if (arr.some((k) => k.length > 20) || arr.length > 20) return sendJSON(res, 400, { error: '点名词最多 20 个,每个不超过 20 字' })
1340
+ if (arr.length) raw.wecom.keywords = [...new Set(arr)]; else delete raw.wecom.keywords
1341
+ }
1342
+ // 命令行工具开关:关 = cli:false(不搜姓名 / 不发图 / 「不 @ 也回复」不生效);开 = 删掉 false(可执行名若是自定义字串不动)
1343
+ if (cliOff !== undefined) {
1344
+ if (cliOff === true || cliOff === 'true') raw.wecom.cli = false
1345
+ else if (raw.wecom.cli === false) delete raw.wecom.cli
1346
+ }
1347
+ // 第二道门:{ provider, model? };provider 空 / null = 关(删字段)。只认配置里存在的模型名,能不能用(有没有 key)由 doctor 的 wecom.gate 行报
1348
+ if (gatekeeper !== undefined) {
1349
+ const prov = gatekeeper && typeof gatekeeper === 'object' ? String(gatekeeper.provider || '').trim() : String(gatekeeper || '').trim()
1350
+ if (!prov) delete raw.wecom.gatekeeper
1351
+ else {
1352
+ if (!(raw.providers || {})[prov]) return sendJSON(res, 400, { error: `门控模型「${prov}」不在模型列表里` })
1353
+ const model = gatekeeper && typeof gatekeeper === 'object' ? String(gatekeeper.model || '').trim() : ''
1354
+ raw.wecom.gatekeeper = model ? { provider: prov, model } : { provider: prov }
1355
+ }
1356
+ }
1357
+ // 「不 @ 也回复」两档:数字就写(0 有意义:关 / 全喂),空串 = 回默认(删字段),非数字拒
1358
+ for (const [k, v] of [['pollSec', pollSec], ['followSec', followSec]]) {
1359
+ if (v === undefined) continue
1360
+ if (v === '' || v === null) { delete raw.wecom[k]; continue }
1361
+ const n = Number(v)
1362
+ if (!Number.isFinite(n) || n < 0) return sendJSON(res, 400, { error: `${k === 'pollSec' ? '轮询间隔' : '跟进窗口'}要是不小于 0 的秒数` })
1363
+ raw.wecom[k] = Math.round(n)
1364
+ }
1365
+ if (typeof botId === 'string' && botId.trim()) raw.wecom.botId = botId.trim()
1366
+ if (typeof secret === 'string' && secret.trim()) raw.wecom.secret = secret.trim()
1367
+ if (typeof botName === 'string') { if (botName.trim()) raw.wecom.botName = botName.trim(); else delete raw.wecom.botName }
1368
+ if (typeof welcome === 'string') { if (welcome.trim()) raw.wecom.welcome = welcome.trim(); else delete raw.wecom.welcome }
1369
+ if (names && typeof names === 'object' && !Array.isArray(names)) {
1370
+ const clean = {}
1371
+ for (const [k, v] of Object.entries(names)) { const kk = String(k || '').trim(), vv = String(v || '').trim(); if (kk && vv) clean[kk] = vv }
1372
+ if (Object.keys(clean).length) raw.wecom.names = clean; else delete raw.wecom.names
1373
+ }
1374
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1375
+ }
1376
+ // 企业微信预检:只调本机官方命令行工具(以机器人凭据授权过的那一份),**不另开长连接**(一个机器人只许一条,开了就把守护进程踢下线)。
1377
+ // 三行:凭据形状 · 工具装没装 · 工具授权能不能列出会话(顺带给出候选群数 —— 这就是「绑定群」弹窗能看到的那份)。
1378
+ // 长连接本身只有守护进程验得了,末尾一句指到概览的「企业微信连接」行。手动点按钮才跑,控制台自动刷新不碰它。
1379
+ // 刷新身份:企微没有「取机器人信息」接口 ⇒ 扫最近被 @ 过的几个群的历史,被 @ 最多的名字就是它,落进 wecom.botName(剥 @ / 点名 / 门控提示词都用它)
1380
+ if (p === '/api/wecom/identity' && req.method === 'POST') {
1381
+ const raw = readRaw(); const we = raw.wecom || {}
1382
+ if (!we.botId || !we.secret) return sendJSON(res, 400, { error: '先填 Bot ID / Secret' })
1383
+ if (we.cli === false) return sendJSON(res, 400, { error: '命令行工具已关闭,取不到群历史;先把「不用命令行工具」取消' })
1384
+ try {
1385
+ const cli = we.cli || 'wecom-cli'
1386
+ const wc = createWecomCli({ bin: cli, ...(wecomExec ? { exec: wecomExec } : {}) })
1387
+ // 正路:工具的身份接口直接给名字(10-12 调研确认是平台唯一能取到机器人名的地方)
1388
+ try {
1389
+ const who = await wc.whoami()
1390
+ const changed = we.botName !== who.bot.name
1391
+ raw.wecom = { ...we, botName: who.bot.name }; writeRaw(raw)
1392
+ return sendJSON(res, 200, { name: who.bot.name, botId: who.bot.id, user: who.user, source: 'identity', changed, state: await buildState() })
1393
+ } catch (e) { if (e && e.code === 'ENOENT') throw e /* 没装:下面也做不了 */ }
1394
+ // 退路(老版工具没有身份服务):扫最近几个群的历史,被 @ 最多的名字就是它
1395
+ const chats = (await listWecomSessions({ cli, ...(wecomExec ? { exec: wecomExec } : {}) })).slice(0, 5) // 最近 5 个群够用,每个群一次子进程
1396
+ const all = []
1397
+ for (const c of chats) { try { all.push(...(await wc.history(c.chatId))) } catch { /* 单个群拉不到不影响别的 */ } }
1398
+ const g = guessBotName(all)
1399
+ if (!g.name) return sendJSON(res, 200, { name: null, groups: chats.length, scanned: all.length, hint: chats.length ? '最近的群历史里没有人 @ 过它 —— 先在群里 @ 它说一句,再刷新' : '还没有机器人被 @ 过的群 —— 建群、把机器人加进去并 @ 一次' })
1400
+ const changed = we.botName !== g.name
1401
+ raw.wecom = { ...we, botName: g.name }; writeRaw(raw)
1402
+ return sendJSON(res, 200, { name: g.name, count: g.count, total: g.total, groups: chats.length, scanned: all.length, changed, source: 'history', state: await buildState() })
1403
+ } catch (e) { return sendJSON(res, 502, { error: (e && e.message) || String(e) }) }
1404
+ }
1405
+ if (p === '/api/wecom/precheck' && req.method === 'POST') {
1406
+ const raw = readRaw(); const we = raw.wecom || {}
1407
+ const checks = []
1408
+ const credOk = !!(we.botId && we.secret)
1409
+ checks.push({ status: credOk ? 'ok' : 'fail', label: '凭据', detail: credOk ? ((isEnvRef(we.botId) || isEnvRef(we.secret)) ? 'Bot ID / Secret 已配(环境变量引用,由守护进程解析)' : 'Bot ID / Secret 已配') : (we.botId ? '缺 Secret' : (we.secret ? '缺 Bot ID' : '缺 Bot ID 与 Secret')) })
1410
+ if (we.cli === false) {
1411
+ checks.push({ status: 'skip', label: '命令行工具', detail: '配置里已关闭 — 群里只显示成员账号,助手不能发图发文件' })
1412
+ } else {
1413
+ try {
1414
+ const chats = await listWecomSessions({ cli: we.cli || 'wecom-cli', ...(wecomExec ? { exec: wecomExec } : {}) })
1415
+ checks.push({ status: 'ok', label: '命令行工具', detail: '已安装并授权' })
1416
+ // 工具授权的是不是同一个机器人:不是 ⇒ 姓名照样查得到,但发图 / 发文件、这里列出的群都是另一个机器人的
1417
+ try {
1418
+ // 身份接口给名字 + ID(正路);老版工具没有就退回 auth show(只有 ID)
1419
+ let botId = null, botName = null, authorized = true
1420
+ try { const who = await createWecomCli({ bin: we.cli || 'wecom-cli', ...(wecomExec ? { exec: wecomExec } : {}) }).whoami(); botId = who.bot.id; botName = who.bot.name }
1421
+ catch { const a = await wecomAuthShow({ cli: we.cli || 'wecom-cli', ...(wecomExec ? { exec: wecomExec } : {}) }); authorized = a.authorized; botId = a.botId }
1422
+ const mine = resolveRef(we.botId)
1423
+ const tag = botId ? `${botId.slice(0, 6)}…${botId.slice(-4)}` : ''
1424
+ if (!authorized) checks.push({ status: 'fail', label: '工具授权身份', detail: '未授权 — 用这里填的 Bot ID / Secret 给命令行工具授权一次' })
1425
+ else if (botId && mine && botId !== mine) checks.push({ status: 'warn', label: '工具授权身份', detail: `授权给了另一个机器人(${botName ? botName + ' · ' : ''}${tag})— 成员姓名照常,但助手发的图 / 文件和下面列出的群都属于那个机器人;请用这里的凭据重新授权` })
1426
+ else checks.push({ status: 'ok', label: '工具授权身份', detail: botId ? `同一个机器人(${botName ? botName + ' · ' : ''}${tag})` : '已授权' })
1427
+ } catch (e) { checks.push({ status: 'warn', label: '工具授权身份', detail: '查不到授权状态:' + String((e && e.message) || e).slice(0, 120) }) }
1428
+ // 通讯录能力单独探一次:工具对「消息 / 通讯录」各有独立的 7 天授权,auth show 看不出;过期 = 群里查不到姓名
1429
+ try {
1430
+ await createWecomCli({ bin: we.cli || 'wecom-cli', ...(wecomExec ? { exec: wecomExec } : {}) }).findUser('a')
1431
+ checks.push({ status: 'ok', label: '通讯录授权', detail: '可查成员姓名(授权每 7 天一续,过期会进运维告警群)' })
1432
+ } catch (e) {
1433
+ const expired = e && String(e.code) === '850003'
1434
+ checks.push({ status: expired ? 'fail' : 'warn', label: '通讯录授权', detail: expired ? '已过期 — 请机器人创建者在企业微信里点续期链接;过期期间群里只显示成员账号' : '查不通:' + String((e && e.message) || e).slice(0, 120) })
1435
+ }
1436
+ checks.push({ status: chats.length ? 'ok' : 'warn', label: '可绑定的群', detail: chats.length ? `${chats.length} 个(机器人被 @ 过的群)` : '0 个 — 新群要先把机器人加进群并 @ 一次' })
1437
+ } catch (e) {
1438
+ const msg = (e && e.message) || String(e)
1439
+ if (/未安装/.test(msg)) checks.push({ status: 'warn', label: '命令行工具', detail: '未安装 — 群里只显示成员账号、助手不能发图发文件、这里列不出群(装好后按机器人凭据授权一次即可)' })
1440
+ else checks.push({ status: 'fail', label: '命令行工具', detail: '已安装但调不通:' + msg.slice(0, 160) + ' — 多半是没用机器人凭据授权,或授权已过期' })
1441
+ }
1442
+ }
1443
+ return sendJSON(res, 200, { checks, note: '长连接只有守护进程连得上(一个机器人只许一条),连接状态看「概览 → 企业微信连接」那一行' })
1444
+ }
1445
+ // 企业微信:候选 = 机器人被 @ 过的群(没有「bot 所在群」接口);群名同样顺手回写(企微群改名后下次打开选择器即同步)。
1446
+ // 与飞书那条各管各的 —— 双平台并存时弹窗按所选平台调对应接口。
1447
+ if (p === '/api/wecom/chats' && req.method === 'GET') {
1448
+ const raw = readRaw(); const we = raw.wecom || {}
1449
+ if (!we.botId || !we.secret) return sendJSON(res, 400, { error: '先在设置里填企业微信机器人的 Bot ID / Secret' })
1450
+ try {
1451
+ const chats = await listWecomSessions({ cli: we.cli || 'wecom-cli', ...(wecomExec ? { exec: wecomExec } : {}) })
1452
+ if (raw.groups) {
1453
+ const byId = new Map(chats.map((c) => [c.chatId, c.name]))
1454
+ let changed = 0
1455
+ for (const [cid, g] of Object.entries(raw.groups)) { const nm = byId.get(cid); if (nm && nm !== g.name) { g.name = nm; changed++ } }
1456
+ if (changed) writeRaw(raw)
1457
+ }
1458
+ return sendJSON(res, 200, { chats, hint: '企业微信只能列出机器人被 @ 过的群:新群要先把机器人加进去并 @ 一次,才会出现在这里' })
1459
+ } catch (e) { return sendJSON(res, 502, { error: (e && e.message) || String(e) }) }
1460
+ }
1461
+ if (p === '/api/feishu/chats' && req.method === 'GET') {
1462
+ const raw = readRaw(); const fe = raw.feishu || {}
1463
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先在设置里填 appId/appSecret' })
1464
+ try {
1465
+ const chats = await fetchChats(feishuClient(fe.appId, fe.appSecret))
1466
+ // Self-healing display names: whenever the picker loads the
1467
+ // live chat list, sync bound groups' names — replaces the manual
1468
+ // 刷新群名 button (renames in Feishu get picked up on next open).
1469
+ if (raw.groups) {
1470
+ const byId = new Map(chats.map((c) => [c.chatId, c.name]))
1471
+ let changed = 0
1472
+ for (const [cid, g] of Object.entries(raw.groups)) {
1473
+ const nm = byId.get(cid)
1474
+ if (nm && nm !== g.name) { g.name = nm; changed++ }
1475
+ }
1476
+ if (changed) writeRaw(raw)
1477
+ }
1478
+ return sendJSON(res, 200, { chats })
1479
+ } catch (e) { return sendJSON(res, 502, { error: larkErrMsg(e) }) }
1480
+ }
1481
+ // Create a new Feishu group chat and return it so the console can pick it
1482
+ // without a re-list. `memberIds` = the admin open_ids the operator chose to
1483
+ // auto-invite (可选任意几个);a bot-only group is invisible in every Feishu
1484
+ // client, so if none were selected we fall back to ALL configured
1485
+ // admins to avoid an invisible group. Further members are invited inside Feishu.
1486
+ if (p === '/api/feishu/chats/create' && req.method === 'POST') {
1487
+ const { name, description, memberIds } = await body(req)
1488
+ if (!name || !name.trim()) return sendJSON(res, 400, { error: '群名称必填' })
1489
+ const raw = readRaw(); const fe = raw.feishu || {}
1490
+ if (!platformsConfigured(raw).feishu && platformsConfigured(raw).wecom) return sendJSON(res, 400, { error: WECOM_NO_API }) // 只配了企微:建群这件事平台上就没有
1491
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先在设置里填 appId/appSecret' })
1492
+ const picked = Array.isArray(memberIds) ? memberIds.filter(Boolean) : []
1493
+ const members = [...new Set(picked.length ? picked : adminList(fe).map((a) => a.openId))]
1494
+ try { return sendJSON(res, 200, await createChat(feishuClient(fe.appId, fe.appSecret), name.trim(), description, members)) }
1495
+ catch (e) {
1496
+ const raw = larkErrMsg(e)
1497
+ // 232043 "bot is invisible to user ids" — 成员不在应用「可用范围」内(≠ 通讯录数据授权范围)。
1498
+ if (/invisible|232043|unavailable ids/i.test(raw)) {
1499
+ const named = adminList(fe).filter((a) => raw.includes(a.openId)).map((a) => a.name || a.openId)
1500
+ const who = named.length ? `(${named.join('、')})` : ''
1501
+ return sendJSON(res, 502, { error: `部分管理员${who}不在应用「可用范围」内,bot 拉不进群 —— 去开放平台「应用发布 → 可用范围」把 TA 加入(或设为全部员工),或建群时取消勾选 TA。\n原始:${raw}` })
1502
+ }
1503
+ return sendJSON(res, 502, { error: raw })
1504
+ }
1505
+ }
1506
+ // Members of one chat — the invite picker's candidate source.
1507
+ if (p === '/api/feishu/chat-members' && req.method === 'GET') {
1508
+ const chatId = url.searchParams.get('chatId')
1509
+ if (!chatId) return sendJSON(res, 400, { error: 'chatId 必填' })
1510
+ const raw = readRaw(); const fe = raw.feishu || {}
1511
+ if (chatPlatformOf(raw, chatId) === 'wecom') return sendJSON(res, 400, { error: WECOM_NO_API }) // 企微群:成员列表 / 拉人没有接口
1512
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先在设置里填 appId/appSecret' })
1513
+ try { return sendJSON(res, 200, { members: await fetchChatMembers(feishuClient(fe.appId, fe.appSecret), chatId) }) }
1514
+ catch (e) { return sendJSON(res, 502, { error: larkErrMsg(e) }) }
1515
+ }
1516
+ // Invite members into an existing chat.
1517
+ if (p === '/api/feishu/chats/members' && req.method === 'POST') {
1518
+ const { chatId, memberIds } = await body(req)
1519
+ if (!chatId || !Array.isArray(memberIds) || !memberIds.filter(Boolean).length) return sendJSON(res, 400, { error: 'chatId 与 memberIds 必填' })
1520
+ const raw = readRaw(); const fe = raw.feishu || {}
1521
+ if (chatPlatformOf(raw, chatId) === 'wecom') return sendJSON(res, 400, { error: WECOM_NO_API })
1522
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先在设置里填 appId/appSecret' })
1523
+ try { await addChatMembers(feishuClient(fe.appId, fe.appSecret), chatId, memberIds.filter(Boolean)); return sendJSON(res, 200, { ok: true, added: memberIds.filter(Boolean).length }) }
1524
+ catch (e) { return sendJSON(res, 502, { error: larkErrMsg(e) }) }
1525
+ }
1526
+ // Disband a chat — safety model:
1527
+ // 1. only a BOUND chat may be disbanded (sbot's own turf; enforced here,
1528
+ // not just hidden in the UI). Success removes the binding atomically.
1529
+ // 2. STRICT two-phase for every disband: phase 1 issues a one-time token
1530
+ // (10 min); other users present ⇒ in-group notice + 30s cool-down,
1531
+ // both server-enforced. Phase 2 (&confirm=1&dtoken=…) validates the
1532
+ // token/cool-down before touching anything.
1533
+ // 3. empty / admin-only chat: token flow too, but zero cool-down — the
1534
+ // console chains the two calls so it stays one interaction.
1535
+ // 4. both phases are appended to ~/.sbot/audit.jsonl.
1536
+ if (p === '/api/feishu/chats' && req.method === 'DELETE') {
1537
+ const chatId = url.searchParams.get('chatId')
1538
+ const confirmed = url.searchParams.get('confirm') === '1'
1539
+ const dtoken = url.searchParams.get('dtoken') || ''
1540
+ if (!chatId) return sendJSON(res, 400, { error: 'chatId 必填' })
1541
+ const raw = readRaw(); const fe = raw.feishu || {}
1542
+ if (chatPlatformOf(raw, chatId) === 'wecom') return sendJSON(res, 400, { error: WECOM_NO_API }) // 企微群:解散本就没有接口
1543
+ const bound = raw.groups && raw.groups[chatId]
1544
+ if (!bound) return sendJSON(res, 400, { error: '仅已绑定的群可从控制台解散 — 未绑定的群不在本产品管辖内' })
1545
+
1546
+ if (confirmed) { // phase 2 — token/cool-down gate BEFORE any credential use
1547
+ const pend = disbandPending.get(chatId)
1548
+ if (!pend || !dtoken || pend.token !== dtoken) return sendJSON(res, 400, { error: '解散确认无效 — 请重新发起解散' })
1549
+ if (Date.now() > pend.expiresAt) { disbandPending.delete(chatId); return sendJSON(res, 400, { error: '解散确认已过期(10 分钟)— 请重新发起' }) }
1550
+ if (Date.now() < pend.readyAt) return sendJSON(res, 400, { error: `冷静期未过 — 还需 ${Math.ceil((pend.readyAt - Date.now()) / 1000)}s(给群成员留出看到预告的时间)` })
1551
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先在设置里填 appId/appSecret' })
1552
+ try {
1553
+ await deleteChat(feishuClient(fe.appId, fe.appSecret), chatId)
1554
+ disbandPending.delete(chatId)
1555
+ delete raw.groups[chatId]
1556
+ writeRaw(raw) // hot-reloads the daemon — binding gone with the group
1557
+ audit.append({ kind: 'disband-confirmed', chatId, name: bound.name || '', users: pend.users, ts: Date.now() })
1558
+ return sendJSON(res, 200, { ...(await buildState()), ok: true, unbound: true })
1559
+ } catch (e) { return sendJSON(res, 502, { error: '解散失败: ' + larkErrMsg(e) }) }
1560
+ }
1561
+
1562
+ // phase 1 — reuse a still-valid pending request (don't spam the group
1563
+ // with repeated notices), else fetch members, notify, arm the token.
1564
+ const prev = disbandPending.get(chatId)
1565
+ if (prev && Date.now() < prev.expiresAt) {
1566
+ return sendJSON(res, 200, { needConfirm: true, users: prev.users, names: prev.names, disbandToken: prev.token, coolDownSec: Math.max(0, Math.ceil((prev.readyAt - Date.now()) / 1000)), reused: true })
1567
+ }
1568
+ if (!fe.appId || !fe.appSecret || isEnvRef(fe.appId) || isEnvRef(fe.appSecret)) return sendJSON(res, 400, { error: '先在设置里填 appId/appSecret' })
1569
+ const client = feishuClient(fe.appId, fe.appSecret)
1570
+ try {
1571
+ const members = await fetchChatMembers(client, chatId)
1572
+ const adminSet = new Set(adminList(fe).map((a) => a.openId))
1573
+ const others = members.filter((m) => !adminSet.has(m.openId))
1574
+ const coolDownSec = others.length ? 30 : 0
1575
+ const pend = { token: crypto.randomBytes(12).toString('hex'), readyAt: Date.now() + coolDownSec * 1000, expiresAt: Date.now() + 10 * 60_000, users: others.length, names: others.slice(0, 10).map((m) => m.name) }
1576
+ if (others.length) await sendChatText(client, chatId, `⚠ 管理员即将解散本群(绑定项目将一并解除),${coolDownSec}s 后可执行。如有异议请立即联系管理员。`)
1577
+ disbandPending.set(chatId, pend)
1578
+ audit.append({ kind: 'disband-request', chatId, name: bound.name || '', users: others.length, coolDownSec, ts: Date.now() })
1579
+ return sendJSON(res, 200, { needConfirm: true, users: pend.users, names: pend.names, disbandToken: pend.token, coolDownSec, notified: others.length > 0 })
1580
+ } catch (e) { return sendJSON(res, 502, { error: '解散失败: ' + larkErrMsg(e) }) }
1581
+ }
1582
+ // ⚠ 项目根目录在控制台**只读**(产品决定):它是围栏的策略,谁登上控制台谁就能改的话,围栏只剩
1583
+ // 一次点击的距离。故意**没有**写接口 —— 只藏按钮不够,拿到令牌照样能 POST。修改只走两条路:安装向导,
1584
+ // 或在机器上改配置文件的 projectsRoots(daemon watchFile 热重载、控制台每请求现读 ⇒ 即时生效)。
1585
+ // tests/unit/uiserver.test.js 钉着 POST /api/projects-roots 与 …/create 必须 404。
1586
+ // 目录浏览:只列目录、一层一层来,**围在项目根目录之内**(根从配置 projectsRoots 现读,
1587
+ // 改配置即生效;默认根缺失时自动建,配置指定的根不建)。越界的 path 夹回根,不报错 —— 浏览器里
1588
+ // 「回到根」比「读取失败」更像一个选目录的控件该有的样子。
1589
+ if (p === '/api/fs/listdir' && req.method === 'GET') {
1590
+ const target = url.searchParams.get('path') || ''
1591
+ try { const info = projectsroot.resolveProjectsRoots(readRaw()); return sendJSON(res, 200, listDirs(target, info.roots, { createMissing: !info.fromConfig })) }
1592
+ catch (e) { return sendJSON(res, 400, { error: (e && e.message) || String(e) }) }
1593
+ }
1594
+ // 在根目录之内新建项目文件夹(围栏同上)
1595
+ if (p === '/api/fs/mkdir' && req.method === 'POST') {
1596
+ const { parent, name } = await body(req)
1597
+ try { const info = projectsroot.resolveProjectsRoots(readRaw()); return sendJSON(res, 200, makeDir(parent, name, info.roots, { createMissing: !info.fromConfig })) }
1598
+ catch (e) { return sendJSON(res, 400, { error: (e && e.message) || String(e) }) }
1599
+ }
1600
+ if (p === '/api/groups' && req.method === 'POST') {
1601
+ const { chatId, workdir: workdirIn, provider, model, name, platform } = await body(req)
1602
+ if (!chatId || !workdirIn) return sendJSON(res, 400, { error: 'chatId 与 workdir 必填' })
1603
+ if (platform !== undefined && platform !== '' && !PLATFORMS.includes(platform)) return sendJSON(res, 400, { error: `platform 只认 ${PLATFORMS.join(' / ')}` })
1604
+ // 显式平台与 id 形态打架(oc_ 标企微 / wr… 标飞书)一定是弹窗档位没切对,不替人猜、直接拒(对抗审查:改既有群时会把企微群改写成飞书)
1605
+ // ⚠ Slack 形态不拿来拒:企微单聊 userid 可能恰好长成 C…/D… 大写串,只有 oc_ / wr 这两种强形态才判冲突
1606
+ if (PLATFORMS.includes(platform) && inferChatPlatform(chatId) && inferChatPlatform(chatId) !== 'slack' && inferChatPlatform(chatId) !== platform) return sendJSON(res, 400, { error: `这个群 id 看着是${inferChatPlatform(chatId) === 'wecom' ? '企业微信' : '飞书'}的,与所选平台不符 — 在弹窗顶部切到对的平台再保存` })
1607
+ // 只收规范的绝对路径(认 `~`,存展开后的):相对路径会随 daemon 的 cwd 漂;含 `.`/`..` 段或重复分隔符的写法直接拒,
1608
+ // 不替人猜 —— `proj/link/..` 字面在根内、内核在链接指向处,存下这个字符串等于把围栏交给内核去反着解释(对抗审查 🔴)。
1609
+ const wd = projectsroot.workdirInput(String(workdirIn))
1610
+ if (wd.error) return sendJSON(res, 400, { error: wd.error })
1611
+ const workdir = wd.path
1612
+ // Fail here, not at the first task: a binding to a missing
1613
+ // directory would only surface when a turn tries to run in it.
1614
+ if (!fs.existsSync(workdir)) return sendJSON(res, 400, { error: `workdir 不存在: ${workdir}` })
1615
+ const raw = readRaw(); raw.groups = raw.groups || {}; delete raw.groups.oc_example_chat_id
1616
+ // 已绑定的群重存:旧对象在下面整个展开再覆盖(见 next),这里只是取出来比目录有没有改
1617
+ const prev = raw.groups[chatId] || {}
1618
+ // 项目根目录围栏(src/projectsroot.js):只对**新的或改过的**目录判 —— 配置文件里手写的越界老绑定
1619
+ // 原样保留(doctor 出 warn),否则只想换个模型的保存也会被拦。选择器那侧的围栏在这儿必须重跑一遍:
1620
+ // 文本框能手填任意路径,受理侧的围栏管不到执行侧(2026-09-08 复核时这里只查了「目录存在」)。
1621
+ // 「没改」按内核语义比(realpath 之后),不按字面:字面相等而 realpath 不同的写法上面已经拒了,这里是第二道
1622
+ if (!prev.workdir || !projectsroot.samePath(workdir, prev.workdir)) {
1623
+ const info = projectsroot.resolveProjectsRoots(raw)
1624
+ if (!projectsroot.withinRoots(workdir, info.roots)) return sendJSON(res, 400, { error: `项目目录须在项目根目录下(${projectsroot.describeRoots(info.roots)}):${workdir}` })
1625
+ }
1626
+ const isNewBinding = !prev.workdir
1627
+ // ⚠ 从旧对象展开再覆盖,不从头重建:本接口只管目录 / 模型 / 名字,这个群的其它配置(permissionMode /
1628
+ // repo / plugins / crons / board / redactPii / preview / paused …)各有各的接口写入。此前这里 `{ workdir, provider }`
1629
+ // 起手、逐个挑字段保留 —— 只记得工具白名单与名字,其余对已绑定的群点一次「保存修改」就整个丢掉:完全访问
1630
+ // 退回只读、画布解绑、定时任务消失、远端断开,而界面上看不出任何异常(2026-09-09 复现,uiserver 单测钉住)。
1631
+ // 逐个挑必然再漏(每加一个群级字段都得回来补一行);展开旧对象才是「没碰的就不动」。
1632
+ const next = { ...prev, workdir, provider: provider || prev.provider || raw.defaultProvider }
1633
+ // 钉住的模型(groups[].model)是型号 id、各家不同:显式给了就用;没给时同一家照留(展开已带上),
1634
+ // 换了家必须丢 —— 把上一家的型号 id 带到新家,等于每轮都指定一个不存在的模型。
1635
+ if (model) next.model = model
1636
+ else if (!isNewBinding && next.provider !== (prev.provider || raw.defaultProvider)) delete next.model
1637
+ // 群的显示名来自飞书群列表,卡片 / 概览靠它代替不可读的 chatId;没发来就沿用旧的(展开已带上)
1638
+ if (name) next.name = name
1639
+ // 群属于哪个平台(双平台并存):弹窗显式给了就写;没给沿用旧的;都没有按 id 形态 / 只配了一个平台推断后**写进配置**
1640
+ // —— 老配置缺这个字段靠推断在跑,控制台重新保存一次就把它钉死(doctor 那行提示的出路就是这里)
1641
+ // ⚠ 不按「那个平台配了凭据没」拒绝:纯配置模式(先绑群、后填凭据)一直是允许的;没配的平台由 doctor 的群行报「收不到消息」
1642
+ next.platform = PLATFORMS.includes(platform) ? platform : chatPlatformOf(raw, chatId)
1643
+ raw.groups[chatId] = next
1644
+ writeRaw(raw)
1645
+ // First-time binding: drop a confirmation into the group (best-effort) so a
1646
+ // fresh chat is immediately alive and members know how to use it.
1647
+ let notified = false, noticeQueued = false // notified = 真送达(飞书 await 判);noticeQueued = 已交 daemon 发(企微 fire-and-forget,送没送到只有日志知道)
1648
+ const fe = raw.feishu || {}
1649
+ const sl = raw.slack || {}
1650
+ if (isNewBinding && next.platform === 'slack') {
1651
+ const bt = isEnvRef(sl.botToken) ? (process.env[String(sl.botToken).slice(4)] || '') : sl.botToken
1652
+ if (bt) {
1653
+ try { await sendSlackText(bt, chatId, bindNoticeText(workdir, 'slack', `${sl.commandAlias || '!'}help`)); notified = true }
1654
+ catch (e) { log('bind notice failed (slack): ' + ((e && e.message) || e)) }
1655
+ }
1656
+ }
1657
+ if (isNewBinding && next.platform === 'feishu' && fe.appId && fe.appSecret && !isEnvRef(fe.appId) && !isEnvRef(fe.appSecret)) {
1658
+ try { await sendChatText(feishuClient(fe.appId, fe.appSecret), chatId, bindNoticeText(workdir, 'feishu')); notified = true }
1659
+ catch (e) { log('bind notice failed: ' + larkErrMsg(e)) }
1660
+ }
1661
+ // 企微群:控制台自己没有机器人的长连接(一个机器人只许一条),绑定通知只能借 daemon 的那条发;`sbot ui` 独立态发不了
1662
+ if (isNewBinding && next.platform === 'wecom' && control && control.notifyGroup) {
1663
+ try { control.notifyGroup(chatId, bindNoticeText(workdir, 'wecom')); noticeQueued = true } catch (e) { log('bind notice failed: ' + (e && e.message)) }
1664
+ }
1665
+ return sendJSON(res, 200, { ...(await buildState()), notified, noticeQueued })
1666
+ }
1667
+ if (p === '/api/groups' && req.method === 'DELETE') {
1668
+ const chatId = url.searchParams.get('chatId'); const raw = readRaw()
1669
+ if (raw.groups) delete raw.groups[chatId]
1670
+ writeRaw(raw); return sendJSON(res, 200, await buildState())
1671
+ }
1672
+ // 群项目的 git 远端(可空)。场景 = 本地已有代码、远端是刚建的空仓库,填地址是为了往上推,
1673
+ // 所以这里只建立关联(git init + origin,幂等),不 clone、不 commit/push —— 推什么、
1674
+ // 怎么写 commit 由 agent 判断。清空地址只解除配置记录,不动 workdir 里的 git。
1675
+ if (p === '/api/groups/repo' && req.method === 'POST') {
1676
+ const { chatId, repo } = await body(req)
1677
+ const raw = readRaw()
1678
+ const g = raw.groups && raw.groups[chatId]
1679
+ if (!g) return sendJSON(res, 400, { error: '未找到该群绑定' })
1680
+ const url2 = String(repo == null ? '' : repo).trim()
1681
+ if (!url2) { // 清空 = 解除关联记录
1682
+ delete g.repo; writeRaw(raw)
1683
+ return sendJSON(res, 200, { ...(await buildState()), cleared: true })
1684
+ }
1685
+ const r = await gitrepo.linkRepo(g.workdir, url2)
1686
+ if (!r.ok) return sendJSON(res, 400, { error: r.error })
1687
+ g.repo = r.url
1688
+ writeRaw(raw)
1689
+ // 与模型/权限/插件变更同源:daemon 在跑时往群里同步一条精简通知
1690
+ if (control && control.notifyGroup) {
1691
+ notifyGroup(chatId, `📦 本群项目已关联代码仓库:${r.url}${r.initialized ? '(已在项目目录初始化 git)' : ''}`)
1692
+ }
1693
+ return sendJSON(res, 200, { ...(await buildState()), initialized: r.initialized, replaced: r.replaced, branch: r.branch })
1694
+ }
1695
+ // 连通性 + 权限探测:ls-remote 不落地任何东西。凭据/主机密钥是这条链路最常见的两种挂法,
1696
+ // 错误已翻成人话(见 gitrepo.humanizeGitError),省得 operator 对着英文 stderr 猜。
1697
+ if (p === '/api/groups/repo/test' && req.method === 'POST') {
1698
+ const { repo } = await body(req)
1699
+ const r = await gitrepo.testRemote(repo)
1700
+ return sendJSON(res, r.ok ? 200 : 400, r.ok ? { ok: true, branches: r.branches, empty: r.empty } : { error: r.error })
1701
+ }
1702
+ // per-project permissions: set this group's allow/deny (scoped to its workdir)
1703
+ // and/or its permissionMode override (only the two never-hang
1704
+ // modes are accepted; 'default'/'acceptEdits' would hang a headless turn).
1705
+ // writeRaw hot-reloads the daemon → the new policy applies on the group's next turn.
1706
+ if (p === '/api/groups/policy' && req.method === 'POST') {
1707
+ const { chatId, allowedTools, disallowedTools, permissionMode } = await body(req); const raw = readRaw()
1708
+ if (!raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1709
+ if (Array.isArray(allowedTools)) raw.groups[chatId].allowedTools = allowedTools
1710
+ if (Array.isArray(disallowedTools)) raw.groups[chatId].disallowedTools = disallowedTools
1711
+ if (permissionMode !== undefined) {
1712
+ if (permissionMode && permissionMode !== 'dontAsk' && permissionMode !== 'bypassPermissions') {
1713
+ return sendJSON(res, 400, { error: '按群执行模式仅支持 dontAsk / bypassPermissions — 其余模式在无头下会挂起等待审批' })
1714
+ }
1715
+ if (permissionMode) raw.groups[chatId].permissionMode = permissionMode
1716
+ else delete raw.groups[chatId].permissionMode // '' = 清除覆盖 → 默认 dontAsk(只读,fail-closed)
1717
+ }
1718
+ writeRaw(raw)
1719
+ if (control && control.notifyGroup) {
1720
+ const t = permissionMode === 'bypassPermissions' ? '⚠ 本群已切到完全访问(可执行任意命令)'
1721
+ : (permissionMode === 'dontAsk' || permissionMode === '') ? '🔧 本群已切到受限模式(白名单/只读)'
1722
+ : '🔧 本群工具权限已更新'
1723
+ notifyGroup(chatId, t)
1724
+ }
1725
+ return sendJSON(res, 200, await buildState())
1726
+ }
1727
+ // Codex 忙时跟进默认开启;显式 false 才关闭。当前轮按启动时配置运行。
1728
+ // providerOf 覆盖运行时模型切换/额度回退,因此必须以实际有效 runner 校验。
1729
+ if (p === '/api/groups/codex-steer' && req.method === 'POST') {
1730
+ const { chatId, enabled } = await body(req); const raw = readRaw()
1731
+ if (!chatId || !raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1732
+ if (typeof enabled !== 'boolean') return sendJSON(res, 400, { error: 'enabled 必须为布尔值' })
1733
+ const name = (control && control.providerOf && control.providerOf(chatId)) || raw.groups[chatId].provider || raw.defaultProvider || loadConfig(configPath).defaultProvider
1734
+ const provider = { ...DEFAULTS.providers, ...(raw.providers || {}) }[name]
1735
+ if (!provider || provider.runner !== 'codex') return sendJSON(res, 400, { error: '仅实际使用 Codex 的群项目可开启忙时跟进' })
1736
+ if (enabled) delete raw.groups[chatId].codexSteer
1737
+ else raw.groups[chatId].codexSteer = false
1738
+ writeRaw(raw)
1739
+ return sendJSON(res, 200, await buildState())
1740
+ }
1741
+ // 暂停/恢复(完全可逆,配置全保留):paused=true ⇒ 该群消息静默丢弃(engine 拦截+推水位线)、
1742
+ // 排队清空、cron 到点跳过、画布轮询跳过、升级播报跳过;进行中的轮跑完不中断(/stop 兜底已有)。
1743
+ // 落盘只写 true 一种情况(恢复=删字段,配置不长毛);热重载即生效。通知发在写回后 —— 出站
1744
+ // platform.send 不受 paused 拦截(拦的是入站),两条通知都能到群。
1745
+ if (p === '/api/groups/pause' && req.method === 'POST') {
1746
+ const { chatId, paused } = await body(req); const raw = readRaw()
1747
+ if (!chatId || !raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1748
+ if (paused) raw.groups[chatId].paused = true
1749
+ else delete raw.groups[chatId].paused
1750
+ writeRaw(raw)
1751
+ audit.append({ kind: 'group-pause', chatId, paused: !!paused, ts: Date.now() })
1752
+ // force:暂停/恢复动作本身的告知必须送达(暂停后群唯一还会收到的一条)
1753
+ notifyGroup(chatId, paused
1754
+ ? '⏸ 本群项目已暂停 — 期间消息不处理,定时任务与画布播报停发;恢复请到控制台'
1755
+ : '▶ 本群项目已恢复', { force: true })
1756
+ return sendJSON(res, 200, await buildState())
1757
+ }
1758
+ // 群预览策略(按群覆盖顶层 preview)。**这是 operator 的正规入口** —— 真机(2026-07-30)群里
1759
+ // 要求「随时都能打开」,agent 就自己造了份配置用 --config 把回收全关;禁掉那条绕法之后,
1760
+ // 合理需求必须有正规出口,否则它还会再找别的绕法。「要不要长期把一个目录暴露在局域网」
1761
+ // 是 operator 的决定,不该被一句话说服。
1762
+ if (p === '/api/groups/preview' && req.method === 'POST') {
1763
+ const { chatId, persist } = await body(req); const raw = readRaw()
1764
+ if (!chatId || !raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1765
+ const g = raw.groups[chatId]
1766
+ // ⚠ 合并而非整块替换:operator 可能手配过该群的其它预览字段(host/portFrom…),
1767
+ // 整块写会把它们抹掉。取消常驻时只摘掉这两个键,块空了才删。
1768
+ if (persist) g.preview = { ...(g.preview || {}), idleMin: 0, maxHours: 0 }
1769
+ else if (g.preview) {
1770
+ delete g.preview.idleMin; delete g.preview.maxHours
1771
+ if (!Object.keys(g.preview).length) delete g.preview
1772
+ }
1773
+ writeRaw(raw)
1774
+ audit.append({ kind: 'group-preview', chatId, persist: !!persist, ts: Date.now() })
1775
+ // 文案按**生效后的实际策略**算,别硬编码「30 分钟 / 8 小时」—— operator 配过顶层
1776
+ // preview 时那就是假话。v0.9.3 起策略对在跑的预览也立刻生效(收割器现读配置),
1777
+ // 所以这里可以把「包括正在跑的那个」写实:此前勾了常驻它照样到点停,通知等于骗人。
1778
+ const eff = preview.normalizeOptions(raw.preview, raw.groups[chatId].preview)
1779
+ notifyGroup(chatId, persist
1780
+ ? '🔓 本群预览已设为常驻(不再按闲置或时长自动停止,正在跑的那个也一并生效)—— 用完请说一声关掉,别让它一直挂在局域网上'
1781
+ : `🔒 本群预览已回到默认回收策略(${eff.idleMin > 0 ? `闲置 ${eff.idleMin} 分钟` : '不按闲置回收'} / ${eff.maxHours > 0 ? `最长 ${eff.maxHours} 小时` : '不限时长'})`)
1782
+ return sendJSON(res, 200, await buildState())
1783
+ }
1784
+ // 群数据明文开关:业务数据群里手机号/邮箱就是用户要查的内容本身,被出站
1785
+ // 脱敏遮成 ‹redacted:phone› 反而把人逼去截图绕行(2026-08-04 真机)。**只放行 PII 类,
1786
+ // 密钥类没有开关**(凭据出现在群里只能是事故)。「这个群的数据能不能见光」是 operator
1787
+ // 的决定 —— 与 preview 常驻同理,agent 不能自己开。改动即通知群(配置变更群内同步惯例)。
1788
+ if (p === '/api/groups/redact' && req.method === 'POST') {
1789
+ const { chatId, showPii } = await body(req); const raw = readRaw()
1790
+ if (!chatId || !raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1791
+ const g = raw.groups[chatId]
1792
+ if (showPii) g.redactPii = false
1793
+ else delete g.redactPii // 关掉 = 回默认遮盖,配置里不留字段(完全可逆惯例)
1794
+ writeRaw(raw)
1795
+ audit.append({ kind: 'group-redact', chatId, showPii: !!showPii, ts: Date.now() })
1796
+ notifyGroup(chatId, showPii
1797
+ ? '🔓 本群已设为数据明文输出:回复里的手机号/邮箱将原样显示(密钥类信息仍会自动遮盖)'
1798
+ : '🔒 本群已恢复数据遮盖:回复里的手机号/邮箱将显示为遮盖标记')
1799
+ return sendJSON(res, 200, await buildState())
1800
+ }
1801
+ // 「需要你处理」里忽略一条。只有失败轮次能忽略:其余几类各有自己的处理路径(反馈本标已处理、
1802
+ // 结束进程、停预览),在这里「忽略」只会把还在的问题藏起来。只回收件箱,前端只重画这一块。
1803
+ if (p === '/api/inbox/dismiss' && req.method === 'POST') {
1804
+ const { key } = await body(req)
1805
+ if (typeof key !== 'string' || !key.startsWith('failed:') || key.length > 300) return sendJSON(res, 400, { error: '这一条不能忽略' })
1806
+ inboxDismiss.add(key)
1807
+ return sendJSON(res, 200, { inbox: await inboxOnly() })
1808
+ }
1809
+ // 群预览服务:手动停一个(概览「预览服务」区的停止按钮)。控制台停 ⇒ **发群一行告知**
1810
+ // (群成员可能正开着那个链接,悄悄拔掉会让人以为 bot 坏了);记录由这里写 —— 「谁杀谁写」。
1811
+ if (p === '/api/previews/stop' && req.method === 'POST') {
1812
+ const { id } = await body(req)
1813
+ const dir = preview.previewDir()
1814
+ const hit = preview.listRecords(dir).find((r) => r.id === id && preview.isLive(r))
1815
+ if (!hit) return sendJSON(res, 404, { error: '没有这个运行中的预览(可能刚被回收)' })
1816
+ preview.markStopped(dir, hit, 'console', { notify: false }) // 先落判定再杀;通知这里直接发,不劳 reaper 重试
1817
+ preview.killWorker(hit.pid)
1818
+ audit.append({ kind: 'preview-stop', id, chatId: hit.chatId, port: hit.port, ts: Date.now() })
1819
+ // ⚠ 文案里含 label —— 那是 **agent 提供的文本**,与群回复同款过 redact(其它控制台通知的文案
1820
+ // 都是 operator 写死的字符串,这条是第一条带外部输入的)
1821
+ notifyGroup(hit.chatId, redact(preview.buildStoppedMessage(hit, 'console')))
1822
+ return sendJSON(res, 200, await buildState())
1823
+ }
1824
+ // 遗留进程:一键结束(概览「后台进程」区的按钮)。只认 pid 列表,但**不直接照单杀** ——
1825
+ // 先不走缓存重新列一遍,只杀此刻仍在遗留名单里的 pid:页面上那份名单最多是 30 秒前的,
1826
+ // 其间进程可能自己退了、pid 被系统发给别的东西,照单 kill 就是误杀无辜进程。名单外的
1827
+ // pid 整单拒绝(400)而不是跳过:请求与实际动作不一致时,静默少杀几个会让人以为都结束了。
1828
+ // 审计记 summary 而不是完整命令行(与 state 同一口径);杀完清缓存,返回的状态立刻是新的。
1829
+ if (p === '/api/leftovers/kill' && req.method === 'POST') {
1830
+ const b = await body(req)
1831
+ const pids = [...new Set((Array.isArray(b.pids) ? b.pids : []).map(Number).filter((n) => Number.isInteger(n) && n > 1))]
1832
+ if (!pids.length) return sendJSON(res, 400, { error: '没有指定要结束的进程' })
1833
+ const cur = await leftover.list()
1834
+ if (!cur.supported) return sendJSON(res, 400, { error: '这台机器不支持查看后台进程' })
1835
+ if (cur.error) return sendJSON(res, 500, { error: `查不了后台进程:${cur.error}` })
1836
+ const byPid = new Map(cur.rows.map((r) => [r.pid, r]))
1837
+ const stale = pids.filter((pid) => !byPid.has(pid))
1838
+ if (stale.length) return sendJSON(res, 400, { error: `有 ${stale.length} 个进程已经不在后台进程名单里(可能刚自己退出了),为防误杀没有动任何进程 —— 刷新后再试` })
1839
+ const r = await leftover.kill(pids)
1840
+ audit.append({ kind: 'leftover-kill', pids, summaries: pids.map((pid) => byPid.get(pid).summary), killed: r.killed, failed: r.failed, ts: Date.now() })
1841
+ leftover.clearCache()
1842
+ if (r.failed.length) return sendJSON(res, 500, { error: `有 ${r.failed.length} 个进程没能结束(${r.failed.map((f) => f.error).join('; ')})` })
1843
+ return sendJSON(res, 200, await buildState())
1844
+ }
1845
+ // 定时任务(定时任务基础技能): per-group crons at groups[chatId].crons. Same store as the
1846
+ // CLI `sbot cron`; the daemon's scheduler picks changes up via config hot-reload. Server is
1847
+ // single-threaded so readRaw→writeRaw here is atomic per-request (the CLI uses a file lock
1848
+ // for cross-process concurrency). crons are scoped to their own group — no cross-group reach.
1849
+ if (p === '/api/groups/cron/add' && req.method === 'POST') {
1850
+ const { chatId, expr, at, kind, text, note } = await body(req); const raw = readRaw()
1851
+ if (!chatId || !raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1852
+ if (kind !== 'do' && kind !== 'say' && kind !== 'run') return sendJSON(res, 400, { error: 'kind 须为 do(触发 agent)、say(只发消息)或 run(跑脚本)' })
1853
+ if (!text || !String(text).trim()) return sendJSON(res, 400, { error: kind === 'do' ? '指令内容必填' : kind === 'run' ? '命令必填' : '消息文本必填' })
1854
+ // at(一次性,YYYY-MM-DD HH:mm) 与 expr(循环 cron) 二选一
1855
+ let when
1856
+ // 简写展开成绝对时间(只填 10:20 → 今天;07-16 10:20 → 今年;完整日期照旧),存绝对值,
1857
+ // 显示层再相对化。用群时区定位「今天/明天」。
1858
+ if (at) { try { when = { at: expandAt(at, Date.now(), normalizeTz(raw.timezone)), once: true } } catch (e) { return sendJSON(res, 400, { error: e && e.message }) } }
1859
+ else { try { when = { expr: normalizeCron(expr) } } catch (e) { return sendJSON(res, 400, { error: (e && e.message) || 'cron 表达式无效' }) } }
1860
+ const g = raw.groups[chatId]; g.crons = Array.isArray(g.crons) ? g.crons : []
1861
+ const task = { id: crypto.randomBytes(4).toString('hex'), ...when, kind, text: String(text), enabled: true }
1862
+ if (note && String(note).trim()) task.note = String(note).trim()
1863
+ if (cronRecoveryEnabled || raw._c2CronRecovery?.enabled === true) {
1864
+ qualifyFuture(task, Date.now(), normalizeTz(raw.timezone))
1865
+ }
1866
+ g.crons.push(task); writeRaw(raw)
1867
+ return sendJSON(res, 200, await buildState())
1868
+ }
1869
+ if (p === '/api/groups/cron/toggle' && req.method === 'POST') {
1870
+ const { chatId, id, enabled } = await body(req); const raw = readRaw()
1871
+ const g = raw.groups && raw.groups[chatId]
1872
+ const c = g && Array.isArray(g.crons) && g.crons.find((x) => x.id === id)
1873
+ if (!c) return sendJSON(res, 404, { error: 'no such cron' })
1874
+ c.enabled = !!enabled
1875
+ if (cronRecoveryEnabled || raw._c2CronRecovery?.enabled === true) {
1876
+ qualifyFuture(c, Date.now(), normalizeTz(raw.timezone))
1877
+ } else delete c.c2RecoveryKey
1878
+ writeRaw(raw)
1879
+ return sendJSON(res, 200, await buildState())
1880
+ }
1881
+ if (p === '/api/groups/cron/remove' && req.method === 'POST') {
1882
+ const { chatId, id } = await body(req); const raw = readRaw()
1883
+ const g = raw.groups && raw.groups[chatId]
1884
+ if (!g || !Array.isArray(g.crons)) return sendJSON(res, 404, { error: 'no such group' })
1885
+ const i = g.crons.findIndex((x) => x.id === id)
1886
+ if (i < 0) return sendJSON(res, 404, { error: 'no such cron' })
1887
+ g.crons.splice(i, 1); writeRaw(raw)
1888
+ return sendJSON(res, 200, await buildState())
1889
+ }
1890
+ // Plugin discovery: the machine's global library, plus — when
1891
+ // chatId is given — that group's project-level plugins and enablement state.
1892
+ // 风格库(只读):内置预设(assets/design-styles/,数量随版本增删)+ ~/.sbot/design-styles 自定义。每项带 layout 图元表,前端直接
1893
+ // 画成 SVG。不进 /api/state —— 它是自动刷新定时拉的,而风格库只在切到「群·项目」页时要,
1894
+ // 没必要让每次心跳都背着 8 份图元。坏掉的单个预设跳过即可,不让整个库列不出来。
1895
+ if (p === '/api/design/styles' && req.method === 'GET') {
1896
+ const out = []
1897
+ for (const s of design.allStyles()) {
1898
+ try { out.push(design.styleCard(fs.readFileSync(s.path, 'utf8'), { slug: s.slug, source: s.source, name: s.name, description: s.description, suits: s.suits })) } catch { /* 单个预设读不动就跳过 */ }
1899
+ }
1900
+ // 不回传预设目录的绝对路径:控制台不展示内部实现路径(前端也不再有地方用),
1901
+ // 少一份就少一处泄漏面 —— 新建与收录都在这一页上做,operator 用不着知道它们存在哪儿。
1902
+ // ⚠ 也不回传字体说明(2026-09-02 产品决定:表单里连那行只读说明都不要)——
1903
+ // 字体恒定用 SANS_STACK 那一套,新建风格时不需要知道,更没有可选项;
1904
+ // 留个没人消费的字段只会变成下一次「两处各写一份」的种子。
1905
+ return sendJSON(res, 200, { styles: out })
1906
+ }
1907
+ // ── 图标库(只读,2026-08-25)───────────────────────────────────────────────
1908
+ // ⭐ **与设计风格库平级**:一个项目分别选「用哪套风格」和「用哪套图标」,而不是把图标
1909
+ // 绑在风格上(换风格就换图标是错的)。这张表在代码里预先整理好,**不开放自定义**。
1910
+ // 每套带 8 个真实图标做样张 —— 挑哪套是「认不认得出、好不好看」的判断,**断言判不了,
1911
+ // 必须按真实尺寸渲染出来看**;而这 8 个正是实测两套库几何全不同的那几个。
1912
+ // ⚠ 图标包不随 sbot 分发(四套合计约 160MB) ⇒ 没装如实标 installed:false(空白会被读成「这套是空的」)。
1913
+ if (p === '/api/design/icons' && req.method === 'GET') {
1914
+ return sendJSON(res, 200, { systems: design.iconSystemPreviews() })
1915
+ }
1916
+ // 给某个群项目选一套图标系统 ⇒ **落进那个项目的 design/icons.md**(不写群配置)。
1917
+ // ⚠ 落点必须在项目里:`sbot design icon` 是 agent 在项目目录里跑的,够不着群配置;
1918
+ // 而项目 clone 到别处时,写在群配置里的选择会整个丢掉。控制台是入口,项目档是落点
1919
+ // (与「设计风格」那一行同款做法)。
1920
+ if (p === '/api/design/icons' && req.method === 'POST') {
1921
+ const { chatId, system, style, force } = await body(req)
1922
+ const g = (readRaw().groups || {})[chatId]
1923
+ if (!g || !g.workdir) return sendJSON(res, 400, { error: '这个群还没绑定项目目录' })
1924
+ if (!fs.existsSync(g.workdir)) return sendJSON(res, 400, { error: '项目目录不存在:' + g.workdir })
1925
+ const cur = design.findIconsDoc({ cwd: g.workdir })
1926
+ // 换系统 = 换掉整套命名词汇,而**页面上已经取过的图标不会自动跟着变**(教训 10:
1927
+ // 做了 A 别让人以为做了 B)⇒ 换之前必须让人明确知道这件事。
1928
+ if (cur && cur.system !== system && !force) {
1929
+ return sendJSON(res, 409, {
1930
+ error: 'confirm',
1931
+ current: cur.system,
1932
+ note: `这个项目现在用的是「${cur.system}」。换成「${system}」会换掉整套命名词汇`
1933
+ + `(同一个「更多」:${design.resolveIconName('more', design.ICON_SYSTEMS[cur.system] || {})} → ${design.resolveIconName('more', design.ICON_SYSTEMS[system] || {})}),`
1934
+ + '页面上已经取过的图标不会自动跟着变 —— 换完要把用到图标的页面重新取一遍。',
1935
+ })
1936
+ }
1937
+ // ⚠ 已有档只改 front matter,正文逐字节保留(同 CLI:人往这份档里写的项目细则不许被冲掉)
1938
+ let text
1939
+ // 线性/实心。⚠ **「没传」与「传了空」是两回事**:没传 = 这次不动它,
1940
+ // 得把当前值原样传回(withIconsDocFront 里空值 = 删键)—— 否则前端「只换系统」
1941
+ // 那一次请求会把人家声明过的实心静默抹成线性;传空串 = 明确清掉,回默认线性。
1942
+ const carried = style === undefined ? (cur && cur.style) || '' : String(style || '')
1943
+ // ⚠ 换到一套供不起当前形态的系统时(Tabler 实心 → Lucide),**丢掉形态而不是报错**:
1944
+ // 这一步用户想做的是换系统,拿「Lucide 没有实心」把整个操作挡下来只会让人卡在这儿;
1945
+ // 而带着它写盘又会落出一份自相矛盾的档。丢掉,并在回执里说一声(不声不响地丢
1946
+ // 就成了「做了 A 让人以为做了 B」)。
1947
+ const sysDef = design.ICON_SYSTEMS[String(system || '').toLowerCase()]
1948
+ const styleDropped = !!(carried && sysDef && !(sysDef.styles || []).includes(carried))
1949
+ // ⭐ 形态要改到**真正说得上话的那份档**去。优先级是 风格档 > 图标档 ⇒
1950
+ // 风格档一旦声明了形态,只写图标档就是白写。真机实证:测试群的两份风格档都写着 filled,
1951
+ // 群卡那个控件因此被压成灰的 —— 「加了个控件但它不管用」比没加更糟。
1952
+ // ⚠ 多份档各说各话时**不替人统一**:点名让人去各自那份里改(小程序用实心、后台用线性
1953
+ // 本来就是正当的,替他抹平等于毁掉别人的设计决定)。
1954
+ let styleTargets = []
1955
+ if (style !== undefined && !styleDropped) {
1956
+ const declared = []
1957
+ for (const d of design.findStyleDocs({ cwd: g.workdir })) {
1958
+ let t = null
1959
+ try { t = fs.readFileSync(d.abs, 'utf8') } catch { continue }
1960
+ const tk = design.parseStyleTokens(t)
1961
+ const st = tk && tk.icons && tk.icons.style
1962
+ if (st) declared.push({ ...d, text: t, style: String(st).toLowerCase() })
1963
+ }
1964
+ if (declared.length) {
1965
+ const forms = [...new Set(declared.map((x) => x.style))]
1966
+ if (forms.length > 1) {
1967
+ return sendJSON(res, 400, {
1968
+ error: '这个项目的几份风格档形态各不相同(' + declared.map((x) => x.name + ' → ' + (x.style === 'filled' ? '实心' : '线性')).join(' · ')
1969
+ + ')—— 要改请到各自那份档里改,这里不替你统一',
1970
+ })
1971
+ }
1972
+ for (const d of declared) {
1973
+ const next = design.withIconsStyleInStyleDoc(d.text, carried)
1974
+ if (next == null || next === d.text) continue
1975
+ const t2 = d.abs + '.' + process.pid + '.tmp'
1976
+ try { fs.writeFileSync(t2, next); fs.renameSync(t2, d.abs) }
1977
+ catch (e) { try { fs.unlinkSync(t2) } catch { /* 没写出来 */ }
1978
+ return sendJSON(res, 500, { error: '写入风格档失败:' + ((e && e.message) || e) }) }
1979
+ styleTargets.push(d.name)
1980
+ }
1981
+ }
1982
+ }
1983
+ const opts = { system, style: styleDropped ? '' : carried, assets: cur ? cur.assets.join(' , ') : '', fallback: cur && cur.fallbackLibrary === null ? 'none' : '' }
1984
+ try { text = design.iconsDocText(cur && cur.text, opts) }
1985
+ catch (e) { return sendJSON(res, 400, { error: (e && e.message) || '写入失败' }) }
1986
+ const abs = path.join(g.workdir, design.ICONS_DOC_REL)
1987
+ const tmp = abs + '.' + process.pid + '.tmp'
1988
+ try {
1989
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
1990
+ fs.writeFileSync(tmp, text)
1991
+ fs.renameSync(tmp, abs) // 原子写:半截档会被认帧挡下,连带图标口径整个消失
1992
+ } catch (e) {
1993
+ try { fs.unlinkSync(tmp) } catch { /* 没写出来 */ } // 不清的话项目 design/ 里会留一个 .tmp
1994
+ return sendJSON(res, 500, { error: '写入图标档失败:' + ((e && e.message) || e) })
1995
+ }
1996
+ log(`design icons: ${chatId} → ${system}${opts.style ? ' · ' + opts.style : ''}${styleTargets.length ? ' · 风格档 ' + styleTargets.join('/') : ''}`)
1997
+ return sendJSON(res, 200, { ok: true, styleDropped, styleTargets, icons: design.projectIconCard(g.workdir), styles: design.projectStyleCards(g.workdir) })
1998
+ }
1999
+ // ── 自定义风格:表单 → 标准风格档 ────────────────────────────────
2000
+ // 实时预览:表单值 → 编译成档 → styleCard。**不落盘、不报错** —— 半填的表单也要出图
2001
+ // (边填边看才是这张表单存在的理由)。布局仍在服务端算,前端只把 rects 画成 SVG:
2002
+ // 与群里那张 PNG 永远同一份几何,不给「两端各画各的」留缝。
2003
+ // ⚠ 这两条都把 form **整个**交给 design.js,**不要**在这里挑字段:表单会长出新项
2004
+ // (补充色 extraColors、字距/描边/间距那组 extraTokens 就是这么加的),白名单一旦
2005
+ // 写在这层,新字段就会静默丢在半路,而校验与归一的唯一权威在 design.js。
2006
+ if (p === '/api/design/preview' && req.method === 'POST') {
2007
+ const { form } = await body(req)
2008
+ try { return sendJSON(res, 200, { card: design.styleCard(design.composeStyleDoc(form)) }) }
2009
+ catch (e) { return sendJSON(res, 400, { error: (e && e.message) || '预览失败' }) }
2010
+ }
2011
+ // 保存:严格校验 → 编译 → 原子落盘。同 slug 已有自定义 ⇒ 409(前端提示勾「覆盖同名」);
2012
+ // 与内置同名不是错误,但要如实回 shadowed,让人知道自己遮住了哪一套。
2013
+ if (p === '/api/design/styles' && req.method === 'POST') {
2014
+ const { form, slug, overwrite, keepProse } = await body(req)
2015
+ try {
2016
+ const f = design.validateStyleForm(form)
2017
+ const s = design.validateStyleSlug(slug)
2018
+ let doc = design.composeStyleDoc(f)
2019
+ // 「修改」默认保留原正文:表单只管 tokens,正文里的 Do's/Don'ts 与具体参照
2020
+ // 是人在群里一轮轮磨出来的 —— 换个强调色就把它冲掉,是这个功能最容易造成的损失。
2021
+ // 勾了「按新设定重写正文」才走整份重生成(那时 keepProse 为假)。
2022
+ // ⚠ 与正文分开的一件事:**表单管不到的 front matter 段(如 icons)一律原样带过去**,
2023
+ // 不受「重写正文」那个勾影响 —— 图标声明不是正文,改个强调色就把它冲掉是静默的损失
2024
+ // (v0.11.33 审出来的:往返后 icons 段整段消失,而页面上没有任何提示)。
2025
+ const cur0 = design.allStyles().find((x) => x.slug === s && x.source === 'user')
2026
+ if (cur0) {
2027
+ try {
2028
+ const old = fs.readFileSync(cur0.path, 'utf8')
2029
+ if (keepProse) doc = design.withStyleProse(doc, old)
2030
+ doc = design.withKeptSections(doc, old)
2031
+ } catch { /* 读不动就用新骨架,不阻断保存 */ }
2032
+ }
2033
+ const r = design.saveUserStyle(s, doc, { overwrite: !!overwrite })
2034
+ return sendJSON(res, 200, { ok: true, slug: s, name: f.name, replaced: r.replaced, shadowed: r.shadowed })
2035
+ } catch (e) { return sendJSON(res, e && e.code === 'CONFLICT' ? 409 : 400, { error: (e && e.message) || '保存失败' }) }
2036
+ }
2037
+ // 删除只碰用户目录 —— 内置的删不掉;删掉一个遮蔽内置的同名 slug ⇒ 那一套回归内置。
2038
+ if (p === '/api/design/styles' && req.method === 'DELETE') {
2039
+ const b = await body(req)
2040
+ try { return sendJSON(res, 200, { ok: true, ...design.deleteUserStyle(b.slug || url.searchParams.get('slug')) }) }
2041
+ catch (e) { return sendJSON(res, 400, { error: (e && e.message) || '删除失败' }) }
2042
+ }
2043
+ // 从参考提炼(确定性提取器,不过模型):网址 ⇒ 抓 HTML + 同源样式表;绝对路径 ⇒ 扫本机目录。
2044
+ // 回的是**候选 + 证据**,不是结论 —— 用户看得见它凭什么这么猜,才知道该改哪一项。
2045
+ //
2046
+ // 两侧的围栏口径**故意不同**:
2047
+ // 网址侧 = 「服务器替我去取」的口子 ⇒ 只接公网地址,本机/内网/云元数据段一律拒。
2048
+ // 这里先挡一次(读代码的人在端点上就看得见围栏);跟跳时**每一跳**的重校验在 design
2049
+ // 那侧 —— 跟跳发生在那儿,只查首跳等于没查(公网 301 到 127.0.0.1 是最省事的一招)。
2050
+ // 目录侧 = 与目录浏览、保存绑定**同一道**项目根目录围栏(src/projectsroot.js)。2026-09-08 之前这里
2051
+ // 敞开着(「他本来就能开终端」)—— 但控制台已开到公网、名单可能不止 operator 一人,而且围栏
2052
+ // 写在三处必然漂:一处收紧另两处还敞着,等于没围。
2053
+ if (p === '/api/design/extract' && req.method === 'POST') {
2054
+ const { ref } = await body(req)
2055
+ const s = String(ref == null ? '' : ref).trim()
2056
+ if (!s) return sendJSON(res, 400, { error: '先填一个参考:网址,或本机上项目所在目录的绝对路径' })
2057
+ try {
2058
+ if (/^https?:\/\//i.test(s)) { design.assertPublicUrl(s); return sendJSON(res, 200, await design.extractFromUrl(s)) }
2059
+ if (path.isAbsolute(s)) {
2060
+ const info = projectsroot.resolveProjectsRoots(readRaw())
2061
+ if (!projectsroot.withinRoots(s, info.roots)) return sendJSON(res, 400, { error: `本机目录须在项目根目录下(${projectsroot.describeRoots(info.roots)}):${s}` })
2062
+ return sendJSON(res, 200, design.extractFromDir(s))
2063
+ }
2064
+ return sendJSON(res, 400, { error: '认不出这个参考 —— 网址要以 http:// 或 https:// 开头,本机目录要写绝对路径' })
2065
+ } catch (e) { return sendJSON(res, 400, { error: (e && e.message) || '提炼失败' }) }
2066
+ }
2067
+ // 收录:把某个群项目里打磨好的那份风格档复制进全局库(原文照抄,只允许改 name)。
2068
+ // 认帧仍走 findStyleDoc + isStyleDoc —— 项目根的 DESIGN.md 很可能是架构文档,不能当风格档收。
2069
+ if (p === '/api/design/collect' && req.method === 'POST') {
2070
+ const { chatId, slug, name, overwrite, rel } = await body(req)
2071
+ const raw = readRaw()
2072
+ const g = raw.groups && raw.groups[chatId]
2073
+ if (!g || !g.workdir) return sendJSON(res, 404, { error: '这个群还没绑定项目目录' })
2074
+ // 一个项目可能有多份风格档(小程序一份、后台一份…)⇒ 必须说清收哪一份。
2075
+ // rel 缺省时收列表第一份(通用档),与 pickStyleDoc 同一套匹配 —— 各写一份必漂移。
2076
+ const docs = design.findStyleDocs({ cwd: g.workdir })
2077
+ if (!docs.length) return sendJSON(res, 404, { error: '这个项目里还没有风格档,没有可收录的内容' })
2078
+ const pick = design.pickStyleDoc(docs, rel || '')
2079
+ if (!pick) return sendJSON(res, 404, { error: '这个项目里没有这一份风格档' })
2080
+ const abs = pick.abs
2081
+ try {
2082
+ let text = fs.readFileSync(abs, 'utf8')
2083
+ if (!design.isStyleDoc(text)) return sendJSON(res, 400, { error: '这个项目里的那份不是风格档,没有收录' })
2084
+ if (name && String(name).trim()) text = design.withStyleName(text, name)
2085
+ const s = design.validateStyleSlug(slug)
2086
+ const r = design.saveUserStyle(s, text, { overwrite: !!overwrite })
2087
+ return sendJSON(res, 200, { ok: true, slug: s, replaced: r.replaced, shadowed: r.shadowed })
2088
+ } catch (e) { return sendJSON(res, e && e.code === 'CONFLICT' ? 409 : 400, { error: (e && e.message) || '收录失败' }) }
2089
+ }
2090
+ // Read-only probe; sbot never installs plugins, it only gates them per group.
2091
+ if (p === '/api/plugins' && req.method === 'GET') {
2092
+ const chatId = url.searchParams.get('chatId')
2093
+ const out = { global: globalPlugins(), codex: codexPlugins() } // global=Claude 用户级 MCP;codex=~/.codex/config.toml
2094
+ // 可达性:连不上的 MCP 此前在这里长得和好的一模一样 —— 勾选框全绿,而每一轮都在替它
2095
+ // 付启动失败。探测带 TTL 缓存(见 plugins.js),控制台反复开合不会真的反复探。
2096
+ // 两套 MCP 都探(codex 那套虽然没有按群门控,连不上一样是每轮白付)。
2097
+ try {
2098
+ const reach = await mcpReachability(os.homedir(), { codex: true }) // doctor 读的是同一个出口,两处不会分叉
2099
+ const attach = (list, results) => {
2100
+ const by = new Map((results || []).map((r) => [r.name, r]))
2101
+ for (const m of list) {
2102
+ const r = by.get(m.name)
2103
+ if (r && r.status !== 'unprobed') m.reach = { status: r.status, detail: r.detail }
2104
+ }
2105
+ }
2106
+ attach(out.global.mcp, reach.claude)
2107
+ attach(out.codex.mcp, reach.codex)
2108
+ } catch {}
2109
+ if (chatId) {
2110
+ const raw = readRaw()
2111
+ const g = raw.groups && raw.groups[chatId]
2112
+ if (!g) return sendJSON(res, 404, { error: 'no such group' })
2113
+ out.project = g.workdir && fs.existsSync(g.workdir) ? projectPlugins(g.workdir) : { mcp: [], skills: [] }
2114
+ out.enabled = g.plugins || null // null = 未接管(claude 默认自加载)
2115
+ }
2116
+ return sendJSON(res, 200, out)
2117
+ }
2118
+ // Per-group plugin enablement. A saved plugins config means strict
2119
+ // MCP takeover on that group's next turn; reset=true removes the config and
2120
+ // restores traditional self-loading. Hot-reload via writeRaw, 下轮生效.
2121
+ if (p === '/api/groups/plugins' && req.method === 'POST') {
2122
+ const { chatId, mcp, skills, reset } = await body(req); const raw = readRaw()
2123
+ if (!raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
2124
+ if (reset) delete raw.groups[chatId].plugins
2125
+ else raw.groups[chatId].plugins = {
2126
+ mcp: (Array.isArray(mcp) ? mcp : []).filter((s) => typeof s === 'string' && s),
2127
+ skills: (Array.isArray(skills) ? skills : []).filter((s) => typeof s === 'string' && s),
2128
+ }
2129
+ writeRaw(raw)
2130
+ if (control && control.notifyGroup) {
2131
+ const t = reset ? '🧩 本群插件已恢复默认(全部加载)'
2132
+ : (Array.isArray(mcp) && mcp.length) ? `🧩 本群插件已更新:${mcp.join('、')}`
2133
+ : '🧩 本群已停用全部额外插件'
2134
+ notifyGroup(chatId, t)
2135
+ }
2136
+ return sendJSON(res, 200, await buildState())
2137
+ }
2138
+ // Plugin install command (整合自群卡):把 MCP 加进本机库
2139
+ // (`claude mcp add --scope user …`)。在中性 cwd(home)执行 —— 用户级安装与 cwd 无关;
2140
+ // 群卡那个 install 框只是 cwd=workdir 的同一动作,对"用户级库"口径冗余,已合并到此。
2141
+ // 操作台专用(localhost/LAN + token/密码),等价开放终端;白名单 env(无密钥);全审计。
2142
+ if (p === '/api/plugins/exec' && req.method === 'POST') {
2143
+ const { command } = await body(req)
2144
+ const cmd = String(command || '').trim()
2145
+ if (!cmd) return sendJSON(res, 400, { error: '命令为空' })
2146
+ return sendJSON(res, 200, await runInstallCommand(cmd, os.homedir()))
2147
+ }
2148
+ // real-time per-group model switch: persist to config (+ hot-reload) AND set
2149
+ // the live runtime override so it takes effect on the very next turn.
2150
+ if (p === '/api/groups/provider' && req.method === 'POST') {
2151
+ const { chatId, provider } = await body(req); const raw = readRaw()
2152
+ if (!raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
2153
+ if (!(raw.providers || {})[provider]) return sendJSON(res, 400, { error: `未知模型 '${provider}'` })
2154
+ raw.groups[chatId].provider = provider
2155
+ // Durable hosts drop the previous provider's pinned model id; their
2156
+ // writer resets the session and applies the change before returning.
2157
+ if (runtimePolicy) delete raw.groups[chatId].model
2158
+ writeRaw(raw)
2159
+ let live = !!runtimePolicy
2160
+ // switch the runtime override AND reset the group's session — else the next
2161
+ // turn resumes the old session, whose history carries the old model's identity.
2162
+ if (control && control.setProvider) { try { control.setProvider(chatId, provider); if (control.clearSession) control.clearSession(chatId); live = true } catch (e) { log('setProvider: ' + (e && e.message)) } }
2163
+ if (live && control.notifyGroup) {
2164
+ const pm = (raw.providers[provider] || {}).model
2165
+ // 换脑 = 会话清空,群成员当场最担心的是「之前交代过的都白说了」。项目里确实有沉淀时补一句。
2166
+ // ⚠ 探测式 —— 没有沉淀的项目说了就是假话(承诺它会读、其实无处可读)。
2167
+ // ⚠ 只陈述事实(东西还在),**不承诺行为**(「它会先读」):读不读是半可信 agent 的事,
2168
+ // 编排层保证不了 —— 清单自己都写着「上下文里没有就自己读一遍」(教训 6/10 形状)。
2169
+ // ⚠ 用户面零工具名:只说人话,不提约定文件名、更不提机器上的目录。
2170
+ // ⭐ 判据必须与自知清单**同一个函数**(probeHandover),且要按**切换之后**那个大脑算 ——
2171
+ // 它才是接下来读东西的那个。曾经分叉过:这里只问「有没有东西」,而清单另有一层
2172
+ // 够不够得着的门控 ⇒ 群里说「都还在」而清单一个字都没给(教训 26/35 家族)。
2173
+ let carry = ''
2174
+ try {
2175
+ const g = raw.groups[chatId] || {}
2176
+ const opts = { full: g.permissionMode === 'bypassPermissions', isCodex: (raw.providers[provider] || {}).runner === 'codex' }
2177
+ const h = require('../projectmemory').probeHandover(g.workdir, opts)
2178
+ if (h) carry = ' —— ' + h.note // ⚠ 文案取自读取器,别在这里另写一份(群内 /model 也说这句)
2179
+ } catch { /* 探不到就不说 */ }
2180
+ notifyGroup(chatId, `🔄 本群模型已切到 ${provider}${pm ? ' · ' + pm : ''}(会话已重置)${carry}`)
2181
+ }
2182
+ return sendJSON(res, 200, { ...(await buildState()), switched: { chatId, provider, live } })
2183
+ }
2184
+ if (p === '/api/groups/new' && req.method === 'POST') {
2185
+ const { chatId } = await body(req)
2186
+ if (!control || !control.clearSession) return sendJSON(res, 400, { error: '后台服务未运行 — 这个动作需要它在跑' })
2187
+ control.clearSession(chatId); return sendJSON(res, 200, { ok: true })
2188
+ }
2189
+ if (p === '/api/groups/stop' && req.method === 'POST') {
2190
+ const { chatId } = await body(req)
2191
+ if (!control || !control.stop) return sendJSON(res, 400, { error: '后台服务未运行 — 这个动作需要它在跑' })
2192
+ return sendJSON(res, 200, { ok: control.stop(chatId) })
2193
+ }
2194
+ // 中断并开新:abort 当前 turn + 清队列 + 清 session,等价 busy 时的 /new
2195
+ if (p === '/api/groups/interrupt' && req.method === 'POST') {
2196
+ const { chatId } = await body(req)
2197
+ if (!control || !control.interrupt) return sendJSON(res, 400, { error: '后台服务未运行 — 这个动作需要它在跑' })
2198
+ const r = control.interrupt(chatId); return sendJSON(res, 200, { ok: true, wasBusy: !!(r && r.wasBusy) })
2199
+ }
2200
+ if (p === '/api/test' && req.method === 'POST') {
2201
+ const { provider, prompt } = await body(req); return sendJSON(res, 200, await testProvider(provider, prompt))
2202
+ }
2203
+ // Claude 卡片「检测登录」:`claude auth status --json`(官方非交互子命令,0.2s,不吃订阅额度)。
2204
+ // 与 codex 的「验证登录」对称。**force 才绕过缓存**:点按钮就是要看此刻的状态(刚
2205
+ // `claude auth login` 完回来点它,拿到缓存的旧「未登录」会让人以为登录没成功);而页面
2206
+ // 加载的自动探不带 force —— daemon 重启后 operator 重登控制台、开几个标签页,每次都
2207
+ // 强制现打就是一轮突发,恰好捅上游额度端点的低限流阈值(429 持续数分钟,群里 /hud
2208
+ // 会跟着遭殃),所以自动探 60s 内直接吃上次的答案,过期了也只顺 TTL 取、不清缓存。
2209
+ if (p === '/api/claude/verify' && req.method === 'POST') {
2210
+ const { force } = await body(req)
2211
+ const cfg = loadConfig(configPath)
2212
+ if (force) claudestatus.clearCache()
2213
+ else if (claudeStatusCache && Date.now() - claudeStatusCache.at < 60_000) return sendJSON(res, 200, claudeStatusCache.login)
2214
+ const login = await claudestatus.cachedLoginStatus(cfg.agent.bin)
2215
+ // 订阅额度(v0.11.3,cc-hud 同源通道):已登录才拉。拿不到(网络/门禁/凭据过期)⇒ null,
2216
+ // 前端不显示额度行,不拖垮登录检测本身。
2217
+ let usage = null
2218
+ if (login && login.ok && login.loggedIn) {
2219
+ try { if (force) claudeusage.clearCache(); usage = await claudeusage.usageLine() } catch { usage = null }
2220
+ }
2221
+ claudeStatusCache = { at: Date.now(), login: { ...login, usage } }
2222
+ return sendJSON(res, 200, { ...login, usage })
2223
+ }
2224
+ // Claude 模型下拉的候选。**未登录不给任何型号**(产品决定:没登录就别列)。
2225
+ // 已登录时 = 内置候选 + 本机账号的额外可选项;为什么不是官方实时清单,见 claudemodels.js 头部。
2226
+ if (p === '/api/claude/models' && req.method === 'GET') {
2227
+ const cfg = loadConfig(configPath)
2228
+ const login = await claudestatus.cachedLoginStatus(cfg.agent.bin)
2229
+ // ⚠ 回填这份缓存时必须**留住上一次拉到的 usage**:本条路不查额度,直接写 { at, login }
2230
+ // 会把 usage 字段洗掉,而 /api/claude/verify 的 60s 去抖随后会把这份缺 usage 的缓存原样
2231
+ // 吐回去 —— 表现就是卡片上的额度行凭空消失,而登录态一切正常,没人查得出为什么。
2232
+ if (login && login.ok) {
2233
+ const keep = claudeStatusCache && claudeStatusCache.login && claudeStatusCache.login.usage
2234
+ claudeStatusCache = { at: Date.now(), login: keep ? { ...login, usage: keep } : login }
2235
+ }
2236
+ return sendJSON(res, 200, { ...claudemodels.listModels({ loggedIn: !!(login && login.ok && login.loggedIn) }), login })
2237
+ }
2238
+ // codex 卡片「验证登录」:确认订阅登录态(codex login status)+ 取订阅用量
2239
+ // (app-server account/rateLimits/read)。都无需 API key;用量缓存 60s(app-server 有 spawn 开销)。
2240
+ if (p === '/api/codex/verify' && req.method === 'POST') {
2241
+ // force = 卡片额度行的 ↻:点按钮就是要此刻的数,绕过下面的 60s 去抖
2242
+ // (去抖是给开局自动探测用的 —— app-server spawn 不便宜,但手点一次的开销该认)。
2243
+ const { force } = await body(req).catch(() => ({}))
2244
+ const login = await codexstatus.loginStatus('codex')
2245
+ let usage = null
2246
+ if (login.loggedIn) {
2247
+ // reuse a fresh (<60s) usage probe if we have one — the app-server spawn isn't free
2248
+ if (!force && codexStatusCache && codexStatusCache.usage && Date.now() - codexStatusCache.at < 60_000) usage = codexStatusCache.usage
2249
+ else usage = await codexstatus.rateLimits('codex')
2250
+ }
2251
+ codexStatusCache = { at: Date.now(), login, usage } // cached into /api/state so the card survives a page reload
2252
+ return sendJSON(res, 200, { login, usage })
2253
+ }
2254
+ if (p === '/api/playground/run' && req.method === 'POST') {
2255
+ const { provider: provName, workdir, prompt, sessionId } = await body(req)
2256
+ const cfg = loadConfig(configPath); applyProxyEnv(cfg)
2257
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' })
2258
+ const send = (event, data) => { try { res.write(`event: ${event}\ndata: ${JSON.stringify(data || {})}\n\n`) } catch { /* client gone */ } }
2259
+ const provider = cfg.providers[provName]
2260
+ if (!prompt) { send('error', { error: 'prompt 为空' }); return res.end() }
2261
+ if (!provider) { send('error', { error: `未知 provider '${provName}'` }); return res.end() }
2262
+ if (provider.baseUrl && !provider.apiKey) { send('error', { error: `provider '${provName}' 缺 apiKey` }); return res.end() }
2263
+ const ac = new AbortController()
2264
+ req.on('close', () => ac.abort()) // browser aborts fetch → stop the turn
2265
+ let buf = ''
2266
+ // Perms are per-project: if this workdir matches a bound group, inherit that
2267
+ // project's allow/deny; otherwise none (dontAsk ⇒ read-only). 试跑 isn't a
2268
+ // "project", so it doesn't get any global default.
2269
+ const pgGroup = Object.values(cfg.groups || {}).find((x) => x.workdir && x.workdir === workdir)
2270
+ try {
2271
+ const { sessionId: newSid, result, aborted } = await runTurn({
2272
+ bin: cfg.agent.bin,
2273
+ prompt,
2274
+ provider,
2275
+ model: provider.model,
2276
+ workdir: workdir || undefined,
2277
+ sessionId: sessionId || undefined,
2278
+ permissionMode: (pgGroup && pgGroup.permissionMode) || 'dontAsk', // 试跑继承匹配群的模式(无全局默认,unset ⇒ dontAsk)
2279
+ allowedTools: pgGroup && pgGroup.allowedTools,
2280
+ disallowedTools: pgGroup && pgGroup.disallowedTools,
2281
+ signal: ac.signal,
2282
+ onEvent: (ev) => {
2283
+ if (ev.type === 'system' && ev.subtype === 'init') send('session', { sessionId: ev.sessionId })
2284
+ if (ev.type === 'assistant') {
2285
+ if (ev.tools && ev.tools.length) send('tool', { tools: ev.tools.map((t) => t.name) })
2286
+ if (ev.text) { buf += ev.text; send('delta', { text: buf }) } // cumulative
2287
+ }
2288
+ },
2289
+ })
2290
+ send('done', {
2291
+ sessionId: newSid,
2292
+ status: aborted ? 'stopped' : (result && result.isError) ? 'error' : 'ok',
2293
+ text: (result && result.text) || buf,
2294
+ usage: result && result.usage ? { in: result.usage.input_tokens || 0, out: result.usage.output_tokens || 0 } : null,
2295
+ })
2296
+ } catch (e) {
2297
+ send('error', { error: (e && e.message) || String(e) })
2298
+ }
2299
+ return res.end()
2300
+ }
2301
+ res.writeHead(404); res.end('not found')
2302
+ } catch (e) {
2303
+ log('ui error: ' + (e && e.message)); sendJSON(res, 500, { error: (e && e.message) || 'server error' })
2304
+ }
2305
+ })
2306
+
2307
+ function setActiveHost(value) {
2308
+ const changed = activeHost !== String(value)
2309
+ activeHost = String(value)
2310
+ loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(activeHost)
2311
+ HOST_OK = hostPattern()
2312
+ if (changed && onHostChange) { try { onHostChange(activeHost, server.address()) } catch (e) { log('ui host callback: ' + (e && e.message)) } }
2313
+ }
2314
+ function listenAgain(bindHost, port) {
2315
+ return new Promise((resolve, reject) => {
2316
+ const onError = (e) => { server.removeListener('listening', onListening); reject(e) }
2317
+ const onListening = () => { server.removeListener('error', onError); setActiveHost(bindHost); resolve() }
2318
+ server.once('error', onError)
2319
+ server.once('listening', onListening)
2320
+ server.listen(port, bindHost)
2321
+ })
2322
+ }
2323
+ async function rebindHost(nextHost) {
2324
+ if (!['127.0.0.1', '0.0.0.0'].includes(nextHost)) throw new Error('只支持本机或全部局域网接口')
2325
+ const address = server.address()
2326
+ if (!address || typeof address !== 'object') throw new Error('控制台尚未监听端口')
2327
+ const oldHost = activeHost
2328
+ if (nextHost === oldHost) return
2329
+ for (const client of sseClients) { try { client.end() } catch {} }
2330
+ sseClients.clear()
2331
+ const stopped = new Promise((resolve, reject) => server.close((e) => e ? reject(e) : resolve()))
2332
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
2333
+ await stopped
2334
+ try { await listenAgain(nextHost, address.port) }
2335
+ catch (e) {
2336
+ try { await listenAgain(oldHost, address.port) } catch (restoreErr) { log('ui host restore listen failed: ' + (restoreErr && restoreErr.message)) }
2337
+ throw e
2338
+ }
2339
+ }
2340
+
2341
+ // Broadcast live state + heartbeat over SSE (cheap; no health probe here).
2342
+ const tick = setInterval(() => {
2343
+ if (!sseClients.size) return
2344
+ const live = getLiveState ? getLiveState() : { running: false }
2345
+ const conn = getConnHealth ? getConnHealth() : null // 飞书 WS 健康度随心跳带上(SSE 无重放,轮询兜底)
2346
+ const msg = `event: tick\ndata: ${JSON.stringify({ live, conn, ts: Date.now(),
2347
+ ...(runtimePolicy ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
2348
+ for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
2349
+ }, 3000)
2350
+
2351
+ return {
2352
+ server,
2353
+ listen(port, host, cb) { server.listen(port, host, cb) },
2354
+ rebind: rebindHost,
2355
+ // let a daemon push turn events into the console's activity feed
2356
+ pushEvent(type, data) {
2357
+ if (type === 'turn') recordActivity(data) // also buffer it for replay (下一步 ②)
2358
+ let msg = `event: ${type}\ndata: ${JSON.stringify(data || {})}\n\n`
2359
+ // turn 同帧附带一枚 tick:任务出现的瞬间,群 busy 标/并发格/队列同步刷新,
2360
+ // 不等 3s 心跳 —— 「新任务出现」与「状态变化」在控制台永远同步可见。
2361
+ if (type === 'turn' && getLiveState) {
2362
+ msg += `event: tick\ndata: ${JSON.stringify({ live: getLiveState(), conn: getConnHealth ? getConnHealth() : null, ts: Date.now() })}\n\n`
2363
+ }
2364
+ for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
2365
+ },
2366
+ close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close(); if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); if (updater) updater.close() },
2367
+ _browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用
2368
+ }
2369
+ }
2370
+
2371
+ module.exports = { createUiServer, execShellCommand, listDirs, makeDir, authConfiguredFrom, bindNoticeText, connHealthRows }