refactor(api): emit moderation events and apply account actions (#3031)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 12:24:15 +02:00
1 parent c488906131
commit 364084c819
385 files changed
+8994 -22221

No files matched your search

@@ -3,7 +3,6 @@
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
import type {GuildID, InviteCode, RoleID, UserID} from '@app/api/BrandedTypes';
import {createChannelID, createRoleID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {assertMutableUserId} from '@app/api/constants/Core';
import type {GuildMemberRow} from '@app/api/database/types/GuildTypes';
@@ -16,6 +15,8 @@ import type {GuildMemberAuthService} from '@app/api/guild/services/member/GuildM
import type {GuildMemberEventService} from '@app/api/guild/services/member/GuildMemberEventService';
import type {GuildMemberSearchIndexService} from '@app/api/guild/services/member/GuildMemberSearchIndexService';
import type {GuildMemberValidationService} from '@app/api/guild/services/member/GuildMemberValidationService';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import type {JoinSource} from '@app/api/infrastructure/activity/Contract.generated';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -26,24 +27,13 @@ import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Guild} from '@app/api/models/Guild';
import type {GuildMember} from '@app/api/models/GuildMember';
import type {User} from '@app/api/models/User';
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
import type {UserSettings} from '@app/api/models/UserSettings';
import {
DEFAULT_PHONE_GATE_MEMBER_THRESHOLD,
evaluateDeferredPhoneGate,
getDeferredPhoneGateConfig,
guildTriggersPhoneGate,
} from '@app/api/risk/DeferredPhoneGate';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {getEffectiveSuspiciousFlags, isProfileSubstringExempt} from '@app/api/user/UserHelpers';
import {
mapUserGuildSettingsToResponse,
mapUserSettingsToResponse,
mapUserToPrivateResponse,
} from '@app/api/user/UserMappers';
import {isProfileSubstringExempt} from '@app/api/user/UserHelpers';
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@app/api/user/UserMappers';
import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '@app/api/user/utils/GuildFolderUtils';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
@@ -55,9 +45,7 @@ import {
} from '@fluxer/constants/src/GuildConstants';
import {
DEFAULT_GUILD_FOLDER_ICON,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
type MentionReplyPreference,
PHONE_REQUIREMENT_FLAGS,
UserNotificationSettings,
} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -69,12 +57,10 @@ import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
import {CommunicationDisabledError} from '@fluxer/errors/src/domains/moderation/CommunicationDisabledError';
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError';
import {DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
@@ -136,6 +122,25 @@ function hasSelfProfileCustomizationUpdate(
return EMAIL_VERIFICATION_REQUIRED_SELF_PROFILE_FIELDS.some((field) => data[field] !== undefined);
}
function joinSourceName(type: JoinSourceType): JoinSource {
switch (type) {
case JoinSourceTypes.CREATOR:
return 'creator';
case JoinSourceTypes.INSTANT_INVITE:
return 'invite';
case JoinSourceTypes.VANITY_URL:
return 'vanity';
case JoinSourceTypes.BOT_INVITE:
return 'bot_invite';
case JoinSourceTypes.ADMIN_FORCE_ADD:
return 'admin_force_add';
case JoinSourceTypes.DISCOVERY:
return 'discovery';
default:
return 'other';
}
}
export class GuildMemberOperationsService {
constructor(
private readonly guildRepository: IGuildRepositoryAggregate,
@@ -406,75 +411,6 @@ export class GuildMemberOperationsService {
await this.gatewayService.leaveGuild({userId: targetId, guildId});
}
private async applyDeferredPhoneGate(user: User, guild: Guild): Promise<void> {
if (user.hasVerifiedPhone) {
return;
}
const rawFlags = user.suspiciousActivityFlags ?? 0;
if ((rawFlags & (DEFERRED_PHONE_ON_COMMUNITY_JOIN | PHONE_REQUIREMENT_FLAGS)) === 0) {
return;
}
const {status, config} = await getDeferredPhoneGateConfig();
const logContext = {
userId: user.id.toString(),
guildId: guild.id.toString(),
discoverable: guild.features.has(GuildFeatures.DISCOVERABLE),
memberCount: guild.memberCount,
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
};
if (
!Config.abusePolicy.phoneFlagging.enabled &&
(getEffectiveSuspiciousFlags(user) & PHONE_REQUIREMENT_FLAGS) === 0
) {
Logger.info(logContext, 'deferred_phone_gate.skipped_phone_flagging_disabled');
return;
}
if (status !== 'ok') {
const undeferredFlags = getEffectiveSuspiciousFlags({
...user,
suspiciousActivityFlags: rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN,
} as User);
if (
(undeferredFlags & PHONE_REQUIREMENT_FLAGS) === 0 ||
!guildTriggersPhoneGate(guild, DEFAULT_PHONE_GATE_MEMBER_THRESHOLD)
) {
return;
}
Logger.info(logContext, `deferred_phone_gate.enforced_while_${status}`);
throw new AccountSuspiciousActivityError(undeferredFlags);
}
const liveFlags = getEffectiveSuspiciousFlags(user);
if ((liveFlags & PHONE_REQUIREMENT_FLAGS) !== 0) {
if (!guildTriggersPhoneGate(guild, config.memberThreshold)) {
return;
}
Logger.info(logContext, 'deferred_phone_gate.blocked_unsatisfied_phone_requirement');
throw new AccountSuspiciousActivityError(liveFlags);
}
const outcome = evaluateDeferredPhoneGate(user, guild, config, Date.now());
if (!outcome.applies) {
Logger.info(logContext, `deferred_phone_gate.skipped_${outcome.reason}`);
return;
}
const promotedFlags = getEffectiveSuspiciousFlags({...user, suspiciousActivityFlags: outcome.flags} as User);
if (promotedFlags === 0) {
Logger.info(logContext, 'deferred_phone_gate.skipped_unenforceable');
return;
}
const updatedUser = await this.userRepository.patchUpsert(
user.id,
{suspicious_activity_flags: outcome.flags},
user.toRow(),
);
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
Logger.info(logContext, 'deferred_phone_gate.applied');
throw new AccountSuspiciousActivityError(promotedFlags);
}
async addUserToGuild(
params: {
userId: UserID;
@@ -482,7 +418,6 @@ export class GuildMemberOperationsService {
sendJoinMessage?: boolean;
skipGuildLimitCheck?: boolean;
skipBanCheck?: boolean;
skipRiskGate?: boolean;
isTemporary?: boolean;
joinSourceType?: JoinSourceType;
sourceInviteCode?: InviteCode;
@@ -500,7 +435,6 @@ export class GuildMemberOperationsService {
sendJoinMessage = true,
skipGuildLimitCheck = false,
skipBanCheck = false,
skipRiskGate = false,
isTemporary = false,
joinSourceType = JoinSourceTypes.INSTANT_INVITE,
sourceInviteCode = null,
@@ -522,9 +456,6 @@ export class GuildMemberOperationsService {
if (!skipGuildLimitCheck) {
await this.enforceGuildLimit(user, userGuildsCount);
}
if (!skipRiskGate && !user.isBot) {
await this.applyDeferredPhoneGate(user, guild);
}
const maxGuildMembers = resolveMaxGuildMembersLimit({
guildFeatures: guild.features,
snapshot: this.limitConfigService.getConfigSnapshot(),
@@ -571,6 +502,15 @@ export class GuildMemberOperationsService {
if (sendJoinMessage && !(guild.systemChannelFlags & SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS)) {
await this.channelService.messages.system.sendJoinSystemMessage({guildId, userId, requestCache});
}
void emitActivity('guild_joined', userId.toString(), {
user_id: userId.toString(),
guild_id: guildId.toString(),
member_count: guild.memberCount + 1,
discoverable: guild.features.has(GuildFeatures.DISCOVERABLE),
invite_code: sourceInviteCode,
inviter_id: inviterId ? inviterId.toString() : null,
join_source: joinSourceName(joinSourceType),
});
if (user.isBot) {
await this.recordGuildAuditLog({
guildId,