Add Matrix command handler + emoji command
This commit is contained in:
		
							parent
							
								
									c1cbdfee82
								
							
						
					
					
						commit
						d1e3640078
					
				
					 5 changed files with 285 additions and 12 deletions
				
			
		| 
						 | 
				
			
			@ -63,7 +63,7 @@ async function stickersToState(stickers) {
 | 
			
		|||
			if (sticker && sticker.description) body += ` - ${sticker.description}`
 | 
			
		||||
			if (!body) body = undefined
 | 
			
		||||
 | 
			
		||||
			let shortcode = sticker.name.toLowerCase().replace(/[^a-zA-Z0-9-_]/g, "-").replace(/^-|-$/g, "").replace(/--+/g, "-")
 | 
			
		||||
			let shortcode = sticker.name.toLowerCase().replace(/[^a-zA-Z0-9_-]/g, "-").replace(/^-|-$/g, "").replace(/--+/g, "-")
 | 
			
		||||
			while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
 | 
			
		||||
			shortcodes.push(shortcode)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,15 +15,6 @@ const utils = sync.require("../converters/utils")
 | 
			
		|||
/** @type {import("./emoji-sheet")} */
 | 
			
		||||
const emojiSheet = sync.require("./emoji-sheet")
 | 
			
		||||
 | 
			
		||||
const BLOCK_ELEMENTS = [
 | 
			
		||||
	"ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
 | 
			
		||||
	"CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
 | 
			
		||||
	"FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
 | 
			
		||||
	"HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
 | 
			
		||||
	"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
 | 
			
		||||
	"TFOOT", "TH", "THEAD", "TR", "UL"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
/** @type {[RegExp, string][]} */
 | 
			
		||||
const markdownEscapes = [
 | 
			
		||||
	[/\\/g, '\\\\'],
 | 
			
		||||
| 
						 | 
				
			
			@ -235,7 +226,7 @@ function splitDisplayName(displayName) {
 | 
			
		|||
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles) {
 | 
			
		||||
	if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
 | 
			
		||||
	// Remove known and unknown emojis from the end of the message
 | 
			
		||||
	const r = /<a?:[a-zA-Z0-9_-]*:[0-9]*>\s*$/
 | 
			
		||||
	const r = /<a?:[a-zA-Z0-9_]*:[0-9]*>\s*$/
 | 
			
		||||
	while (content.match(r)) {
 | 
			
		||||
		content = content.replace(r, "")
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -403,7 +394,7 @@ async function eventToMessage(event, guild, di) {
 | 
			
		|||
				beforeTag = beforeTag || ""
 | 
			
		||||
				afterContext = afterContext || ""
 | 
			
		||||
				afterTag = afterTag || ""
 | 
			
		||||
				if (!BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
 | 
			
		||||
				if (!utils.BLOCK_ELEMENTS.includes(beforeTag.toUpperCase()) && !utils.BLOCK_ELEMENTS.includes(afterTag.toUpperCase())) {
 | 
			
		||||
					return beforeContext + "<br>" + afterContext
 | 
			
		||||
				} else {
 | 
			
		||||
					return whole
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,6 +8,15 @@ let hasher = null
 | 
			
		|||
// @ts-ignore
 | 
			
		||||
require("xxhash-wasm")().then(h => hasher = h)
 | 
			
		||||
 | 
			
		||||
const BLOCK_ELEMENTS = [
 | 
			
		||||
	"ADDRESS", "ARTICLE", "ASIDE", "AUDIO", "BLOCKQUOTE", "BODY", "CANVAS",
 | 
			
		||||
	"CENTER", "DD", "DETAILS", "DIR", "DIV", "DL", "DT", "FIELDSET", "FIGCAPTION", "FIGURE",
 | 
			
		||||
	"FOOTER", "FORM", "FRAMESET", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER",
 | 
			
		||||
	"HGROUP", "HR", "HTML", "ISINDEX", "LI", "MAIN", "MENU", "NAV", "NOFRAMES",
 | 
			
		||||
	"NOSCRIPT", "OL", "OUTPUT", "P", "PRE", "SECTION", "SUMMARY", "TABLE", "TBODY", "TD",
 | 
			
		||||
	"TFOOT", "TH", "THEAD", "TR", "UL"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Determine whether an event is the bridged representation of a discord message.
 | 
			
		||||
 * Such messages shouldn't be bridged again.
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +63,7 @@ function getEventIDHash(eventID) {
 | 
			
		|||
	return signedHash
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
 | 
			
		||||
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
 | 
			
		||||
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
 | 
			
		||||
module.exports.getEventIDHash = getEventIDHash
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,8 @@ const sendEvent = sync.require("./actions/send-event")
 | 
			
		|||
const addReaction = sync.require("./actions/add-reaction")
 | 
			
		||||
/** @type {import("./actions/redact")} */
 | 
			
		||||
const redact = sync.require("./actions/redact")
 | 
			
		||||
/** @type {import("../matrix/matrix-command-handler")} */
 | 
			
		||||
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
 | 
			
		||||
/** @type {import("./converters/utils")} */
 | 
			
		||||
const utils = sync.require("./converters/utils")
 | 
			
		||||
/** @type {import("../matrix/api")}) */
 | 
			
		||||
| 
						 | 
				
			
			@ -78,6 +80,10 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
 | 
			
		|||
async event => {
 | 
			
		||||
	if (utils.eventSenderIsFromDiscord(event.sender)) return
 | 
			
		||||
	const messageResponses = await sendEvent.sendEvent(event)
 | 
			
		||||
	if (event.type === "m.room.message" && event.content.msgtype === "m.text") {
 | 
			
		||||
		// @ts-ignore
 | 
			
		||||
		await matrixCommandHandler.execute(event)
 | 
			
		||||
	}
 | 
			
		||||
}))
 | 
			
		||||
 | 
			
		||||
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
 | 
			
		||||
| 
						 | 
				
			
			@ -99,6 +105,7 @@ async event => {
 | 
			
		|||
		// Try to bridge a failed event again?
 | 
			
		||||
		await retry(event.room_id, event.content["m.relates_to"].event_id)
 | 
			
		||||
	} else {
 | 
			
		||||
		matrixCommandHandler.onReactionAdd(event)
 | 
			
		||||
		await addReaction.addReaction(event)
 | 
			
		||||
	}
 | 
			
		||||
}))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										265
									
								
								matrix/matrix-command-handler.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										265
									
								
								matrix/matrix-command-handler.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,265 @@
 | 
			
		|||
// @ts-check
 | 
			
		||||
 | 
			
		||||
const assert = require("assert").strict
 | 
			
		||||
const Ty = require("../types")
 | 
			
		||||
const {pipeline} = require("stream").promises
 | 
			
		||||
const sharp = require("sharp")
 | 
			
		||||
 | 
			
		||||
const {discord, sync, db, select} = require("../passthrough")
 | 
			
		||||
/** @type {import("./api")}) */
 | 
			
		||||
const api = sync.require("./api")
 | 
			
		||||
/** @type {import("../m2d/converters/utils")} */
 | 
			
		||||
const mxUtils = sync.require("../m2d/converters/utils")
 | 
			
		||||
/** @type {import("../discord/utils")} */
 | 
			
		||||
const dUtils = sync.require("../discord/utils")
 | 
			
		||||
 | 
			
		||||
const PREFIXES = ["//", "/"]
 | 
			
		||||
 | 
			
		||||
const EMOJI_SIZE = 128
 | 
			
		||||
 | 
			
		||||
/** This many normal emojis + this many animated emojis. The total number is doubled. */
 | 
			
		||||
const TIER_EMOJI_SLOTS = new Map([
 | 
			
		||||
	[1, 100],
 | 
			
		||||
	[2, 150],
 | 
			
		||||
	[3, 250]
 | 
			
		||||
])
 | 
			
		||||
 | 
			
		||||
/** @param {number} tier */
 | 
			
		||||
function getSlotCount(tier) {
 | 
			
		||||
	return TIER_EMOJI_SLOTS.get(tier) || 50
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let buttons = []
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param {string} roomID where to add the button
 | 
			
		||||
 * @param {string} eventID where to add the button
 | 
			
		||||
 * @param {string} key emoji to add as a button
 | 
			
		||||
 * @param {string} mxid only listen for responses from this user
 | 
			
		||||
 * @returns {Promise<import("discord-api-types/v10").GatewayMessageReactionAddDispatchData>}
 | 
			
		||||
 */
 | 
			
		||||
async function addButton(roomID, eventID, key, mxid) {
 | 
			
		||||
	await api.sendEvent(roomID, "m.reaction", {
 | 
			
		||||
		"m.relates_to": {
 | 
			
		||||
			rel_type: "m.annotation",
 | 
			
		||||
			event_id: eventID,
 | 
			
		||||
			key
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	return new Promise(resolve => {
 | 
			
		||||
		buttons.push({roomID, eventID, mxid, key, resolve, created: Date.now()})
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Clear out old buttons every so often to free memory
 | 
			
		||||
setInterval(() => {
 | 
			
		||||
	const now = Date.now()
 | 
			
		||||
	buttons = buttons.filter(b => now - b.created < 2*60*60*1000)
 | 
			
		||||
}, 10*60*1000)
 | 
			
		||||
 | 
			
		||||
/** @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event */
 | 
			
		||||
function onReactionAdd(event) {
 | 
			
		||||
	const button = buttons.find(b => b.roomID === event.room_id && b.mxid === event.sender && b.eventID === event.content["m.relates_to"]?.event_id && b.key === event.content["m.relates_to"]?.key)
 | 
			
		||||
	if (button) {
 | 
			
		||||
		buttons = buttons.filter(b => b !== button) // remove button data so it can't be clicked again
 | 
			
		||||
		button.resolve(event)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @callback CommandExecute
 | 
			
		||||
 * @param {Ty.Event.Outer_M_Room_Message} event
 | 
			
		||||
 * @param {any} [ctx]
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef Command
 | 
			
		||||
 * @property {string[]} aliases
 | 
			
		||||
 * @property {CommandExecute} execute
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/** @param {CommandExecute} execute */
 | 
			
		||||
function replyctx(execute) {
 | 
			
		||||
	/** @type {CommandExecute} */
 | 
			
		||||
	return function(event, ctx = {}) {
 | 
			
		||||
		ctx["m.relates_to"] = {
 | 
			
		||||
			"m.in_reply_to": {
 | 
			
		||||
				event_id: event.event_id
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return execute(event, ctx)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const NEWLINE_ELEMENTS = mxUtils.BLOCK_ELEMENTS.concat(["BR"])
 | 
			
		||||
 | 
			
		||||
class MatrixStringBuilder {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		this.body = ""
 | 
			
		||||
		this.formattedBody = ""
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * @param {string} body
 | 
			
		||||
	 * @param {string} formattedBody
 | 
			
		||||
	 * @param {any} [condition]
 | 
			
		||||
	 */
 | 
			
		||||
	add(body, formattedBody, condition = true) {
 | 
			
		||||
		if (condition) {
 | 
			
		||||
			if (!formattedBody) formattedBody = body
 | 
			
		||||
			this.body += body
 | 
			
		||||
			this.formattedBody += formattedBody
 | 
			
		||||
		}
 | 
			
		||||
		return this
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * @param {string} body
 | 
			
		||||
	 * @param {string} [formattedBody]
 | 
			
		||||
	 * @param {any} [condition]
 | 
			
		||||
	 */
 | 
			
		||||
	addLine(body, formattedBody, condition = true) {
 | 
			
		||||
		if (condition) {
 | 
			
		||||
			if (!formattedBody) formattedBody = body
 | 
			
		||||
			if (this.body.length && this.body.slice(-1) !== "\n") this.body += "\n"
 | 
			
		||||
			this.body += body
 | 
			
		||||
			const match = this.formattedBody.match(/<\/?([a-zA-Z]+[a-zA-Z0-9]*)[^>]*>\s*$/)
 | 
			
		||||
			if (this.formattedBody.length && (!match || !NEWLINE_ELEMENTS.includes(match[1].toUpperCase()))) this.formattedBody += "<br>"
 | 
			
		||||
			this.formattedBody += formattedBody
 | 
			
		||||
		}
 | 
			
		||||
		return this
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	get() {
 | 
			
		||||
		return {
 | 
			
		||||
			msgtype: "m.text",
 | 
			
		||||
			body: this.body,
 | 
			
		||||
			format: "org.matrix.custom.html",
 | 
			
		||||
			formatted_body: this.formattedBody
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** @type {Command[]} */
 | 
			
		||||
const commands = [{
 | 
			
		||||
	aliases: ["emoji"],
 | 
			
		||||
	execute: replyctx(
 | 
			
		||||
		async (event, ctx) => {
 | 
			
		||||
			// Guard
 | 
			
		||||
			/** @type {string} */ // @ts-ignore
 | 
			
		||||
			const channelID = select("channel_room", "channel_id", "WHERE room_id = ?").pluck().get(event.room_id)
 | 
			
		||||
			const guildID = discord.channels.get(channelID)?.["guild_id"]
 | 
			
		||||
			let matrixOnlyReason = null
 | 
			
		||||
			const matrixOnlyConclusion = "So the emoji will be uploaded on Matrix-side only. It will still be usable over the bridge, but may have degraded functionality."
 | 
			
		||||
			if (!guildID) {
 | 
			
		||||
				matrixOnlyReason = "NOT_BRIDGED"
 | 
			
		||||
			} else {
 | 
			
		||||
				const guild = discord.guilds.get(guildID)
 | 
			
		||||
				assert(guild)
 | 
			
		||||
				const slots = getSlotCount(guild.premium_tier)
 | 
			
		||||
				const permissions = dUtils.getPermissions([], guild.roles)
 | 
			
		||||
				if (guild.emojis.length >= slots) {
 | 
			
		||||
					matrixOnlyReason = "CAPACITY"
 | 
			
		||||
				} else if (!(permissions | 0x40000000n)) { // MANAGE_GUILD_EXPRESSIONS (apparently CREATE_GUILD_EXPRESSIONS isn't good enough...)
 | 
			
		||||
					matrixOnlyReason = "USER_PERMISSIONS"
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const nameMatch = event.content.body.match(/:([a-zA-Z0-9_]{2,}):/)
 | 
			
		||||
			if (!nameMatch) {
 | 
			
		||||
				return api.sendEvent(event.room_id, "m.room.message", {
 | 
			
		||||
					...ctx,
 | 
			
		||||
					msgtype: "m.text",
 | 
			
		||||
					body: "Not sure what you want to call this emoji. Try writing a new :name: in colons. The name can have letters, numbers, and underscores."
 | 
			
		||||
				})
 | 
			
		||||
			}
 | 
			
		||||
			const name = nameMatch[1]
 | 
			
		||||
 | 
			
		||||
			let mxc
 | 
			
		||||
			const mxcMatch = event.content.body.match(/(mxc:\/\/.*?)\b/)
 | 
			
		||||
			if (mxcMatch) {
 | 
			
		||||
				mxc = mxcMatch[1]
 | 
			
		||||
			}
 | 
			
		||||
			if (!mxc && event.content["m.relates_to"]?.["m.in_reply_to"]?.event_id) {
 | 
			
		||||
				const repliedToEventID = event.content["m.relates_to"]["m.in_reply_to"].event_id
 | 
			
		||||
				const repliedToEvent = await api.getEvent(event.room_id, repliedToEventID)
 | 
			
		||||
				if (repliedToEvent.type === "m.room.message" && repliedToEvent.content.msgtype === "m.image" && repliedToEvent.content.url) {
 | 
			
		||||
					mxc = repliedToEvent.content.url
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			if (!mxc) {
 | 
			
		||||
				return api.sendEvent(event.room_id, "m.room.message", {
 | 
			
		||||
					...ctx,
 | 
			
		||||
					msgtype: "m.text",
 | 
			
		||||
					body: "Not sure what image you wanted to add. Try replying to an uploaded image when you use the command, or write an mxc:// URL in your message."
 | 
			
		||||
				})
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const sent = await api.sendEvent(event.room_id, "m.room.message", {
 | 
			
		||||
				...ctx,
 | 
			
		||||
				...new MatrixStringBuilder()
 | 
			
		||||
					.addLine("## Emoji preview", "<h2>Emoji preview</h2>")
 | 
			
		||||
					.addLine(`Ⓜ️ This room isn't bridged to Discord. ${matrixOnlyConclusion}`, `Ⓜ️ <em>This room isn't bridged to Discord. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "NOT_BRIDGED")
 | 
			
		||||
					.addLine(`Ⓜ️ *Discord ran out of space for emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>Discord ran out of space for emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY")
 | 
			
		||||
					.addLine(`Ⓜ️ *If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}`, `Ⓜ️ <em>If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}</em>`, matrixOnlyReason === "CAPACITY")
 | 
			
		||||
					.addLine("[Preview not available in plain text.]", `Preview: <img data-mx-emoticon height="48" src="${mxc}">`)
 | 
			
		||||
					.addLine("Hit ✅ to add it.")
 | 
			
		||||
					.get()
 | 
			
		||||
			})
 | 
			
		||||
			addButton(event.room_id, sent, "✅", event.sender).then(async () => {
 | 
			
		||||
				const publicUrl = mxUtils.getPublicUrlForMxc(mxc)
 | 
			
		||||
				// @ts-ignore
 | 
			
		||||
				const resizeInput = await fetch(publicUrl, {agent: false}).then(res => res.arrayBuffer())
 | 
			
		||||
				const resizeOutput = await sharp(resizeInput)
 | 
			
		||||
					.resize(EMOJI_SIZE, EMOJI_SIZE, {fit: "inside", withoutEnlargement: true, background: {r: 0, g: 0, b: 0, alpha: 0}})
 | 
			
		||||
					.png()
 | 
			
		||||
					.toBuffer({resolveWithObject: true})
 | 
			
		||||
				if (matrixOnlyReason) {
 | 
			
		||||
					// Edit some state keys
 | 
			
		||||
					api.sendEvent(event.room_id, "m.room.message", {
 | 
			
		||||
						...ctx,
 | 
			
		||||
						msgtype: "m.text",
 | 
			
		||||
						body: "Sorry, adding Matrix-only emojis not supported yet!!"
 | 
			
		||||
					})
 | 
			
		||||
				} else {
 | 
			
		||||
					// Upload it to Discord and have the bridge sync it back to Matrix again
 | 
			
		||||
					console.log(`uploading emoji ${resizeOutput.data.length} bytes to :${name}:`)
 | 
			
		||||
					const emoji = await discord.snow.guildAssets.createEmoji(guildID, {name, image: "data:image/png;base64," + resizeOutput.data.toString("base64")})
 | 
			
		||||
					api.sendEvent(event.room_id, "m.room.message", {
 | 
			
		||||
						...ctx,
 | 
			
		||||
						msgtype: "m.text",
 | 
			
		||||
						body: `Created :${name}:`
 | 
			
		||||
					})
 | 
			
		||||
				}
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	)
 | 
			
		||||
}]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/** @type {CommandExecute} */
 | 
			
		||||
async function execute(event) {
 | 
			
		||||
	let realBody = event.content.body
 | 
			
		||||
	while (realBody.startsWith("> ")) {
 | 
			
		||||
		const i = realBody.indexOf("\n")
 | 
			
		||||
		if (i === -1) return
 | 
			
		||||
		realBody = realBody.slice(i + 1)
 | 
			
		||||
	}
 | 
			
		||||
	realBody = realBody.replace(/^\s*/, "")
 | 
			
		||||
	let words
 | 
			
		||||
	for (const prefix of PREFIXES) {
 | 
			
		||||
		if (realBody.startsWith(prefix)) {
 | 
			
		||||
			words = realBody.slice(prefix.length).split(" ")
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if (!words) return
 | 
			
		||||
	const commandName = words[0]
 | 
			
		||||
	const command = commands.find(c => c.aliases.includes(commandName))
 | 
			
		||||
	if (!command) return
 | 
			
		||||
 | 
			
		||||
	await command.execute(event)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports.execute = execute
 | 
			
		||||
module.exports.onReactionAdd = onReactionAdd
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue