import {
banResponse,
dmResponse,
permissionsResponse,
punishmentEntryRemove,
type BanResponse,
type BushClient,
type BushGuildMember,
type BushGuildMemberManager,
type BushGuildMemberResolvable,
type BushNewsChannel,
type BushTextChannel,
type BushThreadChannel,
type BushUser,
type BushUserResolvable,
type GuildFeatures,
type GuildLogType,
type GuildModel
} from '#lib';
import { Collection, Guild, Snowflake, type GuildChannelManager, type MessageOptions, type MessagePayload } 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
);
}
/**
* 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 }] });
}
/**
* 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: GuildBushBanOptions): Promise {
// checks
if (!this.me!.permissions.has('BAN_MEMBERS')) return banResponse.MISSING_PERMISSIONS;
let caseID: string | undefined = undefined;
let dmSuccessEvent: boolean | undefined = undefined;
const user = (await util.resolveNonCachedUser(options.user))!;
const moderator = client.users.resolve(options.moderator ?? client.user!)!;
if ((await this.bans.fetch()).has(user.id)) return banResponse.ALREADY_BANNED;
const ret = await (async () => {
// dm user
dmSuccessEvent = await Moderation.punishDM({
guild: this,
user: user,
punishment: 'banned',
duration: options.duration ?? 0,
reason: options.reason ?? undefined,
sendFooter: true
});
// 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 banResponse.ACTION_ERROR;
// 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 banResponse.MODLOG_ERROR;
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 banResponse.PUNISHMENT_ENTRY_ADD_ERROR;
if (!dmSuccessEvent) return banResponse.DM_ERROR;
return banResponse.SUCCESS;
})();
if (!([banResponse.ACTION_ERROR, banResponse.MODLOG_ERROR, banResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes(ret))
client.emit(
'bushBan',
user,
moderator,
this,
options.reason ?? undefined,
caseID!,
options.duration ?? 0,
dmSuccessEvent,
options.evidence
);
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: GuildBushUnbanOptions): Promise {
// checks
if (!this.me!.permissions.has('BAN_MEMBERS')) return unbanResponse.MISSING_PERMISSIONS;
let caseID: string | undefined = undefined;
let dmSuccessEvent: boolean | undefined = undefined;
const user = (await util.resolveNonCachedUser(options.user))!;
const moderator = client.users.resolve(options.moderator ?? client.user!)!;
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 unbanResponse.NOT_BANNED;
if (!unbanSuccess) return unbanResponse.ACTION_ERROR;
// add modlog entry
const { log: modlog } = await Moderation.createModLogEntry({
type: ModLogType.UNBAN,
user: user.id,
moderator: moderator.id,
reason: options.reason,
guild: this,
evidence: options.evidence
});
if (!modlog) return unbanResponse.MODLOG_ERROR;
caseID = modlog.id;
// remove punishment entry
const removePunishmentEntrySuccess = await Moderation.removePunishmentEntry({
type: 'ban',
user: user.id,
guild: this
});
if (!removePunishmentEntrySuccess) return unbanResponse.PUNISHMENT_ENTRY_REMOVE_ERROR;
// dm user
dmSuccessEvent = await Moderation.punishDM({
guild: this,
user: user,
punishment: 'unbanned',
reason: options.reason ?? undefined,
sendFooter: false
});
if (!dmSuccessEvent) return unbanResponse.DM_ERROR;
return unbanResponse.SUCCESS;
})();
if (
!([unbanResponse.ACTION_ERROR, unbanResponse.MODLOG_ERROR, unbanResponse.PUNISHMENT_ENTRY_REMOVE_ERROR] as const).includes(
ret
)
)
client.emit('bushUnban', user, moderator, this, options.reason ?? undefined, caseID!, dmSuccessEvent!, options.evidence);
return ret;
}
/**
* 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();
const success = new Collection();
const ret = await (async (): Promise => {
for (const _channel of mappedChannels) {
const channel = _channel!;
if (!channel.isText() && !channel.isThread()) {
errors.set(channel.id, new Error('wrong channel type'));
success.set(channel.id, false);
continue;
}
if (!channel.permissionsFor(this.me!.id)?.has(['MANAGE_CHANNELS'])) {
errors.set(channel.id, new Error('client no permission'));
success.set(channel.id, false);
continue;
} else if (!channel.permissionsFor(options.moderator)?.has(['MANAGE_CHANNELS'])) {
errors.set(channel.id, new Error('moderator no permission'));
success.set(channel.id, false);
continue;
}
const reason = `[${options.unlock ? 'Unlockdown' : 'Lockdown'}] ${moderator.user.tag} | ${
options.reason ?? 'No reason provided'
}`;
const permissionOverwrites = channel.isThread() ? channel.parent!.permissionOverwrites : channel.permissionOverwrites;
const perms = { [channel.isThread() ? 'SEND_MESSAGES_IN_THREADS' : 'SEND_MESSAGES']: options.unlock ? null : false };
const permsForMe = { [channel.isThread() ? 'SEND_MESSAGES_IN_THREADS' : 'SEND_MESSAGES']: options.unlock ? null : true }; // so I can send messages in the channel
const changePermSuccess = await permissionOverwrites.edit(this.id, perms, { reason }).catch((e) => e);
if (changePermSuccess instanceof Error) {
errors.set(channel.id, changePermSuccess);
success.set(channel.id, false);
} else {
success.set(channel.id, true);
await permissionOverwrites.edit(this.me!, permsForMe, { reason });
await channel.send({
embeds: [
{
author: { name: moderator.user.tag, iconURL: moderator.displayAvatarURL({ dynamic: true }) },
title: `This channel has been ${options.unlock ? 'un' : ''}locked`,
description: options.reason ?? 'No reason provided',
color: options.unlock ? util.colors.discord.GREEN : util.colors.discord.RED,
timestamp: Date.now()
}
]
});
}
}
if (errors.size) return errors;
else return `success: ${success.filter((c) => c === true).size}`;
})();
client.emit(options.unlock ? 'bushUnlockdown' : 'bushLockdown', moderator, options.reason, success, options.all);
return ret;
}
}
/**
* Options for unbanning a user
*/
export interface GuildBushUnbanOptions {
/**
* The user to unban
*/
user: BushUserResolvable | BushUser;
/**
* The reason for unbanning the user
*/
reason?: string | null;
/**
* The moderator who unbanned the user
*/
moderator?: BushUserResolvable;
/**
* The evidence for the unban
*/
evidence?: string;
}
/**
* Options for banning a user
*/
export interface GuildBushBanOptions {
/**
* The user to ban
*/
user: BushUserResolvable;
/**
* 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 ValueOf = T[keyof T];
export const unbanResponse = Object.freeze({
...dmResponse,
...permissionsResponse,
...punishmentEntryRemove,
NOT_BANNED: 'user not banned'
} as const);
/**
* Response returned when unbanning a user
*/
export type UnbanResponse = ValueOf;
/**
* Options for locking down channel(s)
*/
export 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
*/
export type LockdownResponse =
| `success: ${number}`
| 'all not chosen and no channel specified'
| 'no channels configured'
| `invalid channel configured: ${string}`
| 'moderator not found'
| Collection;