diff --git a/packages/core/src/bridge/apis/contacts.ts b/packages/core/src/bridge/apis/contacts.ts index 9ab3e151..c31cf146 100644 --- a/packages/core/src/bridge/apis/contacts.ts +++ b/packages/core/src/bridge/apis/contacts.ts @@ -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 diff --git a/packages/onebot/src/modules/contact-actions.ts b/packages/onebot/src/modules/contact-actions.ts index a7be276d..82b025ce 100644 --- a/packages/onebot/src/modules/contact-actions.ts +++ b/packages/onebot/src/modules/contact-actions.ts @@ -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, diff --git a/packages/onebot/src/modules/request-actions.ts b/packages/onebot/src/modules/request-actions.ts index 9a7212e7..efc4a8d2 100644 --- a/packages/onebot/src/modules/request-actions.ts +++ b/packages/onebot/src/modules/request-actions.ts @@ -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); } diff --git a/packages/onebot/tests/group-invitation-requests.test.ts b/packages/onebot/tests/group-invitation-requests.test.ts new file mode 100644 index 00000000..b1a3d4fd --- /dev/null +++ b/packages/onebot/tests/group-invitation-requests.test.ts @@ -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>({ + 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({ + message: { + contentHead: { msgType: 87, timestamp: 1 }, + body: { + msgContent: protobuf_encode({ + 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>(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>(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, + ); + }, + ); +}); diff --git a/packages/onebot/tests/group-request-events.test.ts b/packages/onebot/tests/group-request-events.test.ts index ba0dc460..b88595d5 100644 --- a/packages/onebot/tests/group-request-events.test.ts +++ b/packages/onebot/tests/group-request-events.test.ts @@ -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(); diff --git a/packages/protocol/src/packet-pipeline.ts b/packages/protocol/src/packet-pipeline.ts index 38553729..3d70eb5e 100644 --- a/packages/protocol/src/packet-pipeline.ts +++ b/packages/protocol/src/packet-pipeline.ts @@ -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; } diff --git a/packages/protocol/src/qq-info.ts b/packages/protocol/src/qq-info.ts index a45c2a78..661407b5 100644 --- a/packages/protocol/src/qq-info.ts +++ b/packages/protocol/src/qq-info.ts @@ -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): 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< diff --git a/packages/protocol/tests/oidb-services/contacts/fetch-group-requests.test.ts b/packages/protocol/tests/oidb-services/contacts/fetch-group-requests.test.ts index 88503c06..d918a184 100644 --- a/packages/protocol/tests/oidb-services/contacts/fetch-group-requests.test.ts +++ b/packages/protocol/tests/oidb-services/contacts/fetch-group-requests.test.ts @@ -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);