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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) ───────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user