fix(onebot): handle non-friend group invitations

Refs #636
This commit is contained in:
DrSmoothl
2026-11-02 07:55:40 +08:00
parent 0fa912a32e
commit 2b63f375fc
8 changed files with 184 additions and 8 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import { GroupInviteTracker } from '@snowluma/protocol/group-invite-tracker';
import { isSelfGroupInvitation } from '@snowluma/protocol/qq-info';
import { FetchDownloadRkeys } from '@snowluma/protocol/oidb-services/contacts/fetch-download-rkeys';
import {
FetchFriendListPage,
@@ -607,7 +608,7 @@ export class ContactsApi {
return subType === 'add'
? request.eventType === 1 && request.targetUid === uid
: (request.eventType === 2 || request.eventType === 22) &&
: (isSelfGroupInvitation(request) || request.eventType === 22) &&
request.invitorUid === uid &&
request.targetUid === invitedUid;
}) ?? null
@@ -7,6 +7,7 @@ import {
} from '@snowluma/protocol/dataline/device-contacts';
import {
formatGroupRequestFlag,
isSelfGroupInvitation,
type GroupMemberInfo,
type GroupRequestInfo,
type UserProfileInfo,
@@ -573,11 +574,12 @@ async function resolveRequesterUins(
return resolved;
}
/** Join request (7) or invite that still needs an admin (5 / mapped 22). */
/** Join requests, self invitations, and invitations awaiting administrator approval. */
function isJoinOrInviteNotify(notifyType: number | undefined): boolean {
return (
notifyType === undefined ||
notifyType === 1 ||
notifyType === 2 ||
notifyType === 5 ||
notifyType === 7 ||
notifyType === 22
@@ -589,7 +591,7 @@ function groupRequestActor(request: GroupRequestInfo): {
uin: number;
name: string;
} {
if (request.notifyType === 1) {
if (isSelfGroupInvitation(request)) {
return {
uid: request.invitorUid,
uin: request.invitorUin,
+19 -2
View File
@@ -1,6 +1,10 @@
import { createLogger } from '@snowluma/common/logger';
import type { BridgeInterface } from '@snowluma/core/bridge-interface';
import type { GroupRequestHandle, GroupRequestInfo } from '@snowluma/protocol/qq-info';
import {
isSelfGroupInvitation,
type GroupRequestHandle,
type GroupRequestInfo,
} from '@snowluma/protocol/qq-info';
const log = createLogger('OneBot.Request');
@@ -265,7 +269,7 @@ export async function handleGroupAddRequest(
if (
notice &&
(request.eventType !== 2 ||
(!isSelfGroupInvitation(request) ||
(request.targetUin > 0 && request.targetUin !== Number(bridge.identity.uin)) ||
(request.targetUid &&
bridge.identity.selfUid &&
@@ -282,6 +286,19 @@ export async function handleGroupAddRequest(
const matching = candidates[0];
if (!matching) {
log.warn(
'group request match failed: group=%d kind=%s selfInvitation=%s inboxTypes=%s',
parsed.groupId,
parsed.requestType,
Boolean(notice),
lookup.requests
.filter((request) => request.groupId === parsed.groupId)
.map(
(request) =>
`${request.notifyType ?? '?'}:${request.eventType}:${request.state}`,
)
.join(','),
);
throwMissingGroupRequest('matching group request not found', lookup.failures);
}
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Bridge } from '@snowluma/core/bridge';
import { IdentityService } from '@snowluma/protocol/identity-service';
import { MSG_PUSH_CMD } from '@snowluma/protocol/msg-push';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { PushMsg } from '@snowluma/proto-defs/message';
import type { GroupInvite } from '@snowluma/proto-defs/notify';
import type { OidbBase, OidbSvcTrpcTcp0x10C0Response } from '@snowluma/proto-defs/oidb';
import type { OidbGroupRequestAction } from '@snowluma/proto-defs/oidb-actions/base';
import { getGroupSystemMessages } from '../src/modules/contact-actions';
import { handleGroupAddRequest } from '../src/modules/request-actions';
const bridges: Bridge[] = [];
afterEach(async () => {
await Promise.all(bridges.splice(0).map((bridge) => bridge.dispose()));
});
function setup(filtered = false, overrides: { targetUid?: string; state?: number } = {}) {
const bridge = new Bridge(IdentityService.memory('10001'));
bridges.push(bridge);
bridge.identity.setSelfProfile({
uin: 10001,
uid: 'u_self',
nickname: 'bot',
remark: '',
qid: '',
sex: 'unknown',
age: 0,
sign: '',
avatar: '',
level: 0,
qidianMasterFlag: 0,
qidianCrewFlag: 0,
qidianCrewFlag2: 0,
});
bridge.identity.rememberRequestIdentity({ groupId: 999, uid: 'u_self', uin: 10001 });
bridge.identity.rememberRequestIdentity({ groupId: 999, uid: 'u_inviter', uin: 456 });
const operateTransInfo = Uint8Array.from([8, 42, 16, 231, 7, 24, 2]);
const record = {
sequence: 42n,
eventType: 2,
state: overrides.state ?? 1,
group: { groupUin: 999, groupName: 'group' },
target: { uid: overrides.targetUid ?? 'u_self', name: 'bot' },
invitor: { uid: 'u_inviter', name: 'inviter' },
operateTransInfo,
};
const send = vi.spyOn(bridge, 'sendRawPacket').mockImplementation(async (cmd) => {
let responseData = Buffer.alloc(0);
if (cmd.startsWith('OidbSvcTrpcTcp.0x10c0_')) {
responseData = Buffer.from(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x10C0Response>>({
body: { requests: cmd.endsWith(filtered ? '_2' : '_1') ? [record] : [] },
}),
);
} else if (!cmd.startsWith('OidbSvcTrpcTcp.0x10c8_')) {
throw new Error(`unexpected request: ${cmd}`);
}
return { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData };
});
const flags: string[] = [];
bridge.events.on('group_invite', (event) => flags.push(event.flag));
const notice = () =>
bridge.onPacket({
pid: 1,
uin: '10001',
serviceCmd: MSG_PUSH_CMD,
seqId: 1,
retCode: 0,
fromClient: false,
body: Buffer.from(
protobuf_encode<PushMsg>({
message: {
contentHead: { msgType: 87, timestamp: 1 },
body: {
msgContent: protobuf_encode<GroupInvite>({
groupUin: 999,
invitorUid: 'u_inviter',
}),
},
},
status: 0,
}),
),
});
return { bridge, send, flags, notice, operateTransInfo };
}
describe('non-friend group invitations', () => {
it.each([
{ approve: true, filtered: false },
{ approve: false, filtered: true },
])('handles an emitted flag through the real codecs: %j', async ({ approve, filtered }) => {
const { bridge, send, flags, notice, operateTransInfo } = setup(filtered);
await notice();
expect(flags).toEqual(['invite:999:u_inviter']);
await handleGroupAddRequest(bridge, flags[0]!, approve, '');
const approval = send.mock.calls.find(([cmd]) => cmd.startsWith('OidbSvcTrpcTcp.0x10c8_'))!;
expect(approval[0]).toBe(`OidbSvcTrpcTcp.0x10c8_${filtered ? 2 : 1}`);
const wire = protobuf_decode<OidbBase<OidbGroupRequestAction>>(approval[1]);
expect(wire.body).toMatchObject({
accept: approve ? 1 : 2,
body: { sequence: 42n, groupUin: 999, eventType: 10, message: ' ', operateTransInfo },
});
expect(
await bridge.apis.contacts.resolveGroupJoinRequest(
999,
'u_inviter',
'invite',
'u_self',
),
).toMatchObject({ notifyType: 2, eventType: 10 });
});
it('lists the inviter and returns a flag that can approve the same invitation', async () => {
const { bridge, send, operateTransInfo } = setup();
const rows = await getGroupSystemMessages(bridge);
expect(rows).toEqual([
expect.objectContaining({
group_id: 999,
requester_uin: 456,
requester_nick: 'inviter',
checked: false,
flag: 'slreq:1:42:999:10:0',
}),
]);
await handleGroupAddRequest(bridge, String(rows[0]!.flag), true, '');
const approval = send.mock.calls.find(([cmd]) => cmd.startsWith('OidbSvcTrpcTcp.0x10c8_'))!;
const wire = protobuf_decode<OidbBase<OidbGroupRequestAction>>(approval[1]);
expect(wire.body?.body).toMatchObject({ eventType: 10, operateTransInfo });
});
it.each([{ targetUid: 'u_other' }, { state: 2 }])(
'rejects a mismatched or processed invitation: %j',
async (overrides) => {
const { bridge, send, flags, notice } = setup(false, overrides);
await notice();
await expect(handleGroupAddRequest(bridge, flags[0]!, true, '')).rejects.toThrow(
'matching group request not found',
);
expect(send.mock.calls.some(([cmd]) => cmd.startsWith('OidbSvcTrpcTcp.0x10c8_'))).toBe(
false,
);
},
);
});
@@ -219,7 +219,7 @@ describe('group request events', () => {
expect(received).toEqual([]);
});
it.each([22, 2])(
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();
+2 -2
View File
@@ -5,7 +5,7 @@ import type { BridgeEventBus } from './event-bus';
import type { QQEventVariant } from './events';
import type { GroupInviteTracker } from './group-invite-tracker';
import type { IdentityService } from './identity-service';
import { formatGroupRequestFlag, type GroupRequestInfo } from './qq-info';
import { formatGroupRequestFlag, isSelfGroupInvitation, type GroupRequestInfo } from './qq-info';
const moduleLog = createLogger('Bridge');
const moduleEventLog = createLogger('Event');
@@ -564,7 +564,7 @@ export class IncomingPacketPipeline {
return 0;
}
if (request.eventType === 2) {
if (isSelfGroupInvitation(request)) {
return 0;
}
+6
View File
@@ -121,6 +121,12 @@ export interface GroupRequestInfo {
operateTransInfo?: Uint8Array;
}
/** Invitations handled by the invited account, rather than a group administrator.
* Notification types 1 and 2 use different approval discriminators. */
export function isSelfGroupInvitation(request: Pick<GroupRequestInfo, 'eventType'>): boolean {
return request.eventType === 2 || request.eventType === 10;
}
/** Approval tuple required by OIDB 0x10C8. The OneBot flag is opaque to
* clients, but carrying the complete tuple makes delayed handling stateless. */
export type GroupRequestHandle = Pick<
@@ -51,6 +51,7 @@ function makeSender() {
describe('FetchGroupRequests namespace', () => {
it('maps native list notification types to 0x10C8 operation types', () => {
expect(groupRequestOperationType(1)).toBe(2);
expect(groupRequestOperationType(2)).toBe(10);
expect(groupRequestOperationType(7)).toBe(1);
expect(groupRequestOperationType(17)).toBe(100);
expect(groupRequestOperationType(5)).toBe(22);