commit 660f8e3888f2676f9f6c18e4912ec3c19d9d26a8 Author: David D Wang Date: Thu Jan 12 20:25:38 2023 -0800 Squashed 'patches/' content from commit 5c7f6995 git-subtree-dir: patches git-subtree-split: 5c7f6995ab142df9029d54441c45f1a905b0c691 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..67045665 --- /dev/null +++ b/.gitignore @@ -0,0 +1,104 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..2716b969 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Zeppelin"] + path = Zeppelin + url = https://github.com/ZeppelinBot/Zeppelin diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1f7fe7f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Zeppelin Hangar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..8d4837ea --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# community-patch + +List of community-made patches for self-hosting. + +Check out our [guide page](https://zepdocs.i0.tf/guides/patch/intro) for more information. diff --git a/Zeppelin b/Zeppelin new file mode 160000 index 00000000..8445c37f --- /dev/null +++ b/Zeppelin @@ -0,0 +1 @@ +Subproject commit 8445c37f6410683b7b1b53811cf49eee61b9068a diff --git a/patches/0001-creating-select-menu-role.patch b/patches/0001-creating-select-menu-role.patch new file mode 100644 index 00000000..25645e87 --- /dev/null +++ b/patches/0001-creating-select-menu-role.patch @@ -0,0 +1,792 @@ +From 1f18cfe51543c8990a0921aa31a4f548959fa032 Mon Sep 17 00:00:00 2001 +From: Jernik +Date: Fri, 8 Apr 2022 14:20:26 -0500 +Subject: [PATCH] creating select-menu role patch + +--- + backend/src/data/GuildSelectRoles.ts | 58 +++++++++ + backend/src/data/entities/SelectRole.ts | 24 ++++ + .../1633452231495-CreateSelectRolesTable.ts | 49 ++++++++ + .../events/ButtonInteractionEvt.ts | 2 +- + .../SelectMenuRoles/SelectMenuRolesPlugin.ts | 115 ++++++++++++++++++ + .../commands/PostSelectRolesCmd.ts | 71 +++++++++++ + .../events/ButtonInteractionEvt.ts | 76 ++++++++++++ + .../events/MessageDeletedEvt.ts | 13 ++ + backend/src/plugins/SelectMenuRoles/types.ts | 65 ++++++++++ + .../util/addMemberPendingRoleChange.ts | 58 +++++++++ + .../util/buttonActionHandlers.ts | 54 ++++++++ + .../util/buttonCustomIdFunctions.ts | 37 ++++++ + .../SelectMenuRoles/util/buttonMenuActions.ts | 3 + + .../util/splitMenusIntoRows.ts | 19 +++ + backend/src/plugins/availablePlugins.ts | 2 + + 15 files changed, 645 insertions(+), 1 deletion(-) + create mode 100644 backend/src/data/GuildSelectRoles.ts + create mode 100644 backend/src/data/entities/SelectRole.ts + create mode 100644 backend/src/migrations/1633452231495-CreateSelectRolesTable.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/SelectMenuRolesPlugin.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/commands/PostSelectRolesCmd.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/events/ButtonInteractionEvt.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/events/MessageDeletedEvt.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/types.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/util/addMemberPendingRoleChange.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/util/buttonActionHandlers.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/util/buttonCustomIdFunctions.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/util/buttonMenuActions.ts + create mode 100644 backend/src/plugins/SelectMenuRoles/util/splitMenusIntoRows.ts + +diff --git a/backend/src/data/GuildSelectRoles.ts b/backend/src/data/GuildSelectRoles.ts +new file mode 100644 +index 00000000..c0cbff1f +--- /dev/null ++++ b/backend/src/data/GuildSelectRoles.ts +@@ -0,0 +1,58 @@ ++import { getRepository, Repository } from "typeorm"; ++import { BaseGuildRepository } from "./BaseGuildRepository"; ++import { SelectRole } from "./entities/SelectRole"; ++ ++export class GuildSelectRoles extends BaseGuildRepository { ++ private selectRoles: Repository; ++ ++ constructor(guildId) { ++ super(guildId); ++ this.selectRoles = getRepository(SelectRole); ++ } ++ ++ async getForSelectId(selectId: string) { ++ return this.selectRoles.findOne({ ++ guild_id: this.guildId, ++ menu_id: selectId, ++ }); ++ } ++ ++ async getAllForMessageId(messageId: string) { ++ return this.selectRoles.find({ ++ guild_id: this.guildId, ++ message_id: messageId, ++ }); ++ } ++ ++ async removeForButtonId(selectId: string) { ++ return this.selectRoles.delete({ ++ guild_id: this.guildId, ++ menu_id: selectId, ++ }); ++ } ++ ++ async removeAllForMessageId(messageId: string) { ++ return this.selectRoles.delete({ ++ guild_id: this.guildId, ++ message_id: messageId, ++ }); ++ } ++ ++ async getForButtonGroup(selectGroup: string) { ++ return this.selectRoles.find({ ++ guild_id: this.guildId, ++ menu_group: selectGroup, ++ }); ++ } ++ ++ async add(channelId: string, messageId: string, menuId: string, menuGroup: string, menuName: string) { ++ await this.selectRoles.insert({ ++ guild_id: this.guildId, ++ channel_id: channelId, ++ message_id: messageId, ++ menu_id: menuId, ++ menu_group: menuGroup, ++ menu_name: menuName, ++ }); ++ } ++} +diff --git a/backend/src/data/entities/SelectRole.ts b/backend/src/data/entities/SelectRole.ts +new file mode 100644 +index 00000000..0a72a4b5 +--- /dev/null ++++ b/backend/src/data/entities/SelectRole.ts +@@ -0,0 +1,24 @@ ++import { Column, Entity, PrimaryColumn } from "typeorm"; ++ ++@Entity("select_roles") ++export class SelectRole { ++ @Column() ++ @PrimaryColumn() ++ guild_id: string; ++ ++ @Column() ++ @PrimaryColumn() ++ channel_id: string; ++ ++ @Column() ++ @PrimaryColumn() ++ message_id: string; ++ ++ @Column() ++ @PrimaryColumn() ++ menu_id: string; ++ ++ @Column() menu_group: string; ++ ++ @Column() menu_name: string; ++} +diff --git a/backend/src/migrations/1633452231495-CreateSelectRolesTable.ts b/backend/src/migrations/1633452231495-CreateSelectRolesTable.ts +new file mode 100644 +index 00000000..9c6b341f +--- /dev/null ++++ b/backend/src/migrations/1633452231495-CreateSelectRolesTable.ts +@@ -0,0 +1,49 @@ ++import { MigrationInterface, QueryRunner, Table } from "typeorm"; ++ ++export class CreateSelectRolesTable1633452231495 implements MigrationInterface { ++ public async up(queryRunner: QueryRunner): Promise { ++ await queryRunner.createTable( ++ new Table({ ++ name: "select_roles", ++ columns: [ ++ { ++ name: "guild_id", ++ type: "bigint", ++ isPrimary: true, ++ }, ++ { ++ name: "channel_id", ++ type: "bigint", ++ isPrimary: true, ++ }, ++ { ++ name: "message_id", ++ type: "bigint", ++ isPrimary: true, ++ }, ++ { ++ name: "menu_id", ++ type: "varchar", ++ length: "100", ++ isPrimary: true, ++ isUnique: true, ++ }, ++ { ++ name: "menu_group", ++ type: "varchar", ++ length: "100", ++ }, ++ { ++ name: "menu_name", ++ type: "varchar", ++ length: "100", ++ }, ++ ], ++ }), ++ ); ++ } ++ ++ public async down(queryRunner: QueryRunner): Promise { ++ await queryRunner.dropTable("select_roles"); ++ } ++} +diff --git a/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts b/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts +index 65ace443..8ff382ca 100644 +--- a/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts ++++ b/backend/src/plugins/ReactionRoles/events/ButtonInteractionEvt.ts +@@ -18,7 +18,7 @@ export const ButtonInteractionEvt = reactionRolesEvt({ + + async listener(meta) { + const int = meta.args.interaction; +- if (!int.isMessageComponent()) return; ++ if (!int.isMessageComponent() || !int.isButton()) return; + + const cfg = meta.pluginData.config.get(); + const split = int.customId.split(BUTTON_CONTEXT_SEPARATOR); +diff --git a/backend/src/plugins/SelectMenuRoles/SelectMenuRolesPlugin.ts b/backend/src/plugins/SelectMenuRoles/SelectMenuRolesPlugin.ts +new file mode 100644 +index 00000000..1aa6a69b +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/SelectMenuRolesPlugin.ts +@@ -0,0 +1,115 @@ ++import { PluginOptions } from "knub"; ++import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; ++import { GuildSelectRoles } from "src/data/GuildSelectRoles"; ++import { Queue } from "src/Queue"; ++import { isValidSnowflake } from "src/utils"; ++import { StrictValidationError } from "src/validatorUtils"; ++import { GuildSavedMessages } from "../../data/GuildSavedMessages"; ++import { LogsPlugin } from "../Logs/LogsPlugin"; ++import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; ++import { PostSelectRolesCmd } from "./commands/PostSelectRolesCmd"; ++import { InteractionEvt } from "./events/ButtonInteractionEvt"; ++import { ConfigSchema, SelectMenuRolesPluginType } from "./types"; ++ ++const defaultOptions: PluginOptions = { ++ config: { ++ select_groups: {}, ++ ++ can_manage: false, ++ }, ++ ++ overrides: [ ++ { ++ level: ">=100", ++ config: { ++ can_manage: true, ++ }, ++ }, ++ ], ++}; ++ ++const MAXIMUM_COMPONENT_ROWS = 5; ++ ++const configPreprocessor: ConfigPreprocessorFn = (options) => { ++ if (options.config.select_groups) { ++ for (const [groupName, group] of Object.entries(options.config.select_groups)) { ++ const defaultSelectMenuNames = Object.keys(group.menus); ++ const defaultMenus = Object.values(group.menus); ++ const menuNames = Object.keys(group.menus ?? []); ++ ++ const defaultMenuRowCount = defaultMenus.length; ++ if (defaultMenuRowCount > MAXIMUM_COMPONENT_ROWS || defaultMenuRowCount === 0) { ++ throw new StrictValidationError([ ++ `Invalid row count for menus: You currently have ${defaultMenuRowCount}, the maximum is 5. A new row is started automatically after each menu.`, ++ ]); ++ } ++ ++ for (let i = 0; i < defaultMenus.length; i++) { ++ const defMenu = defaultMenus[i]; ++ if (defMenu.maxValues && (defMenu.maxValues > 25 || defMenu.maxValues < 1)) { ++ throw new StrictValidationError([ ++ `Invalid value for menus/${defaultSelectMenuNames[i]}/maxValues: Maximum Values Must be between 1 and 25`, ++ ]); ++ } ++ if (defMenu.minValues && (defMenu.minValues > 25 || defMenu.minValues < 0)) { ++ throw new StrictValidationError([ ++ `Invalid value for menus/${defaultSelectMenuNames[i]}/maxValues: Minimum Values Must be between 0 and 25`, ++ ]); ++ } ++ if (defMenu.items.length > 25 || defMenu.items.length === 0) { ++ throw new StrictValidationError([ ++ `Invalid values for menus/${defaultSelectMenuNames[i]}/items: Must have between 1 and 25 items configured`, ++ ]); ++ } ++ for (let i2 = 0; i2 < defMenu.items.length; i2++) { ++ const item = defMenu.items[i2]; ++ ++ if (!isValidSnowflake(item.role)) { ++ throw new StrictValidationError([ ++ `Invalid value for menus/${defaultSelectMenuNames[i]}/items/${i2}/role: ${item.role} is not a valid snowflake.`, ++ ]); ++ } ++ if (!item.label && !item.emoji) { ++ throw new StrictValidationError([ ++ `Invalid values for menus/${defaultSelectMenuNames[i]}/items/${i2}/(label|emoji): Must have label, emoji or both set for the select menu to be valid.`, ++ ]); ++ } ++ } ++ } ++ } ++ } ++ ++ return options; ++}; ++ ++export const SelectMenuRolesPlugin = zeppelinGuildPlugin()({ ++ name: "select_menu_roles", ++ showInDocs: true, ++ info: { ++ prettyName: "Select menu roles", ++ }, ++ ++ dependencies: () => [LogsPlugin], ++ configSchema: ConfigSchema, ++ defaultOptions, ++ ++ // prettier-ignore ++ commands: [ ++ PostSelectRolesCmd, ++ ], ++ ++ // prettier-ignore ++ events: [ ++ InteractionEvt, ++ ], ++ configPreprocessor, ++ ++ beforeLoad(pluginData) { ++ const { state, guild } = pluginData; ++ ++ state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); ++ state.selectMenus = GuildSelectRoles.getGuildInstance(guild.id); ++ state.roleChangeQueue = new Queue(); ++ state.pendingRoleChanges = new Map(); ++ }, ++}); +diff --git a/backend/src/plugins/SelectMenuRoles/commands/PostSelectRolesCmd.ts b/backend/src/plugins/SelectMenuRoles/commands/PostSelectRolesCmd.ts +new file mode 100644 +index 00000000..7f017436 +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/commands/PostSelectRolesCmd.ts +@@ -0,0 +1,71 @@ ++import { createHash } from "crypto"; ++import { MessageButton, MessageSelectMenu, Snowflake, MessageSelectOptionData } from "discord.js"; ++import moment from "moment"; ++import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; ++import { commandTypeHelpers as ct } from "../../../commandTypes"; ++import { selectRolesCmd } from "../types"; ++import { splitButtonsIntoRows } from "../util/splitMenusIntoRows"; ++ ++export const PostSelectRolesCmd = selectRolesCmd({ ++ trigger: "select_roles post", ++ permission: "can_manage", ++ ++ signature: { ++ channel: ct.textChannel(), ++ selectGroup: ct.string(), ++ }, ++ ++ async run({ message: msg, args, pluginData }) { ++ const cfg = pluginData.config.get(); ++ if (!cfg.select_groups) { ++ sendErrorMessage(pluginData, msg.channel, "No select menu groups defined in config"); ++ return; ++ } ++ const group = cfg.select_groups[args.selectGroup]; ++ ++ if (!group) { ++ sendErrorMessage(pluginData, msg.channel, `No select menu group matches the name **${args.selectGroup}**`); ++ return; ++ } ++ ++ const selectMenus: MessageSelectMenu[] = []; ++ const toInsert: Array<{ customId; selectGroup; menuName }> = []; ++ for (const [menuName, menu] of Object.entries(group.menus)) { ++ const customId = createHash("md5").update(`${menuName}${moment.utc().valueOf()}`).digest("hex"); ++ const opts: MessageSelectOptionData[] = []; ++ for (const [k, item] of Object.entries(menu.items)) { ++ opts.push({ ++ label: item.label, ++ description: item.description ?? "", ++ default: item.default ?? false, ++ value: item.role, ++ emoji: item.emoji ? pluginData.client.emojis.resolve(item.emoji as Snowflake) ?? item.emoji : undefined, ++ }); ++ } ++ const slm = new MessageSelectMenu() ++ .setMinValues(menu.minValues ?? 1) ++ .setMaxValues(menu.maxValues ?? opts.length) ++ .setCustomId(customId) ++ .setDisabled(menu.disabled ?? false); ++ if (menu.placeholder) slm.setPlaceholder(menu.placeholder); ++ slm.addOptions(...opts); ++ selectMenus.push(slm); ++ ++ toInsert.push({ customId, selectGroup: args.selectGroup, menuName }); ++ } ++ const rows = splitButtonsIntoRows(selectMenus, Object.values(group.menus)); // new MessageActionRow().addComponents(buttons); ++ ++ try { ++ const newMsg = await args.channel.send({ content: group.message, components: rows }); ++ ++ for (const btn of toInsert) { ++ await pluginData.state.selectMenus.add(args.channel.id, newMsg.id, btn.customId, btn.selectGroup, btn.menuName); ++ } ++ } catch (e) { ++ sendErrorMessage(pluginData, msg.channel, `Error trying to post message: ${e}`); ++ return; ++ } ++ ++ await sendSuccessMessage(pluginData, msg.channel, `Successfully posted message in <#${args.channel.id}>`); ++ }, ++}); +diff --git a/backend/src/plugins/SelectMenuRoles/events/ButtonInteractionEvt.ts b/backend/src/plugins/SelectMenuRoles/events/ButtonInteractionEvt.ts +new file mode 100644 +index 00000000..caf30c42 +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/events/ButtonInteractionEvt.ts +@@ -0,0 +1,76 @@ ++import { MessageComponentInteraction } from "discord.js"; ++import humanizeDuration from "humanize-duration"; ++import moment from "moment"; ++import { logger } from "src/logger"; ++import { LogsPlugin } from "src/plugins/Logs/LogsPlugin"; ++import { MINUTES } from "src/utils"; ++import { idToTimestamp } from "src/utils/idToTimestamp"; ++import { interactionEvt } from "../types"; ++import { handleModifyRole } from "../util/buttonActionHandlers"; ++import { MENU_CONTEXT_SEPARATOR, resolveStatefulCustomId } from "../util/buttonCustomIdFunctions"; ++import { SelectMenuActions } from "../util/buttonMenuActions"; ++ ++const INVALIDATION_TIME = 15 * MINUTES; ++ ++export const InteractionEvt = interactionEvt({ ++ event: "interactionCreate", ++ ++ async listener(meta) { ++ const int = meta.args.interaction; ++ if (!int.isMessageComponent() || !int.isSelectMenu()) return; ++ ++ const cfg = meta.pluginData.config.get(); ++ const split = int.customId.split(MENU_CONTEXT_SEPARATOR); ++ const context = (await resolveStatefulCustomId(meta.pluginData, int.customId)) ?? { ++ groupName: split[0], ++ menuName: split[1], ++ action: split[2], ++ stateless: true, ++ }; ++ ++ if (context.stateless) { ++ const timeSinceCreation = moment.utc().valueOf() - idToTimestamp(int.message.id)!; ++ if (timeSinceCreation >= INVALIDATION_TIME) { ++ sendEphemeralReply( ++ int, ++ `Sorry, but these select menus are invalid because they are older than ${humanizeDuration( ++ INVALIDATION_TIME, ++ )}.\nIf the menu is still available, open it again to assign yourself roles!`, ++ ); ++ return; ++ } ++ } ++ ++ const group = cfg.select_groups[context.groupName]; ++ if (!group) { ++ await sendEphemeralReply(int, `A configuration error was encountered, please contact the Administrators!`); ++ meta.pluginData.getPlugin(LogsPlugin).logBotAlert({ ++ body: `**A configuration error occurred** on select menus for message ${int.message.id}, group **${context.groupName}** not found in config`, ++ }); ++ return; ++ } ++ ++ // Verify that detected action is known by us ++ if (!(Object).values(SelectMenuActions).includes(context.action)) { ++ await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`); ++ meta.pluginData.getPlugin(LogsPlugin).logBotAlert({ ++ body: `**A internal error occurred** on select menus for message ${int.message.id}, action **${context.action}** is not known`, ++ }); ++ return; ++ } ++ ++ if (context.action === SelectMenuActions.MODIFY_ROLE) { ++ await handleModifyRole(meta.pluginData, int, group, context); ++ return; ++ } ++ ++ logger.warn( ++ `Action ${context.action} on select menu ${int.customId} (Guild: ${int.guildId}, Channel: ${int.channelId}) is unknown!`, ++ ); ++ await sendEphemeralReply(int, `A internal error was encountered, please contact the Administrators!`); ++ }, ++}); ++ ++async function sendEphemeralReply(interaction: MessageComponentInteraction, message: string) { ++ await interaction.reply({ content: message, ephemeral: true }); ++} +diff --git a/backend/src/plugins/SelectMenuRoles/events/MessageDeletedEvt.ts b/backend/src/plugins/SelectMenuRoles/events/MessageDeletedEvt.ts +new file mode 100644 +index 00000000..2acbbb23 +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/events/MessageDeletedEvt.ts +@@ -0,0 +1,13 @@ ++import { interactionEvt } from "../types"; ++ ++export const MessageDeletedEvt = interactionEvt({ ++ event: "messageDelete", ++ allowBots: true, ++ allowSelf: true, ++ ++ async listener(meta) { ++ const pluginData = meta.pluginData; ++ ++ await pluginData.state.selectMenus.removeAllForMessageId(meta.args.message.id); ++ }, ++}); +diff --git a/backend/src/plugins/SelectMenuRoles/types.ts b/backend/src/plugins/SelectMenuRoles/types.ts +new file mode 100644 +index 00000000..045fe47b +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/types.ts +@@ -0,0 +1,65 @@ ++import * as t from "io-ts"; ++import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; ++import { GuildSelectRoles } from "src/data/GuildSelectRoles"; ++import { GuildSavedMessages } from "../../data/GuildSavedMessages"; ++import { Queue } from "../../Queue"; ++import { tNullable } from "../../utils"; ++ ++const SelectMenuItem = t.type({ ++ label: t.string, ++ role: t.string, ++ description: tNullable(t.string), ++ emoji: tNullable(t.string), ++ default: tNullable(t.boolean), ++}); ++export type TSelectMenuItemOpts = t.TypeOf; ++ ++const SelectMenuOpts = t.type({ ++ items: t.array(SelectMenuItem), ++ placeholder: tNullable(t.string), ++ minValues: tNullable(t.number), ++ maxValues: tNullable(t.number), ++ disabled: tNullable(t.boolean), ++}); ++export type TSelectMenuOpts = t.TypeOf; ++ ++const SelectMenuPairOpts = t.type({ ++ message: t.string, ++ menus: t.record(t.string, SelectMenuOpts), ++}); ++export type TSelectMenuPairOpts = t.TypeOf; ++ ++export const ConfigSchema = t.type({ ++ select_groups: t.record(t.string, SelectMenuPairOpts), ++ can_manage: t.boolean, ++}); ++export type TConfigSchema = t.TypeOf; ++ ++export type RoleChangeMode = "+" | "-"; ++ ++export type PendingMemberRoleChanges = { ++ timeout: NodeJS.Timeout | null; ++ applyFn: () => void; ++ changes: Array<{ ++ mode: RoleChangeMode; ++ roleId: string; ++ }>; ++}; ++ ++export interface SelectMenuRolesPluginType extends BasePluginType { ++ config: TConfigSchema; ++ state: { ++ savedMessages: GuildSavedMessages; ++ selectMenus: GuildSelectRoles; ++ ++ reactionRemoveQueue: Queue; ++ roleChangeQueue: Queue; ++ pendingRoleChanges: Map; ++ pendingRefreshes: Set; ++ ++ autoRefreshTimeout: NodeJS.Timeout; ++ }; ++} ++ ++export const interactionEvt = typedGuildEventListener(); ++export const selectRolesCmd = typedGuildCommand(); +diff --git a/backend/src/plugins/SelectMenuRoles/util/addMemberPendingRoleChange.ts b/backend/src/plugins/SelectMenuRoles/util/addMemberPendingRoleChange.ts +new file mode 100644 +index 00000000..01dcb454 +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/util/addMemberPendingRoleChange.ts +@@ -0,0 +1,58 @@ ++import { Snowflake } from "discord.js"; ++import { GuildPluginData } from "knub"; ++import { logger } from "../../../logger"; ++import { resolveMember } from "../../../utils"; ++import { memberRolesLock } from "../../../utils/lockNameHelpers"; ++import { PendingMemberRoleChanges, SelectMenuRolesPluginType, RoleChangeMode } from "../types"; ++ ++const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 3000; ++ ++export async function addMemberPendingRoleChange( ++ pluginData: GuildPluginData, ++ memberId: string, ++ mode: RoleChangeMode, ++ roleId: string, ++) { ++ if (!pluginData.state.pendingRoleChanges.has(memberId)) { ++ const newPendingRoleChangeObj: PendingMemberRoleChanges = { ++ timeout: null, ++ changes: [], ++ applyFn: async () => { ++ pluginData.state.pendingRoleChanges.delete(memberId); ++ ++ const lock = await pluginData.locks.acquire(memberRolesLock({ id: memberId })); ++ ++ const member = await resolveMember(pluginData.client, pluginData.guild, memberId); ++ if (member) { ++ const newRoleIds = new Set(member.roles.cache.keys()); ++ for (const change of newPendingRoleChangeObj.changes) { ++ if (change.mode === "+") newRoleIds.add(change.roleId as Snowflake); ++ else newRoleIds.delete(change.roleId as Snowflake); ++ } ++ ++ try { ++ await member.edit( ++ { ++ roles: Array.from(newRoleIds.values()), ++ }, ++ "Select menu roles", ++ ); ++ } catch (e) { ++ logger.warn(`Failed to apply role changes to ${member.user.tag} (${member.id}): ${e.message}`); ++ } ++ } ++ lock.unlock(); ++ }, ++ }; ++ ++ pluginData.state.pendingRoleChanges.set(memberId, newPendingRoleChangeObj); ++ } ++ ++ const pendingRoleChangeObj = pluginData.state.pendingRoleChanges.get(memberId)!; ++ pendingRoleChangeObj.changes.push({ mode, roleId }); ++ if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout); ++ pendingRoleChangeObj.timeout = setTimeout( ++ () => pluginData.state.roleChangeQueue.add(pendingRoleChangeObj.applyFn), ++ ROLE_CHANGE_BATCH_DEBOUNCE_TIME, ++ ); ++} +diff --git a/backend/src/plugins/SelectMenuRoles/util/buttonActionHandlers.ts b/backend/src/plugins/SelectMenuRoles/util/buttonActionHandlers.ts +new file mode 100644 +index 00000000..4aa948a5 +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/util/buttonActionHandlers.ts +@@ -0,0 +1,54 @@ ++import { SelectMenuInteraction } from "discord.js"; ++import { GuildPluginData } from "knub"; ++import { LogsPlugin } from "../../Logs/LogsPlugin"; ++import { SelectMenuRolesPluginType, TSelectMenuPairOpts } from "../types"; ++import { addMemberPendingRoleChange } from "./addMemberPendingRoleChange"; ++ ++export async function handleModifyRole( ++ pluginData: GuildPluginData, ++ int: SelectMenuInteraction, ++ group: TSelectMenuPairOpts, ++ context, ++) { ++ const values = int.values; ++ const menuName = context.menuName; ++ if (!values) return; ++ const guildRoles = await pluginData.guild.roles.fetch(); ++ for (const i in values) { ++ if (!guildRoles.find((rl) => rl.id === values[i])) { ++ await int.reply({ ++ content: `A configuration error was encountered, please contact the Administrators!`, ++ ephemeral: true, ++ }); ++ pluginData.getPlugin(LogsPlugin).logBotAlert({ ++ body: `**A configuration error occurred** on select menus for message ${int.message.id}, role **${values[i]}** not found on server`, ++ }); ++ return; ++ } ++ } ++ ++ try { ++ const member = await pluginData.guild.members.fetch(int.user.id); ++ const configuredRoles: string[] = []; ++ const menuGroup = group.menus[menuName]; ++ for (const key in menuGroup.items) { ++ configuredRoles.push(menuGroup.items[key].role); ++ } ++ const memberRoles = Array.from(member.roles.cache.keys()); ++ const toAdd = configuredRoles.filter((rl) => values.includes(rl) && !memberRoles.includes(rl)); ++ const toRemove = configuredRoles.filter( ++ (rl) => !toAdd.includes(rl) && memberRoles.includes(rl) && !values.includes(rl), ++ ); ++ toAdd.forEach((r) => addMemberPendingRoleChange(pluginData, member.id, "+", r)); ++ toRemove.forEach((r) => addMemberPendingRoleChange(pluginData, member.id, "-", r)); ++ await int.deferUpdate(); ++ } catch (e) { ++ await int.reply({ ++ content: "A configuration error was encountered, please contact the Administrators!", ++ ephemeral: true, ++ }); ++ pluginData.getPlugin(LogsPlugin).logBotAlert({ ++ body: `**A configuration error occurred** on select menus for message ${int.message.id}, error: ${e}. We might be missing permissions!`, ++ }); ++ } ++} +diff --git a/backend/src/plugins/SelectMenuRoles/util/buttonCustomIdFunctions.ts b/backend/src/plugins/SelectMenuRoles/util/buttonCustomIdFunctions.ts +new file mode 100644 +index 00000000..c0a4a29a +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/util/buttonCustomIdFunctions.ts +@@ -0,0 +1,37 @@ ++import { Snowflake } from "discord.js"; ++import { GuildPluginData } from "knub"; ++import { SelectMenuRolesPluginType } from "../types"; ++import { SelectMenuActions } from "./buttonMenuActions"; ++ ++export const MENU_CONTEXT_SEPARATOR = ":rm:"; ++ ++export async function generateStatelessCustomId( ++ pluginData: GuildPluginData, ++ groupName: string, ++ menuName: string, ++) { ++ let id = groupName + MENU_CONTEXT_SEPARATOR + menuName + MENU_CONTEXT_SEPARATOR; ++ ++ id += `${SelectMenuActions.MODIFY_ROLE}`; ++ ++ return id; ++} ++ ++export async function resolveStatefulCustomId(pluginData: GuildPluginData, id: string) { ++ const menu = await pluginData.state.selectMenus.getForSelectId(id); ++ ++ if (menu) { ++ const group = pluginData.config.get().select_groups[menu.menu_group]; ++ if (!group) return null; ++ const cfgButton = group.menus[menu.menu_name]; ++ ++ return { ++ groupName: menu.menu_group, ++ menuName: menu.menu_name, ++ action: SelectMenuActions.MODIFY_ROLE, ++ stateless: false, ++ }; ++ } else { ++ return null; ++ } ++} +diff --git a/backend/src/plugins/SelectMenuRoles/util/buttonMenuActions.ts b/backend/src/plugins/SelectMenuRoles/util/buttonMenuActions.ts +new file mode 100644 +index 00000000..d3fd4c1e +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/util/buttonMenuActions.ts +@@ -0,0 +1,3 @@ ++export enum SelectMenuActions { ++ MODIFY_ROLE = "grant", ++} +diff --git a/backend/src/plugins/SelectMenuRoles/util/splitMenusIntoRows.ts b/backend/src/plugins/SelectMenuRoles/util/splitMenusIntoRows.ts +new file mode 100644 +index 00000000..7c0e6d71 +--- /dev/null ++++ b/backend/src/plugins/SelectMenuRoles/util/splitMenusIntoRows.ts +@@ -0,0 +1,19 @@ ++import { MessageActionRow, MessageSelectMenu } from "discord.js"; ++import { TSelectMenuOpts } from "../types"; ++ ++export function splitButtonsIntoRows( ++ actualMenus: MessageSelectMenu[], ++ configButtons: TSelectMenuOpts[], ++): MessageActionRow[] { ++ const rows: MessageActionRow[] = []; ++ let curRow = new MessageActionRow(); ++ ++ for (const item of actualMenus) { ++ curRow.addComponents(item); ++ rows.push(curRow); ++ curRow = new MessageActionRow(); ++ } ++ ++ if (curRow.components.length >= 1) rows.push(curRow); ++ return rows; ++} +diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts +index dc8adf25..f98fc40c 100644 +--- a/backend/src/plugins/availablePlugins.ts ++++ b/backend/src/plugins/availablePlugins.ts +@@ -24,6 +24,7 @@ import { PostPlugin } from "./Post/PostPlugin"; + import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin"; + import { RemindersPlugin } from "./Reminders/RemindersPlugin"; + import { RolesPlugin } from "./Roles/RolesPlugin"; ++import { SelectMenuRolesPlugin } from "./SelectMenuRoles/SelectMenuRolesPlugin"; + import { SelfGrantableRolesPlugin } from "./SelfGrantableRoles/SelfGrantableRolesPlugin"; + import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin"; + import { SpamPlugin } from "./Spam/SpamPlugin"; +@@ -73,6 +74,7 @@ export const guildPlugins: Array> = [ + ContextMenuPlugin, + PhishermanPlugin, + InternalPosterPlugin, ++ SelectMenuRolesPlugin + ]; + + // prettier-ignore +-- +2.33.1.windows.1 + diff --git a/patches/0002-timeouts-support.patch b/patches/0002-timeouts-support.patch new file mode 100644 index 00000000..796b1583 --- /dev/null +++ b/patches/0002-timeouts-support.patch @@ -0,0 +1,238 @@ +From 60b88cc8fab83fe79574525d987d45392581a6cb Mon Sep 17 00:00:00 2001 +From: metal +Date: Tue, 13 Dec 2022 10:40:53 +0000 +Subject: [PATCH 1/4] ghetto timeouts support + +Signed-off-by: GitHub +--- + .../src/plugins/Mutes/functions/clearMute.ts | 5 ++ + .../Mutes/functions/memberHasMutedRole.ts | 5 ++ + .../src/plugins/Mutes/functions/muteUser.ts | 75 +++++++++++-------- + 3 files changed, 55 insertions(+), 30 deletions(-) + +diff --git a/backend/src/plugins/Mutes/functions/clearMute.ts b/backend/src/plugins/Mutes/functions/clearMute.ts +index e7167474..a4ca0ce1 100644 +--- a/backend/src/plugins/Mutes/functions/clearMute.ts ++++ b/backend/src/plugins/Mutes/functions/clearMute.ts +@@ -6,6 +6,7 @@ import { GuildPluginData } from "knub"; + import { MutesPluginType } from "../types"; + import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop"; + import { GuildMember } from "discord.js"; ++import { memberHasMutedRole } from "./memberHasMutedRole"; + + export async function clearMute( + pluginData: GuildPluginData, +@@ -27,6 +28,10 @@ export async function clearMute( + const muteRole = pluginData.config.get().mute_role; + if (muteRole) { + await member.roles.remove(muteRole); ++ } else { ++ if (member.communicationDisabledUntil !== null && memberHasMutedRole(pluginData, member)) { ++ await member.timeout(null); ++ } + } + if (mute?.roles_to_restore) { + const guildRoles = pluginData.guild.roles.cache; +diff --git a/backend/src/plugins/Mutes/functions/memberHasMutedRole.ts b/backend/src/plugins/Mutes/functions/memberHasMutedRole.ts +index 2790285a..a6f91763 100644 +--- a/backend/src/plugins/Mutes/functions/memberHasMutedRole.ts ++++ b/backend/src/plugins/Mutes/functions/memberHasMutedRole.ts +@@ -4,5 +4,10 @@ import { MutesPluginType } from "../types"; + + export function memberHasMutedRole(pluginData: GuildPluginData, member: GuildMember): boolean { + const muteRole = pluginData.config.get().mute_role; ++ if (!muteRole) { ++ if (!member.communicationDisabledUntil) return false; ++ const diff = new Date(member.communicationDisabledUntil).getTime() - Date.now(); ++ return diff > 10 * 1000; ++ } + return muteRole ? member.roles.cache.has(muteRole as Snowflake) : false; + } +diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts +index 3956be8a..b622dd41 100644 +--- a/backend/src/plugins/Mutes/functions/muteUser.ts ++++ b/backend/src/plugins/Mutes/functions/muteUser.ts +@@ -21,6 +21,8 @@ import { MuteOptions, MutesPluginType } from "../types"; + import { Mute } from "../../../data/entities/Mute"; + import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop"; + ++const TIMEOUT_MAX = 27 * 24 * 60 * 60 * 1000; ++ + /** + * TODO: Clean up this function + */ +@@ -36,11 +38,13 @@ export async function muteUser( + const lock = await pluginData.locks.acquire(muteLock({ id: userId })); + + const muteRole = pluginData.config.get().mute_role; +- if (!muteRole) { ++ /*if (!muteRole) { + lock.unlock(); + throw new RecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG); +- } ++ }*/ + ++ if (!muteRole && muteTime && muteTime > TIMEOUT_MAX) muteTime = TIMEOUT_MAX; ++ if (!muteRole && !muteTime) muteTime = TIMEOUT_MAX; + const timeUntilUnmute = muteTime ? humanizeDuration(muteTime) : "indefinite"; + + // No mod specified -> mark Zeppelin as the mod +@@ -90,37 +94,48 @@ export async function muteUser( + } + + // Apply mute role if it's missing +- if (!currentUserRoles.includes(muteRole as Snowflake)) { +- try { +- await member.roles.add(muteRole as Snowflake); +- } catch (e) { +- const actualMuteRole = pluginData.guild.roles.cache.get(muteRole as Snowflake); +- if (!actualMuteRole) { +- lock.unlock(); +- logs.logBotAlert({ +- body: `Cannot mute users, specified mute role Id is invalid`, +- }); +- throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID); +- } ++ if (muteRole) { ++ if (!currentUserRoles.includes(muteRole as Snowflake)) { ++ try { ++ await member.roles.add(muteRole as Snowflake); ++ } catch (e) { ++ const actualMuteRole = pluginData.guild.roles.cache.get(muteRole as Snowflake); ++ if (!actualMuteRole) { ++ lock.unlock(); ++ logs.logBotAlert({ ++ body: `Cannot mute users, specified mute role Id is invalid`, ++ }); ++ throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID); ++ } + +- const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id); +- const zepRoles = pluginData.guild.roles.cache.filter((x) => zep!.roles.cache.has(x.id)); +- // If we have roles and one of them is above the muted role, throw generic error +- if (zepRoles.size >= 0 && zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) { +- lock.unlock(); +- logs.logBotAlert({ +- body: `Cannot mute user ${member.id}: ${e}`, +- }); +- throw e; +- } else { +- // Otherwise, throw error that mute role is above zeps roles +- lock.unlock(); +- logs.logBotAlert({ +- body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`, +- }); +- throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild); ++ const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user!.id); ++ const zepRoles = pluginData.guild.roles.cache.filter((x) => zep!.roles.cache.has(x.id)); ++ // If we have roles and one of them is above the muted role, throw generic error ++ if (zepRoles.size >= 0 && zepRoles.some((zepRole) => zepRole.position > actualMuteRole.position)) { ++ lock.unlock(); ++ logs.logBotAlert({ ++ body: `Cannot mute user ${member.id}: ${e}`, ++ }); ++ throw e; ++ } else { ++ // Otherwise, throw error that mute role is above zeps roles ++ lock.unlock(); ++ logs.logBotAlert({ ++ body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`, ++ }); ++ throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP, pluginData.guild); ++ } + } + } ++ } else { ++ try { ++ await member.timeout(muteTime ?? TIMEOUT_MAX, reason); ++ } catch (e) { ++ lock.unlock(); ++ logs.logBotAlert({ ++ body: `Cannot mute users, timeout failed`, ++ }); ++ } + } + + // If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role) +-- +2.38.1 + + +From 143753ee9688781cc54bb37aa1a9301cdb83b2b0 Mon Sep 17 00:00:00 2001 +From: metal +Date: Sat, 16 Jul 2022 19:08:42 +0000 +Subject: [PATCH 2/4] better fix + +Signed-off-by: GitHub +--- + backend/src/plugins/Mutes/MutesPlugin.ts | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts +index eba6b40f..922d5e14 100644 +--- a/backend/src/plugins/Mutes/MutesPlugin.ts ++++ b/backend/src/plugins/Mutes/MutesPlugin.ts +@@ -92,6 +92,7 @@ export const MutesPlugin = zeppelinGuildPlugin()({ + unmuteUser: mapToPublicFn(unmuteUser), + hasMutedRole(pluginData) { + return (member: GuildMember) => { ++ if (member.isCommunicationDisabled()) return true; + const muteRole = pluginData.config.get().mute_role; + return muteRole ? member.roles.cache.has(muteRole as Snowflake) : false; + }; +-- +2.38.1 + + +From 23031caab6ee3d5d4da316b0a4d9fc6f43fcc359 Mon Sep 17 00:00:00 2001 +From: metal +Date: Sat, 16 Jul 2022 19:04:17 +0000 +Subject: [PATCH 3/4] fix timeout unmute detection + +Signed-off-by: GitHub +--- + backend/src/plugins/Mutes/MutesPlugin.ts | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts +index 922d5e14..fffb4aab 100644 +--- a/backend/src/plugins/Mutes/MutesPlugin.ts ++++ b/backend/src/plugins/Mutes/MutesPlugin.ts +@@ -94,6 +94,7 @@ export const MutesPlugin = zeppelinGuildPlugin()({ + return (member: GuildMember) => { + if (member.isCommunicationDisabled()) return true; + const muteRole = pluginData.config.get().mute_role; ++ if (member.communicationDisabledUntilTimestamp) return true; + return muteRole ? member.roles.cache.has(muteRole as Snowflake) : false; + }; + }, +-- +2.38.1 + + +From 5463c89400e58f64f3e3f822f10ec656f18ae8fe Mon Sep 17 00:00:00 2001 +From: metal +Date: Sat, 16 Jul 2022 19:11:04 +0000 +Subject: [PATCH 4/4] attempted fix at isMuted #2 + +Signed-off-by: GitHub +--- + backend/src/plugins/ModActions/commands/UnmuteCmd.ts | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts +index f09c2e83..e15b6b42 100644 +--- a/backend/src/plugins/ModActions/commands/UnmuteCmd.ts ++++ b/backend/src/plugins/ModActions/commands/UnmuteCmd.ts +@@ -44,7 +44,7 @@ export const UnmuteCmd = modActionsCmd({ + const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); + + // Check if they're muted in the first place +- if (!(await pluginData.state.mutes.isMuted(args.user)) && !hasMuteRole) { ++ if (!(await pluginData.state.mutes.isMuted(user.id)) && !hasMuteRole) { + sendErrorMessage(pluginData, msg.channel, "Cannot unmute: member is not muted"); + return; + } +-- +2.38.1 + diff --git a/patches/0003-Created-Social-Media-Poster-Plugin.patch b/patches/0003-Created-Social-Media-Poster-Plugin.patch new file mode 100644 index 00000000..890383de --- /dev/null +++ b/patches/0003-Created-Social-Media-Poster-Plugin.patch @@ -0,0 +1,480 @@ +From 62ee0291b640269f227698f0aa0114b426965c6e Mon Sep 17 00:00:00 2001 +From: KK +Date: Wed, 1 Jun 2022 07:38:29 -0500 +Subject: [PATCH] Created Social Media Poster Plugin + +--- + backend/src/data/GuildSocialPosts.ts | 42 +++++++ + backend/src/data/entities/SocialPosts.ts | 18 +++ + .../1654041201056-CreateSocialPostsTable.ts | 40 +++++++ + .../SocialMediaPosterPlugin.ts | 77 ++++++++++++ + .../functions/pollSubreddit.ts | 110 ++++++++++++++++++ + backend/src/plugins/SocialMediaPoster/info.ts | 65 +++++++++++ + .../src/plugins/SocialMediaPoster/types.ts | 37 ++++++ + backend/src/plugins/availablePlugins.ts | 2 + + 8 files changed, 391 insertions(+) + create mode 100644 backend/src/data/GuildSocialPosts.ts + create mode 100644 backend/src/data/entities/SocialPosts.ts + create mode 100644 backend/src/migrations/1654041201056-CreateSocialPostsTable.ts + create mode 100644 backend/src/plugins/SocialMediaPoster/SocialMediaPosterPlugin.ts + create mode 100644 backend/src/plugins/SocialMediaPoster/functions/pollSubreddit.ts + create mode 100644 backend/src/plugins/SocialMediaPoster/info.ts + create mode 100644 backend/src/plugins/SocialMediaPoster/types.ts + +diff --git a/backend/src/data/GuildSocialPosts.ts b/backend/src/data/GuildSocialPosts.ts +new file mode 100644 +index 00000000..31f7ad68 +--- /dev/null ++++ b/backend/src/data/GuildSocialPosts.ts +@@ -0,0 +1,42 @@ ++import { getRepository, Repository } from "typeorm"; ++import { BaseGuildRepository } from "./BaseGuildRepository"; ++import { SocialPosts } from "./entities/SocialPosts"; ++ ++export class GuildSocialPosts extends BaseGuildRepository { ++ socialPosts: Repository; ++ ++ constructor(guildId) { ++ super(guildId); ++ this.socialPosts = getRepository(SocialPosts); ++ } ++ ++ async getLastPost(platform: string, path: string) { ++ const post = await this.socialPosts.findOne({ ++ guild_id: this.guildId, ++ platform, ++ path ++ }); ++ return BigInt(post?.last_post ?? 0); ++ } ++ ++ async setLastPost(platform: string, path: string, lastPost: string) { ++ if (await this.getLastPost(platform, path) == BigInt(0)) ++ return await this.addLastPost(platform, path, lastPost); ++ await this.socialPosts.update({ ++ guild_id: this.guildId, ++ platform, ++ path ++ }, { ++ last_post: lastPost ++ }); ++ } ++ ++ async addLastPost(platform: string, path: string, lastPost: string) { ++ await this.socialPosts.insert({ ++ guild_id: this.guildId, ++ platform, ++ path, ++ last_post: lastPost ++ }); ++ } ++} +\ No newline at end of file +diff --git a/backend/src/data/entities/SocialPosts.ts b/backend/src/data/entities/SocialPosts.ts +new file mode 100644 +index 00000000..5497856c +--- /dev/null ++++ b/backend/src/data/entities/SocialPosts.ts +@@ -0,0 +1,18 @@ ++import { Column, Entity, PrimaryColumn } from "typeorm"; ++ ++@Entity("social_posts") ++export class SocialPosts { ++ @Column() ++ @PrimaryColumn() ++ guild_id: string; ++ ++ @Column() ++ platform: string; ++ ++ @Column() ++ path: string; ++ ++ @Column() last_post: string; ++ ++ // Guild, reddit, subreddit, last_post (ms) ++} +diff --git a/backend/src/migrations/1654041201056-CreateSocialPostsTable.ts b/backend/src/migrations/1654041201056-CreateSocialPostsTable.ts +new file mode 100644 +index 00000000..e72b2874 +--- /dev/null ++++ b/backend/src/migrations/1654041201056-CreateSocialPostsTable.ts +@@ -0,0 +1,40 @@ ++import {MigrationInterface, QueryRunner, Table} from "typeorm"; ++ ++export class CreateSocialPostsTable1654041201056 implements MigrationInterface { ++ ++ public async up(queryRunner: QueryRunner): Promise { ++ await queryRunner.createTable( ++ new Table({ ++ name: "social_posts", ++ columns: [ ++ { ++ name: "guild_id", ++ type: "bigint", ++ isPrimary: true, ++ }, ++ { ++ name: "platform", ++ type: "varchar", ++ length: "100", ++ isPrimary: true, ++ }, ++ { ++ name: "path", ++ type: "varchar", ++ length: "100", ++ isPrimary: true, ++ }, ++ { ++ name: "last_post", ++ type: "bigint", ++ }, ++ ], ++ } ++ ), ++ ); ++ } ++ ++ public async down(queryRunner: QueryRunner): Promise { ++ await queryRunner.dropTable("social_posts"); ++ } ++} +diff --git a/backend/src/plugins/SocialMediaPoster/SocialMediaPosterPlugin.ts b/backend/src/plugins/SocialMediaPoster/SocialMediaPosterPlugin.ts +new file mode 100644 +index 00000000..c597116c +--- /dev/null ++++ b/backend/src/plugins/SocialMediaPoster/SocialMediaPosterPlugin.ts +@@ -0,0 +1,77 @@ ++import { GuildPluginData, PluginOptions } from "knub"; ++import { GuildLogs } from "../../data/GuildLogs"; ++import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; ++import { ConfigSchema, SocialMediaPosterPluginType, TPlatformPath } from "./types"; ++import { LogsPlugin } from "../Logs/LogsPlugin"; ++import { convertDelayStringToMS } from "src/utils"; ++import { pollSubreddit } from "./functions/pollSubreddit"; ++import { GuildSocialPosts } from "src/data/GuildSocialPosts"; ++ ++import { pluginInfo } from "./info"; ++ ++const defaultOptions: PluginOptions = { ++ config: { ++ platforms: { ++ reddit: {} ++ }, ++ }, ++}; ++ ++const platforms = { ++ reddit: pollSubreddit ++} ++ ++export const SocialMediaPosterPlugin = zeppelinGuildPlugin()({ ++ name: "social_media_poster", ++ showInDocs: true, ++ ++ info: pluginInfo, ++ ++ configSchema: ConfigSchema, ++ dependencies: () => [LogsPlugin], ++ defaultOptions, ++ ++ beforeLoad(pluginData) { ++ const { state, guild } = pluginData; ++ ++ state.logs = new GuildLogs(guild.id); ++ state.sentPosts = new Map(); ++ state.socialPosts = new GuildSocialPosts(guild.id); ++ }, ++ ++ async afterLoad(pluginData) { ++ const config = pluginData.config.get(); ++ ++ // Start poll timers ++ pluginData.state.pollTimers = []; ++ for (const platform of Object.keys(config.platforms)) { ++ if (!Object.keys(platforms).includes(platform)) continue; ++ if (Object.values(config.platforms[platform]).length == 0) continue; ++ pluginData.state.sentPosts.set(platform, new Map()); ++ for (const [path, pathSettings] of Object.entries(config.platforms[platform])) { ++ await this.addPathTimer(pluginData, 'reddit', path, pathSettings as TPlatformPath); ++ } ++ } ++ }, ++ ++ async addPathTimer(pluginData: GuildPluginData, platform: string, path: string, pathSettings: TPlatformPath) { ++ const pathLastPost = await pluginData.state.socialPosts.getLastPost(platform, path); ++ pluginData.state.sentPosts.get(platform)!.set(path, pathLastPost); ++ ++ const poll = pathSettings.poll_interval ?? '60s'; ++ const pollPeriodMs = convertDelayStringToMS(poll)!; ++ pluginData.state.pollTimers.push( ++ setInterval(() => { ++ platforms[platform](pluginData, path); ++ }, pollPeriodMs), ++ ); ++ }, ++ ++ beforeUnload(pluginData) { ++ if (pluginData.state.pollTimers) { ++ for (const interval of pluginData.state.pollTimers) { ++ clearInterval(interval); ++ } ++ } ++ } ++}); +diff --git a/backend/src/plugins/SocialMediaPoster/functions/pollSubreddit.ts b/backend/src/plugins/SocialMediaPoster/functions/pollSubreddit.ts +new file mode 100644 +index 00000000..cbcfa5ee +--- /dev/null ++++ b/backend/src/plugins/SocialMediaPoster/functions/pollSubreddit.ts +@@ -0,0 +1,110 @@ ++import fetch from "node-fetch"; ++import { GuildPluginData } from "knub"; ++import { SocialMediaPosterPluginType } from "../types"; ++import { LogsPlugin } from "src/plugins/Logs/LogsPlugin"; ++import { convertDelayStringToMS, renderRecursively, validateAndParseMessageContent } from "src/utils"; ++import { renderTemplate, TemplateSafeValueContainer } from "src/templateFormatter"; ++import { userToTemplateSafeUser } from "src/utils/templateSafeObjects"; ++import { MessageOptions, Snowflake, TextChannel } from "discord.js"; ++import { messageIsEmpty } from "src/utils/messageIsEmpty"; ++ ++const REDDIT_URL = 'http://www.reddit.com'; ++const REDDIT_ICON = 'https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png' ++const REDDIT_SPOILER_IMG = 'https://cdn.discordapp.com/attachments/980238012384444446/981535288289525830/SpoilerImg.jpg' ++export const DEFAULT_POST_POLL_COUNT = 10; ++ ++type TRedditPost = { ++ title: string, ++ thumbnail: string, ++ created: number, ++ author: string, ++ permalink: string, ++ url: string, ++ id: string ++}; ++ ++export async function pollSubreddit( ++ pluginData: GuildPluginData, ++ subredditName: string, ++) { ++ const config = pluginData.config.get(); ++ const subreddit = config.platforms.reddit![subredditName]; ++ if (!subreddit) return; ++ if (!subreddit.enabled) return; ++ if (subreddit.channels.length == 0) return; ++ const limit = subreddit.post_poll_count ?? DEFAULT_POST_POLL_COUNT; ++ let redditJson; ++ try { ++ const redditResponse = await fetch(getRedditUrl(subredditName, limit)); ++ if (!redditResponse) return; ++ redditJson = await redditResponse.json(); ++ if (!redditJson) return; ++ } catch (err) { ++ subreddit.enabled = false; ++ const logs = pluginData.getPlugin(LogsPlugin); ++ logs.logBotAlert({ ++ body: `Unable to poll from r/**${subredditName}**.\nPolling will be disabled for this subreddit until a config change is made.`, ++ }); ++ return; ++ } ++ ++ const previousPost = pluginData.state.sentPosts.get('reddit')!.get(subredditName)!; ++ ++ let posts: TRedditPost[] = redditJson.data.children.map((child) => child.data as TRedditPost); ++ posts = posts.filter((post) => { ++ return (BigInt(post.created) > previousPost); ++ }); ++ if (posts.length === 0) return; ++ ++ posts = posts.sort((a,b) => a.created - b.created); ++ ++ pluginData.state.sentPosts.get('reddit')!.set(subredditName, BigInt(posts[posts.length - 1].created)); ++ await pluginData.state.socialPosts.setLastPost('reddit', subredditName, posts[posts.length - 1].created.toString()); ++ ++ for (const post of posts) { ++ const renderReplyText = async (str: string) => ++ { ++ let thumbnail = post.thumbnail; ++ if (!thumbnail.startsWith('http')) thumbnail = REDDIT_SPOILER_IMG; ++ return renderTemplate( ++ str, ++ new TemplateSafeValueContainer({ ++ title: post.title, ++ thumbnail, ++ created: post.created, ++ author: post.author, ++ permalink: REDDIT_URL + post.permalink, ++ url: post.url, ++ id: post.id, ++ reddit_icon: REDDIT_ICON ++ }), ++ ); ++ } ++ ++ const formatted = ++ typeof subreddit.message === "string" ++ ? await renderReplyText(subreddit.message) ++ : ((await renderRecursively(subreddit.message, renderReplyText)) as MessageOptions); ++ ++ if (!formatted) continue; ++ ++ const messageContent = validateAndParseMessageContent(formatted); ++ const messageOpts: MessageOptions = { ++ ...messageContent, ++ }; ++ ++ if (messageIsEmpty(messageOpts)) continue; ++ ++ for (const channelId of subreddit.channels) { ++ const channel = pluginData.guild.channels.cache.get(channelId as Snowflake); ++ if (!channel) continue; ++ if (!channel.isText) continue; ++ const txtChannel = channel as TextChannel; ++ await txtChannel.send(messageOpts); ++ } ++ } ++} ++ ++function getRedditUrl(subreddit, limit) { ++ return `${REDDIT_URL}/r/${subreddit}/new.json?limit=${limit}` ++} +\ No newline at end of file +diff --git a/backend/src/plugins/SocialMediaPoster/info.ts b/backend/src/plugins/SocialMediaPoster/info.ts +new file mode 100644 +index 00000000..263c83cd +--- /dev/null ++++ b/backend/src/plugins/SocialMediaPoster/info.ts +@@ -0,0 +1,65 @@ ++import { trimPluginDescription } from "../../utils"; ++import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint"; ++ ++export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { ++ prettyName: "Social Media Auto Poster", ++ description: trimPluginDescription(` ++ Allows posting new social media posts automatically. ++ `), ++ configurationGuide: trimPluginDescription(` ++ The Social Media Auto Poster plugin is very customizable. For a full list of available platforms, and their options, see Config schema at the bottom of this page. ++ ++ ### Simple reddit poster ++ Automatically sends reddit posts to a channel ++ ++ ~~~yml ++ social_media_poster: ++ config: ++ platforms: ++ reddit: ++ tbhCreature: # Name of the subreddit ++ enabled: true ++ channels: ["473087035574321152"] ++ message: |- ++ **{author}** posted **{title}** in **tbhCreature** ++ ~~~ ++ ++ ### Embed reddit poster ++ This example posts the post as an embed ++ ++ ~~~yml ++ social_media_poster: ++ config: ++ platforms: ++ reddit: ++ tbhCreature: # Name of the subreddit ++ enabled: true ++ channels: ["473087035574321152"] ++ message: ++ embed: ++ title: "{title}" ++ color: 0xff4500 ++ description: "{url}" ++ url: "{permalink}" ++ footer: ++ url: "{permalink}" ++ icon_url: "{reddit_icon}" ++ text: "{author}" ++ thumbnail: ++ url: "{thumbnail}" ++ ~~~ ++ ++ ### List of variables ++ ++ \`\`\` ++ - {title} : Title of the post ++ - {thumbnail} : Img URL for the post ++ - {created} : The epoch time of when posted ++ - {author} : The posts author ++ - {permalink} : The posts permalink url ++ - {url} : The posts URL ++ - {id}: The ID of the post ++ - {reddit_icon}: A 32x32 image of the reddit logo ++ \`\`\` ++ `), ++}; +diff --git a/backend/src/plugins/SocialMediaPoster/types.ts b/backend/src/plugins/SocialMediaPoster/types.ts +new file mode 100644 +index 00000000..710f4ef7 +--- /dev/null ++++ b/backend/src/plugins/SocialMediaPoster/types.ts +@@ -0,0 +1,37 @@ ++import * as t from "io-ts"; ++import { BasePluginType, typedGuildCommand } from "knub"; ++import { GuildSocialPosts } from "src/data/GuildSocialPosts"; ++import { GuildLogs } from "../../data/GuildLogs"; ++import { tMessageContent, tNullable } from "../../utils"; ++import Timeout = NodeJS.Timeout; ++ ++export const PlatformPath = t.type({ ++ enabled: t.boolean, ++ channels: t.array(t.string), ++ message: tMessageContent, ++ poll_interval: tNullable(t.string), ++ post_poll_count: tNullable(t.number) ++}); ++export type TPlatformPath = t.TypeOf; ++ ++export const PlatformTypes = t.type({ ++ reddit: t.record(t.string, PlatformPath), ++}); ++ ++export type TPlatformTypes = t.TypeOf; ++ ++export const ConfigSchema = t.type({ ++ platforms: PlatformTypes, ++}); ++export type TConfigSchema = t.TypeOf; ++ ++export interface SocialMediaPosterPluginType extends BasePluginType { ++ config: TConfigSchema; ++ state: { ++ logs: GuildLogs; ++ pollTimers: Timeout[]; ++ // Platform: { path: lastPost } ++ sentPosts: Map>; ++ socialPosts: GuildSocialPosts; ++ }; ++} +\ No newline at end of file +diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts +index d47df12f..f337e2e2 100644 +--- a/backend/src/plugins/availablePlugins.ts ++++ b/backend/src/plugins/availablePlugins.ts +@@ -38,6 +38,7 @@ import { PhishermanPlugin } from "./Phisherman/PhishermanPlugin"; + import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin"; + import { RoleManagerPlugin } from "./RoleManager/RoleManagerPlugin"; + import { RoleButtonsPlugin } from "./RoleButtons/RoleButtonsPlugin"; ++import { SocialMediaPosterPlugin } from "./SocialMediaPoster/SocialMediaPosterPlugin"; + + // prettier-ignore + export const guildPlugins: Array> = [ +@@ -77,6 +78,7 @@ export const guildPlugins: Array> = [ + InternalPosterPlugin, + RoleManagerPlugin, + RoleButtonsPlugin, ++ SocialMediaPosterPlugin, + ]; + + // prettier-ignore +-- +2.35.1.windows.2 + diff --git a/patches/0005-created-activities-plugin.patch b/patches/0005-created-activities-plugin.patch new file mode 100644 index 00000000..b603ef83 --- /dev/null +++ b/patches/0005-created-activities-plugin.patch @@ -0,0 +1,197 @@ +From 140a4ad8070a442b5008000f551260b7b8053695 Mon Sep 17 00:00:00 2001 +From: KK +Date: Wed, 1 Jun 2022 16:53:20 -0500 +Subject: [PATCH] Created Activities Plugin + +--- + .../plugins/Activities/ActivitiesPlugin.ts | 32 +++++++++ + .../Activities/commands/ActivitiesCmd.ts | 72 +++++++++++++++++++ + .../Activities/commands/ActivitiesList.ts | 13 ++++ + backend/src/plugins/Activities/types.ts | 14 ++++ + backend/src/plugins/availablePlugins.ts | 2 + + 5 files changed, 133 insertions(+) + create mode 100644 backend/src/plugins/Activities/ActivitiesPlugin.ts + create mode 100644 backend/src/plugins/Activities/commands/ActivitiesCmd.ts + create mode 100644 backend/src/plugins/Activities/commands/ActivitiesList.ts + create mode 100644 backend/src/plugins/Activities/types.ts + +diff --git a/backend/src/plugins/Activities/ActivitiesPlugin.ts b/backend/src/plugins/Activities/ActivitiesPlugin.ts +new file mode 100644 +index 00000000..6f11a1fb +--- /dev/null ++++ b/backend/src/plugins/Activities/ActivitiesPlugin.ts +@@ -0,0 +1,32 @@ ++import { PluginOptions } from "knub"; ++import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; ++import { ActivitiesCmd } from "./commands/ActivitiesCmd"; ++import { ActivitiesListCmd } from "./commands/ActivitiesList"; ++import { ActivitiesPluginType, ConfigSchema } from "./types"; ++ ++const defaultOptions: PluginOptions = { ++ config: { ++ start_activities: false ++ }, ++ overrides: [ ++ { ++ level: ">=50", ++ config: { ++ start_activities: true ++ } ++ } ++ ] ++}; ++ ++export const ActivitiesPlugin = zeppelinGuildPlugin()({ ++ name: "activities", ++ showInDocs: true, ++ ++ commands: [ ++ ActivitiesCmd, ++ ActivitiesListCmd ++ ], ++ ++ configSchema: ConfigSchema, ++ defaultOptions, ++}); +diff --git a/backend/src/plugins/Activities/commands/ActivitiesCmd.ts b/backend/src/plugins/Activities/commands/ActivitiesCmd.ts +new file mode 100644 +index 00000000..9507f917 +--- /dev/null ++++ b/backend/src/plugins/Activities/commands/ActivitiesCmd.ts +@@ -0,0 +1,72 @@ ++import { activitiesCmd } from "../types"; ++import { commandTypeHelpers as ct } from "../../../commandTypes"; ++import { sendErrorMessage } from "src/pluginUtils"; ++ ++import { RESTPostAPIChannelInviteJSONBody, APIInvite, InviteTargetType, RouteBases, Routes } from 'discord-api-types/v9'; ++import fetch from "node-fetch"; ++ ++export const activities = { ++ poker: '755827207812677713', ++ betrayal: '773336526917861400', ++ youtube: '880218394199220334', ++ fishington: '814288819477020702', ++ chess: '832012774040141894', ++ checkers: '832013003968348200', ++ letter: '879863686565621790', ++ word: '879863976006127627', ++ sketchheads: '902271654783242291', ++ spellcast: '852509694341283871', ++ ocho: '832025144389533716', ++} ++ ++export const ActivitiesCmd = activitiesCmd({ ++ trigger: ['activities', 'activity'], ++ permission: 'start_activities', ++ ++ signature: { ++ activity: ct.string(), ++ channel: ct.voiceChannel(), ++ }, ++ ++ async run({ message, args, pluginData}) { ++ if (!Object.keys(activities).includes(args.activity.toLowerCase())) { ++ sendErrorMessage(pluginData, message.channel, `Unknown activity \`${args.activity}\`.\nUse one from this list: \`${Object.keys(activities).join(', ')}\``); ++ return; ++ } ++ ++ const channel = args.channel; ++ if (!channel) { ++ sendErrorMessage(pluginData, message.channel, `Unknown channel: ${args.channel}`); ++ return; ++ } ++ ++ if (!channel.isVoice) { ++ sendErrorMessage(pluginData, message.channel, `Supplied channel is not a voice channel`); ++ return; ++ } ++ ++ const r = await fetch(`${RouteBases.api}${Routes.channelInvites(channel.id)}`, { ++ method: 'POST', ++ headers: { ++ authorization: `Bot ${process.env.TOKEN}`, ++ 'content-type': 'application/json' ++ }, ++ body: JSON.stringify({ ++ max_age: 0, ++ target_type: InviteTargetType.EmbeddedApplication, ++ target_application_id: activities[args.activity.toLowerCase()], ++ } as RESTPostAPIChannelInviteJSONBody) ++ }); ++ ++ const invite = await r.json() as APIInvite; ++ ++ if (r.status !== 200) { ++ sendErrorMessage(pluginData, message.channel, `An error occurred: ${(invite as any).message}\nUnable to create an activities invite!\nMake sure I have the "Create Invite" permission in ${channel}.`); ++ return; ++ } ++ ++ message.channel.send({ ++ content: `Click here to join ${args.activity} in ${channel}: https://discord.gg/${invite.code}`, ++ }); ++ }, ++}); +\ No newline at end of file +diff --git a/backend/src/plugins/Activities/commands/ActivitiesList.ts b/backend/src/plugins/Activities/commands/ActivitiesList.ts +new file mode 100644 +index 00000000..84c40f3e +--- /dev/null ++++ b/backend/src/plugins/Activities/commands/ActivitiesList.ts +@@ -0,0 +1,13 @@ ++import { activitiesCmd } from "../types"; ++import { activities } from "./ActivitiesCmd"; ++ ++export const ActivitiesListCmd = activitiesCmd({ ++ trigger: ['activities', 'activity', 'activities_list'], ++ permission: 'start_activities', ++ ++ async run({ message, args, pluginData}) { ++ message.channel.send({ ++ content: `The activities available are: \`${Object.keys(activities).join(', ')}\``, ++ }); ++ }, ++}); +\ No newline at end of file +diff --git a/backend/src/plugins/Activities/types.ts b/backend/src/plugins/Activities/types.ts +new file mode 100644 +index 00000000..901058ad +--- /dev/null ++++ b/backend/src/plugins/Activities/types.ts +@@ -0,0 +1,14 @@ ++import * as t from "io-ts"; ++import { BasePluginType, typedGuildCommand } from "knub"; ++ ++export const ConfigSchema = t.type({ ++ start_activities: t.boolean, ++}); ++export type TConfigSchema = t.TypeOf; ++ ++export interface ActivitiesPluginType extends BasePluginType { ++ config: TConfigSchema; ++ state: {}; ++} ++ ++export const activitiesCmd = typedGuildCommand(); +diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts +index f337e2e2..59cdfd7b 100644 +--- a/backend/src/plugins/availablePlugins.ts ++++ b/backend/src/plugins/availablePlugins.ts +@@ -39,6 +39,7 @@ import { InternalPosterPlugin } from "./InternalPoster/InternalPosterPlugin"; + import { RoleManagerPlugin } from "./RoleManager/RoleManagerPlugin"; + import { RoleButtonsPlugin } from "./RoleButtons/RoleButtonsPlugin"; + import { SocialMediaPosterPlugin } from "./SocialMediaPoster/SocialMediaPosterPlugin"; ++import { ActivitiesPlugin } from "./Activities/ActivitiesPlugin"; + + // prettier-ignore + export const guildPlugins: Array> = [ +@@ -79,6 +80,7 @@ export const guildPlugins: Array> = [ + RoleManagerPlugin, + RoleButtonsPlugin, + SocialMediaPosterPlugin, ++ ActivitiesPlugin, + ]; + + // prettier-ignore +-- +2.35.1.windows.2 +