/** * KenEvent 信封 → 前端 `ServerFrame` 的映射。 * * 应用层(transcript reducer、typing registry、通知面板)消费的是 `RoomEvent` / * `UserEvent` 联合类型,而 KenEvent 只发 RocketMQ 的 `{topic, tag, properties, * body}`。这一层是唯一的翻译点:新增事件时只改这里,页面代码不动。 * * 约定(来自 `docs/events.json` 与 KenApi 的发布点): * - `body` 对 chat 消息类事件就是 `MessageDto` 本体,`seq` 也在里面 * (所以 `message_published` 的游标可直接用 `body.seq`)。 * - `properties` 里的资源 id 全是字符串(RocketMQ 用户属性)。 * - 详情永远以 REST 为准,这里只负责"告诉页面什么变了"。 */ import type { MessageDto } from '@app/services/api' import { CHAT_TOPIC, type KenEventEnvelope, NOTIFY_TOPIC, prop } from './kenEvent' import type { RoomEvent, ServerFrame, UserEvent, WsNotification, WsReactionSummary } from './protocol' function record(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null } function str(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } function num(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } function messageOf(envelope: KenEventEnvelope): MessageDto | null { const body = record(envelope.body) if (!body) return null return typeof body.id === 'string' && typeof body.room_id === 'string' ? (body as unknown as MessageDto) : null } function reactionsOf(envelope: KenEventEnvelope): WsReactionSummary[] { const body = record(envelope.body) const raw = body?.reactions if (!Array.isArray(raw)) return [] return raw.flatMap((entry) => { const item = record(entry) const emoji = str(item?.emoji) const count = num(item?.count) return emoji && count !== undefined ? [{ emoji, count }] : [] }) } function deletedIds(envelope: KenEventEnvelope): string[] { const body = record(envelope.body) const candidates = [body?.message_ids, body?.ids, body?.messages] for (const candidate of candidates) { if (Array.isArray(candidate)) { const ids = candidate.map((item) => str(item) ?? str(record(item)?.id)).filter(Boolean) if (ids.length > 0) return ids as string[] } } const single = prop(envelope, 'message_id') ?? str(body?.id) return single ? [single] : [] } function roomEvent(envelope: KenEventEnvelope): RoomEvent | null { const roomId = prop(envelope, 'room_id') const message = messageOf(envelope) const messageId = prop(envelope, 'message_id') ?? str(record(envelope.body)?.id) const userId = envelope.user_id != null ? String(envelope.user_id) : prop(envelope, 'user_id') const seq = num(record(envelope.body)?.seq) ?? 0 switch (envelope.tag) { case 'message-create': case 'message-update': { if (!roomId || !message) return null return { kind: envelope.tag === 'message-create' ? 'message_published' : 'message_edited', room_id: roomId, seq, message, } } case 'message-delete': case 'messages-delete': { if (!roomId) return null const ids = deletedIds(envelope) if (ids.length === 0) return null return envelope.tag === 'messages-delete' && ids.length > 1 ? { kind: 'messages_deleted', room_id: roomId, seq, message_ids: ids } : { kind: 'message_deleted', room_id: roomId, seq, message_id: ids[0] } } case 'pin-create': case 'pin-delete': { if (!roomId || !messageId) return null return { kind: 'message_pinned', room_id: roomId, seq, message_id: messageId, pinned: envelope.tag === 'pin-create', } } case 'reaction-update': { if (!roomId || !messageId) return null return { kind: 'reaction_updated', room_id: roomId, message_id: messageId, reactions: reactionsOf(envelope), } } case 'room-update': return roomId ? { kind: 'room_updated', room_id: roomId } : null case 'member-create': return roomId && userId ? { kind: 'member_joined', room_id: roomId, user_id: userId } : null case 'member-delete': return roomId && userId ? { kind: 'member_left', room_id: roomId, user_id: userId } : null case 'thread-update': { const threadId = prop(envelope, 'thread_id') return roomId && threadId ? { kind: 'thread_updated', room_id: roomId, thread_id: threadId } : null } case 'voice-presence': return roomId ? { kind: 'voice_presence', room_id: roomId } : null case 'voice-hand': return roomId ? { kind: 'voice_hands', room_id: roomId } : null case 'voice-layout': return roomId ? { kind: 'voice_layout', room_id: roomId } : null case 'voice-moderate': return roomId && userId ? { kind: 'voice_moderation', room_id: roomId, user_id: userId, action: str(record(envelope.body)?.action) ?? 'update', } : null default: return null } } function userEvent(envelope: KenEventEnvelope): UserEvent | null { const roomId = prop(envelope, 'room_id') const messageId = prop(envelope, 'message_id') ?? str(record(envelope.body)?.id) if (envelope.topic === NOTIFY_TOPIC && envelope.tag === 'notification-create') { const body = record(envelope.body) if (!body || typeof body.id !== 'string') return null return { kind: 'notification', notification: body as unknown as WsNotification } } if (envelope.topic !== CHAT_TOPIC) return null switch (envelope.tag) { case 'mention-create': return roomId && messageId ? { kind: 'mention', room_id: roomId, message_id: messageId, everyone: record(envelope.body)?.everyone === true ? true : undefined, } : null case 'reminder-create': return roomId && messageId ? { kind: 'reminder', room_id: roomId, message_id: messageId } : null case 'membership-create': return roomId ? { kind: 'room_added', room_id: roomId } : null case 'read_state-update': { const body = record(envelope.body) const lastRead = str(body?.last_read_message_id) const lastSeq = num(body?.last_read_seq) ?? 0 return roomId ? { kind: 'read_marker', room_id: roomId, last_read_message_id: lastRead, last_read_seq: lastSeq, } : null } case 'presence-update': { const status = str(record(envelope.body)?.status) return status ? { kind: 'presence', status: status as never } : null } default: return null } } /** 单个信封 → 页面帧;无法映射(未消费的事件)返回 null,由调用方静默忽略。 */ export function toServerFrame(envelope: KenEventEnvelope): ServerFrame | null { const event = userEvent(envelope) if (event) return { type: 'user_event', event } const room = roomEvent(envelope) if (!room) return null if ('room_id' in room) return { type: 'event', room_id: room.room_id, event: room } return null } /** 从消息类帧里取房间游标,供 `lastKnownSeq` 使用。 */ export function frameSeq(frame: ServerFrame): { roomId: string; seq: number } | null { if (frame.type !== 'event' || !frame.event) return null if ('seq' in frame.event && typeof frame.event.seq === 'number') { return { roomId: frame.room_id, seq: frame.event.seq } } return null }