Compare commits

...

5 Commits

Author SHA1 Message Date
Cadence Ember 043f178d1e Map Discord member permissions to sim user PLs
Including PL 20 for members who can mention everyone.
2024-03-06 17:40:06 +13:00
Cadence Ember bf3d219716 Add helper for permission calculations 2024-03-06 17:37:55 +13:00
Cadence Ember 2fb68900c7 d->m: Support permissioned @everyone -> @room
This only works if #9 is fixed in the future.
2024-03-06 13:04:51 +13:00
Cadence Ember e2d0ea41d5 Improve video embed formatting 2024-03-06 11:38:46 +13:00
Cadence Ember 1e8066ca0a Embed URL should only appear when embed has title 2024-03-06 09:45:18 +13:00
11 changed files with 927 additions and 34 deletions

View File

@ -135,6 +135,9 @@ async function channelToKState(channel, guild) {
events: {
"m.room.avatar": 0
},
notifications: {
room: 20 // TODO: Matrix users should have the same abilities as unprivileged Discord members. So make this automatically configured based on the guild or channel's default mention everyone permissions. That way if unprivileged Discord members can mention everyone, Matrix users can too.
},
users: reg.ooye.invite.reduce((a, c) => (a[c] = 100, a), {})
},
"chat.schildi.hide_ui/read_receipts": {

View File

@ -131,7 +131,7 @@ async function syncUser(author, pkMessage, roomID) {
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
// Sync the member state
const content = await memberToStateContent(pkMessage, author)
const currentHash = registerUser._hashProfileContent(content)
const currentHash = registerUser._hashProfileContent(content, 0)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) {

View File

@ -2,6 +2,8 @@
const assert = require("assert")
const reg = require("../../matrix/read-registration")
const DiscordTypes = require("discord-api-types/v10")
const mixin = require("mixin-deep")
const passthrough = require("../../passthrough")
const {discord, sync, db, select} = passthrough
@ -9,6 +11,8 @@ const {discord, sync, db, select} = passthrough
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/file")} */
const file = sync.require("../../matrix/file")
/** @type {import("../../discord/utils")} */
const utils = sync.require("../../discord/utils")
/** @type {import("../converters/user-to-mxid")} */
const userToMxid = sync.require("../converters/user-to-mxid")
/** @type {import("xxhash-wasm").XXHashAPI} */ // @ts-ignore
@ -18,7 +22,7 @@ require("xxhash-wasm")().then(h => hasher = h)
/**
* A sim is an account that is being simulated by the bridge to copy events from the other side.
* @param {import("discord-api-types/v10").APIUser} user
* @param {DiscordTypes.APIUser} user
* @returns mxid
*/
async function createSim(user) {
@ -46,7 +50,7 @@ async function createSim(user) {
/**
* Ensure a sim is registered for the user.
* If there is already a sim, use that one. If there isn't one yet, register a new sim.
* @param {import("discord-api-types/v10").APIUser} user
* @param {DiscordTypes.APIUser} user
* @returns {Promise<string>} mxid
*/
async function ensureSim(user) {
@ -62,7 +66,7 @@ async function ensureSim(user) {
/**
* Ensure a sim is registered for the user and is joined to the room.
* @param {import("discord-api-types/v10").APIUser} user
* @param {DiscordTypes.APIUser} user
* @param {string} roomID
* @returns {Promise<string>} mxid
*/
@ -92,8 +96,8 @@ async function ensureSimJoined(user, roomID) {
}
/**
* @param {import("discord-api-types/v10").APIUser} user
* @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
* @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
*/
async function memberToStateContent(user, member, guildID) {
let displayname = user.username
@ -123,8 +127,46 @@ async function memberToStateContent(user, member, guildID) {
return content
}
function _hashProfileContent(content) {
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}`)
/**
* https://gitdab.com/cadence/out-of-your-element/issues/9
* @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
* @param {DiscordTypes.APIGuild} guild
* @param {DiscordTypes.APIGuildChannel} channel
* @returns {number} 0 to 100
*/
function memberToPowerLevel(user, member, guild, channel) {
const permissions = utils.getPermissions(member.roles, guild.roles, user.id, channel.permission_overwrites)
/*
* PL 100 = Administrator = People who can brick the room. RATIONALE:
* - Administrator.
* - Manage Webhooks: People who remove the webhook can break the room.
* - Manage Guild: People who can manage guild can add bots.
* - Manage Channels: People who can manage the channel can delete it.
* (Setting sim users to PL 100 is safe because even though we can't demote the sims we can use code to make the sims demote themselves.)
*/
if (guild.owner_id === user.id || utils.hasSomePermissions(permissions, ["Administrator", "ManageWebhooks", "ManageGuild", "ManageChannels"])) return 100
/*
* PL 50 = Moderator = People who can manage people and messages in many ways. RATIONALE:
* - Manage Messages: Can moderate by pinning or deleting the conversation.
* - Manage Nicknames: Can moderate by removing inappropriate nicknames.
* - Manage Threads: Can moderate by deleting conversations.
* - Kick Members & Ban Members: Can moderate by removing disruptive people.
* - Mute Members & Deafen Members: Can moderate by silencing disruptive people in ways they can't undo.
* - Moderate Members.
*/
if (utils.hasSomePermissions(permissions, ["ManageMessages", "ManageNicknames", "ManageThreads", "KickMembers", "BanMembers", "MuteMembers", "DeafenMembers", "ModerateMembers"])) return 50
/* PL 20 = Mention Everyone for technical reasons. */
if (utils.hasSomePermissions(permissions, ["MentionEveryone"])) return 20
return 0
}
/**
* @param {any} content
* @param {number} powerLevel
*/
function _hashProfileContent(content, powerLevel) {
const unsignedHash = hasher.h64(`${content.displayname}\u0000${content.avatar_url}\u0000${powerLevel}`)
const signedHash = unsignedHash - 0x8000000000000000n // shifting down to signed 64-bit range
return signedHash
}
@ -133,20 +175,30 @@ function _hashProfileContent(content) {
* Sync profile data for a sim user. This function follows the following process:
* 1. Join the sim to the room if needed
* 2. Make an object of what the new room member state content would be, including uploading the profile picture if it hasn't been done before
* 3. Compare against the previously known state content, which is helpfully stored in the database
* 4. If the state content has changed, send it to Matrix and update it in the database for next time
* @param {import("discord-api-types/v10").APIUser} user
* @param {Omit<import("discord-api-types/v10").APIGuildMember, "user">} member
* 3. Calculate the power level the user should get based on their Discord permissions
* 4. Compare against the previously known state content, which is helpfully stored in the database
* 5. If the state content or power level have changed, send them to Matrix and update them in the database for next time
* @param {DiscordTypes.APIUser} user
* @param {Omit<DiscordTypes.APIGuildMember, "user">} member
* @param {DiscordTypes.APIGuild} guild
* @param {DiscordTypes.APIGuildChannel} channel
* @returns {Promise<string>} mxid of the updated sim
*/
async function syncUser(user, member, guildID, roomID) {
async function syncUser(user, member, guild, channel, roomID) {
const mxid = await ensureSimJoined(user, roomID)
const content = await memberToStateContent(user, member, guildID)
const currentHash = _hashProfileContent(content)
const content = await memberToStateContent(user, member, guild.id)
const powerLevel = memberToPowerLevel(user, member, guild, channel)
const currentHash = _hashProfileContent(content, powerLevel)
const existingHash = select("sim_member", "hashed_profile_content", {room_id: roomID, mxid}).safeIntegers().pluck().get()
// only do the actual sync if the hash has changed since we last looked
if (existingHash !== currentHash) {
// Update room member state
await api.sendState(roomID, "m.room.member", mxid, content, mxid)
// Update power levels
const powerLevelsStateContent = await api.getStateEvent(roomID, "m.room.power_levels", "")
mixin(powerLevelsStateContent, {users: {[mxid]: powerLevel}})
api.sendState(roomID, "m.room.power_levels", "", powerLevelsStateContent)
// Update cached hash
db.prepare("UPDATE sim_member SET hashed_profile_content = ? WHERE room_id = ? AND mxid = ?").run(currentHash, roomID, mxid)
}
return mxid
@ -158,23 +210,25 @@ async function syncAllUsersInRoom(roomID) {
const channelID = select("channel_room", "channel_id", {room_id: roomID}).pluck().get()
assert.ok(typeof channelID === "string")
/** @ts-ignore @type {import("discord-api-types/v10").APIGuildChannel} */
/** @ts-ignore @type {DiscordTypes.APIGuildChannel} */
const channel = discord.channels.get(channelID)
const guildID = channel.guild_id
assert.ok(typeof guildID === "string")
/** @ts-ignore @type {DiscordTypes.APIGuild} */
const guild = discord.guilds.get(guildID)
for (const mxid of mxids) {
const userID = select("sim", "user_id", {mxid}).pluck().get()
assert.ok(typeof userID === "string")
/** @ts-ignore @type {Required<import("discord-api-types/v10").APIGuildMember>} */
/** @ts-ignore @type {Required<DiscordTypes.APIGuildMember>} */
const member = await discord.snow.guild.getGuildMember(guildID, userID)
/** @ts-ignore @type {Required<import("discord-api-types/v10").APIUser>} user */
/** @ts-ignore @type {Required<DiscordTypes.APIUser>} user */
const user = member.user
assert.ok(user)
console.log(`[user sync] to matrix: ${user.username} in ${channel.name}`)
await syncUser(user, member, guildID, roomID)
await syncUser(user, member, guild, channel, roomID)
}
}

View File

@ -27,7 +27,6 @@ test("message2event embeds: reply with just an embed", async t => {
msgtype: "m.notice",
"m.mentions": {},
body: "| ## ⏺️ dynastic (@dynastic) https://twitter.com/i/user/719631291747078145"
+ "\n| \n| ## https://twitter.com/i/status/1707484191963648161"
+ "\n| \n| does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?"
+ "\n| \n| ### Retweets"
+ "\n| 119"
@ -35,8 +34,7 @@ test("message2event embeds: reply with just an embed", async t => {
+ "\n| 5581"
+ "\n| — Twitter",
format: "org.matrix.custom.html",
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong></p>'
+ '<p><strong><a href="https://twitter.com/i/status/1707484191963648161">https://twitter.com/i/status/1707484191963648161</a></strong>'
formatted_body: '<blockquote><p><strong><a href="https://twitter.com/i/user/719631291747078145">⏺️ dynastic (@dynastic)</a></strong>'
+ '</p><p>does anyone know where to find that one video of the really mysterious yam-like object being held up to a bunch of random objects, like clocks, and they have unexplained impossible reactions to it?'
+ '</p><p><strong>Retweets</strong><br>119</p><p><strong>Likes</strong><br>5581</p>— Twitter</blockquote>'
}])
@ -141,3 +139,146 @@ test("message2event embeds: crazy html is all escaped", async t => {
"m.mentions": {}
}])
})
test("message2event embeds: title without url", async t => {
const events = await messageToEvent(data.message_with_embeds.title_without_url, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Hi, I'm Amanda!\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong>Hi, I'm Amanda!</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: url without title", async t => {
const events = await messageToEvent(data.message_with_embeds.url_without_title, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: author without url", async t => {
const events = await messageToEvent(data.message_with_embeds.author_without_url, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| ## Amanda\n| \n| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><strong>Amanda</strong></p><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: author url without name", async t => {
const events = await messageToEvent(data.message_with_embeds.author_url_without_name, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.notice",
body: "| I condone pirating music!",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p>I condone pirating music!</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: vx image", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_image, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://vxtwitter.com/TomorrowCorp/status/1760330671074287875 we got a release date!!!",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">https://vxtwitter.com/TomorrowCorp/status/1760330671074287875</a> we got a release date!!!',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
+ "\n| "
+ "\n| ## Twitter https://twitter.com/tomorrowcorp/status/1760330671074287875"
+ "\n| "
+ "\n| ## Tomorrow Corporation (@TomorrowCorp) https://vxtwitter.com/TomorrowCorp/status/1760330671074287875"
+ "\n| "
+ "\n| Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux)."
+ "\n| "
+ "\n| https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms"
+ "\n| "
+ "\n| 💖 123 🔁 36"
+ "\n| "
+ "\n| 📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
+ `</p><p><strong><a href="https://twitter.com/tomorrowcorp/status/1760330671074287875">Twitter</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/TomorrowCorp/status/1760330671074287875">Tomorrow Corporation (@TomorrowCorp)</a></strong>`
+ `</p><p>Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and <a href="http://WorldOfGoo2.com">http://WorldOfGoo2.com</a> (Win/Mac/Linux).`
+ `<br><br><a href="https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms">https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms</a>`
+ `<br><br>💖 123 🔁 36`
+ `</p><p>📸 https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: vx video", async t => {
const events = await messageToEvent(data.message_with_embeds.vx_video, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://vxtwitter.com/McDonalds/status/1759971752254341417",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">https://vxtwitter.com/McDonalds/status/1759971752254341417</a>',
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via vxTwitter / fixvx https://github.com/dylanpdx/BetterTwitFix"
+ "\n| \n| ## McDonalds🤝@studiopierrot"
+ "\n| \n| 💖 89 🔁 21 https://twitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| ## McDonald's (@McDonalds) https://vxtwitter.com/McDonalds/status/1759971752254341417"
+ "\n| \n| 🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter / fixvx</a></sub>`
+ `</p><p><strong><a href="https://twitter.com/McDonalds/status/1759971752254341417">McDonalds🤝@studiopierrot\n\n💖 89 🔁 21</a></strong>`
+ `</p><p><strong><a href="https://vxtwitter.com/McDonalds/status/1759971752254341417">McDonald's (@McDonalds)</a></strong>`
+ `</p><p>🎞️ https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12</p></blockquote>`,
"m.mentions": {}
}])
})
test("message2event embeds: youtube video", async t => {
const events = await messageToEvent(data.message_with_embeds.youtube_video, data.guild.general)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E">https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E</a><br><br><br>Jutomi I'm gonna make these sounds in your walls tonight`,
"m.mentions": {}
}, {
$type: "m.room.message",
msgtype: "m.notice",
body: "| via YouTube https://www.youtube.com"
+ "\n| \n| ## Happy O Funny https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
+ "\n| \n| ## Shoebill stork clattering sounds like machine guun~!! (Japan Matsue... https://www.youtube.com/watch?v=kDMHHw8JqLE"
+ "\n| \n| twitter"
+ "\n| https://twitter.com/matsuevogelpark"
+ "\n| \n| The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill"
+ "\n| some people also called them the living dinosaur~~"
+ "\n| \n| #shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun..."
+ "\n| \n| 🎞️ https://www.youtube.com/embed/kDMHHw8JqLE",
format: "org.matrix.custom.html",
formatted_body: `<blockquote><p><sub><a href="https://www.youtube.com">YouTube</a></sub></p>`
+ `<p><strong><a href="https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg">Happy O Funny</a></strong>`
+ `</p><p><strong><a href="https://www.youtube.com/watch?v=kDMHHw8JqLE">Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...</a></strong>`
+ `</p><p>twitter<br><a href="https://twitter.com/matsuevogelpark">https://twitter.com/matsuevogelpark</a><br><br>The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill<br>some people also called them the living dinosaur~~<br><br>#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...`
+ `</p><p>🎞️ https://www.youtube.com/embed/kDMHHw8JqLE`
+ `</p></blockquote>`,
"m.mentions": {}
}])
})

View File

@ -249,6 +249,8 @@ async function messageToEvent(message, guild, options = {}, di) {
let repliedToEventRow = null
let repliedToEventSenderMxid = null
if (message.mention_everyone) mentions.room = true
function addMention(mxid) {
if (!mentions.user_ids) mentions.user_ids = []
if (!mentions.user_ids.includes(mxid)) mentions.user_ids.push(mxid)
@ -480,7 +482,7 @@ async function messageToEvent(message, guild, options = {}, di) {
// Mentions scenario 3: scan the message content for written @mentions of matrix users. Allows for up to one space between @ and mention.
const matches = [...message.content.matchAll(/@ ?([a-z0-9._]+)\b/gi)]
if (matches.length && matches.some(m => m[1].match(/[a-z]/i))) {
if (matches.length && matches.some(m => m[1].match(/[a-z]/i) && m[1] !== "everyone" && m[1] !== "here")) {
const writtenMentionsText = matches.map(m => m[1].toLowerCase())
const roomID = select("channel_room", "room_id", {channel_id: message.channel_id}).pluck().get()
assert(roomID)
@ -514,10 +516,19 @@ async function messageToEvent(message, guild, options = {}, di) {
// Start building up a replica ("rep") of the embed in Discord-markdown format, which we will convert into both plaintext and formatted body at once
const rep = new mxUtils.MatrixStringBuilder()
// Provider
if (embed.provider?.name) {
if (embed.provider.url) {
rep.addParagraph(`via ${embed.provider.name} ${embed.provider.url}`, tag`<sub><a href="${embed.provider.url}">${embed.provider.name}</a></sub>`)
} else {
rep.addParagraph(`via ${embed.provider.name}`, tag`<sub>${embed.provider.name}</sub>`)
}
}
// Author and URL into a paragraph
let authorNameText = embed.author?.name || ""
if (authorNameText && embed.author?.icon_url) authorNameText = `⏺️ ${authorNameText}` // using the emoji instead of an image
if (authorNameText || embed.author?.url) {
if (authorNameText) {
if (embed.author?.url) {
const authorURL = await transformContentMessageLinks(embed.author.url)
rep.addParagraph(`## ${authorNameText} ${authorURL}`, tag`<strong><a href="${authorURL}">${authorNameText}</a></strong>`)
@ -534,11 +545,11 @@ async function messageToEvent(message, guild, options = {}, di) {
} else {
rep.addParagraph(`## ${body}`, `<strong>${html}</strong>`)
}
} else if (embed.url) {
rep.addParagraph(`## ${embed.url}`, tag`<strong><a href="${embed.url}">${embed.url}</a></strong>`)
}
if (embed.description) {
let embedTypeShouldShowDescription = embed.type !== "video" // Discord doesn't display descriptions for videos
if (embed.provider?.name === "YouTube") embedTypeShouldShowDescription = true // But I personally like showing the descriptions for YouTube videos specifically
if (embed.description && embedTypeShouldShowDescription) {
const {body, html} = await transformContent(embed.description)
rep.addParagraph(body, html)
}
@ -552,7 +563,11 @@ async function messageToEvent(message, guild, options = {}, di) {
rep.addParagraph(fieldRep.get().body, fieldRep.get().formatted_body)
}
if (embed.image?.url) rep.addParagraph(`📸 ${embed.image.url}`)
let chosenImage = embed.image?.url
// the thumbnail seems to be used for "article" type but displayed big at the bottom by discord
if (embed.type === "article" && embed.thumbnail?.url && !chosenImage) chosenImage = embed.thumbnail.url
if (chosenImage) rep.addParagraph(`📸 ${chosenImage}`)
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)

View File

@ -789,3 +789,63 @@ test("message2event: crossposted announcements say where they are crossposted fr
formatted_body: "🔀 <strong>Chewey Bot Official Server #announcements</strong><br>All text based commands are now inactive on Chewey Bot<br>To continue using commands you'll need to use them as slash commands"
}])
})
test("message2event: @everyone", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@room",
"m.mentions": {
room: true
}
}])
})
test("message2event: @here", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_here)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@room",
"m.mentions": {
room: true
}
}])
})
test("message2event: @everyone without permission", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone_without_permission)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
format: "org.matrix.custom.html",
formatted_body: "@everyone &lt;-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
"m.mentions": {}
}])
})
test("message2event: @here without permission", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_here_without_permission)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
format: "org.matrix.custom.html",
formatted_body: "@here &lt;-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
"m.mentions": {}
}])
})
test("message2event: @everyone within a link", async t => {
const events = await messageToEvent(data.message_mention_everyone.at_everyone_within_link)
t.deepEqual(events, [{
$type: "m.room.message",
msgtype: "m.text",
body: "https://github.com/@everyone",
format: "org.matrix.custom.html",
formatted_body: `<a href="https://github.com/@everyone">https://github.com/@everyone</a>`,
"m.mentions": {}
}])
})

View File

@ -12,7 +12,7 @@ const utils = sync.require("../../m2d/converters/utils")
* @typedef ReactionRemoveRequest
* @prop {string} eventID
* @prop {string | null} mxid
* @prop {BigInt} [hash]
* @prop {bigint} [hash]
*/
/**

View File

@ -115,8 +115,7 @@ module.exports = {
if (!member) return
if (!("permission_overwrites" in channel)) continue
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
if ((permissions & wants) !== wants) continue // We don't have permission to look back in this channel
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look back in this channel
/** More recent messages come first. */
// console.log(`[check missed messages] in ${channel.id} (${guild.name} / ${channel.name}) because its last message ${channel.last_message_id} is not in the database`)
@ -164,8 +163,7 @@ module.exports = {
// Permissions check
const permissions = dUtils.getPermissions(member.roles, guild.roles, client.user.id, channel.permission_overwrites)
const wants = BigInt(1 << 10) | BigInt(1 << 16) // VIEW_CHANNEL + READ_MESSAGE_HISTORY
if ((permissions & wants) !== wants) continue // We don't have permission to look up the pins in this channel
if (!dUtils.hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])) continue // We don't have permission to look up the pins in this channel
const row = select("channel_room", ["room_id", "last_bridged_pin_timestamp"], {channel_id: channel.id}).get()
if (!row) continue // Only care about already bridged channels

