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:
committed by
Cadence Ember
parent
afca4de6b6
commit
90606d9176
BIN
docs/img/poll-star-avatar.png
Normal file
BIN
docs/img/poll-star-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
docs/img/poll_win.png
Normal file
BIN
docs/img/poll_win.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
150
src/discord/interactions/poll.js
Normal file
150
src/discord/interactions/poll.js
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
12
src/types.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user