feat(logger): identity-aware readable event rendering

Notice events (recall / join / leave / poke / admin / mute / essence
/ request / invite) and the received-message log now resolve group
IDs and user IDs against the Identity cache so output reads as
human names instead of raw QQ numbers.

Before:
  [Event] 群撤回 12345 | 67890 被 99999 撤回
  [Event] 群 12345 | NickName(67890): ID:42 你好 [图片]

After:
  [Event] 群撤回 [SnowLuma群(12345)] | [Alice(67890)] 被 [Bob(99999)] 撤回
  [Event] 群 [SnowLuma群(12345)] | [Alice(67890)]: ID:42 你好 [图片]

Reply segments now expand one level via messageStore.findEvent —
"[回复:42]" becomes "[回复 [Alice(100)]: 早安]" when the original
is still in the message store, falling back to the bare id when it
isn't.

New file utils/event-format.ts owns:
  - formatGroup / formatUser     identity-aware name resolution
  - formatMessageSegments         shared element preview (replaces
                                  the inline switch in
                                  logReceivedMessage)
  - formatReply                   one-level reply chain expansion
  - formatEvent                   notice-class event renderer

Every formatter wraps its lookup in try/catch and degrades to the
prior numeric form on cache miss / throw, so log formatting can
never crash the calling path. The QQ numeric ID is preserved
alongside the name so third-party parsers that scrape uids still
find them.

