diff --git a/generated/schema.graphql b/generated/schema.graphql index 6511583f..3d2a4ac0 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -13616,6 +13616,9 @@ enum e_notification_types_constraint { } enum e_notification_types_enum { + """An admin is calling you""" + AdminCall + """You received an award""" AwardGranted @@ -13673,6 +13676,9 @@ enum e_notification_types_enum { """A new message in a match's chat""" MatchChatMessage + """A match was found and is waiting for you to ready up""" + MatchFound + """A Valve match you played was imported to 5stack""" MatchImported diff --git a/generated/schema.ts b/generated/schema.ts index 740138de..11849c57 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -5662,7 +5662,7 @@ export interface e_notification_types_aggregate_fields { /** unique or primary key constraints on table "e_notification_types" */ export type e_notification_types_constraint = 'e_notification_types_pkey' -export type e_notification_types_enum = 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady' +export type e_notification_types_enum = 'AdminCall' | 'AwardGranted' | 'ChatMessage' | 'ClipReady' | 'DedicatedServerRconStatus' | 'DedicatedServerStatus' | 'DraftInvite' | 'EloRecompute' | 'EventReminder' | 'FormTeamSuggestion' | 'GameNodeStatus' | 'GameUpdate' | 'LeagueMatchUnscheduled' | 'LeagueProposalAccepted' | 'LeagueProposalDeclined' | 'LeagueProposalReceived' | 'LeagueRegistrationDecision' | 'LeagueRosterUndersized' | 'MatchAbandoned' | 'MatchChatMessage' | 'MatchFound' | 'MatchImported' | 'MatchStatsReady' | 'MatchStatusChange' | 'MatchSupport' | 'NadeDriftScanFinished' | 'NadePracticeInvite' | 'NadePracticeReady' | 'NameChangeApproved' | 'NameChangeDenied' | 'NameChangeRequest' | 'NewsPublished' | 'PlayerReindex' | 'PlayerSanctioned' | 'ScrimAlertMatch' | 'ScrimMatchCanceled' | 'ScrimMatchScheduled' | 'ScrimRequestAccepted' | 'ScrimRequestCountered' | 'ScrimRequestDeclined' | 'ScrimRequestExpired' | 'ScrimRequestReceived' | 'ScrimTimeChanged' | 'SeasonEnded' | 'StorageScan' | 'TeamInvite' | 'TournamentCheckInClosing' | 'TournamentCheckInMissed' | 'TournamentCheckInOpen' | 'TournamentCreated' | 'TournamentInvite' | 'TournamentPartySignup' | 'TournamentReminder' | 'TournamentTeamInvite' | 'UtilityDriftScanFinished' | 'UtilityPracticeInvite' | 'UtilityPracticeReady' /** aggregate max on columns */ @@ -152183,6 +152183,7 @@ export const enumENotificationTypesConstraint = { } export const enumENotificationTypesEnum = { + AdminCall: 'AdminCall' as const, AwardGranted: 'AwardGranted' as const, ChatMessage: 'ChatMessage' as const, ClipReady: 'ClipReady' as const, @@ -152202,6 +152203,7 @@ export const enumENotificationTypesEnum = { LeagueRosterUndersized: 'LeagueRosterUndersized' as const, MatchAbandoned: 'MatchAbandoned' as const, MatchChatMessage: 'MatchChatMessage' as const, + MatchFound: 'MatchFound' as const, MatchImported: 'MatchImported' as const, MatchStatsReady: 'MatchStatsReady' as const, MatchStatusChange: 'MatchStatusChange' as const, diff --git a/hasura/enums/notification-types.sql b/hasura/enums/notification-types.sql index 4cd7a5b0..97d44c45 100644 --- a/hasura/enums/notification-types.sql +++ b/hasura/enums/notification-types.sql @@ -49,6 +49,8 @@ INSERT INTO e_notification_types ("value", "description") VALUES ('UtilityPracticeInvite', 'You were invited to a utility practice session'), ('UtilityPracticeReady', 'Your utility practice server is ready'), ('UtilityDriftScanFinished', 'A utility drift scan finished'), - ('TournamentPartySignup', 'Your lobby was signed up for a tournament as a free agent party') + ('TournamentPartySignup', 'Your lobby was signed up for a tournament as a free agent party'), + ('MatchFound', 'A match was found and is waiting for you to ready up'), + ('AdminCall', 'An admin is calling you') ON CONFLICT("value") DO UPDATE SET "description" = EXCLUDED."description"; diff --git a/src/matches/camera/camera.service.spec.ts b/src/matches/camera/camera.service.spec.ts index 469e9a9b..1d80beda 100644 --- a/src/matches/camera/camera.service.spec.ts +++ b/src/matches/camera/camera.service.spec.ts @@ -18,6 +18,12 @@ describe("CameraService authorization", () => { let mediaMtx: { proxySdp: jest.Mock; isPathReady: jest.Mock; kickSessions: jest.Mock }; let matchAssistant: { isOrganizer: jest.Mock }; let gameStreamer: { validateStatusOriginAuth: jest.Mock }; + let notifications: { + notifyAdminCall: jest.Mock; + retractAdminCall: jest.Mock; + }; + let redisKeys: Map; + let redis: { set: jest.Mock; del: jest.Mock }; let service: CameraService; const scopeRow = (myLineupId: string | null, allowTeammates: boolean) => [ @@ -35,6 +41,21 @@ describe("CameraService authorization", () => { gameStreamer = { validateStatusOriginAuth: jest.fn().mockResolvedValue(true), }; + notifications = { + notifyAdminCall: jest.fn().mockResolvedValue(1), + retractAdminCall: jest.fn().mockResolvedValue(undefined), + }; + redisKeys = new Map(); + redis = { + set: jest.fn(async (key: string, value: unknown, ...args: unknown[]) => { + if (args.includes("NX") && redisKeys.has(key)) { + return null; + } + redisKeys.set(key, String(value)); + return "OK"; + }), + del: jest.fn(async (key: string) => (redisKeys.delete(key) ? 1 : 0)), + }; service = new CameraService( new Logger("CameraAuthTest"), @@ -43,6 +64,8 @@ describe("CameraService authorization", () => { mediaMtx as any, matchAssistant as any, gameStreamer as any, + notifications as any, + { getConnection: () => redis } as any, ); }); @@ -291,6 +314,183 @@ describe("CameraService authorization", () => { ); }); + describe("admin call ring", () => { + const settle = () => new Promise((resolve) => setImmediate(resolve)); + + // Answers each query by what it asks: the caller's scope, whether the + // target is someone playing this match, and a teammate check. + let caller: { lineupId: string | null; allowTeammates: boolean }; + let targetStatus: string | null; + + beforeEach(() => { + caller = { lineupId: null, allowTeammates: false }; + targetStatus = "Live"; + + postgres.query.mockImplementation(async (sql: string) => { + if (sql.includes("allow_teammates")) { + return scopeRow(caller.lineupId, caller.allowTeammates); + } + if (sql.includes("SELECT m.status")) { + return targetStatus ? [{ status: targetStatus }] : []; + } + return [{ exists: true }]; + }); + }); + + const calls = async (by: User = admin, steamId = player.steam_id) => { + const answer = await service.proxyAdminTalk( + MATCH_ID, + steamId, + by, + "offer", + ); + await settle(); + return answer; + }; + + const playerAnswers = async () => { + await service.proxyPlayerTalk(MATCH_ID, player, "offer"); + await settle(); + }; + + const hangsUp = async () => { + await service.hangupAdminTalk(MATCH_ID, player.steam_id, admin); + await settle(); + }; + + it("rings the player when an admin starts talking to them", async () => { + await calls(); + + expect(notifications.notifyAdminCall).toHaveBeenCalledWith( + MATCH_ID, + player.steam_id, + ); + expect(redis.set).toHaveBeenCalledWith( + CameraService.adminCallRingKey(MATCH_ID, player.steam_id), + 1, + "EX", + 60, + "NX", + ); + }); + + it("rings an organizer's call the same way", async () => { + matchAssistant.isOrganizer.mockResolvedValue(true); + + await calls(organizer); + + expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(1); + }); + + it("rings once, not once per publish", async () => { + await calls(); + await calls(); + + expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(1); + }); + + it("keeps each player's ring separate", async () => { + await calls(admin, player.steam_id); + await calls(admin, TEAMMATE); + + expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(2); + }); + + // An organizer's scope is "all" whoever they name, so the organizer of + // one match could otherwise push to any player on the platform. + it("never rings someone who is not playing the match", async () => { + matchAssistant.isOrganizer.mockResolvedValue(true); + targetStatus = null; + + await expect(calls(organizer)).resolves.toBe("answer"); + + expect(notifications.notifyAdminCall).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it("never rings about a match that is over", async () => { + targetStatus = "Finished"; + + await calls(); + + expect(notifications.notifyAdminCall).not.toHaveBeenCalled(); + }); + + it("does not ring when a teammate is the one talking", async () => { + caller = { lineupId: MY_LINEUP, allowTeammates: true }; + + await calls(player, TEAMMATE); + + expect(mediaMtx.proxySdp).toHaveBeenCalled(); + expect(notifications.notifyAdminCall).not.toHaveBeenCalled(); + }); + + it("does not ring for a publish MediaMTX refused", async () => { + mediaMtx.proxySdp.mockRejectedValueOnce(new Error("mediamtx is down")); + + await expect(calls()).rejects.toThrow(/mediamtx/); + + expect(notifications.notifyAdminCall).not.toHaveBeenCalled(); + }); + + it("answers the publish without waiting on the push", async () => { + notifications.notifyAdminCall.mockReturnValue(new Promise(() => {})); + + await expect(calls()).resolves.toBe("answer"); + }); + + it("answers the publish when the push fails", async () => { + notifications.notifyAdminCall.mockRejectedValue(new Error("no hasura")); + + await expect(calls()).resolves.toBe("answer"); + }); + + it("retracts the ring on hangup", async () => { + await calls(); + await hangsUp(); + + expect(notifications.retractAdminCall).toHaveBeenCalledWith( + MATCH_ID, + player.steam_id, + ); + }); + + // Otherwise publish-then-hang-up in a loop rings as fast as it can be + // scripted. + it("does not let a hangup re-arm the ring", async () => { + await calls(); + await hangsUp(); + await calls(); + + expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(1); + }); + + it("rings again once the guard has expired", async () => { + await calls(); + redisKeys.clear(); + await calls(); + + expect(notifications.notifyAdminCall).toHaveBeenCalledTimes(2); + }); + + it("retracts the ring when the player picks up", async () => { + await calls(); + await playerAnswers(); + + expect(notifications.retractAdminCall).toHaveBeenCalledWith( + MATCH_ID, + player.steam_id, + ); + }); + + it("still hangs up when the retract fails", async () => { + notifications.retractAdminCall.mockRejectedValue(new Error("no db")); + + await expect(hangsUp()).resolves.toBeUndefined(); + expect(mediaMtx.kickSessions).toHaveBeenCalled(); + }); + }); + // The broadcast pod has no session; it authenticates as the match itself. describe("broadcast overlay", () => { const AUTH = "match-1:secret"; diff --git a/src/matches/camera/camera.service.ts b/src/matches/camera/camera.service.ts index 0ad603f4..c1aa0341 100644 --- a/src/matches/camera/camera.service.ts +++ b/src/matches/camera/camera.service.ts @@ -1,9 +1,12 @@ import { Injectable, Logger } from "@nestjs/common"; +import Redis from "ioredis"; import { HasuraService } from "../../hasura/hasura.service"; import { PostgresService } from "../../postgres/postgres.service"; import { MediaMtxService } from "../../mediamtx/mediamtx.service"; import { MatchAssistantService } from "../match-assistant/match-assistant.service"; import { GameStreamerService } from "../game-streamer/game-streamer.service"; +import { NotificationsService } from "../../notifications/notifications.service"; +import { RedisManagerService } from "../../redis/redis-manager/redis-manager.service"; import { User } from "../../auth/types/User"; import { isRoleAbove } from "../../utilities/isRoleAbove"; @@ -51,6 +54,14 @@ export type CameraPlayerStatus = { @Injectable() export class CameraService { + // At most one ring per player per match a minute. A retried or renegotiated + // publish arrives here as a fresh one, and the guard deliberately outlives a + // hangup: clearing it there would let publish-then-hang-up in a loop ring a + // player as fast as it could be scripted. + private static readonly ADMIN_CALL_RING_SECONDS = 60; + + private readonly redis: Redis; + constructor( private readonly logger: Logger, private readonly hasura: HasuraService, @@ -58,7 +69,11 @@ export class CameraService { private readonly mediaMtx: MediaMtxService, private readonly matchAssistant: MatchAssistantService, private readonly gameStreamer: GameStreamerService, - ) {} + private readonly notifications: NotificationsService, + redisManager: RedisManagerService, + ) { + this.redis = redisManager.getConnection(); + } public static pathForPlayer(matchId: string, steamId: string) { return `camera-${matchId}-${steamId}`; @@ -70,6 +85,10 @@ export class CameraService { return `camera-talk-${matchId}-${steamId}`; } + public static adminCallRingKey(matchId: string, steamId: string) { + return `camera:admin-call-ring:${matchId}:${steamId}`; + } + // Whether this player's own camera is publishing right now. Used to gate // check-in, which happens before the monitor starts sampling. // @@ -104,9 +123,15 @@ export class CameraService { // was surface area for nothing. Coaches count: they stand behind the team // during a technical timeout, so "on camera" has to mean them too. public async assertCameraPlayer(matchId: string, user: User) { - if (!UUID_PATTERN.test(matchId)) { + if (!(await this.isCameraParticipant(matchId, user.steam_id))) { throw new Error(NOT_AUTHORIZED); } + } + + private async isCameraParticipant(matchId: string, steamId: string) { + if (!UUID_PATTERN.test(matchId)) { + return false; + } const [row] = await this.postgres.query>( `SELECT m.status @@ -127,14 +152,12 @@ export class CameraService { ) ) LIMIT 1`, - [matchId, user.steam_id], + [matchId, steamId], ); // A finished match is not one you can still publish to, the same way an // expired token used to stop working. - if (!row || !CAMERA_ACTIVE_MATCH_STATUSES.includes(row.status)) { - throw new Error(NOT_AUTHORIZED); - } + return Boolean(row) && CAMERA_ACTIVE_MATCH_STATUSES.includes(row.status); } // Site admins, or an organizer of this specific match — deliberately not the @@ -223,7 +246,7 @@ export class CameraService { matchId: string, steamId: string, user: User, - ) { + ): Promise { if (!STEAM_ID_PATTERN.test(steamId)) { throw new Error(NOT_AUTHORIZED); } @@ -231,7 +254,7 @@ export class CameraService { const scope = await this.watchScope(matchId, user); if (scope.kind === "all") { - return; + return scope; } // Same widening as watchScope: a lineup is its roster and its coach, so a @@ -256,6 +279,8 @@ export class CameraService { if (!row) { throw new Error(NOT_AUTHORIZED); } + + return scope; } // The broadcast pod has no site session — it authenticates as the match @@ -345,23 +370,76 @@ export class CameraService { user: User, sdp: string, ) { - await this.assertCanWatchPlayer(matchId, steamId, user); + const scope = await this.assertCanWatchPlayer(matchId, steamId, user); - return this.mediaMtx.proxySdp( + const answer = await this.mediaMtx.proxySdp( CameraService.talkPathForPlayer(matchId, steamId), "whip", sdp, ); + + // "all" is an admin or an organizer. A teammate talking to a teammate is + // not an admin calling, and must not be able to push as one. + if (scope.kind === "all") { + void this.ringPlayer(matchId, steamId); + } + + return answer; } public async proxyPlayerTalk(matchId: string, user: User, sdp: string) { await this.assertCameraPlayer(matchId, user); - return this.mediaMtx.proxySdp( + const answer = await this.mediaMtx.proxySdp( CameraService.talkPathForPlayer(matchId, user.steam_id), "whep", sdp, ); + + void this.endRing(matchId, user.steam_id); + + return answer; + } + + private async ringPlayer(matchId: string, steamId: string) { + try { + // An organizer's scope is "all" without the target ever being checked + // against the match, so without this any organizer of any match could + // push "Admin is calling you" to any player at all. + if (!(await this.isCameraParticipant(matchId, steamId))) { + return; + } + + const claimed = await this.redis.set( + CameraService.adminCallRingKey(matchId, steamId), + 1, + "EX", + CameraService.ADMIN_CALL_RING_SECONDS, + "NX", + ); + + if (!claimed) { + return; + } + + await this.notifications.notifyAdminCall(matchId, steamId); + } catch (error) { + this.logger.warn( + `unable to ring ${steamId} for an admin call on match ${matchId}`, + error, + ); + } + } + + private async endRing(matchId: string, steamId: string) { + try { + await this.notifications.retractAdminCall(matchId, steamId); + } catch (error) { + this.logger.warn( + `unable to retract the admin call ring for ${steamId} on match ${matchId}`, + error, + ); + } } public async getPlayerTalkStatus(matchId: string, user: User) { @@ -390,6 +468,8 @@ export class CameraService { await this.mediaMtx.kickSessions( CameraService.talkPathForPlayer(matchId, steamId), ); + + void this.endRing(matchId, steamId); } public async hangupPlayerTalk(matchId: string, user: User) { diff --git a/src/matchmaking/matchmake.integration.spec.ts b/src/matchmaking/matchmake.integration.spec.ts index 7f625d23..acaee9e6 100644 --- a/src/matchmaking/matchmake.integration.spec.ts +++ b/src/matchmaking/matchmake.integration.spec.ts @@ -16,6 +16,7 @@ import { HasuraService } from "../hasura/hasura.service"; import { MatchAssistantService } from "../matches/match-assistant/match-assistant.service"; import { MatchmakingLobbyService } from "./matchmaking-lobby.service"; import { RedisManagerService } from "../redis/redis-manager/redis-manager.service"; +import { NotificationsService } from "../notifications/notifications.service"; import { MatchmakingQueues } from "./enums/MatchmakingQueues"; import { FakeRedis } from "./testing/fakeRedis"; import { @@ -135,6 +136,13 @@ describe("matchmaking (end to end)", () => { provide: RedisManagerService, useValue: { getConnection: () => redis }, }, + { + provide: NotificationsService, + useValue: { + notifyMatchFound: jest.fn().mockResolvedValue(0), + retractMatchFound: jest.fn().mockResolvedValue(undefined), + }, + }, { provide: `BullQueue_${MatchmakingQueues.Matchmaking}`, useValue: queue as unknown as Queue, diff --git a/src/matchmaking/matchmake.service.spec.ts b/src/matchmaking/matchmake.service.spec.ts index a7f28bc7..a131f385 100644 --- a/src/matchmaking/matchmake.service.spec.ts +++ b/src/matchmaking/matchmake.service.spec.ts @@ -19,6 +19,7 @@ import { HasuraService } from "../hasura/hasura.service"; import { MatchAssistantService } from "../matches/match-assistant/match-assistant.service"; import { MatchmakingLobbyService } from "./matchmaking-lobby.service"; import { RedisManagerService } from "../redis/redis-manager/redis-manager.service"; +import { NotificationsService } from "../notifications/notifications.service"; import { MatchmakingQueues } from "./enums/MatchmakingQueues"; type ConfirmationTeams = { team1: MatchmakingTeam; team2: MatchmakingTeam }; @@ -31,6 +32,10 @@ describe("MatchmakeService", () => { let mockMatchmakingLobbyService: jest.Mocked; let mockRedisManager: jest.Mocked; let mockQueue: jest.Mocked; + let mockNotifications: { + notifyMatchFound: jest.Mock; + retractMatchFound: jest.Mock; + }; let logger: Logger; beforeEach(async () => { @@ -81,6 +86,11 @@ describe("MatchmakeService", () => { remove: jest.fn(), } as any; + mockNotifications = { + notifyMatchFound: jest.fn().mockResolvedValue(10), + retractMatchFound: jest.fn().mockResolvedValue(undefined), + }; + logger = new Logger("Test"); const module: TestingModule = await Test.createTestingModule({ @@ -106,6 +116,10 @@ describe("MatchmakeService", () => { provide: RedisManagerService, useValue: mockRedisManager, }, + { + provide: NotificationsService, + useValue: mockNotifications, + }, { provide: `BullQueue_${MatchmakingQueues.Matchmaking}`, useValue: mockQueue, @@ -885,6 +899,149 @@ describe("MatchmakeService", () => { }); }); + describe("match found push", () => { + const teams = (): ConfirmationTeams => ({ + team1: { + lobbies: ["lobby-a"], + players: [ + { steam_id: "steam-1", rank: 1000 }, + { steam_id: "steam-2", rank: 1000 }, + ], + avgRank: 1000, + }, + team2: { + lobbies: ["lobby-b"], + players: [ + { steam_id: "steam-3", rank: 1000 }, + { steam_id: "steam-4", rank: 1000 }, + ], + avgRank: 1000, + }, + }); + + it("pushes every player in the confirmation", async () => { + await (service as any).createMatchConfirmation( + "us-east", + "Wingman", + teams(), + ); + + expect(mockNotifications.notifyMatchFound).toHaveBeenCalledTimes(1); + + const [confirmationId, steamIds, label, seconds] = + mockNotifications.notifyMatchFound.mock.calls[0]; + + expect(steamIds).toEqual(["steam-1", "steam-2", "steam-3", "steam-4"]); + expect(label).toBe("Wingman"); + expect(seconds).toBe(30); + expect( + mockMatchmakingLobbyService.setMatchConformationIdForLobby, + ).toHaveBeenCalledWith("lobby-a", confirmationId); + }); + + it("still starts the ready check when the push fails", async () => { + mockNotifications.notifyMatchFound.mockRejectedValue( + new Error("hasura is down"), + ); + + await expect( + (service as any).createMatchConfirmation( + "us-east", + "Competitive", + teams(), + ), + ).resolves.toBeUndefined(); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockQueue.add).toHaveBeenCalledWith( + "CancelMatchMaking", + expect.anything(), + expect.objectContaining({ delay: 30 * 1000 }), + ); + }); + + it("does not hold the ready check up on the push", async () => { + mockNotifications.notifyMatchFound.mockReturnValue( + new Promise(() => {}), + ); + + await (service as any).createMatchConfirmation( + "us-east", + "Competitive", + teams(), + ); + + expect(mockQueue.add).toHaveBeenCalledWith( + "CancelMatchMaking", + expect.anything(), + expect.anything(), + ); + }); + + it("retracts the push when the confirmation is removed", async () => { + await service.removeConfirmationDetails("confirmation-1"); + + expect(mockNotifications.retractMatchFound).toHaveBeenCalledWith( + "confirmation-1", + undefined, + ); + }); + + const pendingConfirmation = () => { + const { team1, team2 } = teams(); + + mockRedis.hgetall.mockImplementation(async (key: string) => + key.endsWith(":confirmed") + ? {} + : { + type: "Competitive", + region: "us-east", + lobbyIds: JSON.stringify(["lobby-a", "lobby-b"]), + team1: JSON.stringify(team1.players), + team2: JSON.stringify(team2.players), + }, + ); + }; + + // The push may not have gone out yet -- it is still on its way to the + // player's phone while they accept on the desktop. + it("retracts a player's push as soon as they accept", async () => { + pendingConfirmation(); + + await service.playerConfirmMatchmaking("confirmation-1", "steam-2"); + + expect(mockNotifications.retractMatchFound).toHaveBeenCalledWith( + "confirmation-1", + "steam-2", + ); + }); + + it("leaves the push alone for someone who is not in the ready check", async () => { + pendingConfirmation(); + + await service.playerConfirmMatchmaking("confirmation-1", "steam-99"); + + expect(mockNotifications.retractMatchFound).not.toHaveBeenCalled(); + }); + + it("still removes the confirmation when the retract fails", async () => { + mockNotifications.retractMatchFound.mockRejectedValue( + new Error("postgres is down"), + ); + + await expect( + service.removeConfirmationDetails("confirmation-1"), + ).resolves.toBeUndefined(); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockRedis.del).toHaveBeenCalledWith( + expect.stringContaining("confirmation-1"), + ); + }); + }); + describe("releaseLobbyAndRequeue", () => { it("should release the lock and re-add lobby to all regional queues", async () => { const lobby: MatchmakingLobby = { diff --git a/src/matchmaking/matchmake.service.ts b/src/matchmaking/matchmake.service.ts index 98315586..51782676 100644 --- a/src/matchmaking/matchmake.service.ts +++ b/src/matchmaking/matchmake.service.ts @@ -13,6 +13,7 @@ import { MatchmakingQueues } from "./enums/MatchmakingQueues"; import { MatchmakingLobbyService } from "./matchmaking-lobby.service"; import { RedisManagerService } from "../redis/redis-manager/redis-manager.service"; import { MatchAssistantService } from "src/matches/match-assistant/match-assistant.service"; +import { NotificationsService } from "src/notifications/notifications.service"; import { getMatchmakingQueueCacheKey, getMatchmakingConformationCacheKey, @@ -50,6 +51,7 @@ export class MatchmakeService { public readonly redisManager: RedisManagerService, public readonly matchAssistant: MatchAssistantService, private matchmakingLobbyService: MatchmakingLobbyService, + private readonly notifications: NotificationsService, @InjectQueue(MatchmakingQueues.Matchmaking) private queue: Queue, ) { this.redis = this.redisManager.getConnection(); @@ -651,6 +653,20 @@ export class MatchmakeService { await this.matchmakingLobbyService.sendQueueDetailsToLobby(lobbyId); } + void this.notifications + .notifyMatchFound( + confirmationId, + [...team1.players, ...team2.players].map(({ steam_id }) => steam_id), + type, + 30, + ) + .catch((error) => { + this.logger.warn( + `unable to push match found for confirmation ${confirmationId}`, + error, + ); + }); + await this.cancelMatchMakingDueToReadyCheck(confirmationId); } @@ -693,6 +709,8 @@ export class MatchmakeService { } public async removeConfirmationDetails(confirmationId: string) { + this.retractMatchFound(confirmationId); + const confirmedKey = `${getMatchmakingConformationCacheKey(confirmationId)}:confirmed`; await this.redis.del(confirmedKey); @@ -701,6 +719,17 @@ export class MatchmakeService { await this.redis.del(getMatchmakingConformationCacheKey(confirmationId)); } + private retractMatchFound(confirmationId: string, steamId?: string) { + void this.notifications + .retractMatchFound(confirmationId, steamId) + .catch((error) => { + this.logger.warn( + `unable to retract match found for confirmation ${confirmationId}`, + error, + ); + }); + } + public async getMatchConfirmationDetails(confirmationId: string): Promise<{ type: e_match_types_enum; region: string; @@ -823,6 +852,8 @@ export class MatchmakeService { 1, ); + this.retractMatchFound(confirmationId, steamId); + const { confirmed } = await this.getMatchConfirmationDetails(confirmationId); diff --git a/src/matchmaking/matchmaking.module.ts b/src/matchmaking/matchmaking.module.ts index 46a986d8..d444abc4 100644 --- a/src/matchmaking/matchmaking.module.ts +++ b/src/matchmaking/matchmaking.module.ts @@ -16,12 +16,14 @@ import { CancelMatchMaking } from "./jobs/CancelMatchMaking"; import { MatchmakingController } from "./matchmaking.controller"; import { MarkPlayerOffline } from "./jobs/MarkPlayerOffline"; import { ExpandMatchmaking } from "./jobs/ExpandMatchmaking"; +import { NotificationsModule } from "src/notifications/notifications.module"; @Module({ imports: [ RedisModule, HasuraModule, CacheModule, + NotificationsModule, forwardRef(() => MatchesModule), BullModule.registerQueue({ name: MatchmakingQueues.Matchmaking, diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index e4a4256b..5ad03984 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -51,6 +51,8 @@ describe("discord routing", () => { "MatchChatMessage", "PlayerSanctioned", "MatchImported", + "MatchFound", + "AdminCall", ])("keeps %s off discord", (type) => { expect(NotificationsService.relaysToDiscord(type)).toBe(false); }); @@ -175,6 +177,142 @@ describe("CS2 build notices", () => { }); }); +describe("push-only rings", () => { + const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + + let hasura: { query: jest.Mock; mutation: jest.Mock }; + let postgres: { query: jest.Mock }; + let preferences: { filterInAppRecipients: jest.Mock }; + let pushNotifications: { + filterSubscribed: jest.Mock; + claimFanOut: jest.Mock; + sendForIds: jest.Mock; + }; + let pushBroadcastQueue: { add: jest.Mock }; + let service: NotificationsService; + + beforeEach(() => { + jest.clearAllMocks(); + + hasura = { + query: jest.fn().mockResolvedValue({ settings_by_pk: null }), + mutation: jest.fn(async (mutation: any) => ({ + insert_notifications: { + returning: mutation.insert_notifications.__args.objects.map( + (_: unknown, index: number) => ({ id: `row-${index}` }), + ), + }, + })), + }; + postgres = { query: jest.fn().mockResolvedValue([]) }; + preferences = { + filterInAppRecipients: jest.fn(async (_type, steamIds) => steamIds), + }; + pushNotifications = { + filterSubscribed: jest.fn(async (steamIds: string[]) => steamIds), + claimFanOut: jest.fn().mockResolvedValue(undefined), + sendForIds: jest.fn().mockResolvedValue(undefined), + }; + pushBroadcastQueue = { add: jest.fn().mockResolvedValue({}) }; + + service = new NotificationsService( + hasura as any, + postgres as any, + logger as any, + { get: () => ({ webDomain: "https://example.com" }) } as any, + preferences as any, + pushNotifications as any, + { add: jest.fn() } as any, + pushBroadcastQueue as any, + ); + }); + + const insertedRows = () => + hasura.mutation.mock.calls[0][0].insert_notifications.__args.objects; + + it("writes rings out of the bell without asking the bell's preference", async () => { + await service.notifyMatchFound("confirmation-1", ["1", "2"], "Wingman", 30); + + expect(preferences.filterInAppRecipients).not.toHaveBeenCalled(); + expect(insertedRows().map(({ in_app }: any) => in_app)).toEqual([ + false, + false, + ]); + }); + + // The broadcast worker runs one job at a time; behind a news fan-out a ready + // check would expire before its push was ever sent. + it("sends a ready check straight away rather than queueing it", async () => { + await service.notifyMatchFound("confirmation-1", ["1", "2"], "Wingman", 30); + + expect(pushBroadcastQueue.add).not.toHaveBeenCalled(); + expect(pushNotifications.claimFanOut).toHaveBeenCalledWith([ + "row-0", + "row-1", + ]); + expect(pushNotifications.sendForIds).toHaveBeenCalledWith([ + "row-0", + "row-1", + ]); + }); + + it("still queues every other fan-out", async () => { + await service.notifyPlayers("ScrimAlertMatch", { + title: "Scrim", + message: "A team is available", + role: "user", + entity_id: "alert-1", + steamIds: ["1", "2"], + }); + + expect(pushBroadcastQueue.add).toHaveBeenCalled(); + expect(pushNotifications.sendForIds).not.toHaveBeenCalled(); + }); + + it("swallows a failed urgent send rather than failing the writer", async () => { + pushNotifications.sendForIds.mockRejectedValue(new Error("db away")); + + await expect( + service.notifyMatchFound("confirmation-1", ["1", "2"], "Wingman", 30), + ).resolves.toBe(2); + }); + + // A team named after its captain carries whatever that player called + // themselves, and the message is read back as HTML for its link and text. + it("escapes the match name an admin call is about", async () => { + postgres.query.mockImplementation(async (sql: string) => + sql.includes("get_team_name") + ? [{ label: `Evil's Team vs Team 2` }] + : [], + ); + + await service.notifyAdminCall("m-1", "1"); + + expect(insertedRows()[0].message).toBe( + "An admin wants to talk to you about <a href="/settings">Evil</a>'s Team vs Team 2. Open your camera page to answer.", + ); + }); + + it("clears the previous ring before writing a new one", async () => { + const order: string[] = []; + postgres.query.mockImplementation(async (sql: string, bindings: any[]) => { + if (sql.includes("DELETE")) { + order.push(`retract ${bindings.join(" ")}`); + } + return []; + }); + const insert = hasura.mutation.getMockImplementation(); + hasura.mutation.mockImplementation(async (mutation: any) => { + order.push("insert"); + return insert(mutation); + }); + + await service.notifyAdminCall("m-1", "1"); + + expect(order).toEqual(["retract m-1 1", "insert"]); + }); +}); + describe("NotificationsService", () => { const webDomain = "https://5stack.test"; let service: NotificationsService; diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index d67dbb52..53a911a5 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -19,6 +19,7 @@ import { PushNotificationsService, } from "./push/push-notifications.service"; import { inAppKeyForType } from "./preferences/notification-categories"; +import { deliveryPolicyForType } from "./push/notification-delivery"; @Injectable() export class NotificationsService { @@ -421,6 +422,23 @@ export class NotificationsService { } } + // Never queued. The broadcast worker runs one job at a time and a news + // fan-out can hold it for minutes -- longer than a ready check lasts, and by + // the time it got here the rows would have been retracted. Claimed first so + // the rows' own trigger events stand down. + private async pushUrgent(ids: string[]) { + if (ids.length < 2) { + return; + } + + try { + await this.pushNotifications.claimFanOut(ids); + await this.pushNotifications.sendForIds(ids); + } catch (error) { + this.logger.warn("unable to push an urgent notification", error); + } + } + private async postDiscord( webhook: string, roleId: string | undefined, @@ -501,6 +519,9 @@ export class NotificationsService { // thread this belongs to, what that thread is called, whose avatar to // show. Read by the delivery gate, never by the bell. data?: NotificationData; + // false writes push-only rows: only for recipients with somewhere to be + // pushed to, and never shown in the bell. + inApp?: boolean; }, actions?: Array<{ label: string; @@ -525,7 +546,9 @@ export class NotificationsService { // row could only ever be dead weight. const recipients = Array.from(new Set(notification.steamIds)); const inApp = new Set( - await this.preferences.filterInAppRecipients(type, recipients), + notification.inApp === false + ? [] + : await this.preferences.filterInAppRecipients(type, recipients), ); const pushable = new Set( await this.pushNotifications.filterSubscribed( @@ -562,9 +585,15 @@ export class NotificationsService { }, }); - await this.pushFanOut( - (insert_notifications?.returning ?? []).map(({ id }) => id as string), + const ids = (insert_notifications?.returning ?? []).map( + ({ id }) => id as string, ); + + if (deliveryPolicyForType(type)?.urgent) { + await this.pushUrgent(ids); + } else { + await this.pushFanOut(ids); + } } if (NotificationsService.relaysToDiscord(type)) { @@ -585,6 +614,75 @@ export class NotificationsService { return steamIds.length; } + // Push-only: the ready check itself is on the player's screen over the + // matchmaking socket already, so the bell would only ever show a stale copy. + async notifyMatchFound( + confirmationId: string, + steamIds: string[], + matchTypeLabel: string, + seconds: number, + ) { + return this.notifyPlayers("MatchFound", { + title: "Match found", + message: `Your ${matchTypeLabel} match is ready — accept within ${seconds}s`, + role: "user", + entity_id: confirmationId, + steamIds, + inApp: false, + }); + } + + // Nothing else ever prunes these rows, and deleting them is also what stops a + // push that has not gone out yet from reaching a player who already accepted, + // or a ready check that has already ended. + async retractMatchFound(confirmationId: string, steamId?: string) { + await this.postgres.query( + `DELETE FROM public.notifications + WHERE type = 'MatchFound' + AND entity_id = $1 + AND ($2::bigint IS NULL OR steam_id = $2::bigint)`, + [confirmationId, steamId ?? null], + ); + } + + async notifyAdminCall(matchId: string, steamId: string) { + const [match] = await this.postgres.query>( + `SELECT public.get_team_name(l1) || ' vs ' || public.get_team_name(l2) AS label + FROM public.matches m + JOIN public.match_lineups l1 ON l1.id = m.lineup_1_id + JOIN public.match_lineups l2 ON l2.id = m.lineup_2_id + WHERE m.id = $1::uuid`, + [matchId], + ); + + const about = match?.label + ? `about ${NotificationsService.escapeHtml(match.label)}` + : "about your match"; + + // A call abandoned without a hangup is never retracted, so the one before + // is cleared here rather than left to pile up. + await this.retractAdminCall(matchId, steamId); + + return this.notifyPlayers("AdminCall", { + title: "Admin is calling you", + message: `An admin wants to talk to you ${about}. Open your camera page to answer.`, + role: "user", + entity_id: matchId, + steamIds: [steamId], + inApp: false, + }); + } + + async retractAdminCall(matchId: string, steamId: string) { + await this.postgres.query( + `DELETE FROM public.notifications + WHERE type = 'AdminCall' + AND entity_id = $1 + AND steam_id = $2::bigint`, + [matchId, steamId], + ); + } + // Retracts alerts that describe a condition rather than an event. // // "Map is paused" and "waiting for a server" are true only while they are diff --git a/src/notifications/preferences/notification-categories.ts b/src/notifications/preferences/notification-categories.ts index 185fdc95..ed7fdf08 100644 --- a/src/notifications/preferences/notification-categories.ts +++ b/src/notifications/preferences/notification-categories.ts @@ -22,6 +22,8 @@ export type PreferenceKey = { // and fails if anything is unmapped. export const PUSH_CATEGORIES: Record = { matches: ["MatchStatusChange", "MatchImported", "MatchStatsReady", "ClipReady"], + match_found: ["MatchFound"], + admin_call: ["AdminCall"], chat: ["ChatMessage"], match_chat: ["MatchChatMessage"], tournaments: [ @@ -78,6 +80,8 @@ export const PUSH_CATEGORIES: Record = { export const PUSH_KEYS: PreferenceKey[] = [ { key: "matches", defaultEnabled: true }, + { key: "match_found", defaultEnabled: true }, + { key: "admin_call", defaultEnabled: true }, { key: "chat", defaultEnabled: true }, // Off by default. In-game chat is relayed into the match's room line by // line, so this is the one category that fires constantly and reaches the diff --git a/src/notifications/push/notification-delivery.spec.ts b/src/notifications/push/notification-delivery.spec.ts index 0de8cb36..64dd3422 100644 --- a/src/notifications/push/notification-delivery.spec.ts +++ b/src/notifications/push/notification-delivery.spec.ts @@ -86,6 +86,36 @@ describe("notification delivery policies", () => { }); }); + it.each(["MatchFound", "AdminCall"])( + "rings %s now, even in quiet hours, and lets it expire with its window", + (type) => { + expect(deliveryPolicyForType(type)).toEqual({ + bundleSeconds: 0, + requireUnseen: true, + ttlSeconds: 30, + urgency: "high", + urgent: true, + ignoreQuietHours: true, + }); + }, + ); + + it("leaves every other type to the push service's defaults", () => { + const urgent = new Set(["MatchFound", "AdminCall"]); + const offenders = types.filter((type) => { + const policy = deliveryPolicyForType(type); + return ( + !urgent.has(type) && + (policy.ttlSeconds !== undefined || + policy.urgency !== undefined || + policy.urgent || + policy.ignoreQuietHours) + ); + }); + + expect(offenders).toEqual([]); + }); + describe("threadKeyFor", () => { it("keys on type and entity", () => { expect( @@ -100,6 +130,15 @@ describe("notification delivery policies", () => { ); }); + it("tags a ring by what it is about", () => { + expect( + threadKeyFor({ type: "MatchFound", entity_id: "confirmation-1" }), + ).toEqual("MatchFound:confirmation-1"); + expect(threadKeyFor({ type: "AdminCall", entity_id: "m-1" })).toEqual( + "AdminCall:m-1", + ); + }); + it("prefers an explicit thread key", () => { expect( threadKeyFor({ diff --git a/src/notifications/push/notification-delivery.ts b/src/notifications/push/notification-delivery.ts index 9d78f705..8bb54745 100644 --- a/src/notifications/push/notification-delivery.ts +++ b/src/notifications/push/notification-delivery.ts @@ -11,6 +11,14 @@ export type DeliveryPolicy = { // sent. For chat this also means "the recipient's read cursor for the thread // has moved past this message". requireUnseen: boolean; + // How long the push service may hold the message for an offline device + // before discarding it. Unset keeps web-push's four-week default. + ttlSeconds?: number; + urgency?: "very-low" | "low" | "normal" | "high"; + // Asks the service worker to ring rather than notify. What ringing means is + // the worker's to decide (sw-push.js). + urgent?: boolean; + ignoreQuietHours?: boolean; }; // Notifications differ in how much a delayed or dropped buzz costs, and that @@ -25,6 +33,13 @@ export type DeliveryPolicy = { // Keep this exhaustive: notification-delivery.spec.ts reads the real type list // out of the tree and fails if anything is unmapped. const DELIVERY_POLICIES: Record = { + // A ready check and a ringing call are worthless the moment their window + // closes, and the player is by definition awake for either: they just + // queued, or they are in a live match. A ready check lasts 30 seconds, so a + // buzz the push service delivers any later than that sends the player to a + // confirmation that no longer exists. + urgent: ["MatchFound", "AdminCall"], + // Conversations. The only type where the recipient is routinely staring at // the thing being pushed to them. "15s-unseen": ["ChatMessage", "MatchChatMessage"], @@ -104,6 +119,14 @@ const DELIVERY_POLICIES: Record = { }; const POLICY_BY_NAME: Record = { + urgent: { + bundleSeconds: 0, + requireUnseen: true, + ttlSeconds: 30, + urgency: "high", + urgent: true, + ignoreQuietHours: true, + }, "15s-unseen": { bundleSeconds: 15, requireUnseen: true }, "30s-unseen": { bundleSeconds: 30, requireUnseen: true }, "instant-unseen": { bundleSeconds: 0, requireUnseen: true }, diff --git a/src/notifications/push/push-notifications.service.spec.ts b/src/notifications/push/push-notifications.service.spec.ts index 387efbe9..4827b2c8 100644 --- a/src/notifications/push/push-notifications.service.spec.ts +++ b/src/notifications/push/push-notifications.service.spec.ts @@ -1005,6 +1005,140 @@ describe("PushNotificationsService", () => { }); }); + describe("urgent pushes", () => { + const payloadOf = (call: number) => + JSON.parse((webPush.sendNotification as jest.Mock).mock.calls[call][1]); + + const rings: Record> = { + MatchFound: { + type: "MatchFound", + title: "Match found", + message: "Your Competitive match is ready — accept within 30s", + entity_id: "confirmation-1", + }, + AdminCall: { + type: "AdminCall", + title: "Admin is calling you", + message: + "An admin wants to talk to you about Ancients vs Ratz. Open your camera page to answer.", + entity_id: "m-1", + }, + }; + + const ring = async (type: string) => { + notificationRow = notification(rings[type]); + recipients = ["76561100000000001"]; + + await service.sendForNotification({ id: notificationRow.id, type }); + }; + + it.each(["MatchFound", "AdminCall"])( + "has the push service deliver %s at once or not at all", + async (type) => { + await ring(type); + + expect(webPush.sendNotification).toHaveBeenCalledTimes(1); + expect( + (webPush.sendNotification as jest.Mock).mock.calls[0][2], + ).toEqual({ TTL: 30, urgency: "high" }); + }, + ); + + it("sends every other type with the push service's defaults", async () => { + await service.sendForNotification({ + id: notificationRow.id, + type: "MatchStatusChange", + }); + + expect( + (webPush.sendNotification as jest.Mock).mock.calls[0], + ).toHaveLength(2); + + const payload = payloadOf(0); + expect(payload).not.toHaveProperty("urgent"); + expect(payload).not.toHaveProperty("ttl"); + expect(payload).not.toHaveProperty("expiresAt"); + }); + + it("tells the service worker when the ring stops meaning anything", async () => { + const before = Date.now(); + + await ring("MatchFound"); + + const expiresAt = Date.parse(payloadOf(0).expiresAt); + + expect(expiresAt).toBeGreaterThanOrEqual(before + 30_000); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + 30_000); + }); + + it("tells the service worker a ready check is ringing", async () => { + await ring("MatchFound"); + + expect(payloadOf(0)).toMatchObject({ + title: "Match found", + body: "Your Competitive match is ready — accept within 30s", + url: "/play", + tag: "MatchFound:confirmation-1", + renotify: true, + urgent: true, + ttl: 30, + actions: [], + }); + }); + + it("sends a call to the camera page it is answered from", async () => { + await ring("AdminCall"); + + expect(payloadOf(0)).toMatchObject({ + title: "Admin is calling you", + url: "/matches/m-1/camera", + tag: "AdminCall:m-1", + urgent: true, + ttl: 30, + actions: [], + }); + }); + + it.each(["MatchFound", "AdminCall"])( + "rings %s through quiet hours", + async (type) => { + quietSeconds = 6 * 60 * 60; + + await ring(type); + + expect(webPush.sendNotification).toHaveBeenCalledTimes(1); + expect(pushDeliveryQueue.add).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }, + ); + + it("does not re-hold a ring a closing window finds in quiet hours", async () => { + const held = ["id-a"]; + redis.multi.mockReturnValueOnce(chainableMulti([[null, held]])); + + notificationRow = notification({ ...rings.MatchFound, id: "id-a" }); + bundled = [ + { + ...notificationRow, + steam_id: "76561100000000001", + quiet_seconds: 3600, + subscription_id: "sub-1", + endpoint: subscription("sub-1").endpoint, + p256dh: "p256dh", + auth: "auth", + }, + ]; + + await service.sendPending( + "76561100000000001", + "MatchFound:confirmation-1", + ); + + expect(webPush.sendNotification).toHaveBeenCalledTimes(1); + expect(pushDeliveryQueue.add).not.toHaveBeenCalled(); + }); + }); + it("batches the fan-out types", () => { expect(PushNotificationsService.isBatched("NewsPublished")).toBe(true); expect(PushNotificationsService.isBatched("TournamentCreated")).toBe(true); @@ -1184,6 +1318,32 @@ describe("notificationUrl", () => { ).toBe("/game-server-nodes"); }); + it("opens the play page for a ready check", () => { + expect( + notificationUrl( + { + type: "MatchFound", + message: "Your Competitive match is ready", + entity_id: "confirmation-1", + }, + webDomain, + ), + ).toBe("/play"); + }); + + it("opens the camera page for an admin call", () => { + expect( + notificationUrl( + { + type: "AdminCall", + message: "An admin wants to talk", + entity_id: "m-1", + }, + webDomain, + ), + ).toBe("/matches/m-1/camera"); + }); + it("still needs an entity_id for a route built from one", () => { expect( notificationUrl({ type: "MatchStatusChange", message: "" }, webDomain), diff --git a/src/notifications/push/push-notifications.service.ts b/src/notifications/push/push-notifications.service.ts index aa44f466..f809f706 100644 --- a/src/notifications/push/push-notifications.service.ts +++ b/src/notifications/push/push-notifications.service.ts @@ -632,7 +632,7 @@ export class PushNotificationsService { // seconds before quiet hours began closes inside them, and delivering on // that is exactly the buzz the hold exists to prevent -- so it is held // again, now against the rest of the night. - if (delivery.quietSeconds > 0) { + if (delivery.quietSeconds > 0 && !policy.ignoreQuietHours) { const claim = await this.claimWindow( steamId, thread, @@ -856,7 +856,7 @@ export class PushNotificationsService { // Asleep. Hold everything until the window closes and let the trailing // job deliver it as one summary -- which is the same machinery bundling // already uses, just with a much longer window. - if (delivery.quietSeconds > 0) { + if (delivery.quietSeconds > 0 && !policy.ignoreQuietHours) { const claim = await this.claimWindow( delivery.steamId, thread, @@ -1187,6 +1187,13 @@ export class PushNotificationsService { return []; } + // Both are answered in the app and nowhere else -- a ready check over the + // matchmaking socket, a call on the camera page -- and their rows never + // reach the bell, so a Dismiss would only mark an invisible row read. + if (newest.type === "MatchFound" || newest.type === "AdminCall") { + return []; + } + // A bundle describes several things at once; the only honest button is // the one that applies to all of them. Selected by thread rather than by // id: a bundle is every row in one thread (see threadKeyFor), and the id @@ -1363,6 +1370,8 @@ export class PushNotificationsService { const newest = notifications.at(-1); const thread = threadKeyFor(newest); + const policy = + deliveryPolicyForType(newest.type) ?? DEFAULT_DELIVERY_POLICY; const { title, body } = count <= 1 @@ -1400,8 +1409,23 @@ export class PushNotificationsService { unread: await this.unreadCount(steamId), actions, graphqlUrl: `${this.appConfig.apiDomain}/v1/graphql`, + urgent: policy.urgent, + ttl: policy.ttlSeconds, + // Absolute, because the worker cannot tell how long the push service + // held the message before handing it over. + expiresAt: + policy.ttlSeconds !== undefined + ? new Date(Date.now() + policy.ttlSeconds * 1000).toISOString() + : undefined, }); + const options: webPush.RequestOptions = { + ...(policy.ttlSeconds !== undefined ? { TTL: policy.ttlSeconds } : {}), + ...(policy.urgency ? { urgency: policy.urgency } : {}), + }; + const sendOptions: [] | [webPush.RequestOptions] = + Object.keys(options).length > 0 ? [options] : []; + const delivered: string[] = []; const expired: string[] = []; @@ -1431,6 +1455,7 @@ export class PushNotificationsService { }, }, payload, + ...sendOptions, ); delivered.push(subscription.id); } catch (error) { diff --git a/src/notifications/utilities/notificationUrl.ts b/src/notifications/utilities/notificationUrl.ts index 663addb8..e36095d4 100644 --- a/src/notifications/utilities/notificationUrl.ts +++ b/src/notifications/utilities/notificationUrl.ts @@ -14,6 +14,12 @@ const PATH_BY_TYPE: Record string> = { MatchStatusChange: (id) => `/matches/${id}`, MatchImported: (id) => `/matches/${id}`, MatchSupport: (id) => `/matches/${id}`, + // The ready-check dialog is mounted globally in app.vue, so any page will + // do; this is the one a player queued from. + MatchFound: () => `/play`, + // The talk-back only plays on the player's camera page, which picks the call + // up on its own once the admin is publishing. + AdminCall: (id) => `/matches/${id}/camera`, MatchAbandoned: (id) => `/matches/${id}`, TournamentCreated: (id) => `/tournaments/${id}`, TournamentReminder: (id) => `/tournaments/${id.split(":")[0]}`, diff --git a/test/camera.spec.ts b/test/camera.spec.ts index e9277955..7517ee14 100644 --- a/test/camera.spec.ts +++ b/test/camera.spec.ts @@ -34,6 +34,8 @@ describe("camera authorization (SQL-driven)", () => { {} as any, {} as any, {} as any, + {} as any, + { getConnection: () => ({}) } as any, ); await seedRegionWithServer(postgres, "CameraTestRegion", 27015); }, 600_000); diff --git a/test/notifications.spec.ts b/test/notifications.spec.ts index 9efee8c5..ee7ce58f 100644 --- a/test/notifications.spec.ts +++ b/test/notifications.spec.ts @@ -1,7 +1,11 @@ import * as webPush from "web-push"; import { PostgresService } from "./../src/postgres/postgres.service"; import { Fixtures } from "./utils/fixtures"; -import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; import { TournamentReminders } from "./../src/matches/jobs/TournamentReminders"; import { NotificationsService } from "./../src/notifications/notifications.service"; import { NotificationPreferencesService } from "./../src/notifications/preferences/notification-preferences.service"; @@ -31,6 +35,7 @@ describe("notifications (SQL-driven)", () => { db = await bootMigratedDb("NotificationsTest"); postgres = db.postgres; fx = new Fixtures(postgres, 76561199300000000n); + await seedRegionWithServer(postgres, "NotificationsTestRegion"); }, 600_000); afterAll(async () => { @@ -43,6 +48,7 @@ describe("notifications (SQL-driven)", () => { await postgres.query("DELETE FROM notification_preferences"); await postgres.query("DELETE FROM push_subscriptions"); await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM matches"); await postgres.query("DELETE FROM match_options"); await postgres.query("DELETE FROM teams"); await postgres.query("DELETE FROM players"); @@ -270,6 +276,155 @@ describe("notifications (SQL-driven)", () => { }); }); + describe("push-only rows", () => { + const rows = async () => + postgres.query< + Array<{ steam_id: string; entity_id: string; in_app: boolean }> + >( + `SELECT steam_id::text AS steam_id, entity_id, in_app + FROM notifications + ORDER BY steam_id, entity_id`, + ); + + it("writes a hidden row only for recipients who can be pushed to", async () => { + const pushable = await fx.player(); + const unreachable = await fx.player(); + await subscribed(pushable); + + const written = await notifications().notifyPlayers("MatchFound", { + title: "Match found", + message: "Your Competitive match is ready", + role: "user", + entity_id: "confirmation-1", + steamIds: [pushable, unreachable], + inApp: false, + }); + + expect(written).toBe(1); + expect(await rows()).toEqual([ + { steam_id: pushable, entity_id: "confirmation-1", in_app: false }, + ]); + }); + + it("stays out of the bell for a player whose bell would show it", async () => { + const pushable = await fx.player(); + await subscribed(pushable); + await preferences().set(pushable, "in_app", "MatchFound", true); + + await notifications().notifyMatchFound( + "confirmation-1", + [pushable], + "Wingman", + 30, + ); + + const [row] = await postgres.query< + Array<{ title: string; message: string; in_app: boolean }> + >(`SELECT title, message, in_app FROM notifications`); + + expect(row).toEqual({ + title: "Match found", + message: "Your Wingman match is ready — accept within 30s", + in_app: false, + }); + }); + + it("retracts one ready check and leaves the next alone", async () => { + const [first, second] = [await fx.player(), await fx.player()]; + await subscribed(first); + await subscribed(second); + + await notifications().notifyMatchFound( + "confirmation-1", + [first, second], + "Competitive", + 30, + ); + await notifications().notifyMatchFound( + "confirmation-2", + [first], + "Competitive", + 30, + ); + + await notifications().retractMatchFound("confirmation-1"); + + expect(await rows()).toEqual([ + { steam_id: first, entity_id: "confirmation-2", in_app: false }, + ]); + }); + + it("retracts only the player who accepted", async () => { + const [accepted, waiting] = [await fx.player(), await fx.player()]; + await subscribed(accepted); + await subscribed(waiting); + + await notifications().notifyMatchFound( + "confirmation-1", + [accepted, waiting], + "Competitive", + 30, + ); + + await notifications().retractMatchFound("confirmation-1", accepted); + + expect(await rows()).toEqual([ + { steam_id: waiting, entity_id: "confirmation-1", in_app: false }, + ]); + }); + + it("keeps one ring per player per match however often they are called", async () => { + const { matchId } = await fx.bareMatch(); + const called = await fx.player(); + await subscribed(called); + + await notifications().notifyAdminCall(matchId, called); + await notifications().notifyAdminCall(matchId, called); + + expect(await rows()).toEqual([ + { steam_id: called, entity_id: matchId, in_app: false }, + ]); + }); + + it("names the match an admin is calling about", async () => { + const { matchId } = await fx.bareMatch(); + await postgres.query( + `UPDATE match_lineups ml SET team_name = 'Ancients' + FROM matches m + WHERE m.id = $1 AND ml.id = m.lineup_1_id`, + [matchId], + ); + const called = await fx.player(); + await subscribed(called); + + await notifications().notifyAdminCall(matchId, called); + + const [row] = await postgres.query< + Array<{ title: string; message: string; entity_id: string }> + >(`SELECT title, message, entity_id FROM notifications`); + + expect(row).toEqual({ + title: "Admin is calling you", + message: + "An admin wants to talk to you about Ancients vs Team 2. Open your camera page to answer.", + entity_id: matchId, + }); + }); + + it("retracts a call for the player it rang and nobody else", async () => { + const { matchId } = await fx.bareMatch(); + const [called, other] = [await fx.player(), await fx.player()]; + await subscribed(called); + await subscribed(other); + + await notifications().notifyAdminCall(matchId, called); + await notifications().notifyAdminCall(matchId, other); + await notifications().retractAdminCall(matchId, called); + + expect((await rows()).map(({ steam_id }) => steam_id)).toEqual([other]); + }); + }); + describe("collapseOlderUnread", () => { it("keeps only the newest unread row for a conversation", async () => { const steamId = await fx.player(); @@ -404,6 +559,24 @@ describe("notifications (SQL-driven)", () => { expect(posted).toEqual([]); }); + it.each(["MatchFound", "AdminCall"])( + "keeps a %s ring out of the staff channel", + async (type) => { + const steamId = await fx.player(); + await subscribed(steamId); + await withWebhook().notifyPlayers(type as any, { + title: "Ring", + message: "Ring ring.", + role: "user", + entity_id: "e-1", + steamIds: [steamId], + inApp: false, + }); + + expect(posted).toEqual([]); + }, + ); + it("still relays the types that are meant for it", async () => { // Guards the test itself: if the webhook never fired for any type, every // assertion above would pass for the wrong reason.