feat(onebot): opt-in remote rkey fallback server (#156)

On accounts where the native OIDB 0x9067_202 rkey fetch persistently returns
nothing, every image was served as a bare multimedia.nt.qq.com.cn URL the CDN
rejects with invalid rkey — SnowLuma had no fallback, unlike NapCat.

Add config.rkey.fallbackServers (default []: OFF; no third-party server is
contacted unless the operator configures one). When the native fetch leaves the
cache without a usable image rkey, each configured HTTP endpoint is tried in
turn for { group_rkey, private_rkey, expired_time } (NapCat rkey-server format;
an OneBot { retcode, data } wrapper is also accepted), populating the group/
private image rkey types. Values keep any leading &rkey=/?rkey= since
resolveImageUrl strips before re-appending.

WebUI has no form yet but round-trips rkey.fallbackServers verbatim so a hand-
edited value survives an unrelated config save. Part B of #156 (cooldown is A).
This commit is contained in:
DrSmoothl
2026-06-28 01:37:58 +08:00
parent 219d60410a
commit 12b01907bc
8 changed files with 265 additions and 7 deletions
+33
View File
@@ -9,6 +9,7 @@ import type {
MessageFormat,
OneBotConfig,
OneBotNetworks,
RKeyConfig,
StatusCommandConfig,
WsClientNetwork,
WsRole,
@@ -64,6 +65,7 @@ export function makeDefaultOneBotConfig(): OneBotConfig {
musicSignUrl: '',
statusCommand: makeDefaultStatusCommand(),
notifications: { channelIds: [] },
rkey: { fallbackServers: [] },
};
}
@@ -123,6 +125,7 @@ function toJsonObject(config: OneBotConfig): JsonObject {
trigger: config.statusCommand.trigger,
},
notifications: { channelIds: config.notifications?.channelIds ?? [] },
rkey: { fallbackServers: config.rkey?.fallbackServers ?? [] },
};
}
@@ -211,9 +214,39 @@ function fromJson(sources: JsonObject[], freshInstall: boolean): OneBotConfig {
musicSignUrl,
statusCommand: parseStatusCommand(sources),
notifications: parseNotifications(sources),
rkey: parseRKeyConfig(sources),
};
}
/** Last-write-wins merge of `rkey.fallbackServers` across config sources. Only
* http(s) URLs are kept; the list is deduped. Default empty (feature off). */
function parseRKeyConfig(sources: JsonObject[]): RKeyConfig {
let fallbackServers: string[] = [];
for (const src of sources) {
const raw = src.rkey;
if (!isObject(raw)) continue;
if (Array.isArray(raw.fallbackServers)) {
fallbackServers = normalizeRkeyServers(raw.fallbackServers);
}
}
return { fallbackServers };
}
function normalizeRkeyServers(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const item of value) {
if (typeof item !== 'string') continue;
const v = item.trim();
if (!v || !/^https?:\/\//i.test(v)) continue;
if (seen.has(v)) continue;
seen.add(v);
out.push(v);
}
return out;
}
/** Last-write-wins merge of `notifications.channelIds` across config sources,
* each id validated as a slug + deduped. Mirrors the channel-id rule in
* packages/core/src/notifications/config.ts (CHANNEL_ID_RE) — duplicated
+79 -5
View File
@@ -2,6 +2,7 @@ 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');
@@ -36,6 +37,12 @@ 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 readonly fallbackServers: string[];
constructor(config?: RKeyConfig) {
this.fallbackServers = config?.fallbackServers ?? [];
}
warmUp(bridge: BridgeInterface, uin: string): void {
this.ensureFresh(bridge).then(
@@ -153,11 +160,7 @@ export class RKeyCache {
const cooldown = this.hasUsableRkey() ? RKEY_REFRESH_COOLDOWN : RKEY_EMPTY_COOLDOWN;
if (now - this.lastRefreshAttempt < cooldown) return;
this.lastRefreshAttempt = now;
this.refreshInflight = bridge.apis.contacts.fetchDownloadRKeys()
.then((rkeys) => { this.updateCache(rkeys); })
.catch((err) => {
log.warn('rkey refresh failed: %s', err instanceof Error ? err.message : String(err));
})
this.refreshInflight = this.runRefresh(bridge)
.finally(() => { this.refreshInflight = null; });
}
@@ -172,6 +175,77 @@ export class RKeyCache {
if (timer) 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, type, createTime: 0, ttlSeconds: 0, 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
+1 -1
View File
@@ -81,7 +81,7 @@ export class OneBotInstance {
? moduleLog.child({ uin: uinNum })
: moduleLog;
this.rkeyCache = new RKeyCache();
this.rkeyCache = new RKeyCache(config.rkey);
this.mediaStore = new MediaStore(path.join('data', this.uin, 'media.db'));
this.messageStore = new MessageStore(path.join('data', this.uin, 'messages.json'));
this.reactionStore = new ReactionStore(path.join('data', this.uin, 'reactions.db'));
+21
View File
@@ -82,6 +82,24 @@ export interface StatusCommandConfig {
trigger: string;
}
/**
* Remote rkey fallback. QQ-NT image/file download URLs need a short-lived,
* server-issued `rkey`; SnowLuma fetches it via OIDB 0x9067_202. On accounts
* where that native fetch persistently returns nothing, every image would be
* served as a bare URL the CDN rejects with `invalid rkey` (#156). When
* `fallbackServers` is non-empty, SnowLuma asks those HTTP endpoints for an
* rkey instead. OFF by default (empty list): no third-party server is ever
* contacted unless you opt in by configuring your own endpoint.
*/
export interface RKeyConfig {
/**
* HTTP(S) endpoints returning `{ group_rkey, private_rkey, expired_time }`
* (NapCat rkey-server format; an OneBot `{ retcode, data }` wrapper is also
* accepted). Tried in order until one yields a usable rkey. Default `[]`.
*/
fallbackServers: string[];
}
/** Per-UIN OneBot configuration. */
export interface OneBotConfig {
networks: OneBotNetworks;
@@ -93,6 +111,9 @@ export interface OneBotConfig {
* validated slugs; channels themselves live in config/notifications.json).
* Always present after normalization. */
notifications?: { channelIds: string[] };
/** Remote rkey fallback servers (opt-in, default off). Always present after
* normalization. */
rkey: RKeyConfig;
}
export interface MessageMeta {
+24
View File
@@ -63,6 +63,30 @@ describe('loadOneBotConfig', () => {
expect(onDisk.statusCommand).toEqual({ enabled: true, swallow: false, cooldownSeconds: 5, trigger: '#sl' });
});
it('defaults rkey.fallbackServers to empty (feature off)', () => {
const config = loadOneBotConfig('10055');
expect(config.rkey).toEqual({ fallbackServers: [] });
});
it('parses, validates (http only), dedupes and round-trips rkey.fallbackServers', () => {
const uin = '10056';
const dir = path.join(tempDir, 'config');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `onebot_${uin}.json`),
JSON.stringify({ rkey: { fallbackServers: ['https://a.example/r', 'not-a-url', 'https://a.example/r', 'http://b.example/r', 42] } }),
'utf8',
);
const config = loadOneBotConfig(uin);
expect(config.rkey.fallbackServers).toEqual(['https://a.example/r', 'http://b.example/r']);
saveOneBotConfig(uin, config);
const onDisk = JSON.parse(fs.readFileSync(path.join(dir, `onebot_${uin}.json`), 'utf8'));
expect(onDisk.rkey).toEqual({ fallbackServers: ['https://a.example/r', 'http://b.example/r'] });
expect(loadOneBotConfig(uin).rkey.fallbackServers).toEqual(['https://a.example/r', 'http://b.example/r']);
});
it('fills statusCommand defaults and clamps a negative cooldown', () => {
const uin = '10042';
const dir = path.join(tempDir, 'config');
+93 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { RKeyCache } from '../src/instance-rkey';
import type { MessageElement } from '@snowluma/protocol/events';
@@ -178,3 +178,95 @@ describe('RKeyCache.resolveImageUrl', () => {
expect(fetchDownloadRKeys).not.toHaveBeenCalled();
});
});
describe('RKeyCache remote fallback (#156)', () => {
const SERVER = 'https://rkey.example/r';
function stubFetch(payload: unknown, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(payload), { status }),
);
}
afterEach(() => {
vi.restoreAllMocks();
});
it('is off by default: never contacts a server, leaves the URL bare', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const { bridge } = makeBridge(async () => []); // native yields nothing
const cache = new RKeyCache(); // no config → fallback off
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
expect(url).toBe(NT_IMAGE_URL);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('falls back to the configured server when the native fetch is empty', async () => {
const fetchSpy = stubFetch({
group_rkey: '&rkey=FBGROUP', private_rkey: '&rkey=FBPRIV', expired_time: nowSec() + 3600,
});
const { bridge } = makeBridge(async () => []);
const cache = new RKeyCache({ fallbackServers: [SERVER] });
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
expect(url).toBe(`${NT_IMAGE_URL}&rkey=FBGROUP`);
expect(fetchSpy).toHaveBeenCalledWith(SERVER, expect.anything());
});
it('signs c2c images with the fallback private rkey', async () => {
stubFetch({ group_rkey: '&rkey=FBGROUP', private_rkey: '&rkey=FBPRIV', expired_time: nowSec() + 3600 });
const { bridge } = makeBridge(async () => []);
const cache = new RKeyCache({ fallbackServers: [SERVER] });
const url = await cache.resolveImageUrl(bridge as never, privateImageEl(), false);
expect(url).toBe(`${NT_PRIVATE_IMAGE_URL}&rkey=FBPRIV`);
});
it('accepts an OneBot { retcode, data } wrapper and bare (unprefixed) tokens', async () => {
stubFetch({ retcode: 0, data: { group_rkey: 'BAREKEY', private_rkey: '', expired_time: nowSec() + 3600 } });
const { bridge } = makeBridge(async () => []);
const cache = new RKeyCache({ fallbackServers: [SERVER] });
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
expect(url).toBe(`${NT_IMAGE_URL}&rkey=BAREKEY`);
});
it('does not hit any server when the native fetch already returned a rkey', async () => {
const fetchSpy = stubFetch({ group_rkey: '&rkey=FBGROUP', expired_time: nowSec() + 3600 });
const { bridge } = makeBridge(async () => freshGroupRkey());
const cache = new RKeyCache({ fallbackServers: [SERVER] });
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
expect(url).toBe(`${NT_IMAGE_URL}&rkey=GROUPKEY`); // native rkey, not fallback
expect(fetchSpy).not.toHaveBeenCalled();
});
it('tries the next server when the first one fails', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
.mockRejectedValueOnce(new Error('down'))
.mockResolvedValueOnce(new Response(JSON.stringify({ group_rkey: '&rkey=SECOND', expired_time: nowSec() + 3600 }), { status: 200 }));
const { bridge } = makeBridge(async () => []);
const cache = new RKeyCache({ fallbackServers: ['https://a.example/r', 'https://b.example/r'] });
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
expect(url).toBe(`${NT_IMAGE_URL}&rkey=SECOND`);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('leaves the URL bare when every fallback server fails', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('down'));
const { bridge } = makeBridge(async () => []);
const cache = new RKeyCache({ fallbackServers: [SERVER] });
const url = await cache.resolveImageUrl(bridge as never, imageEl(), true);
expect(url).toBe(NT_IMAGE_URL);
});
});
+11
View File
@@ -65,5 +65,16 @@ export function normalizeOneBotConfig(raw: unknown): OneBotConfig {
musicSignUrl: typeof cfg.musicSignUrl === 'string' ? cfg.musicSignUrl : undefined,
statusCommand: normalizeStatusCommand(cfg.statusCommand),
notifications: normalizeNotifications(cfg.notifications),
rkey: normalizeRkey(cfg.rkey),
};
}
/** Preserve `rkey.fallbackServers` verbatim so a hand-edited value isn't wiped
* when the operator saves an unrelated change from the WebUI (no form yet). */
function normalizeRkey(raw: unknown): { fallbackServers: string[] } {
const obj = (raw ?? {}) as Record<string, unknown>;
const servers = Array.isArray(obj.fallbackServers)
? obj.fallbackServers.filter((s): s is string => typeof s === 'string')
: [];
return { fallbackServers: servers };
}
+3
View File
@@ -111,6 +111,9 @@ export interface OneBotConfig {
statusCommand: StatusCommandConfig;
/** Per-account opt-in to global notification channels (by channel id). */
notifications?: { channelIds: string[] };
/** Opt-in remote rkey fallback servers. No dedicated form yet — preserved
* verbatim across load/save so a hand-edited value survives WebUI writes. */
rkey?: { fallbackServers: string[] };
}
// ─── Notifications (account up/down webhooks) ───────────────────────────────