import {
AllowedMentions,
banResponse,
BushGuildChannelManager,
BushGuildMemberManager,
BushMessage,
dmResponse,
permissionsResponse,
punishmentEntryRemove,
type BanResponse,
type BushClient,
type BushGuildMember,
type BushGuildMemberResolvable,
type BushNewsChannel,
type BushTextChannel,
type BushThreadChannel,
type BushUser,
type BushUserResolvable,
type GuildFeatures,
type GuildLogType,
type GuildModel
} from '#lib';
import { APIMessage } from 'discord-api-types/v10';
import {
Collection,
Guild,
MessageType,
PermissionFlagsBits,
SnowflakeUtil,
ThreadChannel,
Webhook,
WebhookMessageOptions,
type MessageOptions,
type MessagePayload,
type Snowflake
} 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/instance/Guild.js';
import { ModLogType } from '../../models/instance/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 members: BushGuildMemberManager;
public declare channels: BushGuildChannelManager;
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.isTextBased()) return;
if (
!logChannel
.permissionsFor(this.members.me!.id)
?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])
)
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.members.me!.permissions.has(PermissionFlagsBits.BanMembers)) 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 (!user || !moderator) return banResponse.CANNOT_RESOLVE_USER;
if ((await this.bans.fetch()).has(user.id)) return banResponse.ALREADY_BANNED;
const ret = await (async () => {
// 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;
// dm user
dmSuccessEvent = await Moderation.punishDM({
modlog: modlog.id,
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.'}`,
deleteMessageDays: options.deleteDays
})
.catch(() => false);
if (!banSuccess) return banResponse.ACTION_ERROR;
// 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;
}
/**
* {@link bushBan} with less resolving and checks
* @param options Options for banning the user.
* @returns A string status message of the ban.
* **Preconditions:**
* - {@link me} has the `BanMembers` permission
* **Warning:**
* - Doesn't emit bushBan Event
*/
public async massBanOne(options: GuildMassBanOneOptions): Promise {
if (this.bans.cache.has(options.user)) return banResponse.ALREADY_BANNED;
const ret = await (async () => {
// add modlog entry
const { log: modlog } = await Moderation.createModLogEntrySimple({
type: ModLogType.PERM_BAN,
user: options.user,
moderator: options.moderator,
reason: options.reason,
duration: 0,
guild: this.id
});
if (!modlog) return banResponse.MODLOG_ERROR;
let dmSuccessEvent: boolean | undefined = undefined;
// dm user
if (this.members.cache.has(options.user)) {
dmSuccessEvent = await Moderation.punishDM({
modlog: modlog.id,
guild: this,
user: options.user,
punishment: 'banned',
duration: 0,
reason: options.reason ?? undefined,
sendFooter: true
});
}
// ban
const banSuccess = await this.bans
.create(options.user, {
reason: `${options.moderator} | ${options.reason}`,
deleteMessageDays: options.deleteDays
})
.catch(() => false);
if (!banSuccess) return banResponse.ACTION_ERROR;
// add punishment entry so they can be unbanned later
const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
type: 'ban',
user: options.user,
guild: this,
duration: 0,
modlog: modlog.id
});
if (!punishmentEntrySuccess) return banResponse.PUNISHMENT_ENTRY_ADD_ERROR;
if (!dmSuccessEvent) return banResponse.DM_ERROR;
return banResponse.SUCCESS;
})();
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.members.me!.permissions.has(PermissionFlagsBits.BanMembers)) 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!);
if (!user || !moderator) return unbanResponse.CANNOT_RESOLVE_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.members.me!.id)?.has([PermissionFlagsBits.ManageChannels])) {
errors.set(channel.id, new Error('client no permission'));
success.set(channel.id, false);
continue;
} else if (!channel.permissionsFor(moderator)?.has([PermissionFlagsBits.ManageChannels])) {
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 = {
SendMessagesInThreads: options.unlock ? null : false,
SendMessages: options.unlock ? null : false
};
const permsForMe = {
[channel.isThread() ? 'SendMessagesInThreads' : 'SendMessages']: 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.members.me!, permsForMe, { reason });
await channel.send({
embeds: [
{
author: { name: moderator.user.tag, icon_url: moderator.displayAvatarURL() },
title: `This channel has been ${options.unlock ? 'un' : ''}locked`,
description: options.reason ?? 'No reason provided',
color: options.unlock ? util.colors.Green : util.colors.Red,
timestamp: new Date().toISOString()
}
]
});
}
}
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;
}
public async quote(rawQuote: APIMessage, channel: BushTextChannel | BushNewsChannel | BushThreadChannel) {
if (!channel.isTextBased() || channel.isDMBased() || channel.guildId !== this.id || !this.members.me) return null;
if (!channel.permissionsFor(this.members.me).has('ManageWebhooks')) return null;
const quote = new BushMessage(client, rawQuote);
const target = channel instanceof ThreadChannel ? channel.parent : channel;
if (!target) return null;
const webhooks: Collection = await target.fetchWebhooks().catch((e) => e);
if (!(webhooks instanceof Collection)) return null;
// find a webhook that we can use
let webhook = webhooks.find((w) => !!w.token) ?? null;
if (!webhook)
webhook = await target
.createWebhook(`${client.user!.username} Quotes #${target.name}`, {
avatar: client.user!.displayAvatarURL({ size: 2048 }),
reason: 'Creating a webhook for quoting'
})
.catch(() => null);
if (!webhook) return null;
const sendOptions: Omit = {};
const displayName = quote.member?.displayName ?? quote.author.username;
switch (quote.type) {
case MessageType.Default:
case MessageType.Reply:
case MessageType.ChatInputCommand:
case MessageType.ContextMenuCommand:
case MessageType.ThreadStarterMessage:
sendOptions.content = quote.content || undefined;
sendOptions.threadId = channel instanceof ThreadChannel ? channel.id : undefined;
sendOptions.embeds = quote.embeds.length ? quote.embeds : undefined;
sendOptions.attachments = quote.attachments.size ? [...quote.attachments.values()] : undefined;
if (quote.stickers.size && !(quote.content || quote.embeds.length || quote.attachments.size))
sendOptions.content = '[[This message has a sticker but not content]]';
break;
case MessageType.RecipientAdd: {
const recipient = rawQuote.mentions[0];
if (!recipient) {
sendOptions.content = `${util.emojis.error} Cannot resolve recipient.`;
break;
}
if (quote.channel.isThread()) {
const recipientDisplay = quote.guild?.members.cache.get(recipient.id)?.displayName ?? recipient.username;
sendOptions.content = `${util.emojis.join} ${displayName} added ${recipientDisplay} to the thread.`;
} else {
// this should never happen
sendOptions.content = `${util.emojis.join} ${displayName} added ${recipient.username} to the group.`;
}
break;
}
case MessageType.RecipientRemove: {
const recipient = rawQuote.mentions[0];
if (!recipient) {
sendOptions.content = `${util.emojis.error} Cannot resolve recipient.`;
break;
}
if (quote.channel.isThread()) {
const recipientDisplay = quote.guild?.members.cache.get(recipient.id)?.displayName ?? recipient.username;
sendOptions.content = `${util.emojis.leave} ${displayName} removed ${recipientDisplay} from the thread.`;
} else {
// this should never happen
sendOptions.content = `${util.emojis.leave} ${displayName} removed ${recipient.username} from the group.`;
}
break;
}
case MessageType.ChannelNameChange:
sendOptions.content = `<:pencil:957988608994861118> ${displayName} changed the channel name: **${quote.content}**`;
break;
case MessageType.ChannelPinnedMessage:
throw new Error('Not implemented yet: MessageType.ChannelPinnedMessage case');
case MessageType.GuildMemberJoin: {
const messages = [
'{username} joined the party.',
'{username} is here.',
'Welcome, {username}. We hope you brought pizza.',
'A wild {username} appeared.',
'{username} just landed.',
'{username} just slid into the server.',
'{username} just showed up!',
'Welcome {username}. Say hi!',
'{username} hopped into the server.',
'Everyone welcome {username}!',
"Glad you're here, {username}.",
'Good to see you, {username}.',
'Yay you made it, {username}!'
];
const timestamp = SnowflakeUtil.timestampFrom(quote.id);
// this is the same way that the discord client decides what message to use.
const message = messages[timestamp % messages.length].replace(/{username}/g, displayName);
sendOptions.content = `${util.emojis.join} ${message}`;
break;
}
case MessageType.UserPremiumGuildSubscription:
sendOptions.content = `<:NitroBoost:585558042309820447> ${displayName} just boosted the server${
quote.content ? ` **${quote.content}** times` : ''
}!`;
break;
case MessageType.UserPremiumGuildSubscriptionTier1:
case MessageType.UserPremiumGuildSubscriptionTier2:
case MessageType.UserPremiumGuildSubscriptionTier3:
sendOptions.content = `<:NitroBoost:585558042309820447> ${displayName} just boosted the server${
quote.content ? ` **${quote.content}** times` : ''
}! ${quote.guild?.name} has achieved **Level ${quote.type - 8}!**`;
break;
case MessageType.ChannelFollowAdd:
sendOptions.content = `${displayName} has added **${quote.content}** to this channel. Its most important updates will show up here.`;
break;
case MessageType.GuildDiscoveryDisqualified:
sendOptions.content =
'<:SystemMessageCross:842172192418693173> This server has been removed from Server Discovery because it no longer passes all the requirements. Check Server Settings for more details.';
break;
case MessageType.GuildDiscoveryRequalified:
sendOptions.content =
'<:SystemMessageCheck:842172191801212949> This server is eligible for Server Discovery again and has been automatically relisted!';
break;
case MessageType.GuildDiscoveryGracePeriodInitialWarning:
sendOptions.content =
'<:SystemMessageWarn:842172192401915971> This server has failed Discovery activity requirements for 1 week. If this server fails for 4 weeks in a row, it will be automatically removed from Discovery.';
break;
case MessageType.GuildDiscoveryGracePeriodFinalWarning:
sendOptions.content =
'<:SystemMessageWarn:842172192401915971> This server has failed Discovery activity requirements for 3 weeks in a row. If this server fails for 1 more week, it will be removed from Discovery.';
break;
case MessageType.ThreadCreated: {
const threadId = rawQuote.message_reference?.channel_id;
sendOptions.content = `<:thread:865033845753249813> ${displayName} started a thread: **[${quote.content}](https://discord.com/channels/${quote.guildId}/${threadId}
)**. See all threads.`;
break;
}
case MessageType.GuildInviteReminder:
sendOptions.content = 'Wondering who to invite? Start by inviting anyone who can help you build the server!';
break;
case MessageType.ChannelIconChange:
case MessageType.Call:
default:
sendOptions.content = `${util.emojis.error} I cannot quote **${
MessageType[quote.type] || quote.type
}** messages, please report this to my developers.`;
break;
}
sendOptions.allowedMentions = AllowedMentions.none();
sendOptions.username = quote.member?.displayName ?? quote.author.username;
sendOptions.avatarURL = quote.member?.displayAvatarURL({ size: 2048 }) ?? quote.author.displayAvatarURL({ size: 2048 });
return await webhook.send(sendOptions); /* .catch((e: any) => e); */
}
}
/**
* 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;
}
export interface GuildMassBanOneOptions {
/**
* The user to ban
*/
user: Snowflake;
/**
* The reason to ban the user
*/
reason: string;
/**
* The moderator who banned the user
*/
moderator: Snowflake;
/**
* The number of days to delete the user's messages for
*/
deleteDays?: number;
}
/**
* 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;