Compare commits
No commits in common. "553c95351da1de46747652376ee398b56a1c6617" and "c0bbdfde60338d04034d79c08a0575ccf19c7c11" have entirely different histories.
553c95351d
...
c0bbdfde60
29 changed files with 82 additions and 1438 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB |
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -35,7 +35,7 @@
|
|||
"lru-cache": "^11.0.2",
|
||||
"prettier-bytes": "^1.0.4",
|
||||
"sharp": "^0.34.5",
|
||||
"snowtransfer": "^0.17.1",
|
||||
"snowtransfer": "^0.17.0",
|
||||
"stream-mime-type": "^1.0.2",
|
||||
"try-to-catch": "^3.0.1",
|
||||
"uqr": "^0.1.2",
|
||||
|
|
@ -2727,9 +2727,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/snowtransfer": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.1.tgz",
|
||||
"integrity": "sha512-WSXj055EJhzzfD7B3oHVyRTxkqFCaxcVhwKY6B3NkBSHRyM6wHxZLq6VbFYhopUg+lMtd7S1ZO8JM+Ut+js2iA==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.17.0.tgz",
|
||||
"integrity": "sha512-H6Avpsco+HlVIkN+MbX34Q7+9g9Wci0wZQwGsvfw20VqEb7jnnk73iUcWytNMYtKZ72Ud58n6cFnQ3apTEamxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.38.37"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
"lru-cache": "^11.0.2",
|
||||
"prettier-bytes": "^1.0.4",
|
||||
"sharp": "^0.34.5",
|
||||
"snowtransfer": "^0.17.1",
|
||||
"snowtransfer": "^0.17.0",
|
||||
"stream-mime-type": "^1.0.2",
|
||||
"try-to-catch": "^3.0.1",
|
||||
"uqr": "^0.1.2",
|
||||
|
|
|
|||
|
|
@ -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}, "ORDER BY reaction_part").pluck().get()
|
||||
const parentID = select("event_message", "event_id", {message_id: data.message_id, reaction_part: 0}).pluck().get()
|
||||
if (!parentID) return // Nothing can be done if the parent message was never bridged.
|
||||
assert.equal(typeof parentID, "string")
|
||||
|
||||
|
|
|
|||
|
|
@ -487,8 +487,9 @@ async function unbridgeDeletedChannel(channel, guildID) {
|
|||
/** @type {Ty.Event.M_Power_Levels} */
|
||||
const powerLevelContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
|
||||
powerLevelContent.users ??= {}
|
||||
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
for (const mxid of Object.keys(powerLevelContent.users)) {
|
||||
if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) {
|
||||
if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== bot) {
|
||||
delete powerLevelContent.users[mxid]
|
||||
await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
|
||||
}
|
||||
|
|
@ -512,7 +513,7 @@ async function unbridgeDeletedChannel(channel, guildID) {
|
|||
// (the room can be used with less clutter and the member list makes sense if it's bridged somewhere else)
|
||||
if (row.autocreate === 0) {
|
||||
// remove sim members
|
||||
const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, mUtils.bot)
|
||||
const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, bot)
|
||||
const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?")
|
||||
for (const mxid of members) {
|
||||
await api.leaveRoom(roomID, mxid)
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {isDeepStrictEqual} = require("util")
|
||||
|
||||
const passthrough = require("../../passthrough")
|
||||
const {discord, sync, db, select, from} = passthrough
|
||||
const {reg} = require("../../matrix/read-registration")
|
||||
/** @type {import("./poll-vote")} */
|
||||
const vote = sync.require("../actions/poll-vote")
|
||||
/** @type {import("../../m2d/converters/poll-components")} */
|
||||
const pollComponents = sync.require("../../m2d/converters/poll-components")
|
||||
|
||||
// This handles, in the following order:
|
||||
// * verifying Matrix-side votes are accurate for a poll originating on Discord, sending missed votes to Matrix if necessary
|
||||
// * sending a message to Discord if a vote in that poll has been cast on Matrix
|
||||
// This does *not* handle bridging of poll closures on Discord to Matrix; that takes place in converters/message-to-event.js.
|
||||
|
||||
/**
|
||||
* @param {number} percent
|
||||
*/
|
||||
function barChart(percent) {
|
||||
const width = 12
|
||||
const bars = Math.floor(percent*width)
|
||||
return "█".repeat(bars) + "▒".repeat(width-bars)
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 = undefined
|
||||
while (true) {
|
||||
const curVotes = await discord.snow.channel.getPollAnswerVoters(channelID, messageID, answerID, {after: after, limit})
|
||||
voteUsers = voteUsers.concat(curVotes.users)
|
||||
if (curVotes.users.length >= limit) { // Loop again for the next page.
|
||||
// @ts-ignore - stupid
|
||||
after = curVotes.users.at(-1).id
|
||||
} else { // Reached the end.
|
||||
return voteUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof import("../../../test/data.js")["poll_close"]} closeMessage
|
||||
*/
|
||||
async function endPoll(closeMessage) {
|
||||
const pollCloseObject = closeMessage.embeds[0]
|
||||
|
||||
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
|
||||
|
||||
// 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 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.
|
||||
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*.
|
||||
|
||||
/** @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.
|
||||
for (const user of optionUsers) {
|
||||
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({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].matrixOptionVotes.push(matrixOption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.transaction(() => {
|
||||
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.sendVotes(answer.user, closeMessage.channel_id, pollMessageID, pollEventID)
|
||||
}
|
||||
}))
|
||||
|
||||
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.sendVotes(user_id, closeMessage.channel_id, pollMessageID, pollEventID)
|
||||
}))
|
||||
}
|
||||
|
||||
/** @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!
|
||||
// Now that we've corrected the vote totals, we can get the results again and post them to Discord!
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.endPoll = endPoll
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
// @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
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("./register-user")} */
|
||||
const registerUser = sync.require("./register-user")
|
||||
|
||||
const inFlightPollSema = new Semaphore()
|
||||
|
||||
/**
|
||||
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
|
||||
*/
|
||||
async function addVote(data) {
|
||||
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, 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 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 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} pollEventID
|
||||
* @return {Promise<string>} event ID of Matrix vote
|
||||
*/
|
||||
async function debounceSendVotes(data, pollEventID) {
|
||||
return await inFlightPollSema.request(async () => {
|
||||
await scheduler.wait(1000) // Wait for votes to be collected
|
||||
|
||||
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 | string} userOrID
|
||||
* @param {string} channelID
|
||||
* @param {string} pollMessageID
|
||||
* @param {string} pollEventID
|
||||
*/
|
||||
async function sendVotes(userOrID, 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
|
||||
}
|
||||
|
||||
if (typeof userOrID === "string") { // just a string when double-checking a vote removal - good thing the unvoter is already here from having voted
|
||||
var userID = userOrID
|
||||
var senderMxid = from("sim").join("sim_member", "mxid").where({user_id: userOrID, room_id: matchingRoomID}).pluck("mxid").get()
|
||||
if (!senderMxid) return
|
||||
} else { // sent in full when double-checking adding a vote, so we can properly ensure joined
|
||||
var userID = userOrID.id
|
||||
var senderMxid = await registerUser.ensureSimJoined(userOrID, matchingRoomID)
|
||||
}
|
||||
|
||||
const answersArray = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, 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: pollEventID,
|
||||
},
|
||||
"org.matrix.msc3381.poll.response": {
|
||||
answers: answersArray
|
||||
}
|
||||
}, senderMxid)
|
||||
|
||||
return eventID
|
||||
}
|
||||
|
||||
module.exports.addVote = addVote
|
||||
module.exports.removeVote = removeVote
|
||||
module.exports.debounceSendVotes = debounceSendVotes
|
||||
module.exports.sendVotes = sendVotes
|
||||
|
|
@ -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}).and("ORDER BY reaction_part").select("event_id", "room_id").get()
|
||||
.where({message_id: data.message_id, reaction_part: 0}).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, from} = passthrough
|
||||
const { discord, sync, db, select } = passthrough
|
||||
/** @type {import("../converters/message-to-event")} */
|
||||
const messageToEvent = sync.require("../converters/message-to-event")
|
||||
/** @type {import("../../matrix/api")} */
|
||||
|
|
@ -17,12 +17,8 @@ const registerPkUser = sync.require("./register-pk-user")
|
|||
const registerWebhookUser = sync.require("./register-webhook-user")
|
||||
/** @type {import("../actions/create-room")} */
|
||||
const createRoom = sync.require("../actions/create-room")
|
||||
/** @type {import("../actions/poll-end")} */
|
||||
const pollEnd = sync.require("../actions/poll-end")
|
||||
/** @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
|
||||
|
|
@ -55,16 +51,6 @@ async function sendMessage(message, channel, guild, row) {
|
|||
}
|
||||
}
|
||||
|
||||
if (message.type === DiscordTypes.MessageType.PollResult) { // ensure all Discord-side votes were pushed to Matrix before a poll is closed
|
||||
const detailedResultsMessage = await pollEnd.endPoll(message)
|
||||
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
|
||||
var sentResultsMessage = await channelWebhook.sendMessageWithWebhook(channelID, detailedResultsMessage, threadID)
|
||||
}
|
||||
}
|
||||
|
||||
const events = await messageToEvent.messageToEvent(message, guild, {}, {api, snow: discord.snow})
|
||||
const eventIDs = []
|
||||
if (events.length) {
|
||||
|
|
@ -92,35 +78,6 @@ 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") {
|
||||
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
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// part/reaction_part consistency for polls
|
||||
if (sentResultsMessage) {
|
||||
db.transaction(() => {
|
||||
db.prepare("INSERT OR IGNORE INTO message_room (message_id, historical_room_index) VALUES (?, ?)").run(sentResultsMessage.id, historicalRoomIndex)
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ function eventCanBeEdited(ev) {
|
|||
if (ev.old.event_type === "m.room.message" && ev.old.event_subtype !== "m.text" && ev.old.event_subtype !== "m.emote" && ev.old.event_subtype !== "m.notice") {
|
||||
return false
|
||||
}
|
||||
// Discord does not allow stickers to be edited. Poll closures are sent as "edits", but not in a way we care about.
|
||||
if (ev.old.event_type === "m.sticker" || ev.old.event_type === "org.matrix.msc3381.poll.start") {
|
||||
// Discord does not allow stickers to be edited.
|
||||
if (ev.old.event_type === "m.sticker") {
|
||||
return false
|
||||
}
|
||||
// Anything else is fair game.
|
||||
|
|
|
|||
|
|
@ -203,46 +203,6 @@ async function attachmentToEvent(mentions, attachment) {
|
|||
}
|
||||
}
|
||||
|
||||
/** @param {DiscordTypes.APIPoll} poll */
|
||||
async function pollToEvent(poll) {
|
||||
let fallbackText = poll.question.text
|
||||
if (poll.allow_multiselect) {
|
||||
var maxSelections = poll.answers.length;
|
||||
} else {
|
||||
var maxSelections = 1;
|
||||
}
|
||||
let answers = poll.answers.map(answer=>{
|
||||
let matrixText = answer.poll_media.text
|
||||
if (answer.poll_media.emoji) {
|
||||
if (answer.poll_media.emoji.id) {
|
||||
// Custom emoji. It seems like no Matrix client allows custom emoji in poll answers, so leaving this unimplemented.
|
||||
} else {
|
||||
matrixText = "[" + answer.poll_media.emoji.name + "] " + matrixText
|
||||
}
|
||||
}
|
||||
let matrixAnswer = {
|
||||
id: answer.answer_id.toString(),
|
||||
"org.matrix.msc1767.text": matrixText
|
||||
}
|
||||
fallbackText = fallbackText + "\n" + answer.answer_id.toString() + ". " + matrixText
|
||||
return matrixAnswer;
|
||||
})
|
||||
return {
|
||||
$type: "org.matrix.msc3381.poll.start",
|
||||
"org.matrix.msc3381.poll.start": {
|
||||
question: {
|
||||
"org.matrix.msc1767.text": poll.question.text,
|
||||
body: poll.question.text,
|
||||
msgtype: "m.text"
|
||||
},
|
||||
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
|
||||
max_selections: maxSelections,
|
||||
answers: answers
|
||||
},
|
||||
"org.matrix.msc1767.text": fallbackText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DiscordTypes.APIMessage} message
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
|
|
@ -266,20 +226,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
return []
|
||||
}
|
||||
|
||||
if (message.type === DiscordTypes.MessageType.PollResult) {
|
||||
const event_id = select("event_message", "event_id", {message_id: message.message_reference?.message_id}).pluck().get()
|
||||
return [{
|
||||
$type: "org.matrix.msc3381.poll.end",
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id
|
||||
},
|
||||
"org.matrix.msc3381.poll.end": {},
|
||||
body: "This poll has ended.",
|
||||
msgtype: "m.text"
|
||||
}]
|
||||
}
|
||||
|
||||
if (message.type === DiscordTypes.MessageType.ThreadStarterMessage) {
|
||||
// This is the message that appears at the top of a thread when the thread was based off an existing message.
|
||||
// It's just a message reference, no content.
|
||||
|
|
@ -756,12 +702,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
}
|
||||
}
|
||||
|
||||
// Then polls
|
||||
if (message.poll) {
|
||||
const pollEvent = await pollToEvent(message.poll)
|
||||
events.push(pollEvent)
|
||||
}
|
||||
|
||||
// Then embeds
|
||||
const urlPreviewEnabled = select("guild_space", "url_preview", {guild_id: guild?.id}).pluck().get() ?? 1
|
||||
for (const embed of message.embeds || []) {
|
||||
|
|
@ -773,10 +713,6 @@ async function messageToEvent(message, guild, options = {}, di) {
|
|||
continue // Matrix's own URL previews are fine for images.
|
||||
}
|
||||
|
||||
if (embed.type === "poll_result") {
|
||||
// The code here is only for the message to be bridged to Matrix. Dealing with the Discord-side updates is in actions/poll-close.js.
|
||||
}
|
||||
|
||||
if (embed.url?.startsWith("https://discord.com/")) {
|
||||
continue // If discord creates an embed preview for a discord channel link, don't copy that embed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1551,57 +1551,3 @@ test("message2event: forwarded message with unreferenced mention", async t => {
|
|||
"m.mentions": {}
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: single-choice poll", async t => {
|
||||
const events = await messageToEvent(data.message.poll_single_choice, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "org.matrix.msc3381.poll.start",
|
||||
"org.matrix.msc3381.poll.start": {
|
||||
question: {
|
||||
"org.matrix.msc1767.text": "only one answer allowed!",
|
||||
body: "only one answer allowed!",
|
||||
msgtype: "m.text"
|
||||
},
|
||||
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
|
||||
max_selections: 1,
|
||||
answers: [{
|
||||
id: "1",
|
||||
"org.matrix.msc1767.text": "[\ud83d\udc4d] answer one"
|
||||
}, {
|
||||
id: "2",
|
||||
"org.matrix.msc1767.text": "[\ud83d\udc4e] answer two"
|
||||
}, {
|
||||
id: "3",
|
||||
"org.matrix.msc1767.text": "answer three"
|
||||
}]
|
||||
},
|
||||
"org.matrix.msc1767.text": "only one answer allowed!\n1. [\ud83d\udc4d] answer one\n2. [\ud83d\udc4e] answer two\n3. answer three"
|
||||
}])
|
||||
})
|
||||
|
||||
test("message2event: multiple-choice poll", async t => {
|
||||
const events = await messageToEvent(data.message.poll_multiple_choice, data.guild.general, {})
|
||||
t.deepEqual(events, [{
|
||||
$type: "org.matrix.msc3381.poll.start",
|
||||
"org.matrix.msc3381.poll.start": {
|
||||
question: {
|
||||
"org.matrix.msc1767.text": "more than one answer allowed",
|
||||
body: "more than one answer allowed",
|
||||
msgtype: "m.text"
|
||||
},
|
||||
kind: "org.matrix.msc3381.poll.disclosed", // Discord always lets you see results, so keeping this consistent with that.
|
||||
max_selections: 3,
|
||||
answers: [{
|
||||
id: "1",
|
||||
"org.matrix.msc1767.text": "[😭] no"
|
||||
}, {
|
||||
id: "2",
|
||||
"org.matrix.msc1767.text": "oh no"
|
||||
}, {
|
||||
id: "3",
|
||||
"org.matrix.msc1767.text": "oh noooooo"
|
||||
}]
|
||||
},
|
||||
"org.matrix.msc1767.text": "more than one answer allowed\n1. [😭] no\n2. oh no\n3. oh noooooo"
|
||||
}])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class DiscordClient {
|
|||
/** @type {import("cloudstorm").IClientOptions["intents"]} */
|
||||
const intents = [
|
||||
"DIRECT_MESSAGES", "DIRECT_MESSAGE_REACTIONS", "DIRECT_MESSAGE_TYPING",
|
||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
|
||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS",
|
||||
"MESSAGE_CONTENT"
|
||||
]
|
||||
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
|
||||
|
|
@ -31,6 +31,7 @@ class DiscordClient {
|
|||
this.snow = new SnowTransfer(discordToken)
|
||||
this.cloud = new CloudStorm(discordToken, {
|
||||
shards: [0],
|
||||
reconnect: true,
|
||||
snowtransferInstance: this.snow,
|
||||
intents,
|
||||
ws: {
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ const speedbump = sync.require("./actions/speedbump")
|
|||
const retrigger = sync.require("./actions/retrigger")
|
||||
/** @type {import("./actions/set-presence")} */
|
||||
const setPresence = sync.require("./actions/set-presence")
|
||||
/** @type {import("./actions/poll-vote")} */
|
||||
const vote = sync.require("./actions/poll-vote")
|
||||
/** @type {import("../m2d/event-dispatcher")} */
|
||||
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
|
||||
/** @type {import("../discord/interactions/matrix-info")} */
|
||||
|
|
@ -372,20 +370,6 @@ module.exports = {
|
|||
await createSpace.syncSpaceExpressions(data, false)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
* @param {DiscordTypes.GatewayMessagePollVoteDispatchData} data
|
||||
*/
|
||||
async MESSAGE_POLL_VOTE_ADD(client, data) {
|
||||
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_ADD, client, data)) return
|
||||
await vote.addVote(data)
|
||||
},
|
||||
|
||||
async MESSAGE_POLL_VOTE_REMOVE(client, data) {
|
||||
if (retrigger.eventNotFoundThenRetrigger(data.message_id, module.exports.MESSAGE_POLL_VOTE_REMOVE, client, data)) return
|
||||
await vote.removeVote(data)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
* @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
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;
|
||||
21
src/db/orm-defs.d.ts
vendored
21
src/db/orm-defs.d.ts
vendored
|
|
@ -139,27 +139,6 @@ export type Models = {
|
|||
encoded_emoji: string
|
||||
original_encoding: string | null
|
||||
}
|
||||
|
||||
poll: { // not actually in database yet
|
||||
message_id: string
|
||||
max_selections: number
|
||||
question_text: string
|
||||
is_closed: number
|
||||
}
|
||||
|
||||
poll_option: {
|
||||
message_id: string
|
||||
matrix_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
|
||||
}
|
||||
}
|
||||
|
||||
export type Prepared<Row> = {
|
||||
|
|
|
|||
|
|
@ -1,150 +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/poll-vote")} */
|
||||
const vote = sync.require("../../d2m/actions/poll-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
|
||||
|
|
@ -9,7 +9,6 @@ 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
|
||||
|
||||
|
|
@ -69,36 +68,25 @@ 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 (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}`)
|
||||
}
|
||||
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 {
|
||||
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}`)
|
||||
}
|
||||
throw new Error(`Unknown interaction ${interactionId}`)
|
||||
}
|
||||
} catch (e) {
|
||||
let stackLines = null
|
||||
|
|
@ -109,16 +97,12 @@ async function dispatchInteraction(interaction) {
|
|||
stackLines = stackLines.slice(0, cloudstormLine - 2)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ 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")} */
|
||||
|
|
@ -61,7 +59,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 | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} event */
|
||||
/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker} 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
|
||||
|
|
@ -73,7 +71,6 @@ 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()
|
||||
|
|
@ -81,19 +78,7 @@ 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
|
||||
|
||||
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)
|
||||
let {messagesToEdit, messagesToSend, messagesToDelete, ensureJoined} = await eventToMessage.eventToMessage(event, guild, channel, {api, snow: discord.snow, mxcDownloader: emojiSheet.getAndConvertEmoji})
|
||||
|
||||
messagesToEdit = await Promise.all(messagesToEdit.map(async e => {
|
||||
e.message = await resolvePendingFiles(e.message)
|
||||
|
|
@ -119,16 +104,8 @@ 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 || di.pollEnd) && message === messagesToSend[messagesToSend.length - 1] ? 0 : 1
|
||||
const reactionPart = messagesToEdit.length === 0 && 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)
|
||||
|
|
@ -158,25 +135,6 @@ async function sendEvent(event) {
|
|||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
registerUser.ensureSimJoined(user, event.room_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", "poll_win"]) {
|
||||
for (const name of ["L1", "L2"]) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
// @ts-check
|
||||
|
||||
const Ty = require("../../types")
|
||||
const DiscordTypes = require("discord-api-types/v10")
|
||||
const {Readable} = require("stream")
|
||||
const assert = require("assert").strict
|
||||
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 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.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)
|
||||
}
|
||||
})()
|
||||
|
||||
// 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
|
||||
|
|
@ -22,8 +22,6 @@ 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")} */
|
||||
|
|
@ -519,10 +517,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 | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_End} 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} event
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
* @param {DiscordTypes.APIGuildTextChannel} channel
|
||||
* @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
|
||||
* @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
|
||||
*/
|
||||
async function eventToMessage(event, guild, channel, di) {
|
||||
let displayName = event.sender
|
||||
|
|
@ -546,15 +544,13 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
displayNameRunoff = ""
|
||||
}
|
||||
|
||||
let content = event.content["body"] || "" // ultimate fallback
|
||||
let content = event.content.body // ultimate fallback
|
||||
/** @type {{id: string, filename: string}[]} */
|
||||
const attachments = []
|
||||
/** @type {({name: string, mxc: string} | {name: string, mxc: string, key: string, iv: string} | {name: string, buffer: Buffer})[]} */
|
||||
const pendingFiles = []
|
||||
/** @type {DiscordTypes.APIUser[]} */
|
||||
const ensureJoined = []
|
||||
/** @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
|
||||
|
|
@ -632,30 +628,6 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
}
|
||||
attachments.push({id: "0", filename})
|
||||
pendingFiles.push({name: filename, mxc: event.content.url})
|
||||
|
||||
} else if (event.type === "org.matrix.msc3381.poll.start") {
|
||||
const pollContent = event.content["org.matrix.msc3381.poll.start"] // just for convenience
|
||||
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 = ""
|
||||
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.
|
||||
// this event ---is an edit of--> original event ---is a reply to--> past event
|
||||
|
|
@ -856,7 +828,7 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
'<x-turndown id="turndown-root">' + input + '</x-turndown>'
|
||||
);
|
||||
const root = doc.getElementById("turndown-root");
|
||||
async function forEachNode(event, node) {
|
||||
async function forEachNode(node) {
|
||||
for (; node; node = node.nextSibling) {
|
||||
// Check written mentions
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
|
||||
|
|
@ -904,10 +876,10 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
node.setAttribute("data-suppress", "")
|
||||
}
|
||||
}
|
||||
await forEachNode(event, node.firstChild)
|
||||
await forEachNode(node.firstChild)
|
||||
}
|
||||
}
|
||||
await forEachNode(event, root)
|
||||
await forEachNode(root)
|
||||
|
||||
// SPRITE SHEET EMOJIS FEATURE: Emojis at the end of the message that we don't know about will be reuploaded as a sprite sheet.
|
||||
// First we need to determine which emojis are at the end.
|
||||
|
|
@ -1011,16 +983,6 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
messages[0].pendingFiles = pendingFiles
|
||||
}
|
||||
|
||||
if (pollMessages.length) {
|
||||
for (const pollMessage of pollMessages) {
|
||||
messages.push({
|
||||
username: displayNameShortened,
|
||||
avatar_url: avatarURL,
|
||||
...pollMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const messagesToEdit = []
|
||||
const messagesToSend = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
|
|
|
|||
|
|
@ -1,227 +0,0 @@
|
|||
// @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
|
||||
* @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 medal = getMedal(topAnswers, option.count)
|
||||
return {
|
||||
type: DiscordTypes.ComponentType.Container,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.Section,
|
||||
components: [{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: medal && isClosed ? `${medal} ${option.option_text}` : option.option_text
|
||||
}],
|
||||
accessory: {
|
||||
type: DiscordTypes.ComponentType.Button,
|
||||
style: medal === "🥇" && isClosed ? DiscordTypes.ButtonStyle.Success : DiscordTypes.ButtonStyle.Secondary,
|
||||
label: option.count.toString(),
|
||||
custom_id: `POLL_OPTION#${option.matrix_option}`,
|
||||
disabled: isClosed
|
||||
}
|
||||
}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @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[]} 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) {
|
||||
headingComponent = { // This one is for the poll heading.
|
||||
type: DiscordTypes.ComponentType.Section,
|
||||
components: [
|
||||
{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: `## ${questionText}`
|
||||
}
|
||||
],
|
||||
accessory: {
|
||||
type: DiscordTypes.ComponentType.Button,
|
||||
style: DiscordTypes.ButtonStyle.Secondary,
|
||||
custom_id: "POLL_VOTE",
|
||||
label: "Voting closed",
|
||||
disabled: true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
headingComponent = { // This one is for the poll heading.
|
||||
type: DiscordTypes.ComponentType.Section,
|
||||
components: [
|
||||
{
|
||||
type: DiscordTypes.ComponentType.TextDisplay,
|
||||
content: `## ${questionText}`
|
||||
},
|
||||
// @ts-ignore
|
||||
multiSelectInfoComponent.pop()
|
||||
],
|
||||
accessory: {
|
||||
type: DiscordTypes.ComponentType.Button,
|
||||
style: DiscordTypes.ButtonStyle.Primary,
|
||||
custom_id: "POLL_VOTE",
|
||||
label: "Vote!"
|
||||
}
|
||||
}
|
||||
}
|
||||
const optionComponents = optionsToComponents(isClosed, pollOptions)
|
||||
return {
|
||||
flags: DiscordTypes.MessageFlags.IsComponentsV2,
|
||||
components: [headingComponent, ...optionComponents, ...multiSelectInfoComponent]
|
||||
}
|
||||
}
|
||||
|
||||
/** @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
|
||||
|
|
@ -18,8 +18,6 @@ const addReaction = sync.require("./actions/add-reaction")
|
|||
const redact = sync.require("./actions/redact")
|
||||
/** @type {import("./actions/update-pins")}) */
|
||||
const updatePins = sync.require("./actions/update-pins")
|
||||
/** @type {import("./actions/vote")}) */
|
||||
const vote = sync.require("./actions/vote")
|
||||
/** @type {import("../matrix/matrix-command-handler")} */
|
||||
const matrixCommandHandler = sync.require("../matrix/matrix-command-handler")
|
||||
/** @type {import("../matrix/utils")} */
|
||||
|
|
@ -175,7 +173,7 @@ async function onRetryReactionAdd(reactionEvent) {
|
|||
if (event.sender !== `@${reg.sender_localpart}:${reg.ooye.server_name}` || !error) return
|
||||
|
||||
// To stop people injecting misleading messages, the reaction needs to come from either the original sender or a room moderator
|
||||
if (reactionEvent.sender !== error.payload.sender) {
|
||||
if (reactionEvent.sender !== event.sender) {
|
||||
// Check if it's a room moderator
|
||||
const {powers: {[reactionEvent.sender]: senderPower}, powerLevels} = await utils.getEffectivePower(roomID, [reactionEvent.sender], api)
|
||||
if (senderPower < (powerLevels.state_default ?? 50)) return
|
||||
|
|
@ -220,54 +218,6 @@ async event => {
|
|||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.start", guard("org.matrix.msc3381.poll.start",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event it is a org.matrix.msc3381.poll.start because that's what this listener is filtering for
|
||||
*/
|
||||
async event => {
|
||||
if (utils.eventSenderIsFromDiscord(event.sender)) return
|
||||
const messageResponses = await sendEvent.sendEvent(event)
|
||||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
sync.addTemporaryListener(as, "type:org.matrix.msc3381.poll.response", guard("org.matrix.msc3381.poll.response",
|
||||
/**
|
||||
* @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event it is a org.matrix.msc3381.poll.response because that's what this listener is filtering for
|
||||
*/
|
||||
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",
|
||||
/**
|
||||
* @param {Ty.Event.Outer<Ty.Event.M_Reaction>} event it is a m.reaction because that's what this listener is filtering for
|
||||
|
|
@ -357,7 +307,15 @@ async event => {
|
|||
await api.ackEvent(event)
|
||||
}))
|
||||
|
||||
|
||||
function getFromInviteRoomState(inviteRoomState, nskey, key) {
|
||||
if (!Array.isArray(inviteRoomState)) return null
|
||||
for (const event of inviteRoomState) {
|
||||
if (event.type === nskey && event.state_key === "") {
|
||||
return event.content[key]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child",
|
||||
/**
|
||||
|
|
@ -390,16 +348,24 @@ async event => {
|
|||
}
|
||||
|
||||
// We were invited to a room. We should join, and register the invite details for future reference in web.
|
||||
try {
|
||||
var inviteRoomState = await api.getInviteState(event.room_id, event)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return await api.leaveRoomWithReason(event.room_id, `I wasn't able to find out what this room is. Please report this as a bug. Check console for more details. (${e.toString()})`)
|
||||
let attemptedApiMessage = "According to unsigned invite data."
|
||||
let inviteRoomState = event.unsigned?.invite_room_state
|
||||
if (!Array.isArray(inviteRoomState) || inviteRoomState.length === 0) {
|
||||
try {
|
||||
inviteRoomState = await api.getInviteState(event.room_id)
|
||||
attemptedApiMessage = "According to SSS API."
|
||||
} catch (e) {
|
||||
attemptedApiMessage = "According to unsigned invite data. SSS API unavailable: " + e.toString()
|
||||
}
|
||||
}
|
||||
if (!inviteRoomState?.name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite.`)
|
||||
const name = getFromInviteRoomState(inviteRoomState, "m.room.name", "name")
|
||||
const topic = getFromInviteRoomState(inviteRoomState, "m.room.topic", "topic")
|
||||
const avatar = getFromInviteRoomState(inviteRoomState, "m.room.avatar", "url")
|
||||
const creationType = getFromInviteRoomState(inviteRoomState, "m.room.create", "type")
|
||||
if (!name) return await api.leaveRoomWithReason(event.room_id, `Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite! (${attemptedApiMessage})`)
|
||||
await api.joinRoom(event.room_id)
|
||||
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, inviteRoomState.type, inviteRoomState.name, inviteRoomState.topic, inviteRoomState.avatar)
|
||||
if (inviteRoomState.avatar) utils.getPublicUrlForMxc(inviteRoomState.avatar) // make sure it's available in the media_proxy allowed URLs
|
||||
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar)
|
||||
if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs
|
||||
}
|
||||
|
||||
if (utils.eventSenderIsFromDiscord(event.state_key)) return
|
||||
|
|
|
|||
|
|
@ -158,80 +158,20 @@ function getStateEventOuter(roomID, type, key) {
|
|||
|
||||
/**
|
||||
* @param {string} roomID
|
||||
* @param {{unsigned?: {invite_room_state?: Ty.Event.InviteStrippedState[]}}} [event]
|
||||
* @returns {Promise<{name: string?, topic: string?, avatar: string?, type: string?}>}
|
||||
* @returns {Promise<Ty.Event.InviteStrippedState[]>}
|
||||
*/
|
||||
async function getInviteState(roomID, event) {
|
||||
function getFromInviteRoomState(strippedState, nskey, key) {
|
||||
if (!Array.isArray(strippedState)) return null
|
||||
for (const event of strippedState) {
|
||||
if (event.type === nskey && event.state_key === "") {
|
||||
return event.content[key]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Try extracting from event (if passed)
|
||||
if (Array.isArray(event?.unsigned?.invite_room_state) && event.unsigned.invite_room_state.length) {
|
||||
return {
|
||||
name: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.name", "name"),
|
||||
topic: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.topic", "topic"),
|
||||
avatar: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.avatar", "url"),
|
||||
type: getFromInviteRoomState(event.unsigned.invite_room_state, "m.room.create", "type")
|
||||
}
|
||||
}
|
||||
|
||||
// Try calling sliding sync API and extracting from stripped state
|
||||
async function getInviteState(roomID) {
|
||||
/** @type {Ty.R.SSS} */
|
||||
const root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), {
|
||||
lists: {
|
||||
a: {
|
||||
ranges: [[0, 999]],
|
||||
const root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`), {
|
||||
room_subscriptions: {
|
||||
[roomID]: {
|
||||
timeline_limit: 0,
|
||||
required_state: [],
|
||||
filters: {
|
||||
is_invite: true
|
||||
}
|
||||
required_state: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Extract from sliding sync response if valid (seems to be okay on Synapse, Tuwunel and Continuwuity at time of writing)
|
||||
if ("lists" in root) {
|
||||
if (!root.rooms?.[roomID]) {
|
||||
const e = new Error("Room data unavailable via SSS")
|
||||
e["data_sss"] = root
|
||||
throw e
|
||||
}
|
||||
|
||||
const roomResponse = root.rooms[roomID]
|
||||
const strippedState = "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state
|
||||
|
||||
return {
|
||||
name: getFromInviteRoomState(strippedState, "m.room.name", "name"),
|
||||
topic: getFromInviteRoomState(strippedState, "m.room.topic", "topic"),
|
||||
avatar: getFromInviteRoomState(strippedState, "m.room.avatar", "url"),
|
||||
type: getFromInviteRoomState(strippedState, "m.room.create", "type")
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid sliding sync response, try alternative (required for Conduit at time of writing)
|
||||
const hierarchy = await getHierarchy(roomID, {limit: 1})
|
||||
if (hierarchy?.rooms?.[0]?.room_id === roomID) {
|
||||
const room = hierarchy?.rooms?.[0]
|
||||
return {
|
||||
name: room.name ?? null,
|
||||
topic: room.topic ?? null,
|
||||
avatar: room.avatar_url ?? null,
|
||||
type: room.room_type
|
||||
}
|
||||
}
|
||||
|
||||
const e = new Error("Room data unavailable via SSS/hierarchy")
|
||||
e["data_sss"] = root
|
||||
e["data_hierarchy"] = hierarchy
|
||||
throw e
|
||||
const roomResponse = root.rooms[roomID]
|
||||
return "stripped_state" in roomResponse ? roomResponse.stripped_state : roomResponse.invite_state
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
46
src/types.d.ts
vendored
46
src/types.d.ts
vendored
|
|
@ -1,5 +1,3 @@
|
|||
import * as DiscordTypes from "discord-api-types/v10"
|
||||
|
||||
export type AppServiceRegistrationConfig = {
|
||||
id: string
|
||||
as_token: string
|
||||
|
|
@ -271,49 +269,6 @@ export namespace Event {
|
|||
|
||||
export type Outer_M_Sticker = Outer<M_Sticker> & {type: "m.sticker"}
|
||||
|
||||
export type Org_Matrix_Msc3381_Poll_Start = {
|
||||
"org.matrix.msc3381.poll.start": {
|
||||
question: {
|
||||
"org.matrix.msc1767.text": string
|
||||
body: string
|
||||
msgtype: string
|
||||
},
|
||||
kind: string
|
||||
max_selections: number
|
||||
answers: {
|
||||
id: string
|
||||
"org.matrix.msc1767.text": string
|
||||
}[]
|
||||
"org.matrix.msc1767.text": string
|
||||
}
|
||||
}
|
||||
|
||||
export type Outer_Org_Matrix_Msc3381_Poll_Start = Outer<Org_Matrix_Msc3381_Poll_Start> & {type: "org.matrix.msc3381.poll.start"}
|
||||
|
||||
export type Org_Matrix_Msc3381_Poll_Response = {
|
||||
"org.matrix.msc3381.poll.response": {
|
||||
answers: string[]
|
||||
}
|
||||
"m.relates_to": {
|
||||
rel_type: string
|
||||
event_id: string
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -433,7 +388,6 @@ export namespace R {
|
|||
guest_can_join: boolean
|
||||
join_rule?: string
|
||||
name?: string
|
||||
topic?: string
|
||||
num_joined_members: number
|
||||
room_id: string
|
||||
room_type?: string
|
||||
|
|
|
|||
|
|
@ -69,8 +69,3 @@ 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"))
|
||||
}))
|
||||
|
|
|
|||
228
test/data.js
228
test/data.js
|
|
@ -3593,233 +3593,7 @@ module.exports = {
|
|||
},
|
||||
attachments: [],
|
||||
guild_id: "286888431945252874"
|
||||
},
|
||||
poll_single_choice: {
|
||||
type: 0,
|
||||
content: "",
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
timestamp: "2025-02-15T23:19:04.127000+00:00",
|
||||
edited_timestamp: null,
|
||||
flags: 0,
|
||||
components: [],
|
||||
id: "1340462414176718889",
|
||||
channel_id: "1340048919589158986",
|
||||
author: {
|
||||
id: "307894326028140546",
|
||||
username: "ellienyaa",
|
||||
avatar: "f98417a0a0b4aecc7d7667bece353b7e",
|
||||
discriminator: "0",
|
||||
public_flags: 128,
|
||||
flags: 128,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: "unambiguously boring username",
|
||||
avatar_decoration_data: null,
|
||||
banner_color: null,
|
||||
clan: null,
|
||||
primary_guild: null
|
||||
},
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
tts: false,
|
||||
position: 0,
|
||||
poll: {
|
||||
question: {
|
||||
text: "only one answer allowed!"
|
||||
},
|
||||
answers: [
|
||||
{
|
||||
answer_id: 1,
|
||||
poll_media: {
|
||||
text: "answer one",
|
||||
emoji: {
|
||||
id: null,
|
||||
name: "\ud83d\udc4d"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
answer_id: 2,
|
||||
poll_media: {
|
||||
text: "answer two",
|
||||
emoji: {
|
||||
id: null,
|
||||
name: "\ud83d\udc4e"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
answer_id: 3,
|
||||
poll_media: {
|
||||
text: "answer three"
|
||||
}
|
||||
}
|
||||
],
|
||||
expiry: "2025-02-16T23:19:04.122364+00:00",
|
||||
allow_multiselect: false,
|
||||
layout_type: 1,
|
||||
results: {
|
||||
answer_counts: [],
|
||||
is_finalized: false
|
||||
}
|
||||
}
|
||||
},
|
||||
poll_multiple_choice: {
|
||||
type: 0,
|
||||
content: "",
|
||||
mentions: [],
|
||||
mention_roles: [],
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
timestamp: "2025-02-16T00:47:12.310000+00:00",
|
||||
edited_timestamp: null,
|
||||
flags: 0,
|
||||
components: [],
|
||||
id: "1340484594423562300",
|
||||
channel_id: "1340048919589158986",
|
||||
author: {
|
||||
id: "307894326028140546",
|
||||
username: "ellienyaa",
|
||||
avatar: "f98417a0a0b4aecc7d7667bece353b7e",
|
||||
discriminator: "0",
|
||||
public_flags: 128,
|
||||
flags: 128,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: "unambiguously boring username",
|
||||
avatar_decoration_data: null,
|
||||
banner_color: null,
|
||||
clan: null,
|
||||
primary_guild: null
|
||||
},
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
tts: false,
|
||||
position: 0,
|
||||
poll: {
|
||||
question: {
|
||||
text: "more than one answer allowed"
|
||||
},
|
||||
answers: [
|
||||
{
|
||||
answer_id: 1,
|
||||
poll_media: {
|
||||
text: "no",
|
||||
emoji: {
|
||||
id: null,
|
||||
name: "😭"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
answer_id: 2,
|
||||
poll_media: {
|
||||
text: "oh no",
|
||||
emoji: {
|
||||
id: "891723675261366292",
|
||||
name: "this"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
answer_id: 3,
|
||||
poll_media: {
|
||||
text: "oh noooooo",
|
||||
emoji: {
|
||||
id: "964520120682680350",
|
||||
name: "disapprove"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
expiry: "2025-02-17T00:47:12.307985+00:00",
|
||||
allow_multiselect: true,
|
||||
layout_type: 1,
|
||||
results: {
|
||||
answer_counts: [],
|
||||
is_finalized: false
|
||||
}
|
||||
}
|
||||
},
|
||||
poll_close: {
|
||||
type: 46,
|
||||
content: "",
|
||||
mentions: [
|
||||
{
|
||||
id: "307894326028140546",
|
||||
username: "ellienyaa",
|
||||
avatar: "f98417a0a0b4aecc7d7667bece353b7e",
|
||||
discriminator: "0",
|
||||
public_flags: 128,
|
||||
flags: 128,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: "unambiguously boring username",
|
||||
avatar_decoration_data: null,
|
||||
banner_color: null,
|
||||
clan: null,
|
||||
primary_guild: null
|
||||
}
|
||||
],
|
||||
mention_roles: [],
|
||||
attachments: [],
|
||||
embeds: [
|
||||
{
|
||||
type: "poll_result",
|
||||
fields: [
|
||||
{
|
||||
name: "poll_question_text",
|
||||
value: "test poll that's being closed",
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "victor_answer_votes",
|
||||
value: "0",
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "total_votes",
|
||||
value: "0",
|
||||
inline: false
|
||||
}
|
||||
],
|
||||
content_scan_version: 0
|
||||
}
|
||||
],
|
||||
timestamp: "2025-02-20T23:07:12.178000+00:00",
|
||||
edited_timestamp: null,
|
||||
flags: 0,
|
||||
components: [],
|
||||
id: "1342271367374049351",
|
||||
channel_id: "1340048919589158986",
|
||||
author: {
|
||||
id: "307894326028140546",
|
||||
username: "ellienyaa",
|
||||
avatar: "f98417a0a0b4aecc7d7667bece353b7e",
|
||||
discriminator: "0",
|
||||
public_flags: 128,
|
||||
flags: 128,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
global_name: "unambiguously boring username",
|
||||
avatar_decoration_data: null,
|
||||
banner_color: null,
|
||||
clan: null,
|
||||
primary_guild: null
|
||||
},
|
||||
pinned: false,
|
||||
mention_everyone: false,
|
||||
tts: false,
|
||||
message_reference: {
|
||||
type: 0,
|
||||
channel_id: "1340048919589158986",
|
||||
message_id: "1342271353990021206"
|
||||
},
|
||||
position: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
pk_message: {
|
||||
pk_reply_to_matrix: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue