Compare commits
4 commits
0dc9293f0d
...
e565342ac8
| Author | SHA1 | Date | |
|---|---|---|---|
| e565342ac8 | |||
| 2496f4c3b0 | |||
| c0bbdfde60 | |||
| bf9f6b32fd |
19 changed files with 767 additions and 24 deletions
81
src/d2m/actions/add-or-remove-vote.js
Normal file
81
src/d2m/actions/add-or-remove-vote.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// @ts-check
|
||||
|
||||
const assert = require("assert").strict
|
||||
|
||||
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")
|
||||
/** @type {import("./create-room")} */
|
||||
const createRoom = sync.require("../actions/create-room")
|
||||
|
||||
const inFlightPollVotes = new Set()
|
||||
|
||||
/**
|
||||
* @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.
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("discord-api-types/v10").GatewayMessagePollVoteAddDispatch["d"]} data
|
||||
* @param {string} parentID
|
||||
*/
|
||||
async function modifyVote(data, parentID) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
inFlightPollVotes.add(data.user_id+data.message_id)
|
||||
|
||||
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", {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: parentID,
|
||||
},
|
||||
"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
|
||||
139
src/d2m/actions/close-poll.js
Normal file
139
src/d2m/actions/close-poll.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// @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
|
||||
/** @type {import("../../matrix/api")} */
|
||||
const api = sync.require("../../matrix/api")
|
||||
/** @type {import("./register-user")} */
|
||||
const registerUser = sync.require("./register-user")
|
||||
/** @type {import("./create-room")} */
|
||||
const createRoom = sync.require("../actions/create-room")
|
||||
/** @type {import("./add-or-remove-vote.js")} */
|
||||
const vote = sync.require("../actions/add-or-remove-vote")
|
||||
/** @type {import("../../m2d/actions/channel-webhook")} */
|
||||
const channelWebhook = sync.require("../../m2d/actions/channel-webhook")
|
||||
|
||||
// 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){
|
||||
let bars = Math.floor(percent*10)
|
||||
return "█".repeat(bars) + "▒".repeat(10-bars)
|
||||
}
|
||||
|
||||
async function getAllVotes(channel_id, message_id, answer_id){
|
||||
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.
|
||||
break
|
||||
}
|
||||
if (curVotes.users[99]){
|
||||
after = curVotes.users[99].id
|
||||
}
|
||||
voteUsers = voteUsers.concat(curVotes.users)
|
||||
}
|
||||
return voteUsers
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {typeof import("../../../test/data.js")["poll_close"]} message
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
*/
|
||||
async function closePoll(message, guild){
|
||||
const pollCloseObject = message.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 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.
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
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.
|
||||
} else { // This user already voted for another option on the poll.
|
||||
updatedAnswers[userLocation].votes.push(pollOptions[i])
|
||||
}
|
||||
})
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
let combinedVotes = 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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.closePoll = closePoll
|
||||
|
|
@ -487,9 +487,8 @@ 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 !== bot) {
|
||||
if (powerLevelContent.users[mxid] >= 100 && mUtils.eventSenderIsFromDiscord(mxid) && mxid !== mUtils.bot) {
|
||||
delete powerLevelContent.users[mxid]
|
||||
await api.sendState(roomID, "m.room.power_levels", "", powerLevelContent, mxid)
|
||||
}
|
||||
|
|
@ -513,7 +512,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, bot)
|
||||
const members = db.prepare("SELECT mxid FROM sim_member WHERE room_id = ? AND mxid <> ?").pluck().all(roomID, mUtils.bot)
|
||||
const preparedDelete = db.prepare("DELETE FROM sim_member WHERE room_id = ? AND mxid = ?")
|
||||
for (const mxid of members) {
|
||||
await api.leaveRoom(roomID, mxid)
|
||||
|
|
|
|||
|
|
@ -17,6 +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/close-poll")} */
|
||||
const closePoll = sync.require("../actions/close-poll")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const dUtils = sync.require("../../discord/utils")
|
||||
|
||||
|
|
@ -31,6 +33,10 @@ async function sendMessage(message, channel, guild, row) {
|
|||
const historicalRoomIndex = select("historical_channel_room", "historical_room_index", {room_id: roomID}).pluck().get()
|
||||
assert(historicalRoomIndex)
|
||||
|
||||
if (message.type === 46) { // This is a poll_result. We might need to send a message to Discord (if there were any Matrix-side votes), regardless of if this message was sent by the bridge or not.
|
||||
await closePoll.closePoll(message, guild)
|
||||
}
|
||||
|
||||
let senderMxid = null
|
||||
if (dUtils.isWebhookMessage(message)) {
|
||||
const useWebhookProfile = select("guild_space", "webhook_profile", {guild_id: guild.id}).pluck().get() ?? 0
|
||||
|
|
@ -78,7 +84,15 @@ 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.
|
||||
}
|
||||
}
|
||||
|
||||
eventIDs.push(eventID)
|
||||
|
||||
}
|
||||
|
||||
return eventIDs
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
if (ev.old.event_type === "m.sticker") {
|
||||
// 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") {
|
||||
return false
|
||||
}
|
||||
// Anything else is fair game.
|
||||
|
|
|
|||
|
|
@ -203,6 +203,46 @@ 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
|
||||
|
|
@ -226,6 +266,20 @@ 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.
|
||||
|
|
@ -702,6 +756,12 @@ 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 || []) {
|
||||
|
|
@ -713,6 +773,10 @@ 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,3 +1551,57 @@ 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",
|
||||
"GUILDS", "GUILD_EMOJIS_AND_STICKERS", "GUILD_MESSAGES", "GUILD_MESSAGE_REACTIONS", "GUILD_MESSAGE_TYPING", "GUILD_WEBHOOKS", "GUILD_MESSAGE_POLLS",
|
||||
"MESSAGE_CONTENT"
|
||||
]
|
||||
if (reg.ooye.receive_presences !== false) intents.push("GUILD_PRESENCES")
|
||||
|
|
@ -31,7 +31,6 @@ class DiscordClient {
|
|||
this.snow = new SnowTransfer(discordToken)
|
||||
this.cloud = new CloudStorm(discordToken, {
|
||||
shards: [0],
|
||||
reconnect: true,
|
||||
snowtransferInstance: this.snow,
|
||||
intents,
|
||||
ws: {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ 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/add-or-remove-vote")} */
|
||||
const vote = sync.require("./actions/add-or-remove-vote")
|
||||
/** @type {import("../m2d/event-dispatcher")} */
|
||||
const matrixEventDispatcher = sync.require("../m2d/event-dispatcher")
|
||||
/** @type {import("../discord/interactions/matrix-info")} */
|
||||
|
|
@ -370,6 +372,14 @@ module.exports = {
|
|||
await createSpace.syncSpaceExpressions(data, false)
|
||||
},
|
||||
|
||||
async MESSAGE_POLL_VOTE_ADD(client, data){
|
||||
await vote.addVote(data)
|
||||
},
|
||||
|
||||
async MESSAGE_POLL_VOTE_REMOVE(client, data){
|
||||
await vote.removeVote(data)
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {import("./discord-client")} client
|
||||
* @param {DiscordTypes.GatewayPresenceUpdateDispatchData} data
|
||||
|
|
|
|||
19
src/db/migrations/0031-add-polls.sql
Normal file
19
src/db/migrations/0031-add-polls.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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;
|
||||
12
src/db/orm-defs.d.ts
vendored
12
src/db/orm-defs.d.ts
vendored
|
|
@ -139,6 +139,18 @@ export type Models = {
|
|||
encoded_emoji: string
|
||||
original_encoding: string | null
|
||||
}
|
||||
|
||||
poll_vote: {
|
||||
vote: string
|
||||
message_id: string
|
||||
discord_or_matrix_user_id: string
|
||||
}
|
||||
|
||||
poll_option: {
|
||||
message_id: string
|
||||
matrix_option: string
|
||||
discord_option: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Prepared<Row> = {
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ const editMessage = sync.require("../../d2m/actions/edit-message")
|
|||
const emojiSheet = sync.require("../actions/emoji-sheet")
|
||||
|
||||
/**
|
||||
* @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}[]}>}
|
||||
* @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}[]}>}
|
||||
*/
|
||||
async function resolvePendingFiles(message) {
|
||||
if (!message.pendingFiles) return message
|
||||
|
|
@ -59,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} event */
|
||||
/** @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} 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
|
||||
|
|
@ -133,6 +133,12 @@ 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const user of ensureJoined) {
|
||||
|
|
|
|||
24
src/m2d/actions/vote.js
Normal file
24
src/m2d/actions/vote.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// @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
|
||||
|
||||
/** @param {Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Response} event */
|
||||
async function updateVote(event) {
|
||||
|
||||
const messageID = select("event_message", "message_id", {event_id: event.content["m.relates_to"].event_id, event_type: "org.matrix.msc3381.poll.start"}).pluck().get()
|
||||
if (!messageID) return // Nothing can be done if the parent message was never bridged.
|
||||
|
||||
db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ?").run(event.sender, messageID) // Clear all the existing votes, since this overwrites. Technically we could check and only overwrite the changes, but the complexity isn't worth it.
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.updateVote = updateVote
|
||||
|
|
@ -517,7 +517,7 @@ 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} event
|
||||
* @param {Ty.Event.Outer_M_Room_Message | Ty.Event.Outer_M_Room_Message_File | Ty.Event.Outer_M_Sticker | Ty.Event.Outer_M_Room_Message_Encrypted_File | Ty.Event.Outer_Org_Matrix_Msc3381_Poll_Start} event
|
||||
* @param {DiscordTypes.APIGuild} guild
|
||||
* @param {DiscordTypes.APIGuildTextChannel} channel
|
||||
* @param {{api: import("../../matrix/api"), snow: import("snowtransfer").SnowTransfer, mxcDownloader: (mxc: string) => Promise<Buffer | undefined>}} di simple-as-nails dependency injection for the matrix API
|
||||
|
|
@ -544,13 +544,15 @@ 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 {Ty.SendingPoll} */
|
||||
let poll = 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
|
||||
|
|
@ -628,6 +630,24 @@ 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") {
|
||||
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
|
||||
}
|
||||
|
||||
} 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
|
||||
|
|
@ -828,7 +848,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(node) {
|
||||
async function forEachNode(event, node) {
|
||||
for (; node; node = node.nextSibling) {
|
||||
// Check written mentions
|
||||
if (node.nodeType === 3 && node.nodeValue.includes("@") && !nodeIsChildOf(node, ["A", "CODE", "PRE"])) {
|
||||
|
|
@ -876,10 +896,10 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
node.setAttribute("data-suppress", "")
|
||||
}
|
||||
}
|
||||
await forEachNode(node.firstChild)
|
||||
await forEachNode(event, node.firstChild)
|
||||
}
|
||||
}
|
||||
await forEachNode(root)
|
||||
await forEachNode(event, 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.
|
||||
|
|
@ -960,7 +980,7 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
|
||||
// Split into 2000 character chunks
|
||||
const chunks = chunk(content, 2000)
|
||||
/** @type {(DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
|
||||
/** @type {({poll?: Ty.SendingPoll} & DiscordTypes.RESTPostAPIWebhookWithTokenJSONBody & {files?: {name: string, file: Buffer | stream.Readable}[]})[]} */
|
||||
const messages = chunks.map(content => ({
|
||||
content,
|
||||
allowed_mentions: {
|
||||
|
|
@ -983,6 +1003,15 @@ async function eventToMessage(event, guild, channel, di) {
|
|||
messages[0].pendingFiles = pendingFiles
|
||||
}
|
||||
|
||||
if (poll) {
|
||||
if (!messages.length) messages.push({
|
||||
content: " ", // stopgap, remove when library updates
|
||||
username: displayNameShortened,
|
||||
avatar_url: avatarURL
|
||||
})
|
||||
messages[0].poll = poll
|
||||
}
|
||||
|
||||
const messagesToEdit = []
|
||||
const messagesToSend = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ 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")} */
|
||||
|
|
@ -173,7 +175,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 !== event.sender) {
|
||||
if (reactionEvent.sender !== error.payload.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
|
||||
|
|
@ -218,6 +220,25 @@ 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.
|
||||
}))
|
||||
|
||||
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
|
||||
|
|
@ -333,14 +354,20 @@ sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
|
|||
*/
|
||||
async event => {
|
||||
if (event.state_key[0] !== "@") return
|
||||
const bot = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
|
||||
if (event.state_key === bot) {
|
||||
if (event.state_key === utils.bot) {
|
||||
const upgraded = await roomUpgrade.onBotMembership(event, api, createRoom)
|
||||
if (upgraded) return
|
||||
}
|
||||
|
||||
if (event.content.membership === "invite" && event.state_key === bot) {
|
||||
if (event.content.membership === "invite" && event.state_key === utils.bot) {
|
||||
// Supposed to be here already?
|
||||
const guildID = select("guild_space", "guild_id", {space_id: event.room_id}).pluck().get()
|
||||
if (guildID) {
|
||||
await api.joinRoom(event.room_id)
|
||||
return
|
||||
}
|
||||
|
||||
// We were invited to a room. We should join, and register the invite details for future reference in web.
|
||||
let attemptedApiMessage = "According to unsigned invite data."
|
||||
let inviteRoomState = event.unsigned?.invite_room_state
|
||||
|
|
@ -369,7 +396,7 @@ async event => {
|
|||
db.prepare("DELETE FROM member_cache WHERE room_id = ? and mxid = ?").run(event.room_id, event.state_key)
|
||||
|
||||
// Unregister room's use as a direct chat if the bot itself left
|
||||
if (event.state_key === bot) {
|
||||
if (event.state_key === utils.bot) {
|
||||
db.prepare("DELETE FROM direct WHERE room_id = ?").run(event.room_id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ function getStateEventOuter(roomID, type, key) {
|
|||
*/
|
||||
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}`), {
|
||||
const root = await mreq.mreq("POST", path("/client/unstable/org.matrix.simplified_msc3575/sync", `@${reg.sender_localpart}:${reg.ooye.server_name}`, {timeout: "0"}), {
|
||||
room_subscriptions: {
|
||||
[roomID]: {
|
||||
timeline_limit: 0,
|
||||
|
|
|
|||
37
src/types.d.ts
vendored
37
src/types.d.ts
vendored
|
|
@ -1,3 +1,5 @@
|
|||
import * as DiscordTypes from "discord-api-types/v10"
|
||||
|
||||
export type AppServiceRegistrationConfig = {
|
||||
id: string
|
||||
as_token: string
|
||||
|
|
@ -81,6 +83,10 @@ export type WebhookAuthor = {
|
|||
id: string
|
||||
}
|
||||
|
||||
export type SendingPoll = DiscordTypes.RESTAPIPoll & {
|
||||
answers: (DiscordTypes.APIBasePollAnswer & {matrix_option: string})[]
|
||||
}
|
||||
|
||||
export type PkSystem = {
|
||||
id: string
|
||||
uuid: string
|
||||
|
|
@ -269,6 +275,37 @@ 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 M_Room_Member = {
|
||||
membership: string
|
||||
displayname?: string
|
||||
|
|
|
|||
|
|
@ -169,7 +169,10 @@ as.router.post("/api/link", defineEventHandler(async event => {
|
|||
const nick = await api.getStateEvent(parsedBody.matrix, "m.room.name", "").then(content => content.name || null).catch(() => null)
|
||||
const avatar = await api.getStateEvent(parsedBody.matrix, "m.room.avatar", "").then(content => content.url || null).catch(() => null)
|
||||
const topic = await api.getStateEvent(parsedBody.matrix, "m.room.topic", "").then(content => content.topic || null).catch(() => null)
|
||||
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic)
|
||||
db.transaction(() => {
|
||||
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id, nick, custom_avatar, custom_topic) VALUES (?, ?, ?, ?, ?, ?, ?)").run(channel.id, parsedBody.matrix, channel.name, guildID, nick, avatar, topic)
|
||||
db.prepare("INSERT INTO historical_channel_room (reference_channel_id, room_id, upgraded_timestamp) VALUES (?, ?, 0)").run(channel.id, parsedBody.matrix)
|
||||
})()
|
||||
|
||||
// Sync room data and space child
|
||||
await createRoom.syncRoom(parsedBody.discord)
|
||||
|
|
|
|||
228
test/data.js
228
test/data.js
|
|
@ -3593,7 +3593,233 @@ 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