Files
SnowLuma/packages/onebot/tests/historical-media.test.ts
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

507 lines
19 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type { MessageElement } from '@snowluma/protocol/events';
import {
convertEvent,
elementsToOneBotSegments,
type ConverterContext,
} from '../src/event-converter';
import { MediaIndexer } from '../src/media-indexer';
import { MediaStore, MEDIA_DATA_MAX_CHARS } from '../src/media-store';
import { migrateLegacyMedia } from '../src/media-store-migration';
import { MessageStore } from '../src/message-store';
import type { OneBotInstanceContext } from '../src/instance-context';
import { forwardSingleMessage, sendGroupForwardMessage } from '../src/modules/message-actions';
const directories: string[] = [];
afterEach(() => {
for (const dir of directories.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function location() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'historical-media-'));
directories.push(directory);
return {
directory,
dbPath: path.join(directory, 'messages.db'),
legacyPath: path.join(directory, 'media.db'),
};
}
const image = {
file: 'same.png',
fileName: 'same.png',
url: '',
imageUrl: '',
fileSize: 120,
md5Hex: 'aa'.repeat(16),
sha1Hex: 'bb'.repeat(20),
width: 32,
height: 24,
picFormat: 1001,
subType: 0,
summary: '',
isGroup: true,
sessionId: 123,
};
function converter(media: MediaStore): ConverterContext {
const indexer = new MediaIndexer(media);
return {
selfId: 10001,
imageUrlResolver: null,
mediaUrlResolver: null,
messageIdResolver: null,
mediaSegmentSink: async (...args) => await indexer.remember(...args),
};
}
function context(messages: MessageStore, media: MediaStore) {
const sendGroup = vi.fn(async () => ({
sequence: 234,
clientSequence: 0,
random: 1,
timestamp: 1700000001,
}));
const upload = vi.fn(async (_nodes: unknown) => 'forward-id');
const ref = {
selfId: 10001,
messageStore: messages,
mediaStore: media,
converterCtx: converter(media),
cacheMessageMeta: vi.fn(),
bridge: {
identity: { nickname: 'self' },
apis: { message: { sendGroup }, forward: { upload } },
},
} as unknown as OneBotInstanceContext;
return { ref, sendGroup, upload };
}
describe('durable historical media', () => {
it.each(['image', 'record', 'video'] as const)(
'forwards %s after cache churn, alias replacement and reopen',
async (type) => {
const { dbPath } = location();
let messages = new MessageStore(dbPath);
await messages.initialization;
let media = new MediaStore(dbPath, 2);
await media.initialization;
const element = {
type,
fileId: 'same.png',
fileName: 'same.png',
fileSize: 120,
md5Hex: image.md5Hex,
sha1Hex: image.sha1Hex,
width: 32,
height: 24,
picFormat: 1001,
duration: 3,
voiceFormat: 1,
videoFormat: 0,
} as MessageElement;
const event = await convertEvent(converter(media), {
kind: 'group_message',
groupName: '',
time: 1700000000,
selfUin: 10001,
groupId: 123,
senderUin: 456,
senderNick: 'peer',
senderCard: '',
senderRole: 'member',
msgSeq: 10,
msgId: 10,
elements: [element],
});
event!.message_id = 42;
await messages.storeHistoryEvent(42, true, 123, 10, 'GroupMessage', event!);
expect(JSON.stringify(event)).not.toContain('md5Hex');
expect(JSON.stringify(await messages.findEvent(42))).not.toContain('sha1Hex');
// Repeated names and many new resources must not change the old message.
for (let i = 0; i < 80; i++) {
await elementsToOneBotSegments(
converter(media),
[{ ...element, md5Hex: 'cc'.repeat(16) }],
true,
123,
);
await media.rememberImage({ ...image, file: `other-${i}`, fileName: `other-${i}` });
await media.findImage(`other-${i}`);
}
await media.close();
await messages.close();
messages = new MessageStore(dbPath);
await messages.initialization;
media = new MediaStore(dbPath, 2);
await media.initialization;
try {
const { ref, sendGroup, upload } = context(messages, media);
await forwardSingleMessage(ref, 42, { groupId: 999 });
expect(sendGroup).toHaveBeenCalledWith(999, [
expect.objectContaining({
type,
md5Hex: image.md5Hex,
sha1Hex: image.sha1Hex,
noByteFallback: true,
}),
]);
await sendGroupForwardMessage(ref, 999, [{ type: 'node', data: { id: 42 } }]);
expect(upload.mock.calls[0]?.[0]).toEqual([
expect.objectContaining({
elements: [
expect.objectContaining({
type,
md5Hex: image.md5Hex,
noByteFallback: true,
}),
],
}),
]);
expect((await messages.findEvent(42))?.message_id).toBe(42);
} finally {
await media.close();
await messages.close();
}
},
);
it('stores the body and media snapshot atomically and strips inline bytes', async () => {
const { dbPath } = location();
const messages = new MessageStore(dbPath);
await messages.initialization;
const media = new MediaStore(dbPath);
await media.initialization;
const db = new DatabaseSync(dbPath);
try {
const segments = await elementsToOneBotSegments(
converter(media),
[
{
type: 'image',
...image,
url: `base64://${'A'.repeat(100000)}`,
imageUrl: '',
fileId: 'same.png',
},
],
true,
123,
);
const event = { time: 1, message: segments };
db.exec(
"CREATE TRIGGER fail_media BEFORE INSERT ON messages WHEN NEW.media_data IS NOT NULL BEGIN SELECT RAISE(ABORT, 'test media write failure'); END",
);
await expect(
async () => await messages.storeEvent(1, true, 123, 1, 'GroupMessage', event),
).rejects.toThrow('test media write failure');
expect(await messages.findEvent(1)).toBeNull();
db.exec('DROP TRIGGER fail_media');
await messages.storeEvent(1, true, 123, 1, 'GroupMessage', event);
expect((await messages.findMedia(1))?.[0].md5Hex).toBe(image.md5Hex);
expect(JSON.stringify(await messages.findMedia(1))).not.toContain('base64://');
} finally {
db.close();
await media.close();
await messages.close();
}
});
it('propagates media persistence failures instead of silently skipping segments', async () => {
const { dbPath } = location();
const media = new MediaStore(dbPath);
await media.initialization;
await media.close();
await expect(
elementsToOneBotSegments(
converter(media),
[{ type: 'image', fileId: 'test' }],
true,
123,
),
).rejects.toThrow();
});
it('does not substitute another same-named resource for an incomplete snapshot', async () => {
const { dbPath } = location();
const messages = new MessageStore(dbPath);
await messages.initialization;
const media = new MediaStore(dbPath);
await media.initialization;
try {
const segments = await elementsToOneBotSegments(
converter(media),
[{ type: 'image', fileId: image.file }],
true,
123,
);
await messages.storeEvent(42, true, 123, 1, 'GroupMessage', { message: segments });
await media.rememberImage(image);
const { ref, sendGroup } = context(messages, media);
await expect(forwardSingleMessage(ref, 42, { groupId: 999 })).rejects.toThrow(
'metadata is incomplete',
);
expect(sendGroup).not.toHaveBeenCalled();
} finally {
await media.close();
await messages.close();
}
});
it('preserves complete metadata across an incomplete repeat and rejects conflicting resources', async () => {
const { dbPath } = location();
let messages = new MessageStore(dbPath);
await messages.initialization;
let media = new MediaStore(dbPath);
await media.initialization;
const complete: MessageElement = { type: 'image', ...image, fileId: image.file };
const save = async (element: MessageElement) => {
const segments = await elementsToOneBotSegments(converter(media), [element], true, 123);
await messages.storeEvent(42, true, 123, 1, 'GroupMessage', { message: segments });
};
await save(complete);
await save({
type: 'image',
fileId: image.file,
md5Hex: image.md5Hex,
width: 0,
height: 0,
});
await expect(save({ ...complete, md5Hex: 'cc'.repeat(16) })).rejects.toThrow('conflicting');
await media.close();
await messages.close();
messages = new MessageStore(dbPath);
await messages.initialization;
media = new MediaStore(dbPath);
await media.initialization;
try {
const { ref, sendGroup } = context(messages, media);
await forwardSingleMessage(ref, 42, { groupId: 999 });
expect(sendGroup).toHaveBeenCalledWith(999, [
expect.objectContaining({
md5Hex: image.md5Hex,
sha1Hex: image.sha1Hex,
width: image.width,
height: image.height,
}),
]);
} finally {
await media.close();
await messages.close();
}
});
it('does not combine disjoint image fingerprints merely because fileId is the same filename', async () => {
const { dbPath } = location();
const messages = new MessageStore(dbPath);
await messages.initialization;
const media = new MediaStore(dbPath);
await media.initialization;
try {
const original = await elementsToOneBotSegments(
converter(media),
[{ type: 'image', fileId: 'photo.jpg', md5Hex: image.md5Hex, width: 32 }],
true,
123,
);
await messages.storeEvent(42, true, 123, 1, 'GroupMessage', { message: original });
const different = await elementsToOneBotSegments(
converter(media),
[{ type: 'image', fileId: 'photo.jpg', sha1Hex: image.sha1Hex, width: 64 }],
true,
123,
);
await expect(
async () =>
await messages.storeEvent(42, true, 123, 1, 'GroupMessage', {
message: different,
}),
).rejects.toThrow('unverified media association');
expect(await messages.findMedia(42)).toEqual([
{ type: 'image', fileId: 'photo.jpg', md5Hex: image.md5Hex, width: 32 },
]);
} finally {
await media.close();
await messages.close();
}
});
it('rejects corrupt and oversized metadata without deleting history', async () => {
const { dbPath } = location();
const media = new MediaStore(dbPath);
await media.initialization;
const db = new DatabaseSync(dbPath);
try {
await expect(
async () =>
await media.rememberImage({
...image,
summary: 'x'.repeat(MEDIA_DATA_MAX_CHARS),
}),
).rejects.toThrow('storage limit');
await media.rememberImage(image);
db.exec("UPDATE media_entries SET data = '{broken'");
await expect(async () => await media.findImage(image.file)).rejects.toThrow();
db.exec("UPDATE media_entries SET data = 'null'");
await expect(async () => await media.findImage(image.file)).rejects.toThrow(
'invalid persisted',
);
expect((await media.size()).images).toBe(1);
} finally {
db.close();
await media.close();
}
});
it.each(['image', 'record', 'video'] as const)(
'keeps migrated %s history independent of new same-named resources',
async (type) => {
const { dbPath, legacyPath } = location();
const old = new MediaStore(legacyPath);
await old.initialization;
const element = {
type,
fileId: 'same.png',
fileName: 'same.png',
fileSize: 120,
md5Hex: image.md5Hex,
sha1Hex: image.sha1Hex,
width: 32,
height: 24,
picFormat: 1001,
duration: 3,
voiceFormat: 1,
videoFormat: 0,
} as MessageElement;
await elementsToOneBotSegments(converter(old), [element], true, 123);
await old.close();
const source = fs.readFileSync(legacyPath);
const messages = new MessageStore(dbPath);
await messages.initialization;
await messages.storeEvent(42, true, 123, 1, 'GroupMessage', {
user_id: 456,
message_id: 42,
message_type: 'group',
message: [{ type, data: { file: image.file } }],
});
await migrateLegacyMedia(dbPath);
expect(fs.readFileSync(legacyPath)).toEqual(source);
fs.rmSync(legacyPath);
await migrateLegacyMedia(dbPath);
const media = new MediaStore(dbPath);
await media.initialization;
try {
await elementsToOneBotSegments(
converter(media),
[{ ...element, md5Hex: 'cc'.repeat(16) }],
true,
123,
);
const { ref, sendGroup } = context(messages, media);
await forwardSingleMessage(ref, 42, { groupId: 999 });
expect(sendGroup).toHaveBeenCalledWith(999, [
expect.objectContaining({ md5Hex: image.md5Hex }),
]);
} finally {
await media.close();
await messages.close();
}
},
);
it('rolls back a failed legacy batch and resumes after the source is repaired', async () => {
const { dbPath, legacyPath } = location();
const old = new MediaStore(legacyPath);
await old.initialization;
await old.rememberImage(image);
await old.close();
const source = new DatabaseSync(legacyPath);
source.prepare("INSERT INTO media_entries VALUES ('image', 'broken', ?, 1)").run('{broken');
source.close();
await expect(migrateLegacyMedia(dbPath)).rejects.toThrow();
const destination = new MediaStore(dbPath);
await destination.initialization;
expect((await destination.size()).images).toBe(0);
await destination.close();
const repaired = new DatabaseSync(legacyPath);
repaired
.prepare("UPDATE media_entries SET data = ? WHERE primary_key = 'broken'")
.run(JSON.stringify({ ...image, file: 'broken' }));
repaired.close();
await migrateLegacyMedia(dbPath);
const reopened = new MediaStore(dbPath);
await reopened.initialization;
expect((await reopened.size()).images).toBe(2);
await reopened.close();
});
it('normalizes legacy inline payloads without discarding their metadata', async () => {
const { dbPath, legacyPath } = location();
const old = new MediaStore(legacyPath);
await old.initialization;
await old.close();
const inline = `base64://${'Z'.repeat(100000)}`;
const source = new DatabaseSync(legacyPath);
source
.prepare("INSERT INTO media_entries VALUES ('image', ?, ?, 1)")
.run(inline, JSON.stringify({ ...image, file: inline, url: inline, imageUrl: inline }));
source.prepare("INSERT INTO media_keys VALUES ('image', ?, ?)").run(inline, inline);
source.close();
await migrateLegacyMedia(dbPath);
const media = new MediaStore(dbPath);
await media.initialization;
try {
expect(await media.findImage(inline)).toMatchObject({
md5Hex: image.md5Hex,
url: '',
imageUrl: '',
});
expect(JSON.stringify(await media.findImage(inline))).not.toContain('base64://');
} finally {
await media.close();
}
});
it('resumes a cancelled migration from its committed batch cursor', async () => {
const { dbPath, legacyPath } = location();
const old = new MediaStore(legacyPath);
await old.initialization;
for (let i = 0; i < 205; i++) {
await old.rememberImage({ ...image, file: `entry-${i}` });
}
await old.close();
let checks = 0;
await migrateLegacyMedia(dbPath, () => ++checks > 1);
const partial = new MediaStore(dbPath);
await partial.initialization;
expect((await partial.size()).images).toBe(200);
await partial.close();
await migrateLegacyMedia(dbPath);
const complete = new MediaStore(dbPath);
await complete.initialization;
expect((await complete.size()).images).toBe(205);
expect((await complete.findImage('entry-204'))?.md5Hex).toBe(image.md5Hex);
await complete.close();
});
});