dsh-account-pool 0.1.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 +362 -0
- package/cordis.patch.yml +26 -0
- package/lib/accounts.js +576 -0
- package/lib/client.js +2049 -0
- package/lib/headers.js +76 -0
- package/lib/index.js +1142 -0
- package/lib/selector.js +343 -0
- package/lib/shim.js +370 -0
- package/lib/tasks.js +429 -0
- package/lib/trae-accounts.js +453 -0
- package/lib/trae-bridge.js +266 -0
- package/lib/trae-storage.js +215 -0
- package/lib/trae-upstream.js +485 -0
- package/lib/upstream.js +610 -0
- package/lib/usage.js +420 -0
- package/package.json +68 -0
package/lib/accounts.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy 多账号凭据层。
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* - 用 OAuth 设备流把多个账号登录进来,凭证落盘到 $DSH_HOME/.account-pool.<region>.json
|
|
6
|
+
* - 每个账号独立刷新 token(到期前 5 分钟),刷新结果写回自己的副本
|
|
7
|
+
* - 对外只暴露「账号列表 + 取某个账号的可用 token」两个动作
|
|
8
|
+
*
|
|
9
|
+
* 设计要点:
|
|
10
|
+
* - 按区域(cn / global)分文件存,两个区域的账号互不覆盖,可同时在线
|
|
11
|
+
* - 落盘权限 0600;写入先写临时文件再 rename,避免半截文件
|
|
12
|
+
* - 刷新按账号去重(single-flight),并发请求不会同时刷同一个账号
|
|
13
|
+
* - 刷新失败但原 token 未过期时继续沿用旧 token,不因一次网络抖动踢掉账号
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile, writeFile, rename, mkdir, rm } from 'node:fs/promises'
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { commonHeaders as baseHeaders } from './headers.js'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// 上游常量
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** 国内版网关。 */
|
|
26
|
+
const CN_BASE = 'https://copilot.tencent.com'
|
|
27
|
+
/** 国际版网关(桌面端登录域)。 */
|
|
28
|
+
const GLOBAL_BASE = 'https://www.workbuddy.ai'
|
|
29
|
+
/** 国际版备用品牌域(CodeBuddy CLI 登录域)。 */
|
|
30
|
+
const GLOBAL_CB_BASE = 'https://www.codebuddy.ai'
|
|
31
|
+
/** 国内版 Origin/Referer。 */
|
|
32
|
+
const CN_ORIGIN = 'https://www.codebuddy.cn'
|
|
33
|
+
|
|
34
|
+
/** token 到期前多久就提前刷新。 */
|
|
35
|
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000
|
|
36
|
+
|
|
37
|
+
/** 单次 JSON 请求超时。 */
|
|
38
|
+
const JSON_TIMEOUT_MS = 30_000
|
|
39
|
+
|
|
40
|
+
/** 凭据文件格式版本;将来结构变了靠它做迁移。 */
|
|
41
|
+
const FILE_VERSION = 1
|
|
42
|
+
|
|
43
|
+
/** 临时文件名序号:保证并发写入时各自的临时文件互不冲突。 */
|
|
44
|
+
let tmpSeq = 0
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// 区域与网关选择
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 登录域属于哪个区域。
|
|
52
|
+
* workbuddy.ai / codebuddy.ai 都是国际版(同一套网关,两个品牌域);
|
|
53
|
+
* 其余(含空)按国内版处理。
|
|
54
|
+
*/
|
|
55
|
+
export function regionOf(domain) {
|
|
56
|
+
const d = String(domain ?? '').trim().toLowerCase()
|
|
57
|
+
if (d === 'workbuddy.ai' || d.endsWith('.workbuddy.ai')) return 'global'
|
|
58
|
+
if (d === 'codebuddy.ai' || d.endsWith('.codebuddy.ai')) return 'global'
|
|
59
|
+
return 'cn'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 该账号的 chat 上游基地址。国际版必须跟随凭据自己的域,两个域不通用。 */
|
|
63
|
+
export function chatBaseOf(domain) {
|
|
64
|
+
if (regionOf(domain) !== 'global') return CN_BASE
|
|
65
|
+
return String(domain).trim().toLowerCase().endsWith('codebuddy.ai') ? GLOBAL_CB_BASE : GLOBAL_BASE
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 该账号请求要带的 Origin/Referer 基地址。 */
|
|
69
|
+
export function originOf(domain) {
|
|
70
|
+
return regionOf(domain) === 'global' ? chatBaseOf(domain) : CN_ORIGIN
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 稳定账号 id:优先 uin(计费身份,重新登录也不变),退化到 uid。
|
|
75
|
+
* 只取前 24 位十六进制,够用且便于展示。
|
|
76
|
+
*/
|
|
77
|
+
export function accountIdOf(credential) {
|
|
78
|
+
const stable = credential.uin || credential.uid || credential.nickname || 'unknown'
|
|
79
|
+
return createHash('sha256').update(`workbuddy\0${stable}`).digest('hex').slice(0, 24)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// HTTP 小工具
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** 上游统一信封 {code,msg,data}:code!==0 视为业务失败。 */
|
|
87
|
+
async function postJson(url, { headers, body, method = 'POST' } = {}) {
|
|
88
|
+
const response = await fetch(url, {
|
|
89
|
+
method,
|
|
90
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
91
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
92
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
|
|
93
|
+
})
|
|
94
|
+
const text = await response.text()
|
|
95
|
+
let parsed
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(text)
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`上游返回非 JSON(HTTP ${response.status}):${text.slice(0, 200)}`)
|
|
100
|
+
}
|
|
101
|
+
return { status: response.status, envelope: parsed }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 上游通用请求头(不含 Authorization)。 */
|
|
105
|
+
/**
|
|
106
|
+
* 本模块的请求头:在共享头基础上加账号维度。
|
|
107
|
+
* uid 决定设备头,区域决定 Accept-Language。
|
|
108
|
+
*/
|
|
109
|
+
function commonHeaders(origin, credential) {
|
|
110
|
+
return baseHeaders({
|
|
111
|
+
origin,
|
|
112
|
+
region: credential?.region ?? 'cn',
|
|
113
|
+
uid: credential?.uid,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// OAuth 设备授权登录
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 第一步:申请一个授权 URL 与 state。
|
|
123
|
+
* 用户拿这个 URL 去浏览器登录,插件轮询等待完成。
|
|
124
|
+
*/
|
|
125
|
+
export async function startDeviceLogin(region) {
|
|
126
|
+
const base = region === 'global' ? GLOBAL_BASE : CN_BASE
|
|
127
|
+
const origin = region === 'global' ? GLOBAL_BASE : CN_ORIGIN
|
|
128
|
+
const { envelope } = await postJson(`${base}/v2/plugin/auth/state?platform=CLI`, {
|
|
129
|
+
headers: commonHeaders(origin),
|
|
130
|
+
body: {},
|
|
131
|
+
})
|
|
132
|
+
if (envelope.code !== 0) throw new Error(`申请授权失败:code=${envelope.code} ${envelope.msg ?? ''}`)
|
|
133
|
+
const { state, authUrl } = envelope.data ?? {}
|
|
134
|
+
if (!state || !authUrl) throw new Error('上游未返回 state 或 authUrl')
|
|
135
|
+
return { state, authUrl }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 第二步:轮询换 token。
|
|
140
|
+
* 返回 {pending:true} 表示用户还没在浏览器里完成授权;
|
|
141
|
+
* 完成时返回完整凭证。
|
|
142
|
+
*/
|
|
143
|
+
export async function pollDeviceLogin(region, state) {
|
|
144
|
+
const base = region === 'global' ? GLOBAL_BASE : CN_BASE
|
|
145
|
+
const origin = region === 'global' ? GLOBAL_BASE : CN_ORIGIN
|
|
146
|
+
const headers = commonHeaders(origin, { region })
|
|
147
|
+
|
|
148
|
+
const tokenResult = await postJson(`${base}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`, {
|
|
149
|
+
headers,
|
|
150
|
+
method: 'GET',
|
|
151
|
+
})
|
|
152
|
+
const env = tokenResult.envelope
|
|
153
|
+
|
|
154
|
+
// code 11217 = "login ing",是未完成的正常响应,不是错误。
|
|
155
|
+
if (env.code === 11217 || env.code !== 0) {
|
|
156
|
+
if (tokenResult.status === 0 || tokenResult.status >= 500) {
|
|
157
|
+
throw new Error(`token 端点异常:HTTP ${tokenResult.status} ${env.msg ?? ''}`)
|
|
158
|
+
}
|
|
159
|
+
return { pending: true }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const tok = env.data ?? {}
|
|
163
|
+
if (!tok.accessToken) return { pending: true }
|
|
164
|
+
|
|
165
|
+
// 拿账号资料(uid / nickname)。失败不阻塞登录——资料只是展示用。
|
|
166
|
+
let account = {}
|
|
167
|
+
try {
|
|
168
|
+
const acct = await postJson(`${base}/v2/plugin/login/account?state=${encodeURIComponent(state)}`, {
|
|
169
|
+
headers: { ...headers, authorization: `Bearer ${tok.accessToken}` },
|
|
170
|
+
method: 'GET',
|
|
171
|
+
})
|
|
172
|
+
account = acct.envelope.data ?? {}
|
|
173
|
+
} catch {
|
|
174
|
+
// 忽略:uid 缺失时下面会退化到用 domain+nickname 做 id
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
pending: false,
|
|
179
|
+
credential: normalizeCredential({
|
|
180
|
+
accessToken: tok.accessToken,
|
|
181
|
+
refreshToken: tok.refreshToken,
|
|
182
|
+
expiresInSec: tok.expiresIn,
|
|
183
|
+
domain: tok.domain,
|
|
184
|
+
uid: account.uid,
|
|
185
|
+
enterpriseId: account.enterpriseId,
|
|
186
|
+
nickname: account.nickname,
|
|
187
|
+
issuedAtMs: Date.now(),
|
|
188
|
+
}),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 把上游登录/刷新返回值归一成内部凭证结构(时间统一为 epoch 毫秒)。 */
|
|
193
|
+
/**
|
|
194
|
+
* 解析 refresh token 的到期时刻。
|
|
195
|
+
*
|
|
196
|
+
* 上游给的是相对秒数 `refreshExpiresIn`(实测约 60 天),所以要用签发时刻
|
|
197
|
+
* 换算;某些来源可能直接给绝对时间戳,也一并兼容。
|
|
198
|
+
*/
|
|
199
|
+
function refreshExpiresAtOf(raw, issuedAtMs) {
|
|
200
|
+
const relative = Number(raw.refreshExpiresIn ?? raw.refresh_expires_in)
|
|
201
|
+
if (Number.isFinite(relative) && relative > 0) {
|
|
202
|
+
return issuedAtMs + relative * 1000
|
|
203
|
+
}
|
|
204
|
+
const absolute = Number(raw.refreshExpiresAtMs ?? raw.refreshExpiresAt)
|
|
205
|
+
if (Number.isFinite(absolute) && absolute > 0) {
|
|
206
|
+
return absolute < 1e12 ? absolute * 1000 : absolute
|
|
207
|
+
}
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function normalizeCredential(raw) {
|
|
212
|
+
const expiresInSec = Number(raw.expiresInSec ?? raw.expires_in ?? 3600)
|
|
213
|
+
const issuedAtMs = Number(raw.issuedAtMs ?? Date.now())
|
|
214
|
+
const domain = String(raw.domain ?? '')
|
|
215
|
+
const credential = {
|
|
216
|
+
accessToken: String(raw.accessToken ?? ''),
|
|
217
|
+
refreshToken: String(raw.refreshToken ?? ''),
|
|
218
|
+
domain,
|
|
219
|
+
uid: String(raw.uid ?? ''),
|
|
220
|
+
enterpriseId: raw.enterpriseId ? String(raw.enterpriseId) : undefined,
|
|
221
|
+
nickname: raw.nickname ? String(raw.nickname) : undefined,
|
|
222
|
+
uin: raw.uin ? String(raw.uin) : undefined,
|
|
223
|
+
region: regionOf(domain),
|
|
224
|
+
// 记「签发时刻」,而不是只记到期时刻——上游吊销 token 时不会改 expiresAt,
|
|
225
|
+
// 所以 expiresAt 不能用来判断新鲜度(参照项目在真机上踩过这个坑)。
|
|
226
|
+
issuedAtMs,
|
|
227
|
+
expiresAtMs: issuedAtMs + expiresInSec * 1000,
|
|
228
|
+
// refresh token 的到期时间。含义是「最晚什么时候还能换新 token」,
|
|
229
|
+
// 超过它账号就彻底作废、只能重新登录——所以保活必须赶在这之前。
|
|
230
|
+
// 实测上游给的是相对秒数 refreshExpiresIn(约 60 天),
|
|
231
|
+
// 不是绝对时间戳;两种形态都兼容。
|
|
232
|
+
refreshExpiresAtMs: refreshExpiresAtOf(raw, issuedAtMs),
|
|
233
|
+
}
|
|
234
|
+
credential.id = accountIdOf(credential)
|
|
235
|
+
return credential
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// token 刷新
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 用 refresh token 换新的 access token。
|
|
244
|
+
* X-Refresh-Token 只在这里出现,chat 请求绝不带 refresh token(安全红线)。
|
|
245
|
+
* 该端点不吃 JSON body,凭证完全靠请求头传。
|
|
246
|
+
*/
|
|
247
|
+
export async function refreshCredential(credential) {
|
|
248
|
+
const base = chatBaseOf(credential.domain)
|
|
249
|
+
const origin = originOf(credential.domain)
|
|
250
|
+
const headers = {
|
|
251
|
+
...commonHeaders(origin, credential),
|
|
252
|
+
'x-refresh-token': credential.refreshToken,
|
|
253
|
+
// 渠道标识对齐官方客户端 refresh 渠道(网关 headers.go 标注为 "plugin")
|
|
254
|
+
'x-auth-refresh-source': 'plugin',
|
|
255
|
+
}
|
|
256
|
+
if (credential.enterpriseId) headers['x-enterprise-id'] = credential.enterpriseId
|
|
257
|
+
|
|
258
|
+
const { envelope } = await postJson(`${base}/v2/plugin/auth/token/refresh`, { headers, method: 'POST' })
|
|
259
|
+
if (envelope.code !== 0) throw new Error(`刷新失败:code=${envelope.code} ${envelope.msg ?? ''}`)
|
|
260
|
+
|
|
261
|
+
const data = envelope.data ?? {}
|
|
262
|
+
if (!data.accessToken) throw new Error('刷新响应缺少 accessToken')
|
|
263
|
+
|
|
264
|
+
return normalizeCredential({
|
|
265
|
+
accessToken: data.accessToken,
|
|
266
|
+
// 上游可能轮换 refresh token,没给就沿用旧的。
|
|
267
|
+
refreshToken: data.refreshToken || credential.refreshToken,
|
|
268
|
+
// 刷新响应给的是相对秒数 refreshExpiresIn。这里原样透传,
|
|
269
|
+
// 由 refreshExpiresAtOf 统一换算(不写死字段名,避免又对不上)。
|
|
270
|
+
refreshExpiresIn: data.refreshExpiresIn ?? data.refresh_expires_in,
|
|
271
|
+
expiresInSec: data.expiresIn,
|
|
272
|
+
domain: data.domain || credential.domain,
|
|
273
|
+
uid: credential.uid,
|
|
274
|
+
enterpriseId: credential.enterpriseId,
|
|
275
|
+
nickname: credential.nickname,
|
|
276
|
+
uin: credential.uin,
|
|
277
|
+
issuedAtMs: Date.now(),
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// 凭据库
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 一个区域一份的凭据库。
|
|
287
|
+
* 所有方法都是「读文件 → 改 → 原子写回」,进程内并发由调用方串行化。
|
|
288
|
+
*/
|
|
289
|
+
export class CredentialStore {
|
|
290
|
+
/**
|
|
291
|
+
* @param {object} options
|
|
292
|
+
* @param {string} options.dshHome DSH 主目录
|
|
293
|
+
* @param {'cn'|'global'} options.region 本库负责的区域
|
|
294
|
+
*/
|
|
295
|
+
constructor({ dshHome, region }) {
|
|
296
|
+
this.dshHome = dshHome
|
|
297
|
+
this.region = region
|
|
298
|
+
this.file = join(dshHome, `.account-pool.${region}.json`)
|
|
299
|
+
/**
|
|
300
|
+
* 历史位置的只读迁移来源,按优先级排列。
|
|
301
|
+
*
|
|
302
|
+
* 1. 旧位置(DSH 主目录的上一级):更早版本用
|
|
303
|
+
* `process.env.DSH_HOME ?? process.cwd()` 取路径,宿主里 DSH_HOME
|
|
304
|
+
* 常常没设,凭证会落到上一级。
|
|
305
|
+
* 2. 旧文件名:插件早期叫 workbuddy-pool,凭证是
|
|
306
|
+
* `.workbuddy-pool.<region>.json`。改名后必须还能读到,
|
|
307
|
+
* 否则已登录的账号会"消失"。
|
|
308
|
+
*
|
|
309
|
+
* 只读:一旦有写入就落到新路径,旧文件不再更新。
|
|
310
|
+
*/
|
|
311
|
+
this.legacyFiles = [
|
|
312
|
+
join(dirname(dshHome), `.account-pool.${region}.json`),
|
|
313
|
+
join(dshHome, `.workbuddy-pool.${region}.json`),
|
|
314
|
+
join(dirname(dshHome), `.workbuddy-pool.${region}.json`),
|
|
315
|
+
]
|
|
316
|
+
/** 每个账号一把刷新锁,避免并发重复刷新。 */
|
|
317
|
+
this.refreshLocks = new Map()
|
|
318
|
+
/**
|
|
319
|
+
* 写操作串行队列。
|
|
320
|
+
*
|
|
321
|
+
* read→改→write 不是原子操作;并发调用会互相覆盖,而且多个写入
|
|
322
|
+
* 共用同一个 .tmp 文件名会直接踩踏(实测报 rename ENOENT)。
|
|
323
|
+
* 这里把写操作排成一队,一个完成再开始下一个。
|
|
324
|
+
*/
|
|
325
|
+
this.writeQueue = Promise.resolve()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 把一次「读-改-写」排进串行队列。
|
|
330
|
+
* @param {(doc:object) => (void|Promise<void>)} mutate 拿到文档后的修改动作
|
|
331
|
+
*/
|
|
332
|
+
enqueueWrite(mutate) {
|
|
333
|
+
const run = this.writeQueue.then(async () => {
|
|
334
|
+
const doc = await this.read()
|
|
335
|
+
await mutate(doc)
|
|
336
|
+
await this.write(doc)
|
|
337
|
+
})
|
|
338
|
+
// 队列本身不能因为某次失败而断掉,否则后续写永远卡住
|
|
339
|
+
this.writeQueue = run.catch(() => {})
|
|
340
|
+
return run
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 读整份文件;不存在或损坏时返回空结构(不抛错,避免拖垮插件启动)。
|
|
345
|
+
* 新位置读不到时依次回落到历史位置与历史文件名——
|
|
346
|
+
* 见构造函数里 legacyFiles 的说明。
|
|
347
|
+
*/
|
|
348
|
+
async read() {
|
|
349
|
+
for (const path of [this.file, ...this.legacyFiles]) {
|
|
350
|
+
try {
|
|
351
|
+
const raw = await readFile(path, 'utf8')
|
|
352
|
+
const doc = JSON.parse(raw)
|
|
353
|
+
if (doc?.version === FILE_VERSION && typeof doc.accounts === 'object') {
|
|
354
|
+
return doc
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
// 该位置不存在或损坏,试下一个
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { version: FILE_VERSION, accounts: {} }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 原子写回:先写 .tmp 再 rename,权限 0600。 */
|
|
364
|
+
async write(doc) {
|
|
365
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
366
|
+
// 临时文件名加进程内唯一后缀:即使有绕过队列的写入路径,
|
|
367
|
+
// 也不会互相踩踏(这是实测 rename ENOENT 的直接原因)。
|
|
368
|
+
const tmp = `${this.file}.${process.pid}.${++tmpSeq}.tmp`
|
|
369
|
+
await writeFile(tmp, JSON.stringify(doc, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
370
|
+
await rename(tmp, this.file)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 可选账号列表(不含 token,只给界面用)。 */
|
|
374
|
+
async list() {
|
|
375
|
+
const doc = await this.read()
|
|
376
|
+
return Object.values(doc.accounts).map(credential => ({
|
|
377
|
+
id: credential.id,
|
|
378
|
+
nickname: credential.nickname ?? credential.uid ?? credential.id.slice(0, 8),
|
|
379
|
+
uid: credential.uid,
|
|
380
|
+
domain: credential.domain,
|
|
381
|
+
region: credential.region,
|
|
382
|
+
expiresAtMs: credential.expiresAtMs,
|
|
383
|
+
issuedAtMs: credential.issuedAtMs,
|
|
384
|
+
}))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 保存(新增或覆盖)一个账号。经串行队列,避免并发写互相覆盖。 */
|
|
388
|
+
async save(credential) {
|
|
389
|
+
await this.enqueueWrite(doc => {
|
|
390
|
+
doc.accounts[credential.id] = credential
|
|
391
|
+
})
|
|
392
|
+
return credential
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 删除一个账号。经串行队列。 */
|
|
396
|
+
async remove(accountId) {
|
|
397
|
+
let removed = false
|
|
398
|
+
await this.enqueueWrite(doc => {
|
|
399
|
+
if (doc.accounts[accountId] !== undefined) {
|
|
400
|
+
delete doc.accounts[accountId]
|
|
401
|
+
removed = true
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
return removed
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 读单个账号原始凭证(不刷新)。 */
|
|
408
|
+
async get(accountId) {
|
|
409
|
+
const doc = await this.read()
|
|
410
|
+
return doc.accounts[accountId]
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 取一个账号「现在能用的」凭证:快到期就刷新,刷新失败但还没过期就沿用旧的。
|
|
415
|
+
* 刷新按账号去重——同一账号并发调用只发一次刷新请求。
|
|
416
|
+
*
|
|
417
|
+
* @throws 当账号不存在、或已过期且刷新失败
|
|
418
|
+
*/
|
|
419
|
+
async usable(accountId) {
|
|
420
|
+
const credential = await this.get(accountId)
|
|
421
|
+
if (credential === undefined) throw new Error(`账号不存在:${accountId}`)
|
|
422
|
+
|
|
423
|
+
const now = Date.now()
|
|
424
|
+
const needsRefresh = credential.expiresAtMs - now < REFRESH_MARGIN_MS
|
|
425
|
+
if (!needsRefresh) return credential
|
|
426
|
+
|
|
427
|
+
// single-flight:同一账号的并发刷新合并成一次。
|
|
428
|
+
const existing = this.refreshLocks.get(accountId)
|
|
429
|
+
if (existing !== undefined) return existing
|
|
430
|
+
|
|
431
|
+
const task = (async () => {
|
|
432
|
+
try {
|
|
433
|
+
const fresh = await refreshCredential(credential)
|
|
434
|
+
await this.save(fresh)
|
|
435
|
+
return fresh
|
|
436
|
+
} catch (error) {
|
|
437
|
+
// 刷新失败但 token 还没真过期 → 继续用旧的,别因为一次抖动把账号判死。
|
|
438
|
+
if (credential.expiresAtMs > Date.now()) return credential
|
|
439
|
+
throw new Error(`账号 ${credential.nickname ?? accountId} 已过期且刷新失败:${error.message}`)
|
|
440
|
+
} finally {
|
|
441
|
+
this.refreshLocks.delete(accountId)
|
|
442
|
+
}
|
|
443
|
+
})()
|
|
444
|
+
|
|
445
|
+
this.refreshLocks.set(accountId, task)
|
|
446
|
+
return task
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 保活:把「快要失去刷新能力」的账号提前刷一遍。
|
|
451
|
+
*
|
|
452
|
+
* 为什么需要它:插件原先只在**有请求**时才刷新。若长时间没人用,
|
|
453
|
+
* access token 会过期——这还能靠 refresh token 救回;但如果闲置到
|
|
454
|
+
* refresh token 也过期,账号就彻底作废,只能重新登录。
|
|
455
|
+
* 后台定期调用本方法,把两个到期时间都往后推,避免这种情况。
|
|
456
|
+
*
|
|
457
|
+
* @param {number} [maxIdleDays] access token 剩余天数低于它才刷(默认 7)
|
|
458
|
+
* @returns {Promise<{refreshed:number, failed:number, skipped:number}>}
|
|
459
|
+
*/
|
|
460
|
+
async keepAlive(maxIdleDays = 7) {
|
|
461
|
+
const thresholdMs = maxIdleDays * 24 * 3600 * 1000
|
|
462
|
+
const result = { refreshed: 0, failed: 0, skipped: 0 }
|
|
463
|
+
for (const account of await this.list()) {
|
|
464
|
+
const record = await this.get(account.id)
|
|
465
|
+
if (record === undefined) continue
|
|
466
|
+
// 剩余充足就跳过,避免无谓打上游。
|
|
467
|
+
if (record.expiresAtMs - Date.now() > thresholdMs) {
|
|
468
|
+
result.skipped += 1
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const fresh = await refreshCredential(record)
|
|
473
|
+
await this.save(fresh)
|
|
474
|
+
result.refreshed += 1
|
|
475
|
+
} catch {
|
|
476
|
+
result.failed += 1
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return result
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 打开一个区域的凭据库。 */
|
|
484
|
+
export function openStore(dshHome, region) {
|
|
485
|
+
return new CredentialStore({ dshHome, region })
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** 清空一个区域的凭据(登出全部账号)。 */
|
|
489
|
+
export async function clearStore(dshHome, region) {
|
|
490
|
+
await rm(join(dshHome, `.account-pool.${region}.json`), { force: true })
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// auth 文件导入
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 解析一份 auth 文件(网关 auths/*.json 或手写文件),支持两种形态:
|
|
499
|
+
*
|
|
500
|
+
* 嵌套形(网关与官方客户端落盘的格式):
|
|
501
|
+
* { "auth": { "accessToken", "refreshToken", "expiresAt", "domain", "realm" },
|
|
502
|
+
* "account": { "uid", "enterpriseId", "nickname" },
|
|
503
|
+
* "device_token": "..." }
|
|
504
|
+
*
|
|
505
|
+
* 扁平形(手写方便):
|
|
506
|
+
* { "accessToken", "refreshToken", "expiresAt", "domain", "realm",
|
|
507
|
+
* "uid", "enterpriseId", "nickname", "device_token" }
|
|
508
|
+
*
|
|
509
|
+
* 时间单位要注意:auth 文件的 expiresAt 是 **Unix 秒**,而插件内部统一用毫秒。
|
|
510
|
+
* 这里做兼容——数值小于 1e12 视为秒(1e12 秒是公元 33658 年,不可能出现)。
|
|
511
|
+
*
|
|
512
|
+
* @param {string|object} input JSON 文本或已解析对象
|
|
513
|
+
* @param {string} [source] 来源说明(文件名),仅用于报错信息
|
|
514
|
+
* @returns {object} 归一化后的凭证
|
|
515
|
+
* @throws 缺少 accessToken 时抛错
|
|
516
|
+
*/
|
|
517
|
+
export function parseAuthFile(input, source = '') {
|
|
518
|
+
let doc
|
|
519
|
+
if (typeof input === 'string') {
|
|
520
|
+
try {
|
|
521
|
+
doc = JSON.parse(input)
|
|
522
|
+
} catch (error) {
|
|
523
|
+
throw new Error(`${source ? source + ':' : ''}不是合法 JSON(${error.message})`)
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
doc = input
|
|
527
|
+
}
|
|
528
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
529
|
+
throw new Error(`${source ? source + ':' : ''}顶层必须是 JSON 对象`)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 嵌套形与扁平形取同一组字段,只是来源层级不同。
|
|
533
|
+
const nested = doc.auth !== null && typeof doc.auth === 'object' && !Array.isArray(doc.auth)
|
|
534
|
+
const auth = nested ? doc.auth : doc
|
|
535
|
+
const account = nested && doc.account !== null && typeof doc.account === 'object' ? doc.account : doc
|
|
536
|
+
|
|
537
|
+
const accessToken = String(auth.accessToken ?? '').trim()
|
|
538
|
+
if (accessToken === '') {
|
|
539
|
+
throw new Error(`${source ? source + ':' : ''}缺少 accessToken`)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const domain = String(auth.domain ?? '').trim()
|
|
543
|
+
|
|
544
|
+
// expiresAt:兼容秒与毫秒;都没有就按「从现在起 1 小时」保守估计,
|
|
545
|
+
// 反正下次使用时会走刷新。
|
|
546
|
+
const rawExpires = Number(auth.expiresAt ?? auth.expires_at ?? 0)
|
|
547
|
+
let expiresAtMs
|
|
548
|
+
if (Number.isFinite(rawExpires) && rawExpires > 0) {
|
|
549
|
+
expiresAtMs = rawExpires < 1e12 ? rawExpires * 1000 : rawExpires
|
|
550
|
+
} else {
|
|
551
|
+
expiresAtMs = Date.now() + 3600_000
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// realm 显式给了就用;否则按 domain 推断。
|
|
555
|
+
const explicitRealm = String(auth.realm ?? doc.realm ?? '').trim().toLowerCase()
|
|
556
|
+
const region = explicitRealm === 'global' || explicitRealm === 'cn'
|
|
557
|
+
? explicitRealm
|
|
558
|
+
: regionOf(domain)
|
|
559
|
+
|
|
560
|
+
const credential = {
|
|
561
|
+
accessToken,
|
|
562
|
+
refreshToken: String(auth.refreshToken ?? '').trim(),
|
|
563
|
+
domain,
|
|
564
|
+
uid: String(account.uid ?? '').trim(),
|
|
565
|
+
enterpriseId: account.enterpriseId ? String(account.enterpriseId) : undefined,
|
|
566
|
+
nickname: account.nickname ? String(account.nickname) : undefined,
|
|
567
|
+
uin: account.uin ? String(account.uin) : undefined,
|
|
568
|
+
region,
|
|
569
|
+
// 签发时刻未知:用「到期时刻减去一小时」的保守估计,
|
|
570
|
+
// 这样紧接着就会被判为需要刷新,拿到真实签发时间。
|
|
571
|
+
issuedAtMs: Math.min(Date.now(), expiresAtMs - 3600_000),
|
|
572
|
+
expiresAtMs,
|
|
573
|
+
}
|
|
574
|
+
credential.id = accountIdOf(credential)
|
|
575
|
+
return credential
|
|
576
|
+
}
|