Support embed generate MESSAGE_UPDATE events

This commit is contained in:
Cadence Ember 2024-03-15 15:54:13 +13:00
parent 955310b759
commit d01c888d02
5 changed files with 117 additions and 51 deletions

View file

@ -3,13 +3,22 @@
const assert = require("assert").strict const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {discord, sync, db, select, from} = passthrough const {sync, select, from} = passthrough
/** @type {import("./message-to-event")} */ /** @type {import("./message-to-event")} */
const messageToEvent = sync.require("../converters/message-to-event") const messageToEvent = sync.require("../converters/message-to-event")
/** @type {import("../actions/register-user")} */
const registerUser = sync.require("../actions/register-user") function eventCanBeEdited(ev) {
/** @type {import("../actions/create-room")} */ // Discord does not allow files, images, attachments, or videos to be edited.
const createRoom = sync.require("../actions/create-room") if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
return false
}
// Discord does not allow stickers to be edited.
if (ev.old.event_type === "m.sticker") {
return false
}
// Anything else is fair game.
return true
}
/** /**
* @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message * @param {import("discord-api-types/v10").GatewayMessageCreateDispatchData} message
@ -19,12 +28,16 @@ const createRoom = sync.require("../actions/create-room")
* @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API * @param {import("../../matrix/api")} api simple-as-nails dependency injection for the matrix API
*/ */
async function editToChanges(message, guild, api) { async function editToChanges(message, guild, api) {
// If it is a user edit, allow deleting old messages (e.g. they might have removed text from an image). If it is the system adding a generated embed to a message, don't delete old messages since the system only sends partial data.
const isGeneratedEmbed = !("content" in message)
// Figure out what events we will be replacing // Figure out what events we will be replacing
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID) assert(roomID)
/** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */ /** @type {string?} Null if we don't have a sender in the room, which will happen if it's a webhook's message. The bridge bot will do the edit instead. */
const senderMxid = from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null const senderMxid = message.author && from("sim").join("sim_member", "mxid").where({user_id: message.author.id, room_id: roomID}).pluck("mxid").get() || null
const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all() const oldEventRows = select("event_message", ["event_id", "event_type", "event_subtype", "part", "reaction_part"], {message_id: message.id}).all()
@ -48,7 +61,8 @@ async function editToChanges(message, guild, api) {
let eventsToRedact = [] let eventsToRedact = []
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */ /** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
let eventsToSend = [] let eventsToSend = []
// 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. This is represented as nothing. /** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */
let unchangedEvents = []
function shift() { function shift() {
newFallbackContent.shift() newFallbackContent.shift()
@ -81,22 +95,35 @@ async function editToChanges(message, guild, api) {
shift() shift()
} }
// Anything remaining in oldEventRows is present in the old version only and should be redacted. // Anything remaining in oldEventRows is present in the old version only and should be redacted.
eventsToRedact = oldEventRows eventsToRedact = oldEventRows.map(e => ({old: e}))
// If this is a generated embed update, only allow the embeds to be updated, since the system only sends data about events. Ignore changes to other things.
if (isGeneratedEmbed) {
unchangedEvents.push(...eventsToRedact.filter(e => e.old.event_subtype !== "m.notice")) // Move them from eventsToRedact to unchangedEvents.
eventsToRedact = eventsToRedact.filter(e => e.old.event_subtype === "m.notice")
}
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
unchangedEvents.push(...eventsToReplace.filter(ev => !eventCanBeEdited(ev))) // Move them from eventsToRedact to unchangedEvents.
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times. // We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
/** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */ /** @type {({column: string, eventID: string} | {column: string, nextEvent: true})[]} */
const promotions = [] const promotions = []
for (const column of ["part", "reaction_part"]) { for (const column of ["part", "reaction_part"]) {
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
// If no events with part = 0 exist (or will exist), we need to do some management. // If no events with part = 0 exist (or will exist), we need to do some management.
if (!eventsToReplace.some(e => e.old[column] === 0)) { if (!candidatesForParts.some(e => e.old[column] === 0)) {
if (eventsToReplace.length) { if (candidatesForParts.length) {
// We can choose an existing event to promote. Bigger order is better. // We can choose an existing event to promote. Bigger order is better.
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.event_subtype === "m.text") const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
eventsToReplace.sort((a, b) => order(b) - order(a)) candidatesForParts.sort((a, b) => order(b) - order(a))
if (column === "part") { if (column === "part") {
promotions.push({column, eventID: eventsToReplace[0].old.event_id}) // part should be the first one promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
} else { } else {
promotions.push({column, eventID: eventsToReplace[eventsToReplace.length - 1].old.event_id}) // reaction_part should be the last one promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
} }
} else { } else {
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0. // No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
@ -105,24 +132,8 @@ async function editToChanges(message, guild, api) {
} }
} }
// Now, everything in eventsToSend and eventsToRedact is a real change, but everything in eventsToReplace might not have actually changed!
// (Example: a MESSAGE_UPDATE for a text+image message - Discord does not allow the image to be changed, but the text might have been.)
// So we'll remove entries from eventsToReplace that *definitely* cannot have changed. (This is category 4 mentioned above.) Everything remaining *may* have changed.
eventsToReplace = eventsToReplace.filter(ev => {
// Discord does not allow files, images, attachments, or videos to be edited.
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
return false
}
// Discord does not allow stickers to be edited.
if (ev.old.event_type === "m.sticker") {
return false
}
// Anything else is fair game.
return true
})
// Removing unnecessary properties before returning // Removing unnecessary properties before returning
eventsToRedact = eventsToRedact.map(e => e.event_id) eventsToRedact = eventsToRedact.map(e => e.old.event_id)
eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)})) eventsToReplace = eventsToReplace.map(e => ({oldID: e.old.event_id, newContent: makeReplacementEventContent(e.old.event_id, e.newFallbackContent, e.newInnerContent)}))
return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions} return {roomID, eventsToReplace, eventsToRedact, eventsToSend, senderMxid, promotions}

