creditdaddy 1.6.2 → 1.6.5
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 +1 -1
- package/bin/creditdaddy.js +22 -0
- package/build.json +3 -3
- package/package.json +2 -2
- package/src/checkin.js +4 -0
- package/src/constants.js +3 -1
- package/src/cookieSites.js +8 -1
- package/src/daemon.js +2251 -2062
- package/src/doubaoClient.js +0 -1
- package/src/i18n/en.json +94 -1
- package/src/i18n/ja.json +94 -1
- package/src/i18n/ko.json +94 -1
- package/src/i18n/zh-TW.json +94 -1
- package/src/loomyAuth.js +230 -0
- package/src/loomyClient.js +306 -0
- package/src/loomyLocal.js +188 -0
- package/src/minimaxGateway.js +61 -11
- package/src/panel.html +350 -59
- package/src/providers.js +9 -0
- package/src/qoderClient.js +15 -6
- package/src/quotaRefresh.js +5 -0
- package/src/store.js +9 -2
- package/src/tenrouter.js +37 -14
- package/src/tenrouterAccounts.js +1 -0
- package/src/traeClient.js +18 -5
- package/src/traeGateway.js +69 -13
- package/src/transfer.js +3 -0
- package/src/usageSync.js +11 -1
- package/src/zcodeAutoClaim.js +15 -2
- package/src/zcodeClient.js +40 -7
- package/src/zcodeGateway.js +32 -2
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 科大讯飞 Loomy 桌面客户端本机凭据读取与多账号切换。
|
|
3
|
+
*
|
|
4
|
+
* 凭据存储(Windows):
|
|
5
|
+
* C:\Users\Public\Loomy\<UserHash>\userData\auth-session.json
|
|
6
|
+
* UserHash = sha256(os.userInfo().username).slice(0, 12)
|
|
7
|
+
* 结构:{ session, userid, phone, updatedAt }
|
|
8
|
+
*
|
|
9
|
+
* 账号切换:Loomy 启动时读取该文件恢复登录,写入目标账号的 session/userid/phone
|
|
10
|
+
* 并重启客户端即完成换号。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
const EXE_NAME = 'Loomy.exe';
|
|
20
|
+
|
|
21
|
+
function userHash() {
|
|
22
|
+
const username = os.userInfo().username;
|
|
23
|
+
return crypto.createHash('sha256').update(username).digest('hex').slice(0, 12);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function credentialFile() {
|
|
27
|
+
if (process.env.LOOMY_CREDENTIAL_FILE) return process.env.LOOMY_CREDENTIAL_FILE;
|
|
28
|
+
if (process.platform !== 'win32') {
|
|
29
|
+
return path.join(os.homedir(), '.config', 'loomy', 'userData', 'auth-session.json');
|
|
30
|
+
}
|
|
31
|
+
return path.join('C:', 'Users', 'Public', 'Loomy', userHash(), 'userData', 'auth-session.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJson(file) {
|
|
35
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 读取本机当前登录的 Loomy 凭据 → { session, userid, phone, updatedAt };未登录返回 null */
|
|
39
|
+
export function readCredential() {
|
|
40
|
+
const file = credentialFile();
|
|
41
|
+
const j = readJson(file);
|
|
42
|
+
if (!j || !j.session || !j.userid) return null;
|
|
43
|
+
return {
|
|
44
|
+
session: String(j.session).trim(),
|
|
45
|
+
userid: String(j.userid).trim(),
|
|
46
|
+
phone: String(j.phone || '').trim(),
|
|
47
|
+
updatedAt: j.updatedAt || null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 写回凭据文件(用于换号) */
|
|
52
|
+
export function writeCredential({ session, userid, phone = '' }) {
|
|
53
|
+
const file = credentialFile();
|
|
54
|
+
const existing = readJson(file) || {};
|
|
55
|
+
const next = {
|
|
56
|
+
...existing,
|
|
57
|
+
session: String(session).trim(),
|
|
58
|
+
userid: String(userid).trim(),
|
|
59
|
+
phone: String(phone || '').trim(),
|
|
60
|
+
updatedAt: Date.now(),
|
|
61
|
+
};
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
const tmp = `${file}.tmp-${crypto.randomUUID()}`;
|
|
64
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
65
|
+
fs.renameSync(tmp, file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 探测 Loomy 是否在运行 */
|
|
69
|
+
export function loomyRunning() {
|
|
70
|
+
if (process.platform !== 'win32') return false;
|
|
71
|
+
try {
|
|
72
|
+
const out = execFileSync('tasklist.exe',
|
|
73
|
+
['/FI', `IMAGENAME eq ${EXE_NAME}`, '/NH'],
|
|
74
|
+
{ windowsHide: true, encoding: 'utf8', timeout: 8000 });
|
|
75
|
+
return out.toLowerCase().includes(EXE_NAME.toLowerCase()) && !/no tasks|没有找到|没有任务/i.test(out);
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 获取本机 Loomy.exe 路径 */
|
|
80
|
+
export function candidateClientPath() {
|
|
81
|
+
if (process.platform !== 'win32') return null;
|
|
82
|
+
const candidates = [
|
|
83
|
+
'C:\\Program Files\\Loomy\\Loomy.exe',
|
|
84
|
+
'C:\\Program Files (x86)\\Loomy\\Loomy.exe',
|
|
85
|
+
path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Loomy', 'Loomy.exe'),
|
|
86
|
+
];
|
|
87
|
+
return candidates.find((p) => fs.existsSync(p)) || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 本机 Loomy 综合探测 */
|
|
91
|
+
export function detectLoomy() {
|
|
92
|
+
const file = credentialFile();
|
|
93
|
+
const cred = readCredential();
|
|
94
|
+
const clientPath = candidateClientPath();
|
|
95
|
+
return {
|
|
96
|
+
dataDir: path.dirname(file),
|
|
97
|
+
exists: fs.existsSync(file),
|
|
98
|
+
signedIn: Boolean(cred),
|
|
99
|
+
clientInstalled: Boolean(clientPath),
|
|
100
|
+
clientPath,
|
|
101
|
+
running: loomyRunning(),
|
|
102
|
+
credential: cred ? {
|
|
103
|
+
userid: cred.userid,
|
|
104
|
+
phone: cred.phone ? `${cred.phone.slice(0, 3)}****${cred.phone.slice(-4)}` : null,
|
|
105
|
+
updatedAt: cred.updatedAt,
|
|
106
|
+
} : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
111
|
+
|
|
112
|
+
export function terminateLoomy({ timeoutMs = 8000 } = {}) {
|
|
113
|
+
if (process.platform !== 'win32') return { closed: false, supported: false };
|
|
114
|
+
if (!loomyRunning()) return { closed: false, running: false };
|
|
115
|
+
try { execFileSync('taskkill.exe', ['/IM', EXE_NAME, '/T'], { windowsHide: true, stdio: 'ignore', timeout: 15_000 }); } catch {}
|
|
116
|
+
let deadline = Date.now() + timeoutMs;
|
|
117
|
+
while (Date.now() < deadline && loomyRunning()) nap(250);
|
|
118
|
+
if (loomyRunning()) {
|
|
119
|
+
try { execFileSync('taskkill.exe', ['/IM', EXE_NAME, '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: 15_000 }); } catch {}
|
|
120
|
+
deadline = Date.now() + 3000;
|
|
121
|
+
while (Date.now() < deadline && loomyRunning()) nap(250);
|
|
122
|
+
}
|
|
123
|
+
return { closed: !loomyRunning(), running: loomyRunning() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 当前客户端登录的用户 ID(面板「客户端当前」徽章按 uid 比对——publicAccount 不下发 token,面板侧只能比 uid) */
|
|
127
|
+
export function currentLoomyUid() {
|
|
128
|
+
try { return readCredential()?.userid || null; } catch { return null; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 读取 Loomy 客户端当前登录,返回标准化账号对象(供 addAccount / 本机导入)。
|
|
133
|
+
*/
|
|
134
|
+
export async function liveToAccount() {
|
|
135
|
+
const cred = readCredential();
|
|
136
|
+
if (!cred) return null;
|
|
137
|
+
const phoneMasked = cred.phone ? `${cred.phone.slice(0, 3)}****${cred.phone.slice(-4)}` : null;
|
|
138
|
+
return {
|
|
139
|
+
provider: 'loomy',
|
|
140
|
+
token: cred.session,
|
|
141
|
+
refreshToken: null,
|
|
142
|
+
uid: cred.userid,
|
|
143
|
+
name: phoneMasked ? `Loomy (${phoneMasked})` : `Loomy (${cred.userid.slice(-6)})`,
|
|
144
|
+
expiresAt: null,
|
|
145
|
+
source: 'local-app',
|
|
146
|
+
meta: {
|
|
147
|
+
phone: cred.phone,
|
|
148
|
+
capturedAt: new Date().toISOString(),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const napAsync = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 硬切换:客户端退出清理会把内存里的旧登录写回 auth-session.json(迟到写入者——
|
|
157
|
+
* 「客户端没跑就直接写」的温和路径实测会被它回写冲掉,表现为「切了没生效」)。
|
|
158
|
+
* 统一走:先杀客户端 → 写凭据 → 延时复核 → 发现被回写冲掉就再补写一次 → 报告是否落定。
|
|
159
|
+
* 客户端重新打开时 restore() 无校验直接应用文件里的 session,落定即换号成功。
|
|
160
|
+
* 注意:Loomy 客户端界面登录态真源在其渲染层 localStorage(启动时反向推给主进程),
|
|
161
|
+
* 本函数落定的是主进程/opencode 侧的凭据;界面换号仍需在客户端内退出重登。
|
|
162
|
+
*/
|
|
163
|
+
export async function hardSwitchLoomy(account) {
|
|
164
|
+
if (!account?.token) throw new Error('目标账号缺少 session token');
|
|
165
|
+
const cred = readCredential();
|
|
166
|
+
// 凭据文件缺失(未装过客户端 / 用户在客户端里退出登录把它清空)不该挡切换——
|
|
167
|
+
// 目标账号的 session/userid/phone 在账号库里齐全,直接写就是
|
|
168
|
+
const userid = account.uid || cred?.userid;
|
|
169
|
+
if (!userid) throw new Error('目标账号缺少 userid,无法写入 Loomy 凭据(请重新登录该账号后再切换)');
|
|
170
|
+
if (cred && account.token === cred.session) {
|
|
171
|
+
return { switched: false, alreadyActive: true, closedClient: false, verified: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const wasRunning = loomyRunning();
|
|
175
|
+
const t = terminateLoomy();
|
|
176
|
+
const closedClient = wasRunning && t.running === false;
|
|
177
|
+
|
|
178
|
+
const next = { session: account.token, userid, phone: account.meta?.phone || '' };
|
|
179
|
+
writeCredential(next);
|
|
180
|
+
await napAsync(1200);
|
|
181
|
+
let verified = readCredential()?.session === account.token;
|
|
182
|
+
if (!verified) {
|
|
183
|
+
writeCredential(next);
|
|
184
|
+
await napAsync(1200);
|
|
185
|
+
verified = readCredential()?.session === account.token;
|
|
186
|
+
}
|
|
187
|
+
return { switched: true, alreadyActive: false, closedClient, verified };
|
|
188
|
+
}
|
package/src/minimaxGateway.js
CHANGED
|
@@ -54,6 +54,8 @@ const coolingStreak = new Map();
|
|
|
54
54
|
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/刷新,自动复活)
|
|
55
55
|
const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }(查询失败时保留旧快照)
|
|
56
56
|
const WEIGHT_TTL_MS = 10 * 60_000; // 权重超过该时长重新查询额度,让用量变化能反映到分流上
|
|
57
|
+
const WEIGHT_REFRESH_CAP_MS = 2_000; // 请求路径等新权重的上限;超时先用旧快照,刷新转后台
|
|
58
|
+
const weightInflight = new Map(); // accountId → 刷新中 Promise(并发请求共用一次查询)
|
|
57
59
|
|
|
58
60
|
export const stats = {
|
|
59
61
|
lastCallAt: null,
|
|
@@ -69,6 +71,15 @@ function credFingerprint(a) {
|
|
|
69
71
|
return `${a.token || ''}|${a.refreshToken || ''}`;
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
/** 账号被删除时清掉它在各轮换 Map 里的残留(daemon DELETE /api/accounts/:id 调用) */
|
|
75
|
+
export function forgetAccount(id) {
|
|
76
|
+
cooling.delete(id);
|
|
77
|
+
coolingStreak.delete(id);
|
|
78
|
+
dead.delete(id);
|
|
79
|
+
weights.delete(id);
|
|
80
|
+
if (stickyId === id) stickyId = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
function markCooling(id, ms = ACCOUNT_COOLING_MS) {
|
|
73
84
|
cooling.set(id, Date.now() + ms);
|
|
74
85
|
}
|
|
@@ -121,6 +132,7 @@ export function __resetForTests() {
|
|
|
121
132
|
coolingStreak.clear();
|
|
122
133
|
dead.clear();
|
|
123
134
|
weights.clear();
|
|
135
|
+
weightInflight.clear();
|
|
124
136
|
stickyId = null;
|
|
125
137
|
stats.lastCallAt = null;
|
|
126
138
|
stats.calls = 0;
|
|
@@ -132,15 +144,40 @@ export function __resetForTests() {
|
|
|
132
144
|
async function accountWeight(a) {
|
|
133
145
|
const hit = weights.get(a.id);
|
|
134
146
|
if (hit && Date.now() - hit.at < WEIGHT_TTL_MS) return hit.w;
|
|
135
|
-
|
|
147
|
+
const fallback = hit?.w ?? MIN_WEIGHT;
|
|
148
|
+
// 请求路径只等一个很短的上限:额度接口健康时照常拿到新权重精准选主力;
|
|
149
|
+
// 上游黑洞时最多等这么久就先用旧快照——刷新仍在后台继续,下一个请求就有新值。
|
|
150
|
+
// 此前是完整等待额度查询且逐号串行,一个黑洞的上游能让权重过期后的
|
|
151
|
+
// 第一个补全请求卡 1-2 分钟(客户端还挂着已连接的 SSE 请求)
|
|
152
|
+
const refresh = weightRefresh(a);
|
|
153
|
+
let timer;
|
|
154
|
+
const capped = new Promise((r) => { timer = setTimeout(() => r(fallback), WEIGHT_REFRESH_CAP_MS); });
|
|
136
155
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// 额度查询失败时保留上次快照权重:一次网络抖动不该把主力账号踢成保底权重、引发主力来回切换
|
|
156
|
+
return await Promise.race([refresh, capped]);
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
141
159
|
}
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 去重后的权重刷新:并发请求共用同一次额度查询;查询失败保留旧快照权重(时间戳仍刷新,避免每请求都撞同一坏接口) */
|
|
163
|
+
function weightRefresh(a) {
|
|
164
|
+
const existing = weightInflight.get(a.id);
|
|
165
|
+
if (existing) return existing;
|
|
166
|
+
const run = (async () => {
|
|
167
|
+
const hit = weights.get(a.id);
|
|
168
|
+
let w = hit?.w ?? MIN_WEIGHT;
|
|
169
|
+
try {
|
|
170
|
+
const q = await fetchMiniMaxQuota(a, refreshContext(a, (m) => logger.info("MINIMAX-GW", t('{label} {msg}', { label: a.name || a.uid || a.id, msg: m }))));
|
|
171
|
+
w = Math.max(MIN_WEIGHT, Number(q?.remaining) || 0);
|
|
172
|
+
} catch {
|
|
173
|
+
// 额度查询失败时保留上次快照权重:一次网络抖动不该把主力账号踢成保底权重、引发主力来回切换
|
|
174
|
+
}
|
|
175
|
+
weights.set(a.id, { w, at: Date.now() });
|
|
176
|
+
return w;
|
|
177
|
+
})();
|
|
178
|
+
weightInflight.set(a.id, run);
|
|
179
|
+
run.finally(() => { if (weightInflight.get(a.id) === run) weightInflight.delete(a.id); }).catch(() => {});
|
|
180
|
+
return run;
|
|
144
181
|
}
|
|
145
182
|
|
|
146
183
|
// ── 粘性主力选型 ──
|
|
@@ -158,13 +195,16 @@ const STICKY_MARGIN_ABS = 50; // 且绝对差超过 50 积分
|
|
|
158
195
|
async function rotationQueue() {
|
|
159
196
|
const all = (await loadAccounts()).filter((a) => a.provider === 'minimax');
|
|
160
197
|
const now = Date.now();
|
|
161
|
-
const
|
|
198
|
+
const candidates = [];
|
|
162
199
|
for (const a of all) {
|
|
163
200
|
if (isDead(a)) continue;
|
|
164
201
|
if ((cooling.get(a.id) || 0) > now) continue;
|
|
165
202
|
if (!a.token) continue;
|
|
166
|
-
|
|
203
|
+
candidates.push(a);
|
|
167
204
|
}
|
|
205
|
+
// 权重并行取(各自 2s 封顶),不再逐号串行等
|
|
206
|
+
const ws = await Promise.all(candidates.map((a) => accountWeight(a)));
|
|
207
|
+
const ready = candidates.map((a, i) => ({ account: a, weight: ws[i] }));
|
|
168
208
|
if (!ready.length) return [];
|
|
169
209
|
|
|
170
210
|
ready.sort((x, y) => y.weight - x.weight);
|
|
@@ -296,6 +336,8 @@ export async function handleGateway(req, res) {
|
|
|
296
336
|
const attempted = [];
|
|
297
337
|
const clientAbort = new AbortController();
|
|
298
338
|
req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
339
|
+
// 优雅断开(客户端 fetch abort 在上游响应前)只触发 res 'close',不触发 req 'close'
|
|
340
|
+
res.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
299
341
|
|
|
300
342
|
for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length); i++) {
|
|
301
343
|
const account = queue[i];
|
|
@@ -376,8 +418,16 @@ export async function handleGateway(req, res) {
|
|
|
376
418
|
}
|
|
377
419
|
}
|
|
378
420
|
} catch (e) {
|
|
379
|
-
|
|
380
|
-
|
|
421
|
+
if (clientAbort.signal.aborted) {
|
|
422
|
+
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
423
|
+
logger.debug('MINIMAX-GW', '流式转发中断:{err}', { err: e.message });
|
|
424
|
+
} else {
|
|
425
|
+
// 上游中途断了:补一帧 Anthropic error 事件再收尾,别让客户端只见裸 EOF
|
|
426
|
+
logger.warn('MINIMAX-GW', '上游流中断:{err}', { err: e.message });
|
|
427
|
+
try {
|
|
428
|
+
res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'api_error', message: `上游流中断:${e.message}` } })}\n\n`);
|
|
429
|
+
} catch { /* 客户端已不在 */ }
|
|
430
|
+
}
|
|
381
431
|
}
|
|
382
432
|
res.end();
|
|
383
433
|
} else {
|