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:
手瓜一十雪
2026-05-09 15:33:13 +08:00
parent e53a8a01b7
commit 1565749105
7 changed files with 509 additions and 43 deletions
+10 -25
View File
@@ -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.
*/
+3
View File
@@ -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>;
+35 -17
View File
@@ -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') {
+55 -1
View File
@@ -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,
};
}
+34
View 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),
});
+143
View File
@@ -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;
}
+229
View File
@@ -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);
});
});