Add templateFormatter; migrate from formatTemplateString to templateFormatter
This commit is contained in:
parent
5a29068bf1
commit
ba3af1cb63
6 changed files with 435 additions and 32 deletions
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
96
src/templateFormatter.test.ts
Normal file
96
src/templateFormatter.test.ts
Normal 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
299
src/templateFormatter.ts
Normal 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);
|
||||
}
|
|
@ -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}$`);
|
||||
|
|
Loading…
Add table
Reference in a new issue