SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,943 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createLogger,
|
||||
getLogLevel,
|
||||
runWithRequestId,
|
||||
setLogLevel,
|
||||
subscribeLogs,
|
||||
type LogEntry,
|
||||
} from '@snowluma/common/logger';
|
||||
import type { BridgeInterface } from '../../core/src/bridge/bridge-interface';
|
||||
import { ApiHandler } from '../src/api-handler';
|
||||
import { OneBotInstance } from '../src/instance';
|
||||
import { buildApiContext, type OneBotInstanceContext } from '../src/instance-context';
|
||||
import { hashMessageIdInt32, PRIVATE_NT_MESSAGE_EVENT } from '../src/message-id';
|
||||
import { TempSessionStore } from '../src/temp-session-store';
|
||||
import type { JsonObject, OneBotConfig } from '../src/types';
|
||||
|
||||
const SELF_ID = 10001;
|
||||
const PEER_ID = 20002;
|
||||
const RECEIPT = {
|
||||
messageId: 123456,
|
||||
sequence: 87,
|
||||
clientSequence: 9,
|
||||
random: 123456,
|
||||
timestamp: 1_700_000_000,
|
||||
};
|
||||
type Receipt = typeof RECEIPT;
|
||||
|
||||
function makeContext(receiptOrReceipts: Receipt | Receipt[] = RECEIPT): {
|
||||
ctx: OneBotInstanceContext;
|
||||
dispatchEvent: ReturnType<typeof vi.fn<(event: JsonObject) => void>>;
|
||||
events: Map<number, JsonObject>;
|
||||
} {
|
||||
const receipts = Array.isArray(receiptOrReceipts) ? receiptOrReceipts : [receiptOrReceipts];
|
||||
let receiptIndex = 0;
|
||||
|
||||
const nextReceipt = () => {
|
||||
const receipt = receipts[receiptIndex++];
|
||||
|
||||
if (!receipt) {
|
||||
throw new Error('unexpected extra private send');
|
||||
}
|
||||
|
||||
return receipt;
|
||||
};
|
||||
|
||||
const events = new Map<number, JsonObject>();
|
||||
const dispatchEvent = vi.fn<(event: JsonObject) => void>();
|
||||
const bridge = {
|
||||
identity: { nickname: 'SnowLuma' },
|
||||
apis: {
|
||||
message: {
|
||||
sendPrivate: vi.fn(async () => nextReceipt()),
|
||||
sendGroupTempMessage: vi.fn(async () => nextReceipt()),
|
||||
sendC2cFile: vi.fn(async () => nextReceipt()),
|
||||
},
|
||||
groupFile: {
|
||||
uploadPrivate: vi.fn(async () => ({
|
||||
fileId: 'uploaded-file-uuid',
|
||||
fileHash: 'uploaded-file-hash',
|
||||
})),
|
||||
},
|
||||
forward: {
|
||||
upload: vi.fn(async () => 'forward-res-id'),
|
||||
},
|
||||
},
|
||||
resolveUserUid: vi.fn(async () => 'u_peer'),
|
||||
recallUploadedFile: vi.fn((fileId: string) =>
|
||||
fileId === 'uploaded-file-uuid'
|
||||
? {
|
||||
fileId,
|
||||
scope: 'private',
|
||||
userId: PEER_ID,
|
||||
fileName: 'inline.txt',
|
||||
fileSize: 3,
|
||||
fileMd5: new Uint8Array(16),
|
||||
fileSha1: new Uint8Array(20),
|
||||
fileHash: 'uploaded-file-hash',
|
||||
rememberedAt: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
} as unknown as BridgeInterface;
|
||||
|
||||
const ctx = {
|
||||
uin: String(SELF_ID),
|
||||
selfId: SELF_ID,
|
||||
bridge,
|
||||
messageStore: {
|
||||
findMedia: () => null,
|
||||
findEvent: (messageId: number) => events.get(messageId) ?? null,
|
||||
findMeta: () => null,
|
||||
resolveReplySequence: () => null,
|
||||
storeEvent: (
|
||||
messageId: number,
|
||||
_isGroup: boolean,
|
||||
_sessionId: number,
|
||||
_sequence: number,
|
||||
_eventName: string,
|
||||
event: JsonObject,
|
||||
) => {
|
||||
events.set(messageId, event);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
mediaStore: {},
|
||||
reactionStore: {},
|
||||
tempSessions: new TempSessionStore(),
|
||||
converterCtx: {
|
||||
selfId: SELF_ID,
|
||||
imageUrlResolver: null,
|
||||
mediaUrlResolver: null,
|
||||
messageIdResolver: null,
|
||||
mediaSegmentSink: null,
|
||||
},
|
||||
config: {
|
||||
networks: { httpServers: [], httpClients: [], wsServers: [], wsClients: [] },
|
||||
} as OneBotConfig,
|
||||
cacheMessageMeta: vi.fn(),
|
||||
dispatchEvent,
|
||||
} as unknown as OneBotInstanceContext;
|
||||
|
||||
return { ctx, dispatchEvent, events };
|
||||
}
|
||||
|
||||
interface InstanceHarness {
|
||||
dispatch(event: JsonObject, source: 'send' | 'bridge'): Promise<void>;
|
||||
emitEvent: ReturnType<typeof vi.fn<(event: JsonObject) => Promise<void>>>;
|
||||
stored: Map<number, JsonObject>;
|
||||
}
|
||||
|
||||
function makeInstanceHarness(
|
||||
options: {
|
||||
statusCommand?: {
|
||||
enabled: boolean;
|
||||
swallow: boolean;
|
||||
cooldownSeconds: number;
|
||||
trigger: string;
|
||||
};
|
||||
logger?: ReturnType<typeof createLogger>;
|
||||
} = {},
|
||||
): InstanceHarness {
|
||||
const stored = new Map<number, JsonObject>();
|
||||
const emitEvent = vi.fn(async (_event: JsonObject) => {});
|
||||
const instance = Object.create(OneBotInstance.prototype) as OneBotInstance;
|
||||
Object.assign(instance as unknown as Record<string, unknown>, {
|
||||
bridge: { identity: {} },
|
||||
ctx: {
|
||||
config: {
|
||||
statusCommand: options.statusCommand ?? {
|
||||
enabled: false,
|
||||
swallow: false,
|
||||
cooldownSeconds: 5,
|
||||
trigger: '#sl',
|
||||
},
|
||||
},
|
||||
},
|
||||
log: options.logger ?? { success: vi.fn(), trace: vi.fn(), warn: vi.fn() },
|
||||
messageStore: {
|
||||
findMedia: () => null,
|
||||
findEvent: (messageId: number) => stored.get(messageId) ?? null,
|
||||
storeEvent: (
|
||||
messageId: number,
|
||||
_isGroup: boolean,
|
||||
_sessionId: number,
|
||||
_sequence: number,
|
||||
_eventName: string,
|
||||
event: JsonObject,
|
||||
) => {
|
||||
stored.set(messageId, event);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
networkManager: { emitEvent },
|
||||
acceptingActions: true,
|
||||
uin: String(SELF_ID),
|
||||
statusCommandCooldown: new Map([['p:20002', Date.now()]]),
|
||||
recentSelfSentEvents: new Map(),
|
||||
});
|
||||
const dispatch = (
|
||||
instance as unknown as {
|
||||
dispatchEvent(event: JsonObject, source: 'send' | 'bridge'): Promise<void>;
|
||||
}
|
||||
).dispatchEvent.bind(instance);
|
||||
return { dispatch, emitEvent, stored };
|
||||
}
|
||||
|
||||
function privateSentEvent(overrides: JsonObject = {}): JsonObject {
|
||||
return {
|
||||
time: RECEIPT.timestamp,
|
||||
self_id: SELF_ID,
|
||||
post_type: 'message_sent',
|
||||
message_type: 'private',
|
||||
sub_type: 'friend',
|
||||
message_id: 987654,
|
||||
message_seq: RECEIPT.clientSequence,
|
||||
user_id: SELF_ID,
|
||||
target_id: PEER_ID,
|
||||
message: [{ type: 'text', data: { text: 'hello' } }],
|
||||
raw_message: 'hello',
|
||||
sender: { user_id: SELF_ID, nickname: 'SnowLuma' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function groupSentEvent(overrides: JsonObject = {}): JsonObject {
|
||||
return {
|
||||
...privateSentEvent(),
|
||||
message_type: 'group',
|
||||
sub_type: 'normal',
|
||||
message_id: -7654321,
|
||||
message_seq: RECEIPT.sequence,
|
||||
group_id: 30003,
|
||||
target_id: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('OneBot self-sent events', () => {
|
||||
it('reports a client private send once across repeated confirmations and pushes', async () => {
|
||||
const { dispatch, emitEvent, stored } = makeInstanceHarness();
|
||||
const event = privateSentEvent({ message_id: -7654321 });
|
||||
await Promise.all([dispatch(event, 'bridge'), dispatch({ ...event }, 'bridge')]);
|
||||
await dispatch({ ...event, raw_message: 'canonical content' }, 'bridge');
|
||||
expect(emitEvent).toHaveBeenCalledExactlyOnceWith(event);
|
||||
expect(stored.get(-7654321)?.raw_message).toBe('canonical content');
|
||||
});
|
||||
|
||||
it('keeps private action deduplication for more than one echo', async () => {
|
||||
const { dispatch, emitEvent } = makeInstanceHarness();
|
||||
const event = privateSentEvent();
|
||||
await dispatch(event, 'send');
|
||||
await dispatch({ ...event }, 'bridge');
|
||||
await dispatch({ ...event }, 'bridge');
|
||||
expect(emitEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('distinguishes private sends by identity and peer even when text is identical', async () => {
|
||||
const { dispatch, emitEvent } = makeInstanceHarness();
|
||||
const event = privateSentEvent();
|
||||
await dispatch(event, 'bridge');
|
||||
await dispatch({ ...event, message_seq: RECEIPT.clientSequence + 1 }, 'bridge');
|
||||
await dispatch({ ...event, target_id: PEER_ID + 1 }, 'bridge');
|
||||
await dispatch({ ...event, message_id: -7654321 }, 'bridge');
|
||||
expect(emitEvent).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('keeps private deduplication isolated and expires it without deleting history', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const first = makeInstanceHarness();
|
||||
const second = makeInstanceHarness();
|
||||
const event = privateSentEvent();
|
||||
await first.dispatch(event, 'bridge');
|
||||
await second.dispatch(
|
||||
{ ...event, self_id: SELF_ID + 1, user_id: SELF_ID + 1 },
|
||||
'bridge',
|
||||
);
|
||||
expect(second.emitEvent).toHaveBeenCalledOnce();
|
||||
await first.dispatch(event, 'bridge');
|
||||
expect(first.emitEvent).toHaveBeenCalledOnce();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
await first.dispatch(event, 'bridge');
|
||||
expect(first.emitEvent).toHaveBeenCalledTimes(2);
|
||||
expect(first.stored.has(987654)).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a client group send once across repeated server pushes', async () => {
|
||||
const { dispatch, emitEvent, stored } = makeInstanceHarness();
|
||||
const event = groupSentEvent();
|
||||
|
||||
await dispatch(event, 'bridge');
|
||||
await dispatch({ ...event, raw_message: 'canonical echo' }, 'bridge');
|
||||
await dispatch({ ...event, raw_message: 'latest canonical echo' }, 'bridge');
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledOnce();
|
||||
expect(emitEvent).toHaveBeenCalledWith(event);
|
||||
expect(stored.get(-7654321)?.raw_message).toBe('latest canonical echo');
|
||||
});
|
||||
|
||||
it('reports concurrent copies of a client group send only once', async () => {
|
||||
const { dispatch, emitEvent } = makeInstanceHarness();
|
||||
const event = groupSentEvent();
|
||||
|
||||
await Promise.all([
|
||||
dispatch({ ...event }, 'bridge'),
|
||||
dispatch({ ...event }, 'bridge'),
|
||||
dispatch({ ...event }, 'bridge'),
|
||||
]);
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps group sends with identical text but different message identities', async () => {
|
||||
const { dispatch, emitEvent } = makeInstanceHarness();
|
||||
const event = groupSentEvent();
|
||||
|
||||
await dispatch(event, 'bridge');
|
||||
await dispatch({ ...event, message_seq: RECEIPT.sequence + 1 }, 'bridge');
|
||||
await dispatch({ ...event, group_id: 30004 }, 'bridge');
|
||||
await dispatch({ ...event, message_id: -7654322 }, 'bridge');
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('keeps group deduplication isolated between account instances', async () => {
|
||||
const first = makeInstanceHarness();
|
||||
const second = makeInstanceHarness();
|
||||
|
||||
await first.dispatch(groupSentEvent(), 'bridge');
|
||||
await second.dispatch(
|
||||
groupSentEvent({ self_id: SELF_ID + 1, user_id: SELF_ID + 1 }),
|
||||
'bridge',
|
||||
);
|
||||
|
||||
expect(first.emitEvent).toHaveBeenCalledOnce();
|
||||
expect(second.emitEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('expires group self-send identities without treating stored history as already reported', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const { dispatch, emitEvent } = makeInstanceHarness();
|
||||
const event = groupSentEvent();
|
||||
|
||||
await dispatch(event, 'bridge');
|
||||
await dispatch({ ...event }, 'bridge');
|
||||
expect(emitEvent).toHaveBeenCalledOnce();
|
||||
|
||||
vi.advanceTimersByTime(120_000);
|
||||
await dispatch({ ...event }, 'bridge');
|
||||
expect(emitEvent).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the timestamp bound when a group sequence and hash are reused', async () => {
|
||||
const { dispatch, emitEvent } = makeInstanceHarness();
|
||||
const event = groupSentEvent();
|
||||
|
||||
await dispatch(event, 'bridge');
|
||||
await dispatch({ ...event, time: RECEIPT.timestamp + 121 }, 'bridge');
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not publish an event suppressed by the persistence recall check', async () => {
|
||||
const { dispatch, emitEvent } = makeInstanceHarness();
|
||||
// The event's cache operation is the final authority after asynchronous
|
||||
// conversion: a recall may have committed while media was resolving.
|
||||
const event = privateSentEvent();
|
||||
const original = OneBotInstance.prototype;
|
||||
const cache = vi
|
||||
.spyOn(
|
||||
original as unknown as {
|
||||
cacheMessageEvent(event: JsonObject): Promise<boolean>;
|
||||
},
|
||||
'cacheMessageEvent',
|
||||
)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
try {
|
||||
await dispatch(event, 'bridge');
|
||||
expect(emitEvent).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
cache.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports upload_private_file as a private self-sent message', async () => {
|
||||
const { ctx, dispatchEvent } = makeContext();
|
||||
const handler = new ApiHandler(buildApiContext(ctx));
|
||||
|
||||
const response = await handler.handle('upload_private_file', {
|
||||
user_id: PEER_ID,
|
||||
file: 'base64://AQID',
|
||||
name: 'inline.txt',
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { file_id: 'uploaded-file-uuid' },
|
||||
});
|
||||
expect(ctx.bridge.apis.groupFile.uploadPrivate).toHaveBeenCalledWith(
|
||||
PEER_ID,
|
||||
'base64://AQID',
|
||||
'inline.txt',
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(ctx.bridge.apis.message.sendC2cFile).toHaveBeenCalledOnce();
|
||||
expect(dispatchEvent).toHaveBeenCalledOnce();
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
post_type: 'message_sent',
|
||||
message_type: 'private',
|
||||
self_id: SELF_ID,
|
||||
user_id: SELF_ID,
|
||||
target_id: PEER_ID,
|
||||
message_seq: RECEIPT.clientSequence,
|
||||
message: [
|
||||
{
|
||||
type: 'file',
|
||||
data: expect.objectContaining({
|
||||
file_id: 'uploaded-file-uuid',
|
||||
file: 'inline.txt',
|
||||
file_size: 3,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
'send',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails upload_private_file when publishing the private file fails', async () => {
|
||||
const { ctx, dispatchEvent } = makeContext();
|
||||
vi.mocked(ctx.bridge.apis.message.sendC2cFile).mockRejectedValueOnce(
|
||||
new Error('private file publication rejected'),
|
||||
);
|
||||
const handler = new ApiHandler(buildApiContext(ctx));
|
||||
|
||||
const response = await handler.handle('upload_private_file', {
|
||||
user_id: PEER_ID,
|
||||
file: 'base64://AQID',
|
||||
name: 'inline.txt',
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 'failed',
|
||||
retcode: 100,
|
||||
wording: 'private file publication rejected',
|
||||
});
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report upload_private_file when upload_file is disabled', async () => {
|
||||
const { ctx, dispatchEvent } = makeContext();
|
||||
const handler = new ApiHandler(buildApiContext(ctx));
|
||||
|
||||
const response = await handler.handle('upload_private_file', {
|
||||
user_id: PEER_ID,
|
||||
file: 'base64://AQID',
|
||||
name: 'inline.txt',
|
||||
upload_file: false,
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { file_id: 'uploaded-file-uuid' },
|
||||
});
|
||||
expect(ctx.bridge.apis.message.sendC2cFile).not.toHaveBeenCalled();
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a private message sent through an action', async () => {
|
||||
const { ctx, dispatchEvent } = makeContext();
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
const result = await api.sendPrivateMessage(
|
||||
PEER_ID,
|
||||
[{ type: 'text', data: { text: 'hello' } }],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messageId).not.toBe(0);
|
||||
expect(dispatchEvent).toHaveBeenCalledOnce();
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
post_type: 'message_sent',
|
||||
message_type: 'private',
|
||||
sub_type: 'friend',
|
||||
self_id: SELF_ID,
|
||||
user_id: SELF_ID,
|
||||
target_id: PEER_ID,
|
||||
message_seq: RECEIPT.clientSequence,
|
||||
}),
|
||||
'send',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports storage failure without failing or repeating an already successful send', async () => {
|
||||
const { ctx, dispatchEvent, events } = makeContext();
|
||||
vi.spyOn(ctx.messageStore, 'storeEvent').mockImplementation(() => {
|
||||
throw new Error('disk full');
|
||||
});
|
||||
const handler = new ApiHandler(buildApiContext(ctx));
|
||||
const entries: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
|
||||
try {
|
||||
const response = await handler.handle('send_private_msg', {
|
||||
user_id: PEER_ID,
|
||||
message: 'hello',
|
||||
});
|
||||
expect(response).toMatchObject({ status: 'ok', retcode: 0 });
|
||||
expect(ctx.bridge.apis.message.sendPrivate).toHaveBeenCalledOnce();
|
||||
expect(events.size).toBe(0);
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: 'warn',
|
||||
message: expect.stringMatching(/failed to cache self-sent message.*disk full/),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('[#288] reports a private forward built through an action', async () => {
|
||||
const { ctx, dispatchEvent } = makeContext();
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
const result = await api.sendPrivateForwardMsg(PEER_ID, [
|
||||
{
|
||||
type: 'node',
|
||||
data: {
|
||||
user_id: SELF_ID,
|
||||
nickname: 'SnowLuma',
|
||||
content: [{ type: 'text', data: { text: 'forwarded' } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.messageId).not.toBe(0);
|
||||
expect(dispatchEvent).toHaveBeenCalledOnce();
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
post_type: 'message_sent',
|
||||
message_type: 'private',
|
||||
self_id: SELF_ID,
|
||||
user_id: SELF_ID,
|
||||
target_id: PEER_ID,
|
||||
message_seq: RECEIPT.clientSequence,
|
||||
message: [
|
||||
expect.objectContaining({
|
||||
type: 'forward',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
'send',
|
||||
);
|
||||
});
|
||||
|
||||
it('[#288] reports a cached message forwarded to a friend through an action', async () => {
|
||||
const { ctx, dispatchEvent, events } = makeContext();
|
||||
const sourceMessageId = 55;
|
||||
events.set(sourceMessageId, {
|
||||
message_id: sourceMessageId,
|
||||
message_type: 'private',
|
||||
user_id: PEER_ID,
|
||||
message: [{ type: 'text', data: { text: 'forward me' } }],
|
||||
});
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
const result = await api.forwardSingleMsg(sourceMessageId, { userId: PEER_ID });
|
||||
|
||||
expect(result.messageId).not.toBe(0);
|
||||
expect(dispatchEvent).toHaveBeenCalledOnce();
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
post_type: 'message_sent',
|
||||
message_type: 'private',
|
||||
self_id: SELF_ID,
|
||||
user_id: SELF_ID,
|
||||
target_id: PEER_ID,
|
||||
message_seq: RECEIPT.clientSequence,
|
||||
message: [{ type: 'text', data: { text: 'forward me' } }],
|
||||
}),
|
||||
'send',
|
||||
);
|
||||
});
|
||||
|
||||
it('[#288] does not report a private forward without a reliable sequence', async () => {
|
||||
const { ctx, dispatchEvent, events } = makeContext({ ...RECEIPT, sequence: 0 });
|
||||
const sourceMessageId = 56;
|
||||
events.set(sourceMessageId, {
|
||||
message_id: sourceMessageId,
|
||||
message_type: 'private',
|
||||
user_id: PEER_ID,
|
||||
message: [{ type: 'text', data: { text: 'forward me' } }],
|
||||
});
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
await api.forwardSingleMsg(sourceMessageId, { userId: PEER_ID });
|
||||
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a private file sent through an action', async () => {
|
||||
const { ctx, dispatchEvent } = makeContext();
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
const result = await api.sendPrivateMessage(
|
||||
PEER_ID,
|
||||
[
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file_id: 'file-uuid',
|
||||
name: 'report.txt',
|
||||
size: 128,
|
||||
md5: '00112233445566778899aabbccddeeff',
|
||||
},
|
||||
},
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messageId).not.toBe(0);
|
||||
expect(dispatchEvent).toHaveBeenCalledOnce();
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
post_type: 'message_sent',
|
||||
message_type: 'private',
|
||||
self_id: SELF_ID,
|
||||
user_id: SELF_ID,
|
||||
target_id: PEER_ID,
|
||||
message: [
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
data: expect.objectContaining({
|
||||
file_id: 'file-uuid',
|
||||
file: 'report.txt',
|
||||
file_size: 128,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
'send',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a private file uploaded and sent through an action', async () => {
|
||||
const { ctx, dispatchEvent } = makeContext();
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
await api.sendPrivateMessage(
|
||||
PEER_ID,
|
||||
[
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file: 'base64://AQID',
|
||||
name: 'inline.txt',
|
||||
},
|
||||
},
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledOnce();
|
||||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
post_type: 'message_sent',
|
||||
message_type: 'private',
|
||||
target_id: PEER_ID,
|
||||
message: [
|
||||
expect.objectContaining({
|
||||
type: 'file',
|
||||
data: expect.objectContaining({
|
||||
file: 'inline.txt',
|
||||
url: 'base64://AQID',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
'send',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports each QQ message produced by a mixed private action', async () => {
|
||||
const fileReceipt: Receipt = {
|
||||
messageId: 654321,
|
||||
sequence: 88,
|
||||
clientSequence: 10,
|
||||
random: 654321,
|
||||
timestamp: RECEIPT.timestamp + 1,
|
||||
};
|
||||
const { ctx, dispatchEvent } = makeContext([RECEIPT, fileReceipt]);
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
const result = await api.sendPrivateMessage(
|
||||
PEER_ID,
|
||||
[
|
||||
{ type: 'text', data: { text: 'attachment:' } },
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file_id: 'mixed-file-uuid',
|
||||
name: 'mixed.txt',
|
||||
size: 64,
|
||||
md5: '00112233445566778899aabbccddeeff',
|
||||
},
|
||||
},
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(2);
|
||||
expect(dispatchEvent.mock.calls[0]?.[0]).toMatchObject({
|
||||
post_type: 'message_sent',
|
||||
message_seq: RECEIPT.clientSequence,
|
||||
message: [{ type: 'text', data: { text: 'attachment:' } }],
|
||||
});
|
||||
expect(dispatchEvent.mock.calls[1]?.[0]).toMatchObject({
|
||||
post_type: 'message_sent',
|
||||
message_seq: fileReceipt.clientSequence,
|
||||
message: [
|
||||
{
|
||||
type: 'file',
|
||||
data: expect.objectContaining({ file_id: 'mixed-file-uuid' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.messageId).toBe(
|
||||
hashMessageIdInt32(fileReceipt.sequence, PEER_ID, PRIVATE_NT_MESSAGE_EVENT),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports each file in a multi-file private action separately', async () => {
|
||||
const secondReceipt: Receipt = {
|
||||
messageId: 654321,
|
||||
sequence: 88,
|
||||
clientSequence: 10,
|
||||
random: 654321,
|
||||
timestamp: RECEIPT.timestamp + 1,
|
||||
};
|
||||
const { ctx, dispatchEvent } = makeContext([RECEIPT, secondReceipt]);
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
await api.sendPrivateMessage(
|
||||
PEER_ID,
|
||||
[
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file_id: 'first-file',
|
||||
name: 'first.txt',
|
||||
size: 1,
|
||||
md5: '00112233445566778899aabbccddeeff',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file_id: 'second-file',
|
||||
name: 'second.txt',
|
||||
size: 2,
|
||||
md5: 'ffeeddccbbaa00998877665544332211',
|
||||
},
|
||||
},
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
dispatchEvent.mock.calls.map(
|
||||
([event]) => ((event.message as JsonObject[])[0]?.data as JsonObject)?.file_id,
|
||||
),
|
||||
).toEqual(['first-file', 'second-file']);
|
||||
});
|
||||
|
||||
it('does not report a later QQ echo after the action event', async () => {
|
||||
const { dispatch, emitEvent, stored } = makeInstanceHarness();
|
||||
const sentEvent = privateSentEvent();
|
||||
const qqEcho: JsonObject = {
|
||||
...sentEvent,
|
||||
raw_message: 'canonical echo',
|
||||
sender: { user_id: SELF_ID, nickname: 'Server profile' },
|
||||
};
|
||||
|
||||
await dispatch(sentEvent, 'send');
|
||||
await dispatch(qqEcho, 'bridge');
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledOnce();
|
||||
expect(stored.get(987654)?.raw_message).toBe('canonical echo');
|
||||
});
|
||||
|
||||
it('records one truthful handoff terminal for reporting, duplicate suppression, and status suppression', async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
const entries: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
setLogLevel('trace');
|
||||
|
||||
try {
|
||||
const logger = createLogger('Event').child({ uin: SELF_ID });
|
||||
const reporting = makeInstanceHarness({ logger });
|
||||
await runWithRequestId(6001, () => reporting.dispatch(privateSentEvent(), 'send'));
|
||||
expect(reporting.emitEvent).toHaveBeenCalledOnce();
|
||||
|
||||
const duplicate = makeInstanceHarness({ logger });
|
||||
await runWithRequestId(6002, async () => {
|
||||
await duplicate.dispatch(privateSentEvent(), 'send');
|
||||
await duplicate.dispatch(privateSentEvent({ raw_message: 'canonical' }), 'bridge');
|
||||
});
|
||||
expect(duplicate.emitEvent).toHaveBeenCalledOnce();
|
||||
|
||||
const status = makeInstanceHarness({
|
||||
logger,
|
||||
statusCommand: { enabled: true, swallow: true, cooldownSeconds: 5, trigger: '#sl' },
|
||||
});
|
||||
await runWithRequestId(6003, () =>
|
||||
status.dispatch(
|
||||
privateSentEvent({
|
||||
post_type: 'message',
|
||||
user_id: PEER_ID,
|
||||
target_id: undefined,
|
||||
raw_message: '#sl',
|
||||
message: [{ type: 'text', data: { text: '#sl' } }],
|
||||
}),
|
||||
'bridge',
|
||||
),
|
||||
);
|
||||
expect(status.emitEvent).not.toHaveBeenCalled();
|
||||
|
||||
const terminals = entries.filter(
|
||||
(entry) =>
|
||||
entry.scope === 'Event' &&
|
||||
entry.level === 'trace' &&
|
||||
entry.message.startsWith('event_terminal'),
|
||||
);
|
||||
expect(terminals.filter((entry) => entry.req === 6001)).toHaveLength(1);
|
||||
expect(terminals.find((entry) => entry.req === 6001)?.message).toContain(
|
||||
'outcome=reporting_started',
|
||||
);
|
||||
expect(terminals.filter((entry) => entry.req === 6002)).toHaveLength(2);
|
||||
expect(
|
||||
terminals.find(
|
||||
(entry) =>
|
||||
entry.req === 6002 && entry.message.includes('reason=duplicate_self_echo'),
|
||||
)?.message,
|
||||
).toContain('outcome=suppressed');
|
||||
expect(terminals.filter((entry) => entry.req === 6003)).toHaveLength(1);
|
||||
expect(terminals[terminals.length - 1]?.message).toContain(
|
||||
'outcome=suppressed reason=status_command',
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not dispatch an event for a receipt without a reliable sequence', async () => {
|
||||
const { ctx, dispatchEvent } = makeContext({ ...RECEIPT, sequence: 0 });
|
||||
const api = buildApiContext(ctx);
|
||||
|
||||
await api.sendPrivateMessage(PEER_ID, [{ type: 'text', data: { text: 'hello' } }], false);
|
||||
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an earlier QQ echo and does not report the later action event', async () => {
|
||||
const { ctx, dispatchEvent, events } = makeContext();
|
||||
const messageId = hashMessageIdInt32(RECEIPT.sequence, PEER_ID, PRIVATE_NT_MESSAGE_EVENT);
|
||||
const qqEcho: JsonObject = {
|
||||
time: RECEIPT.timestamp,
|
||||
self_id: SELF_ID,
|
||||
post_type: 'message_sent',
|
||||
message_type: 'private',
|
||||
sub_type: 'friend',
|
||||
message_id: messageId,
|
||||
message_seq: RECEIPT.clientSequence,
|
||||
user_id: SELF_ID,
|
||||
target_id: PEER_ID,
|
||||
message: [{ type: 'text', data: { text: 'hello' } }],
|
||||
raw_message: 'canonical echo',
|
||||
sender: { user_id: SELF_ID, nickname: 'Server profile' },
|
||||
};
|
||||
events.set(messageId, qqEcho);
|
||||
|
||||
const api = buildApiContext(ctx);
|
||||
const result = await api.sendPrivateMessage(
|
||||
PEER_ID,
|
||||
[{ type: 'text', data: { text: 'hello' } }],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messageId).toBe(messageId);
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
expect(events.get(messageId)).toBe(qqEcho);
|
||||
});
|
||||
|
||||
it('does not invent a friend event for a group temporary-session send', async () => {
|
||||
const groupId = 30003;
|
||||
const { ctx, dispatchEvent } = makeContext();
|
||||
ctx.tempSessions.record(PEER_ID, groupId);
|
||||
|
||||
const api = buildApiContext(ctx);
|
||||
await api.sendPrivateMessage(
|
||||
PEER_ID,
|
||||
[{ type: 'text', data: { text: 'hello' } }],
|
||||
false,
|
||||
groupId,
|
||||
);
|
||||
|
||||
expect(dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not suppress a bridge echo that was cached but never reported', async () => {
|
||||
const { dispatch, emitEvent, stored } = makeInstanceHarness();
|
||||
const cached: JsonObject = {
|
||||
time: RECEIPT.timestamp,
|
||||
self_id: SELF_ID,
|
||||
post_type: 'message_sent',
|
||||
message_type: 'group',
|
||||
sub_type: 'normal',
|
||||
message_id: 7654321,
|
||||
message_seq: RECEIPT.sequence,
|
||||
group_id: 30003,
|
||||
user_id: SELF_ID,
|
||||
message: [],
|
||||
raw_message: '',
|
||||
sender: { user_id: SELF_ID },
|
||||
};
|
||||
stored.set(7654321, cached);
|
||||
|
||||
await dispatch({ ...cached, raw_message: 'canonical echo' }, 'bridge');
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledOnce();
|
||||
expect(stored.get(7654321)?.raw_message).toBe('canonical echo');
|
||||
});
|
||||
|
||||
it('does not suppress a hash collision from another conversation', async () => {
|
||||
const { dispatch, emitEvent } = makeInstanceHarness();
|
||||
const sentEvent = privateSentEvent();
|
||||
|
||||
await dispatch(sentEvent, 'send');
|
||||
await dispatch({ ...sentEvent, target_id: PEER_ID + 1 }, 'bridge');
|
||||
|
||||
expect(emitEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user