Files
SnowLuma/packages/onebot/tests/private-reply-resolution.test.ts
T
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

470 lines
19 KiB
TypeScript

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FriendMessage, MessageElement } from '@snowluma/protocol/events';
import { convertFriendMessage } from '../src/event-converter/to-message';
import { resolveReplyId } from '../src/event-converter/utils';
import type { ConverterContext } from '../src/event-converter';
import {
hashMessageIdInt32,
PRIVATE_NT_MESSAGE_EVENT,
privateMessageEventName,
} from '../src/message-id';
import { createMessageIdResolver } from '../src/message-id-resolver';
import { MessageStore } from '../src/message-store';
import { backfillReplyTarget } from '../src/modules/message-actions';
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
import { registerEventPipeline } from '../src/event-pipeline';
import type { OneBotInstanceContext } from '../src/instance-context';
import type { JsonObject } from '../src/types';
const self = 10001;
const peer = 20002;
const time = 1_700_000_000;
const quoted = [{ type: 'text', text: 'actual quoted text' }] satisfies MessageElement[];
describe('private quote identity (#618)', () => {
let dir: string;
let store: MessageStore;
let converter: ConverterContext;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'snowluma-quote-'));
store = new MessageStore(path.join(dir, 'messages.db'));
await store.initialization;
converter = {
selfId: self,
imageUrlResolver: null,
mediaUrlResolver: null,
mediaSegmentSink: null,
messageIdResolver: createMessageIdResolver(store),
};
});
afterEach(async () => {
await store.close();
await fs.rm(dir, { recursive: true, force: true });
});
async function save(
sequence: number,
clientSequence: number,
timestamp: number,
text: string,
outgoing = true,
targetId = peer,
): Promise<number> {
const id = hashMessageIdInt32(sequence, targetId, PRIVATE_NT_MESSAGE_EVENT);
await store.storeMeta(id, {
isGroup: false,
targetId,
sequence,
sequenceAuthoritative: true,
eventName: PRIVATE_NT_MESSAGE_EVENT,
clientSequence,
privateDirection: outgoing ? 'outgoing' : 'incoming',
timestamp,
random: sequence,
});
await store.storeEvent(id, false, targetId, sequence, PRIVATE_NT_MESSAGE_EVENT, {
time: timestamp,
post_type: outgoing ? 'message_sent' : 'message',
message_type: 'private',
sub_type: 'friend',
user_id: outgoing ? self : targetId,
message: [{ type: 'text', data: { text } }],
});
return id;
}
function message(replySeq = 444, replyTime = time): FriendMessage {
return {
kind: 'friend_message',
selfUin: self,
senderUin: peer,
peerUin: peer,
senderNick: 'peer',
time: time + 2,
msgSeq: 900,
clientSeq: 900,
ntMsgSeq: 9900,
sequenceAuthoritative: true,
msgId: 55,
elements: [
{ type: 'reply', replySeq, replyTime, replySenderUin: self, replyElements: quoted },
{ type: 'text', text: 'new reply' },
],
};
}
function historyRef(history: FriendMessage[] = []) {
const getC2cLatestHistory = vi.fn(async () => history);
const ref = {
selfId: self,
converterCtx: converter,
messageStore: store,
bridge: {
resolveUserUid: vi.fn(async () => 'u_peer'),
apis: { message: { getC2cLatestHistory } },
},
} as unknown as Parameters<typeof backfillReplyTarget>[0];
return { ref, getC2cLatestHistory };
}
it.each([-3600, 3600])(
'rejects a conversation-sequence match %i seconds away from the quote',
async (delta) => {
const unrelated = await save(444, 1111, time + delta, 'unrelated text');
const target = await save(500, 2222, time, 'actual quoted text');
expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time, quoted)).toBe(
target,
);
expect((await store.findEvent(unrelated))?.message).toEqual([
{ type: 'text', data: { text: 'unrelated text' } },
]);
},
);
it('does not let an older reused client sequence override the quoted time and text', async () => {
await save(100, 444, time - 3600, 'old message');
const target = await save(500, 2222, time, 'actual quoted text');
expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time, quoted)).toBe(
target,
);
});
it('rejects even a sole same-second candidate when its text contradicts the quote', async () => {
await save(300, 3333, time, 'different text');
expect(await store.resolvePrivateReplyMessageId(peer, 9999, true, time, quoted)).toBeNull();
});
it('does not prefer one sequence namespace when both identify different compatible messages', async () => {
await save(100, 444, time, 'same');
await save(444, 2222, time, 'same');
expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time)).toBeNull();
});
it('keeps reused sequences ambiguous when the quote has no usable timestamp', async () => {
await save(100, 444, time - 3600, 'old');
await save(500, 444, time, 'new');
expect(await store.resolvePrivateReplyMessageId(peer, 444, true)).toBeNull();
});
it('uses quoted content to check a nearby-time candidate too', async () => {
await save(300, 3333, time - 2, 'different text');
expect(await store.resolvePrivateReplyMessageId(peer, 9999, true, time, quoted)).toBeNull();
});
it('keeps peer and sender direction isolated when matching by time', async () => {
await save(300, 444, time, 'actual quoted text', false);
await save(300, 444, time, 'actual quoted text', true, 30003);
expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time, quoted)).toBeNull();
});
it('preserves an explicitly unresolved quote instead of using its raw sequence', async () => {
expect(await resolveReplyId(false, peer, 8888, () => 0)).toBe(0);
});
it('omits an unresolved reply identifier while retaining the live message and its stable id', async () => {
const json = await convertFriendMessage(converter, message());
expect(json.message_id).toBe(hashMessageIdInt32(9900, peer, PRIVATE_NT_MESSAGE_EVENT));
expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]);
});
it('uses the canonical history id after backfill instead of a hash of the quote sequence', async () => {
const fetched: FriendMessage = {
kind: 'friend_message',
selfUin: self,
senderUin: self,
peerUin: peer,
senderNick: 'self',
time,
msgSeq: 444,
clientSeq: 444,
ntMsgSeq: 500,
sequenceAuthoritative: true,
msgId: 77,
elements: quoted,
};
const { ref, getC2cLatestHistory } = historyRef([fetched]);
const event = message();
const prepared = await backfillReplyTarget(ref, event);
const json = await convertFriendMessage(converter, prepared as FriendMessage);
const target = hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT);
expect(getC2cLatestHistory).toHaveBeenCalledExactlyOnceWith('u_peer', 20, self, time + 1);
expect(json.message).toEqual([
{ type: 'reply', data: { id: String(target) } },
{ type: 'text', data: { text: 'new reply' } },
]);
expect((await store.findEvent(target))?.message_id).toBe(target);
expect(
await store.findEvent(hashMessageIdInt32(444, peer, PRIVATE_NT_MESSAGE_EVENT)),
).toBeNull();
expect(event.elements[0]).not.toHaveProperty('replyMessageId');
});
it('does not store an invented private message when history cannot identify the target', async () => {
const { ref } = historyRef();
const event = message();
const prepared = await backfillReplyTarget(ref, event);
const json = await convertFriendMessage(converter, prepared as FriendMessage);
expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]);
expect(
await store.findEvent(hashMessageIdInt32(444, peer, PRIVATE_NT_MESSAGE_EVENT)),
).toBeNull();
});
it.each([
{ hasContent: true, ntMsgSeq: 9900 },
{ hasContent: false, ntMsgSeq: 9900 },
{ hasContent: true, ntMsgSeq: 0 },
{ hasContent: false, ntMsgSeq: 0 },
])(
'never resolves a same-second self-sent reply to itself: %j',
async ({ hasContent, ntMsgSeq }) => {
const event = { ...message(9999), senderUin: self, time, ntMsgSeq };
if (!hasContent && event.elements[0]?.type === 'reply') {
event.elements[0].replyElements = undefined;
}
const eventName = privateMessageEventName(true, ntMsgSeq > 0);
const currentId = hashMessageIdInt32(ntMsgSeq || event.msgSeq, peer, eventName);
await store.storeMeta(currentId, {
isGroup: false,
targetId: peer,
sequence: event.ntMsgSeq!,
sequenceAuthoritative: ntMsgSeq > 0,
clientSequence: event.clientSeq!,
eventName,
privateDirection: 'outgoing',
random: event.msgId,
timestamp: time,
});
const directlyConverted = await convertFriendMessage(converter, event);
expect(directlyConverted.message).toEqual([
{ type: 'text', data: { text: 'new reply' } },
]);
const { ref } = historyRef([event]);
const prepared = await backfillReplyTarget(ref, event);
const json = await convertFriendMessage(converter, prepared as FriendMessage);
expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]);
expect(await store.findEvent(currentId)).toBeNull();
},
);
it('does not use a bodyless time-only candidate to confirm known quoted content', async () => {
await store.storeMeta(-55, {
isGroup: false,
targetId: peer,
sequence: 300,
sequenceAuthoritative: true,
clientSequence: 100,
eventName: PRIVATE_NT_MESSAGE_EVENT,
privateDirection: 'outgoing',
random: 1,
timestamp: time,
});
expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time, quoted)).toBeNull();
});
it('does not fetch history for an already verified cached target', async () => {
const target = await save(500, 444, time, 'actual quoted text');
const { ref, getC2cLatestHistory } = historyRef();
const prepared = await backfillReplyTarget(ref, message());
const json = await convertFriendMessage(converter, prepared as FriendMessage);
expect((json.message as JsonObject[])[0]).toEqual({
type: 'reply',
data: { id: String(target) },
});
expect(getC2cLatestHistory).not.toHaveBeenCalled();
});
it('keys outgoing history without a peer field under the requested conversation', async () => {
const fetched: FriendMessage = {
...message(),
peerUin: undefined,
senderUin: self,
time,
msgSeq: 444,
clientSeq: 444,
ntMsgSeq: 500,
elements: quoted,
};
const { ref } = historyRef([fetched]);
const prepared = await backfillReplyTarget(ref, message());
const json = await convertFriendMessage(converter, prepared as FriendMessage);
const target = hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT);
expect((json.message as JsonObject[])[0]).toEqual({
type: 'reply',
data: { id: String(target) },
});
expect((await store.findEvent(target))?.target_id).toBe(peer);
expect(
await store.findEvent(hashMessageIdInt32(500, self, PRIVATE_NT_MESSAGE_EVENT)),
).toBeNull();
expect(fetched.peerUin).toBeUndefined();
});
it.each([
{ senderUin: peer },
{ peerUin: 30003 },
{ selfUin: 30003 },
{ elements: [{ type: 'text', text: 'different text' }] satisfies MessageElement[] },
{ time: time - 3600 },
])('rejects incompatible server history: %j', async (override) => {
const fetched: FriendMessage = {
...message(),
senderUin: self,
time,
msgSeq: 444,
clientSeq: 444,
ntMsgSeq: 500,
elements: quoted,
...override,
};
const { ref } = historyRef([fetched]);
const prepared = await backfillReplyTarget(ref, message());
const json = await convertFriendMessage(converter, prepared as FriendMessage);
expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]);
expect(
await store.findEvent(hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT)),
).toBeNull();
});
it('does not restore or reference a recalled target returned by server history', async () => {
await store.recordPrivateRecall(peer, 444, true, time + 1);
const fetched: FriendMessage = {
...message(),
senderUin: self,
time,
msgSeq: 444,
clientSeq: 444,
ntMsgSeq: 500,
elements: quoted,
};
const { ref } = historyRef([fetched]);
const prepared = await backfillReplyTarget(ref, message());
const json = await convertFriendMessage(converter, prepared as FriendMessage);
expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]);
expect(
await store.findEvent(hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT)),
).toBeNull();
});
it('preserves the live message on a history fetch failure without inventing a target', async () => {
const { ref, getC2cLatestHistory } = historyRef();
getC2cLatestHistory.mockRejectedValueOnce(new Error('isolated history transport failure'));
const prepared = await backfillReplyTarget(ref, message());
const json = await convertFriendMessage(converter, prepared as FriendMessage);
expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]);
expect(getC2cLatestHistory).toHaveBeenCalledOnce();
expect(await store.listSessionEvents(false, peer)).toEqual([]);
});
it('keeps a verified legacy negative id when filling its missing body from quoted content', async () => {
const target = -12345;
await store.storeMeta(target, {
isGroup: false,
targetId: peer,
sequence: 500,
sequenceAuthoritative: true,
clientSequence: 444,
eventName: PRIVATE_NT_MESSAGE_EVENT,
privateDirection: 'outgoing',
random: 7,
timestamp: time,
});
const { ref } = historyRef();
const prepared = await backfillReplyTarget(ref, message());
const json = await convertFriendMessage(converter, prepared as FriendMessage);
expect((json.message as JsonObject[])[0]).toEqual({
type: 'reply',
data: { id: String(target) },
});
expect((await store.findEvent(target))?.message).toEqual([
{ type: 'text', data: { text: 'actual quoted text' } },
]);
});
it('does not overwrite an old identity when history reuses its client sequence with a different server sequence', async () => {
const oldId = -12345;
await store.storeMeta(oldId, {
isGroup: false,
targetId: peer,
sequence: 499,
sequenceAuthoritative: true,
clientSequence: 444,
eventName: PRIVATE_NT_MESSAGE_EVENT,
privateDirection: 'outgoing',
random: 7,
timestamp: time - 1,
});
const fetched: FriendMessage = {
...message(),
senderUin: self,
time,
msgSeq: 444,
clientSeq: 444,
ntMsgSeq: 500,
elements: quoted,
};
const { ref } = historyRef([fetched]);
const prepared = await backfillReplyTarget(ref, message());
const json = await convertFriendMessage(converter, prepared as FriendMessage);
const target = hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT);
expect((json.message as JsonObject[])[0]).toEqual({
type: 'reply',
data: { id: String(target) },
});
expect(await store.findEvent(oldId)).toBeNull();
expect((await store.findMeta(oldId))?.sequence).toBe(499);
expect((await store.findEvent(target))?.message_id).toBe(target);
});
it('dispatches only after canonical backfill is available to an immediate get_msg', async () => {
const fetched: FriendMessage = {
...message(),
senderUin: self,
time,
msgSeq: 444,
clientSeq: 444,
ntMsgSeq: 500,
elements: quoted,
};
const { ref } = historyRef([fetched]);
const bus = new BridgeEventBus();
const dispatched: JsonObject[] = [];
const targets: Array<JsonObject | null> = [];
const ctx = {
...ref,
uin: String(self),
bridge: { ...ref.bridge, events: bus },
cacheMessageMeta: store.storeMeta.bind(store),
dispatchEvent: async (json: JsonObject) => {
dispatched.push(json);
const reply = (json.message as JsonObject[]).find(
(segment) => segment.type === 'reply',
);
targets.push(await store.findEvent(Number((reply?.data as JsonObject)?.id)));
},
} as unknown as OneBotInstanceContext;
const handle = registerEventPipeline(ctx);
try {
await bus.emit(message());
expect(dispatched).toHaveLength(1);
const target = hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT);
expect((dispatched[0]!.message as JsonObject[])[0]).toEqual({
type: 'reply',
data: { id: String(target) },
});
expect(targets[0]?.message_id).toBe(target);
} finally {
handle.stop();
await handle.drain();
}
});
});