import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; import { authedProcedure, router } from '../../trpc.js'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, sendMessage, type MessageDraft, type MessageRecordDraft, type MessageType, } from '@sammo-ts/logic'; import { buildNationTarget, buildTargetFromGeneral, resolveNationInfo } from '../../messages/targets.js'; import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox, fetchMessageById, invalidateMessages, insertMessage, type MessageView, } from '../../messages/store.js'; import { publishRealtimeEvent } from '../../realtime/publisher.js'; import { getOwnedGeneral } from '../shared/general.js'; import { resolveNationPermission } from '../nation/shared.js'; import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); const redactDiplomacyMessages = (messages: MessageView[], permission: number): MessageView[] => { if (permission >= 3) { return messages; } return messages.map((message) => { if (!message.dest || message.dest.nationId === 0) { return message; } return { ...message, text: '(외교 메시지입니다)', option: { ...(message.option ?? {}), invalid: true, }, }; }); }; const isFutureDate = (value: string | undefined, now = Date.now()): boolean => { if (!value) { return false; } const parsed = Date.parse(value); return Number.isFinite(parsed) && parsed > now; }; const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => { if ( isFutureDate(sanctions.mutedUntil) || isFutureDate(sanctions.suspendedUntil) || isFutureDate(sanctions.bannedUntil) ) { return true; } for (const profileName of profileNames) { const restriction = sanctions.serverRestrictions?.[profileName]; if (!restriction) { continue; } if (restriction.until && !isFutureDate(restriction.until)) { continue; } if (restriction.blockedFeatures?.includes('messages')) { return true; } } return false; }; const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => { const value = asRecord(penalty)[key]; if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) { return parsed; } } return fallback; }; const hasPenalty = (penalty: unknown, key: string): boolean => { const value = asRecord(penalty)[key]; return value === true || value === 1 || value === '1'; }; export const messagesRouter = router({ getRecent: authedProcedure .input( z.object({ generalId: z.number().int().positive(), sequence: z.number().int().optional(), }) ) .query(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); const sequence = input.sequence ?? -1; const nationId = general.nationId; const mailboxes = { private: general.id, public: MESSAGE_MAILBOX_PUBLIC, national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, } satisfies Record; const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] = await Promise.all([ fetchMessagesFromMailbox({ db: ctx.db, mailbox: mailboxes.private, msgType: 'private', limit: 15, fromSeq: sequence, }), fetchMessagesFromMailbox({ db: ctx.db, mailbox: mailboxes.public, msgType: 'public', limit: 15, fromSeq: sequence, }), fetchMessagesFromMailbox({ db: ctx.db, mailbox: mailboxes.national, msgType: 'national', limit: 15, fromSeq: sequence, }), fetchMessagesFromMailbox({ db: ctx.db, mailbox: mailboxes.diplomacy, msgType: 'diplomacy', limit: 15, fromSeq: sequence, }), ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }), nationId > 0 ? ctx.db.nation.findUnique({ where: { id: nationId }, select: { meta: true }, }) : null, ]); const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1; const messageBuckets: Record = { private: privateMessages, public: publicMessages, national: nationalMessages, diplomacy: redactDiplomacyMessages(diplomacyMessages, permission), }; let nextSequence = sequence; let minSequence = sequence; let lastType: MessageType | null = null; const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => { for (const message of messages) { if (message.id > nextSequence) { nextSequence = message.id; } if (message.id <= minSequence) { minSequence = message.id; lastType = type; } } }; updateSequence('private', privateMessages); updateSequence('public', publicMessages); updateSequence('national', nationalMessages); updateSequence('diplomacy', diplomacyMessages); if (lastType === 'private' && messageBuckets.private.length > 0) { messageBuckets.private.pop(); } else if (lastType === 'public' && messageBuckets.public.length > 0) { messageBuckets.public.pop(); } else if (lastType === 'national' && messageBuckets.national.length > 0) { messageBuckets.national.pop(); } else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) { messageBuckets.diplomacy.pop(); } return { result: true, ...messageBuckets, sequence: nextSequence, nationId: nationId, generalName: general.name, permission, canRespondDiplomacy: permission >= 4 && general.officerLevel > 4, latestRead: { diplomacy: readState?.latestDiplomacyMessage ?? 0, private: readState?.latestPrivateMessage ?? 0, }, }; }), getContacts: authedProcedure .input(z.object({ generalId: z.number().int().positive() })) .query(async ({ ctx, input }) => { await getOwnedGeneral(ctx, input.generalId); const [nations, generals] = await Promise.all([ ctx.db.nation.findMany({ select: { id: true, name: true, color: true, meta: true }, orderBy: { id: 'asc' }, }), ctx.db.general.findMany({ where: { npcState: { lt: 2 } }, select: { id: true, name: true, nationId: true, officerLevel: true, npcState: true, meta: true, penalty: true, }, orderBy: { id: 'asc' }, }), ]); const nationMeta = new Map(nations.map((nation) => [nation.id, nation.meta])); const grouped = new Map>(); for (const general of generals) { let flags = 0; if (general.officerLevel === 12) flags |= 1; if (general.npcState === 1) flags |= 2; if (resolveNationPermission(general, nationMeta.get(general.nationId) ?? {}, false) === 4) flags |= 4; const list = grouped.get(general.nationId) ?? []; list.push([general.id, general.name, flags]); grouped.set(general.nationId, list); } const nationList = [ { id: 0, name: '재야', color: '#000000', meta: {} }, ...nations.filter((nation) => nation.id !== 0), ]; return { nation: nationList.map((nation) => ({ nationId: nation.id, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id, name: nation.name, color: nation.color, general: grouped.get(nation.id) ?? [], })), }; }), readLatest: authedProcedure .input( z.object({ generalId: z.number().int().positive(), type: z.enum(['private', 'diplomacy']), messageId: z.number().int().positive(), }) ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); const privateValue = input.type === 'private' ? input.messageId : 0; const diplomacyValue = input.type === 'diplomacy' ? input.messageId : 0; await ctx.db.$executeRaw` INSERT INTO message_read_state ( general_id, latest_private_message, latest_diplomacy_message, updated_at ) VALUES (${general.id}, ${privateValue}, ${diplomacyValue}, NOW()) ON CONFLICT (general_id) DO UPDATE SET latest_private_message = GREATEST( message_read_state.latest_private_message, EXCLUDED.latest_private_message ), latest_diplomacy_message = GREATEST( message_read_state.latest_diplomacy_message, EXCLUDED.latest_diplomacy_message ), updated_at = NOW() `; return { ok: true }; }), delete: authedProcedure .input( z.object({ generalId: z.number().int().positive(), messageId: z.number().int().positive(), }) ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); const message = await fetchMessageById(ctx.db, input.messageId); if (!message) { throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' }); } if (message.payload.src.generalId !== general.id) { throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' }); } if (message.msgType === 'diplomacy' && message.payload.option?.action) { throw new TRPCError({ code: 'BAD_REQUEST', message: '시스템 외교 메시지는 삭제할 수 없습니다.', }); } if (message.payload.option?.deletable === false) { throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' }); } if (Date.now() - message.time.getTime() > 5 * 60 * 1000) { throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' }); } const receiverMessageId = message.payload.option?.receiverMessageID; const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national'; const ids = [ message.id, ...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []), ]; await invalidateMessages(ctx.db, ids); return { ok: true, deletedIds: ids }; }), respond: authedProcedure .input( z.object({ generalId: z.number().int().positive(), messageId: z.number().int().positive(), response: z.boolean(), }) ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); const result = await respondToDiplomaticMessage({ db: ctx.db, actor: general, messageId: input.messageId, response: input.response, }); if (result.result) { for (const mailbox of result.affectedMailboxes) { try { await publishRealtimeEvent(ctx.redis, ctx.profile.name, { type: 'messageCreated', at: new Date().toISOString(), mailbox, msgType: 'diplomacy', messageId: input.messageId, senderId: general.id, }); } catch { // 실시간 알림 실패는 외교 응답 실패로 취급하지 않는다. } } } return { result: result.result, reason: result.reason }; }), getOld: authedProcedure .input( z.object({ generalId: z.number().int().positive(), to: z.number().int().positive(), type: zMessageType, }) ) .query(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); const nationId = general.nationId; const nation = nationId > 0 ? await ctx.db.nation.findUnique({ where: { id: nationId }, select: { meta: true }, }) : null; const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1; const mailboxes = { private: general.id, public: MESSAGE_MAILBOX_PUBLIC, national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, } satisfies Record; const messageBuckets: Record = { private: [], public: [], national: [], diplomacy: [], }; const messages = await fetchOldMessagesFromMailbox({ db: ctx.db, mailbox: mailboxes[input.type], msgType: input.type, toSeq: input.to, limit: 15, }); messageBuckets[input.type] = input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages; return { result: true, keepRecent: true, sequence: 0, nationId, generalName: general.name, permission, ...messageBuckets, }; }), send: authedProcedure .input( z.object({ generalId: z.number().int().positive(), mailbox: z.number().int(), text: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) { throw new TRPCError({ code: 'FORBIDDEN', message: '메시지 전송이 제한된 계정입니다.', }); } const src = await buildTargetFromGeneral(ctx.db, general); const now = new Date(); const validUntil = new Date('9999-12-31T00:00:00Z'); let msgType: MessageType; let dest = src; let receiverMailbox = input.mailbox; if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) { if (hasPenalty(general.penalty, 'noSendPublicMsg')) { throw new TRPCError({ code: 'FORBIDDEN', message: '공개 메세지를 보낼 수 없습니다.', }); } msgType = 'public'; } else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) { const sourceNation = general.nationId > 0 ? await ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true }, }) : null; const permission = general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1; const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE; const nationInfo = await resolveNationInfo(ctx.db, destNationId); if (destNationId > 0) { const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } }); if (!destNation) { throw new TRPCError({ code: 'NOT_FOUND', message: '존재하지 않는 국가입니다.', }); } } dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color); msgType = destNationId === general.nationId ? 'national' : 'diplomacy'; receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId; } else if (input.mailbox > 0) { if (hasPenalty(general.penalty, 'noSendPrivateMsg')) { throw new TRPCError({ code: 'FORBIDDEN', message: '개인 메세지를 보낼 수 없습니다.', }); } const intervalSeconds = Math.max( 0, Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2)) ); if (intervalSeconds > 0) { const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`; const acquired = await ctx.redis.set(rateLimitKey, '1', { NX: true, PX: intervalSeconds * 1000, }); if (acquired === null) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`, }); } } const destGeneral = await ctx.db.general.findUnique({ where: { id: input.mailbox }, }); if (!destGeneral) { throw new TRPCError({ code: 'NOT_FOUND', message: '존재하지 않는 유저입니다.', }); } const [sourceNation, destNation] = await Promise.all([ general.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } }) : null, destGeneral.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } }) : null, ]); const sourcePermission = sourceNation && general.nationId > 0 ? resolveNationPermission(general, sourceNation.meta, false) : -1; const destPermission = destNation && destGeneral.nationId > 0 ? resolveNationPermission(destGeneral, destNation.meta, false) : -1; if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) { throw new TRPCError({ code: 'FORBIDDEN', message: '외교권자끼리는 메시지를 보낼 수 없습니다.', }); } dest = await buildTargetFromGeneral(ctx.db, destGeneral); msgType = 'private'; } else { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid mailbox.', }); } const draft: MessageDraft = { msgType, src, dest, text: input.text, time: now, validUntil, option: {}, }; const result = await sendMessage( { insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft), }, draft ); try { await publishRealtimeEvent(ctx.redis, ctx.profile.name, { type: 'messageCreated', at: now.toISOString(), mailbox: receiverMailbox, msgType, messageId: result.receiverId, senderId: general.id, }); } catch { // 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다. } return { msgType, msgId: result.receiverId }; }), });