Add MediaCache and media segment sink
Introduce an in-memory MediaCache to index recently seen image and record segments and wire a MediaSegmentSink into EventConverter to populate it. OneBotInstance now constructs MediaCache and registers a sink to remember image/record metadata; instance-context exposes get_image/get_record lookup handlers that consult the cache (and re-resolve record URLs via bridge OIDB when needed). ApiHandler actions get_image/get_record now accept file_id fallback and use the new context lookups. Removed the old findMediaInStore stub and added comprehensive tests for the cache and sink behavior.
This commit is contained in:
@@ -392,21 +392,23 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
|
||||
// --- Media ---
|
||||
|
||||
h.registerAction('get_image', async (params) => {
|
||||
const file = asString(params.file);
|
||||
const file = asString(params.file) || asString(params.file_id);
|
||||
if (!file) return failedResponse(RETCODE.BAD_REQUEST, 'file is required');
|
||||
const imageInfo = findMediaInStore('image', file, ctx);
|
||||
if (imageInfo) {
|
||||
return okResponse(imageInfo as unknown as import('../types').JsonValue);
|
||||
if (!ctx.getImageInfo) return failedResponse(RETCODE.ACTION_FAILED, 'not implemented');
|
||||
const info = await ctx.getImageInfo(file);
|
||||
if (info) {
|
||||
return okResponse(info as unknown as import('../types').JsonValue);
|
||||
}
|
||||
return failedResponse(RETCODE.ACTION_FAILED, 'image not found in cache');
|
||||
});
|
||||
|
||||
h.registerAction('get_record', async (params) => {
|
||||
const file = asString(params.file);
|
||||
const file = asString(params.file) || asString(params.file_id);
|
||||
if (!file) return failedResponse(RETCODE.BAD_REQUEST, 'file is required');
|
||||
const recordInfo = findMediaInStore('record', file, ctx);
|
||||
if (recordInfo) {
|
||||
return okResponse(recordInfo as unknown as import('../types').JsonValue);
|
||||
if (!ctx.getRecordInfo) return failedResponse(RETCODE.ACTION_FAILED, 'not implemented');
|
||||
const info = await ctx.getRecordInfo(file);
|
||||
if (info) {
|
||||
return okResponse(info as unknown as import('../types').JsonValue);
|
||||
}
|
||||
return failedResponse(RETCODE.ACTION_FAILED, 'record not found in cache');
|
||||
});
|
||||
@@ -736,23 +738,6 @@ export function register(h: ApiHandler, ctx: ApiActionContext): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search through cached message segments to find a media element (image/record)
|
||||
* that matches the given file identifier.
|
||||
*/
|
||||
function findMediaInStore(
|
||||
mediaType: 'image' | 'record',
|
||||
file: string,
|
||||
ctx: ApiActionContext,
|
||||
): Record<string, unknown> | null {
|
||||
// The file param can match a file id, a URL, or a filename stored in segments.
|
||||
// We don't have a reverse index, so this is best-effort from stored events.
|
||||
// Most callers will pass the `file` field from a received message segment.
|
||||
// Just return the info if we can look it up from any recent cached message.
|
||||
// This is a known limitation — full implementation would require a media cache.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse download_file headers parameter into a Record.
|
||||
*/
|
||||
|
||||
@@ -105,6 +105,9 @@ export interface ApiActionContext {
|
||||
getCookiesStr?: (domain: string) => Promise<string>;
|
||||
getCsrfToken?: () => Promise<number>;
|
||||
getCredentials?: (domain: string) => Promise<{ cookies: string; token: number; csrf_token: number }>;
|
||||
// Media lookup (populated from previously dispatched message segments)
|
||||
getImageInfo?: (file: string) => Promise<JsonObject | null>;
|
||||
getRecordInfo?: (file: string) => Promise<JsonObject | null>;
|
||||
}
|
||||
|
||||
type ActionHandler = (params: JsonObject) => Promise<import('./types').ApiResponse>;
|
||||
|
||||
@@ -5,11 +5,24 @@ import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT } from './message-id';
|
||||
export type ImageUrlResolver = (element: MessageElement, isGroup: boolean) => string;
|
||||
export type MediaUrlResolver = (element: MessageElement, isGroup: boolean, sessionId: number) => Promise<string>;
|
||||
export type MessageIdResolver = (isGroup: boolean, sessionId: number, sequence: number, eventName: string) => number;
|
||||
/**
|
||||
* Side-channel callback invoked every time an image/record segment is
|
||||
* produced, so that callers can keep a lookup index (e.g. for `get_image` /
|
||||
* `get_record`) without having to re-scan the persisted message store.
|
||||
*/
|
||||
export type MediaSegmentSink = (
|
||||
mediaType: 'image' | 'record',
|
||||
element: MessageElement,
|
||||
data: JsonObject,
|
||||
isGroup: boolean,
|
||||
sessionId: number,
|
||||
) => void;
|
||||
|
||||
export class EventConverter {
|
||||
private imageUrlResolver_: ImageUrlResolver | null = null;
|
||||
private mediaUrlResolver_: MediaUrlResolver | null = null;
|
||||
private messageIdResolver_: MessageIdResolver | null = null;
|
||||
private mediaSegmentSink_: MediaSegmentSink | null = null;
|
||||
|
||||
setImageUrlResolver(resolver: ImageUrlResolver): void {
|
||||
this.imageUrlResolver_ = resolver;
|
||||
@@ -23,6 +36,10 @@ export class EventConverter {
|
||||
this.messageIdResolver_ = resolver;
|
||||
}
|
||||
|
||||
setMediaSegmentSink(sink: MediaSegmentSink): void {
|
||||
this.mediaSegmentSink_ = sink;
|
||||
}
|
||||
|
||||
async convert(instanceUin: string, event: QQEventVariant): Promise<JsonObject | null> {
|
||||
const selfId = parseSelfId(instanceUin);
|
||||
|
||||
@@ -32,7 +49,7 @@ export class EventConverter {
|
||||
const isSelf = event.senderUin === selfId;
|
||||
const postType = isSelf ? 'message_sent' : 'message';
|
||||
const messageId = this.resolveMessageId(false, event.senderUin, event.msgSeq, PRIVATE_MESSAGE_EVENT);
|
||||
const segments = await elementsToJson(event.elements, false, event.senderUin, this.imageUrlResolver_, this.mediaUrlResolver_, this.messageIdResolver_);
|
||||
const segments = await elementsToJson(event.elements, false, event.senderUin, this.imageUrlResolver_, this.mediaUrlResolver_, this.messageIdResolver_, this.mediaSegmentSink_);
|
||||
return {
|
||||
time: event.time,
|
||||
self_id: selfId,
|
||||
@@ -59,7 +76,7 @@ export class EventConverter {
|
||||
const isSelf = event.senderUin === selfId;
|
||||
const postType = isSelf ? 'message_sent' : 'message';
|
||||
const messageId = this.resolveMessageId(true, event.groupId, event.msgSeq, GROUP_MESSAGE_EVENT);
|
||||
const segments = await elementsToJson(event.elements, true, event.groupId, this.imageUrlResolver_, this.mediaUrlResolver_, this.messageIdResolver_);
|
||||
const segments = await elementsToJson(event.elements, true, event.groupId, this.imageUrlResolver_, this.mediaUrlResolver_, this.messageIdResolver_, this.mediaSegmentSink_);
|
||||
return {
|
||||
time: event.time,
|
||||
self_id: selfId,
|
||||
@@ -90,7 +107,7 @@ export class EventConverter {
|
||||
const isSelf = event.senderUin === selfId;
|
||||
const postType = isSelf ? 'message_sent' : 'message';
|
||||
const messageId = this.resolveMessageId(false, event.senderUin, event.msgSeq, PRIVATE_MESSAGE_EVENT);
|
||||
const segments = await elementsToJson(event.elements, false, event.senderUin, this.imageUrlResolver_, this.mediaUrlResolver_, this.messageIdResolver_);
|
||||
const segments = await elementsToJson(event.elements, false, event.senderUin, this.imageUrlResolver_, this.mediaUrlResolver_, this.messageIdResolver_, this.mediaSegmentSink_);
|
||||
return {
|
||||
time: event.time,
|
||||
self_id: selfId,
|
||||
@@ -320,10 +337,11 @@ async function elementsToJson(
|
||||
resolver?: ImageUrlResolver | null,
|
||||
mediaResolver?: MediaUrlResolver | null,
|
||||
messageIdResolver?: MessageIdResolver | null,
|
||||
mediaSink?: MediaSegmentSink | null,
|
||||
): Promise<JsonArray> {
|
||||
const result: JsonArray = [];
|
||||
for (const element of elements) {
|
||||
result.push(await elementToSegment(element, isGroup, sessionId, resolver, mediaResolver, messageIdResolver));
|
||||
result.push(await elementToSegment(element, isGroup, sessionId, resolver, mediaResolver, messageIdResolver, mediaSink));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -335,8 +353,9 @@ export async function elementsToOneBotSegments(
|
||||
resolver?: ImageUrlResolver | null,
|
||||
mediaResolver?: MediaUrlResolver | null,
|
||||
messageIdResolver?: MessageIdResolver | null,
|
||||
mediaSink?: MediaSegmentSink | null,
|
||||
): Promise<JsonArray> {
|
||||
return elementsToJson(elements, isGroup, sessionId, resolver, mediaResolver, messageIdResolver);
|
||||
return elementsToJson(elements, isGroup, sessionId, resolver, mediaResolver, messageIdResolver, mediaSink);
|
||||
}
|
||||
|
||||
async function elementToSegment(
|
||||
@@ -346,6 +365,7 @@ async function elementToSegment(
|
||||
resolver?: ImageUrlResolver | null,
|
||||
mediaResolver?: MediaUrlResolver | null,
|
||||
messageIdResolver?: MessageIdResolver | null,
|
||||
mediaSink?: MediaSegmentSink | null,
|
||||
): Promise<JsonObject> {
|
||||
if (element.type === 'text') {
|
||||
return { type: 'text', data: { text: element.text ?? '' } };
|
||||
@@ -357,13 +377,12 @@ async function elementToSegment(
|
||||
|
||||
if (element.type === 'image') {
|
||||
const url = resolver ? resolver(element, isGroup) : (element.imageUrl ?? '');
|
||||
return {
|
||||
type: 'image',
|
||||
data: {
|
||||
url,
|
||||
file: element.fileId ?? '',
|
||||
},
|
||||
const data: JsonObject = {
|
||||
url,
|
||||
file: element.fileId ?? '',
|
||||
};
|
||||
if (mediaSink) mediaSink('image', element, data, isGroup, sessionId);
|
||||
return { type: 'image', data };
|
||||
}
|
||||
|
||||
if (element.type === 'at') {
|
||||
@@ -380,13 +399,12 @@ async function elementToSegment(
|
||||
|
||||
if (element.type === 'record') {
|
||||
const url = mediaResolver ? await mediaResolver(element, isGroup, sessionId) : (element.url ?? '');
|
||||
return {
|
||||
type: 'record',
|
||||
data: {
|
||||
file: element.fileName ?? element.fileId ?? '',
|
||||
url,
|
||||
},
|
||||
const data: JsonObject = {
|
||||
file: element.fileName ?? element.fileId ?? '',
|
||||
url,
|
||||
};
|
||||
if (mediaSink) mediaSink('record', element, data, isGroup, sessionId);
|
||||
return { type: 'record', data };
|
||||
}
|
||||
|
||||
if (element.type === 'video') {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { elementsToOneBotSegments } from './event-converter';
|
||||
import { MessageStore } from './message-store';
|
||||
import { parseMessage } from './message-parser';
|
||||
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, hashMessageIdInt32 } from './message-id';
|
||||
import type { MediaCache } from './media-cache';
|
||||
import type { JsonObject, JsonValue, MessageMeta } from './types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import {WebHonorType} from "@/bridge/web/group-honor";
|
||||
@@ -17,12 +18,13 @@ export interface InstanceRef {
|
||||
qqInfo: QQInfo;
|
||||
bridge: Bridge;
|
||||
messageStore: MessageStore;
|
||||
mediaCache: MediaCache;
|
||||
musicSignUrl?: string;
|
||||
cacheMessageMeta(messageId: number, meta: MessageMeta): void;
|
||||
}
|
||||
|
||||
export function buildApiContext(ref: InstanceRef): ApiActionContext {
|
||||
const { bridge, qqInfo, messageStore } = ref;
|
||||
const { bridge, qqInfo, messageStore, mediaCache } = ref;
|
||||
|
||||
return {
|
||||
getLoginInfo: () => getLoginInfo(ref),
|
||||
@@ -135,6 +137,58 @@ export function buildApiContext(ref: InstanceRef): ApiActionContext {
|
||||
getCookiesStr: (domain: string) => bridge.getCookiesStr(domain),
|
||||
getCsrfToken: () => bridge.getCsrfToken(),
|
||||
getCredentials: (domain: string) => bridge.getCredentials(domain),
|
||||
// Media lookup
|
||||
getImageInfo: (file) => handleGetImageInfo(mediaCache, file),
|
||||
getRecordInfo: (file) => handleGetRecordInfo(bridge, mediaCache, file),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Media lookup ---
|
||||
|
||||
async function handleGetImageInfo(
|
||||
mediaCache: MediaCache,
|
||||
file: string,
|
||||
): Promise<JsonObject | null> {
|
||||
const cached = mediaCache.findImage(file);
|
||||
if (!cached) return null;
|
||||
const url = cached.url || cached.imageUrl || '';
|
||||
return {
|
||||
file: url || cached.file,
|
||||
url,
|
||||
file_size: String(cached.fileSize ?? 0),
|
||||
file_name: cached.fileName || cached.file,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetRecordInfo(
|
||||
bridge: Bridge,
|
||||
mediaCache: MediaCache,
|
||||
file: string,
|
||||
): Promise<JsonObject | null> {
|
||||
const cached = mediaCache.findRecord(file);
|
||||
if (!cached) return null;
|
||||
|
||||
// Re-resolve via OIDB if the cached URL is missing or empty.
|
||||
// Mirrors NapCat's getPttUrl path: GetGroupPttUrl / GetPttUrl by fileUuid.
|
||||
let url = cached.url;
|
||||
if (!url && cached.mediaNode) {
|
||||
try {
|
||||
url = cached.isGroup
|
||||
? await bridge.fetchGroupPttUrlByNode(cached.sessionId, cached.mediaNode)
|
||||
: await bridge.fetchPrivatePttUrlByNode(cached.mediaNode);
|
||||
if (url) {
|
||||
mediaCache.updateRecordUrl(file, url);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('get_record url refetch failed: %s', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: url || cached.file,
|
||||
url: url || '',
|
||||
file_size: String(cached.fileSize ?? 0),
|
||||
file_name: cached.fileName || cached.file,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { QQInfo } from '../bridge/qq-info';
|
||||
import { ApiHandler } from './api-handler';
|
||||
import { EventConverter } from './event-converter';
|
||||
import { MessageStore } from './message-store';
|
||||
import { MediaCache } from './media-cache';
|
||||
import { RKeyCache } from './instance-rkey';
|
||||
import { buildApiContext } from './instance-context';
|
||||
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, hashMessageIdInt32 } from './message-id';
|
||||
@@ -25,6 +26,7 @@ export class OneBotInstance {
|
||||
private readonly apiHandler: ApiHandler;
|
||||
private readonly eventConverter: EventConverter;
|
||||
private readonly messageStore: MessageStore;
|
||||
private readonly mediaCache: MediaCache;
|
||||
private readonly httpTransport: HttpTransport;
|
||||
private readonly httpPostTransport: HttpPostTransport;
|
||||
private readonly wsTransport: WsTransport;
|
||||
@@ -42,6 +44,7 @@ export class OneBotInstance {
|
||||
|
||||
this.eventConverter = new EventConverter();
|
||||
this.rkeyCache = new RKeyCache();
|
||||
this.mediaCache = new MediaCache();
|
||||
this.eventConverter.setImageUrlResolver((element, isGroup) =>
|
||||
this.rkeyCache.resolveImageUrl(this.bridge, element, isGroup));
|
||||
this.eventConverter.setMediaUrlResolver(async (element, isGroup, sessionId) => {
|
||||
@@ -70,6 +73,36 @@ export class OneBotInstance {
|
||||
// Then apply RKey if needed
|
||||
return this.rkeyCache.resolveMediaUrl(this.bridge, element, isGroup);
|
||||
});
|
||||
this.eventConverter.setMediaSegmentSink((mediaType, element, data, isGroup, sessionId) => {
|
||||
const url = typeof data.url === 'string' ? data.url : '';
|
||||
const file = typeof data.file === 'string' ? data.file : '';
|
||||
if (mediaType === 'image') {
|
||||
this.mediaCache.rememberImage({
|
||||
file: file || element.fileId || '',
|
||||
url,
|
||||
fileSize: element.fileSize ?? 0,
|
||||
fileName: element.fileId ?? '',
|
||||
subType: element.subType ?? 0,
|
||||
summary: element.summary ?? '',
|
||||
imageUrl: element.imageUrl ?? '',
|
||||
isGroup,
|
||||
sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.mediaCache.rememberRecord({
|
||||
file: file || element.fileName || element.fileId || '',
|
||||
fileId: element.fileId ?? '',
|
||||
url,
|
||||
fileSize: element.fileSize ?? 0,
|
||||
fileName: element.fileName ?? '',
|
||||
duration: element.duration ?? 0,
|
||||
fileHash: element.fileHash ?? '',
|
||||
mediaNode: element.mediaNode,
|
||||
isGroup,
|
||||
sessionId,
|
||||
});
|
||||
});
|
||||
this.eventConverter.setMessageIdResolver((isGroup, sessionId, sequence, eventName) =>
|
||||
hashMessageIdInt32(sequence, sessionId, eventName || (isGroup ? GROUP_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT)));
|
||||
this.messageStore = new MessageStore(path.join('data', this.uin, 'messages.json'));
|
||||
@@ -79,6 +112,7 @@ export class OneBotInstance {
|
||||
qqInfo: this.qqInfo,
|
||||
bridge: this.bridge,
|
||||
messageStore: this.messageStore,
|
||||
mediaCache: this.mediaCache,
|
||||
musicSignUrl: config.musicSignUrl,
|
||||
cacheMessageMeta: (messageId, meta) => this.cacheMessageMeta(messageId, meta),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Bounded in-memory cache of recently seen image / record media elements.
|
||||
//
|
||||
// The OneBot `get_image` and `get_record` APIs take back the `data.file` that
|
||||
// was emitted earlier in a message segment and expect to receive the
|
||||
// corresponding URL (and, ideally, file size / name / path). SnowLuma emits
|
||||
// minimal `{file, url}` segments, so we need a side-channel that records the
|
||||
// full metadata (and enough context to re-resolve the URL later via OIDB, the
|
||||
// same way NapCat's `getPttUrl` does).
|
||||
//
|
||||
// This cache is deliberately small and lives in memory only — message events
|
||||
// already get persisted separately in `MessageStore`. Entries are keyed by
|
||||
// every identifier we might reasonably be asked about: the OneBot `file`
|
||||
// value, the raw `fileId` / `fileUuid`, and the resolved URL. When the cache
|
||||
// outgrows its budget, the least-recently-inserted entries are evicted.
|
||||
|
||||
import type { MessageElement } from '../bridge/events';
|
||||
|
||||
export interface CachedImage {
|
||||
/** Primary `data.file` value as exposed in the OneBot segment. */
|
||||
file: string;
|
||||
/** Last known resolved URL (may be empty if resolution failed). */
|
||||
url: string;
|
||||
fileSize: number;
|
||||
fileName: string;
|
||||
subType: number;
|
||||
summary: string;
|
||||
/** Conversation the media originated from (needed for RKey refresh). */
|
||||
isGroup: boolean;
|
||||
sessionId: number;
|
||||
/** Original imageUrl from the MessageElement, used for RKey re-appending. */
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export interface CachedRecord {
|
||||
/** Primary `data.file` value as exposed in the OneBot segment. */
|
||||
file: string;
|
||||
/** Raw fileId from the MessageElement (typically the fileUuid). */
|
||||
fileId: string;
|
||||
/** Last known resolved URL (may be empty if resolution failed). */
|
||||
url: string;
|
||||
fileSize: number;
|
||||
fileName: string;
|
||||
duration: number;
|
||||
fileHash: string;
|
||||
/** OIDB media node used to re-fetch the URL via getPtt*UrlByNode. */
|
||||
mediaNode?: MessageElement['mediaNode'];
|
||||
/** Conversation the media originated from (group vs private path). */
|
||||
isGroup: boolean;
|
||||
sessionId: number;
|
||||
}
|
||||
|
||||
interface BoundedMap<V> {
|
||||
values: Map<string, V>;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
function createBounded<V>(limit: number): BoundedMap<V> {
|
||||
return { values: new Map<string, V>(), limit: Math.max(32, limit) };
|
||||
}
|
||||
|
||||
function putBounded<V>(map: BoundedMap<V>, key: string, value: V): void {
|
||||
if (!key) return;
|
||||
// Re-insert to push the key to the tail of the insertion order (LRU-ish).
|
||||
if (map.values.has(key)) map.values.delete(key);
|
||||
map.values.set(key, value);
|
||||
while (map.values.size > map.limit) {
|
||||
const oldest = map.values.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
map.values.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function getBounded<V>(map: BoundedMap<V>, key: string): V | null {
|
||||
if (!key) return null;
|
||||
return map.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
export class MediaCache {
|
||||
private readonly images: BoundedMap<CachedImage>;
|
||||
private readonly records: BoundedMap<CachedRecord>;
|
||||
|
||||
constructor(maxEntries = 1024) {
|
||||
this.images = createBounded(maxEntries);
|
||||
this.records = createBounded(maxEntries);
|
||||
}
|
||||
|
||||
rememberImage(info: CachedImage): void {
|
||||
if (!info.file && !info.url && !info.fileName) return;
|
||||
// Store under every plausible lookup key so callers can pass back any of
|
||||
// {file, fileName, url} from the segment they previously received.
|
||||
for (const key of dedupe([info.file, info.fileName, info.url])) {
|
||||
putBounded(this.images, key, info);
|
||||
}
|
||||
}
|
||||
|
||||
rememberRecord(info: CachedRecord): void {
|
||||
if (!info.file && !info.url && !info.fileId && !info.fileName) return;
|
||||
for (const key of dedupe([info.file, info.fileName, info.fileId, info.url])) {
|
||||
putBounded(this.records, key, info);
|
||||
}
|
||||
}
|
||||
|
||||
findImage(key: string): CachedImage | null {
|
||||
return getBounded(this.images, key);
|
||||
}
|
||||
|
||||
findRecord(key: string): CachedRecord | null {
|
||||
return getBounded(this.records, key);
|
||||
}
|
||||
|
||||
/** Replace the cached URL on an image entry (after RKey refresh, etc.). */
|
||||
updateImageUrl(key: string, url: string): void {
|
||||
const cached = this.findImage(key);
|
||||
if (!cached || !url || cached.url === url) return;
|
||||
const next: CachedImage = { ...cached, url };
|
||||
this.rememberImage(next);
|
||||
}
|
||||
|
||||
/** Replace the cached URL on a record entry after an on-demand re-fetch. */
|
||||
updateRecordUrl(key: string, url: string): void {
|
||||
const cached = this.findRecord(key);
|
||||
if (!cached || !url || cached.url === url) return;
|
||||
const next: CachedRecord = { ...cached, url };
|
||||
this.rememberRecord(next);
|
||||
}
|
||||
|
||||
/** Count currently cached entries (test helper). */
|
||||
get size(): { images: number; records: number } {
|
||||
return { images: this.images.values.size, records: this.records.values.size };
|
||||
}
|
||||
}
|
||||
|
||||
function dedupe(values: (string | undefined)[]): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const v of values) {
|
||||
if (!v) continue;
|
||||
if (seen.has(v)) continue;
|
||||
seen.add(v);
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MediaCache } from '../src/onebot/media-cache';
|
||||
import { EventConverter } from '../src/onebot/event-converter';
|
||||
import type { GroupMessage, FriendMessage, MessageElement } from '../src/bridge/events';
|
||||
|
||||
const SELF_UIN = '10001';
|
||||
const SELF_ID = 10001;
|
||||
const PEER_UIN = 22222;
|
||||
const GROUP_ID = 99999;
|
||||
|
||||
function imageElement(overrides: Partial<MessageElement> = {}): MessageElement {
|
||||
return {
|
||||
type: 'image',
|
||||
fileId: 'abc.png',
|
||||
fileSize: 4242,
|
||||
imageUrl: 'https://example.com/abc.png',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function recordElement(overrides: Partial<MessageElement> = {}): MessageElement {
|
||||
return {
|
||||
type: 'record',
|
||||
fileId: 'uuid-base64-fake',
|
||||
fileName: 'silk_test.amr',
|
||||
fileSize: 1024,
|
||||
duration: 3,
|
||||
fileHash: 'deadbeef',
|
||||
mediaNode: { fileUuid: 'uuid-base64-fake', storeId: 1 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeGroupMessage(elements: MessageElement[]): GroupMessage {
|
||||
return {
|
||||
kind: 'group_message',
|
||||
time: 1700000000,
|
||||
selfUin: SELF_ID,
|
||||
groupId: GROUP_ID,
|
||||
senderUin: PEER_UIN,
|
||||
senderNick: 'peer',
|
||||
senderCard: '',
|
||||
senderRole: 'member',
|
||||
msgSeq: 1,
|
||||
msgId: 1,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFriendMessage(elements: MessageElement[]): FriendMessage {
|
||||
return {
|
||||
kind: 'friend_message',
|
||||
time: 1700000000,
|
||||
selfUin: SELF_ID,
|
||||
senderUin: PEER_UIN,
|
||||
senderNick: 'peer',
|
||||
msgSeq: 1,
|
||||
msgId: 1,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MediaCache basic semantics', () => {
|
||||
it('indexes images by file, fileName, and url', () => {
|
||||
const cache = new MediaCache();
|
||||
cache.rememberImage({
|
||||
file: 'abc.png',
|
||||
url: 'https://example.com/abc.png',
|
||||
fileSize: 4242,
|
||||
fileName: 'abc.png',
|
||||
subType: 0,
|
||||
summary: '',
|
||||
imageUrl: 'https://example.com/abc.png',
|
||||
isGroup: true,
|
||||
sessionId: GROUP_ID,
|
||||
});
|
||||
|
||||
expect(cache.findImage('abc.png')?.fileSize).toBe(4242);
|
||||
expect(cache.findImage('https://example.com/abc.png')?.url).toBe('https://example.com/abc.png');
|
||||
expect(cache.findImage('missing.png')).toBeNull();
|
||||
expect(cache.findImage('')).toBeNull();
|
||||
});
|
||||
|
||||
it('indexes records by file, fileName, fileId, and url', () => {
|
||||
const cache = new MediaCache();
|
||||
cache.rememberRecord({
|
||||
file: 'silk_test.amr',
|
||||
fileId: 'uuid-base64-fake',
|
||||
url: 'https://example.com/voice.amr',
|
||||
fileSize: 1024,
|
||||
fileName: 'silk_test.amr',
|
||||
duration: 3,
|
||||
fileHash: 'deadbeef',
|
||||
mediaNode: { fileUuid: 'uuid-base64-fake' },
|
||||
isGroup: false,
|
||||
sessionId: PEER_UIN,
|
||||
});
|
||||
|
||||
expect(cache.findRecord('silk_test.amr')?.duration).toBe(3);
|
||||
expect(cache.findRecord('uuid-base64-fake')?.fileId).toBe('uuid-base64-fake');
|
||||
expect(cache.findRecord('https://example.com/voice.amr')?.url).toBe('https://example.com/voice.amr');
|
||||
expect(cache.findRecord('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('updates URLs in place across all index entries', () => {
|
||||
const cache = new MediaCache();
|
||||
cache.rememberRecord({
|
||||
file: 'silk_test.amr',
|
||||
fileId: 'uuid-base64-fake',
|
||||
url: '',
|
||||
fileSize: 1024,
|
||||
fileName: 'silk_test.amr',
|
||||
duration: 3,
|
||||
fileHash: '',
|
||||
mediaNode: { fileUuid: 'uuid-base64-fake' },
|
||||
isGroup: false,
|
||||
sessionId: PEER_UIN,
|
||||
});
|
||||
cache.updateRecordUrl('silk_test.amr', 'https://refreshed.example.com/voice.amr');
|
||||
expect(cache.findRecord('silk_test.amr')?.url).toBe('https://refreshed.example.com/voice.amr');
|
||||
// Lookup by fileId should also see the refreshed url since it shares the entry.
|
||||
expect(cache.findRecord('uuid-base64-fake')?.url).toBe('https://refreshed.example.com/voice.amr');
|
||||
});
|
||||
|
||||
it('evicts oldest entries when bound is exceeded', () => {
|
||||
const cache = new MediaCache(33);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
cache.rememberImage({
|
||||
file: `file-${i}.png`,
|
||||
url: `https://example.com/${i}.png`,
|
||||
fileSize: i,
|
||||
fileName: `file-${i}.png`,
|
||||
subType: 0,
|
||||
summary: '',
|
||||
imageUrl: '',
|
||||
isGroup: true,
|
||||
sessionId: GROUP_ID,
|
||||
});
|
||||
}
|
||||
// Earliest entry should have been evicted (each item adds 2 keys: file + url),
|
||||
// so the bound is reached well before 100 distinct entries.
|
||||
expect(cache.findImage('file-0.png')).toBeNull();
|
||||
// Last inserted entry must still be present.
|
||||
expect(cache.findImage('file-99.png')?.fileSize).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventConverter media segment sink', () => {
|
||||
it('emits sink invocations for image segments with the right context', async () => {
|
||||
const conv = new EventConverter();
|
||||
const sinkCalls: Array<{ type: string; isGroup: boolean; sessionId: number; file: string }> = [];
|
||||
conv.setMediaSegmentSink((type, _element, data, isGroup, sessionId) => {
|
||||
sinkCalls.push({ type, isGroup, sessionId, file: String(data.file ?? '') });
|
||||
});
|
||||
|
||||
await conv.convert(SELF_UIN, makeGroupMessage([imageElement()]));
|
||||
await conv.convert(SELF_UIN, makeFriendMessage([recordElement()]));
|
||||
|
||||
expect(sinkCalls).toEqual([
|
||||
{ type: 'image', isGroup: true, sessionId: GROUP_ID, file: 'abc.png' },
|
||||
{ type: 'record', isGroup: false, sessionId: PEER_UIN, file: 'silk_test.amr' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets the sink populate a MediaCache that get_image-style lookups can use', async () => {
|
||||
const conv = new EventConverter();
|
||||
const cache = new MediaCache();
|
||||
conv.setImageUrlResolver((element) => element.imageUrl ?? '');
|
||||
conv.setMediaUrlResolver(async (element) => element.url ?? '');
|
||||
conv.setMediaSegmentSink((type, element, data, isGroup, sessionId) => {
|
||||
const url = typeof data.url === 'string' ? data.url : '';
|
||||
const file = typeof data.file === 'string' ? data.file : '';
|
||||
if (type === 'image') {
|
||||
cache.rememberImage({
|
||||
file: file || element.fileId || '',
|
||||
url,
|
||||
fileSize: element.fileSize ?? 0,
|
||||
fileName: element.fileId ?? '',
|
||||
subType: element.subType ?? 0,
|
||||
summary: element.summary ?? '',
|
||||
imageUrl: element.imageUrl ?? '',
|
||||
isGroup,
|
||||
sessionId,
|
||||
});
|
||||
} else {
|
||||
cache.rememberRecord({
|
||||
file: file || element.fileName || element.fileId || '',
|
||||
fileId: element.fileId ?? '',
|
||||
url,
|
||||
fileSize: element.fileSize ?? 0,
|
||||
fileName: element.fileName ?? '',
|
||||
duration: element.duration ?? 0,
|
||||
fileHash: element.fileHash ?? '',
|
||||
mediaNode: element.mediaNode,
|
||||
isGroup,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await conv.convert(SELF_UIN, makeGroupMessage([imageElement()]));
|
||||
await conv.convert(SELF_UIN, makeFriendMessage([recordElement()]));
|
||||
|
||||
const img = cache.findImage('abc.png');
|
||||
expect(img).not.toBeNull();
|
||||
expect(img!.url).toBe('https://example.com/abc.png');
|
||||
expect(img!.fileSize).toBe(4242);
|
||||
expect(img!.isGroup).toBe(true);
|
||||
expect(img!.sessionId).toBe(GROUP_ID);
|
||||
|
||||
const rec = cache.findRecord('silk_test.amr');
|
||||
expect(rec).not.toBeNull();
|
||||
expect(rec!.fileId).toBe('uuid-base64-fake');
|
||||
expect(rec!.duration).toBe(3);
|
||||
expect(rec!.isGroup).toBe(false);
|
||||
expect(rec!.sessionId).toBe(PEER_UIN);
|
||||
// Should also be findable by the raw fileUuid, mirroring how callers may
|
||||
// pass back any of the identifiers we previously emitted.
|
||||
expect(cache.findRecord('uuid-base64-fake')?.fileName).toBe('silk_test.amr');
|
||||
});
|
||||
|
||||
it('does not invoke the sink when no media segments are present', async () => {
|
||||
const conv = new EventConverter();
|
||||
let calls = 0;
|
||||
conv.setMediaSegmentSink(() => { calls++; });
|
||||
await conv.convert(SELF_UIN, makeGroupMessage([{ type: 'text', text: 'hello' }]));
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user