sintbot 1.4.16 → 1.4.18

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,566 @@
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, withFileLock } = require('./core/store')
16
+ const { installationStateDir, MANIFEST } = require('./c2install')
17
+ const { defaultProjectsRoot, tooWideReason, realpathLenient } = require('./projectsroot')
18
+ const { fillPicks } = require('./core/modelcmd')
19
+ const { applyBrainChange } = require('./groupbrain')
20
+
21
+ const CONNECT_OPTIONS = Object.freeze({
22
+ config: { type: 'string' }, 'data-dir': { type: 'string' }, workdir: { type: 'string' },
23
+ name: { type: 'string' }, provider: { type: 'string' }, 'app-id': { type: 'string' },
24
+ chat: { type: 'string' }, basic: { type: 'boolean' }, 'no-open': { type: 'boolean' },
25
+ })
26
+ const APP_ID = /^cli_[A-Za-z0-9]{6,64}$/
27
+ const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
28
+ const PROJECT_NAME = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,59}$/u
29
+ const RUNNERS = Object.freeze(['codex', 'claude'])
30
+ // Files whose presence means the data directory is not fresh; the same list
31
+ // initializeInstallation refuses later, checked here before a scan is spent.
32
+ const STATE_FILES = Object.freeze(['state.json', 'pending-turns.json', 'engine-receipts.json', MANIFEST, 'daemon.lock'])
33
+ // The core keys a step failure on a Feishu code or HTTP status. A local
34
+ // failure (file system, missing chat id) has neither; it is reported as code 1.
35
+ const LOCAL_FAILURE = 1
36
+ const PUBLISH_POLL_MS = 30_000
37
+ const PUBLISH_POLL_STEP_MS = 3_000
38
+ const EXIT_INTERRUPTED = 130
39
+
40
+ function defaultDeps(deps) {
41
+ const feishu = deps.feishu || require('./platform/feishu')
42
+ return {
43
+ registerApp: options => require('@larksuiteoapi/node-sdk').registerApp(options),
44
+ feishuClient: feishu.feishuClient,
45
+ feishu,
46
+ initializeInstallation: (file, options) => require('./c2install').initializeInstallation(file, options),
47
+ spawn: require('node:child_process').spawn,
48
+ spawnSync: require('node:child_process').spawnSync,
49
+ fs: nodeFs,
50
+ now: Date.now,
51
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
52
+ stdout: process.stdout,
53
+ stderr: process.stderr,
54
+ env: process.env,
55
+ home: os.homedir(),
56
+ tmpdir: os.tmpdir(),
57
+ userName: localUserName(),
58
+ isTTY: !!(process.stdin.isTTY && process.stderr.isTTY),
59
+ prompt: null,
60
+ signals: process,
61
+ version: require('../package.json').version,
62
+ ...deps,
63
+ }
64
+ }
65
+
66
+ function localUserName() {
67
+ try { return os.userInfo().username || '' } catch { return '' }
68
+ }
69
+
70
+ function askLine(question, fallback, { stderr }) {
71
+ return new Promise(resolve => {
72
+ const rl = readline.createInterface({ input: process.stdin, output: stderr })
73
+ rl.question(question, answer => { rl.close(); resolve(answer.trim() || fallback) })
74
+ })
75
+ }
76
+
77
+ // Feishu failures arrive as an axios error (body under response.data), as an
78
+ // SDK result whose code is not 0, as the SDK's own token Error ("failed to get
79
+ // tenant_access_token, code: 21001, msg: …") or as a local Error. The core
80
+ // wants { status, code, msg }; no code and no status means the step succeeded.
81
+ function failureInput(error, larkErrMsg) {
82
+ const data = error?.response?.data
83
+ let code = Number.isInteger(data?.code) ? data.code
84
+ : Number.isInteger(error?.code) ? error.code : null
85
+ const status = Number.isInteger(error?.response?.status) ? error.response.status : null
86
+ if (code === null && status === null) {
87
+ const inMessage = /\bcode:\s*(\d+)/.exec(error?.message || '')
88
+ code = inMessage ? Number(inMessage[1]) : LOCAL_FAILURE
89
+ }
90
+ return { status, code, msg: larkErrMsg(error) }
91
+ }
92
+
93
+ function assertOk(result, what) {
94
+ if (result && typeof result === 'object' && Number.isInteger(result.code) && result.code !== 0) {
95
+ const error = new Error(`${what} failed: ${result.msg || result.message || 'code ' + result.code}`)
96
+ error.response = { data: result }
97
+ throw error
98
+ }
99
+ return result
100
+ }
101
+
102
+ // The SDK carries scopes, events and callbacks in nested buckets; the core's
103
+ // flat lists are tenant-identity items.
104
+ function sdkAddons(addons) {
105
+ const out = { preset: addons.preset === true }
106
+ if (addons.scopes.length) out.scopes = { tenant: addons.scopes }
107
+ if (addons.events.length) out.events = { items: { tenant: addons.events } }
108
+ if (addons.callbacks.length) out.callbacks = { items: addons.callbacks }
109
+ return out
110
+ }
111
+
112
+ const escapeHtml = text => String(text).replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch])
113
+ const shellQuote = text => "'" + String(text).replace(/'/g, "'\\''") + "'"
114
+
115
+ // One SVG rect per run of dark modules, quiet zone 4 modules, about 360px.
116
+ function qrPage(url) {
117
+ const matrix = core.qrMatrix(url)
118
+ const quiet = 4
119
+ const total = matrix.size + quiet * 2
120
+ const rects = []
121
+ matrix.rows.forEach((row, y) => {
122
+ let x = 0
123
+ while (x < row.length) {
124
+ if (row[x] !== '1') { x++; continue }
125
+ let run = 1
126
+ while (row[x + run] === '1') run++
127
+ rects.push(`<rect x="${x + quiet}" y="${y + quiet}" width="${run}" height="1"/>`)
128
+ x += run
129
+ }
130
+ })
131
+ return `<!doctype html>
132
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>SintBot · Feishu</title>
133
+ <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}
134
+ svg{width:360px;height:360px;shape-rendering:crispEdges}p{max-width:560px;word-break:break-all;text-align:center;padding:0 16px}</style></head>
135
+ <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>
136
+ <p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p></body></html>
137
+ `
138
+ }
139
+
140
+ function readExistingConfig(fs, file) {
141
+ let text
142
+ try { text = fs.readFileSync(file, 'utf8') } catch (error) {
143
+ if (error.code === 'ENOENT') return null
144
+ throw error
145
+ }
146
+ try { return JSON.parse(text) || {} } catch { return { unreadable: true } }
147
+ }
148
+
149
+ // A configuration may be replaced only to finish an earlier attempt: the same
150
+ // app, and no group bound yet. Anything else is someone's installation.
151
+ function assertReplaceable(existing, configPath, reuseAppId) {
152
+ if (!existing) return
153
+ const retry = `to reuse that app run sintbot connect --app-id <cli_…>, otherwise move ${configPath} aside first`
154
+ if (reuseAppId === undefined) {
155
+ throw new Error(existing.unreadable || existing.feishu?.appId || existing.feishu?.appSecret
156
+ ? `${configPath} already holds a Feishu app; ${retry}`
157
+ : `${configPath} already exists; move it aside before connecting`)
158
+ }
159
+ if (existing.unreadable) throw new Error(`${configPath} is not readable JSON; move it aside before connecting with --app-id`)
160
+ if (existing.feishu?.appId !== reuseAppId) {
161
+ throw new Error(`${configPath} belongs to a different Feishu app (${existing.feishu?.appId || 'none'}); ` +
162
+ `run sintbot connect --app-id ${existing.feishu?.appId || '<cli_…>'} to continue with it, or move the file aside`)
163
+ }
164
+ if (Object.keys(existing.groups || {}).length) {
165
+ throw new Error(`${configPath} already binds ${Object.keys(existing.groups).length} group(s); ` +
166
+ 'connect never replaces a configuration with groups — add groups in the console or by editing the file')
167
+ }
168
+ }
169
+
170
+ function assertFreshState(fs, stateDir) {
171
+ let names = []
172
+ try { names = fs.readdirSync(stateDir) } catch (error) { if (error.code === 'ENOENT') return; throw error }
173
+ const found = names.filter(name => STATE_FILES.includes(name) || /^c2-v2.*\.log$/.test(name))
174
+ if (!found.length) return
175
+ if (found.includes('daemon.lock')) throw new Error(`SintBot is running (${path.join(stateDir, 'daemon.lock')} exists); stop it before connecting`)
176
+ throw new Error(`${found.join(', ')} already exist(s) in ${stateDir}; an existing journal requires review, ` +
177
+ 'and connect never overwrites or migrates existing state')
178
+ }
179
+
180
+ function probeRunners(spawnSync, env) {
181
+ const runners = {}
182
+ for (const runner of RUNNERS) {
183
+ const probe = spawnSync(runner, ['--version'], { encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024, env })
184
+ runners[runner] = !probe.error && probe.status === 0 && new RegExp(runner, 'i').test(probe.stdout || '')
185
+ }
186
+ return runners
187
+ }
188
+
189
+ // Width is judged on the literal path and on what it really points to, so a
190
+ // link to the home directory or the root is refused as well.
191
+ function dirObserved(fs, workdir, explicit, home) {
192
+ let exists = false
193
+ try { exists = fs.statSync(workdir).isDirectory() } catch {}
194
+ const empty = !exists || fs.readdirSync(workdir).length === 0
195
+ const tooWide = tooWideReason(workdir, { home }) ||
196
+ tooWideReason(realpathLenient(workdir), { home: realpathLenient(home) })
197
+ return { workdir, exists, empty, tooWide, explicit }
198
+ }
199
+
200
+ // The same CLI catalog and recommendation used by /model and the console.
201
+ // No saved cache or model prompt: a new group must pin a currently observed
202
+ // recommendation, not inherit an old account's defaults or guess a fallback.
203
+ function connectModelCatalog() {
204
+ const { createModelCatalog } = require('./modelcatalog')
205
+ const codex = require('./agent/codexstatus')
206
+ const claude = require('./agent/claudemodels')
207
+ return createModelCatalog({ cacheFile: null,
208
+ codexstatus: { listModels: bin => codex.listModels(bin, 12000) },
209
+ claudemodels: { ...claude, probeModels: bin => claude.probeModels(bin, { timeoutMs: 12000 }) },
210
+ })
211
+ }
212
+
213
+ function applyInitialGroupDefaults(config, chatId, cat) {
214
+ const current = applyBrainChange(config, chatId)
215
+ if (current.error) throw new Error(`Cannot set the new group's model defaults (${current.error})`)
216
+ const picks = fillPicks({ switching: true, currentModel: current.before.model || '',
217
+ currentEffort: current.before.effort || '', cat })
218
+ // fillPicks may suggest replacing an unsupported old depth on a backend
219
+ // switch. Onboarding only fills missing fields; it never replaces a choice.
220
+ const change = {}
221
+ if (!current.before.model && picks.model) change.model = picks.model
222
+ if (!current.before.effort && picks.effort) change.effort = picks.effort
223
+ const result = applyBrainChange(config, chatId, change)
224
+ if (result.error || !result.after.model || !result.after.effort) throw new Error('The live model catalog did not provide a valid model and thinking depth. Check the executor login and retry.')
225
+ return { provider: result.target, model: result.after.model, effort: result.after.effort,
226
+ name: cat.models?.find(m => m.id === result.after.model)?.name || result.after.model, source: cat.source }
227
+ }
228
+
229
+ async function runConnect(argv, deps = {}) {
230
+ const d = defaultDeps(deps)
231
+ // Ctrl-C (or the setup page's cancel) aborts at the next step boundary for
232
+ // the whole run, not only during the scan; nothing is written after an abort.
233
+ const abort = new AbortController()
234
+ const onSignal = () => abort.abort()
235
+ for (const signal of ['SIGINT', 'SIGTERM']) d.signals.on(signal, onSignal)
236
+ try { return await connectFlow(argv, d, abort) } finally {
237
+ for (const signal of ['SIGINT', 'SIGTERM']) d.signals.off(signal, onSignal)
238
+ }
239
+ }
240
+
241
+ async function connectFlow(argv, d, abort) {
242
+ const { fs, feishu, stderr, stdout } = d
243
+ const { values } = parseArgs({ args: argv, allowPositionals: false, options: CONNECT_OPTIONS })
244
+ const basic = values.basic === true
245
+ const reuseAppId = values['app-id']
246
+ const givenChatId = values.chat
247
+ if (reuseAppId !== undefined && !APP_ID.test(reuseAppId)) throw new Error('--app-id must be a Feishu app id (cli_…)')
248
+ if (givenChatId !== undefined && !CHAT_ID.test(givenChatId)) throw new Error('--chat must be a Feishu group id (oc_…)')
249
+ if (values.provider !== undefined && !RUNNERS.includes(values.provider)) throw new Error('--provider must be codex or claude')
250
+ const say = text => stderr.write(`${text}\n`)
251
+ const interrupted = () => {
252
+ if (!abort.signal.aborted) return false
253
+ say('已取消,没有写入任何文件。')
254
+ return true
255
+ }
256
+ // A wait notice repeats on every poll; it is printed once per change.
257
+ let lastWait = null
258
+ const decide = (step, input) => {
259
+ const plan = core.connectPlan(step, { basic, ...input })
260
+ if (plan.verdict === 'wait') {
261
+ if (!(plan.retryAfterMs > 0)) throw new Error(`The core asked to wait on ${step} without a retry interval`)
262
+ if (plan.notice !== lastWait) say(plan.notice)
263
+ lastWait = plan.notice
264
+ } else {
265
+ lastWait = null
266
+ say(plan.notice)
267
+ }
268
+ return plan
269
+ }
270
+
271
+ // Step 0: never touch an installation that already has credentials, state
272
+ // or a running service; --app-id may only finish an attempt for that app.
273
+ const stateDir = installationStateDir(values['data-dir'] === undefined ? path.join(d.home, '.sbot') : values['data-dir'])
274
+ const configPath = values.config ? path.resolve(values.config)
275
+ : d.env.SBOT_CONFIG ? path.resolve(d.env.SBOT_CONFIG) : path.join(d.home, '.sbot', 'config.json')
276
+ if (!path.isAbsolute(configPath)) throw new Error('The configuration path must be absolute')
277
+ assertReplaceable(readExistingConfig(fs, configPath), configPath, reuseAppId)
278
+ assertFreshState(fs, stateDir)
279
+
280
+ // Project directory per the directory rule: an existing --workdir, or a new
281
+ // folder under ~/Projects named by --name or a prompt.
282
+ let workdir
283
+ const explicit = values.workdir !== undefined
284
+ if (explicit) {
285
+ workdir = values.workdir
286
+ if (!path.isAbsolute(workdir) || workdir !== path.resolve(workdir)) throw new Error('--workdir must be a normalized absolute path')
287
+ let stat = null
288
+ try { stat = fs.statSync(workdir) } catch {}
289
+ if (!stat?.isDirectory()) throw new Error('--workdir must be an existing directory')
290
+ } else {
291
+ let name = values.name
292
+ if (name === undefined) {
293
+ name = d.isTTY ? await (d.prompt || askLine)('项目名(默认 sintbot):', 'sintbot', d) : 'sintbot'
294
+ }
295
+ if (!PROJECT_NAME.test(name) || name === '.' || name === '..') throw new Error('The project name may only use letters, digits, ".", "_" and "-" (max 60)')
296
+ workdir = path.join(defaultProjectsRoot({ home: d.home }), name)
297
+ }
298
+ // The core's directory decision is taken before the scan too, so a refused
299
+ // directory never costs the user a scan; it is taken again before creation.
300
+ const early = core.connectPlan('dir', { basic, observed: dirObserved(fs, workdir, explicit, d.home) })
301
+ if (early.verdict === 'stop') { say(early.notice); return 1 }
302
+
303
+ // Runners present on this Mac; the group's provider must be one of them.
304
+ const runners = probeRunners(d.spawnSync, d.env)
305
+ const available = RUNNERS.filter(runner => runners[runner])
306
+ if (!available.length) throw new Error('Install and sign in to Codex or Claude Code first; neither answered --version')
307
+ let provider = values.provider
308
+ if (provider !== undefined && !runners[provider]) throw new Error(`${provider} did not answer --version; install and sign in to it first`)
309
+ if (provider === undefined) {
310
+ provider = available.length === 1 || !d.isTTY ? available[0]
311
+ : await (d.prompt || askLine)(`执行器(${available.join(' / ')},默认 ${available[0]}):`, available[0], d)
312
+ if (!runners[provider]) throw new Error('--provider must be codex or claude')
313
+ }
314
+ // Everything the configuration needs apart from the scan result is known;
315
+ // let the core validate it now with placeholders for the scanned identities.
316
+ const configInput = { appId: reuseAppId || 'cli_pending', appSecret: 'pending', botOpenId: 'ou_pending', adminOpenId: 'ou_pending',
317
+ chatId: givenChatId || null, workdir, home: d.home, provider, runners }
318
+ const dryRun = core.connectConfig({ ...configInput, chatId: givenChatId || 'oc_onboarding_pending' })
319
+ if (!dryRun.ok) throw new Error(dryRun.error)
320
+ const catalog = d.modelCatalog || connectModelCatalog()
321
+ say('正在读取执行器推荐的型号和思考深度…')
322
+ let selectedCatalog
323
+ try { selectedCatalog = await catalog(provider, { runner: provider }) } catch {}
324
+ if (interrupted()) return EXIT_INTERRUPTED
325
+ if (!selectedCatalog || selectedCatalog.ok !== true || selectedCatalog.source !== 'live' ||
326
+ !Array.isArray(selectedCatalog.models) || !selectedCatalog.models.length) {
327
+ throw new Error(`Could not read live model defaults for ${provider}. Check the executor login and network, then retry. No new Feishu app was created.`)
328
+ }
329
+ const modelDefaults = applyInitialGroupDefaults(dryRun.config, givenChatId || 'oc_onboarding_pending', selectedCatalog)
330
+ d.onModelDefaults?.(modelDefaults)
331
+ say(`新群默认:${modelDefaults.name} · ${modelDefaults.effort}。接入后可在控制台修改。`)
332
+ if (interrupted()) return EXIT_INTERRUPTED
333
+
334
+ // Step 1: the scan. The link is printed, and a local page renders the QR
335
+ // code from the core's matrix; the page is removed however this ends.
336
+ // The app is named before anyone has scanned, so the only name available
337
+ // is the local account's; the scanner's Feishu display name (the SDK
338
+ // returns none, contacts are read later) names the group instead.
339
+ const manifest = core.connectManifest(basic, d.userName, d.version)
340
+ say(manifest.hint)
341
+ let qrDir = null
342
+ const cleanupQr = () => {
343
+ if (!qrDir) return
344
+ try { fs.rmSync(qrDir, { recursive: true, force: true }) } catch {}
345
+ qrDir = null
346
+ }
347
+ process.once('exit', cleanupQr)
348
+ let lastStatus = null
349
+ let registration
350
+ try {
351
+ registration = await d.registerApp({
352
+ ...(reuseAppId === undefined ? { createOnly: true } : { appId: reuseAppId }),
353
+ appPreset: manifest.appPreset, addons: sdkAddons(manifest.addons), source: manifest.source, signal: abort.signal,
354
+ onQRCodeReady: ({ url, expireIn }) => {
355
+ stdout.write(`${url}\n`)
356
+ say(`二维码 ${Math.floor(Number(expireIn) || 0)} 秒内有效。`)
357
+ try {
358
+ qrDir = fs.mkdtempSync(path.join(d.tmpdir, 'sintbot-connect-'))
359
+ const page = path.join(qrDir, 'feishu-qr.html')
360
+ fs.writeFileSync(page, qrPage(url), { mode: 0o600 })
361
+ if (values['no-open'] !== true) {
362
+ const child = d.spawn('open', [page], { stdio: 'ignore', detached: true })
363
+ child.on?.('error', () => {})
364
+ child.unref?.()
365
+ }
366
+ } catch (error) { say(`二维码页面未能打开(${error?.message || error}),请直接打开上面的链接。`) }
367
+ },
368
+ onStatusChange: info => {
369
+ const status = info?.status || ''
370
+ if (status === lastStatus) return
371
+ lastStatus = status
372
+ if (status === 'polling') say('等待扫码…')
373
+ else if (status === 'slow_down') say('等待扫码…(已放慢轮询)')
374
+ else if (status === 'domain_switched') say('已切换到 Lark 国际版域名。')
375
+ },
376
+ })
377
+ } catch (error) {
378
+ if (abort.signal.aborted) return EXIT_INTERRUPTED
379
+ const reason = error?.description ? `${error.code}: ${error.description}` : (error?.message || String(error))
380
+ throw new Error(`Feishu scan did not complete (${reason})`)
381
+ } finally {
382
+ cleanupQr()
383
+ process.off('exit', cleanupQr)
384
+ }
385
+ const appId = registration?.client_id || ''
386
+ const appSecret = registration?.client_secret || ''
387
+ const adminOpenId = registration?.user_info?.open_id || ''
388
+ if (!appId || !appSecret) throw new Error('Feishu scan returned no app credentials')
389
+ // The id (never the secret) lets every later stop be resumed with --app-id.
390
+ say(`应用 ID:${appId}`)
391
+ const client = d.feishuClient(appId, appSecret)
392
+ const { larkErrMsg } = feishu
393
+
394
+ // Step 2a: credentials and bot identity.
395
+ let identity = null
396
+ let plan
397
+ try {
398
+ identity = await feishu.fetchAppIdentity(client, appId)
399
+ // An SDK body with a non-zero code does not throw; read it so a code such
400
+ // as 21001 reaches the core instead of an empty identity.
401
+ if (!identity.openId) assertOk(await client.request({ method: 'GET', url: '/open-apis/bot/v3/info' }), 'bot identity')
402
+ plan = decide('token', {})
403
+ } catch (error) { plan = decide('token', failureInput(error, larkErrMsg)) }
404
+ if (plan.verdict === 'stop') return 1
405
+ if (interrupted()) return EXIT_INTERRUPTED
406
+
407
+ // Step 2b: app enabled, waiting for an organization review when there is one.
408
+ let waitedMs = 0
409
+ for (;;) {
410
+ const enabled = identity?.activateOk === true
411
+ plan = decide('app', { observed: { enabled, pendingReview: enabled ? false : await pendingReview(client, appId), waitedMs } })
412
+ if (plan.verdict !== 'wait') break
413
+ await d.sleep(plan.retryAfterMs)
414
+ if (interrupted()) return EXIT_INTERRUPTED
415
+ waitedMs += plan.retryAfterMs
416
+ try { identity = await feishu.fetchAppIdentity(client, appId) } catch { identity = null }
417
+ }
418
+ if (plan.verdict === 'stop') return 1
419
+
420
+ // Step 2c: the existing readiness checklist.
421
+ const precheck = await feishu.feishuPrecheck(client, { appId })
422
+ if (decide('precheck', { observed: { checks: precheck.checks } }).verdict === 'stop') return 1
423
+ const botOpenId = precheck.botOpenId || identity?.openId || ''
424
+ if (interrupted()) return EXIT_INTERRUPTED
425
+
426
+ // Step 3: contact range, visibility and a published version. Failures only
427
+ // warn; --basic skips both.
428
+ if (basic) {
429
+ decide('scope', {})
430
+ } else {
431
+ try {
432
+ assertOk(await client.request({ method: 'PATCH', url: `/open-apis/application/v7/applications/${encodeURIComponent(appId)}/config`,
433
+ data: { contacts: { contacts_range_type: 'all' }, visibility: { is_visible_to_all: true } } }), 'scope')
434
+ decide('scope', {})
435
+ } catch (error) { decide('scope', failureInput(error, larkErrMsg)) }
436
+ try {
437
+ assertOk(await client.request({ method: 'POST', url: `/open-apis/application/v7/applications/${encodeURIComponent(appId)}/publish`,
438
+ data: { remark: 'SintBot 接入', changelog: `SintBot ${d.version} 接入:机器人、消息事件与卡片回调。`,
439
+ mobile_default_ability: 'bot', pc_default_ability: 'bot' } }), 'publish')
440
+ decide('publish', {})
441
+ await waitPublished(client, appId, d)
442
+ } catch (error) { decide('publish', failureInput(error, larkErrMsg)) }
443
+ }
444
+
445
+ if (interrupted()) return EXIT_INTERRUPTED
446
+ // The real configuration input, validated before anything is created.
447
+ Object.assign(configInput, { appId, appSecret, botOpenId, adminOpenId })
448
+ let built = core.connectConfig(configInput)
449
+ if (!built.ok) { decide('config', { code: LOCAL_FAILURE, msg: built.error, observed: { configPath } }); return 1 }
450
+
451
+ // Step 4: the project directory is judged now and created with the
452
+ // configuration below, so a stopped group step leaves no folder behind.
453
+ const observed = dirObserved(fs, workdir, explicit, d.home)
454
+ if (decide('dir', { observed }).verdict === 'stop') return 1
455
+
456
+ // Step 5: the group. A group given with --chat is bound as it is; otherwise
457
+ // one is created with the scanner as its first member. Failure stops here,
458
+ // before any file exists; the core's notice carries the resume command.
459
+ let chatId = givenChatId || null
460
+ const chatName = (await scannerName(client, adminOpenId, registration.user_info?.name, basic, d)) || manifest.appPreset.name
461
+ if (!chatId) {
462
+ try {
463
+ const chat = await feishu.createChat(client, chatName, '', adminOpenId ? [adminOpenId] : [])
464
+ if (!chat.chatId) throw new Error('create chat returned no chat_id')
465
+ chatId = chat.chatId
466
+ plan = decide('chat', { observed: { appId } }) // chatId in observed means a group the user supplied
467
+ } catch (error) { plan = decide('chat', { ...failureInput(error, larkErrMsg), observed: { appId, chatId: null } }) }
468
+ } else {
469
+ plan = decide('chat', { observed: { appId, chatId } })
470
+ }
471
+ if (plan.verdict === 'stop') return 1
472
+ d.onGroupReady?.({ appId, chatId })
473
+ if (interrupted()) return EXIT_INTERRUPTED // the group exists; the config does not, so --app-id/--chat resumes it
474
+ try {
475
+ await feishu.sendChatText(client, chatId,
476
+ `你好,我是 ${chatName}。这个群绑定的项目目录是 ${workdir};有任务直接 @ 我或在群里说。`)
477
+ } catch {}
478
+
479
+ if (interrupted()) return EXIT_INTERRUPTED
480
+ // Step 6: the folder, config.json (0600 in a 0700 directory) and the README.
481
+ built = core.connectConfig({ ...configInput, chatId })
482
+ if (!built.ok) { decide('config', { code: LOCAL_FAILURE, msg: built.error, observed: { configPath } }); return 1 }
483
+ applyInitialGroupDefaults(built.config, chatId, selectedCatalog)
484
+ let createdDir = false, createdReadme = false, configCommitted = false
485
+ const readme = path.join(workdir, 'README.md')
486
+ try {
487
+ // A scan can wait for minutes. Recheck immediately before committing, under
488
+ // the same short-lived config lock as other writers; never replace a new
489
+ // installation that appeared while this page was waiting.
490
+ fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 })
491
+ withFileLock(configPath, () => {
492
+ assertReplaceable(readExistingConfig(fs, configPath), configPath, reuseAppId)
493
+ assertFreshState(fs, stateDir)
494
+ if (!observed.exists) {
495
+ fs.mkdirSync(path.dirname(workdir), { recursive: true, mode: 0o755 })
496
+ fs.mkdirSync(workdir, { mode: 0o755 })
497
+ createdDir = true
498
+ }
499
+ // Finish fallible project preparation before the config commit. A README
500
+ // failure must not leave a bound config that the retry correctly refuses.
501
+ if (!fs.existsSync(readme)) {
502
+ fs.writeFileSync(readme, built.readme, { mode: 0o644, flag: 'wx' })
503
+ createdReadme = true
504
+ }
505
+ if (fs.existsSync(configPath)) fs.chmodSync(configPath, 0o600)
506
+ atomicWriteJSON(configPath, built.config, { mode: 0o600 })
507
+ configCommitted = true
508
+ })
509
+ plan = decide('config', { observed: { configPath } })
510
+ } catch (error) {
511
+ if (createdReadme && !configCommitted) { try { fs.unlinkSync(readme) } catch {} }
512
+ if (createdDir) { try { if (!fs.readdirSync(workdir).length) fs.rmdirSync(workdir) } catch {} }
513
+ plan = decide('config', { code: LOCAL_FAILURE, msg: error?.message || String(error), observed: { configPath } })
514
+ }
515
+ if (plan.verdict === 'stop') return 1
516
+
517
+ // Step 7: journals, under the unchanged fresh-state rules.
518
+ try {
519
+ d.initializeInstallation(configPath, { dataDir: stateDir })
520
+ plan = core.connectPlan('init', { basic })
521
+ } catch (error) { plan = decide('init', { code: LOCAL_FAILURE, msg: error?.message || String(error) }) }
522
+ if (plan.verdict === 'stop') return 1
523
+ const installed = path.join(d.home, '.local', 'share', 'sintbot', 'bin', 'sintbot')
524
+ const bin = fs.existsSync(installed) ? '"$HOME/.local/share/sintbot/bin/sintbot"' : 'sintbot'
525
+ stdout.write(`${plan.notice}\n${bin} start --config ${shellQuote(configPath)} --data-dir ${shellQuote(stateDir)} --ui\n`)
526
+ return 0
527
+ }
528
+
529
+ const appVersions = (client, appId) => client.request({ method: 'GET',
530
+ url: `/open-apis/application/v6/applications/${encodeURIComponent(appId)}/app_versions`,
531
+ params: { lang: 'zh_cn', page_size: 10, user_id_type: 'open_id' } })
532
+ const versionItems = r => r?.data?.items || r?.items || []
533
+
534
+ // A version waiting for the organization's review (app_versions status 1).
535
+ // Unreadable (missing scope, no version yet) means no review is pending.
536
+ async function pendingReview(client, appId) {
537
+ try { return versionItems(await appVersions(client, appId)).some(item => item?.status === 1) } catch { return false }
538
+ }
539
+
540
+ // After publish, give the app up to 30 s to report a published version. This
541
+ // only paces the next steps; the core has already judged the publish call.
542
+ async function waitPublished(client, appId, d) {
543
+ const until = d.now() + PUBLISH_POLL_MS
544
+ do {
545
+ try { if (versionItems(await appVersions(client, appId)).some(item => item?.status === 2)) return true } catch { return false }
546
+ await d.sleep(PUBLISH_POLL_STEP_MS)
547
+ } while (d.now() < until)
548
+ return false
549
+ }
550
+
551
+ // The scanner's display name, through the same core manifest that names the
552
+ // app, so the group is "<name>的 SintBot". A name in the SDK result (the
553
+ // current SDK sends only open_id) wins over the contact lookup; empty when
554
+ // neither is available.
555
+ async function scannerName(client, openId, sdkName, basic, d) {
556
+ let name = typeof sdkName === 'string' ? sdkName.trim() : ''
557
+ if (!name && openId) {
558
+ try {
559
+ const r = await client.contact.user.get({ params: { user_id_type: 'open_id' }, path: { user_id: openId } })
560
+ name = r?.data?.user?.name || r?.data?.user?.nickname || ''
561
+ } catch { name = '' }
562
+ }
563
+ return name ? core.connectManifest(basic, name, d.version).appPreset.name : ''
564
+ }
565
+
566
+ module.exports = { runConnect, CONNECT_OPTIONS, sdkAddons, qrPage, failureInput, probeRunners, EXIT_INTERRUPTED, connectModelCatalog, applyInitialGroupDefaults }
@@ -70,4 +70,50 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
70
70
  }
