Bridge polls from Matrix as pseudo-polls on Discord (with an embed). Not 100% working.
Co-authored-by: Cadence Ember <cloudrac3r@vivaldi.net>
This commit is contained in:
committed by
Cadence Ember
parent
e565342ac8
commit
afca4de6b6
@@ -1,6 +1,9 @@
|
||||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {Semaphore} = require("@chriscdn/promise-semaphore")
|
||||
const {scheduler} = require("timers/promises")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, select, from} = passthrough
|
||||
@@ -11,71 +14,81 @@ const registerUser = sync.require("./register-user")
|
||||
/** @type {import("./create-room")} */
|
||||
const createRoom = sync.require("../actions/create-room")
|
||||
|
||||
const inFlightPollVotes = new Set()
|
||||
const inFlightPollSema = new Semaphore()
|
||||
|
||||
/**
|
||||
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
|
||||
*/
|
||||
async function addVote(data){
|
||||
const parentID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() // Currently Discord doesn't allow sending a poll with anything else, but we bridge it after all other content so reaction_part: 0 is the part that will have the poll.
|
||||
if (!parentID) return // Nothing can be done if the parent message was never bridged.
|
||||
const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get() // Currently Discord doesn't allow sending a poll with anything else, but we bridge it after all other content so reaction_part: 0 is the part that will have the poll.
|
||||
if (!pollEventID) return // Nothing can be done if the parent message was never bridged.
|
||||
|
||||
let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls.
|
||||
assert(realAnswer)
|
||||
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(data.user_id, data.message_id, realAnswer)
|
||||
return modifyVote(data, parentID)
|
||||
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(data.user_id, data.message_id, realAnswer)
|
||||
return debounceSendVotes(data, pollEventID)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("discord-api-types/v10").GatewayMessagePollVoteRemoveDispatch["d"]} data
|
||||
*/
|
||||
async function removeVote(data){
|
||||
const parentID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get()
|
||||
if (!parentID) return
|
||||
const pollEventID = from("event_message").join("poll_option", "message_id").pluck("event_id").where({message_id: data.message_id, event_type: "org.matrix.msc3381.poll.start"}).get()
|
||||
if (!pollEventID) return
|
||||
|
||||
let realAnswer = select("poll_option", "matrix_option", {message_id: data.message_id, discord_option: data.answer_id.toString()}).pluck().get() // Discord answer IDs don't match those on Matrix-created polls.
|
||||
assert(realAnswer)
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND vote = ?").run(data.user_id, data.message_id, realAnswer)
|
||||
return modifyVote(data, parentID)
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(data.user_id, data.message_id, realAnswer)
|
||||
return debounceSendVotes(data, pollEventID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiple-choice polls send all the votes at the same time. This debounces and sends the combined votes.
|
||||
* In the meantime, the combined votes are assembled in the `poll_vote` database table by the above functions.
|
||||
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
|
||||
* @param {string} parentID
|
||||
* @param {string} pollEventID
|
||||
* @return {Promise<string>} event ID of Matrix vote
|
||||
*/
|
||||
async function modifyVote(data, parentID) {
|
||||
async function debounceSendVotes(data, pollEventID) {
|
||||
return await inFlightPollSema.request(async () => {
|
||||
await scheduler.wait(1000) // Wait for votes to be collected
|
||||
|
||||
if (inFlightPollVotes.has(data.user_id+data.message_id)) { // Multiple votes on a poll, and this function has already been called on at least one of them. Need to add these together so we don't ignore votes if someone is voting rapid-fire on a bunch of different polls.
|
||||
return;
|
||||
const user = await discord.snow.user.getUser(data.user_id) // Gateway event doesn't give us the object, only the ID.
|
||||
return await sendVotes(user, data.channel_id, data.message_id, pollEventID)
|
||||
}, `${data.user_id}/${data.message_id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIUser} user
|
||||
* @param {string} channelID
|
||||
* @param {string} pollMessageID
|
||||
* @param {string} pollEventID
|
||||
*/
|
||||
async function sendVotes(user, channelID, pollMessageID, pollEventID) {
|
||||
const latestRoomID = select("channel_room", "room_id", {channel_id: channelID}).pluck().get()
|
||||
const matchingRoomID = from("message_room").join("historical_channel_room", "historical_room_index").where({message_id: pollMessageID}).pluck("room_id").get()
|
||||
if (!latestRoomID || latestRoomID !== matchingRoomID) { // room upgrade mid-poll??
|
||||
db.prepare("UPDATE poll SET is_closed = 1 WHERE message_id = ?").run(pollMessageID)
|
||||
return
|
||||
}
|
||||
|
||||
inFlightPollVotes.add(data.user_id+data.message_id)
|
||||
const senderMxid = await registerUser.ensureSimJoined(user, matchingRoomID)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)) // Wait a second.
|
||||
|
||||
const user = await discord.snow.user.getUser(data.user_id) // Gateway event doesn't give us the object, only the ID.
|
||||
|
||||
const roomID = await createRoom.ensureRoom(data.channel_id)
|
||||
const senderMxid = await registerUser.ensureSimJoined(user, roomID)
|
||||
|
||||
let answersArray = select("poll_vote", "vote", {discord_or_matrix_user_id: data.user_id, message_id: data.message_id}).pluck().all()
|
||||
|
||||
const eventID = await api.sendEvent(roomID, "org.matrix.msc3381.poll.response", {
|
||||
const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: user.id, message_id: pollMessageID}).pluck().all()
|
||||
const eventID = await api.sendEvent(matchingRoomID, "org.matrix.msc3381.poll.response", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: parentID,
|
||||
event_id: pollEventID,
|
||||
},
|
||||
"org.matrix.msc3381.poll.response": {
|
||||
answers: answersArray
|
||||
}
|
||||
}, senderMxid)
|
||||
|
||||
inFlightPollVotes.delete(data.user_id+data.message_id)
|
||||
|
||||
return eventID
|
||||
|
||||
}
|
||||
|
||||
module.exports.addVote = addVote
|
||||
module.exports.removeVote = removeVote
|
||||
module.exports.modifyVote = modifyVote
|
||||
module.exports.debounceSendVotes = debounceSendVotes
|
||||
module.exports.sendVotes = sendVotes
|
||||
@@ -30,16 +30,25 @@ function barChart(percent){
|
||||
return "█".repeat(bars) + "▒".repeat(10-bars)
|
||||
}
|
||||
|
||||
async function getAllVotes(channel_id, message_id, answer_id){
|
||||
/**
|
||||
* @param {string} channelID
|
||||
* @param {string} messageID
|
||||
* @param {string} answerID
|
||||
* @returns {Promise<DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]>}
|
||||
*/
|
||||
async function getAllVotesOnAnswer(channelID, messageID, answerID){
|
||||
const limit = 100
|
||||
/** @type {DiscordTypes.RESTGetAPIPollAnswerVotersResult["users"]} */
|
||||
let voteUsers = []
|
||||
let after = 0;
|
||||
while (!voteUsers.length || after){
|
||||
let curVotes = await discord.snow.requestHandler.request("/channels/"+channel_id+"/polls/"+message_id+"/answers/"+answer_id, {after: after, limit: 100}, "get", "json")
|
||||
if (curVotes.users.length == 0 && after == 0){ // Zero votes.
|
||||
let after = undefined
|
||||
while (!voteUsers.length || after) {
|
||||
const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit})
|
||||
if (curVotes.users.length === 0) { // Reached the end.
|
||||
break
|
||||
}
|
||||
if (curVotes.users[99]){
|
||||
after = curVotes.users[99].id
|
||||
if (curVotes.users.length >= limit) { // Loop again for the next page.
|
||||
// @ts-ignore - stupid
|
||||
after = curVotes.users.at(-1).id
|
||||
}
|
||||
voteUsers = voteUsers.concat(curVotes.users)
|
||||
}
|
||||
@@ -48,91 +57,89 @@ async function getAllVotes(channel_id, message_id, answer_id){
|
||||
|
||||
|
||||
/**
|
||||
* @param {typeof import("../../../test/data.js")["poll_close"]} message
|
||||
* @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
*/
|
||||
async function closePoll(message, guild){
|
||||
const pollCloseObject = message.embeds[0]
|
||||
async function closePoll(closeMessage, guild){
|
||||
const pollCloseObject = closeMessage.embeds[0]
|
||||
|
||||
const parentID = select("event_message", "event_id", {message_id: message.message_reference.message_id, event_type: "org.matrix.msc3381.poll.start"}).pluck().get()
|
||||
if (!parentID) return // Nothing we can send Discord-side if we don't have the original poll. We will still send a results message Matrix-side.
|
||||
const pollMessageID = closeMessage.message_reference.message_id
|
||||
const pollEventID = select("event_message", "event_id", {message_id: pollMessageID, event_type: "org.matrix.msc3381.poll.start"}).pluck().get()
|
||||
if (!pollEventID) return // Nothing we can send Discord-side if we don't have the original poll. We will still send a results message Matrix-side.
|
||||
|
||||
const discordPollOptions = select("poll_option", "discord_option", {message_id: pollMessageID}).pluck().all()
|
||||
assert(discordPollOptions.every(x => typeof x === "string")) // This poll originated on Discord so it will have Discord option IDs
|
||||
|
||||
const pollOptions = select("poll_option", "discord_option", {message_id: message.message_reference.message_id}).pluck().all()
|
||||
// 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.
|
||||
|
||||
let 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.
|
||||
|
||||
let databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "vote"], {message_id: message.message_reference.message_id}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all()
|
||||
const databaseVotes = select("poll_vote", ["discord_or_matrix_user_id", "matrix_option"], {message_id: pollMessageID}, " AND discord_or_matrix_user_id NOT LIKE '@%'").all()
|
||||
|
||||
if (databaseVotes.length != totalVotes) { // Matching length should be sufficient for most cases.
|
||||
if (databaseVotes.length !== totalVotes) { // Matching length should be sufficient for most cases.
|
||||
let voteUsers = [...new Set(databaseVotes.map(vote => vote.discord_or_matrix_user_id))] // Unique array of all users we have votes for in the database.
|
||||
|
||||
// Main design challenge here: we get the data by *answer*, but we need to send it to Matrix by *user*.
|
||||
|
||||
let updatedAnswers = [] // This will be our new array of answers: [{user: ID, votes: [1, 2, 3]}].
|
||||
for (let i=0;i<pollOptions.length;i++){
|
||||
let optionUsers = await getAllVotes(message.channel_id, message.message_reference.message_id, pollOptions[i]) // Array of user IDs who voted for the option we're testing.
|
||||
optionUsers.map(user=>{
|
||||
let userLocation = updatedAnswers.findIndex(item=>item.id===user.id)
|
||||
/** @type {{user: DiscordTypes.APIUser, matrixOptionVotes: string[]}[]} This will be our new array of answers */
|
||||
const updatedAnswers = []
|
||||
|
||||
for (const discordPollOption of discordPollOptions) {
|
||||
const optionUsers = await getAllVotesOnAnswer(closeMessage.channel_id, pollMessageID, discordPollOption) // Array of user IDs who voted for the option we're testing.
|
||||
optionUsers.map(user => {
|
||||
const userLocation = updatedAnswers.findIndex(answer => answer.user.id === user.id)
|
||||
const matrixOption = select("poll_option", "matrix_option", {message_id: pollMessageID, discord_option: discordPollOption}).pluck().get()
|
||||
assert(matrixOption)
|
||||
if (userLocation === -1){ // We haven't seen this user yet, so we need to add them.
|
||||
updatedAnswers.push({id: user.id, votes: [pollOptions[i].toString()]}) // toString as this is what we store and get from the database and send to Matrix.
|
||||
updatedAnswers.push({user, matrixOptionVotes: [matrixOption]}) // toString as this is what we store and get from the database and send to Matrix.
|
||||
} else { // This user already voted for another option on the poll.
|
||||
updatedAnswers[userLocation].votes.push(pollOptions[i])
|
||||
updatedAnswers[userLocation].matrixOptionVotes.push(matrixOption)
|
||||
}
|
||||
})
|
||||
}
|
||||
updatedAnswers.map(async user=>{
|
||||
voteUsers = voteUsers.filter(item => item != user.id) // Remove any users we have updated answers for from voteUsers. The only remaining entries in this array will be users who voted, but then removed their votes before the poll ended.
|
||||
let userAnswers = select("poll_vote", "vote", {discord_or_matrix_user_id: user.id, message_id: message.message_reference.message_id}).pluck().all().sort()
|
||||
let updatedUserAnswers = user.votes.sort() // Sorting both just in case.
|
||||
if (isDeepStrictEqual(userAnswers,updatedUserAnswers)){
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user.id, message.message_reference.message_id) // Delete existing stored votes.
|
||||
updatedUserAnswers.map(vote=>{
|
||||
db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, vote) VALUES (?, ?, ?)").run(user.id, message.message_reference.message_id, vote)
|
||||
})
|
||||
await vote.modifyVote({user_id: user.id, message_id: message.message_reference.message_id, channel_id: message.channel_id, answer_id: 0}, parentID) // Fake answer ID, not actually needed (but we're sorta faking the datatype to call this function).
|
||||
}
|
||||
})
|
||||
|
||||
voteUsers.map(async user_id=>{ // Remove these votes.
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, message.message_reference.message_id)
|
||||
await vote.modifyVote({user_id: user_id, message_id: message.message_reference.message_id, channel_id: message.channel_id, answer_id: 0}, parentID)
|
||||
})
|
||||
// Check for inconsistencies in what was cached in database vs final confirmed poll answers
|
||||
// If different, sync the final confirmed answers to Matrix-side to make it accurate there too
|
||||
|
||||
await Promise.all(updatedAnswers.map(async answer => {
|
||||
voteUsers = voteUsers.filter(item => item !== answer.user.id) // Remove any users we have updated answers for from voteUsers. The only remaining entries in this array will be users who voted, but then removed their votes before the poll ended.
|
||||
const cachedAnswers = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: answer.user.id, message_id: pollMessageID}).pluck().all()
|
||||
if (!isDeepStrictEqual(new Set(cachedAnswers), new Set(answer.matrixOptionVotes))){
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(answer.user.id, pollMessageID) // Delete existing stored votes.
|
||||
for (const matrixOption of answer.matrixOptionVotes) {
|
||||
db.prepare("INSERT INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(answer.user.id, pollMessageID, matrixOption)
|
||||
}
|
||||
await vote.debounceSendVotes({user_id: answer.user.id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID) // Fake answer ID, not actually needed (but we're sorta faking the datatype to call this function).
|
||||
}
|
||||
}))
|
||||
|
||||
await Promise.all(voteUsers.map(async user_id => { // Remove these votes.
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(user_id, pollMessageID)
|
||||
await vote.debounceSendVotes({user_id: user_id, message_id: pollMessageID, channel_id: closeMessage.channel_id, answer_id: 0}, pollEventID)
|
||||
}))
|
||||
}
|
||||
|
||||
let combinedVotes = 0;
|
||||
/** @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()
|
||||
const combinedVotes = pollResults.reduce((a, c) => a + c.count, 0)
|
||||
|
||||
let pollResults = pollOptions.map(option => {
|
||||
let votes = Number(db.prepare("SELECT COUNT(*) FROM poll_vote WHERE message_id = ? AND vote = ?").get(message.message_reference.message_id, option)["COUNT(*)"])
|
||||
combinedVotes = combinedVotes + votes
|
||||
return {answer: option, votes: votes}
|
||||
})
|
||||
|
||||
if (combinedVotes!=totalVotes){ // This means some votes were cast on Matrix!
|
||||
let pollAnswersObject = (await discord.snow.channel.getChannelMessage(message.channel_id, message.message_reference.message_id)).poll.answers
|
||||
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!
|
||||
let winningAnswer = 0
|
||||
let unique = true
|
||||
for (let i=1;i<pollResults.length;i++){
|
||||
if (pollResults[i].votes>pollResults[winningAnswer].votes){
|
||||
winningAnswer = i
|
||||
unique = true
|
||||
} else if (pollResults[i].votes==pollResults[winningAnswer].votes){
|
||||
unique = false
|
||||
}
|
||||
}
|
||||
const topAnswers = pollResults.toSorted()
|
||||
const unique = topAnswers.length > 1 && topAnswers[0].count === topAnswers[1].count
|
||||
|
||||
let messageString = "📶 Results with Matrix votes\n"
|
||||
for (let i=0;i<pollResults.length;i++){
|
||||
if (i == winningAnswer && unique){
|
||||
messageString = messageString + barChart(pollResults[i].votes/combinedVotes) + " **" + pollAnswersObject[i].poll_media.text + "** (**" + pollResults[i].votes + "**)\n"
|
||||
} else{
|
||||
messageString = messageString + barChart(pollResults[i].votes/combinedVotes) + " " + pollAnswersObject[i].poll_media.text + " (" + pollResults[i].votes + ")\n"
|
||||
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 messageResponse = await channelWebhook.sendMessageWithWebhook(message.channel_id, {content: messageString}, message.thread_id)
|
||||
db.prepare("INSERT INTO message_channel (message_id, channel_id) VALUES (?, ?)").run(messageResponse.id, message.thread_id || message.channel_id)
|
||||
await channelWebhook.sendMessageWithWebhook(closeMessage.channel_id, {content: messageString}, closeMessage.thread_id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,14 +85,26 @@ async function sendMessage(message, channel, guild, row) {
|
||||
// The last event gets reaction_part = 0. Reactions are managed there because reactions are supposed to appear at the bottom.
|
||||
|
||||
|
||||
if (eventType === "org.matrix.msc3381.poll.start"){
|
||||
for (let i=0; i<event["org.matrix.msc3381.poll.start"].answers.length;i++){
|
||||
db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option) VALUES (?, ?, ?)").run(message.id, event["org.matrix.msc3381.poll.start"].answers[i].id, event["org.matrix.msc3381.poll.start"].answers[i].id) // Since we can set the ID on Matrix, we use the same ID that Discord gives us.
|
||||
}
|
||||
if (eventType === "org.matrix.msc3381.poll.start") {
|
||||
db.transaction(() => {
|
||||
db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run(
|
||||
message.id,
|
||||
event["org.matrix.msc3381.poll.start"].max_selections,
|
||||
event["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
|
||||
)
|
||||
for (const [index, option] of Object.entries(event["org.matrix.msc3381.poll.start"].answers)) {
|
||||
db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option, option_text, seq) VALUES (?, ?, ?, ?, ?)").run(
|
||||
message.id,
|
||||
option.id,
|
||||
option.id,
|
||||
option["org.matrix.msc1767.text"],
|
||||
index
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
eventIDs.push(eventID)
|
||||
|
||||
}
|
||||
|
||||
return eventIDs
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE "poll_option" (
|
||||
"message_id" TEXT NOT NULL,
|
||||
"matrix_option" TEXT NOT NULL,
|
||||
"discord_option" TEXT NOT NULL,
|
||||
PRIMARY KEY("message_id","matrix_option")
|
||||
FOREIGN KEY ("message_id") REFERENCES "message_channel" ("message_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE "poll_vote" (
|
||||
"vote" TEXT NOT NULL,
|
||||
"message_id" TEXT NOT NULL,
|
||||
"discord_or_matrix_user_id" TEXT NOT NULL,
|
||||
PRIMARY KEY("vote","message_id","discord_or_matrix_user_id"),
|
||||
FOREIGN KEY("message_id") REFERENCES "message_channel" ("message_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
COMMIT;
|
||||
34
src/db/migrations/0032-add-polls.sql
Normal file
34
src/db/migrations/0032-add-polls.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DROP TABLE IF EXISTS "poll";
|
||||
DROP TABLE IF EXISTS "poll_option";
|
||||
DROP TABLE IF EXISTS "poll_vote";
|
||||
|
||||
CREATE TABLE "poll" (
|
||||
"message_id" TEXT NOT NULL,
|
||||
"max_selections" INTEGER NOT NULL,
|
||||
"question_text" TEXT NOT NULL,
|
||||
"is_closed" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("message_id"),
|
||||
FOREIGN KEY ("message_id") REFERENCES "message_room" ("message_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE "poll_option" (
|
||||
"message_id" TEXT NOT NULL,
|
||||
"matrix_option" TEXT NOT NULL,
|
||||
"discord_option" TEXT,
|
||||
"option_text" TEXT NOT NULL,
|
||||
"seq" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("message_id", "matrix_option"),
|
||||
FOREIGN KEY ("message_id") REFERENCES "poll" ("message_id") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE "poll_vote" (
|
||||
"message_id" TEXT NOT NULL,
|
||||
"matrix_option" TEXT NOT NULL,
|
||||
"discord_or_matrix_user_id" TEXT NOT NULL,
|
||||
PRIMARY KEY ("message_id", "matrix_option", "discord_or_matrix_user_id"),
|
||||
FOREIGN KEY ("message_id", "matrix_option") REFERENCES "poll_option" ("message_id", "matrix_option") ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
|
||||
COMMIT;
|
||||
19
src/db/orm-defs.d.ts
vendored
19
src/db/orm-defs.d.ts
vendored
@@ -140,16 +140,25 @@ export type Models = {
|
||||
original_encoding: string | null
|
||||
}
|
||||
|
||||
poll_vote: {
|
||||
vote: string
|
||||
poll: { // not actually in database yet
|
||||
message_id: string
|
||||
discord_or_matrix_user_id: string
|
||||
max_selections: number
|
||||
question_text: string
|
||||
is_closed: number
|
||||
}
|
||||
|
||||
|
||||
poll_option: {
|
||||
message_id: string
|
||||
matrix_option: string
|
||||
discord_option: string
|
||||
discord_option: string | null
|
||||
option_text: string // not actually in database yet
|
||||
seq: number // not actually in database yet
|
||||
}
|
||||
|
||||
poll_vote: {
|
||||
message_id: string
|
||||
matrix_option: string
|
||||
discord_or_matrix_user_id: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
95
src/discord/interactions/vote.js
Normal file
95
src/discord/interactions/vote.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// @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
|
||||
@@ -22,8 +22,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
|
||||
const emojiSheet = sync.require("../actions/emoji-sheet")
|
||||
|
||||
/**
|
||||
* @param {{poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message
|
||||
* @returns {Promise<{poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>}
|
||||
* @param {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[], pendingFiles?: ({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer | stream.Readable})[]}} message
|
||||
* @returns {Promise<DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]}>}
|
||||
*/
|
||||
async function resolvePendingFiles(message) {
|
||||
if (!message.pendingFiles) return message
|
||||
@@ -71,6 +71,7 @@ async function sendEvent(event) {
|
||||
}
|
||||
/** @type {DiscordTypes.APIGuildTextChannel} */ // @ts-ignore
|
||||
const channel = discord.channels.get(channelID)
|
||||
// @ts-ignore
|
||||
const guild = discord.guilds.get(channel.guild_id)
|
||||
assert(guild)
|
||||
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: event.room_id}).pluck().get()
|
||||
@@ -133,12 +134,25 @@ async function sendEvent(event) {
|
||||
}, guild, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (message.poll){ // Need to store answer mapping in the database.
|
||||
for (let i=0; i<message.poll.answers.length; i++){
|
||||
db.prepare("INSERT INTO poll_option (message_id, matrix_option, discord_option) VALUES (?, ?, ?)").run(messageResponse.id, message.poll.answers[i].matrix_option, messageResponse.poll.answers[i].answer_id.toString())
|
||||
if (event.type === "org.matrix.msc3381.poll.start") { // Need to store answer mapping in the database.
|
||||
db.transaction(() => {
|
||||
const messageID = messageResponses[0].id
|
||||
db.prepare("INSERT INTO poll (message_id, max_selections, question_text, is_closed) VALUES (?, ?, ?, 0)").run(
|
||||
messageID,
|
||||
event.content["org.matrix.msc3381.poll.start"].max_selections,
|
||||
event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
|
||||
)
|
||||
for (const [i, option] of Object.entries(event.content["org.matrix.msc3381.poll.start"].answers)) {
|
||||
db.prepare("INSERT INTO poll_option (message_id, matrix_option, option_text, seq) VALUES (?, ?, ?, ?)").run(
|
||||
messageID,
|
||||
option.id,
|
||||
option["org.matrix.msc1767.text"],
|
||||
i
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
for (const user of ensureJoined) {
|
||||
|
||||
@@ -17,7 +17,7 @@ async function updateVote(event) {
|
||||
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.
|
||||
|
||||
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, vote) VALUES (?, ?, ?)").run(event.sender, messageID, answer)
|
||||
db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(event.sender, messageID, answer)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ const dUtils = sync.require("../../discord/utils")
|
||||
const file = sync.require("../../matrix/file")
|
||||
/** @type {import("./emoji-sheet")} */
|
||||
const emojiSheet = sync.require("./emoji-sheet")
|
||||
/** @type {import("./poll-components")} */
|
||||
const pollComponents = sync.require("./poll-components")
|
||||
/** @type {import("../actions/setup-emojis")} */
|
||||
const setupEmojis = sync.require("../actions/setup-emojis")
|
||||
/** @type {import("../../d2m/converters/user-to-mxid")} */
|
||||
@@ -551,8 +553,8 @@ async function eventToMessage(event, guild, channel, di) {
|
||||
const pendingFiles = []
|
||||
/** @type {DiscordTypes.APIUser[]} */
|
||||
const ensureJoined = []
|
||||
/** @type {Ty.SendingPoll} */
|
||||
let poll = null
|
||||
/** @type {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody?} */
|
||||
let pollMessage = null
|
||||
|
||||
// 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
|
||||
@@ -632,21 +634,17 @@ async function eventToMessage(event, guild, channel, di) {
|
||||
pendingFiles.push({name: filename, mxc: event.content.url})
|
||||
|
||||
} else if (event.type === "org.matrix.msc3381.poll.start") {
|
||||
content = ""
|
||||
const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience
|
||||
let allowMultiselect = (pollContent.max_selections != 1)
|
||||
let answers = pollContent.answers.map(answer=>{
|
||||
return {poll_media: {text: answer["org.matrix.msc1767.text"]}, matrix_option: answer["id"]}
|
||||
})
|
||||
poll = {
|
||||
question: {
|
||||
text: event.content["org.matrix.msc3381.poll.start"].question["org.matrix.msc1767.text"]
|
||||
},
|
||||
answers: answers,
|
||||
duration: 768, // Maximum duration (32 days). Matrix doesn't allow automatically-expiring polls, so this is the only thing that makes sense to send.
|
||||
allow_multiselect: allowMultiselect,
|
||||
layout_type: 1
|
||||
}
|
||||
const isClosed = false;
|
||||
const maxSelections = pollContent.max_selections || 1
|
||||
const questionText = pollContent.question["org.matrix.msc1767.text"]
|
||||
const pollOptions = pollContent.answers.map(answer => ({
|
||||
matrix_option: answer.id,
|
||||
option_text: answer["org.matrix.msc1767.text"],
|
||||
count: 0 // no votes initially
|
||||
}))
|
||||
content = ""
|
||||
pollMessage = pollComponents.getPollComponents(isClosed, maxSelections, questionText, pollOptions)
|
||||
|
||||
} 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.
|
||||
@@ -980,7 +978,7 @@ async function eventToMessage(event, guild, channel, di) {
|
||||
|
||||
// Split into 2000 character chunks
|
||||
const chunks = chunk(content, 2000)
|
||||
/** @type {({poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
|
||||
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
|
||||
const messages = chunks.map(content => ({
|
||||
content,
|
||||
allowed_mentions: {
|
||||
@@ -1003,13 +1001,12 @@ async function eventToMessage(event, guild, channel, di) {
|
||||
messages[0].pendingFiles = pendingFiles
|
||||
}
|
||||
|
||||
if (poll) {
|
||||
if (!messages.length) messages.push({
|
||||
content: " ", // stopgap, remove when library updates
|
||||
if (pollMessage) {
|
||||
messages.push({
|
||||
...pollMessage,
|
||||
username: displayNameShortened,
|
||||
avatar_url: avatarURL
|
||||
})
|
||||
messages[0].poll = poll
|
||||
}
|
||||
|
||||
const messagesToEdit = []
|
||||
|
||||
103
src/m2d/converters/poll-components.js
Normal file
103
src/m2d/converters/poll-components.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// @ts-check
|
||||
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
|
||||
/**
|
||||
* @param {boolean} isClosed
|
||||
* @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
|
||||
* @returns {DiscordTypes.APIMessageTopLevelComponent[]}
|
||||
*/
|
||||
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
|
||||
return {
|
||||
type: DiscordTypes.ComponentType.Container,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.Section,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: option.option_text
|
||||
}],
|
||||
accessory: {
|
||||
type: DiscordTypes.ComponentType.Button,
|
||||
style: winningOrTied ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
|
||||
label: option.count.toString(),
|
||||
custom_id: option.matrix_option,
|
||||
disabled: isClosed
|
||||
}
|
||||
}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isClosed
|
||||
* @param {number} maxSelections
|
||||
* @param {string} questionText
|
||||
* @param {{matrix_option: string, option_text: string, count: number}[]} pollOptions already sorted correctly
|
||||
* @returns {DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody}
|
||||
*/
|
||||
function getPollComponents(isClosed, maxSelections, questionText, pollOptions) {
|
||||
/** @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!",
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
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.Primary,
|
||||
custom_id: "vote",
|
||||
label: "Vote!"
|
||||
}
|
||||
}
|
||||
}
|
||||
const optionComponents = optionsToComponents(isClosed, pollOptions)
|
||||
return {
|
||||
flags: DiscordTypes.MessageFlags.IsComponentsV2,
|
||||
components: [headingComponent, ...optionComponents]
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.getPollComponents = getPollComponents
|
||||
4
src/types.d.ts
vendored
4
src/types.d.ts
vendored
@@ -83,10 +83,6 @@ export type WebhookAuthor = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type SendingPoll = DiscordTypes.RESTAPIPoll & {
|
||||
answers: (DiscordTypes.APIBasePollAnswer & {matrix_option: string})[]
|
||||
}
|
||||
|
||||
export type PkSystem = {
|
||||
id: string
|
||||
uuid: string
|
||||
|
||||
Reference in New Issue
Block a user