@wenbin_wb/dsh-bridge 2.10.9 → 2.10.11
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/CHANGELOG.md +27 -0
- package/client/client.js +251 -11
- package/client/index.js +146 -8
- package/client/mobile-styles.js +18 -2
- package/client/resource-url-compat.js +90 -0
- package/docs/telegram-usage.md +1 -1
- package/lib/bridge-rpc-constants.js +2 -0
- package/lib/bridge-rpc.js +23 -0
- package/lib/compat.js +172 -129
- package/lib/feishu/index.js +234 -225
- package/lib/feishu/node.js +439 -439
- package/lib/index.js +157 -41
- package/lib/platform/base.js +147 -147
- package/lib/platform/commands.js +235 -221
- package/lib/platform/config-restore.js +69 -0
- package/lib/platform/conversation-bridge.js +905 -821
- package/lib/platform/dsh-storage.js +117 -117
- package/lib/platform/index.js +10 -10
- package/lib/platform/message-split.js +229 -229
- package/lib/platform/session-catalog.js +394 -372
- package/lib/platform/session-config.js +52 -0
- package/lib/platform/stream-slices.js +21 -21
- package/lib/qq/index.js +322 -312
- package/lib/restart-helper.mjs +101 -0
- package/lib/telegram/index.js +225 -216
- package/lib/telegram/node.js +322 -322
- package/lib/wechat/gateway.js +986 -986
- package/lib/wechat/index.js +254 -244
- package/lib/wechat/media.js +285 -285
- package/lib/wechat/node.js +352 -352
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { get as httpsGet } from 'node:https';
|
|
|
10
10
|
import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform, arch, release, hostname, uptime } from 'node:os';
|
|
11
11
|
import { join, dirname, basename, resolve, normalize } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
13
|
+
import { readFileSync, appendFileSync, existsSync, realpathSync } from 'node:fs';
|
|
14
14
|
import { readFile, writeFile, mkdir, unlink, readdir, stat, access } from 'node:fs/promises';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import QRCode from 'qrcode';
|
|
@@ -18,6 +18,7 @@ import { installBridgeRpc } from './bridge-rpc.js';
|
|
|
18
18
|
import { CustomTunnelClient } from './tunnel-client.mjs';
|
|
19
19
|
import { CloudflaredManager } from './cloudflared-manager.mjs';
|
|
20
20
|
import { PlatformManager } from './platform/manager.js';
|
|
21
|
+
import { applyRestoredPlatformConfig, RESTORED_STRING_FIELDS, PLATFORM_TIMING_FIELDS } from './platform/config-restore.js';
|
|
21
22
|
import { WechatService } from './wechat/index.js';
|
|
22
23
|
import { QqService } from './qq/index.js';
|
|
23
24
|
import { FeishuService } from './feishu/index.js';
|
|
@@ -27,7 +28,7 @@ import { getDshLoopbackCookie as getDshLoopbackCookieImpl } from './auth/dsh-nat
|
|
|
27
28
|
import { renderLoginPage } from './auth/login-template.js';
|
|
28
29
|
import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
|
|
29
30
|
import { stripSessionProjections } from './session-strip.js';
|
|
30
|
-
import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL } from './compat.js';
|
|
31
|
+
import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL, BROWSER_PROMISE_ITERATOR_POLYFILL } from './compat.js';
|
|
31
32
|
|
|
32
33
|
const name = 'dsh-bridge';
|
|
33
34
|
// 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
|
|
@@ -186,7 +187,8 @@ const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width,
|
|
|
186
187
|
<link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
|
|
187
188
|
<style data-dsh-bridge-overscroll="1">html,body{overscroll-behavior-y:none;-webkit-overflow-scrolling:touch}</style>
|
|
188
189
|
<script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
|
|
189
|
-
${BROWSER_ABORT_SIGNAL_POLYFILL}
|
|
190
|
+
${BROWSER_ABORT_SIGNAL_POLYFILL}
|
|
191
|
+
${BROWSER_PROMISE_ITERATOR_POLYFILL}`;
|
|
190
192
|
const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
|
|
191
193
|
|
|
192
194
|
function isCompressed(headers) {
|
|
@@ -261,6 +263,47 @@ function sanitizeProxyHeaders(headers, extra) {
|
|
|
261
263
|
* 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
|
|
262
264
|
* 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
|
|
263
265
|
*/
|
|
266
|
+
/**
|
|
267
|
+
* 从 `/proc/self/cgroup` 内容里取出最内层(最后一段)的 systemd 单元名。
|
|
268
|
+
*
|
|
269
|
+
* 形如 `0::/user.slice/user-1000.slice/user@1000.service/app.slice/dsh-web.service`
|
|
270
|
+
* 时必须取 `dsh-web.service`(祖先里的 `user@1000.service` 是用户管理器,不是本服务)。
|
|
271
|
+
*
|
|
272
|
+
* @param {string} cgroup /proc/self/cgroup 的原始内容
|
|
273
|
+
* @returns {string} 单元名,取不到返回空串
|
|
274
|
+
*/
|
|
275
|
+
export function parseSystemdUnit(cgroup) {
|
|
276
|
+
const parts = String(cgroup || '').split('/').filter(Boolean);
|
|
277
|
+
for (let i = parts.length - 1; i >= 0; i -= 1) {
|
|
278
|
+
if (parts[i].endsWith('.service')) return parts[i];
|
|
279
|
+
}
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 判断当前 DSH 由谁托管,决定重启方式(纯函数,便于单测)。
|
|
285
|
+
*
|
|
286
|
+
* @param {object} [opts]
|
|
287
|
+
* @param {object} [opts.env] 环境变量(默认 process.env)
|
|
288
|
+
* @param {string} [opts.cgroup] /proc/self/cgroup 内容
|
|
289
|
+
* @returns {{kind:'daemon'|'systemd'|'self', scope?:'user'|'system', unit?:string, reason:string}}
|
|
290
|
+
*/
|
|
291
|
+
export function detectSupervisor({ env = process.env, cgroup = '' } = {}) {
|
|
292
|
+
if (env.DSH_DAEMON || env.PM2_HOME) {
|
|
293
|
+
return { kind: 'daemon', reason: env.PM2_HOME ? 'PM2_HOME' : 'DSH_DAEMON' };
|
|
294
|
+
}
|
|
295
|
+
const text = String(cgroup || '');
|
|
296
|
+
// 只在确实处于 systemd 环境时才认(INVOCATION_ID/JOURNAL_STREAM 是 systemd 注入的标记),
|
|
297
|
+
// 避免在容器里存在同名 cgroup 文本时误判。
|
|
298
|
+
const systemdMarked = Boolean(env.INVOCATION_ID || env.JOURNAL_STREAM) || text.includes('.slice');
|
|
299
|
+
const unit = systemdMarked ? parseSystemdUnit(text) : '';
|
|
300
|
+
if (unit) {
|
|
301
|
+
const parts = text.split('/').filter(Boolean);
|
|
302
|
+
return { kind: 'systemd', scope: parts.includes('user.slice') ? 'user' : 'system', unit, reason: 'cgroup 检出 systemd 单元' };
|
|
303
|
+
}
|
|
304
|
+
return { kind: 'self', reason: '未检出托管器(无 DSH_DAEMON/PM2_HOME,也不在 systemd 单元内)' };
|
|
305
|
+
}
|
|
306
|
+
|
|
264
307
|
class ProxyServer {
|
|
265
308
|
constructor({ localPort, targetPort, authManager, logger, allowedOrigins }) {
|
|
266
309
|
this.localPort = localPort;
|
|
@@ -1300,32 +1343,103 @@ class BridgeService {
|
|
|
1300
1343
|
return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
|
|
1301
1344
|
}
|
|
1302
1345
|
|
|
1303
|
-
// 优雅重启 DSH
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1346
|
+
// 优雅重启 DSH 服务。
|
|
1347
|
+
//
|
|
1348
|
+
// 关键教训(真实事故):DSH 常以 systemd 用户单元(如 dsh-web.service,Restart=on-failure)托管,
|
|
1349
|
+
// 而"自己派生一个子进程再 process.exit(0)"在这种环境下必然失败:
|
|
1350
|
+
// - 干净退出(code 0)不满足 Restart=on-failure,systemd 不会拉起;
|
|
1351
|
+
// - 默认 KillMode=control-group,主进程退出时 systemd 会把 cgroup 里刚派生的子进程一起杀掉。
|
|
1352
|
+
// 结果就是"点了重启,dsh 再也没起来",而且没有任何日志(旧实现 stdio: 'ignore')。
|
|
1353
|
+
// 因此这里先识别托管方式:
|
|
1354
|
+
// - systemd(从 /proc/self/cgroup 取单元名)→ 交给 `systemctl [--user] restart --no-block <unit>`,
|
|
1355
|
+
// 由 systemd 负责 stop+start,新进程仍在正确的 cgroup 里;
|
|
1356
|
+
// - DSH_DAEMON / PM2 → 直接退出,交给守护进程拉起;
|
|
1357
|
+
// - 无托管器 → 派生独立助手(lib/restart-helper.mjs),等旧进程退出 + 端口释放后才拉起新进程,
|
|
1358
|
+
// 并把全过程写入 ~/.dsh/dsh-bridge/restart.log(失败不再无声)。
|
|
1359
|
+
// opts 仅供单测注入(spawnImpl / readCgroup / env / scheduleExit)。
|
|
1360
|
+
async restartDsh(opts = {}) {
|
|
1361
|
+
const env = opts.env ?? process.env;
|
|
1362
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
1363
|
+
const readCgroup = opts.readCgroup
|
|
1364
|
+
?? (() => { try { return readFileSync('/proc/self/cgroup', 'utf8'); } catch { return ''; } });
|
|
1365
|
+
const cgroup = opts.cgroup ?? readCgroup();
|
|
1366
|
+
const scheduleExit = opts.scheduleExit ?? ((ms) => setTimeout(() => process.exit(0), ms));
|
|
1367
|
+
|
|
1368
|
+
const dshHome = env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
1369
|
+
const logFile = join(dshHome, 'dsh-bridge', 'restart.log');
|
|
1370
|
+
const childLog = join(dshHome, 'dsh-bridge', 'restart-child.log');
|
|
1371
|
+
const writeLog = (message) => {
|
|
1372
|
+
try { appendFileSync(logFile, `[${new Date().toISOString()}] ${message}\n`); } catch { /* 日志不可写时忽略 */ }
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
const plan = detectSupervisor({ env, cgroup });
|
|
1376
|
+
writeLog(`重启请求:plan=${plan.kind}${plan.unit ? ` unit=${plan.unit}(${plan.scope})` : ''}(${plan.reason})pid=${process.pid} cwd=${process.cwd()} argv=${JSON.stringify(process.argv.slice(1))}`);
|
|
1377
|
+
this.logger?.info('收到 DSH 重启请求(%s)', plan.kind === 'systemd' ? `systemd:${plan.unit}` : plan.kind);
|
|
1378
|
+
|
|
1379
|
+
if (plan.kind === 'systemd') {
|
|
1380
|
+
const args = [...(plan.scope === 'user' ? ['--user'] : []), 'restart', '--no-block', plan.unit];
|
|
1381
|
+
const spawnError = await new Promise((resolve) => {
|
|
1382
|
+
let settled = false;
|
|
1383
|
+
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
1384
|
+
try {
|
|
1385
|
+
const child = spawnImpl('systemctl', args, { detached: true, stdio: 'ignore', env });
|
|
1386
|
+
child.once?.('error', (err) => settle(err));
|
|
1387
|
+
child.once?.('spawn', () => settle(null));
|
|
1388
|
+
child.unref?.();
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
settle(err);
|
|
1321
1391
|
}
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
if (!spawnError) {
|
|
1395
|
+
writeLog(`已请求 systemd 重启:systemctl ${args.join(' ')}`);
|
|
1396
|
+
scheduleExit(1500);
|
|
1397
|
+
const scopeText = plan.scope === 'user' ? '用户会话' : '系统';
|
|
1398
|
+
return { ok: true, message: `已请求 systemd 重启 ${plan.unit}(${scopeText}),由 systemd 负责停止与拉起…` };
|
|
1399
|
+
}
|
|
1400
|
+
writeLog(`调用 systemctl 失败:${spawnError.message};回退到独立助手方式`);
|
|
1401
|
+
} else if (plan.kind === 'daemon') {
|
|
1402
|
+
writeLog(`交由守护进程拉起(${plan.reason})`);
|
|
1403
|
+
scheduleExit(600);
|
|
1404
|
+
return { ok: true, message: `DSH 正在重启(由 ${plan.reason} 守护进程拉起)…` };
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// 无托管器(或 systemd 调用失败)→ 独立助手:等旧进程退出、端口释放后再拉起新进程,全程留日志
|
|
1408
|
+
const helper = join(dirname(fileURLToPath(import.meta.url)), 'restart-helper.mjs');
|
|
1409
|
+
const payload = JSON.stringify({
|
|
1410
|
+
pid: process.pid,
|
|
1411
|
+
port: this.dshPort ?? 3080,
|
|
1412
|
+
argv: process.argv.slice(1),
|
|
1413
|
+
cwd: process.cwd(),
|
|
1414
|
+
execPath: process.execPath,
|
|
1415
|
+
logFile,
|
|
1416
|
+
childLog,
|
|
1417
|
+
});
|
|
1418
|
+
// spawn 的失败是异步事件('error'),只 try/catch 抓不到——必须挂监听,否则会变成未捕获异常
|
|
1419
|
+
const helperError = await new Promise((resolve) => {
|
|
1420
|
+
let settled = false;
|
|
1421
|
+
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
1422
|
+
try {
|
|
1423
|
+
const child = spawnImpl(process.execPath, [helper, payload], {
|
|
1424
|
+
cwd: process.cwd(),
|
|
1425
|
+
env,
|
|
1426
|
+
detached: true,
|
|
1427
|
+
stdio: 'ignore',
|
|
1428
|
+
});
|
|
1429
|
+
child.once?.('error', (err) => settle(err));
|
|
1430
|
+
child.once?.('spawn', () => { writeLog(`已派生重启助手:helperPid=${child.pid ?? '(未知)'},日志:${logFile}`); settle(null); });
|
|
1431
|
+
child.unref?.();
|
|
1322
1432
|
} catch (err) {
|
|
1323
|
-
|
|
1324
|
-
process.exit(0);
|
|
1433
|
+
settle(err);
|
|
1325
1434
|
}
|
|
1326
|
-
}
|
|
1435
|
+
});
|
|
1327
1436
|
|
|
1328
|
-
|
|
1437
|
+
if (helperError) {
|
|
1438
|
+
writeLog(`派生重启助手失败:${helperError.message}`);
|
|
1439
|
+
return { ok: false, error: `重启失败:${helperError.message}(DSH 仍在运行,未做任何退出)` };
|
|
1440
|
+
}
|
|
1441
|
+
scheduleExit(600);
|
|
1442
|
+
return { ok: true, message: '正在重启:独立助手会在旧进程退出、端口释放后拉起新的 DSH(若失败会记录在 ~/.dsh/dsh-bridge/restart.log)…' };
|
|
1329
1443
|
}
|
|
1330
1444
|
|
|
1331
1445
|
getSystemMetrics() {
|
|
@@ -1954,15 +2068,13 @@ function apply(ctx, config = {}) {
|
|
|
1954
2068
|
const cfg = stored?.[platformKey];
|
|
1955
2069
|
if (!cfg) return;
|
|
1956
2070
|
const node = service.node;
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
}
|
|
1965
|
-
if (cfg.groupAutoApprove != null) node.config.groupAutoApprove = cfg.groupAutoApprove === true;
|
|
2071
|
+
// 白名单/数值字段 + 会话字符串字段(cwd/agentPreset/agentProvider/agentModel)一并回写,
|
|
2072
|
+
// 否则"设置里配了、重启就丢",远程会话会掉到默认或空 preset 层
|
|
2073
|
+
applyRestoredPlatformConfig(node.config, cfg, {
|
|
2074
|
+
numericFields,
|
|
2075
|
+
stringFields: RESTORED_STRING_FIELDS,
|
|
2076
|
+
defaultMaxMessageChars,
|
|
2077
|
+
});
|
|
1966
2078
|
|
|
1967
2079
|
node._restoringConfig = (async () => {
|
|
1968
2080
|
if (cfg.activeSessionId) {
|
|
@@ -1987,7 +2099,7 @@ function apply(ctx, config = {}) {
|
|
|
1987
2099
|
|
|
1988
2100
|
restorePlatform(wechat, {
|
|
1989
2101
|
platformKey: 'wechat',
|
|
1990
|
-
numericFields:
|
|
2102
|
+
numericFields: PLATFORM_TIMING_FIELDS,
|
|
1991
2103
|
hasCredentials: (cfg) => Boolean(cfg.token && cfg.accountId),
|
|
1992
2104
|
applyCredentials: (cfg) => wechat.gateway.setCredentials({
|
|
1993
2105
|
token: cfg.token,
|
|
@@ -1998,6 +2110,7 @@ function apply(ctx, config = {}) {
|
|
|
1998
2110
|
|
|
1999
2111
|
restorePlatform(qq, {
|
|
2000
2112
|
platformKey: 'qq',
|
|
2113
|
+
numericFields: PLATFORM_TIMING_FIELDS,
|
|
2001
2114
|
hasCredentials: (cfg) => Boolean(cfg.appId && cfg.clientSecret),
|
|
2002
2115
|
applyCredentials: (cfg) => qq.gateway.setCredentials({
|
|
2003
2116
|
appId: cfg.appId,
|
|
@@ -2011,6 +2124,7 @@ function apply(ctx, config = {}) {
|
|
|
2011
2124
|
|
|
2012
2125
|
restorePlatform(feishu, {
|
|
2013
2126
|
platformKey: 'feishu',
|
|
2127
|
+
numericFields: PLATFORM_TIMING_FIELDS,
|
|
2014
2128
|
hasCredentials: (cfg) => Boolean(cfg.appId && cfg.appSecret),
|
|
2015
2129
|
applyCredentials: (cfg) => feishu.gateway.updateConfig({
|
|
2016
2130
|
appId: cfg.appId,
|
|
@@ -2021,7 +2135,7 @@ function apply(ctx, config = {}) {
|
|
|
2021
2135
|
|
|
2022
2136
|
restorePlatform(telegram, {
|
|
2023
2137
|
platformKey: 'telegram',
|
|
2024
|
-
numericFields:
|
|
2138
|
+
numericFields: PLATFORM_TIMING_FIELDS,
|
|
2025
2139
|
defaultMaxMessageChars: 4096,
|
|
2026
2140
|
hasCredentials: (cfg) => Boolean(cfg.botToken),
|
|
2027
2141
|
applyCredentials: (cfg) => telegram.gateway.setCredentials({
|
|
@@ -2086,22 +2200,24 @@ function apply(ctx, config = {}) {
|
|
|
2086
2200
|
if (incoming.customTunnel) {
|
|
2087
2201
|
service.customTunnelConfig = incoming.customTunnel;
|
|
2088
2202
|
}
|
|
2089
|
-
// 重新载入各 IM
|
|
2090
|
-
if (incoming.wechat)
|
|
2203
|
+
// 重新载入各 IM 平台白名单与配置(含 cwd/agentPreset/agentProvider/agentModel)
|
|
2204
|
+
if (incoming.wechat) {
|
|
2205
|
+
applyRestoredPlatformConfig(wechat.node.config, incoming.wechat, { stringFields: RESTORED_STRING_FIELDS });
|
|
2206
|
+
}
|
|
2091
2207
|
if (incoming.qq) {
|
|
2092
|
-
qq.node.config
|
|
2208
|
+
applyRestoredPlatformConfig(qq.node.config, incoming.qq, { stringFields: RESTORED_STRING_FIELDS });
|
|
2093
2209
|
if (incoming.qq.appId && incoming.qq.clientSecret) {
|
|
2094
2210
|
qq.gateway.setCredentials({ appId: incoming.qq.appId, clientSecret: incoming.qq.clientSecret });
|
|
2095
2211
|
}
|
|
2096
2212
|
}
|
|
2097
2213
|
if (incoming.feishu) {
|
|
2098
|
-
feishu.node.config
|
|
2214
|
+
applyRestoredPlatformConfig(feishu.node.config, incoming.feishu, { stringFields: RESTORED_STRING_FIELDS });
|
|
2099
2215
|
if (incoming.feishu.appId && incoming.feishu.appSecret) {
|
|
2100
2216
|
feishu.gateway.setCredentials({ appId: incoming.feishu.appId, appSecret: incoming.feishu.appSecret });
|
|
2101
2217
|
}
|
|
2102
2218
|
}
|
|
2103
2219
|
if (incoming.telegram) {
|
|
2104
|
-
telegram.node.config
|
|
2220
|
+
applyRestoredPlatformConfig(telegram.node.config, incoming.telegram, { stringFields: RESTORED_STRING_FIELDS });
|
|
2105
2221
|
if (incoming.telegram.botToken) {
|
|
2106
2222
|
telegram.gateway.setCredentials({ botToken: incoming.telegram.botToken, proxy: incoming.telegram.proxy || '' });
|
|
2107
2223
|
}
|
package/lib/platform/base.js
CHANGED
|
@@ -1,147 +1,147 @@
|
|
|
1
|
-
// dsh-bridge 平台抽象基类
|
|
2
|
-
//
|
|
3
|
-
// 定义 IM 平台适配器的统一接口。每个平台(微信/QQ/飞书/Telegram…)继承本类,
|
|
4
|
-
// 实现协议层(登录、收发消息、typing)。平台无关的会话桥逻辑在 ConversationBridge
|
|
5
|
-
// (lib/platform/conversation-bridge.js)中实现,通过 platform 注入到 bridge。
|
|
6
|
-
//
|
|
7
|
-
// 生命周期:constructor → start() → stop() → dispose()
|
|
8
|
-
// 消息抽象:sendText / sendTyping / sendMedia(由子类实现)
|
|
9
|
-
|
|
10
|
-
export class Platform {
|
|
11
|
-
/**
|
|
12
|
-
* @param {object} opts
|
|
13
|
-
* @param {object} opts.ctx Cordis 上下文
|
|
14
|
-
* @param {object} opts.logger 日志器
|
|
15
|
-
* @param {object} [opts.config] 已持久化的平台配置(凭证等)
|
|
16
|
-
* @param {(patch: object) => (void|Promise<void>)} [opts.onPersist] 主插件保存回调
|
|
17
|
-
* @param {import('./conversation-bridge.js').ConversationBridge} [opts.bridge] 会话桥实例
|
|
18
|
-
*/
|
|
19
|
-
constructor({ ctx, logger, config = {}, onPersist, bridge } = {}) {
|
|
20
|
-
this.ctx = ctx
|
|
21
|
-
this.logger = logger
|
|
22
|
-
this.config = { ...config }
|
|
23
|
-
this.onPersist = onPersist ?? (() => {})
|
|
24
|
-
this.bridge = bridge ?? null
|
|
25
|
-
|
|
26
|
-
// 平台标识(子类必须设置)
|
|
27
|
-
this.id = ''
|
|
28
|
-
this.name = ''
|
|
29
|
-
|
|
30
|
-
// 连接状态与账号:子类可能用 getter 覆盖(如委托给 gateway),
|
|
31
|
-
// 因此仅在未被子类覆盖时才初始化默认值。
|
|
32
|
-
if (!('status' in this)) this.status = 'idle'
|
|
33
|
-
if (!('accountId' in this)) this.accountId = null
|
|
34
|
-
|
|
35
|
-
// 扫码/登录的流式状态(RPC 轮询读取)
|
|
36
|
-
this.loginState = {
|
|
37
|
-
phase: 'idle', // idle | qr | scaned | confirmed | done | error
|
|
38
|
-
qrPayload: null, // 待渲染内容:dataURL 图片 或 二维码文本
|
|
39
|
-
qrKind: null, // 'img' | 'text'
|
|
40
|
-
error: null,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
this.disposers = []
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ---- 平台能力声明(子类可覆盖)----
|
|
47
|
-
|
|
48
|
-
get capabilities() {
|
|
49
|
-
return {
|
|
50
|
-
supportsGroup: false, // 是否支持群聊
|
|
51
|
-
supportsMedia: false, // 是否支持媒体收发
|
|
52
|
-
supportsVoice: false, // 是否支持语音
|
|
53
|
-
supportsTyping: false, // 是否支持 typing 状态
|
|
54
|
-
maxMessageChars: 2000, // 单条消息最大字符数
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
get configured() {
|
|
59
|
-
return false
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ---- 生命周期(子类必须实现 start/stop;dispose 已提供默认实现)----
|
|
63
|
-
|
|
64
|
-
async start() {
|
|
65
|
-
throw new Error(`${this.id || 'platform'}: start() not implemented`)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async stop() {
|
|
69
|
-
throw new Error(`${this.id || 'platform'}: stop() not implemented`)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
dispose() {
|
|
73
|
-
for (const disposer of this.disposers) {
|
|
74
|
-
try { disposer() } catch { /* 忽略 */ }
|
|
75
|
-
}
|
|
76
|
-
this.disposers = []
|
|
77
|
-
this.bridge?.dispose?.()
|
|
78
|
-
this.bridge = null
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ---- 消息抽象(子类必须实现)----
|
|
82
|
-
|
|
83
|
-
async sendText(peerId, text, opts = {}) {
|
|
84
|
-
throw new Error(`${this.id || 'platform'}: sendText() not implemented`)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async sendTyping(peerId, state) {
|
|
88
|
-
return Promise.resolve()
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async sendMedia(peerId, media, opts = {}) {
|
|
92
|
-
throw new Error(`${this.id || 'platform'}: sendMedia() not implemented`)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ---- 登录(子类必须实现 login;getLoginState 已提供默认)----
|
|
96
|
-
|
|
97
|
-
async login(opts = {}) {
|
|
98
|
-
throw new Error(`${this.id || 'platform'}: login() not implemented`)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
getLoginState() {
|
|
102
|
-
return { ...this.loginState }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ---- 状态汇总(供 RPC/UI 读取)----
|
|
106
|
-
|
|
107
|
-
getStatus() {
|
|
108
|
-
return {
|
|
109
|
-
id: this.id,
|
|
110
|
-
name: this.name,
|
|
111
|
-
status: this.status,
|
|
112
|
-
configured: this.configured,
|
|
113
|
-
accountId: this.accountId,
|
|
114
|
-
login: this.getLoginState(),
|
|
115
|
-
peerId: this.bridge?.peerId ?? null,
|
|
116
|
-
sessionId: this.bridge?.activeSessionId ?? null,
|
|
117
|
-
config: this.getEditableConfig?.(),
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** 可编辑配置(供 UI 设置面板读取);子类可覆盖返回具体字段。 */
|
|
122
|
-
getEditableConfig() {
|
|
123
|
-
return {}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ---- 工具 ----
|
|
127
|
-
|
|
128
|
-
setStatus(status) {
|
|
129
|
-
if (this.status === status) return
|
|
130
|
-
this.status = status
|
|
131
|
-
try {
|
|
132
|
-
this.ctx.emit?.(`${this.id}/status`, status)
|
|
133
|
-
} catch { /* emit 失败不致命 */ }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async persist(patch) {
|
|
137
|
-
try {
|
|
138
|
-
await this.onPersist(patch)
|
|
139
|
-
} catch (err) {
|
|
140
|
-
this.logger?.warn?.(`[dsh-bridge ${this.id}] persist failed: %s`, err?.message ?? err)
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async destroy() {
|
|
145
|
-
this.dispose()
|
|
146
|
-
}
|
|
147
|
-
}
|
|
1
|
+
// dsh-bridge 平台抽象基类
|
|
2
|
+
//
|
|
3
|
+
// 定义 IM 平台适配器的统一接口。每个平台(微信/QQ/飞书/Telegram…)继承本类,
|
|
4
|
+
// 实现协议层(登录、收发消息、typing)。平台无关的会话桥逻辑在 ConversationBridge
|
|
5
|
+
// (lib/platform/conversation-bridge.js)中实现,通过 platform 注入到 bridge。
|
|
6
|
+
//
|
|
7
|
+
// 生命周期:constructor → start() → stop() → dispose()
|
|
8
|
+
// 消息抽象:sendText / sendTyping / sendMedia(由子类实现)
|
|
9
|
+
|
|
10
|
+
export class Platform {
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} opts
|
|
13
|
+
* @param {object} opts.ctx Cordis 上下文
|
|
14
|
+
* @param {object} opts.logger 日志器
|
|
15
|
+
* @param {object} [opts.config] 已持久化的平台配置(凭证等)
|
|
16
|
+
* @param {(patch: object) => (void|Promise<void>)} [opts.onPersist] 主插件保存回调
|
|
17
|
+
* @param {import('./conversation-bridge.js').ConversationBridge} [opts.bridge] 会话桥实例
|
|
18
|
+
*/
|
|
19
|
+
constructor({ ctx, logger, config = {}, onPersist, bridge } = {}) {
|
|
20
|
+
this.ctx = ctx
|
|
21
|
+
this.logger = logger
|
|
22
|
+
this.config = { ...config }
|
|
23
|
+
this.onPersist = onPersist ?? (() => {})
|
|
24
|
+
this.bridge = bridge ?? null
|
|
25
|
+
|
|
26
|
+
// 平台标识(子类必须设置)
|
|
27
|
+
this.id = ''
|
|
28
|
+
this.name = ''
|
|
29
|
+
|
|
30
|
+
// 连接状态与账号:子类可能用 getter 覆盖(如委托给 gateway),
|
|
31
|
+
// 因此仅在未被子类覆盖时才初始化默认值。
|
|
32
|
+
if (!('status' in this)) this.status = 'idle'
|
|
33
|
+
if (!('accountId' in this)) this.accountId = null
|
|
34
|
+
|
|
35
|
+
// 扫码/登录的流式状态(RPC 轮询读取)
|
|
36
|
+
this.loginState = {
|
|
37
|
+
phase: 'idle', // idle | qr | scaned | confirmed | done | error
|
|
38
|
+
qrPayload: null, // 待渲染内容:dataURL 图片 或 二维码文本
|
|
39
|
+
qrKind: null, // 'img' | 'text'
|
|
40
|
+
error: null,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.disposers = []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- 平台能力声明(子类可覆盖)----
|
|
47
|
+
|
|
48
|
+
get capabilities() {
|
|
49
|
+
return {
|
|
50
|
+
supportsGroup: false, // 是否支持群聊
|
|
51
|
+
supportsMedia: false, // 是否支持媒体收发
|
|
52
|
+
supportsVoice: false, // 是否支持语音
|
|
53
|
+
supportsTyping: false, // 是否支持 typing 状态
|
|
54
|
+
maxMessageChars: 2000, // 单条消息最大字符数
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get configured() {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- 生命周期(子类必须实现 start/stop;dispose 已提供默认实现)----
|
|
63
|
+
|
|
64
|
+
async start() {
|
|
65
|
+
throw new Error(`${this.id || 'platform'}: start() not implemented`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async stop() {
|
|
69
|
+
throw new Error(`${this.id || 'platform'}: stop() not implemented`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
dispose() {
|
|
73
|
+
for (const disposer of this.disposers) {
|
|
74
|
+
try { disposer() } catch { /* 忽略 */ }
|
|
75
|
+
}
|
|
76
|
+
this.disposers = []
|
|
77
|
+
this.bridge?.dispose?.()
|
|
78
|
+
this.bridge = null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- 消息抽象(子类必须实现)----
|
|
82
|
+
|
|
83
|
+
async sendText(peerId, text, opts = {}) {
|
|
84
|
+
throw new Error(`${this.id || 'platform'}: sendText() not implemented`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async sendTyping(peerId, state) {
|
|
88
|
+
return Promise.resolve()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async sendMedia(peerId, media, opts = {}) {
|
|
92
|
+
throw new Error(`${this.id || 'platform'}: sendMedia() not implemented`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- 登录(子类必须实现 login;getLoginState 已提供默认)----
|
|
96
|
+
|
|
97
|
+
async login(opts = {}) {
|
|
98
|
+
throw new Error(`${this.id || 'platform'}: login() not implemented`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getLoginState() {
|
|
102
|
+
return { ...this.loginState }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- 状态汇总(供 RPC/UI 读取)----
|
|
106
|
+
|
|
107
|
+
getStatus() {
|
|
108
|
+
return {
|
|
109
|
+
id: this.id,
|
|
110
|
+
name: this.name,
|
|
111
|
+
status: this.status,
|
|
112
|
+
configured: this.configured,
|
|
113
|
+
accountId: this.accountId,
|
|
114
|
+
login: this.getLoginState(),
|
|
115
|
+
peerId: this.bridge?.peerId ?? null,
|
|
116
|
+
sessionId: this.bridge?.activeSessionId ?? null,
|
|
117
|
+
config: this.getEditableConfig?.(),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 可编辑配置(供 UI 设置面板读取);子类可覆盖返回具体字段。 */
|
|
122
|
+
getEditableConfig() {
|
|
123
|
+
return {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- 工具 ----
|
|
127
|
+
|
|
128
|
+
setStatus(status) {
|
|
129
|
+
if (this.status === status) return
|
|
130
|
+
this.status = status
|
|
131
|
+
try {
|
|
132
|
+
this.ctx.emit?.(`${this.id}/status`, status)
|
|
133
|
+
} catch { /* emit 失败不致命 */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async persist(patch) {
|
|
137
|
+
try {
|
|
138
|
+
await this.onPersist(patch)
|
|
139
|
+
} catch (err) {
|
|
140
|
+
this.logger?.warn?.(`[dsh-bridge ${this.id}] persist failed: %s`, err?.message ?? err)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async destroy() {
|
|
145
|
+
this.dispose()
|
|
146
|
+
}
|
|
147
|
+
}
|