View file

@ -235,3 +235,28 @@ test("edit2changes: promotes the text event when multiple rows have part = 1 (sh
} }
]) ])
}) })
test("edit2changes: generated embed", async t => {
const {eventsToRedact, eventsToReplace, eventsToSend, promotions} = await editToChanges(data.message_update.embed_generated_social_media_image, data.guild.general, {})
t.deepEqual(eventsToRedact, [])
t.deepEqual(eventsToReplace, [])
t.deepEqual(eventsToSend, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| via hthrflwrs on cohost"
+ "\n| \n| ## This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO https://cohost.org/jkap/post/4794219-empty"
+ "\n| \n| 1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:"
+ "\n| \n| * Both players draw eight cards"
+ "\n| * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand"
+ "\n| * Both players present their best five-or-less-card pok...",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub>hthrflwrs on cohost</sub>`
+ `</p><p><strong><a href="https://cohost.org/jkap/post/4794219-empty">This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO</a></strong>`
+ `</p><p>1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:`
+ `<br><br><ul><li>Both players draw eight cards`
+ `</li><li>Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand`
+ `</li><li>Both players present their best five-or-less-card pok...</li></ul></p></blockquote>`,
"m.mentions": {}
}])
t.deepEqual(promotions, []) // TODO: it would be ideal to promote this to reaction_part = 0. this is OK to do because the main message won't have had any reactions yet.
})

View file

@ -480,32 +480,35 @@ async function messageToEvent(message, guild, options = {}, di) {
message.content = "changed the channel name to **" + message.content + "**" message.content = "changed the channel name to **" + message.content + "**"
} }
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)] if (message.content) {
if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) { // Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const writtenMentionsText = matches.map(m => m[1].toLowerCase()) const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get() if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
assert(roomID) const writtenMentionsText = matches.map(m => m[1].toLowerCase())
const {joined} = await di.api.getJoinedMembers(roomID) const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
for (const [mxid, member] of Object.entries(joined)) { assert(roomID)
if (!userRegex.some(rx => mxid.match(rx))) { const {joined} = await di.api.getJoinedMembers(roomID)
const localpart = mxid.match(/@([^:]*)/) for (const [mxid, member] of Object.entries(joined)) {
assert(localpart) if (!userRegex.some(rx => mxid.match(rx))) {
const displayName = member.display_name || localpart[1] const localpart = mxid.match(/@([^:]*)/)
if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid) assert(localpart)
const displayName = member.display_name || localpart[1]
if (writtenMentionsText.includes(localpart[1].toLowerCase()) || writtenMentionsText.includes(displayName.toLowerCase())) addMention(mxid)
}
} }
} }
}
// Text content appears first // Text content appears first
if (message.content) {
const {body, html} = await transformContent(message.content) const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype, {scanMentions: true}) await addTextEvent(body, html, msgtype, {scanMentions: true})
} }
// Then attachments // Then attachments
const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions))) if (message.attachments) {
events.push(...attachmentEvents) const attachmentEvents = await Promise.all(message.attachments.map(attachmentToEvent.bind(null, mentions)))
events.push(...attachmentEvents)
}
// Then embeds // Then embeds
for (const embed of message.embeds || []) { for (const embed of message.embeds || []) {

View file

@ -3469,6 +3469,31 @@ module.exports = {
} }
], ],
guild_id: "112760669178241024" guild_id: "112760669178241024"
},
embed_generated_social_media_image: {
channel_id: "112760669178241024",
embeds: [
{
color: 8594767,
description: "1v1 physical card game. Each player gets one standard deck of cards with a different backing to differentiate. Every turn proceeds as follows:\n\n * Both players draw eight cards\n * Both players may choose up to eight cards to discard, then draw that number of cards to put back in their hand\n * Both players present their best five-or-less-card pok...",
provider: {
name: "hthrflwrs on cohost"
},
thumbnail: {
height: 1587,
placeholder: "GpoKP5BJZphshnhwmmmYlmh3l7+m+mwJ",
placeholder_version: 1,
proxy_url: "https://images-ext-2.discordapp.net/external/9vTXIzlXU4wyUZvWfmlmQkck8nGLUL-A090W4lWsZ48/https/staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png",
url: "https://staging.cohostcdn.org/avatar/292-6b64b03c-4ada-42f6-8452-109275bfe68d-profile.png",
width: 1644
},
title: "This post nerdsniped me, so here's some RULES FOR REAL-LIFE BALATRO",
type: "link",
url: "https://cohost.org/jkap/post/4794219-empty"
}
],
guild_id: "112760669178241024",
id: "1210387798297682020"
} }
}, },
special_message: { special_message: {

View file

@ -53,7 +53,8 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
('1158842413025071135', '176333891320283136'), ('1158842413025071135', '176333891320283136'),
('1197612733600895076', '112760669178241024'), ('1197612733600895076', '112760669178241024'),
('1202543413652881428', '1160894080998461480'), ('1202543413652881428', '1160894080998461480'),
('1207486471489986620', '1160894080998461480'); ('1207486471489986620', '1160894080998461480'),
('1210387798297682020', '112760669178241024');
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1), ('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
@ -87,7 +88,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo', 'm.room.message', 'm.text', '1158842413025071135', 0, 0, 1), ('$dVCLyj6kxb3DaAWDtjcv2kdSny8JMMHdDhCMz8mDxVo', 'm.room.message', 'm.text', '1158842413025071135', 0, 0, 1),
('$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw', 'm.room.message', 'm.text', '1197612733600895076', 0, 0, 1), ('$7tJoMw1h44n2gxgLUE1T_YinGrLbK0x-TDY1z6M7GBw', 'm.room.message', 'm.text', '1197612733600895076', 0, 0, 1),
('$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU', 'm.room.message', 'm.text', '1202543413652881428', 0, 0, 0), ('$NB6nPgO2tfXyIwwDSF0Ga0BUrsgX1S-0Xl-jAvI8ucU', 'm.room.message', 'm.text', '1202543413652881428', 0, 0, 0),
('$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y', 'm.room.message', 'm.image', '1207486471489986620', 0, 0, 0); ('$OEEK-Wam2FTh6J-6kVnnJ6KnLA_lLRnLTHatKKL62-Y', 'm.room.message', 'm.image', '1207486471489986620', 0, 0, 0),
('$mPSzglkCu-6cZHbYro0RW2u5mHvbH9aXDjO5FCzosc0', 'm.room.message', 'm.text', '1210387798297682020', 0, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),