From d62a4e26aecd066b9fa2517dcbaaf7213386ba94 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 5 Jul 2020 05:00:54 +0300 Subject: [PATCH] Knub 30 conversion base work; Work on Utility plugin Knub 30 conversion --- backend/src/customArgumentTypes.ts | 22 +- backend/src/index.ts | 26 +- backend/src/pluginUtils.ts | 62 ++++ backend/src/plugins/Automod/info.ts | 2 +- backend/src/plugins/Cases.ts | 2 +- backend/src/plugins/Utility/UtilityPlugin.ts | 30 ++ .../src/plugins/Utility/commands/LevelCmd.ts | 23 ++ .../src/plugins/Utility/commands/RolesCmd.ts | 113 +++++++ .../src/plugins/Utility/commands/SearchCmd.ts | 12 + .../src/plugins/Utility/commands/ServerCmd.ts | 126 ++++++++ backend/src/plugins/Utility/refreshMembers.ts | 20 ++ backend/src/plugins/Utility/types.ts | 44 +++ backend/src/plugins/ZeppelinPlugin.ts | 7 + .../src/plugins/ZeppelinPluginBlueprint.ts | 20 ++ backend/src/plugins/ZeppelinPluginClass.ts | 297 +----------------- backend/src/plugins/availablePlugins.ts | 14 +- backend/src/types.ts | 25 ++ backend/src/utils.ts | 64 +++- 18 files changed, 585 insertions(+), 324 deletions(-) create mode 100644 backend/src/pluginUtils.ts create mode 100644 backend/src/plugins/Utility/UtilityPlugin.ts create mode 100644 backend/src/plugins/Utility/commands/LevelCmd.ts create mode 100644 backend/src/plugins/Utility/commands/RolesCmd.ts create mode 100644 backend/src/plugins/Utility/commands/SearchCmd.ts create mode 100644 backend/src/plugins/Utility/commands/ServerCmd.ts create mode 100644 backend/src/plugins/Utility/refreshMembers.ts create mode 100644 backend/src/plugins/Utility/types.ts create mode 100644 backend/src/plugins/ZeppelinPlugin.ts create mode 100644 backend/src/plugins/ZeppelinPluginBlueprint.ts diff --git a/backend/src/customArgumentTypes.ts b/backend/src/customArgumentTypes.ts index ec7fef84..63e20424 100644 --- a/backend/src/customArgumentTypes.ts +++ b/backend/src/customArgumentTypes.ts @@ -7,7 +7,8 @@ import { UnknownUser, } from "./utils"; import { Client, GuildChannel, Message } from "eris"; -import { ICommandContext, TypeConversionError } from "knub"; +import { baseTypeHelpers, CommandContext, TypeConversionError } from "knub"; +import { createTypeHelper } from "knub-command-manager"; export const customArgumentTypes = { delay(value) { @@ -19,26 +20,26 @@ export const customArgumentTypes = { return result; }, - async resolvedUser(value, context: ICommandContext) { - const result = await resolveUser(context.bot, value); + async resolvedUser(value, context: CommandContext) { + const result = await resolveUser(context.pluginData.client, value); if (result == null || result instanceof UnknownUser) { throw new TypeConversionError(`User \`${disableCodeBlocks(value)}\` was not found`); } return result; }, - async resolvedUserLoose(value, context: ICommandContext) { - const result = await resolveUser(context.bot, value); + async resolvedUserLoose(value, context: CommandContext) { + const result = await resolveUser(context.pluginData.client, value); if (result == null) { throw new TypeConversionError(`Invalid user: \`${disableCodeBlocks(value)}\``); } return result; }, - async resolvedMember(value, context: ICommandContext) { + async resolvedMember(value, context: CommandContext) { if (!(context.message.channel instanceof GuildChannel)) return null; - const result = await resolveMember(context.bot, context.message.channel.guild, value); + const result = await resolveMember(context.pluginData.client, context.message.channel.guild, value); if (result == null) { throw new TypeConversionError( `Member \`${disableCodeBlocks(value)}\` was not found or they have left the server`, @@ -47,3 +48,10 @@ export const customArgumentTypes = { return result; }, }; + +export const customArgumentHelpers = { + delay: createTypeHelper(customArgumentTypes.delay), + resolvedUser: createTypeHelper(customArgumentTypes.resolvedUser), + resolvedUserLoose: createTypeHelper(customArgumentTypes.resolvedUserLoose), + resolvedMember: createTypeHelper(customArgumentTypes.resolvedMember), +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 38a7fdec..642e42ca 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,7 +4,7 @@ import yaml from "js-yaml"; import fs from "fs"; const fsp = fs.promises; -import { Knub, logger, PluginError, Plugin, IGlobalConfig, IGuildConfig } from "knub"; +import { Knub, logger, PluginError, pluginUtils } from "knub"; import { SimpleError } from "./SimpleError"; import { Configs } from "./data/Configs"; @@ -63,7 +63,7 @@ if (process.env.NODE_ENV === "production") { } // Verify required Node.js version -const REQUIRED_NODE_VERSION = "10.14.2"; +const REQUIRED_NODE_VERSION = "14.0.0"; const requiredParts = REQUIRED_NODE_VERSION.split(".").map(v => parseInt(v, 10)); const actualVersionParts = process.versions.node.split(".").map(v => parseInt(v, 10)); for (const [i, part] of actualVersionParts.entries()) { @@ -80,8 +80,6 @@ moment.tz.setDefault("UTC"); import { Client, TextChannel } from "eris"; import { connect } from "./data/db"; import { availablePlugins, availableGlobalPlugins, basePlugins } from "./plugins/availablePlugins"; -import { ZeppelinPluginClass } from "./plugins/ZeppelinPluginClass"; -import { customArgumentTypes } from "./customArgumentTypes"; import { errorMessage, isDiscordHTTPError, isDiscordRESTError, successMessage } from "./utils"; import { startUptimeCounter } from "./uptime"; import { AllowedGuilds } from "./data/AllowedGuilds"; @@ -89,6 +87,7 @@ import { IZeppelinGuildConfig, IZeppelinGlobalConfig } from "./types"; import { RecoverablePluginError } from "./RecoverablePluginError"; import { GuildLogs } from "./data/GuildLogs"; import { LogType } from "./data/LogType"; +import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; logger.info("Connecting to database"); connect().then(async () => { @@ -119,16 +118,13 @@ connect().then(async () => { /** * Plugins are enabled if they... * - are base plugins, i.e. always enabled, or - * - are dependencies of other enabled plugins, or * - are explicitly enabled in the guild config + * Dependencies are also automatically loaded by Knub. */ - async getEnabledPlugins(guildId, guildConfig): Promise { + async getEnabledPlugins(this: Knub, guildId, guildConfig): Promise { const configuredPlugins = guildConfig.plugins || {}; - const pluginNames: string[] = Array.from(this.plugins.keys()); - const plugins: Array = Array.from(this.plugins.values()); - const zeppelinPlugins: Array = plugins.filter( - p => p.prototype instanceof ZeppelinPluginClass, - ) as Array; + const pluginNames: string[] = Array.from(this.guildPlugins.keys()); + const plugins: Array = Array.from(this.guildPlugins.values()); const enabledBasePlugins = pluginNames.filter(n => basePlugins.includes(n)); const explicitlyEnabledPlugins = pluginNames.filter(pluginName => { @@ -136,14 +132,8 @@ connect().then(async () => { }); const enabledPlugins = new Set([...enabledBasePlugins, ...explicitlyEnabledPlugins]); - const pluginsEnabledAsDependencies = zeppelinPlugins.reduce((arr, pluginClass) => { - if (!enabledPlugins.has(pluginClass.pluginName)) return arr; - return arr.concat(pluginClass.dependencies); - }, []); - const finalEnabledPlugins = new Set([ ...basePlugins, - ...pluginsEnabledAsDependencies, ...explicitlyEnabledPlugins, ]); return Array.from(finalEnabledPlugins.values()); @@ -172,8 +162,6 @@ connect().then(async () => { threshold: 200, }, - customArgumentTypes, - sendSuccessMessageFn(channel, body) { const guildId = channel instanceof TextChannel ? channel.guild.id : undefined; const emoji = guildId ? bot.getLoadedGuild(guildId).config.success_emoji : undefined; diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts new file mode 100644 index 00000000..488ae2b0 --- /dev/null +++ b/backend/src/pluginUtils.ts @@ -0,0 +1,62 @@ +/** + * @file Utility functions that are plugin-instance-specific (i.e. use PluginData) + */ + +import { Member, TextChannel } from "eris"; +import { configUtils, helpers, PluginData, PluginOptions } from "knub"; +import { + decodeAndValidateStrict, + StrictValidationError, +} from "./validatorUtils"; +import { deepKeyIntersect, errorMessage, successMessage } from "./utils"; +import { ZeppelinPluginClass } from "./plugins/ZeppelinPluginClass"; +import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint"; + +const { getMemberLevel } = helpers; + +export function canActOn(pluginData: PluginData, member1: Member, member2: Member, allowSameLevel = false) { + if (member2.id === this.client.user.id) { + return false; + } + + const ourLevel = getMemberLevel(pluginData, member1); + const memberLevel = getMemberLevel(pluginData, member2); + return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel; +} + +export function pluginConfigPreprocessor(this: typeof ZeppelinPluginClass | ZeppelinPluginBlueprint, options: PluginOptions) { + const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, options.config) : options.config; + if (decodedConfig instanceof StrictValidationError) { + throw decodedConfig; + } + + const decodedOverrides = []; + for (const override of (options.overrides || [])) { + const overrideConfigMergedWithBaseConfig = configUtils.mergeConfig(options.config, override.config || {}); + const decodedOverrideConfig = this.configSchema + ? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig) + : overrideConfigMergedWithBaseConfig; + if (decodedOverrideConfig instanceof StrictValidationError) { + throw decodedOverrideConfig; + } + decodedOverrides.push({ + ...override, + config: deepKeyIntersect(decodedOverrideConfig, override.config || {}), + }); + } + + return { + config: decodedConfig, + overrides: decodedOverrides, + }; +} + +export function sendSuccessMessage(pluginData: PluginData, channel, body) { + const emoji = pluginData.guildConfig.success_emoji || undefined; + channel.createMessage(successMessage(body, emoji)); +} + +export function sendErrorMessage(pluginData: PluginData, channel, body) { + const emoji = pluginData.guildConfig.error_emoji || undefined; + channel.createMessage(errorMessage(body, emoji)); +} diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/info.ts index e8bd4b66..47938ec2 100644 --- a/backend/src/plugins/Automod/info.ts +++ b/backend/src/plugins/Automod/info.ts @@ -1,4 +1,4 @@ -import { PluginInfo, trimPluginDescription } from "../ZeppelinPluginClass"; +import { ZeppelinPluginInfo, trimPluginDescription } from "../ZeppelinPluginClass"; export const pluginInfo: PluginInfo = { prettyName: "Automod", diff --git a/backend/src/plugins/Cases.ts b/backend/src/plugins/Cases.ts index 7fa96cbb..c372c22b 100644 --- a/backend/src/plugins/Cases.ts +++ b/backend/src/plugins/Cases.ts @@ -4,7 +4,7 @@ import { CaseTypes } from "../data/CaseTypes"; import { Case } from "../data/entities/Case"; import moment from "moment-timezone"; import { CaseTypeColors } from "../data/CaseTypeColors"; -import { PluginInfo, trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; +import { ZeppelinPluginInfo, trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; import { GuildArchives } from "../data/GuildArchives"; import { IPluginOptions, logger } from "knub"; import { GuildLogs } from "../data/GuildLogs"; diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts new file mode 100644 index 00000000..b3279390 --- /dev/null +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -0,0 +1,30 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, UtilityPluginType } from "./types"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildCases } from "../../data/GuildCases"; +import { GuildSavedMessages } from "../../data/GuildSavedMessages"; +import { GuildArchives } from "../../data/GuildArchives"; +import { Supporters } from "../../data/Supporters"; +import { ServerCmd } from "./commands/ServerCmd"; +import { RolesCmd } from "./commands/RolesCmd"; +import { LevelCmd } from "./commands/LevelCmd"; + +export const UtilityPlugin = zeppelinPlugin()("utility", { + configSchema: ConfigSchema, + + commands: [ + LevelCmd, + RolesCmd, + ServerCmd, + ], + + onLoad({ state, guild }) { + state.logs = new GuildLogs(guild.id); + state.cases = GuildCases.getGuildInstance(guild.id); + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.archives = GuildArchives.getGuildInstance(guild.id); + state.supporters = new Supporters(); + + state.lastReload = Date.now(); + } +}); diff --git a/backend/src/plugins/Utility/commands/LevelCmd.ts b/backend/src/plugins/Utility/commands/LevelCmd.ts new file mode 100644 index 00000000..c917b2ea --- /dev/null +++ b/backend/src/plugins/Utility/commands/LevelCmd.ts @@ -0,0 +1,23 @@ +import { utilityCmd } from "../types"; +import { customArgumentHelpers as ct } from "../../../customArgumentTypes"; +import { helpers } from "knub"; + +const { getMemberLevel } = helpers; + +export const LevelCmd = utilityCmd( + "level", + { + member: ct.resolvedMember({ required: false }), + }, + + { + description: "Show the permission level of a user", + usage: "!level 106391128718245888", + }, + + ({ message, args, pluginData }) => { + const member = args.member || message.member; + const level = getMemberLevel(pluginData, member); + message.channel.createMessage(`The permission level of ${member.username}#${member.discriminator} is **${level}**`); + } +); diff --git a/backend/src/plugins/Utility/commands/RolesCmd.ts b/backend/src/plugins/Utility/commands/RolesCmd.ts new file mode 100644 index 00000000..482c614c --- /dev/null +++ b/backend/src/plugins/Utility/commands/RolesCmd.ts @@ -0,0 +1,113 @@ +import { utilityCmd } from "../types"; +import { baseTypeHelpers as t } from "knub"; +import { Role, TextChannel } from "eris"; +import { chunkArray, sorter, trimLines } from "../../../utils"; +import { refreshMembersIfNeeded } from "../refreshMembers"; +import { sendErrorMessage } from "../../../pluginUtils"; + +export const RolesCmd = utilityCmd( + "roles", + { + search: t.string({ catchAll: true }), + + counts: t.switchOption(), + sort: t.string({ option: true }), + }, + + { + description: "List all roles or roles matching a search", + usage: "!roles mod", + permission: "can_roles", + }, + + async ({ message: msg, args, pluginData }) => { + const { guild } = pluginData; + + let roles: Array<{ _memberCount?: number } & Role> = Array.from((msg.channel as TextChannel).guild.roles.values()); + let sort = args.sort; + + if (args.search) { + const searchStr = args.search.toLowerCase(); + roles = roles.filter(r => r.name.toLowerCase().includes(searchStr) || r.id === searchStr); + } + + if (args.counts) { + await refreshMembersIfNeeded(guild); + + // If the user requested role member counts as well, calculate them and sort the roles by their member count + const roleCounts: Map = Array.from(guild.members.values()).reduce((map, member) => { + for (const roleId of member.roles) { + if (!map.has(roleId)) map.set(roleId, 0); + map.set(roleId, map.get(roleId) + 1); + } + + return map; + }, new Map()); + + // The "everyone" role always has all members in it + roleCounts.set(guild.id, guild.memberCount); + + for (const role of roles) { + role._memberCount = roleCounts.has(role.id) ? roleCounts.get(role.id) : 0; + } + + if (!sort) sort = "-memberCount"; + roles.sort((a, b) => { + if (a._memberCount > b._memberCount) return -1; + if (a._memberCount < b._memberCount) return 1; + return 0; + }); + } else { + // Otherwise sort by name + roles.sort((a, b) => { + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + return 0; + }); + } + + if (!sort) sort = "name"; + + let sortDir: "ASC" | "DESC" = "ASC"; + if (sort && sort[0] === "-") { + sort = sort.slice(1); + sortDir = "DESC"; + } + + if (sort === "position" || sort === "order") { + roles.sort(sorter("position", sortDir)); + } else if (sort === "memberCount" && args.counts) { + roles.sort(sorter("_memberCount", sortDir)); + } else if (sort === "name") { + roles.sort(sorter(r => r.name.toLowerCase(), sortDir)); + } else { + sendErrorMessage(pluginData, msg.channel, "Unknown sorting method"); + return; + } + + const longestId = roles.reduce((longest, role) => Math.max(longest, role.id.length), 0); + + const chunks = chunkArray(roles, 20); + for (const [i, chunk] of chunks.entries()) { + const roleLines = chunk.map(role => { + const paddedId = role.id.padEnd(longestId, " "); + let line = `${paddedId} ${role.name}`; + if (role._memberCount != null) { + line += role._memberCount === 1 ? ` (${role._memberCount} member)` : ` (${role._memberCount} members)`; + } + return line; + }); + + if (i === 0) { + msg.channel.createMessage( + trimLines(` + ${args.search ? "Total roles found" : "Total roles"}: ${roles.length} + \`\`\`py\n${roleLines.join("\n")}\`\`\` + `), + ); + } else { + msg.channel.createMessage("```py\n" + roleLines.join("\n") + "```"); + } + } + } +); diff --git a/backend/src/plugins/Utility/commands/SearchCmd.ts b/backend/src/plugins/Utility/commands/SearchCmd.ts new file mode 100644 index 00000000..f6f16a69 --- /dev/null +++ b/backend/src/plugins/Utility/commands/SearchCmd.ts @@ -0,0 +1,12 @@ +import { utilityCmd } from "../types"; +import { baseTypeHelpers as t } from "knub"; + +export const SearchCmd = utilityCmd( + ["search", "s"], + { + query: t.string({ catchAll: true }), + + }, + {}, + () => {} +); diff --git a/backend/src/plugins/Utility/commands/ServerCmd.ts b/backend/src/plugins/Utility/commands/ServerCmd.ts new file mode 100644 index 00000000..718a8c86 --- /dev/null +++ b/backend/src/plugins/Utility/commands/ServerCmd.ts @@ -0,0 +1,126 @@ +import { CategoryChannel, EmbedOptions, TextChannel, VoiceChannel } from "eris"; +import moment from "moment-timezone"; +import { embedPadding, formatNumber, memoize, MINUTES, trimLines } from "../../../utils"; +import { utilityCmd } from "../types"; +import humanizeDuration from "humanize-duration"; + +export const ServerCmd = utilityCmd( + "server", + {}, + + { + permission: "can_server", + description: "Show information about the server", + usage: "!server", + }, + + async ({ message }) => { + const embed: EmbedOptions = { + fields: [], + color: parseInt("6b80cf", 16), + }; + + embed.thumbnail = { url: this.guild.iconURL }; + + const createdAt = moment(this.guild.createdAt); + const serverAge = humanizeDuration(moment().valueOf() - this.guild.createdAt, { + largest: 2, + round: true, + }); + + const owner = this.bot.users.get(this.guild.ownerID); + const ownerName = owner ? `${owner.username}#${owner.discriminator}` : "Unknown#0000"; + + embed.fields.push({ + name: `Server information - ${this.guild.name}`, + value: + trimLines(` + Created: **${serverAge} ago** (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")}) + Owner: **${ownerName}** (${this.guild.ownerID}) + Voice region: **${this.guild.region}** + ${this.guild.features.length > 0 ? "Features: " + this.guild.features.join(", ") : ""} + `) + embedPadding, + }); + + const restGuild = await memoize( + () => this.bot.getRESTGuild(this.guildId), + `getRESTGuild_${this.guildId}`, + 10 * MINUTES, + ); + + // For servers with a vanity URL, we can use the numbers from the invite for online count + // (which is nowadays usually more accurate for large servers) + const invite = this.guild.vanityURL + ? await memoize( + () => this.bot.getInvite(this.guild.vanityURL, true), + `getInvite_${this.guild.vanityURL}`, + 10 * MINUTES, + ) + : null; + + const totalMembers = invite ? invite.memberCount : this.guild.memberCount; + + const onlineMemberCount = invite + ? invite.presenceCount + : this.guild.members.filter(m => m.status !== "offline").length; + const offlineMemberCount = this.guild.memberCount - onlineMemberCount; + + let memberCountTotalLines = `Total: **${formatNumber(totalMembers)}**`; + if (restGuild.maxMembers) { + memberCountTotalLines += `\nMax: **${formatNumber(restGuild.maxMembers)}**`; + } + + let memberCountOnlineLines = `Online: **${formatNumber(onlineMemberCount)}**`; + if (restGuild.maxPresences) { + memberCountOnlineLines += `\nMax online: **${formatNumber(restGuild.maxPresences)}**`; + } + + embed.fields.push({ + name: "Members", + inline: true, + value: trimLines(` + ${memberCountTotalLines} + ${memberCountOnlineLines} + Offline: **${formatNumber(offlineMemberCount)}** + `), + }); + + const totalChannels = this.guild.channels.size; + const categories = this.guild.channels.filter(channel => channel instanceof CategoryChannel); + const textChannels = this.guild.channels.filter(channel => channel instanceof TextChannel); + const voiceChannels = this.guild.channels.filter(channel => channel instanceof VoiceChannel); + + embed.fields.push({ + name: "Channels", + inline: true, + value: + trimLines(` + Total: **${totalChannels}** / 500 + Categories: **${categories.length}** + Text: **${textChannels.length}** + Voice: **${voiceChannels.length}** + `) + embedPadding, + }); + + const maxEmojis = + { + 0: 50, + 1: 100, + 2: 150, + 3: 250, + }[this.guild.premiumTier] || 50; + + embed.fields.push({ + name: "Other stats", + inline: true, + value: + trimLines(` + Roles: **${this.guild.roles.size}** / 250 + Emojis: **${this.guild.emojis.length}** / ${maxEmojis} + Boosts: **${this.guild.premiumSubscriptionCount ?? 0}** (level ${this.guild.premiumTier}) + `) + embedPadding, + }); + + message.channel.createMessage({ embed }); + } +); diff --git a/backend/src/plugins/Utility/refreshMembers.ts b/backend/src/plugins/Utility/refreshMembers.ts new file mode 100644 index 00000000..af6f3c7e --- /dev/null +++ b/backend/src/plugins/Utility/refreshMembers.ts @@ -0,0 +1,20 @@ +import { Guild } from "eris"; +import { MINUTES, noop } from "../../utils"; + +const MEMBER_REFRESH_FREQUENCY = 10 * MINUTES; // How often to do a full member refresh when using commands that need it +const memberRefreshLog = new Map }>(); + +export async function refreshMembersIfNeeded(guild: Guild) { + const lastRefresh = memberRefreshLog.get(guild.id); + if (lastRefresh && Date.now() < lastRefresh.time + MEMBER_REFRESH_FREQUENCY) { + return lastRefresh.promise; + } + + const loadPromise = guild.fetchAllMembers().then(noop); + memberRefreshLog.set(guild.id, { + time: Date.now(), + promise: loadPromise, + }); + + return loadPromise; +} diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts new file mode 100644 index 00000000..c927034e --- /dev/null +++ b/backend/src/plugins/Utility/types.ts @@ -0,0 +1,44 @@ +import * as t from "io-ts"; +import { BasePluginType, command, eventListener } from "knub"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildCases } from "../../data/GuildCases"; +import { GuildSavedMessages } from "../../data/GuildSavedMessages"; +import { GuildArchives } from "../../data/GuildArchives"; +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_reload_guild: t.boolean, + can_nickname: t.boolean, + can_ping: t.boolean, + can_source: t.boolean, + can_vcmove: 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, +}); +export type TConfigSchema = t.TypeOf; + +export interface UtilityPluginType extends BasePluginType { + config: TConfigSchema; + state: { + logs: GuildLogs; + cases: GuildCases; + savedMessages: GuildSavedMessages; + archives: GuildArchives; + supporters: Supporters; + + lastReload: number; + }; +} + +export const utilityCmd = command(); +export const utilityEvent = eventListener(); diff --git a/backend/src/plugins/ZeppelinPlugin.ts b/backend/src/plugins/ZeppelinPlugin.ts new file mode 100644 index 00000000..0fe87f04 --- /dev/null +++ b/backend/src/plugins/ZeppelinPlugin.ts @@ -0,0 +1,7 @@ +import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; +import { ZeppelinPluginBlueprint } from "./ZeppelinPluginBlueprint"; + +// prettier-ignore +export type ZeppelinPlugin = + | typeof ZeppelinPluginClass + | ZeppelinPluginBlueprint; diff --git a/backend/src/plugins/ZeppelinPluginBlueprint.ts b/backend/src/plugins/ZeppelinPluginBlueprint.ts new file mode 100644 index 00000000..7c1715ab --- /dev/null +++ b/backend/src/plugins/ZeppelinPluginBlueprint.ts @@ -0,0 +1,20 @@ +import { BasePluginType, plugin, PluginBlueprint } from "knub"; +import * as t from "io-ts"; +import { pluginConfigPreprocessor } from "../pluginUtils"; + +export interface ZeppelinPluginBlueprint extends PluginBlueprint { + configSchema?: t.TypeC; +} + +export function zeppelinPlugin(name: string, blueprint: Omit): ZeppelinPluginBlueprint; +export function zeppelinPlugin(): + (name: string, blueprint: Omit, "name">) => ZeppelinPluginBlueprint; +export function zeppelinPlugin(...args) { + if (args.length) { + const blueprint: ZeppelinPluginBlueprint = plugin(...(args as Parameters)); + blueprint.configPreprocessor = pluginConfigPreprocessor.bind(blueprint); + return blueprint; + } else { + return zeppelinPlugin; + } +} diff --git a/backend/src/plugins/ZeppelinPluginClass.ts b/backend/src/plugins/ZeppelinPluginClass.ts index a8f8b483..4056a807 100644 --- a/backend/src/plugins/ZeppelinPluginClass.ts +++ b/backend/src/plugins/ZeppelinPluginClass.ts @@ -1,301 +1,16 @@ -import { BasePluginType, configUtils, logger, PluginClass, PluginOptions, BasePluginConfig } from "knub"; +import { BasePluginType, PluginClass, PluginOptions } from "knub"; import * as t from "io-ts"; -import { - deepKeyIntersect, - isDiscordRESTError, - isSnowflake, - isUnicodeEmoji, - MINUTES, - Not, - resolveMember, - resolveRoleId, - resolveUser, - resolveUserId, - tDeepPartial, - trimEmptyStartEndLines, - trimIndents, - UnknownUser, -} from "../utils"; -import { Invite, Member, User } from "eris"; -import { performance } from "perf_hooks"; -import { decodeAndValidateStrict, StrictValidationError, validate } from "../validatorUtils"; -import { SimpleCache } from "../SimpleCache"; -import { TZeppelinKnub } from "../types"; -import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError"; - -const SLOW_RESOLVE_THRESHOLD = 1500; - -/** - * Wrapper for the string type that indicates the text will be parsed as Markdown later - */ -type TMarkdown = string; - -export interface PluginInfo { - prettyName: string; - description?: TMarkdown; - usageGuide?: TMarkdown; - configurationGuide?: TMarkdown; -} - -export interface CommandInfo { - description?: TMarkdown; - basicUsage?: TMarkdown; - examples?: TMarkdown; - usageGuide?: TMarkdown; - parameterDescriptions?: { - [key: string]: TMarkdown; - }; - optionDescriptions?: { - [key: string]: TMarkdown; - }; -} - -export function trimPluginDescription(str) { - const emptyLinesTrimmed = trimEmptyStartEndLines(str); - const lines = emptyLinesTrimmed.split("\n"); - const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length; - return trimIndents(emptyLinesTrimmed, firstLineIndentation); -} - -const inviteCache = new SimpleCache>(10 * MINUTES, 200); +import { TZeppelinKnub, ZeppelinPluginInfo } from "../types"; +import { pluginConfigPreprocessor } from "../pluginUtils"; export class ZeppelinPluginClass extends PluginClass { - public static pluginInfo: PluginInfo; + public static pluginInfo: ZeppelinPluginInfo; public static showInDocs: boolean = true; - public static configSchema: t.TypeC; - public static dependencies = []; protected readonly knub: TZeppelinKnub; - protected throwRecoverablePluginError(code: ERRORS) { - throw new RecoverablePluginError(code, this.guild); - } - - protected canActOn(member1: Member, member2: Member, allowSameLevel = false) { - if (member2.id === this.client.user.id) { - return false; - } - - const ourLevel = this.getMemberLevel(member1); - const memberLevel = this.getMemberLevel(member2); - return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel; - } - - /** - * Since we want to do type checking without creating instances of every plugin, - * we need a static version of getDefaultOptions(). This static version is then, - * by turn, called from getDefaultOptions() so everything still works as expected. - */ - public static getStaticDefaultOptions() { - // Implemented by plugin - return {}; - } - - /** - * Wrapper to fetch the real default options from getStaticDefaultOptions() - */ - protected getDefaultOptions(): PluginOptions { - return (this.constructor as typeof ZeppelinPluginClass).getStaticDefaultOptions() as PluginOptions; - } - - /** - * Allows the plugin to preprocess the config before it's validated. - * Useful for e.g. adding default properties to dynamic objects. - */ - protected static preprocessStaticConfig(config: any) { - return config; - } - - /** - * Merges the given options and default options and decodes them according to the config schema of the plugin (if any). - * Throws on any decoding/validation errors. - * - * Intended as an augmented, static replacement for Plugin.getMergedConfig() which is why this is also called from - * getMergedConfig(). - * - * Like getStaticDefaultOptions(), we also want to use this function for type checking without creating an instance of - * the plugin, which is why this has to be a static function. - */ - protected static mergeAndDecodeStaticOptions(options: any): PluginOptions { - if (options == null) { - options = { - enabled: false, - }; - } - - const defaultOptions: any = this.getStaticDefaultOptions(); - let mergedConfig = configUtils.mergeConfig({}, defaultOptions.config || {}, options.config || {}); - const mergedOverrides = options.replaceDefaultOverrides - ? options.overrides - : (defaultOptions.overrides || []).concat(options.overrides || []); - - // Before preprocessing the static config, do a loose check by checking the schema as deeply partial. - // This way the preprocessing function can trust that if a property exists, its value will be the correct (partial) type. - const initialLooseCheck = this.configSchema ? validate(tDeepPartial(this.configSchema), mergedConfig) : null; - if (initialLooseCheck) { - throw initialLooseCheck; - } - - mergedConfig = this.preprocessStaticConfig(mergedConfig); - - const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; - if (decodedConfig instanceof StrictValidationError) { - throw decodedConfig; - } - - const decodedOverrides = []; - for (const override of mergedOverrides) { - const overrideConfigMergedWithBaseConfig = configUtils.mergeConfig({}, mergedConfig, override.config || {}); - const decodedOverrideConfig = this.configSchema - ? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig) - : overrideConfigMergedWithBaseConfig; - if (decodedOverrideConfig instanceof StrictValidationError) { - throw decodedOverrideConfig; - } - decodedOverrides.push({ - ...override, - config: deepKeyIntersect(decodedOverrideConfig, override.config || {}), - }); - } - - return { - config: decodedConfig, - overrides: decodedOverrides, - }; - } - - /** - * Wrapper that calls mergeAndValidateStaticOptions() - */ - protected getMergedOptions(): PluginOptions { - if (!this.mergedPluginOptions) { - this.mergedPluginOptions = ((this - .constructor as unknown) as typeof ZeppelinPluginClass).mergeAndDecodeStaticOptions(this.pluginOptions); - } - - return this.mergedPluginOptions as PluginOptions; - } - - /** - * Run static type checks and other validations on the given options - */ - public static validateOptions(options: any): string[] | null { - // Validate config values - if (this.configSchema) { - try { - this.mergeAndDecodeStaticOptions(options); - } catch (e) { - if (e instanceof StrictValidationError) { - return e.getErrors(); - } - - throw e; - } - } - - // No errors, return null - return null; - } - - public async runLoad(): Promise { - const mergedOptions = this.getMergedOptions(); // This implicitly also validates the config - return super.runLoad(); - } - - public canUseEmoji(snowflake): boolean { - if (isUnicodeEmoji(snowflake)) { - return true; - } else if (isSnowflake(snowflake)) { - for (const guild of this.client.guilds.values()) { - if (guild.emojis.some(e => (e as any).id === snowflake)) { - return true; - } - } - } else { - this.throwRecoverablePluginError(ERRORS.INVALID_EMOJI); - } - } - - /** - * Intended for cross-plugin functionality - */ - public getRuntimeOptions() { - return this.getMergedOptions(); - } - - getUser(userResolvable: string): User | UnknownUser { - const id = resolveUserId(this.client, userResolvable); - return id ? this.client.users.get(id) || new UnknownUser({ id }) : new UnknownUser(); - } - - /** - * Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. - * If the user is not found in the cache, it's fetched from the API. - */ - async resolveUser(userResolvable: string): Promise; - async resolveUser(userResolvable: Not): Promise; - async resolveUser(userResolvable) { - const start = performance.now(); - const user = await resolveUser(this.client, userResolvable); - const time = performance.now() - start; - if (time >= SLOW_RESOLVE_THRESHOLD) { - const rounded = Math.round(time); - logger.warn(`Slow user resolve (${rounded}ms): ${userResolvable}`); - } - return user; - } - - /** - * Resolves a role from the passed string. The passed string can be a role ID, a role mention or a role name. - * In the event of duplicate role names, this function will return the first one it comes across. - * @param roleResolvable - */ - async resolveRoleId(roleResolvable: string): Promise { - const roleId = await resolveRoleId(this.client, this.guildId, roleResolvable); - return roleId; - } - - /** - * Resolves a member from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. - * If the member is not found in the cache, it's fetched from the API. - */ - async getMember(memberResolvable: string, forceFresh = false): Promise { - const start = performance.now(); - - let member; - if (forceFresh) { - const userId = await resolveUserId(this.client, memberResolvable); - try { - member = userId && (await this.client.getRESTGuildMember(this.guild.id, userId)); - } catch (e) { - if (!isDiscordRESTError(e)) { - throw e; - } - } - - if (member) member.id = member.user.id; - } else { - member = await resolveMember(this.client, this.guild, memberResolvable); - } - - const time = performance.now() - start; - if (time >= SLOW_RESOLVE_THRESHOLD) { - const rounded = Math.round(time); - logger.warn(`Slow member resolve (${rounded}ms): ${memberResolvable} in ${this.guild.name} (${this.guild.id})`); - } - - return member; - } - - async resolveInvite(code: string): Promise { - if (inviteCache.has(code)) { - return inviteCache.get(code); - } - - const promise = this.client.getInvite(code).catch(() => null); - inviteCache.set(code, promise); - - return promise; + public static configPreprocessor(options: PluginOptions) { + return pluginConfigPreprocessor.bind(this)(options); } } diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 9826307e..8dbb5816 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -33,7 +33,7 @@ import { AutoDeletePlugin } from "./AutoDelete"; /** * Plugins available to be loaded for individual guilds */ -export const availablePlugins = [ +export const oldAvailablePlugins = [ AutomodPlugin, MessageSaverPlugin, NameHistoryPlugin, @@ -67,7 +67,7 @@ export const availablePlugins = [ /** * Plugins that are always loaded (subset of the names of the plugins in availablePlugins) */ -export const basePlugins = [ +export const oldBasePlugins = [ GuildInfoSaverPlugin.pluginName, MessageSaverPlugin.pluginName, NameHistoryPlugin.pluginName, @@ -78,4 +78,12 @@ export const basePlugins = [ /** * Available global plugins (can't be loaded per-guild, only globally) */ -export const availableGlobalPlugins = [BotControlPlugin, UsernameSaver, GuildConfigReloader]; +export const oldAvailableGlobalPlugins = [BotControlPlugin, UsernameSaver, GuildConfigReloader]; + +export const availablePlugins = [ + UtilityPlugin, +]; + +export const basePlugins = []; + +export const availableGlobalPlugins = []; diff --git a/backend/src/types.ts b/backend/src/types.ts index dd156404..99fc3ae2 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -11,3 +11,28 @@ export interface IZeppelinGlobalConfig extends BaseConfig { } export type TZeppelinKnub = Knub; + +/** + * Wrapper for the string type that indicates the text will be parsed as Markdown later + */ +type TMarkdown = string; + +export interface ZeppelinPluginInfo { + prettyName: string; + description?: TMarkdown; + usageGuide?: TMarkdown; + configurationGuide?: TMarkdown; +} + +export interface CommandInfo { + description?: TMarkdown; + basicUsage?: TMarkdown; + examples?: TMarkdown; + usageGuide?: TMarkdown; + parameterDescriptions?: { + [key: string]: TMarkdown; + }; + optionDescriptions?: { + [key: string]: TMarkdown; + }; +} diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 999d1897..201cb6e4 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,5 +1,5 @@ import { - Attachment, + Attachment, ChannelInvite, Client, Embed, EmbedOptions, @@ -7,7 +7,7 @@ import { Guild, GuildAuditLog, GuildAuditLogEntry, - GuildChannel, + GuildChannel, Invite, Member, Message, MessageContent, @@ -31,6 +31,9 @@ import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils import { either } from "fp-ts/lib/Either"; import safeRegex from "safe-regex"; import moment from "moment-timezone"; +import { performance } from "perf_hooks"; +import { ERRORS } from "./RecoverablePluginError"; +import { SimpleCache } from "./SimpleCache"; const delayStringMultipliers = { w: 1000 * 60 * 60 * 24 * 7, @@ -937,6 +940,19 @@ export function resolveUserId(bot: Client, value: string) { return null; } +/** + * Finds a matching User for the passed user id, user mention, or full username (with discriminator). + * If a user is not found, returns an UnknownUser instead. + */ +export function getUser(userResolvable: string): User | UnknownUser { + const id = resolveUserId(this.client, userResolvable); + return id ? this.client.users.get(id) || new UnknownUser({ id }) : new UnknownUser(); +} + +/** + * Resolves a User from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. + * If the user is not found in the cache, it's fetched from the API. + */ export async function resolveUser(bot: Client, value: string): Promise; export async function resolveUser(bot: Client, value: Not): Promise; export async function resolveUser(bot, value) { @@ -972,6 +988,10 @@ export async function resolveUser(bot, value) { return new UnknownUser({ id: userId }); } +/** + * Resolves a guild Member from the passed user id, user mention, or full username (with discriminator). + * If the member is not found in the cache, it's fetched from the API. + */ export async function resolveMember(bot: Client, guild: Guild, value: string): Promise { const userId = resolveUserId(bot, value); if (!userId) return null; @@ -1002,6 +1022,12 @@ export async function resolveMember(bot: Client, guild: Guild, value: string): P return null; } +/** + * Resolves a role from the passed role ID, role mention, or role name. + * In the event of duplicate role names, this function will return the first one it comes across. + * + * FIXME: Define "first one it comes across" better + */ export async function resolveRoleId(bot: Client, guildId: string, value: string) { if (value == null) { return null; @@ -1028,6 +1054,19 @@ export async function resolveRoleId(bot: Client, guildId: string, value: string) return null; } +const inviteCache = new SimpleCache>(10 * MINUTES, 200); + +export async function resolveInvite(code: string): Promise { + if (inviteCache.has(code)) { + return inviteCache.get(code); + } + + const promise = this.client.getInvite(code).catch(() => null); + inviteCache.set(code, promise); + + return promise; +} + export async function confirm(bot: Client, channel: TextableChannel, userId: string, content: MessageContent) { const msg = await channel.createMessage(content); const reply = await waitForReaction(bot, msg, ["✅", "❌"], userId); @@ -1152,3 +1191,24 @@ export async function renderRecursively(value, fn: RecursiveRenderFn) { return value; } + +export function canUseEmoji(client: Client, emoji: string): boolean { + if (isUnicodeEmoji(emoji)) { + return true; + } else if (isSnowflake(emoji)) { + for (const guild of client.guilds.values()) { + if (guild.emojis.some(e => (e as any).id === emoji)) { + return true; + } + } + } else { + throw new Error(`Invalid emoji ${emoji}`); + } +} + +export function trimPluginDescription(str) { + const emptyLinesTrimmed = trimEmptyStartEndLines(str); + const lines = emptyLinesTrimmed.split("\n"); + const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length; + return trimIndents(emptyLinesTrimmed, firstLineIndentation); +}