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 <cadence@disroot.org>
This commit is contained in:
Ellie Algase
2026-01-25 07:27:59 -06:00
committed by Cadence Ember
parent afca4de6b6
commit 90606d9176
17 changed files with 501 additions and 191 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
docs/img/poll_win.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -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")

View File

@@ -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)
}
}

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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<InteractionMethods[k]>[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

View File

@@ -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<InteractionMethods[k]>[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

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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<Buffer | undefined>}} di simple-as-nails dependency injection for the matrix API
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise<Buffer | undefined>, 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 = []

View File

@@ -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
/** @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

View File

@@ -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",

12
src/types.d.ts vendored
View File

@@ -302,6 +302,18 @@ export namespace Event {
export type Outer_Org_Matrix_Msc3381_Poll_Response = Outer<Org_Matrix_Msc3381_Poll_Response> & {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<Org_Matrix_Msc3381_Poll_End> & {type: "org.matrix.msc3381.poll.end"}
export type M_Room_Member = {
membership: string
displayname?: string

View File

@@ -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"))
}))