Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
/**
|
|
* Token management for the QQ Open Platform.
|
|
*
|
|
* All state (cache, singleflight promises, background refresh controllers)
|
|
* is encapsulated in the `TokenManager` class instance — no module-level
|
|
* globals, fully supporting multi-account concurrent operation.
|
|
*/
|
|
|
|
import {
|
|
asDateTimestampMs,
|
|
parseStrictPositiveInteger,
|
|
resolveExpiresAtMsFromDurationSeconds,
|
|
resolveTimestampMsToIsoString,
|
|
} from "openclaw/plugin-sdk/number-runtime";
|
|
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
|
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
import type { EngineLogger } from "../types.js";
|
|
import { formatErrorMessage } from "../utils/format.js";
|
|
|
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 7200;
|
|
const QQBOT_TOKEN_RESPONSE_LIMIT_BYTES = 8 * 1024;
|
|
|
|
/**
|
|
* Host-scoped SSRF policy for the QQ Bot token endpoint.
|
|
*
|
|
* `TOKEN_URL` is a hard-coded `https://bots.qq.com/...` constant, so this
|
|
* relaxation only ever applies to that single host. Fake-IP proxy stacks
|
|
* (sing-box, Clash, Surge, WSL2 DNS, etc.) routinely map `bots.qq.com` into
|
|
* the RFC 2544 benchmark range `198.18.0.0/15`, which the default SSRF
|
|
* guard blocks. We mirror the existing media-path pattern
|
|
* (`QQBOT_MEDIA_SSRF_POLICY` in `../utils/file-utils.ts`) so the relaxation
|
|
* stays narrowly host-scoped instead of weakening the global default.
|
|
*
|
|
* See https://github.com/openclaw/openclaw/issues/88984.
|
|
*/
|
|
const QQBOT_TOKEN_SSRF_POLICY: SsrFPolicy = {
|
|
hostnameAllowlist: ["bots.qq.com"],
|
|
allowRfc2544BenchmarkRange: true,
|
|
};
|
|
|
|
interface CachedToken {
|
|
token: string;
|
|
expiresAt: number;
|
|
appId: string;
|
|
}
|
|
|
|
interface BackgroundRefreshOptions {
|
|
refreshAheadMs?: number;
|
|
randomOffsetMs?: number;
|
|
minRefreshIntervalMs?: number;
|
|
retryDelayMs?: number;
|
|
}
|
|
|
|
function resolveTokenExpiresInSeconds(value: unknown): number {
|
|
const parsed = parseStrictPositiveInteger(value);
|
|
if (parsed !== undefined) {
|
|
return parsed;
|
|
}
|
|
if (value == null || (typeof value === "number" && !Number.isFinite(value))) {
|
|
return DEFAULT_TOKEN_EXPIRES_IN_SECONDS;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Per-appId token manager with caching, singleflight, and background refresh.
|
|
*
|
|
* Usage:
|
|
* ```ts
|
|
* const tm = new TokenManager({ logger, userAgent: 'QQBotPlugin/1.0' });
|
|
* const token = await tm.getAccessToken('appId', 'secret');
|
|
* ```
|
|
*/
|
|
export class TokenManager {
|
|
private readonly cache = new Map<string, CachedToken>();
|
|
private readonly fetchPromises = new Map<string, Promise<string>>();
|
|
private readonly refreshControllers = new Map<string, AbortController>();
|
|
private readonly logger?: EngineLogger;
|
|
private readonly resolveUserAgent: () => string;
|
|
|
|
constructor(config?: { logger?: EngineLogger; userAgent?: string | (() => string) }) {
|
|
this.logger = config?.logger;
|
|
const ua = config?.userAgent ?? "QQBotPlugin/unknown";
|
|
this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
|
|
}
|
|
|
|
/**
|
|
* Obtain an access token with caching and singleflight semantics.
|
|
*
|
|
* When multiple callers request a token for the same appId concurrently,
|
|
* only one actual HTTP request is made — the others await the same promise.
|
|
*/
|
|
async getAccessToken(appId: string, clientSecret: string): Promise<string> {
|
|
const normalizedId = appId.trim();
|
|
const cached = this.cache.get(normalizedId);
|
|
|
|
// Refresh slightly before expiry without making short-lived tokens unusable.
|
|
const refreshAheadMs = cached
|
|
? Math.min(5 * 60 * 1000, (cached.expiresAt - Date.now()) / 3)
|
|
: 0;
|
|
|
|
if (cached && Date.now() < cached.expiresAt - refreshAheadMs) {
|
|
return cached.token;
|
|
}
|
|
|
|
// Singleflight: reuse an in-progress fetch.
|
|
let pending = this.fetchPromises.get(normalizedId);
|
|
if (pending) {
|
|
this.logger?.debug?.(`[qqbot:token:${normalizedId}] Fetch in progress, reusing promise`);
|
|
return pending;
|
|
}
|
|
|
|
pending = (async () => {
|
|
try {
|
|
return await this.doFetchToken(normalizedId, clientSecret);
|
|
} finally {
|
|
this.fetchPromises.delete(normalizedId);
|
|
}
|
|
})();
|
|
|
|
this.fetchPromises.set(normalizedId, pending);
|
|
return pending;
|
|
}
|
|
|
|
/** Clear the cached token for one appId, or all. */
|
|
clearCache(appId?: string): void {
|
|
if (appId) {
|
|
this.cache.delete(appId.trim());
|
|
this.logger?.debug?.(`[qqbot:token:${appId}] Cache cleared`);
|
|
} else {
|
|
this.cache.clear();
|
|
this.logger?.debug?.(`[token] All caches cleared`);
|
|
}
|
|
}
|
|
|
|
/** Return token status for diagnostics. */
|
|
getStatus(appId: string): {
|
|
status: "valid" | "expired" | "refreshing" | "none";
|
|
expiresAt: number | null;
|
|
} {
|
|
if (this.fetchPromises.has(appId)) {
|
|
return { status: "refreshing", expiresAt: this.cache.get(appId)?.expiresAt ?? null };
|
|
}
|
|
const cached = this.cache.get(appId);
|
|
if (!cached) {
|
|
return { status: "none", expiresAt: null };
|
|
}
|
|
const remaining = cached.expiresAt - Date.now();
|
|
const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3);
|
|
return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt };
|
|
}
|
|
|
|
/** Start a background token refresh loop for one appId. */
|
|
startBackgroundRefresh(
|
|
appId: string,
|
|
clientSecret: string,
|
|
options?: BackgroundRefreshOptions,
|
|
): void {
|
|
if (this.refreshControllers.has(appId)) {
|
|
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh already running`);
|
|
return;
|
|
}
|
|
|
|
const {
|
|
refreshAheadMs = 5 * 60 * 1000,
|
|
randomOffsetMs = 30 * 1000,
|
|
minRefreshIntervalMs = 60 * 1000,
|
|
retryDelayMs = 5 * 1000,
|
|
} = options ?? {};
|
|
|
|
const controller = new AbortController();
|
|
this.refreshControllers.set(appId, controller);
|
|
const { signal } = controller;
|
|
|
|
const loop = async () => {
|
|
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh started`);
|
|
|
|
while (!signal.aborted) {
|
|
try {
|
|
await this.getAccessToken(appId, clientSecret);
|
|
const cached = this.cache.get(appId);
|
|
|
|
if (cached) {
|
|
const expiresIn = cached.expiresAt - Date.now();
|
|
const randomOffset = Math.random() * randomOffsetMs;
|
|
const refreshIn = Math.max(
|
|
expiresIn - refreshAheadMs - randomOffset,
|
|
minRefreshIntervalMs,
|
|
);
|
|
this.logger?.debug?.(
|
|
`[qqbot:token:${appId}] Next refresh in ${Math.round(refreshIn / 1000)}s`,
|
|
);
|
|
await this.abortableSleep(refreshIn, signal);
|
|
} else {
|
|
await this.abortableSleep(minRefreshIntervalMs, signal);
|
|
}
|
|
} catch (err) {
|
|
if (signal.aborted) {
|
|
break;
|
|
}
|
|
this.logger?.error?.(
|
|
`[qqbot:token:${appId}] Background refresh failed: ${formatErrorMessage(err)}`,
|
|
);
|
|
await this.abortableSleep(retryDelayMs, signal);
|
|
}
|
|
}
|
|
|
|
this.refreshControllers.delete(appId);
|
|
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh stopped`);
|
|
};
|
|
|
|
loop().catch((err: unknown) => {
|
|
this.refreshControllers.delete(appId);
|
|
this.logger?.error?.(
|
|
`[qqbot:token:${appId}] Background refresh crashed: ${formatErrorMessage(err)}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Stop background refresh for one appId, or all. */
|
|
stopBackgroundRefresh(appId?: string): void {
|
|
if (appId) {
|
|
const ctrl = this.refreshControllers.get(appId);
|
|
if (ctrl) {
|
|
ctrl.abort();
|
|
this.refreshControllers.delete(appId);
|
|
}
|
|
} else {
|
|
for (const ctrl of this.refreshControllers.values()) {
|
|
ctrl.abort();
|
|
}
|
|
this.refreshControllers.clear();
|
|
}
|
|
}
|
|
|
|
// ---- Internal ----
|
|
|
|
private async doFetchToken(appId: string, clientSecret: string): Promise<string> {
|
|
this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`);
|
|
|
|
let response: Response;
|
|
let release: (() => Promise<void>) | undefined;
|
|
try {
|
|
const guarded = await fetchWithSsrFGuard({
|
|
url: TOKEN_URL,
|
|
auditContext: "qqbot-token",
|
|
capture: false,
|
|
policy: QQBOT_TOKEN_SSRF_POLICY,
|
|
init: {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"User-Agent": this.resolveUserAgent(),
|
|
},
|
|
body: JSON.stringify({ appId, clientSecret }),
|
|
},
|
|
});
|
|
response = guarded.response;
|
|
release = guarded.release;
|
|
} catch (err) {
|
|
this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`);
|
|
throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, {
|
|
cause: err,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const traceId = response.headers.get("x-tps-trace-id") ?? "";
|
|
this.logger?.debug?.(
|
|
`[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`,
|
|
);
|
|
|
|
let rawBody: string;
|
|
try {
|
|
rawBody = await readResponseTextLimited(response, QQBOT_TOKEN_RESPONSE_LIMIT_BYTES);
|
|
} catch (err) {
|
|
throw new Error(`Failed to read access_token response: ${formatErrorMessage(err)}`, {
|
|
cause: err,
|
|
});
|
|
}
|
|
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
|
|
this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`);
|
|
|
|
let data: { access_token?: string; expires_in?: unknown };
|
|
try {
|
|
data = JSON.parse(rawBody);
|
|
} catch {
|
|
throw new Error("QQBot access_token response was malformed JSON");
|
|
}
|
|
|
|
if (!data.access_token) {
|
|
throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
|
|
}
|
|
|
|
const nowMs = asDateTimestampMs(Date.now());
|
|
if (nowMs === undefined) {
|
|
this.logger?.debug?.(`[qqbot:token:${appId}] Not cached: invalid process clock`);
|
|
return data.access_token;
|
|
}
|
|
const expiresAt =
|
|
resolveExpiresAtMsFromDurationSeconds(resolveTokenExpiresInSeconds(data.expires_in), {
|
|
nowMs,
|
|
}) ?? nowMs;
|
|
this.cache.set(appId, { token: data.access_token, expiresAt, appId });
|
|
this.logger?.debug?.(
|
|
`[qqbot:token:${appId}] Cached, expires at: ${resolveTimestampMsToIsoString(expiresAt)}`,
|
|
);
|
|
|
|
return data.access_token;
|
|
} finally {
|
|
await release?.();
|
|
}
|
|
}
|
|
|
|
private abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(resolve, ms);
|
|
if (signal.aborted) {
|
|
clearTimeout(timer);
|
|
reject(new Error("Aborted"));
|
|
return;
|
|
}
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
reject(new Error("Aborted"));
|
|
};
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
}
|
|
}
|