@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user