feat: complete in-game message parity

This commit is contained in:
2026-07-26 05:31:25 +00:00
parent b02ab9fe99
commit 185057ea43
12 changed files with 2080 additions and 244 deletions
+2 -1
View File
@@ -23,13 +23,14 @@ export const resolveNationInfo = async (
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
const nation = await resolveNationInfo(db, general.nationId);
const picture = general.picture?.trim() || 'default.jpg';
return {
generalId: general.id,
generalName: general.name,
nationId: general.nationId,
nationName: nation.name,
color: nation.color,
icon: '',
icon: general.imageServer ? `d_pic/${picture}` : `/image/icons/${picture}`,
};
};
+178 -16
View File
@@ -1,5 +1,7 @@
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 {
@@ -26,6 +28,75 @@ 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(
@@ -85,11 +156,12 @@ export const messagesRouter = router({
: null,
]);
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: diplomacyMessages,
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
};
let nextSequence = sequence;
@@ -128,10 +200,8 @@ export const messagesRouter = router({
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
canRespondDiplomacy:
general.officerLevel > 4 &&
nation !== null &&
resolveNationPermission(general, nation.meta, false) >= 4,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
@@ -178,6 +248,7 @@ export const messagesRouter = router({
];
return {
nation: nationList.map((nation) => ({
nationId: nation.id,
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
name: nation.name,
color: nation.color,
@@ -234,14 +305,24 @@ export const messagesRouter = router({
if (message.payload.src.generalId !== general.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
}
if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) {
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 ids = [message.id, ...(typeof receiverMessageId === 'number' ? [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 };
}),
@@ -291,6 +372,14 @@ export const messagesRouter = router({
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,
@@ -312,7 +401,8 @@ export const messagesRouter = router({
toSeq: input.to,
limit: 15,
});
messageBuckets[input.type] = messages;
messageBuckets[input.type] =
input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages;
return {
result: true,
@@ -320,6 +410,7 @@ export const messagesRouter = router({
sequence: 0,
nationId,
generalName: general.name,
permission,
...messageBuckets,
};
}),
@@ -333,6 +424,12 @@ export const messagesRouter = router({
)
.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();
@@ -340,28 +437,93 @@ export const messagesRouter = router({
let msgType: MessageType;
let dest = src;
let receiverMailbox = input.mailbox;
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
msgType = 'public';
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
if (destNationId <= 0) {
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid nation mailbox.',
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: 'Destination general 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);
@@ -394,7 +556,7 @@ export const messagesRouter = router({
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
type: 'messageCreated',
at: now.toISOString(),
mailbox: input.mailbox,
mailbox: receiverMailbox,
msgType,
messageId: result.receiverId,
senderId: general.id,
+336 -3
View File
@@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const buildContext = (overrides: Record<string, unknown> = {}) => {
const buildContext = (overrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}) => {
const executeRaw = vi.fn(async () => 1);
const updateMany = vi.fn(async () => ({ count: 1 }));
const db = {
@@ -55,11 +55,15 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
$executeRaw: executeRaw,
...overrides,
};
const redis = {
set: vi.fn(async () => 'OK'),
publish: vi.fn(async () => 1),
};
const context = {
db,
auth,
profile: { id: 'che', scenario: 'default', name: 'che:default' },
redis: {},
redis,
turnDaemon: {},
battleSim: {},
uploadDir: 'uploads',
@@ -68,8 +72,9 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
accessTokenStore: {},
flushStore: {},
gameTokenSecret: 'test-secret',
...contextOverrides,
} as unknown as GameApiContext;
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany };
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis };
};
describe('messages router missing-flow compatibility', () => {
@@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => {
expect(result.canRespondDiplomacy).toBe(true);
});
it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => ambassador),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
const result = await caller.messages.getRecent({ generalId: ambassador.id });
expect(result.permission).toBe(4);
expect(result.canRespondDiplomacy).toBe(false);
});
it('redacts recent and old diplomacy content below secret permission 3', async () => {
const diplomacyRow = {
id: 19,
mailbox: 9001,
type: 'diplomacy',
src: 9002,
dest: 9001,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: 8,
generalName: '외교관',
nationId: 2,
nationName: '촉',
color: '#000000',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 1,
nationName: '위',
color: '#ffffff',
icon: '',
},
text: '보이면 안 되는 외교 본문',
option: { action: 'noAggression' },
},
};
const queryRaw = vi.fn(async () => [diplomacyRow]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ meta: {} })),
},
});
const recent = await caller.messages.getRecent({ generalId: general.id });
const old = await caller.messages.getOld({
generalId: general.id,
type: 'diplomacy',
to: 20,
});
expect(recent.permission).toBe(2);
expect(recent.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
});
expect(old.diplomacy[0]).toMatchObject({
text: '(외교 메시지입니다)',
option: { action: 'noAggression', invalid: true },
});
});
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
const queryRaw = vi.fn(async () => [{ id: 51 }]);
const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
}));
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: findNation,
},
});
const result = await caller.messages.send({
generalId: general.id,
mailbox: 9002,
text: '국가 메시지',
});
expect(result.msgType).toBe('national');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
});
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const queryRaw = vi.fn(async () => [{ id: 52 }]);
const { caller } = buildContext({
$queryRaw: queryRaw,
general: {
findUnique: vi.fn(async () => ambassador),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
})),
},
});
const result = await caller.messages.send({
generalId: ambassador.id,
mailbox: 9002,
text: '외교 메시지',
});
expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
});
it('blocks private messages between foreign ambassadors', async () => {
const ambassador = {
...general,
officerLevel: 1,
meta: { permission: 'ambassador' },
} as GeneralRow;
const foreignAmbassador = {
...ambassador,
id: 8,
userId: 'user-8',
name: '상대 외교관',
nationId: 2,
} as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
where.id === ambassador.id ? ambassador : foreignAmbassador
),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
id: where.id,
name: where.id === 1 ? '위' : '촉',
color: '#112233',
meta: {},
})),
},
});
await expect(
caller.messages.send({
generalId: ambassador.id,
mailbox: foreignAmbassador.id,
text: '개인 메시지',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
});
});
it.each([
['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'],
['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'],
])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => {
const penalized = { ...general, penalty } as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => penalized),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
},
});
await expect(
caller.messages.send({
generalId: penalized.id,
mailbox,
text: '차단 메시지',
})
).rejects.toMatchObject({ code: 'FORBIDDEN', message });
});
it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => {
const redis = {
set: vi.fn(async () => null),
publish: vi.fn(async () => 1),
};
const { caller } = buildContext(
{
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
},
},
{ redis }
);
await expect(
caller.messages.send({
generalId: general.id,
mailbox: 8,
text: '너무 빠른 메시지',
})
).rejects.toMatchObject({
code: 'TOO_MANY_REQUESTS',
message: '개인메세지는 2초당 1건만 보낼 수 있습니다!',
});
});
it('blocks sends for a muted authenticated user independently of general permission', async () => {
const mutedAuth = {
...auth,
sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' },
};
const { caller } = buildContext({}, { auth: mutedAuth });
await expect(
caller.messages.send({
generalId: general.id,
mailbox: 9999,
text: '사용자 mute',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '메시지 전송이 제한된 계정입니다.',
});
});
it('rejects every remaining general-scoped message mutation for another user general', async () => {
const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow;
const { caller } = buildContext({
general: {
findUnique: vi.fn(async () => foreignGeneral),
findMany: vi.fn(async () => []),
},
});
await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.messages.readLatest({
generalId: foreignGeneral.id,
type: 'private',
messageId: 1,
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.messages.respond({
generalId: foreignGeneral.id,
messageId: 1,
response: true,
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('persists latest-read updates through the monotonic upsert', async () => {
const { caller, executeRaw } = buildContext();
@@ -154,6 +444,49 @@ describe('messages router missing-flow compatibility', () => {
});
});
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
const queryRaw = vi.fn(async () => [
{
id: 25,
mailbox: 9001,
type: 'diplomacy',
src: 9001,
dest: 9002,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: general.id,
generalName: general.name,
nationId: 1,
nationName: '위',
color: '#fff',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 2,
nationName: '촉',
color: '#000',
icon: '',
},
text: '일반 외교 메시지',
option: { receiverMessageID: 26 },
},
},
]);
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
expect(result.deletedIds).toEqual([25]);
expect(updateMany).toHaveBeenCalledWith({
where: { id: { in: [25] } },
data: { validUntil: expect.any(Date) },
});
});
it('rejects deleting another general message', async () => {
const queryRaw = vi.fn(async () => [
{