diff --git a/d2m/converters/expression.js b/d2m/converters/expression.js index b2b2590..87f0d88 100644 --- a/d2m/converters/expression.js +++ b/d2m/converters/expression.js @@ -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) diff --git a/m2d/converters/event-to-message.js b/m2d/converters/event-to-message.js index b78574c..4c6e9e2 100644 --- a/m2d/converters/event-to-message.js +++ b/m2d/converters/event-to-message.js @@ -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 = /\s*$/ + const r = /\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 + "
" + afterContext } else { return whole diff --git a/m2d/converters/utils.js b/m2d/converters/utils.js index e1579ee..82b241e 100644 --- a/m2d/converters/utils.js +++ b/m2d/converters/utils.js @@ -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 diff --git a/m2d/event-dispatcher.js b/m2d/event-dispatcher.js index 702f59b..48add81 100644 --- a/m2d/event-dispatcher.js +++ b/m2d/event-dispatcher.js @@ -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) } })) diff --git a/matrix/matrix-command-handler.js b/matrix/matrix-command-handler.js new file mode 100644 index 0000000..908b20b --- /dev/null +++ b/matrix/matrix-command-handler.js @@ -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} + */ +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} 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 += "
" + 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", "

Emoji preview

") + .addLine(`Ⓜ️ This room isn't bridged to Discord. ${matrixOnlyConclusion}`, `Ⓜ️ This room isn't bridged to Discord. ${matrixOnlyConclusion}`, matrixOnlyReason === "NOT_BRIDGED") + .addLine(`Ⓜ️ *Discord ran out of space for emojis. ${matrixOnlyConclusion}`, `Ⓜ️ Discord ran out of space for emojis. ${matrixOnlyConclusion}`, matrixOnlyReason === "CAPACITY") + .addLine(`Ⓜ️ *If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}`, `Ⓜ️ If you were a Discord user, you wouldn't have permission to create emojis. ${matrixOnlyConclusion}`, matrixOnlyReason === "CAPACITY") + .addLine("[Preview not available in plain text.]", `Preview: `) + .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