Files
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

1010 lines
40 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase, OidbSvcTrpcTcp0x88D_0Response } from '@snowluma/proto-defs/oidb';
import type { OidbGetGroupExtResp } from '@snowluma/proto-defs/oidb-actions/group-ext';
import type {
GetGroupAdminPermissionsResponse,
GetGroupAdminReceiveVerifyResponse,
SetGroupAdminPermissionsRequest,
SetGroupAdminReceiveVerifyRequest,
SetGroupAdminReceiveVerifyResponse,
} from '@snowluma/proto-defs/oidb-actions/group-admin-permissions';
import type { BridgeContext } from '../../src/bridge/bridge-context';
import type {
Oidb0x8a0Req,
Oidb0x8a7Resp,
Oidb0x89a_0AddOption,
Oidb0x89a_0Search,
Oidb0x89a_0InvitePolicy,
Oidb0x89a_0HistoryVisibility,
Oidb0x89a_0MemberPermission,
Oidb0xf16Req,
OidbGroupDetailRequest,
OidbGroupRequestAction,
OidbKickMember,
OidbKickMemberResponse,
OidbLeaveGroup,
OidbMuteAll,
OidbMuteMember,
OidbRenameGroup,
OidbRenameMember,
OidbSetAdmin,
OidbSpecialTitle,
} from '@snowluma/proto-defs/oidb-actions/base';
import type {
OidbChangeGroupCategoryTag,
OidbGetGroupCategoryTags,
OidbGetGroupCategoryTagsResp,
} from '@snowluma/proto-defs/oidb-actions/group-category-tag';
// Post-namespace migration: GroupAdminApi forwards through namespaces
// under @snowluma/protocol/oidb-services/group-admin. Tests assert
// against bridge.sendRawPacket directly — no module-level mocks.
import { GroupAdminApi, MEMBER_PERMISSIONS_OWNER_ONLY } from '../../src/bridge/apis/group-admin';
import { mockBridge } from './_helpers';
function packResponse(body: Uint8Array) {
return {
success: true,
gotResponse: true,
errorCode: 0,
errorMessage: '',
responseData: Buffer.from(body),
};
}
function packPrivilegeFlag(privilegeFlag?: number, ownerUid?: string) {
return packResponse(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
body: {
groupInfo: {
uin: 12345n,
results: {
...(privilegeFlag === undefined ? {} : { privilegeFlag }),
...(ownerUid === undefined ? {} : { ownerUid }),
},
},
},
}),
);
}
function packOidbFail(code: number) {
return {
success: false,
gotResponse: true,
errorCode: code,
errorMessage: '',
responseData: Buffer.alloc(0),
};
}
function packGroupFlagExt4(groupFlagExt4?: number) {
return packResponse(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
body: {
groupInfo: {
uin: 12345n,
results: groupFlagExt4 === undefined ? {} : { groupFlagExt4 },
},
},
}),
);
}
function packAdminDetail(
results: NonNullable<NonNullable<OidbSvcTrpcTcp0x88D_0Response['groupInfo']>['results']>,
) {
return packResponse(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
body: { groupInfo: { uin: 12345n, results } },
}),
);
}
function packRobotExt(robotMemberSwitch?: number, robotMemberExamine?: number) {
return packResponse(
protobuf_encode<OidbBase<OidbGetGroupExtResp>>({
body: {
items: [
{
groupCode: 12345n,
ext: {
inviteRobotMemberSwitch: robotMemberSwitch,
inviteRobotMemberExamine: robotMemberExamine,
},
},
],
},
}),
);
}
describe('apis/group-admin', () => {
it.each([
[0, true],
[64, false],
[undefined, null],
] as const)(
'maps receive-verification state %s without inventing missing permissions',
async (flag, enabled) => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<GetGroupAdminPermissionsResponse>>({
body: {
groupCode: 12345n,
members: [
{
memberUin: 67890n,
ext: { permissions: { items: [{ item: 1, value: 2 }] } },
},
],
},
}),
),
);
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<GetGroupAdminReceiveVerifyResponse>>({
body: {
groupCode: 12345n,
members: [
{
identity: { uid: 'resolved-uid', uin: 67890n },
memberFlagExt: flag,
},
],
},
}),
),
);
await expect(
new GroupAdminApi(bridge as unknown as BridgeContext).getAdminPermissions(
12345,
67890,
),
).resolves.toEqual({
group_id: 12345,
user_id: 67890,
recall_member_message: null,
kick_member: false,
kick_member_per_day: null,
receive_verify: enabled,
});
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
expect(bridge.resolveUserUid).toHaveBeenCalledExactlyOnceWith(67890, 12345);
},
);
it.each(['OidbSvcTrpcTcp.0x9164_2', 'OidbSvcTrpcTcp.0xfe7_4'])(
'rejects the combined snapshot when %s fails',
async (failedCommand) => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(async (command: string) => {
if (command === failedCommand) {
return packOidbFail(1287);
}
if (command === 'OidbSvcTrpcTcp.0x9164_2') {
return packResponse(
protobuf_encode<OidbBase<GetGroupAdminPermissionsResponse>>({
body: { groupCode: 12345n, members: [{ memberUin: 67890n }] },
}),
);
}
return packResponse(
protobuf_encode<OidbBase<GetGroupAdminReceiveVerifyResponse>>({
body: {
groupCode: 12345n,
members: [{ identity: { uid: 'resolved-uid' }, memberFlagExt: 0 }],
},
}),
);
});
await expect(
new GroupAdminApi(bridge as unknown as BridgeContext).getAdminPermissions(
12345,
67890,
),
).rejects.toThrow('OIDB error 1287');
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
},
);
it('propagates identity resolution failures when reading administrator permissions', async () => {
const bridge = mockBridge();
bridge.resolveUserUid.mockRejectedValueOnce(new Error('identity unavailable'));
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<GetGroupAdminPermissionsResponse>>({
body: { groupCode: 12345n, members: [{ memberUin: 67890n }] },
}),
),
);
await expect(
new GroupAdminApi(bridge as unknown as BridgeContext).getAdminPermissions(12345, 67890),
).rejects.toThrow('identity unavailable');
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1);
});
it('writes administrator capabilities and receive-verification through separate services', async () => {
const bridge = mockBridge();
const api = new GroupAdminApi(bridge as unknown as BridgeContext);
await api.setAdminPermissions(12345, 67890, { recallMemberMessage: false });
const [permissionCmd, permissionBytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(permissionCmd).toBe('OidbSvcTrpcTcp.0x9c1c_2');
expect(
protobuf_decode<OidbBase<SetGroupAdminPermissionsRequest>>(permissionBytes).body,
).toEqual({
groupCode: 12345n,
memberUin: 67890n,
permissions: [{ item: 2, value: 2, extra: null }],
});
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<SetGroupAdminReceiveVerifyResponse>>({
body: { groupCode: 12345n, result: { successfulUins: [67890n] } },
}),
),
);
await api.setAdminReceiveVerify(12345, 67890, false);
const [verifyCmd, verifyBytes] = bridge.sendRawPacket.mock.calls[1]!;
expect(verifyCmd).toBe('OidbSvcTrpcTcp.0x904_1');
expect(
protobuf_decode<OidbBase<SetGroupAdminReceiveVerifyRequest>>(verifyBytes).body,
).toEqual({
groupCode: 12345n,
operation: { adminUins: [67890n], authority: 1, operate: 0 },
});
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
expect(bridge.resolveUserUid).not.toHaveBeenCalled();
});
it('does not swallow an unconfirmed receive-verification response', async () => {
const bridge = mockBridge();
await expect(
new GroupAdminApi(bridge as unknown as BridgeContext).setAdminReceiveVerify(
12345,
67890,
true,
),
).rejects.toThrow('missing or mismatched group');
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1);
});
it('muteMember resolves UID and dispatches 0x1253_1', async () => {
const bridge = mockBridge();
await new GroupAdminApi(bridge as any).muteMember(12345, 67890, 600);
expect(bridge.resolveUserUid).toHaveBeenCalledWith(67890, 12345);
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x1253_1');
const env = protobuf_decode<OidbBase<OidbMuteMember>>(bytes);
expect(env.command).toBe(0x1253);
expect(env.subCommand).toBe(1);
expect(env.body).toMatchObject({
groupUin: 12345,
type: 1,
body: { targetUid: 'resolved-uid', duration: 600 },
});
});
it('muteAll flips the muteState flag based on enable', async () => {
const bridge = mockBridge();
await new GroupAdminApi(bridge as any).muteAll(12345, true);
const env1 = protobuf_decode<OidbBase<OidbMuteAll>>(bridge.sendRawPacket.mock.calls[0]![1]);
expect(env1.body).toMatchObject({ groupUin: 12345, muteState: { state: 0xffffffff } });
await new GroupAdminApi(bridge as any).muteAll(12345, false);
const env2 = protobuf_decode<OidbBase<OidbMuteAll>>(bridge.sendRawPacket.mock.calls[1]![1]);
// proto3 default 0 fields are omitted on the wire — equivalent to "state=0".
expect(env2.body?.muteState?.state ?? 0).toBe(0);
});
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]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x8a0_1');
const env = protobuf_decode<OidbBase<OidbKickMember>>(bytes);
expect(env.body).toMatchObject({
groupUin: 12345,
targetUid: 'resolved-uid',
rejectAddRequest: true,
reason: 'bad behaviour',
});
});
it('kickMember treats a zero per-member result as success (#413)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbKickMemberResponse>>({
body: {
groupUin: 12345,
results: [{ result: 0, uid: 'u_abcdefghijklmnopqrstuv' }],
},
}),
),
);
await expect(
new GroupAdminApi(bridge as any).kickMember(12345, 67890, false),
).resolves.toBeUndefined();
});
it('kickMember rejects a server business error instead of reporting success (#298)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbKickMemberResponse>>({
body: {
groupUin: 12345,
results: [{ result: 1, uid: 'u_abcdefghijklmnopqrstuv' }],
},
}),
),
);
await expect(
new GroupAdminApi(bridge as any).kickMember(12345, 67890, false),
).rejects.toThrow('kick member failed: result=1');
});
it('kickMembers resolves each UID in parallel', async () => {
const bridge = mockBridge();
vi.mocked(bridge.resolveUserUid)
.mockResolvedValueOnce('uid-a')
.mockResolvedValueOnce('uid-b');
await new GroupAdminApi(bridge as any).kickMembers(12345, [11, 22], false);
expect(bridge.resolveUserUid).toHaveBeenCalledTimes(2);
const env = protobuf_decode<OidbBase<Oidb0x8a0Req>>(bridge.sendRawPacket.mock.calls[0]![1]);
expect(env.body?.targetUids).toEqual(['uid-a', 'uid-b']);
expect(env.body?.rejectAddRequest ?? 0).toBe(0);
});
it('kickMembers rejects the same command-level business error', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbKickMemberResponse>>({
body: {
groupUin: 12345,
results: [{ result: 1, uid: 'u_abcdefghijklmnopqrstuv' }],
},
}),
),
);
await expect(
new GroupAdminApi(bridge as any).kickMembers(12345, [67890], false),
).rejects.toThrow('kick members failed: result=1');
});
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);
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x1097_1');
const env = protobuf_decode<OidbBase<OidbLeaveGroup>>(bytes);
expect(env.body).toMatchObject({ groupUin: 12345 });
// The server never pushes a member-decrease for our own voluntary leave, so
// the bridge synthesizes it (self user_id/operator_id) and drops the group.
expect(bridge.events.emit).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'group_member_leave',
groupId: 12345,
userUin: 10001,
operatorUin: 10001,
leaveType: 'leave',
}),
);
expect(bridge.identity.forgetGroup).toHaveBeenCalledWith(12345);
});
it('setAdmin resolves UID and sends 0x1096_1', async () => {
const bridge = mockBridge();
await new GroupAdminApi(bridge as any).setAdmin(12345, 67890, true);
const [cmd, bytes] = bridge.sendRawPacket.mock.calls[0]!;
expect(cmd).toBe('OidbSvcTrpcTcp.0x1096_1');
const env = protobuf_decode<OidbBase<OidbSetAdmin>>(bytes);
expect(env.body).toMatchObject({ groupUin: 12345, uid: 'resolved-uid', isAdmin: true });
});
it('setCard / setName / setSpecialTitle / setRemark / setAddOption / setSearch dispatch the right command', async () => {
const bridge = mockBridge();
await new GroupAdminApi(bridge as any).setCard(1, 2, 'newCard');
await new GroupAdminApi(bridge as any).setName(1, 'newName');
await new GroupAdminApi(bridge as any).setSpecialTitle(1, 2, 'newTitle');
await new GroupAdminApi(bridge as any).setRemark(1, 'newRemark');
await new GroupAdminApi(bridge as any).setAddOption(1, 2);
await new GroupAdminApi(bridge as any).setSearch(1);
const cmds = bridge.sendRawPacket.mock.calls.map((call) => call[0]);
expect(cmds).toEqual([
'OidbSvcTrpcTcp.0x8fc_3',
'OidbSvcTrpcTcp.0x89a_15',
'OidbSvcTrpcTcp.0x8fc_2',
'OidbSvcTrpcTcp.0xf16_1',
'OidbSvcTrpcTcp.0x89a_0',
'OidbSvcTrpcTcp.0x89a_0',
]);
const cardEnv = protobuf_decode<OidbBase<OidbRenameMember>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
expect(cardEnv.body).toMatchObject({
groupUin: 1,
body: { targetUid: 'resolved-uid', targetName: 'newCard' },
});
const nameEnv = protobuf_decode<OidbBase<OidbRenameGroup>>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(nameEnv.body).toMatchObject({ groupUin: 1, body: { targetName: 'newName' } });
const titleEnv = protobuf_decode<OidbBase<OidbSpecialTitle>>(
bridge.sendRawPacket.mock.calls[2]![1],
);
// expireTime is proto int_32 and preserves the signed -1 sentinel.
expect(titleEnv.body?.groupUin).toBe(1);
expect(titleEnv.body?.body?.targetUid).toBe('resolved-uid');
expect(titleEnv.body?.body?.specialTitle).toBe('newTitle');
expect(titleEnv.body?.body?.expireTime).toBe(-1);
const remarkEnv = protobuf_decode<OidbBase<Oidb0xf16Req>>(
bridge.sendRawPacket.mock.calls[3]![1],
);
expect(remarkEnv.body?.inner).toMatchObject({ groupId: 1n, remark: 'newRemark' });
const addOptEnv = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(
bridge.sendRawPacket.mock.calls[4]![1],
);
expect(addOptEnv.body).toMatchObject({ groupUin: 1n, settings: { addType: 2 } });
expect(addOptEnv.body?.settings?.groupQuestion ?? undefined).toBeUndefined();
const searchEnv = protobuf_decode<OidbBase<Oidb0x89a_0Search>>(
bridge.sendRawPacket.mock.calls[5]![1],
);
expect(searchEnv.body?.groupUin).toBe(1n);
});
it('setAddOption forwards question and answer for type 4', async () => {
const bridge = mockBridge();
await new GroupAdminApi(bridge as any).setAddOption(12345, 4, 'how', '42');
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0AddOption>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
expect(envelope.body).toMatchObject({
groupUin: 12345n,
settings: { addType: 4, groupQuestion: 'how', groupAnswer: '42' },
});
});
it('setAddRequest picks _1 / _2 based on filtered flag', async () => {
const bridge = mockBridge();
await new GroupAdminApi(bridge as any).setAddRequest(12345, 5, 1, true, 'ok', false);
await new GroupAdminApi(bridge as any).setAddRequest(12345, 5, 1, false, 'no', true);
const cmds = bridge.sendRawPacket.mock.calls.map((call) => call[0]);
expect(cmds).toEqual(['OidbSvcTrpcTcp.0x10c8_1', 'OidbSvcTrpcTcp.0x10c8_2']);
const env1 = protobuf_decode<OidbBase<OidbGroupRequestAction>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
expect(env1.reserved).toBe(1); // uinForm
expect(env1.body?.accept).toBe(1);
expect(env1.body?.body?.message).toBe('ok');
const env2 = protobuf_decode<OidbBase<OidbGroupRequestAction>>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(env2.body?.accept).toBe(2);
});
it('setMemberInvitePolicy reads, merges, writes, and verifies the privilege flag (#334)', async () => {
const bridge = mockBridge();
const currentPrivilegeFlag = 0x86100001;
const expectedPrivilegeFlag = 0x84000001;
bridge.sendRawPacket
.mockResolvedValueOnce(packPrivilegeFlag(currentPrivilegeFlag))
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packPrivilegeFlag(expectedPrivilegeFlag));
await new GroupAdminApi(bridge as any).setMemberInvitePolicy(12345, 'disabled');
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
'OidbSvcTrpcTcp.0x88d_0',
'OidbSvcTrpcTcp.0x89a_0',
'OidbSvcTrpcTcp.0x88d_0',
]);
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(env.body).toMatchObject({
groupUin: 12345n,
settings: {
appPrivilegeFlag: expectedPrivilegeFlag,
appPrivilegeMask: 0x06100000,
allowMemberInvite: 0,
},
});
});
it('setMemberInvitePolicy treats an omitted privilege flag as zero', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packPrivilegeFlag())
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packPrivilegeFlag(0));
await new GroupAdminApi(bridge as any).setMemberInvitePolicy(12345, 'require_approval');
const env = protobuf_decode<OidbBase<Oidb0x89a_0InvitePolicy>>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(env.body?.settings).toEqual({
appPrivilegeFlag: 0,
appPrivilegeMask: 0x06100000,
allowMemberInvite: 1,
});
});
it('setMemberInvitePolicy fails when group detail has no results block', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
body: { groupInfo: { uin: 12345n } },
}),
),
);
await expect(
new GroupAdminApi(bridge as any).setMemberInvitePolicy(12345, 'require_approval'),
).rejects.toThrow(/unable to read group member invite policy before update/);
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
});
it('setMemberInvitePolicy rejects a successful ack when read-back does not match', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packPrivilegeFlag(0x80100001))
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packPrivilegeFlag(0x80100001));
await expect(
new GroupAdminApi(bridge as any).setMemberInvitePolicy(12345, 'disabled'),
).rejects.toThrow(/was not applied/);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
});
it('setNewMemberHistoryVisibility accepts a 0/1 history-visible read-back (#387)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packGroupFlagExt4(1));
await new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, true);
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
'OidbSvcTrpcTcp.0x89a_0',
'OidbSvcTrpcTcp.0x88d_0',
]);
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
expect(envelope.body).toMatchObject({
groupUin: 12345n,
settings: {
groupFlagExt4: 0x4,
groupFlagExt4Mask: 0x4,
},
});
});
it('setNewMemberHistoryVisibility clears visibility when read-back is the off flag', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packGroupFlagExt4(0));
await new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, false);
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0HistoryVisibility>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
expect(envelope.body?.settings).toEqual({
groupFlagExt4: 0,
groupFlagExt4Mask: 0x4,
});
});
it('setNewMemberHistoryVisibility treats an omitted history flag as hidden', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packGroupFlagExt4());
await new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, false);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
});
it('setNewMemberHistoryVisibility fails when group detail has no results block', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
body: { groupInfo: { uin: 12345n } },
}),
),
);
await expect(
new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, true),
).rejects.toThrow(/unable to read new-member history visibility after update/);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
});
it('setNewMemberHistoryVisibility rejects an ack when read-back stays hidden', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packGroupFlagExt4(0));
await expect(
new GroupAdminApi(bridge as any).setNewMemberHistoryVisibility(12345, true),
).rejects.toThrow(/was not applied/);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2);
});
it('setMemberPermissions applies supplied switches separately and verifies the combined result (#335)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packPrivilegeFlag(0x80018001))
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packPrivilegeFlag(0x80010000));
await new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
allowMemberUploadAlbum: true,
allowMemberTemporarySession: false,
allowMemberCreateGroup: true,
});
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
'OidbSvcTrpcTcp.0x88d_0',
'OidbSvcTrpcTcp.0x89a_0',
'OidbSvcTrpcTcp.0x89a_0',
'OidbSvcTrpcTcp.0x89a_0',
'OidbSvcTrpcTcp.0x88d_0',
]);
const writes = bridge.sendRawPacket.mock.calls
.slice(1, 4)
.map(
(call) =>
protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(call[1]).body?.settings,
);
expect(writes).toEqual([
{ appPrivilegeFlag: 0x80018000, appPrivilegeMask: 0x1 },
{ appPrivilegeFlag: 0x80018000, appPrivilegeMask: 0x10000 },
{ appPrivilegeFlag: 0x80010000, appPrivilegeMask: 0x8000 },
]);
});
it('setMemberPermissions rejects an empty update before reading group state', async () => {
const bridge = mockBridge();
await expect(
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {}),
).rejects.toThrow(/at least one member permission/);
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
});
it('setMemberPermissions treats an omitted privilege flag as all-allowed (#388)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packPrivilegeFlag())
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packPrivilegeFlag(0));
await new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
allowMemberUploadAlbum: true,
});
expect(bridge.sendRawPacket.mock.calls[1]![0]).toBe('OidbSvcTrpcTcp.0x89a_0');
const envelope = protobuf_decode<OidbBase<Oidb0x89a_0MemberPermission>>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(envelope.subCommand ?? 0).toBe(0);
expect(envelope.body?.settings).toEqual({
appPrivilegeFlag: 0,
appPrivilegeMask: 1,
});
});
it('setMemberPermissions fails when group detail has no results block', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
body: { groupInfo: { uin: 12345n } },
}),
),
);
await expect(
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
allowMemberUploadAlbum: true,
}),
).rejects.toThrow(/unable to read group member permissions before update/);
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
});
it('setMemberPermissions rejects a non-owner before writing (#411)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(packPrivilegeFlag(0x80000001, 'u_owner'));
await expect(
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
allowMemberUploadAlbum: true,
}),
).rejects.toThrow(MEMBER_PERMISSIONS_OWNER_ONLY);
expect(bridge.sendRawPacket).toHaveBeenCalledOnce();
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x88d_0');
});
it('setMemberPermissions maps a not-owner server refusal to a readable error (#411)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packPrivilegeFlag(0x80000001))
.mockResolvedValueOnce(packOidbFail(1287));
await expect(
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
allowMemberUploadAlbum: true,
}),
).rejects.toThrow(MEMBER_PERMISSIONS_OWNER_ONLY);
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
'OidbSvcTrpcTcp.0x88d_0',
'OidbSvcTrpcTcp.0x89a_0',
]);
});
it('setMemberPermissions writes when the bot is the group owner', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packPrivilegeFlag(0x80000001, 'self-uid'))
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packPrivilegeFlag(0, 'self-uid'));
await new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
allowMemberUploadAlbum: true,
});
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
'OidbSvcTrpcTcp.0x88d_0',
'OidbSvcTrpcTcp.0x89a_0',
'OidbSvcTrpcTcp.0x88d_0',
]);
});
it('setMemberPermissions rejects an ack when final read-back does not match', async () => {
const bridge = mockBridge();
bridge.sendRawPacket
.mockResolvedValueOnce(packPrivilegeFlag(0x80000001))
.mockResolvedValueOnce(packResponse(Buffer.alloc(0)))
.mockResolvedValueOnce(packPrivilegeFlag(0x80000001));
await expect(
new GroupAdminApi(bridge as any).setMemberPermissions(12345, {
allowMemberUploadAlbum: true,
}),
).rejects.toThrow(/were not applied/);
expect(bridge.sendRawPacket).toHaveBeenCalledTimes(3);
});
it('getAtAllRemain decodes the response and converts BigInts', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<Oidb0x8a7Resp>>({
body: { canAtAll: true, groupRemain: 12, uinRemain: 5 } as any,
}),
),
);
const out = await new GroupAdminApi(bridge as any).getAtAllRemain(12345);
expect(out).toEqual({
can_at_all: true,
remain_at_all_count_for_group: 12,
remain_at_all_count_for_uin: 5,
});
});
it('getAdminSettings composes detail and ext-info into setter-aligned fields (#385)', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
if (cmd === 'OidbSvcTrpcTcp.0x88d_0') {
return packAdminDetail({
addType: 2,
question: 'how',
answer: '42',
privilegeFlag: 0x84018001,
groupFlagExt4: 1,
noFingerOpen: 1,
});
}
if (cmd === 'OidbSvcTrpcTcp.0xef0_1') {
return packRobotExt(1, 2);
}
return packResponse(Buffer.alloc(0));
});
const out = await new GroupAdminApi(bridge as any).getAdminSettings(12345);
expect(out).toEqual({
add_type: 2,
group_question: 'how',
group_answer: '42',
robot_member_switch: 1,
robot_member_examine: 2,
member_invite_policy: 'disabled',
allow_member_upload_album: false,
allow_member_temporary_session: false,
allow_member_create_group: false,
new_member_history_visible: true,
no_finger_open: 1,
no_code_finger_open: 0,
});
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0]).sort()).toEqual([
'OidbSvcTrpcTcp.0x88d_0',
'OidbSvcTrpcTcp.0xef0_1',
]);
const detailReq = protobuf_decode<OidbBase<OidbGroupDetailRequest>>(
bridge.sendRawPacket.mock.calls.find(
(call) => call[0] === 'OidbSvcTrpcTcp.0x88d_0',
)![1],
);
expect(detailReq.body?.config?.flags?.question).toBe('');
expect(detailReq.body?.config?.flags?.answer).toBe('');
});
it('getAdminSettings treats omitted detail/ext zeros as the default values', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
if (cmd === 'OidbSvcTrpcTcp.0x88d_0') {
return packAdminDetail({});
}
if (cmd === 'OidbSvcTrpcTcp.0xef0_1') {
return packRobotExt();
}
return packResponse(Buffer.alloc(0));
});
await expect(new GroupAdminApi(bridge as any).getAdminSettings(12345)).resolves.toEqual({
add_type: 0,
group_question: '',
group_answer: '',
robot_member_switch: 0,
robot_member_examine: 0,
member_invite_policy: 'require_approval',
allow_member_upload_album: true,
allow_member_temporary_session: true,
allow_member_create_group: true,
new_member_history_visible: false,
no_finger_open: 0,
no_code_finger_open: 0,
});
});
it('getAdminSettings fails when group detail has no results block', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockImplementation(async (cmd: string) => {
if (cmd === 'OidbSvcTrpcTcp.0x88d_0') {
return packResponse(
protobuf_encode<OidbBase<OidbSvcTrpcTcp0x88D_0Response>>({
body: { groupInfo: { uin: 12345n } },
}),
);
}
return packRobotExt(0, 0);
});
await expect(new GroupAdminApi(bridge as any).getAdminSettings(12345)).rejects.toThrow(
/unable to read group admin settings/,
);
});
it('getAtAllRemain falls back to zero / false when the response is empty', async () => {
const bridge = mockBridge();
// Empty OidbBase envelope (no body) — invokeOidb substitutes `{}`
// for the deserialize argument; canAtAll/groupRemain/uinRemain
// become undefined and get coerced to false/0.
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(protobuf_encode<OidbBase<Oidb0x8a7Resp>>({})),
);
const out = await new GroupAdminApi(bridge as any).getAtAllRemain(12345);
expect(out).toEqual({
can_at_all: false,
remain_at_all_count_for_group: 0,
remain_at_all_count_for_uin: 0,
});
});
it('lists category tags and keeps category 0 on the wire', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(
protobuf_encode<OidbBase<OidbGetGroupCategoryTagsResp>>({
body: { tags: [{ id: 7, name: '学习', type: 1 }, { name: '未编号' }] },
}),
),
);
const out = await new GroupAdminApi(bridge as any).listCategoryTags(0);
expect(out).toEqual({
tags: [
{ id: 7, name: '学习', type: 1 },
{ id: 0, name: '未编号', type: 0 },
],
});
expect(bridge.sendRawPacket.mock.calls[0]![0]).toBe('OidbSvcTrpcTcp.0x967c_0');
const env = protobuf_decode<OidbBase<OidbGetGroupCategoryTags>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
expect(env.command).toBe(0x967c);
expect(env.body?.groupClass).toBe(0);
});
it('returns an empty tag list when the category has none', async () => {
const bridge = mockBridge();
bridge.sendRawPacket.mockResolvedValueOnce(
packResponse(protobuf_encode<OidbBase<OidbGetGroupCategoryTagsResp>>({ body: {} })),
);
await expect(new GroupAdminApi(bridge as any).listCategoryTags(2)).resolves.toEqual({
tags: [],
});
});
it('adds and removes a category tag by its name', async () => {
const bridge = mockBridge();
await new GroupAdminApi(bridge as any).addCategoryTag(3, ' 户外 ');
await new GroupAdminApi(bridge as any).removeCategoryTag(3, '户外');
expect(bridge.sendRawPacket.mock.calls.map((call) => call[0])).toEqual([
'OidbSvcTrpcTcp.0x967d_0',
'OidbSvcTrpcTcp.0x967e_0',
]);
const added = protobuf_decode<OidbBase<OidbChangeGroupCategoryTag>>(
bridge.sendRawPacket.mock.calls[0]![1],
);
expect(added.body).toMatchObject({ groupClass: 3, name: ' 户外 ' });
const removed = protobuf_decode<OidbBase<OidbChangeGroupCategoryTag>>(
bridge.sendRawPacket.mock.calls[1]![1],
);
expect(removed.body).toMatchObject({ groupClass: 3, name: '户外' });
});
it('refuses an empty tag name instead of sending it', async () => {
const bridge = mockBridge();
await expect(new GroupAdminApi(bridge as any).addCategoryTag(1, '')).rejects.toThrow(
/must not be empty/,
);
await expect(new GroupAdminApi(bridge as any).removeCategoryTag(1, '')).rejects.toThrow(
/must not be empty/,
);
expect(bridge.sendRawPacket).not.toHaveBeenCalled();
});
});