creditdaddy 0.9.5 → 1.2.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/LICENSE +21 -21
- package/README.md +56 -8
- package/bin/creditdaddy.js +209 -191
- package/package.json +4 -3
- package/src/accounts.js +161 -143
- package/src/authDevice.js +205 -205
- package/src/catpawClient.js +103 -0
- package/src/catpawLocal.js +229 -0
- package/src/checkin.js +357 -230
- package/src/constants.js +97 -81
- package/src/daemon.js +935 -658
- package/src/localDetect.js +147 -147
- package/src/logger.js +39 -1
- package/src/mirasimClient.js +189 -189
- package/src/mirasimLocal.js +386 -307
- package/src/panel.html +765 -214
- package/src/providers.js +190 -100
- package/src/qoderApp.js +524 -332
- package/src/qoderClient.js +237 -199
- package/src/qoderUmid.js +198 -198
- package/src/store.js +238 -222
- package/src/tenrouter.js +263 -221
- package/src/tenrouterAccounts.js +163 -0
- package/src/traeClient.js +258 -0
- package/src/traeLocal.js +337 -0
- package/src/transfer.js +160 -141
- package/src/usageSync.js +505 -319
- package/src/workbuddyAuth.js +127 -126
- package/src/workbuddyClient.js +302 -302
- package/src/workbuddyLocal.js +133 -133
- package/src/zcodeAuth.js +131 -131
- package/src/zcodeAutoClaim.js +97 -96
- package/src/zcodeClient.js +580 -529
- package/src/zcodeGateway.js +570 -0
- package/src/zcodeLocal.js +334 -319
- package/src/zcodePlanShape.json +29 -0
- package/src/zcrypto.js +160 -160
package/src/workbuddyClient.js
CHANGED
|
@@ -1,302 +1,302 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WorkBuddy(腾讯 CodeBuddy 系)API 客户端 — 签到、积分、token 刷新。
|
|
3
|
-
*
|
|
4
|
-
* 协议对齐 WorkBuddy 桌面端与 10router 的 codebuddyCheckin / usage / tokenRefresh:
|
|
5
|
-
* POST {host}/v2/billing/meter/checkin-activity-status 今日是否已签、连签天数、每日额度
|
|
6
|
-
* POST {host}/v2/billing/meter/daily-checkin 签到(400 + 10001/“已签到” = 今日已签)
|
|
7
|
-
* POST {host}/v2/billing/meter/get-user-resource 积分包(data.Response.Data.Accounts[])
|
|
8
|
-
* POST {host}/v2/plugin/auth/token/refresh 刷新(X-Refresh-Token 头)
|
|
9
|
-
* 请求头:Authorization Bearer + X-User-Id(JWT sub)+ X-Domain(token 所属域名)。
|
|
10
|
-
*
|
|
11
|
-
* WorkBuddy 登录 token 是 Keycloak JWT,签发方决定归属与 API 域名:
|
|
12
|
-
* www.codebuddy.cn / www.workbuddy.cn / copilot.tencent.com → 国内版
|
|
13
|
-
* www.codebuddy.ai / www.workbuddy.ai
|
|
14
|
-
*
|
|
15
|
-
* token 只在过期或 401 时刷新:刷新会轮换 refreshToken,若该账号正登录在 WorkBuddy
|
|
16
|
-
* 客户端里,主动刷新可能让客户端手里的旧 refreshToken 失效。
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
20
|
-
|
|
21
|
-
const USER_AGENT = 'CLI/2.108.1 CodeBuddy/2.108.1';
|
|
22
|
-
const CN_DEFAULT_HOST = 'www.codebuddy.cn';
|
|
23
|
-
const INTL_DEFAULT_HOST = 'www.codebuddy.ai';
|
|
24
|
-
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
25
|
-
|
|
26
|
-
export function decodeJwt(jwt) {
|
|
27
|
-
try {
|
|
28
|
-
const seg = String(jwt).split('.')[1];
|
|
29
|
-
const b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
|
|
30
|
-
return JSON.parse(Buffer.from(b64 + '='.repeat((4 - (b64.length % 4)) % 4), 'base64').toString('utf8'));
|
|
31
|
-
} catch {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* 从 token 推断归属:{ provider, host, uid, expiresAt };不是 CodeBuddy 系 token 时 provider 为 null。
|
|
38
|
-
*/
|
|
39
|
-
export function inspectToken(token) {
|
|
40
|
-
const c = decodeJwt(token) || {};
|
|
41
|
-
let host = null;
|
|
42
|
-
try { host = new URL(c.iss).host; } catch {}
|
|
43
|
-
let provider = null;
|
|
44
|
-
if (host && /(codebuddy|workbuddy)\.ai$/.test(host)) provider = 'workbuddy-intl';
|
|
45
|
-
else if (host && /(codebuddy|workbuddy)\.cn$|copilot\.tencent\.com$/.test(host)) provider = 'workbuddy';
|
|
46
|
-
return {
|
|
47
|
-
provider,
|
|
48
|
-
host,
|
|
49
|
-
uid: typeof c.sub === 'string' ? c.sub : null,
|
|
50
|
-
expiresAt: Number.isFinite(c.exp) ? new Date(c.exp * 1000).toISOString() : null,
|
|
51
|
-
name: c.name || c.preferred_username || c.nickname || null,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** API 域名:优先客户端记录的 auth.domain,其次 token 签发方,最后按区域默认 */
|
|
56
|
-
export function apiHost(account) {
|
|
57
|
-
const meta = account.meta || {};
|
|
58
|
-
const fromToken = inspectToken(account.token).host;
|
|
59
|
-
const host = meta.domain || (fromToken && fromToken !== 'copilot.tencent.com' ? fromToken : null);
|
|
60
|
-
return host || (account.provider === 'workbuddy-intl' ? INTL_DEFAULT_HOST : CN_DEFAULT_HOST);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function headersFor(account, token) {
|
|
64
|
-
const meta = account.meta || {};
|
|
65
|
-
const h = {
|
|
66
|
-
Accept: 'application/json',
|
|
67
|
-
'Content-Type': 'application/json',
|
|
68
|
-
Authorization: `Bearer ${token}`,
|
|
69
|
-
'X-User-Id': account.uid || inspectToken(token).uid || '',
|
|
70
|
-
'X-Domain': apiHost(account),
|
|
71
|
-
'X-Product': 'SaaS',
|
|
72
|
-
'User-Agent': USER_AGENT,
|
|
73
|
-
};
|
|
74
|
-
if (meta.enterpriseId) {
|
|
75
|
-
h['X-Enterprise-Id'] = meta.enterpriseId;
|
|
76
|
-
h['X-Tenant-Id'] = meta.enterpriseId;
|
|
77
|
-
}
|
|
78
|
-
return h;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function post(account, path, token) {
|
|
82
|
-
const res = await fetch(`https://${apiHost(account)}${path}`, {
|
|
83
|
-
method: 'POST',
|
|
84
|
-
headers: headersFor(account, token),
|
|
85
|
-
body: '{}',
|
|
86
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
87
|
-
});
|
|
88
|
-
const text = await res.text();
|
|
89
|
-
let body = null;
|
|
90
|
-
try { body = text ? JSON.parse(text) : null; } catch {}
|
|
91
|
-
return { status: res.status, body, text };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 国际版「活跃领取」探测:官方规则为「当天有 ≥1 次有效对话请求即视为活跃用户,发放每日赠送积分」。
|
|
95
|
-
// 没有签到接口,所以每天发一条免费档模型(rateMultiplier 0,~0 消耗)的极短流式请求。
|
|
96
|
-
// 对齐 10router src/sse/services/codebuddyCheckin.js 的 intl 探测(stream 网关的系统提示与 typed blocks 是必需的,否则 11101)。
|
|
97
|
-
const INTL_PROBE_MODEL = 'hy4-preview';
|
|
98
|
-
async function postIntlProbe(account, token) {
|
|
99
|
-
const res = await fetch(`https://${apiHost(account)}/v2/chat/completions`, {
|
|
100
|
-
method: 'POST',
|
|
101
|
-
headers: { ...headersFor(account, token), 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
102
|
-
body: JSON.stringify({
|
|
103
|
-
model: INTL_PROBE_MODEL,
|
|
104
|
-
stream: true,
|
|
105
|
-
max_tokens: 16,
|
|
106
|
-
messages: [
|
|
107
|
-
{ role: 'system', content: 'You are CodeBuddy Code.' },
|
|
108
|
-
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
109
|
-
],
|
|
110
|
-
}),
|
|
111
|
-
signal: AbortSignal.timeout(Math.max(FETCH_TIMEOUT_MS, 25_000)),
|
|
112
|
-
});
|
|
113
|
-
// 2xx = 会话建立成功,把 SSE 流排干(最多几个 token)
|
|
114
|
-
const text = await res.text().catch(() => '');
|
|
115
|
-
return { status: res.status, text };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* 国际版「活跃领取」:一次免费档对话请求使账号成为当日活跃用户。
|
|
120
|
-
* 返回与每日签到一致的结果对象(session-ok → checked-in,claimedAmount 0 表示额度随官方结算发放)。
|
|
121
|
-
*/
|
|
122
|
-
export async function checkinWorkbuddyIntl(account, { onRefresh } = {}) {
|
|
123
|
-
const label = account.name || account.id;
|
|
124
|
-
const base = { accountId: account.id, account: label, provider: account.provider };
|
|
125
|
-
try {
|
|
126
|
-
const r = await withToken(account, (t) => postIntlProbe(account, t), onRefresh);
|
|
127
|
-
if (r.status === 401) return { ...base, status: 'failed', error: '鉴权失败(401),token 已失效,请在客户端重新登录后重新导入' };
|
|
128
|
-
if (r.status === 429) return { ...base, status: 'no-activity', message: '今日额度已耗尽,活跃请求暂不可达(等官方发放后再试)', claimedAmount: 0 };
|
|
129
|
-
if (r.status >= 200 && r.status < 300) {
|
|
130
|
-
return { ...base, uid: account.uid, status: 'checked-in', claimedAmount: 0, message: '已发送活跃请求(保持活跃,活跃不等于必有奖励)' };
|
|
131
|
-
}
|
|
132
|
-
const m = (() => { try { return JSON.parse(r.text)?.error?.data?.msg || ''; } catch { return ''; } })();
|
|
133
|
-
return { ...base, status: 'failed', error: `活跃请求失败:HTTP ${r.status} ${m || r.text.slice(0, 120)}`.trim() };
|
|
134
|
-
} catch (e) {
|
|
135
|
-
return { ...base, status: 'failed', error: e?.message || '网络错误' };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function isExpired(account, now = Date.now()) {
|
|
140
|
-
const exp = account.expiresAt || inspectToken(account.token).expiresAt;
|
|
141
|
-
return Boolean(exp) && new Date(exp).getTime() - EXPIRY_SKEW_MS <= now;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* 用 refreshToken 换新 token。返回 { token, refreshToken, expiresAt, refreshExpiresAt, raw } 或抛错。
|
|
146
|
-
*/
|
|
147
|
-
export async function refreshWorkbuddyToken(account) {
|
|
148
|
-
if (!account.refreshToken) throw new Error('没有 refreshToken,无法刷新,请重新导入该账号');
|
|
149
|
-
const hosts = [apiHost(account)];
|
|
150
|
-
if (account.provider === 'workbuddy' && !hosts.includes('copilot.tencent.com')) hosts.push('copilot.tencent.com');
|
|
151
|
-
let lastError = null;
|
|
152
|
-
for (const host of hosts) {
|
|
153
|
-
try {
|
|
154
|
-
const res = await fetch(`https://${host}/v2/plugin/auth/token/refresh`, {
|
|
155
|
-
method: 'POST',
|
|
156
|
-
headers: {
|
|
157
|
-
'Content-Type': 'application/json',
|
|
158
|
-
Accept: 'application/json',
|
|
159
|
-
'User-Agent': USER_AGENT,
|
|
160
|
-
'X-Requested-With': 'XMLHttpRequest',
|
|
161
|
-
'X-Domain': host === 'copilot.tencent.com' ? 'copilot.tencent.com' : apiHost(account),
|
|
162
|
-
'X-Refresh-Token': account.refreshToken,
|
|
163
|
-
'X-Auth-Refresh-Source': 'plugin',
|
|
164
|
-
'X-Product': 'SaaS',
|
|
165
|
-
...(account.meta?.enterpriseId ? { 'X-Enterprise-Id': account.meta.enterpriseId } : {}),
|
|
166
|
-
},
|
|
167
|
-
body: '{}',
|
|
168
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
169
|
-
});
|
|
170
|
-
const j = await res.json().catch(() => ({}));
|
|
171
|
-
const d = j?.data;
|
|
172
|
-
if (res.ok && j?.code === 0 && d?.accessToken) {
|
|
173
|
-
const now = Date.now();
|
|
174
|
-
return {
|
|
175
|
-
token: d.accessToken,
|
|
176
|
-
refreshToken: d.refreshToken || account.refreshToken,
|
|
177
|
-
expiresAt: d.expiresIn ? new Date(now + d.expiresIn * 1000).toISOString() : inspectToken(d.accessToken).expiresAt,
|
|
178
|
-
refreshExpiresAt: d.refreshExpiresIn ? new Date(now + d.refreshExpiresIn * 1000).toISOString() : null,
|
|
179
|
-
raw: d,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
// 12153 / 401:refreshToken 已失效(账号在别处登出或会话被轮换),只能重新登录
|
|
183
|
-
if (res.status === 401 || String(j?.code) === '12153' || /refresh token is invalid/i.test(j?.msg || '')) {
|
|
184
|
-
const e = new Error('登录已失效,请在 WorkBuddy 客户端重新登录该账号后,到「添加账号 → 本机导入」同步');
|
|
185
|
-
e.authInvalid = true;
|
|
186
|
-
throw e;
|
|
187
|
-
}
|
|
188
|
-
lastError = new Error(`刷新失败:HTTP ${res.status} ${j?.msg || ''}`.trim());
|
|
189
|
-
} catch (e) {
|
|
190
|
-
if (e.authInvalid) throw e;
|
|
191
|
-
lastError = e;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
throw lastError || new Error('刷新失败');
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* 以有效 token 执行 fn(token);token 过期或返回 401 时刷新一次再重试。
|
|
199
|
-
* 刷新成功会通过 onRefresh(newCreds) 回写账号(调用方负责持久化)。
|
|
200
|
-
*/
|
|
201
|
-
async function withToken(account, fn, onRefresh) {
|
|
202
|
-
let token = account.token;
|
|
203
|
-
let refreshed = false;
|
|
204
|
-
const doRefresh = async () => {
|
|
205
|
-
const creds = await refreshWorkbuddyToken(account);
|
|
206
|
-
Object.assign(account, { token: creds.token, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt });
|
|
207
|
-
token = creds.token;
|
|
208
|
-
refreshed = true;
|
|
209
|
-
await onRefresh?.(creds);
|
|
210
|
-
};
|
|
211
|
-
if (isExpired(account) && account.refreshToken) await doRefresh();
|
|
212
|
-
let r = await fn(token);
|
|
213
|
-
if (r.status === 401 && !refreshed && account.refreshToken) {
|
|
214
|
-
await doRefresh();
|
|
215
|
-
r = await fn(token);
|
|
216
|
-
}
|
|
217
|
-
return r;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const ALREADY_RE = /已签到|签到过|重复签到|already|repeat/i;
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* 签到。返回与 Qoder 一致的结果对象(status: checked-in / already / failed)。
|
|
224
|
-
* @param {object} account
|
|
225
|
-
* @param {{ onRefresh?: (creds) => Promise<void>|void }} [opts]
|
|
226
|
-
*/
|
|
227
|
-
export async function checkinWorkbuddy(account, { onRefresh } = {}) {
|
|
228
|
-
const label = account.name || account.id;
|
|
229
|
-
const base = { accountId: account.id, account: label, provider: account.provider };
|
|
230
|
-
try {
|
|
231
|
-
// 1) 先查状态:今日已签则不再 POST
|
|
232
|
-
const st = await withToken(account, (t) => post(account, '/v2/billing/meter/checkin-activity-status', t), onRefresh);
|
|
233
|
-
if (st.status === 401) return { ...base, status: 'failed', error: '鉴权失败(401),token 已失效,请在 WorkBuddy 重新登录后重新导入' };
|
|
234
|
-
const sd = st.status === 200 && st.body?.code === 0 ? st.body.data || {} : null;
|
|
235
|
-
const streak = sd ? { streakDays: sd.streak_days ?? 0, dailyCredit: sd.daily_credit ?? null } : {};
|
|
236
|
-
if (sd && (sd.today_checked_in === true || sd.checked_in === true)) {
|
|
237
|
-
return { ...base, uid: account.uid, status: 'already', message: `今日已签(连签 ${sd.streak_days ?? 0} 天)`, claimedAmount: 0, ...streak };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// 2) 签到
|
|
241
|
-
const r = await withToken(account, (t) => post(account, '/v2/billing/meter/daily-checkin', t), onRefresh);
|
|
242
|
-
const code = r.body?.code ?? r.body?.error_code;
|
|
243
|
-
const msg = r.body?.msg ?? r.body?.message ?? '';
|
|
244
|
-
if (r.status >= 200 && r.status < 300 && (code === 0 || code === undefined)) {
|
|
245
|
-
const d = r.body?.data || {};
|
|
246
|
-
const amount = Number(d.credit ?? d.today_credit ?? d.daily_credit ?? sd?.today_credit ?? sd?.daily_credit ?? 0) || 0;
|
|
247
|
-
return { ...base, uid: account.uid, status: 'checked-in', claimedAmount: amount, message: `签到成功(连签 ${(sd?.streak_days ?? 0) + 1} 天)`, ...streak };
|
|
248
|
-
}
|
|
249
|
-
if ((r.status === 400 || (r.status === 200 && code !== 0)) && (String(code) === '10001' || ALREADY_RE.test(msg))) {
|
|
250
|
-
return { ...base, uid: account.uid, status: 'already', message: '今日已签', claimedAmount: 0, ...streak };
|
|
251
|
-
}
|
|
252
|
-
if (r.status === 404) {
|
|
253
|
-
return { ...base, status: 'no-activity', message: `${apiHost(account)} 暂无签到活动(接口 404)`, claimedAmount: 0 };
|
|
254
|
-
}
|
|
255
|
-
return { ...base, status: 'failed', error: `签到失败:HTTP ${r.status} ${msg || r.text.slice(0, 120)}`.trim() };
|
|
256
|
-
} catch (e) {
|
|
257
|
-
return { ...base, status: 'failed', error: e?.message || '网络错误' };
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const num = (precise, plain) => {
|
|
262
|
-
const n = Number(precise ?? plain);
|
|
263
|
-
return Number.isFinite(n) ? n : 0;
|
|
264
|
-
};
|
|
265
|
-
const REFILL_GAP_MS = 2 * 24 * 3600e3;
|
|
266
|
-
const toIso = (v) => {
|
|
267
|
-
if (v === undefined || v === null || v === '') return null;
|
|
268
|
-
const n = Number(v);
|
|
269
|
-
const d = Number.isFinite(n) ? new Date(n < 1e12 ? n * 1000 : n) : new Date(String(v).replace(' ', 'T') + (String(v).includes('+') || String(v).endsWith('Z') ? '' : '+08:00'));
|
|
270
|
-
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* 积分包汇总,归一成 { total, used, remaining, parts: [{ name, total, used, remaining, expiresAt, recurring }] }。
|
|
275
|
-
* 循环额度包(体验版月度额度)取 Cycle* 字段,活动赠送包取 Capacity* 字段(同 10router usage/codebuddy-cn)。
|
|
276
|
-
*/
|
|
277
|
-
export async function fetchWorkbuddyQuota(account, { onRefresh } = {}) {
|
|
278
|
-
const r = await withToken(account, (t) => post(account, '/v2/billing/meter/get-user-resource', t), onRefresh);
|
|
279
|
-
if (r.status === 401 || r.status === 403) throw new Error(`鉴权失败 (HTTP ${r.status}),token 可能已过期`);
|
|
280
|
-
if (r.status !== 200 || r.body?.code !== 0) throw new Error(`积分查询失败:HTTP ${r.status} ${r.body?.msg || ''}`.trim());
|
|
281
|
-
const list = r.body?.data?.Response?.Data?.Accounts;
|
|
282
|
-
const packs = Array.isArray(list) ? list : [];
|
|
283
|
-
const now = Date.now();
|
|
284
|
-
const parts = packs.map((a) => {
|
|
285
|
-
const cycleEnd = toIso(a.CycleEndTime);
|
|
286
|
-
const deductEnd = toIso(a.DeductionEndTime);
|
|
287
|
-
const recurring = Boolean(cycleEnd && deductEnd && new Date(deductEnd) - new Date(cycleEnd) > REFILL_GAP_MS);
|
|
288
|
-
// 循环包(体验版月度额度)看本周期的 Cycle* 字段;一次性赠送包看 Capacity* 字段。直接用 *Remain,不自行相减
|
|
289
|
-
const total = recurring ? num(a.CycleCapacitySizePrecise, a.CycleCapacitySize) : num(a.CapacitySizePrecise, a.CapacitySize);
|
|
290
|
-
const used = recurring ? num(a.CycleCapacityUsedPrecise, a.CycleCapacityUsed) : num(a.CapacityUsedPrecise, a.CapacityUsed);
|
|
291
|
-
const remaining = recurring ? num(a.CycleCapacityRemainPrecise, a.CycleCapacityRemain) : num(a.CapacityRemainPrecise, a.CapacityRemain);
|
|
292
|
-
return {
|
|
293
|
-
name: a.PackageName || a.SubProductName || '积分包',
|
|
294
|
-
total, used, remaining,
|
|
295
|
-
expiresAt: recurring ? cycleEnd : (deductEnd || cycleEnd),
|
|
296
|
-
recurring,
|
|
297
|
-
};
|
|
298
|
-
}).filter((p) => p.total > 0 && (!p.expiresAt || new Date(p.expiresAt).getTime() > now));
|
|
299
|
-
parts.sort((a, b) => (b.remaining > 0) - (a.remaining > 0) || String(a.expiresAt).localeCompare(String(b.expiresAt)));
|
|
300
|
-
const sum = (k) => Math.round(parts.reduce((s, p) => s + p[k], 0) * 100) / 100;
|
|
301
|
-
return { total: sum('total'), used: sum('used'), remaining: sum('remaining'), parts, exceeded: false };
|
|
302
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy(腾讯 CodeBuddy 系)API 客户端 — 签到、积分、token 刷新。
|
|
3
|
+
*
|
|
4
|
+
* 协议对齐 WorkBuddy 桌面端与 10router 的 codebuddyCheckin / usage / tokenRefresh:
|
|
5
|
+
* POST {host}/v2/billing/meter/checkin-activity-status 今日是否已签、连签天数、每日额度
|
|
6
|
+
* POST {host}/v2/billing/meter/daily-checkin 签到(400 + 10001/“已签到” = 今日已签)
|
|
7
|
+
* POST {host}/v2/billing/meter/get-user-resource 积分包(data.Response.Data.Accounts[])
|
|
8
|
+
* POST {host}/v2/plugin/auth/token/refresh 刷新(X-Refresh-Token 头)
|
|
9
|
+
* 请求头:Authorization Bearer + X-User-Id(JWT sub)+ X-Domain(token 所属域名)。
|
|
10
|
+
*
|
|
11
|
+
* WorkBuddy 登录 token 是 Keycloak JWT,签发方决定归属与 API 域名:
|
|
12
|
+
* www.codebuddy.cn / www.workbuddy.cn / copilot.tencent.com → 国内版
|
|
13
|
+
* www.codebuddy.ai / www.workbuddy.ai / workbuddy.cc → 国际版
|
|
14
|
+
*
|
|
15
|
+
* token 只在过期或 401 时刷新:刷新会轮换 refreshToken,若该账号正登录在 WorkBuddy
|
|
16
|
+
* 客户端里,主动刷新可能让客户端手里的旧 refreshToken 失效。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
20
|
+
|
|
21
|
+
const USER_AGENT = 'CLI/2.108.1 CodeBuddy/2.108.1';
|
|
22
|
+
const CN_DEFAULT_HOST = 'www.codebuddy.cn';
|
|
23
|
+
const INTL_DEFAULT_HOST = 'www.codebuddy.ai';
|
|
24
|
+
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
export function decodeJwt(jwt) {
|
|
27
|
+
try {
|
|
28
|
+
const seg = String(jwt).split('.')[1];
|
|
29
|
+
const b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
|
|
30
|
+
return JSON.parse(Buffer.from(b64 + '='.repeat((4 - (b64.length % 4)) % 4), 'base64').toString('utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 从 token 推断归属:{ provider, host, uid, expiresAt };不是 CodeBuddy 系 token 时 provider 为 null。
|
|
38
|
+
*/
|
|
39
|
+
export function inspectToken(token) {
|
|
40
|
+
const c = decodeJwt(token) || {};
|
|
41
|
+
let host = null;
|
|
42
|
+
try { host = new URL(c.iss).host; } catch {}
|
|
43
|
+
let provider = null;
|
|
44
|
+
if (host && /(codebuddy|workbuddy)\.ai$|workbuddy\.cc$/.test(host)) provider = 'workbuddy-intl';
|
|
45
|
+
else if (host && /(codebuddy|workbuddy)\.cn$|copilot\.tencent\.com$/.test(host)) provider = 'workbuddy';
|
|
46
|
+
return {
|
|
47
|
+
provider,
|
|
48
|
+
host,
|
|
49
|
+
uid: typeof c.sub === 'string' ? c.sub : null,
|
|
50
|
+
expiresAt: Number.isFinite(c.exp) ? new Date(c.exp * 1000).toISOString() : null,
|
|
51
|
+
name: c.name || c.preferred_username || c.nickname || null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** API 域名:优先客户端记录的 auth.domain,其次 token 签发方,最后按区域默认 */
|
|
56
|
+
export function apiHost(account) {
|
|
57
|
+
const meta = account.meta || {};
|
|
58
|
+
const fromToken = inspectToken(account.token).host;
|
|
59
|
+
const host = meta.domain || (fromToken && fromToken !== 'copilot.tencent.com' ? fromToken : null);
|
|
60
|
+
return host || (account.provider === 'workbuddy-intl' ? INTL_DEFAULT_HOST : CN_DEFAULT_HOST);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function headersFor(account, token) {
|
|
64
|
+
const meta = account.meta || {};
|
|
65
|
+
const h = {
|
|
66
|
+
Accept: 'application/json',
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
Authorization: `Bearer ${token}`,
|
|
69
|
+
'X-User-Id': account.uid || inspectToken(token).uid || '',
|
|
70
|
+
'X-Domain': apiHost(account),
|
|
71
|
+
'X-Product': 'SaaS',
|
|
72
|
+
'User-Agent': USER_AGENT,
|
|
73
|
+
};
|
|
74
|
+
if (meta.enterpriseId) {
|
|
75
|
+
h['X-Enterprise-Id'] = meta.enterpriseId;
|
|
76
|
+
h['X-Tenant-Id'] = meta.enterpriseId;
|
|
77
|
+
}
|
|
78
|
+
return h;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function post(account, path, token) {
|
|
82
|
+
const res = await fetch(`https://${apiHost(account)}${path}`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: headersFor(account, token),
|
|
85
|
+
body: '{}',
|
|
86
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
87
|
+
});
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
let body = null;
|
|
90
|
+
try { body = text ? JSON.parse(text) : null; } catch {}
|
|
91
|
+
return { status: res.status, body, text };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 国际版「活跃领取」探测:官方规则为「当天有 ≥1 次有效对话请求即视为活跃用户,发放每日赠送积分」。
|
|
95
|
+
// 没有签到接口,所以每天发一条免费档模型(rateMultiplier 0,~0 消耗)的极短流式请求。
|
|
96
|
+
// 对齐 10router src/sse/services/codebuddyCheckin.js 的 intl 探测(stream 网关的系统提示与 typed blocks 是必需的,否则 11101)。
|
|
97
|
+
const INTL_PROBE_MODEL = 'hy4-preview';
|
|
98
|
+
async function postIntlProbe(account, token) {
|
|
99
|
+
const res = await fetch(`https://${apiHost(account)}/v2/chat/completions`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { ...headersFor(account, token), 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
model: INTL_PROBE_MODEL,
|
|
104
|
+
stream: true,
|
|
105
|
+
max_tokens: 16,
|
|
106
|
+
messages: [
|
|
107
|
+
{ role: 'system', content: 'You are CodeBuddy Code.' },
|
|
108
|
+
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
109
|
+
],
|
|
110
|
+
}),
|
|
111
|
+
signal: AbortSignal.timeout(Math.max(FETCH_TIMEOUT_MS, 25_000)),
|
|
112
|
+
});
|
|
113
|
+
// 2xx = 会话建立成功,把 SSE 流排干(最多几个 token)
|
|
114
|
+
const text = await res.text().catch(() => '');
|
|
115
|
+
return { status: res.status, text };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 国际版「活跃领取」:一次免费档对话请求使账号成为当日活跃用户。
|
|
120
|
+
* 返回与每日签到一致的结果对象(session-ok → checked-in,claimedAmount 0 表示额度随官方结算发放)。
|
|
121
|
+
*/
|
|
122
|
+
export async function checkinWorkbuddyIntl(account, { onRefresh } = {}) {
|
|
123
|
+
const label = account.name || account.id;
|
|
124
|
+
const base = { accountId: account.id, account: label, provider: account.provider };
|
|
125
|
+
try {
|
|
126
|
+
const r = await withToken(account, (t) => postIntlProbe(account, t), onRefresh);
|
|
127
|
+
if (r.status === 401) return { ...base, status: 'failed', error: '鉴权失败(401),token 已失效,请在客户端重新登录后重新导入' };
|
|
128
|
+
if (r.status === 429) return { ...base, status: 'no-activity', message: '今日额度已耗尽,活跃请求暂不可达(等官方发放后再试)', claimedAmount: 0 };
|
|
129
|
+
if (r.status >= 200 && r.status < 300) {
|
|
130
|
+
return { ...base, uid: account.uid, status: 'checked-in', claimedAmount: 0, message: '已发送活跃请求(保持活跃,活跃不等于必有奖励)' };
|
|
131
|
+
}
|
|
132
|
+
const m = (() => { try { return JSON.parse(r.text)?.error?.data?.msg || ''; } catch { return ''; } })();
|
|
133
|
+
return { ...base, status: 'failed', error: `活跃请求失败:HTTP ${r.status} ${m || r.text.slice(0, 120)}`.trim() };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { ...base, status: 'failed', error: e?.message || '网络错误' };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isExpired(account, now = Date.now()) {
|
|
140
|
+
const exp = account.expiresAt || inspectToken(account.token).expiresAt;
|
|
141
|
+
return Boolean(exp) && new Date(exp).getTime() - EXPIRY_SKEW_MS <= now;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 用 refreshToken 换新 token。返回 { token, refreshToken, expiresAt, refreshExpiresAt, raw } 或抛错。
|
|
146
|
+
*/
|
|
147
|
+
export async function refreshWorkbuddyToken(account) {
|
|
148
|
+
if (!account.refreshToken) throw new Error('没有 refreshToken,无法刷新,请重新导入该账号');
|
|
149
|
+
const hosts = [apiHost(account)];
|
|
150
|
+
if (account.provider === 'workbuddy' && !hosts.includes('copilot.tencent.com')) hosts.push('copilot.tencent.com');
|
|
151
|
+
let lastError = null;
|
|
152
|
+
for (const host of hosts) {
|
|
153
|
+
try {
|
|
154
|
+
const res = await fetch(`https://${host}/v2/plugin/auth/token/refresh`, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: {
|
|
157
|
+
'Content-Type': 'application/json',
|
|
158
|
+
Accept: 'application/json',
|
|
159
|
+
'User-Agent': USER_AGENT,
|
|
160
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
161
|
+
'X-Domain': host === 'copilot.tencent.com' ? 'copilot.tencent.com' : apiHost(account),
|
|
162
|
+
'X-Refresh-Token': account.refreshToken,
|
|
163
|
+
'X-Auth-Refresh-Source': 'plugin',
|
|
164
|
+
'X-Product': 'SaaS',
|
|
165
|
+
...(account.meta?.enterpriseId ? { 'X-Enterprise-Id': account.meta.enterpriseId } : {}),
|
|
166
|
+
},
|
|
167
|
+
body: '{}',
|
|
168
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
169
|
+
});
|
|
170
|
+
const j = await res.json().catch(() => ({}));
|
|
171
|
+
const d = j?.data;
|
|
172
|
+
if (res.ok && j?.code === 0 && d?.accessToken) {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
return {
|
|
175
|
+
token: d.accessToken,
|
|
176
|
+
refreshToken: d.refreshToken || account.refreshToken,
|
|
177
|
+
expiresAt: d.expiresIn ? new Date(now + d.expiresIn * 1000).toISOString() : inspectToken(d.accessToken).expiresAt,
|
|
178
|
+
refreshExpiresAt: d.refreshExpiresIn ? new Date(now + d.refreshExpiresIn * 1000).toISOString() : null,
|
|
179
|
+
raw: d,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// 12153 / 401:refreshToken 已失效(账号在别处登出或会话被轮换),只能重新登录
|
|
183
|
+
if (res.status === 401 || String(j?.code) === '12153' || /refresh token is invalid/i.test(j?.msg || '')) {
|
|
184
|
+
const e = new Error('登录已失效,请在 WorkBuddy 客户端重新登录该账号后,到「添加账号 → 本机导入」同步');
|
|
185
|
+
e.authInvalid = true;
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
lastError = new Error(`刷新失败:HTTP ${res.status} ${j?.msg || ''}`.trim());
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (e.authInvalid) throw e;
|
|
191
|
+
lastError = e;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
throw lastError || new Error('刷新失败');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 以有效 token 执行 fn(token);token 过期或返回 401 时刷新一次再重试。
|
|
199
|
+
* 刷新成功会通过 onRefresh(newCreds) 回写账号(调用方负责持久化)。
|
|
200
|
+
*/
|
|
201
|
+
async function withToken(account, fn, onRefresh) {
|
|
202
|
+
let token = account.token;
|
|
203
|
+
let refreshed = false;
|
|
204
|
+
const doRefresh = async () => {
|
|
205
|
+
const creds = await refreshWorkbuddyToken(account);
|
|
206
|
+
Object.assign(account, { token: creds.token, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt });
|
|
207
|
+
token = creds.token;
|
|
208
|
+
refreshed = true;
|
|
209
|
+
await onRefresh?.(creds);
|
|
210
|
+
};
|
|
211
|
+
if (isExpired(account) && account.refreshToken) await doRefresh();
|
|
212
|
+
let r = await fn(token);
|
|
213
|
+
if (r.status === 401 && !refreshed && account.refreshToken) {
|
|
214
|
+
await doRefresh();
|
|
215
|
+
r = await fn(token);
|
|
216
|
+
}
|
|
217
|
+
return r;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const ALREADY_RE = /已签到|签到过|重复签到|already|repeat/i;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 签到。返回与 Qoder 一致的结果对象(status: checked-in / already / failed)。
|
|
224
|
+
* @param {object} account
|
|
225
|
+
* @param {{ onRefresh?: (creds) => Promise<void>|void }} [opts]
|
|
226
|
+
*/
|
|
227
|
+
export async function checkinWorkbuddy(account, { onRefresh } = {}) {
|
|
228
|
+
const label = account.name || account.id;
|
|
229
|
+
const base = { accountId: account.id, account: label, provider: account.provider };
|
|
230
|
+
try {
|
|
231
|
+
// 1) 先查状态:今日已签则不再 POST
|
|
232
|
+
const st = await withToken(account, (t) => post(account, '/v2/billing/meter/checkin-activity-status', t), onRefresh);
|
|
233
|
+
if (st.status === 401) return { ...base, status: 'failed', error: '鉴权失败(401),token 已失效,请在 WorkBuddy 重新登录后重新导入' };
|
|
234
|
+
const sd = st.status === 200 && st.body?.code === 0 ? st.body.data || {} : null;
|
|
235
|
+
const streak = sd ? { streakDays: sd.streak_days ?? 0, dailyCredit: sd.daily_credit ?? null } : {};
|
|
236
|
+
if (sd && (sd.today_checked_in === true || sd.checked_in === true)) {
|
|
237
|
+
return { ...base, uid: account.uid, status: 'already', message: `今日已签(连签 ${sd.streak_days ?? 0} 天)`, claimedAmount: 0, ...streak };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2) 签到
|
|
241
|
+
const r = await withToken(account, (t) => post(account, '/v2/billing/meter/daily-checkin', t), onRefresh);
|
|
242
|
+
const code = r.body?.code ?? r.body?.error_code;
|
|
243
|
+
const msg = r.body?.msg ?? r.body?.message ?? '';
|
|
244
|
+
if (r.status >= 200 && r.status < 300 && (code === 0 || code === undefined)) {
|
|
245
|
+
const d = r.body?.data || {};
|
|
246
|
+
const amount = Number(d.credit ?? d.today_credit ?? d.daily_credit ?? sd?.today_credit ?? sd?.daily_credit ?? 0) || 0;
|
|
247
|
+
return { ...base, uid: account.uid, status: 'checked-in', claimedAmount: amount, message: `签到成功(连签 ${(sd?.streak_days ?? 0) + 1} 天)`, ...streak };
|
|
248
|
+
}
|
|
249
|
+
if ((r.status === 400 || (r.status === 200 && code !== 0)) && (String(code) === '10001' || ALREADY_RE.test(msg))) {
|
|
250
|
+
return { ...base, uid: account.uid, status: 'already', message: '今日已签', claimedAmount: 0, ...streak };
|
|
251
|
+
}
|
|
252
|
+
if (r.status === 404) {
|
|
253
|
+
return { ...base, status: 'no-activity', message: `${apiHost(account)} 暂无签到活动(接口 404)`, claimedAmount: 0 };
|
|
254
|
+
}
|
|
255
|
+
return { ...base, status: 'failed', error: `签到失败:HTTP ${r.status} ${msg || r.text.slice(0, 120)}`.trim() };
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return { ...base, status: 'failed', error: e?.message || '网络错误' };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const num = (precise, plain) => {
|
|
262
|
+
const n = Number(precise ?? plain);
|
|
263
|
+
return Number.isFinite(n) ? n : 0;
|
|
264
|
+
};
|
|
265
|
+
const REFILL_GAP_MS = 2 * 24 * 3600e3;
|
|
266
|
+
const toIso = (v) => {
|
|
267
|
+
if (v === undefined || v === null || v === '') return null;
|
|
268
|
+
const n = Number(v);
|
|
269
|
+
const d = Number.isFinite(n) ? new Date(n < 1e12 ? n * 1000 : n) : new Date(String(v).replace(' ', 'T') + (String(v).includes('+') || String(v).endsWith('Z') ? '' : '+08:00'));
|
|
270
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 积分包汇总,归一成 { total, used, remaining, parts: [{ name, total, used, remaining, expiresAt, recurring }] }。
|
|
275
|
+
* 循环额度包(体验版月度额度)取 Cycle* 字段,活动赠送包取 Capacity* 字段(同 10router usage/codebuddy-cn)。
|
|
276
|
+
*/
|
|
277
|
+
export async function fetchWorkbuddyQuota(account, { onRefresh } = {}) {
|
|
278
|
+
const r = await withToken(account, (t) => post(account, '/v2/billing/meter/get-user-resource', t), onRefresh);
|
|
279
|
+
if (r.status === 401 || r.status === 403) throw new Error(`鉴权失败 (HTTP ${r.status}),token 可能已过期`);
|
|
280
|
+
if (r.status !== 200 || r.body?.code !== 0) throw new Error(`积分查询失败:HTTP ${r.status} ${r.body?.msg || ''}`.trim());
|
|
281
|
+
const list = r.body?.data?.Response?.Data?.Accounts;
|
|
282
|
+
const packs = Array.isArray(list) ? list : [];
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
const parts = packs.map((a) => {
|
|
285
|
+
const cycleEnd = toIso(a.CycleEndTime);
|
|
286
|
+
const deductEnd = toIso(a.DeductionEndTime);
|
|
287
|
+
const recurring = Boolean(cycleEnd && deductEnd && new Date(deductEnd) - new Date(cycleEnd) > REFILL_GAP_MS);
|
|
288
|
+
// 循环包(体验版月度额度)看本周期的 Cycle* 字段;一次性赠送包看 Capacity* 字段。直接用 *Remain,不自行相减
|
|
289
|
+
const total = recurring ? num(a.CycleCapacitySizePrecise, a.CycleCapacitySize) : num(a.CapacitySizePrecise, a.CapacitySize);
|
|
290
|
+
const used = recurring ? num(a.CycleCapacityUsedPrecise, a.CycleCapacityUsed) : num(a.CapacityUsedPrecise, a.CapacityUsed);
|
|
291
|
+
const remaining = recurring ? num(a.CycleCapacityRemainPrecise, a.CycleCapacityRemain) : num(a.CapacityRemainPrecise, a.CapacityRemain);
|
|
292
|
+
return {
|
|
293
|
+
name: a.PackageName || a.SubProductName || '积分包',
|
|
294
|
+
total, used, remaining,
|
|
295
|
+
expiresAt: recurring ? cycleEnd : (deductEnd || cycleEnd),
|
|
296
|
+
recurring,
|
|
297
|
+
};
|
|
298
|
+
}).filter((p) => p.total > 0 && (!p.expiresAt || new Date(p.expiresAt).getTime() > now));
|
|
299
|
+
parts.sort((a, b) => (b.remaining > 0) - (a.remaining > 0) || String(a.expiresAt).localeCompare(String(b.expiresAt)));
|
|
300
|
+
const sum = (k) => Math.round(parts.reduce((s, p) => s + p[k], 0) * 100) / 100;
|
|
301
|
+
return { total: sum('total'), used: sum('used'), remaining: sum('remaining'), parts, exceeded: false };
|
|
302
|
+
}
|