diff --git a/locales/en-US/info.yaml b/locales/en-US/info.yaml index dded3cb66..85178412d 100644 --- a/locales/en-US/info.yaml +++ b/locales/en-US/info.yaml @@ -1,5 +1,14 @@ about: "Bastion — It is a multipurpose bot that can help your community get an enhanced Discord experience! Let us know if you want it to have any features that can help your community." +autoThreadArchive: "This thread has been closed and locked." +autoThreadCreate: "This thread has been automatically created from your message in the %channel% channel." +autoThreadFirstMessageError: "This is either not an auto thread or the first message in this thread was deleted." +autoThreadName: "I've updated the name of the thread to **%name%**." +autoThreadNoPerms: "The command can only be used by the thread owner." +autoThreadsInvalidChannel: "Auto threads can only be enabled in Text channels." +autoThreadsDisable: "I've disabled auto threads in the server." +autoThreadsEnable: "I've enabled auto threads in the %channel% channel." changes: "See what's new in **%version%**" +commandThreadOnly: "This command can only be used in a thread." rewardsClaimed: "You've claimed your daily reward of **%amount% Bastion Coins**." rewardsAlreadyClaimed: "You've already claimed your daily reward. Check back tomorrow." donate: "Donate to support the development of Bastion to help keep it running, and enjoy an enhanced Bastion exprience." diff --git a/package.json b/package.json index 0e1a01071..b84111806 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bastion", - "version": "10.3.0", + "version": "10.4.0", "description": "Get an enhanced Discord experience!", "homepage": "https://bastion.traction.one", "main": "./dist/index.js", diff --git a/src/bastion.ts b/src/bastion.ts index 0b6f59d5f..95e4d683b 100644 --- a/src/bastion.ts +++ b/src/bastion.ts @@ -21,7 +21,7 @@ const bastion = new Client({ GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildPresences, GatewayIntentBits.GuildMessages, - // GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildMessageReactions, // GatewayIntentBits.GuildMessageTyping, GatewayIntentBits.DirectMessages, // GatewayIntentBits.DirectMessageReactions, diff --git a/src/commands/channel/create.ts b/src/commands/channel/create.ts index b24ce0994..8fd449be3 100644 --- a/src/commands/channel/create.ts +++ b/src/commands/channel/create.ts @@ -24,7 +24,7 @@ class ChannelCreateCommand extends Command { choices: [ { name: "Text", value: ChannelType.GuildText }, { name: "Voice", value: ChannelType.GuildVoice }, - { name: "Announcement", value: ChannelType.GuildNews }, + { name: "Announcement", value: ChannelType.GuildAnnouncement }, { name: "Stage", value: ChannelType.GuildStageVoice }, { name: "Category", value: ChannelType.GuildCategory }, ], @@ -83,7 +83,7 @@ class ChannelCreateCommand extends Command { bitrate: interaction.guild.premiumTier ? interaction.guild.premiumTier * 128e3 : 96e3, userLimit: limit, rateLimitPerUser: slowmode, - parent: interaction.channel.parentId, + parent: type === ChannelType.GuildCategory ? undefined :interaction.channel.parentId, reason, }); diff --git a/src/commands/config/autoThreads.ts b/src/commands/config/autoThreads.ts new file mode 100644 index 000000000..f2a4cd4d1 --- /dev/null +++ b/src/commands/config/autoThreads.ts @@ -0,0 +1,51 @@ +/*! + * @author TRACTION (iamtraction) + * @copyright 2022 + */ +import { ChannelType, ChatInputCommandInteraction, PermissionFlagsBits } from "discord.js"; +import { Client, Command } from "@bastion/tesseract"; + +import GuildModel from "../../models/Guild"; + +class AutoThreadsCommand extends Command { + constructor() { + super({ + name: "auto-threads", + description: "Configure auto threads in the server.", + options: [], + userPermissions: [ PermissionFlagsBits.ManageGuild ], + }); + } + + public async exec(interaction: ChatInputCommandInteraction<"cached">): Promise { + await interaction.deferReply(); + + // check whether the channel is valid + if (interaction.channel.type !== ChannelType.GuildText) { + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadsInvalidChannel")); + } + + const guildDocument = await GuildModel.findById(interaction.guildId); + + // disable auto threads + if (guildDocument.autoThreadChannels?.includes(interaction.channelId)) { + guildDocument.autoThreadChannels = []; + + await guildDocument.save(); + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadsDisable")); + } + + // set rate limit before enabling auto threads + if (interaction.channel.rateLimitPerUser < 5 ) { + await interaction.channel.setRateLimitPerUser(5, "Auto Threads Channel"); + } + + // enable auto threads + guildDocument.autoThreadChannels = [ interaction.channelId ]; + + await guildDocument.save(); + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadsEnable", { channel: interaction.channel })); + } +} + +export = AutoThreadsCommand; diff --git a/src/commands/config/starboard.ts b/src/commands/config/starboard.ts index 70492f089..00e553a2b 100644 --- a/src/commands/config/starboard.ts +++ b/src/commands/config/starboard.ts @@ -19,6 +19,12 @@ class StarboardCommand extends Command { description: "The channel where starred messages should be sent.", channel_types: [ ChannelType.GuildText ], }, + { + type: ApplicationCommandOptionType.Integer, + name: "threshold", + description: "The minimum number of stars a message needs.", + min_value: 2, + }, ], userPermissions: [ PermissionFlagsBits.ManageGuild ], }); @@ -27,11 +33,13 @@ class StarboardCommand extends Command { public async exec(interaction: ChatInputCommandInteraction<"cached">): Promise { await interaction.deferReply(); const channel = interaction.options.getChannel("channel"); + const threshold = interaction.options.getInteger("threshold"); const guildDocument = await GuildModel.findById(interaction.guildId); // update starboard channel guildDocument.starboardChannel = channel?.id || undefined; + guildDocument.starboardThreshold = threshold || undefined; await guildDocument.save(); return await interaction.editReply(`I've ${ channel?.id ? "enabled" : "disabled" } starboard${ channel?.id ? ` in the **${ channel.name }** channel` : "" }.`); diff --git a/src/commands/thread/close.ts b/src/commands/thread/close.ts new file mode 100644 index 000000000..91f22779b --- /dev/null +++ b/src/commands/thread/close.ts @@ -0,0 +1,42 @@ +/*! + * @author TRACTION (iamtraction) + * @copyright 2022 + */ +import { ChatInputCommandInteraction } from "discord.js"; +import { Client, Command, Logger } from "@bastion/tesseract"; + +class ThreadCloseCommand extends Command { + constructor() { + super({ + name: "close", + description: "Close and lock the thread.", + options: [], + }); + } + + public async exec(interaction: ChatInputCommandInteraction<"cached">): Promise { + await interaction.deferReply({ ephemeral: true }); + + if (!interaction.channel.isThread()) { + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "commandThreadOnly")); + } + + const starterMessage = await interaction.channel.fetchStarterMessage().catch(Logger.error); + if (!starterMessage) { + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadFirstMessageError")); + } + + if (starterMessage.author.id === interaction.user.id) { + await interaction.channel.edit({ + archived: true, + locked: true, + reason: `Requested by ${ interaction.user.tag }`, + }); + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadArchive")); + } + + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadNoPerms")); + } +} + +export = ThreadCloseCommand; diff --git a/src/commands/thread/name.ts b/src/commands/thread/name.ts new file mode 100644 index 000000000..2e28e6406 --- /dev/null +++ b/src/commands/thread/name.ts @@ -0,0 +1,46 @@ +/*! + * @author TRACTION (iamtraction) + * @copyright 2022 + */ +import { ApplicationCommandOptionType, ChatInputCommandInteraction } from "discord.js"; +import { Client, Command, Logger } from "@bastion/tesseract"; + +class ThreadNameCommand extends Command { + constructor() { + super({ + name: "name", + description: "Change the name of the thread.", + options: [ + { + type: ApplicationCommandOptionType.String, + name: "name", + description: "The new name for the thread.", + required: true, + }, + ], + }); + } + + public async exec(interaction: ChatInputCommandInteraction<"cached">): Promise { + await interaction.deferReply({ ephemeral: true }); + const name = interaction.options.getString("name"); + + if (!interaction.channel.isThread()) { + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "commandThreadOnly")); + } + + const starterMessage = await interaction.channel.fetchStarterMessage().catch(Logger.error); + if (!starterMessage) { + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadFirstMessageError")); + } + + if (starterMessage.author.id === interaction.user.id) { + await interaction.channel.setName(name, `Requested by ${ interaction.user.tag }`); + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadName", { name })); + } + + return await interaction.editReply((interaction.client as Client).locales.getText(interaction.guildLocale, "autoThreadNoPerms")); + } +} + +export = ThreadNameCommand; diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index 56939df50..a8e7e0691 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -2,7 +2,7 @@ * @author TRACTION (iamtraction) * @copyright 2022 */ -import { Message, Snowflake, Team } from "discord.js"; +import { ChannelType, Message, Snowflake, Team, ThreadAutoArchiveDuration } from "discord.js"; import { Client, Listener, Logger } from "@bastion/tesseract"; import GuildModel, { Guild as GuildDocument } from "../models/Guild"; @@ -186,6 +186,38 @@ class MessageCreateListener extends Listener<"messageCreate"> { } }; + handleAutoThreads = async (message: Message, guildDocument: GuildDocument): Promise => { + if (!message.content) return; + + // check whether the channel is valid + if (message.channel.type !== ChannelType.GuildText) return; + + // check whether channel has slow mode + if (!message.channel.rateLimitPerUser) return; + + // check whether auto threads is enabled in the channel + if (!guildDocument.autoThreadChannels?.includes(message.channelId)) return; + + // create a new thread + const thread = await message.channel.threads.create({ + type: ChannelType.PrivateThread, + name: message.member.displayName + " — " + new Date().toDateString(), + autoArchiveDuration: ThreadAutoArchiveDuration.OneDay, + reason: `Auto Thread for ${ message.author.tag }`, + invitable: true, + startMessage: message, + }); + + thread.send({ + content: `Hello ${ message.author }! +\nThis thread has been automatically created from your message in the ${ message.channel } channel. +\n**Useful Commands** +• \`/thread name\` — Change the name of the thread. +• \`/thread close\` — Close and lock the thread once you're done. +\n*This thread will be automatically archived after 24 hours of inactivity.*`, + }).catch(Logger.ignore); + }; + handleInstantResponses = async (message: Message): Promise => { if (!message.content) return; @@ -243,6 +275,8 @@ class MessageCreateListener extends Listener<"messageCreate"> { this.handleVotingChannel(message, guildDocument).catch(Logger.error); // karma this.handleKarma(message, guildDocument).catch(Logger.error); + // auto threads + this.handleAutoThreads(message, guildDocument).catch(Logger.error); } else { if (process.env.BASTION_RELAY_DMS || ((message.client as Client).settings as bastion.Settings)?.relayDirectMessages) { // relay direct messages diff --git a/src/listeners/messageReactionAdd.ts b/src/listeners/messageReactionAdd.ts new file mode 100644 index 000000000..51ec5dd8c --- /dev/null +++ b/src/listeners/messageReactionAdd.ts @@ -0,0 +1,83 @@ +/*! + * @author TRACTION (iamtraction) + * @copyright 2022 + */ +import { GuildTextBasedChannel, MessageReaction, PartialMessageReaction, PartialUser, Snowflake, User } from "discord.js"; +import { Listener } from "@bastion/tesseract"; + +import GuildModel from "../models/Guild"; +import memcache from "../utils/memcache"; +import { COLORS } from "../utils/constants"; + +class MessageReactionAddListener extends Listener<"messageReactionAdd"> { + constructor() { + super("messageReactionAdd"); + } + + public async exec(reaction: MessageReaction | PartialMessageReaction, user: User | PartialUser): Promise { + // check whether the reaction was of a star + if (reaction.emoji.name !== "⭐") return; + // check whether the message has the minimum reaction count + if (reaction.count < 2) return; + + // check whether the message is already in starboard + const starboardCache = memcache.get("starboard") as Map || new Map(); + const guildStarboardCache = starboardCache.get(reaction.message.guildId); + if (guildStarboardCache?.includes(reaction.message.id)) return; + + const guildDocument = await GuildModel.findById(reaction.message.guildId); + + // check whether the message has required number of reactions + if (reaction.count < guildDocument.starboardThreshold) return; + // find the starboard channel + const starboardChannel = reaction.message.guild.channels.cache.get(guildDocument.starboardChannel) as GuildTextBasedChannel; + + // check whether starboard is enabled + if (!starboardChannel) return; + + // fetch the message + await reaction.message.fetch(); + + // check whether the message author is starring their own message + if (reaction.message.author?.id === user.id) return; + + // extract image attachment from the message + // although, it can be a video. + // TODO: find a way to filter out videos. + const imageAttachment = reaction.message.attachments.filter(a => Boolean(a.height && a.width)).first(); + + // check whether the message has any content + if (!reaction.message.content && !imageAttachment) return; + + // post the message in the starboard + await starboardChannel.send({ + embeds: [ + { + color: COLORS.YELLOW, + author: { + name: reaction.message.author?.tag, + icon_url: reaction.message.member?.displayAvatarURL(), + url: reaction.message.url, + }, + description: reaction.message.content, + image: { + url: imageAttachment?.url, + }, + footer: { + text: "Starboard", + }, + }, + ], + }); + + // update the starboard cache + if (guildStarboardCache instanceof Array) { + guildStarboardCache.push(reaction.message.id); + } else { + starboardCache.set(reaction.message.guildId, [ reaction.message.id ]); + } + memcache.set("starboard", starboardCache); + } +} + +export = MessageReactionAddListener; diff --git a/src/models/Guild.ts b/src/models/Guild.ts index 69e10a75f..ad623f093 100644 --- a/src/models/Guild.ts +++ b/src/models/Guild.ts @@ -45,14 +45,17 @@ export interface Guild { messageFilterPatterns?: string[]; // spam filters mentionSpamThreshold?: number; + // starboard + starboardChannel?: string; + starboardThreshold?: number; // logs moderationLogChannel?: string; serverLogChannel?: string; // special channels and roles suggestionsChannel?: string; - starboardChannel?: string; reportsChannel?: string; streamerRole?: string; + autoThreadChannels?: string[]; votingChannels?: string[]; // twitch notifications twitchNotificationChannel?: string; @@ -166,22 +169,25 @@ export default mongoose.model("Guild", new mongoose.S mentionSpamThreshold: { type: Number, }, - moderationLogChannel: { + starboardChannel: { type: String, unique: true, sparse: true, }, - serverLogChannel: { + starboardThreshold: { + type: Number, + }, + moderationLogChannel: { type: String, unique: true, sparse: true, }, - suggestionsChannel: { + serverLogChannel: { type: String, unique: true, sparse: true, }, - starboardChannel: { + suggestionsChannel: { type: String, unique: true, sparse: true, @@ -196,6 +202,9 @@ export default mongoose.model("Guild", new mongoose.S unique: true, sparse: true, }, + autoThreadChannels: { + type: [ String ], + }, votingChannels: { type: [ String ], }, diff --git a/src/routes/status.ts b/src/routes/status.ts index 0821461d8..42ffc652d 100644 --- a/src/routes/status.ts +++ b/src/routes/status.ts @@ -15,7 +15,7 @@ router.get("/", auth, async (req: Request, res: Response, next: NextFunction): P const shardingManger: ShardingManager = req.app.get("shard-manager"); const shards = await shardingManger.broadcastEval(client => ({ - shard: client.shard.ids.join(" / "), + id: client.shard.ids.join(" / "), uptime: client.uptime, wsStatus: client.ws.status, wsPing: client.ws.ping,