diff --git a/backend/src/customArgumentTypes.ts b/backend/src/commandTypes.ts similarity index 77% rename from backend/src/customArgumentTypes.ts rename to backend/src/commandTypes.ts index 87879cc5..4d46bfa5 100644 --- a/backend/src/customArgumentTypes.ts +++ b/backend/src/commandTypes.ts @@ -7,10 +7,12 @@ import { UnknownUser, } from "./utils"; import { Client, GuildChannel, Member, Message, User } from "eris"; -import { baseTypeHelpers, CommandContext, TypeConversionError } from "knub"; +import { baseTypeConverters, baseTypeHelpers, CommandContext, TypeConversionError } from "knub"; import { createTypeHelper } from "knub-command-manager"; -export const customArgumentTypes = { +export const commandTypes = { + ...baseTypeConverters, + delay(value) { const result = convertDelayStringToMS(value); if (result == null) { @@ -49,9 +51,11 @@ export const customArgumentTypes = { }, }; -export const customArgumentHelpers = { - delay: createTypeHelper(customArgumentTypes.delay), - resolvedUser: createTypeHelper>(customArgumentTypes.resolvedUser), - resolvedUserLoose: createTypeHelper>(customArgumentTypes.resolvedUserLoose), - resolvedMember: createTypeHelper>(customArgumentTypes.resolvedMember), +export const commandTypeHelpers = { + ...baseTypeHelpers, + + delay: createTypeHelper(commandTypes.delay), + resolvedUser: createTypeHelper>(commandTypes.resolvedUser), + resolvedUserLoose: createTypeHelper>(commandTypes.resolvedUserLoose), + resolvedMember: createTypeHelper>(commandTypes.resolvedMember), }; diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 2794ac43..37a974a6 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -11,18 +11,34 @@ import { LevelCmd } from "./commands/LevelCmd"; import { SearchCmd } from "./commands/SearchCmd"; import { BanSearchCmd } from "./commands/BanSearchCmd"; import { InfoCmd } from "./commands/InfoCmd"; +import { NicknameResetCmd } from "./commands/NicknameResetCmd"; +import { NicknameCmd } from "./commands/NicknameCmd"; +import { PingCmd } from "./commands/PingCmd"; +import { SourceCmd } from "./commands/SourceCmd"; +import { ContextCmd } from "./commands/ContextCmd"; +import { VcmoveCmd } from "./commands/VcmoveCmd"; +import { HelpCmd } from "./commands/HelpCmd"; +import { AboutCmd } from "./commands/AboutCmd"; export const UtilityPlugin = zeppelinPlugin()("utility", { configSchema: ConfigSchema, // prettier-ignore commands: [ + SearchCmd, BanSearchCmd, InfoCmd, LevelCmd, RolesCmd, - SearchCmd, ServerCmd, + NicknameResetCmd, + NicknameCmd, + PingCmd, + SourceCmd, + ContextCmd, + VcmoveCmd, + HelpCmd, + AboutCmd, ], onLoad({ state, guild }) { diff --git a/backend/src/plugins/Utility/commands/AboutCmd.ts b/backend/src/plugins/Utility/commands/AboutCmd.ts new file mode 100644 index 00000000..22024d9a --- /dev/null +++ b/backend/src/plugins/Utility/commands/AboutCmd.ts @@ -0,0 +1,116 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { messageLink, multiSorter, resolveMember, sorter } from "../../../utils"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { GuildChannel, MessageContent, TextChannel } from "eris"; +import { getCurrentUptime } from "../../../uptime"; +import humanizeDuration from "humanize-duration"; +import LCL from "last-commit-log"; +import path from "path"; +import moment from "moment-timezone"; + +export const AboutCmd = utilityCmd({ + trigger: "about", + description: "Show information about Zeppelin's status on the server", + permission: "can_about", + + async run({ message: msg, pluginData }) { + const uptime = getCurrentUptime(); + const prettyUptime = humanizeDuration(uptime, { largest: 2, round: true }); + + let lastCommit; + + try { + // From project root + // FIXME: Store these paths properly somewhere + const lcl = new LCL(path.resolve(__dirname, "..", "..", "..")); + lastCommit = await lcl.getLastCommit(); + } catch (e) {} // tslint:disable-line:no-empty + + let lastUpdate; + let version; + + if (lastCommit) { + lastUpdate = moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]"); + version = lastCommit.shortHash; + } else { + lastUpdate = "?"; + version = "?"; + } + + const shard = pluginData.client.shards.get(pluginData.client.guildShardMap[pluginData.guild.id]); + + const lastReload = humanizeDuration(Date.now() - pluginData.state.lastReload, { + largest: 2, + round: true, + }); + + const basicInfoRows = [ + ["Uptime", prettyUptime], + ["Last reload", `${lastReload} ago`], + ["Last update", lastUpdate], + ["Version", version], + ["API latency", `${shard.latency}ms`], + ]; + + const loadedPlugins = Array.from( + pluginData + .getKnubInstance() + .getLoadedGuild(pluginData.guild.id) + .loadedPlugins.keys(), + ); + loadedPlugins.sort(); + + const aboutContent: MessageContent = { + embed: { + title: `About ${pluginData.client.user.username}`, + fields: [ + { + name: "Status", + value: basicInfoRows + .map(([label, value]) => { + return `${label}: **${value}**`; + }) + .join("\n"), + }, + { + name: `Loaded plugins on this server (${loadedPlugins.length})`, + value: loadedPlugins.join(", "), + }, + ], + }, + }; + + const supporters = await pluginData.state.supporters.getAll(); + supporters.sort( + multiSorter([ + [r => r.amount, "DESC"], + [r => r.name.toLowerCase(), "ASC"], + ]), + ); + + if (supporters.length) { + aboutContent.embed.fields.push({ + name: "Zeppelin supporters 🎉", + value: supporters.map(s => `**${s.name}** ${s.amount ? `${s.amount}€/mo` : ""}`.trim()).join("\n"), + }); + } + + // For the embed color, find the highest colored role the bot has - this is their color on the server as well + const botMember = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user.id); + let botRoles = botMember.roles.map(r => (msg.channel as GuildChannel).guild.roles.get(r)); + botRoles = botRoles.filter(r => !!r); // Drop any unknown roles + botRoles = botRoles.filter(r => r.color); // Filter to those with a color + botRoles.sort(sorter("position", "DESC")); // Sort by position (highest first) + if (botRoles.length) { + aboutContent.embed.color = botRoles[0].color; + } + + // Use the bot avatar as the embed image + if (pluginData.client.user.avatarURL) { + aboutContent.embed.thumbnail = { url: pluginData.client.user.avatarURL }; + } + + msg.channel.createMessage(aboutContent); + }, +}); diff --git a/backend/src/plugins/Utility/commands/ContextCmd.ts b/backend/src/plugins/Utility/commands/ContextCmd.ts new file mode 100644 index 00000000..400460a9 --- /dev/null +++ b/backend/src/plugins/Utility/commands/ContextCmd.ts @@ -0,0 +1,32 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { messageLink } from "../../../utils"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { TextChannel } from "eris"; + +export const ContextCmd = utilityCmd({ + trigger: "context", + description: "Get a link to the context of the specified message", + usage: "!context 94882524378968064 650391267720822785", + permission: "can_context", + + signature: { + channel: ct.channel(), + messageId: ct.string(), + }, + + async run({ message: msg, args, pluginData }) { + if (!(args.channel instanceof TextChannel)) { + sendErrorMessage(pluginData, msg.channel, "Channel must be a text channel"); + return; + } + + const previousMessage = (await this.bot.getMessages(args.channel.id, 1, args.messageId))[0]; + if (!previousMessage) { + sendErrorMessage(pluginData, msg.channel, "Message context not found"); + return; + } + + msg.channel.createMessage(messageLink(this.guildId, previousMessage.channel.id, previousMessage.id)); + }, +}); diff --git a/backend/src/plugins/Utility/commands/HelpCmd.ts b/backend/src/plugins/Utility/commands/HelpCmd.ts new file mode 100644 index 00000000..48631125 --- /dev/null +++ b/backend/src/plugins/Utility/commands/HelpCmd.ts @@ -0,0 +1,91 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { createChunkedMessage, messageLink } from "../../../utils"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { TextChannel } from "eris"; +import { ZeppelinPlugin } from "../../ZeppelinPlugin"; +import { PluginCommandDefinition } from "knub/dist/commands/commandUtils"; +import { LoadedPlugin } from "knub"; + +export const HelpCmd = utilityCmd({ + trigger: "help", + description: "Show a quick reference for the specified command's usage", + usage: "!help clean", + permission: "can_help", + + signature: { + command: ct.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + const searchStr = args.command.toLowerCase(); + + const matchingCommands: Array<{ + plugin: LoadedPlugin; + command: PluginCommandDefinition; + }> = []; + + const guildData = pluginData.getKnubInstance().getLoadedGuild(pluginData.guild.id); + for (const plugin of guildData.loadedPlugins.values()) { + const registeredCommands = plugin.pluginData.commands.getAll(); + for (const registeredCommand of registeredCommands) { + for (const trigger of registeredCommand.originalTriggers) { + const strTrigger = typeof trigger === "string" ? trigger : trigger.source; + + if (strTrigger.startsWith(searchStr)) { + matchingCommands.push({ + plugin, + command: registeredCommand, + }); + } + } + } + } + + const totalResults = matchingCommands.length; + const limitedResults = matchingCommands.slice(0, 3); + const commandSnippets = limitedResults.map(({ plugin, command }) => { + const prefix: string = command.originalPrefix + ? typeof command.originalPrefix === "string" + ? command.originalPrefix + : command.originalPrefix.source + : ""; + + const originalTrigger = command.originalTriggers[0]; + const trigger: string = originalTrigger + ? typeof originalTrigger === "string" + ? originalTrigger + : originalTrigger.source + : ""; + + const description = command.config.extra.blueprint.description; + const usage = command.config.extra.blueprint.usage; + const commandSlug = trigger + .trim() + .toLowerCase() + .replace(/\s/g, "-"); + + const pluginName = plugin.blueprint?.name || plugin.class?.pluginName; + + let snippet = `**${prefix}${trigger}**`; + if (description) snippet += `\n${description}`; + if (usage) snippet += `\nBasic usage: \`${usage}\``; + snippet += `\n`; + + return snippet; + }); + + if (totalResults === 0) { + msg.channel.createMessage("No matching commands found!"); + return; + } + + let message = + totalResults !== limitedResults.length + ? `Results (${totalResults} total, showing first ${limitedResults.length}):\n\n` + : ""; + + message += `${commandSnippets.join("\n\n")}`; + createChunkedMessage(msg.channel, message); + }, +}); diff --git a/backend/src/plugins/Utility/commands/InfoCmd.ts b/backend/src/plugins/Utility/commands/InfoCmd.ts index fc075d44..054dcc5a 100644 --- a/backend/src/plugins/Utility/commands/InfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InfoCmd.ts @@ -1,6 +1,6 @@ import { utilityCmd } from "../types"; import { baseTypeHelpers as t } from "knub"; -import { customArgumentHelpers as ct } from "../../../customArgumentTypes"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; import { embedPadding, resolveMember, trimLines, UnknownUser } from "../../../utils"; import { EmbedOptions, GuildTextableChannel } from "eris"; import moment from "moment-timezone"; diff --git a/backend/src/plugins/Utility/commands/LevelCmd.ts b/backend/src/plugins/Utility/commands/LevelCmd.ts index 7a9a918a..5ddab3a1 100644 --- a/backend/src/plugins/Utility/commands/LevelCmd.ts +++ b/backend/src/plugins/Utility/commands/LevelCmd.ts @@ -1,5 +1,5 @@ import { utilityCmd } from "../types"; -import { customArgumentHelpers as ct } from "../../../customArgumentTypes"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; import { helpers } from "knub"; const { getMemberLevel } = helpers; @@ -17,5 +17,5 @@ export const LevelCmd = utilityCmd({ 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/NicknameCmd.ts b/backend/src/plugins/Utility/commands/NicknameCmd.ts new file mode 100644 index 00000000..797dc9de --- /dev/null +++ b/backend/src/plugins/Utility/commands/NicknameCmd.ts @@ -0,0 +1,47 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { baseTypeHelpers as t } from "knub"; +import { errorMessage } from "../../../utils"; +import { canActOn, sendSuccessMessage } from "../../../pluginUtils"; + +export const NicknameCmd = utilityCmd({ + trigger: "nickname", + description: "Set a member's nickname", + usage: "!nickname 106391128718245888 Drag", + permission: "can_nickname", + + signature: { + member: ct.resolvedMember(), + nickname: t.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + if (msg.member.id !== args.member.id && canActOn(pluginData, msg.member, args.member)) { + msg.channel.createMessage(errorMessage("Cannot change nickname: insufficient permissions")); + return; + } + + const nicknameLength = [...args.nickname].length; + if (nicknameLength < 2 || nicknameLength > 32) { + msg.channel.createMessage(errorMessage("Nickname must be between 2 and 32 characters long")); + return; + } + + const oldNickname = args.member.nick || ""; + + try { + await args.member.edit({ + nick: args.nickname, + }); + } catch (e) { + msg.channel.createMessage(errorMessage("Failed to change nickname")); + return; + } + + sendSuccessMessage( + pluginData, + msg.channel, + `Changed nickname of <@!${args.member.id}> from **${oldNickname}** to **${args.nickname}**`, + ); + }, +}); diff --git a/backend/src/plugins/Utility/commands/NicknameResetCmd.ts b/backend/src/plugins/Utility/commands/NicknameResetCmd.ts new file mode 100644 index 00000000..c69bf3d6 --- /dev/null +++ b/backend/src/plugins/Utility/commands/NicknameResetCmd.ts @@ -0,0 +1,33 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { errorMessage } from "../../../utils"; +import { canActOn, sendSuccessMessage } from "../../../pluginUtils"; + +export const NicknameResetCmd = utilityCmd({ + trigger: "nickname reset", + description: "Reset a member's nickname to their username", + usage: "!nickname reset 106391128718245888", + permission: "can_nickname", + + signature: { + member: ct.resolvedMember(), + }, + + async run({ message: msg, args, pluginData }) { + if (msg.member.id !== args.member.id && canActOn(pluginData, msg.member, args.member)) { + msg.channel.createMessage(errorMessage("Cannot reset nickname: insufficient permissions")); + return; + } + + try { + await args.member.edit({ + nick: "", + }); + } catch (e) { + msg.channel.createMessage(errorMessage("Failed to reset nickname")); + return; + } + + sendSuccessMessage(pluginData, msg.channel, `The nickname of <@!${args.member.id}> has been reset`); + }, +}); diff --git a/backend/src/plugins/Utility/commands/PingCmd.ts b/backend/src/plugins/Utility/commands/PingCmd.ts new file mode 100644 index 00000000..a7d7cc3f --- /dev/null +++ b/backend/src/plugins/Utility/commands/PingCmd.ts @@ -0,0 +1,53 @@ +import { utilityCmd } from "../types"; +import { noop, trimLines } from "../../../utils"; +import { Message } from "eris"; + +const { performance } = require("perf_hooks"); + +export const PingCmd = utilityCmd({ + trigger: "ping", + description: "Test the bot's ping to the Discord API", + permission: "can_ping", + + async run({ message: msg, pluginData }) { + const times = []; + const messages: Message[] = []; + let msgToMsgDelay = null; + + for (let i = 0; i < 4; i++) { + const start = performance.now(); + const message = await msg.channel.createMessage(`Calculating ping... ${i + 1}`); + times.push(performance.now() - start); + messages.push(message); + + if (msgToMsgDelay === null) { + msgToMsgDelay = message.timestamp - msg.timestamp; + } + } + + const highest = Math.round(Math.max(...times)); + const lowest = Math.round(Math.min(...times)); + const mean = Math.round(times.reduce((total, ms) => total + ms, 0) / times.length); + + const shard = pluginData.client.shards.get(pluginData.client.guildShardMap[pluginData.guild.id]); + + msg.channel.createMessage( + trimLines(` + **Ping:** + Lowest: **${lowest}ms** + Highest: **${highest}ms** + Mean: **${mean}ms** + Time between ping command and first reply: **${msgToMsgDelay}ms** + Shard latency: **${shard.latency}ms** + `), + ); + + // Clean up test messages + pluginData.client + .deleteMessages( + messages[0].channel.id, + messages.map(m => m.id), + ) + .catch(noop); + }, +}); diff --git a/backend/src/plugins/Utility/commands/SourceCmd.ts b/backend/src/plugins/Utility/commands/SourceCmd.ts new file mode 100644 index 00000000..414a9984 --- /dev/null +++ b/backend/src/plugins/Utility/commands/SourceCmd.ts @@ -0,0 +1,32 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { errorMessage } from "../../../utils"; +import { getBaseUrl } from "../../../pluginUtils"; +import moment from "moment-timezone"; + +export const SourceCmd = utilityCmd({ + trigger: "source", + description: "View the message source of the specified message id", + usage: "!source 534722219696455701", + permission: "can_source", + + signature: { + messageId: ct.string(), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + msg.channel.createMessage(errorMessage("Unknown message")); + return; + } + + const source = + (savedMessage.data.content || "") + "\n\nSource:\n\n" + JSON.stringify(savedMessage.data); + + const archiveId = await pluginData.state.archives.create(source, moment().add(1, "hour")); + const baseUrl = getBaseUrl(pluginData); + const url = pluginData.state.archives.getUrl(baseUrl, archiveId); + msg.channel.createMessage(`Message source: ${url}`); + }, +}); diff --git a/backend/src/plugins/Utility/commands/VcmoveCmd.ts b/backend/src/plugins/Utility/commands/VcmoveCmd.ts new file mode 100644 index 00000000..261b84d6 --- /dev/null +++ b/backend/src/plugins/Utility/commands/VcmoveCmd.ts @@ -0,0 +1,96 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { + channelMentionRegex, + errorMessage, + isSnowflake, + messageLink, + simpleClosestStringMatch, + stripObjectToScalars, +} from "../../../utils"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { TextChannel, VoiceChannel } from "eris"; +import { LogType } from "../../../data/LogType"; + +export const VcmoveCmd = utilityCmd({ + trigger: "vcmove", + description: "Move a member to another voice channel", + usage: "!vcmove @Dragory 473223047822704651", + permission: "can_vcmove", + + signature: { + member: ct.resolvedMember(), + channel: ct.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + let channel: VoiceChannel; + + if (isSnowflake(args.channel)) { + // Snowflake -> resolve channel directly + const potentialChannel = pluginData.guild.channels.get(args.channel); + if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { + sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + return; + } + + channel = potentialChannel; + } else if (channelMentionRegex.test(args.channel)) { + // Channel mention -> parse channel id and resolve channel from that + const channelId = args.channel.match(channelMentionRegex)[1]; + const potentialChannel = pluginData.guild.channels.get(channelId); + if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { + sendErrorMessage(pluginData, msg.channel, "Unknown or non-voice channel"); + return; + } + + channel = potentialChannel; + } else { + // Search string -> find closest matching voice channel name + const voiceChannels = pluginData.guild.channels.filter(theChannel => { + return theChannel instanceof VoiceChannel; + }) as VoiceChannel[]; + const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, ch => ch.name); + if (!closestMatch) { + sendErrorMessage(pluginData, msg.channel, "No matching voice channels"); + return; + } + + channel = closestMatch; + } + + if (!args.member.voiceState || !args.member.voiceState.channelID) { + sendErrorMessage(pluginData, msg.channel, "Member is not in a voice channel"); + return; + } + + if (args.member.voiceState.channelID === channel.id) { + sendErrorMessage(pluginData, msg.channel, "Member is already on that channel!"); + return; + } + + const oldVoiceChannel = pluginData.guild.channels.get(args.member.voiceState.channelID); + + try { + await args.member.edit({ + channelID: channel.id, + }); + } catch (e) { + msg.channel.createMessage(errorMessage("Failed to move member")); + return; + } + + pluginData.state.logs.log(LogType.VOICE_CHANNEL_FORCE_MOVE, { + mod: stripObjectToScalars(msg.author), + member: stripObjectToScalars(args.member, ["user", "roles"]), + oldChannel: stripObjectToScalars(oldVoiceChannel), + newChannel: stripObjectToScalars(channel), + }); + + sendSuccessMessage( + pluginData, + msg.channel, + `**${args.member.user.username}#${args.member.user.discriminator}** moved to **${channel.name}**`, + ); + }, +}); diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index 992f4a4b..90e7153f 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -3,7 +3,7 @@ import moment from "moment-timezone"; import escapeStringRegexp from "escape-string-regexp"; import safeRegex from "safe-regex"; import { isFullMessage, MINUTES, multiSorter, noop, sorter, trimLines } from "../../utils"; -import { sendErrorMessage } from "../../pluginUtils"; +import { getBaseUrl, sendErrorMessage } from "../../pluginUtils"; import { PluginData } from "knub"; import { ArgsFromSignatureOrArray } from "knub/dist/commands/commandUtils"; import { searchCmdSignature } from "./commands/SearchCmd"; @@ -205,7 +205,7 @@ export async function archiveSearch( moment().add(1, "hour"), ); - const baseUrl = (pluginData.getKnubInstance().getGlobalConfig() as any).url; // FIXME: No any cast + const baseUrl = getBaseUrl(pluginData); const url = await pluginData.state.archives.getUrl(baseUrl, archiveId); msg.channel.createMessage(`Exported search results: ${url}`);