From ae1ec3be0eced12a85aba9e954dcf68e06cbca24 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 3 Nov 2026 23:57:33 +0800 Subject: [PATCH] fix(protocol): decode styled group member cards Use the display text for group messages and member lookups. Refresh unreadable cached cards on demand. Refs #646 --- packages/core/src/bridge/apis/forward.ts | 2 +- .../actions/forward-nested-fetch.test.ts | 6 +- .../tests/actions/forward-resid-less.test.ts | 2 +- .../actions/forward-single-media.test.ts | 2 +- packages/core/tests/actions/forward.test.ts | 2 +- packages/core/tests/identity-service.test.ts | 67 ++++++++ .../onebot/src/modules/contact-actions.ts | 6 +- packages/onebot/tests/contact-actions.test.ts | 23 +++ packages/onebot/tests/event-pipeline.test.ts | 6 +- packages/proto-defs/src/message.ts | 13 +- packages/proto-defs/src/oidb.ts | 3 +- packages/protocol/src/group-member-card.ts | 80 ++++++++++ packages/protocol/src/identity-service.ts | 27 +++- .../src/msg-push/decoders/group-message.ts | 40 ++--- .../contacts/fetch-group-member-list-page.ts | 3 +- packages/protocol/src/packet-pipeline.ts | 2 +- packages/protocol/src/qq-info.ts | 3 +- .../protocol/tests/group-member-card.test.ts | 147 ++++++++++++++++++ .../msg-push/fetch-group-history.test.ts | 4 +- .../fetch-group-member-list-page.test.ts | 2 +- 20 files changed, 390 insertions(+), 50 deletions(-) create mode 100644 packages/protocol/src/group-member-card.ts create mode 100644 packages/protocol/tests/group-member-card.test.ts diff --git a/packages/core/src/bridge/apis/forward.ts b/packages/core/src/bridge/apis/forward.ts index 4ccce1fe..dcfda82c 100644 --- a/packages/core/src/bridge/apis/forward.ts +++ b/packages/core/src/bridge/apis/forward.ts @@ -890,7 +890,7 @@ export class ForwardApi { } for (const [groupId, groupNodes] of byGroup) { - let byUin: Map; + let byUin: Map; try { const members = await this.ctx.apis.contacts.fetchGroupMemberList(groupId); diff --git a/packages/core/tests/actions/forward-nested-fetch.test.ts b/packages/core/tests/actions/forward-nested-fetch.test.ts index d55c5f70..d9aedb5b 100644 --- a/packages/core/tests/actions/forward-nested-fetch.test.ts +++ b/packages/core/tests/actions/forward-nested-fetch.test.ts @@ -28,7 +28,7 @@ function message(...elems: Elem[]): PushMsgBody { responseHead: { fromUin: 20002, fromUid: 'sender-uid', - grp: { groupUin: 12345, memberCard: 'alice' }, + grp: { groupUin: 12345, memberCard: Buffer.from('alice') }, }, contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 }, body: { richText: { elems } }, @@ -163,7 +163,7 @@ describe('get_forward_msg / cache lifetime', () => { it('keeps an inner result attached to its outer result after sender enrichment', async () => { const nameless = message({ text: { str: 'unnamed sender' } }); - nameless.responseHead!.grp!.memberCard = ''; + nameless.responseHead!.grp!.memberCard = new Uint8Array(); const downloads = new Map([ ['enrich-outer', nestedResult('enrich-inner', 'enrich-key', nameless)], ]); @@ -220,7 +220,7 @@ describe('get_forward_msg / cache lifetime', () => { it('does not resurrect evicted nested results when enrichment finishes late', async () => { const nameless = message({ text: { str: 'late sender' } }); - nameless.responseHead!.grp!.memberCard = ''; + nameless.responseHead!.grp!.memberCard = new Uint8Array(); const downloads = new Map([ ['late-outer', nestedResult('late-inner', 'late-key', nameless)], ]); diff --git a/packages/core/tests/actions/forward-resid-less.test.ts b/packages/core/tests/actions/forward-resid-less.test.ts index 4df4e94f..e633a39a 100644 --- a/packages/core/tests/actions/forward-resid-less.test.ts +++ b/packages/core/tests/actions/forward-resid-less.test.ts @@ -43,7 +43,7 @@ function message(...elems: Elem[]): PushMsgBody { responseHead: { fromUin: 20002, fromUid: 'sender-uid', - grp: { groupUin: 12345, memberCard: 'alice' }, + grp: { groupUin: 12345, memberCard: Buffer.from('alice') }, }, contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 }, body: { richText: { elems } }, diff --git a/packages/core/tests/actions/forward-single-media.test.ts b/packages/core/tests/actions/forward-single-media.test.ts index 19547ee1..5b0f5eaf 100644 --- a/packages/core/tests/actions/forward-single-media.test.ts +++ b/packages/core/tests/actions/forward-single-media.test.ts @@ -71,7 +71,7 @@ function setup(id: string) { responseHead: { fromUin: 20002, fromUid: 'sender-uid', - grp: { groupUin: 12345, memberCard: 'alice' }, + grp: { groupUin: 12345, memberCard: Buffer.from('alice') }, }, contentHead: { msgType: 82, sequence: 10, timestamp: 1700000000 }, body: { diff --git a/packages/core/tests/actions/forward.test.ts b/packages/core/tests/actions/forward.test.ts index a2f5a4cc..b312332f 100644 --- a/packages/core/tests/actions/forward.test.ts +++ b/packages/core/tests/actions/forward.test.ts @@ -190,7 +190,7 @@ describe('actions/forward', () => { responseHead: { fromUin: 1787882683, fromUid: 'u_x', - grp: { groupUin: 284840486, memberCard: '墨梓柒' }, + grp: { groupUin: 284840486, memberCard: Buffer.from('墨梓柒') }, }, contentHead: { msgType: 82, sequence: 1, timestamp: 100 }, // 82 = PkgType.GroupMessage body: { richText: { elems: [{ text: { str: 'hi' } }] } }, diff --git a/packages/core/tests/identity-service.test.ts b/packages/core/tests/identity-service.test.ts index 7d149b48..8cc6d008 100644 --- a/packages/core/tests/identity-service.test.ts +++ b/packages/core/tests/identity-service.test.ts @@ -141,6 +141,73 @@ describe('IdentityService', () => { } }); + it('retains legacy member identities and refreshes only damaged cards across restarts', async () => { + const dbPath = tempDbPath('legacy-card'); + const member = makeMember(33333, 'u_member', 'placeholder'); + const other = makeMember(44444, 'u_other', 'ordinary'); + const seed = new IdentityService(SELF_UIN, dbPath); + seed.rememberGroups([makeGroup()]); + seed.rememberGroupMembers(GROUP_ID, [member, other]); + await seed.close(); + // Persisted bytes from #646 after the old text normalization stripped the envelope prefix. + const damaged = Buffer.concat([ + Buffer.from('1209', 'hex'), + Buffer.from('三字名'), + Buffer.from('0a090a0724c3bfc48011221000', 'hex'), + ]); + const sql = new DatabaseSync(dbPath); + + try { + sql.prepare('UPDATE group_members SET card = CAST(? AS TEXT) WHERE uin = ?').run( + damaged, + member.uin, + ); + } finally { + sql.close(); + } + + const stale = new IdentityService(SELF_UIN, dbPath); + + try { + await stale.initialization; + const cached = stale.findGroupMember(GROUP_ID, member.uin)!; + expect(cached).toMatchObject({ card: undefined, uid: member.uid, isRobot: false }); + expect(stale.findGroupMember(GROUP_ID, other.uin)?.card).toBe('ordinary'); + stale.updateGroupMember(GROUP_ID, { ...cached, role: 'admin' }); + } finally { + await stale.close(); + } + + const refreshed = new IdentityService(SELF_UIN, dbPath); + + try { + await refreshed.initialization; + expect(refreshed.findGroupMember(GROUP_ID, member.uin)).toMatchObject({ + card: undefined, + role: 'admin', + }); + refreshed.rememberGroupMembers(GROUP_ID, [ + { ...member, role: 'admin', card: ' 三字名 ' }, + other, + ]); + } finally { + await refreshed.close(); + } + + const restored = new IdentityService(SELF_UIN, dbPath); + + try { + await restored.initialization; + expect(restored.findGroupMember(GROUP_ID, member.uin)).toMatchObject({ + card: ' 三字名 ', + role: 'admin', + }); + expect(restored.findGroupMember(GROUP_ID, other.uin)?.card).toBe('ordinary'); + } finally { + await restored.close(); + } + }); + it('persists group member robot classification across restarts', async () => { const dbPath = tempDbPath('persist-robot'); diff --git a/packages/onebot/src/modules/contact-actions.ts b/packages/onebot/src/modules/contact-actions.ts index 098eb455..870b9ec8 100644 --- a/packages/onebot/src/modules/contact-actions.ts +++ b/packages/onebot/src/modules/contact-actions.ts @@ -311,7 +311,7 @@ export async function getGroupMemberInfo( ): Promise { const cached = bridge.identity.findGroupMember(groupId, userId); - if (noCache || !cached || cached.isRobot === undefined) { + if (noCache || !cached || cached.isRobot === undefined || cached.card === undefined) { await fetchSingleGroupMembers(bridge, groupId, noCache === true); } @@ -727,6 +727,10 @@ function formatGroupMember( ); } + if (member.card === undefined) { + throw new Error(`group member card unavailable: group=${groupId} member=${member.uin}`); + } + const qage = accountAge(profile?.regTime); return { group_id: groupId, diff --git a/packages/onebot/tests/contact-actions.test.ts b/packages/onebot/tests/contact-actions.test.ts index a61d0147..7bb5aea6 100644 --- a/packages/onebot/tests/contact-actions.test.ts +++ b/packages/onebot/tests/contact-actions.test.ts @@ -708,6 +708,29 @@ describe('onebot/contact-actions / getGroupMemberInfo', () => { expect(out).toMatchObject({ user_id: 55, nickname: 'eve' }); }); + it('retries a failed legacy card refresh and then reuses the confirmed empty card', async () => { + const fresh = makeMember(66, 'legacy', ''); + let member: GroupMemberInfo = { ...fresh, card: undefined }; + const fetchGroupMemberList = vi + .fn(async () => { + member = fresh; + return [fresh]; + }) + .mockRejectedValueOnce(new Error('roster offline')); + const bridge = fakeBridge({ + fetchGroupMemberList, + identity: fakeIdentity({ findGroupMember: () => member }), + }); + await expect(getGroupMemberInfo(bridge, 1200, 66)).rejects.toThrow('roster offline'); + expect(await getGroupMemberInfo(bridge, 1200, 66)).toMatchObject({ + nickname: 'legacy', + card: '', + }); + expect(await getGroupMemberInfo(bridge, 1200, 66)).toMatchObject({ card: '' }); + expect(fetchGroupMemberList).toHaveBeenCalledTimes(2); + expect(fetchGroupMemberList).toHaveBeenCalledWith(1200, { force: false }); + }); + it('refreshes a persisted member whose robot classification is unknown', async () => { let member: GroupMemberInfo = { ...makeMember(66, 'legacy'), isRobot: undefined }; const fetchGroupMemberList = vi.fn(async () => { diff --git a/packages/onebot/tests/event-pipeline.test.ts b/packages/onebot/tests/event-pipeline.test.ts index edd35d92..fb7e36cb 100644 --- a/packages/onebot/tests/event-pipeline.test.ts +++ b/packages/onebot/tests/event-pipeline.test.ts @@ -343,7 +343,11 @@ describe('registerEventPipeline', () => { message: { responseHead: { fromUin: SELF_ID, - grp: { groupUin: GROUP_ID, memberCard: 'self', groupName: 'test group' }, + grp: { + groupUin: GROUP_ID, + memberCard: Buffer.from('self'), + groupName: 'test group', + }, }, contentHead: { msgType: 82, diff --git a/packages/proto-defs/src/message.ts b/packages/proto-defs/src/message.ts index af9ccfb1..31978981 100644 --- a/packages/proto-defs/src/message.ts +++ b/packages/proto-defs/src/message.ts @@ -1,6 +1,15 @@ import type { pb, pb_repeated, int_32, uint_32, uint_64, bytes } from '@snowluma/proton'; import type { Elem } from './element'; +export interface GroupCardPart { + control?: pb<1, bytes>; + text?: pb<2, bytes>; +} + +export interface RichGroupCard { + parts?: pb_repeated<1, GroupCardPart>; +} + // ResponseHead.Grp export interface ResponseGrp { groupUin?: pb<1, uint_32>; @@ -13,7 +22,9 @@ export interface ResponseGrp { * message shows "星屿"/"墨梓柒111" (the card) here while field 2 is empty, and * forward nodes carry per-node sender names here. Lagrange labels this * `MemberName`; we keep `memberName` for the field-2 group-history slot. */ - memberCard?: pb<4, string>; + memberCard?: pb<4, bytes>; + /** Type 2 carries the literal sender nickname. Other values may carry a RichGroupCard. */ + memberCardType?: pb<5, uint_32>; /** The real group name (Lagrange: GroupName). "测试123" on-target. */ groupName?: pb<7, string>; } diff --git a/packages/proto-defs/src/oidb.ts b/packages/proto-defs/src/oidb.ts index 8fe59eb4..0a6b75ee 100644 --- a/packages/proto-defs/src/oidb.ts +++ b/packages/proto-defs/src/oidb.ts @@ -156,7 +156,8 @@ export interface OidbSvcTrpcTcp0xFE7_3Uin { } export interface OidbSvcTrpcTcp0xFE7_3Card { - memberCard?: pb<2, string>; + /** Plain UTF-8 or a serialized RichGroupCard. */ + memberCard?: pb<2, bytes>; } export interface OidbSvcTrpcTcp0xFE7_3Level { diff --git a/packages/protocol/src/group-member-card.ts b/packages/protocol/src/group-member-card.ts new file mode 100644 index 00000000..10a9c73f --- /dev/null +++ b/packages/protocol/src/group-member-card.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@snowluma/common/logger'; +import type { ResponseGrp, RichGroupCard } from '@snowluma/proto-defs/message'; +import { protobuf_decode } from '@snowluma/proton'; + +const log = createLogger('Protocol.GroupMemberCard'); +const utf8 = new TextDecoder('utf-8', { fatal: true }); + +/** Keep ordinary text whitespace, but never publish binary framing as a name. */ +export function isBinaryCardText(text: string): boolean { + // eslint-disable-next-line no-control-regex -- These are the binary framing bytes we must detect. + return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text); +} + +function displayText(bytes: Uint8Array): string { + let text: string; + + try { + text = utf8.decode(bytes); + } catch (cause) { + throw new Error('group card contains invalid UTF-8', { cause }); + } + + if (isBinaryCardText(text)) { + throw new Error('group card text contains binary control characters'); + } + + return text; +} + +export function decodeGroupMemberCard(bytes?: Uint8Array): string { + if (!bytes?.length) { + return ''; + } + + // Each part has tag 0x0a, but version/unknown fields can precede the parts. + if (!bytes.includes(0x0a)) { + return displayText(bytes); + } + + let parts: RichGroupCard['parts']; + + try { + parts = protobuf_decode(bytes).parts; + } catch { + // Both formats occupy the same wire field. A newline in a plain name + // is also 0x0a; accept it only if the entire value is valid display text. + return displayText(bytes); + } + + if (!parts?.length) { + return displayText(bytes); + } + + // A control field, even an empty one, takes precedence over text in the same part. + const text = parts.flatMap((part) => + part.control == null && part.text != null ? [part.text] : [], + ); + return displayText(Buffer.concat(text)); +} + +/** A malformed optional display name must not discard the message itself. */ +export function decodeGroupDisplayName(group?: ResponseGrp): string | undefined { + if (!group?.memberCard?.length) { + return undefined; + } + + const bytes = group.memberCard; + + try { + return group.memberCardType === 2 ? displayText(bytes) : decodeGroupMemberCard(bytes); + } catch (error) { + log.warn( + 'invalid group message display name: group=%d bytes=%d reason=%s', + group.groupUin ?? 0, + bytes.length, + error instanceof Error ? error.message : String(error), + ); + return undefined; + } +} diff --git a/packages/protocol/src/identity-service.ts b/packages/protocol/src/identity-service.ts index 0a6fe694..51b67dcd 100644 --- a/packages/protocol/src/identity-service.ts +++ b/packages/protocol/src/identity-service.ts @@ -2,6 +2,7 @@ import { SqliteDatabase } from '@snowluma/sqlite'; import path from 'path'; import { createLogger, type Logger } from '@snowluma/common/logger'; +import { isBinaryCardText } from './group-member-card'; import type { FriendInfo, GroupMemberInfo, @@ -1307,7 +1308,8 @@ export class IdentityService { } const select = this.db!.prepare( - `SELECT group_id, uid, uin, nickname, card, role, level, title, + `SELECT group_id, uid, uin, nickname, + CASE WHEN instr(card, char(0)) = 0 THEN card END AS card, role, level, title, join_time, last_sent_time, shut_up_time, is_robot FROM group_members WHERE group_id = ? AND active = 1`, @@ -1319,7 +1321,7 @@ export class IdentityService { uid: string | null; uin: number | null; nickname: string; - card: string; + card: string | null; role: string; level: number; title: string; @@ -1329,6 +1331,16 @@ export class IdentityService { is_robot: number | null; }>; const members = rows.map(rowToMemberInfo); + const staleCards = members.filter((member) => member.card === undefined).length; + + if (staleCards > 0) { + this.log.warn( + 'cached member cards require refresh: group=%d count=%d', + groupId, + staleCards, + ); + } + this.setGroupMembersInMemory(groupId, members); for (const m of members) { @@ -1459,7 +1471,6 @@ export class IdentityService { uid: uid || primary?.uid || null, uin: uin ?? primary?.uin ?? null, nickname: mergeOptionalText(input.nickname, primary?.nickname ?? ''), - card: mergeOptionalText(input.card, primary?.card ?? ''), role: mergeOptionalText(input.role, primary?.role ?? 'member'), level: normalizeNonNegative(input.level, primary?.level ?? 0), title: mergeOptionalText(input.title, primary?.title ?? ''), @@ -1479,14 +1490,14 @@ export class IdentityService { await this.db!.prepare( `UPDATE group_members - SET uid = ?, uin = ?, nickname = ?, card = ?, role = ?, level = ?, title = ?, + SET uid = ?, uin = ?, nickname = ?, card = COALESCE(?, card), role = ?, level = ?, title = ?, join_time = ?, last_sent_time = ?, shut_up_time = ?, is_robot = ?, active = ?, updated_at = ? WHERE id = ?`, ).run( merged.uid, merged.uin, merged.nickname, - merged.card, + input.card ?? null, merged.role, merged.level, merged.title, @@ -1511,7 +1522,7 @@ export class IdentityService { merged.uid, merged.uin, merged.nickname, - merged.card, + input.card ?? '', merged.role, merged.level, merged.title, @@ -1879,7 +1890,7 @@ function rowToMemberInfo(row: { uid: string | null; uin: number | null; nickname: string; - card: string; + card: string | null; role: string; level: number; title: string; @@ -1892,7 +1903,7 @@ function rowToMemberInfo(row: { uid: row.uid ?? '', uin: row.uin ?? 0, nickname: row.nickname, - card: row.card, + card: row.card !== null && !isBinaryCardText(row.card) ? row.card : undefined, role: row.role || 'member', level: row.level, title: row.title, diff --git a/packages/protocol/src/msg-push/decoders/group-message.ts b/packages/protocol/src/msg-push/decoders/group-message.ts index b76e7a81..ae92777a 100644 --- a/packages/protocol/src/msg-push/decoders/group-message.ts +++ b/packages/protocol/src/msg-push/decoders/group-message.ts @@ -1,4 +1,5 @@ import type { GroupMessage } from '../../events'; +import { decodeGroupDisplayName } from '../../group-member-card'; import type { MsgPushDecoder } from '../registry'; import { decodeRichBody } from '../rich-body-decoder'; @@ -18,38 +19,27 @@ export const decodeGroupMessage: MsgPushDecoder = (ctx) => { senderRole: '', }; const grp = ctx.responseHead?.grp; + const displayName = decodeGroupDisplayName(grp); + const isNickname = grp?.memberCardType === 2; - if (grp) { - ev.groupId = grp.groupUin ?? 0; - // Sender name priority (#201): the group-history fetch fills `memberName` - // (field 2); the local member cache gives a clean nickname/card split; and - // live pushes + merged-forward nodes carry the display name in `memberCard` - // (field 4). Prefer the explicit field-2 name, then the cache, then field 4 - // — the last is what lets a forward node from a group the bot isn't in keep - // its per-node sender name without a member-list lookup. - ev.senderNick = grp.memberName ?? ''; - } - + ev.groupId = grp?.groupUin ?? 0; const member = ctx.identity.findGroupMember(ev.groupId, ctx.fromUin); + // History supplies an explicit nickname. Live type 2 is also a nickname; + // a card from an unknown group doubles as a forward node's display name (#201). + ev.senderNick = + grp?.memberName || + (isNickname ? displayName : undefined) || + (member ? member.nickname : displayName) || + ''; if (member) { - if (!ev.senderNick) { - ev.senderNick = member.nickname; - } - - ev.senderCard = member.card; + ev.senderCard = member.card ?? ''; ev.senderRole = member.role; - // [#1] field 4 (memberCard) is the sender's CURRENT display name (group card - // if set, else base nickname). When it differs from the base nickname it's an - // up-to-date group card — fresher than the cache, which nothing refreshes on - // a card change (no push; a quiet group can freeze at warmup for months). - // Prefer it. (Self-healed back into the cache in packet-pipeline side effects.) - if (grp?.memberCard && grp.memberCard !== ev.senderNick) { - ev.senderCard = grp.memberCard; + // A fresh card supersedes the cache, which can otherwise survive a rename (#1). + if (!isNickname && displayName !== undefined && displayName !== ev.senderNick) { + ev.senderCard = displayName; } - } else if (!ev.senderNick && grp?.memberCard) { - ev.senderNick = grp.memberCard; } // Group name: prefer the message's own wire field (ResponseGrp field 7 — diff --git a/packages/protocol/src/oidb-services/contacts/fetch-group-member-list-page.ts b/packages/protocol/src/oidb-services/contacts/fetch-group-member-list-page.ts index 93e6a4e8..4d05758f 100644 --- a/packages/protocol/src/oidb-services/contacts/fetch-group-member-list-page.ts +++ b/packages/protocol/src/oidb-services/contacts/fetch-group-member-list-page.ts @@ -9,6 +9,7 @@ import type { OidbBase, OidbSvcTrpcTcp0xFE7_3Response } from '@snowluma/proto-defs/oidb'; import type { OidbGroupMemberListRequest } from '@snowluma/proto-defs/oidb-actions/base'; import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import { decodeGroupMemberCard } from '../../group-member-card'; import type { GroupMemberInfo } from '../../qq-info'; import { invokeOidb, type OidbSender } from '../../oidb-service'; @@ -84,7 +85,7 @@ export namespace FetchGroupMemberListPage { uin: raw.uin?.uin ?? 0, uid: raw.uin?.uid ?? '', nickname: raw.memberName ?? '', - card: raw.memberCard?.memberCard ?? '', + card: decodeGroupMemberCard(raw.memberCard?.memberCard), isRobot: false, role: permissionToRole(raw.permission ?? 0), level: raw.level?.level ?? 0, diff --git a/packages/protocol/src/packet-pipeline.ts b/packages/protocol/src/packet-pipeline.ts index 3d70eb5e..fc775171 100644 --- a/packages/protocol/src/packet-pipeline.ts +++ b/packages/protocol/src/packet-pipeline.ts @@ -733,7 +733,7 @@ export class IncomingPacketPipeline { // an actual change, not on every message. const cached = this.deps.identity.findGroupMember(event.groupId, event.senderUin); - if (cached && event.senderCard && event.senderCard !== cached.card) { + if (cached && event.senderCard !== (cached.card ?? '')) { this.deps.identity.updateGroupMember(event.groupId, { ...cached, card: event.senderCard, diff --git a/packages/protocol/src/qq-info.ts b/packages/protocol/src/qq-info.ts index 81b7ec30..0689a997 100644 --- a/packages/protocol/src/qq-info.ts +++ b/packages/protocol/src/qq-info.ts @@ -99,7 +99,8 @@ export interface GroupMemberInfo { uin: number; uid: string; nickname: string; - card: string; + /** Empty when unset; undefined until an unreadable card is refreshed. */ + card?: string; /** Undefined until QQ's server-provided robot ranges classify this member. */ isRobot?: boolean; role: string; // 'owner' | 'admin' | 'member' diff --git a/packages/protocol/tests/group-member-card.test.ts b/packages/protocol/tests/group-member-card.test.ts new file mode 100644 index 00000000..e2a9b8a8 --- /dev/null +++ b/packages/protocol/tests/group-member-card.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import type { PushMsg } from '@snowluma/proto-defs/message'; +import type { OidbBase, OidbSvcTrpcTcp0xFE7_3Response } from '@snowluma/proto-defs/oidb'; +import { protobuf_encode } from '@snowluma/proton'; +import { IdentityService } from '../src/identity-service'; +import type { GroupMemberInfo } from '../src/qq-info'; +import { MSG_PUSH_CMD, parseMsgPushOrThrow } from '../src/msg-push'; +import { FetchGroupMemberListPage } from '../src/oidb-services/contacts/fetch-group-member-list-page'; + +const groupId = 42; +const uin = 123; +const hex = (s: string) => Buffer.from(s, 'hex'); + +async function fetchMember(card: Uint8Array) { + const responseData = Buffer.from( + protobuf_encode>({ + body: { + members: [ + { uin: { uin }, memberName: 'nickname', memberCard: { memberCard: card } }, + ], + }, + }), + ); + const page = await FetchGroupMemberListPage.invoke( + { + sendRawPacket: async () => ({ + success: true, + gotResponse: true, + errorCode: 0, + errorMessage: '', + responseData, + }), + }, + { groupId, token: '' }, + ); + return page.members[0]!; +} + +function push(identity: IdentityService, card: Uint8Array, memberCardType?: number) { + const body = protobuf_encode({ + message: { + responseHead: { + fromUin: uin, + grp: { groupUin: groupId, memberCard: card, memberCardType }, + }, + contentHead: { msgType: 82, sequence: 1 }, + body: { richText: { elems: [{ text: { str: 'hello' } }] } }, + }, + }); + return parseMsgPushOrThrow( + { + pid: 0, + uin: '10001', + serviceCmd: MSG_PUSH_CMD, + seqId: 1, + retCode: 0, + fromClient: false, + body, + }, + identity, + )[0]; +} + +function withCachedMember(member: GroupMemberInfo) { + const identity = IdentityService.memory('10001'); + identity.rememberGroups([ + { + groupId, + groupName: 'group', + remark: '', + memberCount: 1, + memberMax: 10, + members: new Map(), + }, + ]); + identity.rememberGroupMembers(groupId, [member]); + return identity; +} + +describe('group member card wire formats', () => { + it.each([ + // Both #646 samples, with unrelated names of the same UTF-8 byte length. + [ + '测试昵称', + Buffer.concat([ + hex('0a0e120c'), + Buffer.from('测试昵称'), + hex('0a090a0724c7bfc4801a100a090a0724c7bfc4801a111000'), + ]), + ], + [ + '三字名', + Buffer.concat([ + hex('0a0b1209'), + Buffer.from('三字名'), + hex('0a090a0724c3bfc48011221000'), + ]), + ], + // Reordered outer fields, multiple text parts and a control part containing a text field. + ['甲乙', hex('10000a051203e794b20a080a01ff1203e581870a070a001203e581870a051203e4b999')], + ['🌙', hex('0a041202f09f0a0412028c99')], // A UTF-8 code point split between text parts. + [' 普通🌙 ', Buffer.from(' 普通🌙 ')], + ] as const)( + 'decodes %s in the roster and in pushes with or without a cached member', + async (name, card) => { + const member = await fetchMember(card); + expect(member.card).toBe(name); + const identity = IdentityService.memory('10001'); + expect(push(identity, card)).toMatchObject({ senderNick: name, senderCard: '' }); + const cached = withCachedMember({ ...member, card: 'outdated' }); + expect(push(cached, card)).toMatchObject({ + senderNick: 'nickname', + senderCard: name, + }); + }, + ); + + it('treats a control-only card as empty even when a previous card was cached', async () => { + const control = hex('0a030a01ff1000'); + const member = await fetchMember(control); + expect(member.card).toBe(''); + expect(push(withCachedMember({ ...member, card: 'outdated' }), control)).toMatchObject({ + senderNick: 'nickname', + senderCard: '', + }); + }); + + it('keeps explicitly literal nicknames separate from the cached group card', async () => { + const literal = '\n\t!abcdefgh'; // also a valid envelope containing an unknown fixed64 field + const identity = withCachedMember(await fetchMember(Buffer.from('existing card'))); + expect(push(identity, Buffer.from(literal), 2)).toMatchObject({ + senderNick: literal, + senderCard: 'existing card', + }); + }); + + it('rejects unreadable roster data and still delivers a message with damaged metadata', async () => { + const damaged = hex('0a051202ff00'); + await expect(fetchMember(damaged)).rejects.toThrow('group card'); + const identity = withCachedMember(await fetchMember(Buffer.from('existing card'))); + expect(push(identity, damaged)).toMatchObject({ + senderNick: 'nickname', + senderCard: 'existing card', + elements: [{ type: 'text', text: 'hello' }], + }); + }); +}); diff --git a/packages/protocol/tests/msg-push/fetch-group-history.test.ts b/packages/protocol/tests/msg-push/fetch-group-history.test.ts index 2bf771f5..574b2006 100644 --- a/packages/protocol/tests/msg-push/fetch-group-history.test.ts +++ b/packages/protocol/tests/msg-push/fetch-group-history.test.ts @@ -117,7 +117,7 @@ describe('fetchGroupMessageRange / SsoGetGroupMsg', () => { { responseHead: { fromUin: 222, - grp: { groupUin: 9999, memberCard: 'FreshCard' }, + grp: { groupUin: 9999, memberCard: Buffer.from('FreshCard') }, }, // field2 empty, field4 = fresh card contentHead: { msgType: 82, @@ -170,7 +170,7 @@ describe('fetchGroupMessageRange / SsoGetGroupMsg', () => { { responseHead: { fromUin: 222, - grp: { groupUin: 9999, memberCard: 'BaseNick' }, + grp: { groupUin: 9999, memberCard: Buffer.from('BaseNick') }, }, // field4 == base nick → not a card contentHead: { msgType: 82, diff --git a/packages/protocol/tests/oidb-services/contacts/fetch-group-member-list-page.test.ts b/packages/protocol/tests/oidb-services/contacts/fetch-group-member-list-page.test.ts index a48fe922..4c048186 100644 --- a/packages/protocol/tests/oidb-services/contacts/fetch-group-member-list-page.test.ts +++ b/packages/protocol/tests/oidb-services/contacts/fetch-group-member-list-page.test.ts @@ -85,7 +85,7 @@ describe('FetchGroupMemberListPage namespace', () => { uin: { uid: 'u_admin', uin: 22222 }, memberName: 'Ada', specialTitle: 't', - memberCard: { memberCard: 'card' }, + memberCard: { memberCard: Buffer.from('card') }, level: { level: 12 }, joinTimestamp: 1, lastMsgTimestamp: 2,