/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import type {ChannelID, EmojiID, GuildID, InviteCode, RoleID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes'; import {createUserID, createWebhookID} from '@fluxer/api/src/BrandedTypes'; import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository'; import type {ChannelService} from '@fluxer/api/src/channel/services/ChannelService'; import type {GuildAuditLogService} from '@fluxer/api/src/guild/GuildAuditLogService'; import type {GuildAuditLogChange} from '@fluxer/api/src/guild/GuildAuditLogTypes'; import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate'; import {GuildChannelService} from '@fluxer/api/src/guild/services/GuildChannelService'; import {GuildContentService} from '@fluxer/api/src/guild/services/GuildContentService'; import {GuildDataService} from '@fluxer/api/src/guild/services/GuildDataService'; import {GuildMemberService} from '@fluxer/api/src/guild/services/GuildMemberService'; import {GuildModerationService} from '@fluxer/api/src/guild/services/GuildModerationService'; import {GuildRoleService} from '@fluxer/api/src/guild/services/GuildRoleService'; import {GuildSearchService} from '@fluxer/api/src/guild/services/GuildSearchService'; import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService'; import type {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService'; import type {IAssetDeletionQueue} from '@fluxer/api/src/infrastructure/IAssetDeletionQueue'; import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService'; import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService'; import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService'; import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService'; import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService'; import type {InviteRepository} from '@fluxer/api/src/invite/InviteRepository'; import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService'; import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware'; import type {Guild} from '@fluxer/api/src/models/Guild'; import type {GuildAuditLog} from '@fluxer/api/src/models/GuildAuditLog'; import type {GuildMember} from '@fluxer/api/src/models/GuildMember'; import type {User} from '@fluxer/api/src/models/User'; import type {Webhook} from '@fluxer/api/src/models/Webhook'; import type {GuildManagedTraitService} from '@fluxer/api/src/traits/GuildManagedTraitService'; import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository'; import {getCachedUserPartialResponses} from '@fluxer/api/src/user/UserCacheHelpers'; import type {IWebhookRepository} from '@fluxer/api/src/webhook/IWebhookRepository'; import type {ICacheService} from '@fluxer/cache/src/ICacheService'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError'; import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService'; import type {ChannelCreateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas'; import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type { GuildEmojiResponse, GuildEmojiWithUserResponse, GuildStickerResponse, GuildStickerWithUserResponse, } from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas'; import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type { GuildCreateRequest, GuildMemberUpdateRequest, GuildRoleCreateRequest, GuildRoleUpdateRequest, GuildUpdateRequest, } from '@fluxer/schema/src/domains/guild/GuildRequestSchemas'; import type { GuildPartialResponse, GuildResponse, GuildVanityURLResponse, } from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas'; import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService'; interface AuditLogOptions { channel_id?: string; count?: number; delete_member_days?: string; id?: string; integration_type?: number; message_id?: string; members_removed?: number; role_name?: string; type?: number; inviter_id?: string; max_age?: number; max_uses?: number; temporary?: boolean; uses?: number; } interface GuildAuditLogEntryResponse { id: string; action_type: number; user_id: string | null; target_id: string | null; reason?: string; options?: AuditLogOptions; changes?: GuildAuditLogChange; } interface AuditLogWebhook { id: string; type: number; guild_id: string | null; channel_id: string | null; name: string; avatar_hash: string | null; } interface GuildAuth { guildData: GuildResponse; checkPermission: (permission: bigint) => Promise; checkTargetMember: (targetUserId: UserID) => Promise; getAssignableRoleIds: () => Promise>; getMaxRolePosition: () => Promise; getMyPermissions: () => Promise; hasPermission: (permission: bigint) => Promise; canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise; } export class GuildService { public readonly data: GuildDataService; public readonly members: GuildMemberService; public readonly roles: GuildRoleService; public readonly moderation: GuildModerationService; public readonly content: GuildContentService; public readonly channels: GuildChannelService; public readonly search: GuildSearchService; private readonly guildRepository: IGuildRepositoryAggregate; private readonly userCacheService: UserCacheService; private readonly webhookRepository: IWebhookRepository; private readonly guildAuditLogService: GuildAuditLogService; private readonly gatewayService: IGatewayService; constructor( guildRepository: IGuildRepositoryAggregate, channelRepository: IChannelRepository, inviteRepository: InviteRepository, channelService: ChannelService, userCacheService: UserCacheService, gatewayService: IGatewayService, entityAssetService: EntityAssetService, avatarService: AvatarService, assetDeletionQueue: IAssetDeletionQueue, userRepository: IUserRepository, mediaService: IMediaService, cacheService: ICacheService, snowflakeService: SnowflakeService, rateLimitService: IRateLimitService, workerService: IWorkerService, webhookRepository: IWebhookRepository, guildAuditLogService: GuildAuditLogService, limitConfigService: LimitConfigService, guildManagedTraitService?: GuildManagedTraitService, ) { this.gatewayService = gatewayService; this.guildRepository = guildRepository; this.userCacheService = userCacheService; this.webhookRepository = webhookRepository; this.guildAuditLogService = guildAuditLogService; this.data = new GuildDataService( guildRepository, channelRepository, inviteRepository, channelService, gatewayService, entityAssetService, userRepository, snowflakeService, webhookRepository, guildAuditLogService, limitConfigService, guildManagedTraitService, ); this.members = new GuildMemberService( guildRepository, channelService, userCacheService, gatewayService, entityAssetService, userRepository, rateLimitService, guildAuditLogService, limitConfigService, guildManagedTraitService, ); this.roles = new GuildRoleService( guildRepository, guildRepository, snowflakeService, cacheService, gatewayService, guildAuditLogService, limitConfigService, ); this.moderation = new GuildModerationService( guildRepository, userRepository, gatewayService, userCacheService, workerService, guildAuditLogService, ); this.content = new GuildContentService( guildRepository, userCacheService, gatewayService, avatarService, snowflakeService, guildAuditLogService, assetDeletionQueue, limitConfigService, ); this.channels = new GuildChannelService( channelRepository, guildRepository, userCacheService, gatewayService, cacheService, snowflakeService, guildAuditLogService, limitConfigService, ); this.search = new GuildSearchService( channelRepository, channelService, userCacheService, gatewayService, userRepository, mediaService, workerService, ); } async getGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise { return this.data.getGuild({userId, guildId}); } async getUserGuilds( userId: UserID, options?: { before?: GuildID; after?: GuildID; limit?: number; withCounts?: boolean; }, ): Promise> { return this.data.getUserGuilds(userId, options); } async getPublicGuildData(guildId: GuildID): Promise { return this.data.getPublicGuildData(guildId); } async getGuildSystem(guildId: GuildID): Promise { return this.data.getGuildSystem(guildId); } async createGuild( params: {user: User; data: GuildCreateRequest}, auditLogReason?: string | null, ): Promise { return this.data.createGuild(params, auditLogReason); } async updateGuild( params: {userId: UserID; guildId: GuildID; data: GuildUpdateRequest; requestCache: RequestCache}, auditLogReason?: string | null, ): Promise { return this.data.updateGuild(params, auditLogReason); } async updateTextChannelFlexibleNamesFeature( params: {userId: UserID; guildId: GuildID; enabled: boolean; requestCache: RequestCache}, auditLogReason?: string | null, ): Promise { const {userId, guildId, enabled, requestCache} = params; const hasPermission = await this.gatewayService.checkPermission({ guildId, userId, permission: Permissions.MANAGE_GUILD, }); if (!hasPermission) { throw new MissingPermissionsError(); } const currentGuild = await this.data.getGuildSystem(guildId); const hadFlexibleNamesEnabled = currentGuild.features.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES); const featuresSet = new Set(currentGuild.features); if (enabled) { featuresSet.add(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES); } else { featuresSet.delete(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES); } const featuresPayload = Array.from(featuresSet); const updatedGuild = await this.data.updateGuild( { userId, guildId, data: {features: featuresPayload} as GuildUpdateRequest, requestCache, }, auditLogReason, ); if (hadFlexibleNamesEnabled && !enabled) { await this.channels.sanitizeTextChannelNames({guildId, requestCache}); } return updatedGuild; } async updateDetachedBannerFeature( params: {userId: UserID; guildId: GuildID; enabled: boolean; requestCache: RequestCache}, auditLogReason?: string | null, ): Promise { const {userId, guildId, enabled, requestCache} = params; const hasPermission = await this.gatewayService.checkPermission({ guildId, userId, permission: Permissions.MANAGE_GUILD, }); if (!hasPermission) { throw new MissingPermissionsError(); } const currentGuild = await this.data.getGuildSystem(guildId); const featuresSet = new Set(currentGuild.features); if (enabled) { featuresSet.add(GuildFeatures.DETACHED_BANNER); } else { featuresSet.delete(GuildFeatures.DETACHED_BANNER); } const featuresPayload = Array.from(featuresSet); const updatedGuild = await this.data.updateGuild( { userId, guildId, data: {features: featuresPayload} as GuildUpdateRequest, requestCache, }, auditLogReason, ); return updatedGuild; } async getVanityURL(params: {userId: UserID; guildId: GuildID}): Promise { return this.data.getVanityURL(params); } async updateVanityURL( params: {userId: UserID; guildId: GuildID; code: string | null; requestCache: RequestCache}, auditLogReason?: string | null, ): Promise<{code: string}> { return this.data.updateVanityURL(params, auditLogReason); } async deleteGuild(params: {user: User; guildId: GuildID}, auditLogReason?: string | null): Promise { return this.data.deleteGuild(params, auditLogReason); } async deleteGuildAsAdmin(guildId: GuildID, auditLogReason?: string | null): Promise { return this.data.deleteGuildForAdmin(guildId, auditLogReason); } async transferOwnership( params: {userId: UserID; guildId: GuildID; newOwnerId: UserID}, auditLogReason?: string | null, ): Promise { return this.data.transferOwnership(params, auditLogReason); } async checkGuildVerification(params: {user: User; guild: Guild; member: GuildMember}): Promise { return this.data.checkGuildVerification(params); } async getMembers(params: { userId: UserID; guildId: GuildID; limit?: number; after?: UserID; requestCache: RequestCache; }): Promise> { return this.members.getMembers(params); } async getMember(params: { userId: UserID; targetId: UserID; guildId: GuildID; requestCache: RequestCache; }): Promise { return this.members.getMember(params); } async updateMember( params: { userId: UserID; targetId: UserID; guildId: GuildID; data: GuildMemberUpdateRequest | Omit; requestCache: RequestCache; }, auditLogReason?: string | null, ): Promise { if ('communication_disabled_until' in params.data && params.data.communication_disabled_until !== undefined) { if (params.data.communication_disabled_until === null) { getMetricsService().counter({ name: 'fluxer.timeouts.removed', dimensions: { guild_id: params.guildId.toString(), }, }); } else { getMetricsService().counter({ name: 'fluxer.timeouts.added', dimensions: { guild_id: params.guildId.toString(), }, }); } } return this.members.updateMember(params, auditLogReason); } async addMemberRole( params: {userId: UserID; targetId: UserID; guildId: GuildID; roleId: RoleID; requestCache: RequestCache}, auditLogReason?: string | null, ): Promise { return this.members.addMemberRole(params, auditLogReason); } async removeMemberRole( params: {userId: UserID; targetId: UserID; guildId: GuildID; roleId: RoleID; requestCache: RequestCache}, auditLogReason?: string | null, ): Promise { return this.members.removeMemberRole(params, auditLogReason); } async removeMember( params: {userId: UserID; targetId: UserID; guildId: GuildID}, auditLogReason?: string | null, ): Promise { return this.members.removeMember(params, auditLogReason); } async addUserToGuild(params: { userId: UserID; guildId: GuildID; sendJoinMessage?: boolean; skipGuildLimitCheck?: boolean; skipBanCheck?: boolean; isTemporary?: boolean; joinSourceType?: number; sourceInviteCode?: InviteCode; inviterId?: UserID; requestCache: RequestCache; initiatorId?: UserID; }): Promise { return this.members.addUserToGuild(params); } async leaveGuild(params: {userId: UserID; guildId: GuildID}, auditLogReason?: string | null): Promise { return this.members.leaveGuild(params, auditLogReason); } async systemCreateRole(params: { initiatorId: UserID; guildId: GuildID; data: GuildRoleCreateRequest; }): Promise { return this.roles.systemCreateRole(params); } async createRole( params: {userId: UserID; guildId: GuildID; data: GuildRoleCreateRequest}, auditLogReason?: string | null, ): Promise { return this.roles.createRole(params, auditLogReason); } async updateRole( params: {userId: UserID; guildId: GuildID; roleId: RoleID; data: GuildRoleUpdateRequest}, auditLogReason?: string | null, ): Promise { return this.roles.updateRole(params, auditLogReason); } async deleteRole( params: {userId: UserID; guildId: GuildID; roleId: RoleID}, auditLogReason?: string | null, ): Promise { return this.roles.deleteRole(params, auditLogReason); } async updateRolePositions( params: {userId: UserID; guildId: GuildID; updates: Array<{roleId: RoleID; position?: number}>}, auditLogReason?: string | null, ): Promise { return this.roles.updateRolePositions(params, auditLogReason); } async listRoles(params: {userId: UserID; guildId: GuildID}): Promise> { return this.roles.listRoles(params); } async updateHoistPositions( params: {userId: UserID; guildId: GuildID; updates: Array<{roleId: RoleID; hoistPosition: number}>}, auditLogReason?: string | null, ): Promise { return this.roles.updateHoistPositions(params, auditLogReason); } async resetHoistPositions(params: {userId: UserID; guildId: GuildID}, auditLogReason?: string | null): Promise { return this.roles.resetHoistPositions(params, auditLogReason); } async banMember( params: { userId: UserID; targetId: UserID; guildId: GuildID; deleteMessageDays?: number; reason?: string | null; banDurationSeconds?: number; }, auditLogReason?: string | null, ): Promise { await this.moderation.banMember(params, auditLogReason); let reasonType = 'none'; if (params.reason) { reasonType = params.reason.length > 0 ? 'custom' : 'none'; } if (auditLogReason) { reasonType = 'audit_log'; } getMetricsService().counter({ name: 'fluxer.guild_bans.created', dimensions: { guild_id: params.guildId.toString(), reason_type: reasonType, delete_message_days: (params.deleteMessageDays ?? 0).toString(), }, }); } async unbanMember( params: {userId: UserID; targetId: UserID; guildId: GuildID}, auditLogReason?: string | null, ): Promise { await this.moderation.unbanMember(params, auditLogReason); getMetricsService().counter({ name: 'fluxer.guild_bans.deleted', dimensions: { guild_id: params.guildId.toString(), reason_type: auditLogReason ? 'provided' : 'none', delete_message_days: '0', }, }); } async listBans(params: { userId: UserID; guildId: GuildID; requestCache: RequestCache; }): Promise> { return this.moderation.listBans(params); } async checkUserBanStatus(params: {userId: UserID; guildId: GuildID}): Promise { return this.moderation.checkUserBanStatus(params); } async getEmojis(params: { userId: UserID; guildId: GuildID; requestCache: RequestCache; }): Promise> { return this.content.getEmojis(params); } async getEmojiUser(params: { userId: UserID; guildId: GuildID; emojiId: EmojiID; requestCache: RequestCache; }): Promise { return this.content.getEmojiUser(params); } async createEmoji( params: {user: User; guildId: GuildID; name: string; image: string}, auditLogReason?: string | null, ): Promise { const emoji = await this.content.createEmoji(params, auditLogReason); getMetricsService().counter({ name: 'fluxer.emoji.created', value: 1, dimensions: { guild_id: params.guildId.toString(), animated: emoji.animated.toString(), }, }); return emoji; } async bulkCreateEmojis( params: {user: User; guildId: GuildID; emojis: Array<{name: string; image: string}>}, auditLogReason?: string | null, ): Promise<{ success: Array; failed: Array<{name: string; error: string}>; }> { const result = await this.content.bulkCreateEmojis(params, auditLogReason); for (const emoji of result.success) { getMetricsService().counter({ name: 'fluxer.emoji.created', value: 1, dimensions: { guild_id: params.guildId.toString(), animated: emoji.animated.toString(), }, }); } return result; } async updateEmoji( params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; name: string}, auditLogReason?: string | null, ): Promise { const emoji = await this.content.updateEmoji(params, auditLogReason); getMetricsService().counter({ name: 'fluxer.emoji.updated', value: 1, dimensions: { guild_id: params.guildId.toString(), animated: emoji.animated.toString(), }, }); return emoji; } async deleteEmoji( params: {userId: UserID; guildId: GuildID; emojiId: EmojiID; purge?: boolean}, auditLogReason?: string | null, ): Promise { await this.content.deleteEmoji(params, auditLogReason); getMetricsService().counter({ name: 'fluxer.emoji.deleted', value: 1, dimensions: { guild_id: params.guildId.toString(), }, }); } async getStickers(params: { userId: UserID; guildId: GuildID; requestCache: RequestCache; }): Promise> { return this.content.getStickers(params); } async getStickerUser(params: { userId: UserID; guildId: GuildID; stickerId: StickerID; requestCache: RequestCache; }): Promise { return this.content.getStickerUser(params); } async createSticker( params: { user: User; guildId: GuildID; name: string; description?: string | null; tags: Array; image: string; }, auditLogReason?: string | null, ): Promise { const sticker = await this.content.createSticker(params, auditLogReason); getMetricsService().counter({ name: 'fluxer.stickers.created', value: 1, dimensions: { guild_id: params.guildId.toString(), }, }); return sticker; } async bulkCreateStickers( params: { user: User; guildId: GuildID; stickers: Array<{name: string; description?: string | null; tags: Array; image: string}>; }, auditLogReason?: string | null, ): Promise<{ success: Array; failed: Array<{name: string; error: string}>; }> { const result = await this.content.bulkCreateStickers(params, auditLogReason); for (const _ of result.success) { getMetricsService().counter({ name: 'fluxer.stickers.created', value: 1, dimensions: { guild_id: params.guildId.toString(), }, }); } return result; } async updateSticker( params: { userId: UserID; guildId: GuildID; stickerId: StickerID; name: string; description?: string | null; tags: Array; }, auditLogReason?: string | null, ): Promise { return this.content.updateSticker(params, auditLogReason); } async deleteSticker( params: {userId: UserID; guildId: GuildID; stickerId: StickerID; purge?: boolean}, auditLogReason?: string | null, ): Promise { await this.content.deleteSticker(params, auditLogReason); getMetricsService().counter({ name: 'fluxer.stickers.deleted', value: 1, dimensions: { guild_id: params.guildId.toString(), }, }); } async getChannels(params: { userId: UserID; guildId: GuildID; requestCache: RequestCache; }): Promise> { return this.channels.getChannels(params); } async createChannel( params: {userId: UserID; guildId: GuildID; data: ChannelCreateRequest; requestCache: RequestCache}, auditLogReason?: string | null, ): Promise { return this.channels.createChannel(params, auditLogReason); } async updateChannelPositions( params: { userId: UserID; guildId: GuildID; updates: Array<{ channelId: ChannelID; position?: number; parentId: ChannelID | null | undefined; precedingSiblingId: ChannelID | null | undefined; lockPermissions: boolean; }>; requestCache: RequestCache; }, auditLogReason?: string | null, ): Promise { return this.channels.updateChannelPositions(params, auditLogReason); } async listGuildAuditLogs(params: { userId: UserID; guildId: GuildID; requestCache: RequestCache; limit?: number; beforeLogId?: bigint; afterLogId?: bigint; filterUserId?: UserID; actionType?: AuditLogActionType; }): Promise<{ audit_log_entries: Array; users: Array; webhooks: Array; }> { const {userId, guildId, requestCache, limit = 50, beforeLogId, afterLogId, filterUserId, actionType} = params; if (beforeLogId !== undefined && afterLogId !== undefined) { throw InputValidationError.fromCode('before', ValidationErrorCodes.CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER); } const [hasPermission, guild] = await Promise.all([ this.gatewayService.checkPermission({ guildId, userId, permission: Permissions.VIEW_AUDIT_LOG, }), this.guildRepository.findUnique(guildId), ]); if (!guild) { throw new UnknownGuildError(); } if (!hasPermission) { throw new MissingPermissionsError(); } const effectiveLimit = Math.max(1, Math.min(limit, 100)); const shouldBatch = actionType === undefined && !filterUserId; let processedLogs: Array = []; let currentBeforeLogId = beforeLogId; let currentAfterLogId = afterLogId; const maxIterations = 5; let iterations = 0; while (processedLogs.length < effectiveLimit && iterations < maxIterations) { iterations++; const fetchLimit = Math.min(effectiveLimit * 2, 200); const logs = await this.guildRepository.listAuditLogs({ guildId, limit: fetchLimit, beforeLogId: currentBeforeLogId, afterLogId: currentAfterLogId, userId: filterUserId, actionType, }); if (logs.length === 0) { break; } if (shouldBatch) { const batchResult = await this.guildAuditLogService.batchConsecutiveMessageDeleteLogs(guildId, logs); for (const log of batchResult.processedLogs) { if (processedLogs.length < effectiveLimit) { processedLogs.push(log); } } } else { for (const log of logs) { if (processedLogs.length < effectiveLimit) { processedLogs.push(log); } } } if (logs.length < fetchLimit) { break; } const lastLog = logs[logs.length - 1]; if (afterLogId !== undefined) { currentAfterLogId = lastLog.logId; } else { currentBeforeLogId = lastLog.logId; } } processedLogs = processedLogs.slice(0, effectiveLimit); const userIdSet = new Set(); for (const log of processedLogs) { userIdSet.add(log.userId); const targetUserId = this.getAuditLogTargetUserId(log); if (targetUserId) { userIdSet.add(targetUserId); } } const [userPartials, webhookRecords] = await Promise.all([ getCachedUserPartialResponses({ userIds: Array.from(userIdSet), userCacheService: this.userCacheService, requestCache, }), this.loadAuditLogWebhooks(processedLogs), ]); const entries = processedLogs.map((log) => this.mapAuditLogToEntry(log)); const users = Array.from(userPartials.values()); const webhooks = this.buildAuditLogWebhookResponses(webhookRecords.webhooks); getMetricsService().counter({ name: 'fluxer.audit_logs.queries', dimensions: { action_type: actionType?.toString() ?? 'all', user_id: filterUserId?.toString() ?? 'none', result_count: entries.length.toString(), }, }); return { audit_log_entries: entries, users, webhooks, }; } private mapAuditLogToEntry(log: GuildAuditLog): GuildAuditLogEntryResponse { return { id: log.logId.toString(), action_type: log.actionType, user_id: log.userId.toString(), target_id: log.targetId, reason: log.reason ?? undefined, options: this.buildAuditLogOptions(log.options), changes: this.scrubSensitiveChanges(log.changes), }; } private scrubSensitiveChanges(changes: GuildAuditLogChange | null | undefined): GuildAuditLogChange | undefined { if (!changes) { return undefined; } const scrubbed = changes.filter((change) => change.key !== 'ip'); return scrubbed.length > 0 ? scrubbed : undefined; } private buildAuditLogOptions(options: Map): AuditLogOptions | undefined { if (!options.size) { return undefined; } const mapped: AuditLogOptions = {}; for (const [key, value] of options) { switch (key) { case 'channel_id': mapped.channel_id = value; break; case 'count': this.assignNumericOption(mapped, 'count', value); break; case 'delete_member_days': mapped.delete_member_days = value; break; case 'delete_message_days': if (!mapped.delete_member_days) { mapped.delete_member_days = value; } break; case 'id': mapped.id = value; break; case 'integration_type': this.assignNumericOption(mapped, 'integration_type', value); break; case 'message_id': mapped.message_id = value; break; case 'members_removed': this.assignNumericOption(mapped, 'members_removed', value); break; case 'role_name': mapped.role_name = value; break; case 'type': this.assignNumericOption(mapped, 'type', value); break; case 'inviter_id': mapped.inviter_id = value; break; case 'max_age': this.assignNumericOption(mapped, 'max_age', value); break; case 'max_uses': this.assignNumericOption(mapped, 'max_uses', value); break; case 'uses': this.assignNumericOption(mapped, 'uses', value); break; case 'temporary': mapped.temporary = this.parseBooleanOption(value); break; default: break; } } return Object.keys(mapped).length === 0 ? undefined : mapped; } private parseBooleanOption(value: string): boolean { return value === 'true' || value === '1'; } private assignNumericOption( target: AuditLogOptions, key: 'count' | 'integration_type' | 'members_removed' | 'type' | 'max_age' | 'max_uses' | 'uses', value: string, ): void { const parsed = Number(value); if (!Number.isNaN(parsed)) { target[key] = parsed; } } private async loadAuditLogWebhooks(logs: Array): Promise<{ webhooks: Array; }> { const webhookIds = new Set(); for (const log of logs) { if (this.isWebhookAction(log.actionType) && log.targetId) { webhookIds.add(log.targetId); } } if (webhookIds.size === 0) { return {webhooks: []}; } const webhookPromises = Array.from(webhookIds, (id) => { try { return this.webhookRepository.findUnique(createWebhookID(BigInt(id))); } catch { return Promise.resolve(null); } }); const results = await Promise.all(webhookPromises); const foundWebhooks = results.filter((webhook): webhook is Webhook => webhook !== null); return {webhooks: foundWebhooks}; } private buildAuditLogWebhookResponses(webhooks: Array): Array { return webhooks.map((webhook) => ({ id: webhook.id.toString(), type: webhook.type, guild_id: webhook.guildId?.toString() ?? null, channel_id: webhook.channelId?.toString() ?? null, name: webhook.name, avatar_hash: webhook.avatarHash, })); } private isWebhookAction(actionType: AuditLogActionType): boolean { return ( actionType === AuditLogActionType.WEBHOOK_CREATE || actionType === AuditLogActionType.WEBHOOK_UPDATE || actionType === AuditLogActionType.WEBHOOK_DELETE ); } private getAuditLogTargetUserId(log: GuildAuditLog): UserID | null { if (!log.targetId || !this.isUserTargetAction(log.actionType)) { return null; } try { return createUserID(BigInt(log.targetId)); } catch { return null; } } private isUserTargetAction(actionType: AuditLogActionType): boolean { return ( actionType === AuditLogActionType.MEMBER_KICK || actionType === AuditLogActionType.MEMBER_PRUNE || actionType === AuditLogActionType.MEMBER_BAN_ADD || actionType === AuditLogActionType.MEMBER_BAN_REMOVE || actionType === AuditLogActionType.MEMBER_UPDATE || actionType === AuditLogActionType.MEMBER_ROLE_UPDATE || actionType === AuditLogActionType.MEMBER_MOVE || actionType === AuditLogActionType.MEMBER_DISCONNECT || actionType === AuditLogActionType.BOT_ADD ); } async searchMessages(params: { userId: UserID; guildId: GuildID; channelIds: Array; searchParams: MessageSearchRequest; requestCache: RequestCache; }): Promise { return this.search.searchMessages(params); } async searchAllGuilds(params: { userId: UserID; channelIds: Array; searchParams: MessageSearchRequest; requestCache: RequestCache; }): Promise { return this.search.searchAllGuilds(params); } async collectAccessibleGuildChannels(userId: UserID) { return this.search.collectAccessibleGuildChannels(userId); } async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise { const guildData = await this.gatewayService.getGuildData({guildId, userId}); if (!guildData) throw new MissingAccessError(); const checkPermission = async (permission: bigint) => { const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission}); if (!hasPermission) throw new MissingPermissionsError(); }; const checkTargetMember = async (targetUserId: UserID) => { const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId}); if (!canManage) throw new MissingPermissionsError(); }; const getAssignableRoleIds = async () => this.gatewayService.getAssignableRoles({guildId, userId}); const getMaxRolePosition = async () => this.gatewayService.getUserMaxRolePosition({guildId, userId}); const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId}); const hasPermission = async (permission: bigint) => this.gatewayService.checkPermission({guildId, userId, permission}); const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) => this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId}); return { guildData, checkPermission, checkTargetMember, getAssignableRoleIds, getMaxRolePosition, getMyPermissions, hasPermission, canManageRoles, }; } }