mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(users): add temporary new conversation limits (#3100)
This commit is contained in:
@@ -8,6 +8,7 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
|
||||
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {isAccountClosed, isTemporarilyBanned} from '@app/api/user/UserHelpers';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -174,6 +175,7 @@ export class AdminUserBanService {
|
||||
['public_reason', data.public_reason ?? 'null'],
|
||||
]),
|
||||
});
|
||||
await clearNewConversationLimit(userId, {cache: cacheService});
|
||||
await emitAdminAction(adminUserId, userId, 'unban');
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {ReportStatus} from '@app/api/report/IReportRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
|
||||
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
@@ -326,6 +327,7 @@ export class AdminUserDeletionService {
|
||||
['notification_sent', notificationSent ? 'true' : 'false'],
|
||||
]),
|
||||
});
|
||||
await clearNewConversationLimit(userId, {cache: cacheService});
|
||||
await emitAdminAction(adminUserId, userId, 'cancel_deletion');
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
|
||||
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtils';
|
||||
@@ -143,6 +144,10 @@ export class AdminUserSecurityService {
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
const trusted = (newFlags & UserFlags.NOT_SUSPICIOUS) !== 0n && (user.flags & UserFlags.NOT_SUSPICIOUS) === 0n;
|
||||
if (trusted || (user.flags & ~newFlags) !== 0n) {
|
||||
await clearNewConversationLimit(userId, {cache: cacheService});
|
||||
}
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
@@ -470,6 +475,9 @@ export class AdminUserSecurityService {
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
if ((currentFlags & ~newFlags) !== 0) {
|
||||
await clearNewConversationLimit(userId, {cache: cacheService});
|
||||
}
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {ReadStateService} from '@app/api/read_state/ReadStateService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
|
||||
import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
|
||||
import {AUTOMATIC_VOICE_REGION_ID, ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {IncomingCallFlags, RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -168,6 +169,16 @@ export class CallService {
|
||||
const dmRecipientIds = recipientIds.filter((id) => id !== userId);
|
||||
if (dmRecipientIds.length === 1) {
|
||||
await this.dmPermissionValidator.validate({senderId: userId, recipientId: dmRecipientIds[0]});
|
||||
const caller = await this.userRepository.findUnique(userId);
|
||||
if (caller) {
|
||||
await assertMayStartConversation({
|
||||
user: caller,
|
||||
targetId: dmRecipientIds[0]!,
|
||||
users: this.userRepository,
|
||||
messages: this.channelRepository,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const existingCall = await this.gatewayService.getCall(channelId);
|
||||
@@ -336,6 +347,16 @@ export class CallService {
|
||||
const dmRecipientIds = Array.from(channel.recipientIds).filter((id) => id !== userId);
|
||||
if (dmRecipientIds.length === 1) {
|
||||
await this.dmPermissionValidator.validate({senderId: userId, recipientId: dmRecipientIds[0]});
|
||||
const caller = await this.userRepository.findUnique(userId);
|
||||
if (caller) {
|
||||
await assertMayStartConversation({
|
||||
user: caller,
|
||||
targetId: dmRecipientIds[0]!,
|
||||
users: this.userRepository,
|
||||
messages: this.channelRepository,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const callerRequestedNoRing = recipients !== undefined && recipients.length === 0;
|
||||
|
||||
@@ -19,8 +19,14 @@ import type {Channel} from '@app/api/models/Channel';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {MessageReaction} from '@app/api/models/MessageReaction';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {
|
||||
assertMayStartConversation,
|
||||
getNewConversationLimit,
|
||||
oneToOneDmRecipient,
|
||||
} from '@app/api/user/NewConversationLimit';
|
||||
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {NewConversationsLimitedError} from '@fluxer/errors/src/domains/user/NewConversationsLimitedError';
|
||||
import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
@@ -32,8 +38,8 @@ export class MessageInteractionService {
|
||||
private reactionService: MessageReactionService;
|
||||
|
||||
constructor(
|
||||
channelRepository: IChannelRepository,
|
||||
userRepository: IUserRepository,
|
||||
private channelRepository: IChannelRepository,
|
||||
private userRepository: IUserRepository,
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
private gatewayService: IGatewayService,
|
||||
snowflakeService: ISnowflakeService,
|
||||
@@ -69,6 +75,7 @@ export class MessageInteractionService {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
await authChannel.checkPermission(Permissions.SEND_MESSAGES);
|
||||
assertGuildMemberCanCommunicate(authChannel.member);
|
||||
if (!authChannel.guild && (await this.startsNewConversation(authChannel.channel, userId))) return;
|
||||
await this.readStateService.startTyping({authChannel, userId});
|
||||
}
|
||||
|
||||
@@ -108,6 +115,7 @@ export class MessageInteractionService {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId});
|
||||
await this.assertConversationAllowed(authChannel.channel, userId);
|
||||
}
|
||||
await this.pinService.pinMessage({authChannel, messageId, userId, requestCache, auditLogReason});
|
||||
}
|
||||
@@ -170,9 +178,36 @@ export class MessageInteractionService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
if (!authChannel.guild) {
|
||||
await this.assertConversationAllowed(authChannel.channel, userId);
|
||||
}
|
||||
await this.reactionService.addReaction({authChannel, messageId, emoji, userId, sessionId});
|
||||
}
|
||||
|
||||
private async startsNewConversation(channel: Channel, userId: UserID): Promise<boolean> {
|
||||
try {
|
||||
await this.assertConversationAllowed(channel, userId);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof NewConversationsLimitedError) return true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertConversationAllowed(channel: Channel, userId: UserID): Promise<void> {
|
||||
const targetId = oneToOneDmRecipient(channel, userId);
|
||||
if (targetId === null || !(await getNewConversationLimit(userId))) return;
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) return;
|
||||
await assertMayStartConversation({
|
||||
user,
|
||||
targetId,
|
||||
users: this.userRepository,
|
||||
messages: this.channelRepository.messages,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
|
||||
async removeReaction({
|
||||
userId,
|
||||
sessionId,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAttachmentID, createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {emitMessageCreated, emitMessageUpdated} from '@app/api/channel/services/message/MessageActivity';
|
||||
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import {resetActivityEventsForTests, startActivityEvents} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import type {ActivityPublisher} from '@app/api/infrastructure/activity/ActivitySpool';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import {Message} from '@app/api/models/Message';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const HASH = '3F'.repeat(32);
|
||||
|
||||
function attachment(id: bigint, hash: string | null): MessageAttachment {
|
||||
return {
|
||||
attachment_id: createAttachmentID(id),
|
||||
filename: `${id}.png`,
|
||||
size: 10n * id,
|
||||
title: null,
|
||||
description: null,
|
||||
width: 1,
|
||||
height: 1,
|
||||
content_type: 'image/png',
|
||||
content_hash: hash,
|
||||
placeholder: null,
|
||||
flags: 0,
|
||||
duration: null,
|
||||
nsfw: null,
|
||||
waveform: null,
|
||||
};
|
||||
}
|
||||
|
||||
function message(attachments: Array<MessageAttachment>): Message {
|
||||
return new Message({
|
||||
channel_id: createChannelID(10n),
|
||||
bucket: 0,
|
||||
message_id: createMessageID(100n),
|
||||
author_id: createUserID(3n),
|
||||
type: MessageTypes.DEFAULT,
|
||||
webhook_id: null,
|
||||
webhook_name: null,
|
||||
webhook_avatar_hash: null,
|
||||
content: '',
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: 0,
|
||||
mention_everyone: false,
|
||||
mention_users: null,
|
||||
mention_roles: null,
|
||||
mention_channels: null,
|
||||
attachments,
|
||||
embeds: null,
|
||||
sticker_items: null,
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
has_reaction: null,
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
|
||||
class CapturingPublisher implements ActivityPublisher {
|
||||
readonly payloads: Array<string> = [];
|
||||
|
||||
async publish(_subject: string, payload: string): Promise<void> {
|
||||
this.payloads.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
describe('message activity', () => {
|
||||
afterEach(() => {
|
||||
resetActivityEventsForTests();
|
||||
});
|
||||
|
||||
function params(attachments: Array<MessageAttachment>) {
|
||||
return {
|
||||
user: {id: createUserID(3n), isBot: false} as unknown as User,
|
||||
message: message(attachments),
|
||||
channel: {id: createChannelID(10n), type: ChannelTypes.DM} as unknown as Channel,
|
||||
guildId: null,
|
||||
guildOwnerId: null,
|
||||
dmRecipientId: createUserID(4n),
|
||||
channelHadMessages: true,
|
||||
delivered: true,
|
||||
userRepository: {getRelationship: async () => null},
|
||||
};
|
||||
}
|
||||
|
||||
it('serializes attachment metadata', async () => {
|
||||
const publisher = new CapturingPublisher();
|
||||
await startActivityEvents({publisher, kv: new MockKVProvider()});
|
||||
emitMessageCreated(params([attachment(1n, HASH), attachment(2n, null)]));
|
||||
await vi.waitFor(() => expect(publisher.payloads).toHaveLength(1));
|
||||
const event = JSON.parse(publisher.payloads[0]!);
|
||||
expect([event.kind, event.key]).toEqual(['message_created', '3']);
|
||||
expect(event.data).toMatchObject({
|
||||
attachment_count: 2,
|
||||
attachments: [
|
||||
{size: 10, content_type: 'image/png', hash: HASH.toLowerCase()},
|
||||
{size: 20, content_type: 'image/png', hash: null},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes message updates', async () => {
|
||||
const publisher = new CapturingPublisher();
|
||||
await startActivityEvents({publisher, kv: new MockKVProvider()});
|
||||
emitMessageCreated(params([]));
|
||||
emitMessageUpdated(params([attachment(1n, HASH)]));
|
||||
await vi.waitFor(() => expect(publisher.payloads).toHaveLength(2));
|
||||
const [created, updated] = publisher.payloads.map((payload) => JSON.parse(payload));
|
||||
expect(created.kind).toBe('message_created');
|
||||
expect(updated.kind).toBe('message_updated');
|
||||
expect(updated.data).toMatchObject({message_id: '100', attachments: [{hash: HASH.toLowerCase()}]});
|
||||
expect(updated.id).not.toBe(created.id);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {createInviteCode, type GuildID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import type {AttachmentMeta} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getGuildRepository, getInviteRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
@@ -71,7 +72,18 @@ export interface MessageCreatedActivity {
|
||||
userRepository: Pick<IUserRepository, 'getRelationship'>;
|
||||
}
|
||||
|
||||
async function buildAndEmit(params: MessageCreatedActivity): Promise<void> {
|
||||
function attachmentMeta(message: Message): Array<AttachmentMeta> {
|
||||
return message.attachments.slice(0, LIST_MAX).map((attachment) => ({
|
||||
size: Number(attachment.size),
|
||||
content_type: attachment.contentType || null,
|
||||
hash: attachment.contentHash ? attachment.contentHash.toLowerCase() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function buildAndEmit(
|
||||
kind: 'message_created' | 'message_updated',
|
||||
params: MessageCreatedActivity,
|
||||
): Promise<void> {
|
||||
const {user, message, channel, guildId, dmRecipientId} = params;
|
||||
const content = Array.from(message.content ?? '')
|
||||
.slice(0, CONTENT_MAX_CHARS)
|
||||
@@ -87,7 +99,7 @@ async function buildAndEmit(params: MessageCreatedActivity): Promise<void> {
|
||||
? (await params.userRepository.getRelationship(user.id, dmRecipientId, RelationshipTypes.FRIEND)) !== null
|
||||
: false;
|
||||
await emitActivity(
|
||||
'message_created',
|
||||
kind,
|
||||
user.id.toString(),
|
||||
{
|
||||
user_id: user.id.toString(),
|
||||
@@ -102,6 +114,7 @@ async function buildAndEmit(params: MessageCreatedActivity): Promise<void> {
|
||||
content,
|
||||
attachment_count: message.attachments.length,
|
||||
attachment_names: message.attachments.slice(0, LIST_MAX).map((attachment) => attachment.filename),
|
||||
attachments: attachmentMeta(message),
|
||||
link_domains: domains,
|
||||
invite_codes: inviteCodes,
|
||||
invite_guild_ids: targets.map((target) => target.guildId),
|
||||
@@ -114,12 +127,20 @@ async function buildAndEmit(params: MessageCreatedActivity): Promise<void> {
|
||||
delivered: params.delivered,
|
||||
},
|
||||
null,
|
||||
message.id.toString(),
|
||||
kind === 'message_created'
|
||||
? message.id.toString()
|
||||
: `${message.id}:${message.editedTimestamp?.getTime() ?? Date.now()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function emitMessageCreated(params: MessageCreatedActivity): void {
|
||||
void buildAndEmit(params).catch((error: unknown) => {
|
||||
void buildAndEmit('message_created', params).catch((error: unknown) => {
|
||||
Logger.debug({error}, 'Message activity event could not be built');
|
||||
});
|
||||
}
|
||||
|
||||
export function emitMessageUpdated(params: MessageCreatedActivity): void {
|
||||
void buildAndEmit('message_updated', params).catch((error: unknown) => {
|
||||
Logger.debug({error}, 'Message activity event could not be built');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {type ChannelID, createGuildID, createUserID, type MessageID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {MessageUpdateRequest} from '@app/api/channel/MessageTypes';
|
||||
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
|
||||
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
|
||||
import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation';
|
||||
import {emitMessageUpdated} from '@app/api/channel/services/message/MessageActivity';
|
||||
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
|
||||
import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService';
|
||||
import type {MessageEmbedAttachmentResolver} from '@app/api/channel/services/message/MessageEmbedAttachmentResolver';
|
||||
@@ -19,6 +20,8 @@ import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {assertMayStartConversation, oneToOneDmRecipient} from '@app/api/user/NewConversationLimit';
|
||||
import {isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
|
||||
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
@@ -151,6 +154,16 @@ export class MessageEditService {
|
||||
await this.deps.crosspostPropagation.propagateEdit(editedMessage);
|
||||
return {message: editedMessage, authChannel};
|
||||
}
|
||||
const dmRecipientId = oneToOneDmRecipient(channel, userId);
|
||||
if (user && dmRecipientId !== null) {
|
||||
await assertMayStartConversation({
|
||||
user,
|
||||
targetId: dmRecipientId,
|
||||
users: this.deps.userRepository,
|
||||
messages: this.deps.channelRepository.messages,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
const isBugHunterBot = !!user?.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
|
||||
const updateResult = await this.deps.messageWriteLock.withFreshMessage(channelId, messageId, async (fresh) => {
|
||||
if (!fresh) throw new UnknownMessageError();
|
||||
@@ -188,6 +201,19 @@ export class MessageEditService {
|
||||
}
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
await this.deps.crosspostPropagation.propagateEdit(updatedMessage);
|
||||
if (user && ((data.content !== undefined && data.content !== message.content) || hasNewAttachments)) {
|
||||
emitMessageUpdated({
|
||||
user,
|
||||
message: updatedMessage,
|
||||
channel,
|
||||
guildId: guild?.id ? createGuildID(BigInt(guild.id)) : null,
|
||||
guildOwnerId: guild?.owner_id ? createUserID(BigInt(guild.owner_id)) : null,
|
||||
dmRecipientId,
|
||||
channelHadMessages: true,
|
||||
delivered: !(dmRecipientId !== null && isDirectDeliverySuppressed(user)),
|
||||
userRepository: this.deps.userRepository,
|
||||
});
|
||||
}
|
||||
void updateResult.enqueueDeferredEmbeds().catch((error) => {
|
||||
Logger.warn({error, messageId: messageId.toString()}, 'Failed to enqueue deferred embed extraction after edit');
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {MessageSnapshot} from '@app/api/models/MessageSnapshot';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {Webhook} from '@app/api/models/Webhook';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
|
||||
import {isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
|
||||
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
|
||||
import {
|
||||
@@ -855,6 +856,16 @@ export class MessageSendService {
|
||||
}
|
||||
}
|
||||
this.ensureForwardGuildMatches({data, referencedChannelGuildId});
|
||||
const dmRecipientId = this.getOneToOneDmRecipientId(channel, user.id);
|
||||
if (dmRecipientId !== null) {
|
||||
await assertMayStartConversation({
|
||||
user,
|
||||
targetId: dmRecipientId,
|
||||
users: this.deps.userRepository,
|
||||
messages: this.deps.channelRepository.messages,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
await this.ensureAttachmentsExist({
|
||||
attachments: data.attachments,
|
||||
user,
|
||||
@@ -928,7 +939,6 @@ export class MessageSendService {
|
||||
});
|
||||
}
|
||||
}
|
||||
const dmRecipientId = this.getOneToOneDmRecipientId(channel, user.id);
|
||||
const suppressDmRecipientDelivery = dmRecipientId !== null && isDirectDeliverySuppressed(user);
|
||||
const channelHadMessages = channel.lastMessageId !== null;
|
||||
const {message, enqueueDeferredEmbeds} = await this.deps.persistenceService.createMessage({
|
||||
|
||||
@@ -4,9 +4,9 @@ export type Id = string;
|
||||
export type Flags64 = string;
|
||||
export type Channel = "stable" | "canary" | "worker" | "internal" | "import" | "other";
|
||||
export type Meta = { ip: string | null, country: string | null, ua: string | null, locale: string | null, channel: Channel, request_id: string | null, };
|
||||
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "friend_request" | "http_errors" | "user_blocked";
|
||||
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked });
|
||||
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked };
|
||||
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked";
|
||||
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked });
|
||||
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked };
|
||||
export type Registration = { user_id: Id, method: RegMethod, email: string | null, username: string, username_user_chosen: boolean, global_name: string | null, locale: string | null, timezone: string | null, invite_code: string | null, suspicious_flags: number, flags: Flags64, };
|
||||
export type RegMethod = "password" | "unclaimed" | "oauth" | "other";
|
||||
export type EmailChanged = { user_id: Id, new_email: string, was_unclaimed: boolean, has_ever_purchased: boolean, suspicious_flags: number, suspicious_flags_before: number | null, };
|
||||
@@ -26,14 +26,16 @@ export type SessionStarted = { user_id: Id, is_bot: boolean, has_verified_phone:
|
||||
export type GuildJoined = { user_id: Id, guild_id: Id, member_count: number, discoverable: boolean, invite_code: string | null, inviter_id: Id | null, join_source: JoinSource, };
|
||||
export type JoinSource = "creator" | "invite" | "vanity" | "bot_invite" | "admin_force_add" | "discovery" | "other";
|
||||
export type DmOpened = { user_id: Id, recipient_id: Id, channel_id: Id, recipient_is_friend: boolean, delivered: boolean, };
|
||||
export type MessageCreated = { user_id: Id, message_id: Id, channel_id: Id, channel_type: ChannelType, guild_id: Id | null, dm_recipient_id: Id | null, recipient_is_friend: boolean, channel_prior_messages: boolean, is_bot: boolean, content: string, attachment_count: number, attachment_names: Array<string>, link_domains: Array<string>, invite_codes: Array<string>, invite_guild_ids: Array<Id | null>, invite_guild_owner_ids: Array<Id | null>, mention_user_ids: Array<Id>, mentions_recipient: boolean, mention_everyone: boolean, author_owns_guild: boolean, guild_member_count: number | null, delivered: boolean, };
|
||||
export type MessageCreated = { user_id: Id, message_id: Id, channel_id: Id, channel_type: ChannelType, guild_id: Id | null, dm_recipient_id: Id | null, recipient_is_friend: boolean, channel_prior_messages: boolean, is_bot: boolean, content: string, attachment_count: number, attachment_names: Array<string>, attachments: Array<AttachmentMeta>, link_domains: Array<string>, invite_codes: Array<string>, invite_guild_ids: Array<Id | null>, invite_guild_owner_ids: Array<Id | null>, mention_user_ids: Array<Id>, mentions_recipient: boolean, mention_everyone: boolean, author_owns_guild: boolean, guild_member_count: number | null, delivered: boolean, };
|
||||
export type AttachmentMeta = { size: number, content_type: string | null, hash: string | null, };
|
||||
export type MessageUpdated = MessageCreated;
|
||||
export type ChannelType = "dm" | "group_dm" | "guild";
|
||||
export type FriendRequest = { user_id: Id, target_id: Id, delivered: boolean, };
|
||||
export type UserBlocked = { blocker_id: Id, blocked_id: Id, };
|
||||
export type HttpErrors = { ip: string, window_ms: number, s401: number, s403: number, s404: number, s429: number, other_4xx: number, auth_failures: number, token_hashes: Array<string>, };
|
||||
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_suspicious_flags", user_id: Id, set: number, clear: number, if_current: number | null, } | { "type": "set_spammer", user_id: Id, on: boolean, } | { "type": "phone_verified", user_id: Id, method: PhoneMethod, clear_suspicious: number, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "review", user_id: Id, });
|
||||
export type Action = { "type": "set_suspicious_flags", user_id: Id, set: number, clear: number, if_current: number | null, } | { "type": "set_spammer", user_id: Id, on: boolean, } | { "type": "phone_verified", user_id: Id, method: PhoneMethod, clear_suspicious: number, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "review", user_id: Id, };
|
||||
export type ActionOutcome = { action_id: string, action_type: string, status: OutcomeStatus, detail: string | null, observed: Observed | null, };
|
||||
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_suspicious_flags", user_id: Id, set: number, clear: number, if_current: number | null, } | { "type": "phone_verified", user_id: Id, method: PhoneMethod, clear_suspicious: number, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, });
|
||||
export type Action = { "type": "set_suspicious_flags", user_id: Id, set: number, clear: number, if_current: number | null, } | { "type": "phone_verified", user_id: Id, method: PhoneMethod, clear_suspicious: number, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, };
|
||||
export type ActionOutcome = { action_id: string, action_type: string, status: OutcomeStatus, detail: string | null, observed: Observed | null, user_id?: Id, };
|
||||
export type OutcomeStatus = "applied" | "noop" | "conflict" | "expired" | "ineligible" | "exempt" | "unsupported" | "failed";
|
||||
export type Observed = { flags: Flags64, suspicious_flags: number, has_verified_phone: boolean, deleted: boolean, };
|
||||
export type StartReq = { v: number, user_id: Id, user_flags: Flags64, has_verified_phone: boolean, phone: string, requested_channel: PhoneChannel | null, client_ip: string, captcha_passed: boolean, };
|
||||
@@ -89,6 +91,7 @@ export const EVENT_KINDS: ReadonlyArray<EventKind> = [
|
||||
'guild_joined',
|
||||
'dm_opened',
|
||||
'message_created',
|
||||
'message_updated',
|
||||
'friend_request',
|
||||
'http_errors',
|
||||
'user_blocked',
|
||||
@@ -108,6 +111,7 @@ export const EVENT_MAJOR: Record<EventKind, number> = {
|
||||
guild_joined: 1,
|
||||
dm_opened: 1,
|
||||
message_created: 1,
|
||||
message_updated: 1,
|
||||
friend_request: 1,
|
||||
http_errors: 1,
|
||||
user_blocked: 1,
|
||||
@@ -127,6 +131,7 @@ export const EVENT_TTL: Record<EventKind, string> = {
|
||||
guild_joined: '3024000',
|
||||
dm_opened: '259200',
|
||||
message_created: '259200',
|
||||
message_updated: '259200',
|
||||
friend_request: '259200',
|
||||
http_errors: '3600',
|
||||
user_blocked: '259200',
|
||||
@@ -146,6 +151,7 @@ export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
|
||||
guild_joined: 'signal',
|
||||
dm_opened: 'signal',
|
||||
message_created: 'signal',
|
||||
message_updated: 'signal',
|
||||
friend_request: 'signal',
|
||||
http_errors: 'signal',
|
||||
user_blocked: 'signal',
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createMessageID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {IMessageRepository} from '@app/api/channel/repositories/IMessageRepository';
|
||||
import {getCacheService, getChannelRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {RelationshipTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {NewConversationsLimitedError} from '@fluxer/errors/src/domains/user/NewConversationsLimitedError';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
export const NEW_CONVERSATION_LIMIT_MAX_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const RECENT_PAGE = 100;
|
||||
const OPENING_PAGE = 50;
|
||||
|
||||
export interface NewConversationLimit {
|
||||
until_ms: number;
|
||||
applied_at_ms: number;
|
||||
}
|
||||
|
||||
type LimitCache = Pick<ICacheService, 'get' | 'set' | 'delete'>;
|
||||
|
||||
export interface NewConversationLimitDeps {
|
||||
cache?: LimitCache;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function cacheOf(deps: NewConversationLimitDeps): LimitCache {
|
||||
return deps.cache ?? getCacheService();
|
||||
}
|
||||
|
||||
function nowOf(deps: NewConversationLimitDeps): number {
|
||||
return deps.now?.() ?? Date.now();
|
||||
}
|
||||
|
||||
function limitKey(userId: UserID | bigint | string): string {
|
||||
return `user:new_conversation_limit:${userId.toString()}`;
|
||||
}
|
||||
|
||||
export function isNewConversationLimitExempt(user: Pick<User, 'isBot' | 'isSystem' | 'flags'>): boolean {
|
||||
return (
|
||||
user.isBot ||
|
||||
user.isSystem ||
|
||||
(user.flags & UserFlags.STAFF) !== 0n ||
|
||||
(user.flags & UserFlags.NOT_SUSPICIOUS) !== 0n
|
||||
);
|
||||
}
|
||||
|
||||
export async function getNewConversationLimit(
|
||||
userId: UserID,
|
||||
deps: NewConversationLimitDeps = {},
|
||||
): Promise<NewConversationLimit | null> {
|
||||
const stored = await cacheOf(deps).get<NewConversationLimit>(limitKey(userId));
|
||||
if (!stored || typeof stored.until_ms !== 'number' || stored.until_ms <= nowOf(deps)) return null;
|
||||
return stored;
|
||||
}
|
||||
|
||||
export async function setNewConversationLimit(
|
||||
userId: UserID,
|
||||
untilMs: number,
|
||||
deps: NewConversationLimitDeps = {},
|
||||
): Promise<boolean> {
|
||||
const now = nowOf(deps);
|
||||
const until = Math.min(untilMs, now + NEW_CONVERSATION_LIMIT_MAX_MS);
|
||||
if (until <= now) return false;
|
||||
const current = await getNewConversationLimit(userId, deps);
|
||||
if (current && current.until_ms >= until) return false;
|
||||
const value: NewConversationLimit = {until_ms: until, applied_at_ms: now};
|
||||
await cacheOf(deps).set(limitKey(userId), value, Math.ceil((until - now) / 1000));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearNewConversationLimit(userId: UserID, deps: NewConversationLimitDeps = {}): Promise<boolean> {
|
||||
const current = await getNewConversationLimit(userId, deps);
|
||||
await cacheOf(deps).delete(limitKey(userId));
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
export function oneToOneDmRecipient(channel: Pick<Channel, 'guildId' | 'type' | 'recipientIds'>, senderId: UserID) {
|
||||
if (channel.guildId || channel.type !== ChannelTypes.DM) return null;
|
||||
const others = Array.from(channel.recipientIds).filter((id) => id !== senderId);
|
||||
return others.length === 1 ? others[0]! : null;
|
||||
}
|
||||
|
||||
type ConversationUsers = Pick<IUserRepository, 'getRelationship' | 'findExistingDmState' | 'findUnique'>;
|
||||
type ConversationMessages = Pick<IMessageRepository, 'listMessages'>;
|
||||
|
||||
export interface NewConversationCheck {
|
||||
user: Pick<User, 'id' | 'isBot' | 'isSystem' | 'flags'>;
|
||||
targetId: UserID;
|
||||
users: ConversationUsers;
|
||||
messages?: ConversationMessages;
|
||||
channel?: Pick<Channel, 'id' | 'lastMessageId'> | null;
|
||||
}
|
||||
|
||||
async function recipientHasWritten(
|
||||
messages: ConversationMessages,
|
||||
channel: Pick<Channel, 'id' | 'lastMessageId'>,
|
||||
recipientId: UserID,
|
||||
): Promise<boolean> {
|
||||
if (channel.lastMessageId == null) return false;
|
||||
const recent = await messages.listMessages(channel.id, undefined, RECENT_PAGE);
|
||||
if (recent.some((message) => message.authorId === recipientId)) return true;
|
||||
if (recent.length < RECENT_PAGE) return false;
|
||||
const opening = await messages.listMessages(channel.id, undefined, OPENING_PAGE, createMessageID(BigInt(channel.id)));
|
||||
return opening.some((message) => message.authorId === recipientId);
|
||||
}
|
||||
|
||||
export async function assertMayStartConversation(
|
||||
{user, targetId, users, messages, channel}: NewConversationCheck,
|
||||
deps: NewConversationLimitDeps = {},
|
||||
): Promise<void> {
|
||||
if (isNewConversationLimitExempt(user)) return;
|
||||
const limit = await getNewConversationLimit(user.id, deps);
|
||||
if (!limit) return;
|
||||
const [friendship, target] = await Promise.all([
|
||||
users.getRelationship(user.id, targetId, RelationshipTypes.FRIEND),
|
||||
users.findUnique(targetId),
|
||||
]);
|
||||
if (friendship || !target || target.isBot || target.isSystem) return;
|
||||
const existing = channel === undefined ? await users.findExistingDmState(user.id, targetId) : channel;
|
||||
if (existing && (await recipientHasWritten(messages ?? getChannelRepository().messages, existing, targetId))) return;
|
||||
throw new NewConversationsLimitedError();
|
||||
}
|
||||
@@ -15,8 +15,13 @@ import type {
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {
|
||||
clearNewConversationLimit,
|
||||
isNewConversationLimitExempt,
|
||||
setNewConversationLimit,
|
||||
} from '@app/api/user/NewConversationLimit';
|
||||
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
|
||||
import {mapUserToPartialResponse, mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {getSameIpDecisionKey, isPublicIpAddress, parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
@@ -25,7 +30,6 @@ export type ActionOf<T extends ActionEnvelope['type']> = Extract<ActionEnvelope,
|
||||
|
||||
export interface AccountUpdateDispatch {
|
||||
userUpdated(user: User): Promise<void>;
|
||||
memberProfilesUpdated(user: User): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AccountStateDeps {
|
||||
@@ -33,40 +37,25 @@ export interface AccountStateDeps {
|
||||
dispatch: AccountUpdateDispatch;
|
||||
contactChangeLog: Pick<UserContactChangeLogService, 'recordDiff'>;
|
||||
ipBans: Pick<AdminRepository, 'isIpBanned' | 'banIpTemp'>;
|
||||
cache: Pick<ICacheService, 'publish'>;
|
||||
cache: Pick<ICacheService, 'publish' | 'get' | 'set' | 'delete'>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const SUSPICIOUS_FLAGS_WRITE_ATTEMPTS = 3;
|
||||
const MIN_TEMP_BAN_SECONDS = 60;
|
||||
|
||||
function gatewayDispatch(
|
||||
gateway: Pick<IGatewayService, 'dispatchPresence' | 'dispatchGuild' | 'getGuildMember'>,
|
||||
users: Pick<IUserRepository, 'getUserGuildIds'>,
|
||||
): AccountUpdateDispatch {
|
||||
function gatewayDispatch(gateway: Pick<IGatewayService, 'dispatchPresence'>): AccountUpdateDispatch {
|
||||
return {
|
||||
async userUpdated(user) {
|
||||
await gateway.dispatchPresence({userId: user.id, event: 'USER_UPDATE', data: mapUserToPrivateResponse(user)});
|
||||
},
|
||||
async memberProfilesUpdated(user) {
|
||||
const userPartial = mapUserToPartialResponse(user);
|
||||
for (const guildId of await users.getUserGuildIds(user.id)) {
|
||||
const member = await gateway.getGuildMember({guildId, userId: user.id});
|
||||
if (!member.success || !member.memberData) continue;
|
||||
await gateway.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: {...member.memberData, user: userPartial},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function accountStateDepsFromContext(ctx: ApiContext, ipBans: AccountStateDeps['ipBans']): AccountStateDeps {
|
||||
return {
|
||||
users: ctx.services.users,
|
||||
dispatch: gatewayDispatch(ctx.services.gateway, ctx.services.users),
|
||||
dispatch: gatewayDispatch(ctx.services.gateway),
|
||||
contactChangeLog: ctx.services.contactChangeLog,
|
||||
ipBans,
|
||||
cache: ctx.services.cache,
|
||||
@@ -83,18 +72,20 @@ export function observedOf(user: User): Observed {
|
||||
}
|
||||
|
||||
export function outcomeOf(
|
||||
env: Pick<ActionEnvelope, 'id'> & {type: string},
|
||||
env: Pick<ActionEnvelope, 'id'> & {type: string; user_id?: string},
|
||||
status: OutcomeStatus,
|
||||
user: User | null = null,
|
||||
detail: string | null = null,
|
||||
): ActionOutcome {
|
||||
return {
|
||||
const outcome: ActionOutcome = {
|
||||
action_id: env.id,
|
||||
action_type: env.type,
|
||||
status,
|
||||
detail,
|
||||
observed: user ? observedOf(user) : null,
|
||||
};
|
||||
if (env.user_id) outcome.user_id = env.user_id;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function isIneligible(user: User): boolean {
|
||||
@@ -126,21 +117,6 @@ export async function applySuspiciousFlags(
|
||||
});
|
||||
}
|
||||
|
||||
export async function applySpammer(deps: AccountStateDeps, env: ActionOf<'set_spammer'>): Promise<ActionOutcome> {
|
||||
return withAccountChangeSource('action', async () => {
|
||||
const user = await deps.users.findUnique(createUserID(BigInt(env.user_id)));
|
||||
if (!user) return outcomeOf(env, 'ineligible');
|
||||
if (isIneligible(user)) return outcomeOf(env, 'ineligible', user);
|
||||
const has = (user.flags & UserFlags.SPAMMER) !== 0n;
|
||||
if (has === env.on) return outcomeOf(env, 'noop', user);
|
||||
const flags = env.on ? user.flags | UserFlags.SPAMMER : user.flags & ~UserFlags.SPAMMER;
|
||||
const updated = await deps.users.patchUpsert(user.id, {flags}, user.toRow());
|
||||
await deps.dispatch.userUpdated(updated);
|
||||
await deps.dispatch.memberProfilesUpdated(updated);
|
||||
return outcomeOf(env, 'applied', updated);
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyPhoneVerified(
|
||||
deps: AccountStateDeps,
|
||||
env: ActionOf<'phone_verified'>,
|
||||
@@ -195,3 +171,20 @@ export async function applyTempBanIp(deps: AccountStateDeps, env: ActionOf<'temp
|
||||
await deps.cache.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
|
||||
return outcomeOf(env, 'applied');
|
||||
}
|
||||
|
||||
export async function applyLimitNewConversations(
|
||||
deps: AccountStateDeps,
|
||||
env: ActionOf<'limit_new_conversations'>,
|
||||
): Promise<ActionOutcome> {
|
||||
const user = await deps.users.findUnique(createUserID(BigInt(env.user_id)));
|
||||
if (!user) return outcomeOf(env, 'ineligible');
|
||||
if (isIneligible(user)) return outcomeOf(env, 'ineligible', user);
|
||||
const store = {cache: deps.cache, now: deps.now};
|
||||
if (!env.on) {
|
||||
const lifted = await clearNewConversationLimit(user.id, store);
|
||||
return outcomeOf(env, lifted ? 'applied' : 'noop', user);
|
||||
}
|
||||
if (isNewConversationLimitExempt(user)) return outcomeOf(env, 'exempt', user);
|
||||
const applied = await setNewConversationLimit(user.id, env.until_ms, store);
|
||||
return outcomeOf(env, applied ? 'applied' : 'noop', user);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
|
||||
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
|
||||
import type {IUserChannelRepository} from '@app/api/user/repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '@app/api/user/repositories/IUserRelationshipRepository';
|
||||
@@ -164,6 +165,12 @@ export class UserChannelService {
|
||||
if (userId === recipientId) {
|
||||
throw InputValidationError.fromCode('recipient_id', ValidationErrorCodes.CANNOT_DM_YOURSELF);
|
||||
}
|
||||
await assertMayStartConversation({
|
||||
user: callingUser,
|
||||
targetId: recipientId,
|
||||
users: this.userRepository,
|
||||
messages: this.channelRepository,
|
||||
});
|
||||
const suppressed = isDirectDeliverySuppressed(callingUser);
|
||||
const channel = await this.openOneToOneDMChannel({userId, recipientId, suppressed, userCacheService, requestCache});
|
||||
if (!callingUser.isSystem) {
|
||||
|
||||
@@ -13,7 +13,9 @@ import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {Relationship} from '@app/api/models/Relationship';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
|
||||
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
|
||||
import type {IUserChannelRepository} from '@app/api/user/repositories/IUserChannelRepository';
|
||||
import type {IUserRelationshipRepository} from '@app/api/user/repositories/IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from '@app/api/user/repositories/IUserSettingsRepository';
|
||||
import {getCachedUserPartialResponse} from '@app/api/user/UserCacheHelpers';
|
||||
@@ -44,6 +46,7 @@ import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
|
||||
interface UserRelationshipRepository
|
||||
extends IUserAccountRepository,
|
||||
IUserChannelRepository,
|
||||
IUserRelationshipRepository,
|
||||
IUserSettingsRepository {}
|
||||
|
||||
@@ -184,6 +187,7 @@ export class UserRelationshipService {
|
||||
}
|
||||
}
|
||||
const targetUser = await this.validateFriendRequest({userId, targetId});
|
||||
await assertMayStartConversation({user: requesterUser, targetId, users: this.userRepository});
|
||||
await this.validateRelationshipCounts({userId, targetId});
|
||||
const requestRelationship = await this.createFriendRequest({userId, targetId, userCacheService, requestCache});
|
||||
emitFriendRequest(userId, targetId, true);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {updateUserSettings} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createDMChannel,
|
||||
createFriendship,
|
||||
createGuild,
|
||||
ensureSessionStarted,
|
||||
sendMessage,
|
||||
} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {clearNewConversationLimit, setNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
interface ErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
describe('New conversation limit', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function members(count: number): Promise<Array<TestAccount>> {
|
||||
const accounts: Array<TestAccount> = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
await updateUserSettings(harness, account.token, {default_guilds_restricted: false});
|
||||
accounts.push(account);
|
||||
}
|
||||
const guild = await createGuild(harness, accounts[0]!.token, 'New conversation limit');
|
||||
const invite = await createChannelInvite(harness, accounts[0]!.token, guild.system_channel_id!);
|
||||
for (const account of accounts.slice(1)) {
|
||||
await acceptInvite(harness, account.token, invite.code);
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
async function limit(account: TestAccount, hours = 24): Promise<void> {
|
||||
await setNewConversationLimit(createUserID(BigInt(account.userId)), Date.now() + hours * HOUR_MS);
|
||||
}
|
||||
|
||||
async function refusedDm(from: TestAccount, to: TestAccount): Promise<ErrorBody> {
|
||||
const {json} = await createBuilder<ErrorBody>(harness, from.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: to.userId})
|
||||
.expect(403)
|
||||
.executeWithResponse();
|
||||
return json;
|
||||
}
|
||||
|
||||
test('a limited account cannot open a DM with someone it has no conversation with', async () => {
|
||||
const [limited, other] = await members(2);
|
||||
await limit(limited!);
|
||||
const error = await refusedDm(limited!, other!);
|
||||
expect(error.code).toBe(APIErrorCodes.NEW_CONVERSATIONS_LIMITED);
|
||||
expect(error.message).toBe("You can't start new conversations right now. Please try again later.");
|
||||
});
|
||||
|
||||
test('a limited account cannot send the first message into an empty DM', async () => {
|
||||
const [limited, other] = await members(2);
|
||||
const channel = await createDMChannel(harness, limited!.token, other!.userId);
|
||||
await limit(limited!);
|
||||
const {json} = await createBuilder<ErrorBody>(harness, limited!.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'hello'})
|
||||
.expect(403)
|
||||
.executeWithResponse();
|
||||
expect(json.code).toBe(APIErrorCodes.NEW_CONVERSATIONS_LIMITED);
|
||||
});
|
||||
|
||||
test('friends, replies and existing conversations keep working', async () => {
|
||||
const [limited, friend, opener] = await members(3);
|
||||
await createFriendship(harness, limited!, friend!);
|
||||
const theirs = await createDMChannel(harness, opener!.token, limited!.userId);
|
||||
await sendMessage(harness, opener!.token, theirs.id, 'hi there');
|
||||
await limit(limited!);
|
||||
const withFriend = await createDMChannel(harness, limited!.token, friend!.userId);
|
||||
await sendMessage(harness, limited!.token, withFriend.id, 'hi friend');
|
||||
await sendMessage(harness, limited!.token, theirs.id, 'hello back');
|
||||
const reopened = await createDMChannel(harness, limited!.token, opener!.userId);
|
||||
expect(reopened.id).toBe(theirs.id);
|
||||
});
|
||||
|
||||
test('outgoing friend requests are refused while accepting incoming requests still works', async () => {
|
||||
const [limited, other, requester] = await members(3);
|
||||
await limit(limited!);
|
||||
const {json} = await createBuilder<ErrorBody>(harness, limited!.token)
|
||||
.post(`/users/@me/relationships/${other!.userId}`)
|
||||
.body({})
|
||||
.expect(403)
|
||||
.executeWithResponse();
|
||||
expect(json.code).toBe(APIErrorCodes.NEW_CONVERSATIONS_LIMITED);
|
||||
await createFriendship(harness, requester!, limited!);
|
||||
});
|
||||
|
||||
test('a cleared or expired limit lets the account start conversations again', async () => {
|
||||
const [limited, first, second] = await members(3);
|
||||
await limit(limited!);
|
||||
await refusedDm(limited!, first!);
|
||||
await clearNewConversationLimit(createUserID(BigInt(limited!.userId)));
|
||||
await createDMChannel(harness, limited!.token, first!.userId);
|
||||
await setNewConversationLimit(createUserID(BigInt(limited!.userId)), Date.now() - 1);
|
||||
await createDMChannel(harness, limited!.token, second!.userId);
|
||||
});
|
||||
|
||||
test('staff and trusted accounts are never limited', async () => {
|
||||
for (const flag of [UserFlags.STAFF, UserFlags.NOT_SUSPICIOUS]) {
|
||||
const [account, other] = await members(2);
|
||||
const me = await createBuilder<{flags?: string | number}>(harness, account!.token).get('/users/@me').execute();
|
||||
await createBuilder<unknown>(harness, account!.token)
|
||||
.patch(`/test/users/${account!.userId}/flags`)
|
||||
.body({flags: (BigInt(me.flags ?? 0) | flag).toString()})
|
||||
.execute();
|
||||
await limit(account!);
|
||||
await createDMChannel(harness, account!.token, other!.userId);
|
||||
}
|
||||
});
|
||||
|
||||
test('actions in a direct message the other account has not written in are refused', async () => {
|
||||
const [limited, other] = await members(2);
|
||||
const channel = await createDMChannel(harness, limited!.token, other!.userId);
|
||||
const own = await sendMessage(harness, limited!.token, channel.id, 'hey');
|
||||
await limit(limited!);
|
||||
const actions: Array<[string, () => TestRequestBuilder<ErrorBody>]> = [
|
||||
[
|
||||
'send',
|
||||
() =>
|
||||
createBuilder<ErrorBody>(harness, limited!.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'hello'}),
|
||||
],
|
||||
[
|
||||
'edit',
|
||||
() =>
|
||||
createBuilder<ErrorBody>(harness, limited!.token)
|
||||
.patch(`/channels/${channel.id}/messages/${own.id}`)
|
||||
.body({content: 'hello there'}),
|
||||
],
|
||||
['pin', () => createBuilder<ErrorBody>(harness, limited!.token).put(`/channels/${channel.id}/pins/${own.id}`)],
|
||||
[
|
||||
'react',
|
||||
() =>
|
||||
createBuilder<ErrorBody>(harness, limited!.token).put(
|
||||
`/channels/${channel.id}/messages/${own.id}/reactions/%F0%9F%91%8D/@me`,
|
||||
),
|
||||
],
|
||||
[
|
||||
'ring',
|
||||
() =>
|
||||
createBuilder<ErrorBody>(harness, limited!.token)
|
||||
.post(`/channels/${channel.id}/call/ring`)
|
||||
.body({recipients: [other!.userId]}),
|
||||
],
|
||||
];
|
||||
for (const [name, request] of actions) {
|
||||
const {json} = await request().expect(403).executeWithResponse();
|
||||
expect(json.code, name).toBe(APIErrorCodes.NEW_CONVERSATIONS_LIMITED);
|
||||
}
|
||||
});
|
||||
|
||||
test('bots can still be messaged while limited', async () => {
|
||||
const [limited, bot] = await members(2);
|
||||
await createBuilder<unknown>(harness, bot!.token)
|
||||
.post(`/test/users/${bot!.userId}/set-bot-flag`)
|
||||
.body({is_bot: true})
|
||||
.execute();
|
||||
await limit(limited!);
|
||||
const channel = await createDMChannel(harness, limited!.token, bot!.userId);
|
||||
await sendMessage(harness, limited!.token, channel.id, 'help');
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {
|
||||
type AccountStateDeps,
|
||||
applyLimitNewConversations,
|
||||
applyPhoneVerified,
|
||||
applySpammer,
|
||||
applySuspiciousFlags,
|
||||
applyTempBanIp,
|
||||
outcomeOf,
|
||||
@@ -67,12 +67,12 @@ export function applyAction(deps: AccountActionDeps, env: ActionEnvelope): Promi
|
||||
switch (env.type) {
|
||||
case 'set_suspicious_flags':
|
||||
return applySuspiciousFlags(deps.state, env);
|
||||
case 'set_spammer':
|
||||
return applySpammer(deps.state, env);
|
||||
case 'phone_verified':
|
||||
return applyPhoneVerified(deps.state, env);
|
||||
case 'temp_ban_ip':
|
||||
return applyTempBanIp(deps.state, env);
|
||||
case 'limit_new_conversations':
|
||||
return applyLimitNewConversations(deps.state, env);
|
||||
default:
|
||||
return Promise.resolve(outcomeOf(env as ActionEnvelope, 'unsupported'));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
effectsConsumer,
|
||||
} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {User} from '@app/api/models/User';
|
||||
import {NEW_CONVERSATION_LIMIT_MAX_MS} from '@app/api/user/NewConversationLimit';
|
||||
import type {AccountStateDeps} from '@app/api/user/services/AccountStateApplier';
|
||||
import {
|
||||
type AccountActionDeps,
|
||||
@@ -27,6 +28,17 @@ const USER_ID = '1174109840998400001';
|
||||
const NOW = 1_759_000_000_000;
|
||||
const NATS_URL = process.env.FLUXER_TEST_ACTIVITY_NATS_URL;
|
||||
|
||||
function flagEnvelope(overrides: Partial<Extract<ActionEnvelope, {type: 'set_suspicious_flags'}>> = {}) {
|
||||
return envelope<'set_suspicious_flags'>({
|
||||
type: 'set_suspicious_flags',
|
||||
user_id: USER_ID,
|
||||
set: 1,
|
||||
clear: 0,
|
||||
if_current: null,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function envelope<T extends ActionEnvelope['type']>(
|
||||
body: Omit<Extract<ActionEnvelope, {type: T}>, 'v' | 'id' | 'key' | 'issued_at_ms' | 'expires_at_ms'> &
|
||||
Partial<ActionEnvelope>,
|
||||
@@ -80,6 +92,7 @@ class FakeUsers {
|
||||
|
||||
interface Harness {
|
||||
users: FakeUsers;
|
||||
cached: Map<string, unknown>;
|
||||
presence: Array<unknown>;
|
||||
contactLogs: Array<unknown>;
|
||||
bans: Array<{ip: string; ttl: number}>;
|
||||
@@ -89,14 +102,21 @@ interface Harness {
|
||||
|
||||
function harness(): Harness {
|
||||
const users = new FakeUsers();
|
||||
const h: Harness = {users, presence: [], contactLogs: [], bans: [], refreshes: 0, deps: null as never};
|
||||
const h: Harness = {
|
||||
users,
|
||||
cached: new Map(),
|
||||
presence: [],
|
||||
contactLogs: [],
|
||||
bans: [],
|
||||
refreshes: 0,
|
||||
deps: null as never,
|
||||
};
|
||||
const state: AccountStateDeps = {
|
||||
users: users as unknown as AccountStateDeps['users'],
|
||||
dispatch: {
|
||||
userUpdated: async (user) => {
|
||||
h.presence.push(user.id);
|
||||
},
|
||||
memberProfilesUpdated: async () => {},
|
||||
},
|
||||
contactChangeLog: {
|
||||
recordDiff: async (params) => {
|
||||
@@ -113,6 +133,13 @@ function harness(): Harness {
|
||||
publish: async () => {
|
||||
h.refreshes++;
|
||||
},
|
||||
get: async (key: string) => h.cached.get(key) ?? null,
|
||||
set: async (key: string, value: unknown) => {
|
||||
h.cached.set(key, value);
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
h.cached.delete(key);
|
||||
},
|
||||
} as unknown as AccountStateDeps['cache'],
|
||||
now: () => NOW,
|
||||
};
|
||||
@@ -144,6 +171,7 @@ describe('account action apply', () => {
|
||||
status: 'applied',
|
||||
detail: null,
|
||||
observed: {flags: '0', suspicious_flags: 1, has_verified_phone: false, deleted: false},
|
||||
user_id: USER_ID,
|
||||
});
|
||||
expect(h.users.current().suspiciousActivityFlags).toBe(1);
|
||||
expect(h.presence).toHaveLength(1);
|
||||
@@ -197,35 +225,80 @@ describe('account action apply', () => {
|
||||
|
||||
it('treats missing, deleted and bot accounts as ineligible', async () => {
|
||||
h.users.rows.clear();
|
||||
const missing = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}),
|
||||
);
|
||||
const missing = await applyAction(h.deps, flagEnvelope());
|
||||
expect(missing).toMatchObject({status: 'ineligible', observed: null});
|
||||
h.users.put({bot: true});
|
||||
const bot = await applyAction(h.deps, envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}));
|
||||
const bot = await applyAction(h.deps, flagEnvelope());
|
||||
expect(bot.status).toBe('ineligible');
|
||||
h.users.put({flags: UserFlags.DELETED});
|
||||
const deleted = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}),
|
||||
);
|
||||
const deleted = await applyAction(h.deps, flagEnvelope());
|
||||
expect(deleted).toMatchObject({status: 'ineligible', observed: {deleted: true}});
|
||||
});
|
||||
|
||||
it('sets and clears the SPAMMER flag idempotently', async () => {
|
||||
const on = envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true});
|
||||
expect((await applyAction(h.deps, on)).status).toBe('applied');
|
||||
expect(h.users.current().flags & UserFlags.SPAMMER).toBe(UserFlags.SPAMMER);
|
||||
it('limits new conversations until the requested time and lifts the limit once', async () => {
|
||||
const on = envelope<'limit_new_conversations'>({
|
||||
type: 'limit_new_conversations',
|
||||
user_id: USER_ID,
|
||||
on: true,
|
||||
until_ms: NOW + 86_400_000,
|
||||
});
|
||||
expect(await applyAction(h.deps, on)).toMatchObject({status: 'applied', user_id: USER_ID});
|
||||
expect([...h.cached.values()]).toEqual([{until_ms: NOW + 86_400_000, applied_at_ms: NOW}]);
|
||||
expect((await applyAction(h.deps, on)).status).toBe('noop');
|
||||
const off = envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: false});
|
||||
const off = envelope<'limit_new_conversations'>({
|
||||
type: 'limit_new_conversations',
|
||||
user_id: USER_ID,
|
||||
on: false,
|
||||
until_ms: NOW,
|
||||
});
|
||||
expect((await applyAction(h.deps, off)).status).toBe('applied');
|
||||
expect(h.users.current().flags & UserFlags.SPAMMER).toBe(0n);
|
||||
expect(h.cached.size).toBe(0);
|
||||
expect((await applyAction(h.deps, off)).status).toBe('noop');
|
||||
});
|
||||
|
||||
it('caps a limit at the maximum duration and skips one that already ended', async () => {
|
||||
const far = envelope<'limit_new_conversations'>({
|
||||
type: 'limit_new_conversations',
|
||||
user_id: USER_ID,
|
||||
on: true,
|
||||
until_ms: NOW + 2 * NEW_CONVERSATION_LIMIT_MAX_MS,
|
||||
});
|
||||
expect((await applyAction(h.deps, far)).status).toBe('applied');
|
||||
expect([...h.cached.values()]).toMatchObject([{until_ms: NOW + NEW_CONVERSATION_LIMIT_MAX_MS}]);
|
||||
h.cached.clear();
|
||||
const past = envelope<'limit_new_conversations'>({
|
||||
type: 'limit_new_conversations',
|
||||
user_id: USER_ID,
|
||||
on: true,
|
||||
until_ms: NOW,
|
||||
});
|
||||
expect((await applyAction(h.deps, past)).status).toBe('noop');
|
||||
expect(h.cached.size).toBe(0);
|
||||
});
|
||||
|
||||
it('never limits staff, trusted, bot or deleted accounts', async () => {
|
||||
const on = envelope<'limit_new_conversations'>({
|
||||
type: 'limit_new_conversations',
|
||||
user_id: USER_ID,
|
||||
on: true,
|
||||
until_ms: NOW + 86_400_000,
|
||||
});
|
||||
for (const overrides of [{flags: UserFlags.STAFF}, {flags: UserFlags.NOT_SUSPICIOUS}] satisfies Array<
|
||||
Partial<UserRow>
|
||||
>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, on)).status).toBe('exempt');
|
||||
}
|
||||
for (const overrides of [{bot: true}, {flags: UserFlags.DELETED}] satisfies Array<Partial<UserRow>>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, on)).status).toBe('ineligible');
|
||||
}
|
||||
expect(h.cached.size).toBe(0);
|
||||
});
|
||||
|
||||
it('attaches a verified phone once and logs the contact change once', async () => {
|
||||
h.users.put({
|
||||
flags: UserFlags.SPAMMER,
|
||||
flags: UserFlags.HAS_SESSION_STARTED,
|
||||
suspicious_activity_flags:
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
|
||||
});
|
||||
@@ -239,7 +312,7 @@ describe('account action apply', () => {
|
||||
const user = h.users.current();
|
||||
expect(user.hasVerifiedPhone).toBe(true);
|
||||
expect(user.suspiciousActivityFlags).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
expect(user.flags & UserFlags.SPAMMER).toBe(UserFlags.SPAMMER);
|
||||
expect(user.flags).toBe(UserFlags.HAS_SESSION_STARTED);
|
||||
expect((await applyAction(h.deps, action)).status).toBe('noop');
|
||||
expect(h.contactLogs).toHaveLength(1);
|
||||
});
|
||||
@@ -304,22 +377,13 @@ describe('account action apply', () => {
|
||||
});
|
||||
|
||||
it('answers expired actions and unknown shapes without touching the account', async () => {
|
||||
const expired = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true, expires_at_ms: NOW}),
|
||||
);
|
||||
const expired = await applyAction(h.deps, flagEnvelope({expires_at_ms: NOW}));
|
||||
expect(expired.status).toBe('expired');
|
||||
const future = await applyAction(h.deps, {
|
||||
...envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}),
|
||||
v: 2,
|
||||
});
|
||||
const future = await applyAction(h.deps, {...flagEnvelope(), v: 2});
|
||||
expect(future.status).toBe('unsupported');
|
||||
const unknown = await applyAction(h.deps, {
|
||||
...envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}),
|
||||
type: 'future_type',
|
||||
} as unknown as ActionEnvelope);
|
||||
const unknown = await applyAction(h.deps, {...flagEnvelope(), type: 'future_type'} as unknown as ActionEnvelope);
|
||||
expect(unknown).toMatchObject({status: 'unsupported', action_type: 'future_type'});
|
||||
expect(h.users.current().flags).toBe(0n);
|
||||
expect(h.users.current().suspiciousActivityFlags).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,7 +420,7 @@ describe('account action messages', () => {
|
||||
h.deps.publishOutcome = async (_key, outcome) => {
|
||||
outcomes.push(outcome);
|
||||
};
|
||||
const good = fakeMsg(JSON.stringify(envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true})), 1);
|
||||
const good = fakeMsg(JSON.stringify(flagEnvelope()), 1);
|
||||
await handleActionMessage(h.deps, good.msg);
|
||||
expect(outcomes.map((outcome) => outcome.status)).toEqual(['applied']);
|
||||
expect(good.state.acked).toBe(1);
|
||||
@@ -376,7 +440,7 @@ describe('account action messages', () => {
|
||||
h.deps.publishOutcome = async (_key, outcome) => {
|
||||
outcomes.push(outcome);
|
||||
};
|
||||
const data = JSON.stringify(envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}));
|
||||
const data = JSON.stringify(flagEnvelope());
|
||||
const early = fakeMsg(data, 3);
|
||||
await handleActionMessage(h.deps, early.msg);
|
||||
expect(early.state.naks).toEqual([3000]);
|
||||
@@ -422,30 +486,12 @@ describe.skipIf(!NATS_URL)('account action consumer against JetStream', () => {
|
||||
h.deps.retryDelayMs = 200;
|
||||
const js = jetstream(nc);
|
||||
const expires = Date.now() + 60_000;
|
||||
await js.publish('act.07', JSON.stringify(flagEnvelope({id: 'a:07:1:0', expires_at_ms: expires})), {
|
||||
msgID: 'a:07:1:0',
|
||||
});
|
||||
await js.publish(
|
||||
'act.07',
|
||||
JSON.stringify(
|
||||
envelope<'set_spammer'>({
|
||||
type: 'set_spammer',
|
||||
id: 'a:07:1:0',
|
||||
user_id: USER_ID,
|
||||
on: true,
|
||||
expires_at_ms: expires,
|
||||
}),
|
||||
),
|
||||
{msgID: 'a:07:1:0'},
|
||||
);
|
||||
await js.publish(
|
||||
'act.07',
|
||||
JSON.stringify(
|
||||
envelope<'set_spammer'>({
|
||||
type: 'set_spammer',
|
||||
id: 'a:07:2:0',
|
||||
user_id: USER_ID,
|
||||
on: false,
|
||||
expires_at_ms: expires,
|
||||
}),
|
||||
),
|
||||
JSON.stringify(flagEnvelope({id: 'a:07:2:0', set: 0, clear: 1, expires_at_ms: expires})),
|
||||
{msgID: 'a:07:2:0'},
|
||||
);
|
||||
startAccountActionConsumer(h.deps);
|
||||
@@ -457,7 +503,7 @@ describe.skipIf(!NATS_URL)('account action consumer against JetStream', () => {
|
||||
['a:07:1:0', 'applied'],
|
||||
['a:07:2:0', 'applied'],
|
||||
]);
|
||||
expect(h.users.current().flags & UserFlags.SPAMMER).toBe(0n);
|
||||
expect(h.users.current().suspiciousActivityFlags).toBe(0);
|
||||
const info = await (await jetstreamManager(nc)).consumers.info(ACTIONS_STREAM, effectsConsumer(7));
|
||||
expect(info.num_ack_pending).toBe(0);
|
||||
expect(info.num_pending).toBe(0);
|
||||
|
||||
Reference in New Issue
Block a user