View File

@ -137,7 +137,7 @@ const commands = [{
// Check CREATE_INSTANT_INVITE permission
assert(message.member)
const guildPermissions = utils.getPermissions(message.member.roles, guild.roles)
if (!(guildPermissions & BigInt(1))) {
if (!(guildPermissions & DiscordTypes.PermissionFlagsBits.CreateInstantInvite)) {
return discord.snow.channel.createMessage(channel.id, {
...ctx,
content: "You don't have permission to invite people to this Discord server."

View File

@ -1,6 +1,7 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const assert = require("assert").strict
const EPOCH = 1420070400000
@ -49,6 +50,48 @@ function getPermissions(userRoles, guildRoles, userID, channelOverwrites) {
return allowed
}
/**
* Note: You can only provide one permission bit to permissionToCheckFor. To check multiple permissions, call `hasAllPermissions` or `hasSomePermissions`.
* It is designed like this to avoid developer error with bit manipulations.
*
* @param {bigint} resolvedPermissions
* @param {bigint} permissionToCheckFor
* @returns {boolean} whether the user has the requested permission
* @example
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
* hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
*/
function hasPermission(resolvedPermissions, permissionToCheckFor) {
// Make sure permissionToCheckFor has exactly one permission in it
assert.equal(permissionToCheckFor.toString(2).match(/1/g), 1)
// Do the actual calculation
return (resolvedPermissions & permissionToCheckFor) === permissionToCheckFor
}
/**
* @param {bigint} resolvedPermissions
* @param {(keyof DiscordTypes.PermissionFlagsBits)[]} permissionsToCheckFor
* @returns {boolean} whether the user has any of the requested permissions
* @example
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
* hasSomePermissions(permissions, ["ViewChannel", "ReadMessageHistory"])
*/
function hasSomePermissions(resolvedPermissions, permissionsToCheckFor) {
return permissionsToCheckFor.some(x => hasPermission(resolvedPermissions, DiscordTypes.PermissionFlagsBits[x]))
}
/**
* @param {bigint} resolvedPermissions
* @param {(keyof DiscordTypes.PermissionFlagsBits)[]} permissionsToCheckFor
* @returns {boolean} whether the user has all of the requested permissions
* @example
* const permissions = getPermissions(userRoles, guildRoles, userID, channelOverwrites)
* hasAllPermissions(permissions, ["ViewChannel", "ReadMessageHistory"])
*/
function hasAllPermissions(resolvedPermissions, permissionsToCheckFor) {
return permissionsToCheckFor.every(x => hasPermission(resolvedPermissions, DiscordTypes.PermissionFlagsBits[x]))
}
/**
* Command interaction responses have a webhook_id for some reason, but still have real author info of a real bot user in the server.
* @param {DiscordTypes.APIMessage} message
@ -69,6 +112,9 @@ function timestampToSnowflakeInexact(timestamp) {
}
module.exports.getPermissions = getPermissions
module.exports.hasPermission = hasPermission
module.exports.hasSomePermissions = hasSomePermissions
module.exports.hasAllPermissions = hasAllPermissions
module.exports.isWebhookMessage = isWebhookMessage
module.exports.snowflakeToTimestampExact = snowflakeToTimestampExact
module.exports.timestampToSnowflakeInexact = timestampToSnowflakeInexact

View File

@ -1925,6 +1925,163 @@ module.exports = {
webhook_id: "1195662438662680720"
}
},
message_mention_everyone: {
at_everyone: {
id: "1214510099058655252",
type: 0,
content: "@everyone",
channel_id: "1100319550446252084",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: true,
tts: false,
timestamp: "2024-03-05T09:49:32.122000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
at_here: {
id: "1214510192230797332",
type: 0,
content: "@here",
channel_id: "1100319550446252084",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: true,
tts: false,
timestamp: "2024-03-05T09:49:54.336000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
at_everyone_without_permission: {
id: "1214510346623258654",
type: 0,
content: "@everyone <-- this is testing that it DOESN'T mention. if this mentions everyone then my apologies.",
channel_id: "112760669178241024",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-03-05T09:50:31.146000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
at_here_without_permission: {
id: "1214510346623258654",
type: 0,
content: "@here <-- this is testing that it DOESN'T mention. if this mentions people then my apologies.",
channel_id: "112760669178241024",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-03-05T09:50:31.146000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
at_everyone_within_link: {
id: "1214510225885888563",
type: 0,
content: "https://github.com/@everyone",
channel_id: "1100319550446252084",
author: {
id: "772659086046658620",
username: "cadence.worm",
avatar: "4b5c4b28051144e4c111f0113a0f1cf1",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "cadence",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-03-05T09:50:02.360000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
},
message_with_embeds: {
nothing_but_a_field: {
guild_id: "497159726455455754",
@ -2123,6 +2280,185 @@ module.exports = {
attachments: [],
guild_id: "1150201337112449045"
},
vx_image: {
id: "1209926442981269544",
type: 0,
content: "https://vxtwitter.com/TomorrowCorp/status/1760330671074287875 we got a release date!!!",
channel_id: "288058913985789953",
author: {
id: "113340068197859328",
username: "kumaccino",
avatar: "b48302623a12bc7c59a71328f72ccb39",
discriminator: "0",
public_flags: 128,
premium_type: 0,
flags: 128,
banner: null,
accent_color: null,
global_name: "kumaccino",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [
{
type: "article",
url: "https://vxtwitter.com/TomorrowCorp/status/1760330671074287875",
title: "Tomorrow Corporation (@TomorrowCorp)",
description: "Mark your calendar with a wet black stain! World of Goo 2 releases on May 23, 2024 on Nintendo Switch, Epic Games Store (Win/Mac), and http://WorldOfGoo2.com (Win/Mac/Linux).\n" +
"\n" +
"https://tomorrowcorporation.com/posts/world-of-goo-2-now-with-100-more-release-dates-and-platforms\n" +
"\n" +
"💖 123 🔁 36",
color: 8388564,
author: {
name: "Twitter",
url: "https://twitter.com/tomorrowcorp/status/1760330671074287875"
},
provider: {
name: "vxTwitter / fixvx",
url: "https://github.com/dylanpdx/BetterTwitFix"
},
thumbnail: {
url: "https://pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
proxy_url: "https://images-ext-2.discordapp.net/external/eqA-NKoXzJ0Y_l-MlwN6shFDJibC0TbPxMNWSU5IpKY/https/pbs.twimg.com/media/GG3zUMGbIAAxs3h.jpg",
width: 1200,
height: 1200,
placeholder: "5SgKDwTIlqiPjIhzlspniIiNaN8It3AD",
placeholder_version: 1
}
}
],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-02-21T18:15:43.353000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
vx_video: {
id: "1209804622206599190",
type: 0,
content: "https://vxtwitter.com/McDonalds/status/1759971752254341417",
channel_id: "112760669178241024",
author: {
id: "113340068197859328",
username: "kumaccino",
avatar: "b48302623a12bc7c59a71328f72ccb39",
discriminator: "0",
public_flags: 128,
premium_type: 0,
flags: 128,
banner: null,
accent_color: null,
global_name: "kumaccino",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [
{
type: "video",
url: "https://vxtwitter.com/McDonalds/status/1759971752254341417",
title: "McDonald's (@McDonalds)",
description: "McDonalds🤝@studiopierrot\n\n💖 89 🔁 21",
color: 8388564,
author: {
name: "McDonalds🤝@studiopierrot\n\n💖 89 🔁 21",
url: "https://twitter.com/McDonalds/status/1759971752254341417"
},
provider: {
name: "vxTwitter / fixvx",
url: "https://github.com/dylanpdx/BetterTwitFix"
},
video: {
url: "https://video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4?tag=12",
proxy_url: "https://images-ext-1.discordapp.net/external/TInoGDskHFBRSQR0ErWEmvmzi75EO28aSyiEXs3SB8E/%3Ftag%3D12/https/video.twimg.com/ext_tw_video/1759967449548541952/pu/vid/avc1/1280x720/XN1LFIJqAFBdtaoh.mp4",
width: 1280,
height: 720,
placeholder: "AggGBIAIp4iGeYdxjHgAAAAAAA==",
placeholder_version: 1
}
}
],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-02-21T10:11:39.017000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
youtube_video: {
id: "1214383754479534100",
type: 0,
content: "https://youtu.be/kDMHHw8JqLE?si=NaqNjVTtXugHeG_E\n\n\nJutomi I'm gonna make these sounds in your walls tonight",
channel_id: "112760669178241024",
author: {
id: "1060361805152669766",
username: "occimyy",
avatar: "3bf268de3eab1c5441da9585534d8aa5",
discriminator: "0",
public_flags: 0,
premium_type: 0,
flags: 0,
banner: null,
accent_color: null,
global_name: "Occimyy",
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [
{
type: "video",
url: "https://www.youtube.com/watch?v=kDMHHw8JqLE",
title: "Shoebill stork clattering sounds like machine guun~!! (Japan Matsue...",
description: "twitter\n" +
"https://twitter.com/matsuevogelpark\n" +
"\n" +
"The shoebill (Balaeniceps rex) also known as whalehead, whale-headed stork, or shoe-billed stork, is a very large stork-like bird. It derives its name from its enormous shoe-shaped bill\n" +
"some people also called them the living dinosaur~~\n" +
"\n" +
"#shoebill #livingdinosaur #happyofunny #weirdcreature #weirdsoun...",
color: 16711680,
author: {
name: "Happy O Funny",
url: "https://www.youtube.com/channel/UCEpQ9aEb1NafpvWp5Aoizrg"
},
provider: { name: "YouTube", url: "https://www.youtube.com" },
thumbnail: {
url: "https://i.ytimg.com/vi/kDMHHw8JqLE/maxresdefault.jpg",
proxy_url: "https://images-ext-1.discordapp.net/external/eEPOxZQXfTHqvPQJBWqsgG3wxTQN20b8LXqw3jSqyRM/https/i.ytimg.com/vi/kDMHHw8JqLE/maxresdefault.jpg",
width: 1280,
height: 720,
placeholder: "WAgSDIIIdIprl4h4h4dNoEoEaQ==",
placeholder_version: 1
},
video: {
url: "https://www.youtube.com/embed/kDMHHw8JqLE",
width: 1280,
height: 720,
placeholder: "WAgSDIIIdIprl4h4h4dNoEoEaQ==",
placeholder_version: 1
}
}
],
mentions: [],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2024-03-05T01:27:29.227000+00:00",
edited_timestamp: null,
flags: 0,
components: []
},
image_embed_and_attachment: {
id: "1157854642810654821",
type: 0,
@ -2282,6 +2618,246 @@ module.exports = {
edited_timestamp: null,
flags: 0,
components: []
},
title_without_url: {
guild_id: "497159726455455754",
mentions: [],
id: "1141934888862351440",
type: 20,
content: "",
channel_id: "497161350934560778",
author: {
id: "1109360903096369153",
username: "Amanda 🎵",
avatar: "d56cd1b26e043ae512edae2214962faa",
discriminator: "2192",
public_flags: 524288,
flags: 524288,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [
{
type: "rich",
color: 3092790,
title: "Hi, I'm Amanda!",
description: "I condone pirating music!"
}
],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-08-18T03:21:33.629000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
application_id: "1109360903096369153",
interaction: {
id: "1141934887608254475",
type: 2,
name: "stats",
user: {
id: "320067006521147393",
username: "papiophidian",
avatar: "47a19b0445069b826e136da4df4259bb",
discriminator: "0",
public_flags: 4194880,
flags: 4194880,
banner: null,
accent_color: null,
global_name: "PapiOphidian",
avatar_decoration_data: null,
banner_color: null
}
},
webhook_id: "1109360903096369153"
},
url_without_title: {
guild_id: "497159726455455754",
mentions: [],
id: "1141934888862351440",
type: 20,
content: "",
channel_id: "497161350934560778",
author: {
id: "1109360903096369153",
username: "Amanda 🎵",
avatar: "d56cd1b26e043ae512edae2214962faa",
discriminator: "2192",
public_flags: 524288,
flags: 524288,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [
{
type: "rich",
color: 3092790,
url: "https://amanda.moe",
description: "I condone pirating music!"
}
],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-08-18T03:21:33.629000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
application_id: "1109360903096369153",
interaction: {
id: "1141934887608254475",
type: 2,
name: "stats",
user: {
id: "320067006521147393",
username: "papiophidian",
avatar: "47a19b0445069b826e136da4df4259bb",
discriminator: "0",
public_flags: 4194880,
flags: 4194880,
banner: null,
accent_color: null,
global_name: "PapiOphidian",
avatar_decoration_data: null,
banner_color: null
}
},
webhook_id: "1109360903096369153"
},
author_without_url: {
guild_id: "497159726455455754",
mentions: [],
id: "1141934888862351440",
type: 20,
content: "",
channel_id: "497161350934560778",
author: {
id: "1109360903096369153",
username: "Amanda 🎵",
avatar: "d56cd1b26e043ae512edae2214962faa",
discriminator: "2192",
public_flags: 524288,
flags: 524288,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [
{
type: "rich",
color: 3092790,
author: {
name: "Amanda"
},
description: "I condone pirating music!"
}
],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-08-18T03:21:33.629000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
application_id: "1109360903096369153",
interaction: {
id: "1141934887608254475",
type: 2,
name: "stats",
user: {
id: "320067006521147393",
username: "papiophidian",
avatar: "47a19b0445069b826e136da4df4259bb",
discriminator: "0",
public_flags: 4194880,
flags: 4194880,
banner: null,
accent_color: null,
global_name: "PapiOphidian",
avatar_decoration_data: null,
banner_color: null
}
},
webhook_id: "1109360903096369153"
},
author_url_without_name: {
guild_id: "497159726455455754",
mentions: [],
id: "1141934888862351440",
type: 20,
content: "",
channel_id: "497161350934560778",
author: {
id: "1109360903096369153",
username: "Amanda 🎵",
avatar: "d56cd1b26e043ae512edae2214962faa",
discriminator: "2192",
public_flags: 524288,
flags: 524288,
bot: true,
banner: null,
accent_color: null,
global_name: null,
avatar_decoration_data: null,
banner_color: null
},
attachments: [],
embeds: [
{
type: "rich",
color: 3092790,
author: {
url: "https://amanda.moe"
},
description: "I condone pirating music!"
}
],
mention_roles: [],
pinned: false,
mention_everyone: false,
tts: false,
timestamp: "2023-08-18T03:21:33.629000+00:00",
edited_timestamp: null,
flags: 0,
components: [],
application_id: "1109360903096369153",
interaction: {
id: "1141934887608254475",
type: 2,
name: "stats",
user: {
id: "320067006521147393",
username: "papiophidian",
avatar: "47a19b0445069b826e136da4df4259bb",
discriminator: "0",
public_flags: 4194880,
flags: 4194880,
banner: null,
accent_color: null,
global_name: "PapiOphidian",
avatar_decoration_data: null,
banner_color: null
}
},
webhook_id: "1109360903096369153"
}
},
message_update: {