Skip to content
Clone
main
205 lines 7.5 KB
/**
* 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<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : 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
}