Integrate the current development branch, preserve the album comment fixes, and align the changes with repository formatting and dependency tooling.
507 lines
19 KiB
TypeScript
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();
|
|
});
|
|
});
|