mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(push): stop pushes for read, silent and muted messages (#3033)
This commit is contained in:
@@ -243,7 +243,7 @@ export class CallService {
|
||||
channelId,
|
||||
messageId,
|
||||
mentionCount: 0,
|
||||
silent: true,
|
||||
implicit: {unreadThrough: channel.lastMessageId},
|
||||
emitGateway: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
await this.call('push.sync_user_guild_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<{
|
||||
|
||||
@@ -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<Array<ReadState>>;
|
||||
|
||||
abstract getReadState(userId: UserID, channelId: ChannelID): Promise<ReadState | null>;
|
||||
|
||||
abstract upsertReadState(
|
||||
userId: UserID,
|
||||
channelId: ChannelID,
|
||||
@@ -13,7 +20,7 @@ export abstract class IReadStateRepository {
|
||||
mentionCount?: number,
|
||||
lastPinTimestamp?: Date,
|
||||
manual?: boolean,
|
||||
): Promise<ReadState>;
|
||||
): Promise<ReadStateUpsert>;
|
||||
|
||||
abstract incrementReadStateMentions(
|
||||
userId: UserID,
|
||||
|
||||
@@ -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<void>): Promise<ReadSignals> {
|
||||
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<string | null | undefined> {
|
||||
const response = await createBuilder<AckResponse>(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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const messages = await createBuilder<Array<{id: string}>>(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([]);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ReadStateRow, 'user_id' | 'channel_id'>({
|
||||
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<ReadState | null> {
|
||||
const row = await fetchOne<ReadStateRow>(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<ReadState> {
|
||||
): Promise<ReadStateUpsert> {
|
||||
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<ReadState> {
|
||||
): Promise<ReadStateUpsert> {
|
||||
const currentReadState = await fetchOne<ReadStateRow>(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<string, DbOp<unknown>> = {
|
||||
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;
|
||||
|
||||
@@ -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<Array<MessageID>> {
|
||||
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<void>(() => {}));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ReadState | null> {
|
||||
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<ReadState> {
|
||||
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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -54,14 +54,12 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
|
||||
const {userId, targetId, note} = params;
|
||||
|
||||
@@ -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<unknown> {
|
||||
throw new GatewayRpcMethodError(code);
|
||||
},
|
||||
async destroy(): Promise<void> {},
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getBadgeCount,
|
||||
getPushNotificationClientState,
|
||||
isNotificationClearPayload,
|
||||
isPushNotificationReadThrough,
|
||||
matchesPushChannelNotification,
|
||||
normalizePushPayload,
|
||||
resolvePushChannelId,
|
||||
@@ -227,7 +228,10 @@ const getShownPushNotifications = async (tag: string): Promise<ReadonlyArray<Not
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const closePushNotificationsForChannel = async (channelId: string): Promise<number> => {
|
||||
const closePushNotificationsForChannel = async (
|
||||
channelId: string,
|
||||
readThroughMessageId: string | undefined,
|
||||
): Promise<number> => {
|
||||
if (typeof self.registration.getNotifications !== 'function') {
|
||||
return 0;
|
||||
}
|
||||
@@ -235,7 +239,10 @@ const closePushNotificationsForChannel = async (channelId: string): Promise<numb
|
||||
const notifications = await self.registration.getNotifications();
|
||||
let closedCount = 0;
|
||||
for (const notification of notifications) {
|
||||
if (matchesPushChannelNotification(notification, channelId)) {
|
||||
if (
|
||||
matchesPushChannelNotification(notification, channelId) &&
|
||||
isPushNotificationReadThrough(notification, readThroughMessageId)
|
||||
) {
|
||||
notification.close();
|
||||
closedCount++;
|
||||
}
|
||||
@@ -340,7 +347,7 @@ self.addEventListener('push', (event: PushEvent) => {
|
||||
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});
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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, _},
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
].
|
||||
|
||||
|
||||
@@ -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 = #{
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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 ->
|
||||
-spec grace_hold(grace_sessions()) -> grace_hold().
|
||||
grace_hold(Held) when map_size(Held) =:= 0 ->
|
||||
none;
|
||||
Held ->
|
||||
{Held, erlang:monotonic_time(millisecond) + grace_recheck_ms()}
|
||||
end.
|
||||
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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 => #{
|
||||
|
||||
@@ -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()).
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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()}.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 => #{},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()).
|
||||
@@ -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)).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}.
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::Hop> {
|
||||
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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<u8>,
|
||||
ttl_seconds: &'static str,
|
||||
urgency: &'static str,
|
||||
}
|
||||
|
||||
fn seal(state: &AppState, sub: &Subscription, envelope: &Value) -> Result<Record, SendOutcome> {
|
||||
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<u16, Unreachable> {
|
||||
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),
|
||||
|
||||
@@ -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<Self, Rejection> {
|
||||
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::<i64>()
|
||||
.map_err(|_| bad_request)?
|
||||
.clamp(0, MAX_TTL_SECONDS),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum Target<'a> {
|
||||
Apns {
|
||||
environment: ProviderEnvironment,
|
||||
topic: &'a str,
|
||||
},
|
||||
Fcm {
|
||||
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<Self> {
|
||||
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<Target<'a>, Rejection> {
|
||||
fn resolve<'a>(state: &'a AppState, incoming: &'a Incoming) -> Result<Target<'a>, 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<Target<'a>, 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<VendorOutcome, TokenError>,
|
||||
) -> 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<i64, Rejection> {
|
||||
let raw = header(headers, TTL_HEADER).ok_or(Rejection::new(Reason::BadRequest))?;
|
||||
let parsed = raw
|
||||
.parse::<i64>()
|
||||
.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)
|
||||
|
||||
@@ -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<Buckets>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user