3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-03-15 05:41:51 +00:00

Add templateFormatter; migrate from formatTemplateString to templateFormatter

This commit is contained in:
Dragory 2019-03-16 15:42:55 +02:00
parent 5a29068bf1
commit ba3af1cb63
6 changed files with 435 additions and 32 deletions

View file

@ -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<ArchiveEntry> {
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);

View file

@ -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<ILogsPluginConfig> {
interface ILogsPluginPermissions {
pinged: boolean;
}
export class LogsPlugin extends ZeppelinPlugin<ILogsPluginConfig, ILogsPluginPermissions> {
public static pluginName = "logs";
protected guildLogs: GuildLogs;
@ -74,7 +78,7 @@ export class LogsPlugin extends ZeppelinPlugin<ILogsPluginConfig> {
private onMessageDeleteBulkFn;
private onMessageUpdateFn;
getDefaultOptions(): IPluginOptions<ILogsPluginConfig> {
getDefaultOptions(): IPluginOptions<ILogsPluginConfig, ILogsPluginPermissions> {
return {
config: {
channels: {},
@ -84,7 +88,18 @@ export class LogsPlugin extends ZeppelinPlugin<ILogsPluginConfig> {
},
},
permissions: {},
permissions: {
pinged: true,
},
overrides: [
{
level: ">=50",
permissions: {
pinged: false,
},
},
],
};
}
@ -126,7 +141,7 @@ export class LogsPlugin extends ZeppelinPlugin<ILogsPluginConfig> {
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<ILogsPluginConfig> {
}
}
getLogMessage(type, data): string {
async getLogMessage(type, data): Promise<string> {
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) {

View file

@ -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<IModActionsPluginConfig, IM
if (reason && !hasOldCase) {
const template = muteTime ? config.timed_mute_message : config.mute_message;
const muteMessage = formatTemplateString(template, {
const muteMessage = await renderTemplate(template, {
guildName: this.guild.name,
reason,
time: timeUntilUnmute,
@ -689,7 +688,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig, IM
// Attempt to message the user *before* kicking them, as doing it after may not be possible
let userMessageResult: IMessageResult = { status: MessageResultStatus.Ignored };
if (args.reason) {
const kickMessage = formatTemplateString(config.kick_message, {
const kickMessage = await renderTemplate(config.kick_message, {
guildName: this.guild.name,
reason,
});
@ -759,7 +758,7 @@ export class ModActionsPlugin extends ZeppelinPlugin<IModActionsPluginConfig, IM
// Attempt to message the user *before* banning them, as doing it after may not be possible
let userMessageResult: IMessageResult = { status: MessageResultStatus.Ignored };
if (reason) {
const banMessage = formatTemplateString(config.ban_message, {
const banMessage = await renderTemplate(config.ban_message, {
guildName: this.guild.name,
reason,
});

View file

@ -0,0 +1,96 @@
import { parseTemplate, renderParsedTemplate, renderTemplate } from "./templateFormatter";
test("Parses plain string templates correctly", () => {
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 !<?8?>! baz");
});
test("Supports base values in renderTemplate", async () => {
const result = await renderTemplate('{if("", "+", "-")} {if(1, "+", "-")}');
expect(result).toBe("- +");
});

299
src/templateFormatter.ts Normal file
View file

@ -0,0 +1,299 @@
import at from "lodash.at";
const TEMPLATE_CACHE_SIZE = 100;
const templateCache: Map<string, ParsedTemplate> = new Map();
class TemplateParseError extends Error {}
interface ITemplateVar {
identifier: string;
args: Array<string | number | ITemplateVar>;
_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<string | ITemplateVar>;
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);
}

View file

@ -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}$`);