@zmainer/dsh-wx-bridge 1.0.16 → 1.1.1

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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * outbox.mjs — 主动推送的「信箱」纯函数层(无 IO,可离线自测)
3
+ *
4
+ * 设计:任何进程(手机通道会话里的 agent、桌面会话、定时任务)只做一件事——
5
+ * 往 <dataDir>/outbox/ 里丢一个 JSON 文件;桥每 5 秒扫一次,替它发到微信。
6
+ * 这样推送不依赖网络、不依赖 RPC,也不需要调用方知道 iLink 协议。
7
+ * 投递结果用文件位置表达:成功 → outbox/sent/,失败 → outbox/failed/(附原因)。
8
+ */
9
+
10
+ /** 单条推送允许携带的字段(其余字段忽略)。 */
11
+ export function normalizePushEntry(obj) {
12
+ const text = String((obj && obj.text) || '').trim()
13
+ if (!text) throw new Error('empty text')
14
+ return {
15
+ text: text.slice(0, 4000),
16
+ to: String((obj && obj.to) || '').trim(),
17
+ at: String((obj && obj.at) || new Date().toISOString()),
18
+ source: String((obj && obj.source) || '').trim(),
19
+ }
20
+ }
21
+
22
+ /** 解析一个 outbox 文件的内容(坏 JSON / 空文本都要给出可读原因)。 */
23
+ export function parsePushEntry(raw) {
24
+ let obj
25
+ try { obj = JSON.parse(String(raw || '')) } catch (e) { return { error: 'bad-json: ' + String(e?.message ?? e) } }
26
+ try { return { entry: normalizePushEntry(obj) } } catch (e) { return { error: String(e?.message ?? e) } }
27
+ }
28
+
29
+ /**
30
+ * 收件人:显式指定 > 最近发过消息的人 > state 里的默认人 > 唯一已登记的人。
31
+ * **只在已登记白名单里选**:推送不能成为绕过准入的后门;无人可选时返回 ''(调用方判失败)。
32
+ */
33
+ export function pickRecipient(entry, ctx = {}) {
34
+ const allowed = Array.isArray(ctx.allowedUsers) ? ctx.allowedUsers.filter(Boolean) : []
35
+ if (!allowed.length) return ''
36
+ const want = String(entry?.to || '').trim()
37
+ if (want && allowed.includes(want)) return want
38
+ for (const cand of [ctx.defaultPeer, ctx.lastPeer]) {
39
+ if (cand && allowed.includes(cand)) return cand
40
+ }
41
+ return allowed.length === 1 ? allowed[0] : ''
42
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wxpush.mjs — 主动推送 CLI(桥开机时会把它复制到 <dataDir>/wxpush.mjs)
4
+ *
5
+ * 用法(任何进程都能跑:手机通道会话里的 agent、桌面会话、定时任务):
6
+ * node wxpush.mjs "要推送到微信的话"
7
+ * node wxpush.mjs --text "带换行的
8
+ * 多行文本" --to <peerId> --data-dir <桥的数据目录>
9
+ * echo "来自管道的文本" | node wxpush.mjs --stdin
10
+ *
11
+ * 它只做一件事:把一条 JSON 丢进 <dataDir>/outbox/,桥轮询时替它发出去。
12
+ * 零依赖、零网络:没有微信凭据 / 桥没在跑时也只是排队,不会报错给你。
13
+ */
14
+ import { mkdirSync, writeFileSync } from 'node:fs'
15
+ import { join, resolve } from 'node:path'
16
+ import { homedir } from 'node:os'
17
+ import { randomUUID } from 'node:crypto'
18
+
19
+ const args = process.argv.slice(2)
20
+ const argOf = (name, def = '') => { const i = args.indexOf(name); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : def }
21
+
22
+ function positionalText() {
23
+ const skip = new Set()
24
+ for (const key of ['--text', '--to', '--data-dir', '--source']) {
25
+ const i = args.indexOf(key)
26
+ if (i >= 0) { skip.add(i); skip.add(i + 1) }
27
+ }
28
+ return args.filter((a, i) => !skip.has(i) && !a.startsWith('--')).join(' ').trim()
29
+ }
30
+
31
+ async function readStdin() {
32
+ if (process.stdin.isTTY) return ''
33
+ let out = ''
34
+ try {
35
+ for await (const chunk of process.stdin) out += chunk
36
+ } catch { return '' }
37
+ return out.trim()
38
+ }
39
+
40
+ const text = (argOf('--text') || (args.includes('--stdin') ? await readStdin() : '') || positionalText()).trim()
41
+ if (!text) {
42
+ console.error('[wxpush] 没有内容:用法 node wxpush.mjs "要推送的话"(或 --text / --stdin)')
43
+ process.exit(2)
44
+ }
45
+
46
+ const dataDir = resolve(argOf('--data-dir') || process.env.WXBRIDGE_DATA || process.env.BRIDGE_DATA
47
+ || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge'))
48
+ const outbox = join(dataDir, 'outbox')
49
+ const entry = {
50
+ text: text.slice(0, 4000),
51
+ to: argOf('--to'),
52
+ at: new Date().toISOString(),
53
+ source: argOf('--source') || 'wxpush',
54
+ }
55
+ try {
56
+ mkdirSync(outbox, { recursive: true })
57
+ const file = join(outbox, Date.now() + '-' + randomUUID().slice(0, 8) + '.json')
58
+ writeFileSync(file, JSON.stringify(entry, null, 2))
59
+ console.log('[wxpush] 已排队:' + file)
60
+ } catch (e) {
61
+ console.error('[wxpush] 写入失败:' + String(e?.message ?? e))
62
+ process.exit(1)
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmainer/dsh-wx-bridge",
3
- "version": "1.0.16",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "main": "./lib/index.js",
6
6
  "dsh": {
@@ -27,7 +27,7 @@
27
27
  "README.md",
28
28
  "package.json"
29
29
  ],
30
- "description": "DSH plugin: connect WeChat (official iLink/ClawBot) to DeepSeek Harness — settings page with live bridge status, QR device pairing, and the bundled bridge + keeper kernel.",
30
+ "description": "DSH plugin: connect WeChat (official iLink/ClawBot) to DeepSeek Harness — settings page with live bridge status, QR device pairing, in-WeChat tool approvals, proactive push, and the bundled bridge + keeper kernel.",
31
31
  "license": "MIT",
32
32
  "keywords": [
33
33
  "deepseek-harness",
@@ -36,5 +36,17 @@
36
36
  "weixin",
37
37
  "ilink",
38
38
  "clawbot"
39
- ]
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/zhy5/dsh-wx-bridge.git"
43
+ },
44
+ "homepage": "https://github.com/zhy5/dsh-wx-bridge#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/zhy5/dsh-wx-bridge/issues"
47
+ },
48
+ "author": "zmainer",
49
+ "engines": {
50
+ "node": ">=22.19.0"
51
+ }
40
52
  }
