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
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trae(TRAE SOLO)账号与凭证。
|
|
3
|
+
*
|
|
4
|
+
* ## 登录方式:导入桌面端凭证,不做网页登录
|
|
5
|
+
*
|
|
6
|
+
* Trae 授权页把 token **fetch 投递到 `127.0.0.1:18080`**(不是导航跳转)。
|
|
7
|
+
* DSH 跑在 NAS、浏览器在另一台电脑时,那个 127.0.0.1 指用户自己的电脑,
|
|
8
|
+
* 请求被拒 → 页面报「网络错误」;又因为是 fetch 而非导航,地址栏不会出现
|
|
9
|
+
* 带 token 的链接,**连手动复制都拿不到**。那条路在当前部署下走不通。
|
|
10
|
+
*
|
|
11
|
+
* 所以改为「导入桌面端已登录的凭证」:用户在 Windows/macOS 上登录 Trae,
|
|
12
|
+
* 把 `storage.json` 拷过来即可(解析见 trae-storage.js)。解密不依赖本机
|
|
13
|
+
* 硬件,任何机器上都能解。
|
|
14
|
+
*
|
|
15
|
+
* 本文件负责:请求头、token 刷新、凭证库。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises'
|
|
19
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
import { cpus, hostname, release as osRelease } from 'node:os'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// 上游常量(来自 traework2api 的实测结论,不要随意改动)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** SOLO 对话与模型端点。 */
|
|
29
|
+
const AGENT_HOST = 'https://trae-api-cn.mchost.guru'
|
|
30
|
+
/** 签到与积分端点。 */
|
|
31
|
+
const UG_HOST = 'https://api.trae.cn'
|
|
32
|
+
/** OAuth(换 token / 取用户信息)。 */
|
|
33
|
+
const OAUTH_HOST = 'https://api.trae.com.cn'
|
|
34
|
+
/** 登录页。 */
|
|
35
|
+
|
|
36
|
+
// 刷新(ExchangeToken)的 ClientID 按「 edition」分两套——
|
|
37
|
+
// 实测证据来自 dsh-connect-trae 的 REFRESH_CONTRACT:
|
|
38
|
+
// Trae CN 桌面 / Trae 国际桌面 / TRAE SOLO CN → ono9krqynydwx5(无 DeviceInfo)
|
|
39
|
+
// TRAE SOLO 国际 → en1oxy7wnw8j9n(带 DeviceInfo)
|
|
40
|
+
// 用错会报 10101「refresh token is not matched to the client」。
|
|
41
|
+
const CLIENT_ID_DESKTOP = 'ono9krqynydwx5'
|
|
42
|
+
const CLIENT_ID_SOLO_SG = 'en1oxy7wnw8j9n'
|
|
43
|
+
/** 对话请求头 x-app-id 用的客户端标识。 */
|
|
44
|
+
const APP_ID = '6eefa01c-1036-4c7e-9ca5-d891f63bfcd8'
|
|
45
|
+
const IDE_VERSION = '0.1.43'
|
|
46
|
+
/** 客户端构建号,请求头 x-ide-version-code 用。 */
|
|
47
|
+
const IDE_VERSION_CODE = '20260716'
|
|
48
|
+
/**
|
|
49
|
+
* 设备类型与系统版本按**真实运行平台**填,不硬编码 Windows。
|
|
50
|
+
*
|
|
51
|
+
* 参照 dsh-connect-trae 的做法(它用它总结的失败根因之一就是
|
|
52
|
+
* 「每请求随机 machine/device ID 并硬编码 Windows」):
|
|
53
|
+
* 声称 Windows 却从 Linux 发请求是自相矛盾的,容易被风控标记。
|
|
54
|
+
*
|
|
55
|
+
* 注意 machine/device ID 只在**登录时生成一次**并落盘,之后每个请求复用——
|
|
56
|
+
* 那才是「每请求随机」这个坑的反面;这里补的是平台标识。
|
|
57
|
+
*/
|
|
58
|
+
const DEVICE_TYPE = process.platform === 'darwin' ? 'mac'
|
|
59
|
+
: process.platform === 'win32' ? 'windows' : process.platform
|
|
60
|
+
const OS_VERSION = `${process.platform === 'darwin' ? 'macOS'
|
|
61
|
+
: process.platform === 'win32' ? 'Windows' : process.platform} ${osRelease()}`
|
|
62
|
+
/** SOLO 免费通道标识。 */
|
|
63
|
+
const SOLO_FUNCTION = 'solo_work_lite'
|
|
64
|
+
|
|
65
|
+
/** 默认模型(实测可用)。 */
|
|
66
|
+
const DEFAULT_MODEL = 'glm-5.2'
|
|
67
|
+
|
|
68
|
+
/** 请求超时。 */
|
|
69
|
+
const TIMEOUT_MS = 60_000
|
|
70
|
+
|
|
71
|
+
/** 落盘格式版本。 */
|
|
72
|
+
const FILE_VERSION = 1
|
|
73
|
+
|
|
74
|
+
/** 提前多久刷新 token(上游给 14 天,提前 24 小时足够)。 */
|
|
75
|
+
const REFRESH_MARGIN_MS = 24 * 3600 * 1000
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// 工具
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const secondsToMs = (seconds) => (Number(seconds) || 0) * 1000
|
|
82
|
+
const msToSeconds = (ms) => Math.floor((Number(ms) || 0) / 1000)
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 设备 CPU 标识:取第一个 CPU 型号的首个单词(与参照实现一致)。
|
|
86
|
+
* 拿不到就不发这个头——编造一个没有意义。
|
|
87
|
+
*/
|
|
88
|
+
function deviceCpu() {
|
|
89
|
+
try {
|
|
90
|
+
const first = cpus()[0]?.model
|
|
91
|
+
return typeof first === 'string' && first !== '' ? first.split(' ')[0] : undefined
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 随机 hex 串,用于生成 machine_id / device_id。 */
|
|
98
|
+
function randomHex(bytes = 16) {
|
|
99
|
+
return randomBytes(bytes).toString('hex')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 账号稳定 id:按 uid 派生,同一账号重新导入也认得它。
|
|
104
|
+
* @export 供导入接口复用,避免两处各写一份派生规则。
|
|
105
|
+
*/
|
|
106
|
+
export function accountIdOf(uid) {
|
|
107
|
+
return createHash('sha256').update(`trae\0${uid}`).digest('hex').slice(0, 24)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Trae 的通用请求头(带设备标识)。 */
|
|
111
|
+
function baseHeaders(extra = {}) {
|
|
112
|
+
return {
|
|
113
|
+
'content-type': 'application/json',
|
|
114
|
+
'user-agent': `Trae/${IDE_VERSION}`,
|
|
115
|
+
...extra,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* SOLO 域请求头(对话、模型列表)。
|
|
121
|
+
* 三个 token 头都要带:Authorization 是标准形式,另两个是上游特有的。
|
|
122
|
+
*/
|
|
123
|
+
function soloHeaders(credential, stream = false) {
|
|
124
|
+
const token = credential.accessToken
|
|
125
|
+
const headers = baseHeaders({
|
|
126
|
+
accept: stream ? 'text/event-stream' : 'application/json',
|
|
127
|
+
authorization: `Cloud-IDE-JWT ${token}`,
|
|
128
|
+
'x-cloudide-token': token,
|
|
129
|
+
'x-ide-token': token,
|
|
130
|
+
'x-app-id': APP_ID,
|
|
131
|
+
'x-app-version': 'default',
|
|
132
|
+
'x-ide-version': IDE_VERSION,
|
|
133
|
+
'x-ide-version-code': IDE_VERSION_CODE,
|
|
134
|
+
'x-app-version-code': IDE_VERSION_CODE,
|
|
135
|
+
'x-ide-version-type': 'stable',
|
|
136
|
+
'x-device-type': DEVICE_TYPE,
|
|
137
|
+
'x-os-version': OS_VERSION,
|
|
138
|
+
'x-device-cpu': deviceCpu(),
|
|
139
|
+
'request-traffic-type': 'prod',
|
|
140
|
+
})
|
|
141
|
+
if (credential.uid) headers['x-uid'] = credential.uid
|
|
142
|
+
if (credential.machineId) headers['x-machine-id'] = credential.machineId
|
|
143
|
+
if (credential.deviceId) headers['x-device-id'] = credential.deviceId
|
|
144
|
+
return headers
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** ug 域请求头(签到、积分)。 */
|
|
148
|
+
function ugHeaders(credential) {
|
|
149
|
+
// pay/ug 域(积分、签到)用**网页端**形态:实测带 IDE 的 UA 会被 401,
|
|
150
|
+
// 需要 Origin/Referer + 浏览器 UA。
|
|
151
|
+
//
|
|
152
|
+
// 但 **x-device-id 必须保留**:签到领取接口少了它会返回业务码 9004
|
|
153
|
+
// ("order parameters are incorrect")——HTTP 仍是 200,很容易被当成成功。
|
|
154
|
+
// 实测对照(2026-09-21):
|
|
155
|
+
// 带 X-Device-Id → {"code":0,"message":"success"},状态变 checked_in=true
|
|
156
|
+
// 不带 → {"code":9004,...},状态仍是未签到
|
|
157
|
+
const headers = {
|
|
158
|
+
'content-type': 'application/json',
|
|
159
|
+
accept: 'application/json',
|
|
160
|
+
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
|
161
|
+
origin: 'https://www.trae.cn',
|
|
162
|
+
referer: 'https://www.trae.cn/',
|
|
163
|
+
authorization: `Cloud-IDE-JWT ${credential.accessToken}`,
|
|
164
|
+
'x-user-region': 'CN',
|
|
165
|
+
}
|
|
166
|
+
if (credential.deviceId) headers['x-device-id'] = credential.deviceId
|
|
167
|
+
return headers
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 发请求并解信封。上游用 {Result: ...} 包装业务数据。 */
|
|
171
|
+
async function postJson(url, { headers, body = {}, method = 'POST' } = {}) {
|
|
172
|
+
const response = await fetch(url, {
|
|
173
|
+
method,
|
|
174
|
+
headers,
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
177
|
+
})
|
|
178
|
+
const text = await response.text()
|
|
179
|
+
let envelope
|
|
180
|
+
try {
|
|
181
|
+
envelope = text === '' ? {} : JSON.parse(text)
|
|
182
|
+
} catch {
|
|
183
|
+
throw new Error(`上游返回非 JSON(HTTP ${response.status}):${text.slice(0, 160)}`)
|
|
184
|
+
}
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
// 402/401 等由调用方按状态码分类处置
|
|
187
|
+
const message = envelope.msg ?? envelope.Message ?? envelope.error ?? text.slice(0, 160)
|
|
188
|
+
const error = new Error(`HTTP ${response.status}:${message}`)
|
|
189
|
+
error.status = response.status
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 业务码检查:上游会用 HTTP 200 + code!=0 表达失败。
|
|
194
|
+
//
|
|
195
|
+
// 实测踩过:签到领取在缺少 x-device-id 时返回
|
|
196
|
+
// `{"code":9004,"message":"The submitted order parameters are incorrect"}`,
|
|
197
|
+
// 只看 HTTP 状态会把它当成功——于是界面显示"签到成功"但实际没签。
|
|
198
|
+
// 这类"假成功"比报错更危险,必须在这里拦住。
|
|
199
|
+
const code = envelope.code
|
|
200
|
+
if (typeof code === 'number' && code !== 0) {
|
|
201
|
+
const message = envelope.message ?? envelope.msg ?? text.slice(0, 160)
|
|
202
|
+
const error = new Error(`上游业务错误 code=${code}:${message}`)
|
|
203
|
+
error.status = response.status
|
|
204
|
+
error.code = code
|
|
205
|
+
throw error
|
|
206
|
+
}
|
|
207
|
+
return envelope
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// token 刷新
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 刷新 access token(ExchangeToken 同样用于刷新,refreshToken 会轮换)。
|
|
215
|
+
* 返回新凭证;调用方负责落盘。
|
|
216
|
+
*/
|
|
217
|
+
export async function refreshCredential(credential) {
|
|
218
|
+
if (credential.refreshToken === '') throw new Error('缺少 refreshToken,需要重新导入凭证')
|
|
219
|
+
|
|
220
|
+
// 国际 SOLO 的刷新走不同 host + 路径 + ClientID,还要带 DeviceInfo。
|
|
221
|
+
// 其余(CN 桌面 / 国际桌面 / SOLO CN)共用桌面契约。
|
|
222
|
+
const isSoloSg = credential.region === 'sg'
|
|
223
|
+
const host = isSoloSg ? 'https://growsg-normal.trae.ai' : OAUTH_HOST
|
|
224
|
+
const path = isSoloSg
|
|
225
|
+
? '/trae/api/v3/oauth/ExchangeToken'
|
|
226
|
+
: '/cloudide/api/v3/trae/oauth/ExchangeToken'
|
|
227
|
+
const body = {
|
|
228
|
+
ClientID: isSoloSg ? CLIENT_ID_SOLO_SG : CLIENT_ID_DESKTOP,
|
|
229
|
+
RefreshToken: credential.refreshToken,
|
|
230
|
+
ClientSecret: isSoloSg ? '' : '-',
|
|
231
|
+
UserID: credential.uid ?? '',
|
|
232
|
+
}
|
|
233
|
+
if (isSoloSg) {
|
|
234
|
+
// DeviceInfo 的字段名与取值照抄官方客户端(dsh-connect-trae 的实测记录)
|
|
235
|
+
body.DeviceInfo = {
|
|
236
|
+
DeviceID: credential.deviceId ?? '',
|
|
237
|
+
MachineID: credential.machineId ?? '',
|
|
238
|
+
PlatformCode: 'SOLO_PC',
|
|
239
|
+
DeviceType: 'PC',
|
|
240
|
+
DeviceName: hostname(),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const envelope = await postJson(`${host}${path}`, {
|
|
245
|
+
headers: baseHeaders(),
|
|
246
|
+
body,
|
|
247
|
+
})
|
|
248
|
+
const result = envelope.Result ?? {}
|
|
249
|
+
const accessToken = String(result.Token ?? '')
|
|
250
|
+
if (accessToken === '') throw new Error('刷新返回缺少 Token')
|
|
251
|
+
|
|
252
|
+
let expiresAt = Number(result.TokenExpireAt ?? 0)
|
|
253
|
+
if (expiresAt > 1e12) expiresAt = Math.floor(expiresAt / 1000)
|
|
254
|
+
if (expiresAt <= Math.floor(Date.now() / 1000)) {
|
|
255
|
+
const duration = Number(result.TokenExpireDuration ?? 0) || 14 * 24 * 3600
|
|
256
|
+
expiresAt = Math.floor(Date.now() / 1000) + duration
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
...credential,
|
|
261
|
+
accessToken,
|
|
262
|
+
refreshToken: String(result.RefreshToken ?? credential.refreshToken),
|
|
263
|
+
expiresAtMs: secondsToMs(expiresAt),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 拉取账号的最新资料(昵称)。
|
|
269
|
+
*
|
|
270
|
+
* 导入时固化的昵称只是当时的快照——用户后来在 Trae 里改了名字,
|
|
271
|
+
* 界面却永远显示旧名,看起来像被硬编码了。所以这里提供能力,
|
|
272
|
+
* 由凭据库在合适的时机调用并写回。
|
|
273
|
+
*
|
|
274
|
+
* 失败不抛错:昵称是展示信息,拿不到就让旧的继续用。
|
|
275
|
+
*
|
|
276
|
+
* @returns {Promise<{nickname:string}|null>} 拿不到返回 null
|
|
277
|
+
*/
|
|
278
|
+
export async function fetchUserInfo(credential) {
|
|
279
|
+
try {
|
|
280
|
+
const envelope = await postJson(`${OAUTH_HOST}/cloudide/api/v3/trae/GetUserInfo`, {
|
|
281
|
+
headers: baseHeaders({ 'x-cloudide-token': credential.accessToken }),
|
|
282
|
+
body: { ReqSource: 'IDE', IDEVersion: IDE_VERSION },
|
|
283
|
+
})
|
|
284
|
+
const result = envelope.Result ?? envelope
|
|
285
|
+
if (!result?.UserID) return null
|
|
286
|
+
return { nickname: String(result.ScreenName ?? '') }
|
|
287
|
+
} catch {
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// 凭据库
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Trae 凭据库。
|
|
298
|
+
*
|
|
299
|
+
* 与 WorkBuddy 的 CredentialStore 结构一致(串行写队列、原子替换),
|
|
300
|
+
* 但字段与刷新端点不同,所以单独一份——强行复用一个类会让两边都难改。
|
|
301
|
+
*/
|
|
302
|
+
export class TraeCredentialStore {
|
|
303
|
+
constructor({ dshHome }) {
|
|
304
|
+
this.file = join(dshHome, '.trae-pool.json')
|
|
305
|
+
this.writeQueue = Promise.resolve()
|
|
306
|
+
/** 每账号上次刷新昵称的时刻:昵称变化很慢,不值得频繁打接口。 */
|
|
307
|
+
this.nicknameRefreshAt = new Map()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async read() {
|
|
311
|
+
try {
|
|
312
|
+
const doc = JSON.parse(await readFile(this.file, 'utf8'))
|
|
313
|
+
if (doc?.version !== FILE_VERSION || typeof doc.accounts !== 'object') {
|
|
314
|
+
return { version: FILE_VERSION, accounts: {} }
|
|
315
|
+
}
|
|
316
|
+
return doc
|
|
317
|
+
} catch {
|
|
318
|
+
return { version: FILE_VERSION, accounts: {} }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async write(doc) {
|
|
323
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
324
|
+
const tmp = `${this.file}.${process.pid}.${++tmpSeq}.tmp`
|
|
325
|
+
await writeFile(tmp, JSON.stringify(doc, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
326
|
+
await rename(tmp, this.file)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 串行写:避免并发 read-modify-write 互相覆盖。 */
|
|
330
|
+
enqueueWrite(mutate) {
|
|
331
|
+
const run = this.writeQueue.then(async () => {
|
|
332
|
+
const doc = await this.read()
|
|
333
|
+
await mutate(doc)
|
|
334
|
+
await this.write(doc)
|
|
335
|
+
})
|
|
336
|
+
this.writeQueue = run.catch(() => {})
|
|
337
|
+
return run
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async list() {
|
|
341
|
+
const doc = await this.read()
|
|
342
|
+
return Object.values(doc.accounts).map(c => ({
|
|
343
|
+
id: c.id,
|
|
344
|
+
nickname: c.nickname ?? c.uid ?? c.id.slice(0, 8),
|
|
345
|
+
uid: c.uid,
|
|
346
|
+
expiresAtMs: c.expiresAtMs,
|
|
347
|
+
}))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async get(accountId) {
|
|
351
|
+
const doc = await this.read()
|
|
352
|
+
return doc.accounts[accountId]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async save(credential) {
|
|
356
|
+
await this.enqueueWrite(doc => { doc.accounts[credential.id] = credential })
|
|
357
|
+
return credential
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async remove(accountId) {
|
|
361
|
+
let removed = false
|
|
362
|
+
await this.enqueueWrite(doc => {
|
|
363
|
+
if (doc.accounts[accountId] !== undefined) {
|
|
364
|
+
delete doc.accounts[accountId]
|
|
365
|
+
removed = true
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
return removed
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 取可用凭证:快到期就刷新,刷新失败但未过期则沿用旧的。
|
|
373
|
+
* 与 WorkBuddy 侧同样的兜底策略——一次网络抖动不该让账号失效。
|
|
374
|
+
*/
|
|
375
|
+
async usable(accountId) {
|
|
376
|
+
const credential = await this.get(accountId)
|
|
377
|
+
if (credential === undefined) throw new Error(`账号不存在:${accountId}`)
|
|
378
|
+
if (credential.expiresAtMs - Date.now() > REFRESH_MARGIN_MS) {
|
|
379
|
+
// 命中现有凭证:顺带看看昵称要不要更新(带冷却,失败静默)。
|
|
380
|
+
// 不 await——昵称晚一点到没关系,别拖慢请求主链路。
|
|
381
|
+
this.refreshNicknameLater(accountId)
|
|
382
|
+
return credential
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const fresh = await refreshCredential(credential)
|
|
387
|
+
await this.save(fresh)
|
|
388
|
+
return fresh
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (credential.expiresAtMs > Date.now()) return credential
|
|
391
|
+
throw new Error(`账号 ${credential.nickname ?? accountId} 已过期且刷新失败:${error.message}`)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 后台刷新昵称(冷却 6 小时)。
|
|
397
|
+
*
|
|
398
|
+
* 改了名就要能跟上:拿最新 ScreenName 写回磁盘。异步执行、失败静默——
|
|
399
|
+
* 它只是展示信息,任何问题都不该影响请求本身。
|
|
400
|
+
*/
|
|
401
|
+
refreshNicknameLater(accountId) {
|
|
402
|
+
const last = this.nicknameRefreshAt.get(accountId) ?? 0
|
|
403
|
+
const NICKNAME_REFRESH_INTERVAL = 6 * 3600 * 1000
|
|
404
|
+
if (Date.now() - last < NICKNAME_REFRESH_INTERVAL) return
|
|
405
|
+
this.nicknameRefreshAt.set(accountId, Date.now())
|
|
406
|
+
void (async () => {
|
|
407
|
+
const credential = await this.get(accountId)
|
|
408
|
+
if (credential === undefined) return
|
|
409
|
+
const info = await fetchUserInfo(credential)
|
|
410
|
+
if (info === null || info.nickname === '' || info.nickname === credential.nickname) return
|
|
411
|
+
await this.save({ ...credential, nickname: info.nickname })
|
|
412
|
+
})().catch(() => {})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 保活:把快过期的账号提前刷新,避免闲置到失去刷新能力。 */
|
|
416
|
+
async keepAlive(maxIdleDays = 3) {
|
|
417
|
+
const threshold = maxIdleDays * 24 * 3600 * 1000
|
|
418
|
+
const result = { refreshed: 0, failed: 0, skipped: 0 }
|
|
419
|
+
for (const account of await this.list()) {
|
|
420
|
+
const record = await this.get(account.id)
|
|
421
|
+
if (record === undefined) continue
|
|
422
|
+
if (record.expiresAtMs - Date.now() > threshold) { result.skipped += 1; continue }
|
|
423
|
+
try {
|
|
424
|
+
await this.save(await refreshCredential(record))
|
|
425
|
+
result.refreshed += 1
|
|
426
|
+
} catch {
|
|
427
|
+
result.failed += 1
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return result
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 临时文件名序号:并发写入时不冲突。 */
|
|
435
|
+
let tmpSeq = 0
|
|
436
|
+
|
|
437
|
+
/** 打开 Trae 凭据库。 */
|
|
438
|
+
export function openTraeStore(dshHome) {
|
|
439
|
+
return new TraeCredentialStore({ dshHome })
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 导出常量供其他模块使用
|
|
443
|
+
export const TRAE = {
|
|
444
|
+
AGENT_HOST,
|
|
445
|
+
UG_HOST,
|
|
446
|
+
OAUTH_HOST,
|
|
447
|
+
SOLO_FUNCTION,
|
|
448
|
+
DEFAULT_MODEL,
|
|
449
|
+
IDE_VERSION,
|
|
450
|
+
soloHeaders,
|
|
451
|
+
ugHeaders,
|
|
452
|
+
postJson,
|
|
453
|
+
}
|