@lanbaolu/dsh-wechat-bridge 0.5.0 → 0.7.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 +59 -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 +31 -1
- package/lib/bridge/config.js.map +1 -1
- package/lib/bridge/main.js +268 -77
- 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 +181 -2
- package/lib/client/Panel.js.map +1 -1
- package/lib/client.js +501 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +310 -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 +25 -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 +2 -2
package/lib/index.js
CHANGED
|
@@ -24,6 +24,9 @@ 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';
|
|
29
|
+
import { parseCalmConfig } from './bridge/config.js';
|
|
27
30
|
export const name = '@lanbaolu/dsh-wechat-bridge';
|
|
28
31
|
/** Host services the plugin needs. `webServer` is optional (headless profiles). */
|
|
29
32
|
export const inject = ['tools', 'agents', 'agentDefaultModel', 'agentPresets'];
|
|
@@ -103,6 +106,32 @@ export function apply(ctx, config) {
|
|
|
103
106
|
saveSessionIdMap(map);
|
|
104
107
|
}
|
|
105
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* 多用户迁移(P1-2 / M2):旧单用户时代 session-ids.json 的 key 是 bot accountId,
|
|
111
|
+
* 新版统一为 `${accountId}::${userId}`。能确定 owner 的条目一次性改名为新 key
|
|
112
|
+
* (owner 的 DSH 会话无缝续上);不能确定的保留原样不动(绝不丢历史,
|
|
113
|
+
* 只是该条目不会再被命中,新消息走新 key)。
|
|
114
|
+
*/
|
|
115
|
+
function migrateSessionIdMap() {
|
|
116
|
+
const map = loadSessionIdMap();
|
|
117
|
+
let changed = false;
|
|
118
|
+
for (const key of Object.keys(map)) {
|
|
119
|
+
if (key.includes('::'))
|
|
120
|
+
continue;
|
|
121
|
+
const owner = ownerUserIdOf(key);
|
|
122
|
+
if (!owner)
|
|
123
|
+
continue;
|
|
124
|
+
const newKey = `${key}::${owner}`;
|
|
125
|
+
if (!(newKey in map)) {
|
|
126
|
+
map[newKey] = map[key];
|
|
127
|
+
delete map[key];
|
|
128
|
+
changed = true;
|
|
129
|
+
debugLog('session-ids.json migrated to per-user key', { oldKey: key, newKey });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (changed)
|
|
133
|
+
saveSessionIdMap(map);
|
|
134
|
+
}
|
|
106
135
|
// -------------------------------------------------------------------------
|
|
107
136
|
// Explicit project-conversation binding (Web panel selection)
|
|
108
137
|
// -------------------------------------------------------------------------
|
|
@@ -134,8 +163,54 @@ export function apply(ctx, config) {
|
|
|
134
163
|
}
|
|
135
164
|
}
|
|
136
165
|
const selectedSessionIds = new Map(Object.entries(loadSelectedSessionIds()));
|
|
137
|
-
function newDshSessionId(
|
|
138
|
-
|
|
166
|
+
function newDshSessionId(key) {
|
|
167
|
+
// 多用户 key 形如 ${botAccountId}::${userId}——DSH 会话 ID 会进文件路径,
|
|
168
|
+
// ':' 在 Windows 上是非法文件名字符,统一清洗为 '-'。
|
|
169
|
+
const safe = key.replace(/[^A-Za-z0-9_.@-]/g, '-');
|
|
170
|
+
return `wb-${safe}-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
171
|
+
}
|
|
172
|
+
/** session key 的 bot 账号前缀('::' 之前;单段 key 原样返回)。 */
|
|
173
|
+
function botPrefixOf(key) {
|
|
174
|
+
const i = key.indexOf('::');
|
|
175
|
+
return i === -1 ? key : key.slice(0, i);
|
|
176
|
+
}
|
|
177
|
+
/** 从 session key 解出微信用户 ID(单段旧 key 返回空)。 */
|
|
178
|
+
function userIdOfKey(key) {
|
|
179
|
+
return parseSessionKey(key)?.userId || '';
|
|
180
|
+
}
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
// 信任集(多用户支持 P1-2 / M1)——trust.json 是唯一真相源
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
function trustPath() {
|
|
185
|
+
return join(dataDir, 'trust.json');
|
|
186
|
+
}
|
|
187
|
+
function loadTrustFile() {
|
|
188
|
+
return loadTrust(trustPath());
|
|
189
|
+
}
|
|
190
|
+
function saveTrustFile(file) {
|
|
191
|
+
saveTrust(file, trustPath());
|
|
192
|
+
}
|
|
193
|
+
/** owner userId:最新绑定账号的 userId(用于面板展示与账号级会话文件定位)。 */
|
|
194
|
+
function ownerUserIdOf(accountId) {
|
|
195
|
+
try {
|
|
196
|
+
const acc = loadJson(join(dataDir, 'accounts', `${accountId}.json`), {});
|
|
197
|
+
return typeof acc.userId === 'string' ? acc.userId : '';
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* daemon 侧会话文件名:session key `${bot}::${user}` → `${bot}__${user}`;
|
|
205
|
+
* 账号级 key(面板操作)→ 定位 owner 的 per-user 文件,找不到才退回旧文件名。
|
|
206
|
+
*/
|
|
207
|
+
function bridgeSessionStem(key) {
|
|
208
|
+
if (key.includes('::'))
|
|
209
|
+
return key.replace(/::/g, '__');
|
|
210
|
+
const owner = ownerUserIdOf(key);
|
|
211
|
+
if (owner && /^[A-Za-z0-9_.\-@=]+$/.test(owner))
|
|
212
|
+
return `${key}__${owner}`;
|
|
213
|
+
return key;
|
|
139
214
|
}
|
|
140
215
|
// -------------------------------------------------------------------------
|
|
141
216
|
// Agent management
|
|
@@ -169,7 +244,13 @@ export function apply(ctx, config) {
|
|
|
169
244
|
// when there is no mapping, the old log is gone/corrupt, or the requested
|
|
170
245
|
// workspace differs from the persisted session's cwd (unless the user
|
|
171
246
|
// explicitly bound the bridge to a project conversation).
|
|
172
|
-
|
|
247
|
+
//
|
|
248
|
+
// 项目绑定两级粒度:
|
|
249
|
+
// - session key 级(微信内 /session 绑定):只对当前用户生效;
|
|
250
|
+
// - 账号级(Web 面板选择):对该 bot 下所有用户生效(前缀回退查找)。
|
|
251
|
+
const selectedSessionId = selectedSessionIds.get(accountId)
|
|
252
|
+
?? (accountId.includes('::') ? selectedSessionIds.get(botPrefixOf(accountId)) : undefined);
|
|
253
|
+
const selectedIsAccountLevel = !!selectedSessionId && !selectedSessionIds.has(accountId);
|
|
173
254
|
let dshSessionId = sessionIds.get(accountId);
|
|
174
255
|
let handle;
|
|
175
256
|
let resumed = false;
|
|
@@ -246,10 +327,10 @@ export function apply(ctx, config) {
|
|
|
246
327
|
const finalSessionId = dshSessionId;
|
|
247
328
|
sessionIds.set(accountId, finalSessionId);
|
|
248
329
|
persistSessionId(accountId, finalSessionId);
|
|
249
|
-
if (isSelected) {
|
|
330
|
+
if (isSelected && !selectedIsAccountLevel) {
|
|
250
331
|
persistSelectedSessionId(accountId, finalSessionId);
|
|
251
332
|
}
|
|
252
|
-
else if (selectedSessionIds.has(accountId)) {
|
|
333
|
+
else if (!selectedIsAccountLevel && selectedSessionIds.has(accountId)) {
|
|
253
334
|
selectedSessionIds.delete(accountId);
|
|
254
335
|
removeSelectedSessionId(accountId);
|
|
255
336
|
}
|
|
@@ -450,20 +531,22 @@ export function apply(ctx, config) {
|
|
|
450
531
|
message: `已进入项目 ${item.workspaceTitle}(${item.path}),后续对话会记录到这个项目。`,
|
|
451
532
|
};
|
|
452
533
|
}
|
|
453
|
-
function readBridgeAccountSession(
|
|
454
|
-
|
|
455
|
-
|
|
534
|
+
function readBridgeAccountSession(key) {
|
|
535
|
+
const stem = bridgeSessionStem(key);
|
|
536
|
+
validateAccountId(stem);
|
|
537
|
+
return loadJson(join(dataDir, 'sessions', `${stem}.json`), {});
|
|
456
538
|
}
|
|
457
|
-
function writeBridgeAccountSession(
|
|
458
|
-
|
|
459
|
-
|
|
539
|
+
function writeBridgeAccountSession(key, session) {
|
|
540
|
+
const stem = bridgeSessionStem(key);
|
|
541
|
+
validateAccountId(stem);
|
|
542
|
+
saveJson(join(dataDir, 'sessions', `${stem}.json`), session);
|
|
460
543
|
}
|
|
461
|
-
function resetBridgeAccountSession(
|
|
462
|
-
const session = readBridgeAccountSession(
|
|
544
|
+
function resetBridgeAccountSession(key, cwd) {
|
|
545
|
+
const session = readBridgeAccountSession(key);
|
|
463
546
|
session.workingDirectory = cwd;
|
|
464
547
|
session.state = 'idle';
|
|
465
548
|
session.chatHistory = [];
|
|
466
|
-
writeBridgeAccountSession(
|
|
549
|
+
writeBridgeAccountSession(key, session);
|
|
467
550
|
}
|
|
468
551
|
async function selectProjectSession(dshSessionId, accountId) {
|
|
469
552
|
const target = accountId || latestAccountId();
|
|
@@ -492,11 +575,10 @@ export function apply(ctx, config) {
|
|
|
492
575
|
if (sessionsService?.get(SessionId(dshSessionId))) {
|
|
493
576
|
return { ok: false, error: '该会话当前正在 DSH 中打开,请先在 DSH 中关闭该会话后再绑定。' };
|
|
494
577
|
}
|
|
495
|
-
// Drop the current bridge-owned agent so the next message resumes the
|
|
578
|
+
// Drop the current bridge-owned agent(s) so the next message resumes the
|
|
496
579
|
// selected project conversation instead of the previous bridge session.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
580
|
+
// 多用户:账号级绑定影响该 bot 下所有 per-user agent。
|
|
581
|
+
await disposeKeysUnder(target);
|
|
500
582
|
selectedSessionIds.set(target, dshSessionId);
|
|
501
583
|
persistSelectedSessionId(target, dshSessionId);
|
|
502
584
|
resetBridgeAccountSession(target, item.path);
|
|
@@ -513,12 +595,19 @@ export function apply(ctx, config) {
|
|
|
513
595
|
const target = accountId || latestAccountId();
|
|
514
596
|
if (!target)
|
|
515
597
|
return { ok: false, error: '没有已绑定的微信账号。' };
|
|
516
|
-
await
|
|
598
|
+
await disposeKeysUnder(target);
|
|
517
599
|
const config = readBridgeConfig();
|
|
518
600
|
resetBridgeAccountSession(target, config.workingDirectory);
|
|
519
601
|
const daemonResult = daemonRunning() ? await restartDaemon() : { ok: true, message: '守护进程未运行,解除绑定将在下次启动时生效。' };
|
|
520
602
|
return { ok: true, accountId: target, daemon: daemonResult.message };
|
|
521
603
|
}
|
|
604
|
+
/** dispose 精确匹配 key 及其 `${key}::` 前缀下的全部 agent(账号级操作用于多用户)。 */
|
|
605
|
+
async function disposeKeysUnder(key) {
|
|
606
|
+
const targets = [...agents.keys()].filter((k) => k === key || k.startsWith(`${key}::`));
|
|
607
|
+
for (const k of targets) {
|
|
608
|
+
await disposeAgent(k);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
522
611
|
// -------------------------------------------------------------------------
|
|
523
612
|
// SSE broadcast
|
|
524
613
|
// -------------------------------------------------------------------------
|
|
@@ -774,6 +863,11 @@ export function apply(ctx, config) {
|
|
|
774
863
|
cwd: dirname(script),
|
|
775
864
|
env: {
|
|
776
865
|
...process.env,
|
|
866
|
+
// 宿主是 Electron(DSH Desktop)时 process.execPath 是 Electron 二进制:
|
|
867
|
+
// 必须用 ELECTRON_RUN_AS_NODE=1 让它以纯 Node 模式执行 daemon 脚本,
|
|
868
|
+
// 否则每次拉起都会启动一个 Electron 实例(窗口闪现后秒退,watchdog 无限循环)。
|
|
869
|
+
// 纯 node 宿主(npx dsh web)下该变量无害。
|
|
870
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
777
871
|
DSH_HOME: dshHome,
|
|
778
872
|
DSH_BRIDGE_DATA_DIR: dataDir,
|
|
779
873
|
DSH_BRIDGE_API_BASE: `http://127.0.0.1:${internalPort}`,
|
|
@@ -846,6 +940,9 @@ export function apply(ctx, config) {
|
|
|
846
940
|
workingDirectory: raw.workingDirectory || join(homedir(), 'Documents', 'DSH'),
|
|
847
941
|
model: raw.model,
|
|
848
942
|
systemPrompt: raw.systemPrompt,
|
|
943
|
+
notifyRejected: raw.notifyRejected === true || raw.notifyRejected === 'true',
|
|
944
|
+
usageFooter: raw.usageFooter === undefined ? undefined : (raw.usageFooter === true || raw.usageFooter === 'true'),
|
|
945
|
+
calm: raw.calm && typeof raw.calm === 'object' ? raw.calm : undefined,
|
|
849
946
|
};
|
|
850
947
|
}
|
|
851
948
|
catch {
|
|
@@ -856,13 +953,28 @@ export function apply(ctx, config) {
|
|
|
856
953
|
}
|
|
857
954
|
function saveBridgeConfig(config) {
|
|
858
955
|
mkdirSync(dataDir, { recursive: true });
|
|
956
|
+
// 合并写回:不覆盖 daemon 侧写入的其他字段(如 usageFooter)。
|
|
957
|
+
let existing = {};
|
|
958
|
+
try {
|
|
959
|
+
const raw = JSON.parse(readFileSync(bridgeConfigPath(), 'utf8'));
|
|
960
|
+
if (raw && typeof raw === 'object')
|
|
961
|
+
existing = raw;
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
// 首次写入
|
|
965
|
+
}
|
|
859
966
|
const data = {
|
|
967
|
+
...existing,
|
|
860
968
|
workingDirectory: config.workingDirectory,
|
|
861
969
|
};
|
|
862
970
|
if (config.model)
|
|
863
971
|
data.model = config.model;
|
|
864
972
|
if (config.systemPrompt)
|
|
865
973
|
data.systemPrompt = config.systemPrompt;
|
|
974
|
+
if (config.notifyRejected !== undefined)
|
|
975
|
+
data.notifyRejected = config.notifyRejected;
|
|
976
|
+
if (config.calm !== undefined)
|
|
977
|
+
data.calm = config.calm;
|
|
866
978
|
writeFileSync(bridgeConfigPath(), JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
867
979
|
if (process.platform !== 'win32') {
|
|
868
980
|
chmodSync(bridgeConfigPath(), 0o600);
|
|
@@ -954,9 +1066,63 @@ export function apply(ctx, config) {
|
|
|
954
1066
|
accounts: accountFiles,
|
|
955
1067
|
sessions: [...sessionIds.keys()],
|
|
956
1068
|
selectedProject: await selectedProjectPayload(),
|
|
1069
|
+
trust: trustPayload(),
|
|
957
1070
|
};
|
|
958
1071
|
}
|
|
959
1072
|
// -------------------------------------------------------------------------
|
|
1073
|
+
// 信任集管理(P1-2 / M4:面板 + 内部 API)
|
|
1074
|
+
// -------------------------------------------------------------------------
|
|
1075
|
+
function trustPayload() {
|
|
1076
|
+
const file = loadTrustFile();
|
|
1077
|
+
const latest = latestAccountId();
|
|
1078
|
+
return {
|
|
1079
|
+
mode: file.mode,
|
|
1080
|
+
bootstrapConsumed: file.bootstrapConsumed === true,
|
|
1081
|
+
owner: latest ? ownerUserIdOf(latest) : '',
|
|
1082
|
+
notifyRejected: readBridgeConfig().notifyRejected === true,
|
|
1083
|
+
trusted: listTrusted(file),
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function trustAdd(userId, note) {
|
|
1087
|
+
const id = String(userId || '').trim();
|
|
1088
|
+
if (!isPlausibleUserId(id)) {
|
|
1089
|
+
return { ok: false, error: 'userId 格式不合法(应为 4-64 位字母/数字/_ . @ = -)' };
|
|
1090
|
+
}
|
|
1091
|
+
const file = loadTrustFile();
|
|
1092
|
+
const latest = latestAccountId();
|
|
1093
|
+
if (latest && id === ownerUserIdOf(latest)) {
|
|
1094
|
+
return { ok: false, error: 'owner 永远放行,不需要加入信任集' };
|
|
1095
|
+
}
|
|
1096
|
+
saveTrustFile(addTrusted(file, id, 'owner', note?.trim() || undefined));
|
|
1097
|
+
debugLog('trust add via panel', { userId: id });
|
|
1098
|
+
return { ok: true };
|
|
1099
|
+
}
|
|
1100
|
+
function trustRemove(userId) {
|
|
1101
|
+
const id = String(userId || '').trim();
|
|
1102
|
+
const file = loadTrustFile();
|
|
1103
|
+
if (!file.trusted[id]) {
|
|
1104
|
+
return { ok: false, error: `${id} 不在信任集中` };
|
|
1105
|
+
}
|
|
1106
|
+
saveTrustFile(removeTrusted(file, id));
|
|
1107
|
+
debugLog('trust remove via panel', { userId: id });
|
|
1108
|
+
return { ok: true };
|
|
1109
|
+
}
|
|
1110
|
+
function trustSetMode(mode) {
|
|
1111
|
+
if (mode !== 'owner-only' && mode !== 'bootstrap' && mode !== 'manual') {
|
|
1112
|
+
return { ok: false, error: '模式必须是 owner-only / bootstrap / manual' };
|
|
1113
|
+
}
|
|
1114
|
+
saveTrustFile(setTrustMode(loadTrustFile(), mode));
|
|
1115
|
+
debugLog('trust mode set via panel', { mode });
|
|
1116
|
+
return { ok: true };
|
|
1117
|
+
}
|
|
1118
|
+
function trustSetNotifyRejected(enabled) {
|
|
1119
|
+
const config = readBridgeConfig();
|
|
1120
|
+
config.notifyRejected = enabled;
|
|
1121
|
+
saveBridgeConfig(config);
|
|
1122
|
+
debugLog('notifyRejected set via panel', { enabled });
|
|
1123
|
+
return { ok: true };
|
|
1124
|
+
}
|
|
1125
|
+
// -------------------------------------------------------------------------
|
|
960
1126
|
// Optional Web panel routes (same origin, no token)
|
|
961
1127
|
// -------------------------------------------------------------------------
|
|
962
1128
|
function registerWebRoutes() {
|
|
@@ -1091,6 +1257,116 @@ export function apply(ctx, config) {
|
|
|
1091
1257
|
res.end(JSON.stringify(result));
|
|
1092
1258
|
},
|
|
1093
1259
|
}));
|
|
1260
|
+
// ---- 信任集管理(P1-2 / M4)----
|
|
1261
|
+
disposers.push(webServer.register({
|
|
1262
|
+
kind: 'exact',
|
|
1263
|
+
path: '/@lanbaolu/dsh-wechat-bridge/trust',
|
|
1264
|
+
handler: async (_req, res) => {
|
|
1265
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1266
|
+
res.end(JSON.stringify({ ok: true, ...trustPayload() }));
|
|
1267
|
+
},
|
|
1268
|
+
}));
|
|
1269
|
+
disposers.push(webServer.register({
|
|
1270
|
+
kind: 'exact',
|
|
1271
|
+
path: '/@lanbaolu/dsh-wechat-bridge/trust/add',
|
|
1272
|
+
handler: async (req, res) => {
|
|
1273
|
+
try {
|
|
1274
|
+
const body = await readBody(req);
|
|
1275
|
+
const result = trustAdd(String(body.userId || ''), typeof body.note === 'string' ? body.note : undefined);
|
|
1276
|
+
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
1277
|
+
res.end(JSON.stringify({ ...result, ...trustPayload() }));
|
|
1278
|
+
}
|
|
1279
|
+
catch (err) {
|
|
1280
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1281
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1282
|
+
}
|
|
1283
|
+
},
|
|
1284
|
+
}));
|
|
1285
|
+
disposers.push(webServer.register({
|
|
1286
|
+
kind: 'exact',
|
|
1287
|
+
path: '/@lanbaolu/dsh-wechat-bridge/trust/remove',
|
|
1288
|
+
handler: async (req, res) => {
|
|
1289
|
+
try {
|
|
1290
|
+
const body = await readBody(req);
|
|
1291
|
+
const result = trustRemove(String(body.userId || ''));
|
|
1292
|
+
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
1293
|
+
res.end(JSON.stringify({ ...result, ...trustPayload() }));
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1297
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1298
|
+
}
|
|
1299
|
+
},
|
|
1300
|
+
}));
|
|
1301
|
+
disposers.push(webServer.register({
|
|
1302
|
+
kind: 'exact',
|
|
1303
|
+
path: '/@lanbaolu/dsh-wechat-bridge/trust/config',
|
|
1304
|
+
handler: async (req, res) => {
|
|
1305
|
+
try {
|
|
1306
|
+
const body = await readBody(req);
|
|
1307
|
+
let result = { ok: true };
|
|
1308
|
+
if (typeof body.mode === 'string') {
|
|
1309
|
+
result = trustSetMode(body.mode);
|
|
1310
|
+
}
|
|
1311
|
+
if (result.ok && typeof body.notifyRejected === 'boolean') {
|
|
1312
|
+
result = trustSetNotifyRejected(body.notifyRejected);
|
|
1313
|
+
}
|
|
1314
|
+
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
1315
|
+
res.end(JSON.stringify({ ...result, ...trustPayload() }));
|
|
1316
|
+
}
|
|
1317
|
+
catch (err) {
|
|
1318
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1319
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1320
|
+
}
|
|
1321
|
+
},
|
|
1322
|
+
}));
|
|
1323
|
+
// 桥接配置读写(面板「超时安抚」等设置):GET 读全部配置,POST 按字段合并写回。
|
|
1324
|
+
// config.json 由 host 与 daemon 共享,写盘后 daemon 侧最多延迟一个轮询周期生效。
|
|
1325
|
+
disposers.push(webServer.register({
|
|
1326
|
+
kind: 'exact',
|
|
1327
|
+
path: '/@lanbaolu/dsh-wechat-bridge/config',
|
|
1328
|
+
handler: async (req, res) => {
|
|
1329
|
+
try {
|
|
1330
|
+
if (req.method === 'GET' || req.method === undefined) {
|
|
1331
|
+
const config = readBridgeConfig();
|
|
1332
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1333
|
+
res.end(JSON.stringify({
|
|
1334
|
+
ok: true,
|
|
1335
|
+
workingDirectory: config.workingDirectory,
|
|
1336
|
+
model: config.model ?? null,
|
|
1337
|
+
usageFooter: config.usageFooter ?? undefined,
|
|
1338
|
+
notifyRejected: config.notifyRejected ?? false,
|
|
1339
|
+
calm: config.calm ?? {},
|
|
1340
|
+
}));
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
if (req.method === 'POST') {
|
|
1344
|
+
const body = await readBody(req);
|
|
1345
|
+
const config = readBridgeConfig();
|
|
1346
|
+
if (body.calm !== undefined) {
|
|
1347
|
+
if (!body.calm || typeof body.calm !== 'object') {
|
|
1348
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1349
|
+
res.end(JSON.stringify({ ok: false, error: 'calm 必须是对象' }));
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
// 写盘前清洗(非法字段丢弃),daemon 读取时还会再兜底一次。
|
|
1353
|
+
const parsed = parseCalmConfig(body.calm);
|
|
1354
|
+
config.calm = parsed ?? {};
|
|
1355
|
+
}
|
|
1356
|
+
saveBridgeConfig(config);
|
|
1357
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1358
|
+
res.end(JSON.stringify({ ok: true, calm: config.calm ?? {} }));
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
1362
|
+
res.end(JSON.stringify({ ok: false, error: 'method not allowed' }));
|
|
1363
|
+
}
|
|
1364
|
+
catch (err) {
|
|
1365
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1366
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1367
|
+
}
|
|
1368
|
+
},
|
|
1369
|
+
}));
|
|
1094
1370
|
return disposers;
|
|
1095
1371
|
}
|
|
1096
1372
|
// -------------------------------------------------------------------------
|
|
@@ -1131,8 +1407,10 @@ export function apply(ctx, config) {
|
|
|
1131
1407
|
/**
|
|
1132
1408
|
* Deliver a proactive notification to the bound WeChat account via the
|
|
1133
1409
|
* daemon's throttled notify endpoint (daemon-port.json).
|
|
1410
|
+
* 多用户(P1-2 / M3):可指定目标用户(发起任务的微信用户本人);
|
|
1411
|
+
* 缺省时由 daemon 回退到最近活跃用户。
|
|
1134
1412
|
*/
|
|
1135
|
-
async function sendWechatNotify(message) {
|
|
1413
|
+
async function sendWechatNotify(message, userId) {
|
|
1136
1414
|
const portPath = join(dataDir, 'daemon-port.json');
|
|
1137
1415
|
let info = null;
|
|
1138
1416
|
try {
|
|
@@ -1153,7 +1431,7 @@ export function apply(ctx, config) {
|
|
|
1153
1431
|
'Content-Type': 'application/json',
|
|
1154
1432
|
'x-dsh-bridge-token': info.token,
|
|
1155
1433
|
},
|
|
1156
|
-
body: JSON.stringify({ message }),
|
|
1434
|
+
body: JSON.stringify(userId ? { message, userId } : { message }),
|
|
1157
1435
|
signal: controller.signal,
|
|
1158
1436
|
});
|
|
1159
1437
|
clearTimeout(timer);
|
|
@@ -1178,8 +1456,9 @@ export function apply(ctx, config) {
|
|
|
1178
1456
|
* Push an urgent approval question to the bound WeChat account via the
|
|
1179
1457
|
* daemon's direct (non-throttled) /approval endpoint. Resolves false when
|
|
1180
1458
|
* the daemon is unreachable so callers can fall back to other answerers.
|
|
1459
|
+
* 多用户:`key` 是审批归属的 session key,解出 userId 后把审批推给本人。
|
|
1181
1460
|
*/
|
|
1182
|
-
async function pushApprovalMessage(message) {
|
|
1461
|
+
async function pushApprovalMessage(message, key) {
|
|
1183
1462
|
const portPath = join(dataDir, 'daemon-port.json');
|
|
1184
1463
|
let info = null;
|
|
1185
1464
|
try {
|
|
@@ -1191,6 +1470,7 @@ export function apply(ctx, config) {
|
|
|
1191
1470
|
if (!info?.port || !info?.token)
|
|
1192
1471
|
return false;
|
|
1193
1472
|
try {
|
|
1473
|
+
const userId = userIdOfKey(key);
|
|
1194
1474
|
const controller = new AbortController();
|
|
1195
1475
|
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
1196
1476
|
const resp = await fetch(`http://127.0.0.1:${info.port}/approval`, {
|
|
@@ -1199,7 +1479,7 @@ export function apply(ctx, config) {
|
|
|
1199
1479
|
'Content-Type': 'application/json',
|
|
1200
1480
|
'x-dsh-bridge-token': info.token,
|
|
1201
1481
|
},
|
|
1202
|
-
body: JSON.stringify({ message }),
|
|
1482
|
+
body: JSON.stringify(userId ? { message, userId } : { message }),
|
|
1203
1483
|
signal: controller.signal,
|
|
1204
1484
|
});
|
|
1205
1485
|
clearTimeout(timer);
|
|
@@ -1389,8 +1669,11 @@ export function apply(ctx, config) {
|
|
|
1389
1669
|
message: { type: 'string', description: '要发送给微信的通知内容,简洁明确,避免模板化重复措辞。' },
|
|
1390
1670
|
},
|
|
1391
1671
|
output: simpleOutput,
|
|
1392
|
-
execute: async (args) => {
|
|
1393
|
-
|
|
1672
|
+
execute: async (args, exec) => {
|
|
1673
|
+
// 多用户:把通知推给发起当前任务的微信用户本人(解 agent → session key → userId)。
|
|
1674
|
+
const key = accountIdForAgent(exec?.agent);
|
|
1675
|
+
const userId = key ? userIdOfKey(key) : '';
|
|
1676
|
+
const result = await sendWechatNotify(args.message, userId || undefined);
|
|
1394
1677
|
if (!result.ok)
|
|
1395
1678
|
throw new Error(result.message);
|
|
1396
1679
|
return { ok: true, message: result.message };
|
|
@@ -1401,6 +1684,8 @@ export function apply(ctx, config) {
|
|
|
1401
1684
|
// Lifecycle
|
|
1402
1685
|
// -------------------------------------------------------------------------
|
|
1403
1686
|
ctx.effect(() => {
|
|
1687
|
+
// 多用户迁移(幂等):旧单用户 session-ids.json key → per-user key。
|
|
1688
|
+
migrateSessionIdMap();
|
|
1404
1689
|
const server = createServer((req, res) => {
|
|
1405
1690
|
handleInternal(req, res).catch((err) => {
|
|
1406
1691
|
const message = err instanceof Error ? err.message : String(err);
|