import type { BushClient, BushGuildMember, BushGuildMemberManager, BushGuildMemberResolvable, BushNewsChannel, BushTextChannel, BushThreadChannel, BushUser, BushUserResolvable, GuildFeatures, GuildLogType, GuildModel } from '#lib'; import { Collection, Guild, GuildChannelManager, Snowflake, type MessageOptions, type MessagePayload, type UserResolvable } from 'discord.js'; import type { RawGuildData } from 'discord.js/typings/rawDataTypes'; import _ from 'lodash'; import { Moderation } from '../../common/util/Moderation.js'; import { Guild as GuildDB } from '../../models/Guild.js'; import { ModLogType } from '../../models/ModLog.js'; /** * Represents a guild (or a server) on Discord. * It's recommended to see if a guild is available before performing operations or reading data from it. You can * check this with {@link BushGuild.available}. */ export class BushGuild extends Guild { public declare readonly client: BushClient; public declare readonly me: BushGuildMember | null; public declare members: BushGuildMemberManager; public declare channels: GuildChannelManager; public constructor(client: BushClient, data: RawGuildData) { super(client, data); } /** * Checks if the guild has a certain custom feature. * @param feature The feature to check for */ public async hasFeature(feature: GuildFeatures): Promise { const features = await this.getSetting('enabledFeatures'); return features.includes(feature); } /** * Adds a custom feature to the guild. * @param feature The feature to add * @param moderator The moderator responsible for adding a feature */ public async addFeature(feature: GuildFeatures, moderator?: BushGuildMember): Promise { const features = await this.getSetting('enabledFeatures'); const newFeatures = util.addOrRemoveFromArray('add', features, feature); return (await this.setSetting('enabledFeatures', newFeatures, moderator)).enabledFeatures; } /** * Removes a custom feature from the guild. * @param feature The feature to remove * @param moderator The moderator responsible for removing a feature */ public async removeFeature(feature: GuildFeatures, moderator?: BushGuildMember): Promise { const features = await this.getSetting('enabledFeatures'); const newFeatures = util.addOrRemoveFromArray('remove', features, feature); return (await this.setSetting('enabledFeatures', newFeatures, moderator)).enabledFeatures; } /** * Makes a custom feature the opposite of what it was before * @param feature The feature to toggle * @param moderator The moderator responsible for toggling a feature */ public async toggleFeature(feature: GuildFeatures, moderator?: BushGuildMember): Promise { return (await this.hasFeature(feature)) ? await this.removeFeature(feature, moderator) : await this.addFeature(feature, moderator); } /** * Fetches a custom setting for the guild * @param setting The setting to get */ public async getSetting(setting: K): Promise { return ( client.cache.guilds.get(this.id)?.[setting] ?? ((await GuildDB.findByPk(this.id)) ?? GuildDB.build({ id: this.id }))[setting] ); } /** * Sets a custom setting for the guild * @param setting The setting to change * @param value The value to change the setting to * @param moderator The moderator to responsible for changing the setting */ public async setSetting>( setting: K, value: GuildDB[K], moderator?: BushGuildMember ): Promise { const row = (await GuildDB.findByPk(this.id)) ?? GuildDB.build({ id: this.id }); const oldValue = row[setting] as GuildDB[K]; row[setting] = value; client.cache.guilds.set(this.id, row.toJSON() as GuildDB); client.emit('bushUpdateSettings', setting, this, oldValue, row[setting], moderator); return await row.save(); } /** * Get a the log channel configured for a certain log type. * @param logType The type of log channel to get. * @returns Either the log channel or undefined if not configured. */ public async getLogChannel(logType: GuildLogType): Promise { const channelId = (await this.getSetting('logChannels'))[logType]; if (!channelId) return undefined; return ( (this.channels.cache.get(channelId) as BushTextChannel | undefined) ?? ((await this.channels.fetch(channelId)) as BushTextChannel | null) ?? undefined ); } /** * Bans a user, dms them, creates a mod log entry, and creates a punishment entry. * @param options Options for banning the user. * @returns A string status message of the ban. */ public async bushBan(options: BushBanOptions): Promise { // checks if (!this.me!.permissions.has('BAN_MEMBERS')) return 'missing permissions'; let caseID: string | undefined = undefined; const user = (await util.resolveNonCachedUser(options.user))!; const moderator = (await util.resolveNonCachedUser(options.moderator!)) ?? client.user!; const ret = await (async () => { await this.members.cache.get(user.id)?.punishDM('banned', options.reason, options.duration ?? 0); // ban const banSuccess = await this.bans .create(user?.id ?? options.user, { reason: `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`, days: options.deleteDays }) .catch(() => false); if (!banSuccess) return 'error banning'; // add modlog entry const { log: modlog } = await Moderation.createModLogEntry({ type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN, user: user, moderator: moderator.id, reason: options.reason, duration: options.duration, guild: this, evidence: options.evidence }); if (!modlog) return 'error creating modlog entry'; caseID = modlog.id; // add punishment entry so they can be unbanned later const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ type: 'ban', user: user, guild: this, duration: options.duration, modlog: modlog.id }); if (!punishmentEntrySuccess) return 'error creating ban entry'; return 'success'; })(); if (!['error banning', 'error creating modlog entry', 'error creating ban entry'].includes(ret)) client.emit('bushBan', user, moderator, this, options.reason ?? undefined, caseID!, options.duration ?? 0); return ret; } /** * Unbans a user, dms them, creates a mod log entry, and destroys the punishment entry. * @param options Options for unbanning the user. * @returns A status message of the unban. */ public async bushUnban(options: BushUnbanOptions): Promise { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; const user = (await util.resolveNonCachedUser(options.user))!; const moderator = (await util.resolveNonCachedUser(options.moderator ?? this.me))!; const ret = await (async () => { const bans = await this.bans.fetch(); let notBanned = false; if (!bans.has(user.id)) notBanned = true; const unbanSuccess = await this.bans .remove(user, `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`) .catch((e) => { if (e?.code === 'UNKNOWN_BAN') { notBanned = true; return true; } else return false; }); if (notBanned) return 'user not banned'; if (!unbanSuccess) return 'error unbanning'; // add modlog entry const { log: modlog } = await Moderation.createModLogEntry({ type: ModLogType.UNBAN, user: user.id, moderator: moderator.id, reason: options.reason, guild: this }); if (!modlog) return 'error creating modlog entry'; caseID = modlog.id; // remove punishment entry const removePunishmentEntrySuccess = await Moderation.removePunishmentEntry({ type: 'ban', user: user.id, guild: this }); if (!removePunishmentEntrySuccess) return 'error removing ban entry'; const userObject = client.users.cache.get(user.id); const dmSuccess = await userObject ?.send( `You have been unbanned from **${util.discord.escapeMarkdown(this.toString())}** for **${ options.reason ?? 'No reason provided' }**.` ) .catch(() => false); dmSuccessEvent = !!dmSuccess; return 'success'; })(); if (!['error unbanning', 'error creating modlog entry', 'error removing ban entry'].includes(ret)) client.emit('bushUnban', user, moderator, this, options.reason ?? undefined, caseID!, dmSuccessEvent!); return ret; } /** * Sends a message to the guild's specified logging channel * @param logType The corresponding channel that the message will be sent to * @param message The parameters for {@link BushTextChannel.send} */ public async sendLogChannel(logType: GuildLogType, message: string | MessagePayload | MessageOptions) { const logChannel = await this.getLogChannel(logType); if (!logChannel || logChannel.type !== 'GUILD_TEXT') return; if (!logChannel.permissionsFor(this.me!.id)?.has(['VIEW_CHANNEL', 'SEND_MESSAGES', 'EMBED_LINKS'])) return; return await logChannel.send(message).catch(() => null); } /** * Sends a formatted error message in a guild's error log channel * @param title The title of the error embed * @param message The description of the error embed */ public async error(title: string, message: string) { void client.console.info(_.camelCase(title), message.replace(/\*\*(.*?)\*\*/g, '<<$1>>')); void this.sendLogChannel('error', { embeds: [{ title: title, description: message, color: util.colors.error }] }); } /** * Denies send permissions in specified channels * @param options The options for locking down the guild */ public async lockdown(options: LockdownOptions): Promise { if (!options.all && !options.channel) return 'all not chosen and no channel specified'; const channelIds = options.all ? await this.getSetting('lockdownChannels') : [options.channel!.id]; if (!channelIds.length) return 'no channels configured'; const mappedChannels = channelIds.map((id) => this.channels.cache.get(id)); const invalidChannels = mappedChannels.filter((c) => c === undefined); if (invalidChannels.length) return `invalid channel configured: ${invalidChannels.join(', ')}`; const moderator = this.members.resolve(options.moderator); if (!moderator) return 'moderator not found'; const errors = new Collection(); let successCount = 0; for (const _channel of mappedChannels) { const channel = _channel!; if (!channel.permissionsFor(this.me!.id)?.has(['MANAGE_CHANNELS'])) { errors.set(channel.id, new Error('client no permission')); continue; } else if (!channel.permissionsFor(options.moderator)?.has(['MANAGE_CHANNELS'])) { errors.set(channel.id, new Error('moderator no permission')); continue; } const reason = `${options.unlock ? 'Unlocking' : 'Locking Down'} Channel | ${moderator.user.tag} | ${ options.reason ?? 'No reason provided' }`; if (channel.isThread()) { const lockdownSuccess = await channel.parent?.permissionOverwrites .edit(this.id, { SEND_MESSAGES_IN_THREADS: options.unlock ? null : false }, { reason }) .catch((e) => e); if (lockdownSuccess instanceof Error) errors.set(channel.id, lockdownSuccess); else successCount++; } else { const lockdownSuccess = await channel.permissionOverwrites .edit(this.id, { SEND_MESSAGES: options.unlock ? null : false }, { reason }) .catch((e) => e); if (lockdownSuccess instanceof Error) errors.set(channel.id, lockdownSuccess); else successCount++; } } if (errors.size) return errors; else return `success: ${successCount}`; } } /** * Options for unbanning a user */ interface BushUnbanOptions { /** * The user to unban */ user: BushUserResolvable | BushUser; /** * The reason for unbanning the user */ reason?: string | null; /** * The moderator who unbanned the user */ moderator?: BushUserResolvable; } /** * Options for banning a user */ interface BushBanOptions { /** * The user to ban */ user: BushUserResolvable | UserResolvable; /** * The reason to ban the user */ reason?: string | null; /** * The moderator who banned the user */ moderator?: BushUserResolvable; /** * The duration of the ban */ duration?: number; /** * The number of days to delete the user's messages for */ deleteDays?: number; /** * The evidence for the ban */ evidence?: string; } type PunishmentResponse = 'success' | 'missing permissions' | 'error creating modlog entry'; /** * Response returned when banning a user */ type BanResponse = PunishmentResponse | 'error banning' | 'error creating ban entry'; /** * Response returned when unbanning a user */ type UnbanResponse = PunishmentResponse | 'user not banned' | 'error unbanning' | 'error removing ban entry'; /** * Options for locking down channel(s) */ interface LockdownOptions { /** * The moderator responsible for the lockdown */ moderator: BushGuildMemberResolvable; /** * Whether to lock down all (specified) channels */ all: boolean; /** * Reason for the lockdown */ reason?: string; /** * A specific channel to lockdown */ channel?: BushThreadChannel | BushNewsChannel | BushTextChannel; /** * Whether or not to unlock the channel(s) instead of locking them */ unlock?: boolean; } /** * Response returned when locking down a channel */ type LockdownResponse = | `success: ${number}` | 'all not chosen and no channel specified' | 'no channels configured' | `invalid channel configured: ${string}` | 'moderator not found' | Collection;