@wenbin_wb/dsh-bridge 2.10.7 → 2.10.9

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/lib/index.js CHANGED
@@ -10,10 +10,9 @@ 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 } from 'node:fs';
13
+ import { readFileSync, 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
- import { createHash, createHmac } from 'node:crypto';
17
16
  import QRCode from 'qrcode';
18
17
  import { installBridgeRpc } from './bridge-rpc.js';
19
18
  import { CustomTunnelClient } from './tunnel-client.mjs';
@@ -24,6 +23,7 @@ import { QqService } from './qq/index.js';
24
23
  import { FeishuService } from './feishu/index.js';
25
24
  import { TelegramService } from './telegram/index.js';
26
25
  import { AuthManager } from './auth/manager.js';
26
+ import { getDshLoopbackCookie as getDshLoopbackCookieImpl } from './auth/dsh-native-cookie.js';
27
27
  import { renderLoginPage } from './auth/login-template.js';
28
28
  import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
29
29
  import { stripSessionProjections } from './session-strip.js';
@@ -193,35 +193,14 @@ function isCompressed(headers) {
193
193
  return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
194
194
  }
195
195
 
196
- function encodeBase64Url(value) {
197
- return Buffer.from(value).toString('base64url');
198
- }
199
-
200
196
  /**
201
197
  * 读取 DSH 本地凭证并生成 loopback dsh-auth 认证签名 Cookie (适配 DSH 新版原生认证)
198
+ *
199
+ * 实现见 lib/auth/dsh-native-cookie.js(含 cookie 名称/载荷/签名的格式说明与
200
+ * 凭据记录定位策略)。此处仅做 thin wrapper,保持既有调用点不变。
202
201
  */
203
202
  function getDshLoopbackCookie(targetPort) {
204
- try {
205
- const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
206
- const credPath = join(dshHome, '.credentials.yaml');
207
- if (!existsSync(credPath)) return '';
208
- const content = readFileSync(credPath, 'utf8');
209
- const match = content.match(/secret:\s*([A-Za-z0-9_-]+)/);
210
- if (!match) return '';
211
- const secret = Buffer.from(match[1], 'base64url');
212
-
213
- const authority = `127.0.0.1:${targetPort}`;
214
- const name = 'dsh-auth-' + encodeBase64Url(createHash('sha256').update(authority).digest());
215
- const issuedAt = Date.now() - 1000;
216
- const expiresAt = issuedAt + 30 * 24 * 3600 * 1000;
217
- const body = encodeBase64Url(Buffer.from(JSON.stringify({
218
- version: 1, authority, issuedAt, expiresAt,
219
- }), 'utf8'));
220
- const sig = encodeBase64Url(createHmac('sha256', secret).update(body).digest());
221
- return `${name}=v1.${body}.${sig}`;
222
- } catch {
223
- return '';
224
- }
203
+ return getDshLoopbackCookieImpl(targetPort);
225
204
  }
226
205
 
227
206
  /** 把请求头中的 Host 和 Origin 改写成 loopback,让 DSH 的安全栅栏放行 */
@@ -1033,6 +1012,63 @@ class BridgeService {
1033
1012
  }
1034
1013
 
1035
1014
  // 检查 npm 上是否有新版本(优先国内高速镜像 npmmirror,降级 npmjs 官方源)
1015
+ // 探测 DSH 宿主 CLI 的安装形态,判断能否通过"npm 全局升级"自动更新。
1016
+ // 仅当 dsh 命令 resolve 到当前 node 同 prefix 的 node_modules/@deepseek-ai/dsh(标准 npm 全局安装)
1017
+ // 时才允许一键升级;pnpm / 源码 / Electron 加壳等形态无法用 npm -g 升级,仅返回提示。
1018
+ // @returns {Promise<{upgradable:boolean, rootGlobal?:string, dshRealPath?:string, reason?:string}>}
1019
+ async _probeDshUpgrade() {
1020
+ const isWin = process.platform === 'win32';
1021
+ const nodeDir = dirname(process.execPath);
1022
+ const existingPath = process.env.PATH || process.env.Path || '';
1023
+ const extraPaths = isWin ? [nodeDir] : [nodeDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/bin', '/bin', join(homedir(), '.local/bin'), join(homedir(), '.npm-global/bin')];
1024
+ const separator = isWin ? ';' : ':';
1025
+ const augmentedEnv = { ...process.env, PATH: [...extraPaths, existingPath].filter(Boolean).join(separator) };
1026
+ if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
1027
+
1028
+ const runCmd = (cmd, args) => new Promise((resolve) => {
1029
+ let cp;
1030
+ try {
1031
+ cp = spawn(cmd, args, { windowsHide: true, shell: true, env: augmentedEnv, timeout: 8000 });
1032
+ } catch {
1033
+ return resolve(null);
1034
+ }
1035
+ let stdout = ''; let stderr = '';
1036
+ cp.stdout?.on('data', (d) => { stdout += d.toString(); });
1037
+ cp.stderr?.on('data', (d) => { stderr += d.toString(); });
1038
+ cp.on('error', () => resolve(null));
1039
+ cp.on('close', (code) => {
1040
+ if (code === 0 && stdout.trim()) resolve(stdout.trim().split(/\r?\n/)[0]);
1041
+ else resolve(null);
1042
+ });
1043
+ });
1044
+
1045
+ try {
1046
+ // 1. 找 dsh bin 的真实路径(Windows which 不存在,用 where)
1047
+ const dshBin = await runCmd(isWin ? 'where' : 'which', [isWin ? 'dsh.cmd' : 'dsh']);
1048
+ if (!dshBin) {
1049
+ return { upgradable: false, reason: '未找到 dsh 命令(可能通过源码/打包方式运行,非 npm 全局安装)' };
1050
+ }
1051
+ // realpath 在主进程内解析(子进程 node -e + shell 引号嵌套易出错)
1052
+ let dshRealPath = dshBin;
1053
+ try {
1054
+ dshRealPath = realpathSync(dshBin) || dshBin;
1055
+ } catch { /* 保留原始路径继续判断 */ }
1056
+ // 2. 当前 node 对应的全局 node_modules 根
1057
+ const globalNodeModules = await runCmd('npm', ['root', '-g']);
1058
+ const rootGlobal = globalNodeModules ? dirname(globalNodeModules) : dirname(nodeDir);
1059
+ const marker = join('node_modules', '@deepseek-ai', 'dsh');
1060
+ if (dshRealPath.includes(marker) && (dshRealPath.startsWith(rootGlobal) || dshRealPath.includes('node-v') && dshRealPath.includes('lib'))) {
1061
+ return { upgradable: true, rootGlobal, dshRealPath };
1062
+ }
1063
+ return {
1064
+ upgradable: false,
1065
+ reason: 'dsh 非标准 npm 全局安装(Electron/打包/源码/pnpm 等),无法自动升级;请按官方渠道手动更新',
1066
+ };
1067
+ } catch {
1068
+ return { upgradable: false, reason: '探测 dsh 安装形态失败,请按官方渠道手动更新' };
1069
+ }
1070
+ }
1071
+
1036
1072
  async checkVersion() {
1037
1073
  // TTL 缓存:窗口内直接返回上次结果,避免重复请求外网 registry
1038
1074
  if (this._versionCheckCache && Date.now() - this._versionCheckCachedAt < this._versionCheckTtlMs) {
@@ -1062,18 +1098,27 @@ class BridgeService {
1062
1098
  try {
1063
1099
  const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
1064
1100
  .catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
1101
+ // DSH 宿主 CLI 最新版本(npm 全局包 @deepseek-ai/dsh;与插件同源镜像,失败不阻断面板)
1102
+ const dshLatestData = await fetchRegistry('https://registry.npmmirror.com/@deepseek-ai/dsh/latest', 3500)
1103
+ .catch(() => fetchRegistry('https://registry.npmjs.org/@deepseek-ai/dsh/latest', 5000))
1104
+ .catch(() => null);
1105
+ // 安装形态探测(决定是否给"一键升级 DSH"按钮):npm 全局可升级,加壳/源码仅提醒
1106
+ const dshProbe = await this._probeDshUpgrade();
1065
1107
  const result = {
1066
1108
  current: VERSION,
1067
1109
  latest: latestData?.version ?? null,
1068
1110
  releaseNotes: latestData?.releaseNotes ?? null,
1069
1111
  dshVersion: await this.getDshVersion(),
1112
+ dshLatest: dshLatestData?.version ?? null,
1113
+ dshUpgradable: dshProbe.upgradable,
1114
+ dshUpgradeReason: dshProbe.reason || '',
1070
1115
  };
1071
1116
  this._versionCheckCache = result;
1072
1117
  this._versionCheckCachedAt = Date.now();
1073
1118
  return result;
1074
1119
  } catch (e) {
1075
1120
  // 失败不缓存(让下次调用可重试),但记录便于诊断
1076
- return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion() };
1121
+ return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion(), dshLatest: null, dshUpgradable: false, dshUpgradeReason: '' };
1077
1122
  }
1078
1123
  }
1079
1124
 
@@ -1165,6 +1210,96 @@ class BridgeService {
1165
1210
  return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
1166
1211
  }
1167
1212
 
1213
+ // 一键升级 DSH 宿主 CLI(npm 全局包 @deepseek-ai/dsh)。
1214
+ // 升级的是"与当前 node 配对"的全局 prefix(dsh 命令所在目录),完成后需重启 DSH 生效。
1215
+ async upgradeDsh({ version } = {}) {
1216
+ const targetVersion = version ? String(version).trim() : 'latest';
1217
+ // 严格 SemVer 白名单校验(dsh 用 rc 版本号,如 0.1.2-rc.1)
1218
+ if (!/^(latest|\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/.test(targetVersion)) {
1219
+ return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
1220
+ }
1221
+
1222
+ // 安装形态守卫:非标准 npm 全局安装(Electron/源码/pnpm 等)拒绝自动升级,
1223
+ // 避免"装一份不生效的新副本、误导用户以为升级成功"
1224
+ const probe = await this._probeDshUpgrade();
1225
+ if (!probe.upgradable) {
1226
+ return {
1227
+ ok: false,
1228
+ error: probe.reason || '当前 DSH 为非 npm 全局安装,无法自动升级;请按官方渠道手动更新',
1229
+ version: targetVersion,
1230
+ };
1231
+ }
1232
+
1233
+ const pkgSpec = `@deepseek-ai/dsh@${targetVersion}`;
1234
+ const isWin = process.platform === 'win32';
1235
+ const nodeDir = dirname(process.execPath);
1236
+ const home = homedir();
1237
+ const extraPaths = isWin ? [nodeDir] : [
1238
+ nodeDir,
1239
+ '/opt/homebrew/bin',
1240
+ '/opt/homebrew/sbin',
1241
+ '/usr/local/bin',
1242
+ '/usr/bin',
1243
+ '/bin',
1244
+ join(home, '.nvm/current/bin'),
1245
+ join(home, '.fnm/current/bin'),
1246
+ join(home, '.local/bin'),
1247
+ join(home, '.cargo/bin'),
1248
+ ];
1249
+ const separator = isWin ? ';' : ':';
1250
+ const existingPath = process.env.PATH || process.env.Path || '';
1251
+ const augmentedEnv = {
1252
+ ...process.env,
1253
+ PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
1254
+ };
1255
+ if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
1256
+
1257
+ // 与当前 node 配对的 npm 绝对路径(保证装进 dsh 所在 prefix,而非 PATH 里其它 npm)
1258
+ const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
1259
+ const npmBin = existsSync(siblingNpm) ? siblingNpm : 'npm';
1260
+ // 优先用探测到的全局 root(dsh 实际所在 prefix),其次 nodeDir 父目录
1261
+ const globalRoot = probe.rootGlobal || dirname(nodeDir);
1262
+
1263
+ const tasks = [
1264
+ { cmd: npmBin, args: ['install', '-g', '--prefix', globalRoot, pkgSpec] },
1265
+ { cmd: npmBin, args: ['install', '-g', pkgSpec] },
1266
+ ];
1267
+
1268
+ let lastError = null;
1269
+ for (const task of tasks) {
1270
+ try {
1271
+ const res = await new Promise((resolve, reject) => {
1272
+ let cp;
1273
+ try {
1274
+ cp = spawn(task.cmd, task.args, {
1275
+ windowsHide: true,
1276
+ shell: true,
1277
+ env: augmentedEnv,
1278
+ timeout: 180000,
1279
+ });
1280
+ } catch (spawnErr) {
1281
+ return reject(spawnErr);
1282
+ }
1283
+ let stdout = '';
1284
+ let stderr = '';
1285
+ cp.stdout?.on('data', (d) => { stdout += d.toString(); });
1286
+ cp.stderr?.on('data', (d) => { stderr += d.toString(); });
1287
+ cp.on('error', reject);
1288
+ cp.on('close', (code) => {
1289
+ if (code === 0) resolve({ stdout, stderr });
1290
+ else reject(new Error(stderr || stdout || `进程退出码 ${code}`));
1291
+ });
1292
+ });
1293
+ const output = (res.stdout || res.stderr || '升级成功').trim().slice(-500);
1294
+ this.logger?.info('dsh-bridge: DSH 升级命令成功: %s %s', task.cmd, task.args.join(' '));
1295
+ return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
1296
+ } catch (err) {
1297
+ lastError = err;
1298
+ }
1299
+ }
1300
+ return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
1301
+ }
1302
+
1168
1303
  // 优雅重启 DSH 服务(支持守护进程自动拉起或独立派生子进程重启)
1169
1304
  async restartDsh() {
1170
1305
  this.logger?.info('收到 DSH 重启请求,正在调度重启...');
@@ -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
+ }