sintbot 1.4.15 → 1.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/sintbot-v2.js +104 -4
- package/docs/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/install.md +46 -10
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/zh-CN/install.md +46 -10
- package/install.sh +1 -1
- package/package.json +10 -3
- package/src/agent/claudecode.js +5 -2
- package/src/agent/claudemodels.js +79 -8
- package/src/agent/claudeusage.js +188 -3
- package/src/agent/codexapp.js +4 -3
- package/src/agent/codexcode.js +1 -1
- package/src/agent/envconflicts.js +74 -0
- package/src/agent/privatecore.js +65 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +29 -18
- package/src/c2console.js +22 -6
- package/src/c2install.js +1 -1
- package/src/c2multigroupservice.js +22 -2
- package/src/connect.js +488 -0
- package/src/core/brain.js +117 -0
- package/src/core/c2inputrejection.js +19 -6
- package/src/core/c2runnerinput.js +7 -2
- package/src/core/modelcmd.js +132 -0
- package/src/core/modelname.js +61 -0
- package/src/core/v2engine.js +304 -47
- package/src/groupbrain.js +135 -0
- package/src/modelcatalog.js +122 -0
- package/src/platform/feishu.js +39 -7
- package/src/platform/modelcard.js +103 -0
- package/src/ui/console.html +273 -147
- package/src/ui/serverbase.js +174 -17
- package/src/usage.js +131 -38
- package/src/modelcontext.js +0 -13
package/src/connect.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// `sintbot connect`: Feishu scan onboarding. The binary core decides every step
|
|
4
|
+
// and every sentence shown for it (connect_manifest / connect_plan /
|
|
5
|
+
// connect_config / qr_matrix); this file only performs I/O in the order the
|
|
6
|
+
// plan fixes: scan → token → app → precheck → scope → publish → dir → chat →
|
|
7
|
+
// config → init. Every network, file system and process dependency is
|
|
8
|
+
// injectable so the flow can run end to end without a Feishu account.
|
|
9
|
+
const nodeFs = require('node:fs')
|
|
10
|
+
const os = require('node:os')
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const readline = require('node:readline')
|
|
13
|
+
const { parseArgs } = require('node:util')
|
|
14
|
+
const core = require('./agent/privatecore')
|
|
15
|
+
const { atomicWriteJSON } = require('./core/store')
|
|
16
|
+
const { installationStateDir, MANIFEST } = require('./c2install')
|
|
17
|
+
const { defaultProjectsRoot, tooWideReason, realpathLenient } = require('./projectsroot')
|
|
18
|
+
|
|
19
|
+
const CONNECT_OPTIONS = Object.freeze({
|
|
20
|
+
config: { type: 'string' }, 'data-dir': { type: 'string' }, workdir: { type: 'string' },
|
|
21
|
+
name: { type: 'string' }, provider: { type: 'string' }, 'app-id': { type: 'string' },
|
|
22
|
+
chat: { type: 'string' }, basic: { type: 'boolean' }, 'no-open': { type: 'boolean' },
|
|
23
|
+
})
|
|
24
|
+
const APP_ID = /^cli_[A-Za-z0-9]{6,64}$/
|
|
25
|
+
const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
26
|
+
const PROJECT_NAME = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,59}$/u
|
|
27
|
+
const RUNNERS = Object.freeze(['codex', 'claude'])
|
|
28
|
+
// Files whose presence means the data directory is not fresh; the same list
|
|
29
|
+
// initializeInstallation refuses later, checked here before a scan is spent.
|
|
30
|
+
const STATE_FILES = Object.freeze(['state.json', 'pending-turns.json', 'engine-receipts.json', MANIFEST, 'daemon.lock'])
|
|
31
|
+
// The core keys a step failure on a Feishu code or HTTP status. A local
|
|
32
|
+
// failure (file system, missing chat id) has neither; it is reported as code 1.
|
|
33
|
+
const LOCAL_FAILURE = 1
|
|
34
|
+
const PUBLISH_POLL_MS = 30_000
|
|
35
|
+
const PUBLISH_POLL_STEP_MS = 3_000
|
|
36
|
+
const EXIT_INTERRUPTED = 130
|
|
37
|
+
|
|
38
|
+
function defaultDeps(deps) {
|
|
39
|
+
const feishu = deps.feishu || require('./platform/feishu')
|
|
40
|
+
return {
|
|
41
|
+
registerApp: options => require('@larksuiteoapi/node-sdk').registerApp(options),
|
|
42
|
+
feishuClient: feishu.feishuClient,
|
|
43
|
+
feishu,
|
|
44
|
+
initializeInstallation: (file, options) => require('./c2install').initializeInstallation(file, options),
|
|
45
|
+
spawn: require('node:child_process').spawn,
|
|
46
|
+
spawnSync: require('node:child_process').spawnSync,
|
|
47
|
+
fs: nodeFs,
|
|
48
|
+
now: Date.now,
|
|
49
|
+
sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
50
|
+
stdout: process.stdout,
|
|
51
|
+
stderr: process.stderr,
|
|
52
|
+
env: process.env,
|
|
53
|
+
home: os.homedir(),
|
|
54
|
+
tmpdir: os.tmpdir(),
|
|
55
|
+
userName: localUserName(),
|
|
56
|
+
isTTY: !!(process.stdin.isTTY && process.stderr.isTTY),
|
|
57
|
+
prompt: null,
|
|
58
|
+
signals: process,
|
|
59
|
+
version: require('../package.json').version,
|
|
60
|
+
...deps,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function localUserName() {
|
|
65
|
+
try { return os.userInfo().username || '' } catch { return '' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function askLine(question, fallback, { stderr }) {
|
|
69
|
+
return new Promise(resolve => {
|
|
70
|
+
const rl = readline.createInterface({ input: process.stdin, output: stderr })
|
|
71
|
+
rl.question(question, answer => { rl.close(); resolve(answer.trim() || fallback) })
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Feishu failures arrive as an axios error (body under response.data), as an
|
|
76
|
+
// SDK result whose code is not 0, as the SDK's own token Error ("failed to get
|
|
77
|
+
// tenant_access_token, code: 21001, msg: …") or as a local Error. The core
|
|
78
|
+
// wants { status, code, msg }; no code and no status means the step succeeded.
|
|
79
|
+
function failureInput(error, larkErrMsg) {
|
|
80
|
+
const data = error?.response?.data
|
|
81
|
+
let code = Number.isInteger(data?.code) ? data.code
|
|
82
|
+
: Number.isInteger(error?.code) ? error.code : null
|
|
83
|
+
const status = Number.isInteger(error?.response?.status) ? error.response.status : null
|
|
84
|
+
if (code === null && status === null) {
|
|
85
|
+
const inMessage = /\bcode:\s*(\d+)/.exec(error?.message || '')
|
|
86
|
+
code = inMessage ? Number(inMessage[1]) : LOCAL_FAILURE
|
|
87
|
+
}
|
|
88
|
+
return { status, code, msg: larkErrMsg(error) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function assertOk(result, what) {
|
|
92
|
+
if (result && typeof result === 'object' && Number.isInteger(result.code) && result.code !== 0) {
|
|
93
|
+
const error = new Error(`${what} failed: ${result.msg || result.message || 'code ' + result.code}`)
|
|
94
|
+
error.response = { data: result }
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The SDK carries scopes, events and callbacks in nested buckets; the core's
|
|
101
|
+
// flat lists are tenant-identity items.
|
|
102
|
+
function sdkAddons(addons) {
|
|
103
|
+
const out = { preset: addons.preset === true }
|
|
104
|
+
if (addons.scopes.length) out.scopes = { tenant: addons.scopes }
|
|
105
|
+
if (addons.events.length) out.events = { items: { tenant: addons.events } }
|
|
106
|
+
if (addons.callbacks.length) out.callbacks = { items: addons.callbacks }
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const escapeHtml = text => String(text).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch])
|
|
111
|
+
const shellQuote = text => "'" + String(text).replace(/'/g, "'\\''") + "'"
|
|
112
|
+
|
|
113
|
+
// One SVG rect per run of dark modules, quiet zone 4 modules, about 360px.
|
|
114
|
+
function qrPage(url) {
|
|
115
|
+
const matrix = core.qrMatrix(url)
|
|
116
|
+
const quiet = 4
|
|
117
|
+
const total = matrix.size + quiet * 2
|
|
118
|
+
const rects = []
|
|
119
|
+
matrix.rows.forEach((row, y) => {
|
|
120
|
+
let x = 0
|
|
121
|
+
while (x < row.length) {
|
|
122
|
+
if (row[x] !== '1') { x++; continue }
|
|
123
|
+
let run = 1
|
|
124
|
+
while (row[x + run] === '1') run++
|
|
125
|
+
rects.push(`<rect x="${x + quiet}" y="${y + quiet}" width="${run}" height="1"/>`)
|
|
126
|
+
x += run
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
return `<!doctype html>
|
|
130
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><title>SintBot · Feishu</title>
|
|
131
|
+
<style>body{margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;font:14px/1.5 -apple-system,system-ui,sans-serif;background:#fff;color:#222}
|
|
132
|
+
svg{width:360px;height:360px;shape-rendering:crispEdges}p{max-width:560px;word-break:break-all;text-align:center;padding:0 16px}</style></head>
|
|
133
|
+
<body><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}"><rect width="${total}" height="${total}" fill="#fff"/><g fill="#000">${rects.join('')}</g></svg>
|
|
134
|
+
<p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p></body></html>
|
|
135
|
+
`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readExistingConfig(fs, file) {
|
|
139
|
+
let text
|
|
140
|
+
try { text = fs.readFileSync(file, 'utf8') } catch (error) {
|
|
141
|
+
if (error.code === 'ENOENT') return null
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
try { return JSON.parse(text) || {} } catch { return { unreadable: true } }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A configuration may be replaced only to finish an earlier attempt: the same
|
|
148
|
+
// app, and no group bound yet. Anything else is someone's installation.
|
|
149
|
+
function assertReplaceable(existing, configPath, reuseAppId) {
|
|
150
|
+
if (!existing) return
|
|
151
|
+
const retry = `to reuse that app run sintbot connect --app-id <cli_…>, otherwise move ${configPath} aside first`
|
|
152
|
+
if (reuseAppId === undefined) {
|
|
153
|
+
throw new Error(existing.unreadable || existing.feishu?.appId || existing.feishu?.appSecret
|
|
154
|
+
? `${configPath} already holds a Feishu app; ${retry}`
|
|
155
|
+
: `${configPath} already exists; move it aside before connecting`)
|
|
156
|
+
}
|
|
157
|
+
if (existing.unreadable) throw new Error(`${configPath} is not readable JSON; move it aside before connecting with --app-id`)
|
|
158
|
+
if (existing.feishu?.appId !== reuseAppId) {
|
|
159
|
+
throw new Error(`${configPath} belongs to a different Feishu app (${existing.feishu?.appId || 'none'}); ` +
|
|
160
|
+
`run sintbot connect --app-id ${existing.feishu?.appId || '<cli_…>'} to continue with it, or move the file aside`)
|
|
161
|
+
}
|
|
162
|
+
if (Object.keys(existing.groups || {}).length) {
|
|
163
|
+
throw new Error(`${configPath} already binds ${Object.keys(existing.groups).length} group(s); ` +
|
|
164
|
+
'connect never replaces a configuration with groups — add groups in the console or by editing the file')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertFreshState(fs, stateDir) {
|
|
169
|
+
let names = []
|
|
170
|
+
try { names = fs.readdirSync(stateDir) } catch (error) { if (error.code === 'ENOENT') return; throw error }
|
|
171
|
+
const found = names.filter(name => STATE_FILES.includes(name) || /^c2-v2.*\.log$/.test(name))
|
|
172
|
+
if (!found.length) return
|
|
173
|
+
if (found.includes('daemon.lock')) throw new Error(`SintBot is running (${path.join(stateDir, 'daemon.lock')} exists); stop it before connecting`)
|
|
174
|
+
throw new Error(`${found.join(', ')} already exist(s) in ${stateDir}; an existing journal requires review, ` +
|
|
175
|
+
'and connect never overwrites or migrates existing state')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function probeRunners(spawnSync, env) {
|
|
179
|
+
const runners = {}
|
|
180
|
+
for (const runner of RUNNERS) {
|
|
181
|
+
const probe = spawnSync(runner, ['--version'], { encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024, env })
|
|
182
|
+
runners[runner] = !probe.error && probe.status === 0 && new RegExp(runner, 'i').test(probe.stdout || '')
|
|
183
|
+
}
|
|
184
|
+
return runners
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Width is judged on the literal path and on what it really points to, so a
|
|
188
|
+
// link to the home directory or the root is refused as well.
|
|
189
|
+
function dirObserved(fs, workdir, explicit, home) {
|
|
190
|
+
let exists = false
|
|
191
|
+
try { exists = fs.statSync(workdir).isDirectory() } catch {}
|
|
192
|
+
const empty = !exists || fs.readdirSync(workdir).length === 0
|
|
193
|
+
const tooWide = tooWideReason(workdir, { home }) ||
|
|
194
|
+
tooWideReason(realpathLenient(workdir), { home: realpathLenient(home) })
|
|
195
|
+
return { workdir, exists, empty, tooWide, explicit }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runConnect(argv, deps = {}) {
|
|
199
|
+
const d = defaultDeps(deps)
|
|
200
|
+
const { fs, feishu, stderr, stdout } = d
|
|
201
|
+
const { values } = parseArgs({ args: argv, allowPositionals: false, options: CONNECT_OPTIONS })
|
|
202
|
+
const basic = values.basic === true
|
|
203
|
+
const reuseAppId = values['app-id']
|
|
204
|
+
const givenChatId = values.chat
|
|
205
|
+
if (reuseAppId !== undefined && !APP_ID.test(reuseAppId)) throw new Error('--app-id must be a Feishu app id (cli_…)')
|
|
206
|
+
if (givenChatId !== undefined && !CHAT_ID.test(givenChatId)) throw new Error('--chat must be a Feishu group id (oc_…)')
|
|
207
|
+
if (values.provider !== undefined && !RUNNERS.includes(values.provider)) throw new Error('--provider must be codex or claude')
|
|
208
|
+
const say = text => stderr.write(`${text}\n`)
|
|
209
|
+
// A wait notice repeats on every poll; it is printed once per change.
|
|
210
|
+
let lastWait = null
|
|
211
|
+
const decide = (step, input) => {
|
|
212
|
+
const plan = core.connectPlan(step, { basic, ...input })
|
|
213
|
+
if (plan.verdict === 'wait') {
|
|
214
|
+
if (!(plan.retryAfterMs > 0)) throw new Error(`The core asked to wait on ${step} without a retry interval`)
|
|
215
|
+
if (plan.notice !== lastWait) say(plan.notice)
|
|
216
|
+
lastWait = plan.notice
|
|
217
|
+
} else {
|
|
218
|
+
lastWait = null
|
|
219
|
+
say(plan.notice)
|
|
220
|
+
}
|
|
221
|
+
return plan
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Step 0: never touch an installation that already has credentials, state
|
|
225
|
+
// or a running service; --app-id may only finish an attempt for that app.
|
|
226
|
+
const stateDir = installationStateDir(values['data-dir'] === undefined ? path.join(d.home, '.sbot') : values['data-dir'])
|
|
227
|
+
const configPath = values.config ? path.resolve(values.config)
|
|
228
|
+
: d.env.SBOT_CONFIG ? path.resolve(d.env.SBOT_CONFIG) : path.join(d.home, '.sbot', 'config.json')
|
|
229
|
+
if (!path.isAbsolute(configPath)) throw new Error('The configuration path must be absolute')
|
|
230
|
+
assertReplaceable(readExistingConfig(fs, configPath), configPath, reuseAppId)
|
|
231
|
+
assertFreshState(fs, stateDir)
|
|
232
|
+
|
|
233
|
+
// Project directory per the directory rule: an existing --workdir, or a new
|
|
234
|
+
// folder under ~/Projects named by --name or a prompt.
|
|
235
|
+
let workdir
|
|
236
|
+
const explicit = values.workdir !== undefined
|
|
237
|
+
if (explicit) {
|
|
238
|
+
workdir = values.workdir
|
|
239
|
+
if (!path.isAbsolute(workdir) || workdir !== path.resolve(workdir)) throw new Error('--workdir must be a normalized absolute path')
|
|
240
|
+
let stat = null
|
|
241
|
+
try { stat = fs.statSync(workdir) } catch {}
|
|
242
|
+
if (!stat?.isDirectory()) throw new Error('--workdir must be an existing directory')
|
|
243
|
+
} else {
|
|
244
|
+
let name = values.name
|
|
245
|
+
if (name === undefined) {
|
|
246
|
+
name = d.isTTY ? await (d.prompt || askLine)('项目名(默认 sintbot):', 'sintbot', d) : 'sintbot'
|
|
247
|
+
}
|
|
248
|
+
if (!PROJECT_NAME.test(name) || name === '.' || name === '..') throw new Error('The project name may only use letters, digits, ".", "_" and "-" (max 60)')
|
|
249
|
+
workdir = path.join(defaultProjectsRoot({ home: d.home }), name)
|
|
250
|
+
}
|
|
251
|
+
// The core's directory decision is taken before the scan too, so a refused
|
|
252
|
+
// directory never costs the user a scan; it is taken again before creation.
|
|
253
|
+
const early = core.connectPlan('dir', { basic, observed: dirObserved(fs, workdir, explicit, d.home) })
|
|
254
|
+
if (early.verdict === 'stop') { say(early.notice); return 1 }
|
|
255
|
+
|
|
256
|
+
// Runners present on this Mac; the group's provider must be one of them.
|
|
257
|
+
const runners = probeRunners(d.spawnSync, d.env)
|
|
258
|
+
const available = RUNNERS.filter(runner => runners[runner])
|
|
259
|
+
if (!available.length) throw new Error('Install and sign in to Codex or Claude Code first; neither answered --version')
|
|
260
|
+
let provider = values.provider
|
|
261
|
+
if (provider !== undefined && !runners[provider]) throw new Error(`${provider} did not answer --version; install and sign in to it first`)
|
|
262
|
+
if (provider === undefined) {
|
|
263
|
+
provider = available.length === 1 || !d.isTTY ? available[0]
|
|
264
|
+
: await (d.prompt || askLine)(`执行器(${available.join(' / ')},默认 ${available[0]}):`, available[0], d)
|
|
265
|
+
if (!runners[provider]) throw new Error('--provider must be codex or claude')
|
|
266
|
+
}
|
|
267
|
+
// Everything the configuration needs apart from the scan result is known;
|
|
268
|
+
// let the core validate it now with placeholders for the scanned identities.
|
|
269
|
+
const configInput = { appId: reuseAppId || 'cli_pending', appSecret: 'pending', botOpenId: 'ou_pending', adminOpenId: 'ou_pending',
|
|
270
|
+
chatId: givenChatId || null, workdir, home: d.home, provider, runners }
|
|
271
|
+
const dryRun = core.connectConfig(configInput)
|
|
272
|
+
if (!dryRun.ok) throw new Error(dryRun.error)
|
|
273
|
+
|
|
274
|
+
// Step 1: the scan. The link is printed, and a local page renders the QR
|
|
275
|
+
// code from the core's matrix; the page is removed however this ends.
|
|
276
|
+
// The app is named before anyone has scanned, so the only name available
|
|
277
|
+
// is the local account's; the scanner's Feishu display name (the SDK
|
|
278
|
+
// returns none, contacts are read later) names the group instead.
|
|
279
|
+
const manifest = core.connectManifest(basic, d.userName, d.version)
|
|
280
|
+
say(manifest.hint)
|
|
281
|
+
let qrDir = null
|
|
282
|
+
const cleanupQr = () => {
|
|
283
|
+
if (!qrDir) return
|
|
284
|
+
try { fs.rmSync(qrDir, { recursive: true, force: true }) } catch {}
|
|
285
|
+
qrDir = null
|
|
286
|
+
}
|
|
287
|
+
process.once('exit', cleanupQr)
|
|
288
|
+
const abort = new AbortController()
|
|
289
|
+
const onSignal = () => abort.abort()
|
|
290
|
+
for (const signal of ['SIGINT', 'SIGTERM']) d.signals.on(signal, onSignal)
|
|
291
|
+
let lastStatus = null
|
|
292
|
+
let registration
|
|
293
|
+
try {
|
|
294
|
+
registration = await d.registerApp({
|
|
295
|
+
...(reuseAppId === undefined ? { createOnly: true } : { appId: reuseAppId }),
|
|
296
|
+
appPreset: manifest.appPreset, addons: sdkAddons(manifest.addons), source: manifest.source, signal: abort.signal,
|
|
297
|
+
onQRCodeReady: ({ url, expireIn }) => {
|
|
298
|
+
stdout.write(`${url}\n`)
|
|
299
|
+
say(`二维码 ${Math.floor(Number(expireIn) || 0)} 秒内有效。`)
|
|
300
|
+
try {
|
|
301
|
+
qrDir = fs.mkdtempSync(path.join(d.tmpdir, 'sintbot-connect-'))
|
|
302
|
+
const page = path.join(qrDir, 'feishu-qr.html')
|
|
303
|
+
fs.writeFileSync(page, qrPage(url), { mode: 0o600 })
|
|
304
|
+
if (values['no-open'] !== true) {
|
|
305
|
+
const child = d.spawn('open', [page], { stdio: 'ignore', detached: true })
|
|
306
|
+
child.on?.('error', () => {})
|
|
307
|
+
child.unref?.()
|
|
308
|
+
}
|
|
309
|
+
} catch (error) { say(`二维码页面未能打开(${error?.message || error}),请直接打开上面的链接。`) }
|
|
310
|
+
},
|
|
311
|
+
onStatusChange: info => {
|
|
312
|
+
const status = info?.status || ''
|
|
313
|
+
if (status === lastStatus) return
|
|
314
|
+
lastStatus = status
|
|
315
|
+
if (status === 'polling') say('等待扫码…')
|
|
316
|
+
else if (status === 'slow_down') say('等待扫码…(已放慢轮询)')
|
|
317
|
+
else if (status === 'domain_switched') say('已切换到 Lark 国际版域名。')
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (abort.signal.aborted) return EXIT_INTERRUPTED
|
|
322
|
+
const reason = error?.description ? `${error.code}: ${error.description}` : (error?.message || String(error))
|
|
323
|
+
throw new Error(`Feishu scan did not complete (${reason})`)
|
|
324
|
+
} finally {
|
|
325
|
+
cleanupQr()
|
|
326
|
+
process.off('exit', cleanupQr)
|
|
327
|
+
for (const signal of ['SIGINT', 'SIGTERM']) d.signals.off(signal, onSignal)
|
|
328
|
+
}
|
|
329
|
+
const appId = registration?.client_id || ''
|
|
330
|
+
const appSecret = registration?.client_secret || ''
|
|
331
|
+
const adminOpenId = registration?.user_info?.open_id || ''
|
|
332
|
+
if (!appId || !appSecret) throw new Error('Feishu scan returned no app credentials')
|
|
333
|
+
// The id (never the secret) lets every later stop be resumed with --app-id.
|
|
334
|
+
say(`应用 ID:${appId}`)
|
|
335
|
+
const client = d.feishuClient(appId, appSecret)
|
|
336
|
+
const { larkErrMsg } = feishu
|
|
337
|
+
|
|
338
|
+
// Step 2a: credentials and bot identity.
|
|
339
|
+
let identity = null
|
|
340
|
+
let plan
|
|
341
|
+
try {
|
|
342
|
+
identity = await feishu.fetchAppIdentity(client, appId)
|
|
343
|
+
// An SDK body with a non-zero code does not throw; read it so a code such
|
|
344
|
+
// as 21001 reaches the core instead of an empty identity.
|
|
345
|
+
if (!identity.openId) assertOk(await client.request({ method: 'GET', url: '/open-apis/bot/v3/info' }), 'bot identity')
|
|
346
|
+
plan = decide('token', {})
|
|
347
|
+
} catch (error) { plan = decide('token', failureInput(error, larkErrMsg)) }
|
|
348
|
+
if (plan.verdict === 'stop') return 1
|
|
349
|
+
|
|
350
|
+
// Step 2b: app enabled, waiting for an organization review when there is one.
|
|
351
|
+
let waitedMs = 0
|
|
352
|
+
for (;;) {
|
|
353
|
+
const enabled = identity?.activateOk === true
|
|
354
|
+
plan = decide('app', { observed: { enabled, pendingReview: enabled ? false : await pendingReview(client, appId), waitedMs } })
|
|
355
|
+
if (plan.verdict !== 'wait') break
|
|
356
|
+
await d.sleep(plan.retryAfterMs)
|
|
357
|
+
waitedMs += plan.retryAfterMs
|
|
358
|
+
try { identity = await feishu.fetchAppIdentity(client, appId) } catch { identity = null }
|
|
359
|
+
}
|
|
360
|
+
if (plan.verdict === 'stop') return 1
|
|
361
|
+
|
|
362
|
+
// Step 2c: the existing readiness checklist.
|
|
363
|
+
const precheck = await feishu.feishuPrecheck(client, { appId })
|
|
364
|
+
if (decide('precheck', { observed: { checks: precheck.checks } }).verdict === 'stop') return 1
|
|
365
|
+
const botOpenId = precheck.botOpenId || identity?.openId || ''
|
|
366
|
+
|
|
367
|
+
// Step 3: contact range, visibility and a published version. Failures only
|
|
368
|
+
// warn; --basic skips both.
|
|
369
|
+
if (basic) {
|
|
370
|
+
decide('scope', {})
|
|
371
|
+
} else {
|
|
372
|
+
try {
|
|
373
|
+
assertOk(await client.request({ method: 'PATCH', url: `/open-apis/application/v7/applications/${encodeURIComponent(appId)}/config`,
|
|
374
|
+
data: { contacts: { contacts_range_type: 'all' }, visibility: { is_visible_to_all: true } } }), 'scope')
|
|
375
|
+
decide('scope', {})
|
|
376
|
+
} catch (error) { decide('scope', failureInput(error, larkErrMsg)) }
|
|
377
|
+
try {
|
|
378
|
+
assertOk(await client.request({ method: 'POST', url: `/open-apis/application/v7/applications/${encodeURIComponent(appId)}/publish`,
|
|
379
|
+
data: { remark: 'SintBot 接入', changelog: `SintBot ${d.version} 接入:机器人、消息事件与卡片回调。`,
|
|
380
|
+
mobile_default_ability: 'bot', pc_default_ability: 'bot' } }), 'publish')
|
|
381
|
+
decide('publish', {})
|
|
382
|
+
await waitPublished(client, appId, d)
|
|
383
|
+
} catch (error) { decide('publish', failureInput(error, larkErrMsg)) }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The real configuration input, validated before anything is created.
|
|
387
|
+
Object.assign(configInput, { appId, appSecret, botOpenId, adminOpenId })
|
|
388
|
+
let built = core.connectConfig(configInput)
|
|
389
|
+
if (!built.ok) { decide('config', { code: LOCAL_FAILURE, msg: built.error }); return 1 }
|
|
390
|
+
|
|
391
|
+
// Step 4: the project directory is judged now and created with the
|
|
392
|
+
// configuration below, so a stopped group step leaves no folder behind.
|
|
393
|
+
const observed = dirObserved(fs, workdir, explicit, d.home)
|
|
394
|
+
if (decide('dir', { observed }).verdict === 'stop') return 1
|
|
395
|
+
|
|
396
|
+
// Step 5: the group. A group given with --chat is bound as it is; otherwise
|
|
397
|
+
// one is created with the scanner as its first member. Failure stops here,
|
|
398
|
+
// before any file exists; the core's notice carries the resume command.
|
|
399
|
+
let chatId = givenChatId || null
|
|
400
|
+
const chatName = (await scannerName(client, adminOpenId, registration.user_info?.name, basic, d)) || manifest.appPreset.name
|
|
401
|
+
if (!chatId) {
|
|
402
|
+
try {
|
|
403
|
+
const chat = await feishu.createChat(client, chatName, '', adminOpenId ? [adminOpenId] : [])
|
|
404
|
+
if (!chat.chatId) throw new Error('create chat returned no chat_id')
|
|
405
|
+
chatId = chat.chatId
|
|
406
|
+
plan = decide('chat', { observed: { appId, chatId } })
|
|
407
|
+
} catch (error) { plan = decide('chat', { ...failureInput(error, larkErrMsg), observed: { appId, chatId: null } }) }
|
|
408
|
+
} else {
|
|
409
|
+
plan = decide('chat', { observed: { appId, chatId } })
|
|
410
|
+
}
|
|
411
|
+
if (plan.verdict === 'stop') return 1
|
|
412
|
+
try {
|
|
413
|
+
await feishu.sendChatText(client, chatId,
|
|
414
|
+
`你好,我是 ${chatName}。这个群绑定的项目目录是 ${workdir};有任务直接 @ 我或在群里说。`)
|
|
415
|
+
} catch {}
|
|
416
|
+
|
|
417
|
+
// Step 6: the folder, config.json (0600 in a 0700 directory) and the README.
|
|
418
|
+
built = core.connectConfig({ ...configInput, chatId })
|
|
419
|
+
if (!built.ok) { decide('config', { code: LOCAL_FAILURE, msg: built.error }); return 1 }
|
|
420
|
+
let createdDir = false
|
|
421
|
+
try {
|
|
422
|
+
if (!observed.exists) {
|
|
423
|
+
fs.mkdirSync(path.dirname(workdir), { recursive: true, mode: 0o755 })
|
|
424
|
+
fs.mkdirSync(workdir, { mode: 0o755 })
|
|
425
|
+
createdDir = true
|
|
426
|
+
}
|
|
427
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 })
|
|
428
|
+
atomicWriteJSON(configPath, built.config, { mode: 0o600 })
|
|
429
|
+
fs.chmodSync(configPath, 0o600)
|
|
430
|
+
const readme = path.join(workdir, 'README.md')
|
|
431
|
+
if (!fs.existsSync(readme)) fs.writeFileSync(readme, built.readme, { mode: 0o644 })
|
|
432
|
+
plan = decide('config', {})
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (createdDir) { try { if (!fs.readdirSync(workdir).length) fs.rmdirSync(workdir) } catch {} }
|
|
435
|
+
plan = decide('config', { code: LOCAL_FAILURE, msg: error?.message || String(error) })
|
|
436
|
+
}
|
|
437
|
+
if (plan.verdict === 'stop') return 1
|
|
438
|
+
|
|
439
|
+
// Step 7: journals, under the unchanged fresh-state rules.
|
|
440
|
+
try {
|
|
441
|
+
d.initializeInstallation(configPath, { dataDir: stateDir })
|
|
442
|
+
plan = core.connectPlan('init', { basic })
|
|
443
|
+
} catch (error) { plan = decide('init', { code: LOCAL_FAILURE, msg: error?.message || String(error) }) }
|
|
444
|
+
if (plan.verdict === 'stop') return 1
|
|
445
|
+
const installed = path.join(d.home, '.local', 'share', 'sintbot', 'bin', 'sintbot')
|
|
446
|
+
const bin = fs.existsSync(installed) ? '"$HOME/.local/share/sintbot/bin/sintbot"' : 'sintbot'
|
|
447
|
+
stdout.write(`${plan.notice}\n${bin} start --config ${shellQuote(configPath)} --data-dir ${shellQuote(stateDir)} --ui\n`)
|
|
448
|
+
return 0
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const appVersions = (client, appId) => client.request({ method: 'GET',
|
|
452
|
+
url: `/open-apis/application/v6/applications/${encodeURIComponent(appId)}/app_versions`,
|
|
453
|
+
params: { lang: 'zh_cn', page_size: 10, user_id_type: 'open_id' } })
|
|
454
|
+
const versionItems = r => r?.data?.items || r?.items || []
|
|
455
|
+
|
|
456
|
+
// A version waiting for the organization's review (app_versions status 1).
|
|
457
|
+
// Unreadable (missing scope, no version yet) means no review is pending.
|
|
458
|
+
async function pendingReview(client, appId) {
|
|
459
|
+
try { return versionItems(await appVersions(client, appId)).some(item => item?.status === 1) } catch { return false }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// After publish, give the app up to 30 s to report a published version. This
|
|
463
|
+
// only paces the next steps; the core has already judged the publish call.
|
|
464
|
+
async function waitPublished(client, appId, d) {
|
|
465
|
+
const until = d.now() + PUBLISH_POLL_MS
|
|
466
|
+
do {
|
|
467
|
+
try { if (versionItems(await appVersions(client, appId)).some(item => item?.status === 2)) return true } catch { return false }
|
|
468
|
+
await d.sleep(PUBLISH_POLL_STEP_MS)
|
|
469
|
+
} while (d.now() < until)
|
|
470
|
+
return false
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// The scanner's display name, through the same core manifest that names the
|
|
474
|
+
// app, so the group is "<name>的 SintBot". A name in the SDK result (the
|
|
475
|
+
// current SDK sends only open_id) wins over the contact lookup; empty when
|
|
476
|
+
// neither is available.
|
|
477
|
+
async function scannerName(client, openId, sdkName, basic, d) {
|
|
478
|
+
let name = typeof sdkName === 'string' ? sdkName.trim() : ''
|
|
479
|
+
if (!name && openId) {
|
|
480
|
+
try {
|
|
481
|
+
const r = await client.contact.user.get({ params: { user_id_type: 'open_id' }, path: { user_id: openId } })
|
|
482
|
+
name = r?.data?.user?.name || r?.data?.user?.nickname || ''
|
|
483
|
+
} catch { name = '' }
|
|
484
|
+
}
|
|
485
|
+
return name ? core.connectManifest(basic, name, d.version).appPreset.name : ''
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
module.exports = { runConnect, CONNECT_OPTIONS, sdkAddons, qrPage, failureInput }
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Which model and thinking depth a group uses on one model card (backend).
|
|
3
|
+
// This is the single resolver: runner input, card footer, /help · /model ·
|
|
4
|
+
// /status, the agent manifest, the console and the activity rows all call it.
|
|
5
|
+
//
|
|
6
|
+
// Storage: groups[chatId].providerPrefs = { "<card name>": { model?, effort? } },
|
|
7
|
+
// kept per backend. A group that moves between backends keeps each backend's
|
|
8
|
+
// own choice: a model id from one backend is never passed to another, and
|
|
9
|
+
// switching back does not silently fall back to something else.
|
|
10
|
+
// A missing entry means "not chosen": the executor picks its own default.
|
|
11
|
+
// Legacy groups[chatId].model belongs only to the backend the group was on
|
|
12
|
+
// when it was written (group.provider, else defaultProvider).
|
|
13
|
+
//
|
|
14
|
+
// Only shapes are validated, never an allow list: model ids and depth names
|
|
15
|
+
// evolve upstream, and values travel as argv/env entries without a shell.
|
|
16
|
+
// A bad shape is treated as unset instead of failing the turn.
|
|
17
|
+
|
|
18
|
+
const MODEL_RE = /^[\w.\-[\]:/]{1,80}$/
|
|
19
|
+
const EFFORT_RE = /^[a-z]{2,16}$/
|
|
20
|
+
|
|
21
|
+
const isModel = (s) => typeof s === 'string' && MODEL_RE.test(s)
|
|
22
|
+
const isEffort = (s) => typeof s === 'string' && EFFORT_RE.test(s)
|
|
23
|
+
|
|
24
|
+
// Legacy card-level values. Subscription cards no longer carry them after the
|
|
25
|
+
// one-time migration; a third-party endpoint keeps its model as connection
|
|
26
|
+
// configuration (it has no model list to choose from).
|
|
27
|
+
function defaultModel(provider) {
|
|
28
|
+
const p = provider || {}
|
|
29
|
+
return p.model || (p.runner !== 'codex' && p.env && p.env.ANTHROPIC_MODEL) || ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultEffort(provider) {
|
|
33
|
+
const p = provider || {}
|
|
34
|
+
return (p.runner === 'codex' ? p.reasoningEffort : p.env && p.env.CLAUDE_CODE_EFFORT_LEVEL) || ''
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// One entry → valid fields only; nothing valid ⇒ null.
|
|
38
|
+
function cleanPref(x) {
|
|
39
|
+
if (!x || typeof x !== 'object' || Array.isArray(x)) return null
|
|
40
|
+
const o = {}
|
|
41
|
+
if (isModel(x.model)) o.model = x.model
|
|
42
|
+
if (isEffort(x.effort)) o.effort = x.effort
|
|
43
|
+
return Object.keys(o).length ? o : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Whole table: drop bad shapes, empty entries and (when known is given)
|
|
47
|
+
// entries for cards that do not exist. An empty table becomes undefined.
|
|
48
|
+
function normalizePrefs(prefs, known) {
|
|
49
|
+
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return undefined
|
|
50
|
+
const out = {}
|
|
51
|
+
for (const [name, v] of Object.entries(prefs)) {
|
|
52
|
+
if (!/^[\w.-]+$/.test(name)) continue
|
|
53
|
+
if (known && !Object.prototype.hasOwnProperty.call(known, name)) continue
|
|
54
|
+
const c = cleanPref(v)
|
|
55
|
+
if (c) out[name] = c
|
|
56
|
+
}
|
|
57
|
+
return Object.keys(out).length ? out : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param group groups[chatId] (may be empty)
|
|
62
|
+
* @param name the card actually running this turn
|
|
63
|
+
* @param provider cfg.providers[name]
|
|
64
|
+
* @param opts.defaultProvider top-level defaultProvider, only used to decide
|
|
65
|
+
* which backend a legacy group.model belongs to
|
|
66
|
+
* @returns {{ model, effort, groupModel, groupEffort, defaultModel, defaultEffort }}
|
|
67
|
+
* model / effort are effective values ('' = executor default);
|
|
68
|
+
* groupX are the group's own choices ('' = not chosen).
|
|
69
|
+
*/
|
|
70
|
+
function groupBrain(group, name, provider, { defaultProvider = '' } = {}) {
|
|
71
|
+
const g = group || {}
|
|
72
|
+
const pref = cleanPref(g.providerPrefs && typeof g.providerPrefs === 'object' ? g.providerPrefs[name] : null) || {}
|
|
73
|
+
const home = g.provider || defaultProvider || ''
|
|
74
|
+
const legacy = (!pref.model && isModel(g.model) && (!home || home === name)) ? g.model : ''
|
|
75
|
+
const groupModel = pref.model || legacy
|
|
76
|
+
const groupEffort = pref.effort || ''
|
|
77
|
+
const dm = defaultModel(provider)
|
|
78
|
+
const de = defaultEffort(provider)
|
|
79
|
+
return { model: groupModel || dm, effort: groupEffort || de, groupModel, groupEffort, defaultModel: dm, defaultEffort: de }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Move legacy group.model under the backend it belonged to (mutates the raw
|
|
83
|
+
// group). Every writer that changes group.provider calls this first, so the
|
|
84
|
+
// legacy id never attaches itself to the new backend. Returns whether it changed.
|
|
85
|
+
function migrateLegacyModel(g, defaultProvider) {
|
|
86
|
+
if (!g || typeof g !== 'object' || g.model === undefined) return false
|
|
87
|
+
const home = g.provider || defaultProvider || ''
|
|
88
|
+
if (home && isModel(g.model)) {
|
|
89
|
+
const prefs = (g.providerPrefs && typeof g.providerPrefs === 'object' && !Array.isArray(g.providerPrefs)) ? g.providerPrefs : {}
|
|
90
|
+
const cur = cleanPref(prefs[home]) || {}
|
|
91
|
+
if (!cur.model) prefs[home] = { ...cur, model: g.model }
|
|
92
|
+
g.providerPrefs = prefs
|
|
93
|
+
}
|
|
94
|
+
delete g.model
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Active groups whose current backend lacks a choice (doctor and console use
|
|
99
|
+
// the same rule). A subscription card without a model and any card without a
|
|
100
|
+
// depth are listed. Paused groups are skipped.
|
|
101
|
+
// Returns [{ chatId, name, provider, lack: ['model' | 'effort'] }].
|
|
102
|
+
function missingPicks(cfg) {
|
|
103
|
+
const out = []
|
|
104
|
+
const provs = (cfg && cfg.providers) || {}
|
|
105
|
+
for (const [id, g] of Object.entries((cfg && cfg.groups) || {})) {
|
|
106
|
+
if (!g || typeof g !== 'object' || id === 'oc_example_chat_id' || g.paused === true) continue
|
|
107
|
+
const pn = g.provider || cfg.defaultProvider
|
|
108
|
+
const pv = provs[pn]
|
|
109
|
+
if (!pv) continue
|
|
110
|
+
const b = groupBrain(g, pn, pv, { defaultProvider: cfg.defaultProvider })
|
|
111
|
+
const lack = [!b.model && !pv.baseUrl ? 'model' : '', !b.effort ? 'effort' : ''].filter(Boolean)
|
|
112
|
+
if (lack.length) out.push({ chatId: id, name: g.name || '', provider: pn, lack })
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { groupBrain, normalizePrefs, cleanPref, migrateLegacyModel, missingPicks, defaultModel, defaultEffort, isModel, isEffort, MODEL_RE, EFFORT_RE }
|
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
const CODE = 'C2_V2_INPUT_REJECTED'
|
|
8
8
|
const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
9
9
|
|
|
10
|
-
function rejectC2Input(message, { reason, notice = null } = {}) {
|
|
10
|
+
function rejectC2Input(message, { reason, notice = null, card = null } = {}) {
|
|
11
11
|
if (typeof message !== 'string' || !message ||
|
|
12
12
|
typeof reason !== 'string' || !/^[a-z][a-z-]{0,40}$/.test(reason) ||
|
|
13
|
-
notice !== null && (typeof notice !== 'string' || !notice)
|
|
13
|
+
notice !== null && (typeof notice !== 'string' || !notice) ||
|
|
14
|
+
card !== null && (typeof card !== 'object' || Array.isArray(card))) {
|
|
14
15
|
throw new TypeError('C2 input rejection needs a message, reason and optional notice')
|
|
15
16
|
}
|
|
16
17
|
const error = new Error(message)
|
|
17
18
|
error.code = CODE
|
|
18
19
|
error.reason = reason
|
|
19
20
|
error.notice = notice
|
|
21
|
+
// An optional interactive card (the /model card) sent instead of the text;
|
|
22
|
+
// the text stays the fallback when the card is not confirmed.
|
|
23
|
+
if (card) error.card = card
|
|
20
24
|
return error
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -33,10 +37,19 @@ async function answerC2InputRejection({ error, msg, platform, log = () => {} } =
|
|
|
33
37
|
if (error.notice && typeof platform?.replyOnce === 'function' &&
|
|
34
38
|
typeof msg?.chatId === 'string' && target?.chatId === msg.chatId &&
|
|
35
39
|
FEISHU_MESSAGE_ID.test(target?.messageId || '')) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if (error.card && typeof platform.replyCardOnce === 'function') {
|
|
41
|
+
try {
|
|
42
|
+
const response = await platform.replyCardOnce(target, error.card)
|
|
43
|
+
notified = response?.code === 0
|
|
44
|
+
} catch {}
|
|
45
|
+
if (!notified) log(`input rejection card not confirmed (${error.reason}); sending text`)
|
|
46
|
+
}
|
|
47
|
+
if (!notified) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await platform.replyOnce(target, error.notice)
|
|
50
|
+
notified = response?.code === 0
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
40
53
|
if (!notified) log(`input rejection notice not confirmed (${error.reason})`)
|
|
41
54
|
}
|
|
42
55
|
return Object.freeze({ rejected: true, reason: error.reason, notified })
|