diff --git a/src/data/GuildArchives.ts b/src/data/GuildArchives.ts index a7007d43..e3836a0d 100644 --- a/src/data/GuildArchives.ts +++ b/src/data/GuildArchives.ts @@ -3,9 +3,10 @@ import moment from "moment-timezone"; import { ArchiveEntry } from "./entities/ArchiveEntry"; import { getRepository, Repository } from "typeorm"; import { BaseRepository } from "./BaseRepository"; -import { formatTemplateString, trimLines } from "../utils"; +import { trimLines } from "../utils"; import { SavedMessage } from "./entities/SavedMessage"; import { Channel, Guild, User } from "eris"; +import { renderTemplate } from "../templateFormatter"; const DEFAULT_EXPIRY_DAYS = 30; @@ -40,7 +41,7 @@ export class GuildArchives extends BaseRepository { async find(id: string): Promise { return this.archives.findOne({ where: { id }, - relations: this.getRelations() + relations: this.getRelations(), }); } @@ -48,8 +49,8 @@ export class GuildArchives extends BaseRepository { await this.archives.update( { id }, { - expires_at: null - } + expires_at: null, + }, ); } @@ -64,28 +65,30 @@ export class GuildArchives extends BaseRepository { const result = await this.archives.insert({ guild_id: this.guildId, body, - expires_at: expiresAt.format("YYYY-MM-DD HH:mm:ss") + expires_at: expiresAt.format("YYYY-MM-DD HH:mm:ss"), }); return result.identifiers[0].id; } - createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt = null) { + async createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt = null) { if (expiresAt == null) expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days"); - const headerStr = formatTemplateString(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild }); - const msgLines = savedMessages.map(msg => { + const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild }); + const msgLines = []; + for (const msg of savedMessages) { const channel = guild.channels.get(msg.channel_id); const user = { ...msg.data.author, id: msg.user_id }; - return formatTemplateString(MESSAGE_ARCHIVE_MESSAGE_FORMAT, { + const line = await renderTemplate(MESSAGE_ARCHIVE_MESSAGE_FORMAT, { id: msg.id, timestamp: moment(msg.posted_at).format("YYYY-MM-DD HH:mm:ss"), content: msg.data.content, user, - channel + channel, }); - }); + msgLines.push(line); + } const messagesStr = msgLines.join("\n"); return this.create([headerStr, messagesStr].join("\n\n"), expiresAt); diff --git a/src/plugins/Logs.ts b/src/plugins/Logs.ts index 5d6d261b..f13acbce 100644 --- a/src/plugins/Logs.ts +++ b/src/plugins/Logs.ts @@ -8,7 +8,6 @@ import { disableCodeBlocks, disableLinkPreviews, findRelevantAuditLogEntry, - formatTemplateString, noop, stripObjectToScalars, useMediaUrls, @@ -23,6 +22,7 @@ import { SavedMessage } from "../data/entities/SavedMessage"; import { GuildArchives } from "../data/GuildArchives"; import { GuildCases } from "../data/GuildCases"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { renderTemplate } from "../templateFormatter"; interface ILogChannel { include?: string[]; @@ -58,7 +58,11 @@ interface ILogsPluginConfig { }; } -export class LogsPlugin extends ZeppelinPlugin { +interface ILogsPluginPermissions { + pinged: boolean; +} + +export class LogsPlugin extends ZeppelinPlugin { public static pluginName = "logs"; protected guildLogs: GuildLogs; @@ -74,7 +78,7 @@ export class LogsPlugin extends ZeppelinPlugin { private onMessageDeleteBulkFn; private onMessageUpdateFn; - getDefaultOptions(): IPluginOptions { + getDefaultOptions(): IPluginOptions { return { config: { channels: {}, @@ -84,7 +88,18 @@ export class LogsPlugin extends ZeppelinPlugin { }, }, - permissions: {}, + permissions: { + pinged: true, + }, + + overrides: [ + { + level: ">=50", + permissions: { + pinged: false, + }, + }, + ], }; } @@ -126,7 +141,7 @@ export class LogsPlugin extends ZeppelinPlugin { if (!channel || !(channel instanceof TextChannel)) continue; if ((opts.include && opts.include.includes(typeStr)) || (opts.exclude && !opts.exclude.includes(typeStr))) { - const message = this.getLogMessage(type, data); + const message = await this.getLogMessage(type, data); if (message) { if (opts.batched) { // If we're batching log messages, gather all log messages within the set batch_time into a single message @@ -149,12 +164,12 @@ export class LogsPlugin extends ZeppelinPlugin { } } - getLogMessage(type, data): string { + async getLogMessage(type, data): Promise { const config = this.getConfig(); const format = config.format[LogType[type]] || ""; if (format === "") return; - const formatted = formatTemplateString(format, data); + const formatted = await renderTemplate(format, data); const timestampFormat = config.format.timestamp; if (timestampFormat) { diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index f396ae92..437eb580 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -7,7 +7,6 @@ import { createChunkedMessage, errorMessage, findRelevantAuditLogEntry, - formatTemplateString, asSingleLine, stripObjectToScalars, successMessage, @@ -17,11 +16,11 @@ import { GuildMutes } from "../data/GuildMutes"; import { CaseTypes } from "../data/CaseTypes"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; -import Timer = NodeJS.Timer; import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { GuildActions } from "../data/GuildActions"; import { Case } from "../data/entities/Case"; import { Mute } from "../data/entities/Mute"; +import { renderTemplate } from "../templateFormatter"; enum IgnoredEventType { Ban = 1, @@ -510,7 +509,7 @@ export class ModActionsPlugin extends ZeppelinPlugin { + const result = parseTemplate("foo bar baz"); + expect(result).toEqual(["foo bar baz"]); +}); + +test("Parses templates with variables correctly", () => { + const result = parseTemplate("foo {bar} baz"); + expect(result).toEqual([ + "foo ", + { + identifier: "bar", + args: [], + }, + " baz", + ]); +}); + +test("Parses templates with function variables correctly", () => { + const result = parseTemplate('foo {bar("str", 5.07)} baz'); + expect(result).toEqual([ + "foo ", + { + identifier: "bar", + args: ["str", 5.07], + }, + " baz", + ]); +}); + +test("Parses function variables with variable arguments correctly", () => { + const result = parseTemplate('foo {bar("str", 5.07, someVar)} baz'); + expect(result).toEqual([ + "foo ", + { + identifier: "bar", + args: [ + "str", + 5.07, + { + identifier: "someVar", + args: [], + }, + ], + }, + " baz", + ]); +}); + +test("Parses function variables with function variable arguments correctly", () => { + const result = parseTemplate('foo {bar("str", 5.07, deeply(nested(8)))} baz'); + expect(result).toEqual([ + "foo ", + { + identifier: "bar", + args: [ + "str", + 5.07, + { + identifier: "deeply", + args: [ + { + identifier: "nested", + args: [8], + }, + ], + }, + ], + }, + " baz", + ]); +}); + +test("Renders a parsed template correctly", async () => { + const parseResult = parseTemplate('foo {bar("str", 5.07, deeply(nested(8)))} baz'); + const values = { + bar(strArg, numArg, varArg) { + return `${strArg} ${numArg} !${varArg}!`; + }, + deeply(varArg) { + return `<${varArg}>`; + }, + nested(numArg) { + return `?${numArg}?`; + }, + }; + + const renderResult = await renderParsedTemplate(parseResult, values); + expect(renderResult).toBe("foo str 5.07 !! baz"); +}); + +test("Supports base values in renderTemplate", async () => { + const result = await renderTemplate('{if("", "+", "-")} {if(1, "+", "-")}'); + expect(result).toBe("- +"); +}); diff --git a/src/templateFormatter.ts b/src/templateFormatter.ts new file mode 100644 index 00000000..03ad1b53 --- /dev/null +++ b/src/templateFormatter.ts @@ -0,0 +1,299 @@ +import at from "lodash.at"; + +const TEMPLATE_CACHE_SIZE = 100; +const templateCache: Map = new Map(); + +class TemplateParseError extends Error {} + +interface ITemplateVar { + identifier: string; + args: Array; + _state: { + currentArg: string | ITemplateVar; + currentArgType: "string" | "number" | "var"; + inArg: boolean; + inQuote: boolean; + }; + _parent: ITemplateVar; +} + +function newTemplateVar(): ITemplateVar { + return { + identifier: "", + args: [], + _state: { + inArg: false, + currentArg: "", + currentArgType: null, + inQuote: false, + }, + _parent: null, + }; +} + +type ParsedTemplate = Array; + +function cleanUpParseResult(arr) { + arr.forEach(item => { + if (typeof item === "object") { + delete item._state; + delete item._parent; + if (item.args && item.args.length) { + cleanUpParseResult(item.args); + } + } + }); +} + +export function parseTemplate(str: string): ParsedTemplate { + const chars = [...str]; + const result: ParsedTemplate = []; + + let inVar = false; + let currentString = ""; + let currentVar: ITemplateVar; + let rootVar: ITemplateVar; + + let escapeNext = false; + + const dumpArg = () => { + if (!currentVar) return; + + if (currentVar._state.currentArg !== null && currentVar._state.currentArg !== "") { + if (currentVar._state.currentArgType === "number") { + if (isNaN(currentVar._state.currentArg as any)) { + throw new TemplateParseError(`Invalid numeric argument: ${currentVar._state.currentArg}`); + } + + currentVar.args.push(parseFloat(currentVar._state.currentArg as string)); + } else { + currentVar.args.push(currentVar._state.currentArg); + } + } + + currentVar._state.currentArg = ""; + currentVar._state.currentArgType = null; + }; + + const returnToParentVar = () => { + if (!currentVar) return; + currentVar = currentVar._parent; + dumpArg(); + }; + + const exitInjectedVar = () => { + if (rootVar) { + if (currentVar && currentVar !== rootVar) { + throw new TemplateParseError(`Unclosed function!`); + } + + result.push(rootVar); + rootVar = null; + } + + inVar = false; + }; + + let i = -1; + for (const char of chars) { + i++; + if (inVar) { + if (currentVar) { + if (currentVar._state.inArg) { + // We're parsing arguments + if (currentVar._state.inQuote) { + // We're in an open quote + if (escapeNext) { + currentVar._state.currentArg += char; + escapeNext = false; + } else if (char === "\\") { + escapeNext = true; + } else if (char === '"') { + currentVar._state.inQuote = false; + } else { + currentVar._state.currentArg += char; + } + } else if (char === ")") { + // Done with arguments + dumpArg(); + returnToParentVar(); + } else if (char === ",") { + // Comma -> dump argument, start new argument + dumpArg(); + } else if (currentVar._state.currentArgType === "number") { + // We're parsing a number argument + // The actual validation of whether this is a number is in dumpArg() + currentVar._state.currentArg += char; + } else if (char === " ") { + // Whitespace, ignore + continue; + } else if (char === '"') { + // A double quote can start a string argument, but only if we haven't committed to some other type of argument already + if (currentVar._state.currentArgType !== null) { + throw new TemplateParseError(`Unexpected char: ${char} at ${i}`); + } + + currentVar._state.currentArgType = "string"; + currentVar._state.inQuote = true; + } else if (char.match(/\d/)) { + // A number can start a string argument, but only if we haven't committed to some other type of argument already + if (currentVar._state.currentArgType !== null) { + throw new TemplateParseError(`Unexpected char: ${char}`); + } + + currentVar._state.currentArgType = "number"; + currentVar._state.currentArg += char; + } else if (currentVar._state.currentArgType === null) { + // Any other character starts a new var argument if we haven't committed to some other type of argument already + currentVar._state.currentArgType = "var"; + + const newVar = newTemplateVar(); + newVar._parent = currentVar; + newVar.identifier += char; + currentVar._state.currentArg = newVar; + currentVar = newVar; + } else { + throw new TemplateParseError(`Unexpected char: ${char}`); + } + } else { + if (char === "(") { + currentVar._state.inArg = true; + } else if (char === ",") { + // We encountered a comma without ever getting into args + // -> We're a value property, not a function, and we can return to our parent var + returnToParentVar(); + } else if (char === ")") { + // We encountered a closing bracket without ever getting into args + // -> We're a value property, and this closing bracket actually closes out PARENT var + // -> "Return to parent var" twice + returnToParentVar(); + returnToParentVar(); + } else if (char === "}") { + // We encountered a closing curly bracket without ever getting into args + // -> We're a value property, and the current injected var ends here + exitInjectedVar(); + } else { + currentVar.identifier += char; + } + } + } else { + if (char === "}") { + exitInjectedVar(); + } else { + throw new TemplateParseError(`Unexpected char: ${char}`); + } + } + } else { + if (escapeNext) { + currentString += char; + escapeNext = false; + } else if (char === "\\") { + escapeNext = true; + } else if (char === "{") { + if (currentString !== "") { + result.push(currentString); + currentString = ""; + } + + const newVar = newTemplateVar(); + if (currentVar) newVar._parent = currentVar; + currentVar = newVar; + rootVar = newVar; + inVar = true; + } else { + currentString += char; + } + } + } + + if (inVar) { + throw new TemplateParseError("Unterminated injected variable!"); + } + + if (currentString !== "") { + result.push(currentString); + } + + // Clean-up + cleanUpParseResult(result); + + return result; +} + +async function evaluateTemplateVariable(theVar: ITemplateVar, values) { + const value = at(values, theVar.identifier)[0]; + + if (typeof value === "function") { + const args = []; + for (const arg of theVar.args) { + if (typeof arg === "object") { + const argValue = await evaluateTemplateVariable(arg as ITemplateVar, values); + args.push(argValue); + } else { + args.push(arg); + } + } + + return value(...args); + } + + return value; +} + +export async function renderParsedTemplate(parsedTemplate: ParsedTemplate, values: any) { + let result = ""; + + for (const part of parsedTemplate) { + if (typeof part === "object") { + result += await evaluateTemplateVariable(part, values); + } else { + result += part.toString(); + } + } + + return result; +} + +const baseValues = { + if(clause, andThen, andElse) { + return clause ? andThen : andElse; + }, + and(...args) { + for (const arg of args) { + if (!arg) return false; + } + return true; + }, + or(...args) { + for (const arg of args) { + if (arg) return true; + } + return false; + }, + not(arg) { + return !arg; + }, +}; + +export async function renderTemplate(template: string, values = {}, includeBaseValues = true) { + if (includeBaseValues) { + values = Object.assign({}, baseValues, values); + } + + let parseResult: ParsedTemplate; + if (templateCache.has(template)) { + parseResult = templateCache.get(template); + } else { + parseResult = parseTemplate(template); + + // If our template cache is full, delete the first item + if (templateCache.size >= TEMPLATE_CACHE_SIZE) { + const firstKey = templateCache.keys().next().value; + templateCache.delete(firstKey); + } + + templateCache.set(template, parseResult); + } + + return renderParsedTemplate(parseResult, values); +} diff --git a/src/utils.ts b/src/utils.ts index 11b83a7a..a783d2d0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,3 @@ -import at from "lodash.at"; import { Emoji, Guild, GuildAuditLogEntry, TextableChannel } from "eris"; import url from "url"; import tlds from "tlds"; @@ -78,14 +77,6 @@ export function stripObjectToScalars(obj, includedNested: string[] = []) { return result; } -const stringFormatRegex = /{([^{}]+?)}/g; -export function formatTemplateString(str: string, values) { - return str.replace(stringFormatRegex, (match, prop) => { - const value = at(values, prop)[0]; - return typeof value === "string" || typeof value === "number" ? String(value) : ""; - }); -} - export const snowflakeRegex = /[1-9][0-9]{5,19}/; const isSnowflakeRegex = new RegExp(`^${snowflakeRegex.source}$`);