Files
dsh-llm-workbuddy/workbuddy-credits.js
dsh-custom a44227b69a feat: 个人定制版 —— API Key 模式显示剩余积分 + 悬浮卡片
基于 @axiaohungry/dsh-llm-workbuddy 1.3.21 (MIT)。
详见 README.CUSTOM.md。
2026-10-10 13:59:09 +08:00

449 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DEFAULT_BILLING_HOST = "https://www.codebuddy.cn";
const BROWSER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
const ENTERPRISE_EDITIONS = new Set(["ultimate", "exclusive"]);
const REMAINING_FIELDS = [
"SlicePeriodCapacityRemainPrecise",
"SlicePeriodCapacityRemain",
"CycleCapacityRemainPrecise",
"CycleCapacityRemain",
"CapacityRemainPrecise",
"CapacityRemain",
"RemainPrecise",
"Remain",
"Remaining",
"Balance",
];
const TOTAL_FIELDS = [
"SlicePeriodCapacitySizePrecise",
"SlicePeriodCapacitySize",
"CycleCapacitySizePrecise",
"CycleCapacitySize",
"CycleCapacityPrecise",
"CycleCapacity",
"CapacityPrecise",
"Capacity",
"TotalCapacityPrecise",
"TotalCapacity",
"PackageCapacity",
"Quota",
"Amount",
];
const EXPIRY_FIELDS = [
"DeductionEndTime",
"ExpiredTime",
"SlicePeriodEndTime",
"PackageEndTime",
"EndTime",
"CycleEndTime",
"ExpireTime",
"ExpirationTime",
"ValidEndTime",
"ValidPeriodEndTime",
"EndAt",
"ExpireAt",
];
const LABEL_FIELDS = ["PackageName", "PackageTypeName", "AccountName", "ProductName", "Name", "RuleName", "Description"];
const MAX_USAGE_PAGES = 100;
const PAGE_SIZE = 100;
function normalizeHost(value) {
const fallback = new URL(DEFAULT_BILLING_HOST);
if (typeof value !== "string" || !value.trim()) return fallback.origin;
try {
const candidate = new URL(value.includes("://") ? value : `https://${value}`);
if (candidate.protocol !== "https:") return fallback.origin;
const host = candidate.hostname.toLowerCase();
if (!["codebuddy.cn", "www.codebuddy.cn", "workbuddy.cn", "www.workbuddy.cn"].includes(host)) return fallback.origin;
return candidate.origin;
} catch {
return fallback.origin;
}
}
function billingHost(session) {
return normalizeHost(session?.auth?.domain);
}
function authToken(session) {
const token = typeof session?.auth?.accessToken === "string" ? session.auth.accessToken.trim() : "";
if (!token) throw new Error("WorkBuddy 登录令牌为空");
return token;
}
function billingHeaders(session, host, enterpriseId) {
const headers = {
accept: "application/json, text/plain, */*",
"content-type": "application/json",
"x-client-platform": "web",
origin: host,
referer: `${host}/profile/plans-usage`,
authorization: `Bearer ${authToken(session)}`,
"user-agent": BROWSER_USER_AGENT,
};
const domain = typeof session?.auth?.domain === "string" ? session.auth.domain.trim() : "";
if (domain) headers["x-domain"] = domain;
const userId = typeof session?.account?.userId === "string" ? session.account.userId.trim() : "";
if (userId) headers["x-user-id"] = userId;
if (enterpriseId) {
headers["x-enterprise-id"] = String(enterpriseId);
headers["x-tenant-id"] = String(enterpriseId);
}
return headers;
}
function timeoutSignal(timeoutMs, externalSignal) {
if (externalSignal) return externalSignal;
if (typeof AbortSignal?.timeout === "function") return AbortSignal.timeout(timeoutMs);
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs).unref?.();
return controller.signal;
}
async function readJson(response, action) {
const raw = await response.text();
if (!raw.trim()) throw new Error(`${action}返回空响应(HTTP ${response.status})`);
let payload;
try {
payload = JSON.parse(raw);
} catch (error) {
throw new Error(`${action}返回了无法解析的数据`, { cause: error });
}
if (!response.ok) throw new Error(`${action} HTTP ${response.status}: ${raw.slice(0, 160)}`);
if (payload?.code !== undefined && payload.code !== null && payload.code !== 0) {
throw new Error(`${action}失败(${payload.msg ?? payload.message ?? `code=${payload.code}`})`);
}
return payload;
}
function firstNumber(value, fields) {
for (const field of fields) {
const raw = value?.[field];
if (raw === undefined || raw === null || raw === "") continue;
const number = Number(raw);
if (Number.isFinite(number)) return number;
}
return null;
}
function parseTimestamp(value) {
if (value === undefined || value === null || value === "") return null;
if (typeof value === "number" || /^\d+(?:\.\d+)?$/.test(String(value).trim())) {
const number = Number(value);
if (!Number.isFinite(number)) return null;
return number < 1e12 ? Math.round(number * 1000) : Math.round(number);
}
const parsed = Date.parse(String(value).replace(/^(\d{4}-\d\d-\d\d)\s+/, "$1T"));
return Number.isFinite(parsed) ? parsed : null;
}
function firstTimestamp(value, fields) {
for (const field of fields) {
const parsed = parseTimestamp(value?.[field]);
if (parsed !== null) return parsed;
}
return null;
}
function firstText(value, fields) {
for (const field of fields) {
const text = value?.[field];
if (typeof text === "string" && text.trim()) return text.trim();
}
return "";
}
function extractAccounts(payload) {
const candidates = [
payload?.data?.Response?.Data?.Accounts,
payload?.data?.data?.Response?.Data?.Accounts,
payload?.data?.accounts,
payload?.data?.data?.accounts,
payload?.Response?.Data?.Accounts,
];
return candidates.find(Array.isArray) ?? [];
}
function extractCreditSegments(accounts, source = "积分") {
return (Array.isArray(accounts) ? accounts : [])
.flatMap((account) => {
const details = Array.isArray(account?.SlicePeriodUsageDetails) && account.SlicePeriodUsageDetails.length
? account.SlicePeriodUsageDetails.map((detail) => ({ ...account, ...detail }))
: [account];
return details.map((item) => {
const remaining = firstNumber(item, REMAINING_FIELDS);
if (remaining === null || remaining <= 0) return null;
const total = firstNumber(item, TOTAL_FIELDS);
return {
remaining: Number(remaining.toFixed(2)),
total: Number((total === null ? remaining : Math.max(total, remaining)).toFixed(2)),
expiresAt: firstTimestamp(item, EXPIRY_FIELDS),
source: firstText(item, LABEL_FIELDS) || source,
packageCode: item?.PackageCode ? String(item.PackageCode) : "",
};
});
})
.filter(Boolean);
}
function sortSegments(segments) {
return (Array.isArray(segments) ? segments : [])
.filter((segment) => segment && Number(segment.remaining) > 0)
.map((segment) => ({
remaining: Number(Number(segment.remaining).toFixed(2)),
total: Number(Number(segment.total || segment.remaining).toFixed(2)),
expiresAt: segment.expiresAt === null || segment.expiresAt === undefined ? null : Number(segment.expiresAt),
source: String(segment.source || "积分"),
packageCode: String(segment.packageCode || ""),
}))
.sort((left, right) => {
if (left.expiresAt === null && right.expiresAt !== null) return 1;
if (left.expiresAt !== null && right.expiresAt === null) return -1;
return (left.expiresAt || 0) - (right.expiresAt || 0);
});
}
function mergeSegments(segments) {
const merged = new Map();
for (const segment of Array.isArray(segments) ? segments : []) {
if (!segment || Number(segment.remaining) <= 0) continue;
const key = [segment.packageCode || segment.source || "积分", segment.expiresAt ?? "unknown"].join("|");
const previous = merged.get(key);
if (previous) {
previous.remaining += Number(segment.remaining) || 0;
previous.total += Number(segment.total || segment.remaining) || 0;
} else {
merged.set(key, {
remaining: Number(segment.remaining) || 0,
total: Number(segment.total || segment.remaining) || 0,
expiresAt: segment.expiresAt === undefined ? null : segment.expiresAt,
source: String(segment.source || "积分"),
packageCode: String(segment.packageCode || ""),
});
}
}
return sortSegments(Array.from(merged.values()));
}
function buildCreditResourceBody(now = new Date()) {
const end = new Date(now.getTime());
end.setFullYear(end.getFullYear() + 101);
const format = (date) => {
const pad = (value) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
return {
PageNumber: 1,
PageSize: 100,
ProductCode: "p_tcaca",
Status: [0, 3],
PackageEndTimeRangeBegin: format(now),
PackageEndTimeRangeEnd: format(end),
};
}
async function postJson(url, session, body, action, options = {}) {
const response = await (options.fetchImpl || globalThis.fetch)(url, {
method: "POST",
headers: billingHeaders(session, options.host, options.enterpriseId),
body: JSON.stringify(body),
signal: timeoutSignal(options.timeoutMs ?? 12_000, options.signal),
});
return readJson(response, action);
}
function enterpriseUsage(payload) {
const candidates = [payload?.data, payload?.data?.data, payload?.data?.Response?.Data, payload];
const data = candidates.find((value) => value && typeof value === "object" && ("limitNum" in value || "LimitNum" in value));
if (!data) return null;
const limitNum = Number(data.limitNum ?? data.LimitNum);
if (!Number.isFinite(limitNum)) return null;
const reset = firstTimestamp(data, ["cycleResetTime", "CycleResetTime", "CycleResetTimeMs"]);
if (limitNum === -1) return { unlimited: true, credits: null, total: null, count: 0, segments: [], cycleResetTime: reset };
const credit = Number(data.credit ?? data.Credit);
const used = Number.isFinite(credit) ? credit : 0;
const credits = Math.max(0, limitNum - used);
return {
unlimited: false,
credits: Number(credits.toFixed(2)),
total: Number(limitNum.toFixed(2)),
count: 1,
segments: sortSegments([{ remaining: credits, total: limitNum, expiresAt: reset, source: "企业配额" }]),
cycleResetTime: reset,
};
}
async function queryPersonalCredits(session, host, options = {}) {
const payload = await postJson(`${host}/v2/billing/meter/get-user-resource`, session, buildCreditResourceBody(), "WorkBuddy 积分接口", { ...options, host });
const accounts = extractAccounts(payload);
let credits = 0;
for (const account of accounts) {
const remaining = firstNumber(account, [
"CycleCapacityRemainPrecise",
"CycleCapacityRemain",
"CapacityRemainPrecise",
"CapacityRemain",
]);
if (remaining !== null) credits += remaining;
}
// [自用定制 2026-10-10] 提取会员套餐:账单里 CapacityType=2 是套餐/会员类,
// CapacityType=1 是签到/活动/兑换码赠送的积分包。
const planCandidates = accounts.filter((account) => String(account?.CapacityType) === "2");
const plan = planCandidates.find((account) => /高级版|标准版|旗舰版|专业版/.test(String(account?.PackageName || "")))
?? planCandidates[0];
return {
credits: Number(credits.toFixed(2)),
count: accounts.length,
totalDosage: payload?.data?.Response?.Data?.TotalDosage ?? payload?.data?.data?.Response?.Data?.TotalDosage ?? null,
segments: mergeSegments(extractCreditSegments(accounts)),
unlimited: false,
cycleResetTime: null,
planName: plan?.PackageName ?? null,
planCycleEnd: plan?.CycleEndTime ?? null,
};
}
async function resolveEnterpriseId(session, host, options = {}) {
const uid = session?.account?.userId;
if (!uid) return "";
const response = await (options.fetchImpl || globalThis.fetch)(`${host}/console/accounts`, {
method: "GET",
headers: billingHeaders(session, host),
signal: timeoutSignal(8_000, options.signal),
});
const payload = await readJson(response, "WorkBuddy 企业账号接口");
const accounts = payload?.data?.accounts;
const first = Array.isArray(accounts) ? accounts[0] : undefined;
return typeof first?.enterpriseId === "string" ? first.enterpriseId.trim() : "";
}
async function queryEnterpriseCredits(session, host, enterpriseId, options = {}) {
const payload = await postJson(`${host}/v2/billing/meter/get-enterprise-user-usage`, session, {}, "WorkBuddy 企业积分接口", {
...options,
host,
enterpriseId,
});
const parsed = enterpriseUsage(payload);
if (!parsed) throw new Error("WorkBuddy 企业积分接口返回数据无法解析");
return parsed;
}
function pad2(value) {
return String(value).padStart(2, "0");
}
function formatLocalDateTime(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
}
function localDateString(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function parseUsageTime(value) {
return parseTimestamp(value);
}
function usageRows(payload) {
const data = payload?.data;
const rows = data && Array.isArray(data.data) ? data.data : data && Array.isArray(data.rows) ? data.rows : [];
const total = Number(data?.total);
return { rows, total: Number.isSafeInteger(total) && total >= 0 ? total : rows.length };
}
async function queryTodayUsage(session, host, options = {}) {
const now = new Date();
const start = new Date(now.getTime());
start.setHours(0, 0, 0, 0);
const fetchImpl = options.fetchImpl || globalThis.fetch;
const records = [];
let expectedTotal = null;
let fetched = 0;
for (let pageNum = 1; pageNum <= MAX_USAGE_PAGES; pageNum += 1) {
const payload = await postJson(`${host}/billing/meter/get-user-request-usage`, session, {
startTime: formatLocalDateTime(start),
endTime: formatLocalDateTime(now),
pageNum,
pageSize: PAGE_SIZE,
}, "WorkBuddy 今日请求量接口", { ...options, host, fetchImpl, timeoutMs: options.usageTimeoutMs ?? 8_000 });
const page = usageRows(payload);
if (expectedTotal === null) expectedTotal = page.total;
if (!page.rows.length) break;
for (const row of page.rows) {
const requestTime = parseUsageTime(row?.requestTime ?? row?.RequestTime ?? row?.createdAt);
const credit = Number(row?.credit ?? row?.Credit ?? 0);
if (requestTime === null || !Number.isFinite(credit) || credit < 0) continue;
records.push({ requestTime, credit });
}
fetched += page.rows.length;
if (fetched >= expectedTotal || page.rows.length < PAGE_SIZE) break;
}
const used = records.reduce((sum, record) => sum + record.credit, 0);
return {
date: localDateString(now),
used: Number(used.toFixed(2)),
count: records.length,
synced: true,
};
}
async function retry(task, attempts = 2) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await task();
} catch (error) {
lastError = error;
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
}
}
throw lastError;
}
export async function fetchWorkBuddyCredits(session, options = {}) {
const host = billingHost(session);
const account = session?.account && typeof session.account === "object" ? session.account : {};
let creditResult;
let creditError = null;
try {
const enterpriseId = typeof account.enterpriseId === "string" ? account.enterpriseId.trim() : "";
const enterpriseEdition = typeof account.type === "string" && ENTERPRISE_EDITIONS.has(account.type.toLowerCase());
if (enterpriseId || enterpriseEdition) {
const resolvedId = enterpriseId || await retry(() => resolveEnterpriseId(session, host, options));
if (!resolvedId) throw new Error("企业账号缺少 enterpriseId");
creditResult = await retry(() => queryEnterpriseCredits(session, host, resolvedId, options));
} else {
creditResult = await retry(() => queryPersonalCredits(session, host, options));
}
} catch (error) {
creditError = error instanceof Error ? error.message : String(error);
creditResult = { credits: null, count: 0, totalDosage: null, segments: [], unlimited: false, cycleResetTime: null };
}
let todayUsage;
let todayUsageError = null;
try {
todayUsage = await retry(() => queryTodayUsage(session, host, options));
} catch (error) {
todayUsageError = error instanceof Error ? error.message : String(error);
}
return {
...creditResult,
creditError,
todayUsage: todayUsage ?? null,
todayUsageError,
};
}
export const __testing = Object.freeze({
billingHost,
buildCreditResourceBody,
enterpriseUsage,
extractAccounts,
extractCreditSegments,
formatLocalDateTime,
mergeSegments,
normalizeHost,
queryTodayUsage,
sortSegments,
});