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 (sticker && sticker.description) body += ` - ${sticker.description}`
|
||||||
if (!body) body = undefined
|
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 + "~"
|
while (shortcodes.includes(shortcode)) shortcode = shortcode + "~"
|
||||||
shortcodes.push(shortcode)
|
shortcodes.push(shortcode)
|
||||||
|
|
||||||
|
|
|
@ -15,15 +15,6 @@ const utils = sync.require("../converters/utils")
|
||||||
/** @type {import("./emoji-sheet")} */
|
/** @type {import("./emoji-sheet")} */
|
||||||
const emojiSheet = sync.require("./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][]} */
|
/** @type {[RegExp, string][]} */
|
||||||
const markdownEscapes = [
|
const markdownEscapes = [
|
||||||
[/\\/g, '\\\\'],
|
[/\\/g, '\\\\'],
|
||||||
|
@ -235,7 +226,7 @@ function splitDisplayName(displayName) {
|
||||||
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles) {
|
async function uploadEndOfMessageSpriteSheet(content, attachments, pendingFiles) {
|
||||||
if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
|
if (!content.includes("<::>")) return content // No unknown emojis, nothing to do
|
||||||
// Remove known and unknown emojis from the end of the message
|
// 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)) {
|
while (content.match(r)) {
|
||||||
content = content.replace(r, "")
|
content = content.replace(r, "")
|
||||||
}
|
}
|
||||||
|
@ -403,7 +394,7 @@ async function eventToMessage(event, guild, di) {
|
||||||
beforeTag = beforeTag || ""
|
beforeTag = beforeTag || ""
|
||||||
afterContext = afterContext || ""
|
afterContext = afterContext || ""
|
||||||
afterTag = afterTag || ""
|
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
|
return beforeContext + "<br>" + afterContext
|
||||||
} else {
|
} else {
|
||||||
return whole
|
return whole
|
||||||
|
|
|
@ -8,6 +8,15 @@ let hasher = null
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
require("xxhash-wasm")().then(h => hasher = h)
|
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.
|
* Determine whether an event is the bridged representation of a discord message.
|
||||||
* Such messages shouldn't be bridged again.
|
* Such messages shouldn't be bridged again.
|
||||||
|
@ -54,6 +63,7 @@ function getEventIDHash(eventID) {
|
||||||
return signedHash
|
return signedHash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.BLOCK_ELEMENTS = BLOCK_ELEMENTS
|
||||||
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
|
module.exports.eventSenderIsFromDiscord = eventSenderIsFromDiscord
|
||||||
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
|
module.exports.getPublicUrlForMxc = getPublicUrlForMxc
|
||||||
module.exports.getEventIDHash = getEventIDHash
|
module.exports.getEventIDHash = getEventIDHash
|
||||||
|
|
|
@ -14,6 +14,8 @@ const sendEvent = sync.require("./actions/send-event")
|
||||||
const addReaction = sync.require("./actions/add-reaction")
|
const addReaction = sync.require("./actions/add-reaction")
|
||||||
/** @type {import("./actions/redact")} */
|
/** @type {import("./actions/redact")} */
|
||||||
const redact = sync.require("./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")} */
|
/** @type {import("./converters/utils")} */
|
||||||
const utils = sync.require("./converters/utils")
|
const utils = sync.require("./converters/utils")
|
||||||
/** @type {import("../matrix/api")}) */
|
/** @type {import("../matrix/api")}) */
|
||||||
|
@ -78,6 +80,10 @@ sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
|
||||||
async event => {
|
async event => {
|
||||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||||
const messageResponses = await sendEvent.sendEvent(event)
|
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",
|
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
|
||||||
|
@ -99,6 +105,7 @@ async event => {
|
||||||
// Try to bridge a failed event again?
|
// Try to bridge a failed event again?
|
||||||
await retry(event.room_id, event.content["m.relates_to"].event_id)
|
await retry(event.room_id, event.content["m.relates_to"].event_id)
|
||||||
} else {
|
} else {
|
||||||
|
matrixCommandHandler.onReactionAdd(event)
|
||||||
await addReaction.addReaction(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…
Reference in a new issue