@lanbaolu/dsh-wechat-bridge 0.4.1 → 0.6.0

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 (39) hide show
  1. package/README.md +45 -1
  2. package/docs/plan-multi-user.md +92 -0
  3. package/lib/approval.js +2 -2
  4. package/lib/approval.js.map +1 -1
  5. package/lib/bridge/commands/handlers.js +119 -0
  6. package/lib/bridge/commands/handlers.js.map +1 -1
  7. package/lib/bridge/commands/router.js +11 -1
  8. package/lib/bridge/commands/router.js.map +1 -1
  9. package/lib/bridge/config.js +6 -0
  10. package/lib/bridge/config.js.map +1 -1
  11. package/lib/bridge/dsh-client.js.map +1 -1
  12. package/lib/bridge/main.js +370 -71
  13. package/lib/bridge/main.js.map +1 -1
  14. package/lib/bridge/notify.js +9 -8
  15. package/lib/bridge/notify.js.map +1 -1
  16. package/lib/bridge/session-key.js +62 -0
  17. package/lib/bridge/session-key.js.map +1 -0
  18. package/lib/bridge/session.js +109 -16
  19. package/lib/bridge/session.js.map +1 -1
  20. package/lib/bridge/store.js +10 -2
  21. package/lib/bridge/store.js.map +1 -1
  22. package/lib/bridge/trust.js +160 -0
  23. package/lib/bridge/trust.js.map +1 -0
  24. package/lib/client/Panel.js +99 -2
  25. package/lib/client/Panel.js.map +1 -1
  26. package/lib/client.js +279 -0
  27. package/lib/client.js.map +1 -1
  28. package/lib/index.js +259 -26
  29. package/lib/index.js.map +1 -1
  30. package/lib/types/approval.d.ts +6 -2
  31. package/lib/types/bridge/commands/handlers.d.ts +12 -0
  32. package/lib/types/bridge/commands/router.d.ts +13 -0
  33. package/lib/types/bridge/config.d.ts +8 -0
  34. package/lib/types/bridge/dsh-client.d.ts +7 -0
  35. package/lib/types/bridge/notify.d.ts +3 -3
  36. package/lib/types/bridge/session-key.d.ts +14 -0
  37. package/lib/types/bridge/session.d.ts +15 -4
  38. package/lib/types/bridge/trust.d.ts +61 -0
  39. package/package.json +1 -1
@@ -3,7 +3,7 @@ import { createInterface } from 'node:readline';
3
3
  import process from 'node:process';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { join, basename, extname } from 'node:path';
