From 90606d917664da93539ba821a0061eb0b1fd5078 Mon Sep 17 00:00:00 2001 From: Ellie Algase Date: Sun, 25 Jan 2026 07:27:59 -0600 Subject: [PATCH] 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 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 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")) +}))