fix(onebot): report rejected group kick operations

Treat group member removal as successful only when QQ accepts the operation. Preserve the rejection reason so callers no longer receive a false success when a target cannot be removed.

Apply the same behavior to single-member and batch removal actions, while keeping accepted operations unchanged. Add regression coverage for both successful and rejected responses.

Fixes #298
This commit is contained in:
DrSmoothl
2026-07-30 21:42:18 +08:00
parent 0d14408b6f
commit 0ed85e7745
4 changed files with 63 additions and 10 deletions
@@ -62,6 +62,9 @@ describe('apis/group-admin', () => {
it('kickMember resolves UID per-group and forwards reject + reason', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(packResponse(
Buffer.from('220308b960', 'hex'),
));
await new GroupAdminApi(bridge as any).kickMember(12345, 67890, true, 'bad behaviour');
expect(bridge.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
@@ -75,6 +78,20 @@ describe('apis/group-admin', () => {
});
});
it('kickMember rejects a server business error instead of reporting success (#298)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(packResponse(
Buffer.from(
'222008b960121be7bea4e4b8bbe697a0e6b395e8a2abe7a7bbe587bae7bea4e8818a',
'hex',
),
));
await expect(
new GroupAdminApi(bridge as any).kickMember(12345, 67890, false),
).rejects.toThrow('群主无法被移出群聊');
});
it('kickMembers resolves each UID in parallel', async () => {
const bridge = mockBridge();
vi.mocked(bridge.resolveUserUid)
@@ -88,6 +105,20 @@ describe('apis/group-admin', () => {
expect(env.body?.rejectAddRequest ?? 0).toBe(0);
});
it('kickMembers rejects the same command-level business error', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(packResponse(
Buffer.from(
'222008b960121be7bea4e4b8bbe697a0e6b395e8a2abe7a7bbe587bae7bea4e8818a',
'hex',
),
));
await expect(
new GroupAdminApi(bridge as any).kickMembers(12345, [67890], false),
).rejects.toThrow('群主无法被移出群聊');
});
it('leave sends 0x1097_1, emits a self group_member_leave, and forgets the group (#133)', async () => {
const bridge = mockBridge();
await new GroupAdminApi(bridge as any).leave(12345);
@@ -62,6 +62,14 @@ export interface OidbKickMember {
rejectAddRequest?: pb<4, bool>;
reason?: pb<5, string>;
}
// 0x8A0_1 response body. Cross-checked against Lagrange.Core's
// OidbSvcTrpcTcp0x8A0_1Response and the current QQ kick result contract:
// transport/envelope success does not imply the member was removed; a
// command-level refusal is returned in errorMsg.
export interface OidbKickMemberResponse {
groupUin?: pb<1, uint_32>;
errorMsg?: pb<2, string>;
}
export interface OidbLeaveGroup {
groupUin?: pb<1, uint_32>;
}
@@ -3,8 +3,11 @@
// namespace because proton needs distinct types per call site.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { OidbKickMember } from '@snowluma/proto-defs/oidb-actions/base';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbKickMember,
OidbKickMemberResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import type { BridgeContext } from '../../bridge-context';
@@ -29,13 +32,17 @@ export namespace KickMember {
reason: p.reason ?? '',
});
export const deserialize = (_ctx: Deps, _: OidbEmpty): void => {};
export const deserialize = (_ctx: Deps, body: OidbKickMemberResponse): void => {
if (body.errorMsg) {
throw new Error(`kick member failed: ${body.errorMsg}`);
}
};
export const encode = (env: OidbBase<OidbKickMember>): Uint8Array =>
protobuf_encode<OidbBase<OidbKickMember>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbEmpty> =>
protobuf_decode<OidbBase<OidbEmpty>>(bytes);
export const decode = (bytes: Uint8Array): OidbBase<OidbKickMemberResponse> =>
protobuf_decode<OidbBase<OidbKickMemberResponse>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, KickMember, params);
@@ -3,8 +3,11 @@
// namespace so proton can monomorphize independently.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb';
import type { Oidb0x8a0Req } from '@snowluma/proto-defs/oidb-actions/base';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
Oidb0x8a0Req,
OidbKickMemberResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import { invokeOidb, type OidbSender } from '../../oidb-service';
import type { BridgeContext } from '../../bridge-context';
@@ -28,13 +31,17 @@ export namespace KickMembers {
field12: 0,
});
export const deserialize = (_ctx: Deps, _: OidbEmpty): void => {};
export const deserialize = (_ctx: Deps, body: OidbKickMemberResponse): void => {
if (body.errorMsg) {
throw new Error(`kick members failed: ${body.errorMsg}`);
}
};
export const encode = (env: OidbBase<Oidb0x8a0Req>): Uint8Array =>
protobuf_encode<OidbBase<Oidb0x8a0Req>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbEmpty> =>
protobuf_decode<OidbBase<OidbEmpty>>(bytes);
export const decode = (bytes: Uint8Array): OidbBase<OidbKickMemberResponse> =>
protobuf_decode<OidbBase<OidbKickMemberResponse>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<void> =>
invokeOidb(deps, KickMembers, params);