@zmainer/dsh-wx-bridge 1.0.8

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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/cordis.patch.yml +3 -0
  4. package/lib/client.js +440 -0
  5. package/lib/index.js +650 -0
  6. package/lib/kernel/acp-overlay-chat.yml +26 -0
  7. package/lib/kernel/acp-overlay.yml +22 -0
  8. package/lib/kernel/acp-preset-shim.mjs +162 -0
  9. package/lib/kernel/acp.mjs +232 -0
  10. package/lib/kernel/bridge.mjs +1341 -0
  11. package/lib/kernel/keeper.mjs +302 -0
  12. package/lib/kernel/second-brain-contract.txt +7 -0
  13. package/lib/pairing.js +225 -0
  14. package/lib/vendor/qr-svg.cjs +58 -0
  15. package/lib/vendor/qrcode-core/LICENSE +10 -0
  16. package/lib/vendor/qrcode-core/NOTICE.md +21 -0
  17. package/lib/vendor/qrcode-core/alignment-pattern.js +83 -0
  18. package/lib/vendor/qrcode-core/alphanumeric-data.js +59 -0
  19. package/lib/vendor/qrcode-core/bit-buffer.js +37 -0
  20. package/lib/vendor/qrcode-core/bit-matrix.js +65 -0
  21. package/lib/vendor/qrcode-core/byte-data.js +30 -0
  22. package/lib/vendor/qrcode-core/dijkstrajs.LICENSE +19 -0
  23. package/lib/vendor/qrcode-core/dijkstrajs.js +165 -0
  24. package/lib/vendor/qrcode-core/error-correction-code.js +135 -0
  25. package/lib/vendor/qrcode-core/error-correction-level.js +50 -0
  26. package/lib/vendor/qrcode-core/finder-pattern.js +22 -0
  27. package/lib/vendor/qrcode-core/format-info.js +29 -0
  28. package/lib/vendor/qrcode-core/galois-field.js +69 -0
  29. package/lib/vendor/qrcode-core/kanji-data.js +54 -0
  30. package/lib/vendor/qrcode-core/mask-pattern.js +234 -0
  31. package/lib/vendor/qrcode-core/mode.js +167 -0
  32. package/lib/vendor/qrcode-core/numeric-data.js +43 -0
  33. package/lib/vendor/qrcode-core/package.json +8 -0
  34. package/lib/vendor/qrcode-core/polynomial.js +62 -0
  35. package/lib/vendor/qrcode-core/qrcode.js +495 -0
  36. package/lib/vendor/qrcode-core/reed-solomon-encoder.js +56 -0
  37. package/lib/vendor/qrcode-core/regex.js +31 -0
  38. package/lib/vendor/qrcode-core/segments.js +330 -0
  39. package/lib/vendor/qrcode-core/utils.js +63 -0
  40. package/lib/vendor/qrcode-core/version-check.js +9 -0
  41. package/lib/vendor/qrcode-core/version.js +163 -0
  42. package/package.json +40 -0
  43. package/scripts/build-client.mjs +35 -0
  44. package/src/client.js +427 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * WeChat ⇄ DSH bridge keeper (零依赖).
