@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.
- package/README.md +45 -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 +6 -0
- package/lib/bridge/config.js.map +1 -1
- package/lib/bridge/dsh-client.js.map +1 -1
- package/lib/bridge/main.js +370 -71
- 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/store.js +10 -2
- package/lib/bridge/store.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 +259 -26
- 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 +8 -0
- package/lib/types/bridge/dsh-client.d.ts +7 -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';
|
|
@@ -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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
277
|
-
if (!
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
602
|
+
// 破坏性命令(取消进行中任务 / 清空会话):发送者必须先过信任门禁
|
|
603
|
+
// (onMessage 已检),且只作用于自己的会话。
|
|
604
|
+
// owner-only 模式下与原行为一致:仅 owner 本人。
|
|
396
605
|
const ownerId = account?.userId;
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
407
|
-
Object.assign(
|
|
627
|
+
const cleared = sessionStore.clear(userId, userSession);
|
|
628
|
+
Object.assign(userSession, cleared);
|
|
408
629
|
}
|
|
409
630
|
else {
|
|
410
|
-
|
|
411
|
-
sessionStore.save(
|
|
631
|
+
userSession.state = 'idle';
|
|
632
|
+
sessionStore.save(userId, userSession);
|
|
412
633
|
}
|
|
413
634
|
if (text.trim().toLowerCase().startsWith('/stop')) {
|
|
414
|
-
client.stop(
|
|
415
|
-
sender.sendText(
|
|
635
|
+
client.stop(sessionKey).catch(() => { });
|
|
636
|
+
sender.sendText(userId, msg.context_token ?? '', '⏹ 已停止当前对话,排队中的消息已清空。').catch(() => { });
|
|
416
637
|
}
|
|
417
638
|
else {
|
|
418
|
-
client.clear(
|
|
419
|
-
sender.sendText(
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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,
|
|
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
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
1032
|
+
sessionStore.save(fromUserId, session);
|
|
734
1033
|
stopTyping();
|
|
735
1034
|
}
|
|
736
1035
|
}
|