From 7ca14d090bcfb790f84186b3577eb9cdc8e385ec Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Wed, 22 Jul 2020 22:01:53 +0200 Subject: [PATCH] Migrate Tags to new Plugin structure --- backend/src/plugins/Tags/TagsPlugin.ts | 76 +++++++++ .../src/plugins/Tags/commands/TagCreateCmd.ts | 32 ++++ .../src/plugins/Tags/commands/TagDeleteCmd.ts | 23 +++ .../src/plugins/Tags/commands/TagEvalCmd.ts | 17 ++ .../src/plugins/Tags/commands/TagListCmd.ts | 18 +++ .../src/plugins/Tags/commands/TagSourceCmd.ts | 40 +++++ backend/src/plugins/Tags/types.ts | 58 +++++++ .../src/plugins/Tags/util/onMessageCreate.ts | 145 ++++++++++++++++++ .../src/plugins/Tags/util/onMessageDelete.ts | 32 ++++ .../Tags/util/renderSafeTagFromMessage.ts | 45 ++++++ backend/src/plugins/Tags/util/renderTag.ts | 33 ++++ backend/src/plugins/availablePlugins.ts | 2 + 12 files changed, 521 insertions(+) create mode 100644 backend/src/plugins/Tags/TagsPlugin.ts create mode 100644 backend/src/plugins/Tags/commands/TagCreateCmd.ts create mode 100644 backend/src/plugins/Tags/commands/TagDeleteCmd.ts create mode 100644 backend/src/plugins/Tags/commands/TagEvalCmd.ts create mode 100644 backend/src/plugins/Tags/commands/TagListCmd.ts create mode 100644 backend/src/plugins/Tags/commands/TagSourceCmd.ts create mode 100644 backend/src/plugins/Tags/types.ts create mode 100644 backend/src/plugins/Tags/util/onMessageCreate.ts create mode 100644 backend/src/plugins/Tags/util/onMessageDelete.ts create mode 100644 backend/src/plugins/Tags/util/renderSafeTagFromMessage.ts create mode 100644 backend/src/plugins/Tags/util/renderTag.ts diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts new file mode 100644 index 00000000..641f37c8 --- /dev/null +++ b/backend/src/plugins/Tags/TagsPlugin.ts @@ -0,0 +1,76 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, TagsPluginType } from "./types"; +import { PluginOptions } from "knub"; +import { GuildArchives } from "src/data/GuildArchives"; +import { GuildTags } from "src/data/GuildTags"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildLogs } from "src/data/GuildLogs"; +import { onMessageCreate } from "./util/onMessageCreate"; +import { onMessageDelete } from "./util/onMessageDelete"; +import { TagCreateCmd } from "./commands/TagCreateCmd"; +import { TagDeleteCmd } from "./commands/TagDeleteCmd"; +import { TagEvalCmd } from "./commands/TagEvalCmd"; +import { TagListCmd } from "./commands/TagListCmd"; +import { TagSourceCmd } from "./commands/TagSourceCmd"; + +const defaultOptions: PluginOptions = { + config: { + prefix: "!!", + delete_with_command: true, + + user_tag_cooldown: null, + global_tag_cooldown: null, + user_cooldown: null, + global_cooldown: null, + + categories: {}, + + can_create: false, + can_use: false, + can_list: false, + }, + overrides: [ + { + level: ">=50", + config: { + can_use: true, + can_create: true, + can_list: true, + }, + }, + ], +}; + +export const TagsPlugin = zeppelinPlugin()("tags", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + TagEvalCmd, + TagCreateCmd, + TagDeleteCmd, + TagListCmd, + TagSourceCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.archives = GuildArchives.getGuildInstance(guild.id); + state.tags = GuildTags.getGuildInstance(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.logs = new GuildLogs(guild.id); + + state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg); + state.savedMessages.events.on("create", state.onMessageCreateFn); + + state.onMessageDeleteFn = msg => onMessageDelete(pluginData, msg); + state.savedMessages.events.on("delete", state.onMessageDeleteFn); + }, + + onUnload(pluginData) { + pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn); + pluginData.state.savedMessages.events.off("delete", pluginData.state.onMessageDeleteFn); + }, +}); diff --git a/backend/src/plugins/Tags/commands/TagCreateCmd.ts b/backend/src/plugins/Tags/commands/TagCreateCmd.ts new file mode 100644 index 00000000..9e828c14 --- /dev/null +++ b/backend/src/plugins/Tags/commands/TagCreateCmd.ts @@ -0,0 +1,32 @@ +import { tagsCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { parseTemplate, TemplateParseError } from "src/templateFormatter"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; + +export const TagCreateCmd = tagsCmd({ + trigger: "tag", + permission: "can_create", + + signature: { + tag: ct.string(), + body: ct.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + try { + parseTemplate(args.body); + } catch (e) { + if (e instanceof TemplateParseError) { + sendErrorMessage(pluginData, msg.channel, `Invalid tag syntax: ${e.message}`); + return; + } else { + throw e; + } + } + + await pluginData.state.tags.createOrUpdate(args.tag, args.body, msg.author.id); + + const prefix = pluginData.config.get().prefix; + sendSuccessMessage(pluginData, msg.channel, `Tag set! Use it with: \`${prefix}${args.tag}\``); + }, +}); diff --git a/backend/src/plugins/Tags/commands/TagDeleteCmd.ts b/backend/src/plugins/Tags/commands/TagDeleteCmd.ts new file mode 100644 index 00000000..182867a6 --- /dev/null +++ b/backend/src/plugins/Tags/commands/TagDeleteCmd.ts @@ -0,0 +1,23 @@ +import { tagsCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; + +export const TagDeleteCmd = tagsCmd({ + trigger: "tag delete", + permission: "can_create", + + signature: { + tag: ct.string(), + }, + + async run({ message: msg, args, pluginData }) { + const tag = await pluginData.state.tags.find(args.tag); + if (!tag) { + sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + return; + } + + await pluginData.state.tags.delete(args.tag); + sendSuccessMessage(pluginData, msg.channel, "Tag deleted!"); + }, +}); diff --git a/backend/src/plugins/Tags/commands/TagEvalCmd.ts b/backend/src/plugins/Tags/commands/TagEvalCmd.ts new file mode 100644 index 00000000..3138bcd6 --- /dev/null +++ b/backend/src/plugins/Tags/commands/TagEvalCmd.ts @@ -0,0 +1,17 @@ +import { tagsCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { renderTag } from "../util/renderTag"; + +export const TagEvalCmd = tagsCmd({ + trigger: "tag eval", + permission: "can_create", + + signature: { + body: ct.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + const rendered = await renderTag(pluginData, args.body); + msg.channel.createMessage(rendered); + }, +}); diff --git a/backend/src/plugins/Tags/commands/TagListCmd.ts b/backend/src/plugins/Tags/commands/TagListCmd.ts new file mode 100644 index 00000000..260d28af --- /dev/null +++ b/backend/src/plugins/Tags/commands/TagListCmd.ts @@ -0,0 +1,18 @@ +import { tagsCmd } from "../types"; + +export const TagListCmd = tagsCmd({ + trigger: ["tag list", "tags", "taglist"], + permission: "can_list", + + async run({ message: msg, pluginData }) { + const tags = await pluginData.state.tags.all(); + if (tags.length === 0) { + msg.channel.createMessage(`No tags created yet! Use \`tag create\` command to create one.`); + return; + } + + const prefix = pluginData.config.getForMessage(msg).prefix; + const tagNames = tags.map(tag => tag.tag).sort(); + msg.channel.createMessage(`Available tags (use with ${prefix}tag): \`\`\`${tagNames.join(", ")}\`\`\``); + }, +}); diff --git a/backend/src/plugins/Tags/commands/TagSourceCmd.ts b/backend/src/plugins/Tags/commands/TagSourceCmd.ts new file mode 100644 index 00000000..c4a8f914 --- /dev/null +++ b/backend/src/plugins/Tags/commands/TagSourceCmd.ts @@ -0,0 +1,40 @@ +import { tagsCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, getBaseUrl, sendSuccessMessage } from "src/pluginUtils"; +import moment from "moment-timezone"; + +export const TagSourceCmd = tagsCmd({ + trigger: "tag", + permission: "can_create", + + signature: { + tag: ct.string(), + + delete: ct.bool({ option: true, shortcut: "d", isSwitch: true }), + }, + + async run({ message: msg, args, pluginData }) { + if (args.delete) { + const actualTag = await pluginData.state.tags.find(args.tag); + if (!actualTag) { + sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + return; + } + + await pluginData.state.tags.delete(args.tag); + sendSuccessMessage(pluginData, msg.channel, "Tag deleted!"); + return; + } + + const tag = await pluginData.state.tags.find(args.tag); + if (!tag) { + sendErrorMessage(pluginData, msg.channel, "No tag with that name"); + return; + } + + const archiveId = await pluginData.state.archives.create(tag.body, moment().add(10, "minutes")); + const url = pluginData.state.archives.getUrl(getBaseUrl(pluginData), archiveId); + + msg.channel.createMessage(`Tag source:\n${url}`); + }, +}); diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts new file mode 100644 index 00000000..b57c7128 --- /dev/null +++ b/backend/src/plugins/Tags/types.ts @@ -0,0 +1,58 @@ +import * as t from "io-ts"; +import { BasePluginType, command, eventListener } from "knub"; +import { tNullable, tEmbed } from "src/utils"; +import { GuildArchives } from "src/data/GuildArchives"; +import { GuildTags } from "src/data/GuildTags"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildLogs } from "src/data/GuildLogs"; + +export const Tag = t.union([t.string, tEmbed]); + +const TagCategory = t.type({ + prefix: tNullable(t.string), + delete_with_command: tNullable(t.boolean), + + user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag + user_category_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag category + global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag + global_category_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per category + + tags: t.record(t.string, Tag), + + can_use: tNullable(t.boolean), +}); + +export const ConfigSchema = t.type({ + prefix: t.string, + delete_with_command: t.boolean, + + user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag + global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag + user_cooldown: tNullable(t.union([t.string, t.number])), // Per user + global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use + + categories: t.record(t.string, TagCategory), + + can_create: t.boolean, + can_use: t.boolean, + can_list: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface TagsPluginType extends BasePluginType { + config: TConfigSchema; + state: { + archives: GuildArchives; + tags: GuildTags; + savedMessages: GuildSavedMessages; + logs: GuildLogs; + + onMessageCreateFn; + onMessageDeleteFn; + + tagFunctions: any; + }; +} + +export const tagsCmd = command(); +export const tagsEvent = eventListener(); diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts new file mode 100644 index 00000000..b9c16b89 --- /dev/null +++ b/backend/src/plugins/Tags/util/onMessageCreate.ts @@ -0,0 +1,145 @@ +import { TagsPluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { PluginData } from "knub"; +import { resolveMember, convertDelayStringToMS, tStrictMessageContent } from "src/utils"; +import escapeStringRegexp from "escape-string-regexp"; +import { validate } from "src/validatorUtils"; +import { LogType } from "src/data/LogType"; +import { TextChannel } from "eris"; +import { renderSafeTagFromMessage } from "./renderSafeTagFromMessage"; + +export async function onMessageCreate(pluginData: PluginData, msg: SavedMessage) { + if (msg.is_bot) return; + if (!msg.data.content) return; + + const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id); + if (!member) return; + + const config = pluginData.config.getMatchingConfig({ member, channelId: msg.channel_id }); + let deleteWithCommand = false; + + // Find potential matching tag, looping through categories first and checking dynamic tags last + let renderedTag = null; + let matchedTagName; + const cooldowns = []; + + for (const [name, category] of Object.entries(config.categories)) { + const canUse = category.can_use != null ? category.can_use : config.can_use; + if (canUse !== true) continue; + + const prefix = category.prefix != null ? category.prefix : config.prefix; + if (prefix !== "" && !msg.data.content.startsWith(prefix)) continue; + + const withoutPrefix = msg.data.content.slice(prefix.length); + + for (const [tagName, tagBody] of Object.entries(category.tags)) { + const regex = new RegExp(`^${escapeStringRegexp(tagName)}(?:\\s|$)`); + if (regex.test(withoutPrefix)) { + renderedTag = await renderSafeTagFromMessage( + pluginData, + msg.data.content, + prefix, + tagName, + category.tags[tagName], + member, + ); + if (renderedTag) { + matchedTagName = tagName; + break; + } + } + } + + if (renderedTag) { + if (category.user_tag_cooldown) { + const delay = convertDelayStringToMS(String(category.user_tag_cooldown), "s"); + cooldowns.push([`tags-category-${name}-user-${msg.user_id}-tag-${matchedTagName}`, delay]); + } + if (category.global_tag_cooldown) { + const delay = convertDelayStringToMS(String(category.global_tag_cooldown), "s"); + cooldowns.push([`tags-category-${name}-tag-${matchedTagName}`, delay]); + } + if (category.user_category_cooldown) { + const delay = convertDelayStringToMS(String(category.user_category_cooldown), "s"); + cooldowns.push([`tags-category-${name}-user--${msg.user_id}`, delay]); + } + if (category.global_category_cooldown) { + const delay = convertDelayStringToMS(String(category.global_category_cooldown), "s"); + cooldowns.push([`tags-category-${name}`, delay]); + } + + deleteWithCommand = + category.delete_with_command != null ? category.delete_with_command : config.delete_with_command; + + break; + } + } + + // Matching tag was not found from the config, try a dynamic tag + if (!renderedTag) { + if (config.can_use !== true) return; + + const prefix = config.prefix; + if (!msg.data.content.startsWith(prefix)) return; + + const tagNameMatch = msg.data.content.slice(prefix.length).match(/^\S+/); + if (tagNameMatch === null) return; + + const tagName = tagNameMatch[0]; + const tag = await pluginData.state.tags.find(tagName); + if (!tag) return; + + matchedTagName = tagName; + + renderedTag = await renderSafeTagFromMessage(pluginData, msg.data.content, prefix, tagName, tag.body, member); + } + + if (!renderedTag) return; + + if (config.user_tag_cooldown) { + const delay = convertDelayStringToMS(String(config.user_tag_cooldown), "s"); + cooldowns.push([`tags-user-${msg.user_id}-tag-${matchedTagName}`, delay]); + } + + if (config.global_tag_cooldown) { + const delay = convertDelayStringToMS(String(config.global_tag_cooldown), "s"); + cooldowns.push([`tags-tag-${matchedTagName}`, delay]); + } + + if (config.user_cooldown) { + const delay = convertDelayStringToMS(String(config.user_cooldown), "s"); + cooldowns.push([`tags-user-${matchedTagName}`, delay]); + } + + if (config.global_cooldown) { + const delay = convertDelayStringToMS(String(config.global_cooldown), "s"); + cooldowns.push([`tags`, delay]); + } + + const isOnCooldown = cooldowns.some(cd => pluginData.cooldowns.isOnCooldown(cd[0])); + if (isOnCooldown) return; + + for (const cd of cooldowns) { + pluginData.cooldowns.setCooldown(cd[0], cd[1]); + } + + deleteWithCommand = config.delete_with_command; + + const validationError = await validate(tStrictMessageContent, renderedTag); + if (validationError) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Rendering tag ${matchedTagName} resulted in an invalid message: ${validationError.message}`, + }); + return; + } + + const channel = pluginData.guild.channels.get(msg.channel_id) as TextChannel; + const responseMsg = await channel.createMessage(renderedTag); + + // Save the command-response message pair once the message is in our database + if (deleteWithCommand) { + pluginData.state.savedMessages.onceMessageAvailable(responseMsg.id, async () => { + await pluginData.state.tags.addResponse(msg.id, responseMsg.id); + }); + } +} diff --git a/backend/src/plugins/Tags/util/onMessageDelete.ts b/backend/src/plugins/Tags/util/onMessageDelete.ts new file mode 100644 index 00000000..01719320 --- /dev/null +++ b/backend/src/plugins/Tags/util/onMessageDelete.ts @@ -0,0 +1,32 @@ +import { PluginData } from "knub"; +import { TagsPluginType } from "../types"; +import { SavedMessage } from "src/data/entities/SavedMessage"; +import { TextChannel } from "eris"; + +export async function onMessageDelete(pluginData: PluginData, msg: SavedMessage) { + // Command message was deleted -> delete the response as well + const commandMsgResponse = await pluginData.state.tags.findResponseByCommandMessageId(msg.id); + if (commandMsgResponse) { + const channel = pluginData.guild.channels.get(msg.channel_id) as TextChannel; + if (!channel) return; + + const responseMsg = await pluginData.state.savedMessages.find(commandMsgResponse.response_message_id); + if (!responseMsg || responseMsg.deleted_at != null) return; + + await channel.deleteMessage(commandMsgResponse.response_message_id); + return; + } + + // Response was deleted -> delete the command message as well + const responseMsgResponse = await pluginData.state.tags.findResponseByResponseMessageId(msg.id); + if (responseMsgResponse) { + const channel = pluginData.guild.channels.get(msg.channel_id) as TextChannel; + if (!channel) return; + + const commandMsg = await pluginData.state.savedMessages.find(responseMsgResponse.command_message_id); + if (!commandMsg || commandMsg.deleted_at != null) return; + + await channel.deleteMessage(responseMsgResponse.command_message_id); + return; + } +} diff --git a/backend/src/plugins/Tags/util/renderSafeTagFromMessage.ts b/backend/src/plugins/Tags/util/renderSafeTagFromMessage.ts new file mode 100644 index 00000000..6ac80145 --- /dev/null +++ b/backend/src/plugins/Tags/util/renderSafeTagFromMessage.ts @@ -0,0 +1,45 @@ +import { Tag, TagsPluginType } from "../types"; +import { Member } from "eris"; +import * as t from "io-ts"; +import { StrictMessageContent, stripObjectToScalars, renderRecursively } from "src/utils"; +import { parseArguments } from "knub-command-manager"; +import { TemplateParseError } from "src/templateFormatter"; +import { PluginData } from "knub"; +import { renderTag } from "./renderTag"; +import { logger } from "src/logger"; + +export async function renderSafeTagFromMessage( + pluginData: PluginData, + str: string, + prefix: string, + tagName: string, + tagBody: t.TypeOf, + member: Member, +): Promise { + const variableStr = str.slice(prefix.length + tagName.length).trim(); + const tagArgs = parseArguments(variableStr).map(v => v.value); + + const renderTagString = async _str => { + let rendered = await renderTag(pluginData, _str, tagArgs, { + member: stripObjectToScalars(member, ["user"]), + user: stripObjectToScalars(member.user), + }); + rendered = rendered.trim(); + + return rendered; + }; + + // Format the string + try { + return typeof tagBody === "string" + ? { content: await renderTagString(tagBody) } + : await renderRecursively(tagBody, renderTagString); + } catch (e) { + if (e instanceof TemplateParseError) { + logger.warn(`Invalid tag format!\nError: ${e.message}\nFormat: ${tagBody}`); + return null; + } else { + throw e; + } + } +} diff --git a/backend/src/plugins/Tags/util/renderTag.ts b/backend/src/plugins/Tags/util/renderTag.ts new file mode 100644 index 00000000..7778e6a4 --- /dev/null +++ b/backend/src/plugins/Tags/util/renderTag.ts @@ -0,0 +1,33 @@ +import { renderTemplate } from "src/templateFormatter"; +import { PluginData, plugin } from "knub"; +import { TagsPluginType } from "../types"; + +export async function renderTag(pluginData: PluginData, body, args = [], extraData = {}) { + const dynamicVars = {}; + const maxTagFnCalls = 25; + let tagFnCalls = 0; + + const data = { + args, + ...extraData, + ...pluginData.state.tagFunctions, + set(name, val) { + if (typeof name !== "string") return; + dynamicVars[name] = val; + }, + get(name) { + return dynamicVars[name] == null ? "" : dynamicVars[name]; + }, + tag: async (name, ...subTagArgs) => { + if (tagFnCalls++ > maxTagFnCalls) return "\\_recursion\\_"; + if (typeof name !== "string") return ""; + if (name === "") return ""; + // TODO: Incorporate tag categories here + const subTag = await pluginData.state.tags.find(name); + if (!subTag) return ""; + return renderTemplate(subTag.body, { ...data, args: subTagArgs }); + }, + }; + + return renderTemplate(body, data); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 19dae15c..32825725 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -12,6 +12,7 @@ import { PingableRolesPlugin } from "./PingableRoles/PingableRolesPlugin"; import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigReloaderPlugin"; import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; +import { TagsPlugin } from "./Tags/TagsPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -22,6 +23,7 @@ export const guildPlugins: Array> = [ MessageSaverPlugin, NameHistoryPlugin, RemindersPlugin, + TagsPlugin, UsernameSaverPlugin, UtilityPlugin, WelcomeMessagePlugin,