3
+ *
4
+ * 职责:每 KEEPER_INTERVAL_MS 自检一次,不健康则以「脱离父进程树」的方式重新拉起桥。
5
+ * 健康判据:bridge.lock 的 pid 存活 + heartbeat.json 新鲜 + polls 在推进(按 boot 代次)。
6
+ *
7
+ * 全部路径与阈值走环境变量,无任何硬编码绝对路径:
8
+ * BRIDGE_DATA 数据目录(必填;未给则回退 WXBRIDGE_DATA,再回退 $DSH_HOME/wxbridge)
9
+ * BRIDGE_CWD 桥与任务的默认工作目录(默认 process.cwd())
10
+ * BRIDGE_NODE node 可执行文件(默认 process.execPath)
11
+ * KEEPER_INTERVAL_MS / KEEPER_STALE_MS 自检间隔 / 心跳判新阈值(默认 5 分钟)
12
+ *
13
+ * 也可作为模块被宿主进程 import:导出 tick()/status()/startBridge()/stopBridge()。
14
+ */
15
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, openSync, closeSync } from 'node:fs'
16
+ import { spawn, execFileSync } from 'node:child_process'
17
+ import { createHash } from 'node:crypto'
18
+ import { join, dirname } from 'node:path'
19
+ import { homedir } from 'node:os'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const NL = String.fromCharCode(10)
23
+ const HERE = dirname(fileURLToPath(import.meta.url))
24
+
25
+ /**
26
+ * 轮询是否在推进(纯函数,可单测)。
27
+ * 关键:进度下限必须按「桥的代次 boot」隔离——桥重启后 polls 归零,
28
+ * 若沿用上一代的下限,会被误判为「没进展」,导致每轮自检都杀掉重启(死循环)。
29
+ */
30
+ export function progressVerdict(hb, floor, staleMs, now = Date.now()) {
31
+ if (!hb) return true
32
+ const sameBoot = floor && floor.polls >= 0 && Number(floor.boot) === Number(hb.boot)
33
+ if (!sameBoot) return true
34
+ if (hb.polls > floor.polls) return true
35
+ return !(now - (floor.seenAt || 0) > 2 * staleMs)
36
+ }
37
+
38
+ export function readConfigFile() {
39
+ const f = process.env.WXBRIDGE_CONFIG || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge', 'config.json')
40
+ try { return JSON.parse(readFileSync(f, 'utf8')) || {} } catch (e) {
41
+ if (e && e.code !== 'ENOENT') process.stderr.write('[keeper] config unreadable (' + String(e.message) + '): ' + f + NL)
42
+ return {}
43
+ }
44
+ }
45
+
46
+ export function resolveDataDir(explicit) {
47
+ const d = explicit || readConfigFile().dataDir || process.env.BRIDGE_DATA || process.env.WXBRIDGE_DATA
48
+ || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge')
49
+ return d
50
+ }
51
+
52
+ export function createKeeper(opts = {}) {
53
+ const file = readConfigFile()
54
+ const DATA = resolveDataDir(opts.dataDir)
55
+ const CWD = opts.cwd || file.cwd || process.env.BRIDGE_CWD || process.env.BRIDGE_ROOT || process.cwd()
56
+ const NODE = opts.node || process.env.BRIDGE_NODE || process.execPath
57
+
58
+
59
+ const INTERVAL_MS = Number(opts.intervalMs || file.intervalMs || process.env.KEEPER_INTERVAL_MS || 5 * 60 * 1000)
60
+ const STALE_MS = Number(opts.staleMs || file.staleMs || process.env.KEEPER_STALE_MS || 5 * 60 * 1000)
61
+ const BRIDGE = opts.bridgePath || join(HERE, 'bridge.mjs')
62
+ const BRIDGE_ARGS = Array.isArray(opts.bridgeArgs) ? opts.bridgeArgs : []
63
+ const VAULT = opts.vault || file.vault || process.env.BRAIN_VAULT || ''
64
+ // 宿主 home:给了它,桥的子进程就把会话写进宿主的会话仓库(手机对话在桌面工作区可见)
65
+ const HOST_HOME = opts.hostHome || file.hostHome || process.env.BRIDGE_HOST_HOME || ''
66
+ // 与应用同版本的运行时路径(让会话写成应用可读的 schema)
67
+ const DSH_BIN = opts.dshBin || file.dshBin || process.env.BRIDGE_DSH_BIN || ''
68
+ const DETACHED = opts.detached !== false
69
+ /**
70
+ * monitor = 只监测与报告,绝不 spawn / 杀进程(宿主半的默认);
71
+ * heal = 允许自愈(独立 keeper 的职责:脱树重启)。
72
+ * 原因:宿主内嵌的 keeper 若参与自愈,它拉起的桥就挂在宿主进程树里,
73
+ * 宿主被整树终止时桥会被连带杀掉(E-2026-09-21-06)。
74
+ */
75
+ const SUPERVISE = opts.superviseMode === 'heal' ? 'heal' : 'monitor'
76
+
77
+ const LOCK = join(DATA, 'bridge.lock')
78
+ const KEEPER_LOCK = join(DATA, 'keeper.lock')
79
+ const HB = join(DATA, 'heartbeat.json')
80
+ const POLL_STATE = join(DATA, 'keeper-polls.json')
81
+ const LOG = join(DATA, 'keeper.log')
82
+
83
+ mkdirSync(DATA, { recursive: true })
84
+
85
+ const log = (m) => {
86
+ try { appendFileSync(LOG, new Date().toISOString() + ' ' + m + NL) } catch {}
87
+ }
88
+ const childEnv = () => ({
89
+ ...process.env,
90
+ BRIDGE_DATA: DATA,
91
+ BRIDGE_CWD: CWD,
92
+ BRIDGE_ROOT: DATA,
93
+ ...(HOST_HOME ? { BRIDGE_HOST_HOME: HOST_HOME } : {}),
94
+ ...(DSH_BIN ? { BRIDGE_DSH_BIN: DSH_BIN } : {}),
95
+ })
96
+
97
+ const alive = (pid) => { try { process.kill(pid, 0); return true } catch { return false } }
98
+
99
+ function bridgePid() {
100
+ try {
101
+ if (!existsSync(LOCK)) return 0
102
+ const j = JSON.parse(readFileSync(LOCK, 'utf8'))
103
+ return Number(j?.pid) || 0
104
+ } catch { return 0 }
105
+ }
106
+
107
+ function readHeartbeat() {
108
+ try {
109
+ if (!existsSync(HB)) return null
110
+ const j = JSON.parse(readFileSync(HB, 'utf8'))
111
+ const at = Date.parse(j.at)
112
+ const ok = Number.isFinite(at) ? at : 0
113
+ return { at: ok, boot: Number(j.boot) || 0, polls: Number(j.polls) || 0, phase: j.phase, tasks: Number(j.tasks) || 0, age: Date.now() - ok }
114
+ } catch { return null }
115
+ }
116
+
117
+ const loadPollFloor = () => { try { return JSON.parse(readFileSync(POLL_STATE, 'utf8')) } catch { return { boot: 0, polls: -1, seenAt: 0 } } }
118
+ const savePollFloor = (v) => { try { writeFileSync(POLL_STATE, JSON.stringify(v)) } catch {} }
119
+
120
+ /**
121
+ * 拉起桥。detached=false 时桥会成为调用者(宿主)的后代 —— 宿主被整树终止就会连带杀掉桥,
122
+ * 所以「宿主半的常驻守护」必须用 detached=true,只有「一次性前台探测」才用 false。
123
+ */
124
+ function spawnBridge(detached = true) {
125
+ const extra = [...BRIDGE_ARGS,
126
+ ...(VAULT ? ['--vault', VAULT] : []),
127
+ ...(HOST_HOME ? ['--host-home', HOST_HOME] : []),
128
+ ...(DSH_BIN ? ['--dsh-bin', DSH_BIN] : [])]
129
+ // 桥的输出必须留档:原来 stdio:'ignore' → 起不来时**一个字都看不到**,
130
+ // 面板只能显示一个刚打印的 pid(2026-09-21 其他用户"启动不了"的排障黑洞)。
131
+ let fd = 'ignore'
132
+ const logFile = join(DATA, 'bridge-standalone.log')
133
+ // 每次启动写一条分隔线:时间 + kernel 版本 + 关键参数。
134
+ // 没有它,追加式日志会把"上一次的崩溃"混进"这一次的输出",用户会误读(2026-09-21 实况)。
135
+ try {
136
+ mkdirSync(DATA, { recursive: true })
137
+ let ver = '?'
138
+ try { ver = JSON.parse(readFileSync(join(HERE, '..', '..', 'package.json'), 'utf8')).version || '?' } catch {}
139
+ const sep = '===== bridge start ' + new Date().toISOString() + ' pid_parent=' + process.pid +
140
+ ' ver=' + ver + ' data=' + DATA + ' cwd=' + CWD + ' detached=' + detached + ' ====='
141
+ appendFileSync(logFile, '\n' + sep + '\n')
142
+ fd = openSync(logFile, 'a')
143
+ } catch (e) {
144
+ // 出声:这里失败会让"桥为什么起不来"重新变成黑洞,绝不能静默(与 L3 E-2026-09-21-16/20 同族)
145
+ console.error('[keeper] 写 bridge-standalone.log 失败:' + String(e && e.message ? e.message : e))
146
+ }
147
+ const child = spawn(NODE, [BRIDGE, ...extra], { cwd: CWD, env: childEnv(), detached, stdio: ['ignore', fd, fd], windowsHide: true })
148
+ if (typeof fd === 'number') { try { closeSync(fd) } catch {} }
149
+ if (detached) child.unref()
150
+ return child.pid
151
+ }
152
+
153
+ function killTree(pid) {
154
+ try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) } catch {}
155
+ }
156
+
157
+ function cleanOrphans(deadPid) {
158
+ let killed = 0
159
+ try {
160
+ const ps = 'Get-CimInstance Win32_Process -Filter ' + JSON.stringify("name='node.exe'")
161
+ + ' | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress'
162
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-Command', ps], { encoding: 'utf8', windowsHide: true, timeout: 20000 })
163
+ const parsed = JSON.parse(out || '[]')
164
+ const list = Array.isArray(parsed) ? parsed : [parsed]
165
+ for (const it of list) {
166
+ const cmd = String(it.CommandLine || '')
167
+ if (!cmd.includes('dsh/lib/bin.js')) continue
168
+ if (!cmd.toUpperCase().includes(DATA.toUpperCase())) continue
169
+ if (deadPid && Number(it.ProcessId) === Number(deadPid)) continue
170
+ try { execFileSync('taskkill', ['/PID', String(it.ProcessId), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); killed++ } catch {}
171
+ }
172
+ } catch {}
173
+ return killed
174
+ }
175
+
176
+ function status() {
177
+ const pid = bridgePid()
178
+ const hb = readHeartbeat()
179
+ const pidOk = pid > 0 && alive(pid)
180
+ const fresh = !!hb && hb.age < STALE_MS
181
+ return { pid, pidOk, fresh, heartbeat: hb, staleMs: STALE_MS, intervalMs: INTERVAL_MS, dataDir: DATA, cwd: CWD }
182
+ }
183
+
184
+ /**
185
+ * 人工停止意图(粘性):写进 state.json 的 manualStop 字段。
186
+ * 没有它,「停止」按钮形同虚设——下一次自检(≤5 分钟)就会把桥拉回来。
187
+ * 只有显式 start 才清除。
188
+ */
189
+ const readManualStop = () => {
190
+ try {
191
+ const s = JSON.parse(readFileSync(join(DATA, 'state.json'), 'utf8'))
192
+ const m = s && s.manualStop
193
+ return m && m.at ? m : null
194
+ } catch { return null }
195
+ }
196
+ const writeManualStop = (v) => {
197
+ try {
198
+ let s = {}
199
+ try { s = JSON.parse(readFileSync(join(DATA, 'state.json'), 'utf8')) || {} } catch { s = {} }
200
+ if (v) s.manualStop = v; else delete s.manualStop
201
+ // 必须重算 checksum:桥的 loadState() 校验不过会把整份 state 当成篡改隔离(丢会话记忆)
202
+ const c = { ...s }
203
+ delete c.checksum
204
+ s.checksum = createHash('sha256').update(JSON.stringify(c)).digest('hex')
205
+ writeFileSync(join(DATA, 'state.json'), JSON.stringify(s, null, 1))
206
+ } catch (e) { log('manualStop write failed: ' + String(e?.message ?? e)) }
207
+ }
208
+
209
+ function stopBridge(reason) {
210
+ const pid = bridgePid()
211
+ let stopped = false
212
+ if (pid > 0) { killTree(pid); if (alive(pid)) { try { process.kill(pid) } catch {} } stopped = true }
213
+ try { cleanOrphans(pid) } catch {}
214
+ try { writeFileSync(LOCK, '{}') } catch {}
215
+ try { writeFileSync(HB, JSON.stringify({ at: new Date().toISOString(), phase: 'stopped', polls: 0, tasks: 0, pid: 0, boot: 0 })) } catch {}
216
+ writeManualStop({ at: new Date().toISOString(), by: reason || 'user' })
217
+ log('stopped by request pid=' + pid + (reason ? ' by=' + reason : ''))
218
+ return stopped
219
+ }
220
+
221
+ /** 显式启动:清除人工停止意图,再拉起。 */
222
+ function startBridgeNow(reason, detached = true) {
223
+ writeManualStop(null)
224
+ const pid = spawnBridge(detached)
225
+ log('started by request pid=' + pid + (reason ? ' by=' + reason : ''))
226
+ return pid
227
+ }
228
+
229
+ function tick() {
230
+ const pid = bridgePid()
231
+ const hb = readHeartbeat()
232
+ const pidOk = pid > 0 && alive(pid)
233
+ const fresh = !!hb && hb.age < STALE_MS
234
+
235
+ // 人工停止意图优先:不自愈,直到用户显式 start
236
+ const manual = readManualStop()
237
+ if (manual && !pidOk) {
238
+ return { restarted: false, healthy: false, manualStop: manual }
239
+ }
240
+
241
+ let progressing = true
242
+ if (hb) {
243
+ const floor = loadPollFloor()
244
+ progressing = progressVerdict(hb, floor, STALE_MS)
245
+ if (progressing) {
246
+ if (floor.polls < 0 || Number(floor.boot) !== Number(hb.boot) || hb.polls > floor.polls) {
247
+ savePollFloor({ boot: hb.boot, polls: hb.polls, seenAt: Date.now() })
248
+ }
249
+ }
250
+ }
251
+
252
+ if (pidOk && fresh && progressing) {
253
+ return { restarted: false, healthy: true }
254
+ }
255
+
256
+ log('UNHEALTHY pid=' + pid + ' pidOk=' + pidOk + ' fresh=' + fresh + ' progressing=' + progressing
257
+ + ' age=' + (hb ? Math.round(hb.age / 1000) + 's' : 'n/a') + ' polls=' + (hb ? hb.polls : 'n/a')
258
+ + ' mode=' + SUPERVISE)
259
+ if (SUPERVISE === 'monitor') {
260
+ // 宿主半只报告:不杀、不拉。生命周期归独立 keeper(E-2026-09-21-06)。
261
+ return { restarted: false, healthy: false, monitored: true, pid }
262
+ }
263
+ const killed = cleanOrphans(pid)
264
+ if (killed) log('cleaned orphan headless children = ' + killed)
265
+ if (pidOk) { killTree(pid); if (alive(pid)) { try { process.kill(pid) } catch {} } log('killed pid=' + pid) }
266
+ try { writeFileSync(LOCK, '{}') } catch {}
267
+ let newPid = 0
268
+ try { newPid = spawnBridge(DETACHED); log('restart spawned pid=' + newPid + ' detached=' + DETACHED) } catch (e) { log('restart FAILED: ' + String(e?.message ?? e)) }
269
+ return { restarted: true, healthy: false, pid: newPid }
270
+ }
271
+
272
+ function acquireKeeperLock() {
273
+ try {
274
+ if (existsSync(KEEPER_LOCK)) {
275
+ const old = JSON.parse(readFileSync(KEEPER_LOCK, 'utf8'))
276
+ if (old && old.pid && alive(old.pid)) { log('keeper already running pid=' + old.pid + ' -> exit'); return false }
277
+ }
278
+ } catch {}
279
+ writeFileSync(KEEPER_LOCK, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }))
280
+ const release = () => { try { writeFileSync(KEEPER_LOCK, '{}') } catch {} }
281
+ process.on('exit', release)
282
+ process.on('SIGINT', () => { release(); process.exit(0) })
283
+ process.on('SIGTERM', () => { release(); process.exit(0) })
284
+ return true
285
+ }
286
+
287
+ function run() {
288
+ if (!acquireKeeperLock()) process.exit(0)
289
+ log('keeper start pid=' + process.pid + ' data=' + DATA + ' interval=' + Math.round(INTERVAL_MS / 1000) + 's bridge=' + BRIDGE)
290
+ tick()
291
+ setInterval(tick, INTERVAL_MS)
292
+ }
293
+
294
+ return { run, tick, status, startBridge: startBridgeNow, spawnBridge, stopBridge, log, superviseMode: SUPERVISE, hostHome: HOST_HOME, dshBin: DSH_BIN, dataDir: DATA, cwd: CWD, bridgePath: BRIDGE, intervalMs: INTERVAL_MS, staleMs: STALE_MS, manualStop: readManualStop }
295
+ }
296
+
297
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
298
+ if (isMain) {
299
+ const argOf = (n, d) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : d }
300
+ const keeper = createKeeper({ dataDir: argOf('--data-dir'), cwd: argOf('--cwd'), intervalMs: argOf('--interval-ms'), superviseMode: 'heal' })
301
+ keeper.run()
302
+ }
@@ -0,0 +1,7 @@
1
+ You are the assistant behind this chat channel, powered by the {{model}} model. Your working directory is {{cwd}}.
2
+
3
+ # 通道固定提示词(模板 · 可改可清空)
4
+ # - 这段文字(若填写)会作为「固定身份」拼在每条手机任务前面;**留空则不加任何前缀**,
5
+ # 直接把用户原话交给 DSH,由 DSH 自己的组合 / 预设 / 技能 / 工作区决定身份。
6
+ # - 占位符:{{model}} 模型名 | {{cwd}} 工作区 | {{vault}} 知识库路径(可选配置)
7
+ # - 本文件是**模板**:面板里保存后写入数据目录的副本,改这里不影响已保存的配置。
package/lib/pairing.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * 扫码配对(iLink ClawBot):宿主半持有配对会话,客户端只做展示与轮询。
3
+ *
4
+ * 协议(实测自 @local/dsh-weixin 的 IlinkClient):
5
+ * POST ilink/bot/get_bot_qrcode?bot_type=3 body {local_token_list:[...]} → {qrcode, qrcode_img_content}
6
+ * GET ilink/bot/get_qrcode_status?qrcode=…[&verify_code=…] → {status, …}
7
+ * status: wait | scaned | need_verifycode | scaned_but_redirect | binded_redirect | expired | confirmed
8
+ * confirmed 返回 {bot_token, ilink_bot_id, ilink_user_id, baseurl}
9
+ *
10
+ * 产出:bot_token 写进凭据文件(merge 语义,保留其它 refs);state.json 登记 owner 与账号。
11
+ */
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'
13
+ import { createHash } from 'node:crypto'
14
+ import { join, dirname } from 'node:path'
15
+
16
+ const NL = String.fromCharCode(10)
17
+ const API_BASE = process.env.ILINK_BASE || 'https://ilinkai.weixin.qq.com'
18
+ const BOT_TYPE = '3'
19
+ const CRED_REF = 'DSH_WEIXIN_BOT_TOKEN'
20
+
21
+ const headers = () => ({
22
+ 'content-type': 'application/json',
23
+ 'iLink-App-Id': 'bot',
24
+ 'iLink-App-ClientVersion': String((2 << 16) | (4 << 8) | 6),
25
+ })
26
+
27
+ const post = async (url, body, timeoutMs = 15000) => {
28
+ const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), timeoutMs)
29
+ try {
30
+ const res = await fetch(url, { method: 'POST', headers: headers(), body: JSON.stringify(body ?? {}), signal: ctl.signal })
31
+ const raw = await res.text()
32
+ if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + raw.slice(0, 200))
33
+ return JSON.parse(raw)
34
+ } finally { clearTimeout(t) }
35
+ }
36
+
37
+ const get = async (url, timeoutMs = 35000) => {
38
+ const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), timeoutMs)
39
+ try {
40
+ const res = await fetch(url, { method: 'GET', headers: headers(), signal: ctl.signal })
41
+ const raw = await res.text()
42
+ if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + raw.slice(0, 200))
43
+ return JSON.parse(raw)
44
+ } finally { clearTimeout(t) }
45
+ }
46
+
47
+ function readTokenFromFile(credFile) {
48
+ try {
49
+ const m = readFileSync(credFile, 'utf8').match(new RegExp('^\\s*' + CRED_REF + ':\\s*(.+)$', 'm'))
50
+ return m ? m[1].trim().replace(/^["']|["']$/g, '') : ''
51
+ } catch { return '' }
52
+ }
53
+
54
+ /** merge 写入凭据:只增改目标 ref,保留其它 refs、顶层键与夹层注释(原子写)。 */
55
+ export function writeCredentialRef(credFile, ref, value) {
56
+ const hadFile = existsSync(credFile)
57
+ const lines = hadFile ? readFileSync(credFile, 'utf8').split(/\r?\n/) : []
58
+ const refRe = /^\s{2}([A-Za-z_][A-Za-z0-9_]*):(\s*)(.*)$/
59
+ const head = []
60
+ const entries = []
61
+ const tail = []
62
+ let inRefs = false, refsSeen = false, refsClosed = false
63
+ for (const line of lines) {
64
+ if (/^refs:\s*$/.test(line)) { inRefs = true; refsSeen = true; head.push(line); continue }
65
+ if (inRefs && refRe.test(line)) { entries.push(line); continue }
66
+ if (inRefs) { inRefs = false; refsClosed = true }
67
+ if (refsClosed) tail.push(line)
68
+ else head.push(line)
69
+ }
70
+
71
+ let replaced = false
72
+ const outEntries = entries.map((l) => {
73
+ const m = l.match(refRe)
74
+ if (m && m[1] === ref) { replaced = true; return ' ' + ref + ': ' + value }
75
+ return l
76
+ })
77
+ if (!replaced) outEntries.push(' ' + ref + ': ' + value)
78
+
79
+ const headTrim = head.slice()
80
+ while (headTrim.length && headTrim[headTrim.length - 1].trim() === '') headTrim.pop()
81
+ const tailTrim = tail.slice()
82
+ while (tailTrim.length && tailTrim[tailTrim.length - 1].trim() === '') tailTrim.pop()
83
+
84
+ const parts = []
85
+ if (!refsSeen) parts.push('refs:')
86
+ parts.push(...headTrim)
87
+ parts.push(...outEntries)
88
+ if (tailTrim.length) parts.push(...tailTrim)
89
+
90
+ mkdirSync(dirname(credFile), { recursive: true })
91
+ const tmp = credFile + '.tmp'
92
+ writeFileSync(tmp, parts.join(NL) + NL, 'utf8')
93
+ renameSync(tmp, credFile)
94
+ return true
95
+ }
96
+
97
+ /** 把 owner/账号写进桥的 state.json(保持 checksum 一致,原子写)。 */
98
+ export function seedState(stateFile, { userId, accountId, baseUrl, addOwner, dropOthers, cwd }) {
99
+ let s = {}
100
+ try { s = JSON.parse(readFileSync(stateFile, 'utf8')) || {} } catch { s = {} }
101
+ s.peers ||= {}; s.allowedUsers ||= []; s.tasks ||= {}; s.processed ||= []
102
+ if (dropOthers && userId) {
103
+ // 换了绑定账号:清掉旧主人,避免旧账号仍能驱动这台机器
104
+ s.allowedUsers = []
105
+ s.peers = {}
106
+ }
107
+ if (userId && (addOwner || s.allowedUsers.length === 0) && !s.allowedUsers.includes(userId)) s.allowedUsers.push(userId)
108
+ if (userId) s.peers[userId] ||= { cwd: cwd || '', history: [], approvals: [] }
109
+ s.account = { accountId: accountId || null, userId: userId || null, baseUrl: baseUrl || API_BASE, connectedAt: new Date().toISOString() }
110
+ const c = { ...s }; delete c.checksum
111
+ s.checksum = createHash('sha256').update(JSON.stringify(c)).digest('hex')
112
+ mkdirSync(dirname(stateFile), { recursive: true })
113
+ const tmp = stateFile + '.tmp'
114
+ writeFileSync(tmp, JSON.stringify(s, null, 1), 'utf8')
115
+ renameSync(tmp, stateFile)
116
+ return { allowedUsers: s.allowedUsers.length, account: s.account, registered: userId ? s.allowedUsers.includes(userId) : false }
117
+ }
118
+
119
+ /**
120
+ * 创建一次配对会话。toDataUrl 由调用方注入(宿主半用 profile 里的 qrcode 包)。
121
+ */
122
+ export function createPairing(opts) {
123
+ const dataDir = opts.dataDir
124
+ const dshHome = opts.dshHome || process.env.DSH_HOME
125
+ const credFile = opts.credFile || join(dshHome || join(dataDir, 'dsh-home'), '.credentials.yaml')
126
+ const stateFile = join(dataDir, 'state.json')
127
+ const toDataUrl = opts.toDataUrl
128
+ const log = opts.log || (() => {})
129
+
130
+ let session = null
131
+
132
+ const status = () => {
133
+ if (!session) return { active: false }
134
+ return {
135
+ active: true, status: session.status, qrcode: session.qrcode, qrDataUrl: session.qrDataUrl,
136
+ qrContent: session.qrContent || null,
137
+ needsVerifyCode: session.status === 'need_verifycode', verifyCodeTried: !!session.verifyCode,
138
+ startedAt: session.startedAt, expiredAt: session.expiresAt, error: session.error || null,
139
+ confirmed: session.confirmed || null, redirectHost: session.redirectHost || null,
140
+ }
141
+ }
142
+
143
+ const finish = (resp) => {
144
+ const token = resp.bot_token
145
+ const userId = resp.ilink_user_id
146
+ const accountId = resp.ilink_bot_id
147
+ const baseUrl = resp.baseurl || API_BASE
148
+ writeCredentialRef(credFile, CRED_REF, token)
149
+ // 扫码的人就是新主人:直接登记进白名单(否则换号后消息会被"未登记"丢弃,
150
+ // 还得再手工发一次登记口令 —— 这一步是 2026-09-21 换号踩坑后补的)。
151
+ const seeded = seedState(stateFile, { userId, accountId, baseUrl, addOwner: true, dropOthers: true })
152
+ session.status = 'confirmed'
153
+ session.confirmed = {
154
+ accountId, userId, baseUrl, credentialFile: credFile,
155
+ allowedUsers: seeded.allowedUsers, registered: seeded.registered,
156
+ }
157
+ log('pair-confirmed account=' + accountId + ' user=' + String(userId).slice(0, 12)
158
+ + ' cred=' + credFile + ' registered=' + seeded.registered)
159
+ }
160
+
161
+ const refreshQr = async () => {
162
+ const localTokens = []
163
+ const t = readTokenFromFile(credFile) || process.env.DSH_WEIXIN_BOT_TOKEN
164
+ if (t) localTokens.push(t)
165
+ const qr = await post(API_BASE + '/ilink/bot/get_bot_qrcode?bot_type=' + BOT_TYPE, { local_token_list: localTokens })
166
+ if (!qr || !qr.qrcode || !qr.qrcode_img_content) throw new Error('网关没有返回有效二维码')
167
+ session.qrcode = qr.qrcode
168
+ session.pollBase = API_BASE
169
+ session.verifyCode = undefined
170
+ session.qrDataUrl = toDataUrl ? await toDataUrl(qr.qrcode_img_content) : null
171
+ session.qrContent = qr.qrcode_img_content
172
+ session.status = 'wait'
173
+ session.error = null
174
+ session.startedAt = new Date().toISOString()
175
+ session.expiresAt = new Date(Date.now() + 120000).toISOString()
176
+ session.confirmed = null
177
+ log('pair-qr-issued qrcode=' + qr.qrcode)
178
+ }
179
+
180
+ /**
181
+ * 客户端每 ~2.5s 调一次;内部单飞(single-flight):
182
+ * 网关的 get_qrcode_status 是 ~35s 长轮询,若并发发起会堆积 → 同一时刻只允许一次在途请求,
183
+ * 后来的调用直接复用同一个 promise。
184
+ */
185
+ let inflight = null
186
+ const pollOnce = async () => {
187
+ if (!session) return { active: false }
188
+ try {
189
+ let base = session.pollBase || API_BASE
190
+ if (session.redirectHost) base = 'https://' + session.redirectHost
191
+ let url = base + '/ilink/bot/get_qrcode_status?qrcode=' + encodeURIComponent(session.qrcode)
192
+ if (session.verifyCode) url += '&verify_code=' + encodeURIComponent(session.verifyCode)
193
+ const r = await get(url, 40000)
194
+ const st = r.status || 'wait'
195
+ if (st === 'confirmed') { finish(r); return status() }
196
+ if (st === 'expired') { await refreshQr(); return status() }
197
+ if (st === 'scaned_but_redirect' && r.redirect_host) session.redirectHost = r.redirect_host
198
+ if (st === 'binded_redirect') { session.status = 'binded_redirect'; session.error = '该 ClawBot 已绑定;如需重新配对,请先在原绑定端解除' }
199
+ else session.status = st
200
+ if (st === 'scaned') session.verifyCode = undefined
201
+ if (st !== 'binded_redirect') session.error = null
202
+ } catch (e) {
203
+ const msg = String(e?.message ?? e)
204
+ session.error = /abort/i.test(msg) ? null : msg
205
+ }
206
+ return status()
207
+ }
208
+
209
+ const poll = () => {
210
+ if (!session) return Promise.resolve({ active: false })
211
+ if (session.status === 'confirmed' || session.status === 'binded_redirect') return Promise.resolve(status())
212
+ if (inflight) return inflight
213
+ inflight = pollOnce().finally(() => { inflight = null })
214
+ return inflight
215
+ }
216
+
217
+ return {
218
+ start: async () => { session = { startedAt: new Date().toISOString() }; await refreshQr(); return status() },
219
+ poll,
220
+ status,
221
+ setVerifyCode: (code) => { if (!session) return { active: false }; session.verifyCode = String(code || '').trim() || undefined; return status() },
222
+ stop: () => { const was = !!session; session = null; return { active: false, stopped: was } },
223
+ credentialFile: credFile,
224
+ }
225
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 自带二维码渲染(零外部依赖)—— 给「微信连接」面板出配对二维码。
3
+ *
4
+ * 为什么自带:插件此前靠 `require.resolve('qrcode')` 在用户 profile 里找那个包,
5
+ * 而插件本身 `dependencies` 为空 —— 干净安装的机器根本没有它,面板就只剩"备用链接"
6
+ * (2026-09-21 其他用户实测反馈)。
7
+ *
8
+ * 组成:
9
+ * - `qrcode-core/`:从 `qrcode@1.5.4`(MIT,Copyright (c) 2012 Ryan Day)原样复制的**编码核心**,
10
+ * 零外部依赖(只互相 require);见同目录 LICENSE 与 NOTICE.md。
11
+ * - 本文件:只用核心产出模块矩阵,然后自己画 **SVG**(省掉 pngjs/yargs 等依赖与 PNG 编码)。
12
+ *
13
+ * 输出是 `data:image/svg+xml;base64,…`,可直接喂给 `<img src>`(Chromium 正常渲染)。
14
+ */
15
+ const QRCode = require('./qrcode-core/qrcode')
16
+
17
+ /** 取模块矩阵:{size, get(r,c)}。 */
18
+ function modules(text, ecLevel) {
19
+ const qr = QRCode.create(String(text), { errorCorrectionLevel: ecLevel || 'M' })
20
+ const size = qr.modules.size
21
+ const data = qr.modules.data
22
+ return {
23
+ size,
24
+ get: (r, c) => !!data[r * size + c],
25
+ version: qr.version,
26
+ ecLevel: qr.errorCorrectionLevel && qr.errorCorrectionLevel.bit !== undefined ? qr.errorCorrectionLevel.bit : null,
27
+ maskPattern: qr.maskPattern,
28
+ }
29
+ }
30
+
31
+ /**
32
+ * 渲染成 SVG data URL。
33
+ * @param {string} text 要编码的内容(配对时是微信返回的二维码内容)
34
+ * @param {{size?:number, margin?:number, ecLevel?:string}} [opts] size=目标边长(px),margin=静默区格数
35
+ */
36
+ function toSvgDataUrl(text, opts) {
37
+ const o = opts || {}
38
+ const m = modules(text, o.ecLevel)
39
+ const margin = o.margin === undefined ? 2 : o.margin
40
+ const target = o.size || 320
41
+ const scale = Math.max(1, Math.floor(target / (m.size + margin * 2)))
42
+ const px = (m.size + margin * 2) * scale
43
+ const rects = []
44
+ for (let r = 0; r < m.size; r++) {
45
+ for (let c = 0; c < m.size; c++) {
46
+ if (m.get(r, c)) {
47
+ rects.push('<rect x="' + ((c + margin) * scale) + '" y="' + ((r + margin) * scale) + '" width="' + scale + '" height="' + scale + '"/>')
48
+ }
49
+ }
50
+ }
51
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + px + '" height="' + px + '" ' +
52
+ 'shape-rendering="crispEdges" viewBox="0 0 ' + px + ' ' + px + '">' +
53
+ '<rect width="' + px + '" height="' + px + '" fill="#ffffff"/>' +
54
+ '<g fill="#000000">' + rects.join('') + '</g></svg>'
55
+ return 'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
56
+ }
57
+
58
+ module.exports = { toSvgDataUrl, modules }
@@ -0,0 +1,10 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2012 Ryan Day
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10
+
@@ -0,0 +1,21 @@
1
+ # 来源与许可 / Provenance
2
+
3
+ 本目录是 **`qrcode@1.5.4` 的编码核心**原样复制(未修改),来源包:
4
+
5
+ - npm:`qrcode@1.5.4`(MIT)
6
+ - 作者/版权:Copyright (c) 2012 Ryan Day(见同目录 `LICENSE`)
7
+ - 复制范围:该包的 `lib/core/*.js`(编码核心,**零外部依赖**,只互相 require)
8
+ - 未复制:`lib/renderer/*`、`bin/*`、`server.js` 等(依赖 `pngjs` / `yargs`,本插件不需要)
9
+
10
+ 用途:插件自己的 `lib/vendor/qr-svg.cjs` 调用这里的 `qrcode.js` 得到模块矩阵,
11
+ 再自行渲染成 SVG data URL,从而**不依赖用户环境里是否装有 `qrcode` 包**。
12
+
13
+ 此外还复制了 **`dijkstrajs@1.0.3`(MIT)** 的 `dijkstra.js` → 本目录 `dijkstrajs.js`
14
+ (`segments.js` 用它做最优分段;许可见 `dijkstrajs.LICENSE`)。
15
+
16
+ **唯一一处对 vendored 代码的改动**:`segments.js` 里 `require('dijkstrajs')` → `require('./dijkstrajs')`。
17
+ 原因:npm 打包默认忽略 `node_modules/`,把依赖放进 `lib/vendor/node_modules/` 会在发布包里丢掉
18
+ (等于重演"干净安装缺依赖")。改相对路径后所有文件都在包内、零外部解析。
19
+
20
+ 许可合规:MIT 允许复制与再分发,条件是保留版权与许可声明——`LICENSE`(qrcode)与
21
+ `dijkstrajs.LICENSE` 已随本目录一起分发;插件的 `README.md`「许可」一节也注明了这些第三方代码。