Files
SnowLuma/packages/onebot/tests/group-request-events.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

451 lines
18 KiB
TypeScript

import { deflateSync } from 'node:zlib';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { PacketInfo } from '@snowluma/common/protocol-types';
import type { PushMsg } from '@snowluma/proto-defs/message';
import type { GroupInvite } from '@snowluma/proto-defs/notify';
import { protobuf_encode } from '@snowluma/proton';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { MSG_PUSH_CMD } from '@snowluma/protocol/msg-push';
import type { GroupInviteEvent } from '@snowluma/protocol/events';
import type { GroupRequestInfo } from '@snowluma/protocol/qq-info';
import { handleGroupAddRequest } from '../src/modules/request-actions';
import { Bridge } from '@snowluma/core/bridge';
const bridges: Bridge[] = [];
afterEach(async () => {
await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose()));
vi.useRealTimers();
});
function setup() {
const bridge = new Bridge(IdentityService.memory('10001'));
bridges.push(bridge);
const received: GroupInviteEvent[] = [];
bridge.events.on('group_invite', (event) => {
received.push(event);
});
const fetchRequests = vi.spyOn(bridge.apis.contacts, 'fetchGroupRequestsByUid');
const fetchNumericRequests = vi.spyOn(bridge.apis.contacts, 'fetchGroupRequests');
return { bridge, received, fetchRequests, fetchNumericRequests };
}
function packet(message: PushMsg['message']): PacketInfo {
return {
pid: 1,
uin: '10001',
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body: Buffer.from(protobuf_encode<PushMsg>({ message, status: 0 })),
};
}
function card(sequence = 778899, senderUin = 456, app = 'com.tencent.qun.invite'): PacketInfo {
return jsonCard(
{
app,
bizsrc: 'qun.invite',
meta: {
news: { jumpUrl: `mqqapi://group/invite_join?groupcode=999&msgseq=${sequence}` },
},
},
senderUin,
);
}
function jsonCard(content: unknown, senderUin = 456): PacketInfo {
const json = JSON.stringify(content);
return packet({
responseHead: { fromUin: senderUin, fromUid: 'u_inviter', toUin: 10001 },
contentHead: { msgType: 166, timestamp: 1, sequence: 12, ntMsgSeq: 12, msgId: 12 },
body: {
richText: {
elems: [
{ lightApp: { data: Buffer.concat([Buffer.from([1]), deflateSync(json)]) } },
],
},
},
});
}
function selfInviteNotice(invitorUid = 'u_inviter', groupUin = 999): PacketInfo {
return packet({
contentHead: { msgType: 87, timestamp: 1 },
body: {
msgContent: protobuf_encode<GroupInvite>({ groupUin, invitorUid }),
},
});
}
function request(overrides: Partial<GroupRequestInfo> = {}): GroupRequestInfo {
return {
groupId: 999,
groupName: 'group',
targetUid: 'u_applicant',
targetUin: 123,
targetName: '',
invitorUid: '',
invitorUin: 0,
invitorName: '',
operatorUid: '',
operatorUin: 0,
operatorName: '',
sequence: 42,
state: 1,
notifyType: 7,
eventType: 1,
comment: 'please',
filtered: false,
...overrides,
};
}
describe('group request events', () => {
it.each(['com.tencent.qun.invite', 'com.tencent.tuwen.lua'])(
'handles %s invite cards without reading either inbox',
async (app) => {
const { bridge, received, fetchRequests, fetchNumericRequests } = setup();
const setAddRequest = vi
.spyOn(bridge.apis.groupAdmin, 'setAddRequest')
.mockResolvedValue();
await bridge.onPacket(card(778899, 456, app));
expect(received).toEqual([
expect.objectContaining({
groupId: 999,
fromUin: 456,
fromUid: 'u_inviter',
invitedUin: 10001,
subType: 'invite',
flag: 'slreq:1:778899:999:2:0',
}),
]);
await handleGroupAddRequest(bridge, received[0]!.flag, true, '');
expect(setAddRequest).toHaveBeenCalledWith(999, 778899, 2, true, '', false, undefined);
expect(fetchRequests).not.toHaveBeenCalled();
expect(fetchNumericRequests).not.toHaveBeenCalled();
},
);
it.each(['notice first', 'card first'])(
'emits one request when a self-invite notice and its card both arrive: %s',
async (order) => {
const { bridge, received, fetchRequests } = setup();
const packets =
order === 'notice first'
? [selfInviteNotice(), card()]
: [card(), selfInviteNotice()];
for (const incoming of packets) {
await bridge.onPacket(incoming);
}
await bridge.onPacket(card());
expect(received).toHaveLength(1);
expect(fetchRequests).not.toHaveBeenCalled();
},
);
it('keeps distinct card sequences and accounts independent', async () => {
const first = setup();
const second = setup();
await first.bridge.onPacket(card());
await first.bridge.onPacket(card(778900));
await second.bridge.onPacket(card());
expect(first.received).toHaveLength(2);
expect(second.received).toHaveLength(1);
});
it('pairs a notice exactly once and keeps its own card sequence for approval', async () => {
const { bridge, received, fetchRequests } = setup();
bridge.identity.rememberRequestIdentity({ groupId: 999, uid: 'u_inviter', uin: 456 });
const setAddRequest = vi.spyOn(bridge.apis.groupAdmin, 'setAddRequest').mockResolvedValue();
await bridge.onPacket(selfInviteNotice());
await bridge.onPacket(card(700));
await bridge.onPacket(card(701));
await bridge.onPacket(card(700));
expect(received.map((event) => event.flag)).toEqual([
'invite:999:u_inviter',
'slreq:1:701:999:2:0',
]);
await handleGroupAddRequest(bridge, received[0]!.flag, true, '');
expect(setAddRequest).toHaveBeenCalledWith(999, 700, 2, true, '', false, undefined);
expect(fetchRequests).not.toHaveBeenCalled();
});
it.each(['u_other', 'u_inviter'])(
'does not approve a stale card for a new notice from %s',
async (uid) => {
vi.useFakeTimers({ toFake: ['Date'] });
const { bridge, received, fetchRequests } = setup();
bridge.identity.rememberRequestIdentity({ groupId: 999, uid, uin: 789 });
const setAddRequest = vi
.spyOn(bridge.apis.groupAdmin, 'setAddRequest')
.mockResolvedValue();
fetchRequests.mockImplementation(async (filtered) =>
filtered
? []
: [request({ invitorUid: uid, sequence: 43, eventType: 2, targetUin: 10001 })],
);
await bridge.onPacket(selfInviteNotice());
await bridge.onPacket(card(700));
vi.setSystemTime(Date.now() + 4_000);
await bridge.onPacket(selfInviteNotice(uid));
const latest = received.at(-1)!;
expect(latest.flag).toBe(`invite:999:${uid}`);
await handleGroupAddRequest(bridge, latest.flag, true, '');
expect(setAddRequest).toHaveBeenCalledExactlyOnceWith(
999,
43,
2,
true,
'',
false,
undefined,
);
},
);
it('does not emit a self-invite flag without a group and inviter', async () => {
const { bridge, received } = setup();
await bridge.onPacket(selfInviteNotice(''));
await bridge.onPacket(selfInviteNotice(' '));
await bridge.onPacket(selfInviteNotice('u_inviter', 0));
expect(received).toEqual([]);
});
it.each([22, 2, 10])(
'cannot use a self-invitation flag to approve another account joining the group (type %i)',
async (eventType) => {
const { bridge, received, fetchRequests } = setup();
bridge.identity.rememberRequestIdentity({ groupId: 999, uid: 'u_inviter', uin: 456 });
const setAddRequest = vi
.spyOn(bridge.apis.groupAdmin, 'setAddRequest')
.mockResolvedValue();
fetchRequests.mockImplementation(async (filtered) =>
filtered ? [] : [request({ invitorUid: 'u_inviter', eventType, targetUin: 456 })],
);
await bridge.onPacket(selfInviteNotice());
await expect(
handleGroupAddRequest(bridge, received[0]!.flag, true, ''),
).rejects.toThrow('matching group request not found');
expect(setAddRequest).not.toHaveBeenCalled();
},
);
it('emits a later card when the pairing window has expired', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { bridge, received } = setup();
bridge.identity.rememberRequestIdentity({ groupId: 999, uid: 'u_inviter', uin: 456 });
await bridge.onPacket(selfInviteNotice());
vi.setSystemTime(Date.now() + 4_000);
await bridge.onPacket(card(701));
expect(received.map((event) => event.flag)).toEqual([
'invite:999:u_inviter',
'slreq:1:701:999:2:0',
]);
});
it('refuses an ambiguous legacy flag when two self invitations overlap', async () => {
const { bridge, received } = setup();
bridge.identity.rememberRequestIdentity({ groupId: 999, uid: 'u_inviter', uin: 456 });
const setAddRequest = vi.spyOn(bridge.apis.groupAdmin, 'setAddRequest').mockResolvedValue();
await bridge.onPacket(selfInviteNotice());
await bridge.onPacket(selfInviteNotice());
await bridge.onPacket(card(701));
await bridge.onPacket(card(702));
expect(received.map((event) => event.flag)).toEqual([
'invite:999:u_inviter',
'invite:999:u_inviter',
]);
await expect(handleGroupAddRequest(bridge, received[0]!.flag, true, '')).rejects.toThrow(
'multiple pending self invitations',
);
expect(setAddRequest).not.toHaveBeenCalled();
});
it('does not report sent cards or links in unrelated JSON cards as invitations', async () => {
const { bridge, received } = setup();
await bridge.onPacket(card(778899, 10001));
await bridge.onPacket(card(778899, 456, 'unrelated.app'));
expect(received).toEqual([]);
expect(bridge.apis.contacts.getGroupInviteCardSequence(999)).toBeUndefined();
});
it.each([
{ app: 'com.tencent.tuwen.lua', meta: { detail: { title: 'ordinary card' } } },
{ app: 'com.tencent.tuwen.lua', meta: { news: { jumpUrl: '' } } },
{ app: 'com.tencent.tuwen.lua', meta: { news: { jumpUrl: 'https://example.com' } } },
])('preserves ordinary cards as friend messages: %j', async (content) => {
const { bridge, received, fetchRequests } = setup();
const friendMessage = vi.fn();
bridge.events.on('friend_message', friendMessage);
await bridge.onPacket(jsonCard(content));
expect(friendMessage).toHaveBeenCalledOnce();
expect(received).toEqual([]);
expect(fetchRequests).not.toHaveBeenCalled();
});
it('queries requests only after an incoming request notification', async () => {
vi.useFakeTimers();
const { bridge, received, fetchRequests } = setup();
fetchRequests.mockImplementation(async (filtered) => (filtered ? [] : [request()]));
await vi.advanceTimersByTimeAsync(60_000);
expect(fetchRequests).not.toHaveBeenCalled();
bridge.registerCmd('test.request', () => [
{
kind: 'group_invite',
time: 1,
selfUin: 10001,
groupId: 999,
fromUin: 123,
fromUid: 'u_applicant',
subType: 'add',
message: '',
flag: 'add:999:u_applicant',
},
]);
await bridge.onPacket({ ...selfInviteNotice(), serviceCmd: 'test.request' });
expect(fetchRequests).toHaveBeenCalledTimes(2);
expect(received).toEqual([
expect.objectContaining({ message: 'please', flag: 'slreq:1:42:999:1:0' }),
]);
await vi.advanceTimersByTimeAsync(60_000);
expect(fetchRequests).toHaveBeenCalledTimes(2);
});
it('does not emit a fallback request or retry after an inbox failure', async () => {
vi.useFakeTimers();
const { bridge, received, fetchRequests } = setup();
fetchRequests.mockRejectedValue(new Error('request lookup failed'));
bridge.registerCmd('test.request', () => [
{
kind: 'group_invite',
time: 1,
selfUin: 10001,
groupId: 999,
fromUin: 123,
fromUid: 'u_applicant',
subType: 'add',
message: '',
flag: 'add:999:u_applicant',
},
]);
await bridge.onPacket({ ...selfInviteNotice(), serviceCmd: 'test.request' });
await vi.advanceTimersByTimeAsync(60_000);
expect(received).toEqual([]);
expect(fetchRequests).toHaveBeenCalledTimes(2);
});
it('matches only a pending request of the notified type', async () => {
const { bridge, received, fetchRequests } = setup();
fetchRequests.mockImplementation(async (filtered) =>
filtered
? [request({ sequence: 43, filtered: true })]
: [request({ state: 2 }), request({ eventType: 22 }), request({ groupId: 998 })],
);
bridge.registerCmd('test.request', () => [
{
kind: 'group_invite',
time: 1,
selfUin: 10001,
groupId: 999,
fromUin: 123,
fromUid: 'u_applicant',
subType: 'add',
message: '',
flag: 'add:999:u_applicant',
},
]);
await bridge.onPacket({ ...selfInviteNotice(), serviceCmd: 'test.request' });
expect(received).toEqual([expect.objectContaining({ flag: 'slreq:1:43:999:1:1' })]);
});
it.each(['u_invitee', undefined])(
'requires the notified invitee to match pending invitations: %s',
async (invitedUid) => {
const { bridge, received, fetchRequests } = setup();
fetchRequests.mockImplementation(async (filtered) =>
filtered
? []
: [
request({
sequence: 41,
invitorUid: 'u_inviter',
invitorUin: 456,
targetUid: 'u_other',
eventType: 22,
}),
request({
sequence: 42,
invitorUid: 'u_inviter',
invitorUin: 456,
targetUid: 'u_invitee',
targetUin: 789,
eventType: 22,
}),
],
);
bridge.registerCmd('test.request', () => [
{
kind: 'group_invite',
time: 1,
selfUin: 10001,
groupId: 999,
fromUin: 456,
fromUid: 'u_inviter',
invitedUid,
subType: 'invite',
message: '',
flag: 'invite:999:u_inviter',
},
]);
await bridge.onPacket({ ...selfInviteNotice(), serviceCmd: 'test.request' });
expect(received).toEqual(
invitedUid
? [expect.objectContaining({ invitedUin: 789, flag: 'slreq:1:42:999:22:0' })]
: [],
);
},
);
it('drains both inbox requests before releasing the account after a failure', async () => {
const { bridge, received, fetchRequests } = setup();
let finishFiltered!: (value: GroupRequestInfo[]) => void;
const filtered = new Promise<GroupRequestInfo[]>((resolve) => {
finishFiltered = resolve;
});
fetchRequests.mockImplementation((isFiltered) =>
isFiltered ? filtered : Promise.reject(new Error('main inbox failed')),
);
bridge.registerCmd('test.request', () => [
{
kind: 'group_invite',
time: 1,
selfUin: 10001,
groupId: 999,
fromUin: 123,
fromUid: 'u_applicant',
subType: 'add',
message: '',
flag: 'add:999:u_applicant',
},
]);
const processing = bridge.onPacket({ ...selfInviteNotice(), serviceCmd: 'test.request' });
await vi.waitFor(() => expect(fetchRequests).toHaveBeenCalledTimes(2));
const disposed = vi.fn();
const disposal = bridge.dispose().then(disposed);
await Promise.resolve();
expect(disposed).not.toHaveBeenCalled();
finishFiltered([]);
await Promise.all([processing, disposal]);
expect(disposed).toHaveBeenCalledOnce();
expect(received).toEqual([]);
});
});