@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/index.js
CHANGED
|
@@ -24,6 +24,8 @@ import { createApprovalManager } from './approval.js';
|
|
|
24
24
|
import { createWechatAgentSetup } from './agent-setup.js';
|
|
25
25
|
import { startQrLogin, checkQrStatus } from './bridge/wechat/login.js';
|
|
26
26
|
import { loadJson, saveJson, validateAccountId } from './bridge/store.js';
|
|
27
|
+
import { loadTrust, saveTrust, addTrusted, removeTrusted, setTrustMode, listTrusted, isPlausibleUserId } from './bridge/trust.js';
|
|
28
|
+
import { parseSessionKey } from './bridge/session-key.js';
|
|
27
29
|
export const name = '@lanbaolu/dsh-wechat-bridge';
|
|
28
30
|
/** Host services the plugin needs. `webServer` is optional (headless profiles). */
|
|
29
31
|
export const inject = ['tools', 'agents', 'agentDefaultModel', 'agentPresets'];
|
|
@@ -103,6 +105,32 @@ export function apply(ctx, config) {
|
|
|
103
105
|
saveSessionIdMap(map);
|
|
104
106
|
}
|
|
105
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* 多用户迁移(P1-2 / M2):旧单用户时代 session-ids.json 的 key 是 bot accountId,
|
|
110
|
+
* 新版统一为 `${accountId}::${userId}`。能确定 owner 的条目一次性改名为新 key
|
|
111
|
+
* (owner 的 DSH 会话无缝续上);不能确定的保留原样不动(绝不丢历史,
|
|
112
|
+
* 只是该条目不会再被命中,新消息走新 key)。
|
|
113
|
+
*/
|
|
114
|
+
function migrateSessionIdMap() {
|
|
115
|
+
const map = loadSessionIdMap();
|
|
116
|
+
let changed = false;
|
|
117
|
+
for (const key of Object.keys(map)) {
|
|
118
|
+
if (key.includes('::'))
|
|
119
|
+
continue;
|
|
120
|
+
const owner = ownerUserIdOf(key);
|
|
121
|
+
if (!owner)
|
|
122
|
+
continue;
|
|
123
|
+
const newKey = `${key}::${owner}`;
|
|
124
|
+
if (!(newKey in map)) {
|
|
125
|
+
map[newKey] = map[key];
|
|
126
|
+
delete map[key];
|
|
127
|
+
changed = true;
|
|
128
|
+
debugLog('session-ids.json migrated to per-user key', { oldKey: key, newKey });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (changed)
|
|
132
|
+
saveSessionIdMap(map);
|
|
133
|
+
}
|
|
106
134
|
// -------------------------------------------------------------------------
|
|
107
135
|
// Explicit project-conversation binding (Web panel selection)
|
|
108
136
|
// -------------------------------------------------------------------------
|
|
@@ -134,8 +162,54 @@ export function apply(ctx, config) {
|
|
|
134
162
|
}
|
|
135
163
|
}
|
|
136
164
|
const selectedSessionIds = new Map(Object.entries(loadSelectedSessionIds()));
|
|
137
|
-
function newDshSessionId(
|
|
138
|
-
|
|
165
|
+
function newDshSessionId(key) {
|
|
166
|
+
// 多用户 key 形如 ${botAccountId}::${userId}——DSH 会话 ID 会进文件路径,
|
|
167
|
+
// ':' 在 Windows 上是非法文件名字符,统一清洗为 '-'。
|
|
168
|
+
const safe = key.replace(/[^A-Za-z0-9_.@-]/g, '-');
|
|
169
|
+
return `wb-${safe}-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
170
|
+
}
|
|
171
|
+
/** session key 的 bot 账号前缀('::' 之前;单段 key 原样返回)。 */
|
|
172
|
+
function botPrefixOf(key) {
|
|
173
|
+
const i = key.indexOf('::');
|
|
174
|
+
return i === -1 ? key : key.slice(0, i);
|
|
175
|
+
}
|
|
176
|
+
/** 从 session key 解出微信用户 ID(单段旧 key 返回空)。 */
|
|
177
|
+
function userIdOfKey(key) {
|
|
178
|
+
return parseSessionKey(key)?.userId || '';
|
|
179
|
+
}
|
|
180
|
+
// -------------------------------------------------------------------------
|
|
181
|
+
// 信任集(多用户支持 P1-2 / M1)——trust.json 是唯一真相源
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
function trustPath() {
|
|
184
|
+
return join(dataDir, 'trust.json');
|
|
185
|
+
}
|
|
186
|
+
function loadTrustFile() {
|
|
187
|
+
return loadTrust(trustPath());
|
|
188
|
+
}
|
|
189
|
+
function saveTrustFile(file) {
|
|
190
|
+
saveTrust(file, trustPath());
|
|
191
|
+
}
|
|
192
|
+
/** owner userId:最新绑定账号的 userId(用于面板展示与账号级会话文件定位)。 */
|
|
193
|
+
function ownerUserIdOf(accountId) {
|
|
194
|
+
try {
|
|
195
|
+
const acc = loadJson(join(dataDir, 'accounts', `${accountId}.json`), {});
|
|
196
|
+
return typeof acc.userId === 'string' ? acc.userId : '';
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* daemon 侧会话文件名:session key `${bot}::${user}` → `${bot}__${user}`;
|
|
204
|
+
* 账号级 key(面板操作)→ 定位 owner 的 per-user 文件,找不到才退回旧文件名。
|
|
205
|
+
*/
|
|
206
|
+
function bridgeSessionStem(key) {
|
|
207
|
+
if (key.includes('::'))
|
|
208
|
+
return key.replace(/::/g, '__');
|
|
209
|
+
const owner = ownerUserIdOf(key);
|
|
210
|
+
if (owner && /^[A-Za-z0-9_.\-@=]+$/.test(owner))
|
|
211
|
+
return `${key}__${owner}`;
|
|
212
|
+
return key;
|
|
139
213
|
}
|
|
140
214
|
// -------------------------------------------------------------------------
|
|
141
215
|
// Agent management
|
|
@@ -169,7 +243,13 @@ export function apply(ctx, config) {
|
|
|
169
243
|
// when there is no mapping, the old log is gone/corrupt, or the requested
|
|
170
244
|
// workspace differs from the persisted session's cwd (unless the user
|
|
171
245
|
// explicitly bound the bridge to a project conversation).
|
|
172
|
-
|
|
246
|
+
//
|
|
247
|
+
// 项目绑定两级粒度:
|
|
248
|
+
// - session key 级(微信内 /session 绑定):只对当前用户生效;
|
|
249
|
+
// - 账号级(Web 面板选择):对该 bot 下所有用户生效(前缀回退查找)。
|
|
250
|
+
const selectedSessionId = selectedSessionIds.get(accountId)
|
|
251
|
+
?? (accountId.includes('::') ? selectedSessionIds.get(botPrefixOf(accountId)) : undefined);
|
|
252
|
+
const selectedIsAccountLevel = !!selectedSessionId && !selectedSessionIds.has(accountId);
|
|
173
253
|
let dshSessionId = sessionIds.get(accountId);
|
|
174
254
|
let handle;
|
|
175
255
|
let resumed = false;
|
|
@@ -246,10 +326,10 @@ export function apply(ctx, config) {
|
|
|
246
326
|
const finalSessionId = dshSessionId;
|
|
247
327
|
sessionIds.set(accountId, finalSessionId);
|
|
248
328
|
persistSessionId(accountId, finalSessionId);
|
|
249
|
-
if (isSelected) {
|
|
329
|
+
if (isSelected && !selectedIsAccountLevel) {
|
|
250
330
|
persistSelectedSessionId(accountId, finalSessionId);
|
|
251
331
|
}
|
|
252
|
-
else if (selectedSessionIds.has(accountId)) {
|
|
332
|
+
else if (!selectedIsAccountLevel && selectedSessionIds.has(accountId)) {
|
|
253
333
|
selectedSessionIds.delete(accountId);
|
|
254
334
|
removeSelectedSessionId(accountId);
|
|
255
335
|
}
|
|
@@ -450,20 +530,22 @@ export function apply(ctx, config) {
|
|
|
450
530
|
message: `已进入项目 ${item.workspaceTitle}(${item.path}),后续对话会记录到这个项目。`,
|
|
451
531
|
};
|
|
452
532
|
}
|
|
453
|
-
function readBridgeAccountSession(
|
|
454
|
-
|
|
455
|
-
|
|
533
|
+
function readBridgeAccountSession(key) {
|
|
534
|
+
const stem = bridgeSessionStem(key);
|
|
535
|
+
validateAccountId(stem);
|
|
536
|
+
return loadJson(join(dataDir, 'sessions', `${stem}.json`), {});
|
|
456
537
|
}
|
|
457
|
-
function writeBridgeAccountSession(
|
|
458
|
-
|
|
459
|
-
|
|
538
|
+
function writeBridgeAccountSession(key, session) {
|
|
539
|
+
const stem = bridgeSessionStem(key);
|
|
540
|
+
validateAccountId(stem);
|
|
541
|
+
saveJson(join(dataDir, 'sessions', `${stem}.json`), session);
|
|
460
542
|
}
|
|
461
|
-
function resetBridgeAccountSession(
|
|
462
|
-
const session = readBridgeAccountSession(
|
|
543
|
+
function resetBridgeAccountSession(key, cwd) {
|
|
544
|
+
const session = readBridgeAccountSession(key);
|
|
463
545
|
session.workingDirectory = cwd;
|
|
464
546
|
session.state = 'idle';
|
|
465
547
|
session.chatHistory = [];
|
|
466
|
-
writeBridgeAccountSession(
|
|
548
|
+
writeBridgeAccountSession(key, session);
|
|
467
549
|
}
|
|
468
550
|
async function selectProjectSession(dshSessionId, accountId) {
|
|
469
551
|
const target = accountId || latestAccountId();
|
|
@@ -492,11 +574,10 @@ export function apply(ctx, config) {
|
|
|
492
574
|
if (sessionsService?.get(SessionId(dshSessionId))) {
|
|
493
575
|
return { ok: false, error: '该会话当前正在 DSH 中打开,请先在 DSH 中关闭该会话后再绑定。' };
|
|
494
576
|
}
|
|
495
|
-
// Drop the current bridge-owned agent so the next message resumes the
|
|
577
|
+
// Drop the current bridge-owned agent(s) so the next message resumes the
|
|
496
578
|
// selected project conversation instead of the previous bridge session.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
579
|
+
// 多用户:账号级绑定影响该 bot 下所有 per-user agent。
|
|
580
|
+
await disposeKeysUnder(target);
|
|
500
581
|
selectedSessionIds.set(target, dshSessionId);
|
|
501
582
|
persistSelectedSessionId(target, dshSessionId);
|
|
502
583
|
resetBridgeAccountSession(target, item.path);
|
|
@@ -513,12 +594,19 @@ export function apply(ctx, config) {
|
|
|
513
594
|
const target = accountId || latestAccountId();
|
|
514
595
|
if (!target)
|
|
515
596
|
return { ok: false, error: '没有已绑定的微信账号。' };
|
|
516
|
-
await
|
|
597
|
+
await disposeKeysUnder(target);
|
|
517
598
|
const config = readBridgeConfig();
|
|
518
599
|
resetBridgeAccountSession(target, config.workingDirectory);
|
|
519
600
|
const daemonResult = daemonRunning() ? await restartDaemon() : { ok: true, message: '守护进程未运行,解除绑定将在下次启动时生效。' };
|
|
520
601
|
return { ok: true, accountId: target, daemon: daemonResult.message };
|
|
521
602
|
}
|
|
603
|
+
/** dispose 精确匹配 key 及其 `${key}::` 前缀下的全部 agent(账号级操作用于多用户)。 */
|
|
604
|
+
async function disposeKeysUnder(key) {
|
|
605
|
+
const targets = [...agents.keys()].filter((k) => k === key || k.startsWith(`${key}::`));
|
|
606
|
+
for (const k of targets) {
|
|
607
|
+
await disposeAgent(k);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
522
610
|
// -------------------------------------------------------------------------
|
|
523
611
|
// SSE broadcast
|
|
524
612
|
// -------------------------------------------------------------------------
|
|
@@ -553,6 +641,8 @@ export function apply(ctx, config) {
|
|
|
553
641
|
}
|
|
554
642
|
streamClients.delete(sessionId);
|
|
555
643
|
}
|
|
644
|
+
// 每账号最近一次 LLM 用量:inputTokens + cacheReadTokens ≈ 当前上下文大小。
|
|
645
|
+
const lastUsage = new Map();
|
|
556
646
|
// Subscribe to every session event and forward assistant chunks to the
|
|
557
647
|
// bridge daemon. Only sessions created by this plugin are forwarded.
|
|
558
648
|
ctx.on('session/event', (session, event) => {
|
|
@@ -567,10 +657,13 @@ export function apply(ctx, config) {
|
|
|
567
657
|
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
568
658
|
broadcast(accountId, { type: 'chunk', text: chunk.text });
|
|
569
659
|
}
|
|
660
|
+
else if (chunk?.type === 'usage' && chunk.usage) {
|
|
661
|
+
lastUsage.set(accountId, chunk.usage);
|
|
662
|
+
}
|
|
570
663
|
}
|
|
571
664
|
else if (event.type === 'turn/end') {
|
|
572
665
|
debugLog('session turn/end', { accountId, sessionId: sid, reason: event.data.reason });
|
|
573
|
-
broadcast(accountId, { type: 'done', turn: event.data.turn, message: 'turn ended' });
|
|
666
|
+
broadcast(accountId, { type: 'done', turn: event.data.turn, message: 'turn ended', usage: lastUsage.get(accountId) });
|
|
574
667
|
if (pendingProjectSwitches.has(accountId)) {
|
|
575
668
|
pendingProjectSwitches.delete(accountId);
|
|
576
669
|
void disposeAgent(accountId, { preserveSelection: true }).catch((err) => {
|
|
@@ -841,6 +934,7 @@ export function apply(ctx, config) {
|
|
|
841
934
|
workingDirectory: raw.workingDirectory || join(homedir(), 'Documents', 'DSH'),
|
|
842
935
|
model: raw.model,
|
|
843
936
|
systemPrompt: raw.systemPrompt,
|
|
937
|
+
notifyRejected: raw.notifyRejected === true || raw.notifyRejected === 'true',
|
|
844
938
|
};
|
|
845
939
|
}
|
|
846
940
|
catch {
|
|
@@ -851,13 +945,26 @@ export function apply(ctx, config) {
|
|
|
851
945
|
}
|
|
852
946
|
function saveBridgeConfig(config) {
|
|
853
947
|
mkdirSync(dataDir, { recursive: true });
|
|
948
|
+
// 合并写回:不覆盖 daemon 侧写入的其他字段(如 usageFooter)。
|
|
949
|
+
let existing = {};
|
|
950
|
+
try {
|
|
951
|
+
const raw = JSON.parse(readFileSync(bridgeConfigPath(), 'utf8'));
|
|
952
|
+
if (raw && typeof raw === 'object')
|
|
953
|
+
existing = raw;
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// 首次写入
|
|
957
|
+
}
|
|
854
958
|
const data = {
|
|
959
|
+
...existing,
|
|
855
960
|
workingDirectory: config.workingDirectory,
|
|
856
961
|
};
|
|
857
962
|
if (config.model)
|
|
858
963
|
data.model = config.model;
|
|
859
964
|
if (config.systemPrompt)
|
|
860
965
|
data.systemPrompt = config.systemPrompt;
|
|
966
|
+
if (config.notifyRejected !== undefined)
|
|
967
|
+
data.notifyRejected = config.notifyRejected;
|
|
861
968
|
writeFileSync(bridgeConfigPath(), JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
862
969
|
if (process.platform !== 'win32') {
|
|
863
970
|
chmodSync(bridgeConfigPath(), 0o600);
|
|
@@ -949,9 +1056,63 @@ export function apply(ctx, config) {
|
|
|
949
1056
|
accounts: accountFiles,
|
|
950
1057
|
sessions: [...sessionIds.keys()],
|
|
951
1058
|
selectedProject: await selectedProjectPayload(),
|
|
1059
|
+
trust: trustPayload(),
|
|
952
1060
|
};
|
|
953
1061
|
}
|
|
954
1062
|
// -------------------------------------------------------------------------
|
|
1063
|
+
// 信任集管理(P1-2 / M4:面板 + 内部 API)
|
|
1064
|
+
// -------------------------------------------------------------------------
|
|
1065
|
+
function trustPayload() {
|
|
1066
|
+
const file = loadTrustFile();
|
|
1067
|
+
const latest = latestAccountId();
|
|
1068
|
+
return {
|
|
1069
|
+
mode: file.mode,
|
|
1070
|
+
bootstrapConsumed: file.bootstrapConsumed === true,
|
|
1071
|
+
owner: latest ? ownerUserIdOf(latest) : '',
|
|
1072
|
+
notifyRejected: readBridgeConfig().notifyRejected === true,
|
|
1073
|
+
trusted: listTrusted(file),
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
function trustAdd(userId, note) {
|
|
1077
|
+
const id = String(userId || '').trim();
|
|
1078
|
+
if (!isPlausibleUserId(id)) {
|
|
1079
|
+
return { ok: false, error: 'userId 格式不合法(应为 4-64 位字母/数字/_ . @ = -)' };
|
|
1080
|
+
}
|
|
1081
|
+
const file = loadTrustFile();
|
|
1082
|
+
const latest = latestAccountId();
|
|
1083
|
+
if (latest && id === ownerUserIdOf(latest)) {
|
|
1084
|
+
return { ok: false, error: 'owner 永远放行,不需要加入信任集' };
|
|
1085
|
+
}
|
|
1086
|
+
saveTrustFile(addTrusted(file, id, 'owner', note?.trim() || undefined));
|
|
1087
|
+
debugLog('trust add via panel', { userId: id });
|
|
1088
|
+
return { ok: true };
|
|
1089
|
+
}
|
|
1090
|
+
function trustRemove(userId) {
|
|
1091
|
+
const id = String(userId || '').trim();
|
|
1092
|
+
const file = loadTrustFile();
|
|
1093
|
+
if (!file.trusted[id]) {
|
|
1094
|
+
return { ok: false, error: `${id} 不在信任集中` };
|
|
1095
|
+
}
|
|
1096
|
+
saveTrustFile(removeTrusted(file, id));
|
|
1097
|
+
debugLog('trust remove via panel', { userId: id });
|
|
1098
|
+
return { ok: true };
|
|
1099
|
+
}
|
|
1100
|
+
function trustSetMode(mode) {
|
|
1101
|
+
if (mode !== 'owner-only' && mode !== 'bootstrap' && mode !== 'manual') {
|
|
1102
|
+
return { ok: false, error: '模式必须是 owner-only / bootstrap / manual' };
|
|
1103
|
+
}
|
|
1104
|
+
saveTrustFile(setTrustMode(loadTrustFile(), mode));
|
|
1105
|
+
debugLog('trust mode set via panel', { mode });
|
|
1106
|
+
return { ok: true };
|
|
1107
|
+
}
|
|
1108
|
+
function trustSetNotifyRejected(enabled) {
|
|
1109
|
+
const config = readBridgeConfig();
|
|
1110
|
+
config.notifyRejected = enabled;
|
|
1111
|
+
saveBridgeConfig(config);
|
|
1112
|
+
debugLog('notifyRejected set via panel', { enabled });
|
|
1113
|
+
return { ok: true };
|
|
1114
|
+
}
|
|
1115
|
+
// -------------------------------------------------------------------------
|
|
955
1116
|
// Optional Web panel routes (same origin, no token)
|
|
956
1117
|
// -------------------------------------------------------------------------
|
|
957
1118
|
function registerWebRoutes() {
|
|
@@ -1086,6 +1247,69 @@ export function apply(ctx, config) {
|
|
|
1086
1247
|
res.end(JSON.stringify(result));
|
|
1087
1248
|
},
|
|
1088
1249
|
}));
|
|
1250
|
+
// ---- 信任集管理(P1-2 / M4)----
|
|
1251
|
+
disposers.push(webServer.register({
|
|
1252
|
+
kind: 'exact',
|
|
1253
|
+
path: '/@lanbaolu/dsh-wechat-bridge/trust',
|
|
1254
|
+
handler: async (_req, res) => {
|
|
1255
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1256
|
+
res.end(JSON.stringify({ ok: true, ...trustPayload() }));
|
|
1257
|
+
},
|
|
1258
|
+
}));
|
|
1259
|
+
disposers.push(webServer.register({
|
|
1260
|
+
kind: 'exact',
|
|
1261
|
+
path: '/@lanbaolu/dsh-wechat-bridge/trust/add',
|
|
1262
|
+
handler: async (req, res) => {
|
|
1263
|
+
try {
|
|
1264
|
+
const body = await readBody(req);
|
|
1265
|
+
const result = trustAdd(String(body.userId || ''), typeof body.note === 'string' ? body.note : undefined);
|
|
1266
|
+
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
1267
|
+
res.end(JSON.stringify({ ...result, ...trustPayload() }));
|
|
1268
|
+
}
|
|
1269
|
+
catch (err) {
|
|
1270
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1271
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1272
|
+
}
|
|
1273
|
+
},
|
|
1274
|
+
}));
|
|
1275
|
+
disposers.push(webServer.register({
|
|
1276
|
+
kind: 'exact',
|
|
1277
|
+
path: '/@lanbaolu/dsh-wechat-bridge/trust/remove',
|
|
1278
|
+
handler: async (req, res) => {
|
|
1279
|
+
try {
|
|
1280
|
+
const body = await readBody(req);
|
|
1281
|
+
const result = trustRemove(String(body.userId || ''));
|
|
1282
|
+
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
1283
|
+
res.end(JSON.stringify({ ...result, ...trustPayload() }));
|
|
1284
|
+
}
|
|
1285
|
+
catch (err) {
|
|
1286
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1287
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1288
|
+
}
|
|
1289
|
+
},
|
|
1290
|
+
}));
|
|
1291
|
+
disposers.push(webServer.register({
|
|
1292
|
+
kind: 'exact',
|
|
1293
|
+
path: '/@lanbaolu/dsh-wechat-bridge/trust/config',
|
|
1294
|
+
handler: async (req, res) => {
|
|
1295
|
+
try {
|
|
1296
|
+
const body = await readBody(req);
|
|
1297
|
+
let result = { ok: true };
|
|
1298
|
+
if (typeof body.mode === 'string') {
|
|
1299
|
+
result = trustSetMode(body.mode);
|
|
1300
|
+
}
|
|
1301
|
+
if (result.ok && typeof body.notifyRejected === 'boolean') {
|
|
1302
|
+
result = trustSetNotifyRejected(body.notifyRejected);
|
|
1303
|
+
}
|
|
1304
|
+
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
1305
|
+
res.end(JSON.stringify({ ...result, ...trustPayload() }));
|
|
1306
|
+
}
|
|
1307
|
+
catch (err) {
|
|
1308
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1309
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1310
|
+
}
|
|
1311
|
+
},
|
|
1312
|
+
}));
|
|
1089
1313
|
return disposers;
|
|
1090
1314
|
}
|
|
1091
1315
|
// -------------------------------------------------------------------------
|
|
@@ -1126,8 +1350,10 @@ export function apply(ctx, config) {
|
|
|
1126
1350
|
/**
|
|
1127
1351
|
* Deliver a proactive notification to the bound WeChat account via the
|
|
1128
1352
|
* daemon's throttled notify endpoint (daemon-port.json).
|
|
1353
|
+
* 多用户(P1-2 / M3):可指定目标用户(发起任务的微信用户本人);
|
|
1354
|
+
* 缺省时由 daemon 回退到最近活跃用户。
|
|
1129
1355
|
*/
|
|
1130
|
-
async function sendWechatNotify(message) {
|
|
1356
|
+
async function sendWechatNotify(message, userId) {
|
|
1131
1357
|
const portPath = join(dataDir, 'daemon-port.json');
|
|
1132
1358
|
let info = null;
|
|
1133
1359
|
try {
|
|
@@ -1148,7 +1374,7 @@ export function apply(ctx, config) {
|
|
|
1148
1374
|
'Content-Type': 'application/json',
|
|
1149
1375
|
'x-dsh-bridge-token': info.token,
|
|
1150
1376
|
},
|
|
1151
|
-
body: JSON.stringify({ message }),
|
|
1377
|
+
body: JSON.stringify(userId ? { message, userId } : { message }),
|
|
1152
1378
|
signal: controller.signal,
|
|
1153
1379
|
});
|
|
1154
1380
|
clearTimeout(timer);
|
|
@@ -1173,8 +1399,9 @@ export function apply(ctx, config) {
|
|
|
1173
1399
|
* Push an urgent approval question to the bound WeChat account via the
|
|
1174
1400
|
* daemon's direct (non-throttled) /approval endpoint. Resolves false when
|
|
1175
1401
|
* the daemon is unreachable so callers can fall back to other answerers.
|
|
1402
|
+
* 多用户:`key` 是审批归属的 session key,解出 userId 后把审批推给本人。
|
|
1176
1403
|
*/
|
|
1177
|
-
async function pushApprovalMessage(message) {
|
|
1404
|
+
async function pushApprovalMessage(message, key) {
|
|
1178
1405
|
const portPath = join(dataDir, 'daemon-port.json');
|
|
1179
1406
|
let info = null;
|
|
1180
1407
|
try {
|
|
@@ -1186,6 +1413,7 @@ export function apply(ctx, config) {
|
|
|
1186
1413
|
if (!info?.port || !info?.token)
|
|
1187
1414
|
return false;
|
|
1188
1415
|
try {
|
|
1416
|
+
const userId = userIdOfKey(key);
|
|
1189
1417
|
const controller = new AbortController();
|
|
1190
1418
|
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
1191
1419
|
const resp = await fetch(`http://127.0.0.1:${info.port}/approval`, {
|
|
@@ -1194,7 +1422,7 @@ export function apply(ctx, config) {
|
|
|
1194
1422
|
'Content-Type': 'application/json',
|
|
1195
1423
|
'x-dsh-bridge-token': info.token,
|
|
1196
1424
|
},
|
|
1197
|
-
body: JSON.stringify({ message }),
|
|
1425
|
+
body: JSON.stringify(userId ? { message, userId } : { message }),
|
|
1198
1426
|
signal: controller.signal,
|
|
1199
1427
|
});
|
|
1200
1428
|
clearTimeout(timer);
|
|
@@ -1384,8 +1612,11 @@ export function apply(ctx, config) {
|
|
|
1384
1612
|
message: { type: 'string', description: '要发送给微信的通知内容,简洁明确,避免模板化重复措辞。' },
|
|
1385
1613
|
},
|
|
1386
1614
|
output: simpleOutput,
|
|
1387
|
-
execute: async (args) => {
|
|
1388
|
-
|
|
1615
|
+
execute: async (args, exec) => {
|
|
1616
|
+
// 多用户:把通知推给发起当前任务的微信用户本人(解 agent → session key → userId)。
|
|
1617
|
+
const key = accountIdForAgent(exec?.agent);
|
|
1618
|
+
const userId = key ? userIdOfKey(key) : '';
|
|
1619
|
+
const result = await sendWechatNotify(args.message, userId || undefined);
|
|
1389
1620
|
if (!result.ok)
|
|
1390
1621
|
throw new Error(result.message);
|
|
1391
1622
|
return { ok: true, message: result.message };
|
|
@@ -1396,6 +1627,8 @@ export function apply(ctx, config) {
|
|
|
1396
1627
|
// Lifecycle
|
|
1397
1628
|
// -------------------------------------------------------------------------
|
|
1398
1629
|
ctx.effect(() => {
|
|
1630
|
+
// 多用户迁移(幂等):旧单用户 session-ids.json key → per-user key。
|
|
1631
|
+
migrateSessionIdMap();
|
|
1399
1632
|
const server = createServer((req, res) => {
|
|
1400
1633
|
handleInternal(req, res).catch((err) => {
|
|
1401
1634
|
const message = err instanceof Error ? err.message : String(err);
|