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:
DrSmoothl
2026-11-04 23:23:44 +08:00
parent 33bebb4446
commit ae1ec3be0e
20 changed files with 390 additions and 50 deletions
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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 () => {
+5 -1
View File
@@ -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,
+12 -1
View File
@@ -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>;
}
+2 -1
View File
@@ -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;
}
}
+19 -8
View File
@@ -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,
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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,