Files
SnowLuma/packages/onebot/tests/send-private-strip-at.test.ts
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

183 lines
6.3 KiB
TypeScript

// Regression coverage for the private-chat-at bug.
//
// Symptom in the wild: bot called `send_private_msg` with a message
// like `[reply, at, text]` — the group-chat convention of quoting +
// @-mentioning the reply target. QQ ignores mention elements in c2c
// messages so the `at` segment is pointless; worse, it produces a
// broken-looking wire message (the recipient sees a phantom "@QQ号"
// that can't be clicked).
//
// Fix: every private at is rejected before anything reaches the bridge.
import { describe, expect, it, vi } from 'vitest';
import type { BridgeInterface } from '../../src/bridge/bridge-interface';
import type { OneBotInstanceContext } from '../src/instance-context';
import { sendPrivateMessage } from '../src/modules/message-actions';
function fakeBridge(overrides: Partial<BridgeInterface> = {}): BridgeInterface {
return new Proxy({ identity: { nickname: 'SnowLuma' }, ...overrides } as BridgeInterface, {
get(target, prop) {
if (prop in target) {
return (target as any)[prop];
}
throw new Error(`fakeBridge: '${String(prop)}' was not stubbed for this test`);
},
});
}
const goodReceipt = {
messageId: 1,
sequence: 100,
clientSequence: 0,
random: 1,
timestamp: 1700000000,
};
function makeCtx(bridge: BridgeInterface): OneBotInstanceContext {
return {
uin: '10001',
selfId: 10001,
bridge,
messageStore: {
findEvent: () => null,
findMeta: () => null,
resolveReplySequence: () => 42,
storeEvent: vi.fn(),
} as any,
cacheMessageMeta: vi.fn(),
mediaStore: {} as any,
converterCtx: {
selfId: 10001,
imageUrlResolver: null,
mediaUrlResolver: null,
messageIdResolver: null,
mediaSegmentSink: null,
},
musicSignUrl: '',
} as unknown as OneBotInstanceContext;
}
describe('send_private_msg rejects at segments', () => {
it('reply + at + text rejects the whole message', async () => {
const sendPrivate = vi.fn(async (_uin: number, _elements: any[]) => goodReceipt);
const bridge = fakeBridge({
apis: { message: { sendPrivate } } as any,
resolveUserUid: vi.fn(async () => 'u_peer'),
} as any);
const ctx = makeCtx(bridge);
await expect(
sendPrivateMessage(
ctx,
67890,
[
{ type: 'reply', data: { id: '-2050237785' } },
{ type: 'at', data: { qq: '67890' } },
{ type: 'text', data: { text: ' ' } },
{ type: 'text', data: { text: 'quoted reply text' } },
] as any,
false,
),
).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'at' });
expect(sendPrivate).not.toHaveBeenCalled();
});
it('at-only message (no reply) is rejected as unsendable', async () => {
const sendPrivate = vi.fn();
const bridge = fakeBridge({
apis: { message: { sendPrivate } } as any,
resolveUserUid: vi.fn(async () => 'u_peer'),
} as any);
const ctx = makeCtx(bridge);
await expect(
sendPrivateMessage(ctx, 67890, [{ type: 'at', data: { qq: '67890' } }] as any, false),
).rejects.toMatchObject({
code: 'UNSENDABLE_TYPE',
elementType: 'at',
});
expect(sendPrivate).not.toHaveBeenCalled();
});
it('text + at (without reply) rejects the whole message', async () => {
const sendPrivate = vi.fn(async (_uin: number, _elements: any[]) => goodReceipt);
const bridge = fakeBridge({
apis: { message: { sendPrivate } } as any,
resolveUserUid: vi.fn(async () => 'u_peer'),
} as any);
const ctx = makeCtx(bridge);
await expect(
sendPrivateMessage(
ctx,
67890,
[
{ type: 'text', data: { text: 'hello' } },
{ type: 'at', data: { qq: '67890' } },
] as any,
false,
),
).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'at' });
expect(sendPrivate).not.toHaveBeenCalled();
});
it('multiple at segments reject the whole message', async () => {
const sendPrivate = vi.fn(async (_uin: number, _elements: any[]) => goodReceipt);
const bridge = fakeBridge({
apis: { message: { sendPrivate } } as any,
resolveUserUid: vi.fn(async () => 'u_peer'),
} as any);
const ctx = makeCtx(bridge);
await expect(
sendPrivateMessage(
ctx,
67890,
[
{ type: 'reply', data: { id: '123' } },
{ type: 'at', data: { qq: '67890' } },
{ type: 'at', data: { qq: '12345' } },
{ type: 'text', data: { text: 'hi' } },
] as any,
false,
),
).rejects.toMatchObject({ code: 'UNSENDABLE_TYPE', elementType: 'at' });
expect(sendPrivate).not.toHaveBeenCalled();
});
it('group messages are NOT affected (at segments preserved)', async () => {
// Sanity: import sendGroupMessage and verify at segments pass through
const { sendGroupMessage } = await import('../src/modules/message-actions');
const sendGroup = vi.fn(async (_groupId: number, _elements: any[]) => {
return goodReceipt;
});
const bridge = fakeBridge({
apis: { message: { sendGroup } } as any,
resolveUserUid: vi.fn(async () => 'u_peer'),
} as any);
const ctx = makeCtx(bridge);
await sendGroupMessage(
ctx,
99999,
[
{ type: 'reply', data: { id: '123' } },
{ type: 'at', data: { qq: '67890' } },
{ type: 'text', data: { text: 'hi' } },
] as any,
false,
);
expect(sendGroup).toHaveBeenCalledOnce();
const sentElements = sendGroup.mock.calls[0]![1] as any[];
const sentTypes = sentElements.map((e: any) => e.type);
expect(sentTypes).toContain('at');
});
});