Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
UPDATE public.notifications
SET type = 'ChatMessage'
WHERE type = 'MatchChatMessage'
AND entity_id LIKE 'match\_team:%' ESCAPE '\';
Original file line number Diff line number Diff line change
@@ -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 never clear when its room is read. `_` is a LIKE
-- wildcard, hence the escape.
UPDATE public.notifications
SET type = 'MatchChatMessage'
WHERE type = 'ChatMessage'
AND entity_id LIKE 'match\_team:%' ESCAPE '\';
114 changes: 114 additions & 0 deletions src/chat/chat.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })) };
}
Expand Down Expand Up @@ -691,6 +704,107 @@ 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("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],
Expand Down
35 changes: 29 additions & 6 deletions src/chat/chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1784,18 +1784,21 @@ 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 -- 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.
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
Expand Down Expand Up @@ -2537,6 +2540,7 @@ export class ChatService {
): Promise<void> {
const users = await this.getAllUsersInLobby(type, id);
const eventName = `lobby:${type}:${id}:${event}`;
const roster = await this.teamRoomRoster(type, id);

const hiding =
author === undefined
Expand All @@ -2547,6 +2551,10 @@ export class ChatService {
);

for (const { steamId } of users) {
if (roster && !roster.has(String(steamId))) {
continue;
}

if (hiding.has(steamId)) {
continue;
}
Expand Down Expand Up @@ -2585,6 +2593,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<Set<string> | null> {
if (type !== ChatLobbyType.MatchTeam) {
return null;
}

return new Set(await this.getLobbyMemberSteamIds(type, id));
}

public async removeFromLobby(
type: ChatLobbyType,
id: string,
Expand Down
100 changes: 100 additions & 0 deletions src/matches/events/ChatMessageEvent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 SPEAKER = "76561198000000001";

describe("ChatMessageEvent", () => {
let processor: ChatMessageEvent;
let hasura: { query: jest.Mock };
let chat: { sendMessageToChat: jest.Mock };
let logger: Logger;
let speaker: Record<string, unknown> | null;

beforeEach(() => {
speaker = {
name: "keith",
role: "user",
steam_id: SPEAKER,
profile_url: null,
avatar_url: null,
discord_id: null,
};

hasura = {
query: jest.fn(async (query: Record<string, any>) => {
if (query.players_by_pk) {
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(
logger,
hasura as any,
{} as any,
chat as any,
{} as any,
);
});

const send = async (data: Record<string, unknown>) => {
processor.setData(MATCH_ID, {
player: SPEAKER,
message: "gl hf",
...data,
} as any);

await processor.process();
};

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 }),
"gl hf",
true,
"game",
);
});

it("drops a line from a player it does not know", async () => {
speaker = null;

await send({});

expect(chat.sendMessageToChat).not.toHaveBeenCalled();
});

// 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);

expect(chat.sendMessageToChat).not.toHaveBeenCalled();
expect(hasura.query).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("dropping chat"),
expect.anything(),
);
});
});
});
29 changes: 27 additions & 2 deletions src/matches/events/ChatMessageEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,16 @@ import MatchEventProcessor from "./abstracts/MatchEventProcessor";
export default class ChatMessageEvent extends MatchEventProcessor<{
player: string;
message: string;
teamOnly?: unknown;
lineupId?: unknown;
}> {
public async process() {
const room = await this.room();

if (!room) {
return;
}

const { players_by_pk } = await this.hasura.query({
players_by_pk: {
__args: {
Expand All @@ -26,12 +34,29 @@ export default class ChatMessageEvent extends MatchEventProcessor<{
}

await this.chat.sendMessageToChat(
ChatLobbyType.Match,
this.matchId,
room.type,
room.id,
players_by_pk,
this.data.message,
true,
"game",
);
}

// 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 !== undefined || lineupId !== undefined) {
this.logger.warn(
`[${this.matchId}] dropping chat from ${player}: team fields on the all chat event`,
{ teamOnly, lineupId },
);
return null;
}

return { type: ChatLobbyType.Match, id: this.matchId };
}
}
Loading
Loading