opencode-claude-code-ua 0.0.0-stage → 0.3.9
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 +23 -0
- package/NOTICE.md +9 -0
- package/README.md +200 -2
- package/assets/icon.svg +9 -0
- package/index.mjs +19 -0
- package/lib/parameters.mjs +108 -0
- package/lib/ua-protocol.mjs +51 -0
- package/lib/ua-test-plan.mjs +232 -0
- package/package.json +80 -4
- package/setup.mjs +39 -0
- package/src/account-profile.mjs +73 -0
- package/src/account-usage.mjs +33 -0
- package/src/accounts.mjs +354 -0
- package/src/connection.mjs +144 -0
- package/src/diagnostics.mjs +37 -0
- package/src/headers.mjs +60 -0
- package/src/key-usage.mjs +73 -0
- package/src/newapi.mjs +330 -0
- package/src/options.mjs +48 -0
- package/src/plugin.mjs +344 -0
- package/src/profile.mjs +217 -0
- package/src/runtime.mjs +39 -0
- package/src/schedule.mjs +88 -0
- package/src/scheduler.mjs +141 -0
- package/src/setup-server.mjs +478 -0
- package/src/store.mjs +58 -0
- package/src/transport.mjs +84 -0
- package/src/ua-model-catalog.mjs +218 -0
- package/src/ua-plugin.mjs +250 -0
- package/src/ua-runtime.mjs +229 -0
- package/src/ua-service.mjs +129 -0
- package/src/ua-store.mjs +660 -0
- package/src/ua-tests.mjs +153 -0
- package/src/ua-transform.mjs +478 -0
- package/src/ua-usage.mjs +64 -0
- package/ui/accounts.css +75 -0
- package/ui/accounts.html +67 -0
- package/ui/accounts.js +786 -0
- package/ui/pro.css +1683 -0
- package/ui/pro.html +347 -0
- package/ui/pro.js +900 -0
- package/ui/theme.js +39 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { siteRoot } from '../lib/ua-protocol.mjs'
|
|
2
|
+
import { boundedResponse, credential } from './ua-runtime.mjs'
|
|
3
|
+
|
|
4
|
+
const localErrors = new WeakSet()
|
|
5
|
+
const fail = (message, code = 'response') => { const error = Object.assign(new Error(message), { code }); localErrors.add(error); return error }
|
|
6
|
+
const money = value => '$' + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 })
|
|
7
|
+
const quota = value => Number.isSafeInteger(value)
|
|
8
|
+
const unavailable = message => ({ signIn: 'kept', error: message, windows: [{ name: 'Key 用量', aside: true, display: `${message};需签到请绑定站点账号,模型 key 不替代登录凭据` }] })
|
|
9
|
+
|
|
10
|
+
export function createKeyUsage({ fetchImpl = globalThis.fetch, readAuth } = {}) {
|
|
11
|
+
async function request(url, headers) {
|
|
12
|
+
const controller = new AbortController()
|
|
13
|
+
let timer
|
|
14
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail('Key 用量查询超时,请稍后手动重试', 'timeout')) }, 15000) })
|
|
15
|
+
try {
|
|
16
|
+
return await Promise.race([deadline, (async () => {
|
|
17
|
+
const response = await fetchImpl(url, { method: 'GET', headers, redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer', signal: controller.signal })
|
|
18
|
+
const stop = (message, code) => { void response.body?.cancel().catch(() => {}); throw fail(message, code) }
|
|
19
|
+
if (response.redirected || response.status >= 300 && response.status < 400 || response.url && new URL(response.url).origin !== new URL(url).origin) stop('站点重定向已阻止,未向其他地址发送凭据', 'redirect')
|
|
20
|
+
if (response.status === 401 || response.status === 403) stop('Key 用量查询未获授权,请核对 Key 或绑定站点账号', 'auth')
|
|
21
|
+
if (response.status === 404 || response.status === 405) stop('站点未提供此 Key 用量接口', 'unsupported')
|
|
22
|
+
if (!response.ok) stop('Key 用量查询失败,请检查站点服务', 'http')
|
|
23
|
+
const body = await boundedResponse(response, { limit: 1024 * 1024, signal: controller.signal })
|
|
24
|
+
let result
|
|
25
|
+
try { result = JSON.parse(body) } catch { throw fail('站点返回的 Key 用量数据无效') }
|
|
26
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) || result.success === false || result.code === false) throw fail('站点未确认 Key 用量查询成功')
|
|
27
|
+
return result
|
|
28
|
+
})()])
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (localErrors.has(error)) throw error
|
|
31
|
+
throw fail('Key 用量查询未完成,请检查站点地址与网络', 'network')
|
|
32
|
+
} finally { clearTimeout(timer) }
|
|
33
|
+
}
|
|
34
|
+
return async connection => {
|
|
35
|
+
try {
|
|
36
|
+
const root = siteRoot(connection.baseURL)
|
|
37
|
+
const auth = await credential(connection, readAuth)
|
|
38
|
+
const key = ['api-key', 'bearer'].includes(auth.mode) ? auth.key : auth.mode === 'custom' ? /^Bearer\s+([^\s]+)$/i.exec(auth.value ?? '')?.[1] : null
|
|
39
|
+
if (!key) return unavailable('此认证方式不提供模型 Key 的只读用量查询')
|
|
40
|
+
const headers = { accept: 'application/json', authorization: `Bearer ${key}` }
|
|
41
|
+
let result
|
|
42
|
+
try { result = await request(root + '/api/usage/token/', headers) }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code !== 'unsupported') throw error
|
|
45
|
+
const billing = await request(root + '/v1/dashboard/billing/usage', headers)
|
|
46
|
+
const spent = billing.total_usage ?? billing.data?.total_usage
|
|
47
|
+
if (typeof spent !== 'number' || !Number.isFinite(spent) || spent < 0) throw fail('站点未返回可确认的 Key 累计消耗')
|
|
48
|
+
return { plan: 'Key 累计消耗', user: connection.name, signIn: 'kept', windows: [{ name: 'Key 用量', aside: true, amount: spent / 100, unit: 'usd', display: `累计消耗 ${money(spent / 100)};余额未知。签到需绑定站点账号` }] }
|
|
49
|
+
}
|
|
50
|
+
const data = result.data
|
|
51
|
+
if (result.code !== true || data?.object !== 'token_usage' || !quota(data.total_granted) || data.total_granted < 0 || !quota(data.total_used) || data.total_used < 0 || !quota(data.total_available) || typeof data.unlimited_quota !== 'boolean' || !quota(data.expires_at) || data.expires_at < 0) throw fail('站点返回的 Key 额度字段无效')
|
|
52
|
+
let scale = 500000, estimated = true
|
|
53
|
+
try {
|
|
54
|
+
const status = await request(root + '/api/status', { accept: 'application/json' })
|
|
55
|
+
if (status.success === true && typeof status.data?.quota_per_unit === 'number' && Number.isFinite(status.data.quota_per_unit) && status.data.quota_per_unit > 0) { scale = status.data.quota_per_unit; estimated = false }
|
|
56
|
+
} catch { /* The token reading remains valid; label the default conversion as estimated. */ }
|
|
57
|
+
const available = data.total_available / scale, used = data.total_used / scale, total = data.total_granted / scale
|
|
58
|
+
if (![available, used, total].every(Number.isFinite)) throw fail('站点额度换算超出允许范围')
|
|
59
|
+
const user = data.user_quota
|
|
60
|
+
const userAvailable = quota(user?.total_available) && quota(user?.total_used) && user.total_used >= 0 ? user.total_available / scale : null
|
|
61
|
+
const suffix = estimated ? '(估算)' : ''
|
|
62
|
+
const balance = Number.isFinite(userAvailable) ? money(userAvailable) + suffix : data.unlimited_quota ? 'Key 不限额(账号余额未知)' : money(available) + suffix
|
|
63
|
+
let until
|
|
64
|
+
if (data.expires_at > 0) { const date = new Date(data.expires_at * 1000); if (!Number.isFinite(date.getTime())) throw fail('Key 过期时间无效'); until = date.toISOString() }
|
|
65
|
+
return { plan: Number.isFinite(userAvailable) ? 'New API 账号余额(Key 已授权查询)' : 'New API Key 额度', user: connection.name, signIn: 'kept', balance,
|
|
66
|
+
...(until ? { until } : {}),
|
|
67
|
+
windows: [{ name: 'Key 额度', aside: true, amount: used, unit: 'usd',
|
|
68
|
+
...(!data.unlimited_quota ? { limit: total, used: total > 0 ? Math.min(100, Math.max(0, used / total * 100)) : 0 } : {}),
|
|
69
|
+
display: `${data.unlimited_quota ? 'Key 不限额' : `Key 剩余 ${money(available)}`};累计消耗 ${money(used)}${Number.isFinite(userAvailable) ? `;账号余额 ${money(userAvailable)}` : ';这是 Key 额度,不等于账号总余额'}${estimated ? ';额度倍率未确认,按 500000 quota/USD 估算' : ''};签到需绑定站点账号` }],
|
|
70
|
+
}
|
|
71
|
+
} catch (error) { return unavailable(localErrors.has(error) ? error.message : 'Key 用量读取未完成,请核对连接凭据与地址') }
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/newapi.mjs
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { normalizeSchedule, ScheduleValidationError } from './schedule.mjs';
|
|
2
|
+
|
|
3
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
4
|
+
const RESPONSE_LIMIT = 1024 * 1024;
|
|
5
|
+
const TIMEOUT_MS = 15000;
|
|
6
|
+
|
|
7
|
+
export class AccountValidationError extends Error {
|
|
8
|
+
constructor(message = '账号配置无效。', status = 400, fields = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'AccountValidationError';
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.fields = fields;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ERRORS = Object.freeze({
|
|
17
|
+
timeout: '站点请求超时,请稍后手动重试。',
|
|
18
|
+
network: '无法连接站点,请检查地址与网络。',
|
|
19
|
+
redirect: '站点返回了重定向;为保护凭据,已停止请求。',
|
|
20
|
+
auth: '站点认证失败,请检查系统访问令牌、Cookie 和用户 ID。',
|
|
21
|
+
http: '站点请求失败,请在站点确认服务状态。',
|
|
22
|
+
response: '站点返回了无效的数据。',
|
|
23
|
+
size: '站点响应超过允许大小。',
|
|
24
|
+
identity: '站点返回的用户 ID 与配置不一致,已停止操作。',
|
|
25
|
+
credentials: '请填写所选认证方式对应的系统访问令牌或 Cookie。',
|
|
26
|
+
login_credentials: '请填写 AgentRouter 用户名和密码。',
|
|
27
|
+
disabled: '该账号或签到功能未启用。',
|
|
28
|
+
checkin: '签到未完成,请前往站点检查签到条件。',
|
|
29
|
+
cookie_required: '当前令牌缺少签到权限或站点要求登录 Cookie,请检查令牌权限或改用 Cookie。',
|
|
30
|
+
turnstile: '站点要求验证码,请前往站点完成验证后签到。',
|
|
31
|
+
ambiguous: '签到结果未确认;不会自动重试,请在站点确认记录。',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export class NewApiError extends Error {
|
|
35
|
+
constructor(code = 'network', status = null) {
|
|
36
|
+
super(ERRORS[code] ?? ERRORS.network);
|
|
37
|
+
this.name = 'NewApiError';
|
|
38
|
+
this.code = Object.hasOwn(ERRORS, code) ? code : 'network';
|
|
39
|
+
this.status = Number.isInteger(status) ? status : null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Only fixed local messages cross the network/client boundary. */
|
|
44
|
+
export function safeNewApiMessage(error) {
|
|
45
|
+
return error instanceof NewApiError ? ERRORS[error.code] ?? ERRORS.network : ERRORS.network;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Keep only the site's session cookie pair; never persist unrelated Set-Cookie values. */
|
|
49
|
+
function sessionCookie(headers) {
|
|
50
|
+
const combined = headers.get('set-cookie');
|
|
51
|
+
const values = typeof headers.getSetCookie === 'function'
|
|
52
|
+
? headers.getSetCookie()
|
|
53
|
+
: combined ? combined.split(/,(?=\s*[^;=,\s]+=)/u) : [];
|
|
54
|
+
for (const value of values) {
|
|
55
|
+
const pair = String(value).split(';')[0].trim();
|
|
56
|
+
const separator = pair.indexOf('=');
|
|
57
|
+
if (separator <= 0 || pair.slice(0, separator).trim().toLowerCase() !== 'session') continue;
|
|
58
|
+
if (CONTROL.test(pair) || pair.length > 32768) continue;
|
|
59
|
+
return pair;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whitelist the account config; no model-router headers are accepted here. */
|
|
65
|
+
export function normalizeAccountConfig(raw = {}) {
|
|
66
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new AccountValidationError();
|
|
67
|
+
const fields = {};
|
|
68
|
+
function text(key, fallback = '', limit = 256) {
|
|
69
|
+
const value = raw[key] ?? fallback;
|
|
70
|
+
if (typeof value !== 'string' || CONTROL.test(value) || value.length > limit) {
|
|
71
|
+
fields[key] = '请输入不含控制字符且长度合理的文本。';
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
return value.trim();
|
|
75
|
+
}
|
|
76
|
+
function flag(key) {
|
|
77
|
+
if (raw[key] !== undefined && typeof raw[key] !== 'boolean') fields[key] = '必须为开关值。';
|
|
78
|
+
return raw[key] === undefined ? true : raw[key] === true;
|
|
79
|
+
}
|
|
80
|
+
const siteType = text('siteType', 'newapi', 16);
|
|
81
|
+
if (!['newapi', 'agentrouter'].includes(siteType)) fields.siteType = '请选择 New API 或 AgentRouter。';
|
|
82
|
+
let siteUrl = text('siteUrl', '', 2048);
|
|
83
|
+
try {
|
|
84
|
+
const url = new URL(siteUrl);
|
|
85
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || siteUrl.includes('?') || siteUrl.includes('#') || siteUrl.includes('\\') || /%(?:0[0-9a-f]|1[0-9a-f]|7f)/iu.test(siteUrl)) throw new Error();
|
|
86
|
+
const loopback = /^127(?:\.\d{1,3}){3}$/u.test(url.hostname) || ['localhost', '::1', '[::1]'].includes(url.hostname.toLowerCase());
|
|
87
|
+
siteUrl = url.href.replace(/\/+$/u, '');
|
|
88
|
+
if (siteType === 'agentrouter' && url.protocol === 'http:' && !loopback) fields.siteUrl = 'AgentRouter 必须使用 HTTPS 或本机回环地址。';
|
|
89
|
+
} catch { fields.siteUrl = '请输入不含账号、密码、查询参数或片段的 HTTP(S) 站点地址。'; }
|
|
90
|
+
const userId = text('userId', '', 32);
|
|
91
|
+
if (siteType === 'newapi' && !/^[1-9]\d*$/u.test(userId) || siteType === 'agentrouter' && userId && !/^[1-9]\d*$/u.test(userId)) fields.userId = '用户 ID 必须为正整数字符串。';
|
|
92
|
+
const name = text('name', siteType === 'agentrouter' ? 'AgentRouter' : 'New API', 100);
|
|
93
|
+
if (!name) fields.name = '请输入账号名称。';
|
|
94
|
+
const authMode = text('authMode', 'token', 16);
|
|
95
|
+
if (!['token', 'cookie'].includes(authMode)) fields.authMode = '请选择令牌或 Cookie 认证。';
|
|
96
|
+
const username = text('username', '', 256);
|
|
97
|
+
const password = raw.password ?? '';
|
|
98
|
+
if (typeof password !== 'string' || CONTROL.test(password) || password.length > 4096) fields.password = '请输入不含控制字符且长度合理的密钥。';
|
|
99
|
+
const accessToken = text('accessToken', '', 16384);
|
|
100
|
+
const cookie = text('cookie', '', 32768);
|
|
101
|
+
const quotaPerUnit = raw.quotaPerUnit ?? 500000;
|
|
102
|
+
if (typeof quotaPerUnit !== 'number' || !Number.isFinite(quotaPerUnit) || quotaPerUnit <= 0) fields.quotaPerUnit = '额度倍率必须为有限正数。';
|
|
103
|
+
const unit = text('unit', 'USD', 32);
|
|
104
|
+
if (!unit) fields.unit = '请输入单位标签。';
|
|
105
|
+
const enabled = flag('enabled');
|
|
106
|
+
const checkinEnabled = flag('checkinEnabled');
|
|
107
|
+
let schedule;
|
|
108
|
+
try { schedule = normalizeSchedule(raw.schedule); }
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!(error instanceof ScheduleValidationError)) throw error;
|
|
111
|
+
const messages = {
|
|
112
|
+
schedule: '请填写有效的自动签到设置。',
|
|
113
|
+
'schedule.enabled': '自动签到必须为开关值。',
|
|
114
|
+
'schedule.time': '请输入 HH:mm 格式的时间,范围为 00:00 至 23:59。',
|
|
115
|
+
'schedule.timeZone': '请选择有效的时区,例如 Asia/Shanghai 或 UTC。',
|
|
116
|
+
};
|
|
117
|
+
for (const field of Object.keys(error.fields)) fields[field] = messages[field] ?? messages.schedule;
|
|
118
|
+
}
|
|
119
|
+
if (Object.keys(fields).length) throw new AccountValidationError('账号配置无效。', 400, fields);
|
|
120
|
+
return { name, enabled, siteUrl, siteType, userId, username, password, authMode, accessToken, cookie, quotaPerUnit, unit, checkinEnabled, schedule };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createNewApiClient({ fetchImpl = globalThis.fetch, now = () => new Date() } = {}) {
|
|
124
|
+
if (typeof fetchImpl !== 'function') throw new AccountValidationError('缺少网络请求实现。');
|
|
125
|
+
const timestamp = () => new Date(now()).toISOString();
|
|
126
|
+
|
|
127
|
+
async function request(account, endpoint, { method = 'GET', body, accountAuth = true, cookieJar } = {}) {
|
|
128
|
+
const url = new URL(`${account.siteUrl}${endpoint}`);
|
|
129
|
+
if (url.origin !== new URL(account.siteUrl).origin) throw new NewApiError('redirect');
|
|
130
|
+
const headers = { Accept: 'application/json' };
|
|
131
|
+
if (accountAuth) {
|
|
132
|
+
if (account.siteType === 'agentrouter') {
|
|
133
|
+
if (account.userId) headers['New-Api-User'] = account.userId;
|
|
134
|
+
if (account.authMode === 'token') {
|
|
135
|
+
if (!account.accessToken) throw new NewApiError('credentials');
|
|
136
|
+
headers.Authorization = account.accessToken;
|
|
137
|
+
} else {
|
|
138
|
+
if (!account.cookie) throw new NewApiError('credentials');
|
|
139
|
+
headers.Cookie = account.cookie;
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
headers['New-Api-User'] = account.userId;
|
|
143
|
+
if (account.authMode === 'token') {
|
|
144
|
+
if (!account.accessToken) throw new NewApiError('credentials');
|
|
145
|
+
headers.Authorization = `Bearer ${account.accessToken}`;
|
|
146
|
+
} else {
|
|
147
|
+
if (!account.cookie) throw new NewApiError('credentials');
|
|
148
|
+
headers.Cookie = account.cookie;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
let timer;
|
|
155
|
+
let reader;
|
|
156
|
+
const deadline = new Promise((_, reject) => {
|
|
157
|
+
timer = setTimeout(() => {
|
|
158
|
+
controller.abort();
|
|
159
|
+
void reader?.cancel().catch(() => {});
|
|
160
|
+
reject(new NewApiError('timeout'));
|
|
161
|
+
}, TIMEOUT_MS);
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
return await Promise.race([deadline, (async () => {
|
|
165
|
+
const response = await fetchImpl(url.href, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer', signal: controller.signal });
|
|
166
|
+
const rejectResponse = (code, status = response.status) => { void response.body?.cancel().catch(() => {}); throw new NewApiError(code, status); };
|
|
167
|
+
if (response.redirected || response.status >= 300 && response.status < 400 || response.url && new URL(response.url).origin !== url.origin) rejectResponse('redirect');
|
|
168
|
+
if (response.status === 401 || response.status === 403) rejectResponse('auth');
|
|
169
|
+
if (!response.ok) rejectResponse('http');
|
|
170
|
+
if (Number(response.headers.get('content-length')) > RESPONSE_LIMIT) rejectResponse('size');
|
|
171
|
+
if (cookieJar && typeof cookieJar === 'object') {
|
|
172
|
+
const cookie = sessionCookie(response.headers);
|
|
173
|
+
if (cookie) cookieJar.cookie = cookie;
|
|
174
|
+
}
|
|
175
|
+
if (!response.body?.getReader) throw new NewApiError('response');
|
|
176
|
+
reader = response.body.getReader();
|
|
177
|
+
const chunks = []; let size = 0;
|
|
178
|
+
while (true) {
|
|
179
|
+
const { done, value } = await reader.read();
|
|
180
|
+
if (done) break;
|
|
181
|
+
size += value.byteLength;
|
|
182
|
+
if (size > RESPONSE_LIMIT) { void reader.cancel().catch(() => {}); throw new NewApiError('size'); }
|
|
183
|
+
chunks.push(Buffer.from(value));
|
|
184
|
+
}
|
|
185
|
+
let result;
|
|
186
|
+
try { result = JSON.parse(Buffer.concat(chunks, size).toString('utf8')); }
|
|
187
|
+
catch { throw new NewApiError('response'); }
|
|
188
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new NewApiError('response');
|
|
189
|
+
return result;
|
|
190
|
+
})()]);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error instanceof NewApiError) throw error;
|
|
193
|
+
throw new NewApiError(controller.signal.aborted ? 'timeout' : 'network');
|
|
194
|
+
} finally { clearTimeout(timer); }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function agentRouterBalance(account) {
|
|
198
|
+
if (!/^[1-9]\d*$/u.test(account.userId)) throw new NewApiError('identity');
|
|
199
|
+
if (account.authMode === 'token' ? !account.accessToken : !account.cookie) throw new NewApiError('credentials');
|
|
200
|
+
const result = await request(account, '/api/user/self');
|
|
201
|
+
if (result.success !== true || !result.data || typeof result.data !== 'object') throw new NewApiError('response');
|
|
202
|
+
const data = result.data;
|
|
203
|
+
const id = typeof data.id === 'number' && Number.isSafeInteger(data.id) ? String(data.id)
|
|
204
|
+
: typeof data.id === 'string' && /^[1-9]\d*$/u.test(data.id) ? data.id : null;
|
|
205
|
+
if (!id || id !== account.userId) throw new NewApiError('identity');
|
|
206
|
+
if (!Number.isSafeInteger(data.quota) || !Number.isSafeInteger(data.used_quota) || data.used_quota < 0) throw new NewApiError('response');
|
|
207
|
+
const requestCount = data.request_count === undefined || data.request_count === null ? 0 : data.request_count;
|
|
208
|
+
if (!Number.isSafeInteger(requestCount) || requestCount < 0) throw new NewApiError('response');
|
|
209
|
+
return { at: timestamp(), quota: data.quota, usedQuota: data.used_quota, requestCount };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function balance(raw) {
|
|
213
|
+
const account = normalizeAccountConfig(raw);
|
|
214
|
+
if (account.siteType === 'agentrouter') return agentRouterBalance(account);
|
|
215
|
+
const result = await request(account, '/api/user/self');
|
|
216
|
+
if (result.success !== true || !result.data || typeof result.data !== 'object') throw new NewApiError('response');
|
|
217
|
+
const data = result.data;
|
|
218
|
+
const id = typeof data.id === 'number' && Number.isSafeInteger(data.id) ? String(data.id) : data.id;
|
|
219
|
+
if (typeof id !== 'string' || id !== account.userId) throw new NewApiError('identity');
|
|
220
|
+
if (!Number.isSafeInteger(data.quota) || !Number.isSafeInteger(data.used_quota) || data.used_quota < 0 || !Number.isSafeInteger(data.request_count) || data.request_count < 0) throw new NewApiError('response');
|
|
221
|
+
return { at: timestamp(), quota: data.quota, usedQuota: data.used_quota, requestCount: data.request_count };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function agentRouterCheckin(account) {
|
|
225
|
+
if (!account.username || !account.password) throw new NewApiError('login_credentials');
|
|
226
|
+
const cookieJar = {};
|
|
227
|
+
let result;
|
|
228
|
+
try {
|
|
229
|
+
result = await request(account, '/api/user/login', {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
body: { username: account.username, password: account.password },
|
|
232
|
+
accountAuth: false,
|
|
233
|
+
cookieJar,
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!(error instanceof NewApiError)) throw new NewApiError('ambiguous');
|
|
237
|
+
if (['credentials', 'identity', 'redirect', 'auth'].includes(error.code)) throw error;
|
|
238
|
+
// A 4xx response is a definite rejection; a 5xx/body/transport failure may
|
|
239
|
+
// have happened after the site completed the login.
|
|
240
|
+
if (error.code === 'http' && Number.isInteger(error.status) && error.status >= 400 && error.status < 500) {
|
|
241
|
+
throw new NewApiError('checkin', error.status);
|
|
242
|
+
}
|
|
243
|
+
throw new NewApiError('ambiguous', error.status);
|
|
244
|
+
}
|
|
245
|
+
if (result.success === false) throw new NewApiError('checkin');
|
|
246
|
+
if (result.success !== true) throw new NewApiError('ambiguous');
|
|
247
|
+
if (!result.data || typeof result.data !== 'object') throw new NewApiError('ambiguous');
|
|
248
|
+
const data = result.data;
|
|
249
|
+
if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id <= 0) throw new NewApiError('identity');
|
|
250
|
+
const discovered = String(data.id);
|
|
251
|
+
if (account.userId && account.userId !== discovered) throw new NewApiError('identity');
|
|
252
|
+
if (data.checked_in !== undefined && data.checked_in !== true && data.checked_in !== false) throw new NewApiError('ambiguous');
|
|
253
|
+
return { state: data.checked_in === true ? 'success' : 'already', date: null, rewardQuota: null,
|
|
254
|
+
userId: discovered, cookie: cookieJar.cookie, authenticated: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function checkin(raw, { turnstileToken } = {}) {
|
|
258
|
+
const account = normalizeAccountConfig(raw);
|
|
259
|
+
if (!account.enabled || !account.checkinEnabled) throw new NewApiError('disabled');
|
|
260
|
+
if (account.siteType === 'agentrouter') return agentRouterCheckin(account);
|
|
261
|
+
if (turnstileToken !== undefined && (typeof turnstileToken !== 'string' || CONTROL.test(turnstileToken) || turnstileToken.length > 4096)) throw new AccountValidationError('验证码参数无效。');
|
|
262
|
+
await balance(account);
|
|
263
|
+
const site = await request(account, '/api/status');
|
|
264
|
+
if (site.success !== true || !site.data || typeof site.data.turnstile_check !== 'boolean') throw new NewApiError('response');
|
|
265
|
+
if (site.data.checkin_enabled === false) return { state: 'disabled', date: null, rewardQuota: null, checkedAt: null };
|
|
266
|
+
const current = await checkinStatus(account);
|
|
267
|
+
if (current.state !== 'unchecked') return current;
|
|
268
|
+
if (site.data.turnstile_check && !turnstileToken) return { state: 'requires_verification', date: null, rewardQuota: null, checkedAt: null };
|
|
269
|
+
const query = turnstileToken ? `?turnstile=${encodeURIComponent(turnstileToken)}` : '';
|
|
270
|
+
let result;
|
|
271
|
+
try { result = await request(account, `/api/user/checkin${query}`, { method: 'POST', body: {} }); }
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error.code === 'auth') throw new NewApiError(account.authMode === 'token' ? 'cookie_required' : 'auth');
|
|
274
|
+
if (error.code === 'redirect') throw error;
|
|
275
|
+
// A transport/body/5xx failure can happen after the server committed the POST.
|
|
276
|
+
throw new NewApiError('ambiguous');
|
|
277
|
+
}
|
|
278
|
+
if (result.success === true) return claimed('success', result.data);
|
|
279
|
+
if (result.success === false) {
|
|
280
|
+
// Do not trust translated error text to mean already checked: confirm with GET.
|
|
281
|
+
const confirmed = await checkinStatus(account);
|
|
282
|
+
if (confirmed.state === 'already') return confirmed;
|
|
283
|
+
if (site.data.turnstile_check) return { state: 'requires_verification', date: null, rewardQuota: null, checkedAt: null };
|
|
284
|
+
throw new NewApiError(account.authMode === 'token' ? 'cookie_required' : 'checkin');
|
|
285
|
+
}
|
|
286
|
+
throw new NewApiError('ambiguous');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validDate(value) {
|
|
290
|
+
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function claimed(state, data = {}) {
|
|
294
|
+
const at = timestamp();
|
|
295
|
+
return { state, date: validDate(data?.checkin_date) ? data.checkin_date : at.slice(0, 10),
|
|
296
|
+
checkedAt: at, rewardQuota: Number.isSafeInteger(data?.quota_awarded) && data.quota_awarded >= 0 ? data.quota_awarded : null };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function checkinStatus(account) {
|
|
300
|
+
const at = timestamp();
|
|
301
|
+
async function monthStatus(month) {
|
|
302
|
+
let result;
|
|
303
|
+
try { result = await request(account, `/api/user/checkin?month=${month}`); }
|
|
304
|
+
catch (error) { if (error.code === 'auth' && account.authMode === 'token') throw new NewApiError('cookie_required'); throw error; }
|
|
305
|
+
if (result.success !== true) throw new NewApiError(account.authMode === 'token' ? 'cookie_required' : 'checkin');
|
|
306
|
+
if (typeof result.data?.enabled !== 'boolean') throw new NewApiError('response');
|
|
307
|
+
if (result.data.enabled === false) return result.data;
|
|
308
|
+
if (typeof result.data.stats?.checked_in_today !== 'boolean' || !Array.isArray(result.data.stats.records)) throw new NewApiError('response');
|
|
309
|
+
return result.data;
|
|
310
|
+
}
|
|
311
|
+
const status = await monthStatus(at.slice(0, 7));
|
|
312
|
+
if (!status.enabled) return { state: 'disabled', date: null, checkedAt: null, rewardQuota: null };
|
|
313
|
+
if (!status.stats.checked_in_today) return { state: 'unchecked', date: null, checkedAt: null, rewardQuota: null };
|
|
314
|
+
// Site-local days can differ from UTC by a day. Fetch the adjacent month only
|
|
315
|
+
// at a UTC month boundary; the server's checked_in_today remains authoritative.
|
|
316
|
+
const nearbyDates = [-1, 0, 1].map(offset => new Date(Date.parse(at) + offset * 86400000).toISOString().slice(0, 10));
|
|
317
|
+
let records = status.stats.records;
|
|
318
|
+
for (const month of new Set(nearbyDates.map(date => date.slice(0, 7)))) {
|
|
319
|
+
if (month === at.slice(0, 7)) continue;
|
|
320
|
+
const adjacent = await monthStatus(month);
|
|
321
|
+
records = [...records, ...(adjacent.stats?.records ?? [])];
|
|
322
|
+
}
|
|
323
|
+
const todayRecord = records.filter(record => validDate(record?.checkin_date) && nearbyDates.includes(record.checkin_date)).sort((a, b) => b.checkin_date.localeCompare(a.checkin_date))[0];
|
|
324
|
+
// No returned calendar date: document UTC fallback, with unknown reward.
|
|
325
|
+
return claimed('already', todayRecord);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The manager must not short-circuit this client's authoritative day check.
|
|
329
|
+
return { balance, checkin, authoritativeCheckinDay: true };
|
|
330
|
+
}
|
package/src/options.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path'
|
|
2
|
+
import { compileParameters, copyJSON, isObject } from '../lib/parameters.mjs'
|
|
3
|
+
import { createDefaultProfile } from './profile.mjs'
|
|
4
|
+
|
|
5
|
+
const blocked = new Set(['authorization', 'x-api-key', 'api-key', 'x-goog-api-key', 'cookie', 'host', 'content-length', 'content-encoding', 'connection', 'transfer-encoding', 'proxy-authorization', 'proxy-connection', 'keep-alive', 'upgrade', 'te', 'trailer'])
|
|
6
|
+
|
|
7
|
+
export function pluginOptions(raw = {}) {
|
|
8
|
+
if (!isObject(raw)) throw new TypeError('Plugin options must be an object')
|
|
9
|
+
for (const key of Object.keys(raw)) {
|
|
10
|
+
if (!['profileFile', 'accountBindings', 'baseURL', 'userAgent', 'headers', 'models', 'parameters', 'searches'].includes(key)) throw new TypeError(`Unknown plugin option: ${key}`)
|
|
11
|
+
}
|
|
12
|
+
if (raw.profileFile !== undefined && (typeof raw.profileFile !== 'string' || !isAbsolute(raw.profileFile))) throw new TypeError('profileFile must be an absolute path')
|
|
13
|
+
if (raw.baseURL !== undefined) {
|
|
14
|
+
let url
|
|
15
|
+
try { url = new URL(raw.baseURL) } catch { throw new TypeError('baseURL must be an absolute HTTP(S) URL') }
|
|
16
|
+
if (typeof raw.baseURL !== 'string' || !['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash
|
|
17
|
+
|| (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new TypeError('Invalid baseURL: use HTTPS or loopback HTTP without credentials, query or fragment')
|
|
18
|
+
}
|
|
19
|
+
if (raw.userAgent !== undefined && (typeof raw.userAgent !== 'string' || !raw.userAgent.trim() || /[^\x20-\x7e]/.test(raw.userAgent))) throw new TypeError('userAgent must be a nonempty printable ASCII value')
|
|
20
|
+
if (raw.headers !== undefined) {
|
|
21
|
+
if (!isObject(raw.headers)) throw new TypeError('headers must be an object')
|
|
22
|
+
const seen = new Set()
|
|
23
|
+
for (const [key, value] of Object.entries(raw.headers)) {
|
|
24
|
+
const name = key.toLowerCase()
|
|
25
|
+
if (blocked.has(name) || name.startsWith('x-magpie-') || name === 'user-agent') throw new TypeError(`Cannot configure header ${name}; use userAgent for User-Agent`)
|
|
26
|
+
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name) || (value !== null && (typeof value !== 'string' || /[^\x20-\x7e\t]/.test(value)))) throw new TypeError(`Invalid header option: ${name}`)
|
|
27
|
+
if (seen.has(name)) throw new TypeError(`Duplicate header option: ${name}`)
|
|
28
|
+
seen.add(name)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (raw.models !== undefined && (!isObject(raw.models) || !Object.keys(raw.models).length || Object.values(raw.models).some(value => !isObject(value)))) throw new TypeError('models must be a nonempty object of model definitions')
|
|
32
|
+
if (raw.parameters !== undefined) compileParameters(raw.parameters)
|
|
33
|
+
if (raw.searches !== undefined && typeof raw.searches !== 'boolean') throw new TypeError('searches must be a boolean')
|
|
34
|
+
const bindings = raw.accountBindings ?? {}
|
|
35
|
+
if (!isObject(bindings) || Object.entries(bindings).some(([key, value]) => !key || typeof value !== 'string' || !value.trim())) throw new TypeError('accountBindings must map host account keys to site account IDs')
|
|
36
|
+
return { ...copyJSON(raw), accountBindings: { ...bindings } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Saved profiles stay target-scoped; old plugin options are explicit global defaults.
|
|
40
|
+
export function optionProfile(options) {
|
|
41
|
+
const profile = createDefaultProfile()
|
|
42
|
+
if (options.baseURL) profile.upstream.baseUrl = options.baseURL.replace(/\/+$/, '')
|
|
43
|
+
if (options.userAgent !== undefined) profile.headers.userAgent = options.userAgent
|
|
44
|
+
if (options.headers) profile.headers.extra = Object.entries(options.headers).map(([name, value]) => ({ name, value, enabled: true }))
|
|
45
|
+
if (options.models) profile.models = Object.keys(options.models)
|
|
46
|
+
if (options.parameters) profile.parameters = copyJSON(options.parameters)
|
|
47
|
+
return profile
|
|
48
|
+
}
|