diff --git a/src/plugins/Utility.ts b/src/plugins/Utility.ts index 408276a3..f7aea751 100644 --- a/src/plugins/Utility.ts +++ b/src/plugins/Utility.ts @@ -1,10 +1,13 @@ import { decorators as d } from "knub"; import { Channel, EmbedOptions, Member, Message, Role, TextChannel, User, VoiceChannel } from "eris"; import { + channelMentionRegex, chunkArray, embedPadding, errorMessage, + isSnowflake, noop, + simpleClosestStringMatch, stripObjectToScalars, successMessage, trimLines, @@ -49,6 +52,7 @@ export class UtilityPlugin extends ZeppelinPlugin { nickname: false, ping: false, source: false, + vcmove: false, }, overrides: [ { @@ -61,6 +65,7 @@ export class UtilityPlugin extends ZeppelinPlugin { info: true, server: true, nickname: true, + vcmove: true, }, }, { @@ -553,6 +558,63 @@ export class UtilityPlugin extends ZeppelinPlugin { msg.channel.createMessage(`Message source: ${url}`); } + @d.command("vcmove", " ") + @d.permission("vcmove") + async vcmoveCmd(msg: Message, args: { member: Member; channel: string }) { + let channel: VoiceChannel; + + if (isSnowflake(args.channel)) { + // Snowflake -> resolve channel directly + const potentialChannel = this.guild.channels.get(args.channel); + if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { + msg.channel.createMessage(errorMessage("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 = this.guild.channels.get(channelId); + if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { + msg.channel.createMessage(errorMessage("Unknown or non-voice channel")); + return; + } + + channel = potentialChannel; + } else { + // Search string -> find closest matching voice channel name + const voiceChannels = this.guild.channels.filter(theChannel => { + return theChannel instanceof VoiceChannel; + }) as VoiceChannel[]; + const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, ch => ch.name); + if (!closestMatch) { + msg.channel.createMessage(errorMessage("No matching voice channels")); + return; + } + + channel = closestMatch; + } + + if (!args.member.voiceState || !args.member.voiceState.channelID) { + msg.channel.createMessage(errorMessage("Member is not in a voice channel")); + return; + } + + try { + await args.member.edit({ + channelID: channel.id, + }); + } catch (e) { + msg.channel.createMessage(errorMessage("Failed to move member")); + return; + } + + msg.channel.createMessage( + successMessage(`**${args.member.user.username}#${args.member.user.discriminator}** moved to **${channel.name}**`), + ); + } + @d.command("reload_guild") @d.permission("reload_guild") reloadGuildCmd(msg: Message) { diff --git a/src/utils.ts b/src/utils.ts index 61dcdeb0..84ef125e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -204,6 +204,7 @@ export const embedPadding = "\n" + emptyEmbedValue; export const userMentionRegex = /<@!?([0-9]+)>/g; export const roleMentionRegex = /<@&([0-9]+)>/g; +export const channelMentionRegex = /<#([0-9]+)>/g; export function getUserMentions(str: string) { const regex = new RegExp(userMentionRegex.source, "g"); @@ -347,6 +348,41 @@ export function downloadFile(attachmentUrl: string, retries = 3): Promise<{ path }); } +type ItemWithRanking = [T, number]; +export function simpleClosestStringMatch(searchStr, haystack: T[], getter = null): T { + const normalizedSearchStr = searchStr.toLowerCase(); + + // See if any haystack item contains a part of the search string + const itemsWithRankings: Array> = haystack.map(item => { + const itemStr: string = getter ? getter(item) : item; + const normalizedItemStr = itemStr.toLowerCase(); + + let i = 0; + do { + if (!normalizedItemStr.includes(normalizedSearchStr.slice(0, i + 1))) break; + i++; + } while (i < normalizedSearchStr.length); + + if (i > 0 && normalizedItemStr.startsWith(normalizedSearchStr.slice(0, i))) { + // Slightly prioritize items that *start* with the search string + i += 0.5; + } + + return [item, i] as ItemWithRanking; + }); + + // Sort by best match + itemsWithRankings.sort((a, b) => { + return a[1] > b[1] ? -1 : 1; + }); + + if (itemsWithRankings[0][1] === 0) { + return null; + } + + return itemsWithRankings[0][0]; +} + export function noop() { // IT'S LITERALLY NOTHING }