/** * @file Utility functions that are plugin-instance-specific (i.e. use PluginData) */ import { GuildMember, Message, MessageMentionOptions, MessageOptions, TextChannel } from "discord.js"; import * as t from "io-ts"; import { CommandContext, configUtils, ConfigValidationError, GuildPluginData, helpers, PluginOptions } from "knub"; import { PluginOverrideCriteria } from "knub/dist/config/configTypes"; import { ExtendedMatchParams } from "knub/dist/config/PluginConfigManager"; // TODO: Export from Knub index import { AnyPluginData } from "knub/dist/plugins/PluginData"; import { logger } from "./logger"; import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; import { TZeppelinKnub } from "./types"; import { deepKeyIntersect, errorMessage, successMessage, tDeepPartial, tNullable } from "./utils"; import { Tail } from "./utils/typeUtils"; import { decodeAndValidateStrict, StrictValidationError, validate } from "./validatorUtils"; const { getMemberLevel } = helpers; export function canActOn( pluginData: GuildPluginData, member1: GuildMember, member2: GuildMember, allowSameLevel = false, ) { if (member2.id === pluginData.client.user!.id) { return false; } const ourLevel = getMemberLevel(pluginData, member1); const memberLevel = getMemberLevel(pluginData, member2); return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel; } export async function hasPermission( pluginData: AnyPluginData, permission: string, matchParams: ExtendedMatchParams, ) { const config = await pluginData.config.getMatchingConfig(matchParams); 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, }), ); const validTopLevelOverrideKeys = [ "channel", "category", "level", "user", "role", "all", "any", "not", "extra", "config", ]; const BasicPluginStructureType = t.type({ enabled: tNullable(t.boolean), config: tNullable(t.unknown), overrides: tNullable(t.array(t.union([PluginOverrideCriteriaType, t.type({ config: t.unknown })]))), replaceDefaultOverrides: tNullable(t.boolean), }); export function strictValidationErrorToConfigValidationError(err: StrictValidationError) { return new ConfigValidationError( err .getErrors() .map(e => e.toString()) .join("\n"), ); } export function getPluginConfigPreprocessor( blueprint: ZeppelinPlugin, customPreprocessor?: ZeppelinPlugin["configPreprocessor"], ) { return async (options: PluginOptions, strict?: boolean) => { // 1. Validate the basic structure of plugin config const basicOptionsValidation = validate(BasicPluginStructureType, options); if (basicOptionsValidation instanceof StrictValidationError) { throw strictValidationErrorToConfigValidationError(basicOptionsValidation); } // 2. Validate config/overrides against *partial* config schema. This ensures valid properties have valid types. const partialConfigSchema = tDeepPartial(blueprint.configSchema); if (options.config) { const partialConfigValidation = validate(partialConfigSchema, options.config); if (partialConfigValidation instanceof StrictValidationError) { throw strictValidationErrorToConfigValidationError(partialConfigValidation); } } if (options.overrides) { for (const override of options.overrides) { // Validate criteria and extra criteria // FIXME: This is ugly for (const key of Object.keys(override)) { if (!validTopLevelOverrideKeys.includes(key)) { if (strict) { throw new ConfigValidationError(`Unknown override criterion '${key}'`); } delete override[key]; } } if (override.extra != null) { for (const extraCriterion of Object.keys(override.extra)) { if (!blueprint.customOverrideCriteriaFunctions?.[extraCriterion]) { if (strict) { throw new ConfigValidationError(`Unknown override extra criterion '${extraCriterion}'`); } delete override.extra[extraCriterion]; } } } // Validate override config const partialOverrideConfigValidation = decodeAndValidateStrict(partialConfigSchema, override.config || {}); if (partialOverrideConfigValidation instanceof StrictValidationError) { throw strictValidationErrorToConfigValidationError(partialOverrideConfigValidation); } } } // 3. Run custom preprocessor, if any if (customPreprocessor) { options = await customPreprocessor(options); } // 4. Merge with default options and validate/decode the entire config let decodedConfig = {}; const decodedOverrides: Array & { config: any }> = []; if (options.config) { decodedConfig = blueprint.configSchema ? decodeAndValidateStrict(blueprint.configSchema, options.config) : options.config; if (decodedConfig instanceof StrictValidationError) { throw strictValidationErrorToConfigValidationError(decodedConfig); } } if (options.overrides) { for (const override of options.overrides) { const overrideConfigMergedWithBaseConfig = configUtils.mergeConfig(options.config, override.config || {}); const decodedOverrideConfig = blueprint.configSchema ? decodeAndValidateStrict(blueprint.configSchema, overrideConfigMergedWithBaseConfig) : overrideConfigMergedWithBaseConfig; if (decodedOverrideConfig instanceof StrictValidationError) { throw strictValidationErrorToConfigValidationError(decodedOverrideConfig); } decodedOverrides.push({ ...override, config: deepKeyIntersect(decodedOverrideConfig, override.config || {}), }); } } return { config: decodedConfig, overrides: decodedOverrides, }; }; } export async function sendSuccessMessage( pluginData: AnyPluginData, channel: TextChannel, body: string, allowedMentions?: MessageMentionOptions, ): Promise { const emoji = pluginData.fullConfig.success_emoji || undefined; const formattedBody = successMessage(body, emoji); const content: MessageOptions = allowedMentions ? { content: formattedBody, allowedMentions } : { content: formattedBody }; return channel .send({ ...content }) // Force line break .catch(err => { const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : `${channel.id}`; logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`); return undefined; }); } export async function sendErrorMessage( pluginData: AnyPluginData, channel: TextChannel, body: string, allowedMentions?: MessageMentionOptions, ): Promise { const emoji = pluginData.fullConfig.error_emoji || undefined; const formattedBody = errorMessage(body, emoji); const content: MessageOptions = allowedMentions ? { content: formattedBody, allowedMentions } : { content: formattedBody }; return channel .send({ ...content }) // Force line break .catch(err => { const channelInfo = channel.guild ? `${channel.id} (${channel.guild.id})` : `${channel.id}`; logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`); return undefined; }); } export function getBaseUrl(pluginData: AnyPluginData) { const knub = pluginData.getKnubInstance() as TZeppelinKnub; return knub.getGlobalConfig().url; } export function isOwner(pluginData: AnyPluginData, userId: string) { const knub = pluginData.getKnubInstance() as TZeppelinKnub; const owners = knub.getGlobalConfig().owners; if (!owners) { return false; } return owners.includes(userId); } export const isOwnerPreFilter = (_, context: CommandContext) => { return isOwner(context.pluginData, context.message.author.id); }; type AnyFn = (...args: any[]) => any; /** * Creates a public plugin function out of a function with pluginData as the first parameter */ export function mapToPublicFn(inputFn: T) { return pluginData => { return (...args: Tail>): ReturnType => { return inputFn(pluginData, ...args); }; }; }