71
71
  }
72
72
 
73
- module.exports = { prepareC2RunnerInput }
73
+ // Idle compaction (ABI 37): the same process-local arguments as a turn on this
74
+ // group, minus the prompt, durable hooks and follow-up channel. Tool set, MCP,
75
+ // system appendix, model, depth and env must match the turn exactly or the
76
+ // session's prompt cache is rewritten.
77
+ function prepareC2CompactionInput({ appendSystemPrompt, chatId, group, provider, config, brain = null,
78
+ sessionId, signal, extraEnv, keep, onEvent = () => {}, log = () => {} } = {}) {
79
+ if (typeof appendSystemPrompt !== 'string' || !appendSystemPrompt ||
80
+ typeof chatId !== 'string' || !chatId ||
81
+ !group || typeof group.workdir !== 'string' || !group.workdir ||
82
+ !provider || provider.runner !== 'claude' ||
83
+ !config || !config.agent || typeof config.agent !== 'object' ||
84
+ (brain !== null && (typeof brain !== 'object' || typeof brain.model !== 'string' ||
85
+ typeof brain.effort !== 'string')) ||
86
+ typeof sessionId !== 'string' || !sessionId ||
87
+ !signal || typeof signal.aborted !== 'boolean' ||
88
+ !extraEnv || typeof extraEnv !== 'object' || Array.isArray(extraEnv) ||
89
+ extraEnv.SBOT_CHAT_ID !== chatId ||
90
+ Object.values(extraEnv).some(value => typeof value !== 'string') ||
91
+ typeof keep !== 'string' || !keep ||
92
+ typeof onEvent !== 'function' || typeof log !== 'function') {
93
+ throw new TypeError('C2 compaction input requires an exact process-local session')
94
+ }
95
+ const rawCompact = Number(provider.autocompactTokens) || 0
96
+ const autocompactTokens = rawCompact > 0
97
+ ? Math.min(1_000_000, Math.max(100_000, Math.floor(rawCompact))) : 0
98
+ return {
99
+ bin: config.agent.bin,
100
+ appendSystemPrompt, provider,
101
+ steer: false,
102
+ model: brain ? brain.model || undefined : group.model,
103
+ effort: brain ? brain.effort : '',
104
+ workdir: group.workdir,
105
+ sessionId,
106
+ permissionMode: group.permissionMode || 'dontAsk',
107
+ autocompactTokens,
108
+ allowedTools: group.allowedTools,
109
+ disallowedTools: group.disallowedTools,
110
+ plugins: group.plugins,
111
+ signal,
112
+ extraEnv: { ...extraEnv },
113
+ keep,
114
+ log,
115
+ onEvent,
116
+ }
117
+ }
118
+
119
+ module.exports = { prepareC2RunnerInput, prepareC2CompactionInput }