creditdaddy 0.8.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.
@@ -0,0 +1,257 @@
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
+ export function isExpired(account, now = Date.now()) {
95
+ const exp = account.expiresAt || inspectToken(account.token).expiresAt;
96
+ return Boolean(exp) && new Date(exp).getTime() - EXPIRY_SKEW_MS <= now;
97
+ }
98
+
99
+ /**
100
+ * 用 refreshToken 换新 token。返回 { token, refreshToken, expiresAt, refreshExpiresAt, raw } 或抛错。
101
+ */
102
+ export async function refreshWorkbuddyToken(account) {
103
+ if (!account.refreshToken) throw new Error('没有 refreshToken,无法刷新,请重新导入该账号');
104
+ const hosts = [apiHost(account)];
105
+ if (account.provider === 'workbuddy' && !hosts.includes('copilot.tencent.com')) hosts.push('copilot.tencent.com');
106
+ let lastError = null;
107
+ for (const host of hosts) {
108
+ try {
109
+ const res = await fetch(`https://${host}/v2/plugin/auth/token/refresh`, {
110
+ method: 'POST',
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ Accept: 'application/json',
114
+ 'User-Agent': USER_AGENT,
115
+ 'X-Requested-With': 'XMLHttpRequest',
116
+ 'X-Domain': host === 'copilot.tencent.com' ? 'copilot.tencent.com' : apiHost(account),
117
+ 'X-Refresh-Token': account.refreshToken,
118
+ 'X-Auth-Refresh-Source': 'plugin',
119
+ 'X-Product': 'SaaS',
120
+ ...(account.meta?.enterpriseId ? { 'X-Enterprise-Id': account.meta.enterpriseId } : {}),
121
+ },
122
+ body: '{}',
123
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
124
+ });
125
+ const j = await res.json().catch(() => ({}));
126
+ const d = j?.data;
127
+ if (res.ok && j?.code === 0 && d?.accessToken) {
128
+ const now = Date.now();
129
+ return {
130
+ token: d.accessToken,
131
+ refreshToken: d.refreshToken || account.refreshToken,
132
+ expiresAt: d.expiresIn ? new Date(now + d.expiresIn * 1000).toISOString() : inspectToken(d.accessToken).expiresAt,
133
+ refreshExpiresAt: d.refreshExpiresIn ? new Date(now + d.refreshExpiresIn * 1000).toISOString() : null,
134
+ raw: d,
135
+ };
136
+ }
137
+ // 12153 / 401:refreshToken 已失效(账号在别处登出或会话被轮换),只能重新登录
138
+ if (res.status === 401 || String(j?.code) === '12153' || /refresh token is invalid/i.test(j?.msg || '')) {
139
+ const e = new Error('登录已失效,请在 WorkBuddy 客户端重新登录该账号后,到「添加账号 → 本机导入」同步');
140
+ e.authInvalid = true;
141
+ throw e;
142
+ }
143
+ lastError = new Error(`刷新失败:HTTP ${res.status} ${j?.msg || ''}`.trim());
144
+ } catch (e) {
145
+ if (e.authInvalid) throw e;
146
+ lastError = e;
147
+ }
148
+ }
149
+ throw lastError || new Error('刷新失败');
150
+ }
151
+
152
+ /**
153
+ * 以有效 token 执行 fn(token);token 过期或返回 401 时刷新一次再重试。
154
+ * 刷新成功会通过 onRefresh(newCreds) 回写账号(调用方负责持久化)。
155
+ */
156
+ async function withToken(account, fn, onRefresh) {
157
+ let token = account.token;
158
+ let refreshed = false;
159
+ const doRefresh = async () => {
160
+ const creds = await refreshWorkbuddyToken(account);
161
+ Object.assign(account, { token: creds.token, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt });
162
+ token = creds.token;
163
+ refreshed = true;
164
+ await onRefresh?.(creds);
165
+ };
166
+ if (isExpired(account) && account.refreshToken) await doRefresh();
167
+ let r = await fn(token);
168
+ if (r.status === 401 && !refreshed && account.refreshToken) {
169
+ await doRefresh();
170
+ r = await fn(token);
171
+ }
172
+ return r;
173
+ }
174
+
175
+ const ALREADY_RE = /已签到|签到过|重复签到|already|repeat/i;
176
+
177
+ /**
178
+ * 签到。返回与 Qoder 一致的结果对象(status: checked-in / already / failed)。
179
+ * @param {object} account
180
+ * @param {{ onRefresh?: (creds) => Promise<void>|void }} [opts]
181
+ */
182
+ export async function checkinWorkbuddy(account, { onRefresh } = {}) {
183
+ const label = account.name || account.id;
184
+ const base = { accountId: account.id, account: label, provider: account.provider };
185
+ try {
186
+ // 1) 先查状态:今日已签则不再 POST
187
+ const st = await withToken(account, (t) => post(account, '/v2/billing/meter/checkin-activity-status', t), onRefresh);
188
+ if (st.status === 401) return { ...base, status: 'failed', error: '鉴权失败(401),token 已失效,请在 WorkBuddy 重新登录后重新导入' };
189
+ const sd = st.status === 200 && st.body?.code === 0 ? st.body.data || {} : null;
190
+ const streak = sd ? { streakDays: sd.streak_days ?? 0, dailyCredit: sd.daily_credit ?? null } : {};
191
+ if (sd && (sd.today_checked_in === true || sd.checked_in === true)) {
192
+ return { ...base, uid: account.uid, status: 'already', message: `今日已签(连签 ${sd.streak_days ?? 0} 天)`, claimedAmount: 0, ...streak };
193
+ }
194
+
195
+ // 2) 签到
196
+ const r = await withToken(account, (t) => post(account, '/v2/billing/meter/daily-checkin', t), onRefresh);
197
+ const code = r.body?.code ?? r.body?.error_code;
198
+ const msg = r.body?.msg ?? r.body?.message ?? '';
199
+ if (r.status >= 200 && r.status < 300 && (code === 0 || code === undefined)) {
200
+ const d = r.body?.data || {};
201
+ const amount = Number(d.credit ?? d.today_credit ?? d.daily_credit ?? sd?.today_credit ?? sd?.daily_credit ?? 0) || 0;
202
+ return { ...base, uid: account.uid, status: 'checked-in', claimedAmount: amount, message: `签到成功(连签 ${(sd?.streak_days ?? 0) + 1} 天)`, ...streak };
203
+ }
204
+ if ((r.status === 400 || (r.status === 200 && code !== 0)) && (String(code) === '10001' || ALREADY_RE.test(msg))) {
205
+ return { ...base, uid: account.uid, status: 'already', message: '今日已签', claimedAmount: 0, ...streak };
206
+ }
207
+ if (r.status === 404) {
208
+ return { ...base, status: 'no-activity', message: `${apiHost(account)} 暂无签到活动(接口 404)`, claimedAmount: 0 };
209
+ }
210
+ return { ...base, status: 'failed', error: `签到失败:HTTP ${r.status} ${msg || r.text.slice(0, 120)}`.trim() };
211
+ } catch (e) {
212
+ return { ...base, status: 'failed', error: e?.message || '网络错误' };
213
+ }
214
+ }
215
+
216
+ const num = (precise, plain) => {
217
+ const n = Number(precise ?? plain);
218
+ return Number.isFinite(n) ? n : 0;
219
+ };
220
+ const REFILL_GAP_MS = 2 * 24 * 3600e3;
221
+ const toIso = (v) => {
222
+ if (v === undefined || v === null || v === '') return null;
223
+ const n = Number(v);
224
+ 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'));
225
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
226
+ };
227
+
228
+ /**
229
+ * 积分包汇总,归一成 { total, used, remaining, parts: [{ name, total, used, remaining, expiresAt, recurring }] }。
230
+ * 循环额度包(体验版月度额度)取 Cycle* 字段,活动赠送包取 Capacity* 字段(同 10router usage/codebuddy-cn)。
231
+ */
232
+ export async function fetchWorkbuddyQuota(account, { onRefresh } = {}) {
233
+ const r = await withToken(account, (t) => post(account, '/v2/billing/meter/get-user-resource', t), onRefresh);
234
+ if (r.status === 401 || r.status === 403) throw new Error(`鉴权失败 (HTTP ${r.status}),token 可能已过期`);
235
+ if (r.status !== 200 || r.body?.code !== 0) throw new Error(`积分查询失败:HTTP ${r.status} ${r.body?.msg || ''}`.trim());
236
+ const list = r.body?.data?.Response?.Data?.Accounts;
237
+ const packs = Array.isArray(list) ? list : [];
238
+ const now = Date.now();
239
+ const parts = packs.map((a) => {
240
+ const cycleEnd = toIso(a.CycleEndTime);
241
+ const deductEnd = toIso(a.DeductionEndTime);
242
+ const recurring = Boolean(cycleEnd && deductEnd && new Date(deductEnd) - new Date(cycleEnd) > REFILL_GAP_MS);
243
+ // 循环包(体验版月度额度)看本周期的 Cycle* 字段;一次性赠送包看 Capacity* 字段。直接用 *Remain,不自行相减
244
+ const total = recurring ? num(a.CycleCapacitySizePrecise, a.CycleCapacitySize) : num(a.CapacitySizePrecise, a.CapacitySize);
245
+ const used = recurring ? num(a.CycleCapacityUsedPrecise, a.CycleCapacityUsed) : num(a.CapacityUsedPrecise, a.CapacityUsed);
246
+ const remaining = recurring ? num(a.CycleCapacityRemainPrecise, a.CycleCapacityRemain) : num(a.CapacityRemainPrecise, a.CapacityRemain);
247
+ return {
248
+ name: a.PackageName || a.SubProductName || '积分包',
249
+ total, used, remaining,
250
+ expiresAt: recurring ? cycleEnd : (deductEnd || cycleEnd),
251
+ recurring,
252
+ };
253
+ }).filter((p) => p.total > 0 && (!p.expiresAt || new Date(p.expiresAt).getTime() > now));
254
+ parts.sort((a, b) => (b.remaining > 0) - (a.remaining > 0) || String(a.expiresAt).localeCompare(String(b.expiresAt)));
255
+ const sum = (k) => Math.round(parts.reduce((s, p) => s + p[k], 0) * 100) / 100;
256
+ return { total: sum('total'), used: sum('used'), remaining: sum('remaining'), parts, exceeded: false };
257
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 本机 WorkBuddy 客户端凭据 — 读取已登录账号、切换当前账号。
3
+ *
4
+ * WorkBuddy(与 CodeBuddy CLI / 插件共用)把会话存成明文 JSON:
5
+ * Windows: %LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\<认证ID>.info
6
+ * macOS: ~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/
7
+ * Linux: ~/.local/share/CodeBuddyExtension/Data/Public/auth/
8
+ * 当前会话是 workbuddy-desktop.info(国际版客户端可能用其他认证 ID),带时间戳的同名文件是客户端留下的历史会话。
9
+ * 文件结构:{ account: { uid, nickname, phoneNumber, enterpriseId… }, auth: { accessToken, refreshToken, expiresAt, domain… }, accounts: [...] }
10
+ *
11
+ * 切换账号:原子写入会话文件 + 删除同目录的 "<文件>.logged-out" 登出标记;
12
+ * WorkBuddy 监听该文件,外部改动会被直接应用(客户端日志 "External change applied to session")。
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { inspectToken } from './workbuddyClient.js';
20
+
21
+ export function workbuddyAuthDir() {
22
+ const home = os.homedir();
23
+ const tail = ['CodeBuddyExtension', 'Data', 'Public', 'auth'];
24
+ if (process.platform === 'win32') {
25
+ return path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), ...tail);
26
+ }
27
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', ...tail);
28
+ return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), ...tail);
29
+ }
30
+
31
+ const CURRENT_RE = /^[a-z0-9-]+\.info$/i; // workbuddy-desktop.info
32
+ const HISTORY_RE = /^[a-z0-9-]+\.\d{4}-.*\.info$/i; // workbuddy-desktop.<时间戳>.<pid>.<uuid>.info
33
+
34
+ /** 会话 JSON → 账号记录(供 addAccount),不含网络请求 */
35
+ export function sessionToAccount(session, source) {
36
+ const token = session?.auth?.accessToken;
37
+ if (typeof token !== 'string' || !token) return null;
38
+ const info = inspectToken(token);
39
+ if (!info.provider) return null;
40
+ const acc = session.account || {};
41
+ const auth = session.auth || {};
42
+ const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : null);
43
+ // 除 token 外的会话字段原样保留,切换账号时用来还原会话文件
44
+ const { accessToken: _a, refreshToken: _r, ...authRest } = auth;
45
+ return {
46
+ provider: info.provider,
47
+ token,
48
+ refreshToken: auth.refreshToken || null,
49
+ expiresAt: iso(auth.expiresAt) || info.expiresAt,
50
+ uid: acc.uid || info.uid,
51
+ name: acc.nickname || acc.name || null,
52
+ email: acc.email || null,
53
+ source,
54
+ meta: {
55
+ domain: auth.domain || info.host,
56
+ enterpriseId: acc.enterpriseId || null,
57
+ phone: acc.phoneNumber || null,
58
+ refreshExpiresAt: iso(auth.refreshExpiresAt),
59
+ session: {
60
+ account: acc, auth: authRest, accounts: Array.isArray(session.accounts) ? session.accounts : [],
61
+ ...(Array.isArray(session.allAccounts) ? { allAccounts: session.allAccounts } : {}),
62
+ },
63
+ },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * 读取本机 WorkBuddy 会话:当前会话 + 历史会话(按 uid 去重,保留最新)。
69
+ * @returns {{ dir, accounts: Array<{...账号记录, current: boolean, file, fileTime}>, errors }}
70
+ */
71
+ export function readWorkbuddySessions() {
72
+ const dir = workbuddyAuthDir();
73
+ const out = { dir, accounts: [], errors: [] };
74
+ let names = [];
75
+ try { names = fs.readdirSync(dir); } catch { return out; }
76
+ const byUid = new Map();
77
+ for (const name of names) {
78
+ if (!CURRENT_RE.test(name) && !HISTORY_RE.test(name)) continue;
79
+ const file = path.join(dir, name);
80
+ try {
81
+ const current = !HISTORY_RE.test(name);
82
+ if (current && fs.existsSync(`${file}.logged-out`)) continue; // 已登出
83
+ const rec = sessionToAccount(JSON.parse(fs.readFileSync(file, 'utf8')), current ? 'WorkBuddy 当前登录' : 'WorkBuddy 历史会话');
84
+ if (!rec) continue;
85
+ const fileTime = fs.statSync(file).mtimeMs;
86
+ const item = { ...rec, current, file, fileTime };
87
+ const prev = byUid.get(rec.uid);
88
+ if (!prev || (current && !prev.current) || (current === prev.current && fileTime > prev.fileTime)) byUid.set(rec.uid, item);
89
+ } catch (e) {
90
+ out.errors.push({ file, error: e.message });
91
+ }
92
+ }
93
+ out.accounts = [...byUid.values()].sort((a, b) => b.current - a.current || b.fileTime - a.fileTime);
94
+ return out;
95
+ }
96
+
97
+ /** 当前 WorkBuddy 客户端登录的 uid(未登录返回 null) */
98
+ export function currentWorkbuddyUid() {
99
+ return readWorkbuddySessions().accounts.find((a) => a.current)?.uid || null;
100
+ }
101
+
102
+ /**
103
+ * 把账号写成 WorkBuddy 当前会话(切换账号)。需要账号带有导入时保存的会话信息。
104
+ * @returns {{ file, previousUid }}
105
+ */
106
+ export function writeWorkbuddySession(account) {
107
+ const s = account.meta?.session;
108
+ if (!s?.account) throw new Error('该账号缺少 WorkBuddy 会话信息,请从本机重新导入后再切换');
109
+ const dir = workbuddyAuthDir();
110
+ fs.mkdirSync(dir, { recursive: true });
111
+ const file = path.join(dir, 'workbuddy-desktop.info');
112
+ let previousUid = null;
113
+ try { previousUid = JSON.parse(fs.readFileSync(file, 'utf8'))?.account?.uid || null; } catch {}
114
+
115
+ const ms = (iso) => (iso ? new Date(iso).getTime() : undefined);
116
+ const session = {
117
+ account: s.account,
118
+ auth: {
119
+ ...s.auth,
120
+ accessToken: account.token,
121
+ refreshToken: account.refreshToken || s.auth?.refreshToken,
122
+ expiresAt: ms(account.expiresAt) ?? s.auth?.expiresAt,
123
+ lastRefreshTime: Date.now(),
124
+ },
125
+ accounts: s.accounts || [],
126
+ ...(Array.isArray(s.allAccounts) ? { allAccounts: s.allAccounts } : {}),
127
+ };
128
+ const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
129
+ fs.writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 0o600 });
130
+ fs.renameSync(tmp, file);
131
+ try { fs.unlinkSync(`${file}.logged-out`); } catch {}
132
+ return { file, previousUid };
133
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ZCode 浏览器登录 — 与 ZCode 客户端的「CLI 轮询登录」同一协议(无需 zcode:// 回调,任何浏览器都能用):
3
+ *
4
+ * 1. POST https://zcode.z.ai/api/v1/oauth/cli/init Authorization: Bearer <本地随机 poll token>
5
+ * {provider: 'bigmodel' | 'zai'} → {flow_id, authorize_url, expires_at, poll_interval_sec}
6
+ * authorize_url 的回调指向 zcode.z.ai 自己的 /oauth/cli/callback,由服务端完成换 token
7
+ * 2. 用户在浏览器(桌面版为隐私窗口)打开 authorize_url 登录授权
8
+ * 3. GET /api/v1/oauth/cli/poll/{flow_id}(同一 poll token)→ status pending / ready / failed
9
+ * ready 时带 token(zcode JWT)、user、bigmodel{access_token, refresh_token} 或 zai{access_token}
10
+ * 4. Z.ai 的 access_token 还要换一次业务 token:POST https://api.z.ai/api/auth/z/login {token}
11
+ *
12
+ * 拿到的凭据按客户端 credentials.json 的格式(enc:v1 加密)组装,存进账号 meta,
13
+ * 之后与「本机导入」的账号完全一样:可查额度、可一键切换到 ZCode 客户端。
14
+ */
15
+
16
+ import crypto from 'node:crypto';
17
+ import * as zc from './zcrypto.js';
18
+ import { zcodePaths } from './zcodeLocal.js';
19
+ import { FETCH_TIMEOUT_MS } from './constants.js';
20
+
21
+ const API_BASE = 'https://zcode.z.ai';
22
+ const ZAI_BUSINESS_LOGIN_URL = 'https://api.z.ai/api/auth/z/login';
23
+
24
+ async function fetchJson(url, init = {}) {
25
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
26
+ const text = await res.text();
27
+ let body = null;
28
+ try { body = text ? JSON.parse(text) : null; } catch {}
29
+ return { res, body };
30
+ }
31
+
32
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
33
+
34
+ /** kind: 'zcode-bigmodel'(智谱 BigModel)| 'zcode-zai'(Z.ai 国际) */
35
+ export async function startZcodeLogin(kind) {
36
+ const provider = kind === 'zcode-zai' ? 'zai' : 'bigmodel';
37
+ const pollToken = crypto.randomBytes(32).toString('hex');
38
+ const { res, body } = await fetchJson(API_BASE + '/api/v1/oauth/cli/init', {
39
+ method: 'POST',
40
+ headers: { Authorization: 'Bearer ' + pollToken, 'Content-Type': 'application/json', Accept: 'application/json' },
41
+ body: JSON.stringify({ provider }),
42
+ });
43
+ const d = body?.data;
44
+ if (!res.ok || body?.code !== 0 || !str(d?.flow_id) || !str(d?.authorize_url)) {
45
+ throw new Error('ZCode 登录初始化失败:' + (body?.msg || 'HTTP ' + res.status));
46
+ }
47
+ if (!d.authorize_url.startsWith('https://')) throw new Error('ZCode 登录初始化返回了非 https 授权地址');
48
+ const expiresInMs = Number.isFinite(d.expires_at) ? d.expires_at * 1000 - Date.now() : 5 * 60e3;
49
+ return {
50
+ url: d.authorize_url,
51
+ expiresInMs: Math.max(60e3, Math.min(expiresInMs, 15 * 60e3)),
52
+ intervalMs: Math.max(1000, (Number(d.poll_interval_sec) || 2) * 1000),
53
+ data: { provider, flowId: d.flow_id, pollToken },
54
+ };
55
+ }
56
+
57
+ /** 单次轮询:{status:'pending'} | {status:'ok', input};失败抛错 */
58
+ export async function pollZcodeLogin(data) {
59
+ const { res, body } = await fetchJson(API_BASE + '/api/v1/oauth/cli/poll/' + encodeURIComponent(data.flowId), {
60
+ headers: { Authorization: 'Bearer ' + data.pollToken, Accept: 'application/json' },
61
+ });
62
+ if (res.status >= 500 || res.status === 408 || res.status === 429) return { status: 'pending' };
63
+ if (!res.ok || body?.code !== 0) throw new Error('ZCode 授权失败:' + (body?.msg || 'HTTP ' + res.status));
64
+ const d = body.data || {};
65
+ if (d.status === 'pending') return { status: 'pending' };
66
+ if (d.status === 'failed') throw new Error('ZCode 授权失败(用户取消或授权被拒绝)');
67
+ if (d.status !== 'ready') throw new Error('ZCode 授权返回未知状态:' + d.status);
68
+
69
+ const jwt = str(d.token);
70
+ const user = d.user && typeof d.user === 'object' ? d.user : {};
71
+ const userId = user.user_id !== undefined && user.user_id !== null ? String(user.user_id) : null;
72
+ let accessToken = data.provider === 'zai' ? str(d.zai?.access_token) : str(d.bigmodel?.access_token) || str(d.bigmodel?.accessToken);
73
+ const refreshToken = data.provider === 'bigmodel' ? str(d.bigmodel?.refresh_token) || str(d.bigmodel?.refreshToken) : null;
74
+ if (!jwt || !accessToken || !userId) throw new Error('ZCode 授权返回缺少 token / 用户信息(上游结构可能变化)');
75
+ if (data.provider === 'zai') accessToken = await resolveZaiBusinessToken(accessToken);
76
+
77
+ return { status: 'ok', input: buildAccountInput({ provider: data.provider, jwt, accessToken, refreshToken, user, userId }) };
78
+ }
79
+
80
+ /** Z.ai OAuth token → 业务 access token(与客户端 ZaiBusinessTokenResolver 一致) */
81
+ async function resolveZaiBusinessToken(oauthToken) {
82
+ const { res, body } = await fetchJson(ZAI_BUSINESS_LOGIN_URL, {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
85
+ body: JSON.stringify({ token: oauthToken }),
86
+ });
87
+ const ok = res.ok && (body?.code === undefined || body.code === 0 || body.code === 200) && body?.success !== false;
88
+ const t = str(body?.data?.access_token) || str(body?.data?.accessToken);
89
+ if (!ok || !t) throw new Error('Z.ai 业务 token 交换失败:' + (body?.msg || 'HTTP ' + res.status));
90
+ return t;
91
+ }
92
+
93
+ /** 按 ZCode credentials.json 的键名组装并用本机密钥加密(与客户端写出的格式一致) */
94
+ export function buildCredentials({ provider, jwt, accessToken, refreshToken, user, userId }, secret) {
95
+ const label = str(user.name) || str(user.email) || userId;
96
+ const userInfo = {
97
+ id: userId, username: label, displayName: label,
98
+ ...(str(user.avatar) ? { avatarUrl: user.avatar } : {}),
99
+ rawProfile: user,
100
+ };
101
+ const enc = (v) => zc.encryptWithSecret(v, secret);
102
+ const creds = {
103
+ 'oauth:active_provider': enc(provider),
104
+ [`oauth:${provider}:access_token`]: enc(accessToken),
105
+ [`oauth:${provider}:user_info`]: enc(JSON.stringify(userInfo)),
106
+ zcodejwttoken: enc(jwt),
107
+ };
108
+ if (refreshToken) creds[`oauth:${provider}:refresh_token`] = enc(refreshToken);
109
+ return creds;
110
+ }
111
+
112
+ function buildAccountInput(parts) {
113
+ const secret = zc.defaultSecret(zcodePaths().home);
114
+ const credentials = buildCredentials(parts, secret);
115
+ const id = zc.identityWithSecret(credentials, secret);
116
+ return {
117
+ provider: 'zcode',
118
+ token: `zcode-creds:${parts.userId}`, // 与本机导入同一标记,同一用户自动去重 / 续期
119
+ uid: parts.userId,
120
+ name: zc.identityLabel(id),
121
+ email: str(parts.user.email),
122
+ source: 'browser',
123
+ // 不带 config / deviceMid:同一账号再次登录时保留已保存的客户端配置与虚拟设备 ID
124
+ meta: {
125
+ credentials,
126
+ canonicalHash: zc.canonicalHash(credentials),
127
+ capturedAt: new Date().toISOString(),
128
+ loginProvider: parts.provider,
129
+ },
130
+ };
131
+ }