The package previously named "bridge" houses the protocol-byte layer (OIDB cmds, msg-push decoders, highway uploaders, element-builder, event bus). The name "bridge" suggested transport/lifecycle, which it isn't — that role lives in core/hook and core/bridge/manager. Rename to match what's actually inside. This is a pure rename: zero logic changes, all 73 import sites updated mechanically and TS-verified. core/onebot dependencies key updated. ESLint override path adjusted for the moved oidb-services directory. Rename detection threshold: git log --find-renames=20% recommended when following history across this commit — content-replacement (@snowluma/bridge → @snowluma/protocol) dropped per-file similarity below git's default 50% on import-heavy files.
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { protobuf_encode } from '@snowluma/proton';
|
|
import { parseMsgPush, MSG_PUSH_CMD } from '@snowluma/protocol/msg-push';
|
|
import { IdentityService } from '@snowluma/protocol/identity-service';
|
|
import type { GroupMemberInfo, QQGroupInfo } from '@snowluma/protocol/qq-info';
|
|
// Proton's call-site analyzer keys off the literal type identifier
|
|
// printed in `protobuf_encode<X>` — it walks imports by *original*
|
|
// name (not alias). If both `import { X } from 'proto/notify'` and
|
|
// `import { X as Y } from 'events'` appear, the analyzer's import
|
|
// resolver picks up the second one and the proton-side definition
|
|
// loses, so call sites referencing `X` go un-replaced. Side-step by
|
|
// re-aliasing the event-side type's *source* (not just the local
|
|
// name) — we don't need to import it directly since assertions can
|
|
// inline-extract the kind.
|
|
import type {
|
|
GroupChange, NewFriend, FriendRecall, OperatorInfo, SelfJoinInGroup,
|
|
} from '@snowluma/proto-defs/notify';
|
|
import type { PushMsg } from '@snowluma/proto-defs/message';
|
|
import type {
|
|
GroupMemberJoin, FriendAddEvent, QQEventVariant,
|
|
} from '@snowluma/protocol/events';
|
|
|
|
// Local alias for the event-side recall type — derived via Extract so
|
|
// we avoid an `import { FriendRecall as X }` that collides with the
|
|
// proton import above.
|
|
type FriendRecallEvent = Extract<QQEventVariant, { kind: 'friend_recall' }>;
|
|
import type { PacketInfo } from '@snowluma/common/protocol-types';
|
|
import { subscribeLogs } from '@snowluma/common/logger';
|
|
|
|
const SELF_UIN = '10001';
|
|
const GROUP_ID = 123456789;
|
|
|
|
function makeGroupMember(uin: number, uid: string): GroupMemberInfo {
|
|
return {
|
|
uin,
|
|
uid,
|
|
nickname: '',
|
|
card: '',
|
|
role: 'member',
|
|
level: 0,
|
|
title: '',
|
|
joinTime: 0,
|
|
lastSentTime: 0,
|
|
shutUpTime: 0,
|
|
};
|
|
}
|
|
|
|
function makeGroup(members: GroupMemberInfo[] = []): QQGroupInfo {
|
|
return {
|
|
groupId: GROUP_ID,
|
|
groupName: '',
|
|
remark: '',
|
|
memberCount: members.length,
|
|
memberMax: 500,
|
|
members: new Map(members.map((member) => [member.uin, member])),
|
|
};
|
|
}
|
|
|
|
function makeIdentity(members: GroupMemberInfo[] = []): IdentityService {
|
|
const identity = IdentityService.memory(SELF_UIN);
|
|
identity.rememberGroups([makeGroup(members)]);
|
|
if (members.length) identity.rememberGroupMembers(GROUP_ID, members);
|
|
return identity;
|
|
}
|
|
|
|
function makeGroupIncreasePacket(memberUid: string, operatorUid = '', fromUin = GROUP_ID): PacketInfo {
|
|
const operatorBytes = operatorUid
|
|
? protobuf_encode<OperatorInfo>({ operatorField: { uid: operatorUid } })
|
|
: new Uint8Array(0);
|
|
const content = protobuf_encode<GroupChange>({
|
|
groupUin: GROUP_ID,
|
|
memberUid,
|
|
operatorBytes,
|
|
});
|
|
const body = protobuf_encode<PushMsg>({
|
|
message: {
|
|
responseHead: { fromUin },
|
|
contentHead: { msgType: 33, timestamp: 1710000000 },
|
|
body: { msgContent: content },
|
|
},
|
|
status: 0,
|
|
});
|
|
|
|
return {
|
|
pid: 1,
|
|
uin: SELF_UIN,
|
|
serviceCmd: MSG_PUSH_CMD,
|
|
seqId: 1,
|
|
retCode: 0,
|
|
fromClient: false,
|
|
body,
|
|
};
|
|
}
|
|
|
|
describe('parseMsgPush group member increase', () => {
|
|
it('does not fall back to the group id when a joining uid is unresolved', () => {
|
|
const [event] = parseMsgPush(makeGroupIncreasePacket('u_new_member'), makeIdentity()) as GroupMemberJoin[];
|
|
|
|
expect(event.kind).toBe('group_member_join');
|
|
expect(event.groupId).toBe(GROUP_ID);
|
|
expect(event.userUin).toBe(0);
|
|
expect(event.operatorUin).toBe(0);
|
|
expect(event.userUid).toBe('u_new_member');
|
|
});
|
|
|
|
it('resolves joining uid and operator uid from the member cache when available', () => {
|
|
const member = makeGroupMember(22222, 'u_member');
|
|
const operator = makeGroupMember(33333, 'u_operator');
|
|
const [event] = parseMsgPush(
|
|
makeGroupIncreasePacket(member.uid, operator.uid),
|
|
makeIdentity([member, operator]),
|
|
) as GroupMemberJoin[];
|
|
|
|
expect(event.userUin).toBe(member.uin);
|
|
expect(event.operatorUin).toBe(operator.uin);
|
|
expect(event.userUid).toBe(member.uid);
|
|
expect(event.operatorUid).toBe(operator.uid);
|
|
});
|
|
});
|
|
|
|
describe('parseMsgPush Event0x210 subType=38 (acknowledged-but-silent)', () => {
|
|
// Tracked back to the stock-QQ-android decompiled decoder
|
|
// `com.tencent.imcore.message.ext.codec.decoder.msgType0x210.SubType0x26`
|
|
// — it's the QQ-client-internal "troop shortcut bar" / discussion
|
|
// app state push (badge counts + in-app tips), not a chat event.
|
|
// We acknowledge it via `Event0x210SubType.GroupAppStatePush` and
|
|
// drop silently — no OneBot-level event, and — critically —
|
|
// **no** "unknown subType=38" fallback log spam anymore.
|
|
function makeEvent0x210Packet(subType: number, content = new Uint8Array(0)): PacketInfo {
|
|
const body = protobuf_encode<PushMsg>({
|
|
message: {
|
|
responseHead: { fromUin: 22222, type: 0, sigMap: 0 },
|
|
// 528 = Event0x210
|
|
contentHead: { msgType: 528, subType, timestamp: 1710000000 },
|
|
body: { msgContent: content },
|
|
},
|
|
status: 0,
|
|
});
|
|
return {
|
|
pid: 1, uin: SELF_UIN, serviceCmd: MSG_PUSH_CMD, seqId: 1,
|
|
retCode: 0, fromClient: false, body,
|
|
};
|
|
}
|
|
|
|
it('returns [] for subType 38 without falling through to the MsgPush.Unknown log', () => {
|
|
const captured: string[] = [];
|
|
const unsubscribe = subscribeLogs((entry) => {
|
|
// Only care about the spam that the prior implementation produced
|
|
// ("MsgPush.Unknown" with subType=38). Other modules log freely
|
|
// during identity setup etc., we ignore them.
|
|
if (entry.scope === 'MsgPush.Unknown' && /subType=38/.test(entry.message)) {
|
|
captured.push(entry.message);
|
|
}
|
|
});
|
|
try {
|
|
const events = parseMsgPush(makeEvent0x210Packet(38), makeIdentity());
|
|
expect(events).toEqual([]);
|
|
expect(captured).toEqual([]); // no "unknown subType=38" debug log
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it('still flags genuinely-unknown subTypes via MsgPush.Unknown (regression guard)', () => {
|
|
// Counterpart to the silent-on-38 case: anything we *haven't*
|
|
// claimed in the enum should still hit the fallback so it's
|
|
// visible to whoever's tailing debug logs.
|
|
const captured: string[] = [];
|
|
const unsubscribe = subscribeLogs((entry) => {
|
|
if (entry.scope === 'MsgPush.Unknown' && /subType=999/.test(entry.message)) {
|
|
captured.push(entry.message);
|
|
}
|
|
});
|
|
try {
|
|
parseMsgPush(makeEvent0x210Packet(999), makeIdentity());
|
|
expect(captured.length).toBeGreaterThan(0);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── P1 coverage extension: subTypes confirmed by LagrangeGo / acidify
|
|
// that we were previously dropping into the "unknown" fallback. ──
|
|
|
|
function makeEvent0x210PacketAny(subType: number, content: Uint8Array): PacketInfo {
|
|
const body = protobuf_encode<PushMsg>({
|
|
message: {
|
|
responseHead: { fromUin: 22222, type: 0, sigMap: 0 },
|
|
contentHead: { msgType: 528, subType, timestamp: 1710000000 },
|
|
body: { msgContent: content },
|
|
},
|
|
status: 0,
|
|
});
|
|
return {
|
|
pid: 1, uin: SELF_UIN, serviceCmd: MSG_PUSH_CMD, seqId: 1,
|
|
retCode: 0, fromClient: false, body,
|
|
};
|
|
}
|
|
|
|
describe('parseMsgPush Event0x210 subType=179/226 (NewFriend → friend_add)', () => {
|
|
// 179 fires when bot sent a request and other side accepted; 226
|
|
// when bot accepted other side's request. Both share the
|
|
// `NewFriend` wire shape (LagrangeGo registers both via
|
|
// case-fallthrough in `client/listener.go:248`). We surface them
|
|
// identically as `kind: 'friend_add'`.
|
|
|
|
function makeNewFriendPacket(subType: 179 | 226, friendUid: string, time = 1710000000): PacketInfo {
|
|
const content = protobuf_encode<NewFriend>({
|
|
field1: 0,
|
|
info: { uid: friendUid, time, message: 'hello', nickName: 'Alice' },
|
|
});
|
|
return makeEvent0x210PacketAny(subType, content);
|
|
}
|
|
|
|
it('emits friend_add for subType 179', () => {
|
|
const member = makeGroupMember(99999, 'u_new_friend');
|
|
const [event] = parseMsgPush(
|
|
makeNewFriendPacket(179, member.uid),
|
|
makeIdentity([member]),
|
|
) as FriendAddEvent[];
|
|
|
|
expect(event.kind).toBe('friend_add');
|
|
expect(event.userUin).toBe(99999);
|
|
expect(event.time).toBe(1710000000);
|
|
});
|
|
|
|
it('emits friend_add for subType 226 (same wire shape, different surface)', () => {
|
|
const member = makeGroupMember(88888, 'u_other_friend');
|
|
const [event] = parseMsgPush(
|
|
makeNewFriendPacket(226, member.uid),
|
|
makeIdentity([member]),
|
|
) as FriendAddEvent[];
|
|
|
|
expect(event.kind).toBe('friend_add');
|
|
expect(event.userUin).toBe(88888);
|
|
});
|
|
|
|
it('falls back to the packet routingHead.fromUin when the uid is not in the local cache', () => {
|
|
// No member registered for `u_unknown_uid`, but the packet itself
|
|
// carries the new friend's uin on `fromUin` (22222 in
|
|
// makeEvent0x210PacketAny). That's the wire-level convention
|
|
// (LagrangeGo emits unconditionally then calls `ResolveUin` post
|
|
// hoc, see `client/listener.go:259`). Without the fallback the
|
|
// event would either drop or surface `user_id: 0`, both of which
|
|
// confuse OneBot consumers.
|
|
const [event] = parseMsgPush(
|
|
makeNewFriendPacket(179, 'u_unknown_uid'),
|
|
makeIdentity(),
|
|
) as FriendAddEvent[];
|
|
expect(event.kind).toBe('friend_add');
|
|
expect(event.userUin).toBe(22222); // the packet's fromUin
|
|
});
|
|
});
|
|
|
|
describe('parseMsgPush Event0x210 subType=139 (self-recall direction)', () => {
|
|
// 138 = friend recalled their own message sent to bot
|
|
// (peer in friend_recall event = fromUid)
|
|
// 139 = bot recalled own message sent to friend
|
|
// (peer in friend_recall event = toUid)
|
|
// Same wire shape; the subType is the direction discriminator.
|
|
// Acidify makes the same split in `parseFriendRecall:380`. Without
|
|
// 139, self-recalls used to fall through and disappear.
|
|
|
|
function makeFriendRecallPacket(subType: 138 | 139, fromUid: string, toUid: string): PacketInfo {
|
|
const content = protobuf_encode<FriendRecall>({
|
|
info: {
|
|
fromUid, toUid, clientSequence: 4242, newId: 0n,
|
|
time: 1710000000, random: 0, pkgNum: 1, pkgIndex: 0, divSeq: 1,
|
|
},
|
|
});
|
|
return makeEvent0x210PacketAny(subType, content);
|
|
}
|
|
|
|
it('subType 138 keeps fromUid as the resolved peer (other recalled their own msg to us)', () => {
|
|
const friend = makeGroupMember(77777, 'u_friend');
|
|
const [event] = parseMsgPush(
|
|
makeFriendRecallPacket(138, friend.uid, 'u_self'),
|
|
makeIdentity([friend]),
|
|
) as FriendRecallEvent[];
|
|
|
|
expect(event.kind).toBe('friend_recall');
|
|
expect(event.userUin).toBe(77777);
|
|
expect(event.msgSeq).toBe(4242);
|
|
});
|
|
|
|
it('subType 139 picks toUid as the peer (we recalled our msg sent to friend)', () => {
|
|
const friend = makeGroupMember(77777, 'u_friend');
|
|
const [event] = parseMsgPush(
|
|
makeFriendRecallPacket(139, 'u_self', friend.uid),
|
|
makeIdentity([friend]),
|
|
) as FriendRecallEvent[];
|
|
|
|
expect(event.kind).toBe('friend_recall');
|
|
expect(event.userUin).toBe(77777); // resolved from toUid (the friend), not fromUid (us)
|
|
});
|
|
});
|
|
|
|
describe('parseMsgPush PkgType 85 (bot self-joined a group)', () => {
|
|
// PkgType 33 (GroupMemberIncreaseNotice) fires for *other* members
|
|
// joining; the bot's own join after `set_group_add_request` approval
|
|
// / invite-accept arrives at 85 instead. We surface it as
|
|
// `group_member_join` with `userUin = selfUin` so the OneBot
|
|
// converter naturally produces `notice.group_increase`.
|
|
function makeSelfJoinPacket(groupId: number, operatorUid = ''): PacketInfo {
|
|
const content = protobuf_encode<SelfJoinInGroup>({
|
|
groupUin: BigInt(groupId),
|
|
field2: 1,
|
|
operatorUid,
|
|
field4: 0,
|
|
field6: 48,
|
|
field7: '',
|
|
});
|
|
const body = protobuf_encode<PushMsg>({
|
|
message: {
|
|
responseHead: { fromUin: groupId, type: 0, sigMap: 0 },
|
|
contentHead: { msgType: 85, subType: 0, timestamp: 1710000000 },
|
|
body: { msgContent: content },
|
|
},
|
|
status: 0,
|
|
});
|
|
return {
|
|
pid: 1, uin: SELF_UIN, serviceCmd: MSG_PUSH_CMD, seqId: 1,
|
|
retCode: 0, fromClient: false, body,
|
|
};
|
|
}
|
|
|
|
it('emits group_member_join with userUin == selfUin', () => {
|
|
const [event] = parseMsgPush(
|
|
makeSelfJoinPacket(GROUP_ID),
|
|
makeIdentity(),
|
|
) as GroupMemberJoin[];
|
|
|
|
expect(event.kind).toBe('group_member_join');
|
|
expect(event.groupId).toBe(GROUP_ID);
|
|
expect(event.userUin).toBe(Number(SELF_UIN));
|
|
});
|
|
|
|
it('resolves operator uid through identity when present', () => {
|
|
const admin = makeGroupMember(11111, 'u_admin');
|
|
const [event] = parseMsgPush(
|
|
makeSelfJoinPacket(GROUP_ID, admin.uid),
|
|
makeIdentity([admin]),
|
|
) as GroupMemberJoin[];
|
|
|
|
expect(event.operatorUin).toBe(admin.uin);
|
|
expect(event.operatorUid).toBe(admin.uid);
|
|
});
|
|
});
|