26 unit tests cover cache hit / miss, malformed input, throw
recovery, and per-event-kind rendering shapes.
This commit is contained in:
DrSmoothl
2026-05-18 12:32:18 +08:00
parent 84e746c278
commit 257686eac6
4 changed files with 518 additions and 84 deletions
+25 -46
View File
@@ -21,10 +21,23 @@ import type { QQEventVariant } from './events';
import type { IdentityService } from './identity-service';
import type { BridgeEventBus } from './event-bus';
import { createLogger, type Logger } from '../utils/logger';
import { formatEvent } from '../utils/event-format';
const moduleLog = createLogger('Bridge');
const moduleEventLog = createLogger('Event');
// Notice kinds that get logged as a warning (operationally important
// state changes that an operator probably wants to see at default
// info level). Everything else falls through to info.
const WARN_EVENT_KINDS = new Set([
'group_recall',
'friend_recall',
'group_member_leave',
'group_mute',
'friend_request',
'group_invite',
]);
type GroupMemberIdentityEvent = Extract<QQEventVariant, { kind: 'group_member_join' | 'group_member_leave' }>;
export type CmdParser = (pkt: PacketInfo, identity: IdentityService) => QQEventVariant[];
@@ -81,7 +94,7 @@ export class IncomingPacketPipeline {
});
} else {
this.handleSideEffects(event);
printEvent(this.eventLog, event);
printEvent(this.eventLog, this.deps.identity, event);
this.emit(event);
}
}
@@ -239,50 +252,16 @@ export class IncomingPacketPipeline {
}
}
function formatEventUser(uin: number, uid?: string): string {
if (uin > 0) return String(uin);
return uid || '未知用户';
}
function printEvent(log: Logger, event: QQEventVariant): void {
switch (event.kind) {
case 'group_message':
case 'friend_message':
case 'temp_message':
// Message logging is handled by OneBot layer with message ID
break;
case 'group_recall':
log.warn('群撤回 %d | %d 被 %d 撤回', event.groupId, event.authorUin, event.operatorUin);
break;
case 'friend_recall':
log.warn('私聊撤回 %d 撤回了消息', event.userUin);
break;
case 'group_member_join':
log.info('入群 %s 加入 %d', formatEventUser(event.userUin, event.userUid), event.groupId);
break;
case 'group_member_leave':
log.warn('退群 %s %s %d', formatEventUser(event.userUin, event.userUid), event.isKick ? '被踢出' : '退出', event.groupId);
break;
case 'group_mute':
log.warn('禁言 %d | %d %d秒', event.groupId, event.userUin, event.duration);
break;
case 'group_admin':
log.info('管理 %d | %d %s管理员', event.groupId, event.userUin, event.set ? '+' : '-');
break;
case 'friend_poke':
log.info('戳一戳 %d -> %d', event.userUin, event.targetUin);
break;
case 'group_poke':
log.info('群戳 %d | %d -> %d', event.groupId, event.userUin, event.targetUin);
break;
case 'friend_request':
log.warn('好友请求 %d: %s', event.fromUin, event.message);
break;
case 'group_invite':
log.warn('群邀请 %d -> 群%d', event.fromUin, event.groupId);
break;
case 'group_essence':
log.info('精华 %d | %s精华', event.groupId, event.set ? '+' : '-');
break;
function printEvent(log: Logger, identity: IdentityService, event: QQEventVariant): void {
// Message-class events (group/friend/temp message) get rendered by the
// OneBot layer's logReceivedMessage — its output already includes the
// assigned message ID, which the raw packet doesn't have. Returning
// null here is the formatter's signal to skip.
const message = formatEvent(identity, event);
if (!message) return;
if (WARN_EVENT_KINDS.has(event.kind)) {
log.warn('%s', message);
} else {
log.info('%s', message);
}
}
+39 -38
View File
@@ -10,7 +10,7 @@ import { RKeyCache } from './instance-rkey';
import { buildApiContext, type OneBotInstanceContext } from './instance-context';
import { registerEventPipeline } from './event-pipeline';
import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, hashMessageIdInt32 } from './message-id';
import type { JsonObject, MessageMeta, OneBotConfig, NetworkBase } from './types';
import type { JsonObject, JsonValue, MessageMeta, OneBotConfig, NetworkBase } from './types';
import {
OneBotNetworkManager,
WsServerAdapter,
@@ -20,6 +20,7 @@ import {
type NetworkAdapterContext,
} from './network';
import { createLogger, type Logger } from '../utils/logger';
import { formatGroup, formatMessageSegments, formatReply, formatUser } from '../utils/event-format';
const moduleLog = createLogger('Event');
@@ -239,56 +240,56 @@ export class OneBotInstance {
const messageId = toInt(event.message_id);
const isGroup = event.message_type === 'group';
// Build message preview
const parts: string[] = [];
const idStr = `ID:${messageId}`;
const selfTag = isSelf ? '[自身] ' : '';
const identity = this.bridge.identity;
// Walk the segment array once: render via the shared formatter for
// non-reply segments, and resolve `reply` segments through the
// message store so the chain reference becomes legible
// ("[回复 <user>: <body>...]" instead of "[回复:1234567890]").
const renderedParts: string[] = [];
const message = event.message;
if (Array.isArray(message)) {
for (const seg of message) {
if (typeof seg !== 'object' || seg === null || Array.isArray(seg)) continue;
const type = String(seg.type ?? '');
const data: Record<string, unknown> = (
typeof seg.data === 'object' && seg.data !== null && !Array.isArray(seg.data)
) ? seg.data : {};
if (type === 'text' && data.text) {
const text = String(data.text);
const preview = text.length > 50 ? text.substring(0, 50) + '...' : text;
parts.push(preview);
} else if (type === 'image') {
parts.push('[图片]');
} else if (type === 'face') {
parts.push('[表情]');
} else if (type === 'at') {
parts.push(`@${data.qq || ''}`);
} else if (type === 'reply') {
parts.push(`[回复:${data.id || ''}]`);
} else if (type === 'record') {
parts.push('[语音]');
} else if (type === 'video') {
parts.push('[视频]');
const type = String((seg as JsonObject).type ?? '');
const data = (typeof (seg as JsonObject).data === 'object' && (seg as JsonObject).data !== null && !Array.isArray((seg as JsonObject).data))
? (seg as JsonObject).data as Record<string, unknown>
: {};
if (type === 'reply') {
const replyId = toInt(data.id);
renderedParts.push(formatReply(this.messageStore, identity, replyId));
} else {
parts.push(`[${type}]`);
renderedParts.push(formatMessageSegments([seg as JsonValue]));
}
}
} else if (typeof message === 'string') {
renderedParts.push(formatMessageSegments(message));
}
const content = parts.join(' ').trim() || '[空消息]';
const idStr = `ID:${messageId}`;
const selfTag = isSelf ? '[自身] ' : '';
const content = renderedParts.join(' ').trim() || '[空消息]';
if (isGroup) {
const groupId = toInt(event.group_id);
const userId = toInt(event.user_id);
const sender = event.sender as any;
const nickname = sender?.card || sender?.nickname || String(userId);
this.log.success(`${selfTag}群 ${groupId} | ${nickname}(${userId}): ${idStr} ${content}`);
const sender = (typeof event.sender === 'object' && event.sender !== null && !Array.isArray(event.sender))
? event.sender as JsonObject
: {};
const nicknameFromEvent = (sender.card as string) || (sender.nickname as string) || '';
const userPart = nicknameFromEvent
? `[${nicknameFromEvent}(${userId})]`
: formatUser(identity, groupId, userId);
this.log.success(`${selfTag}群 ${formatGroup(identity, groupId)} | ${userPart}: ${idStr} ${content}`);
} else {
const userId = toInt(event.user_id);
const sender = event.sender as any;
const nickname = sender?.nickname || String(userId);
this.log.success(`${selfTag}私聊 ${nickname}(${userId}): ${idStr} ${content}`);
const sender = (typeof event.sender === 'object' && event.sender !== null && !Array.isArray(event.sender))
? event.sender as JsonObject
: {};
const nicknameFromEvent = (sender.nickname as string) || '';
const userPart = nicknameFromEvent
? `[${nicknameFromEvent}(${userId})]`
: formatUser(identity, undefined, userId);
this.log.success(`${selfTag}私聊 ${userPart}: ${idStr} ${content}`);
}
}
+207
View File
@@ -0,0 +1,207 @@
// Identity-aware, one-line readable rendering for log output.
//
// Two layers consume this:
// - bridge/packet-pipeline.ts → notice events (recall / join / poke / …)
// - onebot/instance.ts → received-message log
//
// All formatters are defensive: any field lookup that throws falls
// back to the raw numeric form, so a malformed event or stale
// Identity cache never crashes the logger. Cache misses simply
// degrade to the previous "just print the uin / groupId" output.
import type { IdentityService } from '../bridge/identity-service';
import type { QQEventVariant } from '../bridge/events';
import type { MessageStore } from '../onebot/message-store';
import type { JsonObject, JsonValue } from '../onebot/types';
const MAX_TEXT_PREVIEW = 50;
const MAX_REPLY_BODY_PREVIEW = 30;
/** "[群名(12345)]" if known, else "12345" (or "0" if missing). */
export function formatGroup(identity: IdentityService, groupId: number): string {
if (!groupId || groupId <= 0) return String(groupId || 0);
try {
const g = identity.findGroup(groupId);
const name = g?.groupName?.trim();
return name ? `[${name}(${groupId})]` : String(groupId);
} catch {
return String(groupId);
}
}
/**
* "[card-or-nick(uin)]" when the uin is in the group-member or friend
* roster; falls back to the bare uin string. When only a uid is known
* (no uin yet) the uid is returned as-is so the line still references
* someone — better than logging "0".
*/
export function formatUser(
identity: IdentityService,
groupId: number | undefined,
uin: number,
uid?: string,
): string {
try {
if (!uin || uin <= 0) return uid ? uid : '0';
if (groupId !== undefined && groupId > 0) {
const member = identity.findGroupMember(groupId, uin);
const name = member?.card?.trim() || member?.nickname?.trim();
if (name) return `[${name}(${uin})]`;
}
const friend = identity.findFriend(uin);
const fname = friend?.remark?.trim() || friend?.nickname?.trim();
if (fname) return `[${fname}(${uin})]`;
return String(uin);
} catch {
return String(uin);
}
}
/**
* One-line preview of an OneBot-shape message (array of segments or
* raw string). Element rendering matches the existing logReceivedMessage
* vocabulary so this is a drop-in replacement, with a few additions
* (json / xml / markdown / forward / poke / file).
*/
export function formatMessageSegments(message: JsonValue): string {
try {
if (typeof message === 'string') {
return truncate(message, MAX_TEXT_PREVIEW) || '[空消息]';
}
if (!Array.isArray(message)) return '[空消息]';
const parts: string[] = [];
for (const seg of message) {
if (typeof seg !== 'object' || seg === null || Array.isArray(seg)) continue;
const obj = seg as JsonObject;
const type = String(obj.type ?? '');
const data = (typeof obj.data === 'object' && obj.data !== null && !Array.isArray(obj.data))
? obj.data as Record<string, unknown>
: {};
parts.push(renderSegment(type, data));
}
return parts.join(' ').trim() || '[空消息]';
} catch {
return '[消息渲染异常]';
}
}
function renderSegment(type: string, data: Record<string, unknown>): string {
switch (type) {
case 'text': return truncate(String(data.text ?? ''), MAX_TEXT_PREVIEW);
case 'image': return '[图片]';
case 'face': return '[表情]';
case 'mface': return data.text ? `[${String(data.text)}]` : '[表情]';
case 'at': return data.qq === 'all' ? '@全体成员' : `@${data.qq ?? ''}`;
case 'reply': return `[回复:${data.id ?? ''}]`;
case 'record': return '[语音]';
case 'video': return '[视频]';
case 'file': return data.file ? `[文件:${truncate(String(data.file), 20)}]` : '[文件]';
case 'json': return '[JSON]';
case 'xml': return '[XML]';
case 'markdown': return '[Markdown]';
case 'forward': return '[聊天记录]';
case 'poke': return '[戳一戳]';
default: return `[${type}]`;
}
}
function truncate(s: string, max: number): string {
return s.length > max ? `${s.slice(0, max)}...` : s;
}
/**
* Resolve a `reply` segment's referenced message and return a short
* "[回复 user: body...]" string. On miss (older than store retention,
* never seen, lookup throws) falls back to "[回复:<id>]" so the chain
* reference is at least preserved.
*
* Deliberately one level deep — we don't recurse into the reply's own
* reply. Long chains aren't worth the runtime cost in the hot path,
* and a 1-level peek catches 90% of real-world "what's this referring
* to" questions.
*/
export function formatReply(
messageStore: MessageStore,
identity: IdentityService,
replyId: number,
): string {
try {
if (!replyId) return '[回复:0]';
const event = messageStore.findEvent(replyId);
if (!event) return `[回复:${replyId}]`;
const isGroup = event.message_type === 'group';
const uin = toIntOrZero(event.user_id);
const groupId = isGroup ? toIntOrZero(event.group_id) : undefined;
// Prefer cache; if cache miss, use the sender fields baked into the
// stored event itself (those came from the original receive path
// when the roster was warm).
let userPart = formatUser(identity, groupId, uin);
if (userPart === String(uin) && uin > 0) {
const sender = (typeof event.sender === 'object' && event.sender !== null && !Array.isArray(event.sender))
? event.sender as JsonObject
: {};
const fallback = ((sender.card as string) || (sender.nickname as string) || '').trim();
if (fallback) userPart = `[${fallback}(${uin})]`;
}
const body = truncate(formatMessageSegments(event.message as JsonValue), MAX_REPLY_BODY_PREVIEW);
return `[回复 ${userPart}: ${body}]`;
} catch {
return `[回复:${replyId}]`;
}
}
function toIntOrZero(v: unknown): number {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
/**
* Readable one-line rendering of a notice-class QQEventVariant. Returns
* null for kinds the OneBot layer's message log already renders
* (group_message / friend_message / temp_message / group_file_upload /
* friend_add — those have richer dedicated paths).
*/
export function formatEvent(identity: IdentityService, event: QQEventVariant): string | null {
try {
switch (event.kind) {
case 'group_message':
case 'friend_message':
case 'temp_message':
case 'group_file_upload':
case 'friend_add':
return null;
case 'group_recall':
return `群撤回 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.authorUin)} 被 ${formatUser(identity, event.groupId, event.operatorUin)} 撤回`;
case 'friend_recall':
return `私聊撤回 ${formatUser(identity, undefined, event.userUin)} 撤回了消息`;
case 'group_member_join':
return `入群 ${formatUser(identity, event.groupId, event.userUin, event.userUid)} 加入 ${formatGroup(identity, event.groupId)}`;
case 'group_member_leave':
return `退群 ${formatUser(identity, event.groupId, event.userUin, event.userUid)} ${event.isKick ? '被踢出' : '退出'} ${formatGroup(identity, event.groupId)}`;
case 'group_mute':
return `禁言 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.userUin)} ${event.duration}秒`;
case 'group_admin':
return `管理 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.userUin)} ${event.set ? '+' : '-'}管理员`;
case 'friend_poke':
return `戳一戳 ${formatUser(identity, undefined, event.userUin)} -> ${formatUser(identity, undefined, event.targetUin)}`;
case 'group_poke':
return `群戳 ${formatGroup(identity, event.groupId)} | ${formatUser(identity, event.groupId, event.userUin)} -> ${formatUser(identity, event.groupId, event.targetUin)}`;
case 'friend_request':
return `好友请求 ${formatUser(identity, undefined, event.fromUin)}: ${event.message}`;
case 'group_invite':
return `群邀请 ${formatUser(identity, undefined, event.fromUin)} -> ${formatGroup(identity, event.groupId)}`;
case 'group_essence':
return `精华 ${formatGroup(identity, event.groupId)} | ${event.set ? '+' : '-'}精华`;
default:
return null;
}
} catch {
return null;
}
}
+247
View File
@@ -0,0 +1,247 @@
// Tests for utils/event-format.ts.
//
// Each formatter is defensive about cache misses / malformed input —
// these tests cover both the happy paths (resolves names from
// Identity / MessageStore) and the degraded paths (falls back to the
// raw uin / groupId without crashing).
import { describe, expect, it } from 'vitest';
import {
formatEvent,
formatGroup,
formatMessageSegments,
formatReply,
formatUser,
} from '../src/utils/event-format';
import type { QQEventVariant } from '../src/bridge/events';
// ─── Test fakes (just the find* surface event-format uses) ───
function fakeIdentity(opts: {
groups?: Record<number, { groupName: string }>;
members?: Record<string, { uin: number; card?: string; nickname?: string }>;
friends?: Record<number, { uin: number; nickname?: string; remark?: string }>;
} = {}): any {
return {
findGroup: (gid: number) => opts.groups?.[gid] ?? null,
findGroupMember: (gid: number, uin: number) => opts.members?.[`${gid}/${uin}`] ?? null,
findFriend: (uin: number) => opts.friends?.[uin] ?? null,
};
}
function fakeMessageStore(events: Record<number, Record<string, unknown>>): any {
return {
findEvent: (id: number) => events[id] ?? null,
};
}
describe('formatGroup', () => {
it('renders [groupName(id)] when the group is in cache', () => {
const id = fakeIdentity({ groups: { 123: { groupName: 'TestGroup' } } });
expect(formatGroup(id, 123)).toBe('[TestGroup(123)]');
});
it('falls back to bare id on cache miss', () => {
const id = fakeIdentity();
expect(formatGroup(id, 123)).toBe('123');
});
it('handles 0 / missing group id without crashing', () => {
const id = fakeIdentity();
expect(formatGroup(id, 0)).toBe('0');
});
it('falls back to bare id if findGroup throws', () => {
const id = { findGroup: () => { throw new Error('db dead'); } } as any;
expect(formatGroup(id, 123)).toBe('123');
});
});
describe('formatUser', () => {
it('prefers group-member card over nickname', () => {
const id = fakeIdentity({ members: { '1/100': { uin: 100, card: 'Card', nickname: 'Nick' } } });
expect(formatUser(id, 1, 100)).toBe('[Card(100)]');
});
it('falls back to nickname when card is empty', () => {
const id = fakeIdentity({ members: { '1/100': { uin: 100, card: '', nickname: 'Nick' } } });
expect(formatUser(id, 1, 100)).toBe('[Nick(100)]');
});
it('falls back to friend list when not a group member', () => {
const id = fakeIdentity({ friends: { 100: { uin: 100, nickname: 'FriendNick' } } });
expect(formatUser(id, undefined, 100)).toBe('[FriendNick(100)]');
});
it('prefers friend remark over nickname', () => {
const id = fakeIdentity({ friends: { 100: { uin: 100, nickname: 'A', remark: 'B' } } });
expect(formatUser(id, undefined, 100)).toBe('[B(100)]');
});
it('falls back to uin on full cache miss', () => {
const id = fakeIdentity();
expect(formatUser(id, 1, 100)).toBe('100');
});
it('falls back to uid when only uid is available', () => {
const id = fakeIdentity();
expect(formatUser(id, undefined, 0, 'u_abc')).toBe('u_abc');
});
});
describe('formatMessageSegments', () => {
it('renders text segments and truncates beyond 50 chars', () => {
const long = 'a'.repeat(80);
expect(formatMessageSegments([{ type: 'text', data: { text: long } }]))
.toBe(`${'a'.repeat(50)}...`);
});
it('handles known media types and at/reply', () => {
const out = formatMessageSegments([
{ type: 'text', data: { text: 'hi' } },
{ type: 'image', data: {} },
{ type: 'at', data: { qq: 12345 } },
{ type: 'at', data: { qq: 'all' } },
{ type: 'reply', data: { id: 999 } },
{ type: 'face', data: {} },
{ type: 'video', data: {} },
{ type: 'record', data: {} },
]);
expect(out).toBe('hi [图片] @12345 @全体成员 [回复:999] [表情] [视频] [语音]');
});
it('renders unknown segment types as [type]', () => {
expect(formatMessageSegments([{ type: 'something_new', data: {} }]))
.toBe('[something_new]');
});
it('renders a string message as text (truncated)', () => {
expect(formatMessageSegments('hello')).toBe('hello');
expect(formatMessageSegments('a'.repeat(60))).toBe(`${'a'.repeat(50)}...`);
});
it('returns [空消息] for empty arrays / non-array / null', () => {
expect(formatMessageSegments([])).toBe('[空消息]');
expect(formatMessageSegments(null as any)).toBe('[空消息]');
expect(formatMessageSegments({} as any)).toBe('[空消息]');
});
});
describe('formatReply', () => {
it('renders the resolved sender + body preview when the original is in the store', () => {
const store = fakeMessageStore({
42: {
message_type: 'group',
group_id: 1,
user_id: 100,
sender: { card: 'OrigSender' },
message: [{ type: 'text', data: { text: '早安' } }],
},
});
const id = fakeIdentity({ members: { '1/100': { uin: 100, card: 'OrigSender' } } });
expect(formatReply(store, id, 42)).toBe('[回复 [OrigSender(100)]: 早安]');
});
it('falls back to [回复:<id>] when the original is missing', () => {
const store = fakeMessageStore({});
const id = fakeIdentity();
expect(formatReply(store, id, 9999)).toBe('[回复:9999]');
});
it('uses the sender baked into the stored event when cache misses', () => {
const store = fakeMessageStore({
42: {
message_type: 'private',
user_id: 100,
sender: { nickname: 'StoredNick' },
message: [{ type: 'text', data: { text: 'hi' } }],
},
});
const id = fakeIdentity(); // no cache
expect(formatReply(store, id, 42)).toBe('[回复 [StoredNick(100)]: hi]');
});
it('truncates the body preview to 30 chars', () => {
const store = fakeMessageStore({
42: {
message_type: 'private',
user_id: 100,
sender: { nickname: 'X' },
message: [{ type: 'text', data: { text: 'a'.repeat(80) } }],
},
});
const id = fakeIdentity();
const out = formatReply(store, id, 42);
// Inner text gets truncated to 50 by formatMessageSegments, then to 30 here
expect(out).toContain('...');
expect(out.length).toBeLessThan(80);
});
it('returns the fallback when findEvent throws', () => {
const store = { findEvent: () => { throw new Error('store dead'); } } as any;
const id = fakeIdentity();
expect(formatReply(store, id, 42)).toBe('[回复:42]');
});
});
describe('formatEvent', () => {
const id = fakeIdentity({
groups: { 1: { groupName: 'Grp' } },
members: {
'1/100': { uin: 100, card: 'Alice' },
'1/200': { uin: 200, card: 'Bob' },
'1/300': { uin: 300, nickname: 'Carol' },
},
friends: {
500: { uin: 500, nickname: 'FriendA' },
600: { uin: 600, nickname: 'FriendB' },
},
});
it('returns null for kinds rendered elsewhere', () => {
expect(formatEvent(id, { kind: 'group_message' } as QQEventVariant)).toBeNull();
expect(formatEvent(id, { kind: 'friend_message' } as QQEventVariant)).toBeNull();
expect(formatEvent(id, { kind: 'temp_message' } as QQEventVariant)).toBeNull();
});
it('renders group_recall with all three name slots', () => {
expect(formatEvent(id, {
kind: 'group_recall',
groupId: 1, authorUin: 100, operatorUin: 200,
} as QQEventVariant))
.toBe('群撤回 [Grp(1)] | [Alice(100)] 被 [Bob(200)] 撤回');
});
it('renders group_member_join with group name and joiner', () => {
expect(formatEvent(id, {
kind: 'group_member_join',
groupId: 1, userUin: 300, userUid: '',
} as QQEventVariant))
.toBe('入群 [Carol(300)] 加入 [Grp(1)]');
});
it('renders group_poke with both endpoints', () => {
expect(formatEvent(id, {
kind: 'group_poke',
groupId: 1, userUin: 100, targetUin: 200,
} as QQEventVariant))
.toBe('群戳 [Grp(1)] | [Alice(100)] -> [Bob(200)]');
});
it('renders friend_poke without group context', () => {
expect(formatEvent(id, {
kind: 'friend_poke',
userUin: 500, targetUin: 600,
} as QQEventVariant))
.toBe('戳一戳 [FriendA(500)] -> [FriendB(600)]');
});
it('degrades to numeric IDs when nothing is cached', () => {
const empty = fakeIdentity();
expect(formatEvent(empty, {
kind: 'group_recall',
groupId: 1, authorUin: 100, operatorUin: 200,
} as QQEventVariant))
.toBe('群撤回 1 | 100 被 200 撤回');
});
});