diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts index 2f453f3f..5ded05c7 100644 --- a/src/chat/chat.controller.ts +++ b/src/chat/chat.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + ForbiddenException, Get, Param, Put, @@ -14,6 +15,7 @@ import { ChatService } from "./chat.service"; import { lobbies_set_input } from "generated/schema"; import { HasuraEventData } from "src/hasura/types/HasuraEventData"; import { ChatLobbyType } from "./enums/ChatLobbyTypes"; +import { MatchChatLog } from "./types/MatchChatLog"; @Controller("chat") export class ChatController { @@ -78,6 +80,21 @@ export class ChatController { }; } + @Get("matches/:matchId/log") + @UseGuards(SteamGuard) + public async matchChatLog( + @Req() request: Request, + @Param("matchId") matchId: string, + ): Promise { + const log = await this.chatService.matchChatLog(matchId, request.user); + + if (!log) { + throw new ForbiddenException(); + } + + return log; + } + @HasuraEvent() public async chat_lobbies_removed(data: HasuraEventData) { await this.chatService.removeLobby(ChatLobbyType.MatchMaking, data.old.id); diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 920d7408..1d1524da 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -1914,7 +1914,9 @@ describe("ChatService direct messages", () => { it("keeps the audit row when the swap fails outright, since it may have applied", async () => { store(); - redis.eval.mockRejectedValueOnce(new Error("connection reset")); + redis.eval + .mockResolvedValueOnce(1) + .mockRejectedValueOnce(new Error("connection reset")); await expect(edit()).rejects.toThrow("connection reset"); @@ -2796,7 +2798,7 @@ describe("ChatService direct messages", () => { // test/chat-redis-actions.spec.ts; this is what the service does around it. it("hands the move all four keys, then re-sends history with the reactions it carried", async () => { hashes[REACTIONS] = { [MESSAGE_ID]: JSON.stringify({ fire: [ME] }) }; - redis.eval.mockResolvedValueOnce(1); + redis.eval.mockResolvedValueOnce([MESSAGE_ID, hashes[ROOM][MESSAGE_ID]]); await service.migrateLobbyMessages( ChatLobbyType.Draft, @@ -3414,7 +3416,9 @@ describe("ChatService direct messages", () => { say(FRIEND, "from friend"); say(STRANGER, "from stranger"); blocks = [[ME, FRIEND]]; - redis.eval.mockResolvedValueOnce(2); + redis.eval.mockResolvedValueOnce( + Object.entries(hashes["chat_match_m-1"]).flat(), + ); await service.migrateLobbyMessages( ChatLobbyType.Draft, @@ -3448,7 +3452,9 @@ describe("ChatService direct messages", () => { it("logs a re-send whose block lookup fails instead of leaving it unhandled", async () => { say(FRIEND, "from friend"); - redis.eval.mockResolvedValueOnce(1); + redis.eval.mockResolvedValueOnce( + Object.entries(hashes["chat_match_m-1"]).flat(), + ); playerBlocks.blockedAmong.mockRejectedValueOnce( new Error("pool timeout"), ); diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index a5fd4525..0c4c7ba2 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -8,6 +8,7 @@ import { RconService } from "../rcon/rcon.service"; import { FiveStackWebSocketClient } from "src/sockets/types/FiveStackWebSocketClient"; import { ChatLobbyType } from "./enums/ChatLobbyTypes"; import { + e_match_status_enum, e_notification_types_enum, e_player_roles_enum, e_tournament_free_agent_statuses_enum, @@ -27,6 +28,8 @@ import { ChatDeleteResult } from "./types/ChatDeleteResult"; import { ChatEditResult } from "./types/ChatEditResult"; import { ChatReactions } from "./types/ChatReactions"; import { ChatReactResult } from "./types/ChatReactResult"; +import { MatchChatArchiveEntry } from "./types/MatchChatArchiveEntry"; +import { MatchChatLog } from "./types/MatchChatLog"; @Injectable() export class ChatService { @@ -115,7 +118,7 @@ export class ChatService { end end redis.call('DEL', KEYS[1], KEYS[3]) - return #messages / 2 + return messages `; // The web maps each id to its glyph; laugh is πŸ˜‚. @@ -255,6 +258,154 @@ export class ChatService { private static readonly TOURNAMENT_CHAT_FREE_AGENT_STATUSES: e_tournament_free_agent_statuses_enum[] = ["registered", "waitlisted"]; + // Every match and team room line is copied here for staff to review after + // the live rooms, which keep their own short lifetime, have expired. It is + // no lobby type, so nothing can join it; the chat log is the only way in. + // Bookkeeping lives in the same hash under fields starting with "~", which a + // message id (a uuid) never does. + public static readonly MATCH_CHAT_ARCHIVE_TTL = 60 * 60 * 24 * 7; + + public static MATCH_CHAT_ARCHIVE_MAX_ENTRIES = 5000; + + public static MATCH_CHAT_ARCHIVE_MAX_BYTES = 8 * 1024 * 1024; + + public static readonly MATCH_CHAT_ARCHIVE_MAX_ENTRY_BYTES = 16 * 1024; + + // Besides the original text, which is always kept. + private static readonly MATCH_CHAT_ARCHIVE_KEPT_EDITS = 4; + + // Once the match has ended (~ended), a write no longer moves the expiry: the + // archive goes a week after the end however long people keep talking. + private static readonly ARCHIVE_APPEND_SCRIPT = ` + if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then + return 1 + end + local count = tonumber(redis.call('HGET', KEYS[1], '~count') or '0') + local bytes = tonumber(redis.call('HGET', KEYS[1], '~bytes') or '0') + local stored = 0 + if count >= tonumber(ARGV[3]) or bytes + #ARGV[2] > tonumber(ARGV[4]) then + redis.call('HSET', KEYS[1], '~truncated', '1') + else + redis.call('HSET', KEYS[1], ARGV[1], ARGV[2], + '~count', count + 1, '~bytes', bytes + #ARGV[2]) + stored = 1 + end + if redis.call('HEXISTS', KEYS[1], '~ended') == 0 then + redis.call('EXPIRE', KEYS[1], ARGV[5]) + end + return stored + `; + + // One step, so an edit and a moderator's delete landing together both + // survive. An edit no newer than the entry's own is a resend or arrived out + // of order, and is dropped. The original text stays first; the oldest of the + // rest go when there are too many or the entry outgrows its byte cap, and if + // the original and the latest alone are still too big, the original is cut. + // Past the archive's own cap only the original is kept besides the edit. + private static readonly ARCHIVE_EDIT_SCRIPT = ` + local raw = redis.call('HGET', KEYS[1], ARGV[1]) + if not raw then + return 0 + end + local entry = cjson.decode(raw) + if type(entry.edited_at) == 'string' and entry.edited_at >= ARGV[3] then + return 0 + end + local marker = ' [truncated]' + local function cut(text, keep) + if text:sub(-#marker) == marker then + text = text:sub(1, -#marker - 1) + end + while keep > 0 do + local byte = text:byte(keep + 1) + if byte == nil or byte < 128 or byte >= 192 then + break + end + keep = keep - 1 + end + return text:sub(1, keep) .. marker + end + local edits = entry.edits + if type(edits) ~= 'table' then + edits = {} + end + local writtenAt = entry.edited_at + if type(writtenAt) ~= 'string' then + writtenAt = entry.timestamp + end + table.insert(edits, { message = entry.message, written_at = writtenAt }) + while #edits > tonumber(ARGV[4]) + 1 do + table.remove(edits, 2) + end + entry.edits = edits + entry.message = ARGV[2] + entry.edited_at = ARGV[3] + local encoded = cjson.encode(entry) + while #encoded > tonumber(ARGV[5]) and #entry.edits > 1 do + table.remove(entry.edits, 2) + encoded = cjson.encode(entry) + end + local tries = 0 + while #encoded > tonumber(ARGV[5]) and tries < 32 do + tries = tries + 1 + local original = entry.edits[1] + if #original.message > 64 then + original.message = cut(original.message, math.floor(#original.message / 2)) + else + entry.message = cut(entry.message, math.floor(#entry.message / 2)) + end + encoded = cjson.encode(entry) + end + local bytes = tonumber(redis.call('HGET', KEYS[1], '~bytes') or '0') + if bytes + #encoded - #raw > tonumber(ARGV[6]) then + redis.call('HSET', KEYS[1], '~truncated', '1') + entry.edits = { entry.edits[1] } + entry.history_truncated = true + encoded = cjson.encode(entry) + if bytes + #encoded - #raw > tonumber(ARGV[6]) then + return 0 + end + end + redis.call('HSET', KEYS[1], ARGV[1], encoded, + '~bytes', bytes + #encoded - #raw) + return 1 + `; + + private static readonly ARCHIVE_DELETE_SCRIPT = ` + local raw = redis.call('HGET', KEYS[1], ARGV[1]) + if not raw then + return 0 + end + local entry = cjson.decode(raw) + entry.deleted_at = ARGV[2] + entry.deleted_by = { steam_id = ARGV[3], name = ARGV[4] } + local encoded = cjson.encode(entry) + local bytes = tonumber(redis.call('HGET', KEYS[1], '~bytes') or '0') + redis.call('HSET', KEYS[1], ARGV[1], encoded, + '~bytes', bytes + #encoded - #raw) + return 1 + `; + + private static readonly ARCHIVE_ANCHOR_SCRIPT = ` + redis.call('HSET', KEYS[1], '~ended', '1') + redis.call('EXPIRE', KEYS[1], ARGV[1]) + return 1 + `; + + private static readonly ARCHIVE_REOPEN_SCRIPT = ` + redis.call('HDEL', KEYS[1], '~ended') + redis.call('EXPIRE', KEYS[1], ARGV[1]) + return 1 + `; + + private static readonly MATCH_CHAT_LOG_STATUSES: e_match_status_enum[] = [ + "Finished", + "Tie", + "Canceled", + "Forfeit", + "Surrendered", + ]; + // Which setting governs which room's lifetime, and what it falls back to. // Read from system/ on boot and whenever a setting changes, so there is one // list to keep in step rather than a branch per type in three places. @@ -896,7 +1047,17 @@ export class ChatService { return { error: ChatErrorCode.TooLong }; } - return { text }; + return { text: ChatService.wellFormed(text) }; + } + + // A lone UTF-16 surrogate survives JSON.stringify as an escape that Redis's + // cjson refuses to decode, so a line carrying one could never be edited or + // deleted in the archive. + private static wellFormed(text: string): string { + return text.replace( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? [message]); + if (type === ChatLobbyType.Direct) { void this.deliverDirectMessage(id, player, outgoing); } @@ -1114,6 +1277,16 @@ export class ChatService { void this.to(type, id, "deleted", { id: messageId }); + await this.updateArchivedMessage( + type, + id, + messageId, + ChatService.ARCHIVE_DELETE_SCRIPT, + new Date().toISOString(), + String(current.steam_id), + ChatService.wellFormed(current.name ?? ""), + ); + await this.retractNotifications(type, id, messageId); return { deleted: true }; @@ -1185,6 +1358,16 @@ export class ChatService { return { edited: false, code: ChatErrorCode.NotFound }; } + if ( + !(await this.withinRate( + `chat:edit-rate:${user.steam_id}`, + ChatService.MESSAGE_RATE_LIMIT, + ChatService.MESSAGE_RATE_WINDOW_MS, + )) + ) { + return { edited: false, code: ChatErrorCode.RateLimited }; + } + if (type === ChatLobbyType.Direct) { return await this.editDirectMessage(id, messageId, user, parsed.text); } @@ -1459,7 +1642,7 @@ export class ChatService { ); if (swapped === 1) { - return await this.announceEdit( + const announced = await this.announceEdit( type, id, messageId, @@ -1467,6 +1650,10 @@ export class ChatService { text, editedAt, ); + + await this.archiveEdit(type, id, messageId, text, editedAt); + + return announced; } await this.discardEdit(auditId); @@ -2959,6 +3146,265 @@ export class ChatService { await this.redis.hdel(lobbyKey, steamId); } + // Staff at match organizer and above, by the role held now, once the match + // has ended. Someone who took part (on a lineup or coaching now, or seen + // writing in a team room) gets all chat only. Blocks are not applied: + // reviewing a match means seeing every line of it. + public async matchChatLog( + matchId: string, + user: User | undefined, + ): Promise { + if (!user?.steam_id || !ChatService.UUID.test(matchId)) { + return null; + } + + const current = await this.getCurrentUser(user.steam_id); + + if (!current || !isRoleAbove(current.role, "match_organizer")) { + return null; + } + + const viewer = String(current.steam_id); + + const [match] = await this.postgres.query< + Array<{ + status: e_match_status_enum; + lineup_1_id: string | null; + lineup_2_id: string | null; + on_lineup: boolean; + }> + >( + `SELECT m.status, m.lineup_1_id::text, m.lineup_2_id::text, + EXISTS ( + SELECT 1 FROM public.match_lineup_players mlp + WHERE mlp.match_lineup_id IN (m.lineup_1_id, m.lineup_2_id) + AND mlp.steam_id = $2::bigint + ) OR EXISTS ( + SELECT 1 FROM public.match_lineups ml + WHERE ml.id IN (m.lineup_1_id, m.lineup_2_id) + AND ml.coach_steam_id = $2::bigint + ) AS on_lineup + FROM public.matches m + WHERE m.id = $1::uuid`, + [matchId, viewer], + ); + + if (!match || !ChatService.MATCH_CHAT_LOG_STATUSES.includes(match.status)) { + return null; + } + + const key = ChatService.archiveKey(matchId); + + const [stored, expiresAt] = await Promise.all([ + this.redis.hgetall(key), + this.redis.call("PEXPIRETIME", key) as Promise, + ]); + + const entries = Object.entries(stored) + .filter(([field]) => !field.startsWith("~")) + .map(([, raw]) => JSON.parse(raw) as MatchChatArchiveEntry) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + const withheld = + match.on_lineup || + entries.some( + (entry) => entry.room !== "match" && entry.from?.steam_id === viewer, + ); + + return { + match: entries.filter((entry) => entry.room === "match"), + teams: withheld + ? [] + : [match.lineup_1_id, match.lineup_2_id] + .filter((lineupId): lineupId is string => !!lineupId) + .map((lineupId) => ({ + lineup_id: lineupId, + messages: entries.filter((entry) => entry.room === lineupId), + })), + team_chat_withheld: withheld, + archive_truncated: stored["~truncated"] === "1", + expires_at: expiresAt > 0 ? new Date(expiresAt).toISOString() : null, + }; + } + + public async anchorMatchArchive(matchId: string) { + await this.archiveSafely(matchId, () => + this.redis.eval( + ChatService.ARCHIVE_ANCHOR_SCRIPT, + 1, + ChatService.archiveKey(matchId), + ChatService.MATCH_CHAT_ARCHIVE_TTL, + ), + ); + } + + public async reopenMatchArchive(matchId: string) { + await this.archiveSafely(matchId, () => + this.redis.eval( + ChatService.ARCHIVE_REOPEN_SCRIPT, + 1, + ChatService.archiveKey(matchId), + ChatService.MATCH_CHAT_ARCHIVE_TTL, + ), + ); + } + + public async removeMatchArchive(matchId: string) { + await this.archiveSafely(matchId, () => + this.redis.del(ChatService.archiveKey(matchId)), + ); + } + + // The archive is for review afterwards; nothing about it may stop a line + // being delivered, edited, deleted or moved. + private async archiveSafely(matchId: string, work: () => Promise) { + try { + await work(); + } catch (error) { + this.logger.warn( + `unable to update the chat archive of ${matchId}`, + error, + ); + } + } + + private static archiveKey(matchId: string) { + return `chat:archive:${matchId}`; + } + + private static archiveRoom( + type: ChatLobbyType, + id: string, + ): { matchId: string; room: string } | null { + if (type === ChatLobbyType.Match) { + return { matchId: id, room: "match" }; + } + + if (type === ChatLobbyType.MatchTeam) { + const [matchId, lineupId] = id.split(":"); + + if (matchId && lineupId) { + return { matchId, room: lineupId }; + } + } + + return null; + } + + private async archiveMessages( + type: ChatLobbyType, + id: string, + load: () => ChatMessage[], + ) { + const target = ChatService.archiveRoom(type, id); + + if (!target) { + return; + } + + const key = ChatService.archiveKey(target.matchId); + + await this.archiveSafely(target.matchId, async () => { + for (const message of load()) { + await this.redis.eval( + ChatService.ARCHIVE_APPEND_SCRIPT, + 1, + key, + message.id, + ChatService.archiveEntryJson({ + id: message.id, + room: target.room, + message: ChatService.wellFormed(message.message), + timestamp: message.timestamp, + source: message.source, + from: { + steam_id: String(message.from.steam_id), + name: ChatService.wellFormed(message.from.name ?? ""), + }, + ...(message.edited_at ? { edited_at: message.edited_at } : {}), + }), + ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRIES, + ChatService.MATCH_CHAT_ARCHIVE_MAX_BYTES, + ChatService.MATCH_CHAT_ARCHIVE_TTL, + ); + } + }); + } + + // Halved by whole code points, never through a surrogate pair, until the + // entry fits its byte cap. + private static archiveEntryJson(entry: MatchChatArchiveEntry): string { + const marker = " [truncated]"; + let json = JSON.stringify(entry); + + while ( + Buffer.byteLength(json) > + ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRY_BYTES && + entry.message.length > 0 + ) { + const points = Array.from( + entry.message.endsWith(marker) + ? entry.message.slice(0, -marker.length) + : entry.message, + ); + entry = { + ...entry, + message: + points.slice(0, Math.floor(points.length / 2)).join("") + marker, + }; + json = JSON.stringify(entry); + } + + return json; + } + + private async archiveEdit( + type: ChatLobbyType, + id: string, + messageId: string, + text: string, + editedAt: string, + ) { + await this.updateArchivedMessage( + type, + id, + messageId, + ChatService.ARCHIVE_EDIT_SCRIPT, + ChatService.wellFormed(text), + editedAt, + ChatService.MATCH_CHAT_ARCHIVE_KEPT_EDITS, + ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRY_BYTES, + ChatService.MATCH_CHAT_ARCHIVE_MAX_BYTES, + ); + } + + private async updateArchivedMessage( + type: ChatLobbyType, + id: string, + messageId: string, + script: string, + ...args: Array + ) { + const target = ChatService.archiveRoom(type, id); + + if (!target) { + return; + } + + await this.archiveSafely(target.matchId, () => + this.redis.eval( + script, + 1, + ChatService.archiveKey(target.matchId), + messageId, + ...args, + ), + ); + } + private async getAllUsersInLobby(type: ChatLobbyType, id: string) { const lobbyKey = this.getLobbyKey(type, id); const users = await this.redis.hgetall(lobbyKey); @@ -2980,7 +3426,7 @@ export class ChatService { toType: ChatLobbyType, toId: string, ) { - const moved = await this.redis.eval( + const moved = (await this.redis.eval( ChatService.MOVE_ROOM_MESSAGES_SCRIPT, 4, `chat_${fromType}_${fromId}`, @@ -2988,11 +3434,11 @@ export class ChatService { ChatService.reactionsKey(fromType, fromId), ChatService.reactionsKey(toType, toId), this.ttlFor(toType), - ); + )) as string[]; await this.removeLobby(fromType, fromId); - if (moved === 0) { + if (!Array.isArray(moved) || moved.length === 0) { return; } @@ -3001,5 +3447,13 @@ export class ChatService { void this.resendHistory(toType, toId, messages).catch((error) => { this.logger.warn(`unable to re-send history to ${toType}:${toId}`, error); }); + + // Only what this move carried: the room also holds lines the archive + // already has, with their edits. + await this.archiveMessages(toType, toId, () => + moved + .filter((_, index) => index % 2 === 1) + .map((raw) => JSON.parse(raw) as ChatMessage), + ); } } diff --git a/src/chat/match-chat-archive.spec.ts b/src/chat/match-chat-archive.spec.ts new file mode 100644 index 00000000..f406c7ca --- /dev/null +++ b/src/chat/match-chat-archive.spec.ts @@ -0,0 +1,45 @@ +import { ForbiddenException } from "@nestjs/common"; +import { GUARDS_METADATA } from "@nestjs/common/constants"; +import { ChatController } from "./chat.controller"; +import { SteamGuard } from "../auth/strategies/SteamGuard"; + +const MATCH = "11111111-1111-4111-8111-111111111111"; + +describe("match chat log endpoint", () => { + const user = { steam_id: "76561198000000001", role: "administrator" }; + + it("needs a signed-in player", () => { + expect( + Reflect.getMetadata( + GUARDS_METADATA, + ChatController.prototype.matchChatLog, + ), + ).toContain(SteamGuard); + }); + + it("answers a refusal with a 403", async () => { + const chatService = { matchChatLog: jest.fn().mockResolvedValue(null) }; + const controller = new ChatController(chatService as any); + + await expect( + controller.matchChatLog({ user } as any, MATCH), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(chatService.matchChatLog).toHaveBeenCalledWith(MATCH, user); + }); + + it("hands back the log", async () => { + const read = { + match: [], + teams: [], + team_chat_withheld: true, + expires_at: null, + }; + const controller = new ChatController({ + matchChatLog: jest.fn().mockResolvedValue(read), + } as any); + + await expect(controller.matchChatLog({ user } as any, MATCH)).resolves.toBe( + read, + ); + }); +}); diff --git a/src/chat/types/MatchChatArchiveEntry.ts b/src/chat/types/MatchChatArchiveEntry.ts new file mode 100644 index 00000000..fe500a6a --- /dev/null +++ b/src/chat/types/MatchChatArchiveEntry.ts @@ -0,0 +1,24 @@ +import { ChatMessageSource } from "./ChatMessage"; + +export interface MatchChatArchiveEntry { + id: string; + // "match" for all chat, otherwise the lineup whose team room it was said in. + room: string; + message: string; + timestamp: string; + source?: ChatMessageSource; + from: { + steam_id: string; + name: string; + }; + edited_at?: string; + // Each earlier version, the original first, with when it was written. + edits?: Array<{ message: string; written_at: string }>; + // Edits past the original were dropped to keep the archive under its cap. + history_truncated?: boolean; + deleted_at?: string; + deleted_by?: { + steam_id: string; + name: string; + }; +} diff --git a/src/chat/types/MatchChatLog.ts b/src/chat/types/MatchChatLog.ts new file mode 100644 index 00000000..13ccf409 --- /dev/null +++ b/src/chat/types/MatchChatLog.ts @@ -0,0 +1,10 @@ +import { MatchChatArchiveEntry } from "./MatchChatArchiveEntry"; + +export interface MatchChatLog { + match: MatchChatArchiveEntry[]; + teams: Array<{ lineup_id: string; messages: MatchChatArchiveEntry[] }>; + team_chat_withheld: boolean; + // Hit its per-match cap, so later lines were not kept. + archive_truncated: boolean; + expires_at: string | null; +} diff --git a/src/matches/matches.controller.match-events.spec.ts b/src/matches/matches.controller.match-events.spec.ts index b2e9a133..5e73e952 100644 --- a/src/matches/matches.controller.match-events.spec.ts +++ b/src/matches/matches.controller.match-events.spec.ts @@ -26,6 +26,8 @@ describe("MatchesController β€” match_events on-demand servers", () => { } | null >; let currentMatch: Record; + let chat: Record; + let tournamentVoice: Record; const stopJobs = () => scheduledMatchesQueue.add.mock.calls.filter( @@ -131,6 +133,16 @@ describe("MatchesController β€” match_events on-demand servers", () => { playoutSeconds: jest.fn(async () => 0), }; inPlayMaps = []; + tournamentVoice = { + createMatchVoiceChannels: jest.fn(), + movePlayersToMatchChannels: jest.fn(), + }; + chat = { + removeLobby: jest.fn(), + removeMatchArchive: jest.fn(), + anchorMatchArchive: jest.fn(), + reopenMatchArchive: jest.fn(), + }; controller = new MatchesController( { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, @@ -148,7 +160,7 @@ describe("MatchesController β€” match_events on-demand servers", () => { async (): Promise => undefined, ), } as any, - { removeLobby: jest.fn() } as any, + chat as any, { add: jest.fn() } as any, {} as any, {} as any, @@ -158,10 +170,7 @@ describe("MatchesController β€” match_events on-demand servers", () => { scheduledMatchesQueue as any, {} as any, matchRelay as any, - { - createMatchVoiceChannels: jest.fn(), - movePlayersToMatchChannels: jest.fn(), - } as any, + tournamentVoice as any, gameStreamer as any, {} as any, { resumeAllPausedBatches: jest.fn() } as any, @@ -174,6 +183,69 @@ describe("MatchesController β€” match_events on-demand servers", () => { ); }); + describe("the chat archive", () => { + it("goes with a deleted match", async () => { + await controller.match_events({ + op: "DELETE", + old: row(), + new: {}, + } as any); + + expect(chat.removeMatchArchive).toHaveBeenCalledWith("match-1"); + }); + + it.each(["Finished", "Tie", "Canceled", "Forfeit", "Surrendered"])( + "stops sliding once the match is %s", + async (ended) => { + await controller.match_events({ + op: "UPDATE", + old: row({ status: "Live" }), + new: row({ status: ended }), + } as any); + + expect(chat.anchorMatchArchive).toHaveBeenCalledWith("match-1"); + expect(chat.reopenMatchArchive).not.toHaveBeenCalled(); + }, + ); + + it("is not anchored again by a later update of an ended match", async () => { + await controller.match_events({ + op: "UPDATE", + old: row({ status: "Canceled" }), + new: row({ status: "Finished" }), + } as any); + + expect(chat.anchorMatchArchive).not.toHaveBeenCalled(); + }); + + it("slides again even when the match's voice channels cannot be made", async () => { + tournamentVoice.createMatchVoiceChannels.mockRejectedValue( + new Error("discord unavailable"), + ); + + await controller + .match_events({ + op: "UPDATE", + old: row({ status: "Canceled" }), + new: row({ status: "WaitingForCheckIn" }), + } as any) + .catch((): void => undefined); + + expect(chat.reopenMatchArchive).toHaveBeenCalledWith("match-1"); + }); + + it("slides again when an ended match is started again", async () => { + await controller.match_events({ + op: "UPDATE", + old: row({ status: "Canceled" }), + new: row({ status: "Live" }), + } as any); + + expect(chat.reopenMatchArchive).toHaveBeenCalledWith("match-1"); + expect(chat.anchorMatchArchive).not.toHaveBeenCalled(); + }); + }); + it("stops the on-demand server of a match that is deleted", async () => { await controller.match_events({ op: "DELETE", diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 7687b29d..bcbbfc4d 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -715,6 +715,22 @@ export class MatchesController { await this.stopEndedMatchServer(data, matchId, endedServerId); } + // Ahead of the Discord calls below, any of which can throw and end this. + if (data.op === "UPDATE" && data.old.status !== data.new.status) { + const ended = MatchesController.TERMINAL_STATUSES.includes( + data.new.status as string, + ); + const wasEnded = MatchesController.TERMINAL_STATUSES.includes( + data.old.status as string, + ); + + if (ended && !wasEnded) { + await this.chatService.anchorMatchArchive(matchId); + } else if (wasEnded && !ended) { + await this.chatService.reopenMatchArchive(matchId); + } + } + if ( data.op === "UPDATE" && data.new.status === "WaitingForServer" && @@ -788,6 +804,7 @@ export class MatchesController { if (data.op === "DELETE") { await this.chatService.removeLobby(ChatLobbyType.Match, matchId); + await this.chatService.removeMatchArchive(matchId); // No grace window here, unlike a match that merely ended: there is no // match left to have just played, and the roster the channel is built diff --git a/test/chat-redis-actions.spec.ts b/test/chat-redis-actions.spec.ts index 5e7b86cf..d613e954 100644 --- a/test/chat-redis-actions.spec.ts +++ b/test/chat-redis-actions.spec.ts @@ -292,6 +292,8 @@ describe("chat edits and self deletes (SQL-driven)", () => { for (let round = 0; round < 25; round++) { const matchId = randomUUID(); const id = await place(matchId, user); + // Twenty-five edits in a row would trip the edit rate limit. + await redis.del(`chat:edit-rate:${user.steam_id}`); const [edited, deleted] = await Promise.all([ edit(matchId, id, user), diff --git a/test/match-chat-archive.spec.ts b/test/match-chat-archive.spec.ts new file mode 100644 index 00000000..e3a80fa9 --- /dev/null +++ b/test/match-chat-archive.spec.ts @@ -0,0 +1,1184 @@ +import IORedis, { Redis } from "ioredis"; +import { GenericContainer, StartedTestContainer } from "testcontainers"; +import { PostgresService } from "./../src/postgres/postgres.service"; +import { ChatService } from "./../src/chat/chat.service"; +import { ChatLobbyType } from "./../src/chat/enums/ChatLobbyTypes"; +import { ChatErrorCode } from "./../src/chat/enums/ChatErrorCode"; +import { PlayerBlocksService } from "./../src/player-blocks/player-blocks.service"; +import { Fixtures } from "./utils/fixtures"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +const SEVEN_DAYS = 60 * 60 * 24 * 7; + +// The live rooms expire in an hour and belong to whoever is in the match; the +// archive is what staff review afterwards, and only through the log. +describe("match chat archive (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let container: StartedTestContainer; + let redis: Redis; + let chat: ChatService; + + let match: { id: string; lineup_1_id: string; lineup_2_id: string }; + let organizer: string; + let opponent: string; + let coach: string; + let admin: string; + let player: string; + + // Answers the room access checks from the database, the way the session's + // own Hasura permissions would. + const hasura = { + query: jest.fn(async (query: any, viewer?: string) => { + if (query.players_by_pk) { + const [row] = await postgres.query< + Array<{ steam_id: string; name: string; role: string }> + >( + `SELECT steam_id::text AS steam_id, name, role::text AS role + FROM players WHERE steam_id = $1::bigint`, + [query.players_by_pk.__args.steam_id], + ); + return { players_by_pk: row ?? null }; + } + + if (query.matches_by_pk) { + const [row] = await postgres.query< + Array<{ in_lineup: boolean; staff: boolean }> + >( + `SELECT EXISTS ( + SELECT 1 FROM match_lineup_players mlp + WHERE mlp.match_lineup_id IN (m.lineup_1_id, m.lineup_2_id) + AND mlp.steam_id = $2::bigint + ) AS in_lineup, + EXISTS ( + SELECT 1 FROM players p + WHERE p.steam_id = $2::bigint + AND p.role IN ('match_organizer', 'tournament_organizer', + 'administrator') + ) AS staff + FROM matches m WHERE m.id = $1`, + [query.matches_by_pk.__args.id, viewer], + ); + return { + matches_by_pk: row + ? { + is_coach: false, + is_organizer: row.staff || null, + is_in_lineup: row.in_lineup, + } + : null, + }; + } + + if (query.match_lineups_by_pk) { + const [row] = await postgres.query< + Array<{ + match_id: string; + coach_steam_id: string | null; + is_on_lineup: boolean; + }> + >( + `SELECT ml.match_id, ml.coach_steam_id::text, + EXISTS ( + SELECT 1 FROM match_lineup_players mlp + WHERE mlp.match_lineup_id = ml.id + AND mlp.steam_id = $2::bigint + ) AS is_on_lineup + FROM match_lineups ml WHERE ml.id = $1`, + [query.match_lineups_by_pk.__args.id, viewer], + ); + return { match_lineups_by_pk: row ?? null }; + } + + return {}; + }), + }; + + beforeAll(async () => { + container = await new GenericContainer("redis:8.8-alpine") + .withExposedPorts(6379) + .start(); + redis = new IORedis({ + host: container.getHost(), + port: container.getMappedPort(6379), + }); + + db = await bootMigratedDb("MatchChatArchiveTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199660000000n); + await fx.region(); + + chat = new ChatService( + { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + {} as any, + hasura as any, + postgres, + { getConnection: () => redis } as any, + { + sendChatMessage: jest.fn(async () => {}), + retractChatMessage: jest.fn(async () => {}), + editChatMessage: jest.fn(async () => {}), + } as any, + new PlayerBlocksService(postgres), + ); + }, 600_000); + + afterAll(async () => { + redis?.disconnect(); + await container?.stop(); + await db?.stop(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRIES = defaults.entries; + ChatService.MATCH_CHAT_ARCHIVE_MAX_BYTES = defaults.bytes; + }); + + const defaults = { + entries: ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRIES, + bytes: ChatService.MATCH_CHAT_ARCHIVE_MAX_BYTES, + }; + + beforeEach(async () => { + await redis.flushall(); + await postgres.query("DELETE FROM chat_message_deletions"); + await postgres.query("DELETE FROM chat_message_edits"); + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM players"); + + organizer = await fx.player("Organizer"); + opponent = await fx.player("Opponent"); + coach = await fx.player("Coach"); + admin = await fx.player("Admin"); + player = await fx.player("Player"); + + await setRole(organizer, "match_organizer"); + await setRole(coach, "match_organizer"); + await setRole(admin, "administrator"); + + const created = await fx.match({ type: "Duel", substitutes: 2 }); + match = { + id: created.id, + lineup_1_id: created.lineup_1_id, + lineup_2_id: created.lineup_2_id, + }; + + await postgres.query( + `INSERT INTO match_maps (match_id, map_id, "order") + SELECT $1, id, 1 FROM maps ORDER BY name LIMIT 1`, + [match.id], + ); + await fx.lineupPlayer(match.lineup_1_id, organizer); + await fx.lineupPlayer(match.lineup_1_id, player); + await fx.lineupPlayer(match.lineup_2_id, opponent); + await postgres.query( + "UPDATE match_lineups SET coach_steam_id = $2 WHERE id = $1", + [match.lineup_2_id, coach], + ); + await status("Live"); + }); + + const setRole = (steamId: string, role: string) => + postgres.query("UPDATE players SET role = $2 WHERE steam_id = $1", [ + steamId, + role, + ]); + + const status = (value: string) => + postgres.query("UPDATE matches SET status = $2 WHERE id = $1", [ + match.id, + value, + ]); + + const as = (steamId: string, role = "user") => + ({ steam_id: steamId, name: `p${steamId.slice(-2)}`, role }) as any; + + const teamRoom = (lineupId: string) => `${match.id}:${lineupId}`; + + const say = async ( + type: ChatLobbyType, + id: string, + steamId: string, + text: string, + source: "web" | "game" = "game", + ) => { + const sent = await chat.sendMessageToChat( + type, + id, + as(steamId), + text, + true, + source, + ); + return sent.accepted ? sent.messageId : ""; + }; + + const archive = async () => + Object.entries(await redis.hgetall(`chat:archive:${match.id}`)) + .filter(([field]) => !field.startsWith("~")) + .map(([, raw]) => JSON.parse(raw)); + + const entry = async (id: string) => + (await archive()).find((line) => line.id === id); + + const log = (steamId: string, role = "user") => + chat.matchChatLog(match.id, as(steamId, role)); + + const ids = (lines: Array<{ id: string }>) => lines.map(({ id }) => id); + + const edit = (id: string, text: string, steamId = organizer) => + chat.editMessage(ChatLobbyType.Match, match.id, id, as(steamId), text); + + // Any command aimed at an archive key fails, the way it would with redis + // refusing writes to it; the live rooms are untouched. + const breakArchive = () => { + for (const command of ["hset", "hget", "expire", "eval", "hgetall"]) { + const original = (redis as any)[command].bind(redis); + jest.spyOn(redis as any, command).mockImplementation((...args: any[]) => { + const keys = + command === "eval" ? args.slice(2, 2 + Number(args[1])) : [args[0]]; + if (keys.some((key) => String(key).startsWith("chat:archive:"))) { + return Promise.reject(new Error("archive unavailable")); + } + return original(...args); + }); + } + }; + + describe("writing", () => { + it("keeps every line from the match room and both team rooms, from the web and the game", async () => { + const web = await say( + ChatLobbyType.Match, + match.id, + organizer, + "gl hf", + "web", + ); + const game = await say( + ChatLobbyType.MatchTeam, + teamRoom(match.lineup_2_id), + opponent, + "stack A", + ); + + expect(await entry(web)).toMatchObject({ + room: "match", + source: "web", + message: "gl hf", + from: { steam_id: organizer }, + }); + expect(typeof (await entry(web)).from.name).toBe("string"); + expect(await entry(game)).toMatchObject({ + room: match.lineup_2_id, + source: "game", + message: "stack A", + from: { steam_id: opponent }, + }); + expect(typeof (await entry(game)).timestamp).toBe("string"); + }); + + it("keeps a draft's lines once they move into the match room", async () => { + const id = "7f1d0c2e-8b1a-4c6e-9f00-000000000001"; + await redis.hset( + `chat_${ChatLobbyType.Draft}_draft-1`, + id, + JSON.stringify({ + id, + message: "pick me", + timestamp: new Date().toISOString(), + source: "web", + from: { role: "user", name: "Player", steam_id: player }, + }), + ); + + await chat.migrateLobbyMessages( + ChatLobbyType.Draft, + "draft-1", + ChatLobbyType.Match, + match.id, + ); + + expect(await entry(id)).toMatchObject({ + room: "match", + message: "pick me", + from: { steam_id: player }, + }); + }); + + it("archives only the draft's lines, keeping what the room already had", async () => { + const kept = await say( + ChatLobbyType.Match, + match.id, + organizer, + "typo", + "web", + ); + await edit(kept, "fixed"); + + const moved = "7f1d0c2e-8b1a-4c6e-9f00-000000000002"; + const editedAt = new Date().toISOString(); + await redis.hset( + `chat_${ChatLobbyType.Draft}_draft-2`, + moved, + JSON.stringify({ + id: moved, + message: "pick me now", + timestamp: new Date().toISOString(), + edited_at: editedAt, + source: "web", + from: { role: "user", name: "Player", steam_id: player }, + }), + ); + + await chat.migrateLobbyMessages( + ChatLobbyType.Draft, + "draft-2", + ChatLobbyType.Match, + match.id, + ); + + expect(await entry(kept)).toMatchObject({ + message: "fixed", + edits: [{ message: "typo" }], + }); + expect(await entry(moved)).toMatchObject({ + message: "pick me now", + edited_at: editedAt, + }); + }); + + it("archives a line that reached the draft just before the move", async () => { + const late = "7f1d0c2e-8b1a-4c6e-9f00-000000000004"; + const run = redis.eval.bind(redis) as ( + ...args: any[] + ) => Promise; + jest.spyOn(redis, "eval").mockImplementation((async (...args: any[]) => { + if (String(args[2]) === `chat_${ChatLobbyType.Draft}_draft-4`) { + await redis.hset( + `chat_${ChatLobbyType.Draft}_draft-4`, + late, + JSON.stringify({ + id: late, + message: "last word", + timestamp: new Date().toISOString(), + source: "web", + from: { role: "user", name: "Player", steam_id: player }, + }), + ); + } + return run(...args); + }) as any); + + await chat.migrateLobbyMessages( + ChatLobbyType.Draft, + "draft-4", + ChatLobbyType.Match, + match.id, + ); + + expect(await entry(late)).toMatchObject({ message: "last word" }); + }); + + it("moves a draft's chat even when its lines cannot be read first", async () => { + const id = "7f1d0c2e-8b1a-4c6e-9f00-000000000005"; + await redis.hset( + `chat_${ChatLobbyType.Draft}_draft-5`, + id, + JSON.stringify({ + id, + message: "moving", + timestamp: new Date().toISOString(), + source: "web", + from: { role: "user", name: "Player", steam_id: player }, + }), + ); + const read = redis.hgetall.bind(redis); + jest.spyOn(redis, "hgetall").mockImplementation((async (key: string) => { + if (key.startsWith(`chat_${ChatLobbyType.Draft}_`)) { + throw new Error("read failed"); + } + return read(key); + }) as any); + + await chat.migrateLobbyMessages( + ChatLobbyType.Draft, + "draft-5", + ChatLobbyType.Match, + match.id, + ); + + expect( + await redis.hexists(`chat_${ChatLobbyType.Match}_${match.id}`, id), + ).toBe(1); + }); + + it("stops at its cap and says it was cut short", async () => { + ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRIES = 2; + + const first = await say(ChatLobbyType.Match, match.id, organizer, "1"); + const second = await say(ChatLobbyType.Match, match.id, organizer, "2"); + await say(ChatLobbyType.Match, match.id, organizer, "3"); + await status("Finished"); + + const read = await log(admin, "administrator"); + + expect(ids(read!.match).sort()).toEqual([first, second].sort()); + expect(read!.archive_truncated).toBe(true); + }); + + it("stops at its byte cap too", async () => { + ChatService.MATCH_CHAT_ARCHIVE_MAX_BYTES = 600; + + await say(ChatLobbyType.Match, match.id, organizer, "a".repeat(200)); + await say(ChatLobbyType.Match, match.id, organizer, "b".repeat(200)); + await say(ChatLobbyType.Match, match.id, organizer, "c".repeat(200)); + await status("Finished"); + + const read = await log(admin, "administrator"); + + expect(read!.match.length).toBeLessThan(3); + expect(read!.archive_truncated).toBe(true); + }); + + it("says nothing was cut when nothing was", async () => { + await say(ChatLobbyType.Match, match.id, organizer, "gg"); + await status("Finished"); + + expect((await log(admin, "administrator"))!.archive_truncated).toBe( + false, + ); + }); + + it("stops moving its expiry once the match has ended", async () => { + await say(ChatLobbyType.Match, match.id, organizer, "gg"); + await chat.anchorMatchArchive(match.id); + await redis.expire(`chat:archive:${match.id}`, 100); + + await say(ChatLobbyType.Match, match.id, opponent, "wp"); + + expect(await redis.ttl(`chat:archive:${match.id}`)).toBeLessThanOrEqual( + 100, + ); + }); + + it("anchors a week from the end, even with nothing said yet", async () => { + await chat.anchorMatchArchive(match.id); + + const ttl = await redis.ttl(`chat:archive:${match.id}`); + expect(ttl).toBeGreaterThan(SEVEN_DAYS - 60); + + await redis.expire(`chat:archive:${match.id}`, 100); + await say(ChatLobbyType.Match, match.id, opponent, "wp"); + + expect(await redis.ttl(`chat:archive:${match.id}`)).toBeLessThanOrEqual( + 100, + ); + }); + + it("moves its expiry again when the match is restarted", async () => { + await chat.anchorMatchArchive(match.id); + await chat.reopenMatchArchive(match.id); + await redis.expire(`chat:archive:${match.id}`, 100); + + await say(ChatLobbyType.Match, match.id, opponent, "again"); + + expect(await redis.ttl(`chat:archive:${match.id}`)).toBeGreaterThan( + SEVEN_DAYS - 60, + ); + }); + + it("goes with its match", async () => { + await say(ChatLobbyType.Match, match.id, organizer, "gg"); + + await chat.removeMatchArchive(match.id); + + expect(await redis.exists(`chat:archive:${match.id}`)).toBe(0); + }); + + describe("when the archive cannot be written", () => { + beforeEach(breakArchive); + + it("still delivers a line from the web and from the game", async () => { + const web = await chat.sendMessageToChat( + ChatLobbyType.Match, + match.id, + as(organizer), + "from the site", + true, + "web", + ); + const game = await chat.sendMessageToChat( + ChatLobbyType.MatchTeam, + teamRoom(match.lineup_1_id), + as(player), + "from the game", + true, + "game", + ); + + expect(web).toMatchObject({ accepted: true }); + expect(game).toMatchObject({ accepted: true }); + expect( + await redis.hlen(`chat_${ChatLobbyType.Match}_${match.id}`), + ).toBe(1); + }); + + it("still edits and deletes", async () => { + const typo = await say( + ChatLobbyType.Match, + match.id, + organizer, + "typo", + "web", + ); + const nasty = await say( + ChatLobbyType.Match, + match.id, + organizer, + "nasty", + "web", + ); + + await expect(edit(typo, "fixed")).resolves.toMatchObject({ + edited: true, + }); + await expect( + chat.deleteMessage(ChatLobbyType.Match, match.id, nasty, as(admin)), + ).resolves.toEqual({ deleted: true }); + }); + + it("still moves a finished draft's chat into the match", async () => { + const id = "7f1d0c2e-8b1a-4c6e-9f00-000000000003"; + await redis.hset( + `chat_${ChatLobbyType.Draft}_draft-3`, + id, + JSON.stringify({ + id, + message: "moving", + timestamp: new Date().toISOString(), + source: "web", + from: { role: "user", name: "Player", steam_id: player }, + }), + ); + + await chat.migrateLobbyMessages( + ChatLobbyType.Draft, + "draft-3", + ChatLobbyType.Match, + match.id, + ); + + expect( + await redis.hexists(`chat_${ChatLobbyType.Match}_${match.id}`, id), + ).toBe(1); + }); + }); + + it("leaves other rooms out", async () => { + await chat.sendMessageToChat( + ChatLobbyType.Organizer, + "organizers", + as(admin), + "staff only", + true, + "game", + ); + + expect(await archive()).toEqual([]); + }); + + it("outlives the live room by a week, and leaves the room's hour alone", async () => { + const id = await say(ChatLobbyType.Match, match.id, organizer, "gg"); + + const archiveTtl = await redis.ttl(`chat:archive:${match.id}`); + expect(archiveTtl).toBeGreaterThan(SEVEN_DAYS - 60); + expect(archiveTtl).toBeLessThanOrEqual(SEVEN_DAYS); + + const [roomTtl] = (await redis.call( + "HTTL", + `chat_${ChatLobbyType.Match}_${match.id}`, + "FIELDS", + 1, + id, + )) as number[]; + expect(roomTtl).toBeGreaterThan(60 * 60 - 60); + expect(roomTtl).toBeLessThanOrEqual(60 * 60); + }); + + it("keeps an edited line's earlier text", async () => { + const id = await say( + ChatLobbyType.Match, + match.id, + organizer, + "typo", + "web", + ); + + await expect( + chat.editMessage( + ChatLobbyType.Match, + match.id, + id, + as(organizer), + "fixed", + ), + ).resolves.toMatchObject({ edited: true }); + + expect(await entry(id)).toMatchObject({ + message: "fixed", + edits: [{ message: "typo" }], + }); + expect(typeof (await entry(id)).edited_at).toBe("string"); + }); + + it("rate limits edits the way it limits sends", async () => { + const id = await say( + ChatLobbyType.Match, + match.id, + organizer, + "typo", + "web", + ); + + const results = []; + for ( + let attempt = 0; + attempt < ChatService.MESSAGE_RATE_LIMIT + 1; + attempt++ + ) { + results.push(await edit(id, `fix ${attempt}`)); + } + + expect(results.at(-1)).toEqual({ + edited: false, + code: ChatErrorCode.RateLimited, + }); + expect(results.slice(0, -1).every(({ edited }) => edited)).toBe(true); + }); + + it("keeps the original text and the last four edits, no more", async () => { + const id = await say( + ChatLobbyType.Match, + match.id, + organizer, + "original", + "web", + ); + + for (let attempt = 1; attempt <= 7; attempt++) { + await redis.del(`chat:edit-rate:${organizer}`); + await edit(id, `edit ${attempt}`); + } + + const { message, edits } = await entry(id); + + expect(message).toBe("edit 7"); + expect(edits.map((each: { message: string }) => each.message)).toEqual([ + "original", + "edit 3", + "edit 4", + "edit 5", + "edit 6", + ]); + }); + + it("keeps an entry under its size cap however long the edits", async () => { + const long = (letter: string) => letter.repeat(2000); + const id = await say( + ChatLobbyType.Match, + match.id, + organizer, + long("原"), + "web", + ); + + for (const letter of ["δΈ€", "二", "δΈ‰", "ε››", "δΊ”"]) { + await redis.del(`chat:edit-rate:${organizer}`); + await edit(id, long(letter)); + } + + const raw = await redis.hget(`chat:archive:${match.id}`, id); + const { message, edits } = JSON.parse(raw!); + + expect(Buffer.byteLength(raw!)).toBeLessThanOrEqual( + ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRY_BYTES, + ); + expect(message).toBe(long("δΊ”")); + expect(edits[0].message).toBe(long("原")); + }); + + it("never loses a moderator's delete to an edit landing at the same time", async () => { + const id = await say( + ChatLobbyType.Match, + match.id, + organizer, + "typo", + "web", + ); + + // The delete lands after the edit has changed the live room and before + // the edit reaches the archive. + let interleaved = false; + const run = redis.eval.bind(redis) as ( + ...args: any[] + ) => Promise; + jest.spyOn(redis, "eval").mockImplementation((async (...args: any[]) => { + const archiving = + String(args[2]).startsWith("chat:archive:") && + String(args[0]).includes("table.insert"); + if (archiving && !interleaved) { + interleaved = true; + await chat.deleteMessage( + ChatLobbyType.Match, + match.id, + id, + as(admin), + ); + } + return run(...args); + }) as any); + + await edit(id, "fixed"); + + expect(interleaved).toBe(true); + expect(await entry(id)).toMatchObject({ + message: "fixed", + edits: [{ message: "typo" }], + }); + expect(typeof (await entry(id)).deleted_at).toBe("string"); + }); + + it("applies the same edit once, however often it arrives", async () => { + const id = await say(ChatLobbyType.Match, match.id, organizer, "typo"); + const at = new Date().toISOString(); + + await (chat as any).archiveEdit( + ChatLobbyType.Match, + match.id, + id, + "fixed", + at, + ); + await (chat as any).archiveEdit( + ChatLobbyType.Match, + match.id, + id, + "fixed", + at, + ); + + expect(await entry(id)).toMatchObject({ + message: "fixed", + edited_at: at, + edits: [{ message: "typo" }], + }); + }); + + it("never lets an older edit overwrite a newer one", async () => { + const id = await say(ChatLobbyType.Match, match.id, organizer, "typo"); + const earlier = new Date(Date.now() - 1000).toISOString(); + const later = new Date().toISOString(); + + await (chat as any).archiveEdit( + ChatLobbyType.Match, + match.id, + id, + "second", + later, + ); + await (chat as any).archiveEdit( + ChatLobbyType.Match, + match.id, + id, + "first", + earlier, + ); + + expect(await entry(id)).toMatchObject({ + message: "second", + edited_at: later, + edits: [{ message: "typo" }], + }); + }); + + it("records when each version was written", async () => { + const id = await say(ChatLobbyType.Match, match.id, organizer, "one"); + const sent = (await entry(id)).timestamp; + const second = new Date(Date.now() + 1000).toISOString(); + const third = new Date(Date.now() + 2000).toISOString(); + + await (chat as any).archiveEdit( + ChatLobbyType.Match, + match.id, + id, + "two", + second, + ); + await (chat as any).archiveEdit( + ChatLobbyType.Match, + match.id, + id, + "three", + third, + ); + + expect(await entry(id)).toMatchObject({ + message: "three", + edited_at: third, + edits: [ + { message: "one", written_at: sent }, + { message: "two", written_at: second }, + ], + }); + }); + + it("keeps an entry under its size cap, the original included", async () => { + const id = await say( + ChatLobbyType.Match, + match.id, + organizer, + "\u0001".repeat(2000), + "web", + ); + await edit(id, "\u0002".repeat(2000)); + + const raw = await redis.hget(`chat:archive:${match.id}`, id); + const { message, edits } = JSON.parse(raw!); + + expect(Buffer.byteLength(raw!)).toBeLessThanOrEqual( + ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRY_BYTES, + ); + expect(message).toBe("\u0002".repeat(2000)); + expect(edits[0].message).toMatch(/^\u0001+ \[truncated\]$/); + }); + + it("keeps the whole archive under its byte cap through mass edits", async () => { + ChatService.MATCH_CHAT_ARCHIVE_MAX_ENTRIES = 3; + ChatService.MATCH_CHAT_ARCHIVE_MAX_BYTES = 20_000; + + const lines = []; + for (let n = 0; n < 4; n++) { + lines.push( + await say( + ChatLobbyType.Match, + match.id, + organizer, + `line ${n}`, + "web", + ), + ); + } + + for (const id of lines) { + for (let round = 0; round < 6; round++) { + await redis.del(`chat:edit-rate:${organizer}`); + await edit(id, `"\\${round}`.repeat(500).slice(0, 2000)); + } + } + + const stored = await redis.hgetall(`chat:archive:${match.id}`); + const used = Object.entries(stored) + .filter(([field]) => !field.startsWith("~")) + .reduce((total, [, raw]) => total + Buffer.byteLength(raw), 0); + + expect(used).toBeLessThanOrEqual(20_000); + expect(stored["~truncated"]).toBe("1"); + }); + + it("turns a broken surrogate into a replacement character, so the line can still be edited and deleted", async () => { + const web = await say( + ChatLobbyType.Match, + match.id, + organizer, + "nice \uD83D shot", + "web", + ); + const game = await say( + ChatLobbyType.Match, + match.id, + opponent, + "gg \uDE00", + ); + + expect( + JSON.parse( + (await redis.hget(`chat_${ChatLobbyType.Match}_${match.id}`, web))!, + ).message, + ).toBe("nice \uFFFD shot"); + expect((await entry(game)).message).toBe("gg \uFFFD"); + + await edit(web, "nice shot"); + await chat.deleteMessage(ChatLobbyType.Match, match.id, game, as(admin)); + + expect(await entry(web)).toMatchObject({ message: "nice shot" }); + expect(typeof (await entry(game)).deleted_at).toBe("string"); + }); + + it("keeps a deleted line, marked", async () => { + const id = await say( + ChatLobbyType.Match, + match.id, + organizer, + "something nasty", + "web", + ); + + await expect( + chat.deleteMessage(ChatLobbyType.Match, match.id, id, as(organizer)), + ).resolves.toEqual({ deleted: true }); + + expect( + await redis.hget(`chat_${ChatLobbyType.Match}_${match.id}`, id), + ).toBeNull(); + expect(await entry(id)).toMatchObject({ + message: "something nasty", + deleted_by: { steam_id: organizer, name: "Organizer" }, + }); + expect(typeof (await entry(id)).deleted_at).toBe("string"); + }); + }); + + describe("reading", () => { + let all: string; + let l1: string; + let l2: string; + + beforeEach(async () => { + all = await say(ChatLobbyType.Match, match.id, opponent, "gl"); + l1 = await say( + ChatLobbyType.MatchTeam, + teamRoom(match.lineup_1_id), + player, + "default", + ); + l2 = await say( + ChatLobbyType.MatchTeam, + teamRoom(match.lineup_2_id), + opponent, + "rush B", + ); + }); + + it.each(["Finished", "Tie", "Canceled", "Forfeit", "Surrendered"])( + "gives staff who took no part every room once the match is %s", + async (ended) => { + await status(ended); + + const read = await log(admin, "administrator"); + + expect(ids(read!.match)).toEqual([all]); + expect( + read!.teams.map(({ lineup_id, messages }) => [ + lineup_id, + ids(messages), + ]), + ).toEqual([ + [match.lineup_1_id, [l1]], + [match.lineup_2_id, [l2]], + ]); + expect(read!.team_chat_withheld).toBe(false); + }, + ); + + it("is closed while the match is being played", async () => { + expect(await log(admin, "administrator")).toBeNull(); + }); + + it("opens at match organizer for someone who took no part", async () => { + const staff = await fx.player("Staff"); + await setRole(staff, "match_organizer"); + await status("Finished"); + + const read = await log(staff, "match_organizer"); + + expect(read!.teams).toHaveLength(2); + expect(read!.team_chat_withheld).toBe(false); + }); + + it("is closed to a moderator, the role just below", async () => { + const staff = await fx.player("Staff"); + await setRole(staff, "moderator"); + await status("Finished"); + + expect(await log(staff, "moderator")).toBeNull(); + }); + + it("is closed to a match organizer demoted to moderator", async () => { + const staff = await fx.player("Staff"); + await setRole(staff, "moderator"); + await status("Finished"); + + expect(await log(staff, "match_organizer")).toBeNull(); + }); + + it("is closed below match organizer, organizer of the match or not", async () => { + await postgres.query( + "UPDATE matches SET organizer_steam_id = $2 WHERE id = $1", + [match.id, player], + ); + await status("Finished"); + + expect(await log(player, "user")).toBeNull(); + }); + + it("goes by the role held now, not the one the session was signed in with", async () => { + await status("Finished"); + await setRole(admin, "user"); + + expect(await log(admin, "administrator")).toBeNull(); + }); + + it("gives someone on a lineup all chat only", async () => { + await status("Finished"); + + const read = await log(organizer, "match_organizer"); + + expect(ids(read!.match)).toEqual([all]); + expect(read!.teams).toEqual([]); + expect(read!.team_chat_withheld).toBe(true); + }); + + it("gives a coach all chat only", async () => { + await status("Finished"); + + expect((await log(coach, "match_organizer"))!.teams).toEqual([]); + }); + + it("gives someone who wrote in a team room all chat only, even off the lineup now", async () => { + await say( + ChatLobbyType.MatchTeam, + teamRoom(match.lineup_1_id), + organizer, + "smoke mid", + ); + await postgres.query( + "DELETE FROM match_lineup_players WHERE steam_id = $1", + [organizer], + ); + await status("Finished"); + + const read = await log(organizer, "match_organizer"); + + expect(read!.teams).toEqual([]); + expect(read!.team_chat_withheld).toBe(true); + }); + + it("shows deleted and edited lines with what happened to them", async () => { + const typo = await say( + ChatLobbyType.Match, + match.id, + organizer, + "typo", + "web", + ); + const nasty = await say( + ChatLobbyType.Match, + match.id, + organizer, + "nasty", + "web", + ); + await chat.editMessage( + ChatLobbyType.Match, + match.id, + typo, + as(organizer), + "fixed", + ); + await chat.deleteMessage( + ChatLobbyType.Match, + match.id, + nasty, + as(organizer), + ); + await status("Finished"); + + const read = await log(admin, "administrator"); + const line = (id: string) => read!.match.find((each) => each.id === id); + + expect(line(typo)).toMatchObject({ message: "fixed" }); + expect(typeof line(typo)!.edited_at).toBe("string"); + expect(line(nasty)).toMatchObject({ message: "nasty" }); + expect(typeof line(nasty)!.deleted_at).toBe("string"); + }); + + it("says when the archive goes", async () => { + await status("Finished"); + + const read = await log(admin, "administrator"); + const left = new Date(read!.expires_at!).getTime() - Date.now(); + + expect(left).toBeGreaterThan((SEVEN_DAYS - 60) * 1000); + expect(left).toBeLessThanOrEqual(SEVEN_DAYS * 1000); + }); + + it("still has every line after the live rooms have expired", async () => { + await redis.del( + `chat_${ChatLobbyType.Match}_${match.id}`, + `chat_${ChatLobbyType.MatchTeam}_${teamRoom(match.lineup_1_id)}`, + `chat_${ChatLobbyType.MatchTeam}_${teamRoom(match.lineup_2_id)}`, + ); + await status("Finished"); + + const read = await log(admin, "administrator"); + + expect(ids(read!.match)).toEqual([all]); + expect(read!.teams.flatMap(({ messages }) => ids(messages))).toEqual([ + l1, + l2, + ]); + }); + }); + + describe("the websocket", () => { + const join = async (type: string, id: string, steamId: string) => { + const socket = { + id: `client-${steamId}-${type}`, + user: as(steamId), + send: jest.fn(), + on: jest.fn(), + }; + + await chat.joinMatchLobby(socket as any, type as any, id); + + return socket.send.mock.calls + .map(([payload]: [string]) => JSON.parse(payload)) + .filter(({ event }) => event.endsWith(":messages")) + .flatMap(({ data }) => data.messages); + }; + + it("never hands out an archived line once its live room has gone", async () => { + await setRole(admin, "administrator"); + const all = await say(ChatLobbyType.Match, match.id, opponent, "gl"); + const team = await say( + ChatLobbyType.MatchTeam, + teamRoom(match.lineup_2_id), + opponent, + "rush B", + ); + await redis.del( + `chat_${ChatLobbyType.Match}_${match.id}`, + `chat_${ChatLobbyType.MatchTeam}_${teamRoom(match.lineup_2_id)}`, + ); + + expect(ids(await archive())).toEqual(expect.arrayContaining([all, team])); + + const served = [ + ...(await join(ChatLobbyType.Match, match.id, organizer)), + ...(await join(ChatLobbyType.Match, match.id, admin)), + ...(await join( + ChatLobbyType.MatchTeam, + teamRoom(match.lineup_2_id), + opponent, + )), + ...(await join("archive", match.id, admin)), + ]; + + expect(ids(served)).not.toContain(all); + expect(ids(served)).not.toContain(team); + }); + }); +});