From bf9f6b32fd3fda1bae76bcbd943dac5051851721 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 23 Jan 2026 23:37:55 +1300 Subject: [PATCH 1/4] Just join if registered in database --- src/m2d/event-dispatcher.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index e1f6922..75dad4d 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -333,14 +333,20 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", */ async event => { if (event.state_key[0] !== "@") return - const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` - if (event.state_key === bot) { + if (event.state_key === utils.bot) { const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom) if (upgraded) return } - if (event.content.membership === "invite" && event.state_key === bot) { + if (event.content.membership === "invite" && event.state_key === utils.bot) { + // Supposed to be here already? + const guildID = select("guild_space", "guild_id", {space_id: event.room_id}).pluck().get() + if (guildID) { + await api.joinRoom(event.room_id) + return + } + // We were invited to a room. We should join, and register the invite details for future reference in web. let attemptedApiMessage = "According to unsigned invite data." let inviteRoomState = event.unsigned?.invite_room_state @@ -369,7 +375,7 @@ async event => { db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key) // Unregister room's use as a direct chat if the bot itself left - if (event.state_key === bot) { + if (event.state_key === utils.bot) { db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id) } } From c0bbdfde60338d04034d79c08a0575ccf19c7c11 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sat, 24 Jan 2026 00:31:50 +1300 Subject: [PATCH 2/4] add to historical_channel_room when linking --- src/web/routes/link.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/web/routes/link.js b/src/web/routes/link.js index ce80fd4..8649348 100644 --- a/src/web/routes/link.js +++ b/src/web/routes/link.js @@ -169,7 +169,10 @@ as.router.post("/api/link", defineEventHandler(async event => { const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null) const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null) const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null) - db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic) + db.transaction(() => { + db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic) + db.prepare("INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES (?, ?, 0)").run(channel.id, parsedBody.matrix) + })() // Sync room data and space child await createRoom.syncRoom(parsedBody.discord) From 2496f4c3b046625f6751906e4b100940ec2471cb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 25 Jan 2026 13:50:16 +1300 Subject: [PATCH 3/4] Fix retrying own events as non-moderator --- src/d2m/actions/create-room.js | 5 ++--- src/m2d/event-dispatcher.js | 2 +- src/matrix/api.js | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index f913718..acd47d4 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -487,9 +487,8 @@ async function unbridgeDeletedChannel(channel, guildID) { /** @type {Ty.Event.M_Power_Levels} */ const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "") powerLevelContent.users ??= {} - const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}` for (const mxid of Object.keys(powerLevelContent.users)) { - if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) { + if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) { delete powerLevelContent.users[mxid] await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid) } @@ -513,7 +512,7 @@ async function unbridgeDeletedChannel(channel, guildID) { // (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else) if (row.autocreate === 0) { // remove sim members - const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, bot) + const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, mUtils.bot) const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?") for (const mxid of members) { await api.leaveRoom(roomID, mxid) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 75dad4d..e86dac5 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -173,7 +173,7 @@ async function onRetryReactionAdd(reactionEvent) { if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return // To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator - if (reactionEvent.sender !== event.sender) { + if (reactionEvent.sender !== error.payload.sender) { // Check if it's a room moderator const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api) if (senderPower < (powerLevels.state_default ?? 50)) return diff --git a/src/matrix/api.js b/src/matrix/api.js index 8451857..b71c068 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -162,7 +162,7 @@ function getStateEventOuter(roomID, type, key) { */ async function getInviteState(roomID) { /** @type {Ty.R.SSS} */ - const root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`), { + const root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), { room_subscriptions: { [roomID]: { timeline_limit: 0, From e565342ac8f0d8f1b695a4d335d41c9843773314 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Sat, 24 Jan 2026 18:43:30 -0600 Subject: [PATCH 4/4] initial polls support (not exactly working) --- src/d2m/actions/add-or-remove-vote.js | 81 +++++++ src/d2m/actions/close-poll.js | 139 ++++++++++++ src/d2m/actions/send-message.js | 14 ++ src/d2m/converters/edit-to-changes.js | 4 +- src/d2m/converters/message-to-event.js | 64 ++++++ src/d2m/converters/message-to-event.test.js | 54 +++++ src/d2m/discord-client.js | 3 +- src/d2m/event-dispatcher.js | 10 + src/db/migrations/0031-add-polls.sql | 19 ++ src/db/orm-defs.d.ts | 12 ++ src/m2d/actions/send-event.js | 12 +- src/m2d/actions/vote.js | 24 +++ src/m2d/converters/event-to-message.js | 41 +++- src/m2d/event-dispatcher.js | 21 ++ src/types.d.ts | 37 ++++ test/data.js | 228 +++++++++++++++++++- 16 files changed, 749 insertions(+), 14 deletions(-) create mode 100644 src/d2m/actions/add-or-remove-vote.js create mode 100644 src/d2m/actions/close-poll.js create mode 100644 src/db/migrations/0031-add-polls.sql create mode 100644 src/m2d/actions/vote.js diff --git a/src/d2m/actions/add-or-remove-vote.js b/src/d2m/actions/add-or-remove-vote.js new file mode 100644 index 0000000..6c6fbb6 --- /dev/null +++ b/src/d2m/actions/add-or-remove-vote.js @@ -0,0 +1,81 @@ +// @ts-check + +const assert = require("assert").strict + +const passthrough = require("../../passthrough") +const {discord, sync, db, select, from} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("./register-user")} */ +const registerUser = sync.require("./register-user") +/** @type {import("./create-room")} */ +const createRoom = sync.require("../actions/create-room") + +const inFlightPollVotes = new Set() + +/** + * @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data + */ +async function addVote(data){ + const parentID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() // Currently Discord doesn't allow sending a poll with anything else, but we bridge it after all other content so reaction_part: 0 is the part that will have the poll. + if (!parentID) return // Nothing can be done if the parent message was never bridged. + + let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls. + assert(realAnswer) + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(data.user_id, data.message_id, realAnswer) + return modifyVote(data, parentID) +} + +/** + * @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} data + */ +async function removeVote(data){ + const parentID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() + if (!parentID) return + + let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls. + assert(realAnswer) + db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND vote = ?").run(data.user_id, data.message_id, realAnswer) + return modifyVote(data, parentID) +} + +/** + * @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data + * @param {string} parentID + */ +async function modifyVote(data, parentID) { + + if (inFlightPollVotes.has(data.user_id+data.message_id)) { // Multiple votes on a poll, and this function has already been called on at least one of them. Need to add these together so we don't ignore votes if someone is voting rapid-fire on a bunch of different polls. + return; + } + + inFlightPollVotes.add(data.user_id+data.message_id) + + await new Promise(resolve => setTimeout(resolve, 1000)) // Wait a second. + + const user = await discord.snow.user.getUser(data.user_id) // Gateway event doesn't give us the object, only the ID. + + const roomID = await createRoom.ensureRoom(data.channel_id) + const senderMxid = await registerUser.ensureSimJoined(user, roomID) + + let answersArray = select("poll_vote", "vote", {discord_or_matrix_user_id: data.user_id, message_id: data.message_id}).pluck().all() + + const eventID = await api.sendEvent(roomID, "org.matrix.msc3381.poll.response", { + "m.relates_to": { + rel_type: "m.reference", + event_id: parentID, + }, + "org.matrix.msc3381.poll.response": { + answers: answersArray + } + }, senderMxid) + + inFlightPollVotes.delete(data.user_id+data.message_id) + + return eventID + +} + +module.exports.addVote = addVote +module.exports.removeVote = removeVote +module.exports.modifyVote = modifyVote diff --git a/src/d2m/actions/close-poll.js b/src/d2m/actions/close-poll.js new file mode 100644 index 0000000..e2460c4 --- /dev/null +++ b/src/d2m/actions/close-poll.js @@ -0,0 +1,139 @@ +// @ts-check + +const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") +const {isDeepStrictEqual} = require("util") + +const passthrough = require("../../passthrough") +const {discord, sync, db, select, from} = passthrough +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("./register-user")} */ +const registerUser = sync.require("./register-user") +/** @type {import("./create-room")} */ +const createRoom = sync.require("../actions/create-room") +/** @type {import("./add-or-remove-vote.js")} */ +const vote = sync.require("../actions/add-or-remove-vote") +/** @type {import("../../m2d/actions/channel-webhook")} */ +const channelWebhook = sync.require("../../m2d/actions/channel-webhook") + +// This handles, in the following order: +// * verifying Matrix-side votes are accurate for a poll originating on Discord, sending missed votes to Matrix if necessary +// * sending a message to Discord if a vote in that poll has been cast on Matrix +// This does *not* handle bridging of poll closures on Discord to Matrix; that takes place in converters/message-to-event.js. + +/** + * @param {number} percent + */ +function barChart(percent){ + let bars = Math.floor(percent*10) + return "█".repeat(bars) + "▒".repeat(10-bars) +} + +async function getAllVotes(channel_id, message_id, answer_id){ + let voteUsers = [] + let after = 0; + while (!voteUsers.length || after){ + let curVotes = await discord.snow.requestHandler.request("/channels/"+channel_id+"/polls/"+message_id+"/answers/"+answer_id, {after: after, limit: 100}, "get", "json") + if (curVotes.users.length == 0 && after == 0){ // Zero votes. + break + } + if (curVotes.users[99]){ + after = curVotes.users[99].id + } + voteUsers = voteUsers.concat(curVotes.users) + } + return voteUsers +} + + +/** + * @param {typeof import("../../../test/data.js")["poll_close"]} message + * @param {DiscordTypes.APIGuild} guild +*/ +async function closePoll(message, guild){ + const pollCloseObject = message.embeds[0] + + const parentID = select("event_message", "event_id", {message_id: message.message_reference.message_id, event_type: "org.matrix.msc3381.poll.start"}).pluck().get() + if (!parentID) return // Nothing we can send Discord-side if we don't have the original poll. We will still send a results message Matrix-side. + + const pollOptions = select("poll_option", "discord_option", {message_id: message.message_reference.message_id}).pluck().all() + // If the closure came from Discord, we want to fetch all the votes there again and bridge over any that got lost to Matrix before posting the results. + // Database reads are cheap, and API calls are expensive, so we will only query Discord when the totals don't match. + + let totalVotes = pollCloseObject.fields.find(element => element.name === "total_votes").value // We could do [2], but best not to rely on the ordering staying consistent. + + let databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "vote"], {message_id: message.message_reference.message_id}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all() + + if (databaseVotes.length != totalVotes) { // Matching length should be sufficient for most cases. + let voteUsers = [...new Set(databaseVotes.map(vote => vote.discord_or_matrix_user_id))] // Unique array of all users we have votes for in the database. + + // Main design challenge here: we get the data by *answer*, but we need to send it to Matrix by *user*. + + let updatedAnswers = [] // This will be our new array of answers: [{user: ID, votes: [1, 2, 3]}]. + for (let i=0;i{ + let userLocation = updatedAnswers.findIndex(item=>item.id===user.id) + if (userLocation === -1){ // We haven't seen this user yet, so we need to add them. + updatedAnswers.push({id: user.id, votes: [pollOptions[i].toString()]}) // toString as this is what we store and get from the database and send to Matrix. + } else { // This user already voted for another option on the poll. + updatedAnswers[userLocation].votes.push(pollOptions[i]) + } + }) + } + updatedAnswers.map(async user=>{ + voteUsers = voteUsers.filter(item => item != user.id) // Remove any users we have updated answers for from voteUsers. The only remaining entries in this array will be users who voted, but then removed their votes before the poll ended. + let userAnswers = select("poll_vote", "vote", {discord_or_matrix_user_id: user.id, message_id: message.message_reference.message_id}).pluck().all().sort() + let updatedUserAnswers = user.votes.sort() // Sorting both just in case. + if (isDeepStrictEqual(userAnswers,updatedUserAnswers)){ + db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user.id, message.message_reference.message_id) // Delete existing stored votes. + updatedUserAnswers.map(vote=>{ + db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(user.id, message.message_reference.message_id, vote) + }) + await vote.modifyVote({user_id: user.id, message_id: message.message_reference.message_id, channel_id: message.channel_id, answer_id: 0}, parentID) // Fake answer ID, not actually needed (but we're sorta faking the datatype to call this function). + } + }) + + voteUsers.map(async user_id=>{ // Remove these votes. + db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, message.message_reference.message_id) + await vote.modifyVote({user_id: user_id, message_id: message.message_reference.message_id, channel_id: message.channel_id, answer_id: 0}, parentID) + }) + } + + let combinedVotes = 0; + + let pollResults = pollOptions.map(option => { + let votes = Number(db.prepare("SELECT COUNT(*) FROM poll_vote WHERE message_id = ? AND vote = ?").get(message.message_reference.message_id, option)["COUNT(*)"]) + combinedVotes = combinedVotes + votes + return {answer: option, votes: votes} + }) + + if (combinedVotes!=totalVotes){ // This means some votes were cast on Matrix! + let pollAnswersObject = (await discord.snow.channel.getChannelMessage(message.channel_id, message.message_reference.message_id)).poll.answers + // Now that we've corrected the vote totals, we can get the results again and post them to Discord! + let winningAnswer = 0 + let unique = true + for (let i=1;ipollResults[winningAnswer].votes){ + winningAnswer = i + unique = true + } else if (pollResults[i].votes==pollResults[winningAnswer].votes){ + unique = false + } + } + + let messageString = "📶 Results with Matrix votes\n" + for (let i=0;i{ + let matrixText = answer.poll_media.text + if (answer.poll_media.emoji) { + if (answer.poll_media.emoji.id) { + // Custom emoji. It seems like no Matrix client allows custom emoji in poll answers, so leaving this unimplemented. + } else { + matrixText = "[" + answer.poll_media.emoji.name + "] " + matrixText + } + } + let matrixAnswer = { + id: answer.answer_id.toString(), + "org.matrix.msc1767.text": matrixText + } + fallbackText = fallbackText + "\n" + answer.answer_id.toString() + ". " + matrixText + return matrixAnswer; + }) + return { + $type: "org.matrix.msc3381.poll.start", + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": poll.question.text, + body: poll.question.text, + msgtype: "m.text" + }, + kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. + max_selections: maxSelections, + answers: answers + }, + "org.matrix.msc1767.text": fallbackText + } +} + /** * @param {DiscordTypes.APIMessage} message * @param {DiscordTypes.APIGuild} guild @@ -226,6 +266,20 @@ async function messageToEvent(message, guild, options = {}, di) { return [] } + if (message.type === DiscordTypes.MessageType.PollResult) { + const event_id = select("event_message", "event_id", {message_id: message.message_reference?.message_id}).pluck().get() + return [{ + $type: "org.matrix.msc3381.poll.end", + "m.relates_to": { + rel_type: "m.reference", + event_id + }, + "org.matrix.msc3381.poll.end": {}, + body: "This poll has ended.", + msgtype: "m.text" + }] + } + 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. @@ -702,6 +756,12 @@ async function messageToEvent(message, guild, options = {}, di) { } } + // Then polls + if (message.poll) { + const pollEvent = await pollToEvent(message.poll) + events.push(pollEvent) + } + // Then embeds const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1 for (const embed of message.embeds || []) { @@ -713,6 +773,10 @@ async function messageToEvent(message, guild, options = {}, di) { continue // Matrix's own URL previews are fine for images. } + if (embed.type === "poll_result") { + // The code here is only for the message to be bridged to Matrix. Dealing with the Discord-side updates is in actions/poll-close.js. + } + if (embed.url?.startsWith("https://discord.com/")) { continue // If discord creates an embed preview for a discord channel link, don't copy that embed } diff --git a/src/d2m/converters/message-to-event.test.js b/src/d2m/converters/message-to-event.test.js index 3c0c5d9..c30cd1f 100644 --- a/src/d2m/converters/message-to-event.test.js +++ b/src/d2m/converters/message-to-event.test.js @@ -1551,3 +1551,57 @@ test("message2event: forwarded message with unreferenced mention", async t => { "m.mentions": {} }]) }) + +test("message2event: single-choice poll", async t => { + const events = await messageToEvent(data.message.poll_single_choice, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "org.matrix.msc3381.poll.start", + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": "only one answer allowed!", + body: "only one answer allowed!", + msgtype: "m.text" + }, + kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. + max_selections: 1, + answers: [{ + id: "1", + "org.matrix.msc1767.text": "[\ud83d\udc4d] answer one" + }, { + id: "2", + "org.matrix.msc1767.text": "[\ud83d\udc4e] answer two" + }, { + id: "3", + "org.matrix.msc1767.text": "answer three" + }] + }, + "org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three" + }]) +}) + +test("message2event: multiple-choice poll", async t => { + const events = await messageToEvent(data.message.poll_multiple_choice, data.guild.general, {}) + t.deepEqual(events, [{ + $type: "org.matrix.msc3381.poll.start", + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": "more than one answer allowed", + body: "more than one answer allowed", + msgtype: "m.text" + }, + kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that. + max_selections: 3, + answers: [{ + id: "1", + "org.matrix.msc1767.text": "[😭] no" + }, { + id: "2", + "org.matrix.msc1767.text": "oh no" + }, { + id: "3", + "org.matrix.msc1767.text": "oh noooooo" + }] + }, + "org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo" + }]) +}) diff --git a/src/d2m/discord-client.js b/src/d2m/discord-client.js index c84b466..7b0fcf8 100644 --- a/src/d2m/discord-client.js +++ b/src/d2m/discord-client.js @@ -23,7 +23,7 @@ class DiscordClient { /** @type {import("cloudstorm").IClientOptions["intents"]} */ const intents = [ "DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING", - "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", + "GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS", "MESSAGE_CONTENT" ] if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES") @@ -31,7 +31,6 @@ class DiscordClient { this.snow = new SnowTransfer(discordToken) this.cloud = new CloudStorm(discordToken, { shards: [0], - reconnect: true, snowtransferInstance: this.snow, intents, ws: { diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 0a619ef..599db49 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -32,6 +32,8 @@ const speedbump = sync.require("./actions/speedbump") const retrigger = sync.require("./actions/retrigger") /** @type {import("./actions/set-presence")} */ const setPresence = sync.require("./actions/set-presence") +/** @type {import("./actions/add-or-remove-vote")} */ +const vote = sync.require("./actions/add-or-remove-vote") /** @type {import("../m2d/event-dispatcher")} */ const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") /** @type {import("../discord/interactions/matrix-info")} */ @@ -370,6 +372,14 @@ module.exports = { await createSpace.syncSpaceExpressions(data, false) }, + async MESSAGE_POLL_VOTE_ADD(client, data){ + await vote.addVote(data) + }, + + async MESSAGE_POLL_VOTE_REMOVE(client, data){ + await vote.removeVote(data) + }, + /** * @param {import("./discord-client")} client * @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data diff --git a/src/db/migrations/0031-add-polls.sql b/src/db/migrations/0031-add-polls.sql new file mode 100644 index 0000000..ec879f5 --- /dev/null +++ b/src/db/migrations/0031-add-polls.sql @@ -0,0 +1,19 @@ +BEGIN TRANSACTION; + +CREATE TABLE "poll_option" ( + "message_id" TEXT NOT NULL, + "matrix_option" TEXT NOT NULL, + "discord_option" TEXT NOT NULL, + PRIMARY KEY("message_id","matrix_option") + FOREIGN KEY ("message_id") REFERENCES "message_channel" ("message_id") ON DELETE CASCADE +) WITHOUT ROWID; + +CREATE TABLE "poll_vote" ( + "vote" TEXT NOT NULL, + "message_id" TEXT NOT NULL, + "discord_or_matrix_user_id" TEXT NOT NULL, + PRIMARY KEY("vote","message_id","discord_or_matrix_user_id"), + FOREIGN KEY("message_id") REFERENCES "message_channel" ("message_id") ON DELETE CASCADE +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index 38932cc..797361a 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -139,6 +139,18 @@ export type Models = { encoded_emoji: string original_encoding: string | null } + + poll_vote: { + vote: string + message_id: string + discord_or_matrix_user_id: string + } + + poll_option: { + message_id: string + matrix_option: string + discord_option: string + } } export type Prepared = { diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index ddf82f9..141d33f 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -22,8 +22,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message") const emojiSheet = sync.require("../actions/emoji-sheet") /** - * @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message - * @returns {Promise} + * @param {{poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message + * @returns {Promise<{poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>} */ async function resolvePendingFiles(message) { if (!message.pendingFiles) return message @@ -59,7 +59,7 @@ async function resolvePendingFiles(message) { return newMessage } -/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker} event */ +/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event */ async function sendEvent(event) { const row = from("channel_room").where({room_id: event.room_id}).select("channel_id", "thread_parent").get() if (!row) return [] // allow the bot to exist in unbridged rooms, just don't do anything with it @@ -133,6 +133,12 @@ async function sendEvent(event) { }, guild, null) ) } + + if (message.poll){ // Need to store answer mapping in the database. + for (let i=0; i{ + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(event.sender, messageID, answer) + }) +} + +module.exports.updateVote = updateVote diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index b9f80f3..d9ca074 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -517,7 +517,7 @@ async function getL1L2ReplyLine(called = false) { } /** - * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File} event + * @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event * @param {DiscordTypes.APIGuild} guild * @param {DiscordTypes.APIGuildTextChannel} channel * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise}} di simple-as-nails dependency injection for the matrix API @@ -544,13 +544,15 @@ async function eventToMessage(event, guild, channel, di) { displayNameRunoff = "" } - let content = event.content.body // ultimate fallback + let content = event.content["body"] || "" // ultimate fallback /** @type {{id: string, filename: string}[]} */ const attachments = [] /** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */ const pendingFiles = [] /** @type {DiscordTypes.APIUser[]} */ const ensureJoined = [] + /** @type {Ty.SendingPoll} */ + let poll = null // Convert content depending on what the message is // Handle images first - might need to handle their `body`/`formatted_body` as well, which will fall through to the text processor @@ -628,6 +630,24 @@ async function eventToMessage(event, guild, channel, di) { } attachments.push({id: "0", filename}) pendingFiles.push({name: filename, mxc: event.content.url}) + + } else if (event.type === "org.matrix.msc3381.poll.start") { + content = "" + const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience + let allowMultiselect = (pollContent.max_selections != 1) + let answers = pollContent.answers.map(answer=>{ + return {poll_media: {text: answer["org.matrix.msc1767.text"]}, matrix_option: answer["id"]} + }) + poll = { + question: { + text: event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"] + }, + answers: answers, + duration: 768, // Maximum duration (32 days). Matrix doesn't allow automatically-expiring polls, so this is the only thing that makes sense to send. + allow_multiselect: allowMultiselect, + layout_type: 1 + } + } else { // 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 @@ -828,7 +848,7 @@ async function eventToMessage(event, guild, channel, di) { '' + input + '' ); const root = doc.getElementById("turndown-root"); - async function forEachNode(node) { + async function forEachNode(event, node) { for (; node; node = node.nextSibling) { // Check written mentions if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) { @@ -876,10 +896,10 @@ async function eventToMessage(event, guild, channel, di) { node.setAttribute("data-suppress", "") } } - await forEachNode(node.firstChild) + await forEachNode(event, node.firstChild) } } - await forEachNode(root) + await forEachNode(event, root) // SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet. // First we need to determine which emojis are at the end. @@ -960,7 +980,7 @@ async function eventToMessage(event, guild, channel, di) { // Split into 2000 character chunks const chunks = chunk(content, 2000) - /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ + /** @type {({poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ const messages = chunks.map(content => ({ content, allowed_mentions: { @@ -983,6 +1003,15 @@ async function eventToMessage(event, guild, channel, di) { messages[0].pendingFiles = pendingFiles } + if (poll) { + if (!messages.length) messages.push({ + content: " ", // stopgap, remove when library updates + username: displayNameShortened, + avatar_url: avatarURL + }) + messages[0].poll = poll + } + const messagesToEdit = [] const messagesToSend = [] for (let i = 0; i < messages.length; i++) { diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index e86dac5..424ad58 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -18,6 +18,8 @@ const addReaction = sync.require("./actions/add-reaction") const redact = sync.require("./actions/redact") /** @type {import("./actions/update-pins")}) */ const updatePins = sync.require("./actions/update-pins") +/** @type {import("./actions/vote")}) */ +const vote = sync.require("./actions/vote") /** @type {import("../matrix/matrix-command-handler")} */ const matrixCommandHandler = sync.require("../matrix/matrix-command-handler") /** @type {import("../matrix/utils")} */ @@ -218,6 +220,25 @@ async event => { await api.ackEvent(event) })) +sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.start", guard("org.matrix.msc3381.poll.start", +/** + * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event it is a org.matrix.msc3381.poll.start because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + const messageResponses = await sendEvent.sendEvent(event) + await api.ackEvent(event) +})) + +sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("org.matrix.msc3381.poll.response", +/** + * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event it is a org.matrix.msc3381.poll.response because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + await vote.updateVote(event) // Matrix votes can't be bridged, so all we do is store it in the database. +})) + sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", /** * @param {Ty.Event.Outer} event it is a m.reaction because that's what this listener is filtering for diff --git a/src/types.d.ts b/src/types.d.ts index 25bed64..8f879ec 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,3 +1,5 @@ +import * as DiscordTypes from "discord-api-types/v10" + export type AppServiceRegistrationConfig = { id: string as_token: string @@ -81,6 +83,10 @@ export type WebhookAuthor = { id: string } +export type SendingPoll = DiscordTypes.RESTAPIPoll & { + answers: (DiscordTypes.APIBasePollAnswer & {matrix_option: string})[] +} + export type PkSystem = { id: string uuid: string @@ -269,6 +275,37 @@ export namespace Event { export type Outer_M_Sticker = Outer & {type: "m.sticker"} + export type Org_Matrix_Msc3381_Poll_Start = { + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": string + body: string + msgtype: string + }, + kind: string + max_selections: number + answers: { + id: string + "org.matrix.msc1767.text": string + }[] + "org.matrix.msc1767.text": string + } + } + + export type Outer_Org_Matrix_Msc3381_Poll_Start = Outer & {type: "org.matrix.msc3381.poll.start"} + + export type Org_Matrix_Msc3381_Poll_Response = { + "org.matrix.msc3381.poll.response": { + answers: string[] + } + "m.relates_to": { + rel_type: string + event_id: string + } + } + + export type Outer_Org_Matrix_Msc3381_Poll_Response = Outer & {type: "org.matrix.msc3381.poll.response"} + export type M_Room_Member = { membership: string displayname?: string diff --git a/test/data.js b/test/data.js index 0942a87..786737c 100644 --- a/test/data.js +++ b/test/data.js @@ -3593,7 +3593,233 @@ module.exports = { }, attachments: [], guild_id: "286888431945252874" - } + }, + poll_single_choice: { + type: 0, + content: "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-02-15T23:19:04.127000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1340462414176718889", + channel_id: "1340048919589158986", + author: { + id: "307894326028140546", + username: "ellienyaa", + avatar: "f98417a0a0b4aecc7d7667bece353b7e", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "unambiguously boring username", + avatar_decoration_data: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + position: 0, + poll: { + question: { + text: "only one answer allowed!" + }, + answers: [ + { + answer_id: 1, + poll_media: { + text: "answer one", + emoji: { + id: null, + name: "\ud83d\udc4d" + } + } + }, + { + answer_id: 2, + poll_media: { + text: "answer two", + emoji: { + id: null, + name: "\ud83d\udc4e" + } + } + }, + { + answer_id: 3, + poll_media: { + text: "answer three" + } + } + ], + expiry: "2025-02-16T23:19:04.122364+00:00", + allow_multiselect: false, + layout_type: 1, + results: { + answer_counts: [], + is_finalized: false + } + } + }, + poll_multiple_choice: { + type: 0, + content: "", + mentions: [], + mention_roles: [], + attachments: [], + embeds: [], + timestamp: "2025-02-16T00:47:12.310000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1340484594423562300", + channel_id: "1340048919589158986", + author: { + id: "307894326028140546", + username: "ellienyaa", + avatar: "f98417a0a0b4aecc7d7667bece353b7e", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "unambiguously boring username", + avatar_decoration_data: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + position: 0, + poll: { + question: { + text: "more than one answer allowed" + }, + answers: [ + { + answer_id: 1, + poll_media: { + text: "no", + emoji: { + id: null, + name: "😭" + } + } + }, + { + answer_id: 2, + poll_media: { + text: "oh no", + emoji: { + id: "891723675261366292", + name: "this" + } + } + }, + { + answer_id: 3, + poll_media: { + text: "oh noooooo", + emoji: { + id: "964520120682680350", + name: "disapprove" + } + } + } + ], + expiry: "2025-02-17T00:47:12.307985+00:00", + allow_multiselect: true, + layout_type: 1, + results: { + answer_counts: [], + is_finalized: false + } + } + }, + poll_close: { + type: 46, + content: "", + mentions: [ + { + id: "307894326028140546", + username: "ellienyaa", + avatar: "f98417a0a0b4aecc7d7667bece353b7e", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "unambiguously boring username", + avatar_decoration_data: null, + banner_color: null, + clan: null, + primary_guild: null + } + ], + mention_roles: [], + attachments: [], + embeds: [ + { + type: "poll_result", + fields: [ + { + name: "poll_question_text", + value: "test poll that's being closed", + inline: false + }, + { + name: "victor_answer_votes", + value: "0", + inline: false + }, + { + name: "total_votes", + value: "0", + inline: false + } + ], + content_scan_version: 0 + } + ], + timestamp: "2025-02-20T23:07:12.178000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + id: "1342271367374049351", + channel_id: "1340048919589158986", + author: { + id: "307894326028140546", + username: "ellienyaa", + avatar: "f98417a0a0b4aecc7d7667bece353b7e", + discriminator: "0", + public_flags: 128, + flags: 128, + banner: null, + accent_color: null, + global_name: "unambiguously boring username", + avatar_decoration_data: null, + banner_color: null, + clan: null, + primary_guild: null + }, + pinned: false, + mention_everyone: false, + tts: false, + message_reference: { + type: 0, + channel_id: "1340048919589158986", + message_id: "1342271353990021206" + }, + position: 0 + } }, pk_message: { pk_reply_to_matrix: {