diff --git a/backend/package-lock.json b/backend/package-lock.json index b1d6d1891..1f2f5992e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,7 +22,6 @@ "express": "^4.17.0", "fp-ts": "^2.0.1", "humanize-duration": "^3.15.0", - "io-ts": "^2.0.0", "js-yaml": "^3.13.1", "knub": "^32.0.0-next.16", "knub-command-manager": "^9.1.0", @@ -4975,14 +4974,6 @@ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" }, - "node_modules/io-ts": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.20.tgz", - "integrity": "sha512-Rq2BsYmtwS5vVttie4rqrOCIfHCS9TgpRLFpKQCM1wZBBRY9nWVGmEvm2FnDbSE2un1UE39DvFpTR5UL47YDcA==", - "peerDependencies": { - "fp-ts": "^2.5.0" - } - }, "node_modules/iota-array": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 2ff53482a..fbbe2ddcb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,7 +43,6 @@ "express": "^4.17.0", "fp-ts": "^2.0.1", "humanize-duration": "^3.15.0", - "io-ts": "^2.0.0", "js-yaml": "^3.13.1", "knub": "^32.0.0-next.16", "knub-command-manager": "^9.1.0", diff --git a/backend/src/commandTypes.ts b/backend/src/commandTypes.ts index 47ad04055..9ace53128 100644 --- a/backend/src/commandTypes.ts +++ b/backend/src/commandTypes.ts @@ -17,6 +17,7 @@ import { createTypeHelper } from "knub-command-manager"; import { channelMentionRegex, convertDelayStringToMS, + inputPatternToRegExp, isValidSnowflake, resolveMember, resolveUser, @@ -26,7 +27,6 @@ import { } from "./utils"; import { isValidTimezone } from "./utils/isValidTimezone"; import { MessageTarget, resolveMessageTarget } from "./utils/resolveMessageTarget"; -import { inputPatternToRegExp } from "./validatorUtils"; export const commandTypes = { ...messageCommandBaseTypeConverters, diff --git a/backend/src/configValidator.ts b/backend/src/configValidator.ts index 3bb602a45..96e08ba3c 100644 --- a/backend/src/configValidator.ts +++ b/backend/src/configValidator.ts @@ -1,9 +1,9 @@ -import { ConfigValidationError, PluginConfigManager } from "knub"; +import { PluginConfigManager } from "knub"; import moment from "moment-timezone"; +import { ZodError } from "zod"; import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; import { guildPlugins } from "./plugins/availablePlugins"; -import { PartialZeppelinGuildConfigSchema, ZeppelinGuildConfig } from "./types"; -import { StrictValidationError, decodeAndValidateStrict } from "./validatorUtils"; +import { ZeppelinGuildConfig, zZeppelinGuildConfig } from "./types"; const pluginNameToPlugin = new Map(); for (const plugin of guildPlugins) { @@ -11,8 +11,10 @@ for (const plugin of guildPlugins) { } export async function validateGuildConfig(config: any): Promise { - const validationResult = decodeAndValidateStrict(PartialZeppelinGuildConfigSchema, config); - if (validationResult instanceof StrictValidationError) return validationResult.getErrors(); + const validationResult = zZeppelinGuildConfig.safeParse(config); + if (!validationResult.success) { + return validationResult.error.issues.join("\n"); + } const guildConfig = config as ZeppelinGuildConfig; @@ -41,8 +43,8 @@ export async function validateGuildConfig(config: any): Promise { try { await configManager.init(); } catch (err) { - if (err instanceof ConfigValidationError || err instanceof StrictValidationError) { - return `${pluginName}: ${err.message}`; + if (err instanceof ZodError) { + return `${pluginName}: ${err.issues.join("\n")}`; } throw err; diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 8e6c6da2c..a2f744d93 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -10,22 +10,18 @@ import { PermissionsBitField, TextBasedChannel, } from "discord.js"; -import * as t from "io-ts"; import { AnyPluginData, CommandContext, - ConfigValidationError, ExtendedMatchParams, GuildPluginData, - PluginOverrideCriteria, - helpers, + helpers } from "knub"; import { logger } from "./logger"; import { isStaff } from "./staff"; import { TZeppelinKnub } from "./types"; -import { errorMessage, successMessage, tNullable } from "./utils"; +import { errorMessage, successMessage } from "./utils"; import { Tail } from "./utils/typeUtils"; -import { StrictValidationError, parseIoTsSchema } from "./validatorUtils"; const { getMemberLevel } = helpers; @@ -59,46 +55,6 @@ export async function hasPermission( return helpers.hasPermission(config, permission); } -const PluginOverrideCriteriaType: t.Type> = t.recursion( - "PluginOverrideCriteriaType", - () => - t.partial({ - channel: tNullable(t.union([t.string, t.array(t.string)])), - category: tNullable(t.union([t.string, t.array(t.string)])), - level: tNullable(t.union([t.string, t.array(t.string)])), - user: tNullable(t.union([t.string, t.array(t.string)])), - role: tNullable(t.union([t.string, t.array(t.string)])), - - all: tNullable(t.array(PluginOverrideCriteriaType)), - any: tNullable(t.array(PluginOverrideCriteriaType)), - not: tNullable(PluginOverrideCriteriaType), - - extra: t.unknown, - }), -); - -export function strictValidationErrorToConfigValidationError(err: StrictValidationError) { - return new ConfigValidationError( - err - .getErrors() - .map((e) => e.toString()) - .join("\n"), - ); -} - -export function makeIoTsConfigParser>(schema: Schema): (input: unknown) => t.TypeOf { - return (input: unknown) => { - try { - return parseIoTsSchema(schema, input); - } catch (err) { - if (err instanceof StrictValidationError) { - throw strictValidationErrorToConfigValidationError(err); - } - throw err; - } - }; -} - export async function sendSuccessMessage( pluginData: AnyPluginData, channel: TextBasedChannel, diff --git a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts index 0a8e0438f..d12735779 100644 --- a/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts +++ b/backend/src/plugins/AutoDelete/AutoDeletePlugin.ts @@ -1,11 +1,10 @@ import { PluginOptions } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; -import { AutoDeletePluginType, ConfigSchema } from "./types"; +import { AutoDeletePluginType, zAutoDeleteConfig } from "./types"; import { onMessageCreate } from "./util/onMessageCreate"; import { onMessageDelete } from "./util/onMessageDelete"; import { onMessageDeleteBulk } from "./util/onMessageDeleteBulk"; @@ -24,11 +23,11 @@ export const AutoDeletePlugin = zeppelinGuildPlugin()({ prettyName: "Auto-delete", description: "Allows Zeppelin to auto-delete messages from a channel after a delay", configurationGuide: "Maximum deletion delay is currently 5 minutes", - configSchema: ConfigSchema, + configSchema: zAutoDeleteConfig, }, dependencies: () => [TimeAndDatePlugin, LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zAutoDeleteConfig.parse(input), defaultOptions, beforeLoad(pluginData) { diff --git a/backend/src/plugins/AutoDelete/types.ts b/backend/src/plugins/AutoDelete/types.ts index 24389860f..691004380 100644 --- a/backend/src/plugins/AutoDelete/types.ts +++ b/backend/src/plugins/AutoDelete/types.ts @@ -1,10 +1,10 @@ -import * as t from "io-ts"; import { BasePluginType } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { SavedMessage } from "../../data/entities/SavedMessage"; -import { MINUTES, tDelayString } from "../../utils"; +import { MINUTES, zDelayString } from "../../utils"; import Timeout = NodeJS.Timeout; +import z from "zod"; export const MAX_DELAY = 5 * MINUTES; @@ -13,14 +13,13 @@ export interface IDeletionQueueItem { message: SavedMessage; } -export const ConfigSchema = t.type({ - enabled: t.boolean, - delay: tDelayString, +export const zAutoDeleteConfig = z.strictObject({ + enabled: z.boolean(), + delay: zDelayString, }); -export type TConfigSchema = t.TypeOf; export interface AutoDeletePluginType extends BasePluginType { - config: TConfigSchema; + config: z.output; state: { guildSavedMessages: GuildSavedMessages; guildLogs: GuildLogs; diff --git a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts index 8ec1ecd78..9b01d5384 100644 --- a/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts +++ b/backend/src/plugins/AutoReactions/AutoReactionsPlugin.ts @@ -1,14 +1,13 @@ import { PluginOptions } from "knub"; import { GuildAutoReactions } from "../../data/GuildAutoReactions"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { DisableAutoReactionsCmd } from "./commands/DisableAutoReactionsCmd"; import { NewAutoReactionsCmd } from "./commands/NewAutoReactionsCmd"; import { AddReactionsEvt } from "./events/AddReactionsEvt"; -import { AutoReactionsPluginType, ConfigSchema } from "./types"; +import { AutoReactionsPluginType, zAutoReactionsConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -32,7 +31,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin( description: trimPluginDescription(` Allows setting up automatic reactions to all new messages on a channel `), - configSchema: ConfigSchema, + configSchema: zAutoReactionsConfig, }, // prettier-ignore @@ -40,7 +39,7 @@ export const AutoReactionsPlugin = zeppelinGuildPlugin( LogsPlugin, ], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zAutoReactionsConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/AutoReactions/types.ts b/backend/src/plugins/AutoReactions/types.ts index a02c3c263..996fba8d2 100644 --- a/backend/src/plugins/AutoReactions/types.ts +++ b/backend/src/plugins/AutoReactions/types.ts @@ -1,17 +1,16 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildAutoReactions } from "../../data/GuildAutoReactions"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { AutoReaction } from "../../data/entities/AutoReaction"; -export const ConfigSchema = t.type({ - can_manage: t.boolean, +export const zAutoReactionsConfig = z.strictObject({ + can_manage: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface AutoReactionsPluginType extends BasePluginType { - config: TConfigSchema; + config: z.output; state: { logs: GuildLogs; savedMessages: GuildSavedMessages; diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index ff4f01fda..9634c25db 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -1,4 +1,4 @@ -import { configUtils, CooldownManager } from "knub"; +import { CooldownManager } from "knub"; import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildLogs } from "../../data/GuildLogs"; @@ -8,7 +8,6 @@ import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { MINUTES, SECONDS } from "../../utils"; import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap"; import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap"; -import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils"; import { CountersPlugin } from "../Counters/CountersPlugin"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin"; import { LogsPlugin } from "../Logs/LogsPlugin"; @@ -17,7 +16,6 @@ import { MutesPlugin } from "../Mutes/MutesPlugin"; import { PhishermanPlugin } from "../Phisherman/PhishermanPlugin"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; -import { availableActions } from "./actions/availableActions"; import { AntiraidClearCmd } from "./commands/AntiraidClearCmd"; import { SetAntiraidCmd } from "./commands/SetAntiraidCmd"; import { ViewAntiraidCmd } from "./commands/ViewAntiraidCmd"; @@ -35,8 +33,7 @@ import { clearOldRecentNicknameChanges } from "./functions/clearOldNicknameChang import { clearOldRecentActions } from "./functions/clearOldRecentActions"; import { clearOldRecentSpam } from "./functions/clearOldRecentSpam"; import { pluginInfo } from "./info"; -import { availableTriggers } from "./triggers/availableTriggers"; -import { AutomodPluginType, ConfigSchema } from "./types"; +import { AutomodPluginType, zAutomodConfig } from "./types"; const defaultOptions = { config: { @@ -61,129 +58,6 @@ const defaultOptions = { ], }; -/** - * Config preprocessor to set default values for triggers and perform extra validation - * TODO: Separate input and output types - */ -const configParser = (input: unknown) => { - const rules = (input as any).rules; - if (rules) { - // Loop through each rule - for (const [name, rule] of Object.entries(rules)) { - if (rule == null) { - delete rules[name]; - continue; - } - - rule["name"] = name; - - // If the rule doesn't have an explicitly set "enabled" property, set it to true - if (rule["enabled"] == null) { - rule["enabled"] = true; - } - - if (rule["allow_further_rules"] == null) { - rule["allow_further_rules"] = false; - } - - if (rule["affects_bots"] == null) { - rule["affects_bots"] = false; - } - - if (rule["affects_self"] == null) { - rule["affects_self"] = false; - } - - // Loop through the rule's triggers - if (rule["triggers"]) { - for (const triggerObj of rule["triggers"]) { - for (const triggerName in triggerObj) { - if (!availableTriggers[triggerName]) { - throw new StrictValidationError([`Unknown trigger '${triggerName}' in rule '${rule["name"]}'`]); - } - - const triggerBlueprint = availableTriggers[triggerName]; - - if (typeof triggerBlueprint.defaultConfig === "object" && triggerBlueprint.defaultConfig != null) { - triggerObj[triggerName] = configUtils.mergeConfig( - triggerBlueprint.defaultConfig, - triggerObj[triggerName] || {}, - ); - } else { - triggerObj[triggerName] = triggerObj[triggerName] || triggerBlueprint.defaultConfig; - } - - if (triggerObj[triggerName].match_attachment_type) { - const white = triggerObj[triggerName].match_attachment_type.whitelist_enabled; - const black = triggerObj[triggerName].match_attachment_type.blacklist_enabled; - - if (white && black) { - throw new StrictValidationError([ - `Cannot have both blacklist and whitelist enabled at rule <${rule["name"]}/match_attachment_type>`, - ]); - } else if (!white && !black) { - throw new StrictValidationError([ - `Must have either blacklist or whitelist enabled at rule <${rule["name"]}/match_attachment_type>`, - ]); - } - } - - if (triggerObj[triggerName].match_mime_type) { - const white = triggerObj[triggerName].match_mime_type.whitelist_enabled; - const black = triggerObj[triggerName].match_mime_type.blacklist_enabled; - - if (white && black) { - throw new StrictValidationError([ - `Cannot have both blacklist and whitelist enabled at rule <${rule["name"]}/match_mime_type>`, - ]); - } else if (!white && !black) { - throw new StrictValidationError([ - `Must have either blacklist or whitelist enabled at rule <${rule["name"]}/match_mime_type>`, - ]); - } - } - } - } - } - - if (rule["actions"]) { - for (const actionName in rule["actions"]) { - if (!availableActions[actionName]) { - throw new StrictValidationError([`Unknown action '${actionName}' in rule '${rule["name"]}'`]); - } - - const actionBlueprint = availableActions[actionName]; - const actionConfig = rule["actions"][actionName]; - - if (typeof actionConfig !== "object" || Array.isArray(actionConfig) || actionConfig == null) { - rule["actions"][actionName] = actionConfig; - } else { - rule["actions"][actionName] = configUtils.mergeConfig(actionBlueprint.defaultConfig, actionConfig); - } - } - } - - // Enable logging of automod actions by default - if (rule["actions"]) { - for (const actionName in rule["actions"]) { - if (!availableActions[actionName]) { - throw new StrictValidationError([`Unknown action '${actionName}' in rule '${rule["name"]}'`]); - } - } - - if (rule["actions"]["log"] == null) { - rule["actions"]["log"] = true; - } - if (rule["actions"]["clean"] && rule["actions"]["start_thread"]) { - throw new StrictValidationError([`Cannot have both clean and start_thread at rule '${rule["name"]}'`]); - } - } - } - } - - return parseIoTsSchema(ConfigSchema, input); -}; - export const AutomodPlugin = zeppelinGuildPlugin()({ name: "automod", showInDocs: true, @@ -201,7 +75,7 @@ export const AutomodPlugin = zeppelinGuildPlugin()({ ], defaultOptions, - configParser, + configParser: (input) => zAutomodConfig.parse(input), customOverrideCriteriaFunctions: { antiraid_level: (pluginData, matchParams, value) => { diff --git a/backend/src/plugins/Automod/actions/addRoles.ts b/backend/src/plugins/Automod/actions/addRoles.ts index 53b00005a..892947280 100644 --- a/backend/src/plugins/Automod/actions/addRoles.ts +++ b/backend/src/plugins/Automod/actions/addRoles.ts @@ -1,6 +1,6 @@ import { PermissionFlagsBits, Snowflake } from "discord.js"; -import * as t from "io-ts"; -import { nonNullish, unique } from "../../../utils"; +import z from "zod"; +import { nonNullish, unique, zSnowflake } from "../../../utils"; import { canAssignRole } from "../../../utils/canAssignRole"; import { getMissingPermissions } from "../../../utils/getMissingPermissions"; import { missingPermissionError } from "../../../utils/missingPermissionError"; @@ -11,9 +11,10 @@ import { automodAction } from "../helpers"; const p = PermissionFlagsBits; +const configSchema = z.array(zSnowflake); + export const AddRolesAction = automodAction({ - configType: t.array(t.string), - defaultConfig: [], + configSchema, async apply({ pluginData, contexts, actionConfig, ruleName }) { const members = unique(contexts.map((c) => c.member).filter(nonNullish)); diff --git a/backend/src/plugins/Automod/actions/addToCounter.ts b/backend/src/plugins/Automod/actions/addToCounter.ts index c3a72dae6..30ffe8faf 100644 --- a/backend/src/plugins/Automod/actions/addToCounter.ts +++ b/backend/src/plugins/Automod/actions/addToCounter.ts @@ -1,15 +1,16 @@ -import * as t from "io-ts"; +import z from "zod"; +import { zBoundedCharacters } from "../../../utils"; import { CountersPlugin } from "../../Counters/CountersPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { automodAction } from "../helpers"; -export const AddToCounterAction = automodAction({ - configType: t.type({ - counter: t.string, - amount: t.number, - }), +const configSchema = z.object({ + counter: zBoundedCharacters(0, 100), + amount: z.number(), +}); - defaultConfig: {}, +export const AddToCounterAction = automodAction({ + configSchema, async apply({ pluginData, contexts, actionConfig, ruleName }) { const countersPlugin = pluginData.getPlugin(CountersPlugin); diff --git a/backend/src/plugins/Automod/actions/alert.ts b/backend/src/plugins/Automod/actions/alert.ts index eb6f33483..1a767a0e5 100644 --- a/backend/src/plugins/Automod/actions/alert.ts +++ b/backend/src/plugins/Automod/actions/alert.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; -import * as t from "io-ts"; import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions"; +import z from "zod"; import { LogType } from "../../../data/LogType"; import { createTypedTemplateSafeValueContainer, @@ -12,10 +12,12 @@ import { chunkMessageLines, isTruthy, messageLink, - tAllowedMentions, - tNormalizedNullOptional, validateAndParseMessageContent, verboseChannelMention, + zAllowedMentions, + zBoundedCharacters, + zNullishToUndefined, + zSnowflake } from "../../../utils"; import { messageIsEmpty } from "../../../utils/messageIsEmpty"; import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; @@ -23,14 +25,14 @@ import { InternalPosterPlugin } from "../../InternalPoster/InternalPosterPlugin" import { LogsPlugin } from "../../Logs/LogsPlugin"; import { automodAction } from "../helpers"; -export const AlertAction = automodAction({ - configType: t.type({ - channel: t.string, - text: t.string, - allowed_mentions: tNormalizedNullOptional(tAllowedMentions), - }), +const configSchema = z.object({ + channel: zSnowflake, + text: zBoundedCharacters(1, 4000), + allowed_mentions: zNullishToUndefined(zAllowedMentions.nullable().default(null)), +}); - defaultConfig: {}, +export const AlertAction = automodAction({ + configSchema, async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) { const channel = pluginData.guild.channels.cache.get(actionConfig.channel as Snowflake); diff --git a/backend/src/plugins/Automod/actions/archiveThread.ts b/backend/src/plugins/Automod/actions/archiveThread.ts index d94afdf7a..e73cb3dde 100644 --- a/backend/src/plugins/Automod/actions/archiveThread.ts +++ b/backend/src/plugins/Automod/actions/archiveThread.ts @@ -1,11 +1,12 @@ import { AnyThreadChannel } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { noop } from "../../../utils"; import { automodAction } from "../helpers"; +const configSchema = z.strictObject({}); + export const ArchiveThreadAction = automodAction({ - configType: t.type({}), - defaultConfig: {}, + configSchema, async apply({ pluginData, contexts }) { const threads = contexts diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts index 3f253bc05..dd148ca7a 100644 --- a/backend/src/plugins/Automod/actions/availableActions.ts +++ b/backend/src/plugins/Automod/actions/availableActions.ts @@ -1,4 +1,3 @@ -import * as t from "io-ts"; import { AutomodActionBlueprint } from "../helpers"; import { AddRolesAction } from "./addRoles"; import { AddToCounterAction } from "./addToCounter"; @@ -19,7 +18,7 @@ import { SetSlowmodeAction } from "./setSlowmode"; import { StartThreadAction } from "./startThread"; import { WarnAction } from "./warn"; -export const availableActions: Record> = { +export const availableActions = { clean: CleanAction, warn: WarnAction, mute: MuteAction, @@ -38,25 +37,4 @@ export const availableActions: Record> = { start_thread: StartThreadAction, archive_thread: ArchiveThreadAction, change_perms: ChangePermsAction, -}; - -export const AvailableActions = t.type({ - clean: CleanAction.configType, - warn: WarnAction.configType, - mute: MuteAction.configType, - kick: KickAction.configType, - ban: BanAction.configType, - alert: AlertAction.configType, - change_nickname: ChangeNicknameAction.configType, - log: LogAction.configType, - add_roles: AddRolesAction.configType, - remove_roles: RemoveRolesAction.configType, - set_antiraid_level: SetAntiraidLevelAction.configType, - reply: ReplyAction.configType, - add_to_counter: AddToCounterAction.configType, - set_counter: SetCounterAction.configType, - set_slowmode: SetSlowmodeAction.configType, - start_thread: StartThreadAction.configType, - archive_thread: ArchiveThreadAction.configType, - change_perms: ChangePermsAction.configType, -}); +} satisfies Record>; diff --git a/backend/src/plugins/Automod/actions/ban.ts b/backend/src/plugins/Automod/actions/ban.ts index 9cd0fd863..dcc9563f2 100644 --- a/backend/src/plugins/Automod/actions/ban.ts +++ b/backend/src/plugins/Automod/actions/ban.ts @@ -1,25 +1,23 @@ -import * as t from "io-ts"; -import { convertDelayStringToMS, nonNullish, tDelayString, tNullable, unique } from "../../../utils"; +import z from "zod"; +import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { automodAction } from "../helpers"; +import { zNotify } from "../types"; -export const BanAction = automodAction({ - configType: t.type({ - reason: tNullable(t.string), - duration: tNullable(tDelayString), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), - deleteMessageDays: tNullable(t.number), - postInCaseLog: tNullable(t.boolean), - hide_case: tNullable(t.boolean), - }), +const configSchema = z.strictObject({ + reason: zBoundedCharacters(0, 4000).nullable().default(null), + duration: zDelayString.nullable().default(null), + notify: zNotify.nullable().default(null), + notifyChannel: zSnowflake.nullable().default(null), + deleteMessageDays: z.number().nullable().default(null), + postInCaseLog: z.boolean().nullable().default(null), + hide_case: z.boolean().nullable().default(false), +}); - defaultConfig: { - notify: null, // Use defaults from ModActions - hide_case: false, - }, +export const BanAction = automodAction({ + configSchema, async apply({ pluginData, contexts, actionConfig, matchResult }) { const reason = actionConfig.reason || "Kicked automatically"; diff --git a/backend/src/plugins/Automod/actions/changeNickname.ts b/backend/src/plugins/Automod/actions/changeNickname.ts index d63a3b60f..ce7c610ef 100644 --- a/backend/src/plugins/Automod/actions/changeNickname.ts +++ b/backend/src/plugins/Automod/actions/changeNickname.ts @@ -1,18 +1,16 @@ -import * as t from "io-ts"; -import { nonNullish, unique } from "../../../utils"; +import z from "zod"; +import { nonNullish, unique, zBoundedCharacters } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { automodAction } from "../helpers"; export const ChangeNicknameAction = automodAction({ - configType: t.union([ - t.string, - t.type({ - name: t.string, + configSchema: z.union([ + zBoundedCharacters(0, 32), + z.strictObject({ + name: zBoundedCharacters(0, 32), }), ]), - defaultConfig: {}, - async apply({ pluginData, contexts, actionConfig }) { const members = unique(contexts.map((c) => c.member).filter(nonNullish)); diff --git a/backend/src/plugins/Automod/actions/changePerms.ts b/backend/src/plugins/Automod/actions/changePerms.ts index 1eaa6dd5b..b5bc96867 100644 --- a/backend/src/plugins/Automod/actions/changePerms.ts +++ b/backend/src/plugins/Automod/actions/changePerms.ts @@ -1,7 +1,8 @@ import { PermissionsBitField, PermissionsString } from "discord.js"; -import * as t from "io-ts"; +import { U } from "ts-toolbelt"; +import z from "zod"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter"; -import { isValidSnowflake, noop, tNullable, tPartialDictionary } from "../../../utils"; +import { isValidSnowflake, keys, noop, zSnowflake } from "../../../utils"; import { guildToTemplateSafeGuild, savedMessageToTemplateSafeSavedMessage, @@ -59,16 +60,19 @@ const realToLegacyMap = Object.entries(legacyPermMap).reduce((map, pair) => { return map; }, {}) as Record; +const permissionNames = keys(PermissionsBitField.Flags) as U.ListOf; +const legacyPermissionNames = keys(legacyPermMap) as U.ListOf; +const allPermissionNames = [...permissionNames, ...legacyPermissionNames] as const; + export const ChangePermsAction = automodAction({ - configType: t.type({ - target: t.string, - channel: tNullable(t.string), - perms: tPartialDictionary( - t.union([t.keyof(PermissionsBitField.Flags), t.keyof(legacyPermMap)]), - tNullable(t.boolean), + configSchema: z.strictObject({ + target: zSnowflake, + channel: zSnowflake.nullable().default(null), + perms: z.record( + z.enum(allPermissionNames), + z.boolean().nullable(), ), }), - defaultConfig: {}, async apply({ pluginData, contexts, actionConfig }) { const user = contexts.find((c) => c.user)?.user; diff --git a/backend/src/plugins/Automod/actions/clean.ts b/backend/src/plugins/Automod/actions/clean.ts index 91bf37acf..5c66d370f 100644 --- a/backend/src/plugins/Automod/actions/clean.ts +++ b/backend/src/plugins/Automod/actions/clean.ts @@ -1,12 +1,11 @@ import { GuildTextBasedChannel, Snowflake } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { LogType } from "../../../data/LogType"; import { noop } from "../../../utils"; import { automodAction } from "../helpers"; export const CleanAction = automodAction({ - configType: t.boolean, - defaultConfig: false, + configSchema: z.boolean().default(false), async apply({ pluginData, contexts, ruleName }) { const messageIdsToDeleteByChannelId: Map = new Map(); diff --git a/backend/src/plugins/Automod/actions/exampleAction.ts b/backend/src/plugins/Automod/actions/exampleAction.ts index 05bea0de6..a43fc6768 100644 --- a/backend/src/plugins/Automod/actions/exampleAction.ts +++ b/backend/src/plugins/Automod/actions/exampleAction.ts @@ -1,13 +1,12 @@ -import * as t from "io-ts"; +import z from "zod"; +import { zBoundedCharacters } from "../../../utils"; import { automodAction } from "../helpers"; export const ExampleAction = automodAction({ - configType: t.type({ - someValue: t.string, + configSchema: z.strictObject({ + someValue: zBoundedCharacters(0, 1000), }), - defaultConfig: {}, - // eslint-disable-next-line @typescript-eslint/no-unused-vars async apply({ pluginData, contexts, actionConfig }) { // TODO: Everything diff --git a/backend/src/plugins/Automod/actions/kick.ts b/backend/src/plugins/Automod/actions/kick.ts index 9e6a792d6..5afba467a 100644 --- a/backend/src/plugins/Automod/actions/kick.ts +++ b/backend/src/plugins/Automod/actions/kick.ts @@ -1,24 +1,20 @@ -import * as t from "io-ts"; -import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils"; +import z from "zod"; +import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { automodAction } from "../helpers"; +import { zNotify } from "../types"; export const KickAction = automodAction({ - configType: t.type({ - reason: tNullable(t.string), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), - postInCaseLog: tNullable(t.boolean), - hide_case: tNullable(t.boolean), + configSchema: z.strictObject({ + reason: zBoundedCharacters(0, 4000).nullable().default(null), + notify: zNotify.nullable().default(null), + notifyChannel: zSnowflake.nullable().default(null), + postInCaseLog: z.boolean().nullable().default(null), + hide_case: z.boolean().nullable().default(false), }), - defaultConfig: { - notify: null, // Use defaults from ModActions - hide_case: false, - }, - async apply({ pluginData, contexts, actionConfig, matchResult }) { const reason = actionConfig.reason || "Kicked automatically"; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; diff --git a/backend/src/plugins/Automod/actions/log.ts b/backend/src/plugins/Automod/actions/log.ts index 82075a2ec..dace25f12 100644 --- a/backend/src/plugins/Automod/actions/log.ts +++ b/backend/src/plugins/Automod/actions/log.ts @@ -1,11 +1,10 @@ -import * as t from "io-ts"; +import z from "zod"; import { isTruthy, unique } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { automodAction } from "../helpers"; export const LogAction = automodAction({ - configType: t.boolean, - defaultConfig: true, + configSchema: z.boolean().default(true), async apply({ pluginData, contexts, ruleName, matchResult }) { const users = unique(contexts.map((c) => c.user)).filter(isTruthy); diff --git a/backend/src/plugins/Automod/actions/mute.ts b/backend/src/plugins/Automod/actions/mute.ts index 3219c712e..4a2a42ad3 100644 --- a/backend/src/plugins/Automod/actions/mute.ts +++ b/backend/src/plugins/Automod/actions/mute.ts @@ -1,29 +1,25 @@ -import * as t from "io-ts"; +import z from "zod"; import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; -import { convertDelayStringToMS, nonNullish, tDelayString, tNullable, unique } from "../../../utils"; +import { convertDelayStringToMS, nonNullish, unique, zBoundedCharacters, zDelayString, zSnowflake } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { MutesPlugin } from "../../Mutes/MutesPlugin"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { automodAction } from "../helpers"; +import { zNotify } from "../types"; export const MuteAction = automodAction({ - configType: t.type({ - reason: tNullable(t.string), - duration: tNullable(tDelayString), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), - remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), - restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), - postInCaseLog: tNullable(t.boolean), - hide_case: tNullable(t.boolean), + configSchema: z.strictObject({ + reason: zBoundedCharacters(0, 4000).nullable().default(null), + duration: zDelayString.nullable().default(null), + notify: zNotify.nullable().default(null), + notifyChannel: zSnowflake.nullable().default(null), + remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).nullable().default(null), + restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).nullable().default(null), + postInCaseLog: z.boolean().nullable().default(null), + hide_case: z.boolean().nullable().default(false), }), - defaultConfig: { - notify: null, // Use defaults from ModActions - hide_case: false, - }, - async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) { const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined; const reason = actionConfig.reason || "Muted automatically"; diff --git a/backend/src/plugins/Automod/actions/removeRoles.ts b/backend/src/plugins/Automod/actions/removeRoles.ts index a46d8262c..8b065702c 100644 --- a/backend/src/plugins/Automod/actions/removeRoles.ts +++ b/backend/src/plugins/Automod/actions/removeRoles.ts @@ -1,6 +1,6 @@ import { PermissionFlagsBits, Snowflake } from "discord.js"; -import * as t from "io-ts"; -import { nonNullish, unique } from "../../../utils"; +import z from "zod"; +import { nonNullish, unique, zSnowflake } from "../../../utils"; import { canAssignRole } from "../../../utils/canAssignRole"; import { getMissingPermissions } from "../../../utils/getMissingPermissions"; import { memberRolesLock } from "../../../utils/lockNameHelpers"; @@ -12,9 +12,7 @@ import { automodAction } from "../helpers"; const p = PermissionFlagsBits; export const RemoveRolesAction = automodAction({ - configType: t.array(t.string), - - defaultConfig: [], + configSchema: z.array(zSnowflake).default([]), async apply({ pluginData, contexts, actionConfig, ruleName }) { const members = unique(contexts.map((c) => c.member).filter(nonNullish)); diff --git a/backend/src/plugins/Automod/actions/reply.ts b/backend/src/plugins/Automod/actions/reply.ts index 6239cee89..02ff95452 100644 --- a/backend/src/plugins/Automod/actions/reply.ts +++ b/backend/src/plugins/Automod/actions/reply.ts @@ -1,16 +1,16 @@ import { GuildTextBasedChannel, MessageCreateOptions, PermissionsBitField, Snowflake, User } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter"; import { convertDelayStringToMS, noop, renderRecursively, - tDelayString, - tMessageContent, - tNullable, unique, validateAndParseMessageContent, verboseChannelMention, + zBoundedCharacters, + zDelayString, + zMessageContent } from "../../../utils"; import { hasDiscordPermissions } from "../../../utils/hasDiscordPermissions"; import { messageIsEmpty } from "../../../utils/messageIsEmpty"; @@ -20,17 +20,15 @@ import { automodAction } from "../helpers"; import { AutomodContext } from "../types"; export const ReplyAction = automodAction({ - configType: t.union([ - t.string, - t.type({ - text: tMessageContent, - auto_delete: tNullable(t.union([tDelayString, t.number])), - inline: tNullable(t.boolean), + configSchema: z.union([ + zBoundedCharacters(0, 4000), + z.strictObject({ + text: zMessageContent, + auto_delete: z.union([zDelayString, z.number()]).nullable().default(null), + inline: z.boolean().default(false), }), ]), - defaultConfig: {}, - async apply({ pluginData, contexts, actionConfig, ruleName }) { const contextsWithTextChannels = contexts .filter((c) => c.message?.channel_id) diff --git a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts index ecddabd95..db2a6bef0 100644 --- a/backend/src/plugins/Automod/actions/setAntiraidLevel.ts +++ b/backend/src/plugins/Automod/actions/setAntiraidLevel.ts @@ -1,11 +1,9 @@ -import * as t from "io-ts"; -import { tNullable } from "../../../utils"; +import { zBoundedCharacters } from "../../../utils"; import { setAntiraidLevel } from "../functions/setAntiraidLevel"; import { automodAction } from "../helpers"; export const SetAntiraidLevelAction = automodAction({ - configType: tNullable(t.string), - defaultConfig: "", + configSchema: zBoundedCharacters(0, 100).nullable(), async apply({ pluginData, actionConfig }) { setAntiraidLevel(pluginData, actionConfig ?? null); diff --git a/backend/src/plugins/Automod/actions/setCounter.ts b/backend/src/plugins/Automod/actions/setCounter.ts index 3286fb4b8..dea4bdd64 100644 --- a/backend/src/plugins/Automod/actions/setCounter.ts +++ b/backend/src/plugins/Automod/actions/setCounter.ts @@ -1,16 +1,15 @@ -import * as t from "io-ts"; +import z from "zod"; +import { zBoundedCharacters } from "../../../utils"; import { CountersPlugin } from "../../Counters/CountersPlugin"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { automodAction } from "../helpers"; export const SetCounterAction = automodAction({ - configType: t.type({ - counter: t.string, - value: t.number, + configSchema: z.strictObject({ + counter: zBoundedCharacters(0, 100), + value: z.number(), }), - defaultConfig: {}, - async apply({ pluginData, contexts, actionConfig, ruleName }) { const countersPlugin = pluginData.getPlugin(CountersPlugin); if (!countersPlugin.counterExists(actionConfig.counter)) { diff --git a/backend/src/plugins/Automod/actions/setSlowmode.ts b/backend/src/plugins/Automod/actions/setSlowmode.ts index 7b527f10d..ecac0f214 100644 --- a/backend/src/plugins/Automod/actions/setSlowmode.ts +++ b/backend/src/plugins/Automod/actions/setSlowmode.ts @@ -1,19 +1,15 @@ import { ChannelType, GuildTextBasedChannel, Snowflake } from "discord.js"; -import * as t from "io-ts"; -import { convertDelayStringToMS, isDiscordAPIError, tDelayString, tNullable } from "../../../utils"; +import z from "zod"; +import { convertDelayStringToMS, isDiscordAPIError, zDelayString, zSnowflake } from "../../../utils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { automodAction } from "../helpers"; export const SetSlowmodeAction = automodAction({ - configType: t.type({ - channels: t.array(t.string), - duration: tNullable(tDelayString), + configSchema: z.strictObject({ + channels: z.array(zSnowflake), + duration: zDelayString.nullable().default("10s"), }), - defaultConfig: { - duration: "10s", - }, - async apply({ pluginData, actionConfig }) { const slowmodeMs = Math.max(actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : 0, 0); diff --git a/backend/src/plugins/Automod/actions/startThread.ts b/backend/src/plugins/Automod/actions/startThread.ts index 25b840e1b..e0bc11a40 100644 --- a/backend/src/plugins/Automod/actions/startThread.ts +++ b/backend/src/plugins/Automod/actions/startThread.ts @@ -5,9 +5,9 @@ import { ThreadAutoArchiveDuration, ThreadChannel, } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter"; -import { MINUTES, convertDelayStringToMS, noop, tDelayString, tNullable } from "../../../utils"; +import { MINUTES, convertDelayStringToMS, noop, zBoundedCharacters, zDelayString } from "../../../utils"; import { savedMessageToTemplateSafeSavedMessage, userToTemplateSafeUser } from "../../../utils/templateSafeObjects"; import { automodAction } from "../helpers"; @@ -19,18 +19,14 @@ const validThreadAutoArchiveDurations: ThreadAutoArchiveDuration[] = [ ]; export const StartThreadAction = automodAction({ - configType: t.type({ - name: tNullable(t.string), - auto_archive: tDelayString, - private: tNullable(t.boolean), - slowmode: tNullable(tDelayString), - limit_per_channel: tNullable(t.number), + configSchema: z.strictObject({ + name: zBoundedCharacters(1, 100).nullable(), + auto_archive: zDelayString, + private: z.boolean().default(false), + slowmode: zDelayString.nullable().default(null), + limit_per_channel: z.number().nullable().default(5), }), - defaultConfig: { - limit_per_channel: 5, - }, - async apply({ pluginData, contexts, actionConfig }) { // check if the message still exists, we don't want to create threads for deleted messages const threads = contexts.filter((c) => { diff --git a/backend/src/plugins/Automod/actions/warn.ts b/backend/src/plugins/Automod/actions/warn.ts index 59135cb2c..e8c63340c 100644 --- a/backend/src/plugins/Automod/actions/warn.ts +++ b/backend/src/plugins/Automod/actions/warn.ts @@ -1,24 +1,20 @@ -import * as t from "io-ts"; -import { asyncMap, nonNullish, resolveMember, tNullable, unique } from "../../../utils"; +import z from "zod"; +import { asyncMap, nonNullish, resolveMember, unique, zBoundedCharacters, zSnowflake } from "../../../utils"; import { CaseArgs } from "../../Cases/types"; import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin"; import { resolveActionContactMethods } from "../functions/resolveActionContactMethods"; import { automodAction } from "../helpers"; +import { zNotify } from "../types"; export const WarnAction = automodAction({ - configType: t.type({ - reason: tNullable(t.string), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), - postInCaseLog: tNullable(t.boolean), - hide_case: tNullable(t.boolean), + configSchema: z.strictObject({ + reason: zBoundedCharacters(0, 4000).nullable().default(null), + notify: zNotify.nullable().default(null), + notifyChannel: zSnowflake.nullable().default(null), + postInCaseLog: z.boolean().nullable().default(null), + hide_case: z.boolean().nullable().default(false), }), - defaultConfig: { - notify: null, // Use defaults from ModActions - hide_case: false, - }, - async apply({ pluginData, contexts, actionConfig, matchResult }) { const reason = actionConfig.reason || "Warned automatically"; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts index a1858d371..19364eee6 100644 --- a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts +++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts @@ -1,28 +1,27 @@ -import * as t from "io-ts"; import { SavedMessage } from "../../../data/entities/SavedMessage"; import { humanizeDurationShort } from "../../../humanizeDurationShort"; import { getBaseUrl } from "../../../pluginUtils"; -import { convertDelayStringToMS, sorter, tDelayString, tNullable } from "../../../utils"; +import { convertDelayStringToMS, sorter, zDelayString } from "../../../utils"; import { RecentActionType } from "../constants"; import { automodTrigger } from "../helpers"; import { findRecentSpam } from "./findRecentSpam"; import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions"; import { getMessageSpamIdentifier } from "./getSpamIdentifier"; - -const MessageSpamTriggerConfig = t.type({ - amount: t.number, - within: tDelayString, - per_channel: tNullable(t.boolean), -}); +import z from "zod"; interface TMessageSpamMatchResultType { archiveId: string; } +const configSchema = z.strictObject({ + amount: z.number().int(), + within: zDelayString, + per_channel: z.boolean().optional(), +}); + export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) { return automodTrigger()({ - configType: MessageSpamTriggerConfig, - defaultConfig: {}, + configSchema, async match({ pluginData, context, triggerConfig }) { if (!context.message) { diff --git a/backend/src/plugins/Automod/helpers.ts b/backend/src/plugins/Automod/helpers.ts index 5c5f55c75..d1c04d61c 100644 --- a/backend/src/plugins/Automod/helpers.ts +++ b/backend/src/plugins/Automod/helpers.ts @@ -1,7 +1,7 @@ -import * as t from "io-ts"; import { GuildPluginData } from "knub"; import { Awaitable } from "../../utils/typeUtils"; import { AutomodContext, AutomodPluginType } from "./types"; +import z, { ZodTypeAny } from "zod"; interface BaseAutomodTriggerMatchResult { extraContexts?: AutomodContext[]; @@ -31,21 +31,19 @@ type AutomodTriggerRenderMatchInformationFn = (m matchResult: AutomodTriggerMatchResult; }) => Awaitable; -export interface AutomodTriggerBlueprint { - configType: TConfigType; - defaultConfig: Partial>; - - match: AutomodTriggerMatchFn, TMatchResultExtra>; - renderMatchInformation: AutomodTriggerRenderMatchInformationFn, TMatchResultExtra>; +export interface AutomodTriggerBlueprint { + configSchema: TConfigSchema; + match: AutomodTriggerMatchFn, TMatchResultExtra>; + renderMatchInformation: AutomodTriggerRenderMatchInformationFn, TMatchResultExtra>; } -export function automodTrigger(): ( - blueprint: AutomodTriggerBlueprint, -) => AutomodTriggerBlueprint; +export function automodTrigger(): ( + blueprint: AutomodTriggerBlueprint, +) => AutomodTriggerBlueprint; -export function automodTrigger( - blueprint: AutomodTriggerBlueprint, -): AutomodTriggerBlueprint; +export function automodTrigger( + blueprint: AutomodTriggerBlueprint, +): AutomodTriggerBlueprint; export function automodTrigger(...args) { if (args.length) { @@ -63,15 +61,13 @@ type AutomodActionApplyFn = (meta: { matchResult: AutomodTriggerMatchResult; }) => Awaitable; -export interface AutomodActionBlueprint { - configType: TConfigType; - defaultConfig: Partial>; - - apply: AutomodActionApplyFn>; +export interface AutomodActionBlueprint { + configSchema: TConfigSchema; + apply: AutomodActionApplyFn>; } -export function automodAction( - blueprint: AutomodActionBlueprint, -): AutomodActionBlueprint { +export function automodAction( + blueprint: AutomodActionBlueprint, +): AutomodActionBlueprint { return blueprint; } diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/info.ts index e0102459d..a2071e219 100644 --- a/backend/src/plugins/Automod/info.ts +++ b/backend/src/plugins/Automod/info.ts @@ -1,6 +1,6 @@ import { trimPluginDescription } from "../../utils"; import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint"; -import { ConfigSchema } from "./types"; +import { zAutomodConfig } from "./types"; export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { prettyName: "Automod", @@ -100,5 +100,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { {matchSummary} ~~~ `), - configSchema: ConfigSchema, + configSchema: zAutomodConfig, }; diff --git a/backend/src/plugins/Automod/triggers/antiraidLevel.ts b/backend/src/plugins/Automod/triggers/antiraidLevel.ts index 7c6d17afd..c879455f3 100644 --- a/backend/src/plugins/Automod/triggers/antiraidLevel.ts +++ b/backend/src/plugins/Automod/triggers/antiraidLevel.ts @@ -1,15 +1,14 @@ -import * as t from "io-ts"; -import { tNullable } from "../../../utils"; import { automodTrigger } from "../helpers"; +import z from "zod"; interface AntiraidLevelTriggerResult {} -export const AntiraidLevelTrigger = automodTrigger()({ - configType: t.type({ - level: tNullable(t.string), - }), +const configSchema = z.strictObject({ + level: z.nullable(z.string().max(100)), +}); - defaultConfig: {}, +export const AntiraidLevelTrigger = automodTrigger()({ + configSchema, async match({ triggerConfig, context }) { if (!context.antiraid) { diff --git a/backend/src/plugins/Automod/triggers/anyMessage.ts b/backend/src/plugins/Automod/triggers/anyMessage.ts index 5b611334e..93ce0abcf 100644 --- a/backend/src/plugins/Automod/triggers/anyMessage.ts +++ b/backend/src/plugins/Automod/triggers/anyMessage.ts @@ -1,14 +1,14 @@ import { Snowflake } from "discord.js"; -import * as t from "io-ts"; import { verboseChannelMention } from "../../../utils"; import { automodTrigger } from "../helpers"; +import z from "zod"; interface AnyMessageResultType {} -export const AnyMessageTrigger = automodTrigger()({ - configType: t.type({}), +const configSchema = z.strictObject({}); - defaultConfig: {}, +export const AnyMessageTrigger = automodTrigger()({ + configSchema, async match({ context }) { if (!context.message) { diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index 822e44ff7..a2ac60fba 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -1,4 +1,3 @@ -import * as t from "io-ts"; import { AutomodTriggerBlueprint } from "../helpers"; import { AntiraidLevelTrigger } from "./antiraidLevel"; import { AnyMessageTrigger } from "./anyMessage"; @@ -45,6 +44,7 @@ export const availableTriggers: Record match_attachment_type: MatchAttachmentTypeTrigger, match_mime_type: MatchMimeTypeTrigger, member_join: MemberJoinTrigger, + member_leave: MemberLeaveTrigger, role_added: RoleAddedTrigger, role_removed: RoleRemovedTrigger, @@ -76,46 +76,3 @@ export const availableTriggers: Record thread_archive: ThreadArchiveTrigger, thread_unarchive: ThreadUnarchiveTrigger, }; - -export const AvailableTriggers = t.type({ - any_message: AnyMessageTrigger.configType, - - match_words: MatchWordsTrigger.configType, - match_regex: MatchRegexTrigger.configType, - match_invites: MatchInvitesTrigger.configType, - match_links: MatchLinksTrigger.configType, - match_attachment_type: MatchAttachmentTypeTrigger.configType, - match_mime_type: MatchMimeTypeTrigger.configType, - member_join: MemberJoinTrigger.configType, - member_leave: MemberLeaveTrigger.configType, - role_added: RoleAddedTrigger.configType, - role_removed: RoleRemovedTrigger.configType, - - message_spam: MessageSpamTrigger.configType, - mention_spam: MentionSpamTrigger.configType, - link_spam: LinkSpamTrigger.configType, - attachment_spam: AttachmentSpamTrigger.configType, - emoji_spam: EmojiSpamTrigger.configType, - line_spam: LineSpamTrigger.configType, - character_spam: CharacterSpamTrigger.configType, - member_join_spam: MemberJoinSpamTrigger.configType, - sticker_spam: StickerSpamTrigger.configType, - thread_create_spam: ThreadCreateSpamTrigger.configType, - - counter_trigger: CounterTrigger.configType, - - note: NoteTrigger.configType, - warn: WarnTrigger.configType, - mute: MuteTrigger.configType, - unmute: UnmuteTrigger.configType, - kick: KickTrigger.configType, - ban: BanTrigger.configType, - unban: UnbanTrigger.configType, - - antiraid_level: AntiraidLevelTrigger.configType, - - thread_create: ThreadCreateTrigger.configType, - thread_delete: ThreadDeleteTrigger.configType, - thread_archive: ThreadArchiveTrigger.configType, - thread_unarchive: ThreadUnarchiveTrigger.configType, -}); diff --git a/backend/src/plugins/Automod/triggers/ban.ts b/backend/src/plugins/Automod/triggers/ban.ts index f8a4730b9..f6df0fd68 100644 --- a/backend/src/plugins/Automod/triggers/ban.ts +++ b/backend/src/plugins/Automod/triggers/ban.ts @@ -1,19 +1,16 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; // tslint:disable-next-line:no-empty-interface interface BanTriggerResultType {} -export const BanTrigger = automodTrigger()({ - configType: t.type({ - manual: t.boolean, - automatic: t.boolean, - }), +const configSchema = z.strictObject({ + manual: z.boolean().default(true), + automatic: z.boolean().default(true), +}); - defaultConfig: { - manual: true, - automatic: true, - }, +export const BanTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig }) { if (context.modAction?.type !== "ban") { diff --git a/backend/src/plugins/Automod/triggers/counterTrigger.ts b/backend/src/plugins/Automod/triggers/counterTrigger.ts index 58f21402a..22de3df1d 100644 --- a/backend/src/plugins/Automod/triggers/counterTrigger.ts +++ b/backend/src/plugins/Automod/triggers/counterTrigger.ts @@ -1,18 +1,17 @@ -import * as t from "io-ts"; -import { tNullable } from "../../../utils"; +import z from "zod"; import { automodTrigger } from "../helpers"; // tslint:disable-next-line interface CounterTriggerResult {} -export const CounterTrigger = automodTrigger()({ - configType: t.type({ - counter: t.string, - trigger: t.string, - reverse: tNullable(t.boolean), - }), +const configSchema = z.strictObject({ + counter: z.string().max(100), + trigger: z.string().max(100), + reverse: z.boolean().optional(), +}); - defaultConfig: {}, +export const CounterTrigger = automodTrigger()({ + configSchema, async match({ triggerConfig, context }) { if (!context.counterTrigger) { diff --git a/backend/src/plugins/Automod/triggers/exampleTrigger.ts b/backend/src/plugins/Automod/triggers/exampleTrigger.ts index bf0880a99..e6005e072 100644 --- a/backend/src/plugins/Automod/triggers/exampleTrigger.ts +++ b/backend/src/plugins/Automod/triggers/exampleTrigger.ts @@ -1,18 +1,16 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; interface ExampleMatchResultType { isBanana: boolean; } -export const ExampleTrigger = automodTrigger()({ - configType: t.type({ - allowedFruits: t.array(t.string), - }), +const configSchema = z.strictObject({ + allowedFruits: z.array(z.string().max(100)).max(50).default(["peach", "banana"]), +}); - defaultConfig: { - allowedFruits: ["peach", "banana"], - }, +export const ExampleTrigger = automodTrigger()({ + configSchema, async match({ triggerConfig, context }) { const foundFruit = triggerConfig.allowedFruits.find((fruit) => context.message?.data.content === fruit); diff --git a/backend/src/plugins/Automod/triggers/kick.ts b/backend/src/plugins/Automod/triggers/kick.ts index 163d6bf3f..add6803e0 100644 --- a/backend/src/plugins/Automod/triggers/kick.ts +++ b/backend/src/plugins/Automod/triggers/kick.ts @@ -1,19 +1,16 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; // tslint:disable-next-line:no-empty-interface interface KickTriggerResultType {} -export const KickTrigger = automodTrigger()({ - configType: t.type({ - manual: t.boolean, - automatic: t.boolean, - }), +const configSchema = z.strictObject({ + manual: z.boolean().default(true), + automatic: z.boolean().default(true), +}); - defaultConfig: { - manual: true, - automatic: true, - }, +export const KickTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig }) { if (context.modAction?.type !== "kick") { diff --git a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts index 659be65b1..45f7ede7a 100644 --- a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts +++ b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts @@ -1,5 +1,5 @@ import { escapeInlineCode, Snowflake } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils"; import { automodTrigger } from "../helpers"; @@ -8,20 +8,31 @@ interface MatchResultType { mode: "blacklist" | "whitelist"; } -export const MatchAttachmentTypeTrigger = automodTrigger()({ - configType: t.type({ - filetype_blacklist: t.array(t.string), - blacklist_enabled: t.boolean, - filetype_whitelist: t.array(t.string), - whitelist_enabled: t.boolean, - }), +const configSchema = z.strictObject({ + filetype_blacklist: z.array(z.string().max(32)).max(255).default([]), + blacklist_enabled: z.boolean().default(false), + filetype_whitelist: z.array(z.string().max(32)).max(255).default([]), + whitelist_enabled: z.boolean().default(false), +}).transform((parsed, ctx) => { + if (parsed.blacklist_enabled && parsed.whitelist_enabled) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cannot have both blacklist and whitelist enabled", + }); + return z.NEVER; + } + if (! parsed.blacklist_enabled && ! parsed.whitelist_enabled) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must have either blacklist or whitelist enabled", + }); + return z.NEVER; + } + return parsed; +}); - defaultConfig: { - filetype_blacklist: [], - blacklist_enabled: false, - filetype_whitelist: [], - whitelist_enabled: false, - }, +export const MatchAttachmentTypeTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig: trigger }) { if (!context.message) { diff --git a/backend/src/plugins/Automod/triggers/matchInvites.ts b/backend/src/plugins/Automod/triggers/matchInvites.ts index 9e0b0c4e8..4ea7c3cd6 100644 --- a/backend/src/plugins/Automod/triggers/matchInvites.ts +++ b/backend/src/plugins/Automod/triggers/matchInvites.ts @@ -1,5 +1,5 @@ -import * as t from "io-ts"; -import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, tNullable } from "../../../utils"; +import z from "zod"; +import { getInviteCodesInString, GuildInvite, isGuildInvite, resolveInvite, zSnowflake } from "../../../utils"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; import { automodTrigger } from "../helpers"; @@ -10,30 +10,22 @@ interface MatchResultType { invite?: GuildInvite; } -export const MatchInvitesTrigger = automodTrigger()({ - configType: t.type({ - include_guilds: tNullable(t.array(t.string)), - exclude_guilds: tNullable(t.array(t.string)), - include_invite_codes: tNullable(t.array(t.string)), - exclude_invite_codes: tNullable(t.array(t.string)), - allow_group_dm_invites: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, - }), +const configSchema = z.strictObject({ + include_guilds: z.array(zSnowflake).max(255).optional(), + exclude_guilds: z.array(zSnowflake).max(255).optional(), + include_invite_codes: z.array(z.string().max(32)).max(255).optional(), + exclude_invite_codes: z.array(z.string().max(32)).max(255).optional(), + allow_group_dm_invites: z.boolean().default(false), + match_messages: z.boolean().default(true), + match_embeds: z.boolean().default(false), + match_visible_names: z.boolean().default(false), + match_usernames: z.boolean().default(false), + match_nicknames: z.boolean().default(false), + match_custom_status: z.boolean().default(false), +}); - defaultConfig: { - allow_group_dm_invites: false, - match_messages: true, - match_embeds: false, - match_visible_names: false, - match_usernames: false, - match_nicknames: false, - match_custom_status: false, - }, +export const MatchInvitesTrigger = automodTrigger()({ + configSchema, async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { diff --git a/backend/src/plugins/Automod/triggers/matchLinks.ts b/backend/src/plugins/Automod/triggers/matchLinks.ts index 5a8de7dea..128b3cd14 100644 --- a/backend/src/plugins/Automod/triggers/matchLinks.ts +++ b/backend/src/plugins/Automod/triggers/matchLinks.ts @@ -1,11 +1,10 @@ import { escapeInlineCode } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { allowTimeout } from "../../../RegExpRunner"; import { phishermanDomainIsSafe } from "../../../data/Phisherman"; -import { getUrlsInString, tNullable } from "../../../utils"; +import { getUrlsInString, zRegex } from "../../../utils"; import { mergeRegexes } from "../../../utils/mergeRegexes"; import { mergeWordsIntoRegex } from "../../../utils/mergeWordsIntoRegex"; -import { TRegex } from "../../../validatorUtils"; import { PhishermanPlugin } from "../../Phisherman/PhishermanPlugin"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; @@ -21,40 +20,29 @@ const regexCache = new WeakMap(); const quickLinkCheck = /^https?:\/\//i; +const configSchema = z.strictObject({ + include_domains: z.array(z.string().max(255)).max(255).optional(), + exclude_domains: z.array(z.string().max(255)).max(255).optional(), + include_subdomains: z.boolean().default(true), + include_words: z.array(z.string().max(2000)).max(512).optional(), + exclude_words: z.array(z.string().max(2000)).max(512).optional(), + include_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(), + exclude_regex: z.array(zRegex(z.string().max(2000))).max(512).optional(), + phisherman: z.strictObject({ + include_suspected: z.boolean().optional(), + include_verified: z.boolean().optional(), + }).optional(), + only_real_links: z.boolean(), + match_messages: z.boolean().default(true), + match_embeds: z.boolean().default(true), + match_visible_names: z.boolean().default(false), + match_usernames: z.boolean().default(false), + match_nicknames: z.boolean().default(false), + match_custom_status: z.boolean().default(false), +}); + export const MatchLinksTrigger = automodTrigger()({ - configType: t.type({ - include_domains: tNullable(t.array(t.string)), - exclude_domains: tNullable(t.array(t.string)), - include_subdomains: t.boolean, - include_words: tNullable(t.array(t.string)), - exclude_words: tNullable(t.array(t.string)), - include_regex: tNullable(t.array(TRegex)), - exclude_regex: tNullable(t.array(TRegex)), - phisherman: tNullable( - t.type({ - include_suspected: tNullable(t.boolean), - include_verified: tNullable(t.boolean), - }), - ), - only_real_links: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, - }), - - defaultConfig: { - include_subdomains: true, - match_messages: true, - match_embeds: false, - match_visible_names: false, - match_usernames: false, - match_nicknames: false, - match_custom_status: false, - only_real_links: true, - }, + configSchema, async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { diff --git a/backend/src/plugins/Automod/triggers/matchMimeType.ts b/backend/src/plugins/Automod/triggers/matchMimeType.ts index 1af124711..8da9b2e36 100644 --- a/backend/src/plugins/Automod/triggers/matchMimeType.ts +++ b/backend/src/plugins/Automod/triggers/matchMimeType.ts @@ -1,5 +1,5 @@ import { escapeInlineCode } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { asSingleLine, messageSummary, verboseChannelMention } from "../../../utils"; import { automodTrigger } from "../helpers"; @@ -8,20 +8,31 @@ interface MatchResultType { mode: "blacklist" | "whitelist"; } -export const MatchMimeTypeTrigger = automodTrigger()({ - configType: t.type({ - mime_type_blacklist: t.array(t.string), - blacklist_enabled: t.boolean, - mime_type_whitelist: t.array(t.string), - whitelist_enabled: t.boolean, - }), +const configSchema = z.strictObject({ + mime_type_blacklist: z.array(z.string().max(255)).max(255).default([]), + blacklist_enabled: z.boolean().default(false), + mime_type_whitelist: z.array(z.string().max(255)).max(255).default([]), + whitelist_enabled: z.boolean().default(false), +}).transform((parsed, ctx) => { + if (parsed.blacklist_enabled && parsed.whitelist_enabled) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Cannot have both blacklist and whitelist enabled", + }); + return z.NEVER; + } + if (! parsed.blacklist_enabled && ! parsed.whitelist_enabled) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must have either blacklist or whitelist enabled", + }); + return z.NEVER; + } + return parsed; +}); - defaultConfig: { - mime_type_blacklist: [], - blacklist_enabled: false, - mime_type_whitelist: [], - whitelist_enabled: false, - }, +export const MatchMimeTypeTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig: trigger }) { if (!context.message) return; diff --git a/backend/src/plugins/Automod/triggers/matchRegex.ts b/backend/src/plugins/Automod/triggers/matchRegex.ts index 7d011891f..41df0396b 100644 --- a/backend/src/plugins/Automod/triggers/matchRegex.ts +++ b/backend/src/plugins/Automod/triggers/matchRegex.ts @@ -1,9 +1,9 @@ -import * as t from "io-ts"; +import z from "zod"; import { allowTimeout } from "../../../RegExpRunner"; +import { zRegex } from "../../../utils"; import { mergeRegexes } from "../../../utils/mergeRegexes"; import { normalizeText } from "../../../utils/normalizeText"; import { stripMarkdown } from "../../../utils/stripMarkdown"; -import { TRegex } from "../../../validatorUtils"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; import { automodTrigger } from "../helpers"; @@ -13,33 +13,23 @@ interface MatchResultType { type: MatchableTextType; } +const configSchema = z.strictObject({ + patterns: z.array(zRegex(z.string().max(2000))).max(512), + case_sensitive: z.boolean().default(false), + normalize: z.boolean().default(false), + strip_markdown: z.boolean().default(false), + match_messages: z.boolean().default(true), + match_embeds: z.boolean().default(false), + match_visible_names: z.boolean().default(false), + match_usernames: z.boolean().default(false), + match_nicknames: z.boolean().default(false), + match_custom_status: z.boolean().default(false), +}); + const regexCache = new WeakMap(); export const MatchRegexTrigger = automodTrigger()({ - configType: t.type({ - patterns: t.array(TRegex), - case_sensitive: t.boolean, - normalize: t.boolean, - strip_markdown: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, - }), - - defaultConfig: { - case_sensitive: false, - normalize: false, - strip_markdown: false, - match_messages: true, - match_embeds: false, - match_visible_names: false, - match_usernames: false, - match_nicknames: false, - match_custom_status: false, - }, + configSchema, async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { diff --git a/backend/src/plugins/Automod/triggers/matchWords.ts b/backend/src/plugins/Automod/triggers/matchWords.ts index c5ca79fce..e8c275e13 100644 --- a/backend/src/plugins/Automod/triggers/matchWords.ts +++ b/backend/src/plugins/Automod/triggers/matchWords.ts @@ -1,5 +1,5 @@ import escapeStringRegexp from "escape-string-regexp"; -import * as t from "io-ts"; +import z from "zod"; import { normalizeText } from "../../../utils/normalizeText"; import { stripMarkdown } from "../../../utils/stripMarkdown"; import { getTextMatchPartialSummary } from "../functions/getTextMatchPartialSummary"; @@ -13,37 +13,24 @@ interface MatchResultType { const regexCache = new WeakMap(); -export const MatchWordsTrigger = automodTrigger()({ - configType: t.type({ - words: t.array(t.string), - case_sensitive: t.boolean, - only_full_words: t.boolean, - normalize: t.boolean, - loose_matching: t.boolean, - loose_matching_threshold: t.number, - strip_markdown: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, - }), +const configSchema = z.strictObject({ + words: z.array(z.string().max(2000)).max(512), + case_sensitive: z.boolean().default(false), + only_full_words: z.boolean().default(true), + normalize: z.boolean().default(false), + loose_matching: z.boolean().default(false), + loose_matching_threshold: z.number().int().default(4), + strip_markdown: z.boolean().default(false), + match_messages: z.boolean().default(true), + match_embeds: z.boolean().default(false), + match_visible_names: z.boolean().default(false), + match_usernames: z.boolean().default(false), + match_nicknames: z.boolean().default(false), + match_custom_status: z.boolean().default(false), +}); - defaultConfig: { - case_sensitive: false, - only_full_words: true, - normalize: false, - loose_matching: false, - loose_matching_threshold: 4, - strip_markdown: false, - match_messages: true, - match_embeds: false, - match_visible_names: false, - match_usernames: false, - match_nicknames: false, - match_custom_status: false, - }, +export const MatchWordsTrigger = automodTrigger()({ + configSchema, async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { diff --git a/backend/src/plugins/Automod/triggers/memberJoin.ts b/backend/src/plugins/Automod/triggers/memberJoin.ts index f62d87aab..af9a1fae5 100644 --- a/backend/src/plugins/Automod/triggers/memberJoin.ts +++ b/backend/src/plugins/Automod/triggers/memberJoin.ts @@ -1,17 +1,14 @@ -import * as t from "io-ts"; -import { convertDelayStringToMS, tDelayString } from "../../../utils"; +import z from "zod"; +import { convertDelayStringToMS, zDelayString } from "../../../utils"; import { automodTrigger } from "../helpers"; -export const MemberJoinTrigger = automodTrigger()({ - configType: t.type({ - only_new: t.boolean, - new_threshold: tDelayString, - }), +const configSchema = z.strictObject({ + only_new: z.boolean().default(false), + new_threshold: zDelayString.default("1h"), +}); - defaultConfig: { - only_new: false, - new_threshold: "1h", - }, +export const MemberJoinTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig }) { if (!context.joined || !context.member) { diff --git a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts index c55c88959..e346a8d34 100644 --- a/backend/src/plugins/Automod/triggers/memberJoinSpam.ts +++ b/backend/src/plugins/Automod/triggers/memberJoinSpam.ts @@ -1,18 +1,18 @@ -import * as t from "io-ts"; -import { convertDelayStringToMS, tDelayString } from "../../../utils"; +import z from "zod"; +import { convertDelayStringToMS, zDelayString } from "../../../utils"; import { RecentActionType } from "../constants"; import { findRecentSpam } from "../functions/findRecentSpam"; import { getMatchingRecentActions } from "../functions/getMatchingRecentActions"; import { sumRecentActionCounts } from "../functions/sumRecentActionCounts"; import { automodTrigger } from "../helpers"; -export const MemberJoinSpamTrigger = automodTrigger()({ - configType: t.type({ - amount: t.number, - within: tDelayString, - }), +const configSchema = z.strictObject({ + amount: z.number().int(), + within: zDelayString, +}); - defaultConfig: {}, +export const MemberJoinSpamTrigger = automodTrigger()({ + configSchema, async match({ pluginData, context, triggerConfig }) { if (!context.joined || !context.member) { diff --git a/backend/src/plugins/Automod/triggers/memberLeave.ts b/backend/src/plugins/Automod/triggers/memberLeave.ts index c5a25033c..fa9f37a8b 100644 --- a/backend/src/plugins/Automod/triggers/memberLeave.ts +++ b/backend/src/plugins/Automod/triggers/memberLeave.ts @@ -1,10 +1,10 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; -export const MemberLeaveTrigger = automodTrigger()({ - configType: t.type({}), +const configSchema = z.strictObject({}); - defaultConfig: {}, +export const MemberLeaveTrigger = automodTrigger()({ + configSchema, async match({ context }) { if (!context.joined || !context.member) { diff --git a/backend/src/plugins/Automod/triggers/mute.ts b/backend/src/plugins/Automod/triggers/mute.ts index 144c30f22..dcaf96c52 100644 --- a/backend/src/plugins/Automod/triggers/mute.ts +++ b/backend/src/plugins/Automod/triggers/mute.ts @@ -1,19 +1,16 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; // tslint:disable-next-line:no-empty-interface interface MuteTriggerResultType {} -export const MuteTrigger = automodTrigger()({ - configType: t.type({ - manual: t.boolean, - automatic: t.boolean, - }), +const configSchema = z.strictObject({ + manual: z.boolean().default(true), + automatic: z.boolean().default(true), +}); - defaultConfig: { - manual: true, - automatic: true, - }, +export const MuteTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig }) { if (context.modAction?.type !== "mute") { diff --git a/backend/src/plugins/Automod/triggers/note.ts b/backend/src/plugins/Automod/triggers/note.ts index c6d11be7d..eeaa2c27d 100644 --- a/backend/src/plugins/Automod/triggers/note.ts +++ b/backend/src/plugins/Automod/triggers/note.ts @@ -1,12 +1,13 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; // tslint:disable-next-line:no-empty-interface interface NoteTriggerResultType {} +const configSchema = z.strictObject({}); + export const NoteTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configSchema, async match({ context }) { if (context.modAction?.type !== "note") { diff --git a/backend/src/plugins/Automod/triggers/roleAdded.ts b/backend/src/plugins/Automod/triggers/roleAdded.ts index dc62f1630..47027b6bc 100644 --- a/backend/src/plugins/Automod/triggers/roleAdded.ts +++ b/backend/src/plugins/Automod/triggers/roleAdded.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; -import * as t from "io-ts"; -import { renderUserUsername } from "../../../utils"; +import z from "zod"; +import { renderUserUsername, zSnowflake } from "../../../utils"; import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges"; import { automodTrigger } from "../helpers"; @@ -8,10 +8,13 @@ interface RoleAddedMatchResult { matchedRoleId: string; } -export const RoleAddedTrigger = automodTrigger()({ - configType: t.union([t.string, t.array(t.string)]), +const configSchema = z.union([ + zSnowflake, + z.array(zSnowflake).max(255), +]).default([]); - defaultConfig: "", +export const RoleAddedTrigger = automodTrigger()({ + configSchema, async match({ triggerConfig, context, pluginData }) { if (!context.member || !context.rolesChanged || context.rolesChanged.added!.length === 0) { diff --git a/backend/src/plugins/Automod/triggers/roleRemoved.ts b/backend/src/plugins/Automod/triggers/roleRemoved.ts index 65624827c..26caf227d 100644 --- a/backend/src/plugins/Automod/triggers/roleRemoved.ts +++ b/backend/src/plugins/Automod/triggers/roleRemoved.ts @@ -1,6 +1,6 @@ import { Snowflake } from "discord.js"; -import * as t from "io-ts"; -import { renderUserUsername } from "../../../utils"; +import z from "zod"; +import { renderUserUsername, zSnowflake } from "../../../utils"; import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges"; import { automodTrigger } from "../helpers"; @@ -8,10 +8,13 @@ interface RoleAddedMatchResult { matchedRoleId: string; } -export const RoleRemovedTrigger = automodTrigger()({ - configType: t.union([t.string, t.array(t.string)]), +const configSchema = z.union([ + zSnowflake, + z.array(zSnowflake).max(255), +]).default([]); - defaultConfig: "", +export const RoleRemovedTrigger = automodTrigger()({ + configSchema, async match({ triggerConfig, context, pluginData }) { if (!context.member || !context.rolesChanged || context.rolesChanged.removed!.length === 0) { diff --git a/backend/src/plugins/Automod/triggers/threadArchive.ts b/backend/src/plugins/Automod/triggers/threadArchive.ts index 0e65b10aa..87cc1b2a5 100644 --- a/backend/src/plugins/Automod/triggers/threadArchive.ts +++ b/backend/src/plugins/Automod/triggers/threadArchive.ts @@ -1,6 +1,5 @@ import { User, escapeBold, type Snowflake } from "discord.js"; -import * as t from "io-ts"; -import { tNullable } from "../../../utils"; +import z from "zod"; import { automodTrigger } from "../helpers"; interface ThreadArchiveResult { @@ -11,12 +10,12 @@ interface ThreadArchiveResult { matchedThreadOwner: User | undefined; } -export const ThreadArchiveTrigger = automodTrigger()({ - configType: t.type({ - locked: tNullable(t.boolean), - }), +const configSchema = z.strictObject({ + locked: z.boolean().optional(), +}); - defaultConfig: {}, +export const ThreadArchiveTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig }) { if (!context.threadChange?.archived) { diff --git a/backend/src/plugins/Automod/triggers/threadCreate.ts b/backend/src/plugins/Automod/triggers/threadCreate.ts index 7b8aca714..ba5553de0 100644 --- a/backend/src/plugins/Automod/triggers/threadCreate.ts +++ b/backend/src/plugins/Automod/triggers/threadCreate.ts @@ -1,5 +1,5 @@ import { User, escapeBold, type Snowflake } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; interface ThreadCreateResult { @@ -10,9 +10,10 @@ interface ThreadCreateResult { matchedThreadOwner: User | undefined; } +const configSchema = z.strictObject({}); + export const ThreadCreateTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configSchema, async match({ context }) { if (!context.threadChange?.created) { diff --git a/backend/src/plugins/Automod/triggers/threadCreateSpam.ts b/backend/src/plugins/Automod/triggers/threadCreateSpam.ts index b1d02f476..a4352c118 100644 --- a/backend/src/plugins/Automod/triggers/threadCreateSpam.ts +++ b/backend/src/plugins/Automod/triggers/threadCreateSpam.ts @@ -1,18 +1,18 @@ -import * as t from "io-ts"; -import { convertDelayStringToMS, tDelayString } from "../../../utils"; +import z from "zod"; +import { convertDelayStringToMS, zDelayString } from "../../../utils"; import { RecentActionType } from "../constants"; import { findRecentSpam } from "../functions/findRecentSpam"; import { getMatchingRecentActions } from "../functions/getMatchingRecentActions"; import { sumRecentActionCounts } from "../functions/sumRecentActionCounts"; import { automodTrigger } from "../helpers"; -export const ThreadCreateSpamTrigger = automodTrigger()({ - configType: t.type({ - amount: t.number, - within: tDelayString, - }), +const configSchema = z.strictObject({ + amount: z.number().int(), + within: zDelayString, +}); - defaultConfig: {}, +export const ThreadCreateSpamTrigger = automodTrigger()({ + configSchema, async match({ pluginData, context, triggerConfig }) { if (!context.threadChange?.created) { diff --git a/backend/src/plugins/Automod/triggers/threadDelete.ts b/backend/src/plugins/Automod/triggers/threadDelete.ts index 489b5b4ca..fc538cf8d 100644 --- a/backend/src/plugins/Automod/triggers/threadDelete.ts +++ b/backend/src/plugins/Automod/triggers/threadDelete.ts @@ -1,5 +1,5 @@ import { User, escapeBold, type Snowflake } from "discord.js"; -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; interface ThreadDeleteResult { @@ -10,9 +10,10 @@ interface ThreadDeleteResult { matchedThreadOwner: User | undefined; } +const configSchema = z.strictObject({}); + export const ThreadDeleteTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configSchema, async match({ context }) { if (!context.threadChange?.deleted) { diff --git a/backend/src/plugins/Automod/triggers/threadUnarchive.ts b/backend/src/plugins/Automod/triggers/threadUnarchive.ts index f6047f483..3d4a3cdf8 100644 --- a/backend/src/plugins/Automod/triggers/threadUnarchive.ts +++ b/backend/src/plugins/Automod/triggers/threadUnarchive.ts @@ -1,6 +1,5 @@ import { User, escapeBold, type Snowflake } from "discord.js"; -import * as t from "io-ts"; -import { tNullable } from "../../../utils"; +import z from "zod"; import { automodTrigger } from "../helpers"; interface ThreadUnarchiveResult { @@ -11,12 +10,12 @@ interface ThreadUnarchiveResult { matchedThreadOwner: User | undefined; } -export const ThreadUnarchiveTrigger = automodTrigger()({ - configType: t.type({ - locked: tNullable(t.boolean), - }), +const configSchema = z.strictObject({ + locked: z.boolean().optional(), +}); - defaultConfig: {}, +export const ThreadUnarchiveTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig }) { if (!context.threadChange?.unarchived) { diff --git a/backend/src/plugins/Automod/triggers/unban.ts b/backend/src/plugins/Automod/triggers/unban.ts index c653f6159..25f24ec95 100644 --- a/backend/src/plugins/Automod/triggers/unban.ts +++ b/backend/src/plugins/Automod/triggers/unban.ts @@ -1,12 +1,13 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; // tslint:disable-next-line:no-empty-interface interface UnbanTriggerResultType {} +const configSchema = z.strictObject({}); + export const UnbanTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configSchema, async match({ context }) { if (context.modAction?.type !== "unban") { diff --git a/backend/src/plugins/Automod/triggers/unmute.ts b/backend/src/plugins/Automod/triggers/unmute.ts index fbd946c6f..f9695ef00 100644 --- a/backend/src/plugins/Automod/triggers/unmute.ts +++ b/backend/src/plugins/Automod/triggers/unmute.ts @@ -1,12 +1,13 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; // tslint:disable-next-line:no-empty-interface interface UnmuteTriggerResultType {} +const configSchema = z.strictObject({}); + export const UnmuteTrigger = automodTrigger()({ - configType: t.type({}), - defaultConfig: {}, + configSchema, async match({ context }) { if (context.modAction?.type !== "unmute") { diff --git a/backend/src/plugins/Automod/triggers/warn.ts b/backend/src/plugins/Automod/triggers/warn.ts index 5c350dad8..3586e82dc 100644 --- a/backend/src/plugins/Automod/triggers/warn.ts +++ b/backend/src/plugins/Automod/triggers/warn.ts @@ -1,19 +1,16 @@ -import * as t from "io-ts"; +import z from "zod"; import { automodTrigger } from "../helpers"; // tslint:disable-next-line:no-empty-interface interface WarnTriggerResultType {} -export const WarnTrigger = automodTrigger()({ - configType: t.type({ - manual: t.boolean, - automatic: t.boolean, - }), +const configSchema = z.strictObject({ + manual: z.boolean().default(true), + automatic: z.boolean().default(true), +}); - defaultConfig: { - manual: true, - automatic: true, - }, +export const WarnTrigger = automodTrigger()({ + configSchema, async match({ context, triggerConfig }) { if (context.modAction?.type !== "warn") { diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index bd3f05ee6..59a09a331 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -1,6 +1,6 @@ import { GuildMember, GuildTextBasedChannel, PartialGuildMember, ThreadChannel, User } from "discord.js"; -import * as t from "io-ts"; import { BasePluginType, CooldownManager } from "knub"; +import z from "zod"; import { Queue } from "../../Queue"; import { RegExpRunner } from "../../RegExpRunner"; import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels"; @@ -8,39 +8,81 @@ import { GuildArchives } from "../../data/GuildArchives"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { SavedMessage } from "../../data/entities/SavedMessage"; -import { tNullable } from "../../utils"; +import { entries, zBoundedRecord, zDelayString } from "../../utils"; import { CounterEvents } from "../Counters/types"; import { ModActionType, ModActionsEvents } from "../ModActions/types"; import { MutesEvents } from "../Mutes/types"; -import { AvailableActions } from "./actions/availableActions"; +import { availableActions } from "./actions/availableActions"; import { RecentActionType } from "./constants"; -import { AvailableTriggers } from "./triggers/availableTriggers"; +import { availableTriggers } from "./triggers/availableTriggers"; import Timeout = NodeJS.Timeout; -export const Rule = t.type({ - enabled: t.boolean, - name: t.string, - presets: tNullable(t.array(t.string)), - affects_bots: t.boolean, - affects_self: t.boolean, - triggers: t.array(t.partial(AvailableTriggers.props)), - actions: t.partial(AvailableActions.props), - cooldown: tNullable(t.string), - allow_further_rules: t.boolean, +export type ZTriggersMapHelper = { + [TriggerName in keyof typeof availableTriggers]: typeof availableTriggers[TriggerName]["configSchema"]; +}; +const zTriggersMap = z.strictObject(entries(availableTriggers).reduce((map, [triggerName, trigger]) => { + map[triggerName] = trigger.configSchema; + return map; +}, {} as ZTriggersMapHelper)).partial(); + +type ZActionsMapHelper = { + [ActionName in keyof typeof availableActions]: typeof availableActions[ActionName]["configSchema"]; +}; +const zActionsMap = z.strictObject(entries(availableActions).reduce((map, [actionName, action]) => { + // @ts-expect-error TS can't infer this properly but it works fine thanks to our helper + map[actionName] = action.configSchema; + return map; +}, {} as ZActionsMapHelper)).partial(); + +const zRule = z.strictObject({ + enabled: z.boolean().default(true), + // Typed as "never" because you are not expected to supply this directly. + // The transform instead picks it up from the property key and the output type is a string. + name: z.never().optional().transform((_, ctx) => { + const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); + if (! ruleName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Automod rules must have names", + }); + return z.NEVER; + } + return ruleName; + }), + presets: z.array(z.string().max(100)).max(25).default([]), + affects_bots: z.boolean().default(false), + affects_self: z.boolean().default(false), + cooldown: zDelayString.nullable().default(null), + allow_further_rules: z.boolean().default(false), + triggers: z.array(zTriggersMap), + actions: zActionsMap.refine( + (v) => ! (v.clean && v.start_thread), + { + message: "Cannot have both clean and start_thread active at the same time", + } + ), }); -export type TRule = t.TypeOf; - -export const ConfigSchema = t.type({ - rules: t.record(t.string, Rule), - antiraid_levels: t.array(t.string), - can_set_antiraid: t.boolean, - can_view_antiraid: t.boolean, +export type TRule = z.infer; + +export const zNotify = z.union([ + z.literal("dm"), + z.literal("channel"), +]); + +export const zAutomodConfig = z.strictObject({ + rules: zBoundedRecord( + z.record(z.string().max(100), zRule), + 0, + 100, + ), + antiraid_levels: z.array(z.string().max(100)).max(10), + can_set_antiraid: z.boolean(), + can_view_antiraid: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface AutomodPluginType extends BasePluginType { - config: TConfigSchema; + config: z.output; customOverrideCriteria: { antiraid_level?: string; diff --git a/backend/src/plugins/BotControl/BotControlPlugin.ts b/backend/src/plugins/BotControl/BotControlPlugin.ts index 76478ca26..653e8deff 100644 --- a/backend/src/plugins/BotControl/BotControlPlugin.ts +++ b/backend/src/plugins/BotControl/BotControlPlugin.ts @@ -3,7 +3,7 @@ import { AllowedGuilds } from "../../data/AllowedGuilds"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments"; import { Configs } from "../../data/Configs"; import { GuildArchives } from "../../data/GuildArchives"; -import { makeIoTsConfigParser, sendSuccessMessage } from "../../pluginUtils"; +import { sendSuccessMessage } from "../../pluginUtils"; import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint"; import { getActiveReload, resetActiveReload } from "./activeReload"; import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd"; @@ -22,7 +22,7 @@ import { ReloadServerCmd } from "./commands/ReloadServerCmd"; import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd"; import { RestPerformanceCmd } from "./commands/RestPerformanceCmd"; import { ServersCmd } from "./commands/ServersCmd"; -import { BotControlPluginType, ConfigSchema } from "./types"; +import { BotControlPluginType, zBotControlConfig } from "./types"; const defaultOptions = { config: { @@ -37,7 +37,7 @@ const defaultOptions = { export const BotControlPlugin = zeppelinGlobalPlugin()({ name: "bot_control", - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zBotControlConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/BotControl/types.ts b/backend/src/plugins/BotControl/types.ts index 6f4139f8a..1c1ccea64 100644 --- a/backend/src/plugins/BotControl/types.ts +++ b/backend/src/plugins/BotControl/types.ts @@ -1,23 +1,22 @@ -import * as t from "io-ts"; import { BasePluginType, globalPluginEventListener, globalPluginMessageCommand } from "knub"; +import z from "zod"; import { AllowedGuilds } from "../../data/AllowedGuilds"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments"; import { Configs } from "../../data/Configs"; import { GuildArchives } from "../../data/GuildArchives"; -import { tNullable } from "../../utils"; +import { zBoundedCharacters } from "../../utils"; -export const ConfigSchema = t.type({ - can_use: t.boolean, - can_eligible: t.boolean, - can_performance: t.boolean, - can_add_server_from_invite: t.boolean, - can_list_dashboard_perms: t.boolean, - update_cmd: tNullable(t.string), +export const zBotControlConfig = z.strictObject({ + can_use: z.boolean(), + can_eligible: z.boolean(), + can_performance: z.boolean(), + can_add_server_from_invite: z.boolean(), + can_list_dashboard_perms: z.boolean(), + update_cmd: zBoundedCharacters(0, 2000).nullable(), }); -export type TConfigSchema = t.TypeOf; export interface BotControlPluginType extends BasePluginType { - config: TConfigSchema; + config: z.output; state: { archives: GuildArchives; allowedGuilds: AllowedGuilds; diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts index 45f63c79d..9e6df53a7 100644 --- a/backend/src/plugins/Cases/CasesPlugin.ts +++ b/backend/src/plugins/Cases/CasesPlugin.ts @@ -3,7 +3,7 @@ import { Case } from "../../data/entities/Case"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; -import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { mapToPublicFn } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { InternalPosterPlugin } from "../InternalPoster/InternalPosterPlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; @@ -16,7 +16,7 @@ import { getCaseTypeAmountForUserId } from "./functions/getCaseTypeAmountForUser import { getRecentCasesByMod } from "./functions/getRecentCasesByMod"; import { getTotalCasesByMod } from "./functions/getTotalCasesByMod"; import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel"; -import { CaseArgs, CaseNoteArgs, CasesPluginType, ConfigSchema } from "./types"; +import { CaseArgs, CaseNoteArgs, CasesPluginType, zCasesConfig } from "./types"; // The `any` cast here is to prevent TypeScript from locking up from the circular dependency function getLogsPlugin(): Promise { @@ -42,11 +42,11 @@ export const CasesPlugin = zeppelinGuildPlugin()({ description: trimPluginDescription(` This plugin contains basic configuration for cases created by other plugins `), - configSchema: ConfigSchema, + configSchema: zCasesConfig, }, dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getLogsPlugin()).LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zCasesConfig.parse(input), defaultOptions, public: { diff --git a/backend/src/plugins/Cases/types.ts b/backend/src/plugins/Cases/types.ts index 9ec1a3713..e25b7ed0c 100644 --- a/backend/src/plugins/Cases/types.ts +++ b/backend/src/plugins/Cases/types.ts @@ -1,24 +1,26 @@ -import * as t from "io-ts"; import { BasePluginType } from "knub"; +import { U } from "ts-toolbelt"; +import z from "zod"; import { CaseNameToType, CaseTypes } from "../../data/CaseTypes"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; -import { tDelayString, tNullable, tPartialDictionary } from "../../utils"; -import { tColor } from "../../utils/tColor"; +import { keys, zBoundedCharacters, zDelayString, zSnowflake } from "../../utils"; +import { zColor } from "../../utils/zColor"; -export const ConfigSchema = t.type({ - log_automatic_actions: t.boolean, - case_log_channel: tNullable(t.string), - show_relative_times: t.boolean, - relative_time_cutoff: tDelayString, - case_colors: tNullable(tPartialDictionary(t.keyof(CaseNameToType), tColor)), - case_icons: tNullable(tPartialDictionary(t.keyof(CaseNameToType), t.string)), +const caseKeys = keys(CaseNameToType) as U.ListOf; + +export const zCasesConfig = z.strictObject({ + log_automatic_actions: z.boolean(), + case_log_channel: zSnowflake.nullable(), + show_relative_times: z.boolean(), + relative_time_cutoff: zDelayString.default("1w"), + case_colors: z.record(z.enum(caseKeys), zColor).nullable(), + case_icons: z.record(z.enum(caseKeys), zBoundedCharacters(0, 32)).nullable(), }); -export type TConfigSchema = t.TypeOf; export interface CasesPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { logs: GuildLogs; cases: GuildCases; diff --git a/backend/src/plugins/Censor/CensorPlugin.ts b/backend/src/plugins/Censor/CensorPlugin.ts index 61991e12c..5764d396e 100644 --- a/backend/src/plugins/Censor/CensorPlugin.ts +++ b/backend/src/plugins/Censor/CensorPlugin.ts @@ -1,12 +1,11 @@ import { PluginOptions } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { trimPluginDescription } from "../../utils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; -import { CensorPluginType, ConfigSchema } from "./types"; +import { CensorPluginType, zCensorConfig } from "./types"; import { onMessageCreate } from "./util/onMessageCreate"; import { onMessageUpdate } from "./util/onMessageUpdate"; @@ -54,11 +53,11 @@ export const CensorPlugin = zeppelinGuildPlugin()({ For more advanced filtering, check out the Automod plugin! `), legacy: true, - configSchema: ConfigSchema, + configSchema: zCensorConfig, }, dependencies: () => [LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zCensorConfig.parse(input), defaultOptions, beforeLoad(pluginData) { diff --git a/backend/src/plugins/Censor/types.ts b/backend/src/plugins/Censor/types.ts index 5a1984cee..c1fcd8df6 100644 --- a/backend/src/plugins/Censor/types.ts +++ b/backend/src/plugins/Censor/types.ts @@ -1,30 +1,28 @@ -import * as t from "io-ts"; import { BasePluginType } from "knub"; +import z from "zod"; import { RegExpRunner } from "../../RegExpRunner"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { tNullable } from "../../utils"; -import { TRegex } from "../../validatorUtils"; +import { zBoundedCharacters, zRegex, zSnowflake } from "../../utils"; -export const ConfigSchema = t.type({ - filter_zalgo: t.boolean, - filter_invites: t.boolean, - invite_guild_whitelist: tNullable(t.array(t.string)), - invite_guild_blacklist: tNullable(t.array(t.string)), - invite_code_whitelist: tNullable(t.array(t.string)), - invite_code_blacklist: tNullable(t.array(t.string)), - allow_group_dm_invites: t.boolean, - filter_domains: t.boolean, - domain_whitelist: tNullable(t.array(t.string)), - domain_blacklist: tNullable(t.array(t.string)), - blocked_tokens: tNullable(t.array(t.string)), - blocked_words: tNullable(t.array(t.string)), - blocked_regex: tNullable(t.array(TRegex)), +export const zCensorConfig = z.strictObject({ + filter_zalgo: z.boolean(), + filter_invites: z.boolean(), + invite_guild_whitelist: z.array(zSnowflake).nullable(), + invite_guild_blacklist: z.array(zSnowflake).nullable(), + invite_code_whitelist: z.array(zBoundedCharacters(0, 16)).nullable(), + invite_code_blacklist: z.array(zBoundedCharacters(0, 16)).nullable(), + allow_group_dm_invites: z.boolean(), + filter_domains: z.boolean(), + domain_whitelist: z.array(zBoundedCharacters(0, 255)).nullable(), + domain_blacklist: z.array(zBoundedCharacters(0, 255)).nullable(), + blocked_tokens: z.array(zBoundedCharacters(0, 2000)).nullable(), + blocked_words: z.array(zBoundedCharacters(0, 2000)).nullable(), + blocked_regex: z.array(zRegex(z.string().max(1000))).nullable(), }); -export type TConfigSchema = t.TypeOf; export interface CensorPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { serverLogs: GuildLogs; savedMessages: GuildSavedMessages; diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts index b44678192..615d3467d 100644 --- a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts +++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts @@ -1,18 +1,15 @@ -import * as t from "io-ts"; -import { makeIoTsConfigParser } from "../../pluginUtils"; +import z from "zod"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd"; import { ChannelArchiverPluginType } from "./types"; -const ConfigSchema = t.type({}); - export const ChannelArchiverPlugin = zeppelinGuildPlugin()({ name: "channel_archiver", showInDocs: false, dependencies: () => [TimeAndDatePlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => z.strictObject({}).parse(input), // prettier-ignore messageCommands: [ diff --git a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts index bd05d8009..4e7987a72 100644 --- a/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts +++ b/backend/src/plugins/CompanionChannels/CompanionChannelsPlugin.ts @@ -1,11 +1,10 @@ import { CooldownManager } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { VoiceStateUpdateEvt } from "./events/VoiceStateUpdateEvt"; -import { CompanionChannelsPluginType, ConfigSchema } from "./types"; +import { CompanionChannelsPluginType, zCompanionChannelsConfig } from "./types"; const defaultOptions = { config: { @@ -23,11 +22,11 @@ export const CompanionChannelsPlugin = zeppelinGuildPlugin [LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zCompanionChannelsConfig.parse(input), defaultOptions, events: [VoiceStateUpdateEvt], diff --git a/backend/src/plugins/CompanionChannels/types.ts b/backend/src/plugins/CompanionChannels/types.ts index 46a8a45f8..7cee4b3ad 100644 --- a/backend/src/plugins/CompanionChannels/types.ts +++ b/backend/src/plugins/CompanionChannels/types.ts @@ -1,28 +1,23 @@ -import * as t from "io-ts"; import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub"; +import z from "zod"; import { GuildLogs } from "../../data/GuildLogs"; -import { tNullable } from "../../utils"; +import { zBoundedCharacters, zSnowflake } from "../../utils"; -// Permissions using these numbers: https://abal.moe/Eris/docs/reference (add all allowed/denied ones up) -export const CompanionChannelOpts = t.type({ - voice_channel_ids: t.array(t.string), - text_channel_ids: t.array(t.string), - permissions: t.number, - enabled: tNullable(t.boolean), +export const zCompanionChannelOpts = z.strictObject({ + voice_channel_ids: z.array(zSnowflake), + text_channel_ids: z.array(zSnowflake), + // See https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags + permissions: z.number(), + enabled: z.boolean().nullable().default(true), }); -export type TCompanionChannelOpts = t.TypeOf; +export type TCompanionChannelOpts = z.infer; -export const ConfigSchema = t.type({ - entries: t.record(t.string, CompanionChannelOpts), +export const zCompanionChannelsConfig = z.strictObject({ + entries: z.record(zBoundedCharacters(0, 100), zCompanionChannelOpts), }); -export type TConfigSchema = t.TypeOf; - -export interface ICompanionChannelMap { - [channelId: string]: TCompanionChannelOpts; -} export interface CompanionChannelsPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { errorCooldownManager: CooldownManager; serverLogs: GuildLogs; diff --git a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts index c41e8c09c..57d1a7631 100644 --- a/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts +++ b/backend/src/plugins/ContextMenus/ContextMenuPlugin.ts @@ -1,12 +1,11 @@ import { PluginOptions } from "knub"; import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { MutesPlugin } from "../Mutes/MutesPlugin"; import { UtilityPlugin } from "../Utility/UtilityPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { ContextClickedEvt } from "./events/ContextClickedEvt"; -import { ConfigSchema, ContextMenuPluginType } from "./types"; +import { ContextMenuPluginType, zContextMenusConfig } from "./types"; import { loadAllCommands } from "./utils/loadAllCommands"; const defaultOptions: PluginOptions = { @@ -37,7 +36,7 @@ export const ContextMenuPlugin = zeppelinGuildPlugin()({ showInDocs: false, dependencies: () => [MutesPlugin, LogsPlugin, UtilityPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zContextMenusConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/ContextMenus/actions/mute.ts b/backend/src/plugins/ContextMenus/actions/mute.ts index 7fe2f5d59..556ee248c 100644 --- a/backend/src/plugins/ContextMenus/actions/mute.ts +++ b/backend/src/plugins/ContextMenus/actions/mute.ts @@ -45,9 +45,9 @@ export async function muteAction( try { const result = await mutes.muteUser(userId, durationMs, "Context Menu Action", { caseArgs }); - const muteMessage = `Muted **${result.case.user_name}** ${ + const muteMessage = `Muted **${result.case!.user_name}** ${ durationMs ? `for ${humanizeDuration(durationMs)}` : "indefinitely" - } (Case #${result.case.case_number}) (user notified via ${ + } (Case #${result.case!.case_number}) (user notified via ${ result.notifyResult.method ?? "dm" })\nPlease update the new case with the \`update\` command`; diff --git a/backend/src/plugins/ContextMenus/types.ts b/backend/src/plugins/ContextMenus/types.ts index 02c4a29c3..5dbcd9ce7 100644 --- a/backend/src/plugins/ContextMenus/types.ts +++ b/backend/src/plugins/ContextMenus/types.ts @@ -1,22 +1,20 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener } from "knub"; +import z from "zod"; import { GuildContextMenuLinks } from "../../data/GuildContextMenuLinks"; -export const ConfigSchema = t.type({ - can_use: t.boolean, - - user_muteindef: t.boolean, - user_mute1d: t.boolean, - user_mute1h: t.boolean, - user_info: t.boolean, - message_clean10: t.boolean, - message_clean25: t.boolean, - message_clean50: t.boolean, +export const zContextMenusConfig = z.strictObject({ + can_use: z.boolean(), + user_muteindef: z.boolean(), + user_mute1d: z.boolean(), + user_mute1h: z.boolean(), + user_info: z.boolean(), + message_clean10: z.boolean(), + message_clean25: z.boolean(), + message_clean50: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface ContextMenuPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { contextMenuLinks: GuildContextMenuLinks; }; diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts index 14e3432eb..85be2cf53 100644 --- a/backend/src/plugins/Counters/CountersPlugin.ts +++ b/backend/src/plugins/Counters/CountersPlugin.ts @@ -1,15 +1,12 @@ import { EventEmitter } from "events"; import { PluginOptions } from "knub"; +import { GuildCounters } from "../../data/GuildCounters"; import { - buildCounterConditionString, CounterTrigger, - getReverseCounterComparisonOp, - parseCounterConditionString, + parseCounterConditionString } from "../../data/entities/CounterTrigger"; -import { GuildCounters } from "../../data/GuildCounters"; import { mapToPublicFn } from "../../pluginUtils"; -import { convertDelayStringToMS, MINUTES } from "../../utils"; -import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils"; +import { MINUTES, convertDelayStringToMS, values } from "../../utils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { AddCounterCmd } from "./commands/AddCounterCmd"; import { CountersListCmd } from "./commands/CountersListCmd"; @@ -25,10 +22,8 @@ import { getPrettyNameForCounterTrigger } from "./functions/getPrettyNameForCoun import { offCounterEvent } from "./functions/offCounterEvent"; import { onCounterEvent } from "./functions/onCounterEvent"; import { setCounterValue } from "./functions/setCounterValue"; -import { ConfigSchema, CountersPluginType, TTrigger } from "./types"; +import { CountersPluginType, zCountersConfig } from "./types"; -const MAX_COUNTERS = 5; -const MAX_TRIGGERS_PER_COUNTER = 5; const DECAY_APPLY_INTERVAL = 5 * MINUTES; const defaultOptions: PluginOptions = { @@ -72,50 +67,12 @@ export const CountersPlugin = zeppelinGuildPlugin()({ description: "Keep track of per-user, per-channel, or global numbers and trigger specific actions based on this number", configurationGuide: "See Counters setup guide", - configSchema: ConfigSchema, + configSchema: zCountersConfig, }, defaultOptions, // TODO: Separate input and output types - configParser: (input) => { - for (const [counterName, counter] of Object.entries((input as any).counters || {})) { - counter.name = counterName; - counter.per_user = counter.per_user ?? false; - counter.per_channel = counter.per_channel ?? false; - counter.initial_value = counter.initial_value ?? 0; - counter.triggers = counter.triggers || {}; - - if (Object.values(counter.triggers).length > MAX_TRIGGERS_PER_COUNTER) { - throw new StrictValidationError([`You can only have at most ${MAX_TRIGGERS_PER_COUNTER} triggers per counter`]); - } - - // Normalize triggers - for (const [triggerName, trigger] of Object.entries(counter.triggers)) { - const triggerObj = (typeof trigger === "string" ? { condition: trigger } : trigger) as Partial; - - triggerObj.name = triggerName; - const parsedCondition = parseCounterConditionString(triggerObj.condition || ""); - if (!parsedCondition) { - throw new StrictValidationError([ - `Invalid comparison in counter trigger ${counterName}/${triggerName}: "${triggerObj.condition}"`, - ]); - } - - triggerObj.condition = buildCounterConditionString(parsedCondition[0], parsedCondition[1]); - triggerObj.reverse_condition = - triggerObj.reverse_condition || - buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]); - - counter.triggers[triggerName] = triggerObj as TTrigger; - } - } - - if (Object.values((input as any).counters || {}).length > MAX_COUNTERS) { - throw new StrictValidationError([`You can only have at most ${MAX_COUNTERS} counters`]); - } - - return parseIoTsSchema(ConfigSchema, input); - }, + configParser: (input) => zCountersConfig.parse(input), public: { counterExists: mapToPublicFn(counterExists), @@ -163,13 +120,12 @@ export const CountersPlugin = zeppelinGuildPlugin()({ state.counterTriggersByCounterId.set(dbCounter.id, thisCounterTriggers); // Initialize triggers - for (const trigger of Object.values(counter.triggers)) { - const theTrigger = trigger as TTrigger; - const parsedCondition = parseCounterConditionString(theTrigger.condition)!; - const parsedReverseCondition = parseCounterConditionString(theTrigger.reverse_condition)!; + for (const trigger of values(counter.triggers)) { + const parsedCondition = parseCounterConditionString(trigger.condition)!; + const parsedReverseCondition = parseCounterConditionString(trigger.reverse_condition)!; const counterTrigger = await state.counters.initCounterTrigger( dbCounter.id, - theTrigger.name, + trigger.name, parsedCondition[0], parsedCondition[1], parsedReverseCondition[0], diff --git a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts index 1445bdd83..5e891c184 100644 --- a/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts +++ b/backend/src/plugins/Counters/functions/getPrettyNameForCounterTrigger.ts @@ -1,5 +1,5 @@ import { GuildPluginData } from "knub"; -import { CountersPluginType, TTrigger } from "../types"; +import { CountersPluginType } from "../types"; export function getPrettyNameForCounterTrigger( pluginData: GuildPluginData, @@ -12,6 +12,6 @@ export function getPrettyNameForCounterTrigger( return "Unknown Counter Trigger"; } - const trigger = counter.triggers[triggerName] as TTrigger | undefined; + const trigger = counter.triggers[triggerName]; return trigger ? trigger.pretty_name || trigger.name : "Unknown Counter Trigger"; } diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts index 4187c9e41..2b8cce16c 100644 --- a/backend/src/plugins/Counters/types.ts +++ b/backend/src/plugins/Counters/types.ts @@ -1,45 +1,98 @@ import { EventEmitter } from "events"; -import * as t from "io-ts"; import { BasePluginType } from "knub"; +import z from "zod"; import { GuildCounters } from "../../data/GuildCounters"; -import { CounterTrigger } from "../../data/entities/CounterTrigger"; -import { tDelayString, tNullable } from "../../utils"; +import { CounterTrigger, buildCounterConditionString, getReverseCounterComparisonOp, parseCounterConditionString } from "../../data/entities/CounterTrigger"; +import { zBoundedCharacters, zBoundedRecord, zDelayString } from "../../utils"; import Timeout = NodeJS.Timeout; -export const Trigger = t.type({ - name: t.string, - pretty_name: tNullable(t.string), - condition: t.string, - reverse_condition: t.string, +const MAX_COUNTERS = 5; +const MAX_TRIGGERS_PER_COUNTER = 5; + +export const zTrigger = z.strictObject({ + // Dummy type because name gets replaced by the property key in zTriggerInput + name: z.never().optional().transform(() => ""), + pretty_name: zBoundedCharacters(0, 100).nullable().default(null), + condition: zBoundedCharacters(1, 64).refine( + (str) => parseCounterConditionString(str) !== null, + { message: "Invalid counter trigger condition" }, + ), + reverse_condition: zBoundedCharacters(1, 64).refine( + (str) => parseCounterConditionString(str) !== null, + { message: "Invalid counter trigger reverse condition" }, + ), }); -export type TTrigger = t.TypeOf; - -export const Counter = t.type({ - name: t.string, - pretty_name: tNullable(t.string), - per_channel: t.boolean, - per_user: t.boolean, - initial_value: t.number, - triggers: t.record(t.string, t.union([t.string, Trigger])), - decay: tNullable( - t.type({ - amount: t.number, - every: tDelayString, - }), + +const zTriggerInput = z.union([zBoundedCharacters(0, 100), zTrigger]) + .transform((val, ctx) => { + const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); + if (typeof val === "string") { + const parsedCondition = parseCounterConditionString(val); + if (!parsedCondition) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid counter trigger condition", + }); + return z.NEVER; + } + return { + name: ruleName, + pretty_name: null, + condition: buildCounterConditionString(parsedCondition[0], parsedCondition[1]), + reverse_condition: buildCounterConditionString(getReverseCounterComparisonOp(parsedCondition[0]), parsedCondition[1]), + }; + } + return { + ...val, + name: ruleName, + }; + }); + +export const zCounter = z.strictObject({ + // Typed as "never" because you are not expected to supply this directly. + // The transform instead picks it up from the property key and the output type is a string. + name: z.never().optional().transform((_, ctx) => { + const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); + if (! ruleName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Counters must have names", + }); + return z.NEVER; + } + return ruleName; + }), + pretty_name: zBoundedCharacters(0, 100).nullable().default(null), + per_channel: z.boolean().default(false), + per_user: z.boolean().default(false), + initial_value: z.number().default(0), + triggers: zBoundedRecord( + z.record( + zBoundedCharacters(0, 100), + zTriggerInput, + ), + 1, + MAX_TRIGGERS_PER_COUNTER, ), - can_view: tNullable(t.boolean), - can_edit: tNullable(t.boolean), - can_reset_all: tNullable(t.boolean), + decay: z.strictObject({ + amount: z.number(), + every: zDelayString, + }).nullable().default(null), + can_view: z.boolean(), + can_edit: z.boolean(), + can_reset_all: z.boolean(), }); -export type TCounter = t.TypeOf; -export const ConfigSchema = t.type({ - counters: t.record(t.string, Counter), - can_view: t.boolean, - can_edit: t.boolean, - can_reset_all: t.boolean, +export const zCountersConfig = z.strictObject({ + counters: zBoundedRecord( + z.record(zBoundedCharacters(0, 100), zCounter), + 0, + MAX_COUNTERS, + ), + can_view: z.boolean(), + can_edit: z.boolean(), + can_reset_all: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface CounterEvents { trigger: (counterName: string, triggerName: string, channelId: string | null, userId: string | null) => void; @@ -52,7 +105,7 @@ export interface CounterEventEmitter extends EventEmitter { } export interface CountersPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { counters: GuildCounters; counterIds: Record; diff --git a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts index 6d5ba2ba6..e17cd96bc 100644 --- a/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts +++ b/backend/src/plugins/CustomEvents/CustomEventsPlugin.ts @@ -2,7 +2,6 @@ import { GuildChannel, GuildMember, User } from "discord.js"; import { guildPluginMessageCommand, parseSignature } from "knub"; import { TSignature } from "knub-command-manager"; import { commandTypes } from "../../commandTypes"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { TemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter"; import { UnknownUser } from "../../utils"; import { isScalar } from "../../utils/isScalar"; @@ -14,7 +13,7 @@ import { } from "../../utils/templateSafeObjects"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { runEvent } from "./functions/runEvent"; -import { ConfigSchema, CustomEventsPluginType } from "./types"; +import { CustomEventsPluginType, zCustomEventsConfig } from "./types"; const defaultOptions = { config: { @@ -26,7 +25,7 @@ export const CustomEventsPlugin = zeppelinGuildPlugin()( name: "custom_events", showInDocs: false, - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zCustomEventsConfig.parse(input), defaultOptions, afterLoad(pluginData) { diff --git a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts index 29ded8572..f08709585 100644 --- a/backend/src/plugins/CustomEvents/actions/addRoleAction.ts +++ b/backend/src/plugins/CustomEvents/actions/addRoleAction.ts @@ -1,17 +1,17 @@ -import * as t from "io-ts"; import { GuildPluginData } from "knub"; +import z from "zod"; import { canActOn } from "../../../pluginUtils"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; -import { resolveMember } from "../../../utils"; +import { resolveMember, zSnowflake } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; -export const AddRoleAction = t.type({ - type: t.literal("add_role"), - target: t.string, - role: t.union([t.string, t.array(t.string)]), +export const zAddRoleAction = z.strictObject({ + type: z.literal("add_role"), + target: zSnowflake, + role: z.union([zSnowflake, z.array(zSnowflake)]), }); -export type TAddRoleAction = t.TypeOf; +export type TAddRoleAction = z.infer; export async function addRoleAction( pluginData: GuildPluginData, diff --git a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts index d8d766b58..e894a4461 100644 --- a/backend/src/plugins/CustomEvents/actions/createCaseAction.ts +++ b/backend/src/plugins/CustomEvents/actions/createCaseAction.ts @@ -1,19 +1,20 @@ -import * as t from "io-ts"; import { GuildPluginData } from "knub"; +import z from "zod"; import { CaseTypes } from "../../../data/CaseTypes"; import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter"; +import { zBoundedCharacters, zSnowflake } from "../../../utils"; import { CasesPlugin } from "../../Cases/CasesPlugin"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; -export const CreateCaseAction = t.type({ - type: t.literal("create_case"), - case_type: t.string, - mod: t.string, - target: t.string, - reason: t.string, +export const zCreateCaseAction = z.strictObject({ + type: z.literal("create_case"), + case_type: zBoundedCharacters(0, 32), + mod: zSnowflake, + target: zSnowflake, + reason: zBoundedCharacters(0, 4000), }); -export type TCreateCaseAction = t.TypeOf; +export type TCreateCaseAction = z.infer; export async function createCaseAction( pluginData: GuildPluginData, @@ -32,7 +33,7 @@ export async function createCaseAction( } const casesPlugin = pluginData.getPlugin(CasesPlugin); - await casesPlugin.createCase({ + await casesPlugin!.createCase({ userId: targetId, modId, type: CaseTypes[action.case_type], diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts index bf3144291..7727e3395 100644 --- a/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts +++ b/backend/src/plugins/CustomEvents/actions/makeRoleMentionableAction.ts @@ -1,17 +1,17 @@ import { Snowflake } from "discord.js"; -import * as t from "io-ts"; import { GuildPluginData } from "knub"; +import z from "zod"; import { TemplateSafeValueContainer } from "../../../templateFormatter"; -import { convertDelayStringToMS, noop, tDelayString } from "../../../utils"; +import { convertDelayStringToMS, noop, zDelayString, zSnowflake } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; -export const MakeRoleMentionableAction = t.type({ - type: t.literal("make_role_mentionable"), - role: t.string, - timeout: tDelayString, +export const zMakeRoleMentionableAction = z.strictObject({ + type: z.literal("make_role_mentionable"), + role: zSnowflake, + timeout: zDelayString, }); -export type TMakeRoleMentionableAction = t.TypeOf; +export type TMakeRoleMentionableAction = z.infer; export async function makeRoleMentionableAction( pluginData: GuildPluginData, diff --git a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts index e86d03b56..8dca7323f 100644 --- a/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts +++ b/backend/src/plugins/CustomEvents/actions/makeRoleUnmentionableAction.ts @@ -1,15 +1,16 @@ import { Snowflake } from "discord.js"; -import * as t from "io-ts"; import { GuildPluginData } from "knub"; +import z from "zod"; import { TemplateSafeValueContainer } from "../../../templateFormatter"; +import { zSnowflake } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; -export const MakeRoleUnmentionableAction = t.type({ - type: t.literal("make_role_unmentionable"), - role: t.string, +export const zMakeRoleUnmentionableAction = z.strictObject({ + type: z.literal("make_role_unmentionable"), + role: zSnowflake, }); -export type TMakeRoleUnmentionableAction = t.TypeOf; +export type TMakeRoleUnmentionableAction = z.infer; export async function makeRoleUnmentionableAction( pluginData: GuildPluginData, diff --git a/backend/src/plugins/CustomEvents/actions/messageAction.ts b/backend/src/plugins/CustomEvents/actions/messageAction.ts index f06534ad0..40eee4b81 100644 --- a/backend/src/plugins/CustomEvents/actions/messageAction.ts +++ b/backend/src/plugins/CustomEvents/actions/messageAction.ts @@ -1,16 +1,17 @@ import { Snowflake, TextChannel } from "discord.js"; -import * as t from "io-ts"; import { GuildPluginData } from "knub"; +import z from "zod"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter"; +import { zBoundedCharacters, zSnowflake } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType } from "../types"; -export const MessageAction = t.type({ - type: t.literal("message"), - channel: t.string, - content: t.string, +export const zMessageAction = z.strictObject({ + type: z.literal("message"), + channel: zSnowflake, + content: zBoundedCharacters(0, 4000), }); -export type TMessageAction = t.TypeOf; +export type TMessageAction = z.infer; export async function messageAction( pluginData: GuildPluginData, diff --git a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts index 8ef746cde..d42059f52 100644 --- a/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts +++ b/backend/src/plugins/CustomEvents/actions/moveToVoiceChannelAction.ts @@ -1,18 +1,18 @@ import { Snowflake, VoiceChannel } from "discord.js"; -import * as t from "io-ts"; import { GuildPluginData } from "knub"; +import z from "zod"; import { canActOn } from "../../../pluginUtils"; import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter"; -import { resolveMember } from "../../../utils"; +import { resolveMember, zSnowflake } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; -export const MoveToVoiceChannelAction = t.type({ - type: t.literal("move_to_vc"), - target: t.string, - channel: t.string, +export const zMoveToVoiceChannelAction = z.strictObject({ + type: z.literal("move_to_vc"), + target: zSnowflake, + channel: zSnowflake, }); -export type TMoveToVoiceChannelAction = t.TypeOf; +export type TMoveToVoiceChannelAction = z.infer; export async function moveToVoiceChannelAction( pluginData: GuildPluginData, diff --git a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts index 6bae4c357..dbd7b932d 100644 --- a/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts +++ b/backend/src/plugins/CustomEvents/actions/setChannelPermissionOverrides.ts @@ -1,23 +1,24 @@ import { PermissionsBitField, PermissionsString, Snowflake } from "discord.js"; -import * as t from "io-ts"; import { GuildPluginData } from "knub"; +import z from "zod"; import { TemplateSafeValueContainer } from "../../../templateFormatter"; +import { zSnowflake } from "../../../utils"; import { ActionError } from "../ActionError"; import { CustomEventsPluginType, TCustomEvent } from "../types"; -export const SetChannelPermissionOverridesAction = t.type({ - type: t.literal("set_channel_permission_overrides"), - channel: t.string, - overrides: t.array( - t.type({ - type: t.union([t.literal("member"), t.literal("role")]), - id: t.string, - allow: t.number, - deny: t.number, +export const zSetChannelPermissionOverridesAction = z.strictObject({ + type: z.literal("set_channel_permission_overrides"), + channel: zSnowflake, + overrides: z.array( + z.strictObject({ + type: z.union([z.literal("member"), z.literal("role")]), + id: zSnowflake, + allow: z.number(), + deny: z.number(), }), - ), + ).max(15), }); -export type TSetChannelPermissionOverridesAction = t.TypeOf; +export type TSetChannelPermissionOverridesAction = z.infer; export async function setChannelPermissionOverridesAction( pluginData: GuildPluginData, diff --git a/backend/src/plugins/CustomEvents/types.ts b/backend/src/plugins/CustomEvents/types.ts index e273e7b6e..4572b1cf4 100644 --- a/backend/src/plugins/CustomEvents/types.ts +++ b/backend/src/plugins/CustomEvents/types.ts @@ -1,47 +1,50 @@ -import * as t from "io-ts"; import { BasePluginType } from "knub"; -import { AddRoleAction } from "./actions/addRoleAction"; -import { CreateCaseAction } from "./actions/createCaseAction"; -import { MakeRoleMentionableAction } from "./actions/makeRoleMentionableAction"; -import { MakeRoleUnmentionableAction } from "./actions/makeRoleUnmentionableAction"; -import { MessageAction } from "./actions/messageAction"; -import { MoveToVoiceChannelAction } from "./actions/moveToVoiceChannelAction"; -import { SetChannelPermissionOverridesAction } from "./actions/setChannelPermissionOverrides"; +import z from "zod"; +import { zBoundedCharacters, zBoundedRecord } from "../../utils"; +import { zAddRoleAction } from "./actions/addRoleAction"; +import { zCreateCaseAction } from "./actions/createCaseAction"; +import { zMakeRoleMentionableAction } from "./actions/makeRoleMentionableAction"; +import { zMakeRoleUnmentionableAction } from "./actions/makeRoleUnmentionableAction"; +import { zMessageAction } from "./actions/messageAction"; +import { zMoveToVoiceChannelAction } from "./actions/moveToVoiceChannelAction"; +import { zSetChannelPermissionOverridesAction } from "./actions/setChannelPermissionOverrides"; -// Triggers -const CommandTrigger = t.type({ - type: t.literal("command"), - name: t.string, - params: t.string, - can_use: t.boolean, +const zCommandTrigger = z.strictObject({ + type: z.literal("command"), + name: zBoundedCharacters(0, 100), + params: zBoundedCharacters(0, 255), + can_use: z.boolean(), }); -const AnyTrigger = CommandTrigger; // TODO: Make into a union once we have more triggers +const zAnyTrigger = zCommandTrigger; // TODO: Make into a union once we have more triggers -const AnyAction = t.union([ - AddRoleAction, - CreateCaseAction, - MoveToVoiceChannelAction, - MessageAction, - MakeRoleMentionableAction, - MakeRoleUnmentionableAction, - SetChannelPermissionOverridesAction, +const zAnyAction = z.union([ + zAddRoleAction, + zCreateCaseAction, + zMoveToVoiceChannelAction, + zMessageAction, + zMakeRoleMentionableAction, + zMakeRoleUnmentionableAction, + zSetChannelPermissionOverridesAction, ]); -export const CustomEvent = t.type({ - name: t.string, - trigger: AnyTrigger, - actions: t.array(AnyAction), +export const zCustomEvent = z.strictObject({ + name: zBoundedCharacters(0, 100), + trigger: zAnyTrigger, + actions: z.array(zAnyAction).max(10), }); -export type TCustomEvent = t.TypeOf; +export type TCustomEvent = z.infer; -export const ConfigSchema = t.type({ - events: t.record(t.string, CustomEvent), +export const zCustomEventsConfig = z.strictObject({ + events: zBoundedRecord( + z.record(zBoundedCharacters(0, 100), zCustomEvent), + 0, + 100, + ), }); -export type TConfigSchema = t.TypeOf; export interface CustomEventsPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { clearTriggers: () => void; }; diff --git a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts index c4f7eaaf3..b8639134d 100644 --- a/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts +++ b/backend/src/plugins/GuildAccessMonitor/GuildAccessMonitorPlugin.ts @@ -1,11 +1,10 @@ import { Guild } from "discord.js"; -import * as t from "io-ts"; import { BasePluginType, GlobalPluginData, globalPluginEventListener } from "knub"; import { AllowedGuilds } from "../../data/AllowedGuilds"; import { Configs } from "../../data/Configs"; import { env } from "../../env"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint"; +import z from "zod"; interface GuildAccessMonitorPluginType extends BasePluginType { state: { @@ -26,7 +25,7 @@ async function checkGuild(pluginData: GlobalPluginData()({ name: "guild_access_monitor", - configParser: makeIoTsConfigParser(t.type({})), + configParser: (input) => z.strictObject({}).parse(input), events: [ globalPluginEventListener()({ diff --git a/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts b/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts index 5ae04fb5c..6fdc467ac 100644 --- a/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts +++ b/backend/src/plugins/GuildConfigReloader/GuildConfigReloaderPlugin.ts @@ -1,6 +1,5 @@ -import * as t from "io-ts"; +import z from "zod"; import { Configs } from "../../data/Configs"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { zeppelinGlobalPlugin } from "../ZeppelinPluginBlueprint"; import { reloadChangedGuilds } from "./functions/reloadChangedGuilds"; import { GuildConfigReloaderPluginType } from "./types"; @@ -9,7 +8,7 @@ export const GuildConfigReloaderPlugin = zeppelinGlobalPlugin z.strictObject({}).parse(input), async beforeLoad(pluginData) { const { state } = pluginData; diff --git a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts index 687175f17..a8d71e06c 100644 --- a/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts +++ b/backend/src/plugins/GuildInfoSaver/GuildInfoSaverPlugin.ts @@ -1,18 +1,17 @@ import { Guild } from "discord.js"; -import * as t from "io-ts"; import { guildPluginEventListener } from "knub"; import { AllowedGuilds } from "../../data/AllowedGuilds"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { MINUTES } from "../../utils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { GuildInfoSaverPluginType } from "./types"; +import z from "zod"; export const GuildInfoSaverPlugin = zeppelinGuildPlugin()({ name: "guild_info_saver", showInDocs: false, - configParser: makeIoTsConfigParser(t.type({})), + configParser: (input) => z.strictObject({}).parse(input), events: [ guildPluginEventListener({ diff --git a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts index d29b342db..293d582e0 100644 --- a/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts +++ b/backend/src/plugins/GuildMemberCache/GuildMemberCachePlugin.ts @@ -1,6 +1,6 @@ -import * as t from "io-ts"; +import z from "zod"; import { GuildMemberCache } from "../../data/GuildMemberCache"; -import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { mapToPublicFn } from "../../pluginUtils"; import { SECONDS } from "../../utils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { cancelDeletionOnMemberJoin } from "./events/cancelDeletionOnMemberJoin"; @@ -18,7 +18,7 @@ export const GuildMemberCachePlugin = zeppelinGuildPlugin z.strictObject({}).parse(input), events: [ updateMemberCacheOnMemberUpdate, diff --git a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts index 3fbcbbf8b..9365109ed 100644 --- a/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts +++ b/backend/src/plugins/InternalPoster/InternalPosterPlugin.ts @@ -1,11 +1,12 @@ import { PluginOptions } from "knub"; +import z from "zod"; import { Queue } from "../../Queue"; import { Webhooks } from "../../data/Webhooks"; -import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { mapToPublicFn } from "../../pluginUtils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { editMessage } from "./functions/editMessage"; import { sendMessage } from "./functions/sendMessage"; -import { ConfigSchema, InternalPosterPluginType } from "./types"; +import { InternalPosterPluginType } from "./types"; const defaultOptions: PluginOptions = { config: {}, @@ -16,7 +17,7 @@ export const InternalPosterPlugin = zeppelinGuildPlugin z.strictObject({}).parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/InternalPoster/types.ts b/backend/src/plugins/InternalPoster/types.ts index e5f1a1a35..78dabd42b 100644 --- a/backend/src/plugins/InternalPoster/types.ts +++ b/backend/src/plugins/InternalPoster/types.ts @@ -1,15 +1,9 @@ import { WebhookClient } from "discord.js"; -import * as t from "io-ts"; import { BasePluginType } from "knub"; import { Queue } from "../../Queue"; import { Webhooks } from "../../data/Webhooks"; -export const ConfigSchema = t.type({}); -export type TConfigSchema = t.TypeOf; - export interface InternalPosterPluginType extends BasePluginType { - config: TConfigSchema; - state: { queue: Queue; webhooks: Webhooks; diff --git a/backend/src/plugins/LocateUser/LocateUserPlugin.ts b/backend/src/plugins/LocateUser/LocateUserPlugin.ts index 51d479452..a99dc84cb 100644 --- a/backend/src/plugins/LocateUser/LocateUserPlugin.ts +++ b/backend/src/plugins/LocateUser/LocateUserPlugin.ts @@ -1,7 +1,6 @@ import { PluginOptions } from "knub"; import { onGuildEvent } from "../../data/GuildEvents"; import { GuildVCAlerts } from "../../data/GuildVCAlerts"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { FollowCmd } from "./commands/FollowCmd"; @@ -9,7 +8,7 @@ import { DeleteFollowCmd, ListFollowCmd } from "./commands/ListFollowCmd"; import { WhereCmd } from "./commands/WhereCmd"; import { GuildBanRemoveAlertsEvt } from "./events/BanRemoveAlertsEvt"; import { VoiceStateUpdateAlertEvt } from "./events/SendAlertsEvts"; -import { ConfigSchema, LocateUserPluginType } from "./types"; +import { LocateUserPluginType, zLocateUserConfig } from "./types"; import { clearExpiredAlert } from "./utils/clearExpiredAlert"; import { fillActiveAlertsList } from "./utils/fillAlertsList"; @@ -39,10 +38,10 @@ export const LocateUserPlugin = zeppelinGuildPlugin()({ * Instantly receive an invite to the voice channel of a user * Be notified as soon as a user switches or joins a voice channel `), - configSchema: ConfigSchema, + configSchema: zLocateUserConfig, }, - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zLocateUserConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/LocateUser/types.ts b/backend/src/plugins/LocateUser/types.ts index 1bfb063e1..4139f4656 100644 --- a/backend/src/plugins/LocateUser/types.ts +++ b/backend/src/plugins/LocateUser/types.ts @@ -1,15 +1,14 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildVCAlerts } from "../../data/GuildVCAlerts"; -export const ConfigSchema = t.type({ - can_where: t.boolean, - can_alert: t.boolean, +export const zLocateUserConfig = z.strictObject({ + can_where: z.boolean(), + can_alert: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface LocateUserPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { alerts: GuildVCAlerts; usersWithAlerts: string[]; diff --git a/backend/src/plugins/Logs/LogsPlugin.ts b/backend/src/plugins/Logs/LogsPlugin.ts index e56ba756e..923ac6232 100644 --- a/backend/src/plugins/Logs/LogsPlugin.ts +++ b/backend/src/plugins/Logs/LogsPlugin.ts @@ -6,7 +6,7 @@ import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { LogType } from "../../data/LogType"; import { logger } from "../../logger"; -import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { mapToPublicFn } from "../../pluginUtils"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { TypedTemplateSafeValueContainer, createTypedTemplateSafeValueContainer } from "../../templateFormatter"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; @@ -31,7 +31,7 @@ import { import { LogsThreadCreateEvt, LogsThreadDeleteEvt, LogsThreadUpdateEvt } from "./events/LogsThreadModifyEvts"; import { LogsGuildMemberUpdateEvt } from "./events/LogsUserUpdateEvts"; import { LogsVoiceStateUpdateEvt } from "./events/LogsVoiceChannelEvts"; -import { ConfigSchema, FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel } from "./types"; +import { FORMAT_NO_TIMESTAMP, ILogTypeData, LogsPluginType, TLogChannel, zLogsConfig } from "./types"; import { getLogMessage } from "./util/getLogMessage"; import { log } from "./util/log"; import { onMessageDelete } from "./util/onMessageDelete"; @@ -110,7 +110,6 @@ import { logVoiceChannelForceMove } from "./logFunctions/logVoiceChannelForceMov import { logVoiceChannelJoin } from "./logFunctions/logVoiceChannelJoin"; import { logVoiceChannelLeave } from "./logFunctions/logVoiceChannelLeave"; import { logVoiceChannelMove } from "./logFunctions/logVoiceChannelMove"; -import { asBoundedString } from "../../utils/iotsUtils"; // The `any` cast here is to prevent TypeScript from locking up from the circular dependency function getCasesPlugin(): Promise { @@ -121,12 +120,12 @@ const defaultOptions: PluginOptions = { config: { channels: {}, format: { - timestamp: asBoundedString(FORMAT_NO_TIMESTAMP), // Legacy/deprecated, use timestamp_format below instead + timestamp: FORMAT_NO_TIMESTAMP, ...DefaultLogMessages, }, - ping_user: true, // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true + ping_user: true, allow_user_mentions: false, - timestamp_format: asBoundedString("[]"), + timestamp_format: "[]", include_embed_timestamp: true, }, @@ -134,7 +133,8 @@ const defaultOptions: PluginOptions = { { level: ">=50", config: { - ping_user: false, // Legacy/deprecated, read comment on global ping_user option + // Legacy/deprecated, read comment on global ping_user option + ping_user: false, }, }, ], @@ -145,11 +145,11 @@ export const LogsPlugin = zeppelinGuildPlugin()({ showInDocs: true, info: { prettyName: "Logs", - configSchema: ConfigSchema, + configSchema: zLogsConfig, }, dependencies: async () => [TimeAndDatePlugin, InternalPosterPlugin, (await getCasesPlugin()).CasesPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zLogsConfig.parse(input), defaultOptions, events: [ diff --git a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts index 0596869c2..25c8c3407 100644 --- a/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts +++ b/backend/src/plugins/Logs/logFunctions/logMessageDelete.ts @@ -32,7 +32,7 @@ export function logMessageDelete(pluginData: GuildPluginData, da // See comment on FORMAT_NO_TIMESTAMP in types.ts const config = pluginData.config.get(); const timestampFormat = - (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format; + (config.format.timestamp !== FORMAT_NO_TIMESTAMP ? config.format.timestamp : null) ?? config.timestamp_format ?? undefined; return log( pluginData, diff --git a/backend/src/plugins/Logs/types.ts b/backend/src/plugins/Logs/types.ts index 759f680d2..51f67b872 100644 --- a/backend/src/plugins/Logs/types.ts +++ b/backend/src/plugins/Logs/types.ts @@ -1,4 +1,3 @@ -import * as t from "io-ts"; import { BasePluginType, CooldownManager, guildPluginEventListener } from "knub"; import { z } from "zod"; import { RegExpRunner } from "../../RegExpRunner"; @@ -7,7 +6,7 @@ import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { LogType } from "../../data/LogType"; -import { tMessageContent, tNullable } from "../../utils"; +import { zBoundedCharacters, zMessageContent, zRegex, zSnowflake } from "../../utils"; import { MessageBuffer } from "../../utils/MessageBuffer"; import { TemplateSafeCase, @@ -22,55 +21,56 @@ import { TemplateSafeUnknownUser, TemplateSafeUser, } from "../../utils/templateSafeObjects"; -import { TRegex } from "../../validatorUtils"; -import { tBoundedString } from "../../utils/iotsUtils"; - -export const tLogFormats = t.record(t.string, t.union([t.string, tMessageContent])); -export type TLogFormats = t.TypeOf; - -const LogChannel = t.partial({ - include: t.array(t.string), - exclude: t.array(t.string), - batched: t.boolean, - batch_time: t.number, - excluded_users: t.array(t.string), - excluded_message_regexes: t.array(TRegex), - excluded_channels: t.array(t.string), - excluded_categories: t.array(t.string), - excluded_threads: t.array(t.string), - exclude_bots: t.boolean, - excluded_roles: t.array(t.string), - format: tNullable(tLogFormats), - timestamp_format: t.string, - include_embed_timestamp: t.boolean, + +const DEFAULT_BATCH_TIME = 1000; +const MIN_BATCH_TIME = 250; +const MAX_BATCH_TIME = 5000; + +export const zLogFormats = z.record( + zBoundedCharacters(1, 255), + zMessageContent, +); +export type TLogFormats = z.infer; + +const zLogChannel = z.strictObject({ + include: z.array(zBoundedCharacters(1, 255)).default([]), + exclude: z.array(zBoundedCharacters(1, 255)).default([]), + batched: z.boolean().default(true), + batch_time: z.number().min(MIN_BATCH_TIME).max(MAX_BATCH_TIME).default(DEFAULT_BATCH_TIME), + excluded_users: z.array(zSnowflake).nullable().default(null), + excluded_message_regexes: z.array(zRegex(z.string())).nullable().default(null), + excluded_channels: z.array(zSnowflake).nullable().default(null), + excluded_categories: z.array(zSnowflake).nullable().default(null), + excluded_threads: z.array(zSnowflake).nullable().default(null), + exclude_bots: z.boolean().default(false), + excluded_roles: z.array(zSnowflake).nullable().default(null), + format: zLogFormats.default({}), + timestamp_format: z.string().nullable().default(null), + include_embed_timestamp: z.boolean().nullable().default(null), }); -export type TLogChannel = t.TypeOf; - -const LogChannelMap = t.record(t.string, LogChannel); -export type TLogChannelMap = t.TypeOf; - -export const ConfigSchema = t.type({ - channels: LogChannelMap, - format: t.intersection([ - tLogFormats, - t.type({ - timestamp: tBoundedString(0, 64), // Legacy/deprecated - }), - ]), - ping_user: t.boolean, // Legacy/deprecated, if below is false mentions wont actually ping - allow_user_mentions: t.boolean, - timestamp_format: tBoundedString(0, 64), - include_embed_timestamp: t.boolean, +export type TLogChannel = z.infer; + +const zLogChannelMap = z.record(zSnowflake, zLogChannel); +export type TLogChannelMap = z.infer; + +export const zLogsConfig = z.strictObject({ + channels: zLogChannelMap, + format: z.intersection(zLogFormats, z.strictObject({ + // Legacy/deprecated, use timestamp_format below instead + timestamp: zBoundedCharacters(0, 64).nullable(), + })), + // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true + ping_user: z.boolean(), + allow_user_mentions: z.boolean(), + timestamp_format: z.string().nullable(), + include_embed_timestamp: z.boolean(), }); -export type TConfigSchema = t.TypeOf; -// Hacky way of allowing a """null""" default value for config.format.timestamp -// The type cannot be made nullable properly because io-ts's intersection type still considers -// that it has to match the record type of tLogFormats, which includes string. +// Hacky way of allowing a """null""" default value for config.format.timestamp due to legacy io-ts reasons export const FORMAT_NO_TIMESTAMP = "__NO_TIMESTAMP__"; export interface LogsPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { guildLogs: GuildLogs; savedMessages: GuildSavedMessages; diff --git a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts index 9e775251e..0b140cd7a 100644 --- a/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts +++ b/backend/src/plugins/MessageSaver/MessageSaverPlugin.ts @@ -1,11 +1,10 @@ import { PluginOptions } from "knub"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { SaveMessagesToDBCmd } from "./commands/SaveMessagesToDB"; import { SavePinsToDBCmd } from "./commands/SavePinsToDB"; import { MessageCreateEvt, MessageDeleteBulkEvt, MessageDeleteEvt, MessageUpdateEvt } from "./events/SaveMessagesEvts"; -import { ConfigSchema, MessageSaverPluginType } from "./types"; +import { MessageSaverPluginType, zMessageSaverConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -25,7 +24,7 @@ export const MessageSaverPlugin = zeppelinGuildPlugin()( name: "message_saver", showInDocs: false, - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zMessageSaverConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/MessageSaver/types.ts b/backend/src/plugins/MessageSaver/types.ts index 2fdef6653..f42fa2c3e 100644 --- a/backend/src/plugins/MessageSaver/types.ts +++ b/backend/src/plugins/MessageSaver/types.ts @@ -1,14 +1,13 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -export const ConfigSchema = t.type({ - can_manage: t.boolean, +export const zMessageSaverConfig = z.strictObject({ + can_manage: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface MessageSaverPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { savedMessages: GuildSavedMessages; }; diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index bf83b13e3..4c9c89587 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -6,7 +6,7 @@ import { onGuildEvent } from "../../data/GuildEvents"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildMutes } from "../../data/GuildMutes"; import { GuildTempbans } from "../../data/GuildTempbans"; -import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { mapToPublicFn } from "../../pluginUtils"; import { MINUTES, trimPluginDescription } from "../../utils"; import { CasesPlugin } from "../Cases/CasesPlugin"; import { LogsPlugin } from "../Logs/LogsPlugin"; @@ -47,7 +47,7 @@ import { offModActionsEvent } from "./functions/offModActionsEvent"; import { onModActionsEvent } from "./functions/onModActionsEvent"; import { updateCase } from "./functions/updateCase"; import { warnMember } from "./functions/warnMember"; -import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types"; +import { BanOptions, KickOptions, ModActionsPluginType, WarnOptions, zModActionsConfig } from "./types"; const defaultOptions = { config: { @@ -121,11 +121,11 @@ export const ModActionsPlugin = zeppelinGuildPlugin()({ description: trimPluginDescription(` This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc. `), - configSchema: ConfigSchema, + configSchema: zModActionsConfig, }, dependencies: () => [TimeAndDatePlugin, CasesPlugin, MutesPlugin, LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zModActionsConfig.parse(input), defaultOptions, events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, PostAlertOnMemberJoinEvt, AuditLogEvents], diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index 447b96382..fbe7890a7 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -1,51 +1,50 @@ import { GuildTextBasedChannel } from "discord.js"; import { EventEmitter } from "events"; -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { Queue } from "../../Queue"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildMutes } from "../../data/GuildMutes"; import { GuildTempbans } from "../../data/GuildTempbans"; import { Case } from "../../data/entities/Case"; -import { UserNotificationMethod, UserNotificationResult, tNullable } from "../../utils"; +import { UserNotificationMethod, UserNotificationResult } from "../../utils"; import { CaseArgs } from "../Cases/types"; -export const ConfigSchema = t.type({ - dm_on_warn: t.boolean, - dm_on_kick: t.boolean, - dm_on_ban: t.boolean, - message_on_warn: t.boolean, - message_on_kick: t.boolean, - message_on_ban: t.boolean, - message_channel: tNullable(t.string), - warn_message: tNullable(t.string), - kick_message: tNullable(t.string), - ban_message: tNullable(t.string), - tempban_message: tNullable(t.string), - alert_on_rejoin: t.boolean, - alert_channel: tNullable(t.string), - warn_notify_enabled: t.boolean, - warn_notify_threshold: t.number, - warn_notify_message: t.string, - ban_delete_message_days: t.number, - can_note: t.boolean, - can_warn: t.boolean, - can_mute: t.boolean, - can_kick: t.boolean, - can_ban: t.boolean, - can_unban: t.boolean, - can_view: t.boolean, - can_addcase: t.boolean, - can_massunban: t.boolean, - can_massban: t.boolean, - can_massmute: t.boolean, - can_hidecase: t.boolean, - can_deletecase: t.boolean, - can_act_as_other: t.boolean, - create_cases_for_manual_actions: t.boolean, +export const zModActionsConfig = z.strictObject({ + dm_on_warn: z.boolean(), + dm_on_kick: z.boolean(), + dm_on_ban: z.boolean(), + message_on_warn: z.boolean(), + message_on_kick: z.boolean(), + message_on_ban: z.boolean(), + message_channel: z.nullable(z.string()), + warn_message: z.nullable(z.string()), + kick_message: z.nullable(z.string()), + ban_message: z.nullable(z.string()), + tempban_message: z.nullable(z.string()), + alert_on_rejoin: z.boolean(), + alert_channel: z.nullable(z.string()), + warn_notify_enabled: z.boolean(), + warn_notify_threshold: z.number(), + warn_notify_message: z.string(), + ban_delete_message_days: z.number(), + can_note: z.boolean(), + can_warn: z.boolean(), + can_mute: z.boolean(), + can_kick: z.boolean(), + can_ban: z.boolean(), + can_unban: z.boolean(), + can_view: z.boolean(), + can_addcase: z.boolean(), + can_massunban: z.boolean(), + can_massban: z.boolean(), + can_massmute: z.boolean(), + can_hidecase: z.boolean(), + can_deletecase: z.boolean(), + can_act_as_other: z.boolean(), + create_cases_for_manual_actions: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface ModActionsEvents { note: (userId: string, reason?: string) => void; @@ -62,7 +61,7 @@ export interface ModActionsEventEmitter extends EventEmitter { } export interface ModActionsPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { mutes: GuildMutes; cases: GuildCases; diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index 1a205bbea..7c9eada80 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -5,7 +5,7 @@ import { GuildCases } from "../../data/GuildCases"; import { onGuildEvent } from "../../data/GuildEvents"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildMutes } from "../../data/GuildMutes"; -import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { mapToPublicFn } from "../../pluginUtils"; import { CasesPlugin } from "../Cases/CasesPlugin"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; @@ -22,7 +22,7 @@ import { offMutesEvent } from "./functions/offMutesEvent"; import { onMutesEvent } from "./functions/onMutesEvent"; import { renewTimeoutMute } from "./functions/renewTimeoutMute"; import { unmuteUser } from "./functions/unmuteUser"; -import { ConfigSchema, MutesPluginType } from "./types"; +import { MutesPluginType, zMutesConfig } from "./types"; const defaultOptions = { config: { @@ -65,11 +65,11 @@ export const MutesPlugin = zeppelinGuildPlugin()({ showInDocs: true, info: { prettyName: "Mutes", - configSchema: ConfigSchema, + configSchema: zMutesConfig, }, dependencies: () => [CasesPlugin, LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zMutesConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts index 0c69c657b..e1f266a94 100644 --- a/backend/src/plugins/Mutes/types.ts +++ b/backend/src/plugins/Mutes/types.ts @@ -1,36 +1,35 @@ import { GuildMember } from "discord.js"; import { EventEmitter } from "events"; -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildMutes } from "../../data/GuildMutes"; import { Case } from "../../data/entities/Case"; import { Mute } from "../../data/entities/Mute"; -import { UserNotificationMethod, UserNotificationResult, tNullable } from "../../utils"; +import { UserNotificationMethod, UserNotificationResult, zSnowflake } from "../../utils"; import { CaseArgs } from "../Cases/types"; -export const ConfigSchema = t.type({ - mute_role: tNullable(t.string), - move_to_voice_channel: tNullable(t.string), - kick_from_voice_channel: t.boolean, +export const zMutesConfig = z.strictObject({ + mute_role: zSnowflake.nullable(), + move_to_voice_channel: zSnowflake.nullable(), + kick_from_voice_channel: z.boolean(), - dm_on_mute: t.boolean, - dm_on_update: t.boolean, - message_on_mute: t.boolean, - message_on_update: t.boolean, - message_channel: tNullable(t.string), - mute_message: tNullable(t.string), - timed_mute_message: tNullable(t.string), - update_mute_message: tNullable(t.string), - remove_roles_on_mute: t.union([t.boolean, t.array(t.string)]), - restore_roles_on_mute: t.union([t.boolean, t.array(t.string)]), + dm_on_mute: z.boolean(), + dm_on_update: z.boolean(), + message_on_mute: z.boolean(), + message_on_update: z.boolean(), + message_channel: z.string().nullable(), + mute_message: z.string().nullable(), + timed_mute_message: z.string().nullable(), + update_mute_message: z.string().nullable(), + remove_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false), + restore_roles_on_mute: z.union([z.boolean(), z.array(zSnowflake)]).default(false), - can_view_list: t.boolean, - can_cleanup: t.boolean, + can_view_list: z.boolean(), + can_cleanup: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface MutesEvents { mute: (userId: string, reason?: string, isAutomodAction?: boolean) => void; @@ -43,7 +42,7 @@ export interface MutesEventEmitter extends EventEmitter { } export interface MutesPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { mutes: GuildMutes; cases: GuildCases; diff --git a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts index 7b9273e3e..915ffa3c0 100644 --- a/backend/src/plugins/NameHistory/NameHistoryPlugin.ts +++ b/backend/src/plugins/NameHistory/NameHistoryPlugin.ts @@ -2,10 +2,9 @@ import { PluginOptions } from "knub"; import { Queue } from "../../Queue"; import { GuildNicknameHistory } from "../../data/GuildNicknameHistory"; import { UsernameHistory } from "../../data/UsernameHistory"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { NamesCmd } from "./commands/NamesCmd"; -import { ConfigSchema, NameHistoryPluginType } from "./types"; +import { NameHistoryPluginType, zNameHistoryConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -25,7 +24,7 @@ export const NameHistoryPlugin = zeppelinGuildPlugin()({ name: "name_history", showInDocs: false, - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zNameHistoryConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/NameHistory/types.ts b/backend/src/plugins/NameHistory/types.ts index e85f2f405..70101b539 100644 --- a/backend/src/plugins/NameHistory/types.ts +++ b/backend/src/plugins/NameHistory/types.ts @@ -1,16 +1,15 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { Queue } from "../../Queue"; import { GuildNicknameHistory } from "../../data/GuildNicknameHistory"; import { UsernameHistory } from "../../data/UsernameHistory"; -export const ConfigSchema = t.type({ - can_view: t.boolean, +export const zNameHistoryConfig = z.strictObject({ + can_view: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface NameHistoryPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { nicknameHistory: GuildNicknameHistory; usernameHistory: UsernameHistory; diff --git a/backend/src/plugins/Persist/PersistPlugin.ts b/backend/src/plugins/Persist/PersistPlugin.ts index ecd50067c..dc489a257 100644 --- a/backend/src/plugins/Persist/PersistPlugin.ts +++ b/backend/src/plugins/Persist/PersistPlugin.ts @@ -1,7 +1,6 @@ import { PluginOptions } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildPersistedData } from "../../data/GuildPersistedData"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { GuildMemberCachePlugin } from "../GuildMemberCache/GuildMemberCachePlugin"; import { LogsPlugin } from "../Logs/LogsPlugin"; @@ -9,7 +8,7 @@ import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { LoadDataEvt } from "./events/LoadDataEvt"; import { StoreDataEvt } from "./events/StoreDataEvt"; -import { ConfigSchema, PersistPluginType } from "./types"; +import { PersistPluginType, zPersistConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -28,11 +27,11 @@ export const PersistPlugin = zeppelinGuildPlugin()({ Re-apply roles or nicknames for users when they rejoin the server. Mute roles are re-applied automatically, this plugin is not required for that. `), - configSchema: ConfigSchema, + configSchema: zPersistConfig, }, dependencies: () => [LogsPlugin, RoleManagerPlugin, GuildMemberCachePlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zPersistConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Persist/types.ts b/backend/src/plugins/Persist/types.ts index 8ab258b76..4f42faa97 100644 --- a/backend/src/plugins/Persist/types.ts +++ b/backend/src/plugins/Persist/types.ts @@ -1,17 +1,17 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener } from "knub"; +import z from "zod"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildPersistedData } from "../../data/GuildPersistedData"; +import { zSnowflake } from "../../utils"; -export const ConfigSchema = t.type({ - persisted_roles: t.array(t.string), - persist_nicknames: t.boolean, - persist_voice_mutes: t.boolean, // Deprecated, here to not break old configs +export const zPersistConfig = z.strictObject({ + persisted_roles: z.array(zSnowflake), + persist_nicknames: z.boolean(), + persist_voice_mutes: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface PersistPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { persistedData: GuildPersistedData; diff --git a/backend/src/plugins/Phisherman/PhishermanPlugin.ts b/backend/src/plugins/Phisherman/PhishermanPlugin.ts index e8f74ce38..8e895d841 100644 --- a/backend/src/plugins/Phisherman/PhishermanPlugin.ts +++ b/backend/src/plugins/Phisherman/PhishermanPlugin.ts @@ -1,10 +1,10 @@ import { PluginOptions } from "knub"; import { hasPhishermanMasterAPIKey, phishermanApiKeyIsValid } from "../../data/Phisherman"; -import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { mapToPublicFn } from "../../pluginUtils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { getDomainInfo } from "./functions/getDomainInfo"; import { pluginInfo } from "./info"; -import { ConfigSchema, PhishermanPluginType } from "./types"; +import { PhishermanPluginType, zPhishermanConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -18,7 +18,7 @@ export const PhishermanPlugin = zeppelinGuildPlugin()({ showInDocs: true, info: pluginInfo, - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zPhishermanConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Phisherman/info.ts b/backend/src/plugins/Phisherman/info.ts index 826da43d6..24530dce0 100644 --- a/backend/src/plugins/Phisherman/info.ts +++ b/backend/src/plugins/Phisherman/info.ts @@ -1,6 +1,6 @@ import { trimPluginDescription } from "../../utils"; import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint"; -import { ConfigSchema } from "./types"; +import { zPhishermanConfig } from "./types"; export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { prettyName: "Phisherman", @@ -39,5 +39,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { clean: true ~~~ `), - configSchema: ConfigSchema, + configSchema: zPhishermanConfig, }; diff --git a/backend/src/plugins/Phisherman/types.ts b/backend/src/plugins/Phisherman/types.ts index 56ed7aca5..d21eb38ec 100644 --- a/backend/src/plugins/Phisherman/types.ts +++ b/backend/src/plugins/Phisherman/types.ts @@ -1,14 +1,12 @@ -import * as t from "io-ts"; import { BasePluginType } from "knub"; -import { tNullable } from "../../utils"; +import z from "zod"; -export const ConfigSchema = t.type({ - api_key: tNullable(t.string), +export const zPhishermanConfig = z.strictObject({ + api_key: z.string().max(255).nullable(), }); -export type TConfigSchema = t.TypeOf; export interface PhishermanPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { validApiKey: string | null; diff --git a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts index 331410f93..a33103bf5 100644 --- a/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts +++ b/backend/src/plugins/PingableRoles/PingableRolesPlugin.ts @@ -1,10 +1,9 @@ import { PluginOptions } from "knub"; import { GuildPingableRoles } from "../../data/GuildPingableRoles"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { PingableRoleDisableCmd } from "./commands/PingableRoleDisableCmd"; import { PingableRoleEnableCmd } from "./commands/PingableRoleEnableCmd"; -import { ConfigSchema, PingableRolesPluginType } from "./types"; +import { PingableRolesPluginType, zPingableRolesConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -25,10 +24,10 @@ export const PingableRolesPlugin = zeppelinGuildPlugin( showInDocs: true, info: { prettyName: "Pingable roles", - configSchema: ConfigSchema, + configSchema: zPingableRolesConfig, }, - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zPingableRolesConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/PingableRoles/types.ts b/backend/src/plugins/PingableRoles/types.ts index 272f75940..3bd6faa80 100644 --- a/backend/src/plugins/PingableRoles/types.ts +++ b/backend/src/plugins/PingableRoles/types.ts @@ -1,15 +1,14 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildPingableRoles } from "../../data/GuildPingableRoles"; import { PingableRole } from "../../data/entities/PingableRole"; -export const ConfigSchema = t.type({ - can_manage: t.boolean, +export const zPingableRolesConfig = z.strictObject({ + can_manage: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface PingableRolesPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { pingableRoles: GuildPingableRoles; diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts index 09d069a2d..304d6b65d 100644 --- a/backend/src/plugins/Post/PostPlugin.ts +++ b/backend/src/plugins/Post/PostPlugin.ts @@ -3,7 +3,6 @@ import { onGuildEvent } from "../../data/GuildEvents"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { GuildScheduledPosts } from "../../data/GuildScheduledPosts"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; @@ -14,7 +13,7 @@ import { PostEmbedCmd } from "./commands/PostEmbedCmd"; import { ScheduledPostsDeleteCmd } from "./commands/ScheduledPostsDeleteCmd"; import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd"; import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd"; -import { ConfigSchema, PostPluginType } from "./types"; +import { PostPluginType, zPostConfig } from "./types"; import { postScheduledPost } from "./util/postScheduledPost"; const defaultOptions: PluginOptions = { @@ -36,11 +35,11 @@ export const PostPlugin = zeppelinGuildPlugin()({ showInDocs: true, info: { prettyName: "Post", - configSchema: ConfigSchema, + configSchema: zPostConfig, }, dependencies: () => [TimeAndDatePlugin, LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zPostConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts index 815b19b8d..e0ec7d2c9 100644 --- a/backend/src/plugins/Post/types.ts +++ b/backend/src/plugins/Post/types.ts @@ -1,16 +1,15 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { GuildScheduledPosts } from "../../data/GuildScheduledPosts"; -export const ConfigSchema = t.type({ - can_post: t.boolean, +export const zPostConfig = z.strictObject({ + can_post: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface PostPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { savedMessages: GuildSavedMessages; scheduledPosts: GuildScheduledPosts; diff --git a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts index ac68de33c..06fff9772 100644 --- a/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts +++ b/backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts @@ -2,7 +2,6 @@ import { PluginOptions } from "knub"; import { Queue } from "../../Queue"; import { GuildReactionRoles } from "../../data/GuildReactionRoles"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd"; @@ -10,7 +9,7 @@ import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd"; import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd"; import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt"; import { MessageDeletedEvt } from "./events/MessageDeletedEvt"; -import { ConfigSchema, ReactionRolesPluginType } from "./types"; +import { ReactionRolesPluginType, zReactionRolesConfig } from "./types"; const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API @@ -40,11 +39,11 @@ export const ReactionRolesPlugin = zeppelinGuildPlugin( info: { prettyName: "Reaction roles", legacy: "Consider using the [Role buttons](/docs/plugins/role_buttons) plugin instead.", - configSchema: ConfigSchema, + configSchema: zReactionRolesConfig, }, dependencies: () => [LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zReactionRolesConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/ReactionRoles/types.ts b/backend/src/plugins/ReactionRoles/types.ts index 6f65ad98a..a1c09f001 100644 --- a/backend/src/plugins/ReactionRoles/types.ts +++ b/backend/src/plugins/ReactionRoles/types.ts @@ -1,17 +1,15 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { Queue } from "../../Queue"; import { GuildReactionRoles } from "../../data/GuildReactionRoles"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { tNullable } from "../../utils"; -export const ConfigSchema = t.type({ - auto_refresh_interval: t.number, - remove_user_reactions: t.boolean, - can_manage: t.boolean, - button_groups: tNullable(t.unknown), +export const zReactionRolesConfig = z.strictObject({ + auto_refresh_interval: z.number(), + remove_user_reactions: z.boolean(), + can_manage: z.boolean(), + button_groups: z.nullable(z.unknown()), }); -export type TConfigSchema = t.TypeOf; export type RoleChangeMode = "+" | "-"; @@ -24,12 +22,14 @@ export type PendingMemberRoleChanges = { }>; }; -const ReactionRolePair = t.union([t.tuple([t.string, t.string, t.string]), t.tuple([t.string, t.string])]); -export type TReactionRolePair = t.TypeOf; -type ReactionRolePair = [string, string, string?]; +const zReactionRolePair = z.union([ + z.tuple([z.string(), z.string(), z.string()]), + z.tuple([z.string(), z.string()]), +]); +export type TReactionRolePair = z.infer; export interface ReactionRolesPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { reactionRoles: GuildReactionRoles; savedMessages: GuildSavedMessages; diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts index 128e945c2..5e86ab49e 100644 --- a/backend/src/plugins/Reminders/RemindersPlugin.ts +++ b/backend/src/plugins/Reminders/RemindersPlugin.ts @@ -1,14 +1,13 @@ import { PluginOptions } from "knub"; import { onGuildEvent } from "../../data/GuildEvents"; import { GuildReminders } from "../../data/GuildReminders"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { RemindCmd } from "./commands/RemindCmd"; import { RemindersCmd } from "./commands/RemindersCmd"; import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd"; import { postReminder } from "./functions/postReminder"; -import { ConfigSchema, RemindersPluginType } from "./types"; +import { RemindersPluginType, zRemindersConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -29,11 +28,11 @@ export const RemindersPlugin = zeppelinGuildPlugin()({ showInDocs: true, info: { prettyName: "Reminders", - configSchema: ConfigSchema, + configSchema: zRemindersConfig, }, dependencies: () => [TimeAndDatePlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zRemindersConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts index 5bc896ba8..4356fac0d 100644 --- a/backend/src/plugins/Reminders/types.ts +++ b/backend/src/plugins/Reminders/types.ts @@ -1,14 +1,13 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildReminders } from "../../data/GuildReminders"; -export const ConfigSchema = t.type({ - can_use: t.boolean, +export const zRemindersConfig = z.strictObject({ + can_use: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface RemindersPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { reminders: GuildReminders; diff --git a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts index 79cfb8b4c..3f1e34eaf 100644 --- a/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts +++ b/backend/src/plugins/RoleButtons/RoleButtonsPlugin.ts @@ -1,15 +1,12 @@ import { GuildRoleButtons } from "../../data/GuildRoleButtons"; -import { parseIoTsSchema, StrictValidationError } from "../../validatorUtils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { resetButtonsCmd } from "./commands/resetButtons"; import { onButtonInteraction } from "./events/buttonInteraction"; import { applyAllRoleButtons } from "./functions/applyAllRoleButtons"; -import { createButtonComponents } from "./functions/createButtonComponents"; -import { TooManyComponentsError } from "./functions/TooManyComponentsError"; import { pluginInfo } from "./info"; -import { ConfigSchema, RoleButtonsPluginType } from "./types"; +import { RoleButtonsPluginType, zRoleButtonsConfig } from "./types"; export const RoleButtonsPlugin = zeppelinGuildPlugin()({ name: "role_buttons", @@ -31,41 +28,7 @@ export const RoleButtonsPlugin = zeppelinGuildPlugin()({ ], }, - configParser(input) { - // Auto-fill "name" property for buttons based on the object key - const seenMessages = new Set(); - for (const [name, buttonsConfig] of Object.entries((input as any).buttons ?? {})) { - if (name.length > 16) { - throw new StrictValidationError(["Name for role buttons can be at most 16 characters long"]); - } - - if (buttonsConfig) { - buttonsConfig.name = name; - - if (buttonsConfig.message) { - if ("message_id" in buttonsConfig.message) { - if (seenMessages.has(buttonsConfig.message.message_id)) { - throw new StrictValidationError(["Can't target the same message with two sets of role buttons"]); - } - seenMessages.add(buttonsConfig.message.message_id); - } - } - - if (buttonsConfig.options) { - try { - createButtonComponents(buttonsConfig); - } catch (err) { - if (err instanceof TooManyComponentsError) { - throw new StrictValidationError(["Too many options; can only have max 5 buttons per row on max 5 rows."]); - } - throw new StrictValidationError(["Error validating options"]); - } - } - } - } - - return parseIoTsSchema(ConfigSchema, input); - }, + configParser: (input) => zRoleButtonsConfig.parse(input), dependencies: () => [LogsPlugin, RoleManagerPlugin], diff --git a/backend/src/plugins/RoleButtons/info.ts b/backend/src/plugins/RoleButtons/info.ts index d63fb17cf..6076419c7 100644 --- a/backend/src/plugins/RoleButtons/info.ts +++ b/backend/src/plugins/RoleButtons/info.ts @@ -1,6 +1,6 @@ import { trimPluginDescription } from "../../utils"; import { ZeppelinGuildPluginBlueprint } from "../ZeppelinPluginBlueprint"; -import { ConfigSchema } from "./types"; +import { zRoleButtonsConfig } from "./types"; export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { prettyName: "Role buttons", @@ -78,5 +78,5 @@ export const pluginInfo: ZeppelinGuildPluginBlueprint["info"] = { ... # See above for examples for options ~~~ `), - configSchema: ConfigSchema, + configSchema: zRoleButtonsConfig, }; diff --git a/backend/src/plugins/RoleButtons/types.ts b/backend/src/plugins/RoleButtons/types.ts index f5792f36b..3c94f8a8f 100644 --- a/backend/src/plugins/RoleButtons/types.ts +++ b/backend/src/plugins/RoleButtons/types.ts @@ -1,58 +1,102 @@ import { ButtonStyle } from "discord.js"; -import * as t from "io-ts"; import { BasePluginType } from "knub"; +import z from "zod"; import { GuildRoleButtons } from "../../data/GuildRoleButtons"; -import { tMessageContent, tNullable } from "../../utils"; +import { zBoundedCharacters, zBoundedRecord, zMessageContent, zSnowflake } from "../../utils"; +import { TooManyComponentsError } from "./functions/TooManyComponentsError"; +import { createButtonComponents } from "./functions/createButtonComponents"; -const RoleButtonOption = t.type({ - role_id: t.string, - label: tNullable(t.string), - emoji: tNullable(t.string), +const zRoleButtonOption = z.strictObject({ + role_id: zSnowflake, + label: z.string().nullable().default(null), + emoji: z.string().nullable().default(null), // https://discord.js.org/#/docs/discord.js/v13/typedef/MessageButtonStyle - style: tNullable( - t.union([ - t.literal(ButtonStyle.Primary), - t.literal(ButtonStyle.Secondary), - t.literal(ButtonStyle.Success), - t.literal(ButtonStyle.Danger), + style: z.union([ + z.literal(ButtonStyle.Primary), + z.literal(ButtonStyle.Secondary), + z.literal(ButtonStyle.Success), + z.literal(ButtonStyle.Danger), - // The following are deprecated - t.literal("PRIMARY"), - t.literal("SECONDARY"), - t.literal("SUCCESS"), - t.literal("DANGER"), - // t.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available - ]), - ), - start_new_row: tNullable(t.boolean), + // The following are deprecated + z.literal("PRIMARY"), + z.literal("SECONDARY"), + z.literal("SUCCESS"), + z.literal("DANGER"), + // z.literal("LINK"), // Role buttons don't use link buttons, but adding this here so it's documented why it's not available + ]).nullable().default(null), + start_new_row: z.boolean().default(false), }); -export type TRoleButtonOption = t.TypeOf; +export type TRoleButtonOption = z.infer; -const RoleButtonsConfigItem = t.type({ - name: t.string, - message: t.union([ - t.type({ - channel_id: t.string, - message_id: t.string, +const zRoleButtonsConfigItem = z.strictObject({ + // Typed as "never" because you are not expected to supply this directly. + // The transform instead picks it up from the property key and the output type is a string. + name: z.never().optional().transform((_, ctx) => { + const ruleName = String(ctx.path[ctx.path.length - 2]).trim(); + if (! ruleName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Role buttons must have names", + }); + return z.NEVER; + } + return ruleName; + }), + message: z.union([ + z.strictObject({ + channel_id: zSnowflake, + message_id: zSnowflake, }), - t.type({ - channel_id: t.string, - content: tMessageContent, + z.strictObject({ + channel_id: zSnowflake, + content: zMessageContent, }), ]), - options: t.array(RoleButtonOption), - exclusive: tNullable(t.boolean), -}); -export type TRoleButtonsConfigItem = t.TypeOf; + options: z.array(zRoleButtonOption).max(25), + exclusive: z.boolean().default(false), +}) + .refine((parsed) => { + try { + createButtonComponents(parsed); + } catch (err) { + if (err instanceof TooManyComponentsError) { + return false; + } + throw err; + } + return true; + }, { + message: "Too many options; can only have max 5 buttons per row on max 5 rows." + }); +export type TRoleButtonsConfigItem = z.infer; -export const ConfigSchema = t.type({ - buttons: t.record(t.string, RoleButtonsConfigItem), - can_reset: t.boolean, -}); -export type TConfigSchema = t.TypeOf; +export const zRoleButtonsConfig = z.strictObject({ + buttons: zBoundedRecord( + z.record(zBoundedCharacters(1, 16), zRoleButtonsConfigItem), + 0, + 100, + ), + can_reset: z.boolean(), +}) + .refine((parsed) => { + const seenMessages = new Set(); + for (const button of Object.values(parsed.buttons)) { + if (button.message) { + if ("message_id" in button.message) { + if (seenMessages.has(button.message.message_id)) { + return false; + } + seenMessages.add(button.message.message_id); + } + } + } + return true; + }, { + message: "Can't target the same message with two sets of role buttons", + }); export interface RoleButtonsPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { roleButtons: GuildRoleButtons; }; diff --git a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts index 878b2cd44..34427d987 100644 --- a/backend/src/plugins/RoleManager/RoleManagerPlugin.ts +++ b/backend/src/plugins/RoleManager/RoleManagerPlugin.ts @@ -1,5 +1,5 @@ import { GuildRoleQueue } from "../../data/GuildRoleQueue"; -import { makeIoTsConfigParser, mapToPublicFn } from "../../pluginUtils"; +import { mapToPublicFn } from "../../pluginUtils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { addPriorityRole } from "./functions/addPriorityRole"; @@ -7,14 +7,14 @@ import { addRole } from "./functions/addRole"; import { removePriorityRole } from "./functions/removePriorityRole"; import { removeRole } from "./functions/removeRole"; import { runRoleAssignmentLoop } from "./functions/runRoleAssignmentLoop"; -import { ConfigSchema, RoleManagerPluginType } from "./types"; +import { RoleManagerPluginType, zRoleManagerConfig } from "./types"; export const RoleManagerPlugin = zeppelinGuildPlugin()({ name: "role_manager", showInDocs: false, dependencies: () => [LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zRoleManagerConfig.parse(input), public: { addRole: mapToPublicFn(addRole), diff --git a/backend/src/plugins/RoleManager/types.ts b/backend/src/plugins/RoleManager/types.ts index 54f5033a9..51ba30808 100644 --- a/backend/src/plugins/RoleManager/types.ts +++ b/backend/src/plugins/RoleManager/types.ts @@ -1,12 +1,11 @@ -import * as t from "io-ts"; import { BasePluginType } from "knub"; +import z from "zod"; import { GuildRoleQueue } from "../../data/GuildRoleQueue"; -export const ConfigSchema = t.type({}); -export type TConfigSchema = t.TypeOf; +export const zRoleManagerConfig = z.strictObject({}); export interface RoleManagerPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { roleQueue: GuildRoleQueue; roleAssignmentLoopRunning: boolean; diff --git a/backend/src/plugins/Roles/RolesPlugin.ts b/backend/src/plugins/Roles/RolesPlugin.ts index 6c8109400..0e181cfa0 100644 --- a/backend/src/plugins/Roles/RolesPlugin.ts +++ b/backend/src/plugins/Roles/RolesPlugin.ts @@ -1,6 +1,5 @@ import { PluginOptions } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { RoleManagerPlugin } from "../RoleManager/RoleManagerPlugin"; @@ -9,13 +8,13 @@ import { AddRoleCmd } from "./commands/AddRoleCmd"; import { MassAddRoleCmd } from "./commands/MassAddRoleCmd"; import { MassRemoveRoleCmd } from "./commands/MassRemoveRoleCmd"; import { RemoveRoleCmd } from "./commands/RemoveRoleCmd"; -import { ConfigSchema, RolesPluginType } from "./types"; +import { RolesPluginType, zRolesConfig } from "./types"; const defaultOptions: PluginOptions = { config: { can_assign: false, can_mass_assign: false, - assignable_roles: ["558037973581430785"], + assignable_roles: [], }, overrides: [ { @@ -41,11 +40,11 @@ export const RolesPlugin = zeppelinGuildPlugin()({ description: trimPluginDescription(` Enables authorised users to add and remove whitelisted roles with a command. `), - configSchema: ConfigSchema, + configSchema: zRolesConfig, }, dependencies: () => [LogsPlugin, RoleManagerPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zRolesConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Roles/types.ts b/backend/src/plugins/Roles/types.ts index 346dc5624..caf55b769 100644 --- a/backend/src/plugins/Roles/types.ts +++ b/backend/src/plugins/Roles/types.ts @@ -1,16 +1,15 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildLogs } from "../../data/GuildLogs"; -export const ConfigSchema = t.type({ - can_assign: t.boolean, - can_mass_assign: t.boolean, - assignable_roles: t.array(t.string), +export const zRolesConfig = z.strictObject({ + can_assign: z.boolean(), + can_mass_assign: z.boolean(), + assignable_roles: z.array(z.string()).max(100), }); -export type TConfigSchema = t.TypeOf; export interface RolesPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { logs: GuildLogs; }; diff --git a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts index f16cff922..6f0d666bd 100644 --- a/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts +++ b/backend/src/plugins/SelfGrantableRoles/SelfGrantableRolesPlugin.ts @@ -1,11 +1,10 @@ import { CooldownManager, PluginOptions } from "knub"; import { trimPluginDescription } from "../../utils"; -import { parseIoTsSchema } from "../../validatorUtils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { RoleAddCmd } from "./commands/RoleAddCmd"; import { RoleHelpCmd } from "./commands/RoleHelpCmd"; import { RoleRemoveCmd } from "./commands/RoleRemoveCmd"; -import { ConfigSchema, SelfGrantableRolesPluginType, defaultSelfGrantableRoleEntry } from "./types"; +import { SelfGrantableRolesPluginType, zSelfGrantableRolesConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -66,25 +65,10 @@ export const SelfGrantableRolesPlugin = zeppelinGuildPlugin { - const entries = (input as any).entries; - for (const [key, entry] of Object.entries(entries)) { - // Apply default entry config - entries[key] = { ...defaultSelfGrantableRoleEntry, ...entry }; - - // Normalize alias names - if (entry.roles) { - for (const [roleId, aliases] of Object.entries(entry.roles)) { - entry.roles[roleId] = aliases.map((a) => a.toLowerCase()); - } - } - } - - return parseIoTsSchema(ConfigSchema, input); - }, + configParser: (input) => zSelfGrantableRolesConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/SelfGrantableRoles/types.ts b/backend/src/plugins/SelfGrantableRoles/types.ts index 08d665df3..a1aed241e 100644 --- a/backend/src/plugins/SelfGrantableRoles/types.ts +++ b/backend/src/plugins/SelfGrantableRoles/types.ts @@ -1,31 +1,29 @@ -import * as t from "io-ts"; import { BasePluginType, CooldownManager, guildPluginMessageCommand } from "knub"; +import z from "zod"; +import { zBoundedCharacters, zBoundedRecord } from "../../utils"; -const RoleMap = t.record(t.string, t.array(t.string)); +const zRoleMap = z.record( + zBoundedCharacters(1, 100), + z.array(zBoundedCharacters(1, 2000)) + .max(100) + .transform((parsed) => parsed.map(v => v.toLowerCase())), +); -const SelfGrantableRoleEntry = t.type({ - roles: RoleMap, - can_use: t.boolean, - can_ignore_cooldown: t.boolean, - max_roles: t.number, +const zSelfGrantableRoleEntry = z.strictObject({ + roles: zBoundedRecord(zRoleMap, 0, 100), + can_use: z.boolean().default(false), + can_ignore_cooldown: z.boolean().default(false), + max_roles: z.number().default(0), }); -const PartialRoleEntry = t.partial(SelfGrantableRoleEntry.props); -export type TSelfGrantableRoleEntry = t.TypeOf; +export type TSelfGrantableRoleEntry = z.infer; -export const ConfigSchema = t.type({ - entries: t.record(t.string, SelfGrantableRoleEntry), - mention_roles: t.boolean, +export const zSelfGrantableRolesConfig = z.strictObject({ + entries: zBoundedRecord(z.record(zBoundedCharacters(0, 255), zSelfGrantableRoleEntry), 0, 100), + mention_roles: z.boolean(), }); -type TConfigSchema = t.TypeOf; - -export const defaultSelfGrantableRoleEntry: t.TypeOf = { - can_use: false, - can_ignore_cooldown: false, - max_roles: 0, -}; export interface SelfGrantableRolesPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { cooldowns: CooldownManager; }; diff --git a/backend/src/plugins/Slowmode/SlowmodePlugin.ts b/backend/src/plugins/Slowmode/SlowmodePlugin.ts index 4f6c09969..174f38a99 100644 --- a/backend/src/plugins/Slowmode/SlowmodePlugin.ts +++ b/backend/src/plugins/Slowmode/SlowmodePlugin.ts @@ -2,7 +2,6 @@ import { PluginOptions } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { GuildSlowmodes } from "../../data/GuildSlowmodes"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { SECONDS } from "../../utils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; @@ -11,7 +10,7 @@ import { SlowmodeDisableCmd } from "./commands/SlowmodeDisableCmd"; import { SlowmodeGetCmd } from "./commands/SlowmodeGetCmd"; import { SlowmodeListCmd } from "./commands/SlowmodeListCmd"; import { SlowmodeSetCmd } from "./commands/SlowmodeSetCmd"; -import { ConfigSchema, SlowmodePluginType } from "./types"; +import { SlowmodePluginType, zSlowmodeConfig } from "./types"; import { clearExpiredSlowmodes } from "./util/clearExpiredSlowmodes"; import { onMessageCreate } from "./util/onMessageCreate"; @@ -41,7 +40,7 @@ export const SlowmodePlugin = zeppelinGuildPlugin()({ showInDocs: true, info: { prettyName: "Slowmode", - configSchema: ConfigSchema, + configSchema: zSlowmodeConfig, }, // prettier-ignore @@ -49,7 +48,7 @@ export const SlowmodePlugin = zeppelinGuildPlugin()({ LogsPlugin, ], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zSlowmodeConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Slowmode/types.ts b/backend/src/plugins/Slowmode/types.ts index 089a59ef7..c4fe44d56 100644 --- a/backend/src/plugins/Slowmode/types.ts +++ b/backend/src/plugins/Slowmode/types.ts @@ -1,20 +1,19 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { GuildSlowmodes } from "../../data/GuildSlowmodes"; import { SlowmodeChannel } from "../../data/entities/SlowmodeChannel"; -export const ConfigSchema = t.type({ - use_native_slowmode: t.boolean, - - can_manage: t.boolean, - is_affected: t.boolean, +export const zSlowmodeConfig = z.strictObject({ + use_native_slowmode: z.boolean(), + + can_manage: z.boolean(), + is_affected: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface SlowmodePluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { slowmodes: GuildSlowmodes; savedMessages: GuildSavedMessages; diff --git a/backend/src/plugins/Spam/SpamPlugin.ts b/backend/src/plugins/Spam/SpamPlugin.ts index e1a7efc04..54ec540cd 100644 --- a/backend/src/plugins/Spam/SpamPlugin.ts +++ b/backend/src/plugins/Spam/SpamPlugin.ts @@ -3,12 +3,11 @@ import { GuildArchives } from "../../data/GuildArchives"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildMutes } from "../../data/GuildMutes"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { trimPluginDescription } from "../../utils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { SpamVoiceStateUpdateEvt } from "./events/SpamVoiceEvt"; -import { ConfigSchema, SpamPluginType } from "./types"; +import { SpamPluginType, zSpamConfig } from "./types"; import { clearOldRecentActions } from "./util/clearOldRecentActions"; import { onMessageCreate } from "./util/onMessageCreate"; @@ -53,11 +52,11 @@ export const SpamPlugin = zeppelinGuildPlugin()({ For more advanced spam filtering, check out the Automod plugin! `), legacy: true, - configSchema: ConfigSchema, + configSchema: zSpamConfig, }, dependencies: () => [LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zSpamConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Spam/types.ts b/backend/src/plugins/Spam/types.ts index 1e561477c..74ea872f4 100644 --- a/backend/src/plugins/Spam/types.ts +++ b/backend/src/plugins/Spam/types.ts @@ -1,35 +1,40 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener } from "knub"; +import z from "zod"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildMutes } from "../../data/GuildMutes"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { tNullable } from "../../utils"; +import { zSnowflake } from "../../utils"; -const BaseSingleSpamConfig = t.type({ - interval: t.number, - count: t.number, - mute: tNullable(t.boolean), - mute_time: tNullable(t.number), - remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), - restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])), - clean: tNullable(t.boolean), +const zBaseSingleSpamConfig = z.strictObject({ + interval: z.number(), + count: z.number(), + mute: z.boolean().default(false), + mute_time: z.number().nullable().default(null), + remove_roles_on_mute: z.union([ + z.boolean(), + z.array(zSnowflake), + ]).default(false), + restore_roles_on_mute: z.union([ + z.boolean(), + z.array(zSnowflake), + ]).default(false), + clean: z.boolean().default(false), }); -export type TBaseSingleSpamConfig = t.TypeOf; +export type TBaseSingleSpamConfig = z.infer; -export const ConfigSchema = t.type({ - max_censor: tNullable(BaseSingleSpamConfig), - max_messages: tNullable(BaseSingleSpamConfig), - max_mentions: tNullable(BaseSingleSpamConfig), - max_links: tNullable(BaseSingleSpamConfig), - max_attachments: tNullable(BaseSingleSpamConfig), - max_emojis: tNullable(BaseSingleSpamConfig), - max_newlines: tNullable(BaseSingleSpamConfig), - max_duplicates: tNullable(BaseSingleSpamConfig), - max_characters: tNullable(BaseSingleSpamConfig), - max_voice_moves: tNullable(BaseSingleSpamConfig), +export const zSpamConfig = z.strictObject({ + max_censor: zBaseSingleSpamConfig.nullable(), + max_messages: zBaseSingleSpamConfig.nullable(), + max_mentions: zBaseSingleSpamConfig.nullable(), + max_links: zBaseSingleSpamConfig.nullable(), + max_attachments: zBaseSingleSpamConfig.nullable(), + max_emojis: zBaseSingleSpamConfig.nullable(), + max_newlines: zBaseSingleSpamConfig.nullable(), + max_duplicates: zBaseSingleSpamConfig.nullable(), + max_characters: zBaseSingleSpamConfig.nullable(), + max_voice_moves: zBaseSingleSpamConfig.nullable(), }); -export type TConfigSchema = t.TypeOf; export enum RecentActionType { Message = 1, @@ -53,7 +58,7 @@ interface IRecentAction { } export interface SpamPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { logs: GuildLogs; archives: GuildArchives; diff --git a/backend/src/plugins/Starboard/StarboardPlugin.ts b/backend/src/plugins/Starboard/StarboardPlugin.ts index 050b90375..4ca3c4419 100644 --- a/backend/src/plugins/Starboard/StarboardPlugin.ts +++ b/backend/src/plugins/Starboard/StarboardPlugin.ts @@ -3,12 +3,11 @@ import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { GuildStarboardMessages } from "../../data/GuildStarboardMessages"; import { GuildStarboardReactions } from "../../data/GuildStarboardReactions"; import { trimPluginDescription } from "../../utils"; -import { parseIoTsSchema } from "../../validatorUtils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { MigratePinsCmd } from "./commands/MigratePinsCmd"; import { StarboardReactionAddEvt } from "./events/StarboardReactionAddEvt"; import { StarboardReactionRemoveAllEvt, StarboardReactionRemoveEvt } from "./events/StarboardReactionRemoveEvts"; -import { ConfigSchema, StarboardPluginType, defaultStarboardOpts } from "./types"; +import { StarboardPluginType, zStarboardConfig } from "./types"; import { onMessageDelete } from "./util/onMessageDelete"; const defaultOptions: PluginOptions = { @@ -120,19 +119,10 @@ export const StarboardPlugin = zeppelinGuildPlugin()({ enabled: true ~~~ `), - configSchema: ConfigSchema, + configSchema: zStarboardConfig, }, - configParser(input) { - const boards = (input as any).boards; - if (boards) { - for (const [name, opts] of Object.entries(boards)) { - boards[name] = Object.assign({}, defaultStarboardOpts, opts); - } - } - - return parseIoTsSchema(ConfigSchema, input); - }, + configParser: (input) => zStarboardConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Starboard/types.ts b/backend/src/plugins/Starboard/types.ts index 223501e38..7dc2d8f58 100644 --- a/backend/src/plugins/Starboard/types.ts +++ b/backend/src/plugins/Starboard/types.ts @@ -1,39 +1,33 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { GuildStarboardMessages } from "../../data/GuildStarboardMessages"; import { GuildStarboardReactions } from "../../data/GuildStarboardReactions"; -import { tDeepPartial, tNullable } from "../../utils"; - -const StarboardOpts = t.type({ - channel_id: t.string, - stars_required: t.number, - star_emoji: tNullable(t.array(t.string)), - allow_selfstars: tNullable(t.boolean), - copy_full_embed: tNullable(t.boolean), - enabled: tNullable(t.boolean), - show_star_count: t.boolean, - color: tNullable(t.number), +import { zBoundedRecord, zSnowflake } from "../../utils"; + +const zStarboardOpts = z.strictObject({ + channel_id: zSnowflake, + stars_required: z.number(), + star_emoji: z.array(z.string()).default(["⭐"]), + allow_selfstars: z.boolean().default(false), + copy_full_embed: z.boolean().default(false), + enabled: z.boolean().default(true), + show_star_count: z.boolean().default(true), + color: z.number().nullable().default(null), }); -export type TStarboardOpts = t.TypeOf; - -export const ConfigSchema = t.type({ - boards: t.record(t.string, StarboardOpts), - can_migrate: t.boolean, +export type TStarboardOpts = z.infer; + +export const zStarboardConfig = z.strictObject({ + boards: zBoundedRecord( + z.record(z.string(), zStarboardOpts), + 0, + 100, + ), + can_migrate: z.boolean(), }); -export type TConfigSchema = t.TypeOf; - -export const PartialConfigSchema = tDeepPartial(ConfigSchema); - -export const defaultStarboardOpts: Partial = { - star_emoji: ["⭐"], - enabled: true, - show_star_count: true, - color: null, -}; export interface StarboardPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { savedMessages: GuildSavedMessages; diff --git a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts b/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts deleted file mode 100644 index 572587592..000000000 --- a/backend/src/plugins/Starboard/util/preprocessStaticConfig.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as t from "io-ts"; -import { defaultStarboardOpts, PartialConfigSchema } from "../types"; - -export function preprocessStaticConfig(config: t.TypeOf) { - if (config.boards) { - for (const [name, opts] of Object.entries(config.boards)) { - config.boards[name] = Object.assign({}, defaultStarboardOpts, opts); - } - } - - return config; -} diff --git a/backend/src/plugins/Tags/TagsPlugin.ts b/backend/src/plugins/Tags/TagsPlugin.ts index 3e0a5bb4e..c68f90bfe 100644 --- a/backend/src/plugins/Tags/TagsPlugin.ts +++ b/backend/src/plugins/Tags/TagsPlugin.ts @@ -2,7 +2,6 @@ import { Snowflake } from "discord.js"; import humanizeDuration from "humanize-duration"; import { PluginOptions } from "knub"; import moment from "moment-timezone"; -import { parseIoTsSchema, StrictValidationError } from "src/validatorUtils"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; @@ -19,7 +18,7 @@ import { TagListCmd } from "./commands/TagListCmd"; import { TagSourceCmd } from "./commands/TagSourceCmd"; import { generateTemplateMarkdown } from "./docs"; import { TemplateFunctions } from "./templateFunctions"; -import { ConfigSchema, TagsPluginType } from "./types"; +import { TagsPluginType, zTagsConfig } from "./types"; import { findTagByName } from "./util/findTagByName"; import { onMessageCreate } from "./util/onMessageCreate"; import { onMessageDelete } from "./util/onMessageDelete"; @@ -71,7 +70,7 @@ export const TagsPlugin = zeppelinGuildPlugin()({ ${generateTemplateMarkdown(TemplateFunctions)} `), - configSchema: ConfigSchema, + configSchema: zTagsConfig, }, dependencies: () => [LogsPlugin], @@ -96,28 +95,7 @@ export const TagsPlugin = zeppelinGuildPlugin()({ findTagByName: mapToPublicFn(findTagByName), }, - configParser(_input) { - const input = _input as any; - - if (input.delete_with_command && input.auto_delete_command) { - throw new StrictValidationError([ - `Cannot have both (global) delete_with_command and global_delete_invoke enabled`, - ]); - } - - // Check each category for conflicting options - if (input.categories) { - for (const [name, cat] of Object.entries(input.categories)) { - if ((cat as any).delete_with_command && (cat as any).auto_delete_command) { - throw new StrictValidationError([ - `Cannot have both (category specific) delete_with_command and category_delete_invoke enabled at `, - ]); - } - } - } - - return parseIoTsSchema(ConfigSchema, input); - }, + configParser: (input) => zTagsConfig.parse(input), beforeLoad(pluginData) { const { state, guild } = pluginData; diff --git a/backend/src/plugins/Tags/types.ts b/backend/src/plugins/Tags/types.ts index 3a7eb2b32..441906fee 100644 --- a/backend/src/plugins/Tags/types.ts +++ b/backend/src/plugins/Tags/types.ts @@ -1,52 +1,63 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { GuildTags } from "../../data/GuildTags"; -import { tEmbed, tNullable } from "../../utils"; +import { zEmbedInput } from "../../utils"; -export const Tag = t.union([t.string, tEmbed]); -export type TTag = t.TypeOf; +export const zTag = z.union([z.string(), zEmbedInput]); +export type TTag = z.infer; -export const TagCategory = t.type({ - prefix: tNullable(t.string), - delete_with_command: tNullable(t.boolean), +export const zTagCategory = z.strictObject({ + prefix: z.string().nullable().default(null), + delete_with_command: z.boolean().default(false), - 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 - allow_mentions: tNullable(t.boolean), // Per user, per category - global_category_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per category - auto_delete_command: tNullable(t.boolean), // Any tag, per tag category + user_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag + user_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Per user, per tag category + global_tag_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per tag + allow_mentions: z.boolean().nullable().default(null), + global_category_cooldown: z.union([z.string(), z.number()]).nullable().default(null), // Any user, per category + auto_delete_command: z.boolean().nullable().default(null), - tags: t.record(t.string, Tag), + tags: z.record(z.string(), zTag), - can_use: tNullable(t.boolean), -}); -export type TTagCategory = t.TypeOf; + can_use: z.boolean().nullable().default(null), +}) + .refine( + (parsed) => ! (parsed.auto_delete_command && parsed.delete_with_command), + { + message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled", + }, + ); +export type TTagCategory = z.infer; -export const ConfigSchema = t.type({ - prefix: t.string, - delete_with_command: t.boolean, +export const zTagsConfig = z.strictObject({ + prefix: z.string(), + delete_with_command: z.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 - allow_mentions: t.boolean, // Per user - global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use - auto_delete_command: t.boolean, // Any tag + user_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user, per tag + global_tag_cooldown: z.union([z.string(), z.number()]).nullable(), // Any user, per tag + user_cooldown: z.union([z.string(), z.number()]).nullable(), // Per user + allow_mentions: z.boolean(), // Per user + global_cooldown: z.union([z.string(), z.number()]).nullable(), // Any tag use + auto_delete_command: z.boolean(), // Any tag - categories: t.record(t.string, TagCategory), + categories: z.record(z.string(), zTagCategory), - can_create: t.boolean, - can_use: t.boolean, - can_list: t.boolean, -}); -export type TConfigSchema = t.TypeOf; + can_create: z.boolean(), + can_use: z.boolean(), + can_list: z.boolean(), +}) +.refine( + (parsed) => ! (parsed.auto_delete_command && parsed.delete_with_command), + { + message: "Cannot have both (category specific) delete_with_command and auto_delete_command enabled", + }, +); export interface TagsPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { archives: GuildArchives; tags: GuildTags; diff --git a/backend/src/plugins/Tags/util/findTagByName.ts b/backend/src/plugins/Tags/util/findTagByName.ts index 1b877f33f..98fafc3f3 100644 --- a/backend/src/plugins/Tags/util/findTagByName.ts +++ b/backend/src/plugins/Tags/util/findTagByName.ts @@ -1,12 +1,11 @@ -import * as t from "io-ts"; import { ExtendedMatchParams, GuildPluginData } from "knub"; -import { Tag, TagsPluginType } from "../types"; +import { TTag, TagsPluginType } from "../types"; export async function findTagByName( pluginData: GuildPluginData, name: string, matchParams: ExtendedMatchParams = {}, -): Promise | null> { +): Promise { const config = await pluginData.config.getMatchingConfig(matchParams); // Tag from a hardcoded category diff --git a/backend/src/plugins/Tags/util/onMessageCreate.ts b/backend/src/plugins/Tags/util/onMessageCreate.ts index 12c6cf8d7..f5b0b04ae 100644 --- a/backend/src/plugins/Tags/util/onMessageCreate.ts +++ b/backend/src/plugins/Tags/util/onMessageCreate.ts @@ -2,9 +2,8 @@ import { Snowflake, TextChannel } from "discord.js"; import { GuildPluginData } from "knub"; import { erisAllowedMentionsToDjsMentionOptions } from "src/utils/erisAllowedMentionsToDjsMentionOptions"; import { SavedMessage } from "../../../data/entities/SavedMessage"; -import { convertDelayStringToMS, resolveMember, tStrictMessageContent } from "../../../utils"; +import { convertDelayStringToMS, resolveMember, zStrictMessageContent } from "../../../utils"; import { messageIsEmpty } from "../../../utils/messageIsEmpty"; -import { validate } from "../../../validatorUtils"; import { LogsPlugin } from "../../Logs/LogsPlugin"; import { TagsPluginType } from "../types"; import { matchAndRenderTagFromString } from "./matchAndRenderTagFromString"; @@ -85,10 +84,10 @@ export async function onMessageCreate(pluginData: GuildPluginData = { config: { @@ -39,10 +39,10 @@ export const TimeAndDatePlugin = zeppelinGuildPlugin()({ description: trimPluginDescription(` Allows controlling the displayed time/date formats and timezones `), - configSchema: ConfigSchema, + configSchema: zTimeAndDateConfig, }, - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zTimeAndDateConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/TimeAndDate/types.ts b/backend/src/plugins/TimeAndDate/types.ts index 102d620bf..7a942518c 100644 --- a/backend/src/plugins/TimeAndDate/types.ts +++ b/backend/src/plugins/TimeAndDate/types.ts @@ -1,19 +1,21 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginMessageCommand } from "knub"; +import { U } from "ts-toolbelt"; +import z from "zod"; import { GuildMemberTimezones } from "../../data/GuildMemberTimezones"; -import { tNullable, tPartialDictionary } from "../../utils"; -import { tValidTimezone } from "../../utils/tValidTimezone"; +import { keys } from "../../utils"; +import { zValidTimezone } from "../../utils/zValidTimezone"; import { defaultDateFormats } from "./defaultDateFormats"; -export const ConfigSchema = t.type({ - timezone: tValidTimezone, - date_formats: tNullable(tPartialDictionary(t.keyof(defaultDateFormats), t.string)), - can_set_timezone: t.boolean, +const zDateFormatKeys = z.enum(keys(defaultDateFormats) as U.ListOf); + +export const zTimeAndDateConfig = z.strictObject({ + timezone: zValidTimezone(z.string()), + date_formats: z.record(zDateFormatKeys, z.string()).nullable(), + can_set_timezone: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface TimeAndDatePluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { memberTimezones: GuildMemberTimezones; }; diff --git a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts index 3bdb310c9..e64d3c985 100644 --- a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts +++ b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts @@ -1,16 +1,14 @@ -import * as t from "io-ts"; import { Queue } from "../../Queue"; import { UsernameHistory } from "../../data/UsernameHistory"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { MessageCreateUpdateUsernameEvt, VoiceChannelJoinUpdateUsernameEvt } from "./events/UpdateUsernameEvts"; -import { UsernameSaverPluginType } from "./types"; +import { UsernameSaverPluginType, zUsernameSaverConfig } from "./types"; export const UsernameSaverPlugin = zeppelinGuildPlugin()({ name: "username_saver", showInDocs: false, - configParser: makeIoTsConfigParser(t.type({})), + configParser: (input) => zUsernameSaverConfig.parse(input), // prettier-ignore events: [ diff --git a/backend/src/plugins/UsernameSaver/types.ts b/backend/src/plugins/UsernameSaver/types.ts index d3c5518f0..16d6e3424 100644 --- a/backend/src/plugins/UsernameSaver/types.ts +++ b/backend/src/plugins/UsernameSaver/types.ts @@ -1,8 +1,12 @@ import { BasePluginType, guildPluginEventListener } from "knub"; +import z from "zod"; import { Queue } from "../../Queue"; import { UsernameHistory } from "../../data/UsernameHistory"; +export const zUsernameSaverConfig = z.strictObject({}); + export interface UsernameSaverPluginType extends BasePluginType { + config: z.infer; state: { usernameHistory: UsernameHistory; updateQueue: Queue; diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 68f6987a9..efb892ec8 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -5,7 +5,7 @@ import { GuildCases } from "../../data/GuildCases"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { Supporters } from "../../data/Supporters"; -import { makeIoTsConfigParser, sendSuccessMessage } from "../../pluginUtils"; +import { sendSuccessMessage } from "../../pluginUtils"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { ModActionsPlugin } from "../ModActions/ModActionsPlugin"; @@ -42,7 +42,7 @@ import { getUserInfoEmbed } from "./functions/getUserInfoEmbed"; import { hasPermission } from "./functions/hasPermission"; import { activeReloads } from "./guildReloads"; import { refreshMembersIfNeeded } from "./refreshMembers"; -import { ConfigSchema, UtilityPluginType } from "./types"; +import { UtilityPluginType, zUtilityConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -117,11 +117,11 @@ export const UtilityPlugin = zeppelinGuildPlugin()({ showInDocs: true, info: { prettyName: "Utility", - configSchema: ConfigSchema, + configSchema: zUtilityConfig, }, dependencies: () => [TimeAndDatePlugin, ModActionsPlugin, LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zUtilityConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index 19710b581..dbaacf27b 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -14,10 +14,9 @@ import { ArgsFromSignatureOrArray, GuildPluginData } from "knub"; import moment from "moment-timezone"; import { RegExpRunner, allowTimeout } from "../../RegExpRunner"; import { getBaseUrl, sendErrorMessage } from "../../pluginUtils"; -import { MINUTES, multiSorter, renderUserUsername, sorter, trimLines } from "../../utils"; +import { InvalidRegexError, MINUTES, inputPatternToRegExp, multiSorter, renderUserUsername, sorter, trimLines } from "../../utils"; import { asyncFilter } from "../../utils/async"; import { hasDiscordPermissions } from "../../utils/hasDiscordPermissions"; -import { InvalidRegexError, inputPatternToRegExp } from "../../validatorUtils"; import { banSearchSignature } from "./commands/BanSearchCmd"; import { searchCmdSignature } from "./commands/SearchCmd"; import { getUserInfoEmbed } from "./functions/getUserInfoEmbed"; diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts index aaac036a9..77b3d8acd 100644 --- a/backend/src/plugins/Utility/types.ts +++ b/backend/src/plugins/Utility/types.ts @@ -1,5 +1,5 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener, guildPluginMessageCommand } from "knub"; +import z from "zod"; import { RegExpRunner } from "../../RegExpRunner"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildCases } from "../../data/GuildCases"; @@ -7,39 +7,38 @@ import { GuildLogs } from "../../data/GuildLogs"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { Supporters } from "../../data/Supporters"; -export const ConfigSchema = t.type({ - can_roles: t.boolean, - can_level: t.boolean, - can_search: t.boolean, - can_clean: t.boolean, - can_info: t.boolean, - can_server: t.boolean, - can_inviteinfo: t.boolean, - can_channelinfo: t.boolean, - can_messageinfo: t.boolean, - can_userinfo: t.boolean, - can_roleinfo: t.boolean, - can_emojiinfo: t.boolean, - can_snowflake: t.boolean, - can_reload_guild: t.boolean, - can_nickname: t.boolean, - can_ping: t.boolean, - can_source: t.boolean, - can_vcmove: t.boolean, - can_vckick: t.boolean, - can_help: t.boolean, - can_about: t.boolean, - can_context: t.boolean, - can_jumbo: t.boolean, - jumbo_size: t.Integer, - can_avatar: t.boolean, - info_on_single_result: t.boolean, - autojoin_threads: t.boolean, +export const zUtilityConfig = z.strictObject({ + can_roles: z.boolean(), + can_level: z.boolean(), + can_search: z.boolean(), + can_clean: z.boolean(), + can_info: z.boolean(), + can_server: z.boolean(), + can_inviteinfo: z.boolean(), + can_channelinfo: z.boolean(), + can_messageinfo: z.boolean(), + can_userinfo: z.boolean(), + can_roleinfo: z.boolean(), + can_emojiinfo: z.boolean(), + can_snowflake: z.boolean(), + can_reload_guild: z.boolean(), + can_nickname: z.boolean(), + can_ping: z.boolean(), + can_source: z.boolean(), + can_vcmove: z.boolean(), + can_vckick: z.boolean(), + can_help: z.boolean(), + can_about: z.boolean(), + can_context: z.boolean(), + can_jumbo: z.boolean(), + jumbo_size: z.number(), + can_avatar: z.boolean(), + info_on_single_result: z.boolean(), + autojoin_threads: z.boolean(), }); -export type TConfigSchema = t.TypeOf; export interface UtilityPluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { logs: GuildLogs; cases: GuildCases; diff --git a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts index e007b9689..ba8f9de35 100644 --- a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts +++ b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts @@ -1,10 +1,9 @@ import { PluginOptions } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; -import { makeIoTsConfigParser } from "../../pluginUtils"; import { LogsPlugin } from "../Logs/LogsPlugin"; import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; import { SendWelcomeMessageEvt } from "./events/SendWelcomeMessageEvt"; -import { ConfigSchema, WelcomeMessagePluginType } from "./types"; +import { WelcomeMessagePluginType, zWelcomeMessageConfig } from "./types"; const defaultOptions: PluginOptions = { config: { @@ -19,11 +18,11 @@ export const WelcomeMessagePlugin = zeppelinGuildPlugin [LogsPlugin], - configParser: makeIoTsConfigParser(ConfigSchema), + configParser: (input) => zWelcomeMessageConfig.parse(input), defaultOptions, // prettier-ignore diff --git a/backend/src/plugins/WelcomeMessage/types.ts b/backend/src/plugins/WelcomeMessage/types.ts index 682cfe392..25e9afb16 100644 --- a/backend/src/plugins/WelcomeMessage/types.ts +++ b/backend/src/plugins/WelcomeMessage/types.ts @@ -1,17 +1,15 @@ -import * as t from "io-ts"; import { BasePluginType, guildPluginEventListener } from "knub"; +import z from "zod"; import { GuildLogs } from "../../data/GuildLogs"; -import { tNullable } from "../../utils"; -export const ConfigSchema = t.type({ - send_dm: t.boolean, - send_to_channel: tNullable(t.string), - message: tNullable(t.string), +export const zWelcomeMessageConfig = z.strictObject({ + send_dm: z.boolean(), + send_to_channel: z.string().nullable(), + message: z.string().nullable(), }); -export type TConfigSchema = t.TypeOf; export interface WelcomeMessagePluginType extends BasePluginType { - config: TConfigSchema; + config: z.infer; state: { logs: GuildLogs; sentWelcomeMessages: Set; diff --git a/backend/src/plugins/ZeppelinPluginBlueprint.ts b/backend/src/plugins/ZeppelinPluginBlueprint.ts index 165c67b5c..aa266abcf 100644 --- a/backend/src/plugins/ZeppelinPluginBlueprint.ts +++ b/backend/src/plugins/ZeppelinPluginBlueprint.ts @@ -1,4 +1,3 @@ -import * as t from "io-ts"; import { BasePluginType, globalPlugin, @@ -9,6 +8,7 @@ import { GuildPluginData, } from "knub"; import { TMarkdown } from "../types"; +import { ZodTypeAny } from "zod"; /** * GUILD PLUGINS @@ -23,7 +23,7 @@ export interface ZeppelinGuildPluginBlueprint; + configSchema?: ZodTypeAny; }; } diff --git a/backend/src/types.ts b/backend/src/types.ts index 45974aee2..409c4dc63 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,5 +1,6 @@ -import * as t from "io-ts"; import { BaseConfig, Knub } from "knub"; +import z from "zod"; +import { zSnowflake } from "./utils"; export interface ZeppelinGuildConfig extends BaseConfig { success_emoji?: string; @@ -10,31 +11,19 @@ export interface ZeppelinGuildConfig extends BaseConfig { date_formats?: any; } -export const ZeppelinGuildConfigSchema = t.type({ +export const zZeppelinGuildConfig = z.strictObject({ // From BaseConfig - prefix: t.string, - levels: t.record(t.string, t.number), - plugins: t.record(t.string, t.unknown), + prefix: z.string().optional(), + levels: z.record(zSnowflake, z.number()).optional(), + plugins: z.record(z.string(), z.unknown()).optional(), // From ZeppelinGuildConfig - success_emoji: t.string, - error_emoji: t.string, + success_emoji: z.string().optional(), + error_emoji: z.string().optional(), // Deprecated - timezone: t.string, - date_formats: t.unknown, -}); -export const PartialZeppelinGuildConfigSchema = t.partial(ZeppelinGuildConfigSchema.props); - -export interface ZeppelinGlobalConfig extends BaseConfig { - url: string; - owners?: string[]; -} - -export const ZeppelinGlobalConfigSchema = t.type({ - url: t.string, - owners: t.array(t.string), - plugins: t.record(t.string, t.unknown), + timezone: z.string().optional(), + date_formats: z.unknown().optional(), }); export type TZeppelinKnub = Knub; diff --git a/backend/src/utils.test.ts b/backend/src/utils.test.ts index f5c9f935b..461be47be 100644 --- a/backend/src/utils.test.ts +++ b/backend/src/utils.test.ts @@ -1,6 +1,6 @@ import test from "ava"; -import * as ioTs from "io-ts"; -import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, tAllowedMentions } from "./utils"; +import z from "zod"; +import { convertDelayStringToMS, convertMSToDelayString, getUrlsInString, zAllowedMentions } from "./utils"; import { ErisAllowedMentionFormat } from "./utils/erisAllowedMentionsToDjsMentionOptions"; type AssertEquals = TActual extends TExpected ? true : false; @@ -50,7 +50,7 @@ test("delay strings: reverse conversion (conservative)", (t) => { }); test("tAllowedMentions matches Eris's AllowedMentions", (t) => { - type TAllowedMentions = ioTs.TypeOf; + type TAllowedMentions = z.infer; // eslint-disable-next-line @typescript-eslint/no-unused-vars const typeTest: AssertEquals = true; t.pass(); diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 1bb4d6ce6..641a25734 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -21,31 +21,26 @@ import { PartialChannelData, PartialMessage, RoleResolvable, - Snowflake, Sticker, TextBasedChannel, User, } from "discord.js"; import emojiRegex from "emoji-regex"; -import { either } from "fp-ts/lib/Either"; -import { unsafeCoerce } from "fp-ts/lib/function"; import fs from "fs"; import https from "https"; import humanizeDuration from "humanize-duration"; -import * as t from "io-ts"; import { isEqual } from "lodash"; -import moment from "moment-timezone"; import { performance } from "perf_hooks"; import tlds from "tlds"; import tmp from "tmp"; import { URL } from "url"; -import { z, ZodError } from "zod"; +import { z, ZodEffects, ZodError, ZodRecord, ZodString } from "zod"; import { ISavedMessageAttachmentData, SavedMessage } from "./data/entities/SavedMessage"; import { getProfiler } from "./profiler"; import { SimpleCache } from "./SimpleCache"; import { sendDM } from "./utils/sendDM"; +import { Brand } from "./utils/typeUtils"; import { waitForButtonConfirm } from "./utils/waitForInteraction"; -import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils"; const fsp = fs.promises; @@ -91,71 +86,9 @@ export function isDiscordAPIError(err: Error | string): err is DiscordAPIError { return err instanceof DiscordAPIError; } -export function tNullable>(type: T) { - return t.union([type, t.undefined, t.null], `Nullable<${type.name}>`); -} - -export const tNormalizedNullOrUndefined = new t.Type( - "tNormalizedNullOrUndefined", - (v): v is undefined => typeof v === "undefined", - (v, c) => (v == null ? t.success(undefined) : t.failure(v, c, "Value must be null or undefined")), - () => undefined, -); - -/** - * Similar to `tNullable`, but normalizes both `null` and `undefined` to `undefined`. - * This allows adding optional config options that can be "removed" by setting the value to `null`. - */ -export function tNormalizedNullOptional>(type: T) { - return t.union( - [type, tNormalizedNullOrUndefined], - `Optional<${type.name}>`, // Simplified name for errors and config schema views - ); -} - -export type TDeepPartial = T extends t.InterfaceType - ? TDeepPartialProps - : T extends t.DictionaryType - ? t.DictionaryType> - : T extends t.UnionType - ? t.UnionType>> - : T extends t.IntersectionType - ? t.IntersectionType>> - : T extends t.ArrayType - ? t.ArrayType> - : T; - -// Based on t.PartialC -export interface TDeepPartialProps

- extends t.PartialType< - P, - { - [K in keyof P]?: TDeepPartial>; - }, - { - [K in keyof P]?: TDeepPartial>; - } - > {} - -export function tDeepPartial(type: T): TDeepPartial { - if (type instanceof t.InterfaceType || type instanceof t.PartialType) { - const newProps = {}; - for (const [key, prop] of Object.entries(type.props)) { - newProps[key] = tDeepPartial(prop); - } - return t.partial(newProps) as TDeepPartial; - } else if (type instanceof t.DictionaryType) { - return t.record(type.domain, tDeepPartial(type.codomain)) as TDeepPartial; - } else if (type instanceof t.UnionType) { - return t.union(type.types.map((unionType) => tDeepPartial(unionType))) as TDeepPartial; - } else if (type instanceof t.IntersectionType) { - const types = type.types.map((intersectionType) => tDeepPartial(intersectionType)); - return t.intersection(types as [t.Mixed, t.Mixed]) as unknown as TDeepPartial; - } else if (type instanceof t.ArrayType) { - return t.array(tDeepPartial(type.type)) as TDeepPartial; - } else { - return type as TDeepPartial; - } +// null | undefined -> undefined +export function zNullishToUndefined(type: T): ZodEffects> | undefined> { + return type.transform(v => v ?? undefined); } export function getScalarDifference( @@ -207,29 +140,6 @@ export function differenceToString(diff: Map): st // https://stackoverflow.com/a/49262929/316944 export type Not = T & Exclude; -// io-ts partial dictionary type -// From https://github.com/gcanti/io-ts/issues/429#issuecomment-655394345 -export interface PartialDictionaryC - extends t.DictionaryType< - D, - C, - { - [K in t.TypeOf]?: t.TypeOf; - }, - { - [K in t.OutputOf]?: t.OutputOf; - }, - unknown - > {} - -export const tPartialDictionary = ( - domain: D, - codomain: C, - name?: string, -): PartialDictionaryC => { - return unsafeCoerce(t.record(t.union([domain, t.undefined]), codomain, name)); -}; - export function nonNullish(v: V): v is NonNullable { return v != null; } @@ -240,69 +150,59 @@ export type GroupDMInvite = Invite & { type: typeof ChannelType.GroupDM; }; +function isBoundedString(str: unknown, min: number, max: number): str is string { + if (typeof str !== "string") { + return false; + } + return (str.length >= min && str.length <= max); +} + +export function zBoundedCharacters(min: number, max: number) { + return z.string().refine(str => { + const len = [...str].length; // Unicode aware character split + return (len >= min && len <= max); + }, { + message: `String must be between ${min} and ${max} characters long`, + }); +} + +export const zSnowflake = z.string().refine(str => isSnowflake(str), { + message: "Invalid snowflake ID", +}); + +const regexWithFlags = /^\/(.*?)\/([i]*)$/; + +export class InvalidRegexError extends Error {} + /** - * Mirrors EmbedOptions from Eris + * This function supports two input syntaxes for regexes: // and just */ -export const tEmbed = t.type({ - title: tNullable(t.string), - description: tNullable(t.string), - url: tNullable(t.string), - timestamp: tNullable(t.string), - color: tNullable(t.number), - footer: tNullable( - t.type({ - text: t.string, - icon_url: tNullable(t.string), - proxy_icon_url: tNullable(t.string), - }), - ), - image: tNullable( - t.type({ - url: tNullable(t.string), - proxy_url: tNullable(t.string), - width: tNullable(t.number), - height: tNullable(t.number), - }), - ), - thumbnail: tNullable( - t.type({ - url: tNullable(t.string), - proxy_url: tNullable(t.string), - width: tNullable(t.number), - height: tNullable(t.number), - }), - ), - video: tNullable( - t.type({ - url: tNullable(t.string), - width: tNullable(t.number), - height: tNullable(t.number), - }), - ), - provider: tNullable( - t.type({ - name: t.string, - url: tNullable(t.string), - }), - ), - fields: tNullable( - t.array( - t.type({ - name: tNullable(t.string), - value: tNullable(t.string), - inline: tNullable(t.boolean), - }), - ), - ), - author: tNullable( - t.type({ - name: t.string, - url: tNullable(t.string), - width: tNullable(t.number), - height: tNullable(t.number), - }), - ), -}); +export function inputPatternToRegExp(pattern: string) { + const advancedSyntaxMatch = pattern.match(regexWithFlags); + const [finalPattern, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [pattern, ""]; + try { + return new RegExp(finalPattern, flags); + } catch (e) { + throw new InvalidRegexError(e.message); + } +} + +export function zRegex(zStr: T) { + return zStr.transform((str, ctx) => { + try { + return inputPatternToRegExp(str); + } catch (err) { + if (err instanceof InvalidRegexError) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid regex" + }); + return z.NEVER; + } + throw err; + } + }); +} export const zEmbedInput = z.object({ title: z.string().optional(), @@ -387,15 +287,7 @@ export type StrictMessageContent = { embeds?: APIEmbed[]; }; -export const tStrictMessageContent = t.type({ - content: tNullable(t.string), - tts: tNullable(t.boolean), - disableEveryone: tNullable(t.boolean), - embed: tNullable(tEmbed), - embeds: tNullable(t.array(tEmbed)), -}); - -export const tMessageContent = t.union([t.string, tStrictMessageContent]); +export const zMessageContent = z.union([zBoundedCharacters(0, 4000), zStrictMessageContent]); export function validateAndParseMessageContent(input: unknown): StrictMessageContent { if (input == null) { @@ -454,11 +346,11 @@ function dropNullValuesRecursively(obj: any) { /** * Mirrors AllowedMentions from Eris */ -export const tAllowedMentions = t.type({ - everyone: tNormalizedNullOptional(t.boolean), - users: tNormalizedNullOptional(t.union([t.boolean, t.array(t.string)])), - roles: tNormalizedNullOptional(t.union([t.boolean, t.array(t.string)])), - repliedUser: tNormalizedNullOptional(t.boolean), +export const zAllowedMentions = z.strictObject({ + everyone: zNullishToUndefined(z.boolean().nullable().optional()), + users: zNullishToUndefined(z.union([z.boolean(), z.array(z.string())]).nullable().optional()), + roles: zNullishToUndefined(z.union([z.boolean(), z.array(z.string())]).nullable().optional()), + replied_user: zNullishToUndefined(z.boolean().nullable().optional()), }); export function dropPropertiesByName(obj, propName) { @@ -472,39 +364,18 @@ export function dropPropertiesByName(obj, propName) { } } -export const tAlphanumeric = new t.Type( - "tAlphanumeric", - (s): s is string => typeof s === "string", - (from, to) => - either.chain(t.string.validate(from, to), (s) => { - return s.match(/\W/) ? t.failure(from, to, "String must be alphanumeric") : t.success(s); - }), - (s) => s, -); - -export const tDateTime = new t.Type( - "tDateTime", - (s): s is string => typeof s === "string", - (from, to) => - either.chain(t.string.validate(from, to), (s) => { - const parsed = - s.length === 10 ? moment.utc(s, "YYYY-MM-DD") : s.length === 19 ? moment.utc(s, "YYYY-MM-DD HH:mm:ss") : null; - - return parsed && parsed.isValid() ? t.success(s) : t.failure(from, to, "Invalid datetime"); - }), - (s) => s, -); - -export const tDelayString = new t.Type( - "tDelayString", - (s): s is string => typeof s === "string", - (from, to) => - either.chain(t.string.validate(from, to), (s) => { - const ms = convertDelayStringToMS(s); - return ms === null ? t.failure(from, to, "Invalid delay string") : t.success(s); - }), - (s) => s, -); +export function zBoundedRecord>(record: TRecord, minKeys: number, maxKeys: number): ZodEffects { + return record.refine(data => { + const len = Object.keys(data).length; + return (len >= minKeys && len <= maxKeys); + }, { + message: `Object must have ${minKeys}-${maxKeys} keys`, + }); +} + +export const zDelayString = z.string().max(32).refine(str => convertDelayStringToMS(str) !== null, { + message: "Invalid delay string", +}); // To avoid running into issues with the JS max date vaLue, we cap maximum delay strings *far* below that. // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#The_ECMAScript_epoch_and_timestamps @@ -609,9 +480,11 @@ export function stripObjectToScalars(obj, includedNested: string[] = []) { export const snowflakeRegex = /[1-9][0-9]{5,19}/; +export type Snowflake = Brand; + const isSnowflakeRegex = new RegExp(`^${snowflakeRegex.source}$`); -export function isSnowflake(v: string): boolean { - return isSnowflakeRegex.test(v); +export function isSnowflake(v: unknown): v is Snowflake { + return typeof v === "string" && isSnowflakeRegex.test(v); } export function sleep(ms: number): Promise { @@ -1474,8 +1347,7 @@ export function messageLink(guildIdOrMessage: string | Message | null, channelId } export function isValidEmbed(embed: any): boolean { - const result = decodeAndValidateStrict(tEmbed, embed); - return !(result instanceof StrictValidationError); + return zEmbedInput.safeParse(embed).success; } const formatter = new Intl.NumberFormat("en-US"); @@ -1613,3 +1485,19 @@ export function renderUsername(username: string, discriminator: string): string export function renderUserUsername(user: User | UnknownUser): string { return renderUsername(user.username, user.discriminator); } + +type Entries = Array<{ + [Key in keyof T]-?: [Key, T[Key]]; +}[keyof T]>; + +export function entries(object: T) { + return Object.entries(object) as Entries; +} + +export function keys(object: T) { + return Object.keys(object) as Array; +} + +export function values(object: T) { + return Object.values(object) as Array; +} diff --git a/backend/src/utils/iotsUtils.ts b/backend/src/utils/iotsUtils.ts deleted file mode 100644 index a3636c840..000000000 --- a/backend/src/utils/iotsUtils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as t from "io-ts"; - -interface BoundedStringBrand { - readonly BoundedString: unique symbol; -} - -export function asBoundedString(str: string) { - return str as t.Branded; -} - -export function tBoundedString(min: number, max: number) { - return t.brand( - t.string, - (str): str is t.Branded => (str.length >= min && str.length <= max), - "BoundedString", - ); -} diff --git a/backend/src/utils/tColor.ts b/backend/src/utils/tColor.ts deleted file mode 100644 index f240f7f44..000000000 --- a/backend/src/utils/tColor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { either } from "fp-ts/lib/Either"; -import * as t from "io-ts"; -import { intToRgb } from "./intToRgb"; -import { parseColor } from "./parseColor"; -import { rgbToInt } from "./rgbToInt"; - -export const tColor = new t.Type( - "tColor", - (s): s is number => typeof s === "number", - (from, to) => - either.chain(t.string.validate(from, to), (input) => { - const parsedColor = parseColor(input); - return parsedColor == null ? t.failure(from, to, "Invalid color") : t.success(rgbToInt(parsedColor)); - }), - (s) => intToRgb(s).join(","), -); diff --git a/backend/src/utils/tValidTimezone.ts b/backend/src/utils/tValidTimezone.ts deleted file mode 100644 index 35fc97c54..000000000 --- a/backend/src/utils/tValidTimezone.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { either } from "fp-ts/lib/Either"; -import * as t from "io-ts"; -import { isValidTimezone } from "./isValidTimezone"; - -export const tValidTimezone = new t.Type( - "tValidTimezone", - (s): s is string => typeof s === "string", - (from, to) => - either.chain(t.string.validate(from, to), (input) => { - return isValidTimezone(input) ? t.success(input) : t.failure(from, to, `Invalid timezone: ${input}`); - }), - (s) => s, -); diff --git a/backend/src/utils/typeUtils.ts b/backend/src/utils/typeUtils.ts index 7c8b4cbd9..983015816 100644 --- a/backend/src/utils/typeUtils.ts +++ b/backend/src/utils/typeUtils.ts @@ -14,3 +14,11 @@ export type Awaitable = T | Promise; export type DeepMutable = { -readonly [P in keyof T]: DeepMutable; }; + +// From https://stackoverflow.com/a/70262876/316944 +export declare abstract class As { + private static readonly $as$: unique symbol; + private [As.$as$]: Record; +} + +export type Brand = T & As; diff --git a/backend/src/utils/zColor.ts b/backend/src/utils/zColor.ts new file mode 100644 index 000000000..1bc3ae924 --- /dev/null +++ b/backend/src/utils/zColor.ts @@ -0,0 +1,15 @@ +import z from "zod"; +import { parseColor } from "./parseColor"; +import { rgbToInt } from "./rgbToInt"; + +export const zColor = z.string().transform((val, ctx) => { + const parsedColor = parseColor(val); + if (parsedColor == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid color", + }); + return z.NEVER; + } + return rgbToInt(parsedColor); +}); diff --git a/backend/src/utils/zValidTimezone.ts b/backend/src/utils/zValidTimezone.ts new file mode 100644 index 000000000..b83c47c10 --- /dev/null +++ b/backend/src/utils/zValidTimezone.ts @@ -0,0 +1,8 @@ +import { ZodString } from "zod"; +import { isValidTimezone } from "./isValidTimezone"; + +export function zValidTimezone(z: Z) { + return z.refine((val) => isValidTimezone(val), { + message: "Invalid timezone", + }); +} diff --git a/backend/src/validation.test.ts b/backend/src/validation.test.ts deleted file mode 100644 index d41d6c4af..000000000 --- a/backend/src/validation.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import test from "ava"; -import * as t from "io-ts"; -import { tDeepPartial } from "./utils"; -import * as validatorUtils from "./validatorUtils"; - -test("tDeepPartial works", (ava) => { - const originalSchema = t.type({ - listOfThings: t.record( - t.string, - t.type({ - enabled: t.boolean, - someValue: t.number, - }), - ), - }); - - const deepPartialSchema = tDeepPartial(originalSchema); - - const partialValidValue = { - listOfThings: { - myThing: { - someValue: 5, - }, - }, - }; - - const partialErrorValue = { - listOfThings: { - myThing: { - someValue: "test", - }, - }, - }; - - const result1 = validatorUtils.validate(deepPartialSchema, partialValidValue); - ava.is(result1, null); - - const result2 = validatorUtils.validate(deepPartialSchema, partialErrorValue); - ava.not(result2, null); -}); diff --git a/backend/src/validatorUtils.ts b/backend/src/validatorUtils.ts deleted file mode 100644 index 31139596d..000000000 --- a/backend/src/validatorUtils.ts +++ /dev/null @@ -1,140 +0,0 @@ -import deepDiff from "deep-diff"; -import { either, fold, isLeft } from "fp-ts/lib/Either"; -import { pipe } from "fp-ts/lib/pipeable"; -import * as t from "io-ts"; -import { noop } from "./utils"; - -const regexWithFlags = /^\/(.*?)\/([i]*)$/; - -export class InvalidRegexError extends Error {} - -/** - * This function supports two input syntaxes for regexes: // and just - */ -export function inputPatternToRegExp(pattern: string) { - const advancedSyntaxMatch = pattern.match(regexWithFlags); - const [finalPattern, flags] = advancedSyntaxMatch ? [advancedSyntaxMatch[1], advancedSyntaxMatch[2]] : [pattern, ""]; - try { - return new RegExp(finalPattern, flags); - } catch (e) { - throw new InvalidRegexError(e.message); - } -} - -export const TRegex = new t.Type( - "TRegex", - (s): s is RegExp => s instanceof RegExp, - (from, to) => - either.chain(t.string.validate(from, to), (s) => { - try { - return t.success(inputPatternToRegExp(s)); - } catch (err) { - if (err instanceof InvalidRegexError) { - return t.failure(s, [], err.message); - } - - throw err; - } - }), - (s) => `/${s.source}/${s.flags}`, -); - -// From io-ts/lib/PathReporter -function stringify(v) { - if (typeof v === "function") { - return t.getFunctionName(v); - } - if (typeof v === "number" && !isFinite(v)) { - if (isNaN(v)) { - return "NaN"; - } - return v > 0 ? "Infinity" : "-Infinity"; - } - return JSON.stringify(v); -} - -export class StrictValidationError extends Error { - private readonly errors; - - constructor(errors: string[]) { - errors = Array.from(new Set(errors)); - super(errors.join("\n")); - this.errors = errors; - } - getErrors() { - return this.errors; - } -} - -const report = fold((errors: any): StrictValidationError | void => { - const errorStrings = errors.map((err) => { - const context = err.context.map((c) => c.key).filter((k) => k && !k.startsWith("{")); - while (context.length > 0 && !isNaN(context[context.length - 1])) context.splice(-1); - - const value = stringify(err.value); - return value === undefined - ? `<${context.join("/")}> is required` - : `Invalid value supplied to <${context.join("/")}>${err.message ? `: ${err.message}` : ""}`; - }); - - return new StrictValidationError(errorStrings); -}, noop); - -export function validate(schema: t.Type, value: any): StrictValidationError | null { - const validationResult = schema.decode(value); - return ( - pipe( - validationResult, - fold( - () => report(validationResult), - () => null, - ), - ) || null - ); -} - -export function parseIoTsSchema>(schema: T, value: unknown): t.TypeOf { - const decodeResult = schema.decode(value); - if (isLeft(decodeResult)) { - throw report(decodeResult); - } - return decodeResult.right; -} - -/** - * Decodes and validates the given value against the given schema while also disallowing extra properties - * See: https://github.com/gcanti/io-ts/issues/322 - */ -export function decodeAndValidateStrict( - schema: T, - value: any, - debug = false, -): StrictValidationError | any { - const validationResult = t.exact(schema).decode(value); - return pipe( - validationResult, - fold( - () => report(validationResult), - (result) => { - // Make sure there are no extra properties - if (debug) { - // tslint:disable-next-line:no-console - console.log( - "JSON.stringify() check:", - JSON.stringify(value) === JSON.stringify(result) - ? "they are the same, no excess" - : "they are not the same, might have excess", - result, - ); - } - if (JSON.stringify(value) !== JSON.stringify(result)) { - const diff = deepDiff(result, value); - const errors = diff.filter((d) => d.kind === "N").map((d) => `Unknown property <${d.path.join(".")}>`); - if (errors.length) return new StrictValidationError(errors); - } - - return result; - }, - ), - ); -} diff --git a/package-lock.json b/package-lock.json index 6fe63dc24..d853c8db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "lint-staged": "^9.4.2", "prettier": "^2.8.4", "prettier-plugin-organize-imports": "^3.2.2", + "ts-toolbelt": "^9.6.0", "tsc-watch": "^6.0.4", "typescript": "^5.0.4" } @@ -3483,6 +3484,12 @@ "node": ">=8.0" } }, + "node_modules/ts-toolbelt": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "dev": true + }, "node_modules/tsc-watch": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-6.0.4.tgz", diff --git a/package.json b/package.json index 0cec20930..0309d59c7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint-staged": "^9.4.2", "prettier": "^2.8.4", "prettier-plugin-organize-imports": "^3.2.2", + "ts-toolbelt": "^9.6.0", "tsc-watch": "^6.0.4", "typescript": "^5.0.4" },