Files
dsh-llm-workbuddy/workbuddy-auth.js
T
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

431 lines
17 KiB
JavaScript
Raw 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.
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
export const WORKBUDDY_SESSION_REF = "WORKBUDDY_LOGIN_SESSION";
export const WORKBUDDY_SESSIONS_REF = "WORKBUDDY_LOGIN_SESSIONS";
export const WORKBUDDY_SESSION_ROUTING_REF = "WORKBUDDY_SESSION_ROUTING";
export const WORKBUDDY_API_KEYS_REF = "WORKBUDDY_API_KEYS";
export const LEGACY_SESSION_REF = "CODEBUDDY_LOGIN_SESSION";
export const LEGACY_SESSIONS_REF = "CODEBUDDY_LOGIN_SESSIONS";
export const LEGACY_API_KEYS_REF = "CODEBUDDY_API_KEYS";
function normalizeWorkBuddySessionBinding(binding) {
if (!binding || typeof binding !== "object") return undefined;
if (binding.mode === "token" && typeof binding.accountId === "string" && binding.accountId.trim()) {
return { mode: "token", accountId: binding.accountId.trim() };
}
if (binding.mode === "api-key" && typeof binding.apiKeyRef === "string" && binding.apiKeyRef.trim()) {
return { mode: "api-key", apiKeyRef: binding.apiKeyRef.trim() };
}
return undefined;
}
export function createWorkBuddySessionRoutingState(enabled = false, bindings = {}, lastUsed) {
const normalized = {};
if (bindings && typeof bindings === "object" && !Array.isArray(bindings)) {
for (const [sessionId, binding] of Object.entries(bindings)) {
if (typeof sessionId !== "string" || !sessionId.trim() || !binding || typeof binding !== "object") continue;
const value = normalizeWorkBuddySessionBinding(binding);
if (value) normalized[sessionId] = value;
}
}
const recent = normalizeWorkBuddySessionBinding(lastUsed);
return {
version: 1,
enabled: enabled === true,
bindings: normalized,
...(recent ? { lastUsed: recent } : {}),
};
}
export function serializeWorkBuddySessionRouting(state) {
return JSON.stringify(createWorkBuddySessionRoutingState(state?.enabled, state?.bindings, state?.lastUsed));
}
export function parseWorkBuddySessionRouting(value) {
let parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("WorkBuddy 会话级认证配置已损坏,请重新设置", { cause: error });
}
return createWorkBuddySessionRoutingState(parsed?.enabled, parsed?.bindings, parsed?.lastUsed);
}
const BASE_URL = "https://copilot.tencent.com/v2/plugin";
const USER_AGENT = "CLI/unknown CodeBuddy/2.137.1";
const REQUEST_HEADERS = {
accept: "application/json",
"content-type": "application/json",
"user-agent": USER_AGENT,
"x-product": "SaaS",
};
const NO_ACCOUNT_HEADERS = {
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-No-Enterprise-Id": "true",
"X-No-Department-Info": "true",
};
const NO_ID_HEADERS = {
"X-No-User-Id": "true",
"X-No-Enterprise-Id": "true",
"X-No-Department-Info": "true",
};
function calculateExpiresAt(auth, now = Date.now()) {
const result = { ...auth };
if (!result.expiresAt && Number.isFinite(result.expiresIn)) result.expiresAt = now + result.expiresIn * 1000;
if (!result.refreshExpiresAt && Number.isFinite(result.refreshExpiresIn)) result.refreshExpiresAt = now + result.refreshExpiresIn * 1000;
return result;
}
async function responseBody(response, action) {
try {
return await response.json();
} catch (error) {
throw new Error(`${action}返回了无法解析的数据`, { cause: error });
}
}
async function request(path, options, action) {
let response;
try {
response = await fetch(`${BASE_URL}${path}`, options);
} catch (error) {
if (options.signal?.aborted) throw new Error(`${action}已取消`, { cause: error });
throw new Error(`${action}无法连接 WorkBuddy 中国站`, { cause: error });
}
const body = await responseBody(response, action);
if (!response.ok || body?.code !== 0) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`);
return body.data;
}
function enterpriseHeaders(session) {
const enterpriseId = session.account?.enterpriseId;
return {
...(enterpriseId ? { "X-Enterprise-Id": enterpriseId, "X-Tenant-Id": enterpriseId } : {}),
...(session.auth?.domain ? { "X-Domain": session.auth.domain } : {}),
};
}
async function poll(path, headers, action, timeoutMs, signal) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await delay(1000, undefined, { signal });
let response;
try {
response = await fetch(`${BASE_URL}${path}`, { headers: { ...REQUEST_HEADERS, ...headers }, signal });
} catch (error) {
if (signal?.aborted) throw new Error(`${action}已取消`, { cause: error });
continue;
}
const body = await responseBody(response, action);
if (response.ok && body?.code === 0 && body.data) return body.data;
if (response.status === 401 || response.status === 403) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`);
}
throw new Error(`${action}超时`);
}
function openBrowser(url) {
const [command, args] = process.platform === "win32"
? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
: process.platform === "darwin"
? ["open", [url]]
: ["xdg-open", [url]];
return new Promise((resolve, reject) => {
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
child.once("spawn", () => {
child.unref();
resolve();
});
child.once("error", reject);
});
}
function textValue(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
}
return undefined;
}
function tokenClaims(token) {
if (typeof token !== "string") return {};
try {
const payload = token.split(".")[1];
if (!payload) return {};
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function normalizeApiKeyEntry(entry, now = Date.now()) {
const ref = textValue(entry?.ref);
if (!ref || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) throw new Error("WorkBuddy API Key 引用无效");
const id = textValue(entry?.id) ?? `dsh:${ref}`;
return {
id,
ref,
label: textValue(entry?.label) ?? "DSH 保存的 API Key",
createdAt: Number.isFinite(entry?.createdAt) ? entry.createdAt : now,
updatedAt: Number.isFinite(entry?.updatedAt) ? entry.updatedAt : now,
};
}
export function createWorkBuddyApiKeyStore(entries = [], activeId) {
const keys = [];
for (const entry of entries) {
const normalized = normalizeApiKeyEntry(entry);
if (!keys.some((item) => item.id === normalized.id || item.ref === normalized.ref)) keys.push(normalized);
}
const selected = activeId === null
? null
: typeof activeId === "string" && keys.some((entry) => entry.id === activeId) ? activeId : keys[0]?.id;
return { version: 1, activeId: selected, entries: keys };
}
export function upsertWorkBuddyApiKey(store, entry, now = Date.now()) {
const current = createWorkBuddyApiKeyStore(store?.entries ?? [], store?.activeId);
const incoming = normalizeApiKeyEntry({ ...entry, updatedAt: now }, now);
const index = current.entries.findIndex((item) => item.id === incoming.id || item.ref === incoming.ref);
if (index >= 0) incoming.createdAt = current.entries[index].createdAt;
const entries = index >= 0
? current.entries.map((item, position) => position === index ? incoming : item)
: [...current.entries, incoming];
return { version: 1, activeId: incoming.id, entries };
}
export function serializeWorkBuddyApiKeys(store) {
const normalized = createWorkBuddyApiKeyStore(store?.entries ?? [], store?.activeId);
return JSON.stringify(normalized);
}
export function parseWorkBuddyApiKeys(value) {
let parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("WorkBuddy API Key 列表已损坏,请重新配置", { cause: error });
}
if (!Array.isArray(parsed?.entries)) throw new Error("WorkBuddy API Key 列表格式无效,请重新配置");
return createWorkBuddyApiKeyStore(parsed.entries, parsed.activeId);
}
export function workBuddyApiKeyEntries(store) {
return (store?.entries ?? []).map(({ id, ref, label, createdAt, updatedAt }) => ({ id, ref, label, createdAt, updatedAt }));
}
function normalizeAccount(account, auth) {
// The account endpoint has returned both a flat object and wrapped objects
// across WorkBuddy versions. Keep all known wrappers in the lookup so a newly
// added account gets the same display metadata as an imported account.
const sources = [
account,
account?.account,
account?.user,
account?.userInfo,
account?.profile,
account?.data,
].filter((source) => source && typeof source === "object");
const read = (...keys) => textValue(...sources.flatMap((source) => keys.map((key) => source[key])));
const claims = tokenClaims(auth?.accessToken);
const userId = read("userId", "uid", "user_id", "id") ?? textValue(claims.userId, claims.uid, claims.user_id, claims.sub);
const enterpriseId = read("enterpriseId", "tenantId", "enterprise_id", "tenant_id")
?? textValue(claims.enterpriseId, claims.tenantId, claims.enterprise_id, claims.tenant_id);
const email = read("email", "mail", "emailAddress") ?? textValue(claims.email, claims.mail);
const uin = read("uin", "phoneNumber", "phone", "mobile", "mobilePhone")
?? textValue(claims.uin, claims.phoneNumber, claims.phone_number, claims.mobile);
const type = read("type", "accountType", "account_type");
const displayName = read("displayName", "name", "nickname", "username", "accountName")
?? uin
?? textValue(claims.displayName, claims.name, claims.nickname, claims.username, claims.preferred_username)
?? email;
return {
...(userId ? { userId } : {}),
...(enterpriseId ? { enterpriseId } : {}),
...(email ? { email } : {}),
...(uin ? { uin } : {}),
...(type ? { type } : {}),
...(displayName ? { displayName } : {}),
};
}
export function workBuddySessionId(session) {
const account = normalizeAccount(session?.account, session?.auth);
if (account.userId) return `user:${account.userId}`;
if (account.email) return `email:${account.email}`;
if (account.enterpriseId) return `enterprise:${account.enterpriseId}`;
const refreshToken = session?.auth?.refreshToken ?? session?.auth?.accessToken;
if (!refreshToken) throw new Error("WorkBuddy 登录会话缺少账号标识和令牌");
return `token:${createHash("sha256").update(refreshToken).digest("hex")}`;
}
export function workBuddySessionLabel(session) {
const account = normalizeAccount(session?.account, session?.auth);
return account.displayName ?? account.email ?? account.userId ?? account.enterpriseId ?? `账号 ${workBuddySessionId(session).slice(-8)}`;
}
export function normalizeWorkBuddySessionEntry(session, now = Date.now()) {
const normalized = {
auth: calculateExpiresAt(session?.auth),
account: normalizeAccount(session?.account, session?.auth),
};
if (!normalized.auth.accessToken || !normalized.auth.refreshToken) throw new Error("WorkBuddy 登录会话无效");
const id = typeof session?.id === "string" && session.id.trim() ? session.id : workBuddySessionId(normalized);
const createdAt = Number.isFinite(session?.createdAt) ? session.createdAt : now;
return {
id,
label: typeof session?.label === "string" && session.label.trim() ? session.label : workBuddySessionLabel(normalized),
createdAt,
updatedAt: Number.isFinite(session?.updatedAt) ? session.updatedAt : now,
...normalized,
};
}
export function createWorkBuddySessionStore(entries = [], activeId) {
const sessions = [];
for (const entry of entries) {
const normalized = normalizeWorkBuddySessionEntry(entry);
if (!sessions.some((item) => item.id === normalized.id)) sessions.push(normalized);
}
const selected = typeof activeId === "string" && sessions.some((entry) => entry.id === activeId) ? activeId : sessions[0]?.id;
return { version: 1, activeId: selected, sessions };
}
export function upsertWorkBuddySession(store, session, now = Date.now()) {
const current = createWorkBuddySessionStore(store?.sessions ?? [], store?.activeId);
const incoming = normalizeWorkBuddySessionEntry({ ...session, updatedAt: now }, now);
const index = current.sessions.findIndex((entry) => entry.id === incoming.id);
if (index >= 0) incoming.createdAt = current.sessions[index].createdAt;
const sessions = index >= 0
? current.sessions.map((entry, position) => position === index ? incoming : entry)
: [...current.sessions, incoming];
return { version: 1, activeId: incoming.id, sessions };
}
export function activeWorkBuddySession(store) {
return store?.sessions?.find((entry) => entry.id === store.activeId) ?? store?.sessions?.[0];
}
export function workBuddySessionAccounts(store) {
return (store?.sessions ?? []).map(({ id, label, account, createdAt, updatedAt }) => ({
id,
label,
accountName: label,
userId: account?.userId ?? null,
account,
createdAt,
updatedAt,
}));
}
export async function loginWorkBuddy(onAuthUrl, signal) {
const state = await request("/auth/state?platform=CLI", {
method: "POST",
headers: { ...REQUEST_HEADERS, ...NO_ACCOUNT_HEADERS },
body: "{}",
signal,
}, "创建 WorkBuddy 登录会话");
if (!state?.state || !state?.authUrl) throw new Error("WorkBuddy 登录接口没有返回登录地址");
try {
await openBrowser(state.authUrl);
onAuthUrl?.(state.authUrl, true);
} catch {
onAuthUrl?.(state.authUrl, false);
}
const auth = calculateExpiresAt(await poll(
`/auth/token?state=${encodeURIComponent(state.state)}`,
NO_ACCOUNT_HEADERS,
"等待 WorkBuddy 登录",
10 * 60_000,
signal,
));
if (!auth.accessToken || !auth.refreshToken) throw new Error("WorkBuddy 登录接口没有返回完整令牌");
const account = await poll(
`/login/account?state=${encodeURIComponent(state.state)}`,
{ ...enterpriseHeaders({ auth }), authorization: `Bearer ${auth.accessToken}`, ...NO_ID_HEADERS },
"获取 WorkBuddy 账号",
60_000,
signal,
);
return { auth, account: normalizeAccount(account, auth) };
}
export async function refreshWorkBuddySession(session, signal) {
if (!session?.auth?.refreshToken) throw new Error("WorkBuddy 登录会话缺少刷新令牌,请重新登录");
const auth = await request("/auth/token/refresh", {
method: "POST",
headers: {
...REQUEST_HEADERS,
...enterpriseHeaders(session),
"X-Refresh-Token": session.auth.refreshToken,
"X-Auth-Refresh-Source": "plugin",
},
body: "{}",
signal,
}, "刷新 WorkBuddy 登录令牌");
const fresh = calculateExpiresAt(auth);
const merged = { ...session.auth, ...fresh, refreshToken: fresh?.refreshToken ?? session.auth.refreshToken };
if (!merged.accessToken) throw new Error("WorkBuddy 刷新接口没有返回访问令牌");
return { auth: merged, account: normalizeAccount(session.account, merged) };
}
export function serializeWorkBuddySession(session) {
if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("WorkBuddy 登录会话无效");
return JSON.stringify({ auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account, session.auth) });
}
export function serializeWorkBuddySessions(store) {
const normalized = createWorkBuddySessionStore(store?.sessions ?? [], store?.activeId);
if (normalized.sessions.length === 0) throw new Error("WorkBuddy 登录账号列表为空");
return JSON.stringify(normalized);
}
export function parseWorkBuddySession(value) {
let session;
try {
session = JSON.parse(value);
} catch (error) {
throw new Error("WorkBuddy 登录凭据已损坏,请重新登录", { cause: error });
}
if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("WorkBuddy 登录凭据不完整,请重新登录");
return { auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account, session.auth) };
}
export function parseWorkBuddySessions(value) {
let parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error("WorkBuddy 登录账号列表已损坏,请重新登录", { cause: error });
}
if (Array.isArray(parsed?.sessions)) return createWorkBuddySessionStore(parsed.sessions, parsed.activeId);
if (parsed?.auth) {
const session = parseWorkBuddySession(value);
return createWorkBuddySessionStore([session], workBuddySessionId(session));
}
throw new Error("WorkBuddy 登录账号列表格式无效,请重新登录");
}
export function sessionNeedsRefresh(session, now = Date.now()) {
const expiresAt = Number(session?.auth?.expiresAt);
if (Number.isFinite(expiresAt)) return expiresAt <= now + 2 * 60_000;
try {
const payload = JSON.parse(Buffer.from(session.auth.accessToken.split(".")[1], "base64url").toString("utf8"));
return Number.isFinite(payload.exp) ? payload.exp * 1000 <= now + 2 * 60_000 : true;
} catch {
return true;
}
}
export function sessionCacheDeadline(session, now = Date.now()) {
const expiresAt = Number(session?.auth?.expiresAt);
return Number.isFinite(expiresAt)
? Math.max(now, Math.min(expiresAt - 2 * 60_000, now + 30 * 60_000))
: now + 5 * 60_000;
}