@qzsy/dsh-bridge 2.18.0 → 3.0.1
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 +15 -1094
- package/LICENSE +21 -21
- package/README.md +73 -476
- package/client/build.mjs +1 -3
- package/client/client.js +381 -9747
- package/client/index.js +242 -6828
- package/client/locales.mjs +99 -233
- package/cordis.patch.yml +5 -3
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +414 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +303 -3496
- package/lib/saas/tunnel-client.mjs +529 -467
- package/locale/en.json +3 -3
- package/locale/zh.json +3 -3
- package/package.json +15 -70
- package/README.en.md +0 -488
- package/client/host-families.js +0 -442
- package/client/mobile-styles.js +0 -1299
- package/client/mobile-yield.js +0 -219
- package/client/panel-locales.mjs +0 -777
- package/client/picker-yield.js +0 -77
- package/client/resource-url-compat.js +0 -90
- package/client/settings-nav-icon.js +0 -105
- package/client/theme-tokens.js +0 -49
- package/client/tunnel-guard.js +0 -46
- package/client/unlock-manager.js +0 -144
- package/lib/auth/dsh-native-cookie.js +0 -148
- package/lib/auth/login-template.js +0 -516
- package/lib/auth/manager.js +0 -596
- package/lib/bridge-rpc-constants.js +0 -66
- package/lib/bridge-rpc.js +0 -722
- package/lib/cloudflared-manager.mjs +0 -1277
- package/lib/cloudflared-network-args.js +0 -99
- package/lib/cloudflared-process-scan.js +0 -153
- package/lib/compat.js +0 -172
- package/lib/connection-compat.js +0 -115
- package/lib/feishu/comment.js +0 -50
- package/lib/feishu/gateway.js +0 -644
- package/lib/feishu/index.js +0 -432
- package/lib/feishu/lark-bundled.mjs +0 -131929
- package/lib/feishu/markdown.js +0 -148
- package/lib/feishu/node.js +0 -1152
- package/lib/feishu/slash-commands.js +0 -255
- package/lib/platform/account-ops.js +0 -248
- package/lib/platform/activity.js +0 -169
- package/lib/platform/ask-user.js +0 -293
- package/lib/platform/base.js +0 -147
- package/lib/platform/changes-digest.js +0 -93
- package/lib/platform/commands.js +0 -267
- package/lib/platform/config-restore.js +0 -78
- package/lib/platform/conversation-bridge.js +0 -1544
- package/lib/platform/dsh-storage.js +0 -222
- package/lib/platform/index.js +0 -10
- package/lib/platform/instances.js +0 -458
- package/lib/platform/manager.js +0 -130
- package/lib/platform/message-split.js +0 -229
- package/lib/platform/session-catalog.js +0 -405
- package/lib/platform/session-config.js +0 -52
- package/lib/platform/stream-slices.js +0 -21
- package/lib/qq/gateway.js +0 -1107
- package/lib/qq/index.js +0 -561
- package/lib/qq/node.js +0 -1153
- package/lib/qq/qqbot-bundled.mjs +0 -8273
- package/lib/restart-helper.mjs +0 -133
- package/lib/security/log-sanitize.js +0 -50
- package/lib/security/path-validator.js +0 -195
- package/lib/security/rate-limiter.js +0 -93
- package/lib/session-strip.js +0 -100
- package/lib/sse-keepalive.js +0 -115
- package/lib/telegram/gateway.js +0 -643
- package/lib/telegram/index.js +0 -255
- package/lib/telegram/node.js +0 -410
- package/lib/tunnel-client.mjs +0 -537
- package/lib/wechat/gateway.js +0 -1000
- package/lib/wechat/index.js +0 -282
- package/lib/wechat/media.js +0 -302
- package/lib/wechat/node.js +0 -360
- package/llms.txt +0 -36
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
// DSH 私有存储读取(workspace.json / session_projcache.json 兜底)
|
|
2
|
-
// 自 conversation-bridge.js 拆出。策略:内存服务(workspaceRegistry)优先,
|
|
3
|
-
// 仅当内存服务缺失时才落盘读 DSH 存储;文件不存在时安全返回空值。
|
|
4
|
-
// 注意:不再以 ctx._mock 作为跳过依据——测试通过提供内存服务或注入 DSH_HOME 保持隔离。
|
|
5
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
-
import { createHash } from 'node:crypto'
|
|
7
|
-
import { join, basename } from 'node:path'
|
|
8
|
-
import { homedir } from 'node:os'
|
|
9
|
-
|
|
10
|
-
export function getArchivedSessionIds(ctx) {
|
|
11
|
-
const archived = new Set()
|
|
12
|
-
// 1. 尝试从 ctx.workspaceRegistry 内存服务读取
|
|
13
|
-
try {
|
|
14
|
-
const list = ctx?.workspaceRegistry?.archivedSessionIds
|
|
15
|
-
if (Array.isArray(list)) {
|
|
16
|
-
for (const id of list) {
|
|
17
|
-
if (id) archived.add(String(id))
|
|
18
|
-
}
|
|
19
|
-
return archived
|
|
20
|
-
}
|
|
21
|
-
} catch { /* ignore */ }
|
|
22
|
-
|
|
23
|
-
// 2. 尝试从 DSH workspace 存储文件($DSH_HOME/storages/workspace.json)读取兜底
|
|
24
|
-
{
|
|
25
|
-
try {
|
|
26
|
-
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
27
|
-
const wsFile = join(home, 'storages', 'workspace.json')
|
|
28
|
-
if (existsSync(wsFile)) {
|
|
29
|
-
const data = JSON.parse(readFileSync(wsFile, 'utf8'))
|
|
30
|
-
const fileArchived = data?.global?.archivedSessionIds
|
|
31
|
-
if (Array.isArray(fileArchived)) {
|
|
32
|
-
for (const id of fileArchived) {
|
|
33
|
-
if (id) archived.add(String(id))
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
} catch { /* ignore */ }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return archived
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* 安全探测 ctx 上的非 inject 属性。
|
|
45
|
-
* 宿主 cordis 上下文对插件未在 inject 中声明的属性读取会直接抛错
|
|
46
|
-
* ('cannot get property "x" without inject'),因此内存注入点(测试夹具用)
|
|
47
|
-
* 必须经 try/catch 探测,绝不能让异常外溢到命令路径。
|
|
48
|
-
*
|
|
49
|
-
* 这不仅是"防御性写法",而是**崩溃边界**:探测点若位于宿主的 session 事件回调里
|
|
50
|
-
* (会话桥 onEvent),未捕获的 cordis 异常会冒泡成 `dsh: fatal load failure`,
|
|
51
|
-
* 让整个宿主进程退出(实测 2026-10-16 03:02 因 workspaceChanges 未探测而崩溃重启)。
|
|
52
|
-
*/
|
|
53
|
-
export function peekCtxProperty(ctx, key) {
|
|
54
|
-
if (!ctx) return undefined
|
|
55
|
-
try { return ctx[key] } catch { return undefined }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// 读取单个 per-record 投影缓存文件,返回其 record(消费方期望的形状)。
|
|
59
|
-
// 文件形状:{ version, record: { identity, rows } }
|
|
60
|
-
function readProjRecordFile(file) {
|
|
61
|
-
try {
|
|
62
|
-
const data = JSON.parse(readFileSync(file, 'utf8'))
|
|
63
|
-
const record = data?.record
|
|
64
|
-
if (record && typeof record === 'object') return record
|
|
65
|
-
} catch { /* 损坏/半写文件:视为无缓存(宿主自身也把该格式视为可丢弃的派生数据) */ }
|
|
66
|
-
return undefined
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* 读取 DSH 官方持久化会话缓存元数据(标题、是否空白、创建时间等)。
|
|
71
|
-
*
|
|
72
|
-
* DSH 0.1.7 起 `session_projcache` 领域改为 **per-record 布局**:
|
|
73
|
-
* `<DSH_HOME>/storages/session_projcache/sessions/<sessionId>.json`(每条会话一个文件,
|
|
74
|
-
* 顶层 `{ version, record: { identity, rows } }`)。
|
|
75
|
-
* 0.1.6 及更早则是单文件 `<DSH_HOME>/storages/session_projcache.json`
|
|
76
|
-
* (`{ tables: { sessions: { <id>: { identity, rows } } } }`)。
|
|
77
|
-
* 见 @deepseek-ai/dsh-session-projection-cache 的 projectionCacheDomainSpec
|
|
78
|
-
* (`layout: 'per-record'`)。
|
|
79
|
-
*
|
|
80
|
-
* 两种布局都必须支持:只读旧路径会让升级到 0.1.7 的用户标题全部退化为「新会话」,
|
|
81
|
-
* 只读新路径则会让未升级用户回归。故此处返回一个**按 id 惰性读取**的代理对象:
|
|
82
|
-
* - 消费方(session-catalog)只按 sessionId 取值,从不遍历 keys,
|
|
83
|
-
* 因此无需为一次 /sessions 同步读取数百个文件(本机实测 470 个);
|
|
84
|
-
* - 形状与旧布局一致(直接给出 `{ identity, rows }`),消费方无需感知差异。
|
|
85
|
-
*/
|
|
86
|
-
export function getSessionProjCache(ctx) {
|
|
87
|
-
const injected = peekCtxProperty(ctx, 'sessionProjCache')
|
|
88
|
-
if (injected) return injected
|
|
89
|
-
|
|
90
|
-
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
91
|
-
const legacyFile = join(home, 'storages', 'session_projcache.json')
|
|
92
|
-
const recordDirs = resolveProjCacheDirs(home)
|
|
93
|
-
|
|
94
|
-
// 旧布局(0.1.6-):整份读入,形状已与消费方一致
|
|
95
|
-
const legacy = (() => {
|
|
96
|
-
try {
|
|
97
|
-
if (!existsSync(legacyFile)) return null
|
|
98
|
-
const data = JSON.parse(readFileSync(legacyFile, 'utf8'))
|
|
99
|
-
const sessions = data?.tables?.sessions
|
|
100
|
-
return sessions && typeof sessions === 'object' ? sessions : null
|
|
101
|
-
} catch { return null }
|
|
102
|
-
})()
|
|
103
|
-
|
|
104
|
-
// 新布局(0.1.7+):按 id 惰性读单文件;legacy 命中时优先用 legacy
|
|
105
|
-
const cache = new Map()
|
|
106
|
-
const lookup = (id) => {
|
|
107
|
-
if (cache.has(id)) return cache.get(id)
|
|
108
|
-
let record
|
|
109
|
-
for (const dir of recordDirs) {
|
|
110
|
-
for (const name of projRecordFileNames(id)) {
|
|
111
|
-
try {
|
|
112
|
-
const file = join(dir, name)
|
|
113
|
-
if (existsSync(file)) { record = readProjRecordFile(file) }
|
|
114
|
-
} catch { /* 读取失败视为无缓存 */ }
|
|
115
|
-
if (record) break
|
|
116
|
-
}
|
|
117
|
-
if (record) break
|
|
118
|
-
}
|
|
119
|
-
cache.set(id, record)
|
|
120
|
-
return record
|
|
121
|
-
}
|
|
122
|
-
return new Proxy(legacy ?? {}, {
|
|
123
|
-
get(target, prop) {
|
|
124
|
-
if (typeof prop !== 'string') return Reflect.get(target, prop)
|
|
125
|
-
if (Object.prototype.hasOwnProperty.call(target, prop)) return target[prop]
|
|
126
|
-
return lookup(prop)
|
|
127
|
-
},
|
|
128
|
-
has(target, prop) {
|
|
129
|
-
if (typeof prop === 'string' && Object.prototype.hasOwnProperty.call(target, prop)) return true
|
|
130
|
-
if (typeof prop !== 'string') return Reflect.has(target, prop)
|
|
131
|
-
return !!lookup(prop)
|
|
132
|
-
},
|
|
133
|
-
})
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* 投影缓存文件的候选文件名。
|
|
138
|
-
*
|
|
139
|
-
* 命中两种命名世代(都源自宿主的 per-record 布局):
|
|
140
|
-
* - 官方 `session_projcache`:文件名为**裸 sessionId**(`<id>.json`);
|
|
141
|
-
* - 第三方接管的域(本机实测 @michengai/dsh-archive-manager 的
|
|
142
|
-
* `session_projcache_archive_manager_v2`):文件名是
|
|
143
|
-
* `session_<base64url(sha256(sessionId))>`(见其 projcache.js 的
|
|
144
|
-
* projectionCacheStorageKey,为规避文件名非法字符与长度限制)。
|
|
145
|
-
* 只认其中一种就会在另一世代上读空 → /sessions 标题全部退化成「新会话」。
|
|
146
|
-
*/
|
|
147
|
-
function projRecordFileNames(id) {
|
|
148
|
-
const names = [`${id}.json`]
|
|
149
|
-
try {
|
|
150
|
-
const hash = createHash('sha256').update(String(id), 'utf8').digest('base64url')
|
|
151
|
-
names.push(`session_${hash}.json`)
|
|
152
|
-
} catch { /* 哈希不可用时只试裸 id */ }
|
|
153
|
-
return names
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* 定位投影缓存的 per-record 目录(可多个,按优先级)。
|
|
158
|
-
*
|
|
159
|
-
* `session_projcache` 不是稳定路径:第三方插件可把该存储域整体改名(本机实测
|
|
160
|
-
* dsh-archive-manager → `session_projcache_archive_manager_v2`,更早还有
|
|
161
|
-
* `session_projcache_archive_manager`)。故全部列出、逐个尝试,官方默认路径放最后
|
|
162
|
-
* 兜底;实际命中的那个由「该目录里是否有该会话的文件」决定,而不是靠猜。
|
|
163
|
-
*/
|
|
164
|
-
function resolveProjCacheDirs(home) {
|
|
165
|
-
const base = join(home, 'storages')
|
|
166
|
-
return [
|
|
167
|
-
join(base, 'session_projcache_archive_manager_v2', 'sessions'),
|
|
168
|
-
join(base, 'session_projcache_archive_manager', 'sessions'),
|
|
169
|
-
join(base, 'session_projcache', 'sessions'),
|
|
170
|
-
]
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** 读取 DSH 官方注册的工作区列表及各自绑定的 sessionIds 列表 */
|
|
174
|
-
export async function getRegisteredWorkspaces(ctx) {
|
|
175
|
-
const workspaces = []
|
|
176
|
-
|
|
177
|
-
// 优先从内存服务获取
|
|
178
|
-
if (ctx?.workspaceRegistry) {
|
|
179
|
-
try {
|
|
180
|
-
const list = await ctx.workspaceRegistry.list?.()
|
|
181
|
-
if (Array.isArray(list)) {
|
|
182
|
-
for (const w of list) {
|
|
183
|
-
if (w && w.path) {
|
|
184
|
-
workspaces.push({
|
|
185
|
-
id: w.id || w.path,
|
|
186
|
-
path: w.path,
|
|
187
|
-
title: w.title || basename(w.path),
|
|
188
|
-
sessionIds: Array.isArray(w.sessionIds) ? [...w.sessionIds] : [],
|
|
189
|
-
})
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return workspaces
|
|
193
|
-
}
|
|
194
|
-
} catch { /* ignore */ }
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// 兜底从 workspace.json 存储文件读取
|
|
198
|
-
{
|
|
199
|
-
try {
|
|
200
|
-
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
201
|
-
const wsFile = join(home, 'storages', 'workspace.json')
|
|
202
|
-
if (existsSync(wsFile)) {
|
|
203
|
-
const data = JSON.parse(readFileSync(wsFile, 'utf8'))
|
|
204
|
-
const wsIds = data?.global?.workspaceIds || Object.keys(data?.tables?.workspaces || {})
|
|
205
|
-
const table = data?.tables?.workspaces || {}
|
|
206
|
-
for (const wId of wsIds) {
|
|
207
|
-
const ws = table[wId]
|
|
208
|
-
if (ws && ws.path) {
|
|
209
|
-
workspaces.push({
|
|
210
|
-
id: wId,
|
|
211
|
-
path: ws.path,
|
|
212
|
-
title: ws.title || basename(ws.path),
|
|
213
|
-
sessionIds: Array.isArray(ws.sessionIds) ? [...ws.sessionIds] : [],
|
|
214
|
-
})
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
} catch { /* ignore */ }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return workspaces
|
|
222
|
-
}
|
package/lib/platform/index.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// dsh-bridge 平台抽象层统一导出
|
|
2
|
-
//
|
|
3
|
-
// 提供多平台 IM 接入的基础设施:
|
|
4
|
-
// Platform 平台适配器基类(协议/连接/登录/收发消息)
|
|
5
|
-
// ConversationBridge 平台无关会话桥(白名单/会话/审批/命令/digest)
|
|
6
|
-
// PlatformManager 多平台注册与状态聚合
|
|
7
|
-
|
|
8
|
-
export { Platform } from './base.js'
|
|
9
|
-
export { ConversationBridge, conversationBridgeHelpers, textOfAssistantMessage } from './conversation-bridge.js'
|
|
10
|
-
export { PlatformManager } from './manager.js'
|
|
@@ -1,458 +0,0 @@
|
|
|
1
|
-
// 平台多账号(同平台多实例)纯函数模块 — issue #78
|
|
2
|
-
//
|
|
3
|
-
// config.json 的平台段从"单对象"升级为 `{ accounts: [...], seq }`:
|
|
4
|
-
// - accounts 数组顺序 = 面板展示顺序;
|
|
5
|
-
// - seq 为每个类型独立的单调递增计数器,新增账号取 `${type}:${seq}`,
|
|
6
|
-
// 已删除的序号永不复用(避免会话/白名单串号);
|
|
7
|
-
// - 旧格式(`config[type] = { appId, ... }`)读路径仍兼容,由本模块无损迁移。
|
|
8
|
-
//
|
|
9
|
-
// 本模块只做纯数据变换:不读文件、不依赖 ctx / logger,便于直接单测。
|
|
10
|
-
// 所有函数都不原地修改入参(返回新对象/新数组)。
|
|
11
|
-
|
|
12
|
-
/** 支持多账号的平台类型(与现有 platform.id 一致)。 */
|
|
13
|
-
export const PLATFORM_TYPES = ['wechat', 'qq', 'feishu', 'telegram']
|
|
14
|
-
|
|
15
|
-
/** 单个平台类型的账号数量上限(契约 §5,Lead 裁定 4)。 */
|
|
16
|
-
export const MAX_ACCOUNTS_PER_TYPE = 10
|
|
17
|
-
|
|
18
|
-
/** 平台中文名兜底(正常情况下由 lib/index.js 传入服务类的 this.name)。 */
|
|
19
|
-
export const PLATFORM_TYPE_LABELS = {
|
|
20
|
-
wechat: '微信',
|
|
21
|
-
qq: 'QQ',
|
|
22
|
-
feishu: '飞书',
|
|
23
|
-
telegram: 'Telegram',
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* 各类型用于「同类型内唯一」校验的凭证字段(契约 §5)。
|
|
28
|
-
* 单字段按值比较;wechat 为组合键(token + accountId 必须同时非空才参与查重)。
|
|
29
|
-
*/
|
|
30
|
-
const CREDENTIAL_FIELDS = {
|
|
31
|
-
wechat: ['token', 'accountId'],
|
|
32
|
-
qq: ['appId'],
|
|
33
|
-
feishu: ['appId'],
|
|
34
|
-
telegram: ['botToken'],
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function isPlainObject(value) {
|
|
38
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function nonEmptyString(value) {
|
|
42
|
-
return typeof value === 'string' ? value.trim() : ''
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function normalizeSeq(value) {
|
|
46
|
-
return Number.isSafeInteger(value) && value >= 0 ? value : 0
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* 从 instanceId 取平台类型:`feishu` / `feishu:2` → `feishu`。
|
|
51
|
-
* 非法/空值返回空串(调用方据此拒绝)。
|
|
52
|
-
*/
|
|
53
|
-
export function platformTypeOf(instanceId) {
|
|
54
|
-
if (typeof instanceId !== 'string') return ''
|
|
55
|
-
const idx = instanceId.indexOf(':')
|
|
56
|
-
return idx === -1 ? instanceId : instanceId.slice(0, idx)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** instanceId 是否形如 `type` 或 `type:N`(N ≥ 1 的十进制整数)。 */
|
|
60
|
-
function isValidInstanceId(type, instanceId) {
|
|
61
|
-
if (typeof instanceId !== 'string') return false
|
|
62
|
-
if (instanceId === type) return true
|
|
63
|
-
const prefix = `${type}:`
|
|
64
|
-
if (!instanceId.startsWith(prefix)) return false
|
|
65
|
-
const suffix = instanceId.slice(prefix.length)
|
|
66
|
-
return /^[1-9]\d*$/.test(suffix)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** instanceId 携带的序号(裸 `type` 视为 1,非法返回 0)。 */
|
|
70
|
-
function instanceSeqOf(type, instanceId) {
|
|
71
|
-
if (instanceId === type) return 1
|
|
72
|
-
const prefix = `${type}:`
|
|
73
|
-
if (typeof instanceId !== 'string' || !instanceId.startsWith(prefix)) return 0
|
|
74
|
-
const n = Number(instanceId.slice(prefix.length))
|
|
75
|
-
return Number.isSafeInteger(n) && n > 0 ? n : 0
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 旧格式判定:除 accounts/seq 之外还有字段(说明是"原来的单对象")。 */
|
|
79
|
-
function legacyFieldsOf(raw) {
|
|
80
|
-
const fields = {}
|
|
81
|
-
for (const key of Object.keys(raw)) {
|
|
82
|
-
if (key === 'accounts' || key === 'seq') continue
|
|
83
|
-
fields[key] = raw[key]
|
|
84
|
-
}
|
|
85
|
-
return fields
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* 规范化某个平台的配置段。
|
|
90
|
-
*
|
|
91
|
-
* 迁移规则(契约 §2):
|
|
92
|
-
* - 无 `accounts` 数组(或为空)且存在旧字段 → 整体搬进首个账号(instanceId = type,seq = 1),
|
|
93
|
-
* 旧对象所有键原样保留(含未知字段);
|
|
94
|
-
* - `accounts` 已存在但账号缺 instanceId / instanceId 非法 / 重复 → 按数组顺序补
|
|
95
|
-
* `type`、`type:N`,并提升 seq,其它字段保留;
|
|
96
|
-
* - `config[type]` 完全缺失 → 返回空段(不凭空合成账号,维持缺失语义);
|
|
97
|
-
* - `accounts: []` 原样保留(0 账号是合法状态)。
|
|
98
|
-
*
|
|
99
|
-
* 幂等:对已规范化输出再次调用,`changed === false`。
|
|
100
|
-
*
|
|
101
|
-
* @param {string} type 平台类型
|
|
102
|
-
* @param {unknown} raw config[type] 原始值
|
|
103
|
-
* @returns {{ section: { accounts: object[], seq: number }, changed: boolean }}
|
|
104
|
-
*/
|
|
105
|
-
export function normalizePlatformSection(type, raw) {
|
|
106
|
-
if (raw == null || !isPlainObject(raw)) {
|
|
107
|
-
// 缺失(不合成账号)或垃圾值(归一为空段,需写回)
|
|
108
|
-
return { section: { accounts: [], seq: 0 }, changed: raw != null }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const rawAccounts = Array.isArray(raw.accounts) ? raw.accounts : null
|
|
112
|
-
const legacyFields = legacyFieldsOf(raw)
|
|
113
|
-
const hasLegacyFields = Object.keys(legacyFields).length > 0
|
|
114
|
-
|
|
115
|
-
if (rawAccounts == null) {
|
|
116
|
-
// 无 accounts 数组:只有存在旧明细键才算"旧单对象",否则视为空段
|
|
117
|
-
if (!hasLegacyFields) {
|
|
118
|
-
// 形如 {} / { seq: n } 的空段:保持空账号,仅归一 seq
|
|
119
|
-
const seq = normalizeSeq(raw.seq)
|
|
120
|
-
return { section: { accounts: [], seq }, changed: false }
|
|
121
|
-
}
|
|
122
|
-
// D5:legacyFields 若自带 instanceId(脏数据)不得覆盖迁移结果的类型 id;
|
|
123
|
-
// D6:不得把 seq 回退——旧段若已有更大 seq 则保留,避免序号复用
|
|
124
|
-
const detailFields = { ...legacyFields }
|
|
125
|
-
delete detailFields.instanceId
|
|
126
|
-
const account = { instanceId: type, ...detailFields }
|
|
127
|
-
const seq = Math.max(1, normalizeSeq(raw.seq))
|
|
128
|
-
return { section: { accounts: [account], seq }, changed: true }
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (rawAccounts.length === 0) {
|
|
132
|
-
// D6:accounts 数组存在(哪怕为空)就是新格式,绝不复活成幽灵账号,也不重置 seq
|
|
133
|
-
return { section: { ...raw, accounts: [], seq: normalizeSeq(raw.seq) }, changed: false }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// 已有账号:先保留合法且不重复的 instanceId,统计已用序号
|
|
137
|
-
const used = new Set()
|
|
138
|
-
const normalizedAccounts = []
|
|
139
|
-
for (const entry of rawAccounts) {
|
|
140
|
-
const source = isPlainObject(entry) ? entry : {}
|
|
141
|
-
const candidate = source.instanceId
|
|
142
|
-
if (isValidInstanceId(type, candidate) && !used.has(candidate)) {
|
|
143
|
-
used.add(candidate)
|
|
144
|
-
normalizedAccounts.push({ ...source })
|
|
145
|
-
} else {
|
|
146
|
-
normalizedAccounts.push({ ...source, instanceId: null }) // 占位,第二轮分配
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
let maxUsedSeq = 0
|
|
151
|
-
for (const instanceId of used) maxUsedSeq = Math.max(maxUsedSeq, instanceSeqOf(type, instanceId))
|
|
152
|
-
let seq = Math.max(normalizeSeq(raw.seq), maxUsedSeq, 1)
|
|
153
|
-
|
|
154
|
-
// 第二遍:按数组顺序给缺失/非法/重复的账号分配新 instanceId
|
|
155
|
-
for (let i = 0; i < normalizedAccounts.length; i += 1) {
|
|
156
|
-
const account = normalizedAccounts[i]
|
|
157
|
-
if (account.instanceId) continue
|
|
158
|
-
let nextId
|
|
159
|
-
if (!used.has(type)) {
|
|
160
|
-
nextId = type
|
|
161
|
-
} else {
|
|
162
|
-
do {
|
|
163
|
-
seq += 1
|
|
164
|
-
nextId = `${type}:${seq}`
|
|
165
|
-
} while (used.has(nextId))
|
|
166
|
-
}
|
|
167
|
-
used.add(nextId)
|
|
168
|
-
account.instanceId = nextId
|
|
169
|
-
maxUsedSeq = Math.max(maxUsedSeq, instanceSeqOf(type, nextId))
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
seq = Math.max(seq, maxUsedSeq)
|
|
173
|
-
|
|
174
|
-
// 保证 instanceId 是账号对象的第一个键(与写入侧格式一致,稳定 changed 判定)
|
|
175
|
-
const accounts = normalizedAccounts.map((account) => {
|
|
176
|
-
const { instanceId, ...rest } = account
|
|
177
|
-
return { instanceId, ...rest }
|
|
178
|
-
})
|
|
179
|
-
const section = { ...raw, accounts, seq }
|
|
180
|
-
return { section, changed: JSON.stringify(section) !== JSON.stringify(raw) }
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* 平台凭证字段的历史别名迁移(在规范化之后、比较 changed 之前执行)。
|
|
185
|
-
*
|
|
186
|
-
* QQ 对接改为官方 SDK 后,凭证字段名从 `clientSecret` 统一为 `appSecret`
|
|
187
|
-
* (与 SDK / 飞书一致)。旧配置里的 `clientSecret`/`accessToken`/`gatewayUrl`
|
|
188
|
-
* 属于自研协议栈残留:把密钥搬过去,其余丢弃,避免"配了却连不上"。
|
|
189
|
-
*
|
|
190
|
-
* @param {string} type
|
|
191
|
-
* @param {object} account
|
|
192
|
-
* @returns {object} 新账号对象(无变化时返回原对象引用)
|
|
193
|
-
*/
|
|
194
|
-
export function migrateLegacyAccount(type, account) {
|
|
195
|
-
if (!isPlainObject(account)) return account
|
|
196
|
-
if (type !== 'qq') return account
|
|
197
|
-
const next = { ...account }
|
|
198
|
-
let changed = false
|
|
199
|
-
if (next.appSecret === undefined && typeof next.clientSecret === 'string') {
|
|
200
|
-
next.appSecret = next.clientSecret
|
|
201
|
-
changed = true
|
|
202
|
-
}
|
|
203
|
-
for (const legacyKey of ['clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl']) {
|
|
204
|
-
if (legacyKey in next) {
|
|
205
|
-
delete next[legacyKey]
|
|
206
|
-
changed = true
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return changed ? next : account
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* 对 config 中四个平台段统一规范化(返回新对象,不原地改入参)。
|
|
214
|
-
* 未发生变化的类型保持原引用。
|
|
215
|
-
*
|
|
216
|
-
* @param {object} config 完整 config.json 对象
|
|
217
|
-
* @returns {{ config: object, changed: boolean }}
|
|
218
|
-
*/
|
|
219
|
-
export function normalizePlatformConfig(config) {
|
|
220
|
-
const source = isPlainObject(config) ? config : {}
|
|
221
|
-
const out = { ...source }
|
|
222
|
-
let changed = false
|
|
223
|
-
for (const type of PLATFORM_TYPES) {
|
|
224
|
-
const result = normalizePlatformSection(type, source[type])
|
|
225
|
-
if (result.changed) {
|
|
226
|
-
out[type] = result.section
|
|
227
|
-
changed = true
|
|
228
|
-
}
|
|
229
|
-
// 凭证字段别名迁移(QQ 的 clientSecret → appSecret)
|
|
230
|
-
const section = out[type] ?? result.section
|
|
231
|
-
if (isPlainObject(section) && Array.isArray(section.accounts)) {
|
|
232
|
-
let migrated = false
|
|
233
|
-
const accounts = section.accounts.map((account) => {
|
|
234
|
-
const next = migrateLegacyAccount(type, account)
|
|
235
|
-
if (next !== account) migrated = true
|
|
236
|
-
return next
|
|
237
|
-
})
|
|
238
|
-
if (migrated) {
|
|
239
|
-
out[type] = { ...section, accounts }
|
|
240
|
-
changed = true
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return { config: out, changed }
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* 按 instanceId 合并 patch(read-modify-write:只动目标账号,其它账号原样保留)。
|
|
249
|
-
* 目标不存在时抛错(调用方应视为内部错误/参数错误)。
|
|
250
|
-
*
|
|
251
|
-
* @param {object} section 平台配置段(新旧格式均可,内部先规范化)
|
|
252
|
-
* @param {string} instanceId 目标账号
|
|
253
|
-
* @param {object} patch 要合并的字段
|
|
254
|
-
* @returns {{ accounts: object[], seq: number }} 新配置段
|
|
255
|
-
*/
|
|
256
|
-
export function patchAccount(section, instanceId, patch) {
|
|
257
|
-
const type = platformTypeOf(instanceId)
|
|
258
|
-
if (!PLATFORM_TYPES.includes(type)) throw new Error(`非法的 instanceId: ${instanceId}`)
|
|
259
|
-
const { section: normalized } = normalizePlatformSection(type, section)
|
|
260
|
-
const idx = normalized.accounts.findIndex((account) => account.instanceId === instanceId)
|
|
261
|
-
if (idx === -1) throw new Error(`账号不存在: ${instanceId}`)
|
|
262
|
-
const merged = {
|
|
263
|
-
...normalized.accounts[idx],
|
|
264
|
-
...(isPlainObject(patch) ? patch : {}),
|
|
265
|
-
instanceId, // instanceId 是主键,不允许被 patch 改写
|
|
266
|
-
}
|
|
267
|
-
const accounts = [...normalized.accounts]
|
|
268
|
-
accounts[idx] = merged
|
|
269
|
-
return { ...normalized, accounts }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* 生成一个新账号并追加到末尾(契约:追加末尾,不改现有顺序;seq 只增不复用)。
|
|
274
|
-
*
|
|
275
|
-
* instanceId = `${type}:${seq + 1}`(seq 为该类型当前计数器;已删除的序号不回收)。
|
|
276
|
-
* 迁移来的首个账号使用裸 `type`(见 normalizePlatformSection),新增账号一律带 `:N`。
|
|
277
|
-
*
|
|
278
|
-
* @param {{ section?: object, type: string, name?: string }} opts
|
|
279
|
-
* @returns {{ section: { accounts: object[], seq: number }, account: object }}
|
|
280
|
-
*/
|
|
281
|
-
export function nextAccount({ section, type, name } = {}) {
|
|
282
|
-
if (!PLATFORM_TYPES.includes(type)) throw new Error(`不支持的平台类型: ${type}`)
|
|
283
|
-
const { section: normalized } = normalizePlatformSection(type, section)
|
|
284
|
-
// D3:上限校验下沉到纯函数,任何新增账号路径都必须经过这里
|
|
285
|
-
if (normalized.accounts.length >= MAX_ACCOUNTS_PER_TYPE) {
|
|
286
|
-
throw new Error(`单个平台最多添加 ${MAX_ACCOUNTS_PER_TYPE} 个账号`)
|
|
287
|
-
}
|
|
288
|
-
const usedSeq = Math.max(
|
|
289
|
-
normalizeSeq(normalized.seq),
|
|
290
|
-
...normalized.accounts.map((account) => instanceSeqOf(type, account.instanceId)),
|
|
291
|
-
0,
|
|
292
|
-
)
|
|
293
|
-
const nextSeq = usedSeq + 1
|
|
294
|
-
const instanceId = `${type}:${nextSeq}`
|
|
295
|
-
const account = { instanceId }
|
|
296
|
-
const trimmedName = nonEmptyString(name)
|
|
297
|
-
if (trimmedName) account.name = trimmedName
|
|
298
|
-
return {
|
|
299
|
-
section: { ...normalized, accounts: [...normalized.accounts, account], seq: nextSeq },
|
|
300
|
-
account,
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* 从配置段移除指定账号(保留其它账号与 seq;序号不回收)。
|
|
306
|
-
* 账号不存在时返回内容等价的新段(调用方先做存在性校验)。
|
|
307
|
-
*/
|
|
308
|
-
export function removeAccount(section, instanceId) {
|
|
309
|
-
const type = platformTypeOf(instanceId)
|
|
310
|
-
if (!PLATFORM_TYPES.includes(type)) throw new Error(`非法的 instanceId: ${instanceId}`)
|
|
311
|
-
const { section: normalized } = normalizePlatformSection(type, section)
|
|
312
|
-
return {
|
|
313
|
-
...normalized,
|
|
314
|
-
accounts: normalized.accounts.filter((account) => account.instanceId !== instanceId),
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* 查找与 candidate 凭证重复的"同类型其它账号"。
|
|
320
|
-
* 空凭证不参与查重;找不到返回 null。
|
|
321
|
-
*
|
|
322
|
-
* @param {{ type: string, accounts: object[], candidate: object, excludeInstanceId?: string }} opts
|
|
323
|
-
* @returns {object|null} 冲突账号
|
|
324
|
-
*/
|
|
325
|
-
export function findDuplicateCredential({ type, accounts, candidate, excludeInstanceId } = {}) {
|
|
326
|
-
const fields = CREDENTIAL_FIELDS[type]
|
|
327
|
-
if (!fields || !Array.isArray(accounts) || !isPlainObject(candidate)) return null
|
|
328
|
-
const wanted = fields.map((field) => nonEmptyString(candidate[field]))
|
|
329
|
-
if (wanted.some((value) => !value)) return null // 凭证未填完整:不参与查重
|
|
330
|
-
const hit = accounts.find((account) => {
|
|
331
|
-
if (!isPlainObject(account)) return false
|
|
332
|
-
if (account.instanceId === excludeInstanceId) return false
|
|
333
|
-
return fields.every((field, index) => nonEmptyString(account[field]) === wanted[index])
|
|
334
|
-
})
|
|
335
|
-
return hit ?? null
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* 账号展示名:优先用户填的备注名 `account.name`(trim 后非空,也兼容显式 label),
|
|
340
|
-
* 否则 `${平台中文名} ${该类型内 1 基序号}`(序号 = 数组下标,契约裁定 3)。
|
|
341
|
-
*
|
|
342
|
-
* @param {{ type: string, label?: string, account?: object, index?: number }} opts
|
|
343
|
-
* label 为平台中文名(lib/index.js 传服务类的 this.name),account.name 为用户备注名。
|
|
344
|
-
* @returns {string}
|
|
345
|
-
*/
|
|
346
|
-
export function displayNameFor({ type, label, account, index } = {}) {
|
|
347
|
-
const userName = nonEmptyString(account?.name) || nonEmptyString(label?.accountName)
|
|
348
|
-
if (userName) return userName
|
|
349
|
-
const platformLabel = nonEmptyString(label) || PLATFORM_TYPE_LABELS[type] || type || ''
|
|
350
|
-
const order = Number.isFinite(index) && index > 0 ? Math.floor(index) : 1
|
|
351
|
-
return `${platformLabel} ${order}`
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* 平台账号实例同步编排(依赖注入,便于用 fake service 单测)。
|
|
356
|
-
*
|
|
357
|
-
* 语义(契约 §5):
|
|
358
|
-
* - desired 中不存在的已注册实例:stop(失败只记日志)→ dispose(失败只记日志)→ unregister;
|
|
359
|
-
* - desired 中缺失的实例:createInstance → register;
|
|
360
|
-
* - desired 中已存在的实例:同样回调 restoreInstance 回写配置;
|
|
361
|
-
* - start=true 时只对"本次新建且 startInstance 判定有完整凭证"的实例启动。
|
|
362
|
-
*
|
|
363
|
-
* 返回报告便于测试与日志:{ created, removed, restored, started, errors }。
|
|
364
|
-
*
|
|
365
|
-
* @param {object} opts
|
|
366
|
-
* @param {import('./manager.js').PlatformManager} opts.platformManager
|
|
367
|
-
* @param {Record<string, {accounts: object[], seq: number}>} opts.sections 各类型目标配置段
|
|
368
|
-
* @param {(type: string, account: object, index: number) => (object|Promise<object>)} opts.createInstance
|
|
369
|
-
* @param {(service: object, type: string, account: object, created: boolean) => any} [opts.restoreInstance]
|
|
370
|
-
* @param {(service: object, type: string, account: object) => any} [opts.startInstance]
|
|
371
|
-
* @param {boolean} [opts.start=true]
|
|
372
|
-
* @param {object} [opts.logger]
|
|
373
|
-
*/
|
|
374
|
-
export async function reconcilePlatformInstances({
|
|
375
|
-
platformManager,
|
|
376
|
-
sections,
|
|
377
|
-
createInstance,
|
|
378
|
-
restoreInstance,
|
|
379
|
-
startInstance,
|
|
380
|
-
start = true,
|
|
381
|
-
logger,
|
|
382
|
-
} = {}) {
|
|
383
|
-
if (!platformManager || typeof platformManager.listByType !== 'function' || typeof createInstance !== 'function') {
|
|
384
|
-
throw new Error('reconcilePlatformInstances: 缺少 platformManager / createInstance')
|
|
385
|
-
}
|
|
386
|
-
const report = { created: [], removed: [], restored: [], started: [], errors: [] }
|
|
387
|
-
const recordError = (phase, instanceId, err) => {
|
|
388
|
-
const message = err?.message ?? String(err)
|
|
389
|
-
report.errors.push({ phase, instanceId, message })
|
|
390
|
-
logger?.warn?.('dsh-bridge: 平台账号同步 %s 失败(%s):%s', phase, instanceId, message)
|
|
391
|
-
}
|
|
392
|
-
const exactOf = (instanceId) => (typeof platformManager.getExact === 'function'
|
|
393
|
-
? platformManager.getExact(instanceId)
|
|
394
|
-
: platformManager.get(instanceId))
|
|
395
|
-
const instanceIdOfPlatform = (platform) => platform?.instanceId ?? platform?.id
|
|
396
|
-
|
|
397
|
-
for (const type of PLATFORM_TYPES) {
|
|
398
|
-
const section = sections?.[type] ?? { accounts: [], seq: 0 }
|
|
399
|
-
const desired = Array.isArray(section.accounts) ? section.accounts : []
|
|
400
|
-
const desiredIds = new Set(desired.map((account) => account?.instanceId).filter(Boolean))
|
|
401
|
-
|
|
402
|
-
// 1) 移除配置里已不存在的实例
|
|
403
|
-
for (const platform of platformManager.listByType(type)) {
|
|
404
|
-
const instanceId = instanceIdOfPlatform(platform)
|
|
405
|
-
if (!instanceId || desiredIds.has(instanceId)) continue
|
|
406
|
-
try {
|
|
407
|
-
await platform.stop?.()
|
|
408
|
-
} catch (err) {
|
|
409
|
-
recordError('stop', instanceId, err)
|
|
410
|
-
}
|
|
411
|
-
try {
|
|
412
|
-
platform.dispose?.()
|
|
413
|
-
} catch (err) {
|
|
414
|
-
recordError('dispose', instanceId, err)
|
|
415
|
-
}
|
|
416
|
-
platformManager.unregister(instanceId)
|
|
417
|
-
report.removed.push(instanceId)
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// 2) 创建缺失实例 / 回写已存在实例
|
|
421
|
-
for (let index = 0; index < desired.length; index += 1) {
|
|
422
|
-
const account = desired[index]
|
|
423
|
-
const instanceId = account?.instanceId
|
|
424
|
-
if (!instanceId) continue
|
|
425
|
-
let service = exactOf(instanceId)
|
|
426
|
-
let created = false
|
|
427
|
-
if (!service) {
|
|
428
|
-
try {
|
|
429
|
-
service = await createInstance(type, account, index + 1)
|
|
430
|
-
platformManager.register(service)
|
|
431
|
-
created = true
|
|
432
|
-
report.created.push(instanceId)
|
|
433
|
-
} catch (err) {
|
|
434
|
-
// 单个账号构造失败不阻断其它账号(例如宿主服务异常)
|
|
435
|
-
recordError('create', instanceId, err)
|
|
436
|
-
continue
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
if (typeof restoreInstance === 'function') {
|
|
440
|
-
try {
|
|
441
|
-
await restoreInstance(service, type, account, created)
|
|
442
|
-
report.restored.push(instanceId)
|
|
443
|
-
} catch (err) {
|
|
444
|
-
recordError('restore', instanceId, err)
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
if (start && created && typeof startInstance === 'function') {
|
|
448
|
-
try {
|
|
449
|
-
const started = await startInstance(service, type, account)
|
|
450
|
-
if (started !== false) report.started.push(instanceId)
|
|
451
|
-
} catch (err) {
|
|
452
|
-
recordError('start', instanceId, err)
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
return report
|
|
458
|
-
}
|