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