diff --git a/fluxer_api/src/api/channel/services/CallService.ts b/fluxer_api/src/api/channel/services/CallService.ts index 5139be354..0c49f802c 100644 --- a/fluxer_api/src/api/channel/services/CallService.ts +++ b/fluxer_api/src/api/channel/services/CallService.ts @@ -243,7 +243,7 @@ export class CallService { channelId, messageId, mentionCount: 0, - silent: true, + implicit: {unreadThrough: channel.lastMessageId}, emitGateway: false, }); } diff --git a/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts b/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts index 1ccf98622..affe1b0e0 100644 --- a/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts +++ b/fluxer_api/src/api/channel/services/message/MessagePersistenceService.ts @@ -353,7 +353,7 @@ export class MessagePersistenceService { channelId: params.channelId, messageId: params.messageId, mentionCount: 0, - silent: true, + implicit: {unreadThrough: params.user ? (params.channel?.lastMessageId ?? null) : null}, emitGateway: false, }), ); diff --git a/fluxer_api/src/api/infrastructure/GatewayService.ts b/fluxer_api/src/api/infrastructure/GatewayService.ts index 78c9fff60..705153232 100644 --- a/fluxer_api/src/api/infrastructure/GatewayService.ts +++ b/fluxer_api/src/api/infrastructure/GatewayService.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {ChannelID, GuildID, MessageID, RoleID, UserID} from '@app/api/BrandedTypes'; -import {createChannelID, createRoleID, createUserID} from '@app/api/BrandedTypes'; +import {createChannelID, createGuildID, createRoleID, createUserID} from '@app/api/BrandedTypes'; import {SYSTEM_USER_ID} from '@app/api/constants/Core'; import type {GatewayDispatchEvent} from '@app/api/constants/Gateway'; import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient'; @@ -710,11 +710,29 @@ export class GatewayService { guildId: GuildID; settings: unknown; }): Promise { - await this.call('push.sync_user_guild_settings', { - user_id: userId.toString(), - guild_id: guildId.toString(), - user_guild_settings: settings, - }); + try { + await this.rpcClient.call('push.sync_user_guild_settings', { + user_id: userId.toString(), + guild_id: guildId.toString(), + user_guild_settings: settings, + }); + } catch (error) { + if (!this.isPrivateScopeSyncUnsupportedError(guildId, error)) { + throw this.transformGatewayError(error); + } + Logger.warn( + {userId: userId.toString()}, + '[gateway-rpc] push.sync_user_guild_settings rejected the private scope, gateway predates it', + ); + } + } + + private isPrivateScopeSyncUnsupportedError(guildId: GuildID, error: unknown): boolean { + return ( + guildId === createGuildID(0n) && + error instanceof GatewayRpcMethodError && + error.code === GatewayRpcMethodErrorCodes.INVALID_PARAMS + ); } async getGuildCounts(guildId: GuildID): Promise<{ diff --git a/fluxer_api/src/api/read_state/IReadStateRepository.ts b/fluxer_api/src/api/read_state/IReadStateRepository.ts index 8dc9f41c2..b233a74b5 100644 --- a/fluxer_api/src/api/read_state/IReadStateRepository.ts +++ b/fluxer_api/src/api/read_state/IReadStateRepository.ts @@ -3,9 +3,16 @@ import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes'; import type {ReadState} from '@app/api/models/ReadState'; +export interface ReadStateUpsert { + readState: ReadState; + previous: ReadState | null; +} + export abstract class IReadStateRepository { abstract listReadStates(userId: UserID): Promise>; + abstract getReadState(userId: UserID, channelId: ChannelID): Promise; + abstract upsertReadState( userId: UserID, channelId: ChannelID, @@ -13,7 +20,7 @@ export abstract class IReadStateRepository { mentionCount?: number, lastPinTimestamp?: Date, manual?: boolean, - ): Promise; + ): Promise; abstract incrementReadStateMentions( userId: UserID, diff --git a/fluxer_api/src/api/read_state/PushClearOnReply.test.ts b/fluxer_api/src/api/read_state/PushClearOnReply.test.ts new file mode 100644 index 000000000..93320930b --- /dev/null +++ b/fluxer_api/src/api/read_state/PushClearOnReply.test.ts @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import { + acceptInvite, + createChannel, + createChannelInvite, + createDmChannel, + createFriendship, + createGuild, +} from '@app/api/channel/tests/ChannelTestUtils'; +import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface AckResponse { + read_states: Array<{ + id: string; + last_message_id: string | null; + }>; +} + +interface ReadSignals { + clears: Array<{channelId: string; messageId: string}>; + acks: Array<{channel_id: string; message_id: string}>; +} + +async function captureReadSignals(userId: string, action: () => Promise): Promise { + const clearSpy = vi.spyOn(NoopGatewayService.prototype, 'clearPushChannelNotifications'); + const presenceSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence'); + try { + await action(); + return { + clears: clearSpy.mock.calls + .filter(([params]) => params.userId.toString() === userId) + .map(([params]) => ({channelId: params.channelId.toString(), messageId: params.messageId.toString()})), + acks: presenceSpy.mock.calls + .filter(([params]) => params.userId.toString() === userId && params.event === 'MESSAGE_ACK') + .map(([params]) => { + const data = params.data as {channel_id: string; message_id: string}; + return {channel_id: data.channel_id, message_id: data.message_id}; + }), + }; + } finally { + clearSpy.mockRestore(); + presenceSpy.mockRestore(); + } +} + +async function readLastMessageId( + harness: ApiTestHarness, + token: string, + channelId: string, + olderMessageId: string, +): Promise { + const response = await createBuilder(harness, token) + .post('/read-states/ack') + .body({read_states: [{channel_id: channelId, message_id: olderMessageId}]}) + .expect(HTTP_STATUS.OK) + .execute(); + return response.read_states[0]?.last_message_id; +} + +async function ackMessage( + harness: ApiTestHarness, + token: string, + channelId: string, + messageId: string, + body: {manual?: boolean; mention_count?: number} = {}, +): Promise { + await createBuilder(harness, token) + .post(`/channels/${channelId}/messages/${messageId}/ack`) + .body(body) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); +} + +async function ringCall(harness: ApiTestHarness, token: string, channelId: string): Promise { + await createBuilder(harness, token) + .post(`/channels/${channelId}/call/ring`) + .body({}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); +} + +async function latestMessageId(harness: ApiTestHarness, token: string, channelId: string): Promise { + const messages = await createBuilder>(harness, token) + .get(`/channels/${channelId}/messages?limit=1`) + .expect(HTTP_STATUS.OK) + .execute(); + return messages[0]!.id; +} + +async function setupDm(harness: ApiTestHarness): Promise<{alice: TestAccount; bob: TestAccount; channelId: string}> { + const alice = await createTestAccount(harness); + const bob = await createTestAccount(harness); + await ensureSessionStarted(harness, alice.token); + await ensureSessionStarted(harness, bob.token); + await createFriendship(harness, alice, bob); + const dm = await createDmChannel(harness, alice.token, bob.userId); + return {alice, bob, channelId: dm.id}; +} + +describe('push clear on reply', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + beforeEach(async () => { + await harness.reset(); + }); + afterAll(async () => { + await harness?.shutdown(); + }); + + it('replying in a DM clears the partner message push up to the reply without a MESSAGE_ACK', async () => { + const {alice, bob, channelId} = await setupDm(harness); + const fromBob = await sendMessage(harness, bob.token, channelId, 'ping from bob'); + let replyId = ''; + const signals = await captureReadSignals(alice.userId, async () => { + const reply = await sendMessage(harness, alice.token, channelId, 'reply from alice'); + replyId = reply.id; + }); + expect(BigInt(replyId) > BigInt(fromBob.id)).toBe(true); + expect(signals.clears).toEqual([{channelId, messageId: replyId}]); + expect(signals.acks).toEqual([]); + expect(await readLastMessageId(harness, alice.token, channelId, fromBob.id)).toBe(replyId); + }); + + it('a follow-up message with nothing new unread sends no push clear', async () => { + const {alice, bob, channelId} = await setupDm(harness); + await sendMessage(harness, bob.token, channelId, 'ping from bob'); + await sendMessage(harness, alice.token, channelId, 'reply from alice'); + const signals = await captureReadSignals(alice.userId, async () => { + await sendMessage(harness, alice.token, channelId, 'second reply from alice'); + }); + expect(signals.clears).toEqual([]); + expect(signals.acks).toEqual([]); + }); + + it('the first message in a DM with nothing unread sends no push clear', async () => { + const {alice, channelId} = await setupDm(harness); + const signals = await captureReadSignals(alice.userId, async () => { + await sendMessage(harness, alice.token, channelId, 'hello from alice'); + }); + expect(signals.clears).toEqual([]); + }); + + it('the read state RPC reports the reply as the last read message', async () => { + const {alice, bob, channelId} = await setupDm(harness); + await sendMessage(harness, bob.token, channelId, 'ping from bob'); + const reply = await sendMessage(harness, alice.token, channelId, 'reply from alice'); + const readState = async (userId: string, channel: string) => + ( + await createBuilder<{type: 'get_read_state'; data: {last_message_id: string | null}}>(harness, '') + .post('/test/rpc-session-init') + .body({type: 'get_read_state', user_id: userId, channel_id: channel}) + .expect(HTTP_STATUS.OK) + .execute() + ).data.last_message_id; + expect(await readState(alice.userId, channelId)).toBe(reply.id); + expect(await readState(alice.userId, '1')).toBeNull(); + }); + + it('an explicit ack in a DM sends exactly one push clear and one MESSAGE_ACK', async () => { + const {alice, bob, channelId} = await setupDm(harness); + const fromBob = await sendMessage(harness, bob.token, channelId, 'ping from bob'); + const signals = await captureReadSignals(alice.userId, async () => { + await createBuilder(harness, alice.token) + .post(`/channels/${channelId}/messages/${fromBob.id}/ack`) + .body({}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + }); + expect(signals.clears).toEqual([{channelId, messageId: fromBob.id}]); + expect(signals.acks).toEqual([{channel_id: channelId, message_id: fromBob.id}]); + }); + + it('replying in a guild text channel clears the other member message push up to the reply without a MESSAGE_ACK', async () => { + const alice = await createTestAccount(harness); + const bob = await createTestAccount(harness); + await ensureSessionStarted(harness, alice.token); + await ensureSessionStarted(harness, bob.token); + const guild = await createGuild(harness, alice.token, 'Push Clear Guild'); + const channel = await createChannel(harness, alice.token, guild.id, 'push-clear'); + const invite = await createChannelInvite(harness, alice.token, channel.id); + await acceptInvite(harness, bob.token, invite.code); + const fromBob = await sendMessage(harness, bob.token, channel.id, 'ping from bob'); + let replyId = ''; + const signals = await captureReadSignals(alice.userId, async () => { + const reply = await sendMessage(harness, alice.token, channel.id, 'reply from alice'); + replyId = reply.id; + }); + expect(BigInt(replyId) > BigInt(fromBob.id)).toBe(true); + expect(signals.clears).toEqual([{channelId: channel.id, messageId: replyId}]); + expect(signals.acks).toEqual([]); + expect(await readLastMessageId(harness, alice.token, channel.id, fromBob.id)).toBe(replyId); + }); + + it('starting a call clears the caller push for messages still unread up to the call message', async () => { + const {alice, bob, channelId} = await setupDm(harness); + const first = await sendMessage(harness, bob.token, channelId, 'first from bob'); + const second = await sendMessage(harness, bob.token, channelId, 'second from bob'); + await ackMessage(harness, alice.token, channelId, first.id); + const signals = await captureReadSignals(alice.userId, async () => { + await ringCall(harness, alice.token, channelId); + }); + const callMessageId = await latestMessageId(harness, alice.token, channelId); + expect(BigInt(callMessageId) > BigInt(second.id)).toBe(true); + expect(signals.clears).toEqual([{channelId, messageId: callMessageId}]); + expect(signals.acks).toEqual([]); + }); + + it('starting a call with nothing unread sends the caller no push clear', async () => { + const {alice, bob, channelId} = await setupDm(harness); + const fromBob = await sendMessage(harness, bob.token, channelId, 'ping from bob'); + await ackMessage(harness, alice.token, channelId, fromBob.id); + const signals = await captureReadSignals(alice.userId, async () => { + await ringCall(harness, alice.token, channelId); + }); + expect(signals.clears).toEqual([]); + }); + + it('ending a call clears a participant push up to the call message only when it was unread', async () => { + const {alice, bob, channelId} = await setupDm(harness); + const fromBob = await sendMessage(harness, bob.token, channelId, 'ping from bob'); + await ringCall(harness, alice.token, channelId); + const callMessageId = await latestMessageId(harness, alice.token, channelId); + await ackMessage(harness, bob.token, channelId, fromBob.id, {manual: true, mention_count: 0}); + const endCall = async () => { + await createBuilder(harness, '') + .post('/test/rpc-session-init') + .body({ + type: 'call_ended', + channel_id: channelId, + message_id: callMessageId, + participants: [alice.userId, bob.userId], + ended_timestamp: Date.now(), + }) + .expect(HTTP_STATUS.OK) + .execute(); + }; + const clearSpy = vi.spyOn(NoopGatewayService.prototype, 'clearPushChannelNotifications'); + try { + await endCall(); + const clears = clearSpy.mock.calls.map(([params]) => ({ + userId: params.userId.toString(), + channelId: params.channelId.toString(), + messageId: params.messageId.toString(), + })); + expect(clears).toEqual([{userId: bob.userId, channelId, messageId: callMessageId}]); + } finally { + clearSpy.mockRestore(); + } + }); + + it('joining a guild with an active system channel sends the joiner no push clear', async () => { + const alice = await createTestAccount(harness); + const bob = await createTestAccount(harness); + await ensureSessionStarted(harness, alice.token); + await ensureSessionStarted(harness, bob.token); + const guild = await createGuild(harness, alice.token, 'Push Clear Join Guild'); + const systemChannelId = guild.system_channel_id!; + await sendMessage(harness, alice.token, systemChannelId, 'welcome'); + const invite = await createChannelInvite(harness, alice.token, systemChannelId); + const signals = await captureReadSignals(bob.userId, async () => { + await acceptInvite(harness, bob.token, invite.code); + }); + expect(signals.clears).toEqual([]); + }); +}); diff --git a/fluxer_api/src/api/read_state/ReadStateRepository.test.ts b/fluxer_api/src/api/read_state/ReadStateRepository.test.ts index dfe6015f3..f98594f7d 100644 --- a/fluxer_api/src/api/read_state/ReadStateRepository.test.ts +++ b/fluxer_api/src/api/read_state/ReadStateRepository.test.ts @@ -65,7 +65,7 @@ describe('ReadStateRepository row storage', () => { const channelId = createChannelID(10n); await seedReadState(userId, channelId, 100n, 3); const repository = new ReadStateRepository(); - const readState = await repository.upsertReadState(userId, channelId, createMessageID(90n), 0); + const {readState} = await repository.upsertReadState(userId, channelId, createMessageID(90n), 0); expect(readState.lastMessageId).toBe(createMessageID(100n)); expect(await loadReadState(userId, channelId)).toMatchObject({ message_id: createMessageID(100n), @@ -77,7 +77,7 @@ describe('ReadStateRepository row storage', () => { const channelId = createChannelID(10n); await seedReadState(userId, channelId, 100n, 0); const repository = new ReadStateRepository(); - const readState = await repository.upsertReadState(userId, channelId, createMessageID(90n), 2, undefined, true); + const {readState} = await repository.upsertReadState(userId, channelId, createMessageID(90n), 2, undefined, true); expect(readState.lastMessageId).toBe(createMessageID(90n)); expect(readState.mentionCount).toBe(2); expect(await loadReadState(userId, channelId)).toMatchObject({ diff --git a/fluxer_api/src/api/read_state/ReadStateRepository.ts b/fluxer_api/src/api/read_state/ReadStateRepository.ts index c526f0f6d..d24647a3b 100644 --- a/fluxer_api/src/api/read_state/ReadStateRepository.ts +++ b/fluxer_api/src/api/read_state/ReadStateRepository.ts @@ -14,7 +14,7 @@ import {Db, type DbOp} from '@app/api/database/CassandraTypes'; import type {ReadStateRow} from '@app/api/database/types/ChannelTypes'; import {READ_STATE_COLUMNS} from '@app/api/database/types/ChannelTypes'; import {ReadState} from '@app/api/models/ReadState'; -import type {IReadStateRepository} from '@app/api/read_state/IReadStateRepository'; +import type {IReadStateRepository, ReadStateUpsert} from '@app/api/read_state/IReadStateRepository'; const ReadStates = defineTable({ name: 'read_states', @@ -39,6 +39,14 @@ export class ReadStateRepository implements IReadStateRepository { return rows.map((row) => new ReadState(row)); } + async getReadState(userId: UserID, channelId: ChannelID): Promise { + const row = await fetchOne(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, { + user_id: userId, + channel_id: channelId, + }); + return row ? new ReadState(row) : null; + } + async upsertReadState( userId: UserID, channelId: ChannelID, @@ -46,7 +54,7 @@ export class ReadStateRepository implements IReadStateRepository { mentionCount = 0, lastPinTimestamp?: Date, manual = false, - ): Promise { + ): Promise { return this.upsertReadStateRow(userId, channelId, messageId, mentionCount, lastPinTimestamp, manual); } @@ -57,13 +65,14 @@ export class ReadStateRepository implements IReadStateRepository { mentionCount = 0, lastPinTimestamp?: Date, manual = false, - ): Promise { + ): Promise { const currentReadState = await fetchOne(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, { user_id: userId, channel_id: channelId, }); - if (!manual && currentReadState?.message_id != null && currentReadState.message_id > messageId) { - return new ReadState(currentReadState); + const previous = currentReadState ? new ReadState(currentReadState) : null; + if (!manual && previous?.lastMessageId != null && previous.lastMessageId > messageId) { + return {readState: previous, previous}; } const patch: Record> = { message_id: Db.set(messageId), @@ -73,13 +82,14 @@ export class ReadStateRepository implements IReadStateRepository { patch['last_pin_timestamp'] = Db.set(lastPinTimestamp); } await upsertOne(ReadStates.patchByPk({user_id: userId, channel_id: channelId}, patch)); - return new ReadState({ + const readState = new ReadState({ user_id: userId, channel_id: channelId, message_id: messageId, mention_count: mentionCount, last_pin_timestamp: lastPinTimestamp ?? currentReadState?.last_pin_timestamp ?? null, }); + return {readState, previous}; } async incrementReadStateMentions( @@ -106,7 +116,7 @@ export class ReadStateRepository implements IReadStateRepository { if (baselineMessageId >= messageId) { return null; } - return this.upsertReadStateRow(userId, channelId, baselineMessageId, incrementBy); + return (await this.upsertReadStateRow(userId, channelId, baselineMessageId, incrementBy)).readState; } if (currentReadState.message_id != null && currentReadState.message_id >= messageId) { return null; diff --git a/fluxer_api/src/api/read_state/ReadStateService.test.ts b/fluxer_api/src/api/read_state/ReadStateService.test.ts index 97da4bc13..38cca4e50 100644 --- a/fluxer_api/src/api/read_state/ReadStateService.test.ts +++ b/fluxer_api/src/api/read_state/ReadStateService.test.ts @@ -36,7 +36,7 @@ describe('ReadStateService gateway side effects after the write', () => { const repository = { upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => { stored.push({channelId, messageId}); - return makeReadState(channelId, messageId); + return {readState: makeReadState(channelId, messageId), previous: null}; }), } as unknown as IReadStateRepository; const gatewayService = { @@ -60,9 +60,10 @@ describe('ReadStateService gateway side effects after the write', () => { it('acknowledges the message when the MESSAGE_ACK dispatch fails', async () => { const repository = { - upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => - makeReadState(channelId, messageId), - ), + upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => ({ + readState: makeReadState(channelId, messageId), + previous: null, + })), } as unknown as IReadStateRepository; const gatewayService = { clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined), @@ -85,7 +86,7 @@ describe('ReadStateService gateway side effects after the write', () => { const repository = { upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => { stored.push(channelId.toString()); - return makeReadState(channelId, messageId, 1); + return {readState: makeReadState(channelId, messageId, 1), previous: null}; }), } as unknown as IReadStateRepository; const gatewayService = { @@ -125,3 +126,103 @@ describe('ReadStateService gateway side effects after the write', () => { expect(readStates).toBe(updated); }); }); + +describe('ReadStateService implicit acknowledgements', () => { + async function implicitAck(params: { + previous: ReadState | null; + messageId: MessageID; + unreadThrough: MessageID | null; + }): Promise> { + const repository = { + upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => ({ + readState: makeReadState(channelId, messageId), + previous: params.previous, + })), + } as unknown as IReadStateRepository; + const clearPushChannelNotifications = vi.fn().mockResolvedValue(undefined); + const gatewayService = { + clearPushChannelNotifications, + dispatchPresence: vi.fn().mockResolvedValue(undefined), + } as unknown as IGatewayService; + await new ReadStateService(repository, gatewayService).ackMessage({ + userId: USER_ID, + channelId: CHANNEL_ID, + messageId: params.messageId, + mentionCount: 0, + implicit: {unreadThrough: params.unreadThrough}, + emitGateway: false, + }); + return clearPushChannelNotifications.mock.calls.map(([call]) => call.messageId); + } + + it('clears when the previous read state lagged the latest earlier message', async () => { + const cleared = await implicitAck({ + previous: makeReadState(CHANNEL_ID, createMessageID(30n)), + messageId: createMessageID(40n), + unreadThrough: createMessageID(35n), + }); + expect(cleared).toEqual([createMessageID(40n)]); + }); + + it('does not clear when the previous read state already covered the latest earlier message', async () => { + const cleared = await implicitAck({ + previous: makeReadState(CHANNEL_ID, createMessageID(35n)), + messageId: createMessageID(40n), + unreadThrough: createMessageID(35n), + }); + expect(cleared).toEqual([]); + }); + + it('clears when unread mentions remained', async () => { + const cleared = await implicitAck({ + previous: makeReadState(CHANNEL_ID, createMessageID(35n), 2), + messageId: createMessageID(40n), + unreadThrough: createMessageID(35n), + }); + expect(cleared).toEqual([createMessageID(40n)]); + }); + + it('does not clear when the read state was already past the acknowledged message', async () => { + const cleared = await implicitAck({ + previous: makeReadState(CHANNEL_ID, createMessageID(50n), 2), + messageId: createMessageID(40n), + unreadThrough: createMessageID(40n), + }); + expect(cleared).toEqual([]); + }); + + it('clears when there was no read state and the channel had earlier messages', async () => { + const cleared = await implicitAck({ + previous: null, + messageId: createMessageID(40n), + unreadThrough: createMessageID(35n), + }); + expect(cleared).toEqual([createMessageID(40n)]); + }); + + it('does not clear when there was no read state and no earlier message', async () => { + const cleared = await implicitAck({previous: null, messageId: createMessageID(40n), unreadThrough: null}); + expect(cleared).toEqual([]); + }); + + it('does not wait for the push clear to finish', async () => { + const repository = { + upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => ({ + readState: makeReadState(channelId, messageId), + previous: makeReadState(channelId, createMessageID(30n), 1), + })), + } as unknown as IReadStateRepository; + const clearPushChannelNotifications = vi.fn(() => new Promise(() => {})); + const gatewayService = {clearPushChannelNotifications} as unknown as IGatewayService; + const readState = await new ReadStateService(repository, gatewayService).ackMessage({ + userId: USER_ID, + channelId: CHANNEL_ID, + messageId: createMessageID(40n), + mentionCount: 0, + implicit: {unreadThrough: createMessageID(35n)}, + emitGateway: false, + }); + expect(readState.lastMessageId).toBe(createMessageID(40n)); + expect(clearPushChannelNotifications).toHaveBeenCalledTimes(1); + }); +}); diff --git a/fluxer_api/src/api/read_state/ReadStateService.ts b/fluxer_api/src/api/read_state/ReadStateService.ts index d70e34513..a58a32fe4 100644 --- a/fluxer_api/src/api/read_state/ReadStateService.ts +++ b/fluxer_api/src/api/read_state/ReadStateService.ts @@ -6,6 +6,17 @@ import {Logger} from '@app/api/Logger'; import type {ReadState} from '@app/api/models/ReadState'; import type {IReadStateRepository} from '@app/api/read_state/IReadStateRepository'; +function hadUnreadThrough(previous: ReadState | null, messageId: MessageID, unreadThrough: MessageID | null): boolean { + const previousMessageId = previous?.lastMessageId ?? null; + if (previousMessageId !== null && previousMessageId >= messageId) { + return false; + } + if (previous !== null && previous.mentionCount > 0) { + return true; + } + return unreadThrough !== null && (previousMessageId === null || previousMessageId < unreadThrough); +} + export class ReadStateService { constructor( private repository: IReadStateRepository, @@ -16,17 +27,21 @@ export class ReadStateService { return await this.repository.listReadStates(userId); } + async getReadState(userId: UserID, channelId: ChannelID): Promise { + return await this.repository.getReadState(userId, channelId); + } + async ackMessage(params: { userId: UserID; channelId: ChannelID; messageId: MessageID; mentionCount: number; manual?: boolean; - silent?: boolean; + implicit?: {unreadThrough: MessageID | null}; emitGateway?: boolean; }): Promise { - const {userId, channelId, messageId, mentionCount, manual, silent, emitGateway = true} = params; - const readState = await this.repository.upsertReadState( + const {userId, channelId, messageId, mentionCount, manual, implicit, emitGateway = true} = params; + const {readState, previous} = await this.repository.upsertReadState( userId, channelId, messageId, @@ -34,8 +49,10 @@ export class ReadStateService { undefined, manual ?? false, ); - if (!silent) { + if (!implicit) { await this.clearPushChannelNotifications({userId, channelId, messageId}); + } else if (hadUnreadThrough(previous, messageId, implicit.unreadThrough)) { + void this.clearPushChannelNotifications({userId, channelId, messageId}); } if (emitGateway) { await this.dispatchMessageAck({ diff --git a/fluxer_api/src/api/rpc/RpcService.ts b/fluxer_api/src/api/rpc/RpcService.ts index 5ed22f553..965371260 100644 --- a/fluxer_api/src/api/rpc/RpcService.ts +++ b/fluxer_api/src/api/rpc/RpcService.ts @@ -629,6 +629,16 @@ export class RpcService { data: {channel}, }; } + case 'get_read_state': { + const readState = await this.readStateService.getReadState( + createUserID(request.user_id), + createChannelID(request.channel_id), + ); + return { + type: 'get_read_state', + data: {last_message_id: readState?.lastMessageId?.toString() ?? null}, + }; + } case 'get_gateway_rollout_config': { const rolloutConfig = await this.instanceConfigRepository.getGatewayRolloutConfig(); return { @@ -1978,7 +1988,7 @@ export class RpcService { channelId, messageId: createMessageID(messageId), mentionCount: 0, - silent: true, + implicit: {unreadThrough: createMessageID(messageId)}, }) .catch((error) => { Logger.error( diff --git a/fluxer_api/src/api/user/services/UserAccountUpdatePropagator.ts b/fluxer_api/src/api/user/services/UserAccountUpdatePropagator.ts index 16179f90f..c5e098652 100644 --- a/fluxer_api/src/api/user/services/UserAccountUpdatePropagator.ts +++ b/fluxer_api/src/api/user/services/UserAccountUpdatePropagator.ts @@ -54,13 +54,11 @@ export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator { event: 'USER_GUILD_SETTINGS_UPDATE', data: payload, }); - if (payload.guild_id !== null) { - await this.deps.gatewayService.syncPushUserGuildSettings({ - userId, - guildId: settings.guildId, - settings: payload, - }); - } + await this.deps.gatewayService.syncPushUserGuildSettings({ + userId, + guildId: settings.guildId, + settings: payload, + }); } async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise { diff --git a/fluxer_api/src/api/user/tests/PrivateChannelSettingsPushSync.test.ts b/fluxer_api/src/api/user/tests/PrivateChannelSettingsPushSync.test.ts new file mode 100644 index 000000000..12bdfb09e --- /dev/null +++ b/fluxer_api/src/api/user/tests/PrivateChannelSettingsPushSync.test.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createGuildID, createUserID} from '@app/api/BrandedTypes'; +import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient'; +import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError'; +import {GatewayService} from '@app/api/infrastructure/GatewayService'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {NoopGatewayService} from '@app/api/test/NoopGatewayService'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {updateGuildSettings} from '@app/api/user/tests/UserTestUtils'; +import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'; + +const DM_CHANNEL_ID = '1500000000000000005'; +const MUTE_DM = { + channel_overrides: { + [DM_CHANNEL_ID]: {collapsed: false, message_notifications: 3, muted: true, mute_config: null}, + }, +}; + +function gatewayRejectingSyncWith(code: string): GatewayService { + GatewayRpcClient.createForTests({ + async call(): Promise { + throw new GatewayRpcMethodError(code); + }, + async destroy(): Promise {}, + }); + return new GatewayService(); +} + +function routeSyncThrough(gateway: GatewayService): void { + vi.spyOn(NoopGatewayService.prototype, 'syncPushUserGuildSettings').mockImplementation((params) => + gateway.syncPushUserGuildSettings(params), + ); +} + +describe('Private channel settings push sync', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + beforeEach(async () => { + await harness.reset(); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await GatewayRpcClient.resetForTests(); + }); + afterAll(async () => { + await harness?.shutdown(); + }); + + it('syncs a muted DM to the push cache under the private scope', async () => { + const account = await createTestAccount(harness); + const syncPush = vi.spyOn(NoopGatewayService.prototype, 'syncPushUserGuildSettings'); + + await updateGuildSettings(harness, account.token, MUTE_DM); + + expect(syncPush).toHaveBeenCalledTimes(1); + const [params] = syncPush.mock.calls[0]!; + expect(params.userId.toString()).toBe(account.userId); + expect(params.guildId.toString()).toBe('0'); + expect(params.settings).toMatchObject({ + guild_id: null, + channel_overrides: {[DM_CHANNEL_ID]: {muted: true}}, + }); + }); + + it('still mutes a DM when the gateway predates private scope sync', async () => { + const account = await createTestAccount(harness); + routeSyncThrough(gatewayRejectingSyncWith(GatewayRpcMethodErrorCodes.INVALID_PARAMS)); + + const {json} = await updateGuildSettings(harness, account.token, MUTE_DM); + + expect(json).toMatchObject({channel_overrides: {[DM_CHANNEL_ID]: {muted: true}}}); + }); + + it('fails the DM settings update on any other gateway sync error', async () => { + const account = await createTestAccount(harness); + routeSyncThrough(gatewayRejectingSyncWith(GatewayRpcMethodErrorCodes.INTERNAL_ERROR)); + + await createBuilder(harness, account.token) + .patch('/users/@me/guilds/@me/settings') + .body(MUTE_DM) + .expect(502) + .execute(); + }); + + it('keeps rejecting invalid_params for a real guild', async () => { + const gateway = gatewayRejectingSyncWith(GatewayRpcMethodErrorCodes.INVALID_PARAMS); + + await expect( + gateway.syncPushUserGuildSettings({userId: createUserID(2n), guildId: createGuildID(1n), settings: {}}), + ).rejects.toBeInstanceOf(BadRequestError); + }); +}); diff --git a/fluxer_app/src/features/platform/service_worker/Worker.ts b/fluxer_app/src/features/platform/service_worker/Worker.ts index e20d2e07d..17a704434 100644 --- a/fluxer_app/src/features/platform/service_worker/Worker.ts +++ b/fluxer_app/src/features/platform/service_worker/Worker.ts @@ -19,6 +19,7 @@ import { getBadgeCount, getPushNotificationClientState, isNotificationClearPayload, + isPushNotificationReadThrough, matchesPushChannelNotification, normalizePushPayload, resolvePushChannelId, @@ -227,7 +228,10 @@ const getShownPushNotifications = async (tag: string): Promise => { +const closePushNotificationsForChannel = async ( + channelId: string, + readThroughMessageId: string | undefined, +): Promise => { if (typeof self.registration.getNotifications !== 'function') { return 0; } @@ -235,7 +239,10 @@ const closePushNotificationsForChannel = async (channelId: string): Promise { if (isNotificationClearPayload(payload)) { const channelId = resolvePushChannelId(payload); const closedCount = channelId - ? await closePushNotificationsForChannel(channelId) + ? await closePushNotificationsForChannel(channelId, resolvePushMessageId(payload)) : await closePushNotifications(tag); await updateAppBadge(badgeCount); await log('info', 'push clear received', {tag, channelId, closedCount, badgeCount}); diff --git a/fluxer_app/src/features/platform/service_worker/WorkerPushPayload.ts b/fluxer_app/src/features/platform/service_worker/WorkerPushPayload.ts index 06dd5070b..2c2590025 100644 --- a/fluxer_app/src/features/platform/service_worker/WorkerPushPayload.ts +++ b/fluxer_app/src/features/platform/service_worker/WorkerPushPayload.ts @@ -89,6 +89,20 @@ export const matchesPushChannelNotification = (notification: Notification, chann } return typeof tag === 'string' && tag.startsWith(`${channelTag}:`); }; +const SNOWFLAKE_PATTERN = /^[1-9]\d*$/; +export const isPushNotificationReadThrough = ( + notification: {readonly data?: unknown}, + readThroughMessageId: string | undefined, +): boolean => { + if (readThroughMessageId === undefined || !SNOWFLAKE_PATTERN.test(readThroughMessageId)) { + return true; + } + const messageId = isRecord(notification.data) ? notification.data.message_id : undefined; + if (typeof messageId !== 'string' || !SNOWFLAKE_PATTERN.test(messageId)) { + return true; + } + return BigInt(messageId) <= BigInt(readThroughMessageId); +}; export const getPushNotificationClientState = ( clients: ReadonlyArray<{readonly visibilityState?: string}>, ): PushNotificationClientState => ({ diff --git a/fluxer_gateway/src/gateway/gateway_rpc_push.erl b/fluxer_gateway/src/gateway/gateway_rpc_push.erl index 0aeb2deb8..6e9bc44bd 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_push.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_push.erl @@ -28,12 +28,18 @@ do_sync_user_guild_settings(#{ <<"user_guild_settings">> := Settings }) -> UserId = validation:snowflake_or_throw(<<"user_id">>, UBin), - GuildId = validation:snowflake_or_throw(<<"guild_id">>, GBin), + GuildId = settings_guild_id(GBin), fanout_local_cache_mutation(push, sync_user_guild_settings_local, [ UserId, GuildId, Settings ]), true. +-spec settings_guild_id(term()) -> non_neg_integer(). +settings_guild_id(<<"0">>) -> + 0; +settings_guild_id(GBin) -> + validation:snowflake_or_throw(<<"guild_id">>, GBin). + -spec do_sync_user_blocked_ids(map()) -> true. do_sync_user_blocked_ids(#{<<"user_id">> := UBin, <<"blocked_user_ids">> := BlockedRaw}) -> UserId = validation:snowflake_or_throw(<<"user_id">>, UBin), @@ -137,6 +143,47 @@ invalidate_subscriptions_accepts_a_user_test() -> execute_method(<<"push.invalidate_subscriptions">>, #{<<"user_id">> => <<"1001">>}) ). +sync_user_guild_settings_caches_the_private_scope_test() -> + push_ets_cache:init(), + Settings = #{<<"channel_overrides">> => #{<<"5">> => #{<<"muted">> => true}}}, + try + with_registered_push(fun() -> + ?assert( + execute_method(<<"push.sync_user_guild_settings">>, #{ + <<"user_id">> => <<"1001">>, + <<"guild_id">> => <<"0">>, + <<"user_guild_settings">> => Settings + }) + ) + end), + ?assertEqual(Settings, push_ets_cache:get_user_guild_settings(1001, 0)) + after + push_ets_cache:delete_user_guild_settings(1001, 0) + end. + +sync_user_guild_settings_rejects_an_invalid_guild_id_test() -> + ?assertError( + {validation, _}, + execute_method(<<"push.sync_user_guild_settings">>, #{ + <<"user_id">> => <<"1001">>, + <<"guild_id">> => <<"nope">>, + <<"user_guild_settings">> => #{} + }) + ). + +with_registered_push(Fun) -> + case whereis(push) of + undefined -> + register(push, self()), + try + Fun() + after + unregister(push) + end; + _Pid -> + Fun() + end. + invalidate_subscriptions_rejects_an_invalid_snowflake_test() -> ?assertError( {validation, _}, diff --git a/fluxer_gateway/src/gateway/metrics_handler.erl b/fluxer_gateway/src/gateway/metrics_handler.erl index 452d5a029..c5f03f796 100644 --- a/fluxer_gateway/src/gateway/metrics_handler.erl +++ b/fluxer_gateway/src/gateway/metrics_handler.erl @@ -302,6 +302,12 @@ render_push_outbox_queue_stats(Stats) -> <<"counter">>, <<"Queued recipients skipped because they became active">>, gate_counter(skipped_active, Stats) + ), + format_metric( + <<"fluxer_gateway_push_outbox_followup_clears_total">>, + <<"counter">>, + <<"Clears queued after an in-flight push job whose recipient read the channel">>, + gate_counter(followup_clears, Stats) ) ]. diff --git a/fluxer_gateway/src/gateway/rpc_client.erl b/fluxer_gateway/src/gateway/rpc_client.erl index c6a5c31c3..d448dac3b 100644 --- a/fluxer_gateway/src/gateway/rpc_client.erl +++ b/fluxer_gateway/src/gateway/rpc_client.erl @@ -5,6 +5,7 @@ -export([ call/1, + call/2, call_with_retry/2, handle_http_response/2, rpc_headers/1, @@ -42,9 +43,13 @@ -spec call(rpc_request()) -> rpc_response(). call(Request) -> + call(Request, request_timeout_ms()). + +-spec call(rpc_request(), pos_integer()) -> rpc_response(). +call(Request, Timeout) -> Trace = build_request_trace(Request), maybe_log_voice_request_start(Trace), - Result = do_request(Request), + Result = do_request(Request, Timeout), maybe_log_voice_request_response(Trace, Result), Result. @@ -132,10 +137,9 @@ backoff_delay(Attempt, {_MaxAttempts, BaseMs, MaxMs, JitterMs}) -> end, trunc(CappedDelay + Jitter). --spec do_request(rpc_request()) -> rpc_response(). -do_request(Request) -> +-spec do_request(rpc_request(), pos_integer()) -> rpc_response(). +do_request(Request, Timeout) -> {Url, HostKey, IsHttps} = rpc_url_meta(), - Timeout = request_timeout_ms(), Payload = iolist_to_binary(json:encode(Request)), Headers = rpc_headers(Request), RequestOpts = #{ diff --git a/fluxer_gateway/src/guild/guild.erl b/fluxer_gateway/src/guild/guild.erl index 39d97ffe4..7ba9c1d2a 100644 --- a/fluxer_gateway/src/guild/guild.erl +++ b/fluxer_gateway/src/guild/guild.erl @@ -59,6 +59,8 @@ handle_call({reload, NewData}, _From, State) -> handle_reload_call(NewData, State); handle_call(get_voice_server_pid, _From, State) -> guild_voice_lifecycle:reply_voice_server_pid(State); +handle_call({released_push_holds, SessionIds}, _From, State) when is_list(SessionIds) -> + {reply, guild_sessions:released_push_holds(SessionIds, State), State}; handle_call({terminate}, _From, State) -> {stop, normal, ok, State}; handle_call(Msg, From, State) when is_tuple(Msg) -> @@ -158,6 +160,10 @@ handle_cast({drop_session_member_lists, SessionId}, State) when is_binary(Sessio {noreply, guild_member_list:unsubscribe_session(SessionId, State)}; handle_cast({set_session_typing_override, SessionId, TypingFlag}, State) -> handle_set_session_typing_override_cast(SessionId, TypingFlag, State); +handle_cast({set_session_push_hold, SessionId, Hold}, State) when + is_binary(SessionId), is_boolean(Hold) +-> + {noreply, guild_sessions:set_session_push_hold(SessionId, Hold, State)}; handle_cast({send_guild_sync, SessionId}, State) -> handle_send_guild_sync_cast(SessionId, State); handle_cast({send_members_chunk, SessionId, ChunkData}, State) -> diff --git a/fluxer_gateway/src/guild/guild_dispatch_push.erl b/fluxer_gateway/src/guild/guild_dispatch_push.erl index cdd44e842..9b45c2f28 100644 --- a/fluxer_gateway/src/guild/guild_dispatch_push.erl +++ b/fluxer_gateway/src/guild/guild_dispatch_push.erl @@ -19,6 +19,7 @@ -define(PUSH_WORKER_MAX_AGE_MS, 60000). -define(GRACE_RECHECK_KEY, guild_push_offline_grace_recheck_ms). -define(DEFAULT_GRACE_RECHECK_MS, 7000). +-define(GRACE_HOLD_CALL_TIMEOUT_MS, 5000). -define(PUSH_COUNTERS, guild_push_counters). -define(PUSH_COUNTER_KEYS, [ worker_started, @@ -40,14 +41,17 @@ -type user_id() :: integer(). -type push_worker() :: {integer(), pid(), integer()}. -type push_gate() :: push_worker() | undefined. --type grace_sessions() :: #{user_id() => [pid()]}. +-type grace_sessions() :: #{user_id() => [{binary(), pid()}]}. -type grace_hold() :: {grace_sessions(), integer()} | none. --type held_push() :: {[user_id()], grace_hold(), term()}. +-type held_push() :: {[user_id()], grace_hold(), term(), pid() | undefined}. -export_type([event/0, event_data/0, guild_state/0, guild_id/0]). -spec maybe_send_push_notifications(event(), event_data(), guild_id(), guild_state()) -> ok. maybe_send_push_notifications(message_create, FinalData, GuildId, UpdatedState) -> - case maps:get(disable_push_notifications, UpdatedState, false) of + case + maps:get(disable_push_notifications, UpdatedState, false) orelse + push_message_params:suppresses_notifications(FinalData) + of true -> ok; false -> maybe_spawn_push(FinalData, GuildId, UpdatedState) end; @@ -251,15 +255,16 @@ spawn_push(FinalData, GuildId, UpdatedState, Gate) -> -spec compact_push_state(ets:tid(), map(), guild_id(), guild_state()) -> guild_state(). compact_push_state(MembersTab, Data, GuildId, UpdatedState) -> Sessions = maps:get(sessions, UpdatedState, #{}), - SessionEligibility = build_push_session_eligibility(Sessions, UpdatedState), + {SessionEligibility, Hold} = build_push_session_eligibility(Sessions, UpdatedState), #{ id => maps:get(id, UpdatedState, GuildId), data => compact_push_data(Data), virtual_channel_access => maps:get(virtual_channel_access, UpdatedState, #{}), members_ets => MembersTab, member_presence => maps:get(member_presence, UpdatedState, undefined), + guild_pid => grace_guild_pid(), session_eligibility => SessionEligibility, - grace_hold => grace_hold(Sessions, SessionEligibility), + grace_hold => Hold, member_count => maps:get(member_count, UpdatedState, undefined) }. @@ -316,6 +321,7 @@ legacy_push_state(GuildId, UpdatedState) -> data => maps:get(data, UpdatedState, #{}), sessions => maps:get(sessions, UpdatedState, #{}), member_presence => maps:get(member_presence, UpdatedState, undefined), + guild_pid => grace_guild_pid(), virtual_channel_access => maps:get(virtual_channel_access, UpdatedState, #{}), member_count => maps:get(member_count, UpdatedState, undefined) }. @@ -577,7 +583,8 @@ send_compact_scanned_push(MessageData, GuildId, Scan, State) -> { lists:reverse(maps:get(held_user_ids, Scan, [])), maps:get(grace_hold, State, none), - maps:get(member_presence, State, undefined) + maps:get(member_presence, State, undefined), + maps:get(guild_pid, State, undefined) } ). @@ -654,7 +661,7 @@ send_push_notifications(MessageData, GuildId, State) -> Data = maps:get(data, State), Members = guild_data_index:member_map(Data), Sessions = maps:get(sessions, State, #{}), - SessionEligibility = build_push_session_eligibility(Sessions, State), + {SessionEligibility, Hold} = build_push_session_eligibility(Sessions, State), CandidateUserIds = push_candidate_user_ids(Members, SessionEligibility, MessageData), ChannelIdBin = maps:get(<<"channel_id">>, MessageData, undefined), case guild_dispatch_decorate:parse_snowflake(<<"channel_id">>, ChannelIdBin) of @@ -668,7 +675,7 @@ send_push_notifications(MessageData, GuildId, State) -> CandidateUserIds, ChannelId, SessionEligibility, - grace_hold(Sessions, SessionEligibility), + Hold, Data, State ) @@ -715,7 +722,12 @@ send_to_eligible( SessionEligibility, Data, large_guild_meta(State), - {HeldUserIds, Hold, maps:get(member_presence, State, undefined)} + { + HeldUserIds, + Hold, + maps:get(member_presence, State, undefined), + maps:get(guild_pid, State, undefined) + } ) end. @@ -885,41 +897,81 @@ view_to_filtermap(UserId, ChannelId, Member, State) -> false -> false end. --spec build_push_session_eligibility(map(), guild_state()) -> #{user_id() => boolean()}. +-spec build_push_session_eligibility(map(), guild_state()) -> + {#{user_id() => boolean()}, grace_hold()}. build_push_session_eligibility(Sessions, State) -> case presence_eligibility_enabled() of true -> build_push_presence_eligibility(Sessions, State); - false -> build_push_session_eligibility(Sessions) + false -> build_legacy_session_eligibility(Sessions) + end. + +-spec grace_guild_pid() -> pid() | undefined. +grace_guild_pid() -> + case presence_eligibility_enabled() of + true -> self(); + false -> undefined end. -spec presence_eligibility_enabled() -> boolean(). presence_eligibility_enabled() -> application:get_env(fluxer_gateway, push_presence_eligibility, true) =:= true. --spec build_push_presence_eligibility(map(), guild_state()) -> #{user_id() => boolean()}. +-spec build_legacy_session_eligibility(map()) -> {#{user_id() => boolean()}, grace_hold()}. +build_legacy_session_eligibility(Sessions) -> + Eligibility = build_push_session_eligibility(Sessions), + {Eligibility, grace_hold(suppressed_sessions(Sessions, Eligibility))}. + +-spec build_push_presence_eligibility(map(), guild_state()) -> + {#{user_id() => boolean()}, grace_hold()}. build_push_presence_eligibility(Sessions, State) -> - Presences = maps:get(member_presence, State, undefined), + Engaged = engaged_sessions(Sessions, maps:get(member_presence, State, undefined)), + {engagement_eligibility(Sessions, Engaged), grace_hold(held_engaged_sessions(Engaged))}. + +-spec engaged_sessions(map(), term()) -> grace_sessions(). +engaged_sessions(Sessions, Presences) -> maps:fold( - fun(_Sid, Session, Acc) -> - accumulate_presence_eligibility(Session, Presences, Acc) + fun(Sid, Session, Acc) -> maybe_add_engaged_session(Sid, Session, Presences, Acc) end, + #{}, + Sessions + ). + +-spec maybe_add_engaged_session(term(), term(), term(), grace_sessions()) -> grace_sessions(). +maybe_add_engaged_session(Sid, #{user_id := UserId} = Session, Presences, Acc) when + is_integer(UserId) +-> + case session_engaged(Session, UserId, Presences) of + true -> Acc#{UserId => session_pids(Sid, Session) ++ maps:get(UserId, Acc, [])}; + false -> Acc + end; +maybe_add_engaged_session(_Sid, _Session, _Presences, Acc) -> + Acc. + +-spec session_engaged(map(), user_id(), term()) -> boolean(). +session_engaged(#{push_hold := Hold}, _UserId, _Presences) -> + Hold =:= true; +session_engaged(_Session, UserId, Presences) -> + actively_engaged(UserId, Presences). + +-spec session_pids(term(), map()) -> [{binary(), pid()}]. +session_pids(Sid, #{pid := Pid}) when is_binary(Sid), is_pid(Pid) -> [{Sid, Pid}]; +session_pids(_Sid, _Session) -> []. + +-spec engagement_eligibility(map(), grace_sessions()) -> #{user_id() => boolean()}. +engagement_eligibility(Sessions, Engaged) -> + maps:fold( + fun + (_Sid, #{user_id := UserId}, Acc) when is_integer(UserId) -> + Acc#{UserId => not maps:is_key(UserId, Engaged)}; + (_Sid, _Session, Acc) -> + Acc end, #{}, Sessions ). --spec accumulate_presence_eligibility(map(), term(), #{user_id() => boolean()}) -> - #{user_id() => boolean()}. -accumulate_presence_eligibility(Session, Presences, Acc) -> - case maps:get(user_id, Session, undefined) of - UserId when is_integer(UserId) -> - Acc#{ - UserId => - maps:get(UserId, Acc, true) andalso - not actively_engaged(UserId, Presences) - }; - _ -> - Acc - end. +-spec held_engaged_sessions(grace_sessions()) -> grace_sessions(). +held_engaged_sessions(Engaged) -> + maps:filter(fun(_UserId, Pids) -> Pids =/= [] end, Engaged). -spec actively_engaged(user_id(), term()) -> boolean(). actively_engaged(_UserId, undefined) -> @@ -1059,46 +1111,77 @@ send_push_now(UserIds, Params) -> push:handle_message_create(Params#{user_ids => UserIds}). -spec hold_push_through_grace(held_push(), map()) -> ok. -hold_push_through_grace({[_ | _] = UserIds, {Sessions, RecheckAt}, Presences}, Params) -> +hold_push_through_grace( + {[_ | _] = UserIds, {Sessions, RecheckAt}, Presences, GuildPid}, Params +) -> HeldParams = Params#{ user_roles => maps:with(UserIds, maps:get(user_roles, Params)), connected_users => maps:with(UserIds, maps:get(connected_users, Params)) }, HeldSessions = maps:with(UserIds, Sessions), _ = spawn(fun() -> - deliver_after_grace(HeldParams, HeldSessions, RecheckAt, Presences) + deliver_after_grace(HeldParams, HeldSessions, RecheckAt, {Presences, GuildPid}) end), ok; hold_push_through_grace(_Held, _Params) -> ok. --spec deliver_after_grace(map(), grace_sessions(), integer(), term()) -> ok. -deliver_after_grace(Params, Sessions, RecheckAt, Presences) -> +-spec deliver_after_grace(map(), grace_sessions(), integer(), {term(), pid() | undefined}) -> + ok. +deliver_after_grace(Params, Sessions, RecheckAt, {Presences, GuildPid}) -> ok = apply_push_worker_priority(), - LiveSessions = await_grace_recheck(monitor_grace_sessions(Sessions), RecheckAt), - send_push_now( - grace_recipients(maps:keys(Sessions), maps:values(LiveSessions), Presences), Params - ). + HeldUserIds = maps:keys(Sessions), + AlreadyOffline = users_left_offline(HeldUserIds, Presences), + LiveSessions = maps:values( + await_grace_recheck(monitor_grace_sessions(Sessions), RecheckAt) + ), + Holding = still_holding(LiveSessions, released_push_holds(GuildPid, LiveSessions)), + send_push_now(grace_recipients(HeldUserIds, Holding, Presences, AlreadyOffline), Params). --spec grace_recipients([user_id()], [user_id()], term()) -> [user_id()]. -grace_recipients(HeldUserIds, LiveUserIds, Presences) -> +-spec still_holding([{user_id(), binary()}], [binary()]) -> [user_id()]. +still_holding(LiveSessions, Released) -> + ReleasedSet = maps:from_keys(Released, true), + [UserId || {UserId, Sid} <- LiveSessions, not maps:is_key(Sid, ReleasedSet)]. + +-spec released_push_holds(pid() | undefined, [{user_id(), binary()}]) -> [binary()]. +released_push_holds(GuildPid, [_ | _] = LiveSessions) when is_pid(GuildPid) -> + SessionIds = [Sid || {_UserId, Sid} <- LiveSessions], + try + gen_server:call( + GuildPid, {released_push_holds, SessionIds}, ?GRACE_HOLD_CALL_TIMEOUT_MS + ) + of + Released when is_list(Released) -> Released; + _ -> [] + catch + exit:_ -> [] + end; +released_push_holds(_GuildPid, _LiveSessions) -> + []. + +-spec grace_recipients([user_id()], [user_id()], term(), [user_id()]) -> [user_id()]. +grace_recipients(HeldUserIds, LiveUserIds, Presences, AlreadyOffline) -> Live = lists:usort(LiveUserIds), Gone = lists:usort(HeldUserIds) -- Live, - lists:usort(Gone ++ users_left_offline(Live, Presences)). + lists:usort(Gone ++ (users_left_offline(Live, Presences) -- AlreadyOffline)). --spec monitor_grace_sessions(grace_sessions()) -> #{reference() => user_id()}. +-spec monitor_grace_sessions(grace_sessions()) -> #{reference() => {user_id(), binary()}}. monitor_grace_sessions(Sessions) -> maps:fold(fun monitor_user_sessions/3, #{}, Sessions). --spec monitor_user_sessions(user_id(), [pid()], #{reference() => user_id()}) -> - #{reference() => user_id()}. -monitor_user_sessions(UserId, Pids, Monitors) -> +-spec monitor_user_sessions( + user_id(), [{binary(), pid()}], #{reference() => {user_id(), binary()}} +) -> + #{reference() => {user_id(), binary()}}. +monitor_user_sessions(UserId, Held, Monitors) -> lists:foldl( - fun(Pid, Acc) -> Acc#{erlang:monitor(process, Pid) => UserId} end, Monitors, Pids + fun({Sid, Pid}, Acc) -> Acc#{erlang:monitor(process, Pid) => {UserId, Sid}} end, + Monitors, + Held ). --spec await_grace_recheck(#{reference() => user_id()}, integer()) -> - #{reference() => user_id()}. +-spec await_grace_recheck(#{reference() => {user_id(), binary()}}, integer()) -> + #{reference() => {user_id(), binary()}}. await_grace_recheck(Monitors, RecheckAt) -> Remaining = max(0, RecheckAt - erlang:monotonic_time(millisecond)), receive @@ -1119,14 +1202,11 @@ presence_is_offline(UserId, Presences) -> Presence -> maps:get(<<"status">>, Presence, <<"offline">>) =:= <<"offline">> end. --spec grace_hold(map(), #{user_id() => boolean()}) -> grace_hold(). -grace_hold(Sessions, SessionEligibility) -> - case suppressed_sessions(Sessions, SessionEligibility) of - Held when map_size(Held) =:= 0 -> - none; - Held -> - {Held, erlang:monotonic_time(millisecond) + grace_recheck_ms()} - end. +-spec grace_hold(grace_sessions()) -> grace_hold(). +grace_hold(Held) when map_size(Held) =:= 0 -> + none; +grace_hold(Held) -> + {Held, erlang:monotonic_time(millisecond) + grace_recheck_ms()}. -spec held_sessions(grace_hold()) -> grace_sessions(). held_sessions(none) -> #{}; @@ -1135,35 +1215,37 @@ held_sessions({Held, _RecheckAt}) -> Held. -spec suppressed_sessions(map(), #{user_id() => boolean()}) -> grace_sessions(). suppressed_sessions(Sessions, SessionEligibility) -> maps:fold( - fun(_Sid, Session, Acc) -> maybe_hold_session(Session, SessionEligibility, Acc) end, + fun(Sid, Session, Acc) -> maybe_hold_session(Sid, Session, SessionEligibility, Acc) end, #{}, Sessions ). --spec maybe_hold_session(term(), #{user_id() => boolean()}, grace_sessions()) -> +-spec maybe_hold_session(term(), term(), #{user_id() => boolean()}, grace_sessions()) -> grace_sessions(). -maybe_hold_session(Session, SessionEligibility, Acc) when is_map(Session) -> +maybe_hold_session(Sid, Session, SessionEligibility, Acc) when is_map(Session) -> hold_suppressed_session( maps:get(user_id, Session, undefined), - maps:get(pid, Session, undefined), + session_pids(Sid, Session), SessionEligibility, Acc ); -maybe_hold_session(_Session, _SessionEligibility, Acc) -> +maybe_hold_session(_Sid, _Session, _SessionEligibility, Acc) -> Acc. --spec hold_suppressed_session(term(), term(), #{user_id() => boolean()}, grace_sessions()) -> +-spec hold_suppressed_session( + term(), [{binary(), pid()}], #{user_id() => boolean()}, grace_sessions() +) -> grace_sessions(). -hold_suppressed_session(UserId, Pid, SessionEligibility, Acc) when - is_integer(UserId), is_pid(Pid) +hold_suppressed_session(UserId, [_ | _] = Held, SessionEligibility, Acc) when + is_integer(UserId) -> case maps:get(UserId, SessionEligibility, true) of false -> - Acc#{UserId => [Pid | maps:get(UserId, Acc, [])]}; + Acc#{UserId => Held ++ maps:get(UserId, Acc, [])}; true -> Acc end; -hold_suppressed_session(_UserId, _Pid, _SessionEligibility, Acc) -> +hold_suppressed_session(_UserId, _Held, _SessionEligibility, Acc) -> Acc. -spec grace_recheck_ms() -> pos_integer(). @@ -1258,7 +1340,7 @@ presence_eligibility_suppresses_active_desktop_test() -> ), ?assertEqual( #{1 => false}, - build_push_presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{ + presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{ member_presence => Tab }) ) @@ -1274,7 +1356,7 @@ presence_eligibility_allows_mobile_only_session_test() -> ), ?assertEqual( #{1 => true}, - build_push_presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{ + presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{ member_presence => Tab }) ) @@ -1293,7 +1375,7 @@ presence_eligibility_allows_idle_and_afk_test() -> ), ?assertEqual( #{1 => true, 2 => true}, - build_push_presence_eligibility( + presence_eligibility( #{<<"s1">> => #{user_id => 1}, <<"s2">> => #{user_id => 2}}, #{member_presence => Tab} ) @@ -1307,7 +1389,7 @@ presence_eligibility_allows_unknown_presence_test() -> try ?assertEqual( #{7 => true}, - build_push_presence_eligibility(#{<<"s1">> => #{user_id => 7}}, #{ + presence_eligibility(#{<<"s1">> => #{user_id => 7}}, #{ member_presence => Tab }) ) @@ -1318,24 +1400,121 @@ presence_eligibility_allows_unknown_presence_test() -> presence_eligibility_allows_when_presence_table_missing_test() -> ?assertEqual( #{1 => true}, - build_push_presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{}) + presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{}) ). +presence_eligibility(Sessions, State) -> + {Eligibility, _Hold} = build_push_presence_eligibility(Sessions, State), + Eligibility. + +session_push_hold_keeps_an_active_desktop_engaged_beside_a_backgrounded_phone_test() -> + Tab = ets:new(test_member_presence, [set, public]), + Desktop = spawn(fun() -> ok end), + Phone = spawn(fun() -> ok end), + try + ets:insert( + Tab, {1, #{<<"status">> => <<"online">>, <<"mobile">> => true, <<"afk">> => false}} + ), + Sessions = #{ + <<"desktop">> => #{user_id => 1, pid => Desktop, push_hold => true}, + <<"phone">> => #{user_id => 1, pid => Phone, push_hold => false} + }, + {Eligibility, {Held, _RecheckAt}} = + build_push_presence_eligibility(Sessions, #{member_presence => Tab}), + ?assertEqual(#{1 => false}, Eligibility), + ?assertEqual(#{1 => [{<<"desktop">>, Desktop}]}, Held) + after + ets:delete(Tab) + end. + +session_push_hold_engages_a_user_published_as_offline_test() -> + Tab = ets:new(test_member_presence, [set, public]), + try + ets:insert(Tab, {1, #{<<"status">> => <<"offline">>}}), + ?assertEqual( + #{1 => false}, + presence_eligibility( + #{<<"s1">> => #{user_id => 1, push_hold => true}}, #{member_presence => Tab} + ) + ) + after + ets:delete(Tab) + end. + +session_push_hold_false_overrides_an_active_published_presence_test() -> + Tab = ets:new(test_member_presence, [set, public]), + try + ets:insert( + Tab, {1, #{<<"status">> => <<"online">>, <<"mobile">> => false, <<"afk">> => false}} + ), + ?assertEqual( + {#{1 => true}, none}, + build_push_presence_eligibility( + #{<<"s1">> => #{user_id => 1, pid => self(), push_hold => false}}, + #{member_presence => Tab} + ) + ) + after + ets:delete(Tab) + end. + +released_push_holds_treat_an_old_guild_reply_as_nothing_released_test() -> + GuildPid = spawn(fun() -> + receive + {'$gen_call', From, {released_push_holds, _SessionIds}} -> + gen_server:reply(From, ok) + end + end), + ?assertEqual([], released_push_holds(GuildPid, [{1, <<"s1">>}])). + +released_push_holds_treat_a_dead_guild_as_nothing_released_test() -> + {GuildPid, MRef} = spawn_monitor(fun() -> ok end), + receive + {'DOWN', MRef, process, GuildPid, _} -> ok + end, + ?assertEqual([], released_push_holds(GuildPid, [{1, <<"s1">>}])). + +push_states_carry_the_guild_pid_for_the_grace_recheck_test() -> + ?assertEqual({self(), self()}, push_state_guild_pids()). + +push_states_skip_the_grace_recheck_while_presence_eligibility_is_off_test() -> + ok = application:set_env(fluxer_gateway, push_presence_eligibility, false), + try + ?assertEqual({undefined, undefined}, push_state_guild_pids()) + after + ok = application:unset_env(fluxer_gateway, push_presence_eligibility) + end. + +push_state_guild_pids() -> + Tab = ets:new(push_state_members, []), + try + Compact = compact_push_state(Tab, #{}, 7, #{sessions => #{}}), + Legacy = legacy_push_state(7, #{}), + {maps:get(guild_pid, Compact), maps:get(guild_pid, Legacy)} + after + ets:delete(Tab) + end. + +grace_recipients_keep_holding_a_user_already_offline_when_held_test() -> + Presences = #{1 => #{<<"status">> => <<"offline">>}}, + ?assertEqual([], grace_recipients([1], [1], Presences, [1])), + ?assertEqual([1], grace_recipients([1], [], Presences, [1])). + grace_recipients_push_a_user_whose_held_sessions_all_ended_test() -> Presences = #{1 => #{<<"status">> => <<"online">>}}, - ?assertEqual([1], grace_recipients([1], [], Presences)). + ?assertEqual([1], grace_recipients([1], [], Presences, [])). grace_recipients_skip_a_user_still_online_on_a_held_session_test() -> Presences = #{1 => #{<<"status">> => <<"online">>}}, - ?assertEqual([], grace_recipients([1], [1], Presences)). + ?assertEqual([], grace_recipients([1], [1], Presences, [])). grace_recipients_push_a_user_whose_live_session_went_offline_test() -> Presences = #{1 => #{<<"status">> => <<"offline">>}}, - ?assertEqual([1], grace_recipients([1], [1], Presences)). + ?assertEqual([1], grace_recipients([1], [1], Presences, [])). grace_recipients_skip_a_user_with_one_ended_and_one_live_online_session_test() -> Presences = #{1 => #{<<"status">> => <<"online">>}}, - ?assertEqual([], grace_recipients([1], [1], Presences)). + ?assertEqual([], grace_recipients([1], [1], Presences, [])). grace_recipients_mix_users_test() -> Presences = #{ @@ -1343,7 +1522,7 @@ grace_recipients_mix_users_test() -> 2 => #{<<"status">> => <<"online">>}, 3 => #{<<"status">> => <<"offline">>} }, - ?assertEqual([1, 3], grace_recipients([1, 2, 3], [2, 3, 3], Presences)). + ?assertEqual([1, 3], grace_recipients([1, 2, 3], [2, 3, 3], Presences, [])). deliver_after_grace_pushes_a_user_whose_session_exits_inside_the_window_test() -> Self = self(), @@ -1358,10 +1537,10 @@ deliver_after_grace_pushes_a_user_whose_session_exits_inside_the_window_test() - stop -> ok end end), - Held = #{42 => [Session]}, + Held = #{42 => [{<<"s1">>, Session}]}, Presences = #{42 => #{<<"status">> => <<"online">>}}, RecheckAt = erlang:monotonic_time(millisecond) + 300, - _ = spawn(fun() -> deliver_after_grace(#{}, Held, RecheckAt, Presences) end), + _ = spawn(fun() -> deliver_after_grace(#{}, Held, RecheckAt, {Presences, undefined}) end), timer:sleep(50), Session ! stop, receive diff --git a/fluxer_gateway/src/guild/guild_sessions.erl b/fluxer_gateway/src/guild/guild_sessions.erl index 63e3ea108..39daa4d4f 100644 --- a/fluxer_gateway/src/guild/guild_sessions.erl +++ b/fluxer_gateway/src/guild/guild_sessions.erl @@ -23,6 +23,8 @@ refresh_user_session_cache/2, refresh_all_viewable_channels/1, handle_set_typing_override/3, + set_session_push_hold/3, + released_push_holds/2, handle_send_guild_sync/2, handle_send_members_chunk/3, build_viewable_channel_map/1 @@ -214,6 +216,25 @@ is_session_active(SessionId, State) -> handle_set_typing_override(SessionId, TypingFlag, State) -> guild_sessions_passive:handle_set_typing_override(SessionId, TypingFlag, State). +-spec set_session_push_hold(session_id(), boolean(), guild_state()) -> guild_state(). +set_session_push_hold(SessionId, Hold, State) -> + Sessions = maps:get(sessions, State, #{}), + case maps:get(SessionId, Sessions, undefined) of + SessionData when is_map(SessionData) -> + State#{sessions => Sessions#{SessionId => SessionData#{push_hold => Hold}}}; + _ -> + State + end. + +-spec released_push_holds([session_id()], guild_state()) -> [session_id()]. +released_push_holds(SessionIds, State) -> + Sessions = maps:get(sessions, State, #{}), + [ + SessionId + || SessionId <- SessionIds, + maps:get(push_hold, maps:get(SessionId, Sessions, #{}), undefined) =:= false + ]. + -spec handle_send_guild_sync(session_id(), guild_state()) -> guild_state(). handle_send_guild_sync(SessionId, State) -> guild_sessions_passive:handle_send_guild_sync(SessionId, State). diff --git a/fluxer_gateway/src/presence/presence.erl b/fluxer_gateway/src/presence/presence.erl index cd9aeeb1c..f44c1fc72 100644 --- a/fluxer_gateway/src/presence/presence.erl +++ b/fluxer_gateway/src/presence/presence.erl @@ -22,7 +22,7 @@ }. -type sessions() :: #{session_id() => session_entry()}. -type push_buffer_entry() :: #{ - channel_id := integer(), message_id := integer(), params := map() + channel_id := integer(), message_id := integer(), params := map(), buffered_at => integer() }. -type state() :: #{ user_id := user_id(), diff --git a/fluxer_gateway/src/presence/presence_update.erl b/fluxer_gateway/src/presence/presence_update.erl index ea2bbbbed..b3cff273d 100644 --- a/fluxer_gateway/src/presence/presence_update.erl +++ b/fluxer_gateway/src/presence/presence_update.erl @@ -19,11 +19,12 @@ -type user_id() :: integer(). -type state() :: map(). -type push_buffer_entry() :: #{ - channel_id := integer(), message_id := integer(), params := map() + channel_id := integer(), message_id := integer(), params := map(), buffered_at => integer() }. -define(DEFAULT_PUSH_BUFFER_MAX_ENTRIES, 128). -define(DEFAULT_PUSH_BUFFER_MAX_BYTES, 1048576). +-define(PUSH_READ_MARKS_MAX_CHANNELS, 32). -define(PUSH_BUFFER_MAX_ENTRIES_CONFIG_KEY, presence_push_buffer_max_entries). -define(PUSH_BUFFER_MAX_BYTES_CONFIG_KEY, presence_push_buffer_max_bytes). -define(PUSH_BUFFER_COUNTERS, presence_push_buffer_counters). @@ -133,9 +134,27 @@ handle_message_create_event(Data, State) -> UserId = maps:get(user_id, State), case build_push_create_params(UserId, Data) of undefined -> State; - Params -> route_push_notification(Params, State) + #{author_id := UserId} -> ack_own_message(Data, State); + Params -> route_unread_push_notification(Data, Params, State) end. +-spec route_unread_push_notification(map(), map(), state()) -> state(). +route_unread_push_notification(Data, Params, State) -> + ChannelId = extract_snowflake(<<"channel_id">>, Data), + MessageId = extract_snowflake(<<"id">>, Data), + ReadMarks = maps:get(push_read_marks, State, #{}), + Read = is_integer(MessageId) andalso MessageId =< maps:get(ChannelId, ReadMarks, 0), + case Read orelse push_message_params:suppresses_notifications(Data) of + true -> State; + false -> route_push_notification(Params, State) + end. + +-spec ack_own_message(map(), state()) -> state(). +ack_own_message(Data, State) -> + ChannelId = extract_snowflake(<<"channel_id">>, Data), + MessageId = extract_snowflake(<<"id">>, Data), + maybe_ack_push_buffer(ChannelId, MessageId, State). + -spec handle_message_ack_event(map(), state()) -> state(). handle_message_ack_event(Data, State) -> ChannelId = extract_snowflake(<<"channel_id">>, Data), @@ -146,11 +165,10 @@ handle_message_ack_event(Data, State) -> flush_push_buffer(#{push_buffer := []} = State) -> State; flush_push_buffer(#{push_buffer := Buffer} = State) -> - Entries = lists:reverse(Buffer), - lists:foreach( - fun(Entry) -> push:handle_message_create(maps:get(params, Entry)) end, - Entries - ), + ok = push:handle_buffered_message_creates([ + (maps:get(params, Entry))#{buffered_at => maps:get(buffered_at, Entry, undefined)} + || Entry <- lists:reverse(Buffer) + ]), State#{push_buffer := []}. -spec maybe_update_push_eligibility(state()) -> state(). @@ -362,10 +380,23 @@ maybe_ack_push_buffer(_, _, State) -> ack_push_buffer(ChannelId, MessageId, State) when ChannelId > 0, MessageId > 0 -> Buffer = maps:get(push_buffer, State, []), FilteredBuffer = [E || E <- Buffer, not should_drop_buffer_entry(E, ChannelId, MessageId)], - State#{push_buffer := FilteredBuffer}; + record_read_mark(ChannelId, MessageId, State#{push_buffer := FilteredBuffer}); ack_push_buffer(_, _, State) -> State. +-spec record_read_mark(integer(), integer(), state()) -> state(). +record_read_mark(ChannelId, MessageId, State) -> + ReadMarks = maps:get(push_read_marks, State, #{}), + ReadMark = max(MessageId, maps:get(ChannelId, ReadMarks, 0)), + State#{push_read_marks => cap_read_marks(ReadMarks#{ChannelId => ReadMark})}. + +-spec cap_read_marks(#{integer() => integer()}) -> #{integer() => integer()}. +cap_read_marks(ReadMarks) when map_size(ReadMarks) > ?PUSH_READ_MARKS_MAX_CHANNELS -> + {_, OldestChannelId} = lists:min([{Mark, Id} || {Id, Mark} <- maps:to_list(ReadMarks)]), + maps:remove(OldestChannelId, ReadMarks); +cap_read_marks(ReadMarks) -> + ReadMarks. + -spec should_drop_buffer_entry(push_buffer_entry(), integer(), integer()) -> boolean(). should_drop_buffer_entry(Entry, ChannelId, MessageId) -> maps:get(channel_id, Entry) =:= ChannelId andalso @@ -383,7 +414,12 @@ make_push_buffer_entry(Params) -> build_buffer_entry(ChannelId, MessageId, Params) when is_integer(ChannelId), is_integer(MessageId) -> - #{channel_id => ChannelId, message_id => MessageId, params => Params}; + #{ + channel_id => ChannelId, + message_id => MessageId, + params => Params, + buffered_at => erlang:system_time(millisecond) + }; build_buffer_entry(_, _, _) -> undefined. @@ -512,6 +548,61 @@ buffer_push_notification_caps_bytes_test() -> end ). +private_message(MessageId, ChannelType, Flags) -> + #{ + <<"id">> => integer_to_binary(MessageId), + <<"channel_id">> => <<"5">>, + <<"channel_type">> => ChannelType, + <<"flags">> => Flags, + <<"author">> => #{<<"id">> => <<"20">>} + }. + +pushed_private_message_ids(Sessions, Messages) -> + Self = self(), + ok = meck:new(push, [passthrough, no_link]), + try + ok = meck:expect(push, handle_message_create, fun(Params) -> + Self ! {pushed, maps:get(<<"id">>, maps:get(message_data, Params))}, + ok + end), + State = lists:foldl( + fun(Message, Acc) -> handle_message_create_event(Message, Acc) end, + #{user_id => 10, sessions => Sessions, push_buffer => []}, + Messages + ), + Buffered = [maps:get(message_id, Entry) || Entry <- maps:get(push_buffer, State)], + {pushed_ids(), lists:sort(Buffered)} + after + meck:unload(push) + end. + +pushed_ids() -> + receive + {pushed, Id} -> [binary_to_integer(Id) | pushed_ids()] + after 0 -> [] + end. + +silent_dms_and_group_dms_are_not_pushed_test() -> + Messages = [ + private_message(1, 1, 4096), + private_message(2, 3, 4096 bor 4), + private_message(3, 1, 0), + private_message(4, 3, 4) + ], + ?assertEqual({[3, 4], []}, pushed_private_message_ids(#{}, Messages)). + +silent_dms_and_group_dms_are_not_buffered_behind_an_active_desktop_test() -> + Desktop = #{<<"desktop">> => #{status => online, afk => false, mobile => false}}, + Messages = [ + private_message(1, 1, 4096), + private_message(2, 3, 4096), + private_message(3, 1, 0), + private_message(4, 3, 0) + ], + with_gateway_config(#{}, fun() -> + ?assertEqual({[], [3, 4]}, pushed_private_message_ids(Desktop, Messages)) + end). + push_params(ChannelId, MessageId) -> #{ message_data => #{ diff --git a/fluxer_gateway/src/push/push.erl b/fluxer_gateway/src/push/push.erl index 8fc59d784..393559305 100644 --- a/fluxer_gateway/src/push/push.erl +++ b/fluxer_gateway/src/push/push.erl @@ -8,6 +8,7 @@ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -export([ handle_message_create/1, + handle_buffered_message_creates/1, sync_user_guild_settings/3, sync_user_guild_settings_local/3, sync_user_blocked_ids/2, @@ -15,7 +16,8 @@ invalidate_user_subscriptions_local/1, invalidate_user_badge_count_local/1, invalidate_user_badge_counts_local/1, - clear_channel_notifications/3 + clear_channel_notifications/3, + clear_notifications_enabled/0 ]). -export([get_cache_stats/0]). -export([push_owner_key/1]). @@ -29,7 +31,14 @@ -define(CNT_FETCH_FAILURES, push_blocked_ids_fetch_failures). -define(CNT_SUPPRESSED, push_blocked_ids_suppressed). -define(CNT_BUDGET_EXHAUSTED, push_blocked_ids_budget_exhausted). +-define(CNT_READ_STATE_SUPPRESSED, push_read_state_suppressed). +-define(CNT_READ_STATE_FAILURES, push_read_state_fetch_failures). +-define(CNT_READ_STATE_SKIPPED, push_read_state_fetch_skipped). -define(MAX_FETCH_RPCS, 8). +-define(READ_STATE_FETCH_TIMEOUT_MS, 2000). +-define(READ_STATE_FETCH_MAX_POOL_PCT, 50). +-define(READ_STATE_FETCH_SLOTS, read_state_fetches_in_flight). +-define(MAX_READ_STATE_FETCHES, 64). -define(DEFAULT_FETCH_USERS, 2000). -define(MAX_FETCH_USERS, 5000). -define(DEFAULT_FETCH_CHUNK, 500). @@ -39,6 +48,8 @@ -type state() :: #{ max_entries := non_neg_integer() }. +-type read_key() :: {integer(), integer(), integer()}. +-type read_marks() :: #{{integer(), integer()} => non_neg_integer()}. -spec start_link() -> {ok, pid()} | {error, term()} | ignore. start_link() -> @@ -115,6 +126,17 @@ handle_message_create(Params) -> false -> ok end. +-spec handle_buffered_message_creates([map()]) -> ok. +handle_buffered_message_creates(ParamsList) -> + case is_push_active() of + true -> + {Stale, Fresh} = split_stale_buffered(ParamsList), + lists:foreach(fun handle_message_create/1, Fresh), + spawn_read_state_check(Stale); + false -> + ok + end. + -spec sync_user_guild_settings(integer(), integer(), map()) -> ok. sync_user_guild_settings(UserId, GuildId, Settings) -> maybe_cast(UserId, {sync_user_guild_settings, UserId, GuildId, Settings}). @@ -254,6 +276,149 @@ do_handle_message_create(Params) -> ok end. +-spec split_stale_buffered([map()]) -> {[{read_key(), map()}], [map()]}. +split_stale_buffered(ParamsList) -> + lists:foldr( + fun(Params, {Stale, Fresh}) -> + Unbuffered = maps:remove(buffered_at, Params), + case stale_read_key(Params) of + {ok, Key} -> {[{Key, Unbuffered} | Stale], Fresh}; + none -> {Stale, [Unbuffered | Fresh]} + end + end, + {[], []}, + ParamsList + ). + +-spec stale_read_key(map()) -> {ok, read_key()} | none. +stale_read_key(#{buffered_at := BufferedAt} = Params) when is_integer(BufferedAt) -> + case + erlang:system_time(millisecond) - BufferedAt >= push_outbox:max_age_ms() andalso + push_message_params:context(Params) + of + {ok, #{user_ids := [UserId], channel_id := ChannelId, message_id := MessageId}} -> + {ok, {UserId, ChannelId, MessageId}}; + _ -> + none + end; +stale_read_key(_Params) -> + none. + +-spec spawn_read_state_check([{read_key(), map()}]) -> ok. +spawn_read_state_check([]) -> + ok; +spawn_read_state_check(Stale) -> + Channels = lists:usort([{UserId, ChannelId} || {{UserId, ChannelId, _}, _Params} <- Stale]), + case read_state_fetch_allowed(length(Channels)) of + true -> + _ = spawn(fun() -> publish_unread(Channels, Stale) end), + ok; + false -> + lists:foreach(fun(Entry) -> publish_unless_read(#{}, Entry) end, Stale) + end. + +-spec publish_unread([{integer(), integer()}], [{read_key(), map()}]) -> ok. +publish_unread(Channels, Stale) -> + try + ReadMarks = + try + fetch_each_read_mark(Channels, #{}) + catch + _:_ -> #{} + end, + lists:foreach(fun(Entry) -> publish_unless_read(ReadMarks, Entry) end, Stale) + after + release_read_state_fetch_slot() + end. + +-spec publish_unless_read(read_marks(), {read_key(), map()}) -> ok. +publish_unless_read(ReadMarks, {{UserId, ChannelId, MessageId}, Params}) -> + case MessageId =< maps:get({UserId, ChannelId}, ReadMarks, 0) of + true -> bump_counter(?CNT_READ_STATE_SUPPRESSED); + false -> handle_message_create(Params) + end. + +-spec read_state_fetch_allowed(non_neg_integer()) -> boolean(). +read_state_fetch_allowed(Fetches) -> + case + push_worker_pool:utilization_pct() < ?READ_STATE_FETCH_MAX_POOL_PCT andalso + acquire_read_state_fetch_slot() + of + true -> + true; + false -> + bump_counter(?CNT_READ_STATE_SKIPPED, Fetches), + false + end. + +-spec acquire_read_state_fetch_slot() -> boolean(). +acquire_read_state_fetch_slot() -> + try + ets:update_counter( + ?PUSH_COUNTER_TABLE, ?READ_STATE_FETCH_SLOTS, {2, 1}, {?READ_STATE_FETCH_SLOTS, 0} + ) + of + InFlight when InFlight > ?MAX_READ_STATE_FETCHES -> + release_read_state_fetch_slot(), + false; + _InFlight -> + true + catch + error:badarg -> false + end. + +-spec release_read_state_fetch_slot() -> ok. +release_read_state_fetch_slot() -> + try ets:update_counter(?PUSH_COUNTER_TABLE, ?READ_STATE_FETCH_SLOTS, {2, -1, 0, 0}) of + _InFlight -> ok + catch + error:badarg -> ok + end. + +-spec fetch_each_read_mark([{integer(), integer()}], read_marks()) -> read_marks(). +fetch_each_read_mark([], ReadMarks) -> + ReadMarks; +fetch_each_read_mark([{UserId, ChannelId} = Key | Rest], ReadMarks) -> + case fetch_read_message_id(UserId, ChannelId) of + {ok, ReadMessageId} -> + fetch_each_read_mark(Rest, ReadMarks#{Key => ReadMessageId}); + error -> + bump_counter(?CNT_READ_STATE_SKIPPED, length(Rest)), + ReadMarks + end. + +-spec fetch_read_message_id(integer(), integer()) -> {ok, non_neg_integer()} | error. +fetch_read_message_id(UserId, ChannelId) -> + Request = #{ + <<"type">> => <<"get_read_state">>, + <<"user_id">> => integer_to_binary(UserId), + <<"channel_id">> => integer_to_binary(ChannelId) + }, + case read_state_call(Request) of + {ok, #{<<"last_message_id">> := LastMessageId}} -> + {ok, read_message_id(snowflake_id:parse_maybe(LastMessageId))}; + {ok, _Data} -> + {ok, 0}; + {error, Reason} -> + bump_counter(?CNT_READ_STATE_FAILURES), + logger:debug("Push: read state fetch failed", #{ + reason => Reason, user_id => UserId, channel_id => ChannelId + }), + error + end. + +-spec read_state_call(map()) -> {ok, term()} | {error, term()}. +read_state_call(Request) -> + try + rpc_client:call(Request, ?READ_STATE_FETCH_TIMEOUT_MS) + catch + Class:Reason -> {error, {Class, Reason}} + end. + +-spec read_message_id(integer() | undefined) -> non_neg_integer(). +read_message_id(MessageId) when is_integer(MessageId), MessageId > 0 -> MessageId; +read_message_id(_MessageId) -> 0. + -spec do_handle_message_create_context(push_message_params:context()) -> ok. do_handle_message_create_context(Context) -> #{ @@ -625,7 +790,11 @@ cache_stats_with_counters() -> push_loss_counters() -> #{ counters => counter_table_status(), - worker_pool_dropped => read_counter(?CNT_WORKER_POOL) + worker_pool_dropped => read_counter(?CNT_WORKER_POOL), + read_state_suppressed => read_counter(?CNT_READ_STATE_SUPPRESSED), + read_state_fetch_failures => read_counter(?CNT_READ_STATE_FAILURES), + read_state_fetch_skipped => read_counter(?CNT_READ_STATE_SKIPPED), + read_state_fetches_in_flight => read_counter(?READ_STATE_FETCH_SLOTS) }. -spec counter_table_status() -> live | unavailable. @@ -826,6 +995,207 @@ filter_eligible_users_fetches_large_metadata_once_test() -> ) end. +a_dm_buffered_past_the_outbox_window_and_read_since_is_not_published_test() -> + Suppressed = read_counter_value(?CNT_READ_STATE_SUPPRESSED), + Published = with_buffered_dms( + [{stale_buffered_at(), 123, 456}], read_through(#{123 => <<"456">>}) + ), + ?assertEqual({0, 1}, Published), + ?assertEqual(Suppressed + 1, read_counter_value(?CNT_READ_STATE_SUPPRESSED)). + +a_dm_buffered_past_the_outbox_window_and_still_unread_is_published_test() -> + Published = with_buffered_dms( + [{stale_buffered_at(), 123, 456}], read_through(#{123 => <<"455">>}) + ), + ?assertEqual({1, 1}, Published). + +a_dm_buffered_past_the_outbox_window_without_a_read_state_is_published_test() -> + Published = with_buffered_dms( + [{stale_buffered_at(), 123, 456}], fun(_ChannelId) -> + {ok, #{<<"last_message_id">> => null}} + end + ), + ?assertEqual({1, 1}, Published). + +a_dm_buffered_past_the_outbox_window_is_published_when_the_read_state_fetch_fails_test() -> + Published = with_buffered_dms( + [{stale_buffered_at(), 123, 456}], fun(_ChannelId) -> {error, timeout} end + ), + ?assertEqual({1, 1}, Published). + +a_dm_buffered_within_the_outbox_window_is_published_without_a_read_state_fetch_test() -> + Published = with_buffered_dms( + [{erlang:system_time(millisecond), 123, 456}], fun(_ChannelId) -> + {error, unexpected} + end + ), + ?assertEqual({1, 0}, Published). + +a_buffered_dm_without_a_buffer_time_is_published_without_a_read_state_fetch_test() -> + Published = with_buffered_dms( + [{undefined, 123, 456}], fun(_ChannelId) -> {error, unexpected} end + ), + ?assertEqual({1, 0}, Published). + +a_dm_buffered_past_the_outbox_window_is_published_without_a_fetch_when_the_pool_is_busy_test() -> + Skipped = read_counter_value(?CNT_READ_STATE_SKIPPED), + ok = meck:new(push_worker_pool, [passthrough, no_link]), + try + ok = meck:expect(push_worker_pool, utilization_pct, fun() -> + ?READ_STATE_FETCH_MAX_POOL_PCT + end), + Published = with_buffered_dms( + [{stale_buffered_at(), 123, 456}], read_through(#{123 => <<"456">>}) + ), + ?assertEqual({1, 0}, Published) + after + meck:unload(push_worker_pool) + end, + ?assertEqual(Skipped + 1, read_counter_value(?CNT_READ_STATE_SKIPPED)). + +a_full_buffer_of_read_dms_is_not_published_by_its_own_flush_test() -> + Entries = [{stale_buffered_at(), 123, 1000 + N} || N <- lists:seq(1, 128)], + Published = with_buffered_dms(Entries, fun(123) -> + timer:sleep(50), + {ok, #{<<"last_message_id">> => <<"999999">>}} + end), + ?assertEqual({0, 1}, Published). + +a_flush_fetches_each_channel_read_state_once_test() -> + Entries = [ + {stale_buffered_at(), 123, 456}, + {stale_buffered_at(), 124, 460}, + {stale_buffered_at(), 123, 470} + ], + Published = with_buffered_dms(Entries, read_through(#{123 => <<"456">>, 124 => <<"459">>})), + ?assertEqual({2, 2}, Published). + +a_failed_read_state_fetch_publishes_the_rest_of_the_flush_without_fetching_test() -> + Entries = [ + {stale_buffered_at(), 123, 456}, + {stale_buffered_at(), 124, 460}, + {stale_buffered_at(), 125, 470} + ], + Skipped = read_counter_value(?CNT_READ_STATE_SKIPPED), + Published = with_buffered_dms(Entries, fun(_ChannelId) -> {error, timeout} end), + ?assertEqual({3, 1}, Published), + ?assertEqual(Skipped + 2, read_counter_value(?CNT_READ_STATE_SKIPPED)). + +a_flush_publishes_fresh_and_unread_stale_dms_and_drops_read_ones_test() -> + Stale = stale_buffered_at(), + Params = [ + buffered_dm_params(erlang:system_time(millisecond), 123, 500), + buffered_dm_params(Stale, 124, 456), + buffered_dm_params(Stale, 125, 470) + ], + {Published, Requests} = with_buffered_dm_push( + read_through(#{124 => <<"456">>, 125 => <<"469">>}), + fun() -> + ok = handle_buffered_message_creates(Params), + lists:sort(published_message_ids(1000)) + end + ), + ?assertEqual([470, 500], Published), + ?assertEqual(2, Requests). + +stale_buffered_at() -> + erlang:system_time(millisecond) - push_outbox:max_age_ms() - 1000. + +read_through(ReadMessageIds) -> + fun(ChannelId) -> {ok, #{<<"last_message_id">> => maps:get(ChannelId, ReadMessageIds)}} end. + +read_counter_value(Key) -> + ok = push_worker_pool:init_counter(), + case read_counter(Key) of + Value when is_integer(Value) -> Value; + unavailable -> 0 + end. + +with_buffered_dms(Entries, ReadStateReply) -> + with_buffered_dm_push(ReadStateReply, fun() -> + {Stale, Fresh} = split_stale_buffered([ + buffered_dm_params(BufferedAt, ChannelId, MessageId) + || {BufferedAt, ChannelId, MessageId} <- Entries + ]), + lists:foreach(fun handle_message_create/1, Fresh), + ok = spawn_read_state_check(Stale), + ok = wait_for_read_state_fetches(100), + length(published_message_ids(0)) + end). + +wait_for_read_state_fetches(0) -> + erlang:error(read_state_fetch_still_in_flight); +wait_for_read_state_fetches(Attempts) -> + case read_counter(?READ_STATE_FETCH_SLOTS) of + InFlight when is_integer(InFlight), InFlight > 0 -> + timer:sleep(20), + wait_for_read_state_fetches(Attempts - 1); + _ -> + ok + end. + +with_buffered_dm_push(ReadStateReply, Fun) -> + ok = push_worker_pool:init_counter(), + Self = self(), + Modules = [fluxer_gateway_env, gateway_node_router, rpc_client], + lists:foreach(fun(Module) -> ok = meck:new(Module, [passthrough, no_link]) end, Modules), + try + ok = meck:expect(fluxer_gateway_env, get, fun + (push_enabled) -> true; + (Key) -> meck:passthrough([Key]) + end), + ok = meck:expect(gateway_node_router, owner_node_result, fun(_Key, push) -> + {ok, node()} + end), + ok = meck:expect(rpc_client, call, fun(Request, ?READ_STATE_FETCH_TIMEOUT_MS) -> + Self ! {read_state_request, Request}, + ReadStateReply(binary_to_integer(maps:get(<<"channel_id">>, Request))) + end), + Published = with_registered_push(Fun), + Requests = meck:num_calls(rpc_client, call, ['_', '_']), + assert_read_state_requests(Requests), + {Published, Requests} + after + lists:foreach(fun meck:unload/1, Modules) + end. + +published_message_ids(Timeout) -> + receive + {'$gen_cast', {handle_message_create, Params}} -> + ?assertNot(maps:is_key(buffered_at, Params)), + MessageData = maps:get(message_data, Params), + [ + binary_to_integer(maps:get(<<"id">>, MessageData)) + | published_message_ids(Timeout) + ] + after Timeout -> [] + end. + +buffered_dm_params(BufferedAt, ChannelId, MessageId) -> + #{ + message_data => #{ + <<"channel_id">> => integer_to_binary(ChannelId), + <<"id">> => integer_to_binary(MessageId), + <<"channel_type">> => 1 + }, + user_ids => [71], + guild_id => 0, + author_id => 7, + buffered_at => BufferedAt + }. + +assert_read_state_requests(0) -> + ok; +assert_read_state_requests(Count) -> + receive + {read_state_request, Request} -> + ?assertMatch( + #{<<"type">> := <<"get_read_state">>, <<"user_id">> := <<"71">>}, Request + ), + assert_read_state_requests(Count - 1) + after 0 -> erlang:error(no_read_state_request) + end. + blocked_ids_fetch_defaults_are_bounded_test() -> ?assertEqual(?DEFAULT_FETCH_USERS, blocked_ids_fetch_max_users()), ?assertEqual(?DEFAULT_FETCH_CHUNK, blocked_ids_fetch_chunk()). diff --git a/fluxer_gateway/src/push/push_eligibility.erl b/fluxer_gateway/src/push/push_eligibility.erl index a7f840ca3..6c9954517 100644 --- a/fluxer_gateway/src/push/push_eligibility.erl +++ b/fluxer_gateway/src/push/push_eligibility.erl @@ -92,17 +92,6 @@ is_user_blocked(UserId, AuthorId) -> -spec check_user_guild_settings( integer(), integer(), integer(), map(), integer(), map(), map(), map() | undefined ) -> boolean(). -check_user_guild_settings( - _UserId, - 0, - _ChannelId, - _MessageData, - _GuildDefaultNotifications, - _UserRolesMap, - _ConnectedUsers, - _LargeGuildMetadata -) -> - true; check_user_guild_settings( UserId, GuildId, @@ -114,8 +103,7 @@ check_user_guild_settings( LargeGuildMetadata ) -> Settings = fetch_settings(UserId, GuildId), - MobilePush = get_boolean_setting(mobile_push, Settings, true), - case MobilePush of + case mobile_push_allowed(GuildId, Settings) of false -> false; true -> @@ -132,6 +120,12 @@ check_user_guild_settings( ) end. +-spec mobile_push_allowed(integer(), map()) -> boolean(). +mobile_push_allowed(0, _Settings) -> + true; +mobile_push_allowed(_GuildId, Settings) -> + get_boolean_setting(mobile_push, Settings, true). + -spec fetch_settings(integer(), integer()) -> map(). fetch_settings(UserId, GuildId) -> case push_ets_cache:get_user_guild_settings(UserId, GuildId) of @@ -446,6 +440,110 @@ mention_here_requires_connected_user_test() -> ?assertEqual(false, is_user_mentioned(123, MessageData, #{}, #{}, #{})), ?assertEqual(true, is_user_mentioned(123, MessageData, #{}, #{}, #{123 => true})). +-define(DM_USER, 920001). +-define(DM_AUTHOR, 920002). +-define(DM_CHANNEL, 920005). + +dm_eligible(ChannelType, PrivateSettings) -> + push_ets_cache:init(), + ok = push_ets_cache:put_user_guild_settings(?DM_USER, 0, PrivateSettings), + try + is_eligible_for_push( + ?DM_USER, + ?DM_AUTHOR, + 0, + ?DM_CHANNEL, + #{<<"channel_type">> => ChannelType}, + 0, + #{}, + #{}, + undefined + ) + after + push_ets_cache:delete_user_guild_settings(?DM_USER, 0) + end. + +dm_override(Override) -> + #{<<"channel_overrides">> => #{integer_to_binary(?DM_CHANNEL) => Override}}. + +mute_ending_in(OffsetMs) -> + Ms = erlang:system_time(millisecond) + OffsetMs, + #{ + <<"end_time">> => list_to_binary( + calendar:system_time_to_rfc3339(Ms, [{unit, millisecond}, {offset, "Z"}]) + ) + }. + +a_muted_dm_is_not_pushed_test() -> + Muted = dm_override(#{<<"muted">> => true, <<"mute_config">> => null}), + ?assertEqual(false, dm_eligible(1, Muted)). + +a_muted_group_dm_is_not_pushed_test() -> + Muted = dm_override(#{<<"muted">> => true, <<"mute_config">> => mute_ending_in(60000)}), + ?assertEqual(false, dm_eligible(3, Muted)). + +a_dm_whose_mute_expired_is_pushed_test() -> + Expired = dm_override(#{<<"muted">> => true, <<"mute_config">> => mute_ending_in(-60000)}), + ?assertEqual(true, dm_eligible(1, Expired)), + ?assertEqual(true, dm_eligible(3, Expired)). + +an_unmuted_dm_is_pushed_test() -> + ?assertEqual(true, dm_eligible(1, #{})), + ?assertEqual(true, dm_eligible(3, #{})), + Other = #{<<"channel_overrides">> => #{<<"1">> => #{<<"muted">> => true}}}, + ?assertEqual(true, dm_eligible(1, Other)). + +a_dm_set_to_no_messages_is_not_pushed_test() -> + ?assertEqual(false, dm_eligible(1, dm_override(#{<<"message_notifications">> => 2}))), + ?assertEqual(false, dm_eligible(3, #{<<"message_notifications">> => 2})). + +a_dm_set_to_only_mentions_is_still_pushed_test() -> + ?assertEqual(true, dm_eligible(1, dm_override(#{<<"message_notifications">> => 1}))), + ?assertEqual(true, dm_eligible(3, #{<<"message_notifications">> => 1})). + +all_dms_muted_are_not_pushed_test() -> + ?assertEqual(false, dm_eligible(1, #{<<"muted">> => true})). + +dm_pushes_ignore_the_private_mobile_push_setting_test() -> + ?assertEqual(true, dm_eligible(1, #{<<"mobile_push">> => false})). + +dm_settings_are_fetched_once_for_the_private_scope_and_cached_test() -> + push_ets_cache:init(), + push_ets_cache:delete_user_guild_settings(?DM_USER, 0), + Self = self(), + Muted = dm_override(#{<<"muted">> => true, <<"mute_config">> => null}), + ok = meck:new(rpc_client, [passthrough, no_link]), + try + ok = meck:expect(rpc_client, call, fun(Request) -> + Self ! {settings_request, Request}, + {ok, #{<<"user_guild_settings">> => [Muted]}} + end), + MessageData = #{<<"channel_type">> => 1}, + Check = fun() -> + is_eligible_for_push( + ?DM_USER, ?DM_AUTHOR, 0, ?DM_CHANNEL, MessageData, 0, #{}, #{}, undefined + ) + end, + ?assertEqual(false, Check()), + ?assertEqual(false, Check()), + ?assertEqual(1, meck:num_calls(rpc_client, call, '_')), + receive + {settings_request, Request} -> + ?assertMatch( + #{ + <<"type">> := <<"get_user_guild_settings">>, + <<"guild_id">> := <<"0">>, + <<"user_ids">> := [<<"920001">>] + }, + Request + ) + after 0 -> erlang:error(no_settings_request) + end + after + meck:unload(rpc_client), + push_ets_cache:delete_user_guild_settings(?DM_USER, 0) + end. + mention_here_respects_suppress_everyone_test() -> MessageData = #{<<"mention_everyone">> => true, <<"mention_here">> => true}, Settings = #{suppress_everyone => true}, diff --git a/fluxer_gateway/src/push/push_job_publisher.erl b/fluxer_gateway/src/push/push_job_publisher.erl index 2617a85fd..abf5e6805 100644 --- a/fluxer_gateway/src/push/push_job_publisher.erl +++ b/fluxer_gateway/src/push/push_job_publisher.erl @@ -3,7 +3,7 @@ -module(push_job_publisher). -typing([eqwalizer]). --export([publish_message/8, publish_clear/3]). +-export([publish_message/8, publish_clear/3, clear_job/4]). -export([publish_ring/6, request/3]). -define(SUBJECT_MESSAGE, <<"push.job.message">>). @@ -85,19 +85,37 @@ first_error({error, Reason}, _Second) -> -spec publish_clear(integer(), integer(), integer()) -> ok | {error, term()}. publish_clear(UserId, ChannelId, MessageId) -> - Job = #{ + publish( + ?SUBJECT_CLEAR, + clear_fields(UserId, ChannelId, MessageId), + clear_meta(UserId, ChannelId, MessageId) + ). + +-spec clear_job(integer(), integer(), integer(), integer()) -> push_outbox:job(). +clear_job(UserId, ChannelId, MessageId, AfterMessageId) -> + Job = (clear_fields(UserId, ChannelId, MessageId))#{ + <<"after_message_id">> => integer_to_binary(AfterMessageId) + }, + outbox_job( + ?SUBJECT_CLEAR, + Job, + iolist_to_binary(json:encode(Job)), + clear_meta(UserId, ChannelId, MessageId) + ). + +-spec clear_fields(integer(), integer(), integer()) -> map(). +clear_fields(UserId, ChannelId, MessageId) -> + #{ <<"v">> => ?JOB_VERSION, <<"config_version">> => ?LEGACY_CONFIG_VERSION, <<"user_id">> => integer_to_binary(UserId), <<"channel_id">> => integer_to_binary(ChannelId), <<"message_id">> => integer_to_binary(MessageId) - }, - publish(?SUBJECT_CLEAR, Job, #{ - kind => clear, - user_ids => [UserId], - channel_id => ChannelId, - message_id => MessageId - }). + }. + +-spec clear_meta(integer(), integer(), integer()) -> meta(). +clear_meta(UserId, ChannelId, MessageId) -> + #{kind => clear, user_ids => [UserId], channel_id => ChannelId, message_id => MessageId}. -spec publish_ring(integer(), integer(), integer(), integer(), integer(), map()) -> ok | {error, term()}. diff --git a/fluxer_gateway/src/push/push_message_params.erl b/fluxer_gateway/src/push/push_message_params.erl index cc0f2e36c..86dad25ad 100644 --- a/fluxer_gateway/src/push/push_message_params.erl +++ b/fluxer_gateway/src/push/push_message_params.erl @@ -3,7 +3,7 @@ -module(push_message_params). -typing([eqwalizer]). --export([context/1, owner_key/1]). +-export([context/1, owner_key/1, suppresses_notifications/1]). -export_type([context/0]). @@ -11,6 +11,7 @@ -define(MESSAGE_TYPE_DEFAULT, 0). -define(MESSAGE_TYPE_REPLY, 19). -define(PUSHABLE_MESSAGE_TYPES, [?MESSAGE_TYPE_DEFAULT, ?MESSAGE_TYPE_REPLY]). +-define(MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS, 4096). -type context() :: #{ message_data := map(), @@ -118,10 +119,23 @@ validate(Context) -> -spec validate_message_type(integer(), context()) -> {ok, context()} | {error, term()}. validate_message_type(Type, Context) -> case lists:member(Type, ?PUSHABLE_MESSAGE_TYPES) of - true -> {ok, Context}; + true -> validate_notifications(Context); false -> {error, {unpushable_message_type, Type}} end. +-spec validate_notifications(context()) -> {ok, context()} | {error, term()}. +validate_notifications(#{message_data := MessageData} = Context) -> + case suppresses_notifications(MessageData) of + true -> {error, suppressed_notifications}; + false -> {ok, Context} + end. + +-spec suppresses_notifications(map()) -> boolean(). +suppresses_notifications(#{<<"flags">> := Flags}) when is_integer(Flags) -> + Flags band ?MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS =/= 0; +suppresses_notifications(_MessageData) -> + false. + -spec message_type(context()) -> integer(). message_type(#{message_data := MessageData}) -> normalize_message_type(maps:get(<<"type">>, MessageData, ?MESSAGE_TYPE_DEFAULT)). @@ -230,4 +244,37 @@ context_treats_a_missing_message_type_as_pushable_test() -> context_treats_a_malformed_message_type_as_pushable_test() -> ?assertMatch({ok, _}, context(params_with_message_type(<<"nonsense">>))). +params_with_flags(Flags, ChannelType, GuildId) -> + Params = params_with_message_type(?MESSAGE_TYPE_DEFAULT), + MessageData = maps:get(message_data, Params), + Params#{ + message_data := MessageData#{<<"flags">> => Flags, <<"channel_type">> => ChannelType}, + guild_id := GuildId + }. + +context_rejects_a_silent_dm_test() -> + ?assertEqual( + {error, suppressed_notifications}, context(params_with_flags(4096, 1, 0)) + ). + +context_rejects_a_silent_group_dm_test() -> + ?assertEqual( + {error, suppressed_notifications}, context(params_with_flags(4096, 3, 0)) + ). + +context_rejects_a_silent_guild_message_that_mentions_the_recipient_test() -> + Params = params_with_flags(4096 bor 4, 0, <<"1472200708085309475">>), + MessageData = maps:get(message_data, Params), + Mentioned = MessageData#{ + <<"mentions">> => [#{<<"id">> => <<"1474262819227156566">>}], + <<"mention_everyone">> => true + }, + ?assertEqual( + {error, suppressed_notifications}, context(Params#{message_data := Mentioned}) + ). + +context_allows_a_message_with_other_flags_test() -> + ?assertMatch({ok, _}, context(params_with_flags(4 bor 8192, 1, 0))), + ?assertMatch({ok, _}, context(params_with_flags(0, 3, 0))). + -endif. diff --git a/fluxer_gateway/src/push/push_outbox.erl b/fluxer_gateway/src/push/push_outbox.erl index 0e2550aa6..0467041c7 100644 --- a/fluxer_gateway/src/push/push_outbox.erl +++ b/fluxer_gateway/src/push/push_outbox.erl @@ -11,7 +11,8 @@ note_session_active/1, record_dropped/2, stats/0, - request_timeout_ms/0 + request_timeout_ms/0, + max_age_ms/0 ]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -export_type([job/0, kind/0]). @@ -54,11 +55,13 @@ | sheds | truncations | skipped_active + | followup_clears | enqueued. -type state() :: #{ jobs := gb_trees:tree(non_neg_integer(), entry()), ready := queue:queue(non_neg_integer()), inflight := #{pid() => {reference(), reference(), entry()}}, + followups => #{pid() => #{integer() => integer()}}, next_seq := non_neg_integer(), reads := #{{integer(), integer()} => {integer(), integer()}}, active := #{integer() => {non_neg_integer(), integer()}}, @@ -108,6 +111,10 @@ stats() -> request_timeout_ms() -> env_pos_integer(push_outbox_request_timeout_ms, ?DEFAULT_REQUEST_TIMEOUT_MS). +-spec max_age_ms() -> pos_integer(). +max_age_ms() -> + env_pos_integer(push_outbox_max_age_ms, ?DEFAULT_MAX_AGE_MS). + -spec init([]) -> {ok, state()}. init([]) -> erlang:process_flag(fullsweep_after, 10), @@ -116,6 +123,7 @@ init([]) -> jobs => gb_trees:empty(), ready => queue:new(), inflight => #{}, + followups => #{}, next_seq => 0, reads => #{}, active => #{}, @@ -124,7 +132,7 @@ init([]) -> max_queue => env_pos_integer(push_outbox_max_queue, ?DEFAULT_MAX_QUEUE), max_inflight => env_pos_integer(push_outbox_max_inflight, ?DEFAULT_MAX_INFLIGHT), request_timeout_ms => request_timeout_ms(), - max_age_ms => env_pos_integer(push_outbox_max_age_ms, ?DEFAULT_MAX_AGE_MS), + max_age_ms => max_age_ms(), retry_base_ms => app_pos_integer(push_outbox_retry_base_ms, ?DEFAULT_RETRY_BASE_MS) }}. @@ -298,14 +306,53 @@ apply_read(UserId, ChannelId, MessageId, #{reads := Reads, jobs := Jobs} = State error -> MessageId end, State1 = State#{reads := Reads#{Key => {Watermark, now_ms()}}}, - lists:foldl( + State2 = lists:foldl( fun({Seq, Entry}, Acc) -> truncate_entry(Seq, Entry, UserId, ChannelId, MessageId, Acc) end, State1, gb_trees:to_list(Jobs) + ), + note_inflight_read(UserId, ChannelId, MessageId, State2). + +-spec note_inflight_read(integer(), integer(), integer(), state()) -> state(). +note_inflight_read(UserId, ChannelId, MessageId, #{inflight := Inflight} = State) -> + maps:fold( + fun(Pid, {_MRef, _TRef, Entry}, Acc) -> + note_followup(Pid, Entry, UserId, ChannelId, MessageId, Acc) + end, + State, + Inflight ). +-spec note_followup(pid(), entry(), integer(), integer(), integer(), state()) -> state(). +note_followup( + Pid, + #{ + kind := message, + channel_id := ChannelId, + message_id := JobMessageId, + user_ids := UserIds + }, + UserId, + ChannelId, + MessageId, + State +) when JobMessageId =< MessageId -> + case lists:member(UserId, UserIds) of + false -> State; + true -> record_followup(Pid, UserId, MessageId, State) + end; +note_followup(_Pid, _Entry, _UserId, _ChannelId, _MessageId, State) -> + State. + +-spec record_followup(pid(), integer(), integer(), state()) -> state(). +record_followup(Pid, UserId, MessageId, State) -> + Followups = maps:get(followups, State, #{}), + Reads = maps:get(Pid, Followups, #{}), + Watermark = max(MessageId, maps:get(UserId, Reads, MessageId)), + State#{followups => Followups#{Pid => Reads#{UserId => Watermark}}}. + -spec truncate_entry(non_neg_integer(), entry(), integer(), integer(), integer(), state()) -> state(). truncate_entry( @@ -374,11 +421,63 @@ finish_worker(Pid, Result, #{inflight := Inflight} = State) -> {{MRef, TRef, Entry}, Rest} -> erlang:demonitor(MRef, [flush]), _ = erlang:cancel_timer(TRef, [{async, true}, {info, false}]), - handle_result(Result, Entry, State#{inflight := Rest}); + Followups = maps:get(followups, State, #{}), + Reads = maps:get(Pid, Followups, #{}), + State1 = State#{inflight := Rest, followups => maps:remove(Pid, Followups)}, + follow_up_clears(Entry, Reads, handle_result(Result, Entry, State1)); error -> State end. +-spec follow_up_clears(entry(), #{integer() => integer()}, state()) -> state(). +follow_up_clears(_Entry, Reads, State) when map_size(Reads) =:= 0 -> + State; +follow_up_clears(#{channel_id := ChannelId, message_id := AfterMessageId}, Reads, State) -> + case push:clear_notifications_enabled() of + true -> + maps:fold( + fun(UserId, MessageId, Acc) -> + follow_up_clear(UserId, ChannelId, MessageId, AfterMessageId, Acc) + end, + State, + Reads + ); + false -> + State + end. + +-spec follow_up_clear(integer(), integer(), integer(), integer(), state()) -> state(). +follow_up_clear(UserId, ChannelId, MessageId, AfterMessageId, State) -> + case pending_followup(UserId, ChannelId, State) of + {ok, Pid} -> + record_followup(Pid, UserId, MessageId, State); + none -> + Job = push_job_publisher:clear_job(UserId, ChannelId, MessageId, AfterMessageId), + bump(followup_clears, 1, admit(Job, State)) + end. + +-spec pending_followup(integer(), integer(), state()) -> {ok, pid()} | none. +pending_followup(UserId, ChannelId, #{inflight := Inflight} = State) -> + Pending = maps:filter( + fun(Pid, Reads) -> + is_map_key(UserId, Reads) andalso + inflight_channel(Pid, Inflight) =:= {ok, ChannelId} + end, + maps:get(followups, State, #{}) + ), + case maps:keys(Pending) of + [Pid | _] -> {ok, Pid}; + [] -> none + end. + +-spec inflight_channel(pid(), #{pid() => {reference(), reference(), entry()}}) -> + {ok, integer()} | error. +inflight_channel(Pid, Inflight) -> + case maps:find(Pid, Inflight) of + {ok, {_MRef, _TRef, #{channel_id := ChannelId}}} -> {ok, ChannelId}; + error -> error + end. + -spec reply_result(term()) -> ok | {error, term()}. reply_result(ok) -> ok; reply_result({error, Reason}) -> {error, Reason}; @@ -466,6 +565,7 @@ build_stats(#{jobs := Jobs, inflight := Inflight, counters := Counters, dropped sheds => 0, truncations => 0, skipped_active => 0, + followup_clears => 0, enqueued => 0 }, Counters#{ @@ -569,6 +669,7 @@ test_state(Reads) -> jobs => gb_trees:empty(), ready => queue:new(), inflight => #{}, + followups => #{}, next_seq => 1, reads => Reads, active => #{}, diff --git a/fluxer_gateway/src/session/session_connection_guild.erl b/fluxer_gateway/src/session/session_connection_guild.erl index 450eac104..92b491c5e 100644 --- a/fluxer_gateway/src/session/session_connection_guild.erl +++ b/fluxer_gateway/src/session/session_connection_guild.erl @@ -449,6 +449,7 @@ finalize_guild_connection(GuildId, GuildPid, State, ReadyFun) -> finalize_guild_monitor(GuildId, GuildPid, Guilds0, State, ReadyFun) -> MonitorRef = monitor(process, GuildPid), Guilds = Guilds0#{GuildId => {GuildPid, MonitorRef}}, + ok = session_lifecycle:send_guild_push_hold({GuildPid, MonitorRef}, State), apply_ready_fun(GuildId, GuildPid, ReadyFun, State#{guilds => Guilds}). -spec apply_ready_fun( diff --git a/fluxer_gateway/src/session/session_lifecycle.erl b/fluxer_gateway/src/session/session_lifecycle.erl index d334cc171..b0ad4c7c7 100644 --- a/fluxer_gateway/src/session/session_lifecycle.erl +++ b/fluxer_gateway/src/session/session_lifecycle.erl @@ -21,7 +21,8 @@ handle_resume/3, handle_resume_offline_timeout/2, handle_presence_update_cast/2, - handle_initial_global_presences/2 + handle_initial_global_presences/2, + send_guild_push_hold/2 ]). -export_type([session_state/0, channel_id/0, user_id/0, session_id/0, seq/0, status/0]). @@ -352,6 +353,7 @@ handle_resume(Seq, SocketPid, #{seq := CurrentSeq} = State) -> NewState1 = replace_socket(SocketPid, NewState0), ReplyEvents = replay_missed_events_inline(MissedEvents, SocketPid), NewState = NewState1#{status => ResumeStatus, resume_status => ResumeStatus}, + ok = sync_guild_push_hold(State, NewState), NewState2 = ensure_presence_attached_on_resume( NewState, SessionId, ResumeStatus, Afk, Mobile ), @@ -509,8 +511,40 @@ handle_presence_update_cast(Update, State) -> NewStatus, State#{status => NewStatus, afk => NewAfk, mobile => NewMobile} ), send_presence_update(State, SessionId, NewStatus, NewAfk, NewMobile, Update), + ok = sync_guild_push_hold(State, NewState), {noreply, NewState}. +-spec sync_guild_push_hold(session_state(), session_state()) -> ok. +sync_guild_push_hold(OldState, NewState) -> + notify_guild_push_hold(guild_push_hold(OldState), guild_push_hold(NewState), NewState). + +-spec notify_guild_push_hold(boolean(), boolean(), session_state()) -> ok. +notify_guild_push_hold(Hold, Hold, _State) -> + ok; +notify_guild_push_hold(_OldHold, _NewHold, State) -> + maps:foreach( + fun(_GuildId, GuildRef) -> send_guild_push_hold(GuildRef, State) end, + maps:get(guilds, State, #{}) + ). + +-spec send_guild_push_hold(term(), session_state()) -> ok. +send_guild_push_hold({GuildPid, _Ref}, #{id := SessionId} = State) when is_pid(GuildPid) -> + case maps:get(bot, State, false) of + true -> + ok; + false -> + gen_server:cast( + GuildPid, {set_session_push_hold, SessionId, guild_push_hold(State)} + ) + end; +send_guild_push_hold(_GuildRef, _State) -> + ok. + +-spec guild_push_hold(session_state()) -> boolean(). +guild_push_hold(State) -> + lists:member(maps:get(status, State, online), [online, dnd, invisible]) andalso + not maps:get(afk, State, false) andalso not maps:get(mobile, State, false). + -spec maybe_update_resume_status(status(), session_state()) -> session_state(). maybe_update_resume_status(offline, State) -> State; diff --git a/fluxer_gateway/test/guild_push_engagement_tests.erl b/fluxer_gateway/test/guild_push_engagement_tests.erl new file mode 100644 index 000000000..c498f759f --- /dev/null +++ b/fluxer_gateway/test/guild_push_engagement_tests.erl @@ -0,0 +1,451 @@ +%% SPDX-License-Identifier: AGPL-3.0-or-later + +-module(guild_push_engagement_tests). + +-include_lib("eunit/include/eunit.hrl"). + +-define(GUILD_ID, 7000). +-define(CHANNEL_ID, 7100). +-define(USER, 10). +-define(AUTHOR, 20). +-define(MSG, 7200). +-define(GRACE_MS, 200). + +desktop_active() -> + #{status => online, afk => false, mobile => false}. + +phone_backgrounded() -> + #{status => online, afk => true, mobile => true}. + +desktop_invisible() -> + #{status => invisible, afk => false, mobile => false}. + +user_data() -> + #{<<"id">> => integer_to_binary(?USER), <<"username">> => <<"reader">>}. + +published_presence(PresenceSessions) -> + presence_payload:build( + user_data(), + presence_status:get_current_status(PresenceSessions), + presence_status:get_flattened_mobile(PresenceSessions), + presence_status:get_flattened_afk(PresenceSessions), + null + ). + +guild_state(SessionPids) -> + #{ + id => ?GUILD_ID, + data => #{ + <<"guild">> => #{ + <<"id">> => ?GUILD_ID, + <<"name">> => <<"Guild">>, + <<"owner_id">> => ?USER, + <<"default_message_notifications">> => 0 + }, + <<"members">> => #{?USER => #{<<"user">> => user_data(), <<"roles">> => []}}, + <<"channels">> => [#{<<"id">> => ?CHANNEL_ID, <<"name">> => <<"general">>}], + <<"channel_index">> => #{ + ?CHANNEL_ID => #{<<"id">> => ?CHANNEL_ID, <<"name">> => <<"general">>} + }, + <<"roles">> => [], + <<"role_index">> => #{} + }, + sessions => maps:map( + fun(Sid, Pid) -> #{session_id => Sid, user_id => ?USER, pid => Pid} end, + SessionPids + ), + member_presence => ets:new(test_member_presence, [set, public]), + presence_subscriptions => #{?USER => 1}, + member_list_subscriptions => guild_member_list_subs:new() + }. + +apply_presence(PresenceSessions, State) -> + {noreply, NewState} = guild_presence:handle_bus_presence( + ?USER, published_presence(PresenceSessions), State + ), + NewState. + +report_push_holds(PresenceSessions, State) -> + maps:fold(fun report_push_hold/3, State, PresenceSessions). + +report_push_hold(SessionId, PresenceSession, State) -> + ok = session_lifecycle:send_guild_push_hold( + {self(), make_ref()}, PresenceSession#{id => SessionId} + ), + receive + {'$gen_cast', Msg} -> + {noreply, NewState} = guild:handle_cast(Msg, State), + NewState + after 1000 -> error(no_push_hold_reported) + end. + +apply_session_presence(PresenceSessions, State) -> + report_push_holds(PresenceSessions, apply_presence(PresenceSessions, State)). + +guild_message() -> + #{ + <<"id">> => integer_to_binary(?MSG), + <<"channel_id">> => integer_to_binary(?CHANNEL_ID), + <<"author">> => #{<<"id">> => integer_to_binary(?AUTHOR)}, + <<"content">> => <<"hi">> + }. + +dm_message() -> + #{ + <<"id">> => integer_to_binary(?MSG), + <<"channel_id">> => <<"5">>, + <<"channel_type">> => 1, + <<"author">> => #{<<"id">> => integer_to_binary(?AUTHOR)}, + <<"content">> => <<"hi">> + }. + +desktop_away() -> + #{status => online, afk => true, mobile => false}. + +desktop_offline() -> + #{status => offline, afk => false, mobile => false}. + +fake_guild(State) -> + spawn(fun() -> fake_guild_loop(State) end). + +fake_guild_loop(State) -> + receive + {'$gen_call', From, Request} -> + {reply, Reply, State} = guild:handle_call(Request, From, State), + gen_server:reply(From, Reply), + fake_guild_loop(State); + {set_state, NewState} -> + fake_guild_loop(NewState); + stop -> + ok + end. + +send_held_message(State) -> + Guild = fake_guild(State), + ok = guild_dispatch_push:collect_and_send_push_notifications( + guild_message(), ?GUILD_ID, State#{guild_pid => Guild} + ), + Guild. + +idle_session() -> + spawn(fun() -> + receive + stop -> ok + end + end). + +with_push_spy(Fun) -> + Self = self(), + Previous = application:get_env(fluxer_gateway, guild_push_offline_grace_recheck_ms), + ok = application:set_env(fluxer_gateway, guild_push_offline_grace_recheck_ms, ?GRACE_MS), + ok = meck:new(push, [passthrough, no_link]), + try + ok = meck:expect(push, handle_message_create, fun(Params) -> + Self ! {pushed, maps:get(user_ids, Params)}, + ok + end), + Fun() + after + meck:unload(push), + case Previous of + {ok, Value} -> + application:set_env(fluxer_gateway, guild_push_offline_grace_recheck_ms, Value); + undefined -> + application:unset_env(fluxer_gateway, guild_push_offline_grace_recheck_ms) + end + end. + +immediate_pushes() -> + receive + {pushed, UserIds} -> [UserIds | immediate_pushes()] + after 0 -> [] + end. + +push_within(Ms) -> + receive + {pushed, UserIds} -> UserIds + after Ms -> none + end. + +desktop_plus_backgrounded_phone_still_publishes_online_mobile_not_afk_test() -> + PresenceSessions = #{ + <<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded() + }, + Payload = published_presence(PresenceSessions), + ?assertEqual(<<"online">>, maps:get(<<"status">>, Payload)), + ?assertEqual(true, maps:get(<<"mobile">>, Payload)), + ?assertEqual(false, maps:get(<<"afk">>, Payload)), + ?assertEqual( + {ok, Payload}, + presence_broadcast:current_visible_presence(#{ + sessions => PresenceSessions, user_data => user_data(), custom_status => null + }) + ). + +guild_message_is_held_through_grace_for_a_user_active_on_desktop_with_a_backgrounded_phone_test() -> + Desktop = idle_session(), + Phone = idle_session(), + State0 = guild_state(#{<<"desktop">> => Desktop, <<"phone">> => Phone}), + try + with_push_spy(fun() -> + State = apply_session_presence( + #{<<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded()}, + State0 + ), + Guild = send_held_message(State), + ?assertEqual([], immediate_pushes()), + ?assertEqual(none, push_within(?GRACE_MS * 3)), + Guild ! stop + end) + after + Desktop ! stop, + Phone ! stop, + ets:delete(maps:get(member_presence, State0)) + end. + +guild_message_is_held_through_grace_for_a_user_active_on_desktop_only_test() -> + Desktop = idle_session(), + State0 = guild_state(#{<<"desktop">> => Desktop}), + try + with_push_spy(fun() -> + State = apply_presence(#{<<"desktop">> => desktop_active()}, State0), + ok = guild_dispatch_push:collect_and_send_push_notifications( + guild_message(), ?GUILD_ID, State + ), + ?assertEqual([], immediate_pushes()), + Desktop ! stop, + ?assertEqual([?USER], push_within(?GRACE_MS * 10)) + end) + after + Desktop ! stop, + ets:delete(maps:get(member_presence, State0)) + end. + +invisible_desktop_is_published_as_offline_test() -> + Payload = published_presence(#{<<"desktop">> => desktop_invisible()}), + ?assertEqual(<<"offline">>, maps:get(<<"status">>, Payload)), + ?assertEqual( + not_found, + presence_broadcast:current_visible_presence(#{ + sessions => #{<<"desktop">> => desktop_invisible()}, + user_data => user_data(), + custom_status => null + }) + ). + +guild_message_to_a_user_on_desktop_with_a_backgrounded_phone_is_released_when_the_desktop_ends_test() -> + Desktop = idle_session(), + Phone = idle_session(), + State0 = guild_state(#{<<"desktop">> => Desktop, <<"phone">> => Phone}), + try + with_push_spy(fun() -> + State = apply_session_presence( + #{<<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded()}, + State0 + ), + ok = guild_dispatch_push:collect_and_send_push_notifications( + guild_message(), ?GUILD_ID, State + ), + ?assertEqual([], immediate_pushes()), + Desktop ! stop, + ?assertEqual([?USER], push_within(?GRACE_MS * 10)) + end) + after + Desktop ! stop, + Phone ! stop, + ets:delete(maps:get(member_presence, State0)) + end. + +guild_message_is_pushed_immediately_to_a_user_on_a_backgrounded_phone_only_test() -> + Phone = idle_session(), + State0 = guild_state(#{<<"phone">> => Phone}), + try + with_push_spy(fun() -> + State = apply_session_presence(#{<<"phone">> => phone_backgrounded()}, State0), + ok = guild_dispatch_push:collect_and_send_push_notifications( + guild_message(), ?GUILD_ID, State + ), + ?assertEqual([[?USER]], immediate_pushes()) + end) + after + Phone ! stop, + ets:delete(maps:get(member_presence, State0)) + end. + +guild_message_is_held_through_grace_for_a_user_active_on_an_invisible_desktop_test() -> + Desktop = idle_session(), + State0 = guild_state(#{<<"desktop">> => Desktop}), + try + with_push_spy(fun() -> + Online = apply_session_presence(#{<<"desktop">> => desktop_active()}, State0), + State = apply_session_presence(#{<<"desktop">> => desktop_invisible()}, Online), + ?assertMatch( + #{<<"status">> := <<"offline">>}, + guild_state_member:lookup_presence(maps:get(member_presence, State), ?USER) + ), + Guild = send_held_message(State), + ?assertEqual([], immediate_pushes()), + ?assertEqual(none, push_within(?GRACE_MS * 3)), + Guild ! stop + end) + after + Desktop ! stop, + ets:delete(maps:get(member_presence, State0)) + end. + +dm_path_buffers_the_push_for_an_active_invisible_desktop_test() -> + with_push_spy(fun() -> + State = presence_update:handle_message_create_event(dm_message(), #{ + user_id => ?USER, + sessions => #{<<"desktop">> => desktop_invisible()}, + push_buffer => [] + }), + ?assertEqual(1, length(maps:get(push_buffer, State))), + ?assertEqual([], immediate_pushes()) + end). + +dm_path_buffers_the_push_for_an_active_desktop_with_a_backgrounded_phone_test() -> + with_push_spy(fun() -> + State = presence_update:handle_message_create_event(dm_message(), #{ + user_id => ?USER, + sessions => #{ + <<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded() + }, + push_buffer => [] + }), + ?assertEqual(1, length(maps:get(push_buffer, State))), + ?assertEqual([], immediate_pushes()) + end). + +session_reports_a_push_hold_change_to_its_guilds_test() -> + Base = #{ + id => <<"desktop">>, + status => online, + afk => false, + mobile => false, + presence_pid => undefined, + guilds => #{?GUILD_ID => {self(), make_ref()}} + }, + {noreply, Afk} = session_lifecycle:handle_presence_update_cast(#{afk => true}, Base), + ?assertEqual( + [{set_session_push_hold, <<"desktop">>, false}], received_casts() + ), + {noreply, _Idle} = session_lifecycle:handle_presence_update_cast(#{status => idle}, Afk), + ?assertEqual([], received_casts()), + {noreply, _Back} = session_lifecycle:handle_presence_update_cast( + #{afk => false, status => invisible}, Afk + ), + ?assertEqual( + [{set_session_push_hold, <<"desktop">>, true}], received_casts() + ). + +a_foreground_phone_never_holds_guild_pushes_test() -> + ?assertEqual( + [{set_session_push_hold, <<"phone">>, false}], + reported_push_hold(#{id => <<"phone">>, status => online, afk => false, mobile => true}) + ). + +a_dnd_desktop_holds_guild_pushes_test() -> + ?assertEqual( + [{set_session_push_hold, <<"desktop">>, true}], + reported_push_hold(#{id => <<"desktop">>, status => dnd, afk => false, mobile => false}) + ). + +a_bot_session_never_reports_a_push_hold_test() -> + ?assertEqual( + [], + reported_push_hold(#{ + id => <<"bot">>, status => online, afk => false, mobile => false, bot => true + }) + ). + +a_desktop_back_from_afk_on_dnd_holds_guild_pushes_again_test() -> + Base = #{ + id => <<"desktop">>, + status => online, + afk => true, + mobile => false, + presence_pid => undefined, + guilds => #{?GUILD_ID => {self(), make_ref()}} + }, + {noreply, _Dnd} = session_lifecycle:handle_presence_update_cast( + #{afk => false, status => dnd}, Base + ), + ?assertEqual([{set_session_push_hold, <<"desktop">>, true}], received_casts()). + +only_sessions_that_reported_no_hold_are_released_at_grace_test() -> + State = #{ + sessions => #{ + <<"released">> => #{push_hold => false}, + <<"holding">> => #{push_hold => true}, + <<"never_reported">> => #{} + } + }, + ?assertEqual( + [<<"released">>], + guild_sessions:released_push_holds( + [<<"released">>, <<"holding">>, <<"never_reported">>, <<"gone">>], State + ) + ). + +reported_push_hold(Session) -> + ok = session_lifecycle:send_guild_push_hold({self(), make_ref()}, Session), + received_casts(). + +received_casts() -> + receive + {'$gen_cast', Msg} -> [Msg | received_casts()] + after 0 -> [] + end. + +guild_message_to_a_user_on_desktop_with_a_backgrounded_phone_is_released_when_the_desktop_stops_holding_test() -> + Desktop = idle_session(), + Phone = idle_session(), + State0 = guild_state(#{<<"desktop">> => Desktop, <<"phone">> => Phone}), + try + with_push_spy(fun() -> + State = apply_session_presence( + #{<<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded()}, + State0 + ), + Guild = send_held_message(State), + ?assertEqual([], immediate_pushes()), + Guild ! {set_state, report_push_hold(<<"desktop">>, desktop_offline(), State)}, + ?assertEqual([?USER], push_within(?GRACE_MS * 10)), + ?assert(is_process_alive(Desktop)), + Guild ! stop + end) + after + Desktop ! stop, + Phone ! stop, + ets:delete(maps:get(member_presence, State0)) + end. + +guild_message_to_a_user_on_an_invisible_desktop_is_released_when_the_desktop_goes_afk_test() -> + Desktop = idle_session(), + State0 = guild_state(#{<<"desktop">> => Desktop}), + try + with_push_spy(fun() -> + State = apply_session_presence(#{<<"desktop">> => desktop_invisible()}, State0), + Guild = send_held_message(State), + ?assertEqual([], immediate_pushes()), + Guild ! {set_state, report_push_hold(<<"desktop">>, desktop_away(), State)}, + ?assertEqual([?USER], push_within(?GRACE_MS * 10)), + Guild ! stop + end) + after + Desktop ! stop, + ets:delete(maps:get(member_presence, State0)) + end. + +manually_idle_desktop_does_not_hold_guild_pushes_test() -> + Base = #{ + id => <<"desktop">>, + status => online, + afk => false, + mobile => false, + presence_pid => undefined, + guilds => #{?GUILD_ID => {self(), make_ref()}} + }, + {noreply, _Idle} = session_lifecycle:handle_presence_update_cast(#{status => idle}, Base), + ?assertEqual([{set_session_push_hold, <<"desktop">>, false}], received_casts()). diff --git a/fluxer_gateway/test/guild_push_silent_tests.erl b/fluxer_gateway/test/guild_push_silent_tests.erl new file mode 100644 index 000000000..a629f709a --- /dev/null +++ b/fluxer_gateway/test/guild_push_silent_tests.erl @@ -0,0 +1,85 @@ +%% SPDX-License-Identifier: AGPL-3.0-or-later + +-module(guild_push_silent_tests). + +-include_lib("eunit/include/eunit.hrl"). + +-define(GUILD_ID, 7300). +-define(CHANNEL_ID, 7400). +-define(USER, 30). +-define(AUTHOR, 40). +-define(SUPPRESS_NOTIFICATIONS, 4096). + +member() -> + #{<<"user">> => #{<<"id">> => integer_to_binary(?USER)}, <<"roles">> => []}. + +guild_state(MembersTab) -> + #{ + id => ?GUILD_ID, + data => #{ + <<"guild">> => #{ + <<"id">> => ?GUILD_ID, + <<"name">> => <<"Guild">>, + <<"owner_id">> => ?USER, + <<"default_message_notifications">> => 0 + }, + <<"channels">> => [#{<<"id">> => ?CHANNEL_ID, <<"name">> => <<"general">>}], + <<"channel_index">> => #{ + ?CHANNEL_ID => #{<<"id">> => ?CHANNEL_ID, <<"name">> => <<"general">>} + }, + <<"roles">> => [], + <<"role_index">> => #{}, + members_ets => MembersTab + }, + sessions => #{} + }. + +guild_message(Id, Flags) -> + #{ + <<"id">> => integer_to_binary(Id), + <<"channel_id">> => integer_to_binary(?CHANNEL_ID), + <<"author">> => #{<<"id">> => integer_to_binary(?AUTHOR)}, + <<"content">> => <<"<@30> hi @everyone">>, + <<"mentions">> => [#{<<"id">> => integer_to_binary(?USER)}], + <<"mention_everyone">> => true, + <<"flags">> => Flags + }. + +pushed_message_ids(Messages) -> + Self = self(), + MembersTab = ets:new(guild_push_silent_members, [set, public]), + true = ets:insert(MembersTab, {?USER, member()}), + ok = meck:new(push, [passthrough, no_link]), + try + ok = meck:expect(push, handle_message_create, fun(Params) -> + Self ! {pushed, maps:get(<<"id">>, maps:get(message_data, Params))}, + ok + end), + lists:foreach( + fun(Message) -> + ok = guild_dispatch_push:maybe_send_push_notifications( + message_create, Message, ?GUILD_ID, guild_state(MembersTab) + ) + end, + Messages + ), + lists:sort(collect_pushed(1000)) + after + meck:unload(push), + ets:delete(MembersTab) + end. + +collect_pushed(Timeout) -> + receive + {pushed, Id} -> [binary_to_integer(Id) | collect_pushed(300)] + after Timeout -> [] + end. + +a_silent_guild_message_mentioning_the_member_is_not_pushed_test() -> + Messages = [ + guild_message(1, ?SUPPRESS_NOTIFICATIONS), + guild_message(2, ?SUPPRESS_NOTIFICATIONS bor 4), + guild_message(3, 0), + guild_message(4, 4) + ], + ?assertEqual([3, 4], pushed_message_ids(Messages)). diff --git a/fluxer_gateway/test/push_outbox_read_tests.erl b/fluxer_gateway/test/push_outbox_read_tests.erl new file mode 100644 index 000000000..301264a2d --- /dev/null +++ b/fluxer_gateway/test/push_outbox_read_tests.erl @@ -0,0 +1,327 @@ +%% SPDX-License-Identifier: AGPL-3.0-or-later + +-module(push_outbox_read_tests). +-include_lib("eunit/include/eunit.hrl"). + +-define(USER, 10). +-define(OTHER, 11). +-define(CHANNEL, 5). +-define(MESSAGE, 100). + +a_read_during_an_in_flight_message_job_clears_again_after_it_completes_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _MessageBody} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + ok = push_job_publisher:publish_clear(?USER, ?CHANNEL, ?MESSAGE), + {ClearWorker, ClearBody} = await_request(<<"push.job.clear">>), + ?assertNot(maps:is_key(<<"after_message_id">>, json:decode(ClearBody))), + ClearWorker ! release, + _ = await_delivered(1), + ?assertEqual(nothing, next_request(100)), + MessageWorker ! release, + {FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>), + ?assertEqual( + followup_fields(?USER, ?CHANNEL, ?MESSAGE, ?MESSAGE), + clear_body_fields(FollowupBody) + ), + FollowupWorker ! release, + Stats = await_delivered(3), + ?assertEqual(1, maps:get(followup_clears, Stats)), + ?assertEqual(nothing, next_request(100)) + end). + +reads_during_an_in_flight_job_coalesce_into_one_clear_at_the_highest_read_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER, ?OTHER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE + 5), + ok = push_outbox:truncate_read(?USER, ?CHANNEL + 1, ?MESSAGE + 5), + ok = push_outbox:truncate_read(?OTHER, ?CHANNEL, ?MESSAGE - 1), + MessageWorker ! release, + {FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>), + ?assertEqual( + followup_fields(?USER, ?CHANNEL, ?MESSAGE + 5, ?MESSAGE), + clear_body_fields(FollowupBody) + ), + FollowupWorker ! release, + Stats = await_delivered(2), + ?assertEqual(1, maps:get(followup_clears, Stats)), + ?assertEqual(nothing, next_request(100)) + end). + +a_failed_in_flight_job_clears_the_reader_and_retries_without_them_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER, ?OTHER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + MessageWorker ! fail, + {FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>), + ?assertEqual( + followup_fields(?USER, ?CHANNEL, ?MESSAGE, ?MESSAGE), + clear_body_fields(FollowupBody) + ), + FollowupWorker ! release, + {RetryWorker, RetryBody} = await_request(<<"push.job.message">>), + ?assertEqual([integer_to_binary(?OTHER)], body_user_ids(RetryBody)), + RetryWorker ! release, + Stats = await_delivered(2), + ?assertEqual(1, maps:get(retries, Stats)), + ?assertEqual(1, maps:get(truncations, Stats)), + ?assertEqual(nothing, next_request(100)) + end). + +a_read_across_in_flight_jobs_of_one_channel_clears_once_after_the_last_completes_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {FirstWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:enqueue(message_job([?USER, ?OTHER], ?CHANNEL, ?MESSAGE + 1)), + {SecondWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE + 1), + FirstWorker ! release, + _ = await_delivered(1), + ?assertEqual(nothing, next_request(100)), + SecondWorker ! release, + {FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>), + ?assertEqual( + followup_fields(?USER, ?CHANNEL, ?MESSAGE + 1, ?MESSAGE + 1), + clear_body_fields(FollowupBody) + ), + FollowupWorker ! release, + Stats = await_delivered(3), + ?assertEqual(1, maps:get(followup_clears, Stats)), + ?assertEqual(nothing, next_request(100)) + end). + +an_outbox_state_from_before_follow_ups_still_follows_up_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _} = await_request(<<"push.job.message">>), + _ = sys:replace_state(push_outbox, fun(State) -> maps:remove(followups, State) end), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + MessageWorker ! release, + {FollowupWorker, _} = await_request(<<"push.job.clear">>), + FollowupWorker ! release, + Stats = await_delivered(2), + ?assertEqual(1, maps:get(followup_clears, Stats)) + end). + +a_hot_loaded_outbox_finishes_an_in_flight_job_that_nobody_read_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _} = await_request(<<"push.job.message">>), + _ = sys:replace_state(push_outbox, fun(State) -> maps:remove(followups, State) end), + Outbox = whereis(push_outbox), + MessageWorker ! release, + Stats = await_delivered(1), + ?assertEqual(Outbox, whereis(push_outbox)), + ?assertEqual(0, maps:get(followup_clears, Stats)), + ?assertEqual(nothing, next_request(100)) + end). + +a_read_in_another_channel_does_not_follow_up_an_in_flight_job_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?USER, ?CHANNEL + 1, ?MESSAGE + 5), + MessageWorker ! release, + Stats = await_delivered(1), + ?assertEqual(0, maps:get(followup_clears, Stats)), + ?assertEqual(nothing, next_request(100)) + end). + +a_read_by_someone_the_job_is_not_for_does_not_follow_up_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?OTHER, ?CHANNEL, ?MESSAGE), + MessageWorker ! release, + Stats = await_delivered(1), + ?assertEqual(0, maps:get(followup_clears, Stats)), + ?assertEqual(nothing, next_request(100)) + end). + +reads_during_in_flight_jobs_in_two_channels_clear_each_channel_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {FirstWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL + 1, ?MESSAGE + 50)), + {SecondWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + ok = push_outbox:truncate_read(?USER, ?CHANNEL + 1, ?MESSAGE + 50), + FirstWorker ! release, + {FirstFollowup, FirstBody} = await_request(<<"push.job.clear">>), + ?assertEqual( + followup_fields(?USER, ?CHANNEL, ?MESSAGE, ?MESSAGE), + clear_body_fields(FirstBody) + ), + FirstFollowup ! release, + SecondWorker ! release, + {SecondFollowup, SecondBody} = await_request(<<"push.job.clear">>), + ?assertEqual( + followup_fields(?USER, ?CHANNEL + 1, ?MESSAGE + 50, ?MESSAGE + 50), + clear_body_fields(SecondBody) + ), + SecondFollowup ! release, + Stats = await_delivered(4), + ?assertEqual(2, maps:get(followup_clears, Stats)) + end). + +reads_arriving_out_of_order_still_clear_at_the_highest_read_test() -> + with_outbox(#{}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE + 5), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + MessageWorker ! release, + {FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>), + ?assertEqual( + followup_fields(?USER, ?CHANNEL, ?MESSAGE + 5, ?MESSAGE), + clear_body_fields(FollowupBody) + ), + FollowupWorker ! release, + _ = await_delivered(2) + end). + +no_follow_up_clear_is_sent_while_clears_are_disabled_test() -> + with_outbox(#{push_enrolled_clear_notifications_enabled => false}, fun() -> + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + {MessageWorker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + MessageWorker ! release, + Stats = await_delivered(1), + ?assertEqual(0, maps:get(followup_clears, Stats)), + ?assertEqual(nothing, next_request(100)) + end). + +a_read_removes_the_reader_from_a_message_job_still_queued_test() -> + with_outbox(#{push_outbox_max_inflight => 1}, fun() -> + ok = push_outbox:enqueue(message_job([?OTHER], ?CHANNEL + 1, ?MESSAGE)), + {Blocker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:enqueue(message_job([?USER, ?OTHER], ?CHANNEL, ?MESSAGE)), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + Queued = push_outbox:stats(), + ?assertEqual(1, maps:get(truncations, Queued)), + ?assertEqual(1, maps:get(depth, Queued)), + Blocker ! release, + {Worker, Body} = await_request(<<"push.job.message">>), + ?assertEqual([integer_to_binary(?OTHER)], body_user_ids(Body)), + Worker ! release, + Stats = await_delivered(2), + ?assertEqual(1, maps:get(truncations, Stats)) + end). + +a_read_drops_a_queued_message_job_it_was_the_only_reader_of_test() -> + with_outbox(#{push_outbox_max_inflight => 1}, fun() -> + ok = push_outbox:enqueue(message_job([?OTHER], ?CHANNEL + 1, ?MESSAGE)), + {Blocker, _} = await_request(<<"push.job.message">>), + ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)), + ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE), + ?assertEqual(0, maps:get(depth, push_outbox:stats())), + Blocker ! release, + _ = await_delivered(1), + ?assertEqual(nothing, next_request(200)), + ?assertEqual(1, maps:get(truncations, push_outbox:stats())) + end). + +with_outbox(Env, Fun) -> + Self = self(), + Modules = [fluxer_gateway_env, gateway_node_router, push_job_publisher], + lists:foreach(fun(Module) -> ok = meck:new(Module, [passthrough, no_link]) end, Modules), + try + ok = meck:expect(fluxer_gateway_env, get_optional, fun(Key) -> + maps:get(Key, Env, undefined) + end), + ok = meck:expect(fluxer_gateway_env, get, fun(Key) -> maps:get(Key, Env, undefined) end), + ok = meck:expect(gateway_node_router, active_nodes, fun(push) -> [node()] end), + ok = meck:expect(push_job_publisher, request, fun(Subject, Body, _Timeout) -> + Self ! {request, self(), Subject, Body}, + receive + release -> ok; + fail -> {error, rejected} + end + end), + ok = application:set_env(fluxer_gateway, push_outbox_retry_base_ms, 10), + {ok, Outbox} = push_outbox:start_link(), + unlink(Outbox), + try + Fun() + after + gen_server:stop(Outbox) + end + after + ok = application:unset_env(fluxer_gateway, push_outbox_retry_base_ms), + lists:foreach(fun meck:unload/1, Modules), + drain_requests() + end. + +message_job(UserIds, ChannelId, MessageId) -> + Job = #{ + <<"v">> => 1, + <<"channel_id">> => integer_to_binary(ChannelId), + <<"message_id">> => integer_to_binary(MessageId), + <<"user_ids">> => [integer_to_binary(UserId) || UserId <- UserIds] + }, + #{ + kind => message, + subject => <<"push.job.message">>, + job => Job, + body => iolist_to_binary(json:encode(Job)), + user_ids => UserIds, + channel_id => ChannelId, + message_id => MessageId + }. + +await_request(Subject) -> + receive + {request, Worker, Subject, Body} -> {Worker, Body} + after 2000 -> error({no_request, Subject}) + end. + +next_request(Timeout) -> + receive + {request, _Worker, Subject, Body} -> {Subject, Body} + after Timeout -> nothing + end. + +await_delivered(Count) -> + await_delivered(Count, 100). + +await_delivered(Count, 0) -> + error({not_delivered, Count, push_outbox:stats()}); +await_delivered(Count, Attempts) -> + Stats = push_outbox:stats(), + case maps:get(delivered, Stats) of + Count -> + Stats; + _ -> + receive + after 20 -> await_delivered(Count, Attempts - 1) + end + end. + +followup_fields(UserId, ChannelId, MessageId, AfterMessageId) -> + #{ + <<"user_id">> => integer_to_binary(UserId), + <<"channel_id">> => integer_to_binary(ChannelId), + <<"message_id">> => integer_to_binary(MessageId), + <<"after_message_id">> => integer_to_binary(AfterMessageId) + }. + +clear_body_fields(Body) -> + maps:with( + [<<"user_id">>, <<"channel_id">>, <<"message_id">>, <<"after_message_id">>], + json:decode(Body) + ). + +body_user_ids(Body) -> + maps:get(<<"user_ids">>, json:decode(Body)). + +drain_requests() -> + receive + {request, Worker, _Subject, _Body} -> + Worker ! release, + drain_requests() + after 0 -> ok + end. diff --git a/fluxer_gateway/test/push_read_dm_tests.erl b/fluxer_gateway/test/push_read_dm_tests.erl new file mode 100644 index 000000000..77bdbcc38 --- /dev/null +++ b/fluxer_gateway/test/push_read_dm_tests.erl @@ -0,0 +1,487 @@ +%% SPDX-License-Identifier: AGPL-3.0-or-later + +-module(push_read_dm_tests). +-typing([eqwalizer]). + +-include_lib("eunit/include/eunit.hrl"). + +-define(USER, 910001). +-define(PARTNER, 910002). +-define(DM, 910005). +-define(PARTNER_MSG, 910100). +-define(REPLY_MSG, 910101). +-define(DESKTOP, <<"desktop">>). + +a_dm_read_by_replying_on_desktop_is_not_pushed_when_desktop_goes_afk_test() -> + Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) -> + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + ?assertEqual([?PARTNER_MSG], buffered_message_ids(PresencePid)), + dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)), + ?assertEqual([], push_buffer(PresencePid)), + gen_server:cast(PresencePid, {presence_update, desktop_request(true)}), + sync(PresencePid), + pushed_message_ids([]) + end), + ?assertEqual([], Pushed). + +a_dm_read_by_replying_on_desktop_is_not_pushed_when_desktop_disconnects_test() -> + Pushed = with_desktop_presence(fun(PresencePid, SessionPid) -> + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)), + ?assertEqual([], push_buffer(PresencePid)), + MRef = monitor(process, PresencePid), + SessionPid ! stop, + receive + {'DOWN', MRef, process, PresencePid, normal} -> ok + after 2000 -> + ?assert(false) + end, + pushed_message_ids([]) + end), + ?assertEqual([], Pushed). + +a_reply_keeps_buffered_dms_newer_than_the_reply_and_in_other_channels_test() -> + Buffered = with_desktop_presence(fun(PresencePid, _SessionPid) -> + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + dispatch(PresencePid, message_create, (dm_message(?PARTNER_MSG + 5, ?PARTNER))#{ + <<"channel_id">> => integer_to_binary(?DM + 1) + }), + dispatch(PresencePid, message_create, dm_message(?REPLY_MSG + 5, ?PARTNER)), + dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)), + lists:sort(buffered_message_ids(PresencePid)) + end), + ?assertEqual([?PARTNER_MSG + 5, ?REPLY_MSG + 5], Buffered). + +an_unread_dm_is_pushed_with_its_buffer_time_when_desktop_goes_afk_test() -> + Self = self(), + Before = erlang:system_time(millisecond), + with_desktop_presence(fun(PresencePid, _SessionPid) -> + ok = meck:expect(push, handle_buffered_message_creates, fun(ParamsList) -> + lists:foreach(fun(Params) -> Self ! {pushed_params, Params} end, ParamsList) + end), + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + gen_server:cast(PresencePid, {presence_update, desktop_request(true)}), + sync(PresencePid) + end), + receive + {pushed_params, #{message_data := MessageData, buffered_at := BufferedAt}} -> + ?assertEqual(integer_to_binary(?PARTNER_MSG), maps:get(<<"id">>, MessageData)), + ?assert(BufferedAt >= Before), + ?assert(BufferedAt =< erlang:system_time(millisecond)) + after 0 -> + erlang:error(nothing_pushed) + end. + +a_dm_older_than_the_reply_arriving_after_it_is_not_pushed_test() -> + Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) -> + dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)), + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + ?assertEqual([], push_buffer(PresencePid)), + gen_server:cast(PresencePid, {presence_update, desktop_request(true)}), + sync(PresencePid), + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG - 1, ?PARTNER)), + dispatch(PresencePid, message_create, dm_message(?REPLY_MSG + 1, ?PARTNER)), + pushed_message_ids([]) + end), + ?assertEqual([?REPLY_MSG + 1], Pushed). + +a_buffered_dm_without_a_buffer_time_is_still_pushed_test() -> + Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) -> + sys:replace_state(PresencePid, fun(State) -> + State#{ + push_buffer := [ + #{ + channel_id => ?DM, + message_id => ?PARTNER_MSG, + params => #{message_data => dm_message(?PARTNER_MSG, ?PARTNER)} + } + ] + } + end), + gen_server:cast(PresencePid, {presence_update, desktop_request(true)}), + sync(PresencePid), + pushed_message_ids([]) + end), + ?assertEqual([?PARTNER_MSG], Pushed). + +a_message_ack_drops_the_buffered_dm_push_test() -> + Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) -> + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + dispatch(PresencePid, message_ack, #{ + <<"channel_id">> => integer_to_binary(?DM), + <<"message_id">> => integer_to_binary(?PARTNER_MSG) + }), + ?assertEqual([], push_buffer(PresencePid)), + gen_server:cast(PresencePid, {presence_update, desktop_request(true)}), + sync(PresencePid), + pushed_message_ids([]) + end), + ?assertEqual([], Pushed). + +a_flush_hands_every_buffered_dm_to_push_in_one_batch_test() -> + Self = self(), + with_desktop_presence(fun(PresencePid, _SessionPid) -> + ok = meck:expect(push, handle_buffered_message_creates, fun(ParamsList) -> + Self ! {batch, [maps:get(<<"id">>, maps:get(message_data, P)) || P <- ParamsList]}, + ok + end), + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG + 1, ?PARTNER)), + gen_server:cast(PresencePid, {presence_update, desktop_request(true)}), + sync(PresencePid) + end), + receive + {batch, Ids} -> + ?assertEqual( + [integer_to_binary(?PARTNER_MSG), integer_to_binary(?PARTNER_MSG + 1)], Ids + ) + after 0 -> + erlang:error(no_batch) + end, + receive + {batch, _} -> erlang:error(second_batch) + after 0 -> ok + end. + +read_marks_are_capped_at_32_channels_dropping_the_oldest_test() -> + Marks = with_desktop_presence(fun(PresencePid, _SessionPid) -> + lists:foreach( + fun(N) -> ack(PresencePid, ?DM + N, ?PARTNER_MSG + N) end, + lists:seq(1, 33) + ), + maps:get(push_read_marks, sys:get_state(PresencePid)) + end), + ?assertEqual(32, map_size(Marks)), + ?assertNot(maps:is_key(?DM + 1, Marks)), + ?assertEqual(?PARTNER_MSG + 2, maps:get(?DM + 2, Marks)), + ?assertEqual(?PARTNER_MSG + 33, maps:get(?DM + 33, Marks)). + +a_lower_ack_after_a_reply_does_not_lower_the_read_mark_test() -> + Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) -> + dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)), + ack(PresencePid, ?DM, ?PARTNER_MSG - 10), + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + gen_server:cast(PresencePid, {presence_update, desktop_request(true)}), + sync(PresencePid), + pushed_message_ids([]) + end), + ?assertEqual([], Pushed). + +a_create_arriving_after_its_own_ack_is_not_pushed_test() -> + Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) -> + ack(PresencePid, ?DM, ?PARTNER_MSG), + dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)), + gen_server:cast(PresencePid, {presence_update, desktop_request(true)}), + sync(PresencePid), + pushed_message_ids([]) + end), + ?assertEqual([], Pushed). + +stale_read_state_fetches_are_capped_per_node_and_fail_open_past_the_cap_test() -> + Flushes = 100, + {Fetchers, Published, InFlight, Late} = with_stale_read_state_mocks(fun() -> + lists:foreach( + fun(N) -> + spawn(fun() -> + push:handle_buffered_message_creates([stale_params(?USER + N)]) + end) + end, + lists:seq(1, Flushes) + ), + {Blocked, Immediate} = collect_read_state_traffic(Flushes, [], 0), + Held = read_state_fetches_in_flight(), + lists:foreach(fun(Pid) -> Pid ! release end, Blocked), + wait_for_released_fetch_slots(50), + {Blocked, Immediate, Held, collect_published(0)} + end), + ?assertEqual(64, length(Fetchers)), + ?assertEqual(64, InFlight), + ?assertEqual(Flushes - 64, Published), + ?assertEqual(0, Late), + ?assertEqual(0, read_state_fetches_in_flight()). + +with_stale_read_state_mocks(Fun) -> + Self = self(), + ok = push_worker_pool:init_counter(), + true = ets:delete(push_worker_counter, read_state_fetches_in_flight), + ok = meck:new(fluxer_gateway_env, [passthrough, no_link]), + ok = meck:new(gateway_node_router, [passthrough, no_link]), + ok = meck:new(rpc_client, [passthrough, no_link]), + try + ok = meck:expect(fluxer_gateway_env, get, fun + (push_enabled) -> true; + (Key) -> meck:passthrough([Key]) + end), + ok = meck:expect(gateway_node_router, owner_node_result, fun(_Key, push) -> + Self ! published, + {error, test} + end), + ok = meck:expect(rpc_client, call, fun(_Request, _Timeout) -> + Self ! {fetching, self()}, + receive + release -> {ok, #{<<"last_message_id">> => integer_to_binary(?PARTNER_MSG)}} + after 5000 -> + {error, timeout} + end + end), + Fun() + after + meck:unload(rpc_client), + meck:unload(gateway_node_router), + meck:unload(fluxer_gateway_env) + end. + +stale_params(UserId) -> + #{ + user_ids => [UserId], + guild_id => 0, + author_id => ?PARTNER, + message_data => dm_message(?PARTNER_MSG, ?PARTNER), + buffered_at => erlang:system_time(millisecond) - push_outbox:max_age_ms() - 1000 + }. + +collect_read_state_traffic(Expected, Fetchers, Published) when + length(Fetchers) + Published >= Expected +-> + {Fetchers, Published}; +collect_read_state_traffic(Expected, Fetchers, Published) -> + receive + {fetching, Pid} -> collect_read_state_traffic(Expected, [Pid | Fetchers], Published); + published -> collect_read_state_traffic(Expected, Fetchers, Published + 1) + after 2000 -> + {Fetchers, Published} + end. + +collect_published(Count) -> + receive + published -> collect_published(Count + 1) + after 200 -> + Count + end. + +wait_for_released_fetch_slots(0) -> + ok; +wait_for_released_fetch_slots(Attempts) -> + case read_state_fetches_in_flight() of + 0 -> + ok; + _ -> + timer:sleep(20), + wait_for_released_fetch_slots(Attempts - 1) + end. + +read_state_fetches_in_flight() -> + case ets:lookup(push_worker_counter, read_state_fetches_in_flight) of + [{_, InFlight}] -> InFlight; + [] -> 0 + end. + +an_outbox_read_watermark_filters_a_later_job_for_the_read_message_test() -> + {Sent, Stats} = with_outbox(60000, fun() -> + ok = push_outbox:truncate_read(?USER, ?DM, ?PARTNER_MSG), + sync(whereis(push_outbox)), + ok = push_outbox:enqueue(outbox_job()) + end), + ?assertEqual([], Sent), + ?assertEqual(1, maps:get(truncations, Stats)). + +an_outbox_forgets_a_read_once_the_watermark_is_pruned_test() -> + {Sent, Stats} = with_outbox(50, fun() -> + ok = push_outbox:truncate_read(?USER, ?DM, ?PARTNER_MSG), + Outbox = whereis(push_outbox), + sync(Outbox), + timer:sleep(120), + Outbox ! prune, + sync(Outbox), + ok = push_outbox:enqueue(outbox_job()) + end), + ?assertEqual([<<"rpc.push.message">>], Sent), + ?assertEqual(0, maps:get(truncations, Stats)). + +with_desktop_presence(Fun) -> + maybe_start(presence_bus), + maybe_start(presence_cache), + Self = self(), + ok = meck:new(push, [passthrough, no_link]), + try + ok = meck:expect(push, handle_message_create, fun(Params) -> + Self ! {pushed, maps:get(message_data, Params)}, + ok + end), + ok = meck:expect(push, handle_buffered_message_creates, fun(ParamsList) -> + lists:foreach( + fun(Params) -> Self ! {pushed, maps:get(message_data, Params)} end, ParamsList + ) + end), + {ok, PresencePid} = presence:start_link(presence_data()), + unlink(PresencePid), + SessionPid = start_session(PresencePid), + try + Fun(PresencePid, SessionPid) + after + SessionPid ! stop, + stop_quietly(PresencePid), + _ = pushed_message_ids([]) + end + after + meck:unload(push) + end. + +with_outbox(MaxAgeMs, Fun) -> + Self = self(), + undefined = whereis(push_outbox), + ok = meck:new(fluxer_gateway_env, [passthrough, no_link]), + ok = meck:new(gateway_node_router, [passthrough, no_link]), + ok = meck:new(push_job_publisher, [passthrough, no_link]), + try + ok = meck:expect(fluxer_gateway_env, get_optional, fun + (push_outbox_max_age_ms) -> MaxAgeMs; + (Key) -> meck:passthrough([Key]) + end), + ok = meck:expect(gateway_node_router, active_nodes, fun(push) -> [node()] end), + ok = meck:expect(push_job_publisher, request, fun(Subject, _Body, _Timeout) -> + Self ! {sent, Subject}, + ok + end), + {ok, Outbox} = push_outbox:start_link(), + unlink(Outbox), + try + Fun(), + timer:sleep(100), + Stats = push_outbox:stats(), + {sent_subjects([]), Stats} + after + stop_quietly(Outbox) + end + after + meck:unload(push_job_publisher), + meck:unload(gateway_node_router), + meck:unload(fluxer_gateway_env) + end. + +dispatch(PresencePid, Event, Data) -> + gen_server:cast(PresencePid, {dispatch, Event, Data}), + sync(PresencePid). + +ack(PresencePid, ChannelId, MessageId) -> + dispatch(PresencePid, message_ack, #{ + <<"channel_id">> => integer_to_binary(ChannelId), + <<"message_id">> => integer_to_binary(MessageId) + }). + +push_buffer(PresencePid) -> + maps:get(push_buffer, sys:get_state(PresencePid)). + +buffered_message_ids(PresencePid) -> + [maps:get(message_id, Entry) || Entry <- push_buffer(PresencePid)]. + +sync(Pid) -> + _ = sys:get_state(Pid), + ok. + +pushed_message_ids(Acc) -> + receive + {pushed, MessageData} -> + Id = binary_to_integer(maps:get(<<"id">>, MessageData)), + pushed_message_ids([Id | Acc]) + after 0 -> + lists:reverse(Acc) + end. + +sent_subjects(Acc) -> + receive + {sent, Subject} -> sent_subjects([Subject | Acc]) + after 0 -> + lists:reverse(Acc) + end. + +dm_message(MessageId, AuthorId) -> + #{ + <<"id">> => integer_to_binary(MessageId), + <<"channel_id">> => integer_to_binary(?DM), + <<"channel_type">> => 1, + <<"author">> => #{<<"id">> => integer_to_binary(AuthorId)}, + <<"content">> => <<"hi">> + }. + +outbox_job() -> + Job = #{<<"user_ids">> => [integer_to_binary(?USER)]}, + #{ + kind => message, + subject => <<"rpc.push.message">>, + job => Job, + body => iolist_to_binary(json:encode(Job)), + user_ids => [?USER], + channel_id => ?DM, + message_id => ?PARTNER_MSG + }. + +desktop_request(Afk) -> + #{session_id => ?DESKTOP, status => online, afk => Afk, mobile => false}. + +start_session(PresencePid) -> + Parent = self(), + Pid = spawn(fun() -> + Reply = gen_server:call( + PresencePid, + {session_connect, #{ + session_id => ?DESKTOP, + status => online, + afk => false, + mobile => false, + socket_pid => undefined + }}, + 5000 + ), + Parent ! {session_connected, self(), Reply}, + session_loop() + end), + receive + {session_connected, Pid, {ok, _Sessions}} -> Pid + after 2000 -> + erlang:error(session_connect_timeout) + end. + +session_loop() -> + receive + stop -> ok; + _ -> session_loop() + end. + +stop_quietly(Pid) -> + try gen_server:stop(Pid) of + ok -> ok + catch + exit:_ -> ok + end. + +maybe_start(Name) -> + case whereis(Name) of + undefined -> + case Name:start_link() of + {ok, Pid} -> + unlink(Pid), + ok; + {error, {already_started, _Pid}} -> + ok + end; + _ -> + ok + end. + +presence_data() -> + #{ + user_id => ?USER, + user_data => #{ + <<"id">> => integer_to_binary(?USER), + <<"username">> => <<"test">>, + <<"discriminator">> => <<"0001">>, + <<"avatar">> => null, + <<"flags">> => 0 + }, + guild_ids => [], + friend_ids => [], + group_dm_recipients => #{}, + status => online, + custom_status => null + }. diff --git a/fluxer_gateway/test/session_connection_guild_tests.erl b/fluxer_gateway/test/session_connection_guild_tests.erl index 7d9f3dddd..88ce54ca4 100644 --- a/fluxer_gateway/test/session_connection_guild_tests.erl +++ b/fluxer_gateway/test/session_connection_guild_tests.erl @@ -82,7 +82,7 @@ stale_guild_connect_timeout_cannot_abort_a_later_connect_test() -> try State0 = stale_timer_state(<<"stale-connect">>, GuildId, 4242), {State1, FirstTimeout} = begin_guild_connect(GuildId, State0, TestRef, Tracer), - State2 = complete_guild_connect(GuildId, GuildPid, State1), + State2 = complete_guild_connect(GuildId, GuildPid, State1, TestRef), {State3, SecondTimeout} = begin_guild_connect(GuildId, State2, TestRef, Tracer), {noreply, State4} = session:handle_info(FirstTimeout, State3), ?assertEqual(0, maps:get(GuildId, maps:get(guild_connect_inflight, State4), missing)), @@ -108,7 +108,7 @@ guild_connect_success_cancels_the_connect_timeout_test() -> ?assertMatch({session_connect_async, _}, await_stub_cast(TestRef, 2000)), {_Token, TimerRef} = maps:get(GuildId, maps:get(guild_connect_timers, State1)), ?assert(is_integer(erlang:read_timer(TimerRef))), - State2 = complete_guild_connect(GuildId, GuildPid, State1), + State2 = complete_guild_connect(GuildId, GuildPid, State1, TestRef), ?assertEqual(#{}, maps:get(guild_connect_timers, State2)), ?assertEqual(false, erlang:read_timer(TimerRef)) after @@ -307,11 +307,13 @@ begin_guild_connect(GuildId, State, TestRef, Tracer) -> ?assertMatch({session_connect_async, _}, await_stub_cast(TestRef, 2000)), {State1, await_connect_timeout(Tracer, 2000)}. -complete_guild_connect(GuildId, GuildPid, State) -> +complete_guild_connect(GuildId, GuildPid, State, TestRef) -> {noreply, State1} = session_connection_guild:handle_guild_connect_result( GuildId, 0, {ok, GuildPid, guild_state_payload(GuildId)}, State ), ?assertMatch({GuildPid, _}, maps:get(GuildId, maps:get(guilds, State1))), + SessionId = maps:get(id, State), + ?assertMatch({set_session_push_hold, SessionId, _}, await_stub_cast(TestRef, 2000)), dropped_guild_state(GuildId, State1). stale_timer_state(SessionId, GuildId, UserId) -> diff --git a/fluxer_gateway/test/session_lifecycle_tests.erl b/fluxer_gateway/test/session_lifecycle_tests.erl index f97184209..472efd8da 100644 --- a/fluxer_gateway/test/session_lifecycle_tests.erl +++ b/fluxer_gateway/test/session_lifecycle_tests.erl @@ -304,6 +304,32 @@ handle_resume_restores_resume_status_after_offline_timer_test() -> ?assert(false) end. +handle_resume_after_the_offline_timer_reports_the_push_hold_to_guilds_test() -> + Parent = self(), + Guild = spawn(fun() -> + receive + {'$gen_cast', Msg} -> Parent ! {guild_cast, Msg} + end + end), + State0 = resume_test_state(#{ + status => offline, + resume_status => online, + presence_pid => self(), + guilds => #{1 => {Guild, make_ref()}} + }), + {reply, {ok, _Missed, 0}, State1} = session_lifecycle:handle_resume(0, self(), State0), + ?assertEqual(online, maps:get(status, State1)), + receive + {guild_cast, Msg} -> + ?assertEqual({set_session_push_hold, <<"session-resume-test">>, true}, Msg) + after 200 -> + ?assert(false) + end, + receive + {'$gen_call', {Worker, Tag}, {session_connect, _PresenceUpdate}} -> Worker ! {Tag, ok} + after 200 -> ok + end. + handle_resume_cancels_pending_offline_timer_test() -> Token = make_ref(), TimerRef = erlang:send_after(5000, self(), {resume_offline_timeout, Token}), diff --git a/fluxer_push/src/dedupe.rs b/fluxer_push/src/dedupe.rs index 868c67558..c03575234 100644 --- a/fluxer_push/src/dedupe.rs +++ b/fluxer_push/src/dedupe.rs @@ -19,7 +19,16 @@ impl JobKey { } pub fn of_clear(job: &ClearJob) -> Self { - Self::digest(&["clear", &job.user_id, &job.channel_id, &job.message_id]) + match &job.after_message_id { + Some(after_message_id) => Self::digest(&[ + "clear", + &job.user_id, + &job.channel_id, + &job.message_id, + after_message_id, + ]), + None => Self::digest(&["clear", &job.user_id, &job.channel_id, &job.message_id]), + } } pub fn of_ring(job: &RingJob) -> Self { diff --git a/fluxer_push/src/job.rs b/fluxer_push/src/job.rs index 89465c516..36f2b826f 100644 --- a/fluxer_push/src/job.rs +++ b/fluxer_push/src/job.rs @@ -40,6 +40,8 @@ pub struct ClearJob { pub user_id: String, pub channel_id: String, pub message_id: String, + #[serde(default)] + pub after_message_id: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] diff --git a/fluxer_push/src/metrics.rs b/fluxer_push/src/metrics.rs index 3fd4a93a0..80899a44e 100644 --- a/fluxer_push/src/metrics.rs +++ b/fluxer_push/src/metrics.rs @@ -277,15 +277,17 @@ impl DeliveryRoute { #[repr(usize)] pub enum BucketKey { DeviceToken, + BackgroundDeviceToken, Source, } impl BucketKey { - pub const ALL: [Self; 2] = [Self::DeviceToken, Self::Source]; + pub const ALL: [Self; 3] = [Self::DeviceToken, Self::BackgroundDeviceToken, Self::Source]; pub fn label(self) -> &'static str { match self { Self::DeviceToken => "device_token", + Self::BackgroundDeviceToken => "background_device_token", Self::Source => "source", } } @@ -354,7 +356,7 @@ pub struct Metrics { sends: [[AtomicU64; SEND_RESULT_COUNT]; PROVIDER_COUNT], token_deletions: [AtomicU64; PROVIDER_COUNT], payload_shrinks: [AtomicU64; PAYLOAD_SHRINK_COUNT], - own_relay_shortcuts: AtomicU64, + own_relay_shortcuts: [AtomicU64; RELAY_LEG_COUNT], auth_tokens_minted: [AtomicU64; AUTH_PROVIDER_COUNT], rpc_requests: [[AtomicU64; RPC_OUTCOME_COUNT]; RPC_METHOD_COUNT], relay_consent_updates: [AtomicU64; CONSENT_UPDATE_COUNT], @@ -383,7 +385,7 @@ impl Metrics { sends: [const { [const { AtomicU64::new(0) }; SEND_RESULT_COUNT] }; PROVIDER_COUNT], token_deletions: [const { AtomicU64::new(0) }; PROVIDER_COUNT], payload_shrinks: [const { AtomicU64::new(0) }; PAYLOAD_SHRINK_COUNT], - own_relay_shortcuts: AtomicU64::new(0), + own_relay_shortcuts: [const { AtomicU64::new(0) }; RELAY_LEG_COUNT], auth_tokens_minted: [const { AtomicU64::new(0) }; AUTH_PROVIDER_COUNT], rpc_requests: [const { [const { AtomicU64::new(0) }; RPC_OUTCOME_COUNT] }; RPC_METHOD_COUNT], @@ -436,8 +438,8 @@ impl Metrics { self.token_deletions[provider as usize].fetch_add(1, ORDERING); } - pub fn record_own_relay_shortcut(&self) { - self.own_relay_shortcuts.fetch_add(1, ORDERING); + pub fn record_own_relay_shortcut(&self, leg: RelayLeg) { + self.own_relay_shortcuts[leg as usize].fetch_add(1, ORDERING); } pub fn record_payload_shrink(&self, step: PayloadShrink) { @@ -537,9 +539,11 @@ impl Metrics { Provider::ALL.map(Provider::label), &self.token_deletions, )?; - render_counter( + render_labelled_counter( out, "fluxer_push_own_relay_shortcuts_total", + "leg", + RelayLeg::ALL.map(RelayLeg::label), &self.own_relay_shortcuts, )?; render_labelled_counter( diff --git a/fluxer_push/src/payload.rs b/fluxer_push/src/payload.rs index 6570cfc32..6a22287c1 100644 --- a/fluxer_push/src/payload.rs +++ b/fluxer_push/src/payload.rs @@ -628,6 +628,7 @@ mod tests { user_id: USER_ID.to_owned(), channel_id: CHANNEL_ID.to_owned(), message_id: MESSAGE_ID.to_owned(), + after_message_id: None, } } diff --git a/fluxer_push/src/providers/mod.rs b/fluxer_push/src/providers/mod.rs index e235ea388..c7f204be4 100644 --- a/fluxer_push/src/providers/mod.rs +++ b/fluxer_push/src/providers/mod.rs @@ -5,7 +5,8 @@ pub mod fcm; pub mod own_relay; pub mod web_push; -use crate::metrics::{DeliveryRoute, Provider, SendResult, elapsed_ms}; +use crate::metrics::{DeliveryRoute, Provider, RelayLeg, SendResult, elapsed_ms}; +use crate::relay; use crate::server::AppState; use crate::subscription::{Platform, Subscription}; use crate::vendor::VendorOutcome; @@ -96,11 +97,24 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen } else { let direct = in_process_hop(&sub.endpoint, &state.cfg.own_relay_hosts); match (route, direct) { - (Route::WebPush, Some(hop)) => { + (Route::WebPush, Some(hop)) if hop.leg == own_relay::Leg::Apns => { let hopped = hop.as_subscription(sub); - state.metrics.record_own_relay_shortcut(); + state.metrics.record_own_relay_shortcut(RelayLeg::Apns); apns::send(state, &hopped, envelope).await } + (Route::WebPush, Some(hop)) => { + match relay::FcmTarget::resolve( + state.cfg.fcm.as_ref(), + &hop.app_id, + &hop.device_token, + ) { + Some(target) => { + state.metrics.record_own_relay_shortcut(RelayLeg::Fcm); + web_push::send_to_own_fcm_relay(state, sub, envelope, target).await + } + None => web_push::send(state, sub, envelope).await, + } + } (Route::WebPush, None) => web_push::send(state, sub, envelope).await, (Route::LegacyApns, _) => apns::send(state, sub, envelope).await, (Route::LegacyFcm, _) => fcm::send(state, sub, envelope).await, @@ -123,7 +137,8 @@ fn relay_consent_missing(state: &AppState, endpoint: &str) -> bool { } fn in_process_hop(endpoint: &str, hosts: &[String]) -> Option { - own_relay::parse(endpoint, hosts).filter(|hop| matches!(hop.leg, own_relay::Leg::Apns)) + own_relay::parse(endpoint, hosts) + .filter(|hop| matches!(hop.leg, own_relay::Leg::Apns | own_relay::Leg::Fcm)) } fn route_label(route: Route) -> DeliveryRoute { @@ -164,15 +179,15 @@ mod hop_tests { } #[test] - fn only_the_plain_apns_leg_is_delivered_in_process() { + fn the_plain_apns_leg_is_delivered_in_process() { let apns = format!("https://push.fluxer.com/relay/v1/apns/canary/production/{TOKEN}"); assert!(in_process_hop(&apns, &ours()).is_some()); } #[test] - fn an_fcm_relay_endpoint_keeps_its_encrypted_network_hop() { + fn an_fcm_relay_endpoint_is_delivered_in_process() { let fcm = "https://push.fluxer.com/relay/v1/fcm/canary/tok%3AAPA91bExample"; - assert!(in_process_hop(fcm, &ours()).is_none()); + assert!(in_process_hop(fcm, &ours()).is_some()); } #[test] diff --git a/fluxer_push/src/providers/own_relay.rs b/fluxer_push/src/providers/own_relay.rs index 6a5e7a61a..d337ba628 100644 --- a/fluxer_push/src/providers/own_relay.rs +++ b/fluxer_push/src/providers/own_relay.rs @@ -155,14 +155,14 @@ mod tests { } #[test] - fn only_the_apns_alert_leg_is_taken_in_process() { + fn only_the_alert_legs_are_taken_in_process() { let shortcut = |path: &str| { parse(&format!("https://push.fluxer.com/relay/v1/{path}"), &ours()) - .filter(|hop| matches!(hop.leg, Leg::Apns)) + .filter(|hop| matches!(hop.leg, Leg::Apns | Leg::Fcm)) }; assert!(shortcut(&format!("apns/canary/production/{TOKEN}")).is_some()); assert!(shortcut(&format!("apns-voip/canary/production/{TOKEN}")).is_none()); - assert!(shortcut("fcm/canary/dYC_x9gXTjyyrG8_Aw3nUM%3AAPA91bExample").is_none()); + assert!(shortcut("fcm/canary/dYC_x9gXTjyyrG8_Aw3nUM%3AAPA91bExample").is_some()); } #[test] diff --git a/fluxer_push/src/providers/web_push.rs b/fluxer_push/src/providers/web_push.rs index fcfa53949..faedf3a19 100644 --- a/fluxer_push/src/providers/web_push.rs +++ b/fluxer_push/src/providers/web_push.rs @@ -3,6 +3,7 @@ use crate::crypto; use crate::payload::{self, RecordKind}; use crate::providers::{SendOutcome, own_relay}; +use crate::relay; use crate::resolver; use crate::server::AppState; use crate::subscription::Subscription; @@ -44,19 +45,10 @@ const MAX_HOSTNAME_BYTES: usize = 253; const MAX_LABEL_BYTES: usize = 63; pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> SendOutcome { - if !endpoint_is_allowed(&sub.endpoint) { - return SendOutcome::permanent("endpoint_rejected"); - } - let (Some(p256dh), Some(auth)) = (sub.p256dh_key.as_deref(), sub.auth_key.as_deref()) else { - return SendOutcome::permanent("missing_keys"); + let record = match seal(state, sub, envelope) { + Ok(record) => record, + Err(outcome) => return outcome, }; - let (Ok(p256dh), Ok(auth)) = ( - crypto::decode_subscription_key(p256dh), - crypto::decode_subscription_key(auth), - ) else { - return SendOutcome::permanent("invalid_keys"); - }; - let vapid = &state.cfg.vapid; let token = match state .tokens @@ -67,41 +59,72 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen Err(error) => return SendOutcome::permanent(format!("vapid_token: {error}")), }; let authorization = format!("vapid t={token}, k={}", vapid.public_key); + deliver(state, sub, &record, &Hop::Network(&authorization)).await +} + +pub async fn send_to_own_fcm_relay( + state: &AppState, + sub: &Subscription, + envelope: &Value, + target: relay::FcmTarget<'_>, +) -> SendOutcome { + let record = match seal(state, sub, envelope) { + Ok(record) => record, + Err(outcome) => return outcome, + }; + deliver(state, sub, &record, &Hop::OwnFcmRelay(target)).await +} + +enum Hop<'a> { + Network(&'a str), + OwnFcmRelay(relay::FcmTarget<'a>), +} + +struct Record { + body: Vec, + ttl_seconds: &'static str, + urgency: &'static str, +} + +fn seal(state: &AppState, sub: &Subscription, envelope: &Value) -> Result { + if !endpoint_is_allowed(&sub.endpoint) { + return Err(SendOutcome::permanent("endpoint_rejected")); + } + let (Some(p256dh), Some(auth)) = (sub.p256dh_key.as_deref(), sub.auth_key.as_deref()) else { + return Err(SendOutcome::permanent("missing_keys")); + }; + let (Ok(p256dh), Ok(auth)) = ( + crypto::decode_subscription_key(p256dh), + crypto::decode_subscription_key(auth), + ) else { + return Err(SendOutcome::permanent("invalid_keys")); + }; let (plaintext, shrunk) = payload::fit(envelope, PLAINTEXT_BUDGET); if let Some(step) = shrunk { state.metrics.record_payload_shrink(step); } - let body = match crypto::encrypt_aes128gcm(&plaintext, &p256dh, &auth, RECORD_SIZE) { - Ok(body) => body, - Err(error) => return SendOutcome::permanent(format!("encrypt: {error}")), - }; + let body = crypto::encrypt_aes128gcm(&plaintext, &p256dh, &auth, RECORD_SIZE) + .map_err(|error| SendOutcome::permanent(format!("encrypt: {error}")))?; let (ttl_seconds, urgency) = delivery_headers(envelope); + Ok(Record { + body, + ttl_seconds, + urgency, + }) +} +async fn deliver( + state: &AppState, + sub: &Subscription, + record: &Record, + hop: &Hop<'_>, +) -> SendOutcome { let mut attempt: u32 = 0; loop { - let response = state - .web_push_http - .post(&sub.endpoint) - .header(TTL_HEADER, ttl_seconds) - .header(URGENCY_HEADER, urgency) - .header(CONTENT_TYPE, OCTET_STREAM) - .header(CONTENT_ENCODING, AES128GCM) - .header(AUTHORIZATION, &authorization) - .body(body.clone()) - .send() - .await; - - let status = match response { - Ok(response) => response.status().as_u16(), - Err(error) => { - let unreachable = Unreachable::of(&error); - warn!( - error = %error.without_url(), - kind = unreachable.label(), - endpoint = %origin_of(&sub.endpoint), - "web push request did not complete" - ); + let status = match post(state, sub, record, hop).await { + Ok(status) => status, + Err(unreachable) => { if unreachable.is_permanent() { return SendOutcome::permanent(unreachable.label()); } @@ -125,6 +148,52 @@ pub async fn send(state: &AppState, sub: &Subscription, envelope: &Value) -> Sen } } +async fn post( + state: &AppState, + sub: &Subscription, + record: &Record, + hop: &Hop<'_>, +) -> Result { + let authorization = match hop { + Hop::Network(authorization) => authorization, + Hop::OwnFcmRelay(target) => { + return Ok(relay::forward_fcm( + &state.http, + &state.tokens, + &state.metrics, + target, + &record.body, + record.urgency, + record.ttl_seconds, + ) + .await); + } + }; + let response = state + .web_push_http + .post(&sub.endpoint) + .header(TTL_HEADER, record.ttl_seconds) + .header(URGENCY_HEADER, record.urgency) + .header(CONTENT_TYPE, OCTET_STREAM) + .header(CONTENT_ENCODING, AES128GCM) + .header(AUTHORIZATION, *authorization) + .body(record.body.clone()) + .send() + .await; + response + .map(|response| response.status().as_u16()) + .map_err(|error| { + let unreachable = Unreachable::of(&error); + warn!( + error = %error.without_url(), + kind = unreachable.label(), + endpoint = %origin_of(&sub.endpoint), + "web push request did not complete" + ); + unreachable + }) +} + fn delivery_headers(envelope: &Value) -> (&'static str, &'static str) { match payload::record_kind(envelope) { RecordKind::Message => (ALERT_TTL_SECONDS, ALERT_URGENCY), diff --git a/fluxer_push/src/relay/mod.rs b/fluxer_push/src/relay/mod.rs index 2192d551e..bced79f19 100644 --- a/fluxer_push/src/relay/mod.rs +++ b/fluxer_push/src/relay/mod.rs @@ -5,7 +5,7 @@ pub mod envelope; mod quota; pub mod reject; -use crate::config::{ProviderEnvironment, RelayConfig}; +use crate::config::{FcmConfig, ProviderEnvironment, RelayConfig}; use crate::metrics::{Metrics, RelayLeg, RelayResult}; use crate::server::{Sidecar, serve, sidecar_router}; use crate::tokens::{TokenCache, TokenError}; @@ -204,14 +204,71 @@ struct Delivery { ttl_seconds: i64, } +impl Delivery { + fn parse(urgency: Option<&str>, ttl_seconds: Option<&str>) -> Result { + let bad_request = Rejection::new(Reason::BadRequest); + Ok(Self { + urgency: Urgency::from_header(urgency).ok_or(bad_request)?, + ttl_seconds: ttl_seconds + .ok_or(bad_request)? + .parse::() + .map_err(|_| bad_request)? + .clamp(0, MAX_TTL_SECONDS), + }) + } +} + enum Target<'a> { Apns { environment: ProviderEnvironment, topic: &'a str, }, - Fcm { - project_id: &'a str, - }, + Fcm(FcmTarget<'a>), +} + +pub struct FcmTarget<'a> { + cfg: &'a FcmConfig, + project_id: &'a str, + device_token: &'a str, +} + +impl<'a> FcmTarget<'a> { + pub fn resolve( + cfg: Option<&'a FcmConfig>, + app_id: &str, + device_token: &'a str, + ) -> Option { + let cfg = cfg?; + Some(Self { + project_id: cfg.listed_project_id(app_id)?, + cfg, + device_token, + }) + } +} + +pub async fn forward_fcm( + http: &reqwest::Client, + tokens: &TokenCache, + metrics: &Metrics, + target: &FcmTarget<'_>, + record: &[u8], + urgency: &str, + ttl_seconds: &str, +) -> u16 { + let verdict = async { + if !device_token_is_shaped(RelayLeg::Fcm, target.device_token) { + return Err(Rejection::new(Reason::DeviceTokenInvalid)); + } + let delivery = Delivery::parse(Some(urgency), Some(ttl_seconds))?; + let payload = envelope::encode_payload(record); + send_fcm(http, tokens, metrics, target, &delivery, &payload).await + } + .await; + verdict + .err() + .map_or(StatusCode::OK, |rejection| rejection.reason.status()) + .as_u16() } async fn relay(state: &AppState, incoming: Incoming, headers: HeaderMap, body: Body) -> Response { @@ -264,14 +321,14 @@ async fn forward( if !is_aes128gcm(headers) { return Err(Rejection::new(Reason::BadRequest)); } - let delivery = Delivery { - urgency: Urgency::from_header(header(headers, URGENCY_HEADER)) - .ok_or(Rejection::new(Reason::BadRequest))?, - ttl_seconds: ttl_seconds(headers)?, - }; + let delivery = Delivery::parse(header(headers, URGENCY_HEADER), header(headers, TTL_HEADER))?; state.quota.take( &state.metrics, &incoming.device_token, + match incoming.leg { + RelayLeg::ApnsVoip => Urgency::Alert, + _ => delivery.urgency, + }, client_ip::for_rate_limit(&state.cfg, incoming.peer, headers), Instant::now(), )?; @@ -293,13 +350,21 @@ async fn forward( Target::Apns { environment, topic } => { send_apns(state, incoming, &delivery, &payload, environment, topic).await } - Target::Fcm { project_id } => { - send_fcm(state, incoming, &delivery, &payload, project_id).await + Target::Fcm(target) => { + send_fcm( + &state.http, + &state.tokens, + &state.metrics, + &target, + &delivery, + &payload, + ) + .await } } } -fn resolve<'a>(state: &'a AppState, incoming: &Incoming) -> Result, Rejection> { +fn resolve<'a>(state: &'a AppState, incoming: &'a Incoming) -> Result, Rejection> { let unknown = Rejection::new(Reason::AppUnknown); match incoming.leg { RelayLeg::Apns | RelayLeg::ApnsVoip => { @@ -314,12 +379,13 @@ fn resolve<'a>(state: &'a AppState, incoming: &Incoming) -> Result, R topic: topic.ok_or(unknown)?, }) } - RelayLeg::Fcm => { - let cfg = state.cfg.fcm.as_ref().ok_or(unknown)?; - Ok(Target::Fcm { - project_id: cfg.listed_project_id(&incoming.app_id).ok_or(unknown)?, - }) - } + RelayLeg::Fcm => FcmTarget::resolve( + state.cfg.fcm.as_ref(), + &incoming.app_id, + &incoming.device_token, + ) + .map(Target::Fcm) + .ok_or(unknown), } } @@ -361,41 +427,30 @@ async fn send_apns( request, ) .await; - finish(state, incoming.leg, outcome) + finish(&state.metrics, incoming.leg, outcome) } async fn send_fcm( - state: &AppState, - incoming: &Incoming, + http: &reqwest::Client, + tokens: &TokenCache, + metrics: &Metrics, + target: &FcmTarget<'_>, delivery: &Delivery, payload: &str, - project_id: &str, ) -> Result<(), Rejection> { - let cfg = state - .cfg - .fcm - .as_ref() - .ok_or(Rejection::new(Reason::AppUnknown))?; let body = envelope::fcm_body( - &incoming.device_token, + target.device_token, payload, delivery.urgency, delivery.ttl_seconds, )?; - let outcome = vendor::send_fcm( - &state.http, - &state.tokens, - &state.metrics, - cfg, - project_id, - body, - ) - .await; - finish(state, RelayLeg::Fcm, outcome) + let outcome = + vendor::send_fcm(http, tokens, metrics, target.cfg, target.project_id, body).await; + finish(metrics, RelayLeg::Fcm, outcome) } fn finish( - state: &AppState, + metrics: &Metrics, leg: RelayLeg, outcome: Result, ) -> Result<(), Rejection> { @@ -418,7 +473,7 @@ fn finish( Err(Rejection::new(refusal_reason(&refusal))), ), }; - state.metrics.record_relay_vendor_request(leg, result); + metrics.record_relay_vendor_request(leg, result); verdict } @@ -455,14 +510,6 @@ fn is_aes128gcm(headers: &HeaderMap) -> bool { .is_some_and(|value| value.eq_ignore_ascii_case(AES128GCM)) } -fn ttl_seconds(headers: &HeaderMap) -> Result { - let raw = header(headers, TTL_HEADER).ok_or(Rejection::new(Reason::BadRequest))?; - let parsed = raw - .parse::() - .map_err(|_| Rejection::new(Reason::BadRequest))?; - Ok(parsed.clamp(0, MAX_TTL_SECONDS)) -} - fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers .get(name) diff --git a/fluxer_push/src/relay/quota.rs b/fluxer_push/src/relay/quota.rs index d9496466e..a9f0d4683 100644 --- a/fluxer_push/src/relay/quota.rs +++ b/fluxer_push/src/relay/quota.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use super::envelope::Urgency; use super::reject::{Reason, Rejection}; use crate::config::BucketConfig; use crate::metrics::{BucketKey, Metrics}; @@ -23,6 +24,7 @@ type Key = [u8; KEY_BYTES]; pub struct Quota { admissions: Semaphore, device_tokens: Buckets, + background_device_tokens: Buckets, sources: Option, } @@ -35,6 +37,7 @@ impl Quota { Self { admissions: Semaphore::new(max_concurrent), device_tokens: Buckets::new(device_tokens), + background_device_tokens: Buckets::new(device_tokens), sources: sources.map(Buckets::new), } } @@ -49,13 +52,21 @@ impl Quota { &self, metrics: &Metrics, device_token: &str, + urgency: Urgency, client_ip: IpAddr, now: Instant, ) -> Result<(), Rejection> { + let (which, device_tokens) = match urgency { + Urgency::Alert => (BucketKey::DeviceToken, &self.device_tokens), + Urgency::Background => ( + BucketKey::BackgroundDeviceToken, + &self.background_device_tokens, + ), + }; self.check( metrics, - BucketKey::DeviceToken, - &self.device_tokens, + which, + device_tokens, device_token_key(device_token), now, )?; diff --git a/packages/schema/src/domains/rpc/RpcSchemas.ts b/packages/schema/src/domains/rpc/RpcSchemas.ts index 75e75992a..c1da63a53 100644 --- a/packages/schema/src/domains/rpc/RpcSchemas.ts +++ b/packages/schema/src/domains/rpc/RpcSchemas.ts @@ -205,6 +205,11 @@ export const RpcRequest = z.discriminatedUnion('type', [ channel_id: SnowflakeType.describe('ID of the DM channel'), user_id: SnowflakeType.describe('ID of the user requesting the channel'), }), + z.object({ + type: z.literal('get_read_state').describe('Request type for fetching the read state of one channel'), + user_id: SnowflakeType.describe('ID of the user who owns the read state'), + channel_id: SnowflakeType.describe('ID of the channel'), + }), z.object({ type: z.literal('validate_custom_status').describe('Request type for validating a custom status'), user_id: SnowflakeType.describe('ID of the user'), @@ -515,6 +520,14 @@ export const RpcResponse = z.discriminatedUnion('type', [ }) .describe('DM channel result'), }), + z.object({ + type: z.literal('get_read_state').describe('Response type for a channel read state'), + data: z + .object({ + last_message_id: SnowflakeStringType.nullable().describe('ID of the last read message, or null if none'), + }) + .describe('Channel read state result'), + }), z.object({ type: z.literal('get_gateway_rollout_config').describe('Response type for gateway rollout configuration'), data: z