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
425 lines
18 KiB
TypeScript
425 lines
18 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 type { MessageElement } from '@snowluma/protocol/events';
|
|
import { subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
|
import { ApiHandler } from '../src/api-handler';
|
|
import { elementsToOneBotSegments } from '../src/event-converter';
|
|
import { buildApiContext, type OneBotInstanceContext } from '../src/instance-context';
|
|
import { MediaIndexer } from '../src/media-indexer';
|
|
import { MediaStore } from '../src/media-store';
|
|
import { MessageStore } from '../src/message-store';
|
|
import { sendGroupMessage, sendPrivateMessage } from '../src/modules/message-actions';
|
|
import { segmentsToRawMessage } from '../src/helper/cq';
|
|
|
|
const cleanups: Array<() => void | Promise<void>> = [];
|
|
afterEach(async () => {
|
|
for (const cleanup of cleanups.splice(0).reverse()) {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
function fixture() {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'self-sent-media-'));
|
|
cleanups.push(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const dbPath = path.join(directory, 'messages.db');
|
|
const groupFile = {
|
|
getPttUrl: vi.fn(async () => 'https://media.example/group-record?rkey=fresh'),
|
|
getPrivatePttUrl: vi.fn(async () => 'https://media.example/private-record?rkey=fresh'),
|
|
getVideoUrl: vi.fn(async () => 'https://media.example/group-video?rkey=fresh'),
|
|
getPrivateVideoUrl: vi.fn(async () => 'https://media.example/private-video?rkey=fresh'),
|
|
};
|
|
let sequence = 1;
|
|
const send = vi.fn(async (_target: number, elements: MessageElement[]) => {
|
|
for (const [index, element] of elements.entries()) {
|
|
if (element.type !== 'record' && element.type !== 'video') {
|
|
continue;
|
|
}
|
|
|
|
// The protocol upload writes the resource reference onto the sent element.
|
|
Object.assign(element, {
|
|
fileId: `${element.type}-${sequence}-${index}`,
|
|
fileName: `${element.type}.media`,
|
|
fileSize: 3,
|
|
md5Hex: 'aa'.repeat(16),
|
|
sha1Hex: 'bb'.repeat(20),
|
|
mediaNode: { fileUuid: `${element.type}-${sequence}-${index}`, storeId: 1 },
|
|
});
|
|
}
|
|
|
|
return { sequence: sequence++, clientSequence: 1, random: 1, timestamp: 1700000000 };
|
|
});
|
|
|
|
async function open() {
|
|
const messageStore = new MessageStore(dbPath);
|
|
await messageStore.initialization;
|
|
const mediaStore = new MediaStore(dbPath);
|
|
await mediaStore.initialization;
|
|
const indexer = new MediaIndexer(mediaStore);
|
|
|
|
const close = async () => {
|
|
await mediaStore.close();
|
|
await messageStore.close();
|
|
};
|
|
|
|
cleanups.push(close);
|
|
const ref = {
|
|
selfId: 10001,
|
|
uin: '10001',
|
|
messageStore,
|
|
mediaStore,
|
|
bridge: {
|
|
identity: { nickname: 'self' },
|
|
apis: { message: { sendPrivate: send, sendGroup: send }, groupFile },
|
|
},
|
|
converterCtx: {
|
|
selfId: 10001,
|
|
imageUrlResolver: null,
|
|
mediaUrlResolver: null,
|
|
messageIdResolver: null,
|
|
mediaSegmentSink: async (...args: Parameters<MediaIndexer['remember']>) =>
|
|
await indexer.remember(...args),
|
|
},
|
|
cacheMessageMeta: messageStore.storeMeta.bind(messageStore),
|
|
} as unknown as OneBotInstanceContext;
|
|
return {
|
|
ref,
|
|
handler: new ApiHandler(buildApiContext(ref)),
|
|
close: async () => {
|
|
cleanups.splice(cleanups.indexOf(close), 1);
|
|
await close();
|
|
},
|
|
};
|
|
}
|
|
|
|
return { open, groupFile, directory };
|
|
}
|
|
|
|
describe('self-sent remote media', () => {
|
|
it.each([
|
|
['error', 'https://original.example/failed.jpg'],
|
|
['empty', 'https://original.example/failed.jpg'],
|
|
['error', ''],
|
|
['empty', ''],
|
|
] as const)(
|
|
'keeps the message image URL when refresh returns %s (original=%s)',
|
|
async (outcome, originalUrl) => {
|
|
const { open } = fixture();
|
|
const { ref, handler } = await open();
|
|
|
|
for (const file of ['failed.jpg', 'fresh.jpg']) {
|
|
await ref.mediaStore.rememberImage({
|
|
file,
|
|
fileName: file,
|
|
fileSize: 1,
|
|
subType: 0,
|
|
summary: '',
|
|
isGroup: true,
|
|
sessionId: 20002,
|
|
imageUrl: `https://resource.example/${file}`,
|
|
url: `https://cached.example/${file}`,
|
|
});
|
|
}
|
|
|
|
const resolver = vi.fn();
|
|
|
|
if (outcome === 'error') {
|
|
resolver.mockRejectedValueOnce(new Error('QQ resource expired'));
|
|
} else {
|
|
resolver.mockResolvedValueOnce('');
|
|
}
|
|
|
|
resolver.mockResolvedValueOnce('https://fresh.example/fresh.jpg');
|
|
ref.converterCtx.imageUrlResolver = resolver;
|
|
const message = [
|
|
{ type: 'image', data: { file: 'failed.jpg', url: originalUrl } },
|
|
{ type: 'image', data: { file_id: 'fresh.jpg', url: '' } },
|
|
{
|
|
type: 'image',
|
|
data: { file: 'missing.jpg', url: 'https://original.example/missing.jpg' },
|
|
},
|
|
];
|
|
await ref.messageStore.storeEvent(42, true, 20002, 42, 'GroupMessage', {
|
|
message_id: 42,
|
|
message,
|
|
raw_message: segmentsToRawMessage(message),
|
|
});
|
|
const original = await ref.messageStore.findEvent(42);
|
|
|
|
expect(await handler.handle('get_msg', { message_id: 42 })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message: [
|
|
{ type: 'image', data: { file: 'failed.jpg', url: originalUrl } },
|
|
{
|
|
type: 'image',
|
|
data: { file_id: 'fresh.jpg', url: 'https://fresh.example/fresh.jpg' },
|
|
},
|
|
{
|
|
type: 'image',
|
|
data: {
|
|
file: 'missing.jpg',
|
|
url: 'https://original.example/missing.jpg',
|
|
},
|
|
},
|
|
],
|
|
raw_message: `[CQ:image,file=failed.jpg,url=${originalUrl}][CQ:image,file=,url=https://fresh.example/fresh.jpg][CQ:image,file=missing.jpg,url=https://original.example/missing.jpg]`,
|
|
},
|
|
});
|
|
// URLs already on the message remain refreshable without a global alias.
|
|
expect(resolver).toHaveBeenCalledTimes(3);
|
|
expect(resolver).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ imageUrl: 'https://original.example/missing.jpg' }),
|
|
true,
|
|
);
|
|
expect(await ref.messageStore.findEvent(42)).toEqual(original);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ type: 'record', isGroup: false, method: 'getPrivatePttUrl' },
|
|
{ type: 'record', isGroup: true, method: 'getPttUrl' },
|
|
{ type: 'video', isGroup: false, method: 'getPrivateVideoUrl' },
|
|
{ type: 'video', isGroup: true, method: 'getVideoUrl' },
|
|
] as const)(
|
|
'refreshes $type (group=$isGroup) from its own snapshot after restart',
|
|
async ({ type, isGroup, method }) => {
|
|
const { open, groupFile } = fixture();
|
|
let account = await open();
|
|
const send = isGroup ? sendGroupMessage : sendPrivateMessage;
|
|
const { messageId } = await send(
|
|
account.ref,
|
|
20002,
|
|
[{ type, data: { file: 'base64://AQID' } }],
|
|
false,
|
|
);
|
|
const snapshot = (await account.ref.messageStore.findMedia(messageId))![0]!;
|
|
expect(snapshot.mediaNode).toEqual({ fileUuid: `${type}-1-0`, storeId: 1 });
|
|
expect(
|
|
JSON.stringify(await account.ref.messageStore.findEvent(messageId)),
|
|
).not.toContain('base64://');
|
|
expect(JSON.stringify(snapshot)).not.toContain('base64://');
|
|
expect(groupFile[method]).not.toHaveBeenCalled();
|
|
await account.close();
|
|
account = await open();
|
|
|
|
// A later upload with the same display filename must not replace the old reference.
|
|
await send(
|
|
account.ref,
|
|
20002,
|
|
[{ type, data: { file: 'https://upload.example/source' } }],
|
|
false,
|
|
);
|
|
const response = await account.handler.handle('get_msg', { message_id: messageId });
|
|
const url = `https://media.example/${isGroup ? 'group' : 'private'}-${type}?rkey=fresh`;
|
|
expect(response).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message: [{ type, data: { file: `${type}.media`, url } }],
|
|
raw_message: `[CQ:${type},file=${type}.media,url=${url}]`,
|
|
},
|
|
});
|
|
expect(groupFile[method].mock.calls).toEqual([
|
|
isGroup ? [20002, snapshot.mediaNode] : [snapshot.mediaNode],
|
|
]);
|
|
expect((await account.ref.messageStore.findMedia(messageId))![0]).toEqual(snapshot);
|
|
expect(JSON.stringify(response)).not.toContain('mediaNode');
|
|
expect((await account.ref.messageStore.findEvent(messageId))!.message).toEqual([
|
|
{ type, data: { file: `${type}.media`, url: '' } },
|
|
]);
|
|
},
|
|
);
|
|
|
|
it('get_record refreshes the remote URL after the local upload source is removed', async () => {
|
|
const { open, groupFile, directory } = fixture();
|
|
const { ref, handler } = await open();
|
|
const source = path.join(directory, 'original.media');
|
|
fs.writeFileSync(source, 'media');
|
|
await sendGroupMessage(ref, 20002, [{ type: 'record', data: { file: source } }], false);
|
|
fs.unlinkSync(source);
|
|
const remote = groupFile.getPttUrl;
|
|
|
|
for (const key of ['first', 'second']) {
|
|
remote.mockResolvedValueOnce(`https://media.example/record?rkey=${key}`);
|
|
const result = await handler.handle('get_record', { file: 'record.media' });
|
|
expect(result).toMatchObject({
|
|
status: 'ok',
|
|
data: { url: `https://media.example/record?rkey=${key}` },
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain('original.media');
|
|
}
|
|
|
|
expect(remote).toHaveBeenCalledTimes(2);
|
|
expect(remote).toHaveBeenCalledWith(20002, { fileUuid: 'record-1-0', storeId: 1 });
|
|
});
|
|
it('returns a historical message unchanged without requiring media metadata', async () => {
|
|
const { open, groupFile } = fixture();
|
|
const { ref, handler } = await open();
|
|
await sendPrivateMessage(
|
|
ref,
|
|
20002,
|
|
[{ type: 'record', data: { file: 'base64://AQID' } }],
|
|
false,
|
|
);
|
|
await ref.messageStore.storeEvent(42, false, 20002, 42, 'private_message_sent', {
|
|
message_id: 42,
|
|
message_type: 'private',
|
|
message: [
|
|
{
|
|
type: 'record',
|
|
data: { file: 'record.media', url: 'https://old.example/voice' },
|
|
},
|
|
],
|
|
raw_message: '[CQ:record,file=record.media,url=https://old.example/voice]',
|
|
});
|
|
expect(await handler.handle('get_msg', { message_id: 42 })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message: [
|
|
{
|
|
type: 'record',
|
|
data: { file: 'record.media', url: 'https://old.example/voice' },
|
|
},
|
|
],
|
|
raw_message: '[CQ:record,file=record.media,url=https://old.example/voice]',
|
|
},
|
|
});
|
|
expect(groupFile.getPrivatePttUrl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('indexes self-sent voice for get_record and requests a fresh URL on each read', async () => {
|
|
const { open, groupFile } = fixture();
|
|
const { ref, handler } = await open();
|
|
await sendPrivateMessage(
|
|
ref,
|
|
20002,
|
|
[{ type: 'record', data: { file: 'base64://AQID' } }],
|
|
false,
|
|
);
|
|
let file = 'record.media';
|
|
|
|
for (const key of ['first', 'second']) {
|
|
groupFile.getPrivatePttUrl.mockResolvedValueOnce(
|
|
`https://media.example/voice?rkey=${key}`,
|
|
);
|
|
expect(await handler.handle('get_record', { file })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
file: `https://media.example/voice?rkey=${key}`,
|
|
url: `https://media.example/voice?rkey=${key}`,
|
|
},
|
|
});
|
|
file = `https://media.example/voice?rkey=${key}`;
|
|
}
|
|
|
|
expect(groupFile.getPrivatePttUrl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('get_record reports remote failure without using the upload source', async () => {
|
|
const { open, groupFile } = fixture();
|
|
const { ref, handler } = await open();
|
|
await sendPrivateMessage(
|
|
ref,
|
|
20002,
|
|
[{ type: 'record', data: { file: 'https://upload.example/source' } }],
|
|
false,
|
|
);
|
|
groupFile.getPrivatePttUrl.mockRejectedValueOnce(new Error('QQ resource expired'));
|
|
expect(await handler.handle('get_record', { file: 'record.media' })).toMatchObject({
|
|
status: 'failed',
|
|
retcode: 100,
|
|
wording: 'QQ resource expired',
|
|
});
|
|
});
|
|
|
|
it.each(['record', 'video'] as const)(
|
|
'keeps a failed %s URL and refreshes subsequent media without losing text',
|
|
async (type) => {
|
|
const { open, groupFile } = fixture();
|
|
const { ref, handler } = await open();
|
|
const remote = type === 'record' ? groupFile.getPttUrl : groupFile.getVideoUrl;
|
|
const segments = await elementsToOneBotSegments(
|
|
ref.converterCtx,
|
|
[
|
|
{ type: 'text', text: 'before ' },
|
|
{
|
|
type,
|
|
fileName: 'expired.media',
|
|
url: 'https://old.example/expired',
|
|
mediaNode: { fileUuid: 'expired' },
|
|
},
|
|
{ type: 'text', text: ' between ' },
|
|
{
|
|
type: 'image',
|
|
fileId: 'photo.jpg',
|
|
imageUrl: 'https://old.example/photo.jpg',
|
|
},
|
|
{
|
|
type,
|
|
fileName: 'available.media',
|
|
url: 'https://old.example/available',
|
|
mediaNode: { fileUuid: 'available' },
|
|
},
|
|
{ type, fileName: 'empty.media', url: '' },
|
|
{ type: 'text', text: ' after' },
|
|
],
|
|
true,
|
|
20002,
|
|
);
|
|
await ref.messageStore.storeEvent(42, true, 20002, 42, 'GroupMessage', {
|
|
message_id: 42,
|
|
message: segments,
|
|
raw_message: segmentsToRawMessage(segments),
|
|
});
|
|
const original = await ref.messageStore.findEvent(42);
|
|
const entries: LogEntry[] = [];
|
|
cleanups.push(subscribeLogs((entry) => entries.push(entry)));
|
|
remote.mockRejectedValueOnce(new Error('QQ resource expired'));
|
|
remote.mockResolvedValueOnce('https://media.example/available?rkey=fresh');
|
|
|
|
expect(await handler.handle('get_msg', { message_id: 42 })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message: [
|
|
{ type: 'text', data: { text: 'before ' } },
|
|
{
|
|
type,
|
|
data: { file: 'expired.media', url: 'https://old.example/expired' },
|
|
},
|
|
{ type: 'text', data: { text: ' between ' } },
|
|
{
|
|
type: 'image',
|
|
data: { file: 'photo.jpg', url: 'https://old.example/photo.jpg' },
|
|
},
|
|
{
|
|
type,
|
|
data: {
|
|
file: 'available.media',
|
|
url: 'https://media.example/available?rkey=fresh',
|
|
},
|
|
},
|
|
{ type, data: { file: 'empty.media', url: '' } },
|
|
{ type: 'text', data: { text: ' after' } },
|
|
],
|
|
raw_message: `before [CQ:${type},file=expired.media,url=https://old.example/expired] between [CQ:image,file=photo.jpg,url=https://old.example/photo.jpg][CQ:${type},file=available.media,url=https://media.example/available?rkey=fresh][CQ:${type},file=empty.media,url=] after`,
|
|
},
|
|
});
|
|
expect(remote.mock.calls).toEqual([
|
|
[20002, { fileUuid: 'expired' }],
|
|
[20002, { fileUuid: 'available' }],
|
|
]);
|
|
expect(
|
|
entries.some(
|
|
(entry) =>
|
|
entry.level === 'warn' &&
|
|
entry.message.includes('message_id=42') &&
|
|
entry.message.includes(`type=${type}`) &&
|
|
entry.message.includes('QQ resource expired'),
|
|
),
|
|
).toBe(true);
|
|
expect(await ref.messageStore.findEvent(42)).toEqual(original);
|
|
},
|
|
);
|
|
});
|