m->d files and stickers

This commit is contained in:
Cadence Ember 2023-09-02 23:28:41 +12:00
parent a1be84cb60
commit 6803b156bc
5 changed files with 219 additions and 9 deletions

View file

@ -1,6 +1,7 @@
// @ts-check // @ts-check
const assert = require("assert").strict const assert = require("assert").strict
const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, discord, db} = passthrough const {sync, discord, db} = passthrough
@ -12,9 +13,30 @@ const eventToMessage = sync.require("../converters/event-to-message")
/** @type {import("../../matrix/api")}) */ /** @type {import("../../matrix/api")}) */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @param {import("../../types").Event.Outer<any>} event */ /**
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {pendingFiles?: {name: string, url: string}[]}} 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 => {
const file = await fetch(p.url).then(res => res.arrayBuffer()).then(x => Buffer.from(x))
return {
name: p.name,
file
}
}))
const newMessage = {
...message,
files
}
delete newMessage.pendingFiles
return newMessage
}
/** @param {Ty.Event.M_Outer_M_Room_Message | Ty.Event.M_Outer_M_Room_Message_File | Ty.Event.M_Outer_M_Sticker} event */
async function sendEvent(event) { async function sendEvent(event) {
// TODO: we just assume the bridge has already been created // TODO: we just assume the bridge has already been created, is that really ok?
const row = db.prepare("SELECT channel_id, thread_parent FROM channel_room WHERE room_id = ?").get(event.room_id) const row = db.prepare("SELECT channel_id, thread_parent FROM channel_room WHERE room_id = ?").get(event.room_id)
let channelID = row.channel_id let channelID = row.channel_id
let threadID = undefined let threadID = undefined
@ -29,7 +51,15 @@ async function sendEvent(event) {
// no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it // no need to sync the matrix member to the other side. but if I did need to, this is where I'd do it
const {messagesToEdit, messagesToSend, messagesToDelete} = await eventToMessage.eventToMessage(event, guild, {api}) let {messagesToEdit, messagesToSend, messagesToDelete} = await eventToMessage.eventToMessage(event, guild, {api})
messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
e.message = await resolvePendingFiles(e.message)
return e
}))
messagesToSend = await Promise.all(messagesToSend.map(message => {
return resolvePendingFiles(message)
}))
let eventPart = 0 // 0 is primary, 1 is supporting let eventPart = 0 // 0 is primary, 1 is supporting
@ -48,7 +78,7 @@ async function sendEvent(event) {
for (const message of messagesToSend) { for (const message of messagesToSend) {
const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID)
db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, channelID) db.prepare("REPLACE INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, channelID)
db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, source) VALUES (?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content.msgtype || null, messageResponse.id, eventPart) // source 0 = matrix db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, source) VALUES (?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content["msgtype"] || null, messageResponse.id, eventPart) // source 0 = matrix
eventPart = 1 eventPart = 1
messageResponses.push(messageResponse) messageResponses.push(messageResponse)

View file

@ -4,6 +4,7 @@ const Ty = require("../../types")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const chunk = require("chunk-text") const chunk = require("chunk-text")
const TurndownService = require("turndown") const TurndownService = require("turndown")
const assert = require("assert").strict
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const { sync, db, discord } = passthrough const { sync, db, discord } = passthrough
@ -124,7 +125,7 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) {
} }
/** /**
* @param {Ty.Event.Outer<Ty.Event.M_Room_Message>} event * @param {Ty.Event.M_Outer_M_Room_Message | Ty.Event.M_Outer_M_Room_Message_File | Ty.Event.M_Outer_M_Sticker} event
* @param {import("discord-api-types/v10").APIGuild} guild * @param {import("discord-api-types/v10").APIGuild} guild
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/ */
@ -143,12 +144,15 @@ async function eventToMessage(event, guild, di) {
// Try to extract an accurate display name and avatar URL from the member event // Try to extract an accurate display name and avatar URL from the member event
const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api)
if (member.displayname) displayName = member.displayname if (member.displayname) displayName = member.displayname
if (member.avatar_url) avatarURL = utils.getPublicUrlForMxc(member.avatar_url) if (member.avatar_url) avatarURL = utils.getPublicUrlForMxc(member.avatar_url) || undefined
let content = event.content.body // ultimate fallback let content = event.content.body // ultimate fallback
const attachments = []
/** @type {{name: string, url: string}[]} */
const pendingFiles = []
// Convert content depending on what the message is // Convert content depending on what the message is
if (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") { if (event.type === "m.room.message" && (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote")) {
// Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events. // Handling edits. If the edit was an edit of a reply, edits do not include the reply reference, so we need to fetch up to 2 more events.
// this event ---is an edit of--> original event ---is a reply to--> past event // this event ---is an edit of--> original event ---is a reply to--> past event
await (async () => { await (async () => {
@ -261,7 +265,7 @@ async function eventToMessage(event, guild, di) {
// @ts-ignore bad type from turndown // @ts-ignore bad type from turndown
content = turndownService.turndown(input) content = turndownService.turndown(input)
// It's optimised for commonmark, we need to replace the space-space-newline with just newline // It's designed for commonmark, we need to replace the space-space-newline with just newline
content = content.replace(/ \n/g, "\n") content = content.replace(/ \n/g, "\n")
} else { } else {
// Looks like we're using the plaintext body! // Looks like we're using the plaintext body!
@ -274,6 +278,23 @@ async function eventToMessage(event, guild, di) {
// Markdown needs to be escaped // Markdown needs to be escaped
content = content.replace(/([*_~`#])/g, `\\$1`) content = content.replace(/([*_~`#])/g, `\\$1`)
} }
} else if (event.type === "m.room.message" && (event.content.msgtype === "m.file" || event.content.msgtype === "m.video" || event.content.msgtype === "m.audio" || event.content.msgtype === "m.image")) {
content = ""
const filename = event.content.body
const url = utils.getPublicUrlForMxc(event.content.url)
assert(url)
attachments.push({id: "0", filename})
pendingFiles.push({name: filename, url})
} else if (event.type === "m.sticker") {
content = ""
let filename = event.content.body
if (event.type === "m.sticker" && event.content.info.mimetype.includes("/")) {
filename += "." + event.content.info.mimetype.split("/")[1]
}
const url = utils.getPublicUrlForMxc(event.content.url)
assert(url)
attachments.push({id: "0", filename})
pendingFiles.push({name: filename, url})
} }
content = replyLine + content content = replyLine + content
@ -286,6 +307,19 @@ async function eventToMessage(event, guild, di) {
avatar_url: avatarURL avatar_url: avatarURL
}))) })))
if (attachments.length) {
// If content is empty (should be the case when uploading a file) then chunk-text will create 0 messages.
// There needs to be a message to add attachments to.
if (!messages.length) messages.push({
content,
username: displayName,
avatar_url: avatarURL
})
messages[0].attachments = attachments
// @ts-ignore these will be converted to real files when the message is about to be sent
messages[0].pendingFiles = pendingFiles
}
const messagesToEdit = [] const messagesToEdit = []
const messagesToSend = [] const messagesToSend = []
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {

View file

@ -1110,3 +1110,107 @@ test("event2message: skips caching the member if the member does not exist, some
t.deepEqual(db.prepare("SELECT avatar_url, displayname, mxid FROM member_cache WHERE room_id = '!not_real:cadence.moe'").all(), []) t.deepEqual(db.prepare("SELECT avatar_url, displayname, mxid FROM member_cache WHERE room_id = '!not_real:cadence.moe'").all(), [])
t.equal(called, 1, "getStateEvent should be called once") t.equal(called, 1, "getStateEvent should be called once")
}) })
test("event2message: text attachments work", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
content: {
body: "chiki-powerups.txt",
info: {
size: 971,
mimetype: "text/plain"
},
msgtype: "m.file",
url: "mxc://cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"
},
sender: "@cadence:cadence.moe",
event_id: "$c2WVyP6KcfAqh5imOa8e0xzt2C8JTR-cWbEd3GargEQ",
room_id: "!PnyBKvUBOhjuCucEfk:cadence.moe"
}),
{
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", filename: "chiki-powerups.txt"}],
pendingFiles: [{name: "chiki-powerups.txt", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/zyThGlYQxvlvBVbVgKDDbiHH"}]
}]
}
)
})
test("event2message: image attachments work", async t => {
t.deepEqual(
await eventToMessage({
type: "m.room.message",
sender: "@cadence:cadence.moe",
content: {
body: "cool cat.png",
info: {
size: 43170,
mimetype: "image/png",
w: 480,
h: 480,
"xyz.amorgan.blurhash": "URTHsVaTpdj2eKZgkkkXp{pHl7feo@lSl9Z$"
},
msgtype: "m.image",
url: "mxc://cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"
},
event_id: "$CXQy3Wmg1A-gL_xAesC1HQcQTEXwICLdSwwUx55FBTI",
room_id: "!PnyBKvUBOhjuCucEfk:cadence.moe"
}),
{
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", filename: "cool cat.png"}],
pendingFiles: [{name: "cool cat.png", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/IvxVJFLEuksCNnbojdSIeEvn"}]
}]
}
)
})
test("event2message: stickers work", async t => {
t.deepEqual(
await eventToMessage({
type: "m.sticker",
sender: "@cadence:cadence.moe",
content: {
body: "get_real2",
url: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN",
info: {
w: 320,
h: 298,
mimetype: "image/gif",
size: 331394,
thumbnail_info: {
w: 320,
h: 298,
mimetype: "image/gif",
size: 331394
},
thumbnail_url: "mxc://cadence.moe/NyMXQFAAdniImbHzsygScbmN"
}
},
event_id: "$PdI-KjdQ8Z_Tb4x9_7wKRPZCsrrXym4BXtbAPekypuM",
room_id: "!PnyBKvUBOhjuCucEfk:cadence.moe"
}),
{
messagesToDelete: [],
messagesToEdit: [],
messagesToSend: [{
username: "cadence [they]",
content: "",
avatar_url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/azCAhThKTojXSZJRoWwZmhvU",
attachments: [{id: "0", filename: "get_real2.gif"}],
pendingFiles: [{name: "get_real2.gif", url: "https://matrix.cadence.moe/_matrix/media/r0/download/cadence.moe/NyMXQFAAdniImbHzsygScbmN"}]
}]
}
)
})

