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.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- package/src/usage.js +144 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// 控制台「需要你处理」收件箱 + 实时活动的账本回填(纯逻辑,可测;读写盘只在 readLedgerTail / createDismissStore)。
|
|
3
|
+
//
|
|
4
|
+
// 为什么要有收件箱:agent 跑得越久、群越多,「哪里在等我」就越难从聊天记录和概览各区里拼出来 ——
|
|
5
|
+
// 失败的轮次、没人认领的浏览器接管、没处理的反馈、残留进程,此前散在群消息和概览的四个地方。
|
|
6
|
+
// 这里只**汇总**,不新增通知渠道:群里该说的照旧在群里说,控制台是人主动来看的地方。
|
|
7
|
+
//
|
|
8
|
+
// 为什么要回填:活动流是内存环形缓冲,daemon 一重启(含每次发版)概览就是空的,而账本一直在盘上。
|
|
9
|
+
// 回填的事件 id 与引擎同形(`chatId:ts`),之后 SSE 重放同一轮也只会原地覆盖,不会重复。
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs')
|
|
12
|
+
const path = require('node:path')
|
|
13
|
+
|
|
14
|
+
const FAIL_STATUSES = new Set(['error', 'failed']) // stopped 是人按的、no-reply 是设计如此、empty 会自己重试一次 —— 都不算「要人管」
|
|
15
|
+
const FAIL_WINDOW_MS = 72 * 3600_000 // 三天前的失败,人早就在群里重说过或者放弃了
|
|
16
|
+
const MAX_FAILS = 20
|
|
17
|
+
const DISMISS_TTL_MS = 7 * 24 * 3600_000
|
|
18
|
+
const DISMISS_MAX = 200
|
|
19
|
+
|
|
20
|
+
const groupLabel = (groups, chatId) => {
|
|
21
|
+
const g = groups && groups[chatId]
|
|
22
|
+
return (g && g.name) || String(chatId || '').slice(-8)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 只读账本末尾一段:账本一轮一行、只增不减(本机 30 天约 4,600 行),整本读进来没必要。
|
|
26
|
+
// 从中间切开时丢掉第一行残片;坏行跳过(账本是 best-effort 追加,半行是可能的)。
|
|
27
|
+
function readLedgerTail(file, maxBytes = 512 * 1024) {
|
|
28
|
+
let fd
|
|
29
|
+
try {
|
|
30
|
+
fd = fs.openSync(file, 'r')
|
|
31
|
+
const size = fs.fstatSync(fd).size
|
|
32
|
+
const start = Math.max(0, size - maxBytes)
|
|
33
|
+
const buf = Buffer.alloc(size - start)
|
|
34
|
+
fs.readSync(fd, buf, 0, buf.length, start)
|
|
35
|
+
const lines = buf.toString('utf8').split('\n')
|
|
36
|
+
if (start > 0) lines.shift()
|
|
37
|
+
const rows = []
|
|
38
|
+
for (const l of lines) {
|
|
39
|
+
if (!l.trim()) continue
|
|
40
|
+
try { const r = JSON.parse(l); if (r && typeof r === 'object') rows.push(r) } catch { /* 半行 / 坏行 */ }
|
|
41
|
+
}
|
|
42
|
+
return rows
|
|
43
|
+
} catch { return [] } finally { if (fd !== undefined) try { fs.closeSync(fd) } catch {} }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 账本尾巴按 (大小, 修改时刻) 缓存:收件箱每次刷新都要读,账本却只在一轮结束时才变 ——
|
|
47
|
+
// 本机尾部 512 KB 约 2,400 行,没变也每次解析一遍是白干。返回的数组是共享的,调用方别改它。
|
|
48
|
+
function createLedgerTail(file, maxBytes) {
|
|
49
|
+
let key = null
|
|
50
|
+
let rows = []
|
|
51
|
+
return {
|
|
52
|
+
rows() {
|
|
53
|
+
let st
|
|
54
|
+
try { st = fs.statSync(file) } catch { key = null; rows = []; return rows }
|
|
55
|
+
const k = `${st.size}:${st.mtimeMs}`
|
|
56
|
+
if (k !== key) { rows = readLedgerTail(file, maxBytes); key = k }
|
|
57
|
+
return rows
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 账本行 → 活动事件(形状对齐引擎的 done 事件)。定时脚本行跳过:它没有模型、没有 token,
|
|
63
|
+
// 本机 30 天里占了 93%,混进来活动流就只剩脚本了。返回从旧到新(与活动缓冲同序)。
|
|
64
|
+
function ledgerActivity(rows, groups, n = 20) {
|
|
65
|
+
const turns = (rows || []).filter((r) => r && !r.script && r.ts && r.chatId)
|
|
66
|
+
return turns.slice(-n).map((r) => ({
|
|
67
|
+
id: `${r.chatId}:${r.ts}`,
|
|
68
|
+
ts: r.ts,
|
|
69
|
+
chatId: r.chatId,
|
|
70
|
+
group: String(r.chatId).slice(-8), // 与引擎 meta.group 同口径;群名由控制台按 chatId 换
|
|
71
|
+
name: groupLabel(groups, r.chatId),
|
|
72
|
+
sender: r.sender || '',
|
|
73
|
+
text: r.text || '',
|
|
74
|
+
kind: 'done',
|
|
75
|
+
status: r.status || '',
|
|
76
|
+
ms: r.ms || 0,
|
|
77
|
+
tokens: r.tokens || null,
|
|
78
|
+
...(r.err ? { err: String(r.err) } : {}),
|
|
79
|
+
fromLedger: true,
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 失败轮次:同群同一个人之后又有 ok 轮 ⇒ 当作已经重说过、自动消失 —— 否则一次网络抖动的失败
|
|
84
|
+
// 要在收件箱里挂三天,人只能一条条手动忽略。
|
|
85
|
+
function failedTurns(rows, now) {
|
|
86
|
+
const recent = (rows || []).filter((r) => r && !r.script && r.ts > now - FAIL_WINDOW_MS)
|
|
87
|
+
const out = []
|
|
88
|
+
for (let i = 0; i < recent.length; i++) {
|
|
89
|
+
const f = recent[i]
|
|
90
|
+
if (!FAIL_STATUSES.has(f.status)) continue
|
|
91
|
+
const retried = recent.slice(i + 1).some((r) => r.chatId === f.chatId && r.sender === f.sender && r.status === 'ok')
|
|
92
|
+
if (!retried) out.push(f)
|
|
93
|
+
}
|
|
94
|
+
return out.slice(-MAX_FAILS)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 汇总成一张单子。文案不在这里拼:控制台有中英两套界面,这里只给事实(kind + 字段),由前端成句。
|
|
98
|
+
// failed 失败的轮次 · 可忽略
|
|
99
|
+
// takeover agent 在群里发了接管链接、还没人认领(只算挂了群卡的票;管理员自己在控制台签的不算)
|
|
100
|
+
// issues 反馈本里没处理的(一行汇总,点了滚到反馈本)
|
|
101
|
+
// leftovers 残留的后台进程(同上)
|
|
102
|
+
// preview 服务整个项目目录的预览(全部代码都在局域网上可读)
|
|
103
|
+
// health 健康检查没通过的项(一行汇总,排最前:它是「现在就坏着」,没有发生时刻可排)
|
|
104
|
+
function buildInbox({ ledgerRows = [], issues = null, previews = [], leftovers = null, health = null, update = null, takeovers = [], groups = {}, dismissed = new Set(), now = Date.now() } = {}) {
|
|
105
|
+
const items = []
|
|
106
|
+
if (update && update.available && update.latestVersion) {
|
|
107
|
+
items.push({ key: `update:${update.latestVersion}`, kind: 'update', from: update.currentVersion || '', to: update.latestVersion, ts: update.checkedAt || now, dismissable: true })
|
|
108
|
+
}
|
|
109
|
+
for (const f of failedTurns(ledgerRows, now)) {
|
|
110
|
+
items.push({ key: `failed:${f.chatId}:${f.ts}`, kind: 'failed', chatId: f.chatId, group: groupLabel(groups, f.chatId), ts: f.ts,
|
|
111
|
+
sender: f.sender || '', text: f.text || '', err: f.err ? String(f.err) : '', dismissable: true })
|
|
112
|
+
}
|
|
113
|
+
for (const t of takeovers || []) {
|
|
114
|
+
if (!t || !t.chatId) continue
|
|
115
|
+
items.push({ key: `takeover:${t.id}`, kind: 'takeover', chatId: t.chatId, group: groupLabel(groups, t.chatId), ts: t.at, exp: t.exp,
|
|
116
|
+
pageTitle: t.pageTitle || '', pageUrl: t.pageUrl || '' })
|
|
117
|
+
}
|
|
118
|
+
if (issues && issues.openCount > 0) {
|
|
119
|
+
const latest = (issues.open || [])[0]
|
|
120
|
+
items.push({ key: 'issues', kind: 'issues', count: issues.openCount, ts: latest ? latest.ts : null })
|
|
121
|
+
}
|
|
122
|
+
const lo = leftovers && Array.isArray(leftovers.rows) ? leftovers.rows : []
|
|
123
|
+
if (lo.length) {
|
|
124
|
+
const oldest = Math.max(...lo.map((x) => Number(x.ageSec) || 0))
|
|
125
|
+
items.push({ key: 'leftovers', kind: 'leftovers', count: lo.length, ts: now - oldest * 1000 })
|
|
126
|
+
}
|
|
127
|
+
for (const p of previews || []) {
|
|
128
|
+
if (!p || !p.projectRoot) continue
|
|
129
|
+
items.push({ key: `preview:${p.id}`, kind: 'preview', chatId: p.chatId, group: p.name || groupLabel(groups, p.chatId), ts: now - (Number(p.upMs) || 0), port: p.port })
|
|
130
|
+
}
|
|
131
|
+
const sorted = items.filter((it) => !dismissed.has(it.key)).sort((a, b) => (b.ts || 0) - (a.ts || 0))
|
|
132
|
+
const bad = health && Array.isArray(health.rows) ? health.rows.filter((r) => r && r.status === 'fail') : [] // warn 不算:那是「可以更好」,不是「在等你」
|
|
133
|
+
return bad.length ? [{ key: 'health', kind: 'health', count: bad.length, labels: bad.map((r) => String(r.label || '')) }, ...sorted] : sorted
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 「忽略」记在盘上(daemon 重启不复活),7 天后清、最多 200 条 —— 失败轮次本身 72 小时就出窗口了,
|
|
137
|
+
// 留 7 天只是余量。文件读坏就当空:最坏是几条已忽略的失败重新出现,不阻断任何东西。
|
|
138
|
+
function createDismissStore({ file, now = Date.now, log = () => {} } = {}) {
|
|
139
|
+
let keys = null // key -> 忽略时刻
|
|
140
|
+
const load = () => {
|
|
141
|
+
if (keys) return
|
|
142
|
+
keys = new Map()
|
|
143
|
+
try {
|
|
144
|
+
const d = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
145
|
+
for (const [k, t] of Object.entries((d && d.keys) || {})) if (typeof t === 'number') keys.set(k, t)
|
|
146
|
+
} catch { /* 没有 / 读坏 ⇒ 空 */ }
|
|
147
|
+
}
|
|
148
|
+
const prune = () => {
|
|
149
|
+
const t = now()
|
|
150
|
+
for (const [k, at] of keys) if (!(at > t - DISMISS_TTL_MS)) keys.delete(k)
|
|
151
|
+
const extra = keys.size - DISMISS_MAX
|
|
152
|
+
if (extra > 0) for (const k of [...keys.keys()].slice(0, extra)) keys.delete(k) // Map 按插入序 ⇒ 先丢最老的
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
set() { load(); prune(); return new Set(keys.keys()) },
|
|
156
|
+
add(key) {
|
|
157
|
+
load(); keys.delete(key); keys.set(String(key), now()); prune()
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
160
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
161
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: 1, keys: Object.fromEntries(keys) }) + '\n', { mode: 0o600 })
|
|
162
|
+
fs.renameSync(tmp, file)
|
|
163
|
+
} catch (e) { log(`inbox: 写不进 ${path.basename(file)}(${e.message})—— 这次忽略只在内存里`) }
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { FAIL_WINDOW_MS, readLedgerTail, createLedgerTail, ledgerActivity, failedTurns, buildInbox, createDismissStore }
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// 控制台「用飞书登录」—— 标准 OAuth 2.0 授权码流程的纯逻辑(可测,不碰 http 服务)。
|
|
3
|
+
//
|
|
4
|
+
// 流程:登录页按钮 → GET /api/auth/feishu/start(签发一次性 state,302 到飞书授权页)
|
|
5
|
+
// → 飞书回调 GET /api/auth/feishu/callback?code&state(校验 state → 换 access_token
|
|
6
|
+
// → 取 open_id → 对照管理员名单 → 签发控制台会话 → 回一页 HTML 把令牌写进浏览器再跳回首页)。
|
|
7
|
+
//
|
|
8
|
+
// 只放行配置里管理员名单(feishu.admins[].openId)里的人 —— 名单本来就是「谁能替这台机器说话」,
|
|
9
|
+
// 登录复用同一份名单,不另起一套账号。open_id 按应用算,两台机器各自的应用各自的名单,天然分开。
|
|
10
|
+
//
|
|
11
|
+
// ⚠ 回调地址必须是公网 HTTPS 且在开放平台「安全设置 → 重定向 URL」里预先登记(飞书侧校验),
|
|
12
|
+
// 所以入口只在配置了 ui.publicUrl 之后才出现;局域网访问继续用密码登录。
|
|
13
|
+
// ⚠ 这里的 HTML 是用户可见文案:零工具名、零机器路径。
|
|
14
|
+
|
|
15
|
+
const crypto = require('node:crypto')
|
|
16
|
+
|
|
17
|
+
const AUTHORIZE_URL = 'https://accounts.feishu.cn/open-apis/authen/v1/authorize'
|
|
18
|
+
const TOKEN_URL = 'https://accounts.feishu.cn/oauth/v3/token' // v2 已标废弃,文档推荐 v3
|
|
19
|
+
const USERINFO_URL = 'https://open.feishu.cn/open-apis/authen/v1/user_info'
|
|
20
|
+
const CALLBACK_PATH = '/api/auth/feishu/callback'
|
|
21
|
+
const START_PATH = '/api/auth/feishu/start'
|
|
22
|
+
const FETCH_TIMEOUT_MS = 10_000
|
|
23
|
+
|
|
24
|
+
// 公网地址只认 https://host[:port],其余(路径 / 查询 / http / 空)一律不认 —— 回调地址由它拼出来,
|
|
25
|
+
// 拼错一段飞书就会报「重定向 URL 不匹配」,与其让人猜不如在存的时候就挡住。
|
|
26
|
+
function normalizePublicUrl(s) {
|
|
27
|
+
const t = String(s || '').trim()
|
|
28
|
+
if (!t) return null
|
|
29
|
+
let u
|
|
30
|
+
try { u = new URL(t) } catch { return null }
|
|
31
|
+
if (u.protocol !== 'https:') return null
|
|
32
|
+
if (!u.hostname) return null
|
|
33
|
+
if (u.username || u.password) return null
|
|
34
|
+
return `https://${u.host}` // host 含端口(非默认时)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const redirectUri = (publicUrl) => `${publicUrl}${CALLBACK_PATH}`
|
|
38
|
+
|
|
39
|
+
function buildAuthorizeUrl({ appId, redirectUri: ru, state }) {
|
|
40
|
+
const q = new URLSearchParams({ client_id: appId, redirect_uri: ru, response_type: 'code', state })
|
|
41
|
+
return `${AUTHORIZE_URL}?${q.toString()}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 一次性 state:防 CSRF(回调必须带我们签发过的 state,且只能用一次)。带 TTL 与上限,
|
|
45
|
+
// 免得有人狂刷 /start 把内存撑爆 —— 满了就淘汰最早的。
|
|
46
|
+
// ⚠ 上限不能太小:淘汰最早的 = 攻击者在别人「点按钮 → 授权 → 回调」那十几秒里刷满,就把别人的 state
|
|
47
|
+
// 挤掉、登录恒失败(对抗审查 🟡)。一条 state 约 60 字节,一万条不到 1MB,门槛抬 50 倍。
|
|
48
|
+
function createStateStore({ ttlMs = 10 * 60_000, max = 10_000, now = Date.now, random = () => crypto.randomBytes(16).toString('hex') } = {}) {
|
|
49
|
+
const pending = new Map() // state -> issuedAt
|
|
50
|
+
const prune = () => { const t = now(); for (const [k, at] of pending) if (t - at > ttlMs) pending.delete(k) }
|
|
51
|
+
return {
|
|
52
|
+
issue() {
|
|
53
|
+
prune()
|
|
54
|
+
while (pending.size >= max) pending.delete(pending.keys().next().value)
|
|
55
|
+
const s = random(); pending.set(s, now()); return s
|
|
56
|
+
},
|
|
57
|
+
consume(state) {
|
|
58
|
+
prune()
|
|
59
|
+
if (!state || !pending.has(state)) return false
|
|
60
|
+
pending.delete(state); return true
|
|
61
|
+
},
|
|
62
|
+
get size() { prune(); return pending.size },
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 网络层的错都翻成人话:超时 / 连不上 各一句,别把英文 AbortError 直接摆到登录失败页上。
|
|
67
|
+
async function fetchJson(fetchImpl, url, init) {
|
|
68
|
+
const ac = new AbortController()
|
|
69
|
+
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS)
|
|
70
|
+
try {
|
|
71
|
+
let r
|
|
72
|
+
try { r = await fetchImpl(url, { ...init, signal: ac.signal }) } catch (e) {
|
|
73
|
+
throw new Error(e && e.name === 'AbortError' ? `连接飞书超时(${FETCH_TIMEOUT_MS / 1000} 秒)` : `连不上飞书:${(e && e.message) || e}`)
|
|
74
|
+
}
|
|
75
|
+
let j = null
|
|
76
|
+
try { j = await r.json() } catch { j = null }
|
|
77
|
+
return { status: r.status, json: j }
|
|
78
|
+
} finally { clearTimeout(timer) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 授权码 → user_access_token。成功形状 {code:0, access_token, ...};失败 {code:2xxxx, error, error_description}。
|
|
82
|
+
async function exchangeCode({ fetchImpl = globalThis.fetch, appId, appSecret, code, redirectUri: ru }) {
|
|
83
|
+
const { status, json } = await fetchJson(fetchImpl, TOKEN_URL, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
86
|
+
body: JSON.stringify({ grant_type: 'authorization_code', client_id: appId, client_secret: appSecret, code, redirect_uri: ru }),
|
|
87
|
+
})
|
|
88
|
+
if (!json || typeof json !== 'object') throw new Error(`飞书没有返回有效响应(HTTP ${status})`)
|
|
89
|
+
if (json.code !== 0 || !json.access_token) {
|
|
90
|
+
const why = json.error_description || json.error || json.msg || `code ${json.code}`
|
|
91
|
+
throw new Error(`飞书拒绝了这次授权:${why}`)
|
|
92
|
+
}
|
|
93
|
+
return { accessToken: String(json.access_token) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// user_access_token → 这个人在本应用下的 open_id 与姓名(不需要额外 scope)。
|
|
97
|
+
async function fetchUserInfo({ fetchImpl = globalThis.fetch, accessToken }) {
|
|
98
|
+
const { status, json } = await fetchJson(fetchImpl, USERINFO_URL, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } })
|
|
99
|
+
const d = json && json.data
|
|
100
|
+
if (!json || json.code !== 0 || !d || !d.open_id) throw new Error(`取不到飞书身份(HTTP ${status}${json && json.msg ? ' · ' + json.msg : ''})`)
|
|
101
|
+
return { openId: String(d.open_id), name: String(d.name || d.en_name || ''), unionId: d.union_id ? String(d.union_id) : '' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 名单匹配:精确、区分大小写(open_id 是不透明标识符,不做任何「宽容」)。
|
|
105
|
+
function adminMatch(admins, openId) {
|
|
106
|
+
if (!openId || !Array.isArray(admins)) return null
|
|
107
|
+
return admins.find((a) => a && a.openId === openId) || null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const escHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
111
|
+
|
|
112
|
+
// 回调落地页:成功 → 把令牌写进浏览器存储再回首页;失败 → 说人话并给回登录页的链接。
|
|
113
|
+
// 令牌只经这页的脚本落地,不进 URL、不进日志。样式跟随系统深浅色,不依赖控制台的 CSS。
|
|
114
|
+
function loginResultHtml({ ok, token = '', name = '', message = '' }) {
|
|
115
|
+
const tok = /^[0-9a-f]{16,128}$/.test(String(token)) ? String(token) : ''
|
|
116
|
+
const body = ok && tok
|
|
117
|
+
? `<p class="t">登录成功${name ? ',' + escHtml(name) : ''}</p><p class="s">正在进入控制台…</p>
|
|
118
|
+
<script>try{localStorage.setItem('sbot-token','${tok}');sessionStorage.setItem('sbot-token','${tok}')}catch(e){}location.replace('/')</script>`
|
|
119
|
+
: `<p class="t">没能登录</p><p class="s">${escHtml(message || '未知原因')}</p><p class="s"><a href="/">回到登录页</a></p>`
|
|
120
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SintBot 控制台</title>
|
|
121
|
+
<style>:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font:14px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;background:#f6f6f7;color:#1c1c1e}@media(prefers-color-scheme:dark){body{background:#1e1e2e;color:#cdd6f4}}main{padding:28px 32px;border-radius:14px;background:rgba(127,127,127,.08);text-align:center}.t{font-size:16px;font-weight:600;margin:0 0 6px}.s{margin:0;opacity:.75}a{color:inherit}</style></head><body><main>${body}</main></body></html>`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 入口能不能亮:三样都齐才亮(缺一样就在设置页说清缺哪样,登录页不出按钮)。
|
|
125
|
+
function feishuLoginReadiness({ publicUrl, appId, appSecret, admins }) {
|
|
126
|
+
if (!normalizePublicUrl(publicUrl)) return { ready: false, why: '未填公网地址' }
|
|
127
|
+
if (!appId || !appSecret) return { ready: false, why: '未配置飞书应用凭证' }
|
|
128
|
+
if (!Array.isArray(admins) || !admins.some((a) => a && a.openId)) return { ready: false, why: '管理员名单为空(只有名单里的人能登录)' }
|
|
129
|
+
return { ready: true, why: '' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
AUTHORIZE_URL, TOKEN_URL, USERINFO_URL, CALLBACK_PATH, START_PATH,
|
|
134
|
+
normalizePublicUrl, redirectUri, buildAuthorizeUrl, createStateStore,
|
|
135
|
+
exchangeCode, fetchUserInfo, adminMatch, loginResultHtml, feishuLoginReadiness, escHtml,
|
|
136
|
+
}
|