@lanbaolu/dsh-wechat-bridge 0.5.0 → 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.
@@ -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';
@@ -20,6 +20,7 @@ import { DATA_DIR } from './constants.js';
20
20
  import { MessageType } from './wechat/types.js';
21
21
  import { DshClient } from './dsh-client.js';
22
22
  import { createNotifyThrottle } from './notify.js';
23
+ import { loadTrust, saveTrust, decideTrust, setTrustMode } from './trust.js';
23
24
  // ---------------------------------------------------------------------------
24
25
  // Helpers
25
26
  // ---------------------------------------------------------------------------
@@ -30,15 +31,53 @@ const MAX_MESSAGE_LENGTH = 4000;
30
31
  */
31
32
  let lastActiveUserId = '';
32
33
  /**
33
- * iLink 主动发消息(bot → 用户)必须回传最近一次入站消息携带的
34
+ * iLink 主动发消息(bot → 用户)必须回传该用户最近一次入站消息携带的
34
35
  * context_token,空 token 会被服务端拒绝(ret:-2 "prepare failed")。
35
- * 每次收到绑定用户的消息就刷新并持久化它,供主动通知 / 审批推送使用。
36
+ *
37
+ * P1-2 / M3:每个受信用户各存一份(入站消息按 from_user_id 刷新),
38
+ * 主动通知 / 审批推送显式带 userId 取对应 token;`lastContextToken`
39
+ * 保留为兜底(未带 userId 的旧调用路径 / 未知用户回退)。
36
40
  */
41
+ const contextTokens = new Map();
37
42
  let lastContextToken = '';
38
43
  function contextTokenPath() {
39
44
  return join(DATA_DIR, 'context-token.json');
40
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
+ }
41
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 兜底
42
81
  try {
43
82
  const parsed = JSON.parse(readFileSync(contextTokenPath(), 'utf8'));
44
83
  lastContextToken = typeof parsed.token === 'string' ? parsed.token : '';
@@ -47,17 +86,26 @@ function loadContextToken() {
47
86
  lastContextToken = '';
48
87
  }
49
88
  }
50
- function updateContextToken(token) {
51
- 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)
52
97
  return;
