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
736 lines
29 KiB
TypeScript
736 lines
29 KiB
TypeScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
|
|
import type { OidbBase } from '@snowluma/proto-defs/oidb';
|
|
import type { EncodableMediaMsgInfo, NTV2UploadRichMediaResp } from '@snowluma/proto-defs/highway';
|
|
import type { BridgeContext } from '@snowluma/protocol/bridge-context';
|
|
import type { FriendMessage, GroupMessage, MessageElement } from '@snowluma/protocol/events';
|
|
import { uploadImageMsgInfo } from '@snowluma/protocol/highway/image-upload';
|
|
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 {
|
|
backfillReplyTarget,
|
|
sendGroupMessage,
|
|
sendPrivateMessage,
|
|
} from '../src/modules/message-actions';
|
|
import { GROUP_MESSAGE_EVENT, privateMessageEventName } from '../src/message-id';
|
|
import { createMessageIdResolver } from '../src/message-id-resolver';
|
|
import { segmentsToRawMessage } from '../src/helper/cq';
|
|
import type { JsonObject } from '../src/types';
|
|
|
|
const png =
|
|
'base64://iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jFZkAAAAASUVORK5CYII=';
|
|
const self = 10001;
|
|
const peer = 20002;
|
|
const time = 1700000000;
|
|
const legacyId = -659;
|
|
const md5 = 'aa'.repeat(16);
|
|
const sha1 = 'bb'.repeat(20);
|
|
const cleanups: Array<() => void | Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
for (const cleanup of cleanups.splice(0).reverse()) {
|
|
await cleanup();
|
|
}
|
|
});
|
|
|
|
function fixture(isGroup: boolean) {
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'image-history-'));
|
|
cleanups.push(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const dbPath = path.join(directory, 'messages.db');
|
|
const url = `https://multimedia.nt.qq.com.cn/download?appid=${isGroup ? 1407 : 1406}&fileid=original-resource`;
|
|
const remoteImage = {
|
|
type: 'image',
|
|
fileId: `${md5}.webp`,
|
|
imageUrl: url,
|
|
md5Hex: md5,
|
|
sha1Hex: sha1,
|
|
} satisfies MessageElement;
|
|
const historyMessage: FriendMessage | GroupMessage = {
|
|
selfUin: self,
|
|
senderUin: self,
|
|
senderNick: 'self',
|
|
time,
|
|
msgId: 456,
|
|
elements: [remoteImage, { type: 'text', text: 'server representation' }],
|
|
...(isGroup
|
|
? {
|
|
kind: 'group_message',
|
|
groupId: peer,
|
|
groupName: '',
|
|
senderCard: '',
|
|
senderRole: 'member',
|
|
msgSeq: 42,
|
|
}
|
|
: {
|
|
kind: 'friend_message',
|
|
peerUin: peer,
|
|
msgSeq: 123,
|
|
clientSeq: 123,
|
|
ntMsgSeq: 42,
|
|
sequenceAuthoritative: true,
|
|
}),
|
|
};
|
|
const getGroupMessageBySeq = vi.fn(async (): Promise<GroupMessage | null> =>
|
|
isGroup ? (historyMessage as GroupMessage) : null,
|
|
);
|
|
const getC2cMessageBySeq = vi.fn(async (): Promise<FriendMessage | null> =>
|
|
isGroup ? null : (historyMessage as FriendMessage),
|
|
);
|
|
const getC2cLatestHistory = vi.fn(async (): Promise<FriendMessage[]> =>
|
|
isGroup ? [] : [historyMessage as FriendMessage],
|
|
);
|
|
const resolver = vi.fn(async (element: MessageElement) => `${element.imageUrl}&rkey=fresh`);
|
|
const uploads: EncodableMediaMsgInfo[] = [];
|
|
const protocolBridge = {
|
|
identity: { uin: String(self) },
|
|
sendRawPacket: vi.fn(async () => ({
|
|
success: true,
|
|
gotResponse: true,
|
|
errorCode: 0,
|
|
responseData: protobuf_encode<OidbBase<NTV2UploadRichMediaResp>>({
|
|
body: {
|
|
upload: {
|
|
msgInfo: {
|
|
msgInfoBody: [
|
|
{
|
|
index: { fileUuid: 'original-resource' },
|
|
picture: {
|
|
domain: 'multimedia.nt.qq.com.cn',
|
|
urlPath: url.slice(
|
|
'https://multimedia.nt.qq.com.cn'.length,
|
|
),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
})),
|
|
} as unknown as BridgeContext;
|
|
|
|
const send = async (_target: number, elements: MessageElement[]) => {
|
|
for (const element of elements) {
|
|
if (element.type === 'image') {
|
|
uploads.push(
|
|
protobuf_decode<EncodableMediaMsgInfo>(
|
|
await uploadImageMsgInfo(
|
|
protocolBridge,
|
|
isGroup,
|
|
isGroup ? peer : 'u_peer',
|
|
element,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return { sequence: 42, clientSequence: 123, random: 456, timestamp: time };
|
|
};
|
|
|
|
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: self,
|
|
uin: String(self),
|
|
messageStore,
|
|
mediaStore,
|
|
bridge: {
|
|
identity: { nickname: 'self' },
|
|
resolveUserUid: vi.fn(async () => 'u_peer'),
|
|
apis: {
|
|
message: {
|
|
sendPrivate: send,
|
|
sendGroup: send,
|
|
getGroupMessageBySeq,
|
|
getC2cMessageBySeq,
|
|
getC2cLatestHistory,
|
|
},
|
|
},
|
|
},
|
|
converterCtx: {
|
|
selfId: self,
|
|
imageUrlResolver: resolver,
|
|
mediaUrlResolver: null,
|
|
messageIdResolver: createMessageIdResolver(messageStore),
|
|
mediaSegmentSink: (...args: Parameters<MediaIndexer['remember']>) =>
|
|
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,
|
|
resolver,
|
|
url,
|
|
uploads,
|
|
historyMessage,
|
|
getGroupMessageBySeq,
|
|
getC2cMessageBySeq,
|
|
getC2cLatestHistory,
|
|
};
|
|
}
|
|
|
|
async function saveIncomplete(
|
|
ref: OneBotInstanceContext,
|
|
isGroup: boolean,
|
|
authoritative = true,
|
|
snapshot = true,
|
|
elements?: MessageElement[],
|
|
) {
|
|
const eventName = isGroup ? GROUP_MESSAGE_EVENT : privateMessageEventName(true, authoritative);
|
|
await ref.messageStore.storeMeta(legacyId, {
|
|
isGroup,
|
|
targetId: peer,
|
|
sequence: authoritative ? 42 : 123,
|
|
sequenceAuthoritative: authoritative,
|
|
eventName,
|
|
clientSequence: 123,
|
|
...(isGroup ? {} : { privateDirection: 'outgoing' as const }),
|
|
random: 456,
|
|
timestamp: time,
|
|
});
|
|
const message = await elementsToOneBotSegments(
|
|
{ ...ref.converterCtx, imageUrlResolver: null },
|
|
elements ?? [
|
|
{
|
|
type: 'image',
|
|
url: png,
|
|
fileName: `${md5}.webp`,
|
|
md5Hex: md5,
|
|
sha1Hex: sha1,
|
|
picFormat: 1002,
|
|
},
|
|
{ type: 'text', text: 'original caption' },
|
|
],
|
|
isGroup,
|
|
peer,
|
|
);
|
|
const event: JsonObject = {
|
|
message_id: legacyId,
|
|
self_id: self,
|
|
post_type: 'message_sent',
|
|
message_type: isGroup ? 'group' : 'private',
|
|
sub_type: isGroup ? 'normal' : 'friend',
|
|
user_id: self,
|
|
target_id: peer,
|
|
...(isGroup ? { group_id: peer } : {}),
|
|
time,
|
|
message_seq: isGroup ? 42 : 123,
|
|
message: snapshot ? message : structuredClone(message),
|
|
raw_message: segmentsToRawMessage(message),
|
|
};
|
|
await ref.messageStore.storeEvent(
|
|
legacyId,
|
|
isGroup,
|
|
peer,
|
|
authoritative ? 42 : 123,
|
|
eventName,
|
|
event,
|
|
{ sequenceAuthoritative: authoritative },
|
|
);
|
|
return ref.messageStore.findEvent(legacyId);
|
|
}
|
|
|
|
describe('image history (#659)', () => {
|
|
it('accepts an omitted outgoing peer only within the explicitly requested history conversation', async () => {
|
|
const f = fixture(false);
|
|
const { ref, handler } = await f.open();
|
|
await saveIncomplete(ref, false);
|
|
delete (f.historyMessage as FriendMessage).peerUin;
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: { message: [{ data: { url: `${f.url}&rkey=fresh` } }, {}] },
|
|
});
|
|
expect(f.getC2cMessageBySeq).toHaveBeenCalledExactlyOnceWith('u_peer', 42, self);
|
|
});
|
|
|
|
it('uses the explicit recipient on older self-keyed private records without rewriting their identity', async () => {
|
|
const f = fixture(false);
|
|
const { ref, handler } = await f.open();
|
|
await saveIncomplete(ref, false);
|
|
const meta = { ...(await ref.messageStore.findMeta(legacyId))!, targetId: self };
|
|
await ref.messageStore.storeMeta(legacyId, meta);
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: { message_id: legacyId, message: [{ data: { url: `${f.url}&rkey=fresh` } }, {}] },
|
|
});
|
|
expect(ref.bridge.resolveUserUid).toHaveBeenCalledExactlyOnceWith(peer);
|
|
expect(await ref.messageStore.findMeta(legacyId)).toEqual(meta);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'never restores a private message recalled while its history request is in flight (failed=%s)',
|
|
async (failed) => {
|
|
const f = fixture(false);
|
|
const { ref, handler } = await f.open();
|
|
await saveIncomplete(ref, false);
|
|
f.getC2cMessageBySeq.mockImplementationOnce(async () => {
|
|
expect(await ref.messageStore.recordPrivateRecall(peer, 123, true, time + 1)).toBe(
|
|
legacyId,
|
|
);
|
|
|
|
if (failed) {
|
|
throw new Error('history request failed after recall');
|
|
}
|
|
|
|
return f.historyMessage as FriendMessage;
|
|
});
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'failed',
|
|
});
|
|
expect(await ref.messageStore.findEvent(legacyId)).toBeNull();
|
|
expect(await ref.messageStore.findMeta(legacyId)).toBeNull();
|
|
},
|
|
);
|
|
|
|
it('keeps a newer stored body when history completes after an update', async () => {
|
|
const f = fixture(false);
|
|
const { ref, handler } = await f.open();
|
|
await saveIncomplete(ref, false);
|
|
f.getC2cMessageBySeq.mockImplementationOnce(async () => {
|
|
await saveIncomplete(ref, false, true, true, [
|
|
{
|
|
type: 'image',
|
|
imageUrl: 'https://new.example/image',
|
|
md5Hex: md5,
|
|
sha1Hex: sha1,
|
|
},
|
|
{ type: 'text', text: 'newer caption' },
|
|
]);
|
|
return f.historyMessage as FriendMessage;
|
|
});
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message_id: legacyId,
|
|
message: [
|
|
{ data: { url: 'https://new.example/image&rkey=fresh' } },
|
|
{ data: { text: 'newer caption' } },
|
|
],
|
|
},
|
|
});
|
|
expect((await ref.messageStore.findMedia(legacyId))![0]!.imageUrl).toBe(
|
|
'https://new.example/image',
|
|
);
|
|
});
|
|
|
|
it('refuses an ambiguous sender-local sequence instead of choosing the first history item', async () => {
|
|
const f = fixture(false);
|
|
const { ref, handler } = await f.open();
|
|
const before = await saveIncomplete(ref, false, false);
|
|
f.getC2cLatestHistory.mockResolvedValue([
|
|
f.historyMessage as FriendMessage,
|
|
{ ...(f.historyMessage as FriendMessage), ntMsgSeq: 43 },
|
|
]);
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: { message: [{ data: { url: '' } }, {}] },
|
|
});
|
|
expect(await ref.messageStore.findEvent(legacyId)).toEqual(before);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'repairs only the missing image in a mixed message and preserves ordered metadata (group=%s)',
|
|
async (isGroup) => {
|
|
const f = fixture(isGroup);
|
|
const { ref, handler } = await f.open();
|
|
const first: MessageElement = { type: 'image', md5Hex: md5, sha1Hex: sha1, url: png };
|
|
const second: MessageElement = {
|
|
type: 'image',
|
|
md5Hex: 'cc'.repeat(16),
|
|
sha1Hex: 'dd'.repeat(20),
|
|
imageUrl: 'https://existing.example/second',
|
|
fileName: 'second.webp',
|
|
};
|
|
await saveIncomplete(ref, isGroup, true, true, [
|
|
first,
|
|
{ type: 'text', text: 'between images' },
|
|
second,
|
|
]);
|
|
const before = await ref.messageStore.findMedia(legacyId);
|
|
f.historyMessage.elements = [
|
|
{ ...first, imageUrl: f.url },
|
|
{ ...second, imageUrl: 'https://history.example/second' },
|
|
];
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message_id: legacyId,
|
|
message: [
|
|
{ data: { url: `${f.url}&rkey=fresh` } },
|
|
{ data: { text: 'between images' } },
|
|
{ data: { url: 'https://existing.example/second&rkey=fresh' } },
|
|
],
|
|
},
|
|
});
|
|
expect((await ref.messageStore.findMedia(legacyId))![1]).toEqual(before![1]);
|
|
},
|
|
);
|
|
|
|
it('rejects reordered images atomically without saving a partial repair', async () => {
|
|
const f = fixture(true);
|
|
const { ref, handler } = await f.open();
|
|
const first: MessageElement = { type: 'image', md5Hex: md5, sha1Hex: sha1, url: png };
|
|
const second: MessageElement = {
|
|
type: 'image',
|
|
md5Hex: 'cc'.repeat(16),
|
|
sha1Hex: 'dd'.repeat(20),
|
|
url: png,
|
|
};
|
|
const before = await saveIncomplete(ref, true, true, true, [first, second]);
|
|
f.historyMessage.elements = [
|
|
{ ...second, imageUrl: f.url },
|
|
{ ...first, imageUrl: f.url },
|
|
];
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: { message: [{ data: { url: '' } }, { data: { url: '' } }] },
|
|
});
|
|
expect(await ref.messageStore.findEvent(legacyId)).toEqual(before);
|
|
});
|
|
|
|
it('repairs a legacy image without inventing snapshots for other media', async () => {
|
|
const f = fixture(true);
|
|
const { ref, handler } = await f.open();
|
|
await saveIncomplete(ref, true, true, false, [
|
|
{ type: 'image', url: png, md5Hex: md5, sha1Hex: sha1 },
|
|
{ type: 'record', fileName: 'voice.amr', url: 'https://original.example/voice' },
|
|
]);
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message: [
|
|
{ type: 'image', data: { url: `${f.url}&rkey=fresh` } },
|
|
{ type: 'record', data: { url: 'https://original.example/voice' } },
|
|
],
|
|
},
|
|
});
|
|
expect(await ref.messageStore.findMedia(legacyId)).toBeNull();
|
|
expect(await ref.messageStore.findEvent(legacyId)).toMatchObject({
|
|
message: [{ data: { url: f.url } }, {}],
|
|
});
|
|
});
|
|
|
|
it('does not repair a message with an unverified group identity', async () => {
|
|
const f = fixture(true);
|
|
const { ref, handler } = await f.open();
|
|
const before = await saveIncomplete(ref, true);
|
|
(f.historyMessage as GroupMessage).groupId = 30003;
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: { message: [{ data: { url: '' } }, {}] },
|
|
});
|
|
expect(await ref.messageStore.findEvent(legacyId)).toEqual(before);
|
|
});
|
|
|
|
it.each(['conversation', 'fingerprint'] as const)(
|
|
'does not use a conflicting legacy filename alias (%s)',
|
|
async (conflict) => {
|
|
const f = fixture(false);
|
|
const { ref, handler } = await f.open();
|
|
const before = await saveIncomplete(ref, false, true, false);
|
|
f.getC2cMessageBySeq.mockResolvedValue(null);
|
|
await ref.mediaStore.rememberImage({
|
|
file: `${md5}.png`,
|
|
fileName: `${md5}.png`,
|
|
fileSize: 1,
|
|
imageUrl: 'https://wrong.example/image',
|
|
url: '',
|
|
isGroup: conflict === 'conversation',
|
|
sessionId: conflict === 'conversation' ? 30003 : peer,
|
|
md5Hex: 'cc'.repeat(16),
|
|
subType: 0,
|
|
summary: '',
|
|
});
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: { message: [{ data: { url: '' } }, {}] },
|
|
});
|
|
expect(f.resolver).not.toHaveBeenCalled();
|
|
expect(await ref.messageStore.findEvent(legacyId)).toEqual(before);
|
|
},
|
|
);
|
|
|
|
it('repairs incoming private media under its existing message identity', async () => {
|
|
const f = fixture(false);
|
|
const { ref, handler } = await f.open();
|
|
await saveIncomplete(ref, false);
|
|
const source = f.historyMessage as FriendMessage;
|
|
source.senderUin = peer;
|
|
delete source.peerUin;
|
|
const meta = (await ref.messageStore.findMeta(legacyId))!;
|
|
await ref.messageStore.storeMeta(legacyId, { ...meta, privateDirection: 'incoming' });
|
|
const message = await elementsToOneBotSegments(
|
|
{ ...ref.converterCtx, imageUrlResolver: null },
|
|
[{ type: 'image', md5Hex: md5, sha1Hex: sha1 }],
|
|
false,
|
|
peer,
|
|
);
|
|
const event = {
|
|
...(await ref.messageStore.findEvent(legacyId))!,
|
|
user_id: peer,
|
|
post_type: 'message',
|
|
message,
|
|
raw_message: segmentsToRawMessage(message),
|
|
};
|
|
await ref.messageStore.storeEvent(legacyId, false, peer, 42, meta.eventName, event);
|
|
source.elements = [source.elements[0]!];
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message_id: legacyId,
|
|
user_id: peer,
|
|
message: [{ data: { url: `${f.url}&rkey=fresh` } }],
|
|
},
|
|
});
|
|
expect((await ref.messageStore.findMeta(legacyId))!.privateDirection).toBe('incoming');
|
|
});
|
|
|
|
it('reports an unverifiable image instead of guessing a resource from an arbitrary filename', async () => {
|
|
const f = fixture(true);
|
|
const { ref, handler } = await f.open();
|
|
const before = await saveIncomplete(ref, true, true, false, [
|
|
{ type: 'image', fileId: 'photo.webp' },
|
|
]);
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: { message: [{ data: { file: 'photo.webp', url: '' } }] },
|
|
});
|
|
expect(await ref.messageStore.findEvent(legacyId)).toEqual(before);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'retains a newly uploaded resource through send, cache churn and restart (group=%s)',
|
|
async (isGroup) => {
|
|
const f = fixture(isGroup);
|
|
let account = await f.open();
|
|
const sent = await (isGroup ? sendGroupMessage : sendPrivateMessage)(
|
|
account.ref,
|
|
peer,
|
|
[{ type: 'image', data: { file: png } }],
|
|
false,
|
|
);
|
|
expect(f.uploads[0]?.msgInfoBody?.[0]?.index?.fileUuid).toBe('original-resource');
|
|
const media = (await account.ref.messageStore.findMedia(sent.messageId))![0]!;
|
|
expect(media.imageUrl).toBe(f.url);
|
|
const saved = await account.ref.messageStore.findEvent(sent.messageId);
|
|
expect(saved).toMatchObject({
|
|
message: [{ type: 'image', data: { file: media.fileName, url: f.url } }],
|
|
});
|
|
expect(JSON.stringify(media)).not.toContain('base64://');
|
|
expect(JSON.stringify(saved)).not.toContain('base64://');
|
|
expect(f.resolver).not.toHaveBeenCalled();
|
|
await account.close();
|
|
account = await f.open();
|
|
// Reused filenames and newer resources cannot replace a message's own reference.
|
|
await account.ref.mediaStore.rememberImage({
|
|
file: media.fileName!,
|
|
fileName: media.fileName!,
|
|
fileSize: 1,
|
|
imageUrl: 'https://wrong.example/image',
|
|
url: '',
|
|
isGroup: !isGroup,
|
|
sessionId: 30003,
|
|
subType: 0,
|
|
summary: '',
|
|
});
|
|
expect(
|
|
await account.handler.handle('get_msg', { message_id: sent.messageId }),
|
|
).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message_id: sent.messageId,
|
|
real_id: sent.messageId,
|
|
message: [{ type: 'image', data: { url: `${f.url}&rkey=fresh` } }],
|
|
},
|
|
});
|
|
expect(f.resolver).toHaveBeenCalledWith(
|
|
expect.objectContaining({ imageUrl: f.url }),
|
|
isGroup,
|
|
);
|
|
expect(f.getC2cMessageBySeq).not.toHaveBeenCalled();
|
|
expect(f.getC2cLatestHistory).not.toHaveBeenCalled();
|
|
expect(f.getGroupMessageBySeq).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ isGroup: false, authoritative: true, snapshot: true },
|
|
{ isGroup: true, authoritative: true, snapshot: true },
|
|
{ isGroup: false, authoritative: false, snapshot: true },
|
|
{ isGroup: false, authoritative: true, snapshot: false },
|
|
{ isGroup: true, authoritative: true, snapshot: false },
|
|
])(
|
|
'repairs an incomplete stored image without replacing its identity ($isGroup/$authoritative/$snapshot)',
|
|
async ({ isGroup, authoritative, snapshot }) => {
|
|
const f = fixture(isGroup);
|
|
let account = await f.open();
|
|
await saveIncomplete(account.ref, isGroup, authoritative, snapshot);
|
|
const meta = await account.ref.messageStore.findMeta(legacyId);
|
|
await account.close();
|
|
account = await f.open();
|
|
|
|
if (!isGroup) {
|
|
const quoted = await backfillReplyTarget(account.ref, {
|
|
kind: 'friend_message',
|
|
selfUin: self,
|
|
senderUin: peer,
|
|
peerUin: peer,
|
|
senderNick: 'peer',
|
|
time: time + 2,
|
|
msgSeq: 900,
|
|
clientSeq: 900,
|
|
ntMsgSeq: 9900,
|
|
msgId: 55,
|
|
elements: [
|
|
{ type: 'reply', replySeq: 777, replySenderUin: self, replyTime: time },
|
|
],
|
|
});
|
|
expect(quoted.elements[0]).toMatchObject({ replyMessageId: legacyId });
|
|
}
|
|
|
|
const expected = {
|
|
status: 'ok',
|
|
data: {
|
|
message_id: legacyId,
|
|
real_id: legacyId,
|
|
message: [
|
|
{ type: 'image', data: { file: `${md5}.png`, url: `${f.url}&rkey=fresh` } },
|
|
{ type: 'text', data: { text: 'original caption' } },
|
|
],
|
|
},
|
|
};
|
|
expect(await account.handler.handle('get_msg', { message_id: legacyId })).toMatchObject(
|
|
expected,
|
|
);
|
|
expect(await account.ref.messageStore.findMeta(legacyId)).toEqual(meta);
|
|
expect(await account.ref.messageStore.findEvent(legacyId)).toMatchObject({
|
|
message_id: legacyId,
|
|
message: [{ data: { url: f.url } }, { data: { text: 'original caption' } }],
|
|
});
|
|
expect((await account.ref.messageStore.findMedia(legacyId))![0]).toMatchObject({
|
|
imageUrl: f.url,
|
|
md5Hex: md5,
|
|
});
|
|
|
|
if (isGroup) {
|
|
expect(f.getGroupMessageBySeq).toHaveBeenCalledExactlyOnceWith(peer, 42, self);
|
|
} else if (authoritative) {
|
|
expect(f.getC2cMessageBySeq).toHaveBeenCalledExactlyOnceWith('u_peer', 42, self);
|
|
} else {
|
|
expect(f.getC2cLatestHistory).toHaveBeenCalledExactlyOnceWith(
|
|
'u_peer',
|
|
20,
|
|
self,
|
|
time + 1,
|
|
);
|
|
}
|
|
|
|
await account.close();
|
|
account = await f.open();
|
|
f.getGroupMessageBySeq.mockClear();
|
|
f.getC2cMessageBySeq.mockClear();
|
|
f.getC2cLatestHistory.mockClear();
|
|
expect(await account.handler.handle('get_msg', { message_id: legacyId })).toMatchObject(
|
|
expected,
|
|
);
|
|
expect(f.getGroupMessageBySeq).not.toHaveBeenCalled();
|
|
expect(f.getC2cMessageBySeq).not.toHaveBeenCalled();
|
|
expect(f.getC2cLatestHistory).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(['sender', 'peer', 'sequence', 'time', 'fingerprint', 'missing', 'request'] as const)(
|
|
'keeps the original record and reports an unverified repair: %s',
|
|
async (mismatch) => {
|
|
const f = fixture(false);
|
|
const { ref, handler } = await f.open();
|
|
const original = await saveIncomplete(ref, false);
|
|
const media = await ref.messageStore.findMedia(legacyId);
|
|
const source = f.historyMessage as FriendMessage;
|
|
|
|
if (mismatch === 'sender') {
|
|
source.senderUin = peer;
|
|
}
|
|
|
|
if (mismatch === 'peer') {
|
|
source.peerUin = 30003;
|
|
}
|
|
|
|
if (mismatch === 'sequence') {
|
|
source.ntMsgSeq = 43;
|
|
}
|
|
|
|
if (mismatch === 'time') {
|
|
source.time += 60;
|
|
}
|
|
|
|
if (mismatch === 'fingerprint') {
|
|
source.elements[0]!.md5Hex = 'cc'.repeat(16);
|
|
}
|
|
|
|
if (mismatch === 'missing') {
|
|
f.getC2cMessageBySeq.mockResolvedValue(null);
|
|
}
|
|
|
|
if (mismatch === 'request') {
|
|
f.getC2cMessageBySeq.mockRejectedValue(new Error('history unavailable'));
|
|
}
|
|
|
|
const logs: LogEntry[] = [];
|
|
const unsubscribe = subscribeLogs((entry) => logs.push(entry));
|
|
|
|
try {
|
|
expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({
|
|
status: 'ok',
|
|
data: {
|
|
message_id: legacyId,
|
|
message: [{ data: { url: '' } }, { data: { text: 'original caption' } }],
|
|
},
|
|
});
|
|
expect(
|
|
logs.some(
|
|
(entry) =>
|
|
entry.level === 'warn' &&
|
|
entry.message.includes('image history repair'),
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
|
|
expect(await ref.messageStore.findEvent(legacyId)).toEqual(original);
|
|
expect(await ref.messageStore.findMedia(legacyId)).toEqual(media);
|
|
},
|
|
);
|
|
});
|