From 4714e67c10dfe8833908fbd6c404574b8ecf91b1 Mon Sep 17 00:00:00 2001 From: LewsTherinTelescope <92685708+LewsTherinTelescope@users.noreply.github.com> Date: Wed, 16 Feb 2022 20:13:26 -0600 Subject: [PATCH] Add new webhook commands --- src/bot.ts | 3 +- src/deploy.ts | 104 ++++++++++++++++++++++++++++++++++- src/message-utils.ts | 126 +++++++++++++++++++++++++++++++++++++------ src/modhook.ts | 114 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 20 deletions(-) create mode 100644 src/modhook.ts diff --git a/src/bot.ts b/src/bot.ts index 2320775..0354335 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -4,6 +4,7 @@ import spoilerAttachments from './spoiler-attachments'; import autoThreadInvite from './auto-thread-invite'; import rawMessage from './raw-message'; import messageFilter from './message-filter'; +import { simple as mod, full as modhook } from './modhook'; import { Command } from './commands'; import logger from './logger'; @@ -27,7 +28,7 @@ interface Module { additionalHandlers?: Partial<{ [K in keyof ClientEvents]: (client: Client, ...args: ClientEvents[K]) => Awaitable }>; } -const modules: Module[] = [autoPublish, spoilerAttachments, autoThreadInvite, rawMessage, messageFilter]; +const modules: Module[] = [autoPublish, spoilerAttachments, autoThreadInvite, rawMessage, messageFilter, mod, modhook]; const commands = modules.reduce<{ [name: string]: Command }>( (acc, module) => { if (module.command === undefined) { diff --git a/src/deploy.ts b/src/deploy.ts index 84e8678..940fa05 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -7,6 +7,14 @@ if (token === undefined) { process.exit(1); } +const guildTextChannelTypes = [ + 'GUILD_TEXT' as const, + 'GUILD_NEWS' as const, + 'GUILD_NEWS_THREAD' as const, + 'GUILD_PRIVATE_THREAD' as const, + 'GUILD_PUBLIC_THREAD' as const, +]; + const filterTypes: ApplicationCommandOptionChoice[] = [ { name: 'Matches any message that contains the filter content verbatim', @@ -284,12 +292,104 @@ const cmds: ApplicationCommandDataResolvable[] = [ ] } ] - } + }, + { + type: 'CHAT_INPUT', + name: 'mod', + description: 'Send a message in a more official-looking way', + options: [ + { + type: 'STRING', + name: 'content', + description: 'Content of the message to send', + required: true + }, + { + type: 'STRING', + name: 'edit-link', + description: 'If provided, will edit the message at the given link rather than sending a new one', + required: false + }, + { + type: 'CHANNEL', + name: 'channel', + description: 'If provided, will send in the specified channel instead of this one', + required: false, + channelTypes: guildTextChannelTypes + }, + //{ + // type: 'ATTACHMENT', + // name: 'attachment', + // description: 'Optional attachment to include', + // required: false + //}, + ], + defaultPermission: false + }, + { + type: 'CHAT_INPUT', + name: 'webhook', + description: 'Send and edit webhook messages', + options: [ + { + type: 'STRING', + name: 'content', + description: 'Content of the message to send. Prefix with `RAW:` to interpret the rest of the string as raw JSON.', + required: true + }, + { + type: 'STRING', + name: 'edit-link', + description: '(Incompat. with appearance + destination opts) Link to message to edit rather than sending a new one', + required: false + }, + { + type: 'CHANNEL', + name: 'channel', + description: 'If provided, will send in the specified channel instead of this one', + required: false, + channelTypes: guildTextChannelTypes + }, + //{ + // type: 'ATTACHMENT', + // name: 'attachment', + // description: 'Optional attachment to include', + // required: false + //}, + { + type: 'STRING', + name: 'webhook-id', + description: 'Alternate webhook to use instead of the default one.', + required: false + }, + { + type: 'STRING', + name: 'username', + description: 'Alternate username to post with instead of the webhook\'s default', + required: false + }, + { + type: 'STRING', + name: 'avatar-link', + description: 'Alternate avatar to post with instead of the webhook\'s default. Lower priority than `avatar`.', + required: false + }, + //{ + // type: 'ATTACHMENT', + // name: 'avatar', + // description: 'Alternate avatar to post with instead of the webhook\'s default. Higher priority than `avatar-link`.', + // required: false + //}, + ], + defaultPermission: false + }, ]; const client = new Client({ intents: [] }); client.once('ready', async () => { - await client.application.commands.set(cmds); + const guildId = process.env.HARMONY_DEPLOY_GUILD; + if (guildId) await (await client.guilds.fetch(guildId)).commands.set(cmds); + else await client.application.commands.set(cmds); console.log('All commands deployed'); client.destroy(); }); diff --git a/src/message-utils.ts b/src/message-utils.ts index 79ea28b..8222804 100644 --- a/src/message-utils.ts +++ b/src/message-utils.ts @@ -1,3 +1,4 @@ +import { Client, DMChannel, GuildChannel, Message, PartialGroupDMChannel, Permissions, UserResolvable } from 'discord.js'; import markdown, { Capture, Parser, SingleASTNode, State } from 'simple-markdown'; const rules = { @@ -5,10 +6,10 @@ const rules = { match: (source: string) => /^[\s\S]+?(?=[^0-9A-Za-z\s\u00C0-\uFFFF-]|\n\n|\n|\w+:\S|$)/.exec(source) }), blockQuote: Object.assign({}, markdown.defaultRules.blockQuote, { - match (source: string, state: State, prevSource: string): Capture | null { + match(source: string, state: State, prevSource: string): Capture | null { return !/^$|\n *$/.test(prevSource) || state.inQuote ? null : /^( *>>> ([\s\S]*))|^( *> [^\n]*(\n *> [^\n]*)*\n?)/.exec(source); }, - parse (capture: Capture, parse: Parser, state: State) { + parse(capture: Capture, parse: Parser, state: State) { const all = capture[0]; const isBlock = Boolean(/^ *>>> ?/.exec(all)); const removeSyntaxRegex = isBlock ? /^ *>>> ?/ : /^ *> ?/gm; @@ -32,7 +33,7 @@ const rules = { }), codeBlock: Object.assign({}, markdown.defaultRules.codeBlock, { match: markdown.inlineRegex(/^```(([a-z0-9-]+?)\n+)?\n*([^]+?)\n*```/i), - parse (capture: Capture, parse: Parser, state: State) { + parse(capture: Capture, parse: Parser, state: State) { return { lang: (capture[2] || '').trim(), content: capture[3], @@ -43,7 +44,7 @@ const rules = { newline: markdown.defaultRules.newline, escape: markdown.defaultRules.escape, autolink: Object.assign({}, markdown.defaultRules.autolink, { - parse (capture: Capture) { + parse(capture: Capture) { return { content: [ { @@ -56,7 +57,7 @@ const rules = { } }), url: Object.assign({}, markdown.defaultRules.url, { - parse (capture: Capture) { + parse(capture: Capture) { return { content: [ { @@ -78,7 +79,7 @@ const rules = { emoticon: { order: markdown.defaultRules.text.order, match: (source: string) => /^(¯\\_\(ツ\)_\/¯)/.exec(source), - parse (capture: Capture) { + parse(capture: Capture) { return { type: 'text', content: capture[1] @@ -91,7 +92,7 @@ const rules = { spoiler: { order: 0, match: (source: string) => /^\|\|([\s\S]+?)\|\|/.exec(source), - parse (capture: Capture, parse: Parser, state: State) { + parse(capture: Capture, parse: Parser, state: State) { return { content: parse(capture[1], state) }; @@ -103,7 +104,7 @@ const discordRules = { discordUser: { order: markdown.defaultRules.strong.order, match: (source: string) => /^<@!?([0-9]*)>/.exec(source), - parse (capture: Capture) { + parse(capture: Capture) { return { id: capture[1] }; @@ -112,7 +113,7 @@ const discordRules = { discordChannel: { order: markdown.defaultRules.strong.order, match: (source: string) => /^<#?([0-9]*)>/.exec(source), - parse (capture: Capture) { + parse(capture: Capture) { return { id: capture[1] }; @@ -121,7 +122,7 @@ const discordRules = { discordRole: { order: markdown.defaultRules.strong.order, match: (source: string) => /^<@&([0-9]*)>/.exec(source), - parse (capture: Capture) { + parse(capture: Capture) { return { id: capture[1] }; @@ -130,7 +131,7 @@ const discordRules = { discordEmoji: { order: markdown.defaultRules.strong.order, match: (source: string) => /^<(a?):(\w+):(\d+)>/.exec(source), - parse (capture: Capture) { + parse(capture: Capture) { return { animated: capture[1] === 'a', name: capture[2], @@ -141,14 +142,14 @@ const discordRules = { discordEveryone: { order: markdown.defaultRules.strong.order, match: (source: string) => /^@everyone/.exec(source), - parse () { + parse() { return {}; } }, discordHere: { order: markdown.defaultRules.strong.order, match: (source: string) => /^@here/.exec(source), - parse () { + parse() { return {}; } } @@ -156,14 +157,14 @@ const discordRules = { Object.assign(rules, discordRules); const messageUtils = markdown.parserFor(rules); -export default function parse (source: string): Array { +export default function parse(source: string): Array { return messageUtils(source, { inline: true }); } -export function sanitize (source: string): string { +export function sanitize(source: string): string { const parsed = parse(source); - function sanitizeNode (node: SingleASTNode) { + function sanitizeNode(node: SingleASTNode) { switch (node.type) { case 'strong': case 'em': @@ -203,7 +204,7 @@ export function sanitize (source: string): string { // Discord doesn't allow escaping backticks inside a code block, so if // we want accurate source, we can't use code blocks, have to escape it // all instead -export function escape (content: string): string { +export function escape(content: string): string { return content.replaceAll('\\', '\\\\') .replaceAll('*', '\\*') .replaceAll('_', '\\_') @@ -214,3 +215,94 @@ export function escape (content: string): string { .replaceAll('<', '\\<'); } +/** + * Regex patterns to match various Discord URL types and retrieve information from them via named + * capture groups. + */ +export const patterns = class patterns { + /** + * [Docs](https://discord.com/developers/docs/reference#snowflakes) + */ + static snowflake = '\\d{18}'; + + /** + * Matches a user or role mention. + * + * Groups: + * * `mentionId`: ID of the user or role + */ + static mention = `<@(?:!|&)?(?${patterns.snowflake})>`; + + /** + * Groups: + * * `scheme` (optional): `http` or `https` + * * `version` (optional): `canary` or `ptb`, if applicable + */ + static web = '(?:(?https?):\\/\\/)?(?:(?canary|ptb)\\.)?discord\\.com'; + + /** + * Groups: + * * `scheme`: `discord` + */ + static protocol = 'discord:\\/\\/-'; + + /** + * @see patterns.web for web links + * @see patterns.protocol for Discord protocol + */ + static base = `(?:${patterns.web}|${patterns.protocol})`; + + /** + * Groups: + * * `guildId`: snowflake ID of the guild, or `@me` for DMs + */ + static guild = `${patterns.base}\\/channels\\/(?${patterns.snowflake}|@me)`; + + /** + * Groups: + * * `channelId`: snowflake ID of the channel + */ + static channel = `${patterns.guild}\\/(?${patterns.snowflake})`; + + /** + * Groups: + * * `messageId`: snowflake ID of the message + */ + static message = `${patterns.channel}\\/(?${patterns.snowflake})`; + + /** + * Groups: + * * `webhookId`: snowflake ID of the webhook + * * `webhookToken` (optional): token that can be used to access the webhook via URL + */ + static webhook = `${patterns.base}\\/api\\/webhooks\\/)?(?${patterns.snowflake})(?:\\/(?.{68})\\/?)?`; +}; + +export class MessageResolveError extends TypeError { + constructor(message: string) { + super(message); + } +} + +export async function resolveMessageLink(client: Client, link: string, options?: { + /** + * Ensures that the given user has permission to access the message. + */ + asUser: UserResolvable +}): Promise { + const match = link?.match(patterns.message); + if (!match) throw new TypeError('The provided link is not a message link!'); + + const { channelId, messageId } = match.groups; + const channel = await client.channels.fetch(channelId); + if (!(channel.isText())) throw new MessageResolveError('Channel is not a text channel!'); + else if ( + (channel instanceof GuildChannel && !channel + .permissionsFor(options.asUser) + ?.has(Permissions.FLAGS.VIEW_CHANNEL)) + || (channel instanceof DMChannel && channel.recipient.id !== client.users.resolveId(options.asUser)) + || (channel instanceof PartialGroupDMChannel && !channel.recipients.some(r => r.username === (client.users.resolve(options.asUser)).username)) + ) throw new MessageResolveError('User does not have permission!'); + + return await channel.messages.fetch(messageId); +} diff --git a/src/modhook.ts b/src/modhook.ts new file mode 100644 index 0000000..04027e5 --- /dev/null +++ b/src/modhook.ts @@ -0,0 +1,114 @@ +import { Client, CommandInteraction, GuildTextBasedChannel, MessageOptions, NewsChannel, TextChannel, Webhook } from 'discord.js'; +import { SimpleCommand } from './commands'; +import logger from './logger'; +import { MessageResolveError, patterns, resolveMessageLink } from './message-utils'; +import { guilds as storage } from './storage'; + +async function getWebhook(channel: TextChannel | NewsChannel): Promise { + const guildId = channel.guildId; + const channelId = channel.id; + const client = channel.client; + + storage.channels(guildId); + const stored: string = storage.channels(guildId).get(channelId, 'webhook'); + if (stored) return client.fetchWebhook(stored); + else { + const webhook = await channel.createWebhook(channel.guild.name, { + avatar: channel.guild.iconURL(), + reason: 'Harmony mod message sending' + }); + storage.channels(guildId).set(channelId, 'webhook', webhook.id); + return webhook; + } +} + +const handlers = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async send(client: Client, interaction: CommandInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + const content = interaction.options.getString('content', true); + + const editLink = interaction.options.getString('edit-link'); + const webhookArg = interaction.options.getString('webhook-id'); + const channel = interaction.options.getChannel('channel'); + if ((editLink && webhookArg) || (editLink && channel) || (webhookArg && channel)) { + return await interaction.editReply('Destination-setting options cannot be used alongside each other.'); + } + const webhookId = (webhookArg) ? patterns.webhook.match(webhookArg).groups?.webhookId ?? null : undefined; + if (webhookId === null) return await interaction.editReply('`webhook-id` does not point to a valid webhook!'); + + let message; + if (editLink) try { + message = await resolveMessageLink(client, editLink, { asUser: interaction.user }); + if (!message.webhookId || message.guildId !== interaction.guildId) throw new MessageResolveError(''); + } catch (e) { + if (e instanceof MessageResolveError) { + return await interaction.editReply('`edit-link` does not point to a valid message!'); + } else throw e; + } + + const webhook = editLink + ? await message.fetchWebhook() + : webhookId + ? await interaction.client.fetchWebhook(webhookId) + : await getWebhook((channel ?? interaction.channel) as TextChannel); + + if (webhook.type === 'Channel Follower') return await interaction.editReply('Cannot use channel follower webhooks.'); + + //const attachment = interaction.options.getAttachment('attachment'); + //const avatarImg = interaction.options.getAttachment('avatar'); + + const parsed: MessageOptions = (content.startsWith('RAW:{')) + ? JSON.parse(content.replace('RAW:', '')) + : { content: content.replaceAll(' ', '\n') }; + + logger.info({ + message: `Sending message from ${interaction.user.tag}`, + context: { + sourceChannel: `#${(interaction.channel as GuildTextBasedChannel).name}`, + sentChannel: webhook.channelId, + guild: interaction.guildId, + webhook: webhook.url, + editLink, + } + }); + + const username = interaction.options.getString('username'); + const avatarURL = /*avatarImg?.url??*/ interaction.options.getString('avatar-link'); + if (editLink) { + if (avatarURL || username) return await interaction.editReply('`edit-link` cannot be used alongside appearance options.'); + else await webhook.editMessage(message, parsed); + } else { + await webhook.send({ + ...parsed, + username: username ?? interaction.guild.name, + avatarURL: avatarURL ?? interaction.guild.iconURL(), + //attachments: [attachment], + }); + } + + await interaction.editReply('Sent!'); + } +}; + +/** + * Easy-to-use variant of the command. Should accept only two options: `content` (required) and `edit-link` (optional). + */ +export const simple = { + command: new SimpleCommand( + 'mod', + handlers.send + ) +}; + +/** + * Full version of the command, which is far more flexible but far more complicated. + * + * Note: Only difference here is the name, deploy metadata is where the main differences are. + */ +export const full = { + command: new SimpleCommand( + 'webhook', + handlers.send + ) +};