Files
SnowLuma/packages/protocol/tests/self-invite-dispatch.test.ts
DrSmoothl 299fa85a74 fix(onebot): match group invitations to the correct request
Report distinct invitations separately and approve only the request that belongs to the account and inviter. Reject ambiguous approvals and resolve the sender of invitations from outside the contact list.

Refs #612
2026-10-31 18:28:05 +08:00

189 lines
6.6 KiB
TypeScript

import type { PacketInfo } from '@snowluma/common/protocol-types';
import { BridgeEventBus } from '@snowluma/protocol/event-bus';
import type { GroupInviteEvent, QQEventVariant } from '@snowluma/protocol/events';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { GroupInviteTracker } from '@snowluma/protocol/group-invite-tracker';
import { IncomingPacketPipeline } from '@snowluma/protocol/packet-pipeline';
import { afterEach, describe, expect, it, vi } from 'vitest';
const SELF_UIN = 10001;
const GROUP_ID = 12345;
const INVITOR_UID = 'u_inviter';
const INVITOR_UIN = 20002;
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
function packet(): PacketInfo {
return {
pid: 42,
uin: String(SELF_UIN),
serviceCmd: 'test.self-invite',
seqId: 1,
retCode: 0,
fromClient: false,
body: Buffer.from([0x00]),
};
}
/** The MSF notice: no approval msgseq, only the legacy flag from the decoder. */
function selfInviteNotice(): GroupInviteEvent {
return {
kind: 'group_invite',
time: 1,
selfUin: SELF_UIN,
groupId: GROUP_ID,
fromUin: INVITOR_UIN,
fromUid: INVITOR_UID,
invitedUin: SELF_UIN,
invitedUid: undefined,
subType: 'invite',
message: '',
flag: `invite:${GROUP_ID}:${INVITOR_UID}`,
};
}
/** The private "qun.invite" card, which carries the real approval msgseq. */
function inviteCard(sequence = 777): QQEventVariant {
return {
kind: 'friend_message',
time: 1,
selfUin: SELF_UIN,
senderUin: INVITOR_UIN,
senderUid: INVITOR_UID,
senderNick: 'inviter',
msgSeq: 1,
msgId: 2,
elements: [],
inviteCardGroupUin: GROUP_ID,
inviteCardSequence: sequence,
} as QQEventVariant;
}
function makePipeline(options: { request?: 'found' | 'missing' } = {}) {
const identity = IdentityService.memory(String(SELF_UIN));
const events = new BridgeEventBus();
const received: QQEventVariant[] = [];
events.onAny((event) => {
// The card arrives as a friend_message and the pipeline derives a
// group_invite from it; only the latter is under test here.
if ((event as QQEventVariant).kind === 'group_invite') {
received.push(event as QQEventVariant);
}
});
const resolveGroupJoinRequest = vi.fn(async () =>
options.request === 'found'
? {
groupId: GROUP_ID,
groupName: '',
targetUid: 'u_target',
targetUin: 0,
targetName: '',
invitorUid: INVITOR_UID,
invitorUin: INVITOR_UIN,
invitorName: '',
operatorUid: '',
operatorUin: 0,
operatorName: '',
sequence: 42,
state: 1,
notifyType: 1,
eventType: 1,
comment: '',
filtered: false,
}
: null,
);
const pipeline = new IncomingPacketPipeline({
identity,
events,
fetchGroupList: vi.fn(async () => {}),
fetchGroupMemberList: vi.fn(async () => {}),
resolveGroupJoinRequest,
groupInvites: new GroupInviteTracker(),
});
return { pipeline, received, resolveGroupJoinRequest };
}
describe('self-invite delivery', () => {
it('dispatches a self-invite that the request inbox cannot resolve', async () => {
const { pipeline, received } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
await pipeline.process(packet());
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
kind: 'group_invite',
groupId: GROUP_ID,
invitedUin: SELF_UIN,
subType: 'invite',
});
});
it('keeps the decoder-provided legacy flag so the request stays actionable', async () => {
const { pipeline, received } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
await pipeline.process(packet());
expect((received[0] as GroupInviteEvent).flag).toBe(
`invite:${GROUP_ID}:${INVITOR_UID}`,
);
});
it('keeps the legacy flag even when the inbox has a matching request', async () => {
// Self-invite notices are absent from the UIN-based inbox, so the pipeline
// does not consult it at all -- the legacy flag must therefore survive.
const { pipeline, received, resolveGroupJoinRequest } = makePipeline({ request: 'found' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
await pipeline.process(packet());
expect(resolveGroupJoinRequest).not.toHaveBeenCalled();
expect((received[0] as GroupInviteEvent).flag).toBe(
`invite:${GROUP_ID}:${INVITOR_UID}`,
);
});
it('does not query the inbox and still dispatches a self-invite', async () => {
const { pipeline, received, resolveGroupJoinRequest } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
await pipeline.process(packet());
expect(received).toHaveLength(1);
// A self-invite never shows up in the UIN-based inbox, so the lookup is skipped.
expect(resolveGroupJoinRequest).not.toHaveBeenCalled();
});
it('lets the card win when it arrives first', async () => {
const { pipeline, received } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
pipeline.registerCmd('test.card', () => [inviteCard()]);
await pipeline.process({ ...packet(), serviceCmd: 'test.card' });
await pipeline.process(packet());
expect(received).toHaveLength(1);
expect((received[0] as GroupInviteEvent).flag).toBe(`slreq:1:777:${GROUP_ID}:2:0`);
});
it('suppresses the notice when the card already delivered the invitation', async () => {
const { pipeline, received } = makePipeline({ request: 'missing' });
pipeline.registerCmd('test.self-invite', () => [selfInviteNotice()]);
pipeline.registerCmd('test.card', () => [inviteCard()]);
await pipeline.process(packet());
await pipeline.process({ ...packet(), serviceCmd: 'test.card' });
expect(received).toHaveLength(1);
expect((received[0] as GroupInviteEvent).flag).toBe(`invite:${GROUP_ID}:${INVITOR_UID}`);
});
});