diff --git a/docs/img/poll-star-avatar.png b/docs/img/poll-star-avatar.png new file mode 100644 index 0000000..a435555 Binary files /dev/null and b/docs/img/poll-star-avatar.png differ diff --git a/docs/img/poll_win.png b/docs/img/poll_win.png new file mode 100644 index 0000000..61c8590 Binary files /dev/null and b/docs/img/poll_win.png differ diff --git a/src/d2m/actions/add-reaction.js b/src/d2m/actions/add-reaction.js index 8d86e5f..476f8dd 100644 --- a/src/d2m/actions/add-reaction.js +++ b/src/d2m/actions/add-reaction.js @@ -21,7 +21,7 @@ async function addReaction(data) { const user = data.member?.user assert.ok(user && user.username) - const parentID = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get() + const parentID = select("event_message", "event_id", {message_id: data.message_id}, "ORDER BY reaction_part").pluck().get() if (!parentID) return // Nothing can be done if the parent message was never bridged. assert.equal(typeof parentID, "string") diff --git a/src/d2m/actions/close-poll.js b/src/d2m/actions/close-poll.js index a715177..d97b7b4 100644 --- a/src/d2m/actions/close-poll.js +++ b/src/d2m/actions/close-poll.js @@ -6,6 +6,7 @@ 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")} */ @@ -14,6 +15,8 @@ const registerUser = sync.require("./register-user") 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/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") @@ -26,8 +29,9 @@ const channelWebhook = sync.require("../../m2d/actions/channel-webhook") * @param {number} percent */ function barChart(percent){ - let bars = Math.floor(percent*10) - return "█".repeat(bars) + "▒".repeat(10-bars) + const width = 12 + const bars = Math.floor(percent*width) + return "█".repeat(bars) + "▒".repeat(width-bars) } /** @@ -73,7 +77,7 @@ async function closePoll(closeMessage, guild){ // 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. - 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 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")) +}))