From edc7b87cd57e3e4d97173b1e2a1f42d0693e01a5 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 17:34:08 -0400 Subject: [PATCH 1/5] feature: relay in-game team chat into the team's web chat room --- .../down.sql | 4 + .../up.sql | 8 + src/chat/chat.service.spec.ts | 72 ++++++ src/chat/chat.service.ts | 14 +- src/matches/events/ChatMessageEvent.spec.ts | 223 ++++++++++++++++++ src/matches/events/ChatMessageEvent.ts | 90 ++++++- src/matches/matches.controller.spec.ts | 83 +++++++ src/matches/matches.controller.ts | 6 + .../match-team-chat-notification-type.spec.ts | 84 +++++++ 9 files changed, 576 insertions(+), 8 deletions(-) create mode 100644 hasura/migrations/default/1888000000400_match_team_chat_notification_type/down.sql create mode 100644 hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql create mode 100644 src/matches/events/ChatMessageEvent.spec.ts create mode 100644 test/match-team-chat-notification-type.spec.ts diff --git a/hasura/migrations/default/1888000000400_match_team_chat_notification_type/down.sql b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/down.sql new file mode 100644 index 00000000..f0849ce7 --- /dev/null +++ b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/down.sql @@ -0,0 +1,4 @@ +UPDATE public.notifications + SET type = 'ChatMessage' + WHERE type = 'MatchChatMessage' + AND entity_id LIKE 'match\_team:%' ESCAPE '\'; diff --git a/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql new file mode 100644 index 00000000..4a9fa6c7 --- /dev/null +++ b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql @@ -0,0 +1,8 @@ +-- Team rooms now notify as MatchChatMessage (ChatService.notificationTypeFor), +-- and the read-clear and bell collapse look rows up by that type, so a row left +-- on the old one would sit unread forever. `_` is a LIKE wildcard, hence the +-- escape. +UPDATE public.notifications + SET type = 'MatchChatMessage' + WHERE type = 'ChatMessage' + AND entity_id LIKE 'match\_team:%' ESCAPE '\'; diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 30a28cc9..9e4b039d 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -361,6 +361,19 @@ describe("ChatService direct messages", () => { : {}; } + if (query.match_lineups_by_pk) { + return query.match_lineups_by_pk.__args.id === "l-1" + ? { + match_lineups_by_pk: { + match_id: "m-1", + coach_steam_id: null, + is_on_lineup: true, + lineup_players: [{ steam_id: ME }, { steam_id: FRIEND }], + }, + } + : {}; + } + if (query.players) { return { players: staff.map((steam_id) => ({ steam_id })) }; } @@ -691,6 +704,65 @@ describe("ChatService direct messages", () => { }); }); + describe("notification type", () => { + it.each([ChatLobbyType.Match, ChatLobbyType.MatchTeam])( + "files %s chat under match chat", + (type) => { + expect(ChatService.notificationTypeFor(type)).toBe("MatchChatMessage"); + }, + ); + + it.each([ + ChatLobbyType.Direct, + ChatLobbyType.Tournament, + ChatLobbyType.MatchMaking, + ChatLobbyType.Draft, + ChatLobbyType.Organizer, + ])("files %s chat as a plain chat message", (type) => { + expect(ChatService.notificationTypeFor(type)).toBe("ChatMessage"); + }); + + it("notifies and collapses a team room line under one type", async () => { + await service.sendMessageToChat( + ChatLobbyType.MatchTeam, + "m-1:l-1", + { steam_id: ME, name: "Someone", role } as any, + "rotate b", + true, + "game", + ); + + for (let tick = 0; tick < 5; tick++) { + await new Promise((resolve) => setImmediate(resolve)); + } + + expect(notifications.notifyPlayers).toHaveBeenCalledWith( + "MatchChatMessage", + expect.objectContaining({ + entity_id: "match_team:m-1:l-1", + steamIds: [FRIEND], + }), + ); + expect(notifications.collapseOlderUnread).toHaveBeenCalledWith( + "MatchChatMessage", + "match_team:m-1:l-1", + [FRIEND], + ); + }); + + it("clears a team room's badge under the type it was sent with", async () => { + await service.markThreadRead(ChatLobbyType.MatchTeam, "m-1:l-1", { + steam_id: ME, + } as any); + + expect(notifications.markConversationRead).toHaveBeenCalledWith( + "MatchChatMessage", + "match_team:m-1:l-1", + ME, + ); + }); + }); + describe("message text", () => { it.each([ ["a number", 42], diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 2eeda4f1..a7e387bf 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -1784,18 +1784,20 @@ export class ChatService { // Match chat is its own notification type, and so its own push category. // - // Every line typed in-game is relayed into the match room by - // ChatMessageEvent, so a live match fires this per lineup member per line -- - // and the player it reaches is the one already reading those lines in the - // game. Sharing a category with direct messages meant the only way to stop - // that was to mute DMs too. + // Every line typed in-game is relayed by ChatMessageEvent -- all chat into + // the match room, team chat into the lineup's team room -- so a live match + // fires this per lineup member per line, and the player it reaches is the + // one already reading those lines in the game. Sharing a category with direct + // messages meant the only way to stop that was to mute DMs too. // // The insert, the bell collapse and the read-clear all have to agree on the // type or the collapse stops collapsing and the badge never clears. public static notificationTypeFor( type: ChatLobbyType, ): e_notification_types_enum { - return type === ChatLobbyType.Match ? "MatchChatMessage" : "ChatMessage"; + return type === ChatLobbyType.Match || type === ChatLobbyType.MatchTeam + ? "MatchChatMessage" + : "ChatMessage"; } // What to call this room when a push has to name it -- "3 new messages from diff --git a/src/matches/events/ChatMessageEvent.spec.ts b/src/matches/events/ChatMessageEvent.spec.ts new file mode 100644 index 00000000..9c4f108d --- /dev/null +++ b/src/matches/events/ChatMessageEvent.spec.ts @@ -0,0 +1,223 @@ +import { Logger } from "@nestjs/common"; +import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; +import ChatMessageEvent from "./ChatMessageEvent"; + +const MATCH_ID = "11111111-1111-1111-1111-111111111111"; +const LINEUP_ID = "22222222-2222-2222-2222-222222222222"; +const OTHER_MATCH_ID = "33333333-3333-3333-3333-333333333333"; +const SPEAKER = "76561198000000001"; +const COACH = "76561198000000002"; + +describe("ChatMessageEvent", () => { + let processor: ChatMessageEvent; + let hasura: { query: jest.Mock }; + let chat: { sendMessageToChat: jest.Mock }; + let lineup: { + id: string; + match_id: string; + coach_steam_id: string | null; + lineup_players: Array<{ steam_id: string }>; + } | null; + + const speaker = { + name: "keith", + role: "user", + steam_id: SPEAKER, + profile_url: null as string | null, + avatar_url: null as string | null, + discord_id: null as string | null, + }; + + beforeEach(() => { + lineup = { + id: LINEUP_ID, + match_id: MATCH_ID, + coach_steam_id: COACH, + lineup_players: [{ steam_id: SPEAKER }], + }; + + hasura = { + query: jest.fn(async (query: Record) => { + if (query.players_by_pk) { + return { players_by_pk: { ...speaker } }; + } + + if (query.match_lineups_by_pk) { + return { match_lineups_by_pk: lineup }; + } + + throw new Error("unexpected query"); + }), + }; + chat = { sendMessageToChat: jest.fn(async () => ({ accepted: true })) }; + + processor = new ChatMessageEvent( + new Logger("ChatMessageEventTest"), + hasura as any, + {} as any, + chat as any, + {} as any, + ); + }); + + const send = async (data: Record) => { + processor.setData(MATCH_ID, { + player: SPEAKER, + message: "rotate b", + ...data, + } as any); + + await processor.process(); + }; + + const lineupQuery = () => + hasura.query.mock.calls + .map(([query]) => query.match_lineups_by_pk) + .find(Boolean); + + it("relays all chat into the match room as it always has", async () => { + await send({}); + + expect(chat.sendMessageToChat).toHaveBeenCalledWith( + ChatLobbyType.Match, + MATCH_ID, + expect.objectContaining({ steam_id: SPEAKER }), + "rotate b", + true, + "game", + ); + expect(lineupQuery()).toBeUndefined(); + }); + + it("relays team chat into the speaker's team room", async () => { + await send({ teamOnly: true, lineupId: LINEUP_ID }); + + expect(chat.sendMessageToChat).toHaveBeenCalledTimes(1); + expect(chat.sendMessageToChat).toHaveBeenCalledWith( + ChatLobbyType.MatchTeam, + `${MATCH_ID}:${LINEUP_ID}`, + expect.objectContaining({ steam_id: SPEAKER }), + "rotate b", + true, + "game", + ); + }); + + it("checks the speaker against the lineup the plugin named", async () => { + await send({ teamOnly: true, lineupId: LINEUP_ID }); + + expect(lineupQuery().__args).toEqual({ id: LINEUP_ID }); + expect(lineupQuery().lineup_players.__args).toEqual({ + where: { steam_id: { _eq: SPEAKER } }, + }); + }); + + it("lets the lineup's coach speak in its team room", async () => { + lineup!.lineup_players = []; + + processor.setData(MATCH_ID, { + player: COACH, + message: "timeout", + teamOnly: true, + lineupId: LINEUP_ID, + }); + await processor.process(); + + expect(chat.sendMessageToChat).toHaveBeenCalledWith( + ChatLobbyType.MatchTeam, + `${MATCH_ID}:${LINEUP_ID}`, + expect.anything(), + "timeout", + true, + "game", + ); + }); + + it("keys the room on the lineup as stored, whatever case the plugin sent", async () => { + await send({ teamOnly: true, lineupId: LINEUP_ID.toUpperCase() }); + + expect(chat.sendMessageToChat.mock.calls[0][1]).toBe( + `${MATCH_ID}:${LINEUP_ID}`, + ); + }); + + describe("never falls back to the match room", () => { + const expectDropped = () => { + expect(chat.sendMessageToChat).not.toHaveBeenCalled(); + }; + + it("drops team chat without a lineup", async () => { + await send({ teamOnly: true }); + + expectDropped(); + expect(lineupQuery()).toBeUndefined(); + }); + + it("drops a lineup id that is not a uuid", async () => { + await send({ teamOnly: true, lineupId: "lineup-1" }); + + expectDropped(); + expect(lineupQuery()).toBeUndefined(); + }); + + it("drops a lineup id that is not a string", async () => { + await send({ teamOnly: true, lineupId: 42 }); + + expectDropped(); + }); + + it("drops a lineup without the team flag", async () => { + // both or neither: a lone lineup id is a team line gone wrong, not all chat + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a team flag that is not a boolean", async () => { + await send({ teamOnly: "true", lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a lineup that does not exist", async () => { + lineup = null; + + await send({ teamOnly: true, lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a lineup from another match", async () => { + lineup!.match_id = OTHER_MATCH_ID; + + await send({ teamOnly: true, lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a speaker who is not on the lineup", async () => { + lineup!.lineup_players = []; + + await send({ teamOnly: true, lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a speaker when the lineup has no coach", async () => { + lineup!.lineup_players = []; + lineup!.coach_steam_id = null; + + await send({ teamOnly: true, lineupId: LINEUP_ID }); + + expectDropped(); + }); + }); + + it("drops a line from a player it does not know", async () => { + hasura.query.mockResolvedValueOnce({ players_by_pk: null }); + + await send({ teamOnly: true, lineupId: LINEUP_ID }); + + expect(chat.sendMessageToChat).not.toHaveBeenCalled(); + }); +}); diff --git a/src/matches/events/ChatMessageEvent.ts b/src/matches/events/ChatMessageEvent.ts index d0f21764..85aeaefb 100644 --- a/src/matches/events/ChatMessageEvent.ts +++ b/src/matches/events/ChatMessageEvent.ts @@ -4,7 +4,12 @@ import MatchEventProcessor from "./abstracts/MatchEventProcessor"; export default class ChatMessageEvent extends MatchEventProcessor<{ player: string; message: string; + teamOnly?: boolean; + lineupId?: string; }> { + private static readonly UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + public async process() { const { players_by_pk } = await this.hasura.query({ players_by_pk: { @@ -25,13 +30,94 @@ export default class ChatMessageEvent extends MatchEventProcessor<{ return; } + if (!this.isTeamMessage()) { + await this.chat.sendMessageToChat( + ChatLobbyType.Match, + this.matchId, + players_by_pk, + this.data.message, + true, + "game", + ); + return; + } + + const teamRoomId = await this.teamRoomId(); + + if (!teamRoomId) { + return; + } + await this.chat.sendMessageToChat( - ChatLobbyType.Match, - this.matchId, + ChatLobbyType.MatchTeam, + teamRoomId, players_by_pk, this.data.message, true, "game", ); } + + // Either field marks a team line, and a team line this cannot place is + // dropped rather than sent to the match room, which holds the other team too. + private isTeamMessage(): boolean { + return Boolean(this.data.teamOnly) || this.data.lineupId != null; + } + + private async teamRoomId(): Promise { + const { teamOnly, lineupId, player } = this.data; + + if ( + teamOnly !== true || + typeof lineupId !== "string" || + !ChatMessageEvent.UUID.test(lineupId) + ) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: malformed lineup`, + { teamOnly, lineupId }, + ); + return null; + } + + const { match_lineups_by_pk } = await this.hasura.query({ + match_lineups_by_pk: { + __args: { + id: lineupId, + }, + id: true, + match_id: true, + coach_steam_id: true, + lineup_players: { + __args: { + where: { + steam_id: { + _eq: player, + }, + }, + }, + steam_id: true, + }, + }, + }); + + if (!match_lineups_by_pk || match_lineups_by_pk.match_id !== this.matchId) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: lineup ${lineupId} is not part of this match`, + ); + return null; + } + + const isCoach = + match_lineups_by_pk.coach_steam_id != null && + String(match_lineups_by_pk.coach_steam_id) === String(player); + + if (match_lineups_by_pk.lineup_players.length === 0 && !isCoach) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: not on lineup ${lineupId}`, + ); + return null; + } + + return `${match_lineups_by_pk.match_id}:${match_lineups_by_pk.id}`; + } } diff --git a/src/matches/matches.controller.spec.ts b/src/matches/matches.controller.spec.ts index 4ae94785..ba6b39fb 100644 --- a/src/matches/matches.controller.spec.ts +++ b/src/matches/matches.controller.spec.ts @@ -7,6 +7,22 @@ jest.mock("@kubernetes/client-node", () => ({ import { MatchesController } from "./matches.controller"; +// Positional, in constructor order; anything a test does not reach stays {}. +const controllerWith = (deps: { + hasura?: unknown; + matchAssistant?: unknown; + gameModes?: unknown; +}) => { + const args: any[] = Array.from({ length: 30 }, () => ({})); + + args[1] = deps.hasura ?? {}; + args[3] = { get: jest.fn(() => ({})) }; + args[5] = deps.matchAssistant ?? {}; + args[29] = deps.gameModes ?? {}; + + return new (MatchesController as any)(...args) as MatchesController; +}; + describe("MatchesController", () => { let controller: MatchesController; let matchAssistant: { @@ -174,4 +190,71 @@ describe("MatchesController", () => { expect(notifications.send).not.toHaveBeenCalled(); }); }); + + describe("current match", () => { + const lineup = (id: string) => ({ + id, + name: id, + team: null as null, + coach_steam_id: null as null, + lineup_players: [] as unknown[], + }); + + const payload = async () => { + const hasura = { + query: jest.fn(async (query: Record) => { + if (query.servers_by_pk) { + return { servers_by_pk: { current_match: { id: "match-1" } } }; + } + + if (query.matches_by_pk) { + return { + matches_by_pk: { + id: "match-1", + status: "Live", + is_tournament_match: false, + draft_games: [] as unknown[], + server: { server_region: { is_lan: false } }, + options: { type: "Competitive", game_mode_id: null as null }, + match_maps: [] as unknown[], + lineup_1: lineup("lineup-1"), + lineup_2: lineup("lineup-2"), + tournament_brackets: [] as unknown[], + }, + }; + } + + if (query.match_type_cfgs) { + return { match_type_cfgs: [] as unknown[] }; + } + + return { settings_by_pk: null as null }; + }), + }; + + const json = jest.fn(); + const response = { status: jest.fn(() => ({ json })) }; + + await controllerWith({ + hasura, + gameModes: { + resolveForServer: jest.fn(async (): Promise => null), + pluginCfgLayers: jest.fn(async () => [] as unknown[]), + }, + }).getMatchDetails( + { params: { serverId: "server-1" }, headers: {} } as any, + response as any, + ); + + expect(response.status).toHaveBeenCalledWith(200); + + return json.mock.calls[0][0]; + }; + + it("advertises team chat relay as a JSON boolean", async () => { + // the plugin only relays say_team when this is present, and its + // deserializer rejects the whole payload if it arrives as a string + expect((await payload()).relay_team_chat).toBe(true); + }); + }); }); diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 8896a78c..8fc37404 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -356,6 +356,7 @@ export class MatchesController { const match = matches_by_pk as typeof matches_by_pk & { is_lan: boolean; is_draft_match: boolean; + relay_team_chat: boolean; options: typeof matches_by_pk.options & { use_playcast: boolean; show_elo_ranks: boolean; @@ -396,6 +397,11 @@ export class MatchesController { match.is_draft_match = match.draft_games.length > 0; delete match.draft_games; + // The plugin relays say_team only when this is present, so an api that + // cannot route team chat never receives any. A JSON boolean: the plugin's + // deserializer rejects the whole payload over a string. + match.relay_team_chat = true; + const fivestackRanksSettingName = match.is_tournament_match ? "fivestack_ranks_tournaments" : "fivestack_ranks_matches"; diff --git a/test/match-team-chat-notification-type.spec.ts b/test/match-team-chat-notification-type.spec.ts new file mode 100644 index 00000000..2a8dfc1d --- /dev/null +++ b/test/match-team-chat-notification-type.spec.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// Team rooms moved from ChatMessage to MatchChatMessage. The read-clear and the +// bell collapse look rows up by the new type, so any team row left on the old +// one would sit unread in the bell forever. +describe("match team chat notification type migration", () => { + let db: SqlTestDb; + + const migration = join( + __dirname, + "../hasura/migrations/default/1888000000400_match_team_chat_notification_type", + ); + const up = readFileSync(join(migration, "up.sql"), "utf8"); + const down = readFileSync(join(migration, "down.sql"), "utf8"); + + const STEAM_ID = "76561199500000011"; + + const typeOf = async (entityId: string) => + ( + await db.postgres.query>( + `SELECT type FROM public.notifications WHERE entity_id = $1`, + [entityId], + ) + ).at(0)?.type; + + beforeAll(async () => { + db = await bootMigratedDb("MatchTeamChatNotificationTypeTest"); + + await db.postgres.query( + `INSERT INTO public.players (steam_id, name) VALUES ($1, 'Retyped') + ON CONFLICT (steam_id) DO NOTHING`, + [STEAM_ID], + ); + + for (const entity of [ + "match_team:m-9:l-1", + "match:m-9", + "direct:1:2", + "matchXteam:m-9:l-1", + ]) { + await db.postgres.query( + `INSERT INTO public.notifications + (type, title, message, role, steam_id, entity_id) + VALUES ('ChatMessage', 'Luke', 'hey', 'user', $1, $2)`, + [STEAM_ID, entity], + ); + } + + await db.postgres.query(up); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + it("moves a team room's rows to match chat", async () => { + expect(await typeOf("match_team:m-9:l-1")).toBe("MatchChatMessage"); + }); + + it("leaves rows that are not team rooms alone", async () => { + expect(await typeOf("match:m-9")).toBe("ChatMessage"); + expect(await typeOf("direct:1:2")).toBe("ChatMessage"); + }); + + it("does not treat the underscore as a wildcard", async () => { + expect(await typeOf("matchXteam:m-9:l-1")).toBe("ChatMessage"); + }); + + it("puts only the team rows back on the way down", async () => { + await db.postgres.query( + `UPDATE public.notifications SET type = 'MatchChatMessage' + WHERE entity_id = 'match:m-9'`, + ); + + await db.postgres.query(down); + + expect(await typeOf("match_team:m-9:l-1")).toBe("ChatMessage"); + expect(await typeOf("match:m-9")).toBe("MatchChatMessage"); + + await db.postgres.query(up); + }); +}); From 0d85ad8854b5fad96f4103de30f5672d809cace0 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 17:58:38 -0400 Subject: [PATCH 2/5] bug: team room lines only reach its current roster --- .../up.sql | 4 +- src/chat/chat.service.spec.ts | 42 +++++++++++++++++++ src/chat/chat.service.ts | 20 +++++++++ src/matches/events/ChatMessageEvent.spec.ts | 21 +++++++--- .../match-team-chat-notification-type.spec.ts | 2 +- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql index 4a9fa6c7..6e2627c1 100644 --- a/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql +++ b/hasura/migrations/default/1888000000400_match_team_chat_notification_type/up.sql @@ -1,7 +1,7 @@ -- Team rooms now notify as MatchChatMessage (ChatService.notificationTypeFor), -- and the read-clear and bell collapse look rows up by that type, so a row left --- on the old one would sit unread forever. `_` is a LIKE wildcard, hence the --- escape. +-- on the old one would never clear when its room is read. `_` is a LIKE +-- wildcard, hence the escape. UPDATE public.notifications SET type = 'MatchChatMessage' WHERE type = 'ChatMessage' diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 9e4b039d..751f296c 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -763,6 +763,48 @@ describe("ChatService direct messages", () => { }); }); + describe("live delivery", () => { + const present = (key: string, steamIds: string[]) => + redis.hgetall.mockImplementation(async (hash: string) => + hash === key + ? Object.fromEntries( + steamIds.map((steamId) => [ + steamId, + JSON.stringify({ user: { steam_id: steamId } }), + ]), + ) + : {}, + ); + + const deliveredTo = () => + redis.publish.mock.calls + .map(([, payload]) => JSON.parse(payload)) + .filter(({ event }) => event.endsWith(":chat")) + .map(({ steamId }) => steamId); + + afterEach(() => { + redis.hgetall.mockResolvedValue({}); + }); + + it("keeps a team room's lines from someone no longer on the lineup", async () => { + // still present from when they were on it: presence is only cleared on + // leave, and outlives a move to the other lineup + present("chat:match_team:m-1:l-1", [ME, FRIEND, STRANGER]); + + await service.to(ChatLobbyType.MatchTeam, "m-1:l-1", "chat", {}, ME); + + expect(deliveredTo()).toEqual([ME, FRIEND]); + }); + + it("still reaches everyone present in a match room", async () => { + present("chat:match:m-1", [ME, FRIEND, STRANGER]); + + await service.to(ChatLobbyType.Match, "m-1", "chat", {}, ME); + + expect(deliveredTo()).toEqual([ME, FRIEND, STRANGER]); + }); + }); + describe("message text", () => { it.each([ ["a number", 42], diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index a7e387bf..84b0bd6b 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -2539,6 +2539,7 @@ export class ChatService { ): Promise { const users = await this.getAllUsersInLobby(type, id); const eventName = `lobby:${type}:${id}:${event}`; + const roster = await this.teamRoomRoster(type, id); const hiding = author === undefined @@ -2549,6 +2550,10 @@ export class ChatService { ); for (const { steamId } of users) { + if (roster && !roster.has(String(steamId))) { + continue; + } + if (hiding.has(steamId)) { continue; } @@ -2587,6 +2592,21 @@ export class ChatService { ); } + // Presence outlives membership (see canPostIn), and a team room is the one + // whose lines the rest of the match must never see: a player moved to the + // other lineup, or benched, would otherwise keep receiving them until their + // presence expired. + private async teamRoomRoster( + type: ChatLobbyType, + id: string, + ): Promise | null> { + if (type !== ChatLobbyType.MatchTeam) { + return null; + } + + return new Set(await this.getLobbyMemberSteamIds(type, id)); + } + public async removeFromLobby( type: ChatLobbyType, id: string, diff --git a/src/matches/events/ChatMessageEvent.spec.ts b/src/matches/events/ChatMessageEvent.spec.ts index 9c4f108d..e6ff58de 100644 --- a/src/matches/events/ChatMessageEvent.spec.ts +++ b/src/matches/events/ChatMessageEvent.spec.ts @@ -7,6 +7,7 @@ const LINEUP_ID = "22222222-2222-2222-2222-222222222222"; const OTHER_MATCH_ID = "33333333-3333-3333-3333-333333333333"; const SPEAKER = "76561198000000001"; const COACH = "76561198000000002"; +const TEAMMATE = "76561198000000003"; describe("ChatMessageEvent", () => { let processor: ChatMessageEvent; @@ -33,7 +34,7 @@ describe("ChatMessageEvent", () => { id: LINEUP_ID, match_id: MATCH_ID, coach_steam_id: COACH, - lineup_players: [{ steam_id: SPEAKER }], + lineup_players: [{ steam_id: SPEAKER }, { steam_id: TEAMMATE }], }; hasura = { @@ -43,7 +44,17 @@ describe("ChatMessageEvent", () => { } if (query.match_lineups_by_pk) { - return { match_lineups_by_pk: lineup }; + const speakerId = + query.match_lineups_by_pk.lineup_players.__args.where.steam_id._eq; + + return { + match_lineups_by_pk: lineup && { + ...lineup, + lineup_players: lineup.lineup_players.filter( + ({ steam_id }) => steam_id === speakerId, + ), + }, + }; } throw new Error("unexpected query"); @@ -113,8 +124,6 @@ describe("ChatMessageEvent", () => { }); it("lets the lineup's coach speak in its team room", async () => { - lineup!.lineup_players = []; - processor.setData(MATCH_ID, { player: COACH, message: "timeout", @@ -196,7 +205,7 @@ describe("ChatMessageEvent", () => { }); it("drops a speaker who is not on the lineup", async () => { - lineup!.lineup_players = []; + lineup!.lineup_players = [{ steam_id: TEAMMATE }]; await send({ teamOnly: true, lineupId: LINEUP_ID }); @@ -204,7 +213,7 @@ describe("ChatMessageEvent", () => { }); it("drops a speaker when the lineup has no coach", async () => { - lineup!.lineup_players = []; + lineup!.lineup_players = [{ steam_id: TEAMMATE }]; lineup!.coach_steam_id = null; await send({ teamOnly: true, lineupId: LINEUP_ID }); diff --git a/test/match-team-chat-notification-type.spec.ts b/test/match-team-chat-notification-type.spec.ts index 2a8dfc1d..8a3bdb31 100644 --- a/test/match-team-chat-notification-type.spec.ts +++ b/test/match-team-chat-notification-type.spec.ts @@ -4,7 +4,7 @@ import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; // Team rooms moved from ChatMessage to MatchChatMessage. The read-clear and the // bell collapse look rows up by the new type, so any team row left on the old -// one would sit unread in the bell forever. +// one would never clear when its room is read. describe("match team chat notification type migration", () => { let db: SqlTestDb; From 25a5546fd06f8cea78dc815c0f05eaf982497fce Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:07:46 -0400 Subject: [PATCH 3/5] bug: send team chat under its own event so an older api drops it --- src/chat/chat.service.ts | 11 +- src/matches/events/ChatMessageEvent.spec.ts | 206 ++++------------- src/matches/events/ChatMessageEvent.ts | 95 ++------ .../events/TeamChatMessageEvent.spec.ts | 207 ++++++++++++++++++ src/matches/events/TeamChatMessageEvent.ts | 66 ++++++ src/matches/events/index.spec.ts | 12 + src/matches/events/index.ts | 2 + src/matches/match-events.gateway.spec.ts | 19 ++ 8 files changed, 366 insertions(+), 252 deletions(-) create mode 100644 src/matches/events/TeamChatMessageEvent.spec.ts create mode 100644 src/matches/events/TeamChatMessageEvent.ts create mode 100644 src/matches/events/index.spec.ts diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 84b0bd6b..ef9f4f10 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -1784,11 +1784,12 @@ export class ChatService { // Match chat is its own notification type, and so its own push category. // - // Every line typed in-game is relayed by ChatMessageEvent -- all chat into - // the match room, team chat into the lineup's team room -- so a live match - // fires this per lineup member per line, and the player it reaches is the - // one already reading those lines in the game. Sharing a category with direct - // messages meant the only way to stop that was to mute DMs too. + // Every line typed in-game is relayed -- all chat into the match room by + // ChatMessageEvent, team chat into the lineup's team room by + // TeamChatMessageEvent -- so a live match fires this per lineup member per + // line, and the player it reaches is the one already reading those lines in + // the game. Sharing a category with direct messages meant the only way to + // stop that was to mute DMs too. // // The insert, the bell collapse and the read-clear all have to agree on the // type or the collapse stops collapsing and the badge never clears. diff --git a/src/matches/events/ChatMessageEvent.spec.ts b/src/matches/events/ChatMessageEvent.spec.ts index e6ff58de..d6e26a0d 100644 --- a/src/matches/events/ChatMessageEvent.spec.ts +++ b/src/matches/events/ChatMessageEvent.spec.ts @@ -4,66 +4,40 @@ import ChatMessageEvent from "./ChatMessageEvent"; const MATCH_ID = "11111111-1111-1111-1111-111111111111"; const LINEUP_ID = "22222222-2222-2222-2222-222222222222"; -const OTHER_MATCH_ID = "33333333-3333-3333-3333-333333333333"; const SPEAKER = "76561198000000001"; -const COACH = "76561198000000002"; -const TEAMMATE = "76561198000000003"; describe("ChatMessageEvent", () => { let processor: ChatMessageEvent; let hasura: { query: jest.Mock }; let chat: { sendMessageToChat: jest.Mock }; - let lineup: { - id: string; - match_id: string; - coach_steam_id: string | null; - lineup_players: Array<{ steam_id: string }>; - } | null; - - const speaker = { - name: "keith", - role: "user", - steam_id: SPEAKER, - profile_url: null as string | null, - avatar_url: null as string | null, - discord_id: null as string | null, - }; + let logger: Logger; + let speaker: Record | null; beforeEach(() => { - lineup = { - id: LINEUP_ID, - match_id: MATCH_ID, - coach_steam_id: COACH, - lineup_players: [{ steam_id: SPEAKER }, { steam_id: TEAMMATE }], + speaker = { + name: "keith", + role: "user", + steam_id: SPEAKER, + profile_url: null, + avatar_url: null, + discord_id: null, }; hasura = { query: jest.fn(async (query: Record) => { if (query.players_by_pk) { - return { players_by_pk: { ...speaker } }; - } - - if (query.match_lineups_by_pk) { - const speakerId = - query.match_lineups_by_pk.lineup_players.__args.where.steam_id._eq; - - return { - match_lineups_by_pk: lineup && { - ...lineup, - lineup_players: lineup.lineup_players.filter( - ({ steam_id }) => steam_id === speakerId, - ), - }, - }; + return { players_by_pk: speaker }; } throw new Error("unexpected query"); }), }; chat = { sendMessageToChat: jest.fn(async () => ({ accepted: true })) }; + logger = new Logger("ChatMessageEventTest"); + jest.spyOn(logger, "warn").mockImplementation(() => undefined); processor = new ChatMessageEvent( - new Logger("ChatMessageEventTest"), + logger, hasura as any, {} as any, chat as any, @@ -74,159 +48,53 @@ describe("ChatMessageEvent", () => { const send = async (data: Record) => { processor.setData(MATCH_ID, { player: SPEAKER, - message: "rotate b", + message: "gl hf", ...data, } as any); await processor.process(); }; - const lineupQuery = () => - hasura.query.mock.calls - .map(([query]) => query.match_lineups_by_pk) - .find(Boolean); - - it("relays all chat into the match room as it always has", async () => { + it("relays all chat into the match room", async () => { await send({}); + expect(chat.sendMessageToChat).toHaveBeenCalledTimes(1); expect(chat.sendMessageToChat).toHaveBeenCalledWith( ChatLobbyType.Match, MATCH_ID, expect.objectContaining({ steam_id: SPEAKER }), - "rotate b", - true, - "game", - ); - expect(lineupQuery()).toBeUndefined(); - }); - - it("relays team chat into the speaker's team room", async () => { - await send({ teamOnly: true, lineupId: LINEUP_ID }); - - expect(chat.sendMessageToChat).toHaveBeenCalledTimes(1); - expect(chat.sendMessageToChat).toHaveBeenCalledWith( - ChatLobbyType.MatchTeam, - `${MATCH_ID}:${LINEUP_ID}`, - expect.objectContaining({ steam_id: SPEAKER }), - "rotate b", + "gl hf", true, "game", ); }); - it("checks the speaker against the lineup the plugin named", async () => { - await send({ teamOnly: true, lineupId: LINEUP_ID }); - - expect(lineupQuery().__args).toEqual({ id: LINEUP_ID }); - expect(lineupQuery().lineup_players.__args).toEqual({ - where: { steam_id: { _eq: SPEAKER } }, - }); - }); + it("drops a line from a player it does not know", async () => { + speaker = null; - it("lets the lineup's coach speak in its team room", async () => { - processor.setData(MATCH_ID, { - player: COACH, - message: "timeout", - teamOnly: true, - lineupId: LINEUP_ID, - }); - await processor.process(); + await send({}); - expect(chat.sendMessageToChat).toHaveBeenCalledWith( - ChatLobbyType.MatchTeam, - `${MATCH_ID}:${LINEUP_ID}`, - expect.anything(), - "timeout", - true, - "game", - ); + expect(chat.sendMessageToChat).not.toHaveBeenCalled(); }); - it("keys the room on the lineup as stored, whatever case the plugin sent", async () => { - await send({ teamOnly: true, lineupId: LINEUP_ID.toUpperCase() }); - - expect(chat.sendMessageToChat.mock.calls[0][1]).toBe( - `${MATCH_ID}:${LINEUP_ID}`, - ); - }); + // team lines have their own event; one on this event is from a plugin on + // the contract that sent them as chat, and must not reach the other team + describe("drops team fields instead of posting them to the match room", () => { + it.each([ + ["a team line", { teamOnly: true, lineupId: LINEUP_ID }], + ["a lone team flag", { teamOnly: true }], + ["a lone lineup", { lineupId: LINEUP_ID }], + ["a false team flag", { teamOnly: false }], + ["a null lineup", { lineupId: null }], + ])("%s", async (_, data) => { + await send(data); - describe("never falls back to the match room", () => { - const expectDropped = () => { expect(chat.sendMessageToChat).not.toHaveBeenCalled(); - }; - - it("drops team chat without a lineup", async () => { - await send({ teamOnly: true }); - - expectDropped(); - expect(lineupQuery()).toBeUndefined(); + expect(hasura.query).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("dropping chat"), + expect.anything(), + ); }); - - it("drops a lineup id that is not a uuid", async () => { - await send({ teamOnly: true, lineupId: "lineup-1" }); - - expectDropped(); - expect(lineupQuery()).toBeUndefined(); - }); - - it("drops a lineup id that is not a string", async () => { - await send({ teamOnly: true, lineupId: 42 }); - - expectDropped(); - }); - - it("drops a lineup without the team flag", async () => { - // both or neither: a lone lineup id is a team line gone wrong, not all chat - await send({ lineupId: LINEUP_ID }); - - expectDropped(); - }); - - it("drops a team flag that is not a boolean", async () => { - await send({ teamOnly: "true", lineupId: LINEUP_ID }); - - expectDropped(); - }); - - it("drops a lineup that does not exist", async () => { - lineup = null; - - await send({ teamOnly: true, lineupId: LINEUP_ID }); - - expectDropped(); - }); - - it("drops a lineup from another match", async () => { - lineup!.match_id = OTHER_MATCH_ID; - - await send({ teamOnly: true, lineupId: LINEUP_ID }); - - expectDropped(); - }); - - it("drops a speaker who is not on the lineup", async () => { - lineup!.lineup_players = [{ steam_id: TEAMMATE }]; - - await send({ teamOnly: true, lineupId: LINEUP_ID }); - - expectDropped(); - }); - - it("drops a speaker when the lineup has no coach", async () => { - lineup!.lineup_players = [{ steam_id: TEAMMATE }]; - lineup!.coach_steam_id = null; - - await send({ teamOnly: true, lineupId: LINEUP_ID }); - - expectDropped(); - }); - }); - - it("drops a line from a player it does not know", async () => { - hasura.query.mockResolvedValueOnce({ players_by_pk: null }); - - await send({ teamOnly: true, lineupId: LINEUP_ID }); - - expect(chat.sendMessageToChat).not.toHaveBeenCalled(); }); }); diff --git a/src/matches/events/ChatMessageEvent.ts b/src/matches/events/ChatMessageEvent.ts index 85aeaefb..c8c8e3ed 100644 --- a/src/matches/events/ChatMessageEvent.ts +++ b/src/matches/events/ChatMessageEvent.ts @@ -4,13 +4,16 @@ import MatchEventProcessor from "./abstracts/MatchEventProcessor"; export default class ChatMessageEvent extends MatchEventProcessor<{ player: string; message: string; - teamOnly?: boolean; - lineupId?: string; + teamOnly?: unknown; + lineupId?: unknown; }> { - private static readonly UUID = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - public async process() { + const room = await this.room(); + + if (!room) { + return; + } + const { players_by_pk } = await this.hasura.query({ players_by_pk: { __args: { @@ -30,27 +33,9 @@ export default class ChatMessageEvent extends MatchEventProcessor<{ return; } - if (!this.isTeamMessage()) { - await this.chat.sendMessageToChat( - ChatLobbyType.Match, - this.matchId, - players_by_pk, - this.data.message, - true, - "game", - ); - return; - } - - const teamRoomId = await this.teamRoomId(); - - if (!teamRoomId) { - return; - } - await this.chat.sendMessageToChat( - ChatLobbyType.MatchTeam, - teamRoomId, + room.type, + room.id, players_by_pk, this.data.message, true, @@ -58,66 +43,20 @@ export default class ChatMessageEvent extends MatchEventProcessor<{ ); } - // Either field marks a team line, and a team line this cannot place is - // dropped rather than sent to the match room, which holds the other team too. - private isTeamMessage(): boolean { - return Boolean(this.data.teamOnly) || this.data.lineupId != null; - } - - private async teamRoomId(): Promise { + // Team lines travel as teamChat so an api that predates them drops them as + // an unknown event. One that still carries team fields here comes from a + // plugin on the old contract, and the match room holds the other team too. + protected async room(): Promise<{ type: ChatLobbyType; id: string } | null> { const { teamOnly, lineupId, player } = this.data; - if ( - teamOnly !== true || - typeof lineupId !== "string" || - !ChatMessageEvent.UUID.test(lineupId) - ) { + if (teamOnly !== undefined || lineupId !== undefined) { this.logger.warn( - `[${this.matchId}] dropping team chat from ${player}: malformed lineup`, + `[${this.matchId}] dropping chat from ${player}: team fields on the all chat event`, { teamOnly, lineupId }, ); return null; } - const { match_lineups_by_pk } = await this.hasura.query({ - match_lineups_by_pk: { - __args: { - id: lineupId, - }, - id: true, - match_id: true, - coach_steam_id: true, - lineup_players: { - __args: { - where: { - steam_id: { - _eq: player, - }, - }, - }, - steam_id: true, - }, - }, - }); - - if (!match_lineups_by_pk || match_lineups_by_pk.match_id !== this.matchId) { - this.logger.warn( - `[${this.matchId}] dropping team chat from ${player}: lineup ${lineupId} is not part of this match`, - ); - return null; - } - - const isCoach = - match_lineups_by_pk.coach_steam_id != null && - String(match_lineups_by_pk.coach_steam_id) === String(player); - - if (match_lineups_by_pk.lineup_players.length === 0 && !isCoach) { - this.logger.warn( - `[${this.matchId}] dropping team chat from ${player}: not on lineup ${lineupId}`, - ); - return null; - } - - return `${match_lineups_by_pk.match_id}:${match_lineups_by_pk.id}`; + return { type: ChatLobbyType.Match, id: this.matchId }; } } diff --git a/src/matches/events/TeamChatMessageEvent.spec.ts b/src/matches/events/TeamChatMessageEvent.spec.ts new file mode 100644 index 00000000..5c1e031d --- /dev/null +++ b/src/matches/events/TeamChatMessageEvent.spec.ts @@ -0,0 +1,207 @@ +import { Logger } from "@nestjs/common"; +import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; +import TeamChatMessageEvent from "./TeamChatMessageEvent"; + +const MATCH_ID = "11111111-1111-1111-1111-111111111111"; +const LINEUP_ID = "22222222-2222-2222-2222-222222222222"; +const OTHER_MATCH_ID = "33333333-3333-3333-3333-333333333333"; +const SPEAKER = "76561198000000001"; +const COACH = "76561198000000002"; +const TEAMMATE = "76561198000000003"; + +describe("TeamChatMessageEvent", () => { + let processor: TeamChatMessageEvent; + let hasura: { query: jest.Mock }; + let chat: { sendMessageToChat: jest.Mock }; + let known: boolean; + let lineup: { + id: string; + match_id: string; + coach_steam_id: string | null; + lineup_players: Array<{ steam_id: string }>; + } | null; + + beforeEach(() => { + known = true; + lineup = { + id: LINEUP_ID, + match_id: MATCH_ID, + coach_steam_id: COACH, + lineup_players: [{ steam_id: SPEAKER }, { steam_id: TEAMMATE }], + }; + + hasura = { + query: jest.fn(async (query: Record) => { + if (query.players_by_pk) { + return { + players_by_pk: known + ? { + name: "keith", + role: "user", + steam_id: query.players_by_pk.__args.steam_id, + profile_url: null as string | null, + avatar_url: null as string | null, + discord_id: null as string | null, + } + : null, + }; + } + + if (query.match_lineups_by_pk) { + const speakerId = + query.match_lineups_by_pk.lineup_players.__args.where.steam_id._eq; + + return { + match_lineups_by_pk: lineup && { + ...lineup, + lineup_players: lineup.lineup_players.filter( + ({ steam_id }) => steam_id === speakerId, + ), + }, + }; + } + + throw new Error("unexpected query"); + }), + }; + chat = { sendMessageToChat: jest.fn(async () => ({ accepted: true })) }; + + const logger = new Logger("TeamChatMessageEventTest"); + jest.spyOn(logger, "warn").mockImplementation(() => undefined); + + processor = new TeamChatMessageEvent( + logger, + hasura as any, + {} as any, + chat as any, + {} as any, + ); + }); + + const send = async (data: Record) => { + processor.setData(MATCH_ID, { + player: SPEAKER, + message: "rotate b", + ...data, + } as any); + + await processor.process(); + }; + + const lineupQuery = () => + hasura.query.mock.calls + .map(([query]) => query.match_lineups_by_pk) + .find(Boolean); + + it("relays team chat into the speaker's team room", async () => { + await send({ lineupId: LINEUP_ID }); + + expect(chat.sendMessageToChat).toHaveBeenCalledTimes(1); + expect(chat.sendMessageToChat).toHaveBeenCalledWith( + ChatLobbyType.MatchTeam, + `${MATCH_ID}:${LINEUP_ID}`, + expect.objectContaining({ steam_id: SPEAKER }), + "rotate b", + true, + "game", + ); + }); + + it("checks the speaker against the lineup the plugin named", async () => { + await send({ lineupId: LINEUP_ID }); + + expect(lineupQuery().__args).toEqual({ id: LINEUP_ID }); + expect(lineupQuery().lineup_players.__args).toEqual({ + where: { steam_id: { _eq: SPEAKER } }, + }); + }); + + it("lets the lineup's coach speak in its team room", async () => { + await send({ player: COACH, message: "timeout", lineupId: LINEUP_ID }); + + expect(chat.sendMessageToChat).toHaveBeenCalledWith( + ChatLobbyType.MatchTeam, + `${MATCH_ID}:${LINEUP_ID}`, + expect.objectContaining({ steam_id: COACH }), + "timeout", + true, + "game", + ); + }); + + it("keys the room on the lineup as stored, whatever case the plugin sent", async () => { + await send({ lineupId: LINEUP_ID.toUpperCase() }); + + expect(chat.sendMessageToChat.mock.calls[0][1]).toBe( + `${MATCH_ID}:${LINEUP_ID}`, + ); + }); + + describe("never falls back to the match room", () => { + const expectDropped = () => { + expect(chat.sendMessageToChat).not.toHaveBeenCalled(); + }; + + it("drops team chat without a lineup", async () => { + await send({}); + + expectDropped(); + expect(hasura.query).not.toHaveBeenCalled(); + }); + + it("drops a lineup id that is not a uuid", async () => { + await send({ lineupId: "lineup-1" }); + + expectDropped(); + expect(hasura.query).not.toHaveBeenCalled(); + }); + + it("drops a lineup id that is not a string", async () => { + await send({ lineupId: 42 }); + + expectDropped(); + expect(hasura.query).not.toHaveBeenCalled(); + }); + + it("drops a lineup that does not exist", async () => { + lineup = null; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a lineup from another match", async () => { + lineup!.match_id = OTHER_MATCH_ID; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a speaker who is not on the lineup", async () => { + lineup!.lineup_players = [{ steam_id: TEAMMATE }]; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a speaker when the lineup has no coach", async () => { + lineup!.lineup_players = [{ steam_id: TEAMMATE }]; + lineup!.coach_steam_id = null; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + + it("drops a line from a player it does not know", async () => { + known = false; + + await send({ lineupId: LINEUP_ID }); + + expectDropped(); + }); + }); +}); diff --git a/src/matches/events/TeamChatMessageEvent.ts b/src/matches/events/TeamChatMessageEvent.ts new file mode 100644 index 00000000..6f7d680a --- /dev/null +++ b/src/matches/events/TeamChatMessageEvent.ts @@ -0,0 +1,66 @@ +import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; +import ChatMessageEvent from "./ChatMessageEvent"; + +export default class TeamChatMessageEvent extends ChatMessageEvent { + private static readonly UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + protected async room(): Promise<{ type: ChatLobbyType; id: string } | null> { + const { lineupId, player } = this.data; + + if ( + typeof lineupId !== "string" || + !TeamChatMessageEvent.UUID.test(lineupId) + ) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: malformed lineup`, + { lineupId }, + ); + return null; + } + + const { match_lineups_by_pk } = await this.hasura.query({ + match_lineups_by_pk: { + __args: { + id: lineupId, + }, + id: true, + match_id: true, + coach_steam_id: true, + lineup_players: { + __args: { + where: { + steam_id: { + _eq: player, + }, + }, + }, + steam_id: true, + }, + }, + }); + + if (!match_lineups_by_pk || match_lineups_by_pk.match_id !== this.matchId) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: lineup ${lineupId} is not part of this match`, + ); + return null; + } + + const isCoach = + match_lineups_by_pk.coach_steam_id != null && + String(match_lineups_by_pk.coach_steam_id) === String(player); + + if (match_lineups_by_pk.lineup_players.length === 0 && !isCoach) { + this.logger.warn( + `[${this.matchId}] dropping team chat from ${player}: not on lineup ${lineupId}`, + ); + return null; + } + + return { + type: ChatLobbyType.MatchTeam, + id: `${match_lineups_by_pk.match_id}:${match_lineups_by_pk.id}`, + }; + } +} diff --git a/src/matches/events/index.spec.ts b/src/matches/events/index.spec.ts new file mode 100644 index 00000000..54686898 --- /dev/null +++ b/src/matches/events/index.spec.ts @@ -0,0 +1,12 @@ +import { MatchEvents } from "./index"; +import ChatMessageEvent from "./ChatMessageEvent"; +import TeamChatMessageEvent from "./TeamChatMessageEvent"; + +describe("MatchEvents", () => { + // the plugin sends team lines under their own name so that an api without + // a handler for it drops them rather than posting them as all chat + it("routes team chat and all chat to separate handlers", () => { + expect(MatchEvents.chat).toBe(ChatMessageEvent); + expect(MatchEvents.teamChat).toBe(TeamChatMessageEvent); + }); +}); diff --git a/src/matches/events/index.ts b/src/matches/events/index.ts index aece0351..c717c888 100644 --- a/src/matches/events/index.ts +++ b/src/matches/events/index.ts @@ -16,6 +16,7 @@ import FlashEvent from "./FlashEvent"; import ObjectiveEvent from "./ObjectiveEvent"; import UnusedUtility from "./UnusedUtility"; import ChatMessageEvent from "./ChatMessageEvent"; +import TeamChatMessageEvent from "./TeamChatMessageEvent"; import MatchSurrendered from "./MatchSurrendered"; import MatchAbandoned from "./MatchAbandoned"; @@ -26,6 +27,7 @@ export const MatchEvents = { updateLineups: MatchUpdatedLineupsEvent, chat: ChatMessageEvent, + teamChat: TeamChatMessageEvent, /** * Player diff --git a/src/matches/match-events.gateway.spec.ts b/src/matches/match-events.gateway.spec.ts index ac2fecca..4a819359 100644 --- a/src/matches/match-events.gateway.spec.ts +++ b/src/matches/match-events.gateway.spec.ts @@ -210,6 +210,25 @@ describe("MatchEventsGateway.handleMatchEvent dedup", () => { expect(store.has(`match-events:${MATCH_1}:msg-1`)).toBe(false); }); + it("acks and drops an event it has no handler for", async () => { + // how an api that predates teamChat drops team lines: not processed, + // and acked so the plugin stops retrying + const { gateway, cache, processor, logger } = makeGateway({}); + const result = await gateway.handleMatchEvent({ + ...message, + data: { event: "teamChat", data: { player: "p", message: "m" } }, + } as any); + + expect(result).toBe("msg-1"); + expect(processor.setData).not.toHaveBeenCalled(); + expect(processor.process).not.toHaveBeenCalled(); + expect(cache.put).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + "unable to find event handler", + "teamChat", + ); + }); + it("short-circuits on a dedup hit without processing", async () => { const { gateway, processor } = makeGateway({ cacheHit: true }); const result = await gateway.handleMatchEvent(authedSocket(), event()); From 1dbab050cba9092256b92ad5bda5782325ece261 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:27:08 -0400 Subject: [PATCH 4/5] bug: drop team chat with a malformed speaker before the roster check --- src/matches/events/TeamChatMessageEvent.spec.ts | 12 ++++++++++++ src/matches/events/TeamChatMessageEvent.ts | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/matches/events/TeamChatMessageEvent.spec.ts b/src/matches/events/TeamChatMessageEvent.spec.ts index 5c1e031d..d825bb6f 100644 --- a/src/matches/events/TeamChatMessageEvent.spec.ts +++ b/src/matches/events/TeamChatMessageEvent.spec.ts @@ -163,6 +163,18 @@ describe("TeamChatMessageEvent", () => { expect(hasura.query).not.toHaveBeenCalled(); }); + it.each([ + ["missing", undefined], + ["empty", ""], + ["not a string", 76561198000000001], + ["not a steam id", "keith"], + ])("drops a speaker that is %s", async (_, player) => { + await send({ player, lineupId: LINEUP_ID }); + + expectDropped(); + expect(hasura.query).not.toHaveBeenCalled(); + }); + it("drops a lineup that does not exist", async () => { lineup = null; diff --git a/src/matches/events/TeamChatMessageEvent.ts b/src/matches/events/TeamChatMessageEvent.ts index 6f7d680a..838a1337 100644 --- a/src/matches/events/TeamChatMessageEvent.ts +++ b/src/matches/events/TeamChatMessageEvent.ts @@ -8,6 +8,16 @@ export default class TeamChatMessageEvent extends ChatMessageEvent { protected async room(): Promise<{ type: ChatLobbyType; id: string } | null> { const { lineupId, player } = this.data; + // a missing speaker would leave the roster filter below as + // `steam_id: {}`, which matches every player on the lineup + if (typeof player !== "string" || !/^\d+$/.test(player)) { + this.logger.warn( + `[${this.matchId}] dropping team chat: malformed player`, + { player }, + ); + return null; + } + if ( typeof lineupId !== "string" || !TeamChatMessageEvent.UUID.test(lineupId) From e14bb5d44928602c757d8f2bd266f95c02b2b7db Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 20:59:54 -0400 Subject: [PATCH 5/5] test: fit the unknown-event test to the bound gateway harness --- src/matches/match-events.gateway.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/matches/match-events.gateway.spec.ts b/src/matches/match-events.gateway.spec.ts index 4a819359..6080ec30 100644 --- a/src/matches/match-events.gateway.spec.ts +++ b/src/matches/match-events.gateway.spec.ts @@ -213,19 +213,21 @@ describe("MatchEventsGateway.handleMatchEvent dedup", () => { it("acks and drops an event it has no handler for", async () => { // how an api that predates teamChat drops team lines: not processed, // and acked so the plugin stops retrying - const { gateway, cache, processor, logger } = makeGateway({}); - const result = await gateway.handleMatchEvent({ - ...message, - data: { event: "teamChat", data: { player: "p", message: "m" } }, - } as any); + const { gateway, cache, processor, logger } = makeGateway(); + const result = await gateway.handleMatchEvent( + authedSocket(), + event({ name: "notAnEvent", data: { player: "p", message: "m" } }), + ); expect(result).toBe("msg-1"); expect(processor.setData).not.toHaveBeenCalled(); expect(processor.process).not.toHaveBeenCalled(); - expect(cache.put).not.toHaveBeenCalled(); + expect(cache.put.mock.calls.some(([key]) => key.endsWith(":msg-1"))).toBe( + false, + ); expect(logger.warn).toHaveBeenCalledWith( "unable to find event handler", - "teamChat", + "notAnEvent", ); });