diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 297da24c..6e776961 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -72,3 +72,13 @@ export function getBaseUrl(pluginData: PluginData) { const knub = pluginData.getKnubInstance() as TZeppelinKnub; return knub.getGlobalConfig().url; } + +export function isOwner(pluginData: PluginData, userId: string) { + const knub = pluginData.getKnubInstance() as TZeppelinKnub; + const owners = knub.getGlobalConfig().owners; + if (!owners) { + return false; + } + + return owners.includes(userId); +} diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts new file mode 100644 index 00000000..599977db --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts @@ -0,0 +1,16 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { ChannelArchiverPluginType } from "./types"; +import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd"; + +export const ChannelArchiverPlugin = zeppelinPlugin()("channel_archiver", { + showInDocs: false, + + // prettier-ignore + commands: [ + ArchiveChannelCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + }, +}); diff --git a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts new file mode 100644 index 00000000..61eff881 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts @@ -0,0 +1,110 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { channelArchiverCmd } from "../types"; +import { isOwner, sendErrorMessage } from "src/pluginUtils"; +import { confirm, SECONDS, noop } from "src/utils"; +import moment from "moment-timezone"; +import { rehostAttachment } from "../rehostAttachment"; + +const MAX_ARCHIVED_MESSAGES = 5000; +const MAX_MESSAGES_PER_FETCH = 100; +const PROGRESS_UPDATE_INTERVAL = 5 * SECONDS; + +export const ArchiveChannelCmd = channelArchiverCmd({ + trigger: "archive_channel", + permission: null, + + config: { + preFilters: [ + (command, context) => { + return isOwner(context.pluginData, context.message.author.id); + }, + ], + }, + + signature: { + channel: ct.textChannel(), + + "attachment-channel": ct.textChannel({ option: true }), + messages: ct.number({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + if (!args["attachment-channel"]) { + const confirmed = await confirm( + pluginData.client, + msg.channel, + msg.author.id, + "No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.", + ); + if (!confirmed) { + sendErrorMessage(pluginData, msg.channel, "Canceled"); + return; + } + } + + const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES; + if (maxMessagesToArchive <= 0) return; + + const archiveLines = []; + let archivedMessages = 0; + let previousId; + + const startTime = Date.now(); + const progressMsg = await msg.channel.createMessage("Creating archive..."); + const progressUpdateInterval = setInterval(() => { + const secondsSinceStart = Math.round((Date.now() - startTime) / 1000); + progressMsg + .edit(`Creating archive...\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`) + .catch(() => clearInterval(progressUpdateInterval)); + }, PROGRESS_UPDATE_INTERVAL); + + while (archivedMessages < maxMessagesToArchive) { + const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages); + const messages = await args.channel.getMessages(messagesToFetch, previousId); + if (messages.length === 0) break; + + for (const message of messages) { + const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss"); + let content = `[${ts}] [${message.author.id}] [${message.author.username}#${ + message.author.discriminator + }]: ${message.content || ""}`; + + if (message.attachments.length) { + if (args["attachment-channel"]) { + const rehostedAttachmentUrl = await rehostAttachment(message.attachments[0], args["attachment-channel"]); + content += `\n-- Attachment: ${rehostedAttachmentUrl}`; + } else { + content += `\n-- Attachment: ${message.attachments[0].url}`; + } + } + + if (message.reactions && Object.keys(message.reactions).length > 0) { + const reactionCounts = []; + for (const [emoji, info] of Object.entries(message.reactions)) { + reactionCounts.push(`${info.count}x ${emoji}`); + } + content += `\n-- Reactions: ${reactionCounts.join(", ")}`; + } + + archiveLines.push(content); + previousId = message.id; + archivedMessages++; + } + } + + clearInterval(progressUpdateInterval); + + archiveLines.reverse(); + + const nowTs = moment().format("YYYY-MM-DD HH:mm:ss"); + + let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`; + result += `\n\n${archiveLines.join("\n")}\n`; + + progressMsg.delete().catch(noop); + msg.channel.createMessage("Archive created!", { + file: Buffer.from(result), + name: `archive-${args.channel.name}-${moment().format("YYYY-MM-DD-HH-mm-ss")}.txt`, + }); + }, +}); diff --git a/backend/src/plugins/ChannelArchiver/rehostAttachment.ts b/backend/src/plugins/ChannelArchiver/rehostAttachment.ts new file mode 100644 index 00000000..0b12e360 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/rehostAttachment.ts @@ -0,0 +1,29 @@ +import { Attachment, TextChannel } from "eris"; +import { downloadFile } from "src/utils"; +import fs from "fs"; +const fsp = fs.promises; + +const MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8; + +export async function rehostAttachment(attachment: Attachment, targetChannel: TextChannel): Promise { + if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) { + return "Attachment too big to rehost"; + } + + let downloaded; + try { + downloaded = await downloadFile(attachment.url, 3); + } catch (e) { + return "Failed to download attachment after 3 tries"; + } + + try { + const rehostMessage = await targetChannel.createMessage(`Rehost of attachment ${attachment.id}`, { + name: attachment.filename, + file: await fsp.readFile(downloaded.path), + }); + return rehostMessage.attachments[0].url; + } catch (e) { + return "Failed to rehost attachment"; + } +} diff --git a/backend/src/plugins/ChannelArchiver/types.ts b/backend/src/plugins/ChannelArchiver/types.ts new file mode 100644 index 00000000..cbed3e52 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/types.ts @@ -0,0 +1,7 @@ +import { BasePluginType, command } from "knub"; + +export interface ChannelArchiverPluginType extends BasePluginType { + state: {}; +} + +export const channelArchiverCmd = command(); diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts new file mode 100644 index 00000000..91154385 --- /dev/null +++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts @@ -0,0 +1,65 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { SlowmodePluginType, ConfigSchema } from "./types"; +import { GuildSlowmodes } from "src/data/GuildSlowmodes"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildLogs } from "src/data/GuildLogs"; +import { SECONDS } from "src/utils"; +import { onMessageCreate } from "./util/onMessageCreate"; +import { clearExpiredSlowmodes } from "./util/clearExpiredSlowmodes"; +import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd"; +import { SlowmodeClearCmd } from "./commands/SlowmodeClearCmd"; +import { SlowmodeListCmd } from "./commands/SlowmodeListCmd"; +import { SlowmodeGetChannelCmd } from "./commands/SlowmodeGetChannelCmd"; +import { SlowmodeSetChannelCmd } from "./commands/SlowmodeSetChannelCmd"; + +const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * SECONDS; + +const defaultOptions: PluginOptions = { + config: { + use_native_slowmode: true, + + can_manage: false, + is_affected: true, + }, + + overrides: [ + { + level: ">=50", + config: { + can_manage: true, + is_affected: false, + }, + }, + ], +}; + +export const SlowmodePlugin = zeppelinPlugin()("slowmode", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + SlowmodeDisableCmd, + SlowmodeClearCmd, + SlowmodeListCmd, + SlowmodeGetChannelCmd, + SlowmodeSetChannelCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.slowmodes = GuildSlowmodes.getGuildInstance(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.logs = new GuildLogs(guild.id); + state.clearInterval = setInterval(() => clearExpiredSlowmodes(pluginData), BOT_SLOWMODE_CLEAR_INTERVAL); + + state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.savedMessages.events.on("create", state.onMessageCreateFn); + }, + + onUnload(pluginData) { + pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn); + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts new file mode 100644 index 00000000..a5806b41 --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeClearCmd.ts @@ -0,0 +1,40 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { slowmodeCmd } from "../types"; +import { clearBotSlowmodeFromUserId } from "../util/clearBotSlowmodeFromUserId"; + +export const SlowmodeClearCmd = slowmodeCmd({ + trigger: ["slowmode clear", "slowmode c"], + permission: "can_manage", + + signature: { + channel: ct.textChannel(), + user: ct.resolvedUserLoose(), + + force: ct.bool({ option: true, isSwitch: true }), + }, + + async run({ message: msg, args, pluginData }) { + const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id); + if (!channelSlowmode) { + sendErrorMessage(pluginData, msg.channel, "Channel doesn't have slowmode!"); + return; + } + + try { + await clearBotSlowmodeFromUserId(pluginData, args.channel, args.user.id, args.force); + } catch (e) { + return sendErrorMessage( + pluginData, + msg.channel, + `Failed to clear slowmode from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`, + ); + } + + sendSuccessMessage( + pluginData, + msg.channel, + `Slowmode cleared from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`, + ); + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts new file mode 100644 index 00000000..20294f2d --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeDisableCmd.ts @@ -0,0 +1,20 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { slowmodeCmd } from "../types"; +import { disableBotSlowmodeForChannel } from "../util/disableBotSlowmodeForChannel"; +import { noop } from "src/utils"; +import { actualDisableSlowmodeCmd } from "../util/actualDisableSlowmodeCmd"; + +export const SlowmodeDisableCmd = slowmodeCmd({ + trigger: ["slowmode disable", "slowmode d"], + permission: "can_manage", + + signature: { + channel: ct.textChannel(), + }, + + async run({ message: msg, args, pluginData }) { + // Workaround until you can call this cmd from SlowmodeSetChannelCmd + actualDisableSlowmodeCmd(msg, args, pluginData); + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts new file mode 100644 index 00000000..589f1373 --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeGetChannelCmd.ts @@ -0,0 +1,37 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { slowmodeCmd } from "../types"; +import { TextChannel } from "eris"; +import humanizeDuration from "humanize-duration"; + +export const SlowmodeGetChannelCmd = slowmodeCmd({ + trigger: "slowmode", + permission: "can_manage", + source: "guild", + + signature: { + channel: ct.textChannel({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + const channel = args.channel || (msg.channel as TextChannel); + + let currentSlowmode = channel.rateLimitPerUser; + let isNative = true; + + if (!currentSlowmode) { + const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); + if (botSlowmode) { + currentSlowmode = botSlowmode.slowmode_seconds; + isNative = false; + } + } + + if (currentSlowmode) { + const humanized = humanizeDuration(channel.rateLimitPerUser * 1000); + const slowmodeType = isNative ? "native" : "bot-maintained"; + msg.channel.createMessage(`The current slowmode of <#${channel.id}> is **${humanized}** (${slowmodeType})`); + } else { + msg.channel.createMessage("Channel is not on slowmode"); + } + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts new file mode 100644 index 00000000..422cb503 --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeListCmd.ts @@ -0,0 +1,46 @@ +import { slowmodeCmd } from "../types"; +import { GuildChannel, TextChannel } from "eris"; +import { createChunkedMessage } from "knub/dist/helpers"; +import { errorMessage } from "src/utils"; +import humanizeDuration from "humanize-duration"; + +export const SlowmodeListCmd = slowmodeCmd({ + trigger: ["slowmode list", "slowmode l", "slowmodes"], + permission: "can_manage", + + async run({ message: msg, pluginData }) { + const channels = pluginData.guild.channels; + const slowmodes: Array<{ channel: GuildChannel; seconds: number; native: boolean }> = []; + + for (const channel of channels.values()) { + if (!(channel instanceof TextChannel)) continue; + + // Bot slowmode + const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); + if (botSlowmode) { + slowmodes.push({ channel, seconds: botSlowmode.slowmode_seconds, native: false }); + continue; + } + + // Native slowmode + if (channel.rateLimitPerUser) { + slowmodes.push({ channel, seconds: channel.rateLimitPerUser, native: true }); + continue; + } + } + + if (slowmodes.length) { + const lines = slowmodes.map(slowmode => { + const humanized = humanizeDuration(slowmode.seconds * 1000); + + const type = slowmode.native ? "native slowmode" : "bot slowmode"; + + return `<#${slowmode.channel.id}> **${humanized}** ${type}`; + }); + + createChunkedMessage(msg.channel, lines.join("\n")); + } else { + msg.channel.createMessage(errorMessage("No active slowmodes!")); + } + }, +}); diff --git a/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts b/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts new file mode 100644 index 00000000..ab59ce26 --- /dev/null +++ b/backend/src/plugins/Slowmode/commands/SlowmodeSetChannelCmd.ts @@ -0,0 +1,93 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { slowmodeCmd } from "../types"; +import { TextChannel } from "eris"; +import humanizeDuration from "humanize-duration"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { convertDelayStringToMS, HOURS, DAYS } from "src/utils"; +import { disableBotSlowmodeForChannel } from "../util/disableBotSlowmodeForChannel"; +import { actualDisableSlowmodeCmd } from "../util/actualDisableSlowmodeCmd"; + +const NATIVE_SLOWMODE_LIMIT = 6 * HOURS; // 6 hours +const MAX_SLOWMODE = DAYS * 365 * 100; // 100 years + +export const SlowmodeSetChannelCmd = slowmodeCmd({ + trigger: "slowmode", + permission: "can_manage", + source: "guild", + + // prettier-ignore + signature: [ + { + time: ct.string(), + }, + { + channel: ct.textChannel(), + time: ct.string(), + } + ], + + async run({ message: msg, args, pluginData }) { + const channel = args.channel || msg.channel; + + if (channel == null || !(channel instanceof TextChannel)) { + sendErrorMessage(pluginData, msg.channel, "Channel must be a text channel"); + return; + } + + const seconds = Math.ceil(convertDelayStringToMS(args.time, "s") / 1000); + const useNativeSlowmode = + pluginData.config.getForChannel(channel).use_native_slowmode && seconds <= NATIVE_SLOWMODE_LIMIT; + + if (seconds === 0) { + // Workaround until we can call SlowmodeDisableCmd from here + return actualDisableSlowmodeCmd(msg, { channel }, pluginData); + } + + if (seconds > MAX_SLOWMODE) { + sendErrorMessage( + pluginData, + msg.channel, + `Sorry, slowmodes can be at most 100 years long. Maybe 99 would be enough?`, + ); + return; + } + + if (useNativeSlowmode) { + // Native slowmode + + // If there is an existing bot-maintained slowmode, disable that first + const existingBotSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); + if (existingBotSlowmode) { + await disableBotSlowmodeForChannel(pluginData, channel); + } + + // Set slowmode + try { + await channel.edit({ + rateLimitPerUser: seconds, + }); + } catch (e) { + return sendErrorMessage(pluginData, msg.channel, "Failed to set native slowmode (check permissions)"); + } + } else { + // Bot-maintained slowmode + + // If there is an existing native slowmode, disable that first + if (channel.rateLimitPerUser) { + await channel.edit({ + rateLimitPerUser: 0, + }); + } + + await pluginData.state.slowmodes.setChannelSlowmode(channel.id, seconds); + } + + const humanizedSlowmodeTime = humanizeDuration(seconds * 1000); + const slowmodeType = useNativeSlowmode ? "native slowmode" : "bot-maintained slowmode"; + sendSuccessMessage( + pluginData, + msg.channel, + `Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`, + ); + }, +}); diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts new file mode 100644 index 00000000..0f956644 --- /dev/null +++ b/backend/src/plugins/Slowmode/types.ts @@ -0,0 +1,28 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener, command } from "knub"; +import { GuildSlowmodes } from "src/data/GuildSlowmodes"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildLogs } from "src/data/GuildLogs"; + +export const ConfigSchema = t.type({ + use_native_slowmode: t.boolean, + + can_manage: t.boolean, + is_affected: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface SlowmodePluginType extends BasePluginType { + config: TConfigSchema; + state: { + slowmodes: GuildSlowmodes; + savedMessages: GuildSavedMessages; + logs: GuildLogs; + clearInterval: NodeJS.Timeout; + + onMessageCreateFn; + }; +} + +export const slowmodeCmd = command(); +export const slowmodeEvt = eventListener(); diff --git a/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts new file mode 100644 index 00000000..948c474a --- /dev/null +++ b/backend/src/plugins/Slowmode/util/actualDisableSlowmodeCmd.ts @@ -0,0 +1,39 @@ +import { Message } from "eris"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { disableBotSlowmodeForChannel } from "./disableBotSlowmodeForChannel"; +import { noop } from "src/utils"; + +export async function actualDisableSlowmodeCmd(msg: Message, args, pluginData) { + const botSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(args.channel.id); + const hasNativeSlowmode = args.channel.rateLimitPerUser; + + if (!botSlowmode && hasNativeSlowmode === 0) { + sendErrorMessage(pluginData, msg.channel, "Channel is not on slowmode!"); + return; + } + + const initMsg = await msg.channel.createMessage("Disabling slowmode..."); + + // Disable bot-maintained slowmode + let failedUsers = []; + if (botSlowmode) { + const result = await disableBotSlowmodeForChannel(pluginData, args.channel); + failedUsers = result.failedUsers; + } + + // Disable native slowmode + if (hasNativeSlowmode) { + await args.channel.edit({ rateLimitPerUser: 0 }); + } + + if (failedUsers.length) { + sendSuccessMessage( + pluginData, + msg.channel, + `Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`, + ); + } else { + sendSuccessMessage(pluginData, msg.channel, "Slowmode disabled!"); + initMsg.delete().catch(noop); + } +} diff --git a/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts b/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts new file mode 100644 index 00000000..0b22a738 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/applyBotSlowmodeToUserId.ts @@ -0,0 +1,43 @@ +import { SlowmodePluginType } from "../types"; +import { PluginData } from "knub"; +import { GuildChannel, TextChannel, Constants } from "eris"; +import { UnknownUser, isDiscordRESTError, stripObjectToScalars } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { logger } from "src/logger"; + +export async function applyBotSlowmodeToUserId( + pluginData: PluginData, + channel: GuildChannel & TextChannel, + userId: string, +) { + // Deny sendMessage permission from the user. If there are existing permission overwrites, take those into account. + const existingOverride = channel.permissionOverwrites.get(userId); + const newDeniedPermissions = (existingOverride ? existingOverride.deny : 0) | Constants.Permissions.sendMessages; + const newAllowedPermissions = (existingOverride ? existingOverride.allow : 0) & ~Constants.Permissions.sendMessages; + + try { + await channel.editPermission(userId, newAllowedPermissions, newDeniedPermissions, "member"); + } catch (e) { + const user = pluginData.client.users.get(userId) || new UnknownUser({ id: userId }); + + if (isDiscordRESTError(e) && e.code === 50013) { + logger.warn( + `Missing permissions to apply bot slowmode to user ${userId} on channel ${channel.name} (${channel.id}) on server ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Missing permissions to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`, + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + }); + } else { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`, + user: stripObjectToScalars(user), + channel: stripObjectToScalars(channel), + }); + throw e; + } + } + + await pluginData.state.slowmodes.addSlowmodeUser(channel.id, userId); +} diff --git a/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts b/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts new file mode 100644 index 00000000..dcde2322 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/clearBotSlowmodeFromUserId.ts @@ -0,0 +1,24 @@ +import { PluginData } from "knub"; +import { SlowmodePluginType } from "../types"; +import { GuildChannel, TextChannel } from "eris"; + +export async function clearBotSlowmodeFromUserId( + pluginData: PluginData, + channel: GuildChannel & TextChannel, + userId: string, + force = false, +) { + try { + // Remove permission overrides from the channel for this user + // Previously we diffed the overrides so we could clear the "send messages" override without touching other + // overrides. Unfortunately, it seems that was a bit buggy - we didn't always receive the event for the changed + // overrides and then we also couldn't diff against them. For consistency's sake, we just delete the override now. + await channel.deletePermission(userId); + } catch (e) { + if (!force) { + throw e; + } + } + + await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, userId); +} diff --git a/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts b/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts new file mode 100644 index 00000000..1f0889a9 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/clearExpiredSlowmodes.ts @@ -0,0 +1,31 @@ +import { PluginData } from "knub"; +import { SlowmodePluginType } from "../types"; +import { LogType } from "src/data/LogType"; +import { logger } from "src/logger"; +import { GuildChannel, TextChannel } from "eris"; +import { UnknownUser, stripObjectToScalars } from "src/utils"; +import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId"; + +export async function clearExpiredSlowmodes(pluginData: PluginData) { + const expiredSlowmodeUsers = await pluginData.state.slowmodes.getExpiredSlowmodeUsers(); + for (const user of expiredSlowmodeUsers) { + const channel = pluginData.guild.channels.get(user.channel_id); + if (!channel) { + await pluginData.state.slowmodes.clearSlowmodeUser(user.channel_id, user.user_id); + continue; + } + + try { + await clearBotSlowmodeFromUserId(pluginData, channel as GuildChannel & TextChannel, user.user_id); + } catch (e) { + logger.error(e); + + const realUser = pluginData.client.users.get(user.user_id) || new UnknownUser({ id: user.user_id }); + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed to clear slowmode permissions from {userMention(user)} in {channelMention(channel)}`, + user: stripObjectToScalars(realUser), + channel: stripObjectToScalars(channel), + }); + } + } +} diff --git a/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts b/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts new file mode 100644 index 00000000..f505a9c1 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/disableBotSlowmodeForChannel.ts @@ -0,0 +1,28 @@ +import { GuildChannel, TextChannel } from "eris"; +import { PluginData } from "knub"; +import { SlowmodePluginType } from "../types"; +import { clearBotSlowmodeFromUserId } from "./clearBotSlowmodeFromUserId"; + +export async function disableBotSlowmodeForChannel( + pluginData: PluginData, + channel: GuildChannel & TextChannel, +) { + // Disable channel slowmode + await pluginData.state.slowmodes.deleteChannelSlowmode(channel.id); + + // Remove currently applied slowmodes + const users = await pluginData.state.slowmodes.getChannelSlowmodeUsers(channel.id); + const failedUsers = []; + + for (const slowmodeUser of users) { + try { + await clearBotSlowmodeFromUserId(pluginData, channel, slowmodeUser.user_id); + } catch (e) { + // Removing the slowmode failed. Record this so the permissions can be changed manually, and remove the database entry. + failedUsers.push(slowmodeUser.user_id); + await pluginData.state.slowmodes.clearSlowmodeUser(channel.id, slowmodeUser.user_id); + } + } + + return { failedUsers }; +} diff --git a/backend/src/plugins/Slowmode/util/onMessageCreate.ts b/backend/src/plugins/Slowmode/util/onMessageCreate.ts new file mode 100644 index 00000000..13bebcd3 --- /dev/null +++ b/backend/src/plugins/Slowmode/util/onMessageCreate.ts @@ -0,0 +1,42 @@ +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { GuildChannel, TextChannel } from "eris"; +import { PluginData } from "knub"; +import { SlowmodePluginType } from "../types"; +import { resolveMember } from "src/utils"; +import { applyBotSlowmodeToUserId } from "./applyBotSlowmodeToUserId"; +import { hasPermission } from "src/pluginUtils"; + +export async function onMessageCreate(pluginData: PluginData, msg: SavedMessage) { + if (msg.is_bot) return; + + const channel = pluginData.guild.channels.get(msg.channel_id) as GuildChannel & TextChannel; + if (!channel) return; + + // Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters) + const thisMsgLock = await pluginData.locks.acquire(`message-${msg.id}`); + if (thisMsgLock.interrupted) return; + + // Check if this channel even *has* a bot-maintained slowmode + const channelSlowmode = await pluginData.state.slowmodes.getChannelSlowmode(channel.id); + if (!channelSlowmode) return thisMsgLock.unlock(); + + // Make sure this user is affected by the slowmode + const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); + const isAffected = hasPermission(pluginData, "is_affected", { channelId: channel.id, userId: msg.user_id, member }); + if (!isAffected) return thisMsgLock.unlock(); + + // Delete any extra messages sent after a slowmode was already applied + const userHasSlowmode = await pluginData.state.slowmodes.userHasSlowmode(channel.id, msg.user_id); + if (userHasSlowmode) { + const message = await channel.getMessage(msg.id); + if (message) { + message.delete(); + return thisMsgLock.interrupt(); + } + + return thisMsgLock.unlock(); + } + + await applyBotSlowmodeToUserId(pluginData, channel, msg.user_id); + thisMsgLock.unlock(); +} diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts new file mode 100644 index 00000000..5d251077 --- /dev/null +++ b/backend/src/plugins/Starboard/StarboardPlugin.ts @@ -0,0 +1,118 @@ +import { PluginOptions } from "knub"; +import { ConfigSchema, StarboardPluginType } from "./types"; +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { trimPluginDescription } from "src/utils"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildStarboardMessages } from "src/data/GuildStarboardMessages"; +import { GuildStarboardReactions } from "src/data/GuildStarboardReactions"; +import { onMessageDelete } from "./util/onMessageDelete"; +import { MigratePinsCmd } from "./commands/MigratePinsCmd"; +import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt"; +import { StarboardReactionRemoveEvt, StarboardReactionRemoveAllEvt } from "./events/StarboardReactionRemoveEvts"; + +const defaultOptions: PluginOptions = { + config: { + can_migrate: false, + boards: {}, + }, + + overrides: [ + { + level: ">=100", + config: { + can_migrate: true, + }, + }, + ], +}; + +export const StarboardPlugin = zeppelinPlugin()("starboard", { + configSchema: ConfigSchema, + defaultOptions, + + info: { + prettyName: "Starboard", + description: trimPluginDescription(` + This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel. + `), + configurationGuide: trimPluginDescription(` + ### Note on emojis + To specify emoji in the config, you need to use the emoji's "raw form". + To obtain this, post the emoji with a backslash in front of it. + + - Example with a default emoji: "\:star:" => "⭐" + - Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>" + + ### Basic starboard + Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226). + + ~~~yml + starboard: + config: + boards: + basic: + channel_id: "604342689038729226" + stars_required: 5 + ~~~ + + ### Custom star emoji + This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji + + ~~~yml + starboard: + config: + boards: + basic: + channel_id: "604342689038729226" + star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"] + stars_required: 5 + ~~~ + + ### Limit starboard to a specific channel + This is identical to the basic starboard above, but only works from a specific channel (473087035574321152). + + ~~~yml + starboard: + config: + boards: + basic: + enabled: false # The starboard starts disabled and is then enabled in a channel override below + channel_id: "604342689038729226" + stars_required: 5 + overrides: + - channel: "473087035574321152" + config: + boards: + basic: + enabled: true + ~~~ + `), + }, + + // prettier-ignore + commands: [ + MigratePinsCmd, + ], + + // prettier-ignore + events: [ + StarboardReactionAddEvt, + StarboardReactionRemoveEvt, + StarboardReactionRemoveAllEvt, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.starboardMessages = GuildStarboardMessages.getGuildInstance(guild.id); + state.starboardReactions = GuildStarboardReactions.getGuildInstance(guild.id); + + state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.savedMessages.events.on("delete", state.onMessageDeleteFn); + }, + + onUnload(pluginData) { + pluginData.state.savedMessages.events.off("delete", pluginData.state.onMessageDeleteFn); + }, +}); diff --git a/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts new file mode 100644 index 00000000..e37155c3 --- /dev/null +++ b/backend/src/plugins/Starboard/commands/MigratePinsCmd.ts @@ -0,0 +1,52 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { starboardCmd } from "../types"; +import { sendSuccessMessage, sendErrorMessage } from "src/pluginUtils"; +import { TextChannel } from "eris"; +import { saveMessageToStarboard } from "../util/saveMessageToStarboard"; + +export const MigratePinsCmd = starboardCmd({ + trigger: "starboard migrate_pins", + permission: "can_migrate", + + description: "Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.", + + signature: { + pinChannel: ct.textChannel(), + starboardName: ct.string(), + }, + + async run({ message: msg, args, pluginData }) { + const config = await pluginData.config.get(); + const starboard = config.boards[args.starboardName]; + if (!starboard) { + sendErrorMessage(pluginData, msg.channel, "Unknown starboard specified"); + return; + } + + const starboardChannel = pluginData.guild.channels.get(starboard.channel_id); + if (!starboardChannel || !(starboardChannel instanceof TextChannel)) { + sendErrorMessage(pluginData, msg.channel, "Starboard has an unknown/invalid channel id"); + return; + } + + msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`); + + const pins = await args.pinChannel.getPins(); + pins.reverse(); // Migrate pins starting from the oldest message + + for (const pin of pins) { + const existingStarboardMessage = await pluginData.state.starboardMessages.getMatchingStarboardMessages( + starboardChannel.id, + pin.id, + ); + if (existingStarboardMessage.length > 0) continue; + await saveMessageToStarboard(pluginData, pin, starboard); + } + + sendSuccessMessage( + pluginData, + msg.channel, + `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`, + ); + }, +}); diff --git a/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts new file mode 100644 index 00000000..1f8aef28 --- /dev/null +++ b/backend/src/plugins/Starboard/events/StarboardReactionAddEvt.ts @@ -0,0 +1,74 @@ +import { starboardEvt } from "../types"; +import { Message } from "eris"; +import { UnknownUser, resolveMember, noop } from "src/utils"; +import { saveMessageToStarboard } from "../util/saveMessageToStarboard"; + +export const StarboardReactionAddEvt = starboardEvt({ + event: "messageReactionAdd", + + async listener(meta) { + const pluginData = meta.pluginData; + + let msg = meta.args.message as Message; + const userId = meta.args.userID; + const emoji = meta.args.emoji; + + if (!msg.author) { + // Message is not cached, fetch it + try { + msg = await msg.channel.getMessage(msg.id); + } catch (e) { + // Sometimes we get this event for messages we can't fetch with getMessage; ignore silently + return; + } + } + + // No self-votes! + if (msg.author.id === userId) return; + + const user = await resolveMember(pluginData.client, pluginData.guild, userId); + if (user instanceof UnknownUser) return; + if (user.bot) return; + + const config = pluginData.config.getMatchingConfig({ member: user, channelId: msg.channel.id }); + const applicableStarboards = Object.values(config.boards) + .filter(board => board.enabled) + // Can't star messages in the starboard channel itself + .filter(board => board.channel_id !== msg.channel.id) + // Matching emoji + .filter(board => { + return board.star_emoji.some((boardEmoji: string) => { + if (emoji.id) { + // Custom emoji + const customEmojiMatch = boardEmoji.match(/^?$/); + if (customEmojiMatch) { + return customEmojiMatch[1] === emoji.id; + } + + return boardEmoji === emoji.id; + } else { + // Unicode emoji + return emoji.name === boardEmoji; + } + }); + }); + + for (const starboard of applicableStarboards) { + // Save reaction into the database + await pluginData.state.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop); + + // If the message has already been posted to this starboard, we don't need to do anything else + const starboardMessages = await pluginData.state.starboardMessages.getMatchingStarboardMessages( + starboard.channel_id, + msg.id, + ); + if (starboardMessages.length > 0) continue; + + const reactions = await pluginData.state.starboardReactions.getAllReactionsForMessageId(msg.id); + const reactionsCount = reactions.length; + if (reactionsCount >= starboard.stars_required) { + await saveMessageToStarboard(pluginData, msg, starboard); + } + } + }, +}); diff --git a/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts b/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts new file mode 100644 index 00000000..5e15ab90 --- /dev/null +++ b/backend/src/plugins/Starboard/events/StarboardReactionRemoveEvts.ts @@ -0,0 +1,17 @@ +import { starboardEvt } from "../types"; + +export const StarboardReactionRemoveEvt = starboardEvt({ + event: "messageReactionRemove", + + async listener(meta) { + await meta.pluginData.state.starboardReactions.deleteStarboardReaction(meta.args.message.id, meta.args.userID); + }, +}); + +export const StarboardReactionRemoveAllEvt = starboardEvt({ + event: "messageReactionRemoveAll", + + async listener(meta) { + await meta.pluginData.state.starboardReactions.deleteAllStarboardReactionsForMessageId(meta.args.message.id); + }, +}); diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts new file mode 100644 index 00000000..4f382ab8 --- /dev/null +++ b/backend/src/plugins/Starboard/types.ts @@ -0,0 +1,43 @@ +import * as t from "io-ts"; +import { BasePluginType, command, eventListener } from "knub"; +import { tNullable, tDeepPartial } from "src/utils"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildStarboardMessages } from "src/data/GuildStarboardMessages"; +import { GuildStarboardReactions } from "src/data/GuildStarboardReactions"; + +const StarboardOpts = t.type({ + channel_id: t.string, + stars_required: t.number, + star_emoji: tNullable(t.array(t.string)), + copy_full_embed: tNullable(t.boolean), + enabled: tNullable(t.boolean), +}); +export type TStarboardOpts = t.TypeOf; + +export const ConfigSchema = t.type({ + boards: t.record(t.string, StarboardOpts), + can_migrate: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export const PartialConfigSchema = tDeepPartial(ConfigSchema); + +export const defaultStarboardOpts: Partial = { + star_emoji: ["⭐"], + enabled: true, +}; + +export interface StarboardPluginType extends BasePluginType { + config: TConfigSchema; + + state: { + savedMessages: GuildSavedMessages; + starboardMessages: GuildStarboardMessages; + starboardReactions: GuildStarboardReactions; + + onMessageDeleteFn; + }; +} + +export const starboardCmd = command(); +export const starboardEvt = eventListener(); diff --git a/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts b/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts new file mode 100644 index 00000000..1c32b42e --- /dev/null +++ b/backend/src/plugins/Starboard/util/getStarboardOptsForStarboardChannel.ts @@ -0,0 +1,19 @@ +import { TStarboardOpts, StarboardPluginType, defaultStarboardOpts } from "../types"; +import { PluginData } from "knub"; + +export function getStarboardOptsForStarboardChannel( + pluginData: PluginData, + starboardChannel, +): TStarboardOpts[] { + const config = pluginData.config.getForChannel(starboardChannel); + + const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id); + configs.forEach(cfg => { + if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled; + if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji; + if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required; + if (cfg.copy_full_embed == null) cfg.copy_full_embed = false; + }); + + return configs; +} diff --git a/backend/src/plugins/Starboard/util/onMessageDelete.ts b/backend/src/plugins/Starboard/util/onMessageDelete.ts new file mode 100644 index 00000000..c79e3da8 --- /dev/null +++ b/backend/src/plugins/Starboard/util/onMessageDelete.ts @@ -0,0 +1,27 @@ +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { PluginData } from "knub"; +import { StarboardPluginType } from "../types"; +import { removeMessageFromStarboard } from "./removeMessageFromStarboard"; +import { removeMessageFromStarboardMessages } from "./removeMessageFromStarboardMessages"; + +export async function onMessageDelete(pluginData: PluginData, msg: SavedMessage) { + // Deleted source message + const starboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForMessageId(msg.id); + for (const starboardMessage of starboardMessages) { + removeMessageFromStarboard(pluginData, starboardMessage); + } + + // Deleted message from the starboard + const deletedStarboardMessages = await pluginData.state.starboardMessages.getStarboardMessagesForStarboardMessageId( + msg.id, + ); + if (deletedStarboardMessages.length === 0) return; + + for (const starboardMessage of deletedStarboardMessages) { + removeMessageFromStarboardMessages( + pluginData, + starboardMessage.starboard_message_id, + starboardMessage.starboard_channel_id, + ); + } +} diff --git a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts new file mode 100644 index 00000000..b690fbf8 --- /dev/null +++ b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts @@ -0,0 +1,12 @@ +import { PartialConfigSchema, defaultStarboardOpts } from "../types"; +import * as t from "io-ts"; + +export function preprocessStaticConfig(config: t.TypeOf) { + if (config.boards) { + for (const [name, opts] of Object.entries(config.boards)) { + config.boards[name] = Object.assign({}, defaultStarboardOpts, config.boards[name]); + } + } + + return config; +} diff --git a/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts b/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts new file mode 100644 index 00000000..6c790b5d --- /dev/null +++ b/backend/src/plugins/Starboard/util/removeMessageFromStarboard.ts @@ -0,0 +1,6 @@ +import { StarboardMessage } from "src/data/entities/StarboardMessage"; +import { noop } from "src/utils"; + +export async function removeMessageFromStarboard(pluginData, msg: StarboardMessage) { + await pluginData.client.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop); +} diff --git a/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts b/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts new file mode 100644 index 00000000..1d00177d --- /dev/null +++ b/backend/src/plugins/Starboard/util/removeMessageFromStarboardMessages.ts @@ -0,0 +1,3 @@ +export async function removeMessageFromStarboardMessages(pluginData, starboard_message_id: string, channel_id: string) { + await pluginData.state.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id); +} diff --git a/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts b/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts new file mode 100644 index 00000000..c060f8b5 --- /dev/null +++ b/backend/src/plugins/Starboard/util/saveMessageToStarboard.ts @@ -0,0 +1,70 @@ +import { PluginData } from "knub"; +import { StarboardPluginType, TStarboardOpts } from "../types"; +import { Message, GuildChannel, TextChannel, Embed } from "eris"; +import moment from "moment-timezone"; +import { EMPTY_CHAR, messageLink } from "src/utils"; +import path from "path"; + +export async function saveMessageToStarboard( + pluginData: PluginData, + msg: Message, + starboard: TStarboardOpts, +) { + const channel = pluginData.guild.channels.get(starboard.channel_id); + if (!channel) return; + + const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]"); + + const embed: Embed = { + footer: { + text: `#${(msg.channel as GuildChannel).name}`, + }, + author: { + name: `${msg.author.username}#${msg.author.discriminator}`, + }, + fields: [], + timestamp: new Date(msg.timestamp).toISOString(), + type: "rich", + }; + + if (msg.author.avatarURL) { + embed.author.icon_url = msg.author.avatarURL; + } + + if (msg.content) { + embed.description = msg.content; + } + + // Merge media and - if copy_full_embed is enabled - fields and title from the first embed in the original message + if (msg.embeds.length > 0) { + if (msg.embeds[0].image) embed.image = msg.embeds[0].image; + + if (starboard.copy_full_embed) { + if (msg.embeds[0].title) { + const titleText = msg.embeds[0].url ? `[${msg.embeds[0].title}](${msg.embeds[0].url})` : msg.embeds[0].title; + embed.fields.push({ name: EMPTY_CHAR, value: titleText }); + } + + if (msg.embeds[0].fields) embed.fields.push(...msg.embeds[0].fields); + } + } + + // If there are no embeds, add the first image attachment explicitly + else if (msg.attachments.length) { + for (const attachment of msg.attachments) { + const ext = path + .extname(attachment.filename) + .slice(1) + .toLowerCase(); + if (!["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) continue; + + embed.image = { url: attachment.url }; + break; + } + } + + embed.fields.push({ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }); + + const starboardMessage = await (channel as TextChannel).createMessage({ embed }); + await pluginData.state.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index aad4e1d6..ad0f7f8a 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -14,10 +14,14 @@ import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; import { RolesPlugin } from "./Roles/RolesPlugin"; +import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin"; +import { StarboardPlugin } from "./Starboard/StarboardPlugin"; +import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; // prettier-ignore export const guildPlugins: Array> = [ AutoReactionsPlugin, + ChannelArchiverPlugin, LocateUserPlugin, PersistPlugin, PingableRolesPlugin, @@ -25,6 +29,8 @@ export const guildPlugins: Array> = [ NameHistoryPlugin, RemindersPlugin, RolesPlugin, + SlowmodePlugin, + StarboardPlugin, TagsPlugin, UsernameSaverPlugin, UtilityPlugin,