From 2496f4c3b046625f6751906e4b100940ec2471cb Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Sun, 25 Jan 2026 13:50:16 +1300 Subject: [PATCH 1/7] 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 2/7] 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: { From afca4de6b6e350dea6735287ad1d539c5264da4a Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Sun, 25 Jan 2026 00:28:42 -0600 Subject: [PATCH 3/7] Bridge polls from Matrix as pseudo-polls on Discord (with an embed). Not 100% working. Co-authored-by: Cadence Ember --- src/d2m/actions/add-or-remove-vote.js | 71 +++++++------ src/d2m/actions/close-poll.js | 139 +++++++++++++------------ src/d2m/actions/send-message.js | 22 +++- src/db/migrations/0031-add-polls.sql | 19 ---- src/db/migrations/0032-add-polls.sql | 34 ++++++ src/db/orm-defs.d.ts | 19 +++- src/discord/interactions/vote.js | 95 +++++++++++++++++ src/m2d/actions/send-event.js | 26 +++-- src/m2d/actions/vote.js | 2 +- src/m2d/converters/event-to-message.js | 39 ++++--- src/m2d/converters/poll-components.js | 103 ++++++++++++++++++ src/types.d.ts | 4 - 12 files changed, 417 insertions(+), 156 deletions(-) delete mode 100644 src/db/migrations/0031-add-polls.sql create mode 100644 src/db/migrations/0032-add-polls.sql create mode 100644 src/discord/interactions/vote.js create mode 100644 src/m2d/converters/poll-components.js diff --git a/src/d2m/actions/add-or-remove-vote.js b/src/d2m/actions/add-or-remove-vote.js index 6c6fbb6..e9fee36 100644 --- a/src/d2m/actions/add-or-remove-vote.js +++ b/src/d2m/actions/add-or-remove-vote.js @@ -1,6 +1,9 @@ // @ts-check const assert = require("assert").strict +const DiscordTypes = require("discord-api-types/v10") +const {Semaphore} = require("@chriscdn/promise-semaphore") +const {scheduler} = require("timers/promises") const passthrough = require("../../passthrough") const {discord, sync, db, select, from} = passthrough @@ -11,71 +14,81 @@ const registerUser = sync.require("./register-user") /** @type {import("./create-room")} */ const createRoom = sync.require("../actions/create-room") -const inFlightPollVotes = new Set() +const inFlightPollSema = new Semaphore() /** * @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. + const pollEventID = 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 (!pollEventID) 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) + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(data.user_id, data.message_id, realAnswer) + return debounceSendVotes(data, pollEventID) } /** * @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 + const pollEventID = 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 (!pollEventID) 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) + db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(data.user_id, data.message_id, realAnswer) + return debounceSendVotes(data, pollEventID) } /** + * Multiple-choice polls send all the votes at the same time. This debounces and sends the combined votes. + * In the meantime, the combined votes are assembled in the `poll_vote` database table by the above functions. * @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data - * @param {string} parentID + * @param {string} pollEventID + * @return {Promise} event ID of Matrix vote */ -async function modifyVote(data, parentID) { +async function debounceSendVotes(data, pollEventID) { + return await inFlightPollSema.request(async () => { + await scheduler.wait(1000) // Wait for votes to be collected - 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; + const user = await discord.snow.user.getUser(data.user_id) // Gateway event doesn't give us the object, only the ID. + return await sendVotes(user, data.channel_id, data.message_id, pollEventID) + }, `${data.user_id}/${data.message_id}`) +} + +/** + * @param {DiscordTypes.APIUser} user + * @param {string} channelID + * @param {string} pollMessageID + * @param {string} pollEventID + */ +async function sendVotes(user, channelID, pollMessageID, pollEventID) { + const latestRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() + const matchingRoomID = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: pollMessageID}).pluck("room_id").get() + if (!latestRoomID || latestRoomID !== matchingRoomID) { // room upgrade mid-poll?? + db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(pollMessageID) + return } - inFlightPollVotes.add(data.user_id+data.message_id) + const senderMxid = await registerUser.ensureSimJoined(user, matchingRoomID) - 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", { + const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: user.id, message_id: pollMessageID}).pluck().all() + const eventID = await api.sendEvent(matchingRoomID, "org.matrix.msc3381.poll.response", { "m.relates_to": { rel_type: "m.reference", - event_id: parentID, + event_id: pollEventID, }, "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 +module.exports.debounceSendVotes = debounceSendVotes +module.exports.sendVotes = sendVotes \ No newline at end of file diff --git a/src/d2m/actions/close-poll.js b/src/d2m/actions/close-poll.js index e2460c4..a715177 100644 --- a/src/d2m/actions/close-poll.js +++ b/src/d2m/actions/close-poll.js @@ -30,16 +30,25 @@ function barChart(percent){ return "█".repeat(bars) + "▒".repeat(10-bars) } -async function getAllVotes(channel_id, message_id, answer_id){ +/** + * @param {string} channelID + * @param {string} messageID + * @param {string} answerID + * @returns {Promise} + */ +async function getAllVotesOnAnswer(channelID, messageID, answerID){ + const limit = 100 + /** @type {DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]} */ 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. + let after = undefined + while (!voteUsers.length || after) { + const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit}) + if (curVotes.users.length === 0) { // Reached the end. break } - if (curVotes.users[99]){ - after = curVotes.users[99].id + if (curVotes.users.length >= limit) { // Loop again for the next page. + // @ts-ignore - stupid + after = curVotes.users.at(-1).id } voteUsers = voteUsers.concat(curVotes.users) } @@ -48,91 +57,89 @@ async function getAllVotes(channel_id, message_id, answer_id){ /** - * @param {typeof import("../../../test/data.js")["poll_close"]} message + * @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage * @param {DiscordTypes.APIGuild} guild */ -async function closePoll(message, guild){ - const pollCloseObject = message.embeds[0] +async function closePoll(closeMessage, guild){ + const pollCloseObject = closeMessage.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 pollMessageID = closeMessage.message_reference.message_id + const pollEventID = select("event_message", "event_id", {message_id: pollMessageID, event_type: "org.matrix.msc3381.poll.start"}).pluck().get() + if (!pollEventID) 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 discordPollOptions = select("poll_option", "discord_option", {message_id: pollMessageID}).pluck().all() + assert(discordPollOptions.every(x => typeof x === "string")) // This poll originated on Discord so it will have Discord option IDs - 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. + const 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() + const databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "matrix_option"], {message_id: pollMessageID}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all() - if (databaseVotes.length != totalVotes) { // Matching length should be sufficient for most cases. + 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) + /** @type {{user: DiscordTypes.APIUser, matrixOptionVotes: string[]}[]} This will be our new array of answers */ + const updatedAnswers = [] + + for (const discordPollOption of discordPollOptions) { + const optionUsers = await getAllVotesOnAnswer(closeMessage.channel_id, pollMessageID, discordPollOption) // Array of user IDs who voted for the option we're testing. + optionUsers.map(user => { + const userLocation = updatedAnswers.findIndex(answer => answer.user.id === user.id) + const matrixOption = select("poll_option", "matrix_option", {message_id: pollMessageID, discord_option: discordPollOption}).pluck().get() + assert(matrixOption) 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. + updatedAnswers.push({user, matrixOptionVotes: [matrixOption]}) // 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[userLocation].matrixOptionVotes.push(matrixOption) } }) } - 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) - }) + // Check for inconsistencies in what was cached in database vs final confirmed poll answers + // If different, sync the final confirmed answers to Matrix-side to make it accurate there too + + await Promise.all(updatedAnswers.map(async answer => { + voteUsers = voteUsers.filter(item => item !== answer.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. + const cachedAnswers = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: answer.user.id, message_id: pollMessageID}).pluck().all() + if (!isDeepStrictEqual(new Set(cachedAnswers), new Set(answer.matrixOptionVotes))){ + db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(answer.user.id, pollMessageID) // Delete existing stored votes. + for (const matrixOption of answer.matrixOptionVotes) { + db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(answer.user.id, pollMessageID, matrixOption) + } + await vote.debounceSendVotes({user_id: answer.user.id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID) // Fake answer ID, not actually needed (but we're sorta faking the datatype to call this function). + } + })) + + await Promise.all(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, pollMessageID) + await vote.debounceSendVotes({user_id: user_id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID) + })) } - let combinedVotes = 0; + /** @type {{discord_option: string, option_text: string, count: number}[]} */ + const pollResults = db.prepare("SELECT discord_option, option_text, count(*) as count FROM poll_option INNER JOIN poll_vote USING (message_id, matrix_option) GROUP BY discord_option").all() + const combinedVotes = pollResults.reduce((a, c) => a + c.count, 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 + if (combinedVotes !== totalVotes) { // This means some votes were cast on Matrix! + const message = await discord.snow.channel.getChannelMessage(closeMessage.channel_id, pollMessageID) + assert(message?.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 - } - } + const topAnswers = pollResults.toSorted() + const unique = topAnswers.length > 1 && topAnswers[0].count === topAnswers[1].count - let messageString = "📶 Results with Matrix votes\n" - for (let i=0;i { + db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run( + message.id, + event["org.matrix.msc3381.poll.start"].max_selections, + event["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"] + ) + for (const [index, option] of Object.entries(event["org.matrix.msc3381.poll.start"].answers)) { + db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option, option_text, seq) VALUES (?, ?, ?, ?, ?)").run( + message.id, + option.id, + option.id, + option["org.matrix.msc1767.text"], + index + ) + } + })() } eventIDs.push(eventID) - } return eventIDs diff --git a/src/db/migrations/0031-add-polls.sql b/src/db/migrations/0031-add-polls.sql deleted file mode 100644 index ec879f5..0000000 --- a/src/db/migrations/0031-add-polls.sql +++ /dev/null @@ -1,19 +0,0 @@ -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/migrations/0032-add-polls.sql b/src/db/migrations/0032-add-polls.sql new file mode 100644 index 0000000..0697937 --- /dev/null +++ b/src/db/migrations/0032-add-polls.sql @@ -0,0 +1,34 @@ +BEGIN TRANSACTION; + +DROP TABLE IF EXISTS "poll"; +DROP TABLE IF EXISTS "poll_option"; +DROP TABLE IF EXISTS "poll_vote"; + +CREATE TABLE "poll" ( + "message_id" TEXT NOT NULL, + "max_selections" INTEGER NOT NULL, + "question_text" TEXT NOT NULL, + "is_closed" INTEGER NOT NULL, + PRIMARY KEY ("message_id"), + FOREIGN KEY ("message_id") REFERENCES "message_room" ("message_id") ON DELETE CASCADE +) WITHOUT ROWID; + +CREATE TABLE "poll_option" ( + "message_id" TEXT NOT NULL, + "matrix_option" TEXT NOT NULL, + "discord_option" TEXT, + "option_text" TEXT NOT NULL, + "seq" INTEGER NOT NULL, + PRIMARY KEY ("message_id", "matrix_option"), + FOREIGN KEY ("message_id") REFERENCES "poll" ("message_id") ON DELETE CASCADE +) WITHOUT ROWID; + +CREATE TABLE "poll_vote" ( + "message_id" TEXT NOT NULL, + "matrix_option" TEXT NOT NULL, + "discord_or_matrix_user_id" TEXT NOT NULL, + PRIMARY KEY ("message_id", "matrix_option", "discord_or_matrix_user_id"), + FOREIGN KEY ("message_id", "matrix_option") REFERENCES "poll_option" ("message_id", "matrix_option") ON DELETE CASCADE +) WITHOUT ROWID; + +COMMIT; diff --git a/src/db/orm-defs.d.ts b/src/db/orm-defs.d.ts index 797361a..e36ed49 100644 --- a/src/db/orm-defs.d.ts +++ b/src/db/orm-defs.d.ts @@ -140,16 +140,25 @@ export type Models = { original_encoding: string | null } - poll_vote: { - vote: string + poll: { // not actually in database yet message_id: string - discord_or_matrix_user_id: string + max_selections: number + question_text: string + is_closed: number } - + poll_option: { message_id: string matrix_option: string - discord_option: string + discord_option: string | null + option_text: string // not actually in database yet + seq: number // not actually in database yet + } + + poll_vote: { + message_id: string + matrix_option: string + discord_or_matrix_user_id: string } } diff --git a/src/discord/interactions/vote.js b/src/discord/interactions/vote.js new file mode 100644 index 0000000..91ba97e --- /dev/null +++ b/src/discord/interactions/vote.js @@ -0,0 +1,95 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, select, from, db} = require("../../passthrough") +const assert = require("assert/strict") +const {id: botID} = require("../../../addbot") +const {InteractionMethods} = require("snowtransfer") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") +/** @type {import("../../m2d/converters/poll-components")} */ +const pollComponents = sync.require("../../m2d/converters/poll-components") +/** @type {import("../../d2m/actions/add-or-remove-vote")} */ +const vote = sync.require("../../d2m/actions/add-or-remove-vote") + +/** + * @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interact({data, message, member, user}, {api}) { + const discordUser = member?.user || user + assert(discordUser) + const userID = discordUser.id + + const matrixPollEvent = select("event_message", "event_id", {message_id: message.id}).pluck().get() + assert(matrixPollEvent) + + const matrixOption = select("poll_option", "matrix_option", {discord_option: data.custom_id, message_id: message.id}).pluck().get() + assert(matrixOption) + + const pollRow = select("poll", ["question_text", "max_selections"], {message_id: message.id}).get() + assert(pollRow) + const maxSelections = pollRow.max_selections + const alreadySelected = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: message.id}).pluck().all() + + // Show modal (if no capacity) + if (maxSelections > 1 && alreadySelected.length === maxSelections) { + // TODO: show modal + return + } + + // We are going to do a server operation so need to show loading state + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.DeferredMessageUpdate, + }} + + // Remove a vote + if (alreadySelected.includes(data.custom_id)) { + db.prepare("DELETE FROM poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id) + } + // Replace votes (if only one selection is allowed) + else if (maxSelections === 1 && alreadySelected.length === 1) { + db.transaction(() => { + db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID) + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id) + })() + } + // Add a vote (if capacity) + else if (alreadySelected.length < maxSelections) { + db.transaction(() => { + db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID) + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id) + })() + } + + // Sync changes to Matrix + await vote.sendVotes(discordUser, message.channel_id, message.id, matrixPollEvent) + + // Check the poll is not closed (it may have been closed by sendVotes if we discover we can't send) + const isClosed = select("poll", "is_closed", {message_id: message.id}).pluck().get() + + /** @type {{matrix_option: string, option_text: string, count: number}[]} */ + const pollResults = db.prepare("SELECT matrix_option, option_text, count(*) as count FROM poll_option INNER JOIN poll_vote USING (message_id, matrix_option) GROUP BY matrix_option").all() + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.UpdateMessage, + data: pollComponents.getPollComponents(!!isClosed, maxSelections, pollRow.question_text, pollResults) + }} +} + +/* c8 ignore start */ + +/** @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction */ +async function interact(interaction) { + for await (const response of _interact(interaction, {api})) { + if (response.createInteractionResponse) { + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } + } +} + +module.exports.interact = interact +module.exports._interact = _interact diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index 141d33f..f18385f 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 {{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}[]}>} + * @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} */ async function resolvePendingFiles(message) { if (!message.pendingFiles) return message @@ -71,6 +71,7 @@ async function sendEvent(event) { } /** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore const channel = discord.channels.get(channelID) + // @ts-ignore const guild = discord.guilds.get(channel.guild_id) assert(guild) const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: event.room_id}).pluck().get() @@ -133,12 +134,25 @@ async function sendEvent(event) { }, guild, null) ) } + } - if (message.poll){ // Need to store answer mapping in the database. - for (let i=0; i { + const messageID = messageResponses[0].id + db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run( + messageID, + event.content["org.matrix.msc3381.poll.start"].max_selections, + event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"] + ) + for (const [i, option] of Object.entries(event.content["org.matrix.msc3381.poll.start"].answers)) { + db.prepare("INSERT INTO poll_option (message_id, matrix_option, option_text, seq) VALUES (?, ?, ?, ?)").run( + messageID, + option.id, + option["org.matrix.msc1767.text"], + i + ) } - } + })() } for (const user of ensureJoined) { diff --git a/src/m2d/actions/vote.js b/src/m2d/actions/vote.js index e19328c..5bb5cd4 100644 --- a/src/m2d/actions/vote.js +++ b/src/m2d/actions/vote.js @@ -17,7 +17,7 @@ async function updateVote(event) { db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(event.sender, messageID) // Clear all the existing votes, since this overwrites. Technically we could check and only overwrite the changes, but the complexity isn't worth it. event.content["org.matrix.msc3381.poll.response"].answers.map(answer=>{ - db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(event.sender, messageID, answer) + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(event.sender, messageID, answer) }) } diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index d9ca074..ab53d08 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -22,6 +22,8 @@ const dUtils = sync.require("../../discord/utils") const file = sync.require("../../matrix/file") /** @type {import("./emoji-sheet")} */ const emojiSheet = sync.require("./emoji-sheet") +/** @type {import("./poll-components")} */ +const pollComponents = sync.require("./poll-components") /** @type {import("../actions/setup-emojis")} */ const setupEmojis = sync.require("../actions/setup-emojis") /** @type {import("../../d2m/converters/user-to-mxid")} */ @@ -551,8 +553,8 @@ async function eventToMessage(event, guild, channel, di) { const pendingFiles = [] /** @type {DiscordTypes.APIUser[]} */ const ensureJoined = [] - /** @type {Ty.SendingPoll} */ - let poll = null + /** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody?} */ + let pollMessage = 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 @@ -632,21 +634,17 @@ async function eventToMessage(event, guild, channel, di) { 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 - } + const isClosed = false; + const maxSelections = pollContent.max_selections || 1 + const questionText = pollContent.question["org.matrix.msc1767.text"] + const pollOptions = pollContent.answers.map(answer => ({ + matrix_option: answer.id, + option_text: answer["org.matrix.msc1767.text"], + count: 0 // no votes initially + })) + content = "" + pollMessage = pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions) } 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. @@ -980,7 +978,7 @@ async function eventToMessage(event, guild, channel, di) { // Split into 2000 character chunks const chunks = chunk(content, 2000) - /** @type {({poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ + /** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */ const messages = chunks.map(content => ({ content, allowed_mentions: { @@ -1003,13 +1001,12 @@ async function eventToMessage(event, guild, channel, di) { messages[0].pendingFiles = pendingFiles } - if (poll) { - if (!messages.length) messages.push({ - content: " ", // stopgap, remove when library updates + if (pollMessage) { + messages.push({ + ...pollMessage, username: displayNameShortened, avatar_url: avatarURL }) - messages[0].poll = poll } const messagesToEdit = [] diff --git a/src/m2d/converters/poll-components.js b/src/m2d/converters/poll-components.js new file mode 100644 index 0000000..8aafa2b --- /dev/null +++ b/src/m2d/converters/poll-components.js @@ -0,0 +1,103 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") + +/** + * @param {boolean} isClosed + * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly + * @returns {DiscordTypes.APIMessageTopLevelComponent[]} +*/ +function optionsToComponents(isClosed, pollOptions) { + const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count) + /** @type {DiscordTypes.APIMessageTopLevelComponent[]} */ + return pollOptions.map(option => { + const winningOrTied = option.count && topAnswers[0].count === option.count + return { + type: DiscordTypes.ComponentType.Container, + components: [{ + type: DiscordTypes.ComponentType.Section, + components: [{ + type: DiscordTypes.ComponentType.TextDisplay, + content: option.option_text + }], + accessory: { + type: DiscordTypes.ComponentType.Button, + style: winningOrTied ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary, + label: option.count.toString(), + custom_id: option.matrix_option, + disabled: isClosed + } + }] + } + }) +} + +/** + * @param {boolean} isClosed + * @param {number} maxSelections + * @param {string} questionText + * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly + * @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody} + */ +function getPollComponents(isClosed, maxSelections, questionText, pollOptions) { + /** @type {DiscordTypes.APIMessageTopLevelComponent} */ + let headingComponent + if (isClosed) { + const multiSelectString = + ( maxSelections === 1 ? "-# ~~Select one answer~~" + : maxSelections >= pollOptions.length ? "-# ~~Select one or more answers~~" + : `-# ~~Select up to ${maxSelections} answers~~`) + headingComponent = { // This one is for the poll heading. + type: DiscordTypes.ComponentType.Section, + components: [ + { + type: DiscordTypes.ComponentType.TextDisplay, + content: `## ${questionText}` + }, + { + type: DiscordTypes.ComponentType.TextDisplay, + content: multiSelectString + } + ], + accessory: { + type: DiscordTypes.ComponentType.Button, + style: DiscordTypes.ButtonStyle.Secondary, + custom_id: "vote", + label: "Voting closed!", + disabled: true + } + } + } + else { + const multiSelectString = + ( maxSelections === 1 ? "-# Select one answer" + : maxSelections >= pollOptions.length ? "-# Select one or more answers" + : `-# Select up to ${maxSelections} answers`) + headingComponent = { // This one is for the poll heading. + type: DiscordTypes.ComponentType.Section, + components: [ + { + type: DiscordTypes.ComponentType.TextDisplay, + content: `## ${questionText}` + }, + { + type: DiscordTypes.ComponentType.TextDisplay, + content: multiSelectString + } + ], + accessory: { + type: DiscordTypes.ComponentType.Button, + style: DiscordTypes.ButtonStyle.Primary, + custom_id: "vote", + label: "Vote!" + } + } + } + const optionComponents = optionsToComponents(isClosed, pollOptions) + return { + flags: DiscordTypes.MessageFlags.IsComponentsV2, + components: [headingComponent, ...optionComponents] + } +} + +module.exports.getPollComponents = getPollComponents \ No newline at end of file diff --git a/src/types.d.ts b/src/types.d.ts index 8f879ec..f18116e 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -83,10 +83,6 @@ export type WebhookAuthor = { id: string } -export type SendingPoll = DiscordTypes.RESTAPIPoll & { - answers: (DiscordTypes.APIBasePollAnswer & {matrix_option: string})[] -} - export type PkSystem = { id: string uuid: string From 90606d917664da93539ba821a0061eb0b1fd5078 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Sun, 25 Jan 2026 07:27:59 -0600 Subject: [PATCH 4/7] Add full support for polls, both m2d and d2m. Mostly works, but a few edge-cases still need to be worked out. Co-authored-by: Cadence Ember --- docs/img/poll-star-avatar.png | Bin 0 -> 3654 bytes docs/img/poll_win.png | Bin 0 -> 6573 bytes src/d2m/actions/add-reaction.js | 2 +- src/d2m/actions/close-poll.js | 41 +++--- src/d2m/actions/remove-reaction.js | 2 +- src/d2m/actions/send-message.js | 22 ++- src/discord/interactions/poll.js | 150 +++++++++++++++++++++ src/discord/interactions/vote.js | 95 ------------- src/discord/register-interactions.js | 60 ++++++--- src/m2d/actions/send-event.js | 28 +++- src/m2d/actions/setup-emojis.js | 2 +- src/m2d/actions/vote.js | 32 ++++- src/m2d/converters/event-to-message.js | 34 +++-- src/m2d/converters/poll-components.js | 178 +++++++++++++++++++++---- src/m2d/event-dispatcher.js | 29 ++++ src/types.d.ts | 12 ++ src/web/server.js | 5 + 17 files changed, 501 insertions(+), 191 deletions(-) create mode 100644 docs/img/poll-star-avatar.png create mode 100644 docs/img/poll_win.png create mode 100644 src/discord/interactions/poll.js delete mode 100644 src/discord/interactions/vote.js diff --git a/docs/img/poll-star-avatar.png b/docs/img/poll-star-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..a4355557615dd35f4d099fa7908079d7b219b9f1 GIT binary patch literal 3654 zcmcgvdpML^7k_6&C)W-_GU}X6DYryoay^7`za}9HQ&Vn@VWeE*oJvGZjTtnEaZ5Oi z+cXTPP9ltEMxk6%G$xG7Wg275_x7EC&+~l$ed~GNXTQ&Ht-aP>d;ivY_RjM1a8i)f zlm!4l0qN}E0{{^47Xthw0}jEMY7{tZi*pW)2Y^JaA0MRZY0_s9+=)GY66+g_!s5zr1)&1$Lw;{@iYQh~+H=9_zvgk#EV|kq+ERCh`Nxd3KVAKg)m0lN3PEGlWS%f^Gs3u z>5jPbvc9}w8D#YCuXX*EYoS*^26NFnK;z7+qFj+dMNrDZx99Ps%1AHZysTkJ;>OZj z^gZ6+2Jyto1Q1e0)mo!6gC@u6>%+8d9E2_qF&R`&EH>oj2t~zL@dQcSiD%6kvcMSk^P^AYnnFQp@_5z;3yTSorFus zW60rCNt)XW5+fwf)w$!i*8Xy zo==#>`$QxLd05z(rb64NPQX36m^sE;GCPI>dYiwp5|l_Q{U#enZ1S!tO4sIO8SI^??#OyF^wqmfmo`)rBx=N#rY-^xbjj=g$i;ij_tA$O7dqcd;) zXs&^p8~E{rE7~SpHi06v0%VRFvZFYhQcI_mW-8J8KbGvoN0h@$R zl8`Xe#EzPNrdcvnq{XJo~6b}cr%cl2$8lQ{un<;hH-{Bt6vakEf zr3N0@1RV%S_B?I%4THsx^~eJXk*KcyHLB$D)Fs8uP6M0oaM1Ca#~xil_Y@?16P@&Ph9bSIOlaY zY`zK!yX-D^V&SqSqBpo4IJCc~q_h2na+keNEH+;$zRl*;pBO=?1NWvaB+LWW>Dk@p z!Z1ek8&~9Fgr-5A0%z=OujR;q>%lNy$%mmo46+s4oJ3CcGZ+1kyHLMBG z#dS7NFH+um?!Iu~hk~r2$Zo;Sjke%u4k_zcH4wMd|cmtvC{v&g$xH73u_6TN~@NSHw%R0R${)2~Tbm$+5bVIrLby_+i0%@M1UBgg2860`-k-)-F$ zsq%W`n;$QBm{i3BODSi#vv$2sJmwNq(UyN?LoKA8`#QJ&Zji61A(q#$V(dzyH$Pmr z9L$2+qE41wkP8c~2!8iIt?Lgx94z?N5e(IJcfb~dF0Y@Y(--2%0nbl}*e;A;=GTno zG%us=MtTP;FhVV23}NM({WhX@A8A;!87wknc8lod6FbhW~JOte{_970U@27vCwc+Q(x|PEMXNS+4G=ONWxKfuc9C z;DIC_KZlmK(Bmx}cM8~YC3?fhJ>L^+?S)_Yo$~J1^AF`za-F|7kf5od@x7^FqT*vo z^V9J-WKj0=5BYO@25-g#2^yN3>xBg=uJW7uhmfH#Ev=2^ADW%Ew`F8x>W@;RKeY5~ z9;FVguC2vnE3(~^R7S$MOfp$TMMVTpIgF6AX4ft*E~>|j{yDt}&djh+*L3snNc9FC ziX3YEo){w>OdbLk-t$YcndvI79PH`GGaOwls2@5kEUcygrrZ$t<}xkVn%dP>vg^j9 zZuwFdS}^~r@%N%@v&+KeGJ8`w*-{sDVe!voi2p~AfrH+F|6yudnMvt|SjVmB)l_W> zFK6Ed)E*}h%cM-@a?!Qeg@+|2H~Z5A1wOH{OEgOzKNt$|N3306&zsXSH^xaInO(`M zwhH6oBD@RBA}*K%2IS9eAN(C!Z5J9k1wp2%kfmNEcs}$>GW=)t%@KY0cRJ!jh^?tT z%$k}Sn=D0Ely16aU1(P-0*Rl8i*Z;rtw@xFpwrh95R&lP51Ws&hYUaT($y+1EIjD4 z-b&%kSuT$@Q|L;&t%LPdWp9)}Gu>+_#eDTl$)>}S*gx?rPZP;?;1=vZjS=jg!O-Xy zpGtPhmfDR^XkVVW8wu`!LCkUT4*n*_ka(;a&8g(lhPVFfU8MARqqeP7)Vo##+~=CA zHPV=)$+7+ko1eANr`US@&5i=Allp2UO3cihG5{5pl+<4CQ!^fgYIC`zz4Bs~@3{~8 zjR4Z^Mj3TakZw9cK}o3r$yu>R_LotI-%ny_8$n{Rc*$X_otYO%AT6!&>hcohW?h6U zED?@m2kEL#-c-y;QnkIUojmt&d0Dve5nm|$_!Osu#Q1|d;KSG60zI=J|p8*G?k_J%Jv*cuG70< z&_><#gH~U{}9?T5w!f0qG|aEB1x`bh`=u`48zl04WB*j$+_h<}hL>z`|1z!e<0=XFfzMhGd; zTZ=FTn=X_5df?P-<-Xx8kT8uWuiCQ1Sr(dqO|+@W{;`*3u{|4F?M(~b?4UejS)k^o zM?%xYvE}xAw1=R>XI|vOitswL&_SX37hiOt+R@m_4HTGQxQy40!J?x!_)1=t&XIRM zL6XDP){MJ<<>8E_NY|#PrxkG$JbmgQ8jUShNgAJsU7DYN_cLtE*l!Ti?$+pcn5qYu zmqrY_Zgoa~_kat3vx&m(pZ%5zo|KWprym#bf05ywRX{VMKi>f9={eLCY^cuAwly`; zD;U$O@Zs5}rAca8m*cLgcFQZs%RfFu4H_4}4bQ&z-pY{Ygjy5xc)TwD(o))f#CGeO z&}z=w;=3Qxb%5uHjAmVKVooR(j1j?J)5!|_qAgphm}k8{x0A!+2#}w%nCysP_6Vq4 zWZA{3)>4jRyrg6U5T(vtURQfpC{#6)Q)B_7@m!39wr=E$6i}S_a6y`#!+4%-R%D65w zgp;1J$>|}`ijbU~oPUYjHErg&m9zx129EsJHz#i}_h1fKhDm$DO^#V0?Bz}=!@?LK zXChGJoXMmcWl{q;a4y?a+?YGrcfDuY-Zc~C*XQ~9E63S+W%13VMHb}KG1*cXaC1O! zlm)&-8+I1-aN!k;bt#XcULM~JH4%vz42BfT;=h(wR#qzbP1^@@q?$~0Vd@dY7BDZ^ zm16Nc%c5}fcX}vp;el)h*ur(yvfJpQCD%s|=g^0@!lS{cqN%w(=C`2LzF^O4o9Nub z`#PuZUb?_fz;eg`JB(^oX>bC+1&I79rYz@008KW4D~Gm00K5403|v2 za140u4L+#+4ebK~VB^}w5AslQ1Z)6YsOxqpD?e{kh+BXc5E2q1bJzENpog2kmyBP4 z&x=j|UyTTWntE-a=N^_`1kqES}>@;06w#v}gTxXf`^F$(W1$yzE_UMvqfcVIc@HL*j20@#sLh`d zGw&_;Mc$1!b_TM4wKrUP)m3>$#dr6}r%HViN=a#K{WrDms4;2u6;uf?tQm{$tq6nw zuq_8{3FLO}VdHkYxV#^fb*OG2f$c?gt11uhp23)urPo@HM8n~KD_XIe%SmTGq)l#*=~ z;nyxZ_AViTNF9V(aR*J0)y6Kf&#lo0yjzb6jW!@$&5-u=B*@)FCzSj&dYjwCE;HGi zef#h2b6dpC**lOQRKm=+ly*1lSxc*2O~_o4J_;u6`tN@1l28N3$}`R0<*>)G?C5dA z;Tz}ROD>T4EvS+{!c6-u-2WBJ%ahRXsbHeqV-p&zXwF+rCGwCnKk{PJFS6d0{)q(;>_6|?EDyHl7FwULYye4Qwg7- zJKRVKcuz0|ddnLgO*{1E=RW`3p^q>$zYF<+8JtgHRzYg{=vJWE;f!@-?=R0ZeU@<$ zEfT)d2(+P@sYaVh+L@Qsfcd`DRu1%cbNdnY(>(e!bIy(1U)TS9 zW%Dtnkk=v5VD6ZHzTRg??}|pT=c+^vmsQ&7t=U$@8`1%p!Nc{%nu zx?IKbcth$35LV%8UlD)i?y%{mT|TDOFJ+! z3X54DuGD03D&=XeR;Fo|Y^fN~UaEl8E<(|>(bCR)ic0zzuMZ{0EyBc}TZK`v@WpK& zR@bg<0N^912fZW)}0{*fu zeaEBGGe94aG(%kjxF3#uHF1ejTsRU^C>Xm(coCZ0b@hf&BtE@B`gcWqXsN(6AjiM#QRbW z+D<0sD|Q|?`sE&BkCgNF>gaoe#+j=%S@cg+wQ&HYzKp|OX}rtu?`4H^M}&S2)E=n! z45LHW<5hE@fLtEOrz&+9gn%zY0Ct;_FDBAQdv&gu zh_K|_YYTV-8b8yy^Me}vbp4KPx-(N0c-uX0O)ZwkM?TXY1Kt;Yoi5LClT0sZ z(8CJR)D_l=jt%2(9(jO%ho;bBr!u_bk#wF1zgR^O|YwuseK}+YF?_0ZeiHzZZ!x|Ppu0A=xITw zpS$@@efXj-7Z(NCCdTsv>D>OT#jP!nMXo@%;-^8QlZS=7@_50+{voJ9OEwrnNosop zmYo;F4U)$N<2JVNR_?h7b&ygyF<#A$ib%o-_r`J$|>Z@OdatX+F^E0)G2}AX$ zhs+=U+j9Qn|09+nB3P=VaQ43Qnj5%jPiu&$)J#i)^@1e760_NtD-%LRu#-e8%EpIi&*Xjs!5&mgKcJwXhnNr0ZDLQ(A8n)cG; z!TY|4UfW%VJMFV|t1ROgzcsG@;!&vt!pBrk?tDC{^|yIexbZEQABg@CULZSo&^@WK zo~EUX=<^+5MW+&oc&;2R3G%d_r$z^2=>w6fXKF9Bupg_757%a+hYYYz$^px4edj!7rK{{d!e1zAUeG z)$Xhvoh@hKse%>DfZ*>!Q5`7ywW5)y**iz&FQRx!t9(`cNd8VDI8nb4rT2W(9eoHZ zn}!QCYS*v(wFK9@_%1z^_RjpTmlak(0xJ+0{0K$9mUbbC*-5;{QHa7?p-LIxDDv^N z8F<34PM$Gy6tcEB-Ncs5@QISNM1g)Qh;$aBMx&XDv~MEb7#plk()j9H^p&Wzz(acd z7L()+5inOn2wKh@5jWy=AdmPn9?OnBS1hNqnX%zlyp$?C-Mtv3WDX|hFzO4ZNRYpC6Q{{z8u_0gcaPr=)O;|>`Uw^OfLnE8Iz zQ*;<}No{w%GZbPswj%u+;X_)IM;vK*+SeSl$~prG7-hmv{NPeOO;>IFqMiw(*57`W z#o030aEnT1l}3?DIV(en7EROIIPrbe1Iai1#rE6x&pyh72@&}Y7rOt)xR2oo3rk|* z=Ikl%_^h*#2{V6k_daWr%%vqAwuyrJrwoLLlbAXP(y!v54 zqhs*5XbLT$Fktp!=)b2|HeVlAPWsgrDWTrdpQ#HO7$f3CApp-1G~X#0zEY7Fhp3Me zWUNfnSj-QW2g{aHtL*HJPD8rdz0zR+~UE9dF8n2_USn}J;IPjb8@ zOGg4N(D&sQk=DtLNzgvn3wORy&YZ8lVIrk&OVP#ZZcCcalLZ1)41EYGR`lE{ZWJL~ z$+)}{yT=jZS<~{iPI)J%|$wa`B$WwGMc_>}X zugEX#KfHrDqRW2pyZu;?$hZQ>PCcvSWIVr)`m(q6@z?0X(|OLfTvyq<3b!>D$-8`y z)_z1?M6HXH4xrH=@LY)d$8&kxJLzVTs)spD3whd~>gqeAr(d~j3>Pr1cQC;L*CG@e zH@95Ay{e_T`EBRblWkws?cCMT9rSqfU-qMy?Weuog?k39fc!!hZh}pV)XV&q^i8sp zcQI8XcGUDWHp->A=6C zqBBDxZ*V;HQLIWG69aCI+W$ZeVBr&G7PQJJ) zJkh;XW!uuZkQCwaL=3F!SG|aot#kNJIu^dPlQxk}!W2YHQ*%Ch^DyGc-4Cco-^av( zvh{Ky)ZnJh)2%d}t}gr6+|il>f9;8s?OayCv-|IIa1&0)&h$r7H1(z@r*5q^^6Q9) z#a$_FT4a~i?01-e9S)IT+b%u|%nsh+A%30WOp9qLAAeBvDr?N_o6%IE*#PjbyY%rJE7bzjR z(I78kY72DeaB&(sq$T~Cq4ELkuLKw82SfYcrX_gY{RY?ov#3ePsyLA+THZ?)Gj$fZ zDta@NY<2<66M&_pb~{9TNjVZ+qh=Pzb317N~ zdkI`ct`Bk4s*nfun$3%d8X(K{P<;gG(!Vbc`cSo~^vKJGdv%3zOTsps6#->+4M0yXDHIdC~$<!%5&0iZuszFH|nAxY!yZs3oh1HYO zm_Puq@;DYikCq3DeU60_L-%GX^ia114uRkXDM<*nrPf;F0}|oBBaIhqyKD}haQ@ap zuCJAiE1m9SJi;~2JqsxhL|w;#e60|LfVjq^=qofIZ(AVZRPpj{68>=P!8-?SQWTNL zIEvbXef+;;eT0E6yaoWUT=OBC+A+_-nM^xIRGY?Y%dLJ~&V*x?QyW~{sL@#r_U5He zAA8+YK8WQn5zYjU0MR~87C5Q`OGn?n{gUA@dYSd zgnkHF8_;srtTx-D0wG)X6|oV^kCklg40>@C4qPqvQRki>|*^7G_J(L zz8H%ysN}e0DwWEWPpRdBM^#Ij@LBHIp<=5r*`(U1xH9NkF{k;9J{J1u?v|9mgpsr4FjaejSXc7KFCW05DRd`$7DZLPlfxFhL2xbv=X}Ul5P;STZ6?jY!dwWd2;^@IPHK z|9D$m35nL2d^*f@@=3aj%q;~R3-kz?p5L?z{gql8v91qN+V+Pa(8(762*pO>?Q>cp zicmx^Hy)Cs)yZniC^tFJGWT17St_ScGMX zEO9@M&NlP81KDsFtsbS^WC8wxB%pJy?-5O5+s{QLh*HG<0*C#{DoqALk<4fg5YQ}k z@YIJk(x1hEz^C`3zULiOg)NCF_^v$^c_ysA^nDKJ9)uP=vj9*l*J@SNwl?icX(cX{s=H0qW@f()m9L9|FV&Yhyh#k4en;WK+(_A>*T`d{ zIJGligSVhgW=EU32UZ(&uW-->Uj`c%us2L?WG element.name === "total_votes").value // We could do [2], but best not to rely on the ordering staying consistent. + const totalVotes = +pollCloseObject.fields.find(element => element.name === "total_votes").value // We could do [2], but best not to rely on the ordering staying consistent. const databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "matrix_option"], {message_id: pollMessageID}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all() @@ -120,26 +124,27 @@ async function closePoll(closeMessage, guild){ })) } - /** @type {{discord_option: string, option_text: string, count: number}[]} */ - const pollResults = db.prepare("SELECT discord_option, option_text, count(*) as count FROM poll_option INNER JOIN poll_vote USING (message_id, matrix_option) GROUP BY discord_option").all() + /** @type {{matrix_option: string, option_text: string, count: number}[]} */ + const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(pollMessageID) const combinedVotes = pollResults.reduce((a, c) => a + c.count, 0) + const totalVoters = db.prepare("SELECT count(DISTINCT discord_or_matrix_user_id) as count FROM poll_vote WHERE message_id = ?").pluck().get(pollMessageID) if (combinedVotes !== totalVotes) { // This means some votes were cast on Matrix! - const message = await discord.snow.channel.getChannelMessage(closeMessage.channel_id, pollMessageID) - assert(message?.poll?.answers) // Now that we've corrected the vote totals, we can get the results again and post them to Discord! - const topAnswers = pollResults.toSorted() - const unique = topAnswers.length > 1 && topAnswers[0].count === topAnswers[1].count - - let messageString = "📶 Results including Matrix votes\n" - for (const result of pollResults) { - if (result === topAnswers[0] && unique) { - messageString = messageString + `${barChart(result.count/combinedVotes)} **${result.option_text}** (**${result.count}**)\n` - } else { - messageString = messageString + `${barChart(result.count/combinedVotes)} ${result.option_text} (${result.count})\n` - } + const topAnswers = pollResults.toSorted((a, b) => b.count - a.count) + let messageString = "" + for (const option of pollResults) { + const medal = pollComponents.getMedal(topAnswers, option.count) + const countString = `${String(option.count).padStart(String(topAnswers[0].count).length)}` + const votesString = option.count === 1 ? "vote " : "votes" + const label = medal === "🥇" ? `**${option.option_text}**` : option.option_text + messageString += `\`\u200b${countString} ${votesString}\u200b\` ${barChart(option.count/totalVoters)} ${label} ${medal}\n` + } + return { + username: "Total results including Matrix votes", + avatar_url: `${reg.ooye.bridge_origin}/discord/poll-star-avatar.png`, + content: messageString } - await channelWebhook.sendMessageWithWebhook(closeMessage.channel_id, {content: messageString}, closeMessage.thread_id) } } diff --git a/src/d2m/actions/remove-reaction.js b/src/d2m/actions/remove-reaction.js index 0f7eec9..af7fd6a 100644 --- a/src/d2m/actions/remove-reaction.js +++ b/src/d2m/actions/remove-reaction.js @@ -22,7 +22,7 @@ async function removeSomeReactions(data) { if (!row) return const eventReactedTo = from("event_message").join("message_room", "message_id").join("historical_channel_room", "historical_room_index") - .where({message_id: data.message_id, reaction_part: 0}).select("event_id", "room_id").get() + .where({message_id: data.message_id}).and("ORDER BY reaction_part").select("event_id", "room_id").get() if (!eventReactedTo) return // Due to server restrictions, all relations (i.e. reactions) have to be in the same room as the original event. diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 1e227b5..b8b0cda 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -4,7 +4,7 @@ const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") const passthrough = require("../../passthrough") -const { discord, sync, db, select } = passthrough +const { discord, sync, db, select, from} = passthrough /** @type {import("../converters/message-to-event")} */ const messageToEvent = sync.require("../converters/message-to-event") /** @type {import("../../matrix/api")} */ @@ -21,6 +21,8 @@ const createRoom = sync.require("../actions/create-room") const closePoll = sync.require("../actions/close-poll") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") +/** @type {import("../../m2d/actions/channel-webhook")} */ +const channelWebhook = sync.require("../../m2d/actions/channel-webhook") /** * @param {DiscordTypes.GatewayMessageCreateDispatchData} message @@ -33,10 +35,6 @@ async function sendMessage(message, channel, guild, row) { const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get() assert(historicalRoomIndex) - if (message.type === 46) { // This is a poll_result. We might need to send a message to Discord (if there were any Matrix-side votes), regardless of if this message was sent by the bridge or not. - await closePoll.closePoll(message, guild) - } - let senderMxid = null if (dUtils.isWebhookMessage(message)) { const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0 @@ -104,6 +102,20 @@ async function sendMessage(message, channel, guild, row) { })() } + if (message.type === DiscordTypes.MessageType.PollResult) { // We might need to send a message to Discord (if there were any Matrix-side votes). + const detailedResultsMessage = await closePoll.closePoll(message, guild) + if (detailedResultsMessage) { + const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() + const channelID = threadParent ? threadParent : message.channel_id + const threadID = threadParent ? message.channel_id : undefined + const sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) + db.transaction(() => { + db.prepare("UPDATE event_message SET reaction_part = 1 WHERE event_id = ?").run(eventID) + db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, sentResultsMessage.id, 1, 0) // part = 1, reaction_part = 0 + })() + } + } + eventIDs.push(eventID) } diff --git a/src/discord/interactions/poll.js b/src/discord/interactions/poll.js new file mode 100644 index 0000000..94ecb4c --- /dev/null +++ b/src/discord/interactions/poll.js @@ -0,0 +1,150 @@ +// @ts-check + +const DiscordTypes = require("discord-api-types/v10") +const {discord, sync, select, from, db} = require("../../passthrough") +const assert = require("assert/strict") +const {id: botID} = require("../../../addbot") +const {InteractionMethods} = require("snowtransfer") + +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") +/** @type {import("../../m2d/converters/poll-components")} */ +const pollComponents = sync.require("../../m2d/converters/poll-components") +/** @type {import("../../d2m/actions/add-or-remove-vote")} */ +const vote = sync.require("../../d2m/actions/add-or-remove-vote") + +/** + * @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction + * @param {{api: typeof api}} di + * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} + */ +async function* _interact({data, message, member, user}, {api}) { + if (!member?.user) return + const userID = member.user.id + + const pollRow = select("poll", ["question_text", "max_selections"], {message_id: message.id}).get() + if (!pollRow) return + + // Definitely supposed to be a poll button click. We can use assertions now. + + const matrixPollEvent = select("event_message", "event_id", {message_id: message.id}).pluck().get() + assert(matrixPollEvent) + + const maxSelections = pollRow.max_selections + const alreadySelected = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: message.id}).pluck().all() + + // Show modal (if no capacity or if requested) + if (data.custom_id === "POLL_VOTE" || (maxSelections > 1 && alreadySelected.length === maxSelections)) { + const options = select("poll_option", ["matrix_option", "option_text", "seq"], {message_id: message.id}, "ORDER BY seq").all().map(option => ({ + value: option.matrix_option, + label: option.option_text, + default: alreadySelected.includes(option.matrix_option) + })) + const checkboxGroupExtras = maxSelections === 1 && options.length > 1 ? {} : { + type: 22, // DiscordTypes.ComponentType.CheckboxGroup + min_values: 0, + max_values: maxSelections + } + return yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.Modal, + data: { + custom_id: "POLL_MODAL", + title: "Poll", + components: [{ + type: DiscordTypes.ComponentType.TextDisplay, + content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}` + }, { + type: DiscordTypes.ComponentType.Label, + label: pollRow.question_text, + component: /* { + type: 21, // DiscordTypes.ComponentType.RadioGroup + custom_id: "POLL_MODAL_SELECTION", + options, + required: false, + ...checkboxGroupExtras + } */ + { + type: DiscordTypes.ComponentType.StringSelect, + custom_id: "POLL_MODAL_SELECTION", + options, + required: false, + min_values: 0, + max_values: maxSelections, + } + }] + } + }} + } + + if (data.custom_id === "POLL_MODAL") { + // Clicked options via modal + /** @type {DiscordTypes.APIMessageStringSelectInteractionData} */ // @ts-ignore - close enough to the real thing + const component = data.components[1].component + assert.equal(component.custom_id, "POLL_MODAL_SELECTION") + + // Replace votes with selection + db.transaction(() => { + db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID) + for (const option of component.values) { + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, option) + } + })() + + // Update counts on message + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.UpdateMessage, + data: pollComponents.getPollComponentsFromDatabase(message.id) + }} + + // Sync changes to Matrix + await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent) + } else { + // Clicked buttons on message + const optionPrefix = "POLL_OPTION#" // we use a prefix to prevent someone from sending a Matrix poll that intentionally collides with other elements of the embed + const matrixOption = select("poll_option", "matrix_option", {matrix_option: data.custom_id.substring(optionPrefix.length), message_id: message.id}).pluck().get() + assert(matrixOption) + + // Remove a vote + if (alreadySelected.includes(matrixOption)) { + db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(userID, message.id, matrixOption) + } + // Replace votes (if only one selection is allowed) + else if (maxSelections === 1 && alreadySelected.length === 1) { + db.transaction(() => { + db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID) + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption) + })() + } + // Add a vote (if capacity) + else if (alreadySelected.length < maxSelections) { + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption) + } + + // Update counts on message + yield {createInteractionResponse: { + type: DiscordTypes.InteractionResponseType.UpdateMessage, + data: pollComponents.getPollComponentsFromDatabase(message.id) + }} + + // Sync changes to Matrix + await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent) + } +} + +/* c8 ignore start */ + +/** @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction */ +async function interact(interaction) { + for await (const response of _interact(interaction, {api})) { + if (response.createInteractionResponse) { + await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) + } else if (response.editOriginalInteractionResponse) { + await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse) + } + } +} + +module.exports.interact = interact +module.exports._interact = _interact diff --git a/src/discord/interactions/vote.js b/src/discord/interactions/vote.js deleted file mode 100644 index 91ba97e..0000000 --- a/src/discord/interactions/vote.js +++ /dev/null @@ -1,95 +0,0 @@ -// @ts-check - -const DiscordTypes = require("discord-api-types/v10") -const {discord, sync, select, from, db} = require("../../passthrough") -const assert = require("assert/strict") -const {id: botID} = require("../../../addbot") -const {InteractionMethods} = require("snowtransfer") - -/** @type {import("../../matrix/api")} */ -const api = sync.require("../../matrix/api") -/** @type {import("../../matrix/utils")} */ -const utils = sync.require("../../matrix/utils") -/** @type {import("../../m2d/converters/poll-components")} */ -const pollComponents = sync.require("../../m2d/converters/poll-components") -/** @type {import("../../d2m/actions/add-or-remove-vote")} */ -const vote = sync.require("../../d2m/actions/add-or-remove-vote") - -/** - * @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction - * @param {{api: typeof api}} di - * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters[2]}>} - */ -async function* _interact({data, message, member, user}, {api}) { - const discordUser = member?.user || user - assert(discordUser) - const userID = discordUser.id - - const matrixPollEvent = select("event_message", "event_id", {message_id: message.id}).pluck().get() - assert(matrixPollEvent) - - const matrixOption = select("poll_option", "matrix_option", {discord_option: data.custom_id, message_id: message.id}).pluck().get() - assert(matrixOption) - - const pollRow = select("poll", ["question_text", "max_selections"], {message_id: message.id}).get() - assert(pollRow) - const maxSelections = pollRow.max_selections - const alreadySelected = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: message.id}).pluck().all() - - // Show modal (if no capacity) - if (maxSelections > 1 && alreadySelected.length === maxSelections) { - // TODO: show modal - return - } - - // We are going to do a server operation so need to show loading state - yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.DeferredMessageUpdate, - }} - - // Remove a vote - if (alreadySelected.includes(data.custom_id)) { - db.prepare("DELETE FROM poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id) - } - // Replace votes (if only one selection is allowed) - else if (maxSelections === 1 && alreadySelected.length === 1) { - db.transaction(() => { - db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID) - db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id) - })() - } - // Add a vote (if capacity) - else if (alreadySelected.length < maxSelections) { - db.transaction(() => { - db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID) - db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, data.custom_id) - })() - } - - // Sync changes to Matrix - await vote.sendVotes(discordUser, message.channel_id, message.id, matrixPollEvent) - - // Check the poll is not closed (it may have been closed by sendVotes if we discover we can't send) - const isClosed = select("poll", "is_closed", {message_id: message.id}).pluck().get() - - /** @type {{matrix_option: string, option_text: string, count: number}[]} */ - const pollResults = db.prepare("SELECT matrix_option, option_text, count(*) as count FROM poll_option INNER JOIN poll_vote USING (message_id, matrix_option) GROUP BY matrix_option").all() - return yield {createInteractionResponse: { - type: DiscordTypes.InteractionResponseType.UpdateMessage, - data: pollComponents.getPollComponents(!!isClosed, maxSelections, pollRow.question_text, pollResults) - }} -} - -/* c8 ignore start */ - -/** @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction */ -async function interact(interaction) { - for await (const response of _interact(interaction, {api})) { - if (response.createInteractionResponse) { - await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse) - } - } -} - -module.exports.interact = interact -module.exports._interact = _interact diff --git a/src/discord/register-interactions.js b/src/discord/register-interactions.js index 0c5a0b0..63b04b0 100644 --- a/src/discord/register-interactions.js +++ b/src/discord/register-interactions.js @@ -9,6 +9,7 @@ const invite = sync.require("./interactions/invite.js") const permissions = sync.require("./interactions/permissions.js") const reactions = sync.require("./interactions/reactions.js") const privacy = sync.require("./interactions/privacy.js") +const poll = sync.require("./interactions/poll.js") // User must have EVERY permission in default_member_permissions to be able to use the command @@ -68,25 +69,36 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{ console.error(e) }) +/** @param {DiscordTypes.APIInteraction} interaction */ async function dispatchInteraction(interaction) { - const interactionId = interaction.data.custom_id || interaction.data.name + const interactionId = interaction.data?.["custom_id"] || interaction.data?.["name"] try { - if (interactionId === "Matrix info") { - await matrixInfo.interact(interaction) - } else if (interactionId === "invite") { - await invite.interact(interaction) - } else if (interactionId === "invite_channel") { - await invite.interactButton(interaction) - } else if (interactionId === "Permissions") { - await permissions.interact(interaction) - } else if (interactionId === "permissions_edit") { - await permissions.interactEdit(interaction) - } else if (interactionId === "Reactions") { - await reactions.interact(interaction) - } else if (interactionId === "privacy") { - await privacy.interact(interaction) + if (interaction.type === DiscordTypes.InteractionType.MessageComponent || interaction.type === DiscordTypes.InteractionType.ModalSubmit) { + // All we get is custom_id, don't know which context the button was clicked in. + // So we namespace these ourselves in the custom_id. Currently the only existing namespace is POLL_. + if (interaction.data.custom_id.startsWith("POLL_")) { + await poll.interact(interaction) + } else { + throw new Error(`Unknown message component ${interaction.data.custom_id}`) + } } else { - throw new Error(`Unknown interaction ${interactionId}`) + if (interactionId === "Matrix info") { + await matrixInfo.interact(interaction) + } else if (interactionId === "invite") { + await invite.interact(interaction) + } else if (interactionId === "invite_channel") { + await invite.interactButton(interaction) + } else if (interactionId === "Permissions") { + await permissions.interact(interaction) + } else if (interactionId === "permissions_edit") { + await permissions.interactEdit(interaction) + } else if (interactionId === "Reactions") { + await reactions.interact(interaction) + } else if (interactionId === "privacy") { + await privacy.interact(interaction) + } else { + throw new Error(`Unknown interaction ${interactionId}`) + } } } catch (e) { let stackLines = null @@ -97,12 +109,16 @@ async function dispatchInteraction(interaction) { stackLines = stackLines.slice(0, cloudstormLine - 2) } } - await discord.snow.interaction.createFollowupMessage(id, interaction.token, { - content: `Interaction failed: **${interactionId}**` - + `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\`` - + `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``, - flags: DiscordTypes.MessageFlags.Ephemeral - }) + try { + await discord.snow.interaction.createFollowupMessage(id, interaction.token, { + content: `Interaction failed: **${interactionId}**` + + `\nError trace:\n\`\`\`\n${stackLines.join("\n")}\`\`\`` + + `Interaction data:\n\`\`\`\n${JSON.stringify(interaction.data, null, 2)}\`\`\``, + flags: DiscordTypes.MessageFlags.Ephemeral + }) + } catch (_) { + throw e + } } } diff --git a/src/m2d/actions/send-event.js b/src/m2d/actions/send-event.js index f18385f..00557a1 100644 --- a/src/m2d/actions/send-event.js +++ b/src/m2d/actions/send-event.js @@ -14,6 +14,8 @@ const channelWebhook = sync.require("./channel-webhook") const eventToMessage = sync.require("../converters/event-to-message") /** @type {import("../../matrix/api")}) */ const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/utils")}) */ +const utils = sync.require("../../matrix/utils") /** @type {import("../../d2m/actions/register-user")} */ const registerUser = sync.require("../../d2m/actions/register-user") /** @type {import("../../d2m/actions/edit-message")} */ @@ -59,7 +61,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 | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} 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 | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} 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 @@ -79,7 +81,19 @@ 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 - let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, channel, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji}) + const di = {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji} + + if (event.type === "org.matrix.msc3381.poll.end") { + // Validity already checked by dispatcher. Poll is definitely closed. Update it and DI necessary data. + const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get() + assert(messageID) + db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(messageID) + di.pollEnd = { + messageID + } + } + + let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, channel, di) messagesToEdit = await Promise.all(messagesToEdit.map(async e => { e.message = await resolvePendingFiles(e.message) @@ -105,8 +119,16 @@ async function sendEvent(event) { await channelWebhook.deleteMessageWithWebhook(channelID, id, threadID) } + // Poll ends do not follow the normal laws of parts. + // Normally when editing and adding extra parts, the new parts should always have part = 1 and reaction_part = 1 (because the existing part, which is being edited, already took 0). + // However for polls, the edit is actually for a different message. The message being sent is truly a new message, and should have parts = 0. + // So in that case, just override these variables to have the right values. + if (di.pollEnd) { + eventPart = 0 + } + for (const message of messagesToSend) { - const reactionPart = messagesToEdit.length === 0 && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1 + const reactionPart = (messagesToEdit.length === 0 || di.pollEnd) && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1 const messageResponse = await channelWebhook.sendMessageWithWebhook(channelID, message, threadID) db.transaction(() => { db.prepare("INSERT INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(messageResponse.id, historicalRoomIndex) diff --git a/src/m2d/actions/setup-emojis.js b/src/m2d/actions/setup-emojis.js index 1be1d2d..4664135 100644 --- a/src/m2d/actions/setup-emojis.js +++ b/src/m2d/actions/setup-emojis.js @@ -9,7 +9,7 @@ async function setupEmojis() { const {id} = require("../../../addbot") const {discord, db} = passthrough const emojis = await discord.snow.assets.getAppEmojis(id) - for (const name of ["L1", "L2"]) { + for (const name of ["L1", "L2", "poll_win"]) { const existing = emojis.items.find(e => e.name === name) if (existing) { db.prepare("REPLACE INTO auto_emoji (name, emoji_id) VALUES (?, ?)").run(existing.name, existing.id) diff --git a/src/m2d/actions/vote.js b/src/m2d/actions/vote.js index 5bb5cd4..926b957 100644 --- a/src/m2d/actions/vote.js +++ b/src/m2d/actions/vote.js @@ -8,17 +8,35 @@ const crypto = require("crypto") const passthrough = require("../../passthrough") const {sync, discord, db, select} = passthrough +const {reg} = require("../../matrix/read-registration") +/** @type {import("../../matrix/api")} */ +const api = sync.require("../../matrix/api") +/** @type {import("../../matrix/utils")} */ +const utils = sync.require("../../matrix/utils") +/** @type {import("../converters/poll-components")} */ +const pollComponents = sync.require("../converters/poll-components") +/** @type {import("./channel-webhook")} */ +const webhook = sync.require("./channel-webhook") + /** @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event */ async function updateVote(event) { - - const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start"}).pluck().get() + const messageRow = select("event_message", ["message_id", "source"], {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start"}).get() + const messageID = messageRow?.message_id if (!messageID) return // Nothing can be done if the parent message was never bridged. - db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(event.sender, messageID) // Clear all the existing votes, since this overwrites. Technically we could check and only overwrite the changes, but the complexity isn't worth it. + db.transaction(() => { + db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(event.sender, messageID) // Clear all the existing votes, since this overwrites. + for (const answer of event.content["org.matrix.msc3381.poll.response"].answers) { + db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(event.sender, messageID, answer) + } + })() - event.content["org.matrix.msc3381.poll.response"].answers.map(answer=>{ - db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(event.sender, messageID, answer) - }) + // If poll was started on Matrix, the Discord version is using components, so we can update that to the current status + if (messageRow.source === 0) { + const channelID = select("channel_room", "channel_id", {room_id: event.room_id}).pluck().get() + assert(channelID) + await webhook.editMessageWithWebhook(channelID, messageID, pollComponents.getPollComponentsFromDatabase(messageID)) + } } -module.exports.updateVote = updateVote +module.exports.updateVote = updateVote \ No newline at end of file diff --git a/src/m2d/converters/event-to-message.js b/src/m2d/converters/event-to-message.js index ab53d08..b03de95 100644 --- a/src/m2d/converters/event-to-message.js +++ b/src/m2d/converters/event-to-message.js @@ -519,10 +519,10 @@ 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 | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} 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 | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} 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 + * @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise, pollEnd?: {messageID: string}}} di simple-as-nails dependency injection for the matrix API */ async function eventToMessage(event, guild, channel, di) { let displayName = event.sender @@ -553,8 +553,8 @@ async function eventToMessage(event, guild, channel, di) { const pendingFiles = [] /** @type {DiscordTypes.APIUser[]} */ const ensureJoined = [] - /** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody?} */ - let pollMessage = null + /** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody[]} */ + const pollMessages = [] // 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 @@ -644,7 +644,17 @@ async function eventToMessage(event, guild, channel, di) { count: 0 // no votes initially })) content = "" - pollMessage = pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions) + pollMessages.push(pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions)) + + } else if (event.type === "org.matrix.msc3381.poll.end") { + assert(di.pollEnd) + content = "" + messageIDsToEdit.push(di.pollEnd.messageID) + pollMessages.push(pollComponents.getPollComponentsFromDatabase(di.pollEnd.messageID)) + pollMessages.push({ + ...await pollComponents.getPollEndMessageFromDatabase(channel.id, di.pollEnd.messageID), + avatar_url: `${reg.ooye.bridge_origin}/discord/poll-star-avatar.png` + }) } 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. @@ -1001,12 +1011,14 @@ async function eventToMessage(event, guild, channel, di) { messages[0].pendingFiles = pendingFiles } - if (pollMessage) { - messages.push({ - ...pollMessage, - username: displayNameShortened, - avatar_url: avatarURL - }) + if (pollMessages.length) { + for (const pollMessage of pollMessages) { + messages.push({ + username: displayNameShortened, + avatar_url: avatarURL, + ...pollMessage, + }) + } } const messagesToEdit = [] diff --git a/src/m2d/converters/poll-components.js b/src/m2d/converters/poll-components.js index 8aafa2b..a8233e0 100644 --- a/src/m2d/converters/poll-components.js +++ b/src/m2d/converters/poll-components.js @@ -1,6 +1,28 @@ // @ts-check +const assert = require("assert").strict const DiscordTypes = require("discord-api-types/v10") +const {sync, db, discord, select, from} = require("../../passthrough") + +/** @type {import("../actions/setup-emojis")} */ +const setupEmojis = sync.require("../actions/setup-emojis") + +/** + * @param {{count: number}[]} topAnswers + * @param {number} count + * @returns {string} + */ +function getMedal(topAnswers, count) { + const winningOrTied = count && topAnswers[0].count === count + const secondOrTied = !winningOrTied && count && topAnswers[1]?.count === count && topAnswers.slice(-1)[0].count !== count + const thirdOrTied = !winningOrTied && !secondOrTied && count && topAnswers[2]?.count === count && topAnswers.slice(-1)[0].count !== count + const medal = + ( winningOrTied ? "🥇" + : secondOrTied ? "🥈" + : thirdOrTied ? "🥉" + : "") + return medal +} /** * @param {boolean} isClosed @@ -11,20 +33,20 @@ function optionsToComponents(isClosed, pollOptions) { const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count) /** @type {DiscordTypes.APIMessageTopLevelComponent[]} */ return pollOptions.map(option => { - const winningOrTied = option.count && topAnswers[0].count === option.count + const medal = getMedal(topAnswers, option.count) return { type: DiscordTypes.ComponentType.Container, components: [{ type: DiscordTypes.ComponentType.Section, components: [{ type: DiscordTypes.ComponentType.TextDisplay, - content: option.option_text + content: medal && isClosed ? `${medal} ${option.option_text}` : option.option_text }], accessory: { type: DiscordTypes.ComponentType.Button, - style: winningOrTied ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary, + style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary, label: option.count.toString(), - custom_id: option.matrix_option, + custom_id: `POLL_OPTION#${option.matrix_option}`, disabled: isClosed } }] @@ -32,6 +54,34 @@ function optionsToComponents(isClosed, pollOptions) { }) } +/** + * @param {number} maxSelections + * @param {number} optionCount + */ +function getMultiSelectString(maxSelections, optionCount) { + if (maxSelections === 1) { + return "Select one answer" + } else if (maxSelections >= optionCount) { + return "Select one or more answers" + } else { + return `Select up to ${maxSelections} answers` + } +} + +/** + * @param {number} maxSelections + * @param {number} optionCount + */ +function getMultiSelectClosedString(maxSelections, optionCount) { + if (maxSelections === 1) { + return "Single choice" + } else if (maxSelections >= optionCount) { + return "Multiple choice" + } else { + return `Multiple choice (up to ${maxSelections})` + } +} + /** * @param {boolean} isClosed * @param {number} maxSelections @@ -40,39 +90,31 @@ function optionsToComponents(isClosed, pollOptions) { * @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody} */ function getPollComponents(isClosed, maxSelections, questionText, pollOptions) { + /** @type {DiscordTypes.APIMessageTopLevelComponent[]} array because it can move around */ + const multiSelectInfoComponent = [{ + type: DiscordTypes.ComponentType.TextDisplay, + content: isClosed ? `-# ${getMultiSelectClosedString(maxSelections, pollOptions.length)}` : `-# ${getMultiSelectString(maxSelections, pollOptions.length)}` + }] /** @type {DiscordTypes.APIMessageTopLevelComponent} */ let headingComponent if (isClosed) { - const multiSelectString = - ( maxSelections === 1 ? "-# ~~Select one answer~~" - : maxSelections >= pollOptions.length ? "-# ~~Select one or more answers~~" - : `-# ~~Select up to ${maxSelections} answers~~`) headingComponent = { // This one is for the poll heading. type: DiscordTypes.ComponentType.Section, components: [ { type: DiscordTypes.ComponentType.TextDisplay, content: `## ${questionText}` - }, - { - type: DiscordTypes.ComponentType.TextDisplay, - content: multiSelectString } ], accessory: { type: DiscordTypes.ComponentType.Button, style: DiscordTypes.ButtonStyle.Secondary, - custom_id: "vote", - label: "Voting closed!", + custom_id: "POLL_VOTE", + label: "Voting closed", disabled: true } } - } - else { - const multiSelectString = - ( maxSelections === 1 ? "-# Select one answer" - : maxSelections >= pollOptions.length ? "-# Select one or more answers" - : `-# Select up to ${maxSelections} answers`) + } else { headingComponent = { // This one is for the poll heading. type: DiscordTypes.ComponentType.Section, components: [ @@ -80,15 +122,13 @@ function getPollComponents(isClosed, maxSelections, questionText, pollOptions) { type: DiscordTypes.ComponentType.TextDisplay, content: `## ${questionText}` }, - { - type: DiscordTypes.ComponentType.TextDisplay, - content: multiSelectString - } + // @ts-ignore + multiSelectInfoComponent.pop() ], accessory: { type: DiscordTypes.ComponentType.Button, style: DiscordTypes.ButtonStyle.Primary, - custom_id: "vote", + custom_id: "POLL_VOTE", label: "Vote!" } } @@ -96,8 +136,92 @@ function getPollComponents(isClosed, maxSelections, questionText, pollOptions) { const optionComponents = optionsToComponents(isClosed, pollOptions) return { flags: DiscordTypes.MessageFlags.IsComponentsV2, - components: [headingComponent, ...optionComponents] + components: [headingComponent, ...optionComponents, ...multiSelectInfoComponent] } } -module.exports.getPollComponents = getPollComponents \ No newline at end of file +/** @param {string} messageID */ +function getPollComponentsFromDatabase(messageID) { + const pollRow = select("poll", ["max_selections", "is_closed", "question_text"], {message_id: messageID}).get() + assert(pollRow) + /** @type {{matrix_option: string, option_text: string, count: number}[]} */ + const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(messageID) + return getPollComponents(!!pollRow.is_closed, pollRow.max_selections, pollRow.question_text, pollResults) +} + +/** + * @param {string} channelID + * @param {string} messageID + * @param {string} questionText + * @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly + * @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody} + */ +function getPollEndMessage(channelID, messageID, questionText, pollOptions) { + const topAnswers = pollOptions.toSorted((a, b) => b.count - a.count) + const totalVotes = pollOptions.reduce((a, c) => a + c.count, 0) + const tied = topAnswers[0].count === topAnswers[1].count + const titleString = `-# The poll **${questionText}** has closed.` + let winnerString = "" + let resultsString = "" + if (totalVotes == 0) { + winnerString = "There was no winner" + } else if (tied) { + winnerString = "It's a draw!" + resultsString = `${Math.round((topAnswers[0].count/totalVotes)*100)}%` + } else { + const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get() + winnerString = `${topAnswers[0].option_text} <:${pollWin?.name}:${pollWin?.emoji_id}>` + resultsString = `Winning answer • ${Math.round((topAnswers[0].count/totalVotes)*100)}%` + } + // @ts-ignore + const guildID = discord.channels.get(channelID).guild_id + let mainContent = `**${winnerString}**` + if (resultsString) { + mainContent += `\n-# ${resultsString}` + } + return { + flags: DiscordTypes.MessageFlags.IsComponentsV2, + components: [{ + type: DiscordTypes.ComponentType.TextDisplay, + content: titleString + }, { + type: DiscordTypes.ComponentType.Container, + components: [{ + type: DiscordTypes.ComponentType.Section, + components: [{ + type: DiscordTypes.ComponentType.TextDisplay, + content: `**${winnerString}**\n-# ${resultsString}` + }], + accessory: { + type: DiscordTypes.ComponentType.Button, + style: DiscordTypes.ButtonStyle.Link, + url: `https://discord.com/channels/${guildID}/${channelID}/${messageID}`, + label: "View Poll" + } + }] + }] + } +} + +/** + * @param {string} channelID + * @param {string} messageID + */ +async function getPollEndMessageFromDatabase(channelID, messageID) { + const pollWin = select("auto_emoji", ["name", "emoji_id"], {name: "poll_win"}).get() + if (!pollWin) { + await setupEmojis.setupEmojis() + } + + const pollRow = select("poll", ["max_selections", "question_text"], {message_id: messageID}).get() + assert(pollRow) + /** @type {{matrix_option: string, option_text: string, count: number}[]} */ + const pollResults = db.prepare("SELECT matrix_option, option_text, seq, count(discord_or_matrix_user_id) as count FROM poll_option LEFT JOIN poll_vote USING (message_id, matrix_option) WHERE message_id = ? GROUP BY matrix_option ORDER BY seq").all(messageID) + return getPollEndMessage(channelID, messageID, pollRow.question_text, pollResults) +} + +module.exports.getMultiSelectString = getMultiSelectString +module.exports.getPollComponents = getPollComponents +module.exports.getPollComponentsFromDatabase = getPollComponentsFromDatabase +module.exports.getPollEndMessageFromDatabase = getPollEndMessageFromDatabase +module.exports.getMedal = getMedal diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index 424ad58..eb1fba0 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -237,6 +237,35 @@ sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("or 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. + await api.ackEvent(event) +})) + +sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.end", guard("org.matrix.msc3381.poll.end", +/** + * @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event it is a org.matrix.msc3381.poll.end because that's what this listener is filtering for + */ +async event => { + if (utils.eventSenderIsFromDiscord(event.sender)) return + const pollEventID = event.content["m.relates_to"]?.event_id + if (!pollEventID) return // Validity check + const messageID = select("event_message", "message_id", {event_id: pollEventID, event_type: "org.matrix.msc3381.poll.start", source: 0}).pluck().get() + if (!messageID) return // Nothing can be done if the parent message was never bridged. Also, Discord-native polls cannot be ended by others, so this only works for polls started on Matrix. + try { + var pollEvent = await api.getEvent(event.room_id, pollEventID) // Poll start event must exist for this to be valid + } catch (e) { + return + } + + // According to the rules, the poll end is only allowed if it was sent by the poll starter, or by someone with redact powers. + if (pollEvent.sender !== event.sender) { + const {powerLevels, powers: {[event.sender]: enderPower}} = await utils.getEffectivePower(event.room_id, [event.sender], api) + if (enderPower < (powerLevels.redact ?? 50)) { + return // Not allowed + } + } + + const messageResponses = await sendEvent.sendEvent(event) + await api.ackEvent(event) })) sync.addTemporaryListener(as, "type:m.reaction", guard("m.reaction", diff --git a/src/types.d.ts b/src/types.d.ts index f18116e..951d93c 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -302,6 +302,18 @@ export namespace Event { export type Outer_Org_Matrix_Msc3381_Poll_Response = Outer & {type: "org.matrix.msc3381.poll.response"} + export type Org_Matrix_Msc3381_Poll_End = { + "org.matrix.msc3381.poll.end": {}, + "org.matrix.msc1767.text": string, + body: string, + "m.relates_to": { + rel_type: string + event_id: string + } + } + + export type Outer_Org_Matrix_Msc3381_Poll_End = Outer & {type: "org.matrix.msc3381.poll.end"} + export type M_Room_Member = { membership: string displayname?: string diff --git a/src/web/server.js b/src/web/server.js index 3cb3060..9d9f5a3 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -69,3 +69,8 @@ as.router.get("/icon.png", defineEventHandler(event => { handleCacheHeaders(event, {maxAge: 86400}) return fs.promises.readFile(join(__dirname, "../../docs/img/icon.png")) })) + +as.router.get("/discord/poll-star-avatar.png", defineEventHandler(event => { + handleCacheHeaders(event, {maxAge: 86400}) + return fs.promises.readFile(join(__dirname, "../../docs/img/poll-star-avatar.png")) +})) From f3ae7ba7920ef17ed1929f2ab7f6016c3a09ecc7 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 26 Jan 2026 02:35:58 +1300 Subject: [PATCH 5/7] Rename poll files a bit better --- src/d2m/actions/{close-poll.js => poll-end.js} | 8 ++++---- .../actions/{add-or-remove-vote.js => poll-vote.js} | 0 src/d2m/actions/send-message.js | 6 +++--- src/d2m/event-dispatcher.js | 4 ++-- src/discord/interactions/poll.js | 10 +++++----- 5 files changed, 14 insertions(+), 14 deletions(-) rename src/d2m/actions/{close-poll.js => poll-end.js} (97%) rename src/d2m/actions/{add-or-remove-vote.js => poll-vote.js} (100%) diff --git a/src/d2m/actions/close-poll.js b/src/d2m/actions/poll-end.js similarity index 97% rename from src/d2m/actions/close-poll.js rename to src/d2m/actions/poll-end.js index d97b7b4..55a4aec 100644 --- a/src/d2m/actions/close-poll.js +++ b/src/d2m/actions/poll-end.js @@ -13,8 +13,8 @@ const api = sync.require("../../matrix/api") 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("./poll-vote")} */ +const vote = sync.require("../actions/poll-vote") /** @type {import("../../m2d/converters/poll-components")} */ const pollComponents = sync.require("../../m2d/converters/poll-components") /** @type {import("../../m2d/actions/channel-webhook")} */ @@ -64,7 +64,7 @@ async function getAllVotesOnAnswer(channelID, messageID, answerID){ * @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage * @param {DiscordTypes.APIGuild} guild */ -async function closePoll(closeMessage, guild){ +async function endPoll(closeMessage, guild){ const pollCloseObject = closeMessage.embeds[0] const pollMessageID = closeMessage.message_reference.message_id @@ -148,4 +148,4 @@ async function closePoll(closeMessage, guild){ } } -module.exports.closePoll = closePoll +module.exports.endPoll = endPoll diff --git a/src/d2m/actions/add-or-remove-vote.js b/src/d2m/actions/poll-vote.js similarity index 100% rename from src/d2m/actions/add-or-remove-vote.js rename to src/d2m/actions/poll-vote.js diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index b8b0cda..3fb8d20 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -17,8 +17,8 @@ const registerPkUser = sync.require("./register-pk-user") const registerWebhookUser = sync.require("./register-webhook-user") /** @type {import("../actions/create-room")} */ const createRoom = sync.require("../actions/create-room") -/** @type {import("../actions/close-poll")} */ -const closePoll = sync.require("../actions/close-poll") +/** @type {import("../actions/poll-end")} */ +const pollEnd = sync.require("../actions/poll-end") /** @type {import("../../discord/utils")} */ const dUtils = sync.require("../../discord/utils") /** @type {import("../../m2d/actions/channel-webhook")} */ @@ -103,7 +103,7 @@ async function sendMessage(message, channel, guild, row) { } if (message.type === DiscordTypes.MessageType.PollResult) { // We might need to send a message to Discord (if there were any Matrix-side votes). - const detailedResultsMessage = await closePoll.closePoll(message, guild) + const detailedResultsMessage = await pollEnd.endPoll(message, guild) if (detailedResultsMessage) { const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() const channelID = threadParent ? threadParent : message.channel_id diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index 599db49..e8c20a6 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -32,8 +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("./actions/poll-vote")} */ +const vote = sync.require("./actions/poll-vote") /** @type {import("../m2d/event-dispatcher")} */ const matrixEventDispatcher = sync.require("../m2d/event-dispatcher") /** @type {import("../discord/interactions/matrix-info")} */ diff --git a/src/discord/interactions/poll.js b/src/discord/interactions/poll.js index 94ecb4c..0a4689d 100644 --- a/src/discord/interactions/poll.js +++ b/src/discord/interactions/poll.js @@ -12,8 +12,8 @@ const api = sync.require("../../matrix/api") const utils = sync.require("../../matrix/utils") /** @type {import("../../m2d/converters/poll-components")} */ const pollComponents = sync.require("../../m2d/converters/poll-components") -/** @type {import("../../d2m/actions/add-or-remove-vote")} */ -const vote = sync.require("../../d2m/actions/add-or-remove-vote") +/** @type {import("../../d2m/actions/poll-vote")} */ +const vote = sync.require("../../d2m/actions/poll-vote") /** * @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction @@ -34,7 +34,7 @@ async function* _interact({data, message, member, user}, {api}) { const maxSelections = pollRow.max_selections const alreadySelected = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: message.id}).pluck().all() - + // Show modal (if no capacity or if requested) if (data.custom_id === "POLL_VOTE" || (maxSelections > 1 && alreadySelected.length === maxSelections)) { const options = select("poll_option", ["matrix_option", "option_text", "seq"], {message_id: message.id}, "ORDER BY seq").all().map(option => ({ @@ -91,7 +91,7 @@ async function* _interact({data, message, member, user}, {api}) { db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, option) } })() - + // Update counts on message yield {createInteractionResponse: { type: DiscordTypes.InteractionResponseType.UpdateMessage, @@ -105,7 +105,7 @@ async function* _interact({data, message, member, user}, {api}) { const optionPrefix = "POLL_OPTION#" // we use a prefix to prevent someone from sending a Matrix poll that intentionally collides with other elements of the embed const matrixOption = select("poll_option", "matrix_option", {matrix_option: data.custom_id.substring(optionPrefix.length), message_id: message.id}).pluck().get() assert(matrixOption) - + // Remove a vote if (alreadySelected.includes(matrixOption)) { db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(userID, message.id, matrixOption) From 0c781f9b72be4d61b2f2c7c6f3e0228c484adf6b Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Mon, 26 Jan 2026 20:51:30 +1300 Subject: [PATCH 6/7] Fixes to vote counting --- package-lock.json | 8 +++--- package.json | 2 +- src/d2m/actions/poll-end.js | 48 +++++++++++++-------------------- src/d2m/actions/poll-vote.js | 23 +++++++++------- src/d2m/actions/send-message.js | 29 +++++++++++--------- src/d2m/event-dispatcher.js | 10 +++++-- 6 files changed, 63 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index eeccc7c..dd0cbbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.17.0", + "snowtransfer": "^0.17.1", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", @@ -2727,9 +2727,9 @@ } }, "node_modules/snowtransfer": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.0.tgz", - "integrity": "sha512-H6Avpsco+HlVIkN+MbX34Q7+9g9Wci0wZQwGsvfw20VqEb7jnnk73iUcWytNMYtKZ72Ud58n6cFnQ3apTEamxw==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.1.tgz", + "integrity": "sha512-WSXj055EJhzzfD7B3oHVyRTxkqFCaxcVhwKY6B3NkBSHRyM6wHxZLq6VbFYhopUg+lMtd7S1ZO8JM+Ut+js2iA==", "license": "MIT", "dependencies": { "discord-api-types": "^0.38.37" diff --git a/package.json b/package.json index 7919960..1cad178 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "lru-cache": "^11.0.2", "prettier-bytes": "^1.0.4", "sharp": "^0.34.5", - "snowtransfer": "^0.17.0", + "snowtransfer": "^0.17.1", "stream-mime-type": "^1.0.2", "try-to-catch": "^3.0.1", "uqr": "^0.1.2", diff --git a/src/d2m/actions/poll-end.js b/src/d2m/actions/poll-end.js index 55a4aec..936dedf 100644 --- a/src/d2m/actions/poll-end.js +++ b/src/d2m/actions/poll-end.js @@ -7,18 +7,10 @@ const {isDeepStrictEqual} = require("util") const passthrough = require("../../passthrough") const {discord, sync, db, select, from} = passthrough const {reg} = require("../../matrix/read-registration") -/** @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("./poll-vote")} */ const vote = sync.require("../actions/poll-vote") /** @type {import("../../m2d/converters/poll-components")} */ const pollComponents = sync.require("../../m2d/converters/poll-components") -/** @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 @@ -28,7 +20,7 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") /** * @param {number} percent */ -function barChart(percent){ +function barChart(percent) { const width = 12 const bars = Math.floor(percent*width) return "█".repeat(bars) + "▒".repeat(width-bars) @@ -40,31 +32,27 @@ function barChart(percent){ * @param {string} answerID * @returns {Promise} */ -async function getAllVotesOnAnswer(channelID, messageID, answerID){ +async function getAllVotesOnAnswer(channelID, messageID, answerID) { const limit = 100 /** @type {DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]} */ let voteUsers = [] let after = undefined - while (!voteUsers.length || after) { + while (true) { const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit}) - if (curVotes.users.length === 0) { // Reached the end. - break - } + voteUsers = voteUsers.concat(curVotes.users) if (curVotes.users.length >= limit) { // Loop again for the next page. // @ts-ignore - stupid after = curVotes.users.at(-1).id + } else { // Reached the end. + return voteUsers } - voteUsers = voteUsers.concat(curVotes.users) } - return voteUsers } - /** * @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage - * @param {DiscordTypes.APIGuild} guild */ -async function endPoll(closeMessage, guild){ +async function endPoll(closeMessage) { const pollCloseObject = closeMessage.embeds[0] const pollMessageID = closeMessage.message_reference.message_id @@ -91,16 +79,16 @@ async function endPoll(closeMessage, guild){ for (const discordPollOption of discordPollOptions) { const optionUsers = await getAllVotesOnAnswer(closeMessage.channel_id, pollMessageID, discordPollOption) // Array of user IDs who voted for the option we're testing. - optionUsers.map(user => { + for (const user of optionUsers) { const userLocation = updatedAnswers.findIndex(answer => answer.user.id === user.id) const matrixOption = select("poll_option", "matrix_option", {message_id: pollMessageID, discord_option: discordPollOption}).pluck().get() assert(matrixOption) - if (userLocation === -1){ // We haven't seen this user yet, so we need to add them. + if (userLocation === -1) { // We haven't seen this user yet, so we need to add them. updatedAnswers.push({user, matrixOptionVotes: [matrixOption]}) // 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].matrixOptionVotes.push(matrixOption) } - }) + } } // Check for inconsistencies in what was cached in database vs final confirmed poll answers @@ -109,18 +97,20 @@ async function endPoll(closeMessage, guild){ await Promise.all(updatedAnswers.map(async answer => { voteUsers = voteUsers.filter(item => item !== answer.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. const cachedAnswers = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: answer.user.id, message_id: pollMessageID}).pluck().all() - if (!isDeepStrictEqual(new Set(cachedAnswers), new Set(answer.matrixOptionVotes))){ - db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(answer.user.id, pollMessageID) // Delete existing stored votes. - for (const matrixOption of answer.matrixOptionVotes) { - db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(answer.user.id, pollMessageID, matrixOption) - } - await vote.debounceSendVotes({user_id: answer.user.id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID) // Fake answer ID, not actually needed (but we're sorta faking the datatype to call this function). + if (!isDeepStrictEqual(new Set(cachedAnswers), new Set(answer.matrixOptionVotes))) { + db.transaction(() => { + db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(answer.user.id, pollMessageID) // Delete existing stored votes. + for (const matrixOption of answer.matrixOptionVotes) { + db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(answer.user.id, pollMessageID, matrixOption) + } + })() + await vote.sendVotes(answer.user, closeMessage.channel_id, pollMessageID, pollEventID) } })) await Promise.all(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, pollMessageID) - await vote.debounceSendVotes({user_id: user_id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID) + await vote.sendVotes(user_id, closeMessage.channel_id, pollMessageID, pollEventID) })) } diff --git a/src/d2m/actions/poll-vote.js b/src/d2m/actions/poll-vote.js index e9fee36..85a223d 100644 --- a/src/d2m/actions/poll-vote.js +++ b/src/d2m/actions/poll-vote.js @@ -11,15 +11,13 @@ const {discord, sync, db, select, from} = passthrough 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 inFlightPollSema = new Semaphore() /** * @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data */ -async function addVote(data){ +async function addVote(data) { const pollEventID = 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 (!pollEventID) return // Nothing can be done if the parent message was never bridged. @@ -32,7 +30,7 @@ async function addVote(data){ /** * @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} data */ -async function removeVote(data){ +async function removeVote(data) { const pollEventID = 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 (!pollEventID) return @@ -59,12 +57,12 @@ async function debounceSendVotes(data, pollEventID) { } /** - * @param {DiscordTypes.APIUser} user + * @param {DiscordTypes.APIUser | string} userOrID * @param {string} channelID * @param {string} pollMessageID * @param {string} pollEventID */ -async function sendVotes(user, channelID, pollMessageID, pollEventID) { +async function sendVotes(userOrID, channelID, pollMessageID, pollEventID) { const latestRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get() const matchingRoomID = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: pollMessageID}).pluck("room_id").get() if (!latestRoomID || latestRoomID !== matchingRoomID) { // room upgrade mid-poll?? @@ -72,9 +70,16 @@ async function sendVotes(user, channelID, pollMessageID, pollEventID) { return } - const senderMxid = await registerUser.ensureSimJoined(user, matchingRoomID) + if (typeof userOrID === "string") { // just a string when double-checking a vote removal - good thing the unvoter is already here from having voted + var userID = userOrID + var senderMxid = from("sim").join("sim_member", "mxid").where({user_id: userOrID, room_id: matchingRoomID}).pluck("mxid").get() + if (!senderMxid) return + } else { // sent in full when double-checking adding a vote, so we can properly ensure joined + var userID = userOrID.id + var senderMxid = await registerUser.ensureSimJoined(userOrID, matchingRoomID) + } - const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: user.id, message_id: pollMessageID}).pluck().all() + const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: pollMessageID}).pluck().all() const eventID = await api.sendEvent(matchingRoomID, "org.matrix.msc3381.poll.response", { "m.relates_to": { rel_type: "m.reference", @@ -91,4 +96,4 @@ async function sendVotes(user, channelID, pollMessageID, pollEventID) { module.exports.addVote = addVote module.exports.removeVote = removeVote module.exports.debounceSendVotes = debounceSendVotes -module.exports.sendVotes = sendVotes \ No newline at end of file +module.exports.sendVotes = sendVotes diff --git a/src/d2m/actions/send-message.js b/src/d2m/actions/send-message.js index 3fb8d20..3005ca8 100644 --- a/src/d2m/actions/send-message.js +++ b/src/d2m/actions/send-message.js @@ -55,6 +55,16 @@ async function sendMessage(message, channel, guild, row) { } } + if (message.type === DiscordTypes.MessageType.PollResult) { // ensure all Discord-side votes were pushed to Matrix before a poll is closed + const detailedResultsMessage = await pollEnd.endPoll(message) + if (detailedResultsMessage) { + const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() + const channelID = threadParent ? threadParent : message.channel_id + const threadID = threadParent ? message.channel_id : undefined + var sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) + } + } + const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow}) const eventIDs = [] if (events.length) { @@ -102,18 +112,13 @@ async function sendMessage(message, channel, guild, row) { })() } - if (message.type === DiscordTypes.MessageType.PollResult) { // We might need to send a message to Discord (if there were any Matrix-side votes). - const detailedResultsMessage = await pollEnd.endPoll(message, guild) - if (detailedResultsMessage) { - const threadParent = select("channel_room", "thread_parent", {channel_id: message.channel_id}).pluck().get() - const channelID = threadParent ? threadParent : message.channel_id - const threadID = threadParent ? message.channel_id : undefined - const sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID) - db.transaction(() => { - db.prepare("UPDATE event_message SET reaction_part = 1 WHERE event_id = ?").run(eventID) - db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, sentResultsMessage.id, 1, 0) // part = 1, reaction_part = 0 - })() - } + // part/reaction_part consistency for polls + if (sentResultsMessage) { + db.transaction(() => { + db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(sentResultsMessage.id, historicalRoomIndex) + db.prepare("UPDATE event_message SET reaction_part = 1 WHERE event_id = ?").run(eventID) + db.prepare("INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES (?, ?, ?, ?, ?, ?, 1)").run(eventID, eventType, event.msgtype || null, sentResultsMessage.id, 1, 0) // part = 1, reaction_part = 0 + })() } eventIDs.push(eventID) diff --git a/src/d2m/event-dispatcher.js b/src/d2m/event-dispatcher.js index e8c20a6..7c2e118 100644 --- a/src/d2m/event-dispatcher.js +++ b/src/d2m/event-dispatcher.js @@ -372,11 +372,17 @@ module.exports = { await createSpace.syncSpaceExpressions(data, false) }, - async MESSAGE_POLL_VOTE_ADD(client, data){ + /** + * @param {import("./discord-client")} client + * @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data + */ + async MESSAGE_POLL_VOTE_ADD(client, data) { + if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return await vote.addVote(data) }, - async MESSAGE_POLL_VOTE_REMOVE(client, data){ + async MESSAGE_POLL_VOTE_REMOVE(client, data) { + if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return await vote.removeVote(data) }, From 553c95351da1de46747652376ee398b56a1c6617 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 30 Jan 2026 00:42:04 +1300 Subject: [PATCH 7/7] Maybe fix getting invite state This SSS API call should work on Synapse, Tuwunel, and Continuwuity. A fallback via hierarchy is provided for Conduit. --- src/m2d/event-dispatcher.js | 34 +++++------------ src/matrix/api.js | 74 +++++++++++++++++++++++++++++++++---- src/types.d.ts | 1 + 3 files changed, 77 insertions(+), 32 deletions(-) diff --git a/src/m2d/event-dispatcher.js b/src/m2d/event-dispatcher.js index eb1fba0..48bff11 100644 --- a/src/m2d/event-dispatcher.js +++ b/src/m2d/event-dispatcher.js @@ -357,15 +357,7 @@ async event => { await api.ackEvent(event) })) -function getFromInviteRoomState(inviteRoomState, nskey, key) { - if (!Array.isArray(inviteRoomState)) return null - for (const event of inviteRoomState) { - if (event.type === nskey && event.state_key === "") { - return event.content[key] - } - } - return null -} + sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child", /** @@ -398,24 +390,16 @@ async event => { } // 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 - if (!Array.isArray(inviteRoomState) || inviteRoomState.length === 0) { - try { - inviteRoomState = await api.getInviteState(event.room_id) - attemptedApiMessage = "According to SSS API." - } catch (e) { - attemptedApiMessage = "According to unsigned invite data. SSS API unavailable: " + e.toString() - } + try { + var inviteRoomState = await api.getInviteState(event.room_id, event) + } catch (e) { + console.error(e) + return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`) } - const name = getFromInviteRoomState(inviteRoomState, "m.room.name", "name") - const topic = getFromInviteRoomState(inviteRoomState, "m.room.topic", "topic") - const avatar = getFromInviteRoomState(inviteRoomState, "m.room.avatar", "url") - const creationType = getFromInviteRoomState(inviteRoomState, "m.room.create", "type") - if (!name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite! (${attemptedApiMessage})`) + if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`) await api.joinRoom(event.room_id) - db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar) - if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs + db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar) + if (inviteRoomState.avatar) utils.getPublicUrlForMxc(inviteRoomState.avatar) // make sure it's available in the media_proxy allowed URLs } if (utils.eventSenderIsFromDiscord(event.state_key)) return diff --git a/src/matrix/api.js b/src/matrix/api.js index b71c068..ddb77b5 100644 --- a/src/matrix/api.js +++ b/src/matrix/api.js @@ -158,20 +158,80 @@ function getStateEventOuter(roomID, type, key) { /** * @param {string} roomID - * @returns {Promise} + * @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event] + * @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>} */ -async function getInviteState(roomID) { +async function getInviteState(roomID, event) { + function getFromInviteRoomState(strippedState, nskey, key) { + if (!Array.isArray(strippedState)) return null + for (const event of strippedState) { + if (event.type === nskey && event.state_key === "") { + return event.content[key] + } + } + return null + } + + // Try extracting from event (if passed) + if (Array.isArray(event?.unsigned?.invite_room_state) && event.unsigned.invite_room_state.length) { + return { + name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"), + topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"), + avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"), + type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type") + } + } + + // Try calling sliding sync API and extracting from stripped state /** @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}`, {timeout: "0"}), { - room_subscriptions: { - [roomID]: { + lists: { + a: { + ranges: [[0, 999]], timeline_limit: 0, - required_state: [] + required_state: [], + filters: { + is_invite: true + } } } }) - const roomResponse = root.rooms[roomID] - return "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state + + // Extract from sliding sync response if valid (seems to be okay on Synapse, Tuwunel and Continuwuity at time of writing) + if ("lists" in root) { + if (!root.rooms?.[roomID]) { + const e = new Error("Room data unavailable via SSS") + e["data_sss"] = root + throw e + } + + const roomResponse = root.rooms[roomID] + const strippedState = "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state + + return { + name: getFromInviteRoomState(strippedState, "m.room.name", "name"), + topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"), + avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"), + type: getFromInviteRoomState(strippedState, "m.room.create", "type") + } + } + + // Invalid sliding sync response, try alternative (required for Conduit at time of writing) + const hierarchy = await getHierarchy(roomID, {limit: 1}) + if (hierarchy?.rooms?.[0]?.room_id === roomID) { + const room = hierarchy?.rooms?.[0] + return { + name: room.name ?? null, + topic: room.topic ?? null, + avatar: room.avatar_url ?? null, + type: room.room_type + } + } + + const e = new Error("Room data unavailable via SSS/hierarchy") + e["data_sss"] = root + e["data_hierarchy"] = hierarchy + throw e } /** diff --git a/src/types.d.ts b/src/types.d.ts index 951d93c..6ee2eb1 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -433,6 +433,7 @@ export namespace R { guest_can_join: boolean join_rule?: string name?: string + topic?: string num_joined_members: number room_id: string room_type?: string