diff --git a/packages/core/src/bridge/packet-pipeline.ts b/packages/core/src/bridge/packet-pipeline.ts index 19654a08..f6df7002 100644 --- a/packages/core/src/bridge/packet-pipeline.ts +++ b/packages/core/src/bridge/packet-pipeline.ts @@ -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; 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); } } diff --git a/packages/core/src/onebot/instance.ts b/packages/core/src/onebot/instance.ts index 1e42cfab..1d2cefcc 100644 --- a/packages/core/src/onebot/instance.ts +++ b/packages/core/src/onebot/instance.ts @@ -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 + // ("[回复 : ...]" 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 = ( - 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 + : {}; + 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}`); } } diff --git a/packages/core/src/utils/event-format.ts b/packages/core/src/utils/event-format.ts new file mode 100644 index 00000000..ffa1aa28 --- /dev/null +++ b/packages/core/src/utils/event-format.ts @@ -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 + : {}; + parts.push(renderSegment(type, data)); + } + return parts.join(' ').trim() || '[空消息]'; + } catch { + return '[消息渲染异常]'; + } +} + +function renderSegment(type: string, data: Record): 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 "[回复:]" 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; + } +} diff --git a/packages/core/tests/event-format.test.ts b/packages/core/tests/event-format.test.ts new file mode 100644 index 00000000..fd4400e9 --- /dev/null +++ b/packages/core/tests/event-format.test.ts @@ -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; + members?: Record; + friends?: Record; +} = {}): 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>): 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 [回复:] 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 撤回'); + }); +});