diff --git a/src/customArgumentTypes.ts b/src/customArgumentTypes.ts new file mode 100644 index 00000000..7fb84973 --- /dev/null +++ b/src/customArgumentTypes.ts @@ -0,0 +1,40 @@ +import { convertDelayStringToMS, resolveMember, resolveUser, UnknownUser } from "./utils"; +import { CommandArgumentTypeError } from "knub"; +import { Client, GuildChannel, Message } from "eris"; + +export const customArgumentTypes = { + delay(value) { + const result = convertDelayStringToMS(value); + if (result == null) { + throw new CommandArgumentTypeError(`Could not convert ${value} to a delay`); + } + + return result; + }, + + async resolvedUser(value, msg, bot: Client) { + const result = resolveUser(bot, value); + if (result == null || result instanceof UnknownUser) { + throw new CommandArgumentTypeError(`User \`${value}\` was not found`); + } + return result; + }, + + async resolvedUserLoose(value, msg, bot: Client) { + const result = resolveUser(bot, value); + if (result == null) { + throw new CommandArgumentTypeError(`Invalid user: ${value}`); + } + return result; + }, + + async resolvedMember(value, msg: Message, bot: Client) { + if (!(msg.channel instanceof GuildChannel)) return null; + + const result = await resolveMember(bot, msg.channel.guild, value); + if (result == null) { + throw new CommandArgumentTypeError(`Member \`${value}\` was not found or they have left the server`); + } + return result; + }, +}; diff --git a/src/index.ts b/src/index.ts index e1e9fa5b..30188d89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import yaml from "js-yaml"; import fs from "fs"; const fsp = fs.promises; -import { Knub, logger, PluginError, CommandArgumentTypeError, Plugin } from "knub"; +import { Knub, logger, PluginError, Plugin } from "knub"; import { SimpleError } from "./SimpleError"; require("dotenv").config(); @@ -74,8 +74,9 @@ import { AutoReactionsPlugin } from "./plugins/AutoReactionsPlugin"; import { PingableRolesPlugin } from "./plugins/PingableRolesPlugin"; import { SelfGrantableRolesPlugin } from "./plugins/SelfGrantableRolesPlugin"; import { RemindersPlugin } from "./plugins/Reminders"; -import { convertDelayStringToMS, errorMessage, successMessage } from "./utils"; +import { errorMessage, successMessage } from "./utils"; import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; +import { customArgumentTypes } from "./customArgumentTypes"; // Run latest database migrations logger.info("Running database migrations"); @@ -178,16 +179,7 @@ connect().then(async conn => { threshold: 200, }, - customArgumentTypes: { - delay(value) { - const result = convertDelayStringToMS(value); - if (result == null) { - throw new CommandArgumentTypeError(`Could not convert ${value} to a delay`); - } - - return result; - }, - }, + customArgumentTypes, sendSuccessMessageFn(channel, body) { channel.createMessage(successMessage(body)); diff --git a/src/plugins/Logs.ts b/src/plugins/Logs.ts index dce88db0..5ddfb8f8 100644 --- a/src/plugins/Logs.ts +++ b/src/plugins/Logs.ts @@ -10,7 +10,7 @@ import { findRelevantAuditLogEntry, noop, stripObjectToScalars, - unknownUser, + UnknownUser, useMediaUrls, } from "../utils"; import DefaultLogMessages from "../data/DefaultLogMessages.json"; @@ -256,7 +256,7 @@ export class LogsPlugin extends ZeppelinPlugin { ErisConstants.AuditLogActions.MEMBER_BAN_ADD, user.id, ); - const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser; + const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); this.guildLogs.log( LogType.MEMBER_BAN, @@ -275,7 +275,7 @@ export class LogsPlugin extends ZeppelinPlugin { ErisConstants.AuditLogActions.MEMBER_BAN_REMOVE, user.id, ); - const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser; + const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); this.guildLogs.log( LogType.MEMBER_UNBAN, @@ -308,7 +308,7 @@ export class LogsPlugin extends ZeppelinPlugin { ErisConstants.AuditLogActions.MEMBER_ROLE_UPDATE, member.id, ); - const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser; + const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); if (addedRoles.length && removedRoles.length) { // Roles added *and* removed diff --git a/src/plugins/Utility.ts b/src/plugins/Utility.ts index 066d6500..4779da1d 100644 --- a/src/plugins/Utility.ts +++ b/src/plugins/Utility.ts @@ -14,6 +14,7 @@ import { stripObjectToScalars, successMessage, trimLines, + UnknownUser, } from "../utils"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; @@ -187,16 +188,10 @@ export class UtilityPlugin extends ZeppelinPlugin { } } - @d.command("level", "[userId:string]") + @d.command("level", "[member:resolvedMember]") @d.permission("can_level") - async levelCmd(msg: Message, args) { - const member = args.userId ? this.guild.members.get(args.userId) : msg.member; - - if (!member) { - msg.channel.createMessage(errorMessage("Member not found")); - return; - } - + async levelCmd(msg: Message, args: { member?: Member }) { + const member = args.member || msg.member; const level = this.getMemberLevel(member); msg.channel.createMessage(`The permission level of ${member.username}#${member.discriminator} is **${level}**`); } @@ -413,15 +408,17 @@ export class UtilityPlugin extends ZeppelinPlugin { }, CLEAN_COMMAND_DELETE_DELAY); } - @d.command("info", "") + @d.command("info", "[user:resolvedUserLoose]") @d.permission("can_info") - async infoCmd(msg: Message, args: { userId: string }) { + async infoCmd(msg: Message, args: { user?: User | UnknownUser }) { + const user = args.user || msg.author; + const member = user && (await this.getMember(user.id)); + const embed: EmbedOptions = { fields: [], }; - const user = this.bot.users.get(args.userId); - if (user) { + if (user && !(user instanceof UnknownUser)) { const createdAt = moment(user.createdAt); const accountAge = humanizeDuration(moment().valueOf() - user.createdAt, { largest: 2, @@ -444,7 +441,6 @@ export class UtilityPlugin extends ZeppelinPlugin { embed.title = `Unknown user`; } - const member = this.guild.members.get(args.userId); if (member) { const joinedAt = moment(member.joinedAt); const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, { @@ -476,7 +472,7 @@ export class UtilityPlugin extends ZeppelinPlugin { } } - const cases = (await this.cases.getByUserId(args.userId)).filter(c => !c.is_hidden); + const cases = (await this.cases.getByUserId(user.id)).filter(c => !c.is_hidden); if (cases.length > 0) { cases.sort((a, b) => { @@ -501,16 +497,16 @@ export class UtilityPlugin extends ZeppelinPlugin { msg.channel.createMessage({ embed }); } - @d.command(/(?:nickname|nick) reset/, "") + @d.command(/(?:nickname|nick) reset/, "") @d.permission("can_nickname") - async nicknameResetCmd(msg: Message, args: { target: Member; nickname: string }) { - if (msg.member.id !== args.target.id && !this.canActOn(msg.member, args.target)) { + async nicknameResetCmd(msg: Message, args: { member: Member }) { + if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) { msg.channel.createMessage(errorMessage("Cannot reset nickname: insufficient permissions")); return; } try { - await args.target.edit({ + await args.member.edit({ nick: "", }); } catch (e) { @@ -518,13 +514,13 @@ export class UtilityPlugin extends ZeppelinPlugin { return; } - msg.channel.createMessage(successMessage(`Nickname of <@!${args.target.id}> is now reset`)); + msg.channel.createMessage(successMessage(`The nickname of <@!${args.member.id}> has been reset`)); } - @d.command(/nickname|nick/, " ") + @d.command(/nickname|nick/, " ") @d.permission("can_nickname") - async nicknameCmd(msg: Message, args: { target: Member; nickname: string }) { - if (msg.member.id !== args.target.id && !this.canActOn(msg.member, args.target)) { + async nicknameCmd(msg: Message, args: { member: Member; nickname: string }) { + if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) { msg.channel.createMessage(errorMessage("Cannot change nickname: insufficient permissions")); return; } @@ -535,8 +531,10 @@ export class UtilityPlugin extends ZeppelinPlugin { return; } + const oldNickname = args.member.nick || ""; + try { - await args.target.edit({ + await args.member.edit({ nick: args.nickname, }); } catch (e) { @@ -544,7 +542,9 @@ export class UtilityPlugin extends ZeppelinPlugin { return; } - msg.channel.createMessage(successMessage(`Changed nickname of <@!${args.target.id}> to ${args.nickname}`)); + msg.channel.createMessage( + successMessage(`Changed nickname of <@!${args.member.id}> from **${oldNickname}** to **${args.nickname}**`), + ); } @d.command("server") @@ -673,7 +673,7 @@ export class UtilityPlugin extends ZeppelinPlugin { msg.channel.createMessage(`Message source: ${url}`); } - @d.command("vcmove", " ") + @d.command("vcmove", " ") @d.permission("can_vcmove") async vcmoveCmd(msg: Message, args: { member: Member; channel: string }) { let channel: VoiceChannel; diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index 7a51e714..03ac28cb 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -1,7 +1,7 @@ import { IBasePluginConfig, IPluginOptions, Plugin } from "knub"; import { PluginRuntimeError } from "../PluginRuntimeError"; import Ajv, { ErrorObject } from "ajv"; -import { createUnknownUser, isSnowflake, isUnicodeEmoji, UnknownUser } from "../utils"; +import { isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; import { Member, User } from "eris"; export class ZeppelinPlugin extends Plugin { @@ -81,59 +81,10 @@ export class ZeppelinPlugin extends Plug * Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. */ async resolveUser(userResolvable: string): Promise { - if (userResolvable == null) { - return createUnknownUser(); - } - - let userId; - - // A user mention? - const mentionMatch = userResolvable.match(/^<@!?(\d+)>$/); - if (mentionMatch) { - userId = mentionMatch[1]; - } - - // A non-mention, full username? - if (!userId) { - const usernameMatch = userResolvable.match(/^@?([^#]+)#(\d{4})$/); - if (usernameMatch) { - const user = this.bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]); - userId = user.id; - } - } - - // Just a user ID? - if (!userId) { - const idMatch = userResolvable.match(/^\d+$/); - if (!idMatch) { - return null; - } - - userId = userResolvable; - } - - const cachedUser = this.bot.users.find(u => u.id === userId); - if (cachedUser) return cachedUser; - - try { - const freshUser = await this.bot.getRESTUser(userId); - return freshUser; - } catch (e) {} // tslint:disable-line - - return createUnknownUser({ id: userId }); + return resolveUser(this.bot, userResolvable); } - async getMember(userId: string): Promise { - // See if we have the member cached... - let member = this.guild.members.get(userId); - - // If not, fetch it from the API - if (!member) { - try { - member = await this.bot.getRESTGuildMember(this.guildId, userId); - } catch (e) {} // tslint:disable-line - } - - return member; + async getMember(memberResolvable: string): Promise { + return resolveMember(this.bot, this.guild, memberResolvable); } } diff --git a/src/utils.ts b/src/utils.ts index 22d561a2..330a15b7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { Client, Emoji, Guild, GuildAuditLogEntry, TextableChannel, TextChannel, User } from "eris"; +import { Client, Emoji, Guild, GuildAuditLogEntry, Member, TextableChannel, TextChannel, User } from "eris"; import url from "url"; import tlds from "tlds"; import emojiRegex from "emoji-regex"; @@ -234,7 +234,7 @@ export function getRoleMentions(str: string) { * Disables link previews in the given string by wrapping links in < > */ export function disableLinkPreviews(str: string): string { - return str.replace(/(?"); + return str.replace(/(?"); } export function deactivateMentions(content: string): string { @@ -496,19 +496,76 @@ export function ucfirst(str) { return str[0].toUpperCase() + str.slice(1); } -export type UnknownUser = { - id: string; - username: string; - discriminator: string; - [key: string]: any; -}; +export class UnknownUser { + public id: string = null; + public username = "Unknown"; + public discriminator = "0000"; -export const unknownUser: UnknownUser = { - id: null, - username: "Unknown", - discriminator: "0000", -}; - -export function createUnknownUser(props = {}): UnknownUser { - return { ...unknownUser, ...props }; + constructor(props = {}) { + for (const key in props) { + this[key] = props[key]; + } + } +} + +export async function resolveUser(bot: Client, value: string): Promise { + if (value == null || typeof value !== "string") { + return new UnknownUser(); + } + + let userId; + + // A user mention? + const mentionMatch = value.match(/^<@!?(\d+)>$/); + if (mentionMatch) { + userId = mentionMatch[1]; + } + + // A non-mention, full username? + if (!userId) { + const usernameMatch = value.match(/^@?([^#]+)#(\d{4})$/); + if (usernameMatch) { + const user = bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]); + userId = user.id; + } + } + + // Just a user ID? + if (!userId) { + const idMatch = value.match(/^\d+$/); + if (!idMatch) { + return null; + } + + userId = value; + } + + const cachedUser = bot.users.find(u => u.id === userId); + if (cachedUser) return cachedUser; + + try { + const freshUser = await bot.getRESTUser(userId); + bot.users.add(freshUser, bot); + return freshUser; + } catch (e) {} // tslint:disable-line + + return new UnknownUser({ id: userId }); +} + +export async function resolveMember(bot: Client, guild: Guild, value: string): Promise { + // Start by resolving the user + const user = await resolveUser(bot, value); + if (!user) return null; + + // See if we have the member cached... + let member = guild.members.get(user.id); + + // If not, fetch it from the API + if (!member) { + try { + member = await bot.getRESTGuildMember(guild.id, user.id); + } catch (e) {} // tslint:disable-line + } + + return member; }