diff --git a/packages/onebot/src/media-store.ts b/packages/onebot/src/media-store.ts index 0f32c686..e47979bf 100644 --- a/packages/onebot/src/media-store.ts +++ b/packages/onebot/src/media-store.ts @@ -235,7 +235,7 @@ export class MediaStore { throw new Error(`cannot store ${type} metadata without an identifier`); } - const data = serializeMediaInfo(info); + const data = serializeMediaInfo(await this.mergeStoredUrls(type, primary, info)); await this.db.exec('BEGIN IMMEDIATE'); try { @@ -323,6 +323,64 @@ export class MediaStore { ), ); } + + /** Re-indexing an object without a download address — a quoted or forwarded + * image often carries only its hash — must not erase an address already + * proven for the same object. `get_image` hands `url` straight to its + * caller, so a dropped address turns a known picture into an unusable + * record that the caller retries until it gives up. + */ + private async mergeStoredUrls(type: string, primary: string, info: object): Promise { + const next: Record = { ...(info as Record) }; + const missing = (['url', 'imageUrl'] as const).filter( + (field) => field in next && (typeof next[field] !== 'string' || !next[field]), + ); + + if (missing.length === 0) { + return info; + } + + const stored = await this.readStoredUrls(type, primary); + + for (const field of missing) { + if (stored[field]) { + next[field] = stored[field]; + } + } + + return next; + } + + /** Direct row read: `remember` already runs inside the serial queue. */ + private async readStoredUrls( + type: string, + primary: string, + ): Promise<{ url: string; imageUrl: string }> { + const empty = { url: '', imageUrl: '' }; + const row = (await this.findEntry.get(type, type, primary, primary)) as + | { data: string } + | undefined; + + if (!row) { + return empty; + } + + try { + const parsed: unknown = JSON.parse(row.data); + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return empty; + } + + const stored = parsed as Record; + return { + url: typeof stored.url === 'string' ? stored.url : '', + imageUrl: typeof stored.imageUrl === 'string' ? stored.imageUrl : '', + }; + } catch { + return empty; + } + } } export function serializeMediaInfo(info: object): string { diff --git a/packages/onebot/src/modules/media-actions.ts b/packages/onebot/src/modules/media-actions.ts index 0deff6e2..83aba54a 100644 --- a/packages/onebot/src/modules/media-actions.ts +++ b/packages/onebot/src/modules/media-actions.ts @@ -148,14 +148,17 @@ async function resolveImageUrl( cached: CachedImage | null, resolver?: ImageUrlResolver | null, ): Promise { - if (!cached?.imageUrl || !resolver) { + // get_image returns `url` ahead of `imageUrl`, so a record indexed from + // segment data can carry its address only on `url`. That address still + // needs signing when its rkey has expired; ignoring it here handed the + // caller back an unrefreshed URL. + const source = (cached?.imageUrl || cached?.url || '').trim(); + + if (!cached || !source || !resolver) { return null; } - return resolver( - { type: 'image', imageUrl: cached.imageUrl, subType: cached.subType }, - cached.isGroup, - ); + return resolver({ type: 'image', imageUrl: source, subType: cached.subType }, cached.isGroup); } /** diff --git a/packages/onebot/tests/media-store.test.ts b/packages/onebot/tests/media-store.test.ts index 1a7d8244..da3580d9 100644 --- a/packages/onebot/tests/media-store.test.ts +++ b/packages/onebot/tests/media-store.test.ts @@ -119,6 +119,41 @@ describe('MediaStore basic semantics', () => { await store.close(); }); + it('keeps a stored download URL when a later index carries none', async () => { + const store = open('img-url-retain'); + + await store.rememberImage({ + file: 'abc.png', + url: 'https://example.com/abc.png?rkey=live', + fileSize: 4242, + fileName: 'abc.png', + subType: 0, + summary: '', + imageUrl: 'https://example.com/abc.png?rkey=live', + isGroup: true, + sessionId: GROUP_ID, + }); + + // A quoted copy reaches the store with only the hash and no address. + await store.rememberImage({ + file: 'abc.png', + url: '', + fileSize: 4242, + fileName: 'abc.png', + subType: 0, + summary: '', + imageUrl: '', + isGroup: true, + sessionId: GROUP_ID, + }); + + const cached = await store.findImage('abc.png'); + + expect(cached?.url).toBe('https://example.com/abc.png?rkey=live'); + expect(cached?.imageUrl).toBe('https://example.com/abc.png?rkey=live'); + await store.close(); + }); + it('indexes records by file, fileName, fileId, and url', async () => { const store = open('rec-keys'); await store.rememberRecord({