diff --git a/backend/src/plugins/CustomEvents/ActionError.ts b/backend/src/plugins/CustomEvents/ActionError.ts new file mode 100644 index 00000000..07237673 --- /dev/null +++ b/backend/src/plugins/CustomEvents/ActionError.ts @@ -0,0 +1 @@ +export class ActionError extends Error {} diff --git a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts new file mode 100644 index 00000000..6cebd2d5 --- /dev/null +++ b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts @@ -0,0 +1,39 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, CustomEventsPluginType } from "./types"; +import { command, parseSignature } from "knub"; +import { commandTypes } from "../../commandTypes"; +import { stripObjectToScalars } from "../../utils"; +import { runEvent } from "./functions/runEvent"; + +const defaultOptions = { + config: { + events: {}, + }, +}; + +export const CustomEventsPlugin = zeppelinPlugin()("custom_events", { + configSchema: ConfigSchema, + defaultOptions, + + onLoad(pluginData) { + const config = pluginData.config.get(); + for (const [key, event] of Object.entries(config.events)) { + if (event.trigger.type === "command") { + const signature = event.trigger.params ? parseSignature(event.trigger.params, commandTypes) : {}; + const eventCommand = command({ + trigger: event.trigger.name, + permission: `events.${key}.trigger.can_use`, + signature, + run({ message, args }) { + const strippedMsg = stripObjectToScalars(message, ["channel", "author"]); + runEvent(pluginData, event, { msg: message, args }, { args, msg: strippedMsg }); + }, + }); + } + } + }, + + onUnload() { + // TODO: Run clearTriggers() once we actually have something there + }, +}); diff --git a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts new file mode 100644 index 00000000..c5430724 --- /dev/null +++ b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts @@ -0,0 +1,36 @@ +import { PluginData } from "knub"; +import { CustomEventsPluginType, TCustomEvent } from "../types"; +import * as t from "io-ts"; +import { renderTemplate } from "../../../templateFormatter"; +import { resolveMember } from "../../../utils"; +import { ActionError } from "../ActionError"; +import { canActOn } from "../../../pluginUtils"; +import { Message } from "eris"; + +export const AddRoleAction = t.type({ + type: t.literal("add_role"), + target: t.string, + role: t.union([t.string, t.array(t.string)]), +}); +export type TAddRoleAction = t.TypeOf; + +export async function addRoleAction( + pluginData: PluginData, + action: TAddRoleAction, + values: any, + event: TCustomEvent, + eventData: any, +) { + const targetId = await renderTemplate(action.target, values, false); + const target = await resolveMember(pluginData.client, pluginData.guild, targetId); + if (!target) throw new ActionError(`Unknown target member: ${targetId}`); + + if (event.trigger.type === "command" && !canActOn(pluginData, (eventData.msg as Message).member, target)) { + throw new ActionError("Missing permissions"); + } + + const rolesToAdd = Array.isArray(action.role) ? action.role : [action.role]; + await target.edit({ + roles: Array.from(new Set([...target.roles, ...rolesToAdd])), + }); +} diff --git a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts new file mode 100644 index 00000000..bb9359f6 --- /dev/null +++ b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts @@ -0,0 +1,41 @@ +import { PluginData } from "knub"; +import { CustomEventsPluginType, TCustomEvent } from "../types"; +import * as t from "io-ts"; +import { renderTemplate } from "../../../templateFormatter"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { ActionError } from "../ActionError"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; + +export const CreateCaseAction = t.type({ + type: t.literal("create_case"), + case_type: t.string, + mod: t.string, + target: t.string, + reason: t.string, +}); +export type TCreateCaseAction = t.TypeOf; + +export async function createCaseAction( + pluginData: PluginData, + action: TCreateCaseAction, + values: any, + event: TCustomEvent, + eventData: any, +) { + const modId = await renderTemplate(action.mod, values, false); + const targetId = await renderTemplate(action.target, values, false); + + const reason = await renderTemplate(action.reason, values, false); + + if (CaseTypes[action.case_type] == null) { + throw new ActionError(`Invalid case type: ${action.type}`); + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + await casesPlugin.createCase({ + userId: targetId, + modId, + type: CaseTypes[action.case_type], + reason: `__[${event.name}]__ ${reason}`, + }); +} diff --git a/backend/src/plugins/CustomEvents/actions/messageAction.ts b/backend/src/plugins/CustomEvents/actions/messageAction.ts new file mode 100644 index 00000000..a29aa2f8 --- /dev/null +++ b/backend/src/plugins/CustomEvents/actions/messageAction.ts @@ -0,0 +1,26 @@ +import { PluginData } from "knub"; +import { CustomEventsPluginType } from "../types"; +import * as t from "io-ts"; +import { renderTemplate } from "../../../templateFormatter"; +import { ActionError } from "../ActionError"; +import { TextChannel } from "eris"; + +export const MessageAction = t.type({ + type: t.literal("message"), + channel: t.string, + content: t.string, +}); +export type TMessageAction = t.TypeOf; + +export async function messageAction( + pluginData: PluginData, + action: TMessageAction, + values: any, +) { + const targetChannelId = await renderTemplate(action.channel, values, false); + const targetChannel = pluginData.guild.channels.get(targetChannelId); + if (!targetChannel) throw new ActionError("Unknown target channel"); + if (!(targetChannel instanceof TextChannel)) throw new ActionError("Target channel is not a text channel"); + + await targetChannel.createMessage({ content: action.content }); +} diff --git a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts new file mode 100644 index 00000000..1fdb48f2 --- /dev/null +++ b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts @@ -0,0 +1,41 @@ +import { PluginData } from "knub"; +import { CustomEventsPluginType, TCustomEvent } from "../types"; +import * as t from "io-ts"; +import { renderTemplate } from "../../../templateFormatter"; +import { resolveMember } from "../../../utils"; +import { ActionError } from "../ActionError"; +import { canActOn } from "../../../pluginUtils"; +import { Message, VoiceChannel } from "eris"; + +export const MoveToVoiceChannelAction = t.type({ + type: t.literal("move_to_vc"), + target: t.string, + channel: t.string, +}); +export type TMoveToVoiceChannelAction = t.TypeOf; + +export async function moveToVoiceChannelAction( + pluginData: PluginData, + action: TMoveToVoiceChannelAction, + values: any, + event: TCustomEvent, + eventData: any, +) { + const targetId = await renderTemplate(action.target, values, false); + const target = await resolveMember(pluginData.client, pluginData.guild, targetId); + if (!target) throw new ActionError("Unknown target member"); + + if (event.trigger.type === "command" && !canActOn(pluginData, (eventData.msg as Message).member, target)) { + throw new ActionError("Missing permissions"); + } + + const targetChannelId = await renderTemplate(action.channel, values, false); + const targetChannel = pluginData.guild.channels.get(targetChannelId); + if (!targetChannel) throw new ActionError("Unknown target channel"); + if (!(targetChannel instanceof VoiceChannel)) throw new ActionError("Target channel is not a voice channel"); + + if (!target.voiceState.channelID) return; + await target.edit({ + channelID: targetChannel.id, + }); +} diff --git a/backend/src/plugins/CustomEvents/functions/runEvent.ts b/backend/src/plugins/CustomEvents/functions/runEvent.ts new file mode 100644 index 00000000..20dc842c --- /dev/null +++ b/backend/src/plugins/CustomEvents/functions/runEvent.ts @@ -0,0 +1,42 @@ +import { PluginData } from "knub"; +import { CustomEventsPluginType, TCustomEvent } from "../types"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { ActionError } from "../ActionError"; +import { Message } from "eris"; +import { addRoleAction } from "../actions/addRoleAction"; +import { createCaseAction } from "../actions/createCaseAction"; +import { moveToVoiceChannelAction } from "../actions/moveToVoiceChannelAction"; +import { messageAction } from "../actions/messageAction"; + +export async function runEvent( + pluginData: PluginData, + event: TCustomEvent, + eventData: any, + values: any, +) { + try { + for (const action of event.actions) { + if (action.type === "add_role") { + await addRoleAction(pluginData, action, values, event, eventData); + } else if (action.type === "create_case") { + await createCaseAction(pluginData, action, values, event, eventData); + } else if (action.type === "move_to_vc") { + await moveToVoiceChannelAction(pluginData, action, values, event, eventData); + } else if (action.type === "message") { + await messageAction(pluginData, action, values); + } + } + } catch (e) { + if (e instanceof ActionError) { + if (event.trigger.type === "command") { + sendErrorMessage(pluginData, (eventData.msg as Message).channel, e.message); + } else { + // TODO: Where to log action errors from other kinds of triggers? + } + + return; + } + + throw e; + } +} diff --git a/backend/src/plugins/CustomEvents/types.ts b/backend/src/plugins/CustomEvents/types.ts new file mode 100644 index 00000000..7db0b14d --- /dev/null +++ b/backend/src/plugins/CustomEvents/types.ts @@ -0,0 +1,40 @@ +import * as t from "io-ts"; +import { BasePluginType } from "knub"; +import { AddRoleAction } from "./actions/addRoleAction"; +import { CreateCaseAction } from "./actions/createCaseAction"; +import { MoveToVoiceChannelAction } from "./actions/moveToVoiceChannelAction"; +import { MessageAction } from "./actions/messageAction"; + +// Triggers +const CommandTrigger = t.type({ + type: t.literal("command"), + name: t.string, + params: t.string, + can_use: t.boolean, +}); +type TCommandTrigger = t.TypeOf; + +const AnyTrigger = CommandTrigger; // TODO: Make into a union once we have more triggers +type TAnyTrigger = t.TypeOf; + +const AnyAction = t.union([AddRoleAction, CreateCaseAction, MoveToVoiceChannelAction, MessageAction]); +type TAnyAction = t.TypeOf; + +export const CustomEvent = t.type({ + name: t.string, + trigger: AnyTrigger, + actions: t.array(AnyAction), +}); +export type TCustomEvent = t.TypeOf; + +export const ConfigSchema = t.type({ + events: t.record(t.string, CustomEvent), +}); +export type TConfigSchema = t.TypeOf; + +export interface CustomEventsPluginType extends BasePluginType { + config: TConfigSchema; + state: { + clearTriggers: () => void; + }; +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index a77e18d5..9b6963e4 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -28,6 +28,7 @@ import { SpamPlugin } from "./Spam/SpamPlugin"; import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin"; import { AutomodPlugin } from "./Automod/AutomodPlugin"; import { CompanionChannelsPlugin } from "./CompanionChannels/CompanionChannelsPlugin"; +import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -59,6 +60,7 @@ export const guildPlugins: Array> = [ MutesPlugin, AutomodPlugin, CompanionChannelsPlugin, + CustomEventsPlugin, ]; // prettier-ignore