53
- lastContextToken = token;
54
- try {
55
- mkdirSync(DATA_DIR, { recursive: true });
56
- writeFileSync(contextTokenPath(), JSON.stringify({ token, updatedAt: Date.now() }) + '\n', 'utf8');
98
+ let changed = false;
99
+ if (userId && contextTokens.get(userId) !== token) {
100
+ contextTokens.set(userId, token);
101
+ changed = true;
57
102
  }
58
- catch (err) {
59
- logger.warn('Failed to persist context token', { error: err instanceof Error ? err.message : String(err) });
103
+ if (token !== lastContextToken) {
104
+ lastContextToken = token;
105
+ changed = true;
60
106
  }
107
+ if (changed)
108
+ persistContextTokens();
61
109
  }
62
110
  // ---------------------------------------------------------------------------
63
111
  // 崩溃安全:跨进程轮询锁 + 入站去重
@@ -367,11 +415,13 @@ async function runSetup() {
367
415
  // ---------------------------------------------------------------------------
368
416
  async function runDaemon() {
369
417
  const config = loadConfig();
370
- const account = loadLatestAccount();
371
- if (!account) {
418
+ const loadedAccount = loadLatestAccount();
419
+ if (!loadedAccount) {
372
420
  console.error('未找到微信账号,请先运行: node lib/bridge/main.js setup');
373
421
  process.exit(1);
374
422
  }
423
+ // 守卫后 account 必非 null;闭包(信任门禁等)拿不到收窄,这里显式声明非空。
424
+ const account = loadedAccount;
375
425
  const apiBase = process.env.DSH_BRIDGE_API_BASE;
376
426
  const apiToken = process.env.DSH_BRIDGE_API_TOKEN;
377
427
  if (!apiBase || !apiToken) {
@@ -380,17 +430,15 @@ async function runDaemon() {
380
430
  }
381
431
  const client = new DshClient(apiBase, apiToken);
382
432
  const api = new WeChatApi(account.botToken, account.baseUrl);
383
- const sessionStore = createSessionStore();
384
- const session = sessionStore.load(account.accountId);
385
- if (config.workingDirectory && session.workingDirectory === process.cwd()) {
386
- session.workingDirectory = config.workingDirectory;
387
- sessionStore.save(account.accountId, session);
388
- }
389
- if (session.state !== 'idle') {
390
- logger.warn('Resetting stale session state on startup', { state: session.state });
391
- session.state = 'idle';
392
- sessionStore.save(account.accountId, session);
393
- }
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();
394
442
  const sender = createSender(api, account.accountId);
395
443
  lastActiveUserId = account.userId || '';
396
444
  loadContextToken();
@@ -399,8 +447,13 @@ async function runDaemon() {
399
447
  // Proactive notification endpoint (DSH → daemon), throttled.
400
448
  // WeChat personal accounts are sensitive to proactive high-frequency pushes,
401
449
  // so notifications go through a queue + rate limits (see notify.ts).
450
+ // P1-2 / M3:body 可带 userId 指定目标用户(取该用户的 context_token),
451
+ // 缺省回退 lastActiveUserId(旧调用路径兼容)。
402
452
  // -------------------------------------------------------------------------
403
- 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
+ });
404
457
  const notifyPortPath = join(DATA_DIR, 'daemon-port.json');
405
458
  const notifyServer = createServer((req, res) => {
406
459
  const token = process.env.DSH_BRIDGE_API_TOKEN;
@@ -431,6 +484,7 @@ async function runDaemon() {
431
484
  try {
432
485
  const parsed = JSON.parse(body);
433
486
  const message = String(parsed?.message ?? '');
487
+ const targetUserId = typeof parsed?.userId === 'string' && parsed.userId ? parsed.userId : undefined;
434
488
  // 审批是阻塞交互且量极低(由用户自己的任务触发),绕过节流直发,
435
489
  // 否则 60s 的最小通知间隔会把审批拖到超时。
436
490
  if (isApproval) {
@@ -439,7 +493,8 @@ async function runDaemon() {
439
493
  res.end(JSON.stringify({ ok: false, error: 'empty message' }));
440
494
  return;
441
495
  }
442
- sender.sendText(lastActiveUserId, lastContextToken, message)
496
+ const target = targetUserId || lastActiveUserId;
497
+ sender.sendText(target, contextTokenFor(target), message)
443
498
  .then(() => {
444
499
  res.writeHead(200, { 'Content-Type': 'application/json' });
445
500
  res.end(JSON.stringify({ ok: true }));
@@ -450,7 +505,7 @@ async function runDaemon() {
450
505
  });
451
506
  return;
452
507
  }
453
- const result = notifyThrottle.enqueue(message);
508
+ const result = notifyThrottle.enqueue(message, targetUserId);
454
509
  res.writeHead(result.accepted ? 200 : 400, { 'Content-Type': 'application/json' });
455
510
  res.end(JSON.stringify(result));
456
511
  }
@@ -470,70 +525,150 @@ async function runDaemon() {
470
525
  catch (err) {
471
526
  logger.warn('Failed to persist notify endpoint info', { error: err instanceof Error ? err.message : String(err) });
472
527
  }
473
- const messageQueue = [];
474
- let processingQueue = false;
475
- async function drainQueue() {
476
- 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))
477
546
  return;
478
- processingQueue = true;
479
- while (messageQueue.length > 0) {
480
- const msg = messageQueue.shift();
481
- 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);
482
596
  }
483
- processingQueue = false;
597
+ return decision.reason;
484
598
  }
485
599
  function handlePriorityCommand(msg) {
486
600
  if (msg.message_type !== MessageType.USER || !msg.item_list)
487
601
  return false;
488
- // Priority commands are destructive (cancel in-flight turn / clear session).
489
- // Fail-closed sender check: only the bound account owner may trigger them,
490
- // otherwise any contact could stop or wipe someone's running work.
602
+ // 破坏性命令(取消进行中任务 / 清空会话):发送者必须先过信任门禁
603
+ // (onMessage 已检),且只作用于自己的会话。
604
+ // owner-only 模式下与原行为一致:仅 owner 本人。
491
605
  const ownerId = account?.userId;
492
- 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) {
493
612
  return false;
613
+ }
494
614
  const text = extractTextFromItems(msg.item_list);
495
615
  if (!/^\/(?:stop|clear|new)(?:\s|$)/i.test(text))
496
616
  return false;
497
- 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')
498
621
  return false;
499
- messageQueue.length = 0;
622
+ // 只清自己的排队消息,不影响其他用户。
623
+ const q = messageQueues.get(userId);
624
+ if (q)
625
+ q.length = 0;
500
626
  if (/^\/(?:clear|new)(?:\s|$)/i.test(text)) {
501
- const cleared = sessionStore.clear(account.accountId, session);
502
- Object.assign(session, cleared);
627
+ const cleared = sessionStore.clear(userId, userSession);
628
+ Object.assign(userSession, cleared);
503
629
  }
504
630
  else {
505
- session.state = 'idle';
506
- sessionStore.save(account.accountId, session);
631
+ userSession.state = 'idle';
632
+ sessionStore.save(userId, userSession);
507
633
  }
508
634
  if (text.trim().toLowerCase().startsWith('/stop')) {
509
- client.stop(account.accountId).catch(() => { });
510
- sender.sendText(msg.from_user_id, msg.context_token ?? '', '⏹ 已停止当前对话,排队中的消息已清空。').catch(() => { });
635
+ client.stop(sessionKey).catch(() => { });
636
+ sender.sendText(userId, msg.context_token ?? '', '⏹ 已停止当前对话,排队中的消息已清空。').catch(() => { });
511
637
  }
512
638
  else {
513
- client.clear(account.accountId).catch(() => { });
514
- sender.sendText(msg.from_user_id, msg.context_token ?? '', '✅ 会话已清除。').catch(() => { });
639
+ client.clear(sessionKey).catch(() => { });
640
+ sender.sendText(userId, msg.context_token ?? '', '✅ 会话已清除。').catch(() => { });
515
641
  }
516
642
  return true;
517
643
  }
518
644
  /**
519
645
  * 审批回复是时间敏感的交互(host 侧的 agent 正挂着等裁决),必须像
520
646
  * /stop 一样抢在消息队列之前处理——否则排队到任务结束就死锁到超时。
521
- * 同样 fail-closed:只认绑定账号本人的 /yes /no
647
+ * 多用户下任何受信用户都可回复 /yes /no,但只裁决自己 session 的 pending
648
+ * (host 侧 approvalManager 按 session key 归属,双保险)。
522
649
  */
523
650
  async function handleApprovalReply(msg) {
524
651
  if (msg.message_type !== MessageType.USER || !msg.item_list)
525
652
  return false;
526
653
  const ownerId = account?.userId;
527
- 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) {
528
660
  return false;
661
+ }
529
662
  const text = extractTextFromItems(msg.item_list).trim();
530
663
  const match = /^\/(yes|no)(?:\s|$)/i.exec(text);
531
664
  if (!match)
532
665
  return false;
533
666
  const approved = match[1].toLowerCase() === 'yes';
667
+ const userId = msg.from_user_id;
668
+ const sessionKey = sessionStore.keyFor(userId);
534
669
  let reply;
535
670
  try {
536
- const result = await client.decideApproval(account.accountId, approved);
671
+ const result = await client.decideApproval(sessionKey, approved);
537
672
  if (result.ok) {
538
673
  reply = approved
539
674
  ? `✅ 已批准${result.toolName ? `:${result.toolName}` : ''},任务继续执行。`
@@ -549,7 +684,7 @@ async function runDaemon() {
549
684
  catch {
550
685
  reply = '⚠️ 审批结果未能送达 DSH,请到电脑端确认任务状态。';
551
686
  }
552
- await sender.sendText(ownerId, msg.context_token ?? '', reply).catch(() => { });
687
+ await sender.sendText(userId, msg.context_token ?? '', reply).catch(() => { });
553
688
  return true;
554
689
  }
555
690
  const callbacks = {
@@ -565,16 +700,23 @@ async function runDaemon() {
565
700
  }
566
701
  scheduleDedupSave();
567
702
  }
568
- // 绑定用户的每条入站消息都刷新 context_token(主动推送的通行证)。
569
- if (msg.context_token && msg.from_user_id && msg.from_user_id === account?.userId) {
570
- updateContextToken(msg.context_token);
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
+ }
571
712
  }
572
713
  if (handlePriorityCommand(msg))
573
714
  return;
574
715
  if (await handleApprovalReply(msg))
575
716
  return;
576
- messageQueue.push(msg);
577
- drainQueue();
717
+ if (msg.message_type === MessageType.USER && msg.from_user_id) {
718
+ enqueueMessage(msg);
719
+ }
578
720
  },
579
721
  onSessionExpired: () => {
580
722
  logger.warn('Session expired, will keep retrying...');
@@ -616,18 +758,18 @@ async function runDaemon() {
616
758
  // ---------------------------------------------------------------------------
617
759
  // Message handling
618
760
  // ---------------------------------------------------------------------------
619
- async function handleMessage(msg, account, session, sessionStore, sender, config, client, messageQueue) {
761
+ async function handleMessage(msg, account, sessionStore, sender, config, client, messageQueue) {
620
762
  if (msg.message_type !== MessageType.USER)
621
763
  return;
622
764
  if (!msg.from_user_id || !msg.item_list)
623
765
  return;
624
- // Fail-closed: with no known owner we accept nobody. login.ts guarantees a
625
- // real userId on save, but an empty/legacy field must deny, not allow-all.
626
- if (!account.userId || msg.from_user_id !== account.userId)
627
- return;
766
+ // 门禁判定(拒绝/放行)已在 onMessage 统一执行(含优先命令与审批回复),
767
+ // 这里重读 trust.json 只是为了给命令上下文提供当前信任状态(trustCtx),不是重复判定。
768
+ const trustFile = loadTrust();
628
769
  const contextToken = msg.context_token ?? '';
629
770
  const fromUserId = msg.from_user_id;
630
- lastActiveUserId = fromUserId;
771
+ // 加载该用户的独立 session(P1-2 / M2:per-user 会话隔离)。
772
+ const session = sessionStore.load(fromUserId);
631
773
  const userText = extractTextFromItems(msg.item_list);
632
774
  const imageItem = extractFirstImageUrl(msg.item_list);
633
775
  const fileItem = extractFirstFileItem(msg.item_list);
@@ -640,27 +782,49 @@ async function handleMessage(msg, account, session, sessionStore, sender, config
640
782
  if (userText.startsWith('/')) {
641
783
  const updateSession = (partial) => {
642
784
  Object.assign(session, partial);
643
- sessionStore.save(account.accountId, session);
785
+ sessionStore.save(fromUserId, session);
644
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
+ };
645
800
  const ctx = {
646
801
  accountId: account.accountId,
802
+ fromUserId,
803
+ ownerUserId: account.userId,
647
804
  session,
648
805
  updateSession,
649
- clearSession: () => sessionStore.clear(account.accountId),
806
+ clearSession: () => sessionStore.clear(fromUserId),
650
807
  getChatHistoryText: (limit) => sessionStore.getChatHistoryText(session, limit),
651
808
  text: userText,
652
809
  listProjects: () => client.listProjects(),
653
810
  selectProject: (sessionId) => client.selectProject(sessionId),
654
811
  detachProject: () => client.detachProject(),
655
812
  getStatus: () => client.status(),
813
+ trust: trustCtx,
656
814
  };
657
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
+ }
658
821
  if (result.handled && result.reply) {
659
822
  await sender.sendText(fromUserId, contextToken, result.reply);
660
823
  // /clear and /new must also clear the real DSH session (and its persisted
661
824
  // id mapping), even when the daemon is idle and not just mid-turn.
662
825
  if (/^\/(?:clear|new)(?:\s|$)/i.test(userText.trim())) {
663
- await client.clear(account.accountId).catch((err) => {
826
+ const sessionKey = sessionStore.keyFor(fromUserId);
827
+ await client.clear(sessionKey).catch((err) => {
664
828
  logger.warn('Failed to clear DSH session from slash command', {
665
829
  error: err instanceof Error ? err.message : String(err),
666
830
  });
@@ -686,8 +850,11 @@ async function handleMessage(msg, account, session, sessionStore, sender, config
686
850
  await sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken, account, session, sessionStore, sender, config, client);
687
851
  }
688
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);
689
856
  session.state = 'processing';
690
- sessionStore.save(account.accountId, session);
857
+ sessionStore.save(fromUserId, session);
691
858
  sessionStore.addChatMessage(session, 'user', userText || '(图片/文件)');
692
859
  const stopTyping = sender.startTyping(fromUserId, contextToken);
693
860
  try {
@@ -715,7 +882,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
715
882
  }
716
883
  const accepted = await client.prompt({
717
884
  text: prompt,
718
- sessionId: account.accountId,
885
+ sessionId: sessionKey,
719
886
  cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, homedir()),
720
887
  model: session.model,
721
888
  systemPrompt: config.systemPrompt,
@@ -724,7 +891,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
724
891
  if (!accepted) {
725
892
  await sender.sendText(fromUserId, contextToken, '消息已收到,但 DSH 未接受处理请求。');
726
893
  session.state = 'idle';
727
- sessionStore.save(account.accountId, session);
894
+ sessionStore.save(fromUserId, session);
728
895
  return;
729
896
  }
730
897
  // Stream the assistant response back.
@@ -785,7 +952,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
785
952
  }
786
953
  }, 2000);
787
954
  const controller = new AbortController();
788
- await client.stream(account.accountId, (event) => {
955
+ await client.stream(sessionKey, (event) => {
789
956
  switch (event.type) {
790
957
  case 'chunk':
791
958
  if (event.text) {
@@ -862,7 +1029,7 @@ async function sendToDsh(userText, imageItem, fileItem, fromUserId, contextToken
862
1029
  }
863
1030
  finally {
864
1031
  session.state = 'idle';
865
- sessionStore.save(account.accountId, session);
1032
+ sessionStore.save(fromUserId, session);
866
1033
  stopTyping();
867
1034
  }
868
1035
  }