Support sending unknown mx emojis as sprite sheet

This commit is contained in:
Cadence Ember 2023-09-24 01:55:47 +12:00
parent 2b537f42f0
commit 49d9d31b30
7 changed files with 512 additions and 34 deletions

View file

@ -17,13 +17,19 @@ const eventToMessage = sync.require("../converters/event-to-message")
const api = sync.require("../../matrix/api")
/**
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {pendingFiles?: ({name: string, url: string} | {name: string, url: string, key: string, iv: string})[]}} message
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[], pendingFiles?: ({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]}} message
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer}[]}>}
*/
async function resolvePendingFiles(message) {
if (!message.pendingFiles) return message
const files = await Promise.all(message.pendingFiles.map(async p => {
let fileBuffer
if ("buffer" in p) {
return {
name: p.name,
file: p.buffer
}
}
if ("key" in p) {
// Encrypted
const d = crypto.createDecipheriv("aes-256-ctr", Buffer.from(p.key, "base64url"), Buffer.from(p.iv, "base64url"))
@ -44,7 +50,7 @@ async function resolvePendingFiles(message) {
}))
const newMessage = {
...message,
files
files: files.concat(message.files || [])
}
delete newMessage.pendingFiles
return newMessage

View file

@ -0,0 +1,95 @@
// @ts-check
const assert = require("assert").strict
const {pipeline} = require("stream").promises
const sharp = require("sharp")
const {GIFrame} = require("giframe")
const utils = require("./utils")
const fetch = require("node-fetch").default
const streamMimeType = require("stream-mime-type")
const SIZE = 48
const RESULT_WIDTH = 400
const IMAGES_ACROSS = Math.floor(RESULT_WIDTH / SIZE)
/**
* Composite a bunch of Matrix emojis into a kind of spritesheet image to upload to Discord.
* @param {string[]} mxcs mxc URLs, in order
* @returns {Promise<Buffer>} PNG image
*/
async function compositeMatrixEmojis(mxcs) {
let buffers = await Promise.all(mxcs.map(async mxc => {
const abortController = new AbortController()
try {
const url = utils.getPublicUrlForMxc(mxc)
assert(url)
/** @type {import("node-fetch").Response} res */
// If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing.
// If we were using connection pooling, we would be forced to download the entire GIF.
// So we set no agent to ensure we are not connection pooling.
// @ts-ignore the signal is slightly different from the type it wants (still works fine)
const res = await fetch(url, {agent: false, signal: abortController.signal})
const {stream, mime} = await streamMimeType.getMimeType(res.body)
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") {
/** @type {{info: sharp.OutputInfo, buffer: Buffer}} */
const result = await new Promise((resolve, reject) => {
const transformer = sharp()
.resize(SIZE, SIZE, {fit: "contain"})
.png({compressionLevel: 0})
.toBuffer((err, buffer, info) => {
if (err) return reject(err)
resolve({info, buffer})
})
pipeline(
stream,
transformer
)
})
return result.buffer
} else if (mime === "image/gif") {
const giframe = new GIFrame(0)
stream.on("data", chunk => {
giframe.feed(chunk)
})
const frame = await giframe.getFrame()
const buffer = await sharp(frame.pixels, {raw: {width: frame.width, height: frame.height, channels: 4}})
.resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}})
.png({compressionLevel: 0})
.toBuffer({resolveWithObject: true})
return buffer.data
} else {
// unsupported mime type
console.error(`I don't know what a ${mime} emoji is.`)
return null
}
} finally {
abortController.abort()
}
}))
const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE
const imagesDown = Math.ceil(buffers.length / IMAGES_ACROSS)
const totalHeight = imagesDown * SIZE
const comp = []
let left = 0, top = 0
for (const buffer of buffers) {
if (Buffer.isBuffer(buffer)) {
comp.push({left, top, input: buffer})
;(left += SIZE) + SIZE > RESULT_WIDTH && (left = 0, top += SIZE)
}
}
const output = await sharp({create: {width: totalWidth, height: totalHeight, channels: 4, background: {r: 0, g: 0, b: 0, alpha: 0}}})
.composite(comp)
.png()
.toBuffer({resolveWithObject: true})
return output.data
}
module.exports.compositeMatrixEmojis = compositeMatrixEmojis

