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