diff --git a/backend/src/plugins/Censor/util/applyFiltersToMsg.ts b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts index 76bbd073..92036958 100644 --- a/backend/src/plugins/Censor/util/applyFiltersToMsg.ts +++ b/backend/src/plugins/Censor/util/applyFiltersToMsg.ts @@ -1,9 +1,16 @@ import { PluginData } from "knub"; import { CensorPluginType } from "../types"; import { SavedMessage } from "src/data/entities/SavedMessage"; -import { Embed, GuildInvite } from "eris"; +import { AnyInvite, Embed, GuildInvite } from "eris"; import { ZalgoRegex } from "src/data/Zalgo"; -import { getInviteCodesInString, getUrlsInString, resolveMember, resolveInvite } from "src/utils"; +import { + getInviteCodesInString, + getUrlsInString, + resolveMember, + resolveInvite, + isGuildInvite, + isRESTGuildInvite, +} from "src/utils"; import cloneDeep from "lodash.clonedeep"; import { censorMessage } from "./censorMessage"; import escapeStringRegexp from "escape-string-regexp"; @@ -52,7 +59,7 @@ export async function applyFiltersToMsg( const inviteCodes = getInviteCodesInString(messageContent); - const invites: Array = await Promise.all( + const invites: Array = await Promise.all( inviteCodes.map(code => resolveInvite(pluginData.client, code)), ); @@ -63,27 +70,29 @@ export async function applyFiltersToMsg( return true; } - if (!invite.guild && !allowGroupDMInvites) { + if (!isGuildInvite(invite) && !allowGroupDMInvites) { censorMessage(pluginData, savedMessage, `group dm invites are not allowed`); return true; } - if (inviteGuildWhitelist && !inviteGuildWhitelist.includes(invite.guild.id)) { - censorMessage( - pluginData, - savedMessage, - `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) not found in whitelist`, - ); - return true; - } + if (isRESTGuildInvite(invite)) { + if (inviteGuildWhitelist && !inviteGuildWhitelist.includes(invite.guild.id)) { + censorMessage( + pluginData, + savedMessage, + `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) not found in whitelist`, + ); + return true; + } - if (inviteGuildBlacklist && inviteGuildBlacklist.includes(invite.guild.id)) { - censorMessage( - pluginData, - savedMessage, - `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) found in blacklist`, - ); - return true; + if (inviteGuildBlacklist && inviteGuildBlacklist.includes(invite.guild.id)) { + censorMessage( + pluginData, + savedMessage, + `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) found in blacklist`, + ); + return true; + } } if (inviteCodeWhitelist && !inviteCodeWhitelist.includes(invite.code)) { diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 55669576..0e52f5bf 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -27,6 +27,7 @@ import { JumboCmd } from "./commands/JumboCmd"; import { AvatarCmd } from "./commands/AvatarCmd"; import { CleanCmd } from "./commands/CleanCmd"; import { Message } from "eris"; +import { InviteInfoCmd } from "./commands/InviteInfoCmd"; const defaultOptions: PluginOptions = { config: { @@ -36,6 +37,7 @@ const defaultOptions: PluginOptions = { can_clean: false, can_info: false, can_server: false, + can_invite: false, can_reload_guild: false, can_nickname: false, can_ping: false, @@ -59,6 +61,7 @@ const defaultOptions: PluginOptions = { can_clean: true, can_info: true, can_server: true, + can_invite: true, can_nickname: true, can_vcmove: true, can_help: true, @@ -108,6 +111,7 @@ export const UtilityPlugin = zeppelinPlugin()("utility", { JumboCmd, AvatarCmd, CleanCmd, + InviteInfoCmd, ], onLoad(pluginData) { diff --git a/backend/src/plugins/Utility/commands/InviteInfoCmd.ts b/backend/src/plugins/Utility/commands/InviteInfoCmd.ts new file mode 100644 index 00000000..4121bfc0 --- /dev/null +++ b/backend/src/plugins/Utility/commands/InviteInfoCmd.ts @@ -0,0 +1,27 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { getInviteInfoEmbed } from "../functions/getInviteInfoEmbed"; +import { parseInviteCodeInput } from "../../../utils"; + +export const InviteInfoCmd = utilityCmd({ + trigger: ["invite", "inviteinfo"], + description: "Show information about an invite", + usage: "!invite overwatch", + permission: "can_invite", + + signature: { + inviteCode: ct.string({ required: false }), + }, + + async run({ message, args, pluginData }) { + const inviteCode = parseInviteCodeInput(args.inviteCode); + const embed = await getInviteInfoEmbed(pluginData, inviteCode); + if (!embed) { + sendErrorMessage(pluginData, message.channel, "Unknown invite"); + return; + } + + message.channel.createMessage({ embed }); + }, +}); diff --git a/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts new file mode 100644 index 00000000..bd763e41 --- /dev/null +++ b/backend/src/plugins/Utility/functions/getInviteInfoEmbed.ts @@ -0,0 +1,94 @@ +import { PluginData } from "knub"; +import { UtilityPluginType } from "../types"; +import { BaseInvite, Constants, EmbedOptions, RESTChannelInvite, RESTPrivateInvite } from "eris"; +import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp"; +import moment from "moment-timezone"; +import humanizeDuration from "humanize-duration"; +import { emptyEmbedValue, formatNumber, isRESTGroupDMInvite, isRESTGuildInvite, resolveInvite } from "../../../utils"; + +export async function getInviteInfoEmbed( + pluginData: PluginData, + inviteCode: string, +): Promise { + const invite = await resolveInvite(pluginData.client, inviteCode, true); + if (!invite) { + return null; + } + + if (isRESTGuildInvite(invite)) { + const embed: EmbedOptions = { + fields: [], + }; + + if (invite.guild.icon) { + embed.thumbnail = { + url: `https://cdn.discordapp.com/icons/${invite.guild.id}/${invite.guild.icon}.png?size=256`, + }; + } + + embed.title = `Server Invite - ${invite.guild.name}`; + embed.url = `https://discord.gg/${invite.code}`; + + embed.fields.push({ + name: "Server ID", + value: `\`${invite.guild.id}\``, + inline: true, + }); + + embed.fields.push({ + name: "Channel", + value: + invite.channel.type === Constants.ChannelTypes.GUILD_VOICE + ? `🔉 ${invite.channel.name}\n\`${invite.channel.id}\`` + : `#${invite.channel.name}\n\`${invite.channel.id}\``, + }); + + const createdAtTimestamp = snowflakeToTimestamp(invite.guild.id); + const createdAt = moment(createdAtTimestamp, "x"); + const serverAge = humanizeDuration(Date.now() - createdAtTimestamp, { + largest: 2, + round: true, + }); + + embed.fields.push({ + name: "Server age", + value: serverAge, + }); + + embed.fields.push({ + name: "Members", + value: `**${formatNumber(invite.memberCount)}** (${formatNumber(invite.presenceCount)} online)`, + inline: true, + }); + + return embed; + } + + if (isRESTGroupDMInvite(invite)) { + const embed: EmbedOptions = { + fields: [], + }; + + if (invite.channel.icon) { + embed.thumbnail = { + url: `https://cdn.discordapp.com/channel-icons/${invite.channel.id}/${invite.channel.icon}.png?size=256`, + }; + } + + embed.title = invite.channel.name ? `Group DM Invite - ${invite.channel.name}` : `Group DM Invite`; + + embed.fields.push({ + name: "Channel ID", + value: `\`${invite.channel.id}\``, + }); + + embed.fields.push({ + name: "Members", + value: formatNumber((invite as any).memberCount), + }); + + return embed; + } + + return null; +} diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts index f2637dbd..868f917a 100644 --- a/backend/src/plugins/Utility/types.ts +++ b/backend/src/plugins/Utility/types.ts @@ -13,6 +13,7 @@ export const ConfigSchema = t.type({ can_clean: t.boolean, can_info: t.boolean, can_server: t.boolean, + can_invite: t.boolean, can_reload_guild: t.boolean, can_nickname: t.boolean, can_ping: t.boolean, diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 9b811df0..3e3b0311 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,6 +1,7 @@ import { AnyInvite, Attachment, + BaseInvite, ChannelInvite, Client, Embed, @@ -15,6 +16,8 @@ import { Message, MessageContent, PossiblyUncachedMessage, + RESTChannelInvite, + RESTPrivateInvite, TextableChannel, TextChannel, User, @@ -450,6 +453,14 @@ export function getUrlsInString(str: string, onlyUnique = false): MatchedURL[] { }, []); } +export function parseInviteCodeInput(str: string): string { + if (str.match(/^[a-z0-9]{6,}$/i)) { + return str; + } + + return getInviteCodesInString(str)[0]; +} + export function getInviteCodesInString(str: string): string[] { const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite|discord.com\/invite)\/([a-z0-9]+)/gi; return Array.from(str.matchAll(inviteCodeRegex)).map(m => m[1]); @@ -1064,13 +1075,15 @@ export async function resolveRoleId(bot: Client, guildId: string, value: string) const inviteCache = new SimpleCache>(10 * MINUTES, 200); -export async function resolveInvite(client: Client, code: string): Promise { - if (inviteCache.has(code)) { - return inviteCache.get(code); +export async function resolveInvite(client: Client, code: string, withCounts?: boolean): Promise { + const key = `${code}:${withCounts ? 1 : 0}`; + + if (inviteCache.has(key)) { + return inviteCache.get(key); } - const promise = client.getInvite(code).catch(() => null); - inviteCache.set(code, promise); + const promise = client.getInvite(code, withCounts).catch(() => null); + inviteCache.set(key, promise); return promise; } @@ -1229,6 +1242,14 @@ export function isGuildInvite(invite: AnyInvite): invite is GuildInvite { return (invite as GuildInvite).guild != null; } +export function isRESTGuildInvite(invite: BaseInvite): invite is RESTChannelInvite { + return (invite as any).guild != null; +} + +export function isRESTGroupDMInvite(invite: BaseInvite): invite is RESTPrivateInvite { + return (invite as any).guild == null && (invite as any).channel != null; +} + export function asyncMap(arr: T[], fn: (item: T) => Promise): Promise { return Promise.all(arr.map((item, index) => fn(item))); } diff --git a/backend/src/utils/snowflakeToTimestamp.ts b/backend/src/utils/snowflakeToTimestamp.ts new file mode 100644 index 00000000..d06f0018 --- /dev/null +++ b/backend/src/utils/snowflakeToTimestamp.ts @@ -0,0 +1,7 @@ +/** + * @return Unix timestamp in milliseconds + */ +export function snowflakeToTimestamp(snowflake: string) { + // https://discord.com/developers/docs/reference#snowflakes-snowflake-id-format-structure-left-to-right + return Number(BigInt(snowflake) >> 22n) + 1420070400000; +}