diff --git a/m2d/actions/send-event.js b/m2d/actions/send-event.js index 18a1e0f..e870675 100644 --- a/m2d/actions/send-event.js +++ b/m2d/actions/send-event.js @@ -1,6 +1,7 @@ // @ts-check const assert = require("assert").strict +const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") const {sync, discord, db} = passthrough @@ -12,9 +13,30 @@ const eventToMessage = sync.require("../converters/event-to-message") /** @type {import("../../matrix/api")}) */ const api = sync.require("../../matrix/api") -/** @param {import("../../types").Event.Outer} event */ +/** + * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {pendingFiles?: {name: string, url: string}[]}} message + * @returns {Promise} + */ +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) { - // 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) let channelID = row.channel_id 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 - 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 @@ -48,7 +78,7 @@ async function sendEvent(event) { for (const message of messagesToSend) { 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("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 messageResponses.push(messageResponse) diff --git a/m2d/converters/event-to-message.js b/m2d/converters/event-to-message.js index deb7b9f..a8a5b65 100644 --- a/m2d/converters/event-to-message.js +++ b/m2d/converters/event-to-message.js @@ -4,6 +4,7 @@ const Ty = require("../../types") const DiscordTypes = require("discord-api-types/v10") const chunk = require("chunk-text") const TurndownService = require("turndown") +const assert = require("assert").strict const passthrough = require("../../passthrough") const { sync, db, discord } = passthrough @@ -124,7 +125,7 @@ async function getMemberFromCacheOrHomeserver(roomID, mxid, api) { } /** - * @param {Ty.Event.Outer} 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 {{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 const member = await getMemberFromCacheOrHomeserver(event.room_id, event.sender, di?.api) 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 + const attachments = [] + /** @type {{name: string, url: string}[]} */ + const pendingFiles = [] // 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. // this event ---is an edit of--> original event ---is a reply to--> past event await (async () => { @@ -261,7 +265,7 @@ async function eventToMessage(event, guild, di) { // @ts-ignore bad type from turndown 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") } else { // Looks like we're using the plaintext body! @@ -274,6 +278,23 @@ async function eventToMessage(event, guild, di) { // Markdown needs to be escaped 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 @@ -286,6 +307,19 @@ async function eventToMessage(event, guild, di) { 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 messagesToSend = [] for (let i = 0; i < messages.length; i++) { diff --git a/m2d/converters/event-to-message.test.js b/m2d/converters/event-to-message.test.js index 411d97c..5a83f0c 100644 --- a/m2d/converters/event-to-message.test.js +++ b/m2d/converters/event-to-message.test.js @@ -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.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"}] + }] + } + ) +}) diff --git a/m2d/event-dispatcher.js b/m2d/event-dispatcher.js index 6adacf7..026b067 100644 --- a/m2d/event-dispatcher.js +++ b/m2d/event-dispatcher.js @@ -54,7 +54,16 @@ function guard(type, fn) { sync.addTemporaryListener(as, "type:m.room.message", guard("m.room.message", /** - * @param {Ty.Event.Outer} 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 => { if (utils.eventSenderIsFromDiscord(event.sender)) return diff --git a/types.d.ts b/types.d.ts index dcde3ad..b74172b 100644 --- a/types.d.ts +++ b/types.d.ts @@ -80,6 +80,39 @@ export namespace Event { } } + export type M_Outer_M_Room_Message = Outer & {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 & {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 & {type: "m.sticker"} + export type M_Room_Member = { membership: string displayname?: string