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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 撤回');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user