View file

@ -12,6 +12,8 @@ const {sync, db, discord, select, from} = passthrough
const file = sync.require("../../matrix/file")
/** @type {import("../converters/utils")} */
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",
@ -117,15 +119,21 @@ turndownService.addRule("inlineLink", {
}
})
/** @type {string[]} SPRITE SHEET EMOJIS FEATURE: mxc urls for the currently processing message */
let endOfMessageEmojis = []
turndownService.addRule("emoji", {
filter: function (node, options) {
if (node.nodeName !== "IMG" || !node.hasAttribute("data-mx-emoticon") || !node.getAttribute("src")) return false
let row = select("emoji", ["id", "name", "animated"], "WHERE mxc_url = ?").get(node.getAttribute("src"))
if (node.nodeName !== "IMG" || !node.hasAttribute("data-mx-emoticon") || !node.getAttribute("src") || !node.getAttribute("title")) return false
return true
},
replacement: function (content, node) {
const mxcUrl = node.getAttribute("src")
let row = select("emoji", ["id", "name", "animated"], "WHERE mxc_url = ?").get(mxcUrl)
if (!row) {
// We don't know what this is... but maybe we can guess based on the name?
const guessedName = node.getAttribute("title")?.replace?.(/^:|:$/g, "")
if (!guessedName) return false
for (const guild of discord.guilds.values()) {
const guessedName = node.getAttribute("title").replace(/^:|:$/g, "")
for (const guild of discord?.guilds.values() || []) {
/** @type {{name: string, id: string, animated: number}[]} */
// @ts-ignore
const emojis = guild.emojis
@ -136,21 +144,18 @@ turndownService.addRule("emoji", {
}
}
}
if (!row) return false
node.setAttribute("data-emoji-id", row.id)
node.setAttribute("data-emoji-name", row.name)
node.setAttribute("data-emoji-animated-char", row.animated ? "a" : "")
return true
},
replacement: function (content, node) {
/** @type {string} */
const id = node.getAttribute("data-emoji-id")
/** @type {string} */
const animatedChar = node.getAttribute("data-emoji-animated-char")
/** @type {string} */
const name = node.getAttribute("data-emoji-name")
return `<${animatedChar}:${name}:${id}>`
if (row) {
const animatedChar = row.animated ? "a" : ""
return `<${animatedChar}:${row.name}:${row.id}>`
} else {
if (endOfMessageEmojis.includes(mxcUrl)) {
// After control returns to the main converter, it will rewind over this, delete this section, and upload the emojis as a sprite sheet.
return `<::>`
} else {
// This emoji is not at the end of the message, it is in the middle. We don't upload middle emojis as a sprite sheet.
return `[${node.getAttribute("title")}](${utils.getPublicUrlForMxc(mxcUrl)})`
}
}
}
})
@ -220,6 +225,29 @@ function splitDisplayName(displayName) {
}
}
/**
* At the time of this executing, we know what the end of message emojis are, and we know that at least one of them is unknown.
* This function will strip them from the content and generate the correct pending file of the sprite sheet.
* @param {string} content
* @param {{id: string, name: string}[]} attachments
* @param {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} pendingFiles
*/
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*$/
while (content.match(r)) {
content = content.replace(r, "")
}
// Create a sprite sheet of known and unknown emojis from the end of the message
const buffer = await emojiSheet.compositeMatrixEmojis(endOfMessageEmojis)
// Attach it
const name = "emojis.png"
attachments.push({id: "0", name})
pendingFiles.push({name, buffer})
return content
}
/**
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event
* @param {import("discord-api-types/v10").APIGuild} guild
@ -251,7 +279,7 @@ async function eventToMessage(event, guild, di) {
let content = event.content.body // ultimate fallback
const attachments = []
/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string})[]} */
/** @type {({name: string, url: string} | {name: string, url: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
const pendingFiles = []
// Convert content depending on what the message is
@ -387,11 +415,27 @@ async function eventToMessage(event, guild, di) {
// input = input.replace(/ /g, "&nbsp;")
// There is also a corresponding test to uncomment, named "event2message: whitespace is retained"
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
// First we need to determine which emojis are at the end.
endOfMessageEmojis = []
let match
let last = input.length
while ((match = input.slice(0, last).match(/<img [^>]*>\s*$/))) {
if (!match[0].includes("data-mx-emoticon")) break
const mxcUrl = match[0].match(/\bsrc="(mxc:\/\/[^"]+)"/)
if (mxcUrl) endOfMessageEmojis.unshift(mxcUrl[1])
if (typeof match.index !== "number") break
last = match.index
}
// @ts-ignore bad type from turndown
content = turndownService.turndown(input)
// It's designed for commonmark, we need to replace the space-space-newline with just newline
content = content.replace(/ \n/g, "\n")
// SPRITE SHEET EMOJIS FEATURE:
content = await uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles)
} else {
// Looks like we're using the plaintext body!
content = event.content.body

View file

@ -4,6 +4,14 @@ const data = require("../../test/data")
const {MatrixServerError} = require("../../matrix/mreq")
const {db, select} = require("../../passthrough")
function slow() {
if (process.argv.includes("--slow")) {
return test
} else {
return test.skip
}
}
/**
* @param {string} roomID
* @param {string} eventID
@ -1744,3 +1752,81 @@ test("event2message: animated emojis work", async t => {
}
)
})
test("event2message: unknown emojis in the middle are linked", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: 'a <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy\" title=\":ms_robot_grin:\" alt=\":ms_robot_grin:\"> b'
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
}),
{
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "a [:ms_robot_grin:](https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy) b",
avatar_url: undefined
}]
}
)
})
slow()("event2message: unknown emoji in the end is reuploaded as a sprite sheet", async t => {
const messages = await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: 'a b <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/RLMgJGfgTPjIQtvvWZsYjhjy\" title=\":ms_robot_grin:\" alt=\":ms_robot_grin:\">'
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
})
const testResult = {
content: messages.messagesToSend[0].content,
fileName: messages.messagesToSend[0].pendingFiles[0].name,
fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64")
}
t.deepEqual(testResult, {
content: "a b",
fileName: "emojis.png",
fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAPoAAAD6AG1e1JrAAALkklEQVR4nM1ZeWyUxxV/azAGwn0JMJUppPhce++1Oc1i"
})
})
slow()("event2message: known and unknown emojis in the end are reuploaded as a sprite sheet", async t => {
t.comment("SKIPPED")
const messages = await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
msgtype: "m.text",
body: "wrong body",
format: "org.matrix.custom.html",
formatted_body: 'known unknown: <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC\" title=\":hippo:\" alt=\":hippo:\"> <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/wcouHVjbKJJYajkhJLsyeJAA\" title=\":ms_robot_dress:\" alt=\":ms_robot_dress:\"> and known unknown: <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc\" title=\":hipposcope:\" alt=\":hipposcope:\"> <img data-mx-emoticon height=\"32\" src=\"mxc://cadence.moe/HYcztccFIPgevDvoaWNsEtGJ\" title=\":ms_robot_cat:\" alt=\":ms_robot_cat:\">'
},
event_id: "$g07oYSZFWBkxohNEfywldwgcWj1hbhDzQ1sBAKvqOOU",
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe"
})
const testResult = {
content: messages.messagesToSend[0].content,
fileName: messages.messagesToSend[0].pendingFiles[0].name,
fileContentStart: messages.messagesToSend[0].pendingFiles[0].buffer.subarray(0, 90).toString("base64")
}
require("fs").writeFileSync("/tmp/emojis.png", messages.messagesToSend[0].pendingFiles[0].buffer)
t.deepEqual(testResult, {
content: "known unknown: <:hippo:230201364309868544> [:ms_robot_dress:](https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/wcouHVjbKJJYajkhJLsyeJAA) and known unknown:",
fileName: "emojis.png",
fileContentStart: "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAYAAADuFn/PAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAT5UlEQVR4nOVbCXSVRZauR9gMsoYlvKwvARKSkPUlJOyL"
})
})