Files
SnowLuma/packages/onebot/src/instance-rkey.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

364 lines
12 KiB
TypeScript

import { createLogger } from '@snowluma/common/logger';
import type { DownloadRKeyInfo } from '@snowluma/core/bridge';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import type { MessageElement } from '@snowluma/protocol/events';
import type { RKeyConfig } from './types';
const log = createLogger('OneBot');
interface CachedRKey {
value: string;
expiresAt: number;
}
const RKEY_REFRESH_SKEW = 60;
const RKEY_REFRESH_COOLDOWN = 30;
// When the cache holds NO usable image rkey, the alternative to refreshing is
// handing out a bare URL the CDN rejects with `invalid rkey` (issue #156). So
// retry far more eagerly than the 30s steady-state backoff — but still throttled
// so a persistently-failing fetch isn't hammered once per inbound image.
const RKEY_EMPTY_COOLDOWN = 10;
// Upper bound on how long URL resolution may block on a refresh round-trip.
// A stuck packet send must never wedge message conversion — past this we
// fall back to whatever the cache holds (possibly nothing).
const RKEY_REFRESH_TIMEOUT_MS = 5000;
const PRIVATE_IMAGE_RKEY_TYPE = 10;
const GROUP_IMAGE_RKEY_TYPE = 20;
const PRIVATE_VIDEO_RKEY_TYPE = 12;
const GROUP_VIDEO_RKEY_TYPE = 22;
const PRIVATE_PTT_RKEY_TYPE = 14;
const GROUP_PTT_RKEY_TYPE = 24;
const FALLBACK_IMAGE_RKEY_TYPE = 2;
export class RKeyCache {
private cache = new Map<number, CachedRKey>();
private lastRefreshAttempt = 0;
private refreshInflight: Promise<void> | null = null;
/** Opt-in remote rkey endpoints; empty = feature off (the default). */
private fallbackServers: string[];
constructor(config?: RKeyConfig) {
this.fallbackServers = config?.fallbackServers ?? [];
}
/** Replace the fallback endpoint list (global-config hot-reload). */
setFallbackServers(servers: string[]): void {
this.fallbackServers = servers;
}
warmUp(bridge: BridgeInterface, uin: string): void {
this.ensureFresh(bridge).then(
() => log.info('rkeys loaded: UIN=%s count=%d', uin, this.cache.size),
(err) =>
log.warn(
'failed to load rkeys for UIN %s: %s',
uin,
err instanceof Error ? err.message : String(err),
),
);
}
resolveImageUrl(
bridge: BridgeInterface,
element: MessageElement,
isGroup: boolean,
): Promise<string> {
const url = element.imageUrl ?? '';
// Forwarded images retain their upload scene, encoded by appid in the URL.
const type =
imageRKeyTypeFromUrl(url) ??
(isGroup ? GROUP_IMAGE_RKEY_TYPE : PRIVATE_IMAGE_RKEY_TYPE);
return this.resolveUrl(bridge, url, type);
}
resolveMediaUrl(
bridge: BridgeInterface,
element: MessageElement,
isGroup: boolean,
): Promise<string> {
let type: number;
if (element.type === 'video') {
type = isGroup ? GROUP_VIDEO_RKEY_TYPE : PRIVATE_VIDEO_RKEY_TYPE;
} else if (element.type === 'record') {
type = isGroup ? GROUP_PTT_RKEY_TYPE : PRIVATE_PTT_RKEY_TYPE;
} else {
type = isGroup ? GROUP_IMAGE_RKEY_TYPE : PRIVATE_IMAGE_RKEY_TYPE;
}
return this.resolveUrl(bridge, element.url ?? '', type);
}
private async resolveUrl(bridge: BridgeInterface, url: string, type: number): Promise<string> {
if (!urlNeedsRKey(url)) {
return url;
}
const rkey = await this.findRKeyForType(bridge, type);
if (!rkey) {
return url;
}
const separator = url.includes('?') ? '&rkey=' : '?rkey=';
return url + separator + encodeURIComponent(stripRKeyPrefix(rkey));
}
private updateCache(rkeys: DownloadRKeyInfo[]): void {
const now = Math.floor(Date.now() / 1000);
for (const rk of rkeys) {
if (!rk.rkey || !rk.type) {
continue;
}
const baseTime = rk.createTime || now;
const ttl = rk.ttlSeconds || 3600;
this.cache.set(rk.type, {
value: rk.rkey,
expiresAt: baseTime + ttl,
});
}
}
/** True when at least one common image rkey (group or private) is cached and
* still valid — i.e. we can sign a typical image without a fresh fetch. */
private hasUsableRkey(): boolean {
return (
this.findInCache(GROUP_IMAGE_RKEY_TYPE) !== null ||
this.findInCache(PRIVATE_IMAGE_RKEY_TYPE) !== null
);
}
/** Pull a still-valid rkey out of the cache, or null if missing/expiring. */
private findInCache(primaryType: number): string | null {
const now = Math.floor(Date.now() / 1000);
const tryFind = (type: number): string | null => {
const cached = this.cache.get(type);
if (!cached || !cached.value) {
return null;
}
if (cached.expiresAt !== 0 && now + RKEY_REFRESH_SKEW >= cached.expiresAt) {
return null;
}
return cached.value;
};
return tryFind(primaryType) ?? tryFind(FALLBACK_IMAGE_RKEY_TYPE);
}
private async findRKeyForType(
bridge: BridgeInterface,
primaryType: number,
): Promise<string | null> {
const cached = this.findInCache(primaryType);
if (cached) {
return cached;
}
// Cache miss or inside the expiry skew: block on a fresh fetch rather
// than hand back a URL with a stale/absent rkey — the CDN rejects those
// with `{"retcode":-5503010,"retmsg":"invalid rkey"}`. Mirrors NapCat's
// getImageUrl path, which does `if (isExpired()) await refreshRkey()`
// before building the URL instead of refreshing in the background.
await this.ensureFresh(bridge);
return this.findInCache(primaryType);
}
/**
* Refresh the rkey cache via OIDB 0x9067_202, coalescing concurrent
* callers onto one in-flight request and backing off (RKEY_REFRESH_COOLDOWN
* when a usable rkey is held, RKEY_EMPTY_COOLDOWN while none is) so a
* persistently-failing fetch isn't hammered once per inbound image. The await
* is bounded by RKEY_REFRESH_TIMEOUT_MS.
*/
private async ensureFresh(bridge: BridgeInterface): Promise<void> {
if (!this.refreshInflight) {
const now = Math.floor(Date.now() / 1000);
// Back off hard once we hold a usable rkey; retry quickly while we don't,
// so a transient empty/failed fetch self-heals in seconds, not 30s (#156).
const cooldown = this.hasUsableRkey() ? RKEY_REFRESH_COOLDOWN : RKEY_EMPTY_COOLDOWN;
if (now - this.lastRefreshAttempt < cooldown) {
return;
}
this.lastRefreshAttempt = now;
this.refreshInflight = this.runRefresh(bridge).finally(() => {
this.refreshInflight = null;
});
}
const inflight = this.refreshInflight;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, RKEY_REFRESH_TIMEOUT_MS);
});
try {
await Promise.race([inflight, timeout]);
} finally {
clearTimeout(timer);
}
}
/** One refresh round: native OIDB fetch, then — only if that left us without
* a usable image rkey AND fallback servers are configured — the remote
* fallback. Never throws; logs and degrades to whatever the cache holds. */
private async runRefresh(bridge: BridgeInterface): Promise<void> {
try {
const rkeys = await bridge.apis.contacts.fetchDownloadRKeys();
this.updateCache(rkeys);
} catch (err) {
log.warn('rkey refresh failed: %s', err instanceof Error ? err.message : String(err));
}
if (this.fallbackServers.length > 0 && !this.hasUsableRkey()) {
await this.refreshFromFallback();
}
}
/** Ask each configured fallback server in turn for an rkey, stopping at the
* first that yields one. Populates the group/private image rkey types. */
private async refreshFromFallback(): Promise<void> {
for (const server of this.fallbackServers) {
try {
const data = await fetchFallbackRkey(server);
if (!data) {
continue;
}
const now = Math.floor(Date.now() / 1000);
const expiresAt = data.expiredTime > now ? data.expiredTime : now + 3600;
if (data.groupRkey) {
this.setFallbackRkey(GROUP_IMAGE_RKEY_TYPE, data.groupRkey, expiresAt);
}
if (data.privateRkey) {
this.setFallbackRkey(PRIVATE_IMAGE_RKEY_TYPE, data.privateRkey, expiresAt);
}
if (data.groupRkey || data.privateRkey) {
log.info('rkey loaded from fallback server %s', server);
return;
}
} catch (err) {
log.warn(
'rkey fallback server %s failed: %s',
server,
err instanceof Error ? err.message : String(err),
);
}
}
}
private setFallbackRkey(type: number, value: string, expiresAt: number): void {
this.cache.set(type, { value, expiresAt });
}
}
interface FallbackRkeyData {
groupRkey: string;
privateRkey: string;
/** Absolute unix-seconds expiry (0 when the server omits it). */
expiredTime: number;
}
/** Fetch + parse one fallback server's response. Accepts the bare
* `{ group_rkey, private_rkey, expired_time }` shape and an OneBot
* `{ retcode, data: {...} }` wrapper. Returns null when neither rkey is
* present. The stored values keep any leading `&rkey=`/`?rkey=` — resolveImageUrl
* strips it before re-appending, so both prefixed and bare tokens work. */
async function fetchFallbackRkey(server: string): Promise<FallbackRkeyData | null> {
const res = await fetch(server, { signal: AbortSignal.timeout(RKEY_REFRESH_TIMEOUT_MS) });
if (!res.ok) {
throw new Error(`http ${res.status}`);
}
let body = (await res.json()) as Record<string, unknown>;
if (body && typeof body === 'object' && 'retcode' in body && isRecord(body.data)) {
body = body.data;
}
if (!isRecord(body)) {
return null;
}
const groupRkey = typeof body.group_rkey === 'string' ? body.group_rkey : '';
const privateRkey = typeof body.private_rkey === 'string' ? body.private_rkey : '';
if (!groupRkey && !privateRkey) {
return null;
}
const expiredTime = typeof body.expired_time === 'number' ? body.expired_time : 0;
return { groupRkey, privateRkey, expiredTime };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// QQ-NT download appids embedded in the image URL query string. The scene
// they encode is a property of the image (where it was uploaded), so they
// outrank the carrying message's group/private context — see resolveImageUrl.
const APPID_PRIVATE_IMAGE = '1406';
const APPID_GROUP_IMAGE = '1407';
/** Map the `appid` baked into an NT download URL to its rkey type. Returns
* null when the URL carries no recognised appid, leaving the caller to fall
* back to the message's group/private context. */
function imageRKeyTypeFromUrl(url: string): number | null {
const match = url.match(/[?&]appid=(\d+)/);
if (!match) {
return null;
}
if (match[1] === APPID_PRIVATE_IMAGE) {
return PRIVATE_IMAGE_RKEY_TYPE;
}
if (match[1] === APPID_GROUP_IMAGE) {
return GROUP_IMAGE_RKEY_TYPE;
}
return null;
}
function urlNeedsRKey(url: string): boolean {
if (!url || url.includes('rkey=')) {
return false;
}
if (url.includes('gchat.qpic.cn')) {
return false;
}
return (
url.includes('multimedia.nt.qq.com.cn') ||
url.includes('.nt.qq.com.cn') ||
url.includes('/download')
);
}
function stripRKeyPrefix(rkey: string): string {
for (const prefix of ['&rkey=', '?rkey=']) {
if (rkey.startsWith(prefix)) {
return rkey.slice(prefix.length);
}
}
return rkey;
}