package/src/client.js CHANGED
@@ -116,6 +116,8 @@ function Panel() {
116
116
  const [presets, setPresets] = R.useState([])
117
117
  const [presetSel, setPresetSel] = R.useState('')
118
118
  const [allowlist, setAllowlist] = R.useState('strict')
119
+ const [approval, setApproval] = R.useState('ask')
120
+ const [pushText, setPushText] = R.useState('')
119
121
  const [presetState, setPresetState] = R.useState(null)
120
122
  useWxStyle()
121
123
 
@@ -145,9 +147,43 @@ function Panel() {
145
147
  .then((r) => r.json())
146
148
  .then((d) => { if (alive) setPair(d) })
147
149
  .catch(() => {})
150
+ fetch('/wxbridge/approval', { cache: 'no-store' })
151
+ .then((r) => r.json())
152
+ .then((d) => { if (alive && d && typeof d.approval === 'string') setApproval(d.approval) })
153
+ .catch(() => {})
148
154
  return () => { alive = false }
149
155
  }, [])
150
156
 
157
+ const saveApproval = async (mode) => {
158
+ setBusy('approval')
159
+ try {
160
+ const r = await fetch('/wxbridge/approval', {
161
+ method: 'POST', headers: { 'content-type': 'application/json' },
162
+ body: JSON.stringify({ mode }),
163
+ })
164
+ const d = await r.json()
165
+ if (typeof d.approval === 'string') setApproval(d.approval)
166
+ setNote(d.ok
167
+ ? ('审批策略已切到 ' + mode + ':' + (d.note || ''))
168
+ : ('切换失败:' + (d.error || '未知错误')))
169
+ } catch (e) { setNote('切换失败:' + String(e && e.message ? e.message : e)) } finally { setBusy('') }
170
+ }
171
+
172
+ const sendPush = async () => {
173
+ const text = String(pushText || '').trim()
174
+ if (!text) { setNote('先写一条要推送的内容。'); return }
175
+ setBusy('push')
176
+ try {
177
+ const r = await fetch('/wxbridge/push', {
178
+ method: 'POST', headers: { 'content-type': 'application/json' },
179
+ body: JSON.stringify({ text }),
180
+ })
181
+ const d = await r.json()
182
+ setNote(d.ok ? ('已排队推送:' + (d.note || '')) : ('推送失败:' + (d.error || '未知错误')))
183
+ if (d.ok) setPushText('')
184
+ } catch (e) { setNote('推送失败:' + String(e && e.message ? e.message : e)) } finally { setBusy('') }
185
+ }
186
+
151
187
  const loadPresets = () => fetch('/wxbridge/presets', { cache: 'no-store' })
152
188
  .then((r) => r.json())
153
189
  .then((d) => { setPresetState(d); setPresets(d.presets || []); setPresetSel(typeof d.selected === 'string' ? d.selected : '') })
@@ -445,6 +481,51 @@ function Panel() {
445
481
  R.createElement('span', { style: dim }, '当前:' + allowlist + '(改完会写入 config.json 并重启桥,立即生效)')),
446
482
  ),
447
483
 
484
+ R.createElement('div', { style: { marginTop: '14px', paddingTop: '12px', borderTop: subtle } },
485
+ R.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } },
486
+ wxBubbles(16, WX, skin.dark ? '#0d1220' : '#ffffff', 'transparent'),
487
+ R.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '审批策略'),
488
+ R.createElement('span', { style: { fontSize: '11px', color: dim.color } }, '(需要审批的操作怎么答)')),
489
+ R.createElement('select', {
490
+ value: approval, onChange: (e) => setApproval(e.target.value),
491
+ style: { ...skin.control, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: '8px', fontSize: '12px' },
492
+ }, [
493
+ R.createElement('option', { key: 'ask', value: 'ask', style: skin.option }, 'ask(默认)· 把审批卡推到微信,等你回「批准 / 拒绝」,120 秒未回按拒绝'),
494
+ R.createElement('option', { key: 'allow', value: 'allow', style: skin.option }, 'allow · 自动批准,不打扰你(旧行为)'),
495
+ R.createElement('option', { key: 'reject', value: 'reject', style: skin.option }, 'reject · 一律拒绝(需要审批的操作会失败)'),
496
+ ]),
497
+ R.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center' } },
498
+ R.createElement('button', {
499
+ className: 'wxbridge-btn', onClick: () => saveApproval(approval), disabled: !!busy,
500
+ style: {
501
+ marginRight: '10px', padding: '6px 14px', borderRadius: '999px', fontSize: '12px', fontWeight: 500,
502
+ cursor: busy ? 'default' : 'pointer', border: '1px solid ' + WX, background: WX, color: '#fff',
503
+ },
504
+ }, busy === 'approval' ? '处理中…' : '保存审批策略'),
505
+ R.createElement('span', { style: dim },
506
+ '当前:' + approval + '(保存在 config.json 的 acp.permPolicy;改了立即生效,不必重启)' +
507
+ (approval === 'ask' ? ' · 只有把通道权限预设收紧(acp.permPreset)才会真的产生审批卡' : ''))),
508
+ ),
509
+ R.createElement('div', { style: { marginTop: '14px', paddingTop: '12px', borderTop: subtle } },
510
+ R.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } },
511
+ wxBubbles(16, WX, skin.dark ? '#0d1220' : '#ffffff', 'transparent'),
512
+ R.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '主动推送'),
513
+ R.createElement('span', { style: { fontSize: '11px', color: dim.color } }, '(试一下能不能推到你手机)')),
514
+ R.createElement('input', {
515
+ value: pushText, onChange: (e) => setPushText(e.target.value), placeholder: '要推到微信的一句话(例如:测试推送)',
516
+ style: { ...skin.control, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: '8px', fontSize: '12px' },
517
+ }),
518
+ R.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center' } },
519
+ R.createElement('button', {
520
+ className: 'wxbridge-btn', onClick: () => sendPush(), disabled: !!busy,
521
+ style: {
522
+ marginRight: '10px', padding: '6px 14px', borderRadius: '999px', fontSize: '12px', fontWeight: 500,
523
+ cursor: busy ? 'default' : 'pointer', border: '1px solid ' + WX, background: WX, color: '#fff',
524
+ },
525
+ }, busy === 'push' ? '发送中…' : '发一条测试推送'),
526
+ R.createElement('span', { style: dim }, '桥每 5 秒取件;结果在数据目录的 outbox/sent、outbox/failed')),
527
+ ),
528
+
448
529
  note ? R.createElement('pre', { style: { margin: '10px 0 0 0', padding: '9px 11px', borderRadius: '10px', background: mutedBg, border: subtle, fontSize: '11px', overflow: 'auto', maxHeight: '160px' } }, note) : null,
449
530
 
450
531
  // ── 操作日志 ───────────────────────────────────────────────────