3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-25 02:25:01 +00:00

Squashed 'patches/' content from commit 5c7f6995

git-subtree-dir: patches
git-subtree-split: 5c7f6995ab142df9029d54441c45f1a905b0c691
This commit is contained in:
David D Wang 2023-01-12 20:25:38 -08:00
commit 660f8e3888
9 changed files with 1841 additions and 0 deletions

104
.gitignore vendored Normal file
View file

@ -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

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "Zeppelin"]
path = Zeppelin
url = https://github.com/ZeppelinBot/Zeppelin

21
LICENSE Normal file
View file

@ -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.

5
README.md Normal file
View file

@ -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.

1
Zeppelin Submodule

@ -0,0 +1 @@
Subproject commit 8445c37f6410683b7b1b53811cf49eee61b9068a

View file

@ -0,0 +1,792 @@
From 1f18cfe51543c8990a0921aa31a4f548959fa032 Mon Sep 17 00:00:00 2001
From: Jernik <info@jernik.me>
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<SelectRole>;
+
+ 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<void> {
+ 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<void> {
+ 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<SelectMenuRolesPluginType> = {
+ config: {
+ select_groups: {},
+
+ can_manage: false,
+ },
+
+ overrides: [
+ {
+ level: ">=100",
+ config: {
+ can_manage: true,
+ },
+ },
+ ],
+};
+
+const MAXIMUM_COMPONENT_ROWS = 5;
+
+const configPreprocessor: ConfigPreprocessorFn<SelectMenuRolesPluginType> = (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<SelectMenuRolesPluginType>()({
+ 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 (!(<any>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<typeof SelectMenuItem>;
+
+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<typeof SelectMenuOpts>;
+
+const SelectMenuPairOpts = t.type({
+ message: t.string,
+ menus: t.record(t.string, SelectMenuOpts),
+});
+export type TSelectMenuPairOpts = t.TypeOf<typeof SelectMenuPairOpts>;
+
+export const ConfigSchema = t.type({
+ select_groups: t.record(t.string, SelectMenuPairOpts),
+ can_manage: t.boolean,
+});
+export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+
+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<string, PendingMemberRoleChanges>;
+ pendingRefreshes: Set<string>;
+
+ autoRefreshTimeout: NodeJS.Timeout;
+ };
+}
+
+export const interactionEvt = typedGuildEventListener<SelectMenuRolesPluginType>();
+export const selectRolesCmd = typedGuildCommand<SelectMenuRolesPluginType>();
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<SelectMenuRolesPluginType>,
+ 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<SelectMenuRolesPluginType>,
+ 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<SelectMenuRolesPluginType>,
+ 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<SelectMenuRolesPluginType>, 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<ZeppelinGuildPluginBlueprint<any>> = [
ContextMenuPlugin,
PhishermanPlugin,
InternalPosterPlugin,
+ SelectMenuRolesPlugin
];
// prettier-ignore
--
2.33.1.windows.1

View file

@ -0,0 +1,238 @@
From 60b88cc8fab83fe79574525d987d45392581a6cb Mon Sep 17 00:00:00 2001
From: metal <metal@i0.tf>
Date: Tue, 13 Dec 2022 10:40:53 +0000
Subject: [PATCH 1/4] ghetto timeouts support
Signed-off-by: GitHub <noreply@github.com>
---
.../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<MutesPluginType>,
@@ -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<MutesPluginType>, 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 <metal@i0.tf>
Date: Sat, 16 Jul 2022 19:08:42 +0000
Subject: [PATCH 2/4] better fix
Signed-off-by: GitHub <noreply@github.com>
---
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<MutesPluginType>()({
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 <metal@i0.tf>
Date: Sat, 16 Jul 2022 19:04:17 +0000
Subject: [PATCH 3/4] fix timeout unmute detection
Signed-off-by: GitHub <noreply@github.com>
---
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<MutesPluginType>()({
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 <metal@i0.tf>
Date: Sat, 16 Jul 2022 19:11:04 +0000
Subject: [PATCH 4/4] attempted fix at isMuted #2
Signed-off-by: GitHub <noreply@github.com>
---
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

View file

@ -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<SocialPosts>;
+
+ 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<void> {
+ 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<void> {
+ 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<SocialMediaPosterPluginType> = {
+ config: {
+ platforms: {
+ reddit: {}
+ },
+ },
+};
+
+const platforms = {
+ reddit: pollSubreddit
+}
+
+export const SocialMediaPosterPlugin = zeppelinGuildPlugin<SocialMediaPosterPluginType>()({
+ 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<SocialMediaPosterPluginType>, 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<SocialMediaPosterPluginType>,
+ 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<typeof PlatformPath>;
+
+export const PlatformTypes = t.type({
+ reddit: t.record(t.string, PlatformPath),
+});
+
+export type TPlatformTypes = t.TypeOf<typeof PlatformTypes>;
+
+export const ConfigSchema = t.type({
+ platforms: PlatformTypes,
+});
+export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
+
+export interface SocialMediaPosterPluginType extends BasePluginType {
+ config: TConfigSchema;
+ state: {
+ logs: GuildLogs;
+ pollTimers: Timeout[];
+ // Platform: { path: lastPost }
+ sentPosts: Map<string, Map<string, BigInt>>;
+ 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<ZeppelinGuildPluginBlueprint<any>> = [
@@ -77,6 +78,7 @@ export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
InternalPosterPlugin,
RoleManagerPlugin,
RoleButtonsPlugin,
+ SocialMediaPosterPlugin,
];
// prettier-ignore
--
2.35.1.windows.2

View file

@ -0,0 +1,197 @@
From 140a4ad8070a442b5008000f551260b7b8053695 Mon Sep 17 00:00:00 2001
From: KK <morrkade05@gmail.com>
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<ActivitiesPluginType> = {
+ config: {
+ start_activities: false
+ },
+ overrides: [
+ {
+ level: ">=50",
+ config: {
+ start_activities: true
+ }
+ }
+ ]
+};
+
+export const ActivitiesPlugin = zeppelinGuildPlugin<ActivitiesPluginType>()({
+ 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<typeof ConfigSchema>;
+
+export interface ActivitiesPluginType extends BasePluginType {
+ config: TConfigSchema;
+ state: {};
+}
+
+export const activitiesCmd = typedGuildCommand<ActivitiesPluginType>();
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<ZeppelinGuildPluginBlueprint<any>> = [
@@ -79,6 +80,7 @@ export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
RoleManagerPlugin,
RoleButtonsPlugin,
SocialMediaPosterPlugin,
+ ActivitiesPlugin,
];
// prettier-ignore
--
2.35.1.windows.2