Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -27,7 +28,7 @@ interface Module {
additionalHandlers?: Partial<{ [K in keyof ClientEvents]: (client: Client, ...args: ClientEvents[K]) => Awaitable<void> }>;
}

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) {
Expand Down
104 changes: 102 additions & 2 deletions src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
});
Expand Down
126 changes: 109 additions & 17 deletions src/message-utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Client, DMChannel, GuildChannel, Message, PartialGroupDMChannel, Permissions, UserResolvable } from 'discord.js';
import markdown, { Capture, Parser, SingleASTNode, State } from 'simple-markdown';

const rules = {
text: Object.assign({}, markdown.defaultRules.text, {
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;
Expand All @@ -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],
Expand All @@ -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: [
{
Expand All @@ -56,7 +57,7 @@ const rules = {
}
}),
url: Object.assign({}, markdown.defaultRules.url, {
parse (capture: Capture) {
parse(capture: Capture) {
return {
content: [
{
Expand All @@ -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]
Expand All @@ -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)
};
Expand All @@ -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]
};
Expand All @@ -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]
};
Expand All @@ -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]
};
Expand All @@ -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],
Expand All @@ -141,29 +142,29 @@ 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 {};
}
}
};
Object.assign(rules, discordRules);

const messageUtils = markdown.parserFor(rules);
export default function parse (source: string): Array<SingleASTNode> {
export default function parse(source: string): Array<SingleASTNode> {
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':
Expand Down Expand Up @@ -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('_', '\\_')
Expand All @@ -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 = `<@(?:!|&)?(?<mentionId>${patterns.snowflake})>`;

/**
* Groups:
* * `scheme` (optional): `http` or `https`
* * `version` (optional): `canary` or `ptb`, if applicable
*/
static web = '(?:(?<scheme>https?):\\/\\/)?(?:(?<version>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\\/(?<guildId>${patterns.snowflake}|@me)`;

/**
* Groups:
* * `channelId`: snowflake ID of the channel
*/
static channel = `${patterns.guild}\\/(?<channelId>${patterns.snowflake})`;

/**
* Groups:
* * `messageId`: snowflake ID of the message
*/
static message = `${patterns.channel}\\/(?<messageId>${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\\/)?(?<webhookId>${patterns.snowflake})(?:\\/(?<webhookToken>.{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<Message> {
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);
}
Loading