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
co-authored by
Cadence Ember
parent
afca4de6b6
commit
90606d9176
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user