diff --git a/assets/icons/snowflake.png b/assets/icons/snowflake.png new file mode 100644 index 00000000..20240f05 Binary files /dev/null and b/assets/icons/snowflake.png differ diff --git a/assets/icons/snowflake.svg b/assets/icons/snowflake.svg new file mode 100644 index 00000000..258c161b --- /dev/null +++ b/assets/icons/snowflake.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/src/commandTypes.ts b/backend/src/commandTypes.ts index 9298a292..74919110 100644 --- a/backend/src/commandTypes.ts +++ b/backend/src/commandTypes.ts @@ -1,10 +1,14 @@ import { + channelMentionRegex, convertDelayStringToMS, disableCodeBlocks, disableInlineCode, isSnowflake, + isValidSnowflake, resolveMember, resolveUser, + resolveUserId, + roleMentionRegex, UnknownUser, } from "./utils"; import { GuildChannel, Member, TextChannel, User } from "eris"; @@ -63,6 +67,23 @@ export const commandTypes = { return result; }, + + async anyId(value: string, context: CommandContext) { + const userId = resolveUserId(context.pluginData.client, value); + if (userId) return userId; + + const channelIdMatch = value.match(channelMentionRegex); + if (channelIdMatch) return channelIdMatch[1]; + + const roleIdMatch = value.match(roleMentionRegex); + if (roleIdMatch) return roleIdMatch[1]; + + if (isValidSnowflake(value)) { + return value; + } + + throw new TypeConversionError(`Could not parse ID: \`${disableInlineCode(value)}\``); + }, }; export const commandTypeHelpers = { @@ -73,4 +94,5 @@ export const commandTypeHelpers = { resolvedUserLoose: createTypeHelper>(commandTypes.resolvedUserLoose), resolvedMember: createTypeHelper>(commandTypes.resolvedMember), messageTarget: createTypeHelper>(commandTypes.messageTarget), + anyId: createTypeHelper>(commandTypes.anyId), }; diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 5e31bc34..1300e8d1 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -31,6 +31,7 @@ import { InviteInfoCmd } from "./commands/InviteInfoCmd"; import { ChannelInfoCmd } from "./commands/ChannelInfoCmd"; import { MessageInfoCmd } from "./commands/MessageInfoCmd"; import { InfoCmd } from "./commands/InfoCmd"; +import { SnowflakeInfoCmd } from "./commands/SnowflakeInfoCmd"; const defaultOptions: PluginOptions = { config: { @@ -44,6 +45,7 @@ const defaultOptions: PluginOptions = { can_channelinfo: false, can_messageinfo: false, can_userinfo: false, + can_snowflake: false, can_reload_guild: false, can_nickname: false, can_ping: false, @@ -71,6 +73,7 @@ const defaultOptions: PluginOptions = { can_channelinfo: true, can_messageinfo: true, can_userinfo: true, + can_snowflake: true, can_nickname: true, can_vcmove: true, can_help: true, @@ -124,6 +127,7 @@ export const UtilityPlugin = zeppelinPlugin()("utility", { ChannelInfoCmd, MessageInfoCmd, InfoCmd, + SnowflakeInfoCmd, ], onLoad(pluginData) { diff --git a/backend/src/plugins/Utility/commands/InfoCmd.ts b/backend/src/plugins/Utility/commands/InfoCmd.ts index 37bf2968..bc2b1e77 100644 --- a/backend/src/plugins/Utility/commands/InfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InfoCmd.ts @@ -2,7 +2,7 @@ import { utilityCmd } from "../types"; import { commandTypeHelpers as ct } from "../../../commandTypes"; import { sendErrorMessage } from "../../../pluginUtils"; import { getInviteInfoEmbed } from "../functions/getInviteInfoEmbed"; -import { parseInviteCodeInput, resolveInvite, resolveUser } from "../../../utils"; +import { isValidSnowflake, parseInviteCodeInput, resolveInvite, resolveUser } from "../../../utils"; import { getUserInfoEmbed } from "../functions/getUserInfoEmbed"; import { resolveMessageTarget } from "../../../utils/resolveMessageTarget"; import { canReadChannel } from "../../../utils/canReadChannel"; @@ -11,6 +11,7 @@ import { getChannelInfoEmbed } from "../functions/getChannelInfoEmbed"; import { getServerInfoEmbed } from "../functions/getServerInfoEmbed"; import { getChannelId } from "knub/dist/utils"; import { getGuildPreview } from "../functions/getGuildPreview"; +import { getSnowflakeInfoEmbed } from "../functions/getSnowflakeInfoEmbed"; export const InfoCmd = utilityCmd({ trigger: "info", @@ -93,6 +94,13 @@ export const InfoCmd = utilityCmd({ } } + // 7. Arbitrary ID + if (isValidSnowflake(value)) { + const embed = getSnowflakeInfoEmbed(pluginData, value, true); + message.channel.createMessage({ embed }); + return; + } + // 7. No can do sendErrorMessage(pluginData, message.channel, "Could not find anything with that value"); }, diff --git a/backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts b/backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts new file mode 100644 index 00000000..b327dfc0 --- /dev/null +++ b/backend/src/plugins/Utility/commands/SnowflakeInfoCmd.ts @@ -0,0 +1,21 @@ +import { utilityCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage } from "../../../pluginUtils"; +import { getChannelInfoEmbed } from "../functions/getChannelInfoEmbed"; +import { getSnowflakeInfoEmbed } from "../functions/getSnowflakeInfoEmbed"; + +export const SnowflakeInfoCmd = utilityCmd({ + trigger: ["snowflake", "snowflakeinfo"], + description: "Show information about a snowflake ID", + usage: "!snowflake 534722016549404673", + permission: "can_snowflake", + + signature: { + id: ct.anyId(), + }, + + run({ message, args, pluginData }) { + const embed = getSnowflakeInfoEmbed(pluginData, args.id); + message.channel.createMessage({ embed }); + }, +}); diff --git a/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts new file mode 100644 index 00000000..68b4ce82 --- /dev/null +++ b/backend/src/plugins/Utility/functions/getSnowflakeInfoEmbed.ts @@ -0,0 +1,44 @@ +import { Message, GuildTextableChannel, EmbedOptions } from "eris"; +import { PluginData } from "knub"; +import { UtilityPluginType } from "../types"; +import { UnknownUser, trimLines, embedPadding, resolveMember, resolveUser, preEmbedPadding } from "src/utils"; +import moment from "moment-timezone"; +import { CaseTypes } from "src/data/CaseTypes"; +import humanizeDuration from "humanize-duration"; +import { snowflakeToTimestamp } from "../../../utils/snowflakeToTimestamp"; + +const SNOWFLAKE_ICON = "https://cdn.discordapp.com/attachments/740650744830623756/742020790471491668/snowflake.png"; + +export function getSnowflakeInfoEmbed( + pluginData: PluginData, + snowflake: string, + showUnknownWarning = false, +): EmbedOptions { + const embed: EmbedOptions = { + fields: [], + }; + + embed.author = { + name: `Snowflake: ${snowflake}`, + icon_url: SNOWFLAKE_ICON, + }; + + if (showUnknownWarning) { + embed.description = + "This is a valid [snowflake ID](https://discord.com/developers/docs/reference#snowflakes), but I don't know what it's for."; + } + + const createdAtMS = snowflakeToTimestamp(snowflake); + const createdAt = moment(createdAtMS, "x"); + const snowflakeAge = humanizeDuration(Date.now() - createdAtMS, { + largest: 2, + round: true, + }); + + embed.fields.push({ + name: preEmbedPadding + "Basic information", + value: `Created: **${snowflakeAge} ago** (\`${createdAt.format("MMM D, YYYY [at] H:mm [UTC]")}\`)`, + }); + + return embed; +} diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts index b1ccbd7e..c75cd9bb 100644 --- a/backend/src/plugins/Utility/types.ts +++ b/backend/src/plugins/Utility/types.ts @@ -17,6 +17,7 @@ export const ConfigSchema = t.type({ can_channelinfo: t.boolean, can_messageinfo: t.boolean, can_userinfo: t.boolean, + can_snowflake: 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 8bda4bda..3f6c5ef3 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -58,6 +58,19 @@ export const WEEKS = 7 * 24 * HOURS; export const EMPTY_CHAR = "\u200b"; +// https://discord.com/developers/docs/reference#snowflakes +export const MIN_SNOWFLAKE = 0b000000000000000000000000000000000000000000_00001_00001_000000000001; +// 0b111111111111111111111111111111111111111111_11111_11111_111111111111 without _ which BigInt doesn't support +export const MAX_SNOWFLAKE = BigInt("0b1111111111111111111111111111111111111111111111111111111111111111"); + +const snowflakePattern = /^[1-9]\d+$/; +export function isValidSnowflake(str: string) { + if (!str.match(snowflakePattern)) return false; + if (parseInt(str, 10) < MIN_SNOWFLAKE) return false; + if (BigInt(str) > MAX_SNOWFLAKE) return false; + return true; +} + export const DISCORD_HTTP_ERROR_NAME = "DiscordHTTPError"; export const DISCORD_REST_ERROR_NAME = "DiscordRESTError"; diff --git a/backend/src/utils/snowflakeToTimestamp.ts b/backend/src/utils/snowflakeToTimestamp.ts index d06f0018..09494eed 100644 --- a/backend/src/utils/snowflakeToTimestamp.ts +++ b/backend/src/utils/snowflakeToTimestamp.ts @@ -1,7 +1,13 @@ +import { isValidSnowflake } from "../utils"; + /** * @return Unix timestamp in milliseconds */ export function snowflakeToTimestamp(snowflake: string) { + if (!isValidSnowflake(snowflake)) { + throw new Error(`Invalid snowflake: ${snowflake}`); + } + // https://discord.com/developers/docs/reference#snowflakes-snowflake-id-format-structure-left-to-right - return Number(BigInt(snowflake) >> 22n) + 1420070400000; + return Number(BigInt(snowflake) >> 22n) + 1_420_070_400_000; }