@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.
- package/README.md +38 -1
- package/docs/plan-multi-user.md +92 -0
- package/lib/approval.js +2 -2
- package/lib/approval.js.map +1 -1
- package/lib/bridge/commands/handlers.js +119 -0
- package/lib/bridge/commands/handlers.js.map +1 -1
- package/lib/bridge/commands/router.js +11 -1
- package/lib/bridge/commands/router.js.map +1 -1
- package/lib/bridge/config.js +4 -1
- package/lib/bridge/config.js.map +1 -1
- package/lib/bridge/main.js +240 -73
- package/lib/bridge/main.js.map +1 -1
- package/lib/bridge/notify.js +9 -8
- package/lib/bridge/notify.js.map +1 -1
- package/lib/bridge/session-key.js +62 -0
- package/lib/bridge/session-key.js.map +1 -0
- package/lib/bridge/session.js +109 -16
- package/lib/bridge/session.js.map +1 -1
- package/lib/bridge/trust.js +160 -0
- package/lib/bridge/trust.js.map +1 -0
- package/lib/client/Panel.js +99 -2
- package/lib/client/Panel.js.map +1 -1
- package/lib/client.js +279 -0
- package/lib/client.js.map +1 -1
- package/lib/index.js +253 -25
- package/lib/index.js.map +1 -1
- package/lib/types/approval.d.ts +6 -2
- package/lib/types/bridge/commands/handlers.d.ts +12 -0
- package/lib/types/bridge/commands/router.d.ts +13 -0
- package/lib/types/bridge/config.d.ts +6 -0
- package/lib/types/bridge/notify.d.ts +3 -3
- package/lib/types/bridge/session-key.d.ts +14 -0
- package/lib/types/bridge/session.d.ts +15 -4
- package/lib/types/bridge/trust.d.ts +61 -0
- package/package.json +1 -1
package/lib/bridge/main.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
98
|
+
let changed = false;
|
|
99
|
+
if (userId && contextTokens.get(userId) !== token) {
|
|
100
|
+
contextTokens.set(userId, token);
|
|
101
|
+
changed = true;
|
|
57
102
|
}
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
371
|
-
if (!
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
602
|
+
// 破坏性命令(取消进行中任务 / 清空会话):发送者必须先过信任门禁
|
|
603
|
+
// (onMessage 已检),且只作用于自己的会话。
|
|
604
|
+
// owner-only 模式下与原行为一致:仅 owner 本人。
|
|
491
605
|
const ownerId = account?.userId;
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
502
|
-
Object.assign(
|
|
627
|
+
const cleared = sessionStore.clear(userId, userSession);
|
|
628
|
+
Object.assign(userSession, cleared);
|
|
503
629
|
}
|
|
504
630
|
else {
|
|
505
|
-
|
|
506
|
-
sessionStore.save(
|
|
631
|
+
userSession.state = 'idle';
|
|
632
|
+
sessionStore.save(userId, userSession);
|
|
507
633
|
}
|
|
508
634
|
if (text.trim().toLowerCase().startsWith('/stop')) {
|
|
509
|
-
client.stop(
|
|
510
|
-
sender.sendText(
|
|
635
|
+
client.stop(sessionKey).catch(() => { });
|
|
636
|
+
sender.sendText(userId, msg.context_token ?? '', '⏹ 已停止当前对话,排队中的消息已清空。').catch(() => { });
|
|
511
637
|
}
|
|
512
638
|
else {
|
|
513
|
-
client.clear(
|
|
514
|
-
sender.sendText(
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
569
|
-
if (msg.
|
|
570
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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,
|
|
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
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
1032
|
+
sessionStore.save(fromUserId, session);
|
|
866
1033
|
stopTyping();
|
|
867
1034
|
}
|
|
868
1035
|
}
|