pi-multi-quota 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cookie.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Ark 控制台 cookie 解析。
3
+ *
4
+ * 依据 SPEC §2.3 的实测结论:
5
+ * - 请求头 `x-csrf-token` 的值与 cookie 中 `csrfToken=` 完全相同
6
+ * ⇒ 用户只需维护一个 cookie 字符串,程序自行取 csrf。
7
+ * - `digest` 是 SSO access token(JWT),其 `exp` 约 24 小时后到期,只能人工更新。
8
+ * ⇒ 只做 base64url 解码 payload,**不验签**(无密钥、也无需信任校验)。
9
+ *
10
+ * 本模块是纯函数:不发网络请求、不读配置、不写日志。
11
+ * 错误一律走 result 返回,绝不抛异常;message 只含错误码语义的中文短句,
12
+ * 严禁回显 cookie 原文。
13
+ */
14
+
15
+ export interface ParsedArkCookie {
16
+ csrfToken: string;
17
+ /** cookie 中 AccountID 的值;缺失时 undefined。用于账号防呆比对。 */
18
+ accountId?: string;
19
+ /** digest 的 exp,epoch 毫秒;缺失或不可解析时 undefined。 */
20
+ digestExpMs?: number;
21
+ /** 相对 now 的剩余毫秒;digestExpMs 缺失时 undefined。可为负(已过期)。 */
22
+ expiresInMs?: number;
23
+ }
24
+
25
+ export type CookieParseResult =
26
+ | { ok: true; value: ParsedArkCookie }
27
+ | { ok: false; code: "missing-csrf" | "missing-digest" | "malformed-digest"; message: string };
28
+
29
+ const CSRF_KEY = "csrfToken";
30
+ const ACCOUNT_ID_KEY = "AccountID";
31
+ const DIGEST_KEY = "digest";
32
+
33
+ /** base64url 段的合法字符集;用它先挡掉明显非法的 payload,避免 Buffer 静默丢弃字符。 */
34
+ const BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;
35
+
36
+ /** cookie 值两端可能被双引号包裹(RFC 6265 允许),解析时剥掉。 */
37
+ function unquote(value: string): string {
38
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
39
+ return value.slice(1, -1);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ /** 拆 cookie 串为键值表;同名键后者覆盖前者(取最后一个)。 */
45
+ function collectPairs(raw: string): Map<string, string> {
46
+ const pairs = new Map<string, string>();
47
+ for (const segment of raw.split(";")) {
48
+ const trimmed = segment.trim();
49
+ if (trimmed === "") continue;
50
+ const eq = trimmed.indexOf("=");
51
+ // 没有键名或没有 "=" 的碎片直接忽略,不能当成有效键值对
52
+ if (eq <= 0) continue;
53
+ const name = trimmed.slice(0, eq).trim();
54
+ const value = unquote(trimmed.slice(eq + 1).trim());
55
+ pairs.set(name, value);
56
+ }
57
+ return pairs;
58
+ }
59
+
60
+ /** 把 JWT payload 段解成 exp(epoch 秒);任何畸形返回 undefined。 */
61
+ function decodeJwtExpSeconds(payloadSegment: string | undefined): number | undefined {
62
+ if (payloadSegment === undefined || payloadSegment === "" || !BASE64URL_SEGMENT.test(payloadSegment)) {
63
+ return undefined;
64
+ }
65
+ const padded = payloadSegment.padEnd(Math.ceil(payloadSegment.length / 4) * 4, "=");
66
+ let payload: unknown;
67
+ try {
68
+ payload = JSON.parse(Buffer.from(padded, "base64url").toString("utf8"));
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ if (typeof payload !== "object" || payload === null) return undefined;
73
+ const exp = (payload as Record<string, unknown>)["exp"];
74
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined;
75
+ return exp;
76
+ }
77
+
78
+ /**
79
+ * 解析 Ark 控制台 cookie。
80
+ *
81
+ * @param raw cookie 原文(浏览器复制出来的整串)
82
+ * @param now 当前时刻,epoch 毫秒;可注入以便测试,默认 `Date.now()`
83
+ */
84
+ export function parseArkCookie(raw: string, now: number = Date.now()): CookieParseResult {
85
+ const pairs = collectPairs(raw);
86
+
87
+ const csrfToken = pairs.get(CSRF_KEY);
88
+ if (csrfToken === undefined || csrfToken === "") {
89
+ return {
90
+ ok: false,
91
+ code: "missing-csrf",
92
+ message: "cookie 中缺少 csrfToken,请复制完整的 cookie",
93
+ };
94
+ }
95
+
96
+ const digest = pairs.get(DIGEST_KEY);
97
+ if (digest === undefined || digest === "") {
98
+ return {
99
+ ok: false,
100
+ code: "missing-digest",
101
+ message: "cookie 中缺少 digest,请重新登录后复制完整的 cookie",
102
+ };
103
+ }
104
+
105
+ const segments = digest.split(".");
106
+ const expSeconds = segments.length === 3 ? decodeJwtExpSeconds(segments[1]) : undefined;
107
+ if (expSeconds === undefined) {
108
+ return {
109
+ ok: false,
110
+ code: "malformed-digest",
111
+ message: "cookie 的 digest 无法解析出有效期,请重新登录后复制完整的 cookie",
112
+ };
113
+ }
114
+
115
+ const digestExpMs = expSeconds * 1000;
116
+ const value: ParsedArkCookie = {
117
+ csrfToken,
118
+ digestExpMs,
119
+ expiresInMs: digestExpMs - now,
120
+ };
121
+
122
+ const accountId = pairs.get(ACCOUNT_ID_KEY);
123
+ if (accountId !== undefined && accountId !== "") {
124
+ value.accountId = accountId;
125
+ }
126
+
127
+ return { ok: true, value };
128
+ }
129
+
130
+ /** 把一段正向时长格式化为「X 天 Y 小时」/「X 小时 Y 分」/「X 分钟」/「<1 分钟」。 */
131
+ function formatDuration(ms: number): string {
132
+ const totalMinutes = Math.floor(ms / 60_000);
133
+ if (totalMinutes < 1) return "<1 分钟";
134
+
135
+ const days = Math.floor(totalMinutes / 1_440);
136
+ const hours = Math.floor(totalMinutes / 60) % 24;
137
+ const minutes = totalMinutes % 60;
138
+
139
+ if (days > 0) return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`;
140
+ if (hours > 0) return minutes > 0 ? `${hours} 小时 ${minutes} 分` : `${hours} 小时`;
141
+ return `${minutes} 分钟`;
142
+ }
143
+
144
+ /** 人可读剩余时间,例如 "3 小时 12 分" / "已过期 20 分钟" / "<1 分钟"。 */
145
+ export function describeExpiry(ms: number): string {
146
+ const text = formatDuration(Math.abs(ms));
147
+ return ms < 0 ? `已过期 ${text}` : text;
148
+ }
package/src/footer.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * footer 单行渲染 / 详情渲染 + 宽度预算裁剪(纯函数,无 IO)。
3
+ * 契约见 SPEC.md §3.2(数据模型);错误短原因映射见本文件下方的 ERROR_TEXTS。
4
+ * 未登记的 error code 只展示 code 本身,绝不回显 QuotaError.message。
5
+ */
6
+
7
+ import type { AccountReport, WindowLevel } from "./types.js";
8
+
9
+ export interface FooterOptions {
10
+ /** 可见字符上限(按 Unicode 码点计),默认 60。 */
11
+ maxWidth?: number;
12
+ /** 当前活跃账号 id(多账号时用于裁剪优先级)。 */
13
+ currentAccountId?: string;
14
+ }
15
+
16
+ /** 默认宽度预算(SPEC §4.1)/ 账号段落分隔符 / 截断收尾标记。 */
17
+ const DEFAULT_MAX_WIDTH = 60;
18
+ const SEGMENT_SEPARATOR = " · ";
19
+ const ELLIPSIS = "…";
20
+
21
+ /** footer 的窗口标签:session→5h、weekly→wk、monthly→mo。 */
22
+ const WINDOW_LABELS: Record<WindowLevel, string> = {
23
+ session: "5h",
24
+ weekly: "wk",
25
+ monthly: "mo",
26
+ };
27
+
28
+ /** 窗口渲染顺序固定为 5h / wk / mo,与 SPEC §4.1 的示例一致。 */
29
+ const WINDOW_ORDER: readonly WindowLevel[] = ["session", "weekly", "monthly"];
30
+
31
+ /** 错误码 → 可展示短句(footer 侧的唯一映射表)。 */
32
+ const ERROR_TEXTS: Record<string, string> = {
33
+ "missing-credential": "未配置",
34
+ NotLogin: "cookie 过期",
35
+ InvalidCSRFToken: "cookie 不完整",
36
+ "unknown-shape": "接口变更",
37
+ network: "网络错误",
38
+ timeout: "超时",
39
+ };
40
+
41
+ const MINUTE_MS = 60_000;
42
+ const HOUR_MS = 3_600_000;
43
+ const DAY_MS = 86_400_000;
44
+
45
+ /** footer 的百分比取整(SPEC 示例:12.5 → 13,100 → 100)。 */
46
+ function formatPercent(percent: number): string {
47
+ return `${Math.round(percent)}%`;
48
+ }
49
+
50
+ /** ERROR_TEXTS 映射:已知 code 用展示文本,`http-<status>` 用 `HTTP <status>`,其余原样返回 code。 */
51
+ function errorReason(code: string): string {
52
+ if (code.startsWith("http-")) {
53
+ const status = code.slice("http-".length);
54
+ return status.length > 0 ? `HTTP ${status}` : code;
55
+ }
56
+ return ERROR_TEXTS[code] ?? code;
57
+ }
58
+
59
+ /** 币种前缀:CNY→¥、USD→$、其余用「代码 + 空格」。 */
60
+ function currencyPrefix(currency: string): string {
61
+ if (currency === "CNY") return "¥";
62
+ if (currency === "USD") return "$";
63
+ return `${currency} `;
64
+ }
65
+
66
+ /** 可见宽度:按 Unicode 码点计,避免 UTF-16 代理对造成偏差。 */
67
+ function visibleWidth(text: string): number {
68
+ return Array.from(text).length;
69
+ }
70
+
71
+ function truncateToWidth(text: string, maxWidth: number): string {
72
+ const chars = Array.from(text);
73
+ if (chars.length <= maxWidth) return text;
74
+ return `${chars.slice(0, Math.max(maxWidth - 1, 0)).join("")}${ELLIPSIS}`;
75
+ }
76
+
77
+ /** 段落主体:windows 类为各窗口百分比,balance 类为各币种金额。 */
78
+ function segmentBody(report: AccountReport): string[] {
79
+ if (report.kind === "balance") {
80
+ return (report.balances ?? []).map((entry) => `${currencyPrefix(entry.currency)}${entry.total}`);
81
+ }
82
+ const parts: string[] = [];
83
+ for (const level of WINDOW_ORDER) {
84
+ const window = (report.windows ?? []).find((candidate) => candidate.level === level);
85
+ if (window === undefined) continue;
86
+ parts.push(`${WINDOW_LABELS[level]} ${formatPercent(window.percent)}`);
87
+ }
88
+ return parts;
89
+ }
90
+
91
+ /** 段落名的 stale 前缀。 */
92
+ function segmentName(report: AccountReport): string {
93
+ return report.stale === true ? `~${report.displayName}` : report.displayName;
94
+ }
95
+
96
+ /**
97
+ * 完整形态段落。刻意不含重置时间(resetsAt)—— 倒计时只由 renderDetail 渲染,
98
+ * 因此宽度裁剪的第一级(先砍倒计时)在 footer 里天然成立,无字段可砍。
99
+ */
100
+ function renderSegment(report: AccountReport): string {
101
+ const hasData =
102
+ (report.windows?.length ?? 0) > 0 || (report.balances?.length ?? 0) > 0;
103
+ // stale 数据优先于错误展示:SPEC §5 要求网络失败时保留上次成功数据,
104
+ // 错误原因改由 renderDetail 呈现。否则 SC7(断网后仍显示上次数据)会退化成丢数据。
105
+ if (report.error !== undefined && !hasData) {
106
+ return `${report.displayName} ✗ ${errorReason(report.error.code)}`;
107
+ }
108
+ const body = segmentBody(report);
109
+ if (body.length === 0) return segmentName(report);
110
+ return `${segmentName(report)} ${body.join(" ")}`;
111
+ }
112
+
113
+ /** 最简形态段落(裁剪第三级):只有 windows 段还能再退化,错误段与余额段本身已是最短。 */
114
+ function renderCompactSegment(report: AccountReport): string {
115
+ if (report.error !== undefined || report.kind === "balance") return renderSegment(report);
116
+ const windows = report.windows ?? [];
117
+ const window =
118
+ windows.find((candidate) => candidate.level === "monthly") ?? windows[windows.length - 1];
119
+ if (window === undefined) return segmentName(report);
120
+ return `${segmentName(report)} ${WINDOW_LABELS[window.level]} ${formatPercent(window.percent)}`;
121
+ }
122
+
123
+ /** 单行 footer 文本。空数组 → 返回 ""。 */
124
+ export function renderFooter(reports: AccountReport[], opts: FooterOptions = {}): string {
125
+ if (reports.length === 0) return "";
126
+ const maxWidth = opts.maxWidth ?? DEFAULT_MAX_WIDTH;
127
+ if (maxWidth <= 0) return "";
128
+
129
+ const full = reports.map(renderSegment).join(SEGMENT_SEPARATOR);
130
+ if (visibleWidth(full) <= maxWidth) return full;
131
+
132
+ // 裁剪顺序(SPEC §4.1):
133
+ // (a) 砍重置倒计时 —— footer 段落不含该字段,天然满足;
134
+ // (b) 全量 compact —— 优先保住**全部账号**(即使每段只剩一个数字);
135
+ // (c) 退到只留当前账号(完整形态);
136
+ // (d) 当前账号 compact;(e) 仍超宽 → 截断。
137
+ // (b) 优先于 (c) 的理由:知道「两个账号各自还剩多少」
138
+ // 比「一个账号的三个窗口」对切换决策更有价值(且原逻辑从不尝试 b,会白白丢掉账号)。
139
+ const currentAccountId = opts.currentAccountId;
140
+ const currentReports =
141
+ currentAccountId === undefined
142
+ ? []
143
+ : reports.filter((report) => report.accountId === currentAccountId);
144
+
145
+ const compactAll = reports.map(renderCompactSegment).join(SEGMENT_SEPARATOR);
146
+
147
+ if (currentReports.length > 0) {
148
+ if (visibleWidth(compactAll) <= maxWidth) return compactAll;
149
+ const trimmed = currentReports.map(renderSegment).join(SEGMENT_SEPARATOR);
150
+ if (visibleWidth(trimmed) <= maxWidth) return trimmed;
151
+ const compact = currentReports.map(renderCompactSegment).join(SEGMENT_SEPARATOR);
152
+ if (visibleWidth(compact) <= maxWidth) return compact;
153
+ return truncateToWidth(compact, maxWidth);
154
+ }
155
+
156
+ // 无 currentAccountId 或未命中(OpenCode/DeepSeek 恒定如此,Ark 未绑定账号时也是):
157
+ // 先逐段 compact 再截断,避免直接砍成「Ark-A 5h 13% wk 37…」这种半截形态。
158
+ if (visibleWidth(compactAll) <= maxWidth) return compactAll;
159
+ return truncateToWidth(compactAll, maxWidth);
160
+ }
161
+
162
+ /** 重置倒计时:<1 分钟 → "<1m";<1 天 → "4h 12m";否则 "2d 3h"。 */
163
+ function formatCountdown(resetsAtSec: number, nowMs: number): string {
164
+ const remainingMs = resetsAtSec * 1000 - nowMs;
165
+ if (remainingMs < MINUTE_MS) return "<1m";
166
+ if (remainingMs >= DAY_MS) {
167
+ const days = Math.floor(remainingMs / DAY_MS);
168
+ const hours = Math.floor((remainingMs % DAY_MS) / HOUR_MS);
169
+ return `${days}d ${hours}h`;
170
+ }
171
+ const hours = Math.floor(remainingMs / HOUR_MS);
172
+ const minutes = Math.floor((remainingMs % HOUR_MS) / MINUTE_MS);
173
+ return `${hours}h ${minutes}m`;
174
+ }
175
+
176
+ function detailLines(report: AccountReport, nowMs: number): string[] {
177
+ const staleSuffix = report.stale === true ? "(上次成功数据)" : "";
178
+ const lines = [`${report.displayName} (${report.sourceId}) ${staleSuffix}`.trimEnd()];
179
+
180
+ if (report.error !== undefined) {
181
+ lines.push(` ✗ ${report.error.code} · ${errorReason(report.error.code)}`);
182
+ return lines;
183
+ }
184
+
185
+ if (report.kind === "balance") {
186
+ for (const entry of report.balances ?? []) {
187
+ const parts = [`总额 ${entry.total}`];
188
+ if (entry.granted !== undefined) parts.push(`赠送 ${entry.granted}`);
189
+ if (entry.toppedUp !== undefined) parts.push(`充值 ${entry.toppedUp}`);
190
+ lines.push(` ${entry.currency} ${parts.join(" ")}`);
191
+ }
192
+ return lines;
193
+ }
194
+
195
+ const windows = report.windows ?? [];
196
+ for (const level of WINDOW_ORDER) {
197
+ const window = windows.find((candidate) => candidate.level === level);
198
+ if (window === undefined) continue;
199
+ let line = ` ${level} 已用 ${window.percent.toFixed(1)}%`;
200
+ if (window.resetsAt !== undefined) {
201
+ line += ` 重置于 ${formatCountdown(window.resetsAt, nowMs)} 后`;
202
+ }
203
+ lines.push(line);
204
+ }
205
+ return lines;
206
+ }
207
+
208
+ /** /quota 详情视图:逐行文本,含重置倒计时。空数组 → 返回 []。 */
209
+ export function renderDetail(reports: AccountReport[]): string[] {
210
+ const nowMs = Date.now();
211
+ const lines: string[] = [];
212
+ for (const report of reports) {
213
+ lines.push(...detailLines(report, nowMs));
214
+ }
215
+ return lines;
216
+ }