creditdaddy 1.2.0 → 1.3.2
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 +40 -10
- package/bin/creditdaddy.js +131 -2
- package/package.json +29 -29
- package/src/accounts.js +18 -7
- package/src/authDevice.js +3 -0
- package/src/bgdaemon.js +332 -0
- package/src/catpawClient.js +103 -103
- package/src/catpawLocal.js +229 -229
- package/src/checkin.js +10 -4
- package/src/constants.js +4 -2
- package/src/daemon.js +137 -13
- package/src/localDetect.js +147 -147
- package/src/logger.js +104 -6
- package/src/minimaxAuth.js +127 -0
- package/src/minimaxClient.js +339 -0
- package/src/minimaxGateway.js +410 -0
- package/src/minimaxLocal.js +259 -0
- package/src/mirasimClient.js +189 -189
- package/src/panel.html +459 -36
- package/src/providers.js +26 -0
- package/src/qoderApp.js +3 -9
- package/src/qoderClient.js +237 -237
- package/src/qoderUmid.js +198 -198
- package/src/store.js +65 -7
- package/src/tenrouter.js +8 -0
- package/src/tenrouterAccounts.js +38 -3
- package/src/traeClient.js +6 -1
- package/src/traeGateway.js +679 -0
- package/src/transfer.js +24 -1
- package/src/usageSync.js +505 -505
- package/src/winProc.js +36 -0
- package/src/workbuddyLocal.js +133 -133
- package/src/zcodeAuth.js +131 -131
- package/src/zcodeAutoClaim.js +97 -97
- package/src/zcodeClient.js +93 -18
- package/src/zcodeGateway.js +104 -46
- package/src/zcodePlanShape.json +3 -3
- package/src/zcrypto.js +160 -160
package/src/logger.js
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
const RING_SIZE = 300;
|
|
4
4
|
const ring = [];
|
|
5
5
|
|
|
6
|
-
// ─── 按日期归档:logs/daemon-YYYY-MM-DD.log
|
|
6
|
+
// ─── 按日期归档:logs/daemon-YYYY-MM-DD.log(跨天自动换文件,历史自动 gzip) ───
|
|
7
7
|
import fsSync from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import os from 'node:os';
|
|
10
|
+
import zlib from 'node:zlib';
|
|
11
|
+
|
|
12
|
+
const ARCHIVE_RETENTION_DAYS = 7; // .log.gz 归档保留天数,更早的自动清理
|
|
10
13
|
|
|
11
14
|
let archiveDir = null;
|
|
12
15
|
try {
|
|
@@ -24,7 +27,15 @@ function archiveLine(line) {
|
|
|
24
27
|
try {
|
|
25
28
|
const day = line.at.slice(0, 10);
|
|
26
29
|
if (archiveDay !== day || !archiveStream) {
|
|
27
|
-
if (archiveStream)
|
|
30
|
+
if (archiveStream) {
|
|
31
|
+
// 跨天 / 重启:把上一份流关闭,待其落盘完成(close 事件)后再 gzip
|
|
32
|
+
const prev = archiveStream;
|
|
33
|
+
const prevFile = path.join(archiveDir, `daemon-${archiveDay}.log`);
|
|
34
|
+
archiveStream = null;
|
|
35
|
+
prev.on('error', () => {});
|
|
36
|
+
prev.on('close', () => gzipAndCleanup(prevFile));
|
|
37
|
+
prev.end();
|
|
38
|
+
}
|
|
28
39
|
archiveStream = fsSync.createWriteStream(path.join(archiveDir, `daemon-${day}.log`), { flags: 'a' });
|
|
29
40
|
archiveDay = day;
|
|
30
41
|
}
|
|
@@ -40,6 +51,48 @@ function ts() {
|
|
|
40
51
|
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
|
41
52
|
}
|
|
42
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 把一份已关闭的日志 gzip 成 .log.gz(此时流已 flush、fd 已释放,可安全同步读),并按保留期清理过期归档。
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ 压缩后**截断**原文件而不是删除它:同一天的文件可能被另一个实例(前台 daemon /
|
|
58
|
+
* 桌面版)以 O_APPEND 持有同一个 fd。删掉原文件后,POSIX 上它的后续写入会落进一个
|
|
59
|
+
* 已无链接的 inode —— 别的实例还在正常跑,日志却静默消失。截断成 0 字节后 O_APPEND
|
|
60
|
+
* 会从 0 重新追加,同一个 inode、同一批数据,谁都不会丢。
|
|
61
|
+
*/
|
|
62
|
+
function gzipAndCleanup(file) {
|
|
63
|
+
try {
|
|
64
|
+
if (fsSync.existsSync(file)) {
|
|
65
|
+
const raw = fsSync.readFileSync(file);
|
|
66
|
+
if (raw.length) {
|
|
67
|
+
fsSync.writeFileSync(file + '.gz', zlib.gzipSync(raw));
|
|
68
|
+
fsSync.truncateSync(file, 0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
cleanupArchive();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 清理超过保留期的 .log.gz 归档(以及残留的旧 .log),按文件名里的日期判断,不依赖 mtime */
|
|
76
|
+
function cleanupArchive() {
|
|
77
|
+
if (!archiveDir) return;
|
|
78
|
+
try {
|
|
79
|
+
// 保留期按「文件名日期距今 N 天」算:今天与最近 7 天的归档都保留
|
|
80
|
+
const cutoff = new Date();
|
|
81
|
+
cutoff.setDate(cutoff.getDate() - ARCHIVE_RETENTION_DAYS);
|
|
82
|
+
cutoff.setHours(0, 0, 0, 0);
|
|
83
|
+
for (const name of fsSync.readdirSync(archiveDir)) {
|
|
84
|
+
const m = /^daemon-(\d{4}-\d{2}-\d{2})\.log(\.gz)?$/.exec(name);
|
|
85
|
+
if (!m) continue; // 只动本 daemon 的归档文件,不碰目录里其他文件
|
|
86
|
+
const f = path.join(archiveDir, name);
|
|
87
|
+
let fileDay;
|
|
88
|
+
try { fileDay = new Date(m[1] + 'T00:00:00'); } catch { continue; }
|
|
89
|
+
if (fileDay.getTime() < cutoff.getTime()) {
|
|
90
|
+
try { fsSync.unlinkSync(f); } catch {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
|
|
43
96
|
export function log(level, tag, msg) {
|
|
44
97
|
const line = { at: ts(), level, tag, msg: String(msg) };
|
|
45
98
|
ring.push(line);
|
|
@@ -56,13 +109,58 @@ export const logger = {
|
|
|
56
109
|
debug: (tag, msg) => log('debug', tag, msg),
|
|
57
110
|
};
|
|
58
111
|
|
|
59
|
-
|
|
60
|
-
|
|
112
|
+
/**
|
|
113
|
+
* 把各账号的失败结论压缩成「失败类型×次数」摘要,写进“全部失败”日志——
|
|
114
|
+
* 网络抖动 / 验证码 / 额度 / 限流 / 拉黑 一眼可辨,不用再靠“有没有伴随 3012”去反推。
|
|
115
|
+
* 三处网关共用同一口径;未知错误归入「上游错误」。
|
|
116
|
+
*/
|
|
117
|
+
export function summarizeAttempts(attempted) {
|
|
118
|
+
if (!Array.isArray(attempted) || !attempted.length) return '';
|
|
119
|
+
const buckets = new Map();
|
|
120
|
+
for (const a of attempted) {
|
|
121
|
+
const err = String(a && a.error ? a.error : '未知错误');
|
|
122
|
+
let key;
|
|
123
|
+
if (a && a.captcha) key = '验证码被拒';
|
|
124
|
+
else if (/验证码/.test(err)) key = '验证码被拒';
|
|
125
|
+
else if (/拉黑|凭据失效|JWT|Token 彻底失效|401/.test(err)) key = '账号拉黑';
|
|
126
|
+
else if (/额度耗尽|额度不足|已打标/.test(err)) key = '额度耗尽';
|
|
127
|
+
else if (/额度/.test(err)) key = '额度瞬时拒绝';
|
|
128
|
+
else if (/429|限流/.test(err)) key = '429 限流';
|
|
129
|
+
else if (/超时|timeout|timed out|连接|ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|terminated|aborted|fetch failed|网络|建立事件流失败/.test(err)) key = '网络错误';
|
|
130
|
+
else key = '上游错误';
|
|
131
|
+
buckets.set(key, (buckets.get(key) || 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
return [...buckets.entries()].map(([k, n]) => `${k}×${n}`).join('、');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getLogs(limit = 100, tag = null) {
|
|
137
|
+
// tag=null 是面板「运行日志」:网关调用日志(*-GW)刷屏快、口径不同,
|
|
138
|
+
// 只在自己的标签页里展示,不混进运行日志;显式传 tag 的查询不受影响。
|
|
139
|
+
const lines = tag ? ring.filter((l) => l.tag === tag) : ring.filter((l) => !(l.tag || '').endsWith('-GW'));
|
|
140
|
+
return lines.slice(-limit);
|
|
61
141
|
}
|
|
62
142
|
|
|
63
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* 测试收尾 / 进程退出用:关闭当前归档流,落盘后 gzip 归档并清理过期文件。
|
|
145
|
+
*
|
|
146
|
+
* 返回 Promise,等流真正 close(gzip 已完成)才 resolve —— 优雅退出路径紧接着就要
|
|
147
|
+
* process.exit(),不等的话回调根本没机会跑,今天的日志既不落盘也不归档。
|
|
148
|
+
* 调用方不 await(测试里就是)也完全兼容,只是没人等它。
|
|
149
|
+
*/
|
|
64
150
|
export function closeArchiveStream() {
|
|
65
|
-
|
|
151
|
+
if (!archiveStream) return Promise.resolve();
|
|
152
|
+
const prev = archiveStream;
|
|
153
|
+
const prevFile = path.join(archiveDir, `daemon-${archiveDay}.log`);
|
|
66
154
|
archiveStream = null;
|
|
67
155
|
archiveDay = null;
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
let settled = false;
|
|
158
|
+
const done = () => { if (!settled) { settled = true; resolve(); } };
|
|
159
|
+
prev.on('error', done);
|
|
160
|
+
prev.on('close', () => { gzipAndCleanup(prevFile); done(); });
|
|
161
|
+
// 兜底:万一 close 事件不来(fd 被外部占住),不让退出流程永远挂在这里
|
|
162
|
+
const timer = setTimeout(done, 2000);
|
|
163
|
+
timer.unref?.();
|
|
164
|
+
prev.end(done);
|
|
165
|
+
});
|
|
68
166
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MiniMax Code 浏览器登录 — 官方 OAuth 设备码授权(RFC 8628 + S256 PKCE),
|
|
3
|
+
* 与客户端「扫码登录」同一协议,任何浏览器 / 桌面版隐私窗口都能完成,无需 zcode:// 式本地回调。
|
|
4
|
+
*
|
|
5
|
+
* 1. POST https://account.minimax.cn/oauth2/device/code
|
|
6
|
+
* {client_id:mcode-public, scope:agent.default, audience:agent-backend,
|
|
7
|
+
* code_challenge:<S256>, code_challenge_method:S256}
|
|
8
|
+
* → {device_code, user_code, verification_uri_complete, expires_in:300, interval:3}
|
|
9
|
+
* 2. 用户在浏览器打开 verification_uri_complete(account.minimax.cn/oauth-authorize?user_code=…)登录授权
|
|
10
|
+
* 3. POST https://account.minimax.cn/oauth2/token
|
|
11
|
+
* {grant_type:urn:ietf:params:oauth:grant-type:device_code, device_code, client_id, code_verifier}
|
|
12
|
+
* → 未授权时 400 authorization_pending / slow_down(继续等);授权后 200
|
|
13
|
+
* {access_token, refresh_token, expires_in, …}
|
|
14
|
+
*
|
|
15
|
+
* 与「本机导入」的关键区别:设备码登录拿到的是**独立 loginEpoch** 的一条新 refresh token 链,
|
|
16
|
+
* 不与本机 ~/.minimax 客户端共用凭据,因此 CreditDaddy 自行刷新不会作废客户端的 token(消除轮换互斥)。
|
|
17
|
+
* 故本流程**绝不回写** auth.json,device 账号自持凭据链。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
22
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
23
|
+
import { MINIMAX_ACCOUNT_BASE, fetchMiniMaxProfile } from './minimaxClient.js';
|
|
24
|
+
|
|
25
|
+
const DEVICE_CODE_URL = `${MINIMAX_ACCOUNT_BASE}/oauth2/device/code`;
|
|
26
|
+
const TOKEN_URL = `${MINIMAX_ACCOUNT_BASE}/oauth2/token`;
|
|
27
|
+
const CLIENT_ID = 'mcode-public';
|
|
28
|
+
const SCOPE = 'agent.default';
|
|
29
|
+
const AUDIENCE = 'agent-backend';
|
|
30
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
31
|
+
|
|
32
|
+
function base64Url(buf) {
|
|
33
|
+
return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function postForm(url, fields) {
|
|
37
|
+
const form = new URLSearchParams();
|
|
38
|
+
for (const [k, v] of Object.entries(fields)) form.set(k, v);
|
|
39
|
+
const res = await fetchJsonRace(url, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
42
|
+
body: form.toString(),
|
|
43
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
44
|
+
});
|
|
45
|
+
const text = await res.text().catch(() => '');
|
|
46
|
+
let body = null;
|
|
47
|
+
try { body = text ? JSON.parse(text) : null; } catch {}
|
|
48
|
+
return { res, body, text };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 发起设备码授权,返回浏览器授权地址与轮询参数 */
|
|
52
|
+
export async function startMiniMaxLogin() {
|
|
53
|
+
const verifier = base64Url(crypto.randomBytes(32));
|
|
54
|
+
const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest());
|
|
55
|
+
const { res, body, text } = await postForm(DEVICE_CODE_URL, {
|
|
56
|
+
client_id: CLIENT_ID, scope: SCOPE, audience: AUDIENCE,
|
|
57
|
+
code_challenge: challenge, code_challenge_method: 'S256',
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok || !body?.device_code || !body?.verification_uri_complete) {
|
|
60
|
+
throw new Error('MiniMax 设备码初始化失败:HTTP ' + res.status + (text ? ' ' + text.slice(0, 200) : ''));
|
|
61
|
+
}
|
|
62
|
+
const url = String(body.verification_uri_complete);
|
|
63
|
+
if (!url.startsWith('https://')) throw new Error('MiniMax 授权返回了非 https 地址');
|
|
64
|
+
return {
|
|
65
|
+
url,
|
|
66
|
+
expiresInMs: Math.max(60e3, (Number(body.expires_in) || 300) * 1000),
|
|
67
|
+
intervalMs: Math.max(2000, (Number(body.interval) || 3) * 1000),
|
|
68
|
+
data: { deviceCode: body.device_code, verifier, userCode: body.user_code || null },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 单次轮询:{status:'pending'} | {status:'ok', input}
|
|
74
|
+
* authorization_pending / slow_down 视为继续等待;其余错误抛出(终态失败)。
|
|
75
|
+
*/
|
|
76
|
+
export async function pollMiniMaxLogin(data) {
|
|
77
|
+
const { res, body, text } = await postForm(TOKEN_URL, {
|
|
78
|
+
grant_type: DEVICE_GRANT,
|
|
79
|
+
device_code: data.deviceCode,
|
|
80
|
+
client_id: CLIENT_ID,
|
|
81
|
+
code_verifier: data.verifier,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const errCode = body?.error;
|
|
85
|
+
if (res.status === 400 && (errCode === 'authorization_pending' || errCode === 'slow_down')) {
|
|
86
|
+
return { status: 'pending' };
|
|
87
|
+
}
|
|
88
|
+
if (errCode === 'expired_token') throw new Error('MiniMax 授权已超时,请重新发起');
|
|
89
|
+
if (errCode === 'access_denied') throw new Error('MiniMax 授权被拒绝');
|
|
90
|
+
if (!res.ok || !body?.access_token) {
|
|
91
|
+
throw new Error('MiniMax 授权失败:HTTP ' + res.status + (errCode ? ' ' + errCode : '') + (text ? ' ' + text.slice(0, 200) : ''));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const accessToken = String(body.access_token);
|
|
95
|
+
const refreshToken = body.refresh_token ? String(body.refresh_token) : null;
|
|
96
|
+
const expiresIn = Number(body.expires_in) || 3600;
|
|
97
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
98
|
+
|
|
99
|
+
// best-effort 取资料补全 uid / 昵称 / 邮箱;失败不阻断(uid 为空则按 token 去重)
|
|
100
|
+
let profile = null;
|
|
101
|
+
try { profile = await fetchMiniMaxProfile(accessToken); } catch {}
|
|
102
|
+
|
|
103
|
+
const uid = profile?.userId || null;
|
|
104
|
+
const name = profile?.name || (uid ? `MiniMax_${String(uid).slice(-6)}` : null);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
status: 'ok',
|
|
108
|
+
input: {
|
|
109
|
+
provider: 'minimax',
|
|
110
|
+
token: accessToken,
|
|
111
|
+
refreshToken,
|
|
112
|
+
expiresAt,
|
|
113
|
+
uid: uid ? String(uid) : null,
|
|
114
|
+
name: name || profile?.email || null,
|
|
115
|
+
email: profile?.email || null,
|
|
116
|
+
source: 'browser',
|
|
117
|
+
meta: {
|
|
118
|
+
clientId: CLIENT_ID,
|
|
119
|
+
scopes: [SCOPE],
|
|
120
|
+
audience: AUDIENCE,
|
|
121
|
+
loginVia: 'device-code',
|
|
122
|
+
userCode: data.userCode || null,
|
|
123
|
+
capturedAt: new Date().toISOString(),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MiniMax Code 服务端 API 客户端 — 用户信息、额度查询、每日签到与 Token 续期。
|
|
3
|
+
*
|
|
4
|
+
* 官方标准端点:
|
|
5
|
+
* - 用户资料:GET https://agent.minimax.cn/matrix/api/v1/user/profile
|
|
6
|
+
* - 额度查询:POST https://agent.minimax.cn/matrix/api/v1/commerce/get_membership_info
|
|
7
|
+
* - 签到状态:GET https://agent.minimax.cn/minimax-cloud/api/v1/signin/status?timezone_offset=28800&is_desktop=1&client=desktop
|
|
8
|
+
* - 签到领取:POST https://agent.minimax.cn/minimax-cloud/api/v1/signin/claim?timezone_offset=28800&is_desktop=1&client=desktop
|
|
9
|
+
* - Token 续期:POST https://account.minimax.cn/oauth2/token
|
|
10
|
+
*
|
|
11
|
+
* 补全模型网关端点:
|
|
12
|
+
* - Anthropic 兼容端点:POST https://agent.minimax.cn/mavis/api/v1/llm/v1/messages
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
16
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
17
|
+
|
|
18
|
+
export const MINIMAX_AGENT_BASE = 'https://agent.minimax.cn';
|
|
19
|
+
export const MINIMAX_ACCOUNT_BASE = 'https://account.minimax.cn';
|
|
20
|
+
export const MINIMAX_MESSAGES_URL = `${MINIMAX_AGENT_BASE}/mavis/api/v1/llm/v1/messages`;
|
|
21
|
+
|
|
22
|
+
export const PROFILE_URL = `${MINIMAX_AGENT_BASE}/matrix/api/v1/user/profile`;
|
|
23
|
+
export const MEMBERSHIP_URL = `${MINIMAX_AGENT_BASE}/matrix/api/v1/commerce/get_membership_info`;
|
|
24
|
+
export const SIGNIN_STATUS_URL = `${MINIMAX_AGENT_BASE}/minimax-cloud/api/v1/signin/status?timezone_offset=28800&is_desktop=1&client=desktop`;
|
|
25
|
+
export const SIGNIN_CLAIM_URL = `${MINIMAX_AGENT_BASE}/minimax-cloud/api/v1/signin/claim?timezone_offset=28800&is_desktop=1&client=desktop`;
|
|
26
|
+
export const TOKEN_REFRESH_URL = `${MINIMAX_ACCOUNT_BASE}/oauth2/token`;
|
|
27
|
+
|
|
28
|
+
/** 时间戳宽容转换(口径同 traeClient.iso):≤1e11 视为秒,否则视为毫秒;0/无效 → null */
|
|
29
|
+
function isoMs(v) {
|
|
30
|
+
const n = Number(v);
|
|
31
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
32
|
+
const d = new Date(n < 1e11 ? n * 1000 : n);
|
|
33
|
+
return Number.isFinite(d.getTime()) && d.getUTCFullYear() < 9000 ? d.toISOString() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function authHeaders(token) {
|
|
37
|
+
const t = token?.startsWith('Bearer ') ? token : `Bearer ${String(token || '').trim()}`;
|
|
38
|
+
return {
|
|
39
|
+
Authorization: t,
|
|
40
|
+
Accept: 'application/json',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 刷新 MiniMax OAuth 凭据
|
|
46
|
+
* @param {string} refreshToken
|
|
47
|
+
* @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>}
|
|
48
|
+
*/
|
|
49
|
+
export async function refreshMiniMaxToken(refreshToken) {
|
|
50
|
+
if (!refreshToken) throw new Error('缺少 refreshToken');
|
|
51
|
+
const form = new URLSearchParams();
|
|
52
|
+
form.set('grant_type', 'refresh_token');
|
|
53
|
+
form.set('refresh_token', refreshToken);
|
|
54
|
+
form.set('client_id', 'mcode-public');
|
|
55
|
+
form.set('scope', 'agent.default');
|
|
56
|
+
form.set('audience', 'agent-backend');
|
|
57
|
+
|
|
58
|
+
const res = await fetchJsonRace(TOKEN_REFRESH_URL, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
62
|
+
Accept: 'application/json',
|
|
63
|
+
},
|
|
64
|
+
body: form.toString(),
|
|
65
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const text = await res.text().catch(() => '');
|
|
70
|
+
const err = new Error(`MiniMax token 刷新失败: HTTP ${res.status} ${text}`);
|
|
71
|
+
if (res.status === 400 || res.status === 401) err.auth = true;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const data = await res.json().catch(() => null);
|
|
76
|
+
if (!data?.access_token) throw new Error('MiniMax token 刷新未返回 access_token');
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
accessToken: data.access_token,
|
|
80
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
81
|
+
expiresIn: Number(data.expires_in) || 3600,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 带自动刷新 401 重试的高阶调用。
|
|
87
|
+
*
|
|
88
|
+
* 刷新前若该账号来自本机导入(meta.authRecordKey),先从 ~/.minimax auth.json 重读该 record 的
|
|
89
|
+
* 最新 refreshToken——本机客户端会独立轮换刷新,账号库快照可能已被服务端作废(invalid_grant)。
|
|
90
|
+
* 刷新成功后经 ctx.onRefresh 回写账号库,并尝试安全回写 auth.json(见 minimaxLocal.writeMiniMaxAuth)。
|
|
91
|
+
*/
|
|
92
|
+
export async function withMiniMaxAuth(account, fn, ctx = {}) {
|
|
93
|
+
try {
|
|
94
|
+
return await fn(account.token);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (!err?.auth || !account.refreshToken) throw err;
|
|
97
|
+
const creds = await refreshAccountToken(account, ctx);
|
|
98
|
+
// 重试放在刷新之外:刷新成功但重试再 401 时,日志该说的是「重试仍被拒」,
|
|
99
|
+
// 而不是误导人的「自动刷新失败」(那时新 token 其实已经拿到并落库了)
|
|
100
|
+
return await fn(creds.token);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* refreshToken 是一次性的、刷新即轮换:两个并发 401 拿同一个 refreshToken 去刷新,
|
|
106
|
+
* 必然一个成功一个 invalid_grant。签到轮(checkin)与面板的额度查询互不知情,
|
|
107
|
+
* 面板每 2 分钟刷一轮账号额度,很容易和 2 小时一次的签到轮撞上。
|
|
108
|
+
* 用 in-flight 表把同一账号的并发刷新合并成一次,后到者直接用刷新出来的新 token 重试。
|
|
109
|
+
*/
|
|
110
|
+
const refreshInFlight = new Map();
|
|
111
|
+
|
|
112
|
+
function refreshAccountToken(account, ctx = {}) {
|
|
113
|
+
const key = account.id || account.token;
|
|
114
|
+
const inflight = refreshInFlight.get(key);
|
|
115
|
+
if (inflight) return inflight;
|
|
116
|
+
const p = (async () => {
|
|
117
|
+
// 本机导入账号:刷新前对齐客户端最新的 refreshToken,避免用陈旧快照刷新触发 invalid_grant
|
|
118
|
+
try {
|
|
119
|
+
const { alignMiniMaxFromLocal } = await import('./minimaxLocal.js');
|
|
120
|
+
alignMiniMaxFromLocal(account, ctx.log);
|
|
121
|
+
} catch {}
|
|
122
|
+
ctx.log?.('MiniMax token 已过期,正在自动刷新…');
|
|
123
|
+
try {
|
|
124
|
+
const refreshed = await refreshMiniMaxToken(account.refreshToken);
|
|
125
|
+
const expiresAt = new Date(Date.now() + refreshed.expiresIn * 1000).toISOString();
|
|
126
|
+
const creds = {
|
|
127
|
+
token: refreshed.accessToken,
|
|
128
|
+
refreshToken: refreshed.refreshToken,
|
|
129
|
+
expiresAt,
|
|
130
|
+
};
|
|
131
|
+
account.token = creds.token;
|
|
132
|
+
account.refreshToken = creds.refreshToken;
|
|
133
|
+
account.expiresAt = expiresAt;
|
|
134
|
+
await ctx.onRefresh?.(creds);
|
|
135
|
+
return creds;
|
|
136
|
+
} catch (refErr) {
|
|
137
|
+
ctx.log?.(`MiniMax token 自动刷新失败: ${refErr.message}`);
|
|
138
|
+
throw refErr;
|
|
139
|
+
}
|
|
140
|
+
})().finally(() => refreshInFlight.delete(key));
|
|
141
|
+
refreshInFlight.set(key, p);
|
|
142
|
+
return p;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 获取用户信息
|
|
147
|
+
*/
|
|
148
|
+
export async function fetchMiniMaxProfile(token) {
|
|
149
|
+
const res = await fetchJsonRace(PROFILE_URL, {
|
|
150
|
+
headers: authHeaders(token),
|
|
151
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
152
|
+
});
|
|
153
|
+
if (res.status === 401) {
|
|
154
|
+
const err = new Error('MiniMax 登录凭据已失效 (401)');
|
|
155
|
+
err.auth = true;
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
if (!res.ok) throw new Error(`获取 MiniMax 用户信息失败: HTTP ${res.status}`);
|
|
159
|
+
const data = await res.json().catch(() => null);
|
|
160
|
+
if (!data || data.base_resp?.status_code !== 0) {
|
|
161
|
+
const msg = data?.base_resp?.status_msg || '未知错误';
|
|
162
|
+
throw new Error(`MiniMax 用户信息错误: ${msg}`);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
userId: String(data.user_id || ''),
|
|
166
|
+
name: data.user_name || null,
|
|
167
|
+
email: data.email || null,
|
|
168
|
+
avatarUrl: data.avatar_url || data.default_avatar_url || null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 校验 MiniMax 账号 (verify)
|
|
174
|
+
*/
|
|
175
|
+
export async function verifyMiniMaxAccount(account) {
|
|
176
|
+
const p = await fetchMiniMaxProfile(account.token);
|
|
177
|
+
return {
|
|
178
|
+
name: p.name || `MiniMax_${p.userId.slice(-6)}`,
|
|
179
|
+
uid: p.userId,
|
|
180
|
+
email: p.email,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 查询额度 (quota)
|
|
186
|
+
*/
|
|
187
|
+
export async function fetchMiniMaxQuota(account, ctx = {}) {
|
|
188
|
+
return withMiniMaxAuth(account, async (token) => {
|
|
189
|
+
const res = await fetchJsonRace(MEMBERSHIP_URL, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: {
|
|
192
|
+
...authHeaders(token),
|
|
193
|
+
'Content-Type': 'application/json',
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify({}),
|
|
196
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
197
|
+
});
|
|
198
|
+
if (res.status === 401) {
|
|
199
|
+
const err = new Error('MiniMax 凭据已失效 (401)');
|
|
200
|
+
err.auth = true;
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
if (!res.ok) throw new Error(`获取 MiniMax 额度失败: HTTP ${res.status}`);
|
|
204
|
+
const data = await res.json().catch(() => null);
|
|
205
|
+
if (!data || data.base_resp?.status_code !== 0) {
|
|
206
|
+
const msg = data?.base_resp?.status_msg || '未知错误';
|
|
207
|
+
throw new Error(`MiniMax 额度错误: ${msg}`);
|
|
208
|
+
}
|
|
209
|
+
return normalizeMiniMaxQuota(data);
|
|
210
|
+
}, ctx);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 解析并归一化 MiniMax 额度
|
|
215
|
+
*/
|
|
216
|
+
export function normalizeMiniMaxQuota(data) {
|
|
217
|
+
const summary = data.op_credit_summary || {};
|
|
218
|
+
const total = Number(summary.total_remaining_amount ?? data.total_remains_credit) || 0;
|
|
219
|
+
const free = Number(summary.free_remaining_amount) || 0;
|
|
220
|
+
const purchased = Number(summary.purchased_remaining_amount) || 0;
|
|
221
|
+
|
|
222
|
+
const parts = [];
|
|
223
|
+
if (free > 0) {
|
|
224
|
+
parts.push({
|
|
225
|
+
name: '免费/活动积分',
|
|
226
|
+
total: free,
|
|
227
|
+
used: 0,
|
|
228
|
+
remaining: free,
|
|
229
|
+
recurring: true,
|
|
230
|
+
unit: '积分',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (purchased > 0) {
|
|
234
|
+
parts.push({
|
|
235
|
+
name: '购买积分',
|
|
236
|
+
total: purchased,
|
|
237
|
+
used: 0,
|
|
238
|
+
remaining: purchased,
|
|
239
|
+
recurring: false,
|
|
240
|
+
unit: '积分',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (!parts.length) {
|
|
244
|
+
parts.push({
|
|
245
|
+
name: '积分',
|
|
246
|
+
total,
|
|
247
|
+
used: 0,
|
|
248
|
+
remaining: total,
|
|
249
|
+
recurring: true,
|
|
250
|
+
unit: '积分',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
total: Math.round(total * 100) / 100,
|
|
256
|
+
used: 0,
|
|
257
|
+
remaining: Math.round(total * 100) / 100,
|
|
258
|
+
// 顶层不设 unit:面板 creditSum 用「有 unit 即异单位」来排除合计,
|
|
259
|
+
// MiniMax 与 Qoder / Trae 同为「积分」口径,必须计入剩余积分合计。
|
|
260
|
+
plan: data.plan_name || (data.is_pro_builder ? 'Pro' : '免费版'),
|
|
261
|
+
// expires_at 是毫秒时间戳:再乘 1000 会显示成 58729 年(实测免费版返回 0,按无到期处理)
|
|
262
|
+
planExpiresAt: isoMs(data.expires_at),
|
|
263
|
+
parts,
|
|
264
|
+
empty: total <= 0,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 每日自动签到 (checkin)
|
|
270
|
+
*/
|
|
271
|
+
export async function checkinMiniMax(account, ctx = {}) {
|
|
272
|
+
return withMiniMaxAuth(account, async (token) => {
|
|
273
|
+
// 1. 查询签到状态
|
|
274
|
+
const statusRes = await fetchJsonRace(SIGNIN_STATUS_URL, {
|
|
275
|
+
headers: authHeaders(token),
|
|
276
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
277
|
+
});
|
|
278
|
+
if (statusRes.status === 401) {
|
|
279
|
+
const err = new Error('MiniMax 凭据已失效 (401)');
|
|
280
|
+
err.auth = true;
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
if (!statusRes.ok) throw new Error(`查询签到状态失败: HTTP ${statusRes.status}`);
|
|
284
|
+
const statusData = await statusRes.json().catch(() => null);
|
|
285
|
+
if (!statusData || statusData.base_resp?.status_code !== 0) {
|
|
286
|
+
throw new Error(`查询签到状态错误: ${statusData?.base_resp?.status_msg || '未知'}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const days = statusData.data?.days || [];
|
|
290
|
+
const today = days.find((d) => d.is_today);
|
|
291
|
+
|
|
292
|
+
// status: 3 = 已领取,1 = 未领取/待领取
|
|
293
|
+
if (today && today.status === 3) {
|
|
294
|
+
return {
|
|
295
|
+
status: 'already',
|
|
296
|
+
message: `今日第 ${today.day_no} 天已签到(${today.points} + ${today.bonus_points} 积分)`,
|
|
297
|
+
amount: (today.points || 0) + (today.bonus_points || 0),
|
|
298
|
+
streakDays: today.day_no || 0,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 2. 发起签到 claim
|
|
303
|
+
const claimRes = await fetchJsonRace(SIGNIN_CLAIM_URL, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: authHeaders(token),
|
|
306
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
307
|
+
});
|
|
308
|
+
if (claimRes.status === 401) {
|
|
309
|
+
const err = new Error('MiniMax 凭据已失效 (401)');
|
|
310
|
+
err.auth = true;
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
if (!claimRes.ok) throw new Error(`签到领取失败: HTTP ${claimRes.status}`);
|
|
314
|
+
const claimData = await claimRes.json().catch(() => null);
|
|
315
|
+
if (!claimData || claimData.base_resp?.status_code !== 0) {
|
|
316
|
+
throw new Error(`签到领取错误: ${claimData?.base_resp?.status_msg || '未知'}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const cdata = claimData.data || {};
|
|
320
|
+
// claim_result: 1 = 新签到成功, 2 = 重复签到已领
|
|
321
|
+
const pts = Number(cdata.points) || (today ? (today.points || 0) + (today.bonus_points || 0) : 0);
|
|
322
|
+
if (cdata.claim_result === 2) {
|
|
323
|
+
return {
|
|
324
|
+
status: 'already',
|
|
325
|
+
message: `今日已领取(+${pts} 积分)`,
|
|
326
|
+
amount: pts,
|
|
327
|
+
streakDays: cdata.day_no || today?.day_no || 0,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
status: 'checked-in',
|
|
333
|
+
message: `签到成功第 ${cdata.day_no || today?.day_no || 1} 天(+${pts} 积分)`,
|
|
334
|
+
claimedAmount: pts,
|
|
335
|
+
amount: pts,
|
|
336
|
+
streakDays: cdata.day_no || today?.day_no || 0,
|
|
337
|
+
};
|
|
338
|
+
}, ctx);
|
|
339
|
+
}
|