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
This commit is contained in:
@@ -890,7 +890,7 @@ export class ForwardApi {
|
||||
}
|
||||
|
||||
for (const [groupId, groupNodes] of byGroup) {
|
||||
let byUin: Map<number, { nickname: string; card: string }>;
|
||||
let byUin: Map<number, { nickname: string; card?: string }>;
|
||||
|
||||
try {
|
||||
const members = await this.ctx.apis.contacts.fetchGroupMemberList(groupId);
|
||||
|
||||
@@ -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)],
|
||||
]);
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' } }] } },
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ export async function getGroupMemberInfo(
|
||||
): Promise<JsonObject | null> {
|
||||
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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<RichGroupCard>(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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<OidbBase<OidbSvcTrpcTcp0xFE7_3Response>>({
|
||||
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<PushMsg>({
|
||||
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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user