diff --git a/backend/package-lock.json b/backend/package-lock.json index 5255f48e..b44de2a2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,7 +12,8 @@ "cors": "^2.8.5", "cross-env": "^5.2.0", "deep-diff": "^1.0.2", - "discord.js": "^13.0.1", + "discord-api-types": "^0.22.0", + "discord.js": "^13.1.0", "dotenv": "^4.0.0", "emoji-regex": "^8.0.0", "erlpack": "github:almeidx/erlpack#f0c535f73817fd914806d6ca26a7730c14e0fb7c", @@ -81,8 +82,8 @@ "version": "30.0.0-beta.38", "license": "MIT", "dependencies": { - "discord-api-types": "^0.21.0-next.ab1951b.1626870574", - "discord.js": "^13.0.1", + "discord-api-types": "^0.22.0", + "discord.js": "^13.1.0", "knub-command-manager": "^9.1.0", "ts-essentials": "^6.0.7" }, @@ -161,9 +162,9 @@ } }, "node_modules/@discordjs/builders": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.4.0.tgz", - "integrity": "sha512-EiwLltKph6TSaPJIzJYdzNc1PnA2ZNaaE0t0ODg3ghnpVHqfgd0YX9/srsleYHW2cw1sfIq+kbM+h0etf7GWLA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.5.0.tgz", + "integrity": "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw==", "dependencies": { "@sindresorhus/is": "^4.0.1", "discord-api-types": "^0.22.0", @@ -188,9 +189,9 @@ } }, "node_modules/@discordjs/builders/node_modules/tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" }, "node_modules/@discordjs/collection": { "version": "0.2.1", @@ -2166,11 +2167,11 @@ } }, "node_modules/discord.js": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.1.tgz", - "integrity": "sha512-pEODCFfxypBnGEYpSgjkn1jt70raCS1um7Zp0AXEfW1DcR29wISzQ/WeWdnjP5KTXGi0LTtkRiUjOsMgSoukxA==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.1.0.tgz", + "integrity": "sha512-gxO4CXKdHpqA+WKG+f5RNnd3srTDj5uFJHgOathksDE90YNq/Qijkd2WlMgTTMS6AJoEnHxI7G9eDQHCuZ+xDA==", "dependencies": { - "@discordjs/builders": "^0.4.0", + "@discordjs/builders": "^0.5.0", "@discordjs/collection": "^0.2.1", "@discordjs/form-data": "^3.0.1", "@sapphire/async-queue": "^1.1.4", @@ -6449,9 +6450,9 @@ } }, "@discordjs/builders": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.4.0.tgz", - "integrity": "sha512-EiwLltKph6TSaPJIzJYdzNc1PnA2ZNaaE0t0ODg3ghnpVHqfgd0YX9/srsleYHW2cw1sfIq+kbM+h0etf7GWLA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.5.0.tgz", + "integrity": "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw==", "requires": { "@sindresorhus/is": "^4.0.1", "discord-api-types": "^0.22.0", @@ -6466,9 +6467,9 @@ "integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==" }, "tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" } } }, @@ -8047,11 +8048,11 @@ "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==" }, "discord.js": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.0.1.tgz", - "integrity": "sha512-pEODCFfxypBnGEYpSgjkn1jt70raCS1um7Zp0AXEfW1DcR29wISzQ/WeWdnjP5KTXGi0LTtkRiUjOsMgSoukxA==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.1.0.tgz", + "integrity": "sha512-gxO4CXKdHpqA+WKG+f5RNnd3srTDj5uFJHgOathksDE90YNq/Qijkd2WlMgTTMS6AJoEnHxI7G9eDQHCuZ+xDA==", "requires": { - "@discordjs/builders": "^0.4.0", + "@discordjs/builders": "^0.5.0", "@discordjs/collection": "^0.2.1", "@discordjs/form-data": "^3.0.1", "@sapphire/async-queue": "^1.1.4", @@ -8867,8 +8868,8 @@ "@typescript-eslint/eslint-plugin": "^4.23.0", "@typescript-eslint/parser": "^4.23.0", "chai": "^4.3.4", - "discord-api-types": "^0.21.0-next.ab1951b.1626870574", - "discord.js": "^13.0.1", + "discord-api-types": "^0.22.0", + "discord.js": "^13.1.0", "eslint": "^7.2.0", "husky": "^4.3.8", "knub-command-manager": "^9.1.0", diff --git a/backend/package.json b/backend/package.json index 582edf50..3f9589e4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,7 +27,8 @@ "cors": "^2.8.5", "cross-env": "^5.2.0", "deep-diff": "^1.0.2", - "discord.js": "^13.0.1", + "discord-api-types": "^0.22.0", + "discord.js": "^13.1.0", "dotenv": "^4.0.0", "emoji-regex": "^8.0.0", "erlpack": "github:almeidx/erlpack#f0c535f73817fd914806d6ca26a7730c14e0fb7c", diff --git a/backend/src/data/GuildContextMenuLinks.ts b/backend/src/data/GuildContextMenuLinks.ts new file mode 100644 index 00000000..76afd3da --- /dev/null +++ b/backend/src/data/GuildContextMenuLinks.ts @@ -0,0 +1,35 @@ +import { DeleteResult, getRepository, InsertResult, Repository } from "typeorm"; +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { ContextMenuLink } from "./entities/ContextMenuLink"; + +export class GuildContextMenuLinks extends BaseGuildRepository { + private contextLinks: Repository<ContextMenuLink>; + + constructor(guildId) { + super(guildId); + this.contextLinks = getRepository(ContextMenuLink); + } + + async get(id: string): Promise<ContextMenuLink | undefined> { + return this.contextLinks.findOne({ + where: { + guild_id: this.guildId, + context_id: id, + }, + }); + } + + async create(contextId: string, contextAction: string): Promise<InsertResult> { + return this.contextLinks.insert({ + guild_id: this.guildId, + context_id: contextId, + action_name: contextAction, + }); + } + + async deleteAll(): Promise<DeleteResult> { + return this.contextLinks.delete({ + guild_id: this.guildId, + }); + } +} diff --git a/backend/src/data/entities/ContextMenuLink.ts b/backend/src/data/entities/ContextMenuLink.ts new file mode 100644 index 00000000..df66fae3 --- /dev/null +++ b/backend/src/data/entities/ContextMenuLink.ts @@ -0,0 +1,10 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity("context_menus") +export class ContextMenuLink { + @Column() guild_id: string; + + @Column() @PrimaryColumn() context_id: string; + + @Column() action_name: string; +} diff --git a/backend/src/migrations/1628809879962-CreateContextMenuTable.ts b/backend/src/migrations/1628809879962-CreateContextMenuTable.ts new file mode 100644 index 00000000..236b7b3c --- /dev/null +++ b/backend/src/migrations/1628809879962-CreateContextMenuTable.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateContextMenuTable1628809879962 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.createTable( + new Table({ + name: "context_menus", + columns: [ + { + name: "guild_id", + type: "bigint", + }, + { + name: "context_id", + type: "bigint", + isPrimary: true, + isUnique: true, + }, + { + name: "action_name", + type: "varchar", + length: "100", + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.dropTable("context_menus"); + } +} diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts new file mode 100644 index 00000000..7aefe88f --- /dev/null +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -0,0 +1,61 @@ +import { PluginOptions } from "knub"; +import { StrictValidationError } from "src/validatorUtils"; +import { ConfigPreprocessorFn } from "../../../../../Knub/dist/config/configTypes"; +import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks"; +import { LogsPlugin } from "../Logs/LogsPlugin"; +import { MutesPlugin } from "../Mutes/MutesPlugin"; +import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; +import { availableTypes } from "./actions/availableActions"; +import { ContextClickedEvt } from "./events/ContextClickedEvt"; +import { ConfigSchema, ContextMenuPluginType } from "./types"; +import { loadAllCommands } from "./utils/loadAllCommands"; + +const defaultOptions: PluginOptions<ContextMenuPluginType> = { + config: { + context_actions: {}, + }, +}; + +const configPreprocessor: ConfigPreprocessorFn<ContextMenuPluginType> = options => { + if (options.config.context_actions) { + for (const [name, contextMenu] of Object.entries(options.config.context_actions)) { + if (Object.entries(contextMenu.action).length !== 1) { + throw new StrictValidationError([`Invalid value for context_actions/${name}: Must have exactly one action.`]); + } + + const actionName = Object.entries(contextMenu.action)[0][0]; + if (!availableTypes[actionName].includes(contextMenu.type)) { + throw new StrictValidationError([ + `Invalid value for context_actions/${name}/${actionName}: ${actionName} is not allowed on type ${contextMenu.type}.`, + ]); + } + } + } + + return options; +}; + +export const ContextMenuPlugin = zeppelinGuildPlugin<ContextMenuPluginType>()({ + name: "context_menu", + + configSchema: ConfigSchema, + defaultOptions, + configPreprocessor, + + // prettier-ignore + events: [ + ContextClickedEvt, + ], + + beforeLoad(pluginData) { + const { state, guild } = pluginData; + + state.contextMenuLinks = new GuildContextMenuLinks(guild.id); + }, + + afterLoad(pluginData) { + loadAllCommands(pluginData); + }, + + dependencies: [MutesPlugin, LogsPlugin], +}); diff --git a/backend/src/plugins/ContextMenus/actions/availableActions.ts b/backend/src/plugins/ContextMenus/actions/availableActions.ts new file mode 100644 index 00000000..24816790 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/availableActions.ts @@ -0,0 +1,19 @@ +import * as t from "io-ts"; +import { ContextActionBlueprint } from "../helpers"; +import { CleanAction } from "./clean"; +import { MuteAction } from "./mute"; + +export const availableActions: Record<string, ContextActionBlueprint<any>> = { + mute: MuteAction, + clean: CleanAction, +}; + +export const AvailableActions = t.type({ + mute: MuteAction.configType, + clean: CleanAction.configType, +}); + +export const availableTypes: Record<string, string[]> = { + mute: ["USER"], + clean: ["MESSAGE"], +}; diff --git a/backend/src/plugins/ContextMenus/actions/clean.ts b/backend/src/plugins/ContextMenus/actions/clean.ts new file mode 100644 index 00000000..8184d480 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/clean.ts @@ -0,0 +1,64 @@ +import { TextChannel } from "discord.js"; +import * as t from "io-ts"; +import { canActOn } from "src/pluginUtils"; +import { LogType } from "../../../data/LogType"; +import { UtilityPlugin } from "../../../plugins/Utility/UtilityPlugin"; +import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; +import { tNullable } from "../../../utils"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { contextMenuAction } from "../helpers"; + +export const CleanAction = contextMenuAction({ + configType: t.type({ + amount: tNullable(t.number), + targetUserOnly: tNullable(t.boolean), + "delete-pins": tNullable(t.boolean), + }), + + defaultConfig: { + amount: 10, + targetUserOnly: false, + "delete-pins": false, + }, + + async apply({ pluginData, actionConfig, actionName, interaction }) { + interaction.deferReply({ ephemeral: true }); + const targetMessage = interaction.channel + ? await interaction.channel.messages.fetch(interaction.targetId) + : await (pluginData.guild.channels.resolve(interaction.channelId) as TextChannel).messages.fetch( + interaction.targetId, + ); + + const amount = actionConfig.amount ?? 10; + const targetUserOnly = actionConfig.targetUserOnly ?? false; + const deletePins = actionConfig["delete-pins"] ?? false; + + const user = targetUserOnly ? targetMessage.author.id : undefined; + const targetMember = await pluginData.guild.members.fetch(targetMessage.author.id); + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + const utility = pluginData.getPlugin(UtilityPlugin); + + if (targetUserOnly && !canActOn(pluginData, executingMember, targetMember)) { + interaction.followUp({ ephemeral: true, content: "Cannot clean users messages: insufficient permissions" }); + return; + } + + try { + interaction.followUp(`Cleaning... Amount: ${amount}, User Only: ${targetUserOnly}, Pins: ${deletePins}`); + utility.clean( + { count: amount, user, channel: targetMessage.channel.id, "delete-pins": deletePins }, + targetMessage, + ); + } catch (e) { + interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + body: `Failed to clean in <#${interaction.channelId}> in ContextMenu action \`${actionName}\``, + }); + } else { + throw e; + } + } + }, +}); diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts new file mode 100644 index 00000000..00a9c131 --- /dev/null +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -0,0 +1,83 @@ +import humanizeDuration from "humanize-duration"; +import * as t from "io-ts"; +import { canActOn } from "src/pluginUtils"; +import { LogType } from "../../../data/LogType"; +import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; +import { convertDelayStringToMS, tDelayString, tNullable } from "../../../utils"; +import { CaseArgs } from "../../Cases/types"; +import { LogsPlugin } from "../../Logs/LogsPlugin"; +import { MutesPlugin } from "../../Mutes/MutesPlugin"; +import { contextMenuAction } from "../helpers"; +import { resolveActionContactMethods } from "../utils/resolveActionContactMethods"; + +export const MuteAction = contextMenuAction({ + configType: t.type({ + reason: tNullable(t.string), + duration: tNullable(tDelayString), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), + remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), + restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), + postInCaseLog: tNullable(t.boolean), + hide_case: tNullable(t.boolean), + }), + + defaultConfig: { + notify: null, // Use defaults from ModActions + hide_case: false, + }, + + async apply({ pluginData, actionConfig, actionName, interaction }) { + const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined; + const reason = actionConfig.reason || "Context Menu Action"; + const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; + const rolesToRemove = actionConfig.remove_roles_on_mute; + const rolesToRestore = actionConfig.restore_roles_on_mute; + + const caseArgs: Partial<CaseArgs> = { + modId: pluginData.client.user!.id, + automatic: true, + postInCaseLogOverride: actionConfig.postInCaseLog ?? undefined, + hide: Boolean(actionConfig.hide_case), + }; + + interaction.deferReply({ ephemeral: true }); + const mutes = pluginData.getPlugin(MutesPlugin); + const userId = interaction.targetId; + const targetMember = await pluginData.guild.members.fetch(interaction.targetId); + const executingMember = await pluginData.guild.members.fetch(interaction.user.id); + + if (!canActOn(pluginData, executingMember, targetMember)) { + interaction.followUp({ ephemeral: true, content: "Cannot mute: insufficient permissions" }); + return; + } + + try { + const result = await mutes.muteUser( + userId, + duration, + reason, + { contactMethods, caseArgs, isAutomodAction: true }, + rolesToRemove, + rolesToRestore, + ); + + const muteMessage = `Muted **${result.case.user_name}** ${ + duration ? `for ${humanizeDuration(duration)}` : "indefinitely" + } (Case #${result.case.case_number}) (user notified via ${result.notifyResult.method ?? + "dm"})\nPlease update the new case with the \`update\` command`; + + interaction.followUp({ ephemeral: true, content: muteMessage }); + } catch (e) { + interaction.followUp({ ephemeral: true, content: "Plugin error, please check your BOT_ALERTs" }); + + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { + body: `Failed to mute <@!${userId}> in ContextMenu action \`${actionName}\` because a mute role has not been specified in server config`, + }); + } else { + throw e; + } + } + }, +}); diff --git a/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts b/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts new file mode 100644 index 00000000..62675e41 --- /dev/null +++ b/backend/src/plugins/ContextMenus/events/ContextClickedEvt.ts @@ -0,0 +1,13 @@ +import { ContextMenuInteraction } from "discord.js"; +import { contextMenuEvt } from "../types"; +import { routeContextAction } from "../utils/contextRouter"; + +export const ContextClickedEvt = contextMenuEvt({ + event: "interactionCreate", + + async listener(meta) { + if (!meta.args.interaction.isContextMenu) return; + const inter = meta.args.interaction as ContextMenuInteraction; + await routeContextAction(meta.pluginData, inter); + }, +}); diff --git a/backend/src/plugins/ContextMenus/helpers.ts b/backend/src/plugins/ContextMenus/helpers.ts new file mode 100644 index 00000000..5a98f505 --- /dev/null +++ b/backend/src/plugins/ContextMenus/helpers.ts @@ -0,0 +1,25 @@ +import { ContextMenuInteraction } from "discord.js"; +import * as t from "io-ts"; +import { GuildPluginData } from "../../../../../Knub/dist"; +import { Awaitable } from "../../../../../Knub/dist/utils"; +import { ContextMenuPluginType } from "./types"; + +type ContextActionApplyFn<TConfigType> = (meta: { + actionName: string; + pluginData: GuildPluginData<ContextMenuPluginType>; + actionConfig: TConfigType; + interaction: ContextMenuInteraction; +}) => Awaitable<void>; + +export interface ContextActionBlueprint<TConfigType extends t.Any> { + configType: TConfigType; + defaultConfig: Partial<t.TypeOf<TConfigType>>; + + apply: ContextActionApplyFn<t.TypeOf<TConfigType>>; +} + +export function contextMenuAction<TConfigType extends t.Any>( + blueprint: ContextActionBlueprint<TConfigType>, +): ContextActionBlueprint<TConfigType> { + return blueprint; +} diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts new file mode 100644 index 00000000..9b34cb6a --- /dev/null +++ b/backend/src/plugins/ContextMenus/types.ts @@ -0,0 +1,38 @@ +import * as t from "io-ts"; +import { BasePluginType, typedGuildCommand, typedGuildEventListener } from "knub"; +import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks"; +import { tNullable } from "../../utils"; +import { AvailableActions } from "./actions/availableActions"; + +export enum ContextMenuTypes { + USER = 2, + MESSAGE = 3, +} + +export const ContextMenuTypeNameToNumber: Record<string, number> = { + USER: 2, + MESSAGE: 3, +}; + +const ContextActionOpts = t.type({ + enabled: tNullable(t.boolean), + label: t.string, + type: t.keyof(ContextMenuTypes), + action: t.partial(AvailableActions.props), +}); +export type TContextActionOpts = t.TypeOf<typeof ContextActionOpts>; + +export const ConfigSchema = t.type({ + context_actions: t.record(t.string, ContextActionOpts), +}); +export type TConfigSchema = t.TypeOf<typeof ConfigSchema>; + +export interface ContextMenuPluginType extends BasePluginType { + config: TConfigSchema; + state: { + contextMenuLinks: GuildContextMenuLinks; + }; +} + +export const contextMenuCmd = typedGuildCommand<ContextMenuPluginType>(); +export const contextMenuEvt = typedGuildEventListener<ContextMenuPluginType>(); diff --git a/backend/src/plugins/ContextMenus/utils/contextRouter.ts b/backend/src/plugins/ContextMenus/utils/contextRouter.ts new file mode 100644 index 00000000..c3fd3abe --- /dev/null +++ b/backend/src/plugins/ContextMenus/utils/contextRouter.ts @@ -0,0 +1,28 @@ +import { ContextMenuInteraction } from "discord.js"; +import { GuildPluginData } from "../../../../../../Knub/dist"; +import { availableActions } from "../actions/availableActions"; +import { ContextMenuPluginType } from "../types"; + +export async function routeContextAction( + pluginData: GuildPluginData<ContextMenuPluginType>, + interaction: ContextMenuInteraction, +) { + const contextLink = await pluginData.state.contextMenuLinks.get(interaction.commandId); + if (!contextLink) return; + const contextActions = Object.entries(pluginData.config.get().context_actions); + + const configLink = contextActions.find(x => x[0] === contextLink.action_name); + if (!configLink) return; + + for (const [actionName, actionConfig] of Object.entries(configLink[1].action)) { + if (actionConfig == null) return; + const action = availableActions[actionName]; + action.apply({ + actionName, + pluginData, + actionConfig, + interaction, + }); + return; + } +} diff --git a/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts b/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts new file mode 100644 index 00000000..a83cbd4a --- /dev/null +++ b/backend/src/plugins/ContextMenus/utils/loadAllCommands.ts @@ -0,0 +1,37 @@ +import { ApplicationCommandData } from "discord.js"; +import { LogType } from "src/data/LogType"; +import { LogsPlugin } from "src/plugins/Logs/LogsPlugin"; +import { GuildPluginData } from "../../../../../../Knub/dist"; +import { ContextMenuPluginType, ContextMenuTypeNameToNumber } from "../types"; + +export async function loadAllCommands(pluginData: GuildPluginData<ContextMenuPluginType>) { + const comms = await pluginData.client.application!.commands; + const actions = pluginData.config.get().context_actions; + const newCommands: ApplicationCommandData[] = []; + const addedNames: string[] = []; + + for (const [name, configAction] of Object.entries(actions)) { + if (!configAction.enabled) continue; + + const data: ApplicationCommandData = { + type: ContextMenuTypeNameToNumber[configAction.type], + name: configAction.label, + }; + addedNames.push(name); + newCommands.push(data); + } + + const setCommands = await comms.set(newCommands, pluginData.guild.id).catch(e => { + pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, `Unable to overwrite context menus: ${e}`); + return undefined; + }); + if (!setCommands) return; + + const setCommandsArray = [...setCommands.values()]; + await pluginData.state.contextMenuLinks.deleteAll(); + + for (let i = 0; i < setCommandsArray.length; i++) { + const command = setCommandsArray[i]; + pluginData.state.contextMenuLinks.create(command.id, addedNames[i]); + } +} diff --git a/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts b/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts new file mode 100644 index 00000000..bec64765 --- /dev/null +++ b/backend/src/plugins/ContextMenus/utils/resolveActionContactMethods.ts @@ -0,0 +1,32 @@ +import { Snowflake, TextChannel } from "discord.js"; +import { GuildPluginData } from "knub"; +import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; +import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils"; +import { ContextMenuPluginType } from "../types"; + +export function resolveActionContactMethods( + pluginData: GuildPluginData<ContextMenuPluginType>, + actionConfig: { + notify?: string | null; + notifyChannel?: string | null; + }, +): UserNotificationMethod[] { + if (actionConfig.notify === "dm") { + return [{ type: "dm" }]; + } else if (actionConfig.notify === "channel") { + if (!actionConfig.notifyChannel) { + throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL); + } + + const channel = pluginData.guild.channels.cache.get(actionConfig.notifyChannel as Snowflake); + if (!(channel instanceof TextChannel)) { + throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL); + } + + return [{ type: "channel", channel }]; + } else if (actionConfig.notify && disableUserNotificationStrings.includes(actionConfig.notify)) { + return []; + } + + return []; +} diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 2b7a7b41..a2e9a6e7 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -13,7 +13,7 @@ import { AboutCmd } from "./commands/AboutCmd"; import { AvatarCmd } from "./commands/AvatarCmd"; import { BanSearchCmd } from "./commands/BanSearchCmd"; import { ChannelInfoCmd } from "./commands/ChannelInfoCmd"; -import { CleanCmd } from "./commands/CleanCmd"; +import { CleanArgs, cleanCmd, CleanCmd } from "./commands/CleanCmd"; import { ContextCmd } from "./commands/ContextCmd"; import { EmojiInfoCmd } from "./commands/EmojiInfoCmd"; import { HelpCmd } from "./commands/HelpCmd"; @@ -156,6 +156,14 @@ export const UtilityPlugin = zeppelinGuildPlugin<UtilityPluginType>()({ AutoJoinThreadSyncEvt, ], + public: { + clean(pluginData) { + return (args: CleanArgs, msg) => { + cleanCmd(pluginData, args, msg); + }; + }, + }, + beforeLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Utility/commands/CleanCmd.ts b/backend/src/plugins/Utility/commands/CleanCmd.ts index ca96d57f..c7f8e6c3 100644 --- a/backend/src/plugins/Utility/commands/CleanCmd.ts +++ b/backend/src/plugins/Utility/commands/CleanCmd.ts @@ -10,12 +10,13 @@ import { getBaseUrl, sendErrorMessage, sendSuccessMessage } from "../../../plugi import { allowTimeout } from "../../../RegExpRunner"; import { DAYS, getInviteCodesInString, noop, SECONDS } from "../../../utils"; import { utilityCmd, UtilityPluginType } from "../types"; +import { boolean, number } from "io-ts"; const MAX_CLEAN_COUNT = 150; const MAX_CLEAN_TIME = 1 * DAYS; const CLEAN_COMMAND_DELETE_DELAY = 5 * SECONDS; -async function cleanMessages( +export async function cleanMessages( pluginData: GuildPluginData<UtilityPluginType>, channel: TextChannel, savedMessages: SavedMessage[], @@ -61,6 +62,142 @@ const opts = { "to-id": ct.anyId({ option: true, shortcut: "id" }), }; +export interface CleanArgs { + count: number; + update?: boolean; + user?: string; + channel?: string; + bots?: boolean; + "delete-pins"?: boolean; + "has-invites"?: boolean; + match?: RegExp; + "to-id"?: string; +} + +export async function cleanCmd(pluginData: GuildPluginData<UtilityPluginType>, args: CleanArgs | any, msg) { + if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { + sendErrorMessage(pluginData, msg.channel, `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`); + return; + } + + const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel; + if (!targetChannel || !(targetChannel instanceof TextChannel)) { + sendErrorMessage(pluginData, msg.channel, `Invalid channel specified`); + return; + } + + if (targetChannel.id !== msg.channel.id) { + const configForTargetChannel = await pluginData.config.getMatchingConfig({ + userId: msg.author.id, + member: msg.member, + channelId: targetChannel.id, + categoryId: targetChannel.parentId, + }); + if (configForTargetChannel.can_clean !== true) { + sendErrorMessage(pluginData, msg.channel, `Missing permissions to use clean on that channel`); + return; + } + } + + const cleaningMessage = msg.channel.send("Cleaning..."); + + const messagesToClean: SavedMessage[] = []; + let beforeId = msg.id; + const timeCutoff = msg.createdTimestamp - MAX_CLEAN_TIME; + const upToMsgId = args["to-id"]; + let foundId = false; + + const deletePins = args["delete-pins"] != null ? args["delete-pins"] : false; + let pins: Message[] = []; + if (!deletePins) { + pins = [...(await msg.channel.messages.fetchPinned().catch(() => [])).values()]; + } + + while (messagesToClean.length < args.count) { + const potentialMessages = await targetChannel.messages.fetch({ + before: beforeId, + limit: args.count, + }); + if (potentialMessages.size === 0) break; + + const existingStored = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]); + const alreadyStored = existingStored.map(stored => stored.id); + const messagesToStore = [ + ...potentialMessages.filter(potentialMsg => !alreadyStored.includes(potentialMsg.id)).values(), + ]; + await pluginData.state.savedMessages.createFromMessages(messagesToStore); + + const potentialMessagesToClean = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]); + if (potentialMessagesToClean.length === 0) break; + + const filtered: SavedMessage[] = []; + for (const message of potentialMessagesToClean) { + const contentString = message.data.content || ""; + if (args.user && message.user_id !== args.user) continue; + if (args.bots && !message.is_bot) continue; + if (!deletePins && pins.find(x => x.id === message.id) != null) continue; + if (args["has-invites"] && getInviteCodesInString(contentString).length === 0) continue; + if (upToMsgId != null && message.id < upToMsgId) { + foundId = true; + break; + } + if (moment.utc(message.posted_at).valueOf() < timeCutoff) continue; + if (args.match && !(await pluginData.state.regexRunner.exec(args.match, contentString).catch(allowTimeout))) { + continue; + } + + filtered.push(message); + } + const remaining = args.count - messagesToClean.length; + const withoutOverflow = filtered.slice(0, remaining); + messagesToClean.push(...withoutOverflow); + + beforeId = potentialMessages.lastKey()!; + + if (foundId || moment.utc(potentialMessages.last()!.createdTimestamp).valueOf() < timeCutoff) { + break; + } + } + + let responseMsg: Message | undefined; + if (messagesToClean.length > 0) { + const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, msg.author); + + let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`; + if (targetChannel.id !== msg.channel.id) { + responseText += ` in <#${targetChannel.id}>: ${cleanResult.archiveUrl}`; + } + + if (args.update) { + const modActions = pluginData.getPlugin(ModActionsPlugin); + const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id; + const updateMessage = `Cleaned ${messagesToClean.length} ${ + messagesToClean.length === 1 ? "message" : "messages" + } in <#${channelId}>: ${cleanResult.archiveUrl}`; + if (typeof args.update === "number") { + modActions.updateCase(msg, args.update, updateMessage); + } else { + modActions.updateCase(msg, null, updateMessage); + } + } + + responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText); + } else { + responseMsg = await sendErrorMessage(pluginData, msg.channel, `Found no messages to clean!`); + } + + await (await cleaningMessage).delete(); + + if (targetChannel.id === msg.channel.id) { + // Delete the !clean command and the bot response if a different channel wasn't specified + // (so as not to spam the cleaned channel with the command itself) + setTimeout(() => { + msg.delete().catch(noop); + responseMsg?.delete().catch(noop); + }, CLEAN_COMMAND_DELETE_DELAY); + } +} + export const CleanCmd = utilityCmd({ trigger: ["clean", "clear"], description: "Remove a number of recent messages", @@ -83,126 +220,6 @@ export const CleanCmd = utilityCmd({ ], async run({ message: msg, args, pluginData }) { - if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { - sendErrorMessage(pluginData, msg.channel, `Clean count must be between 1 and ${MAX_CLEAN_COUNT}`); - return; - } - - const targetChannel = args.channel ? pluginData.guild.channels.cache.get(args.channel as Snowflake) : msg.channel; - if (!targetChannel || !(targetChannel instanceof TextChannel)) { - sendErrorMessage(pluginData, msg.channel, `Invalid channel specified`); - return; - } - - if (targetChannel.id !== msg.channel.id) { - const configForTargetChannel = await pluginData.config.getMatchingConfig({ - userId: msg.author.id, - member: msg.member, - channelId: targetChannel.id, - categoryId: targetChannel.parentId, - }); - if (configForTargetChannel.can_clean !== true) { - sendErrorMessage(pluginData, msg.channel, `Missing permissions to use clean on that channel`); - return; - } - } - - const cleaningMessage = msg.channel.send("Cleaning..."); - - const messagesToClean: SavedMessage[] = []; - let beforeId = msg.id; - const timeCutoff = msg.createdTimestamp - MAX_CLEAN_TIME; - const upToMsgId = args["to-id"]; - let foundId = false; - - const deletePins = args["delete-pins"] != null ? args["delete-pins"] : false; - let pins: Message[] = []; - if (!deletePins) { - pins = [...(await msg.channel.messages.fetchPinned().catch(() => [])).values()]; - } - - while (messagesToClean.length < args.count) { - const potentialMessages = await targetChannel.messages.fetch({ - before: beforeId, - limit: args.count, - }); - if (potentialMessages.size === 0) break; - - const existingStored = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]); - const alreadyStored = existingStored.map(stored => stored.id); - const messagesToStore = [ - ...potentialMessages.filter(potentialMsg => !alreadyStored.includes(potentialMsg.id)).values(), - ]; - await pluginData.state.savedMessages.createFromMessages(messagesToStore); - - const potentialMessagesToClean = await pluginData.state.savedMessages.getMultiple([...potentialMessages.keys()]); - if (potentialMessagesToClean.length === 0) break; - - const filtered: SavedMessage[] = []; - for (const message of potentialMessagesToClean) { - const contentString = message.data.content || ""; - if (args.user && message.user_id !== args.user) continue; - if (args.bots && !message.is_bot) continue; - if (!deletePins && pins.find(x => x.id === message.id) != null) continue; - if (args["has-invites"] && getInviteCodesInString(contentString).length === 0) continue; - if (upToMsgId != null && message.id < upToMsgId) { - foundId = true; - break; - } - if (moment.utc(message.posted_at).valueOf() < timeCutoff) continue; - if (args.match && !(await pluginData.state.regexRunner.exec(args.match, contentString).catch(allowTimeout))) { - continue; - } - - filtered.push(message); - } - const remaining = args.count - messagesToClean.length; - const withoutOverflow = filtered.slice(0, remaining); - messagesToClean.push(...withoutOverflow); - - beforeId = potentialMessages.lastKey()!; - - if (foundId || moment.utc(potentialMessages.last()!.createdTimestamp).valueOf() < timeCutoff) { - break; - } - } - - let responseMsg: Message | undefined; - if (messagesToClean.length > 0) { - const cleanResult = await cleanMessages(pluginData, targetChannel, messagesToClean, msg.author); - - let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`; - if (targetChannel.id !== msg.channel.id) { - responseText += ` in <#${targetChannel.id}>: ${cleanResult.archiveUrl}`; - } - - if (args.update) { - const modActions = pluginData.getPlugin(ModActionsPlugin); - const channelId = targetChannel.id !== msg.channel.id ? targetChannel.id : msg.channel.id; - const updateMessage = `Cleaned ${messagesToClean.length} ${ - messagesToClean.length === 1 ? "message" : "messages" - } in <#${channelId}>: ${cleanResult.archiveUrl}`; - if (typeof args.update === "number") { - modActions.updateCase(msg, args.update, updateMessage); - } else { - modActions.updateCase(msg, null, updateMessage); - } - } - - responseMsg = await sendSuccessMessage(pluginData, msg.channel, responseText); - } else { - responseMsg = await sendErrorMessage(pluginData, msg.channel, `Found no messages to clean!`); - } - - await (await cleaningMessage).delete(); - - if (targetChannel.id === msg.channel.id) { - // Delete the !clean command and the bot response if a different channel wasn't specified - // (so as not to spam the cleaned channel with the command itself) - setTimeout(() => { - msg.delete().catch(noop); - responseMsg?.delete().catch(noop); - }, CLEAN_COMMAND_DELETE_DELAY); - } + cleanCmd(pluginData, args, msg); }, }); diff --git a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts index d1788bce..5052ac11 100644 --- a/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts +++ b/backend/src/plugins/Utility/functions/getChannelInfoEmbed.ts @@ -134,9 +134,8 @@ export async function getChannelInfoEmbed( const memberCount = thread.memberCount ?? thread.members.cache.size; const owner = await thread.fetchOwner().catch(() => null); const ownerMention = owner?.user ? verboseUserMention(owner.user) : "Unknown#0000"; - const humanizedArchiveTime = `Archive duration: **${humanizeDuration( - (thread.autoArchiveDuration ?? 0) * MINUTES, - )}**`; + const autoArchiveDuration = thread.autoArchiveDuration === "MAX" ? 10080 : thread.autoArchiveDuration; // TODO: Boost level check + const humanizedArchiveTime = `Archive duration: **${humanizeDuration((autoArchiveDuration ?? 0) * MINUTES)}**`; embed.fields.push({ name: preEmbedPadding + "Thread information", diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 3f0568f6..34535b7c 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -6,6 +6,7 @@ import { CasesPlugin } from "./Cases/CasesPlugin"; import { CensorPlugin } from "./Censor/CensorPlugin"; import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; import { CompanionChannelsPlugin } from "./CompanionChannels/CompanionChannelsPlugin"; +import { ContextMenuPlugin } from "./ContextMenus/ContextMenuPlugin"; import { CountersPlugin } from "./Counters/CountersPlugin"; import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin"; import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin"; @@ -67,6 +68,7 @@ export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [ CustomEventsPlugin, TimeAndDatePlugin, CountersPlugin, + ContextMenuPlugin, ]; // prettier-ignore