View file

@ -54,7 +54,16 @@ function guard(type, fn) {
sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message",
/** /**
* @param {Ty.Event.Outer<Ty.Event.M_Room_Message>} event it is a m.room.message because that's what this listener is filtering for * @param {Ty.Event.M_Outer_M_Room_Message | Ty.Event.M_Outer_M_Room_Message_File} event it is a m.room.message because that's what this listener is filtering for
*/
async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return
const messageResponses = await sendEvent.sendEvent(event)
}))
sync.addTemporaryListener(as, "type:m.sticker", guard("m.sticker",
/**
* @param {Ty.Event.M_Outer_M_Sticker} event it is a m.sticker because that's what this listener is filtering for
*/ */
async event => { async event => {
if (utils.eventSenderIsFromDiscord(event.sender)) return if (utils.eventSenderIsFromDiscord(event.sender)) return

33
types.d.ts vendored
View file

@ -80,6 +80,39 @@ export namespace Event {
} }
} }
export type M_Outer_M_Room_Message = Outer<M_Room_Message> & {type: "m.room.message"}
export type M_Room_Message_File = {
msgtype: "m.file" | "m.image" | "m.video" | "m.audio"
body: string
url: string
info?: any
"m.relates_to"?: {
"m.in_reply_to": {
event_id: string
}
rel_type?: "m.replace"
event_id?: string
}
}
export type M_Outer_M_Room_Message_File = Outer<M_Room_Message_File> & {type: "m.room.message"}
export type M_Sticker = {
body: string
url: string
info: {
mimetype: string
w?: number
h?: number
size?: number
thumbnail_info?: any
thumbnail_url?: string
}
}
export type M_Outer_M_Sticker = Outer<M_Sticker> & {type: "m.sticker"}
export type M_Room_Member = { export type M_Room_Member = {
membership: string membership: string
displayname?: string displayname?: string