2023-08-15 05:20:31 +00:00
// @ts-check
2023-10-05 23:31:10 +00:00
const assert = require ( "assert" ) . strict
2023-08-15 05:20:31 +00:00
const passthrough = require ( "../../passthrough" )
2024-03-15 02:54:13 +00:00
const { sync , select , from } = passthrough
2023-08-15 05:20:31 +00:00
/** @type {import("./message-to-event")} */
const messageToEvent = sync . require ( "../converters/message-to-event" )
2024-03-16 12:07:50 +00:00
/** @type {import("../../m2d/converters/utils")} */
const utils = sync . require ( "../../m2d/converters/utils" )
2024-03-15 02:54:13 +00:00
function eventCanBeEdited ( 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
}
2023-08-15 05:20:31 +00:00
/ * *
* @ param { import ( "discord-api-types/v10" ) . GatewayMessageCreateDispatchData } message
* IMPORTANT : This may not have all the normal fields ! The API documentation doesn 't provide possible types, just says it' s all optional !
* Since I don 't have a spec, I will have to capture some real traffic and add it as test cases... I hope they don' t change anything later ...
* @ param { import ( "discord-api-types/v10" ) . APIGuild } guild
2023-08-17 00:35:34 +00:00
* @ param { import ( "../../matrix/api" ) } api simple - as - nails dependency injection for the matrix API
2023-08-15 05:20:31 +00:00
* /
2023-08-17 00:35:34 +00:00
async function editToChanges ( message , guild , api ) {
2024-03-15 02:54:13 +00:00
// 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 )
2023-08-15 05:20:31 +00:00
// Figure out what events we will be replacing
2023-10-05 23:31:10 +00:00
const roomID = select ( "channel_room" , "room_id" , { channel _id : message . channel _id } ) . pluck ( ) . get ( )
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. */
2024-03-16 12:07:50 +00:00
let senderMxid = null
if ( message . author ) {
senderMxid = from ( "sim" ) . join ( "sim_member" , "mxid" ) . where ( { user _id : message . author . id , room _id : roomID } ) . pluck ( "mxid" ) . get ( ) || null
} else {
// Should be a system generated embed. We want the embed to be sent by the same user who sent the message, so that the messages get grouped in most clients.
const eventID = select ( "event_message" , "event_id" , { message _id : message . id } ) . pluck ( ) . get ( )
assert ( eventID ) // this should have been checked earlier in a calling function
const event = await api . getEvent ( roomID , eventID )
if ( utils . eventSenderIsFromDiscord ( event . sender ) ) {
senderMxid = event . sender
}
}
2023-10-05 23:31:10 +00:00
2023-10-14 09:08:10 +00:00
const oldEventRows = select ( "event_message" , [ "event_id" , "event_type" , "event_subtype" , "part" , "reaction_part" ] , { message _id : message . id } ) . all ( )
2023-08-15 05:20:31 +00:00
// Figure out what we will be replacing them with
2023-08-16 08:44:38 +00:00
const newFallbackContent = await messageToEvent . messageToEvent ( message , guild , { includeEditFallbackStar : true } , { api } )
const newInnerContent = await messageToEvent . messageToEvent ( message , guild , { includeReplyFallback : false } , { api } )
assert . ok ( newFallbackContent . length === newInnerContent . length )
2023-08-15 05:20:31 +00:00
// Match the new events to the old events
/ *
Rules :
+ The events must have the same type .
+ The events must have the same subtype .
2023-08-16 05:03:05 +00:00
Events will therefore be divided into four categories :
2023-08-15 05:20:31 +00:00
* /
/** 1. Events that are matched, and should be edited by sending another m.replace event */
let eventsToReplace = [ ]
/** 2. Events that are present in the old version only, and should be blanked or redacted */
let eventsToRedact = [ ]
/** 3. Events that are present in the new version only, and should be sent as new, with references back to the context */
let eventsToSend = [ ]
2024-03-15 02:54:13 +00:00
/** 4. Events that are matched and have definitely not changed, so they don't need to be edited or replaced at all. */
let unchangedEvents = [ ]
2023-08-15 05:20:31 +00:00
2023-08-16 08:44:38 +00:00
function shift ( ) {
newFallbackContent . shift ( )
newInnerContent . shift ( )
}
2023-08-15 05:20:31 +00:00
// For each old event...
2023-08-16 08:44:38 +00:00
outer : while ( newFallbackContent . length ) {
const newe = newFallbackContent [ 0 ]
2023-08-15 05:20:31 +00:00
// Find a new event to pair it with...
for ( let i = 0 ; i < oldEventRows . length ; i ++ ) {
const olde = oldEventRows [ i ]
2023-08-25 13:44:50 +00:00
if ( olde . event _type === newe . $type && olde . event _subtype === ( newe . msgtype || null ) ) { // The spec does allow subtypes to change, so I can change this condition later if I want to
2023-08-15 05:20:31 +00:00
// Found one!
// Set up the pairing
eventsToReplace . push ( {
old : olde ,
2023-08-16 08:44:38 +00:00
newFallbackContent : newFallbackContent [ 0 ] ,
newInnerContent : newInnerContent [ 0 ]
2023-08-15 05:20:31 +00:00
} )
// These events have been handled now, so remove them from the source arrays
2023-08-16 08:44:38 +00:00
shift ( )
2023-08-15 05:20:31 +00:00
oldEventRows . splice ( i , 1 )
// Go all the way back to the start of the next iteration of the outer loop
continue outer
}
}
// If we got this far, we could not pair it to an existing event, so it'll have to be a new one
2023-08-17 00:35:34 +00:00
eventsToSend . push ( newInnerContent [ 0 ] )
2023-08-16 08:44:38 +00:00
shift ( )
2023-08-15 05:20:31 +00:00
}
// Anything remaining in oldEventRows is present in the old version only and should be redacted.
2024-03-15 02:54:13 +00:00
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 )
2023-08-15 05:20:31 +00:00
2023-10-14 09:08:10 +00:00
// 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})[]} */
const promotions = [ ]
for ( const column of [ "part" , "reaction_part" ] ) {
2024-03-15 02:54:13 +00:00
const candidatesForParts = unchangedEvents . concat ( eventsToReplace )
2023-10-14 09:08:10 +00:00
// If no events with part = 0 exist (or will exist), we need to do some management.
2024-03-15 02:54:13 +00:00
if ( ! candidatesForParts . some ( e => e . old [ column ] === 0 ) ) {
if ( candidatesForParts . length ) {
2023-10-14 09:08:10 +00:00
// We can choose an existing event to promote. Bigger order is better.
2024-03-15 02:54:13 +00:00
const order = e => 2 * + ( e . event _type === "m.room.message" ) + 1 * + ( e . old . event _subtype === "m.text" )
candidatesForParts . sort ( ( a , b ) => order ( b ) - order ( a ) )
2024-03-04 04:02:38 +00:00
if ( column === "part" ) {
2024-03-15 02:54:13 +00:00
promotions . push ( { column , eventID : candidatesForParts [ 0 ] . old . event _id } ) // part should be the first one
2024-03-04 04:02:38 +00:00
} else {
2024-03-15 02:54:13 +00:00
promotions . push ( { column , eventID : candidatesForParts [ candidatesForParts . length - 1 ] . old . event _id } ) // reaction_part should be the last one
2024-03-04 04:02:38 +00:00
}
2023-10-14 09:08:10 +00:00
} else {
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
promotions . push ( { column , nextEvent : true } )
}
2023-10-06 03:50:23 +00:00
}
}
2023-08-16 05:03:05 +00:00
// Removing unnecessary properties before returning
2024-03-15 02:54:13 +00:00
eventsToRedact = eventsToRedact . map ( e => e . old . event _id )
2023-08-17 04:41:28 +00:00
eventsToReplace = eventsToReplace . map ( e => ( { oldID : e . old . event _id , newContent : makeReplacementEventContent ( e . old . event _id , e . newFallbackContent , e . newInnerContent ) } ) )
2023-08-16 05:03:05 +00:00
2023-10-14 09:08:10 +00:00
return { roomID , eventsToReplace , eventsToRedact , eventsToSend , senderMxid , promotions }
2023-08-15 05:20:31 +00:00
}
2023-08-16 05:03:05 +00:00
/ * *
* @ template T
* @ param { string } oldID
2023-08-16 08:44:38 +00:00
* @ param { T } newFallbackContent
* @ param { T } newInnerContent
2023-08-16 05:03:05 +00:00
* @ returns { import ( "../../types" ) . Event . ReplacementContent < T > } content
* /
2023-08-17 04:41:28 +00:00
function makeReplacementEventContent ( oldID , newFallbackContent , newInnerContent ) {
2023-08-16 08:44:38 +00:00
const content = {
... newFallbackContent ,
2023-08-16 05:03:05 +00:00
"m.mentions" : { } ,
"m.new_content" : {
2023-08-16 08:44:38 +00:00
... newInnerContent
2023-08-16 05:03:05 +00:00
} ,
"m.relates_to" : {
rel _type : "m.replace" ,
event _id : oldID
}
}
2023-08-16 08:44:38 +00:00
delete content [ "m.new_content" ] [ "$type" ]
2023-08-16 05:03:05 +00:00
// Client-Server API spec 11.37.3: Any m.relates_to property within m.new_content is ignored.
2023-08-16 08:44:38 +00:00
delete content [ "m.new_content" ] [ "m.relates_to" ]
return content
2023-08-16 05:03:05 +00:00
}
module . exports . editToChanges = editToChanges
2023-08-17 04:41:28 +00:00
module . exports . makeReplacementEventContent = makeReplacementEventContent