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/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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
- try {
37
- const response = await platform.replyOnce(target, error.notice)
38
- notified = response?.code === 0
39
- } catch {}
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 })