diff --git a/packages/onebot/src/config.ts b/packages/onebot/src/config.ts index 4bf2e4b1..c9cbddd0 100644 --- a/packages/onebot/src/config.ts +++ b/packages/onebot/src/config.ts @@ -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(); + 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 diff --git a/packages/onebot/src/instance-rkey.ts b/packages/onebot/src/instance-rkey.ts index b809f13b..d8340b5f 100644 --- a/packages/onebot/src/instance-rkey.ts +++ b/packages/onebot/src/instance-rkey.ts @@ -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(); private lastRefreshAttempt = 0; private refreshInflight: Promise | 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 { + 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 { + 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 { + 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; + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); } // QQ-NT download appids embedded in the image URL query string. The scene diff --git a/packages/onebot/src/instance.ts b/packages/onebot/src/instance.ts index acd65dba..5f0ec60d 100644 --- a/packages/onebot/src/instance.ts +++ b/packages/onebot/src/instance.ts @@ -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')); diff --git a/packages/onebot/src/types.ts b/packages/onebot/src/types.ts index b85ce86c..caf2a48c 100644 --- a/packages/onebot/src/types.ts +++ b/packages/onebot/src/types.ts @@ -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 { diff --git a/packages/onebot/tests/config.test.ts b/packages/onebot/tests/config.test.ts index bda0a22e..9b241622 100644 --- a/packages/onebot/tests/config.test.ts +++ b/packages/onebot/tests/config.test.ts @@ -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'); diff --git a/packages/onebot/tests/instance-rkey.test.ts b/packages/onebot/tests/instance-rkey.test.ts index cacd0380..15a95371 100644 --- a/packages/onebot/tests/instance-rkey.test.ts +++ b/packages/onebot/tests/instance-rkey.test.ts @@ -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); + }); +}); diff --git a/packages/webui/src/lib/onebot-config.ts b/packages/webui/src/lib/onebot-config.ts index 6cf87f12..bec68543 100644 --- a/packages/webui/src/lib/onebot-config.ts +++ b/packages/webui/src/lib/onebot-config.ts @@ -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; + const servers = Array.isArray(obj.fallbackServers) + ? obj.fallbackServers.filter((s): s is string => typeof s === 'string') + : []; + return { fallbackServers: servers }; +} diff --git a/packages/webui/src/types.ts b/packages/webui/src/types.ts index fb2b3b5c..951240df 100644 --- a/packages/webui/src/types.ts +++ b/packages/webui/src/types.ts @@ -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) ───────────────────────────────