From 954c88bee2a859b74d705a7d93a245d99f09a864 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 5 Jul 2020 15:59:15 +0300 Subject: [PATCH] Port !search and !bansearch to Knub 30 --- .../plugins/Utility/commands/BanSearchCmd.ts | 32 ++ .../src/plugins/Utility/commands/SearchCmd.ts | 32 +- .../src/plugins/Utility/commands/search.ts | 408 ++++++++++++++++++ backend/src/utils.ts | 11 +- 4 files changed, 476 insertions(+), 7 deletions(-) create mode 100644 backend/src/plugins/Utility/commands/BanSearchCmd.ts create mode 100644 backend/src/plugins/Utility/commands/search.ts diff --git a/backend/src/plugins/Utility/commands/BanSearchCmd.ts b/backend/src/plugins/Utility/commands/BanSearchCmd.ts new file mode 100644 index 00000000..d3e8328d --- /dev/null +++ b/backend/src/plugins/Utility/commands/BanSearchCmd.ts @@ -0,0 +1,32 @@ +import { utilityCmd } from "../types"; +import { baseTypeHelpers as t } from "knub"; +import { archiveSearch, displaySearch, SearchType } from "./search"; + +// Separate from BanSearchCmd to avoid a circular reference from ./search.ts +export const banSearchSignature = { + query: t.string({ catchAll: true }), + + page: t.number({ option: true, shortcut: "p" }), + sort: t.string({ option: true }), + "case-sensitive": t.switchOption({ shortcut: "cs" }), + export: t.switchOption({ shortcut: "e" }), + ids: t.switchOption(), + regex: t.switchOption({ shortcut: "re" }), +}; + +export const BanSearchCmd = utilityCmd({ + trigger: ["bansearch", "bs"], + description: "Search banned users", + usage: "!bansearch dragory", + permission: "can_search", + + signature: banSearchSignature, + + run({ pluginData, message, args }) { + if (args.export) { + return archiveSearch(pluginData, args, SearchType.BanSearch, message); + } else { + return displaySearch(pluginData, args, SearchType.BanSearch, message); + } + }, +}); diff --git a/backend/src/plugins/Utility/commands/SearchCmd.ts b/backend/src/plugins/Utility/commands/SearchCmd.ts index 78e49ae4..c415f3b7 100644 --- a/backend/src/plugins/Utility/commands/SearchCmd.ts +++ b/backend/src/plugins/Utility/commands/SearchCmd.ts @@ -1,14 +1,36 @@ import { utilityCmd } from "../types"; import { baseTypeHelpers as t } from "knub"; +import { archiveSearch, displaySearch, SearchType } from "./search"; + +// Separate from SearchCmd to avoid a circular reference from ./search.ts +export const searchCmdSignature = { + query: t.string({ catchAll: true }), + + page: t.number({ option: true, shortcut: "p" }), + role: t.string({ option: true, shortcut: "r" }), + voice: t.switchOption({ shortcut: "v" }), + bot: t.switchOption({ shortcut: "b" }), + sort: t.string({ option: true }), + "case-sensitive": t.switchOption({ shortcut: "cs" }), + export: t.switchOption({ shortcut: "e" }), + ids: t.switchOption(), + regex: t.switchOption({ shortcut: "re" }), + "status-search": t.switchOption({ shortcut: "ss" }), +}; export const SearchCmd = utilityCmd({ trigger: ["search", "s"], + description: "Search server members", + usage: "!search dragory", + permission: "can_search", - signature: { - query: t.string({ catchAll: true }), - }, - - run() { + signature: searchCmdSignature, + run({ pluginData, message, args }) { + if (args.export) { + return archiveSearch(pluginData, args, SearchType.MemberSearch, message); + } else { + return displaySearch(pluginData, args, SearchType.MemberSearch, message); + } }, }); diff --git a/backend/src/plugins/Utility/commands/search.ts b/backend/src/plugins/Utility/commands/search.ts new file mode 100644 index 00000000..91492dc7 --- /dev/null +++ b/backend/src/plugins/Utility/commands/search.ts @@ -0,0 +1,408 @@ +import { Member, Message, User } from "eris"; +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 { PluginData } from "knub"; +import { ArgsFromSignatureOrArray } from "knub/dist/commands/commandUtils"; +import { searchCmdSignature } from "./SearchCmd"; +import { banSearchSignature } from "./BanSearchCmd"; +import { UtilityPluginType } from "../types"; +import { refreshMembersIfNeeded } from "../refreshMembers"; + +const SEARCH_RESULTS_PER_PAGE = 15; +const SEARCH_ID_RESULTS_PER_PAGE = 50; +const SEARCH_EXPORT_LIMIT = 1_000_000; + +export enum SearchType { + MemberSearch, + BanSearch, +} + +export class SearchError extends Error {} + +type MemberSearchParams = ArgsFromSignatureOrArray; +type BanSearchParams = ArgsFromSignatureOrArray; + +export async function displaySearch( + pluginData: PluginData, + args: MemberSearchParams, + searchType: SearchType.MemberSearch, + msg: Message, +); +export async function displaySearch( + pluginData: PluginData, + args: BanSearchParams, + searchType: SearchType.BanSearch, + msg: Message, +); +export async function displaySearch( + pluginData: PluginData, + args: MemberSearchParams | BanSearchParams, + searchType: SearchType, + msg: Message, +) { + // If we're not exporting, load 1 page of search results at a time and allow the user to switch pages with reactions + let originalSearchMsg: Message = null; + let searching = false; + let currentPage = args.page || 1; + let hasReactions = false; + let clearReactionsFn = null; + let clearReactionsTimeout = null; + + const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE; + + const loadSearchPage = async page => { + if (searching) return; + searching = true; + + // The initial message is created here, as well as edited to say "Searching..." on subsequent requests + // We don't "await" this so we can start loading the search results immediately instead of after the message has been created/edited + let searchMsgPromise: Promise; + if (originalSearchMsg) { + searchMsgPromise = originalSearchMsg.edit("Searching..."); + } else { + searchMsgPromise = msg.channel.createMessage("Searching..."); + searchMsgPromise.then(m => (originalSearchMsg = m)); + } + + let searchResult; + try { + switch (searchType) { + case SearchType.MemberSearch: + searchResult = await performMemberSearch(pluginData, args as MemberSearchParams, page, perPage); + break; + case SearchType.BanSearch: + searchResult = await performBanSearch(pluginData, args as BanSearchParams, page, perPage); + break; + } + } catch (e) { + if (e instanceof SearchError) { + return sendErrorMessage(pluginData, msg.channel, e.message); + } + + throw e; + } + + if (searchResult.totalResults === 0) { + return sendErrorMessage(pluginData, msg.channel, "No results found"); + } + + const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members"; + const headerText = + searchResult.totalResults > perPage + ? trimLines(` + **Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults}) + `) + : `Found ${searchResult.totalResults} ${resultWord}`; + + const resultList = args.ids + ? formatSearchResultIdList(searchResult.results) + : formatSearchResultList(searchResult.results); + + const result = trimLines(` + ${headerText} + \`\`\`js + ${resultList} + \`\`\` + `); + + const searchMsg = await searchMsgPromise; + searchMsg.edit(result); + + // Set up pagination reactions if needed. The reactions are cleared after a timeout. + if (searchResult.totalResults > perPage) { + if (!hasReactions) { + hasReactions = true; + searchMsg.addReaction("⬅"); + searchMsg.addReaction("➡"); + searchMsg.addReaction("🔄"); + + const listenerFn = pluginData.events.on("messageReactionAdd", ({ args: { message: rMsg, emoji, userID } }) => { + if (rMsg.id !== searchMsg.id) return; + if (userID !== msg.author.id) return; + if (!["⬅", "➡", "🔄"].includes(emoji.name)) return; + + if (emoji.name === "⬅" && currentPage > 1) { + loadSearchPage(currentPage - 1); + } else if (emoji.name === "➡" && currentPage < searchResult.lastPage) { + loadSearchPage(currentPage + 1); + } else if (emoji.name === "🔄") { + loadSearchPage(currentPage); + } + + if (isFullMessage(rMsg)) { + rMsg.removeReaction(emoji.name, userID); + } + }); + + clearReactionsFn = async () => { + searchMsg.removeReactions().catch(noop); + pluginData.events.off("messageReactionAdd", listenerFn); + }; + } + + clearTimeout(clearReactionsTimeout); + clearReactionsTimeout = setTimeout(clearReactionsFn, 5 * MINUTES); + } + + currentPage = searchResult.page; + searching = false; + }; + + loadSearchPage(currentPage); +} + +export async function archiveSearch( + pluginData: PluginData, + args: MemberSearchParams, + searchType: SearchType.MemberSearch, + msg: Message, +); +export async function archiveSearch( + pluginData: PluginData, + args: BanSearchParams, + searchType: SearchType.BanSearch, + msg: Message, +); +export async function archiveSearch( + pluginData: PluginData, + args: MemberSearchParams | BanSearchParams, + searchType: SearchType, + msg: Message, +) { + let results; + try { + switch (searchType) { + case SearchType.MemberSearch: + results = await performMemberSearch(pluginData, args as MemberSearchParams, 1, SEARCH_EXPORT_LIMIT); + break; + case SearchType.BanSearch: + results = await performBanSearch(pluginData, args as BanSearchParams, 1, SEARCH_EXPORT_LIMIT); + break; + } + } catch (e) { + if (e instanceof SearchError) { + return sendErrorMessage(pluginData, msg.channel, e.message); + } + + throw e; + } + + if (results.totalResults === 0) { + return sendErrorMessage(pluginData, msg.channel, "No results found"); + } + + const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results); + + const archiveId = await pluginData.state.archives.create( + trimLines(` + Search results (total ${results.totalResults}): + + ${resultList} + `), + moment().add(1, "hour"), + ); + + const baseUrl = (pluginData.getKnubInstance().getGlobalConfig() as any).url; // FIXME: No any cast + const url = await pluginData.state.archives.getUrl(baseUrl, archiveId); + + msg.channel.createMessage(`Exported search results: ${url}`); + + return; +} + +async function performMemberSearch( + pluginData: PluginData, + args: MemberSearchParams, + page = 1, + perPage = SEARCH_RESULTS_PER_PAGE, +): Promise<{ results: Member[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { + refreshMembersIfNeeded(pluginData.guild); + + let matchingMembers = Array.from(pluginData.guild.members.values()); + + if (args.role) { + const roleIds = args.role.split(","); + matchingMembers = matchingMembers.filter(member => { + for (const role of roleIds) { + if (!member.roles.includes(role)) return false; + } + + return true; + }); + } + + if (args.voice) { + matchingMembers = matchingMembers.filter(m => m.voiceState.channelID != null); + } + + if (args.bot) { + matchingMembers = matchingMembers.filter(m => m.bot); + } + + if (args.query) { + let queryRegex: RegExp; + if (args.regex) { + queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i"); + } else { + queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); + } + + if (!safeRegex(queryRegex)) { + throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)"); + } + + if (args["status-search"]) { + matchingMembers = matchingMembers.filter(member => { + if (member.game) { + if (member.game.name && member.game.name.match(queryRegex)) { + return true; + } + + if (member.game.state && member.game.state.match(queryRegex)) { + return true; + } + + if (member.game.details && member.game.details.match(queryRegex)) { + return true; + } + + if (member.game.assets) { + if (member.game.assets.small_text && member.game.assets.small_text.match(queryRegex)) { + return true; + } + + if (member.game.assets.large_text && member.game.assets.large_text.match(queryRegex)) { + return true; + } + } + + if (member.game.emoji && member.game.emoji.name.match(queryRegex)) { + return true; + } + } + return false; + }); + } else { + matchingMembers = matchingMembers.filter(member => { + if (member.nick && member.nick.match(queryRegex)) return true; + + const fullUsername = `${member.user.username}#${member.user.discriminator}`; + if (fullUsername.match(queryRegex)) return true; + + return false; + }); + } + } + + const [, sortDir, sortBy] = args.sort ? args.sort.match(/^(-?)(.*)$/) : [null, "ASC", "name"]; + const realSortDir = sortDir === "-" ? "DESC" : "ASC"; + + if (sortBy === "id") { + matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir)); + } else { + matchingMembers.sort( + multiSorter([ + [m => m.username.toLowerCase(), realSortDir], + [m => m.discriminator, realSortDir], + ]), + ); + } + + const lastPage = Math.max(1, Math.ceil(matchingMembers.length / perPage)); + page = Math.min(lastPage, Math.max(1, page)); + + const from = (page - 1) * perPage; + const to = Math.min(from + perPage, matchingMembers.length); + + const pageMembers = matchingMembers.slice(from, to); + + return { + results: pageMembers, + totalResults: matchingMembers.length, + page, + lastPage, + from: from + 1, + to, + }; +} + +async function performBanSearch( + pluginData: PluginData, + args: BanSearchParams, + page = 1, + perPage = SEARCH_RESULTS_PER_PAGE, +): Promise<{ results: User[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { + let matchingBans = (await pluginData.guild.getBans()).map(x => x.user); + + if (args.query) { + let queryRegex: RegExp; + if (args.regex) { + queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i"); + } else { + queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); + } + + if (!safeRegex(queryRegex)) { + throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)"); + } + + matchingBans = matchingBans.filter(user => { + const fullUsername = `${user.username}#${user.discriminator}`; + if (fullUsername.match(queryRegex)) return true; + }); + } + + const [, sortDir, sortBy] = args.sort ? args.sort.match(/^(-?)(.*)$/) : [null, "ASC", "name"]; + const realSortDir = sortDir === "-" ? "DESC" : "ASC"; + + if (sortBy === "id") { + matchingBans.sort(sorter(m => BigInt(m.id), realSortDir)); + } else { + matchingBans.sort( + multiSorter([ + [m => m.username.toLowerCase(), realSortDir], + [m => m.discriminator, realSortDir], + ]), + ); + } + + const lastPage = Math.max(1, Math.ceil(matchingBans.length / perPage)); + page = Math.min(lastPage, Math.max(1, page)); + + const from = (page - 1) * perPage; + const to = Math.min(from + perPage, matchingBans.length); + + const pageMembers = matchingBans.slice(from, to); + + return { + results: pageMembers, + totalResults: matchingBans.length, + page, + lastPage, + from: from + 1, + to, + }; +} + +function formatSearchResultList(members: Array): string { + const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0); + const lines = members.map(member => { + const paddedId = member.id.padEnd(longestId, " "); + let line; + if (member instanceof Member) { + line = `${paddedId} ${member.user.username}#${member.user.discriminator}`; + if (member.nick) line += ` (${member.nick})`; + } else { + line = `${paddedId} ${member.username}#${member.discriminator}`; + } + return line; + }); + return lines.join("\n"); +} + +function formatSearchResultIdList(members: Array): string { + return members.map(m => m.id).join(" "); +} diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 201cb6e4..0547eca0 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,5 +1,6 @@ import { - Attachment, ChannelInvite, + Attachment, + ChannelInvite, Client, Embed, EmbedOptions, @@ -7,10 +8,12 @@ import { Guild, GuildAuditLog, GuildAuditLogEntry, - GuildChannel, Invite, + GuildChannel, + Invite, Member, Message, MessageContent, + PossiblyUncachedMessage, TextableChannel, TextChannel, User, @@ -1212,3 +1215,7 @@ export function trimPluginDescription(str) { const firstLineIndentation = (lines[0].match(/^ +/g) || [""])[0].length; return trimIndents(emptyLinesTrimmed, firstLineIndentation); } + +export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message { + return (msg as Message).createdAt != null; +}