6
- import { unlinkSync, writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
6
+ import { unlinkSync, writeFileSync, readFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { WeChatApi } from './wechat/api.js';
9
9
  import { loadLatestAccount } from './wechat/accounts.js';
@@ -14,11 +14,13 @@ import { downloadImage, extractText, extractFirstImageUrl, extractFirstFileItem,
14
14
  import { createSessionStore } from './session.js';
15
15
  import { routeCommand } from './commands/router.js';
16
16
  import { loadConfig, saveConfig } from './config.js';
17
+ import { loadJson, saveJson } from './store.js';
17
18
  import { logger } from './logger.js';
18
19
  import { DATA_DIR } from './constants.js';
19
20
  import { MessageType } from './wechat/types.js';
20
21
  import { DshClient } from './dsh-client.js';
21
22
  import { createNotifyThrottle } from './notify.js';
23
+ import { loadTrust, saveTrust, decideTrust, setTrustMode } from './trust.js';
22
24
  // ---------------------------------------------------------------------------
23
25
  // Helpers
24
26
  // ---------------------------------------------------------------------------
@@ -29,15 +31,53 @@ const MAX_MESSAGE_LENGTH = 4000;
29
31
  */
30
32
  let lastActiveUserId = '';
31
33
  /**
32
- * iLink 主动发消息(bot → 用户)必须回传最近一次入站消息携带的
34
+ * iLink 主动发消息(bot → 用户)必须回传该用户最近一次入站消息携带的
33
35
  * context_token,空 token 会被服务端拒绝(ret:-2 "prepare failed")。
34
- * 每次收到绑定用户的消息就刷新并持久化它,供主动通知 / 审批推送使用。
36
+ *
37
+ * P1-2 / M3:每个受信用户各存一份(入站消息按 from_user_id 刷新),
38
+ * 主动通知 / 审批推送显式带 userId 取对应 token;`lastContextToken`
39
+ * 保留为兜底(未带 userId 的旧调用路径 / 未知用户回退)。
35
40
  */
41
+ const contextTokens = new Map();
36
42
  let lastContextToken = '';
37
43
  function contextTokenPath() {
38
44
  return join(DATA_DIR, 'context-token.json');
39
45
  }
46
+ function contextTokensPath() {
47
+ return join(DATA_DIR, 'context-tokens.json');
48
+ }
49
+ function persistContextTokens() {
50
+ try {
51
+ mkdirSync(DATA_DIR, { recursive: true });
52
+ const tokens = {};
53
+ for (const [k, v] of contextTokens)
54
+ tokens[k] = v;
55
+ writeFileSync(contextTokensPath(), JSON.stringify({ tokens, updatedAt: Date.now() }) + '\n', 'utf8');
56
+ // 旧单 token 文件继续写(最近一条),老版本工具/排查脚本仍可读。
57
+ writeFileSync(contextTokenPath(), JSON.stringify({ token: lastContextToken, updatedAt: Date.now() }) + '\n', 'utf8');
58
+ // 敏感 token 文件与 trust/config 对齐 0600,避免同机其他用户可读。
59
+ if (process.platform !== 'win32') {
60
+ chmodSync(contextTokensPath(), 0o600);
61
+ chmodSync(contextTokenPath(), 0o600);
62
+ }
63
+ }
64
+ catch (err) {
65
+ logger.warn('Failed to persist context tokens', { error: err instanceof Error ? err.message : String(err) });
66
+ }
67
+ }
40
68
  function loadContextToken() {
69
+ // per-user 表
70
+ try {
71
+ const parsed = JSON.parse(readFileSync(contextTokensPath(), 'utf8'));
72
+ for (const [k, v] of Object.entries(parsed.tokens ?? {})) {
73
+ if (typeof v === 'string' && v && k)
74
+ contextTokens.set(k, v);
75
+ }
76
+ }
77
+ catch {
78
+ // 首次启动没有文件属正常
79
+ }
80
+ // 旧单 token 兜底
41
81
  try {
42
82
  const parsed = JSON.parse(readFileSync(contextTokenPath(), 'utf8'));
43
83
  lastContextToken = typeof parsed.token === 'string' ? parsed.token : '';
@@ -46,17 +86,119 @@ function loadContextToken() {
46
86
  lastContextToken = '';
47
87
  }
48
88
  }
49
- function updateContextToken(token) {
50
- if (!token || token === lastContextToken)
89
+ /** 主动推送取 token:优先该用户最近入站的,缺省回退全局最近一条。 */
90
+ function contextTokenFor(userId) {
91
+ if (userId && contextTokens.has(userId))
92
+ return contextTokens.get(userId);
93
+ return lastContextToken;
94
+ }
95
+ function updateContextToken(userId, token) {
96
+ if (!token)
51
97
  return;
52
- lastContextToken = token;
98
+ let changed = false;
99
+ if (userId && contextTokens.get(userId) !== token) {
100
+ contextTokens.set(userId, token);
101
+ changed = true;
102
+ }
103
+ if (token !== lastContextToken) {
104
+ lastContextToken = token;
105
+ changed = true;
106
+ }
107
+ if (changed)
108
+ persistContextTokens();
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // 崩溃安全:跨进程轮询锁 + 入站去重
112
+ // ---------------------------------------------------------------------------
113
+ /**
114
+ * 跨进程轮询锁:同一时刻只允许一个 daemon 轮询微信账号(getupdates 游标
115
+ * 双写会互相吞消息、双写会话日志)。pid 存活 + 90s 心跳判断,陈旧锁自动
116
+ * 接管(宿主看门狗重启 / 上次崩溃残留都不会卡死)。
117
+ */
118
+ const POLL_LOCK_PATH = join(DATA_DIR, 'poll.lock');
119
+ function readPollLock() {
120
+ try {
121
+ return JSON.parse(readFileSync(POLL_LOCK_PATH, 'utf8'));
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ function isPidAlive(pid) {
128
+ try {
129
+ process.kill(pid, 0);
130
+ return true;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ function writePollLock() {
53
137
  try {
54
138
  mkdirSync(DATA_DIR, { recursive: true });
55
- writeFileSync(contextTokenPath(), JSON.stringify({ token, updatedAt: Date.now() }) + '\n', 'utf8');
139
+ writeFileSync(POLL_LOCK_PATH, JSON.stringify({ pid: process.pid, heartbeat: Date.now() }) + '\n', 'utf8');
56
140
  }
57
141
  catch (err) {
58
- logger.warn('Failed to persist context token', { error: err instanceof Error ? err.message : String(err) });
142
+ logger.warn('Failed to write poll lock', { error: err instanceof Error ? err.message : String(err) });
143
+ }
144
+ }
145
+ function acquirePollLock() {
146
+ const existing = readPollLock();
147
+ if (existing && typeof existing.pid === 'number' && existing.pid !== process.pid
148
+ && isPidAlive(existing.pid)
149
+ && typeof existing.heartbeat === 'number'
150
+ && existing.heartbeat > Date.now() - 90_000) {
151
+ return false;
59
152
  }
153
+ writePollLock();
154
+ return true;
155
+ }
156
+ /**
157
+ * 入站去重:崩溃重投 / 轮询重叠窗口里 getupdates 可能重放已处理消息,
158
+ * 按 message_id(缺省回退 seq)直接跳过。最近条目持久化,重启后仍生效。
159
+ */
160
+ const DEDUP_PATH = join(DATA_DIR, 'dedup.json');
161
+ const DEDUP_TTL_MS = 60 * 60 * 1000;
162
+ const seenMessages = new Map();
163
+ let dedupSaveTimer;
164
+ function loadDedup() {
165
+ const parsed = loadJson(DEDUP_PATH, { entries: {} });
166
+ const cutoff = Date.now() - DEDUP_TTL_MS;
167
+ for (const [key, ts] of Object.entries(parsed.entries ?? {})) {
168
+ if (typeof ts === 'number' && ts > cutoff)
169
+ seenMessages.set(key, ts);
170
+ }
171
+ }
172
+ /** 返回 true = 首次见到(应处理);false = 重复(应跳过)。 */
173
+ function markSeen(key) {
174
+ if (seenMessages.has(key))
175
+ return false;
176
+ seenMessages.set(key, Date.now());
177
+ if (seenMessages.size > 1000) {
178
+ const cutoff = Date.now() - DEDUP_TTL_MS;
179
+ for (const [k, ts] of seenMessages) {
180
+ if (ts < cutoff)
181
+ seenMessages.delete(k);
182
+ }
183
+ }
184
+ return true;
185
+ }
186
+ function scheduleDedupSave() {
187
+ if (dedupSaveTimer)
188
+ return;
189
+ dedupSaveTimer = setTimeout(() => {
190
+ dedupSaveTimer = undefined;
191
+ try {
192
+ const entries = {};
193
+ for (const [k, ts] of seenMessages)
194
+ entries[k] = ts;
195
+ saveJson(DEDUP_PATH, { entries });
196
+ }
197
+ catch {
198
+ // 去重表丢失只影响崩溃重投场景,不阻塞消息处理
199
+ }
200
+ }, 1000);
201
+ dedupSaveTimer.unref?.();
60
202
  }
61
203
  /** Extensions eligible for auto-push when detected in DSH's response text. */
62
204
  const AUTO_PUSH_EXTENSIONS = new Set([
@@ -273,11 +415,13 @@ async function runSetup() {
273
415
  // ---------------------------------------------------------------------------
274
416
  async function runDaemon() {
275
417
  const config = loadConfig();
276
- const account = loadLatestAccount();
277
- if (!account) {
418
+ const loadedAccount = loadLatestAccount();
419
+ if (!loadedAccount) {
278
420
  console.error('未找到微信账号,请先运行: node lib/bridge/main.js setup');
279
421
  process.exit(1);
280
422
  }
423
+ // 守卫后 account 必非 null;闭包(信任门禁等)拿不到收窄,这里显式声明非空。
424
+ const account = loadedAccount;
281
425
  const apiBase = process.env.DSH_BRIDGE_API_BASE;
282
426
  const apiToken = process.env.DSH_BRIDGE_API_TOKEN;
283
427
  if (!apiBase || !apiToken) {
@@ -286,26 +430,30 @@ async function runDaemon() {
286
430
  }
287
431
  const client = new DshClient(apiBase, apiToken);
288
432
  const api = new WeChatApi(account.botToken, account.baseUrl);
289
- const sessionStore = createSessionStore();
290
- const session = sessionStore.load(account.accountId);
291
- if (config.workingDirectory && session.workingDirectory === process.cwd()) {
292
- session.workingDirectory = config.workingDirectory;
293
- sessionStore.save(account.accountId, session);
294
- }
295
- if (session.state !== 'idle') {
296
- logger.warn('Resetting stale session state on startup', { state: session.state });
297
- session.state = 'idle';
298
- sessionStore.save(account.accountId, session);
299
- }
433
+ const sessionStore = createSessionStore({
434
+ botAccountId: account.accountId,
435
+ ownerUserId: account.userId,
436
+ defaultWorkingDirectory: config.workingDirectory,
437
+ });
438
+ // 升级到多用户:把旧单用户数据 ${accountId}.json 迁移到 ${accountId}__${ownerUserId}.json,
439
+ // 并把本 bot 所有会话的陈旧 'processing' 状态重置为 'idle'(崩溃恢复)。
440
+ sessionStore.runMigrations();
441
+ sessionStore.resetStaleStates();
300
442
  const sender = createSender(api, account.accountId);
301
443
  lastActiveUserId = account.userId || '';
302
444
  loadContextToken();
445
+ loadDedup();
303
446
  // -------------------------------------------------------------------------
304
447
  // Proactive notification endpoint (DSH → daemon), throttled.
305
448
  // WeChat personal accounts are sensitive to proactive high-frequency pushes,
306
449
  // so notifications go through a queue + rate limits (see notify.ts).
450
+ // P1-2 / M3:body 可带 userId 指定目标用户(取该用户的 context_token),
451
+ // 缺省回退 lastActiveUserId(旧调用路径兼容)。
307
452
  // -------------------------------------------------------------------------
308
- const notifyThrottle = createNotifyThrottle((message) => sender.sendText(lastActiveUserId, lastContextToken, message));
453
+ const notifyThrottle = createNotifyThrottle((message, userId) => {
454
+ const target = userId || lastActiveUserId;
455
+ return sender.sendText(target, contextTokenFor(target), message);
456
+ });
309
457
  const notifyPortPath = join(DATA_DIR, 'daemon-port.json');
310
458
  const notifyServer = createServer((req, res) => {
311
459
  const token = process.env.DSH_BRIDGE_API_TOKEN;
@@ -336,6 +484,7 @@ async function runDaemon() {
336
484
  try {
337
485
  const parsed = JSON.parse(body);
338
486
  const message = String(parsed?.message ?? '');
487
+ const targetUserId = typeof parsed?.userId === 'string' && parsed.userId ? parsed.userId : undefined;
339
488
  // 审批是阻塞交互且量极低(由用户自己的任务触发),绕过节流直发,
340
489
  // 否则 60s 的最小通知间隔会把审批拖到超时。
341
490
  if (isApproval) {
@@ -344,7 +493,8 @@ async function runDaemon() {
344
493
  res.end(JSON.stringify({ ok: false, error: 'empty message' }));
345
494
  return;
346
495
  }
347
- sender.sendText(lastActiveUserId, lastContextToken, message)
496
+ const target = targetUserId || lastActiveUserId;
497
+ sender.sendText(target, contextTokenFor(target), message)
348
498
  .then(() => {
349
499
  res.writeHead(200, { 'Content-Type': 'application/json' });
350
500
  res.end(JSON.stringify({ ok: true }));
@@ -355,7 +505,7 @@ async function runDaemon() {
355
505
  });
356
506
  return;
357
507
  }
358
- const result = notifyThrottle.enqueue(message);
508
+ const result = notifyThrottle.enqueue(message, targetUserId);
359
509
  res.writeHead(result.accepted ? 200 : 400, { 'Content-Type': 'application/json' });
360
510
  res.end(JSON.stringify(result));
361
511
  }
@@ -375,70 +525,150 @@ async function runDaemon() {
375
525
  catch (err) {
376
526
  logger.warn('Failed to persist notify endpoint info', { error: err instanceof Error ? err.message : String(err) });
377
527
  }
378
- const messageQueue = [];
379
- let processingQueue = false;
380
- async function drainQueue() {
381
- if (processingQueue)
528
+ // -------------------------------------------------------------------------
529
+ // P1-2 / M3:per-user 消息队列——A 的长任务不再阻塞 B。
530
+ // 每个用户一条队列串行消费;用户之间并行(host 侧本来就是独立 agent)。
531
+ // -------------------------------------------------------------------------
532
+ const messageQueues = new Map();
533
+ const drainingUsers = new Set();
534
+ function enqueueMessage(msg) {
535
+ const uid = msg.from_user_id;
536
+ let q = messageQueues.get(uid);
537
+ if (!q) {
538
+ q = [];
539
+ messageQueues.set(uid, q);
540
+ }
541
+ q.push(msg);
542
+ void drainUserQueue(uid);
543
+ }
544
+ async function drainUserQueue(userId) {
545
+ if (drainingUsers.has(userId))
382
546
  return;
383
- processingQueue = true;
384
- while (messageQueue.length > 0) {
385
- const msg = messageQueue.shift();
386
- await handleMessage(msg, account, session, sessionStore, sender, config, client, messageQueue);
547
+ drainingUsers.add(userId);
548
+ try {
549
+ const q = messageQueues.get(userId);
550
+ while (q && q.length > 0) {
551
+ const msg = q.shift();
552
+ await handleMessage(msg, account, sessionStore, sender, config, client, q);
553
+ }
554
+ }
555
+ catch (err) {
556
+ logger.error('drainUserQueue failed', { userId, error: err instanceof Error ? err.message : String(err) });
557
+ }
558
+ finally {
559
+ drainingUsers.delete(userId);
560
+ }
561
+ }
562
+ /**
563
+ * 信任门禁判定(供 onMessage / 优先命令复用):
564
+ * 返回 null = 放行;返回字符串 = 拒绝原因(已记日志,必要时已通知 owner)。
565
+ * bootstrap 自动入集 / lastSeenAt 刷新等副作用在此落盘(lastSeenAt 60s 节流)。
566
+ */
567
+ let lastTrustSeenPersist = 0;
568
+ function checkTrustGate(msg) {
569
+ const trustFile = loadTrust();
570
+ const decision = decideTrust({
571
+ fromUserId: msg.from_user_id ?? '',
572
+ ownerUserId: account.userId,
573
+ file: trustFile,
574
+ });
575
+ if (decision.file !== trustFile) {
576
+ // bootstrap 自动入集必落盘;trusted 分支的 lastSeenAt 刷新节流到 60s 一次,
577
+ // 避免每条消息都写盘(面板「最近活跃」有秒级精度足够)。
578
+ if (decision.reason !== 'trusted' || Date.now() - lastTrustSeenPersist > 60_000) {
579
+ saveTrust(decision.file);
580
+ lastTrustSeenPersist = Date.now();
581
+ }
582
+ }
583
+ if (decision.allowed)
584
+ return null;
585
+ logger.info('Inbound message rejected by trust gate', {
586
+ fromUserId: msg.from_user_id,
587
+ reason: decision.reason,
588
+ mode: trustFile.mode,
589
+ });
590
+ // 可选:通知 owner 有人尝试联系(不回复陌生人,避免泄露任何内部信息)。
591
+ // 走 notifyThrottle 队列,避免陌生人多条消息刷屏直撞 iLink 主动推送风控。
592
+ if (config.notifyRejected && account.userId && msg.item_list) {
593
+ const text = extractTextFromItems(msg.item_list).slice(0, 80) || '(非文本)';
594
+ const hint = `🔒 陌生人尝试联系:${msg.from_user_id}\n内容预览:${text}`;
595
+ notifyThrottle.enqueue(hint, account.userId);
387
596
  }
388
- processingQueue = false;
597
+ return decision.reason;
389
598
  }
390
599
  function handlePriorityCommand(msg) {
391
600
  if (msg.message_type !== MessageType.USER || !msg.item_list)
392
601
  return false;
393
- // Priority commands are destructive (cancel in-flight turn / clear session).
394
- // Fail-closed sender check: only the bound account owner may trigger them,
395
- // otherwise any contact could stop or wipe someone's running work.
602
+ // 破坏性命令(取消进行中任务 / 清空会话):发送者必须先过信任门禁
603
+ // (onMessage 已检),且只作用于自己的会话。
604
+ // owner-only 模式下与原行为一致:仅 owner 本人。
396
605
  const ownerId = account?.userId;
397
- if (!ownerId || msg.from_user_id !== ownerId)
606
+ const trustFile = loadTrust();
607
+ if (trustFile.mode === 'owner-only') {
608
+ if (!ownerId || msg.from_user_id !== ownerId)
609
+ return false;
610
+ }
611
+ else if (!msg.from_user_id) {
398
612
  return false;
613
+ }
399
614
  const text = extractTextFromItems(msg.item_list);
400
615
  if (!/^\/(?:stop|clear|new)(?:\s|$)/i.test(text))
401
616
  return false;
402
- if (session.state !== 'processing')
617
+ const userId = msg.from_user_id;
618
+ const sessionKey = sessionStore.keyFor(userId);
619
+ const userSession = sessionStore.load(userId);
620
+ if (userSession.state !== 'processing')
403
621
  return false;
404
- messageQueue.length = 0;
622
+ // 只清自己的排队消息,不影响其他用户。
623
+ const q = messageQueues.get(userId);
624
+ if (q)
625
+ q.length = 0;
405
626
  if (/^\/(?:clear|new)(?:\s|$)/i.test(text)) {
406
- const cleared = sessionStore.clear(account.accountId, session);
407
- Object.assign(session, cleared);
627
+ const cleared = sessionStore.clear(userId, userSession);
628
+ Object.assign(userSession, cleared);
408
629
  }
409
630
  else {
410
- session.state = 'idle';
411
- sessionStore.save(account.accountId, session);
631
+ userSession.state = 'idle';
632
+ sessionStore.save(userId, userSession);
412
633
  }
413
634
  if (text.trim().toLowerCase().startsWith('/stop')) {
414
- client.stop(account.accountId).catch(() => { });
415
- sender.sendText(msg.from_user_id, msg.context_token ?? '', '⏹ 已停止当前对话,排队中的消息已清空。').catch(() => { });
635
+ client.stop(sessionKey).catch(() => { });
636
+ sender.sendText(userId, msg.context_token ?? '', '⏹ 已停止当前对话,排队中的消息已清空。').catch(() => { });
416
637
  }
417
638
  else {
418
- client.clear(account.accountId).catch(() => { });
419
- sender.sendText(msg.from_user_id, msg.context_token ?? '', '✅ 会话已清除。').catch(() => { });
639
+ client.clear(sessionKey).catch(() => { });
640
+ sender.sendText(userId, msg.context_token ?? '', '✅ 会话已清除。').catch(() => { });
420
641
  }
421
642
  return true;
422
643
  }
423
644
  /**
424
645
  * 审批回复是时间敏感的交互(host 侧的 agent 正挂着等裁决),必须像
425
646
  * /stop 一样抢在消息队列之前处理——否则排队到任务结束就死锁到超时。
426
- * 同样 fail-closed:只认绑定账号本人的 /yes /no
647
+ * 多用户下任何受信用户都可回复 /yes /no,但只裁决自己 session 的 pending
648
+ * (host 侧 approvalManager 按 session key 归属,双保险)。
427
649
  */
428
650
  async function handleApprovalReply(msg) {
429
651
  if (msg.message_type !== MessageType.USER || !msg.item_list)
430
652
  return false;
431
653
  const ownerId = account?.userId;
432
- if (!ownerId || msg.from_user_id !== ownerId)
654
+ const trustFile = loadTrust();
655
+ if (trustFile.mode === 'owner-only') {
656
+ if (!ownerId || msg.from_user_id !== ownerId)
657
+ return false;
658
+ }
659
+ else if (!msg.from_user_id) {
433
660
  return false;
661
+ }
434
662
  const text = extractTextFromItems(msg.item_list).trim();
435
663
  const match = /^\/(yes|no)(?:\s|$)/i.exec(text);
436
664
  if (!match)
437
665
  return false;
438
666
  const approved = match[1].toLowerCase() === 'yes';
667
+ const userId = msg.from_user_id;
668
+ const sessionKey = sessionStore.keyFor(userId);
439
669
  let reply;
440
670
  try {
441
- const result = await client.decideApproval(account.accountId, approved);
671
+ const result = await client.decideApproval(sessionKey, approved);
442
672
  if (result.ok) {
443
673
  reply = approved
444
674
  ? `✅ 已批准${result.toolName ? `:${result.toolName}` : ''},任务继续执行。`
@@ -454,33 +684,65 @@ async function runDaemon() {
454
684
  catch {
455
685
  reply = '⚠️ 审批结果未能送达 DSH,请到电脑端确认任务状态。';
456
686
  }
457
- await sender.sendText(ownerId, msg.context_token ?? '', reply).catch(() => { });
687
+ await sender.sendText(userId, msg.context_token ?? '', reply).catch(() => { });
458
688
  return true;
459
689
  }
460
690
  const callbacks = {
461
691
  onMessage: async (msg) => {
462
- // 绑定用户的每条入站消息都刷新 context_token(主动推送的通行证)。
463
- if (msg.context_token && msg.from_user_id && msg.from_user_id === account?.userId) {
464
- updateContextToken(msg.context_token);
692
+ // 崩溃安全:重复投递(崩溃重投 / 轮询重叠)直接跳过。
693
+ const dedupKey = msg.message_id !== undefined && msg.message_id !== null
694
+ ? `id:${String(msg.message_id)}`
695
+ : msg.seq !== undefined ? `seq:${String(msg.seq)}` : '';
696
+ if (dedupKey) {
697
+ if (!markSeen(dedupKey)) {
698
+ logger.debug('Duplicate inbound message skipped', { dedupKey });
699
+ return;
700
+ }
701
+ scheduleDedupSave();
702
+ }
703
+ // P1-2 / M1:信任门禁——优先命令与审批回复也受门禁约束(在门禁之后处理)。
704
+ if (msg.message_type === MessageType.USER && msg.from_user_id) {
705
+ if (checkTrustGate(msg) !== null)
706
+ return;
707
+ // 受信用户的每条入站消息都刷新该用户的 context_token(主动推送通行证)。
708
+ if (msg.context_token) {
709
+ lastActiveUserId = msg.from_user_id;
710
+ updateContextToken(msg.from_user_id, msg.context_token);
711
+ }
465
712
  }
466
713
  if (handlePriorityCommand(msg))
467
714
  return;
468
715
  if (await handleApprovalReply(msg))
469
716
  return;
470
- messageQueue.push(msg);
471
- drainQueue();
717
+ if (msg.message_type === MessageType.USER && msg.from_user_id) {
718
+ enqueueMessage(msg);
719
+ }
472
720
  },
473
721
  onSessionExpired: () => {
474
722
  logger.warn('Session expired, will keep retrying...');
475
723
  console.error('⚠️ 微信会话已过期,请重新运行 setup 扫码绑定');
476
724
  },
477
725
  };
726
+ // 崩溃安全:拒绝第二个活着的 daemon 同时轮询(游标双写会互相吞消息)。
727
+ if (!acquirePollLock()) {
728
+ console.error('⚠️ 另一个桥接守护进程正在轮询本账号,本进程退出。若确无其他实例运行,删除 ~/.dsh/wechat-bridge/poll.lock 后重试。');
729
+ process.exit(1);
730
+ }
731
+ const lockTimer = setInterval(writePollLock, 30_000);
732
+ lockTimer.unref?.();
478
733
  const monitor = createMonitor(api, callbacks);
479
734
  function shutdown() {
480
735
  logger.info('Shutting down...');
481
736
  monitor.stop();
482
737
  notifyServer.close();
483
738
  notifyThrottle.stop();
739
+ clearInterval(lockTimer);
740
+ try {
741
+ const lock = readPollLock();
742
+ if (lock?.pid === process.pid)
743
+ unlinkSync(POLL_LOCK_PATH);
744
+ }
745
+ catch { /* ignore */ }
484
746
  try {
485
747
  unlinkSync(notifyPortPath);
486
748
  }
@@ -496,18 +758,18 @@ async function runDaemon() {
496
758
  // ---------------------------------------------------------------------------
497
759
  // Message handling
498
760
  // ---------------------------------------------------------------------------
499
- async function handleMessage(msg, account, session, sessionStore, sender, config, client, messageQueue) {
761
+ async function handleMessage(msg, account, sessionStore, sender, config, client, messageQueue) {
500
762
  if (msg.message_type !== MessageType.USER)
501
763
  return;
502
764
  if (!msg.from_user_id || !msg.item_list)
503
765
  return;
504
- // Fail-closed: with no known owner we accept nobody. login.ts guarantees a
505
- // real userId on save, but an empty/legacy field must deny, not allow-all.
506
- if (!account.userId || msg.from_user_id !== account.userId)
507
- return;
766
+ // 门禁判定(拒绝/放行)已在 onMessage 统一执行(含优先命令与审批回复),
767
+ // 这里重读 trust.json 只是为了给命令上下文提供当前信任状态(trustCtx),不是重复判定。
768
+ const trustFile = loadTrust();
508
769
  const contextToken = msg.context_token ?? '';
509
770
  const fromUserId = msg.from_user_id;
510
- lastActiveUserId = fromUserId;
771
+ // 加载该用户的独立 session(P1-2 / M2:per-user 会话隔离)。
772
+ const session = sessionStore.load(fromUserId);
511
773
  const userText = extractTextFromItems(msg.item_list);
512
774
  const imageItem = extractFirstImageUrl(msg.item_list);
513
775
  const fileItem = extractFirstFileItem(msg.item_list);
@@ -520,27 +782,49 @@ async function handleMessage(msg, account, session, sessionStore, sender, config
520
782
  if (userText.startsWith('/')) {
521
783
  const updateSession = (partial) => {
522
784
  Object.assign(session, partial);
523
- sessionStore.save(account.accountId, session);
785
+ sessionStore.save(fromUserId, session);
524
786
  };
787
+ const trustCtx = trustFile.mode === 'owner-only'
788
+ ? undefined
789
+ : {
790
+ load: loadTrust,
791
+ save: saveTrust,
792
+ listModeLabel: () => {
793
+ switch (trustFile.mode) {
794
+ case 'owner-only': return 'owner-only(仅本机主人)';
795
+ case 'bootstrap': return `bootstrap${trustFile.bootstrapConsumed ? '(已用完首次)' : '(首次联系自动入集)'}`;
796
+ case 'manual': return 'manual(仅 /trust 添加的人)';
797
+ }
798
+ },
799
+ };
525
800
  const ctx = {
526
801
  accountId: account.accountId,
802
+ fromUserId,
803
+ ownerUserId: account.userId,
527
804
  session,
528
805
  updateSession,
529
- clearSession: () => sessionStore.clear(account.accountId),
806
+ clearSession: () => sessionStore.clear(fromUserId),
530
807
  getChatHistoryText: (limit) => sessionStore.getChatHistoryText(session, limit),
531
808
  text: userText,
532
809
  listProjects: () => client.listProjects(),
533
810
  selectProject: (sessionId) => client.selectProject(sessionId),
534
811
  detachProject: () => client.detachProject(),
535
812
  getStatus: () => client.status(),
813
+ trust: trustCtx,
536
814
  };
537
815
  const result = await routeCommand(ctx);
816
+ // /trustmode 的副作用:写入 trust.json(唯一真相源),并即时刷新内存视图。
817
+ if (result.setTrustMode) {
818
+ const updated = setTrustMode(loadTrust(), result.setTrustMode);
819
+ saveTrust(updated);
820
+ }
538
821
  if (result.handled && result.reply) {
539
822
  await sender.sendText(fromUserId, contextToken, result.reply);
540
823
  // /clear and /new must also clear the real DSH session (and its persisted
541
824
  // id mapping), even when the daemon is idle and not just mid-turn.
542
825
  if (/^\/(?:clear|new)(?:\s|$)/i.test(userText.trim())) {
543
- await client.clear(account.accountId).catch((err) => {
826
+ const sessionKey = sessionStore.keyFor(fromUserId);
827
+ await client.clear(sessionKey).catch((err) => {
544
828
  logger.warn('Failed to clear DSH session from slash command', {
545
829
  error: err instanceof Error ? err.message : String(err),
546
830
  });
@@ -566,8 +850,11 @@ async function handleMessage(msg, account, session, sessionStore, sender, config
566
850
  await sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken, account, session, sessionStore, sender, config, client);
567
851
  }
568
852
  async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken, account, session, sessionStore, sender, config, client) {
853
+ // P1-2 / M2:session key = botAccountId::userId —— 每个微信用户独立会话。
854
+ // owner 也走同一套路径(与迁移后的数据一致)。
855
+ const sessionKey = sessionStore.keyFor(fromUserId);
569
856
  session.state = 'processing';
570
- sessionStore.save(account.accountId, session);
857
+ sessionStore.save(fromUserId, session);
571
858
  sessionStore.addChatMessage(session, 'user', userText || '(图片/文件)');
572
859
  const stopTyping = sender.startTyping(fromUserId, contextToken);
573
860
  try {
@@ -595,7 +882,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
595
882
  }
596
883
  const accepted = await client.prompt({
597
884
  text: prompt,
598
- sessionId: account.accountId,
885
+ sessionId: sessionKey,
599
886
  cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()),
600
887
  model: session.model,
601
888
  systemPrompt: config.systemPrompt,
@@ -604,7 +891,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
604
891
  if (!accepted) {
605
892
  await sender.sendText(fromUserId, contextToken, '消息已收到,但 DSH 未接受处理请求。');
606
893
  session.state = 'idle';
607
- sessionStore.save(account.accountId, session);
894
+ sessionStore.save(fromUserId, session);
608
895
  return;
609
896
  }
610
897
  // Stream the assistant response back.
@@ -613,6 +900,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
613
900
  let keepaliveTimer;
614
901
  let pendingSend = '';
615
902
  let lastSentTime = Date.now();
903
+ let turnUsage;
616
904
  // 流式攒批策略:攒够字数或流停顿时才发送,避免碎片消息刷屏:
617
905
  // - 缓冲 ≥ 1200 字:立即按自然边界切出一段发送(见 chunk 分支);
618
906
  // - 缓冲 ≥ 600 字:定时器补一刀发掉;
@@ -664,7 +952,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
664
952
  }
665
953
  }, 2000);
666
954
  const controller = new AbortController();
667
- await client.stream(account.accountId, (event) => {
955
+ await client.stream(sessionKey, (event) => {
668
956
  switch (event.type) {
669
957
  case 'chunk':
670
958
  if (event.text) {
@@ -686,7 +974,9 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
686
974
  }
687
975
  break;
688
976
  case 'done':
689
- // Wait for the next stream tick to flush the remaining buffer.
977
+ // 流结束:记录本轮用量(供尾注),剩余缓冲由下方 flush 兜底。
978
+ if (event.usage)
979
+ turnUsage = event.usage;
690
980
  break;
691
981
  }
692
982
  }, controller.signal);
@@ -694,6 +984,15 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
694
984
  clearInterval(flushTimer);
695
985
  if (keepaliveTimer)
696
986
  clearInterval(keepaliveTimer);
987
+ // 上下文用量尾注:inputTokens + cacheReadTokens ≈ 当前上下文大小。
988
+ // 并入最后一段缓冲一起发,不额外产生消息(config.json 可关:usageFooter=false)。
989
+ if (config.usageFooter !== false && turnUsage) {
990
+ const ctxK = Math.round(((turnUsage.inputTokens ?? 0) + (turnUsage.cacheReadTokens ?? 0)) / 100) / 10;
991
+ const out = turnUsage.outputTokens ?? 0;
992
+ if (ctxK > 0) {
993
+ pendingSend += `\n\n🧮 上下文约 ${ctxK}k tokens · 本轮输出 ${out}`;
994
+ }
995
+ }
697
996
  await flush();
698
997
  const resultText = finalText.trim();
699
998
  if (resultText) {
@@ -730,7 +1029,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
730
1029
  }
731
1030
  finally {
732
1031
  session.state = 'idle';
733
- sessionStore.save(account.accountId, session);
1032
+ sessionStore.save(fromUserId, session);
734
1033
  stopTyping();
735
1034
  }
736
1035
  }