sintbot 1.4.16 → 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 }
@@ -569,22 +569,37 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
569
569
  // scope) is cached as null so we don't hammer the API, and callers fall back.
570
570
  const nameCache = new Map() // openId -> name | null
571
571
  let nameScopeWarned = false
572
- async function senderNameOf(openId) {
572
+ let memberNameWarned = false
573
+ async function senderNameOf(openId, chatId = null) {
573
574
  if (!openId) return null
574
575
  if (nameCache.has(openId)) return nameCache.get(openId)
576
+ let name = null
575
577
  try {
576
578
  const r = await client.contact.user.get({ params: { user_id_type: 'open_id' }, path: { user_id: openId } })
577
- const name = (r && r.data && r.data.user && (r.data.user.name || r.data.user.nickname)) || null
578
- nameCache.set(openId, name)
579
- return name
579
+ name = (r && r.data && r.data.user && (r.data.user.name || r.data.user.nickname)) || null
580
580
  } catch (e) {
581
- nameCache.set(openId, null)
582
581
  if (!nameScopeWarned) {
583
582
  nameScopeWarned = true
584
583
  log('feishu: 拉取用户名失败(需要 contact:contact.base:readonly scope?),发送者标注回退为 open_id — ' + larkErrMsg(e))
585
584
  }
586
- return null
587
585
  }
586
+ // 通讯录查不到(缺 scope、或发送者不在应用的通讯录范围内 —— 基础版多人群常见)时,退到当前群的
587
+ // 成员列表:名字随成员一起返回,顺带把同群其他人也写进同一份缓存。
588
+ if (!name && chatId) {
589
+ try {
590
+ for (const m of await fetchChatMembers(client, chatId)) {
591
+ if (m.name && m.name !== m.openId && !nameCache.get(m.openId)) nameCache.set(m.openId, m.name)
592
+ }
593
+ name = nameCache.get(openId) || null
594
+ } catch (e) {
595
+ if (!memberNameWarned) {
596
+ memberNameWarned = true
597
+ log('feishu: 群成员列表取名失败 — ' + larkErrMsg(e))
598
+ }
599
+ }
600
+ }
601
+ nameCache.set(openId, name)
602
+ return name
588
603
  }
589
604
 
590
605
  async function sendText(chatId, content) {
@@ -737,7 +752,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
737
752
  let senderName = openId || 'user'
738
753
  if (openId) {
739
754
  if (nameCache.has(openId)) senderName = nameCache.get(openId) || openId
740
- else senderNameOf(openId).catch(() => {}) // background warm; does not reorder onMessage
755
+ else senderNameOf(openId, message.chat_id).catch(() => {}) // background warm; does not reorder onMessage
741
756
  }
742
757
  // Fire-and-forget into the engine (NFR1): a turn can run for minutes,
743
758
  // and whether the SDK serializes pending handlers is not contractual —