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;
}