From 63ae6607bf205892a7ae0171bc9f7700ce33fc05 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 21 Aug 2023 21:04:41 +1200 Subject: [PATCH 1/3] changing spaces to tabs --- .vscode/settings.json | 3 + d2m/actions/add-reaction.js | 22 ++--- m2d/actions/channel-webhook.js | 8 +- m2d/actions/send-event.js | 10 +-- matrix/api.js | 129 +++++++++++++++++----------- matrix/kstate.test.js | 26 +++--- matrix/read-registration.test.js | 10 +-- scripts/save-channel-names-to-db.js | 48 +++++------ scripts/save-event-types-to-db.js | 18 ++-- 9 files changed, 154 insertions(+), 120 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c63c08..9f1e183 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1,5 @@ { + "editor.insertSpaces": false, + "editor.detectIndentation": false, + "editor.tabSize": 3 } diff --git a/d2m/actions/add-reaction.js b/d2m/actions/add-reaction.js index b46af59..2eab8f0 100644 --- a/d2m/actions/add-reaction.js +++ b/d2m/actions/add-reaction.js @@ -15,20 +15,20 @@ const createRoom = sync.require("../actions/create-room") * @param {import("discord-api-types/v10").GatewayMessageReactionAddDispatchData} data */ async function addReaction(data) { - const user = data.member?.user - assert.ok(user && user.username) - const parentID = db.prepare("SELECT event_id FROM event_message WHERE message_id = ? AND part = 0").pluck().get(data.message_id) // 0 = primary - if (!parentID) return // Nothing can be done if the parent message was never bridged. - assert.equal(typeof parentID, "string") + const user = data.member?.user + assert.ok(user && user.username) + const parentID = db.prepare("SELECT event_id FROM event_message WHERE message_id = ? AND part = 0").pluck().get(data.message_id) // 0 = primary + if (!parentID) return // Nothing can be done if the parent message was never bridged. + assert.equal(typeof parentID, "string") const roomID = await createRoom.ensureRoom(data.channel_id) const senderMxid = await registerUser.ensureSimJoined(user, roomID) const eventID = await api.sendEvent(roomID, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: parentID, - key: data.emoji.name - } - }, senderMxid) + "m.relates_to": { + rel_type: "m.annotation", + event_id: parentID, + key: data.emoji.name + } + }, senderMxid) return eventID } diff --git a/m2d/actions/channel-webhook.js b/m2d/actions/channel-webhook.js index 6d39da7..b0bc072 100644 --- a/m2d/actions/channel-webhook.js +++ b/m2d/actions/channel-webhook.js @@ -52,10 +52,10 @@ async function withWebhook(channelID, callback) { * @param {string} [threadID] */ async function sendMessageWithWebhook(channelID, data, threadID) { - const result = await withWebhook(channelID, async webhook => { - return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID, disableEveryone: true}) - }) - return result + const result = await withWebhook(channelID, async webhook => { + return discord.snow.webhook.executeWebhook(webhook.id, webhook.token, data, {wait: true, thread_id: threadID, disableEveryone: true}) + }) + return result } module.exports.ensureWebhook = ensureWebhook diff --git a/m2d/actions/send-event.js b/m2d/actions/send-event.js index 88ba0fd..39eed22 100644 --- a/m2d/actions/send-event.js +++ b/m2d/actions/send-event.js @@ -12,7 +12,7 @@ const eventToMessage = sync.require("../converters/event-to-message") /** @param {import("../../types").Event.Outer} 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 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 @@ -21,16 +21,16 @@ async function sendEvent(event) { channelID = row.thread_parent // it's the thread's parent... get with the times... } - // 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 messages = eventToMessage.eventToMessage(event) - assert(Array.isArray(messages)) // sanity + assert(Array.isArray(messages)) // sanity - /** @type {DiscordTypes.APIMessage[]} */ + /** @type {DiscordTypes.APIMessage[]} */ const messageResponses = [] let eventPart = 0 // 0 is primary, 1 is supporting for (const message of messages) { - const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) + const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, channel_id, part, source) VALUES (?, ?, ?, ?, ?, ?, 0)").run(event.event_id, event.type, event.content.msgtype || null, messageResponse.id, channelID, eventPart) // source 0 = matrix eventPart = 1 // TODO: use more intelligent algorithm to determine whether primary or supporting? diff --git a/matrix/api.js b/matrix/api.js index 81d8a16..b382631 100644 --- a/matrix/api.js +++ b/matrix/api.js @@ -19,15 +19,15 @@ const makeTxnId = sync.require("./txnid") * @returns {string} the new endpoint */ function path(p, mxid, otherParams = {}) { - if (!mxid) return p - const u = new URL(p, "http://localhost") - u.searchParams.set("user_id", mxid) - for (const entry of Object.entries(otherParams)) { - if (entry[1] != undefined) { - u.searchParams.set(entry[0], entry[1]) - } - } - return u.pathname + "?" + u.searchParams.toString() + if (!mxid) return p + const u = new URL(p, "http://localhost") + u.searchParams.set("user_id", mxid) + for (const entry of Object.entries(otherParams)) { + if (entry[1] != undefined) { + u.searchParams.set(entry[0], entry[1]) + } + } + return u.pathname + "?" + u.searchParams.toString() } /** @@ -35,40 +35,40 @@ function path(p, mxid, otherParams = {}) { * @returns {Promise} */ function register(username) { - console.log(`[api] register: ${username}`) - return mreq.mreq("POST", "/client/v3/register", { - type: "m.login.application_service", - username - }) + console.log(`[api] register: ${username}`) + return mreq.mreq("POST", "/client/v3/register", { + type: "m.login.application_service", + username + }) } /** * @returns {Promise} room ID */ async function createRoom(content) { - console.log(`[api] create room:`, content) - /** @type {Ty.R.RoomCreated} */ - const root = await mreq.mreq("POST", "/client/v3/createRoom", content) - return root.room_id + console.log(`[api] create room:`, content) + /** @type {Ty.R.RoomCreated} */ + const root = await mreq.mreq("POST", "/client/v3/createRoom", content) + return root.room_id } /** * @returns {Promise} room ID */ async function joinRoom(roomIDOrAlias, mxid) { - /** @type {Ty.R.RoomJoined} */ - const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid)) - return root.room_id + /** @type {Ty.R.RoomJoined} */ + const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid)) + return root.room_id } async function inviteToRoom(roomID, mxidToInvite, mxid) { - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), { - user_id: mxidToInvite - }) + await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/invite`, mxid), { + user_id: mxidToInvite + }) } async function leaveRoom(roomID, mxid) { - await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) + await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) } /** @@ -77,9 +77,9 @@ async function leaveRoom(roomID, mxid) { * @template T */ async function getEvent(roomID, eventID) { - /** @type {Ty.Event.Outer} */ - const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/event/${eventID}`) - return root + /** @type {Ty.Event.Outer} */ + const root = await mreq.mreq("GET", `/client/v3/rooms/${roomID}/event/${eventID}`) + return root } /** @@ -87,7 +87,17 @@ async function getEvent(roomID, eventID) { * @returns {Promise} */ function getAllState(roomID) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`) + return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state`) +} + +/** + * @param {string} roomID + * @param {string} type + * @param {string} key + * @returns the *content* of the state event + */ +function getStateEvent(roomID, type, key) { + return mreq.mreq("GET", `/client/v3/rooms/${roomID}/state/${type}/${key}`) } /** @@ -96,7 +106,7 @@ function getAllState(roomID) { * @returns {Promise<{joined: {[mxid: string]: Ty.R.RoomMember}}>} */ function getJoinedMembers(roomID) { - return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`) + return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`) } /** @@ -107,12 +117,12 @@ function getJoinedMembers(roomID) { * @returns {Promise} event ID */ async function sendState(roomID, type, stateKey, content, mxid) { - console.log(`[api] state: ${roomID}: ${type}/${stateKey}`) - assert.ok(type) - assert.ok(typeof stateKey === "string") - /** @type {Ty.R.EventSent} */ - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/state/${type}/${stateKey}`, mxid), content) - return root.event_id + console.log(`[api] state: ${roomID}: ${type}/${stateKey}`) + assert.ok(type) + assert.ok(typeof stateKey === "string") + /** @type {Ty.R.EventSent} */ + const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/state/${type}/${stateKey}`, mxid), content) + return root.event_id } /** @@ -123,31 +133,51 @@ async function sendState(roomID, type, stateKey, content, mxid) { * @param {number} [timestamp] timestamp of the newly created event, in unix milliseconds */ async function sendEvent(roomID, type, content, mxid, timestamp) { - console.log(`[api] event ${type} to ${roomID} as ${mxid || "default sim"}`) - /** @type {Ty.R.EventSent} */ - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/send/${type}/${makeTxnId.makeTxnId()}`, mxid, {ts: timestamp}), content) - return root.event_id + console.log(`[api] event ${type} to ${roomID} as ${mxid || "default sim"}`) + /** @type {Ty.R.EventSent} */ + const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/send/${type}/${makeTxnId.makeTxnId()}`, mxid, {ts: timestamp}), content) + return root.event_id } /** * @returns {Promise} room ID */ async function redactEvent(roomID, eventID, mxid) { - /** @type {Ty.R.EventRedacted} */ - const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/redact/${eventID}/${makeTxnId.makeTxnId()}`, mxid), {}) - return root.event_id + /** @type {Ty.R.EventRedacted} */ + const root = await mreq.mreq("PUT", path(`/client/v3/rooms/${roomID}/redact/${eventID}/${makeTxnId.makeTxnId()}`, mxid), {}) + return root.event_id } async function profileSetDisplayname(mxid, displayname) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), { - displayname - }) + await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/displayname`, mxid), { + displayname + }) } async function profileSetAvatarUrl(mxid, avatar_url) { - await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), { - avatar_url - }) + await mreq.mreq("PUT", path(`/client/v3/profile/${mxid}/avatar_url`, mxid), { + avatar_url + }) +} + +/** + * Set a user's power level within a room. + * @param {string} roomID + * @param {string} mxid + * @param {number} power + */ +async function setUserPower(roomID, mxid, power) { + // Yes it's this hard https://github.com/matrix-org/matrix-appservice-bridge/blob/2334b0bae28a285a767fe7244dad59f5a5963037/src/components/intent.ts#L352 + const powerLevels = await getStateEvent(roomID, "m.room.power_levels", "") + const users = powerLevels.users || {} + if (power != null) { + users[mxid] = power + } else { + delete users[mxid] + } + powerLevels.users = users + await sendState(roomID, "m.room.power_levels", "", powerLevels) + return powerLevels } module.exports.path = path @@ -164,3 +194,4 @@ module.exports.sendEvent = sendEvent module.exports.redactEvent = redactEvent module.exports.profileSetDisplayname = profileSetDisplayname module.exports.profileSetAvatarUrl = profileSetAvatarUrl +module.exports.setUserPower = setUserPower diff --git a/matrix/kstate.test.js b/matrix/kstate.test.js index ed59e9d..1541898 100644 --- a/matrix/kstate.test.js +++ b/matrix/kstate.test.js @@ -2,22 +2,22 @@ const {kstateToState, stateToKState, diffKState, kstateStripConditionals} = requ const {test} = require("supertape") test("kstate strip: strips false conditions", t => { - t.deepEqual(kstateStripConditionals({ - a: {$if: false, value: 2}, - b: {value: 4} - }), { - b: {value: 4} - }) + t.deepEqual(kstateStripConditionals({ + a: {$if: false, value: 2}, + b: {value: 4} + }), { + b: {value: 4} + }) }) test("kstate strip: keeps true conditions while removing $if", t => { - t.deepEqual(kstateStripConditionals({ - a: {$if: true, value: 2}, - b: {value: 4} - }), { - a: {value: 2}, - b: {value: 4} - }) + t.deepEqual(kstateStripConditionals({ + a: {$if: true, value: 2}, + b: {value: 4} + }), { + a: {value: 2}, + b: {value: 4} + }) }) test("kstate2state: general", t => { diff --git a/matrix/read-registration.test.js b/matrix/read-registration.test.js index d402cfb..e5123b9 100644 --- a/matrix/read-registration.test.js +++ b/matrix/read-registration.test.js @@ -2,9 +2,9 @@ const {test} = require("supertape") const reg = require("./read-registration") test("reg: has necessary parameters", t => { - const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"] - t.deepEqual( - propertiesToCheck.filter(p => p in reg), - propertiesToCheck - ) + const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"] + t.deepEqual( + propertiesToCheck.filter(p => p in reg), + propertiesToCheck + ) }) diff --git a/scripts/save-channel-names-to-db.js b/scripts/save-channel-names-to-db.js index a70b1bb..6f5867a 100644 --- a/scripts/save-channel-names-to-db.js +++ b/scripts/save-channel-names-to-db.js @@ -18,10 +18,10 @@ passthrough.discord = discord ;(async () => { await discord.cloud.connect() - console.log("Discord gateway started") + console.log("Discord gateway started") - const f = event => onPacket(discord, event, () => discord.cloud.off("event", f)) - discord.cloud.on("event", f) + const f = event => onPacket(discord, event, () => discord.cloud.off("event", f)) + discord.cloud.on("event", f) })() const expectedGuilds = new Set() @@ -30,29 +30,29 @@ const prepared = db.prepare("UPDATE channel_room SET name = ? WHERE channel_id = /** @param {DiscordClient} discord */ function onPacket(discord, event, unsubscribe) { - if (event.t === "READY") { - for (const obj of event.d.guilds) { - expectedGuilds.add(obj.id) - } + if (event.t === "READY") { + for (const obj of event.d.guilds) { + expectedGuilds.add(obj.id) + } - } else if (event.t === "GUILD_CREATE") { - expectedGuilds.delete(event.d.id) + } else if (event.t === "GUILD_CREATE") { + expectedGuilds.delete(event.d.id) - // Store the channel. - for (const channel of event.d.channels || []) { - prepared.run(channel.name, channel.id) - } + // Store the channel. + for (const channel of event.d.channels || []) { + prepared.run(channel.name, channel.id) + } - // Checked them all? - if (expectedGuilds.size === 0) { - discord.cloud.disconnect() - unsubscribe() + // Checked them all? + if (expectedGuilds.size === 0) { + discord.cloud.disconnect() + unsubscribe() - // I don't know why node keeps running. - setTimeout(() => { - console.log("Stopping now.") - process.exit() - }, 1500).unref() - } - } + // I don't know why node keeps running. + setTimeout(() => { + console.log("Stopping now.") + process.exit() + }, 1500).unref() + } + } } diff --git a/scripts/save-event-types-to-db.js b/scripts/save-event-types-to-db.js index 83f5d2b..547e85c 100644 --- a/scripts/save-event-types-to-db.js +++ b/scripts/save-event-types-to-db.js @@ -18,13 +18,13 @@ const rows = db.prepare("SELECT event_id, room_id, event_type FROM event_message const preparedUpdate = db.prepare("UPDATE event_message SET event_type = ?, event_subtype = ? WHERE event_id = ?") ;(async () => { - for (const row of rows) { - if (row.event_type == null) { - const event = await api.getEvent(row.room_id, row.event_id) - const type = event.type - const subtype = event.content.msgtype || null - preparedUpdate.run(type, subtype, row.event_id) - console.log(`Updated ${row.event_id} -> ${type} + ${subtype}`) - } - } + for (const row of rows) { + if (row.event_type == null) { + const event = await api.getEvent(row.room_id, row.event_id) + const type = event.type + const subtype = event.content.msgtype || null + preparedUpdate.run(type, subtype, row.event_id) + console.log(`Updated ${row.event_id} -> ${type} + ${subtype}`) + } + } })() From b8e0ddc79af10aa460c79c3de2c795742ca292a7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 21 Aug 2023 21:16:10 +1200 Subject: [PATCH 2/3] I WAS USING THE WRONG VARIABLE :GRIEF: --- d2m/actions/send-message.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/d2m/actions/send-message.js b/d2m/actions/send-message.js index a5c8dac..2dcc324 100644 --- a/d2m/actions/send-message.js +++ b/d2m/actions/send-message.js @@ -38,7 +38,7 @@ async function sendMessage(message, guild) { const eventWithoutType = {...event} delete eventWithoutType.$type - const eventID = await api.sendEvent(roomID, eventType, event, senderMxid, new Date(message.timestamp).getTime()) + const eventID = await api.sendEvent(roomID, eventType, eventWithoutType, senderMxid, new Date(message.timestamp).getTime()) db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, channel_id, part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, message.id, message.channel_id, eventPart) // source 1 = discord eventPart = 1 // TODO: use more intelligent algorithm to determine whether primary or supporting From f7be5f158286cb414211450996f2b8cf9bfaa499 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 21 Aug 2023 23:31:40 +1200 Subject: [PATCH 3/3] finally got the thread's early messages working --- d2m/actions/announce-thread.js | 26 +-- d2m/actions/create-room.js | 11 +- d2m/converters/message-to-event.js | 23 +++ d2m/converters/message-to-event.test.js | 22 +++ d2m/converters/thread-to-announcement.js | 46 ++++++ d2m/converters/thread-to-announcement.test.js | 150 ++++++++++++++++++ d2m/discord-packets.js | 6 +- d2m/event-dispatcher.js | 5 +- db/data-for-test.sql | 6 +- test/data.js | 57 +++++++ test/test.js | 1 + 11 files changed, 321 insertions(+), 32 deletions(-) create mode 100644 d2m/converters/thread-to-announcement.js create mode 100644 d2m/converters/thread-to-announcement.test.js diff --git a/d2m/actions/announce-thread.js b/d2m/actions/announce-thread.js index 546d307..aa6def2 100644 --- a/d2m/actions/announce-thread.js +++ b/d2m/actions/announce-thread.js @@ -4,14 +4,10 @@ const assert = require("assert") const passthrough = require("../../passthrough") const { discord, sync, db } = passthrough -/** @type {import("../converters/message-to-event")} */ -const messageToEvent = sync.require("../converters/message-to-event") +/** @type {import("../converters/thread-to-announcement")} */ +const threadToAnnouncement = sync.require("../converters/thread-to-announcement") /** @type {import("../../matrix/api")} */ const api = sync.require("../../matrix/api") -/** @type {import("./register-user")} */ -const registerUser = sync.require("./register-user") -/** @type {import("../actions/create-room")} */ -const createRoom = sync.require("../actions/create-room") /** * @param {string} parentRoomID @@ -21,24 +17,10 @@ const createRoom = sync.require("../actions/create-room") async function announceThread(parentRoomID, threadRoomID, thread) { /** @type {string?} */ const creatorMxid = db.prepare("SELECT mxid FROM sim WHERE discord_id = ?").pluck().get(thread.owner_id) - /** @type {string?} */ - const branchedFromEventID = db.prepare("SELECT event_id FROM event_message WHERE message_id = ?").get(thread.id) - const msgtype = creatorMxid ? "m.emote" : "m.text" - const template = creatorMxid ? "started a thread:" : "Thread started:" - let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}` - let html = `${template} ${thread.name}` + const content = await threadToAnnouncement.threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, {api}) - const mentions = {} - - await api.sendEvent(parentRoomID, "m.room.message", { - msgtype, - body: `${template} , - format: "org.matrix.custom.html", - formatted_body: "", - "m.mentions": mentions - - }, creatorMxid) + await api.sendEvent(parentRoomID, "m.room.message", content, creatorMxid) } module.exports.announceThread = announceThread diff --git a/d2m/actions/create-room.js b/d2m/actions/create-room.js index 2ee0913..4576320 100644 --- a/d2m/actions/create-room.js +++ b/d2m/actions/create-room.js @@ -70,12 +70,15 @@ async function channelToKState(channel, guild) { avatarEventContent.url = await file.uploadDiscordFileToMxc(avatarEventContent.discord_path) // TODO: somehow represent future values in kstate (callbacks?), while still allowing for diffing, so test cases don't need to touch the media API } + let history_visibility = "invited" + if (channel["thread_metadata"]) history_visibility = "world_readable" + const channelKState = { "m.room.name/": {name: convertedName}, "m.room.topic/": {topic: convertedTopic}, "m.room.avatar/": avatarEventContent, "m.room.guest_access/": {guest_access: "can_join"}, - "m.room.history_visibility/": {history_visibility: "invited"}, + "m.room.history_visibility/": {history_visibility}, [`m.space.parent/${spaceID}`]: { via: ["cadence.moe"], // TODO: put the proper server here canonical: true @@ -234,19 +237,23 @@ async function _unbridgeRoom(channelID) { * @returns {Promise} */ async function _syncSpaceMember(channel, spaceID, roomID) { + console.error(channel) + console.error("syncing space for", roomID) const spaceKState = await roomToKState(spaceID) let spaceEventContent = {} if ( channel.type !== DiscordTypes.ChannelType.PrivateThread // private threads do not belong in the space (don't offer people something they can't join) - || channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) + && !channel["thread_metadata"]?.archived // archived threads do not belong in the space (don't offer people conversations that are no longer relevant) ) { spaceEventContent = { via: ["cadence.moe"] // TODO: use the proper server } } + console.error(spaceEventContent) const spaceDiff = ks.diffKState(spaceKState, { [`m.space.child/${roomID}`]: spaceEventContent }) + console.error(spaceDiff) return applyKStateDiffToRoom(spaceID, spaceDiff) } diff --git a/d2m/converters/message-to-event.js b/d2m/converters/message-to-event.js index c34b389..c5da78d 100644 --- a/d2m/converters/message-to-event.js +++ b/d2m/converters/message-to-event.js @@ -65,6 +65,29 @@ function getDiscordParseCallbacks(message, useHTML) { async function messageToEvent(message, guild, options = {}, di) { const events = [] + if (message.type === DiscordTypes.MessageType.ThreadCreated) { + // This is the kind of message that appears when somebody makes a thread which isn't close enough to the message it's based off. + // It lacks the lines and the pill, so it looks kind of like a member join message, and it says: + // [#] NICKNAME started a thread: __THREAD NAME__. __See all threads__ + // We're already bridging the THREAD_CREATED gateway event to make a comparable message, so drop this one. + return [] + } + + if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) { + // This is the message that appears at the top of a thread when the thread was based off an existing message. + // It's just a message reference, no content. + const ref = message.message_reference + assert(ref) + assert(ref.message_id) + const row = db.prepare("SELECT room_id, event_id FROM event_message INNER JOIN channel_room USING (channel_id) WHERE channel_id = ? AND message_id = ?").get(ref.channel_id, ref.message_id) + if (!row) return [] + const event = await di.api.getEvent(row.room_id, row.event_id) + return [{ + ...event.content, + $type: event.type + }] + } + /** @type {{room?: boolean, user_ids?: string[]}} We should consider the following scenarios for mentions: diff --git a/d2m/converters/message-to-event.test.js b/d2m/converters/message-to-event.test.js index 260ecda..062ee0b 100644 --- a/d2m/converters/message-to-event.test.js +++ b/d2m/converters/message-to-event.test.js @@ -341,3 +341,25 @@ test("message2event: type 4 channel name change", async t => { formatted_body: "changed the channel name to worming" }]) }) + +test("message2event: thread start message reference", async t => { + const events = await messageToEvent(data.special_message.thread_start_context, data.guild.general, {}, { + api: { + getEvent: mockGetEvent(t, "!PnyBKvUBOhjuCucEfk:cadence.moe", "$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo", { + "type": "m.room.message", + "sender": "@_ooye_cadence:cadence.moe", + "content": { + "m.mentions": {}, + "msgtype": "m.text", + "body": "layer 4" + } + }) + } + }) + t.deepEqual(events, [{ + $type: "m.room.message", + msgtype: "m.text", + body: "layer 4", + "m.mentions": {} + }]) +}) diff --git a/d2m/converters/thread-to-announcement.js b/d2m/converters/thread-to-announcement.js new file mode 100644 index 0000000..405f7e9 --- /dev/null +++ b/d2m/converters/thread-to-announcement.js @@ -0,0 +1,46 @@ +// @ts-check + +const assert = require("assert") + +const passthrough = require("../../passthrough") +const { discord, sync, db } = passthrough +/** @type {import("../../matrix/read-registration")} */ +const reg = sync.require("../../matrix/read-registration.js") + +const userRegex = reg.namespaces.users.map(u => new RegExp(u.regex)) + +/** + * @param {string} parentRoomID + * @param {string} threadRoomID + * @param {string?} creatorMxid + * @param {import("discord-api-types/v10").APIThreadChannel} thread + * @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API + */ +async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thread, di) { + /** @type {string?} */ + const branchedFromEventID = db.prepare("SELECT event_id FROM event_message WHERE message_id = ?").pluck().get(thread.id) + /** @type {{"m.mentions"?: any, "m.in_reply_to"?: any}} */ + const context = {} + if (branchedFromEventID) { + // Need to figure out who sent that event... + const event = await di.api.getEvent(parentRoomID, branchedFromEventID) + context["m.relates_to"] = {"m.in_reply_to": {event_id: event.event_id}} + if (event.sender && !userRegex.some(rx => event.sender.match(rx))) context["m.mentions"] = {user_ids: [event.sender]} + } + + const msgtype = creatorMxid ? "m.emote" : "m.text" + const template = creatorMxid ? "started a thread:" : "Thread started:" + let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}` + let html = `${template} ${thread.name}` + + return { + msgtype, + body, + format: "org.matrix.custom.html", + formatted_body: html, + "m.mentions": {}, + ...context + } +} + +module.exports.threadToAnnouncement = threadToAnnouncement diff --git a/d2m/converters/thread-to-announcement.test.js b/d2m/converters/thread-to-announcement.test.js new file mode 100644 index 0000000..06d937f --- /dev/null +++ b/d2m/converters/thread-to-announcement.test.js @@ -0,0 +1,150 @@ +const {test} = require("supertape") +const {threadToAnnouncement} = require("./thread-to-announcement") +const data = require("../../test/data") +const Ty = require("../../types") + +/** + * @param {string} roomID + * @param {string} eventID + * @returns {(roomID: string, eventID: string) => Promise>} + */ +function mockGetEvent(t, roomID_in, eventID_in, outer) { + return async function(roomID, eventID) { + t.equal(roomID, roomID_in) + t.equal(eventID, eventID_in) + return new Promise(resolve => { + setTimeout(() => { + resolve({ + event_id: eventID_in, + room_id: roomID_in, + origin_server_ts: 1680000000000, + unsigned: { + age: 2245, + transaction_id: "$local.whatever" + }, + ...outer + }) + }) + }) + } +} + +test("thread2announcement: no known creator, no branched from event", async t => { + const content = await threadToAnnouncement("!parent", "!thread", null, { + name: "test thread", + id: "-1" + }) + t.deepEqual(content, { + msgtype: "m.text", + body: "Thread started: test thread https://matrix.to/#/!thread", + format: "org.matrix.custom.html", + formatted_body: `Thread started: test thread`, + "m.mentions": {} + }) +}) + +test("thread2announcement: known creator, no branched from event", async t => { + const content = await threadToAnnouncement("!parent", "!thread", "@_ooye_crunch_god:cadence.moe", { + name: "test thread", + id: "-1" + }) + t.deepEqual(content, { + msgtype: "m.emote", + body: "started a thread: test thread https://matrix.to/#/!thread", + format: "org.matrix.custom.html", + formatted_body: `started a thread: test thread`, + "m.mentions": {} + }) +}) + +test("thread2announcement: no known creator, branched from discord event", async t => { + const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", null, { + name: "test thread", + id: "1126786462646550579" + }, { + api: { + getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", { + type: 'm.room.message', + sender: '@_ooye_bot:cadence.moe', + content: { + msgtype: 'm.text', + body: 'testing testing testing' + } + }) + } + }) + t.deepEqual(content, { + msgtype: "m.text", + body: "Thread started: test thread https://matrix.to/#/!thread", + format: "org.matrix.custom.html", + formatted_body: `Thread started: test thread`, + "m.mentions": {}, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + } + } + }) +}) + +test("thread2announcement: known creator, branched from discord event", async t => { + const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", "@_ooye_crunch_god:cadence.moe", { + name: "test thread", + id: "1126786462646550579" + }, { + api: { + getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg", { + type: 'm.room.message', + sender: '@_ooye_bot:cadence.moe', + content: { + msgtype: 'm.text', + body: 'testing testing testing' + } + }) + } + }) + t.deepEqual(content, { + msgtype: "m.emote", + body: "started a thread: test thread https://matrix.to/#/!thread", + format: "org.matrix.custom.html", + formatted_body: `started a thread: test thread`, + "m.mentions": {}, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg" + } + } + }) +}) + +test("thread2announcement: no known creator, branched from matrix event", async t => { + const content = await threadToAnnouncement("!kLRqKKUQXcibIMtOpl:cadence.moe", "!thread", null, { + name: "test thread", + id: "1128118177155526666" + }, { + api: { + getEvent: mockGetEvent(t, "!kLRqKKUQXcibIMtOpl:cadence.moe", "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4", { + type: "m.room.message", + content: { + msgtype: "m.text", + body: "so can you reply to my webhook uwu" + }, + sender: "@cadence:cadence.moe" + }) + } + }) + t.deepEqual(content, { + msgtype: "m.text", + body: "Thread started: test thread https://matrix.to/#/!thread", + format: "org.matrix.custom.html", + formatted_body: `Thread started: test thread`, + "m.mentions": { + user_ids: ["@cadence:cadence.moe"] + }, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4" + } + } + }) +}) diff --git a/d2m/discord-packets.js b/d2m/discord-packets.js index 776d4b1..a1a4505 100644 --- a/d2m/discord-packets.js +++ b/d2m/discord-packets.js @@ -86,18 +86,16 @@ const utils = { await eventDispatcher.onChannelOrThreadUpdate(client, message.d, false) } else if (message.t === "THREAD_CREATE") { - console.log(message) - // await eventDispatcher.onThreadCreate(client, message.d) + // @ts-ignore + await eventDispatcher.onThreadCreate(client, message.d) } else if (message.t === "THREAD_UPDATE") { await eventDispatcher.onChannelOrThreadUpdate(client, message.d, true) } else if (message.t === "MESSAGE_CREATE") { - console.log(message) await eventDispatcher.onMessageCreate(client, message.d) } else if (message.t === "MESSAGE_UPDATE") { - console.log(message) await eventDispatcher.onMessageUpdate(client, message.d) } else if (message.t === "MESSAGE_DELETE") { diff --git a/d2m/event-dispatcher.js b/d2m/event-dispatcher.js index 6e4ffc4..c871ff1 100644 --- a/d2m/event-dispatcher.js +++ b/d2m/event-dispatcher.js @@ -10,6 +10,8 @@ const editMessage = sync.require("./actions/edit-message") const deleteMessage = sync.require("./actions/delete-message") /** @type {import("./actions/add-reaction")}) */ const addReaction = sync.require("./actions/add-reaction") +/** @type {import("./actions/announce-thread")}) */ +const announceThread = sync.require("./actions/announce-thread") /** @type {import("./actions/create-room")}) */ const createRoom = sync.require("./actions/create-room") /** @type {import("../matrix/api")}) */ @@ -108,8 +110,7 @@ module.exports = { * @param {import("discord-api-types/v10").APIThreadChannel} thread */ async onThreadCreate(client, thread) { - console.log(thread) - const parentRoomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").get(thread.parent_id) + const parentRoomID = db.prepare("SELECT room_id FROM channel_room WHERE channel_id = ?").pluck().get(thread.parent_id) if (!parentRoomID) return // Not interested in a thread if we aren't interested in its wider channel const threadRoomID = await createRoom.syncRoom(thread.id) // Create room (will share the same inflight as the initial message to the thread) await announceThread.announceThread(parentRoomID, threadRoomID, thread) diff --git a/db/data-for-test.sql b/db/data-for-test.sql index ee31fe3..4a406c9 100644 --- a/db/data-for-test.sql +++ b/db/data-for-test.sql @@ -58,7 +58,8 @@ INSERT INTO guild_space (guild_id, space_id) VALUES INSERT INTO channel_room (channel_id, room_id, name, nick, thread_parent) VALUES ('112760669178241024', '!kLRqKKUQXcibIMtOpl:cadence.moe', 'heave', 'main', NULL), ('497161350934560778', '!edUxjVdzgUvXDUIQCK:cadence.moe', 'amanda-spam', NULL, NULL), -('160197704226439168', '!uCtjHhfGlYbVnPVlkG:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL); +('160197704226439168', '!uCtjHhfGlYbVnPVlkG:cadence.moe', 'the-stanley-parable-channel', 'bots', NULL), +('1100319550446252084', '!PnyBKvUBOhjuCucEfk:cadence.moe', 'worm-farm', NULL, NULL); INSERT INTO sim (discord_id, sim_name, localpart, mxid) VALUES ('0', 'bot', '_ooye_bot', '@_ooye_bot:cadence.moe'), @@ -80,7 +81,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, chan ('$51f4yqHinwnSbPEQ9dCgoyy4qiIJSX0QYYVUnvwyTCJ', 'm.room.message', 'm.image', '1141501302736695317', '112760669178241024', 0, 1), ('$vgTKOR5ZTYNMKaS7XvgEIDaOWZtVCEyzLLi5Pc5Gz4M', 'm.room.message', 'm.text', '1128084851279536279', '112760669178241024', 0, 1), ('$YUJFa5j0ZJe7PUvD2DykRt9g51RoadUEYmuJLdSEbJ0', 'm.room.message', 'm.image', '1128084851279536279', '112760669178241024', 1, 1), -('$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q', 'm.room.message', 'm.text', '1128084748338741392', '112760669178241024', 0, 1); +('$oLyUTyZ_7e_SUzGNWZKz880ll9amLZvXGbArJCKai2Q', 'm.room.message', 'm.text', '1128084748338741392', '112760669178241024', 0, 1), +('$FchUVylsOfmmbj-VwEs5Z9kY49_dt2zd0vWfylzy5Yo', 'm.room.message', 'm.text', '1143121514925928541', '1100319550446252084', 0, 1); INSERT INTO file (discord_url, mxc_url) VALUES ('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'), diff --git a/test/data.js b/test/data.js index fc8cbbd..30d108a 100644 --- a/test/data.js +++ b/test/data.js @@ -1375,6 +1375,63 @@ module.exports = { flags: 0, components: [], position: 12 + }, + updated_to_start_thread_from_here: { + t: "MESSAGE_UPDATE", + s: 19, + op: 0, + d: { + id: "1143121514925928541", + flags: 32, + channel_id: "1100319550446252084", + guild_id: "1100319549670301727" + }, + shard_id: 0 + }, + thread_start_context: { + type: 21, + tts: false, + timestamp: "2023-08-21T09:57:12.558000+00:00", + position: 0, + pinned: false, + message_reference: { + message_id: "1143121514925928541", + guild_id: "1100319549670301727", + channel_id: "1100319550446252084" + }, + mentions: [], + mention_roles: [], + mention_everyone: false, + member: { + roles: [], + premium_since: null, + pending: false, + nick: "worm", + mute: false, + joined_at: "2023-04-25T07:17:03.696000+00:00", + flags: 0, + deaf: false, + communication_disabled_until: null, + avatar: null + }, + id: "1143121620744032327", + flags: 0, + embeds: [], + edited_timestamp: null, + content: "", + components: [], + channel_id: "1143121514925928541", + author: { + username: "cadence.worm", + public_flags: 0, + id: "772659086046658620", + global_name: "cadence", + discriminator: "0", + avatar_decoration_data: null, + avatar: "4b5c4b28051144e4c111f0113a0f1cf1" + }, + attachments: [], + guild_id: "1100319549670301727" } } } diff --git a/test/test.js b/test/test.js index 03394f0..606bd4b 100644 --- a/test/test.js +++ b/test/test.js @@ -23,6 +23,7 @@ require("../matrix/read-registration.test") require("../d2m/converters/message-to-event.test") require("../d2m/converters/message-to-event.embeds.test") require("../d2m/converters/edit-to-changes.test") +require("../d2m/converters/thread-to-announcement.test") require("../d2m/actions/create-room.test") require("../d2m/converters/user-to-mxid.test") require("../d2m/actions/register-user.test")