Compare commits

...

22 commits

Author SHA1 Message Date
07d6eb3c12 Fix existingPartZero assertion error 2024-11-02 20:35:52 +13:00
15e5b17b0d When inviting bot, check it has bot scope 2024-11-02 19:22:30 +13:00
14115c0e06 Attempt retrigger after speedbump 2024-11-01 17:25:11 +13:00
0d8b9d5705 Forwarded messages code coverage and plaintext fix 2024-11-01 16:50:28 +13:00
1b539cfa64 Forwarding text messages 2024-11-01 16:39:56 +13:00
b23b818192 Use attachment proxy for external_url 2024-10-31 17:34:50 +13:00
49948ae2c1 Support forwarded messages 2024-10-31 17:34:25 +13:00
ac165845d7 Remove unused parameter 2024-10-31 14:42:15 +13:00
cce432aeee Compatibility: send {} with room joins
Now compatible with the spec and with condu(wu)it.
2024-10-31 11:55:54 +13:00
e5f7c7fdcb Proxy discord attachment links within embeds 2024-10-31 11:53:34 +13:00
4167a01ed1 Add test template for forwarded message 2024-10-25 16:51:20 +13:00
c127923f4d Make the link button do something 2024-10-18 16:35:47 +13:00
da5525a542 Make invite interaction async
Fix potential lag issues
2024-10-14 13:09:40 +13:00
6f7ed829b8 Create and populate guild_id column 2024-10-05 02:23:58 +13:00
5a86c07eb9 Host QR codes locally 2024-10-04 02:21:57 +13:00
4287a329f5 Display list of unlinked rooms 2024-10-03 17:21:42 +13:00
086e8cdc25 Add privacy level controls on web 2024-10-03 03:26:49 +13:00
9f9d1f615e Test coverage for all interactions 2024-09-30 23:35:09 +13:00
3662ee5db6 Fix interaction updates 2024-09-30 22:50:19 +13:00
d72b162fe7 Mobile design 2024-09-30 17:24:26 +13:00
b79b010568 Update heatsync dependency 2024-09-30 16:46:20 +13:00
f77602afa6 Add tests for privacy interaction 2024-09-30 16:26:12 +13:00
36 changed files with 1200 additions and 215 deletions

15
package-lock.json generated
View file

@ -29,7 +29,7 @@
"entities": "^5.0.0",
"get-stream": "^6.0.1",
"h3": "^1.12.0",
"heatsync": "^2.5.3",
"heatsync": "^2.5.5",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"node-fetch": "^2.6.7",
@ -38,6 +38,7 @@
"snowtransfer": "^0.10.5",
"stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
"zod": "^3.23.8"
},
@ -1929,9 +1930,9 @@
}
},
"node_modules/heatsync": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.4.tgz",
"integrity": "sha512-KzsM+wR0MIykD80kCHNZCpNvFY4uC1Yze8R37eehJyGIvEepJd+7ubczh6FVoBFtK0nVEszt5Hl8AbzUvb+vMQ==",
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/heatsync/-/heatsync-2.5.5.tgz",
"integrity": "sha512-Sy2/X2a69W2W1xgp7GBY81naHtWXxwV8N6uzPTJLQXgq4oTMJeL6F/AUlGS+fUa/Pt5ioxzi7gvd8THMJ3GpyA==",
"dependencies": {
"backtracker": "^4.0.0"
}
@ -3237,6 +3238,12 @@
"pathe": "^1.1.2"
}
},
"node_modules/uqr": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz",
"integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View file

@ -38,7 +38,7 @@
"entities": "^5.0.0",
"get-stream": "^6.0.1",
"h3": "^1.12.0",
"heatsync": "^2.5.3",
"heatsync": "^2.5.5",
"lru-cache": "^10.4.3",
"minimist": "^1.2.8",
"node-fetch": "^2.6.7",
@ -47,6 +47,7 @@
"snowtransfer": "^0.10.5",
"stream-mime-type": "^1.0.2",
"try-to-catch": "^3.0.1",
"uqr": "^0.1.2",
"xxhash-wasm": "^1.0.2",
"zod": "^3.23.8"
},

View file

@ -195,5 +195,6 @@ Total transitive production dependencies: 147
* (0) prettier-bytes: It does what I want and has no dependencies.
* (2) snowtransfer: Discord API library with bring-your-own-caching that I trust.
* (0) try-to-catch: Not strictly necessary, but it's already pulled in by supertape, so I may as well.
* (0) uqr: QR code SVG generator. Used on the website to scan in an invite link.
* (0) xxhash-wasm: Used where cryptographically secure hashing is not required.
* (0) zod: Input validation for the web server. It's popular and easy to use.

View file

@ -39,7 +39,6 @@ const passthrough = require("../src/passthrough")
const db = new sqlite("ooye.db")
const migrate = require("../src/db/migrate")
/** @type {import("heatsync").default} */ // @ts-ignore
const sync = new HeatSync({watchFS: false})
Object.assign(passthrough, {sync, db})

View file

@ -12,7 +12,6 @@ const {reg} = require("../src/matrix/read-registration")
const passthrough = require("../src/passthrough")
const db = new sqlite("ooye.db")
/** @type {import("heatsync").default} */ // @ts-ignore
const sync = new HeatSync()
Object.assign(passthrough, {sync, db})

View file

@ -12,6 +12,7 @@ function debugRetrigger(message) {
}
}
const paused = new Set()
const emitter = new EventEmitter()
/**
@ -25,13 +26,15 @@ const emitter = new EventEmitter()
* @returns {boolean} false if the event was found and the function will be ignored, true if the event was not found and the function will be retriggered
*/
function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
if (eventID) {
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
return false // event was found so don't retrigger
if (!paused.has(messageID)) {
const eventID = select("event_message", "event_id", {message_id: messageID}).pluck().get()
if (eventID) {
debugRetrigger(`[retrigger] OK mid <-> eid = ${messageID} <-> ${eventID}`)
return false // event was found so don't retrigger
}
}
debugRetrigger(`[retrigger] WAIT mid <-> eid = ${messageID} <-> ${eventID}`)
debugRetrigger(`[retrigger] WAIT mid = ${messageID}`)
emitter.once(messageID, () => {
debugRetrigger(`[retrigger] TRIGGER mid = ${messageID}`)
fn(...rest)
@ -46,6 +49,25 @@ function eventNotFoundThenRetrigger(messageID, fn, ...rest) {
return true // event was not found, then retrigger
}
/**
* Anything calling retrigger during the callback will be paused and retriggered after the callback resolves.
* @template T
* @param {string} messageID
* @param {Promise<T>} promise
* @returns {Promise<T>}
*/
async function pauseChanges(messageID, promise) {
try {
debugRetrigger(`[retrigger] PAUSE mid = ${messageID}`)
paused.add(messageID)
return await promise
} finally {
debugRetrigger(`[retrigger] RESUME mid = ${messageID}`)
paused.delete(messageID)
messageFinishedBridging(messageID)
}
}
/**
* Triggers any pending operations that were waiting on the corresponding event ID.
* @param {string} messageID
@ -59,3 +81,4 @@ function messageFinishedBridging(messageID) {
module.exports.eventNotFoundThenRetrigger = eventNotFoundThenRetrigger
module.exports.messageFinishedBridging = messageFinishedBridging
module.exports.pauseChanges = pauseChanges

View file

@ -122,35 +122,41 @@ async function editToChanges(message, guild, api) {
eventsToReplace = eventsToReplace.filter(eventCanBeEdited)
// We want to maintain exactly one part = 0 and one reaction_part = 0 database row at all times.
// This would be disrupted if existing events that are (reaction_)part = 0 will be redacted.
// If that is the case, pick a different existing or newly sent event to be (reaction_)part = 0.
/** @type {({column: string, eventID: string, value?: number} | {column: string, nextEvent: true})[]} */
const promotions = []
for (const column of ["part", "reaction_part"]) {
const candidatesForParts = unchangedEvents.concat(eventsToReplace)
// If no events with part = 0 exist (or will exist), we need to do some management.
if (!candidatesForParts.some(e => e.old[column] === 0)) {
// Try to find an existing event to promote. Bigger order is better.
if (candidatesForParts.length) {
// We can choose an existing event to promote. Bigger order is better.
const order = e => 2*+(e.event_type === "m.room.message") + 1*+(e.old.event_subtype === "m.text")
candidatesForParts.sort((a, b) => order(b) - order(a))
if (column === "part") {
promotions.push({column, eventID: candidatesForParts[0].old.event_id}) // part should be the first one
} else if (eventsToSend.length) {
promotions.push({column, nextEvent: true}) // reaction_part should be the last one
} else {
promotions.push({column, eventID: candidatesForParts[candidatesForParts.length - 1].old.event_id}) // reaction_part should be the last one
}
} else {
// No existing events to promote, but new events are being sent. Whatever gets sent will be the next part = 0.
}
// Or, if there are no existing events to promote and new events will be sent, whatever gets sent will be the next part = 0.
else {
promotions.push({column, nextEvent: true})
}
}
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
if (eventsToSend.length && !promotions.length) {
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
if (!existingReaction) {
const existingPartZero = candidatesForParts.find(p => p.old.reaction_part === 0)
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
}
}
// If adding events, try to keep reactions attached to the bottom of the group (unless reactions have already been added)
if (eventsToSend.length && !promotions.length) {
const existingReaction = select("reaction", "message_id", {message_id: message.id}).pluck().get()
if (!existingReaction) {
const existingPartZero = unchangedEvents.concat(eventsToReplace).find(p => p.old.reaction_part === 0)
assert(existingPartZero) // will exist because a reaction_part=0 always exists and no events are being removed
promotions.push({column: "reaction_part", eventID: existingPartZero.old.event_id, value: 1}) // update the current reaction_part to 1
promotions.push({column: "reaction_part", nextEvent: true}) // the newly created event will have reaction_part = 0
}
}

View file

@ -67,7 +67,7 @@ test("message2event embeds: image embed and attachment", async t => {
msgtype: "m.image",
url: "mxc://cadence.moe/zAXdQriaJuLZohDDmacwWWDR",
body: "Screenshot_20231001_034036.jpg",
external_url: "https://cdn.discordapp.com/attachments/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg?ex=651a1faa&is=6518ce2a&hm=eb5ca80a3fa7add8765bf404aea2028a28a2341e4a62435986bcdcf058da82f3&",
external_url: "https://bridge.example.org/download/discordcdn/176333891320283136/1157854643037163610/Screenshot_20231001_034036.jpg",
filename: "Screenshot_20231001_034036.jpg",
info: {
h: 1170,

View file

@ -103,7 +103,7 @@ const embedTitleParser = markdown.markdownEngine.parserFor({
* @param {DiscordTypes.APIAttachment} attachment
*/
async function attachmentToEvent(mentions, attachment) {
const publicURL = dUtils.getPublicUrlForCdn(attachment.url)
const external_url = dUtils.getPublicUrlForCdn(attachment.url)
const emoji =
attachment.content_type?.startsWith("image/jp") ? "📸"
: attachment.content_type?.startsWith("image/") ? "🖼️"
@ -117,9 +117,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded SPOILER file: ${publicURL} (${pb(attachment.size)})`,
body: `${emoji} Uploaded SPOILER file: ${external_url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${publicURL}">${publicURL}</a> (${pb(attachment.size)})</blockquote>`
formatted_body: `<blockquote>${emoji} Uploaded SPOILER file: <a href="${external_url}">${external_url}</a> (${pb(attachment.size)})</blockquote>`
}
}
// for large files, always link them instead of uploading so I don't use up all the space in the content repo
@ -128,9 +128,9 @@ async function attachmentToEvent(mentions, attachment) {
$type: "m.room.message",
"m.mentions": mentions,
msgtype: "m.text",
body: `${emoji} Uploaded file: ${publicURL} (${pb(attachment.size)})`,
body: `${emoji} Uploaded file: ${external_url} (${pb(attachment.size)})`,
format: "org.matrix.custom.html",
formatted_body: `${emoji} Uploaded file: <a href="${publicURL}">${attachment.filename}</a> (${pb(attachment.size)})`
formatted_body: `${emoji} Uploaded file: <a href="${external_url}">${attachment.filename}</a> (${pb(attachment.size)})`
}
} else if (attachment.content_type?.startsWith("image/") && attachment.width && attachment.height) {
return {
@ -138,7 +138,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions,
msgtype: "m.image",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
external_url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
@ -154,7 +154,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions,
msgtype: "m.video",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
external_url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
@ -170,7 +170,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions,
msgtype: "m.audio",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
external_url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
@ -185,7 +185,7 @@ async function attachmentToEvent(mentions, attachment) {
"m.mentions": mentions,
msgtype: "m.file",
url: await file.uploadDiscordFileToMxc(attachment.url),
external_url: attachment.url,
external_url,
body: attachment.description || attachment.filename,
filename: attachment.filename,
info: {
@ -197,11 +197,12 @@ async function attachmentToEvent(mentions, attachment) {
}
/**
* @param {import("discord-api-types/v10").APIMessage} message
* @param {import("discord-api-types/v10").APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean}} options default values:
* @param {DiscordTypes.APIMessage} message
* @param {DiscordTypes.APIGuild} guild
* @param {{includeReplyFallback?: boolean, includeEditFallbackStar?: boolean, alwaysReturnFormattedBody?: boolean}} options default values:
* - includeReplyFallback: true
* - includeEditFallbackStar: false
* - alwaysReturnFormattedBody: false - formatted_body will be skipped if it is the same as body because the message is plaintext. if you want the formatted_body to be returned anyway, for example to merge it with another message, then set this to true.
* @param {{api: import("../../matrix/api")}} di simple-as-nails dependency injection for the matrix API
*/
async function messageToEvent(message, guild, options = {}, di) {
@ -236,8 +237,11 @@ async function messageToEvent(message, guild, options = {}, di) {
const interaction = message.interaction_metadata || message.interaction
if (message.type === DiscordTypes.MessageType.ChatInputCommand && interaction && "name" in interaction) {
// Commands are sent by the responding bot. Need to attach the metadata of the person using the command at the top.
if (message.content) message.content = `\n${message.content}`
message.content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${message.content}`
let content = message.content
if (content) content = `\n${content}`
else if ((message.flags || 0) & DiscordTypes.MessageFlags.Loading) content = " — interaction loading..."
content = `> ↪️ <@${interaction.user.id}> used \`/${interaction.name}\`${content}`
message = {...message, content} // editToChanges reuses the object so we can't mutate it. have to clone it
}
/**
@ -425,8 +429,13 @@ async function messageToEvent(message, guild, options = {}, di) {
return {body, html}
}
// FIXME: What was the scanMentions parameter supposed to activate? It's unused.
async function addTextEvent(body, html, msgtype, {scanMentions}) {
/**
* After converting Discord content to Matrix plaintext and HTML content, post-process the bodies and push the resulting text event
* @param {string} body matrix event plaintext body
* @param {string} html matrix event HTML body
* @param {string} msgtype matrix event msgtype (maybe m.text or m.notice)
*/
async function addTextEvent(body, html, msgtype) {
// Star * prefix for fallback edits
if (options.includeEditFallbackStar) {
body = "* " + body
@ -434,7 +443,7 @@ async function messageToEvent(message, guild, options = {}, di) {
}
const flags = message.flags || 0
if (flags & 2) {
if (flags & DiscordTypes.MessageFlags.IsCrosspost) {
body = `[🔀 ${message.author.username}]\n` + body
html = `🔀 <strong>${message.author.username}</strong><br>` + html
}
@ -488,7 +497,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const isPlaintext = body === html
if (!isPlaintext) {
if (!isPlaintext || options.alwaysReturnFormattedBody) {
Object.assign(newTextMessageEvent, {
format: "org.matrix.custom.html",
formatted_body: html
@ -506,7 +515,58 @@ async function messageToEvent(message, guild, options = {}, di) {
message.content = "changed the channel name to **" + message.content + "**"
}
// Forwarded content appears first
if (message.message_reference?.type === DiscordTypes.MessageReferenceType.Forward && message.message_snapshots?.length) {
// Forwarded notice
const eventID = select("event_message", "event_id", {message_id: message.message_reference.message_id}).pluck().get()
const room = select("channel_room", ["room_id", "name", "nick"], {channel_id: message.message_reference.channel_id}).get()
const forwardedNotice = new mxUtils.MatrixStringBuilder()
if (room) {
const roomName = room && (room.nick || room.name)
const via = await getViaServersMemo(room.room_id)
if (eventID) {
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}/${eventID}?${via}">${roomName}</a></em>`
)
} else {
forwardedNotice.addLine(
`[🔀 Forwarded from #${roomName}]`,
tag`🔀 <em>Forwarded from <a href="https://matrix.to/#/${room.room_id}?${via}">${roomName}</a></em>`
)
}
} else {
forwardedNotice.addLine(
`[🔀 Forwarded message]`,
tag`🔀 <em>Forwarded message</em>`
)
}
// Forwarded content
// @ts-ignore
const forwardedEvents = await messageToEvent(message.message_snapshots[0].message, guild, {includeReplyFallback: false, includeEditFallbackStar: false, alwaysReturnFormattedBody: true}, di)
// Indent
for (const event of forwardedEvents) {
if (["m.text", "m.notice"].includes(event.msgtype)) {
event.msgtype = "m.notice"
event.body = event.body.split("\n").map(l => "» " + l).join("\n")
event.formatted_body = `<blockquote>${event.formatted_body}</blockquote>`
}
}
// Try to merge the forwarded content with the forwarded notice
let {body, formatted_body} = forwardedNotice.get()
if (forwardedEvents.length >= 1 && ["m.text", "m.notice"].includes(forwardedEvents[0].msgtype)) { // Try to merge the forwarded content and the forwarded notice
forwardedEvents[0].body = body + "\n" + forwardedEvents[0].body
forwardedEvents[0].formatted_body = formatted_body + "<br>" + forwardedEvents[0].formatted_body
} else {
await addTextEvent(body, formatted_body, "m.notice")
}
events.push(...forwardedEvents)
}
// Then text content
if (message.content) {
// 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)]
@ -525,9 +585,8 @@ async function messageToEvent(message, guild, options = {}, di) {
}
}
// Text content appears first
const {body, html} = await transformContent(message.content)
await addTextEvent(body, html, msgtype, {scanMentions: true})
await addTextEvent(body, html, msgtype)
}
// Then attachments
@ -599,9 +658,9 @@ async function messageToEvent(message, guild, options = {}, di) {
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 (chosenImage) rep.addParagraph(`📸 ${dUtils.getPublicUrlForCdn(chosenImage)}`)
if (embed.video?.url) rep.addParagraph(`🎞️ ${embed.video.url}`)
if (embed.video?.url) rep.addParagraph(`🎞️ ${dUtils.getPublicUrlForCdn(embed.video.url)}`)
if (embed.footer?.text) rep.addLine(`${embed.footer.text}`, tag`${embed.footer.text}`)
let {body, formatted_body: html} = rep.get()
@ -609,7 +668,7 @@ async function messageToEvent(message, guild, options = {}, di) {
html = `<blockquote>${html}</blockquote>`
// Send as m.notice to apply the usual automated/subtle appearance, showing this wasn't actually typed by the person
await addTextEvent(body, html, "m.notice", {scanMentions: false})
await addTextEvent(body, html, "m.notice")
}
// Then stickers

View file

@ -337,7 +337,7 @@ test("message2event: attachment with no content", async t => {
msgtype: "m.image",
url: "mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM",
body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png",
external_url: "https://bridge.example.org/download/discordcdn/497161332244742154/1124628646431297546/image.png",
filename: "image.png",
info: {
mimetype: "image/png",
@ -373,7 +373,7 @@ test("message2event: stickers", async t => {
msgtype: "m.image",
url: "mxc://cadence.moe/ZDCNYnkPszxGKgObUIFmvjus",
body: "image.png",
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1106366167486038016/image.png",
external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1106366167486038016/image.png",
filename: "image.png",
info: {
mimetype: "image/png",
@ -427,7 +427,7 @@ test("message2event: skull webp attachment with content", async t => {
mimetype: "image/webp",
size: 74290
},
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084747910918195/skull.webp",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084747910918195/skull.webp",
filename: "skull.webp",
url: "mxc://cadence.moe/sDxWmDErBhYBxtDcJQgBETes"
}])
@ -461,7 +461,7 @@ test("message2event: reply to skull webp attachment with content", async t => {
mimetype: "image/jpeg",
size: 85906
},
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1128084851023675515/RDT_20230704_0936184915846675925224905.jpg",
filename: "RDT_20230704_0936184915846675925224905.jpg",
url: "mxc://cadence.moe/WlAbFSiNRIHPDEwKdyPeGywa"
}])
@ -551,7 +551,7 @@ test("message2event: reply with a video", async t => {
body: "Ins_1960637570.mp4",
filename: "Ins_1960637570.mp4",
url: "mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU",
external_url: "https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4?ex=65bbee8f&is=65a9798f&hm=ae14f7824c3d526c5e11c162e012e1ee405fd5776e1e9302ed80ccd86503cfda&",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1197621094786531358/Ins_1960637570.mp4",
info: {
h: 854,
mimetype: "video/mp4",
@ -572,7 +572,7 @@ test("message2event: voice message", async t => {
t.deepEqual(events, [{
$type: "m.room.message",
body: "voice-message.ogg",
external_url: "https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg?ex=65c92d4c&is=65b6b84c&hm=0654bab5027474cbe23875954fa117cf44d8914c144cd151879590fa1baf8b1c&",
external_url: "https://bridge.example.org/download/discordcdn/1099031887500034088/1112476845502365786/voice-message.ogg",
filename: "voice-message.ogg",
info: {
duration: 3960.0000381469727,
@ -595,7 +595,7 @@ test("message2event: misc file", async t => {
}, {
$type: "m.room.message",
body: "the.yml",
external_url: "https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml?ex=65cd6270&is=65baed70&hm=8c5f1b571784e3c7f99628492298815884e351ae0dc7c2ae40dd22d97caf27d9&",
external_url: "https://bridge.example.org/download/discordcdn/122155380120748034/1174514575220158545/the.yml",
filename: "the.yml",
info: {
mimetype: "text/plain; charset=utf-8",
@ -1014,3 +1014,123 @@ test("message2event: @everyone within a link", async t => {
"m.mentions": {}
}])
})
test("message2event: forwarded image", async t => {
const events = await messageToEvent(data.message.forwarded_image)
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded message]",
format: "org.matrix.custom.html",
formatted_body: "🔀 <em>Forwarded message</em>",
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "100km.gif",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
filename: "100km.gif",
info: {
h: 300,
mimetype: "image/gif",
size: 2965649,
w: 300,
},
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
},
])
})
test("message2event: constructed forwarded message", async t => {
const events = await messageToEvent(data.message.constructed_forwarded_message, {}, {}, {
api: {
async getJoinedMembers() {
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded from #wonderland]"
+ "\n» What's cooking, good looking? :hipposcope:",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!qzDBLKlildpzrrOnFZ:cadence.moe/$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE?via=cadence.moe&amp;via=matrix.org">wonderland</a></em>`
+ `<br><blockquote>What's cooking, good looking? <img data-mx-emoticon height="32" src="mxc://cadence.moe/WbYqNlACRuicynBfdnPYtmvc" title=":hipposcope:" alt=":hipposcope:"></blockquote>`,
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "100km.gif",
external_url: "https://bridge.example.org/download/discordcdn/112760669178241024/1296237494987133070/100km.gif",
filename: "100km.gif",
info: {
h: 300,
mimetype: "image/gif",
size: 2965649,
w: 300,
},
"m.mentions": {},
msgtype: "m.image",
url: "mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh",
},
{
$type: "m.room.message",
body: "» | ## This man"
+ "\n» | "
+ "\n» | ## This man is 100 km away from your house"
+ "\n» | "
+ "\n» | ### Distance away"
+ "\n» | 99 km"
+ "\n» | "
+ "\n» | ### Distance away"
+ "\n» | 98 km",
format: "org.matrix.custom.html",
formatted_body: "<blockquote><blockquote><p><strong>This man</strong></p><p><strong>This man is 100 km away from your house</strong></p><p><strong>Distance away</strong><br>99 km</p><p><strong>Distance away</strong><br>98 km</p></blockquote></blockquote>",
"m.mentions": {},
msgtype: "m.notice"
}
])
})
test("message2event: constructed forwarded text", async t => {
const events = await messageToEvent(data.message.constructed_forwarded_text, {}, {}, {
api: {
async getJoinedMembers() {
return {
joined: {
"@_ooye_bot:cadence.moe": {display_name: null, avatar_url: null},
"@user:matrix.org": {display_name: null, avatar_url: null}
}
}
}
}
})
t.deepEqual(events, [
{
$type: "m.room.message",
body: "[🔀 Forwarded from #amanda-spam]"
+ "\n» What's cooking, good looking?",
format: "org.matrix.custom.html",
formatted_body: `🔀 <em>Forwarded from <a href="https://matrix.to/#/!CzvdIdUQXgUjDVKxeU:cadence.moe?via=cadence.moe&amp;via=matrix.org">amanda-spam</a></em>`
+ `<br><blockquote>What's cooking, good looking?</blockquote>`,
"m.mentions": {},
msgtype: "m.notice",
},
{
$type: "m.room.message",
body: "What's cooking everybody ‼️",
"m.mentions": {},
msgtype: "m.text",
}
])
})

View file

@ -4,7 +4,11 @@
const DiscordTypes = require("discord-api-types/v10")
const passthrough = require("../passthrough")
const { sync } = passthrough
const {sync, db} = passthrough
function populateGuildID(guildID, channelID) {
db.prepare("UPDATE channel_room SET guild_id = ? WHERE channel_id = ?").run(guildID, channelID)
}
const utils = {
/**
@ -36,13 +40,16 @@ const utils = {
channel.guild_id = message.d.id
arr.push(channel.id)
client.channels.set(channel.id, channel)
populateGuildID(message.d.id, channel.id)
}
for (const thread of message.d.threads || []) {
// @ts-ignore
thread.guild_id = message.d.id
arr.push(thread.id)
client.channels.set(thread.id, thread)
populateGuildID(message.d.id, thread.id)
}
if (listen === "full") {
eventDispatcher.checkMissedExpressions(message.d)
eventDispatcher.checkMissedPins(client, message.d)
@ -91,7 +98,11 @@ const utils = {
} else if (message.t === "THREAD_CREATE") {
client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) {
populateGuildID(message.d["guild_id"], message.d.id)
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else if (message.t === "CHANNEL_UPDATE" || message.t === "THREAD_UPDATE") {
client.channels.set(message.d.id, message.d)
@ -113,21 +124,21 @@ const utils = {
client.guildChannelMap.delete(message.d.id)
} else if (message.t === "CHANNEL_CREATE" || message.t === "CHANNEL_DELETE") {
if (message.t === "CHANNEL_CREATE") {
client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else {
client.channels.delete(message.d.id)
if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels) {
const previous = channels.indexOf(message.d.id)
if (previous !== -1) channels.splice(previous, 1)
}
} else if (message.t === "CHANNEL_CREATE") {
client.channels.set(message.d.id, message.d)
if (message.d["guild_id"]) { // obj[prop] notation can be used to access a property without typescript complaining that it doesn't exist on all values something can have
populateGuildID(message.d["guild_id"], message.d.id)
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels && !channels.includes(message.d.id)) channels.push(message.d.id)
}
} else if (message.t === "CHANNEL_DELETE") {
client.channels.delete(message.d.id)
if (message.d["guild_id"]) {
const channels = client.guildChannelMap.get(message.d["guild_id"])
if (channels) {
const previous = channels.indexOf(message.d.id)
if (previous !== -1) channels.splice(previous, 1)
}
}
}

View file

@ -281,9 +281,6 @@ module.exports = {
// Otherwise, if there are embeds, then the system generated URL preview embeds.
if (!(typeof data.content === "string" || "embeds" in data)) return
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
if (data.webhook_id) {
const row = select("webhook", "webhook_id", {webhook_id: data.webhook_id}).pluck().get()
if (row) return // The message was sent by the bridge's own webhook on discord. We don't want to reflect this back, so just drop it.
@ -295,6 +292,9 @@ module.exports = {
const {affected, row} = await speedbump.maybeDoSpeedbump(data.channel_id, data.id)
if (affected) return
// Check that the sending-to room exists, and deal with Eventual Consistency(TM)
if (retrigger.eventNotFoundThenRetrigger(data.id, module.exports.onMessageUpdate, client, data)) return
/** @type {DiscordTypes.GatewayMessageCreateDispatchData} */
// @ts-ignore
const message = data
@ -304,7 +304,7 @@ module.exports = {
assert(guild)
// @ts-ignore
await editMessage.editMessage(message, guild, row)
await retrigger.pauseChanges(message.id, editMessage.editMessage(message, guild, row))
},
/**

View file

@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE channel_room ADD COLUMN guild_id TEXT;
COMMIT;

View file

@ -1,9 +1,10 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const assert = require("assert/strict")
const {discord, sync, db, select, from} = require("../../passthrough")
const {InteractionMethods} = require("snowtransfer")
const {id: botID} = require("../../../addbot")
const {discord, sync, db, select} = require("../../passthrough")
/** @type {import("../../d2m/actions/create-room")} */
const createRoom = sync.require("../../d2m/actions/create-room")
@ -11,41 +12,57 @@ const createRoom = sync.require("../../d2m/actions/create-room")
const createSpace = sync.require("../../d2m/actions/create-space")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/read-registration")} */
const {reg} = sync.require("../../matrix/read-registration")
/**
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction
* @param {{api: typeof api}} di
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/
async function _interact({data, channel, guild_id}, {api}) {
async function* _interact({data, channel, guild_id}, {api}) {
// Check guild exists - it might not exist if the application was added with applications.commands scope and not bot scope
const guild = discord.guilds.get(guild_id)
if (!guild) return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `I can't perform actions in this server because there is no bot presence in the server. You should try re-adding this bot to the server, making sure that it has bot scope (not just commands).\nIf you add the bot from ${reg.ooye.bridge_origin} this should work automatically.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}}
// Get named MXID
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
const options = data.options
const input = options?.[0]?.value || ""
const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
if (!mxid) return {
if (!mxid) return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`",
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
const guild = discord.guilds.get(guild_id)
assert(guild)
}}
// Ensure guild and room are bridged
db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, 1)").run(guild_id)
const existing = createRoom.existsOrAutocreatable(channel, guild_id)
if (existing === 0) return {
if (existing === 0) return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
}}
assert(existing) // can't be null or undefined as we just inserted the guild_active row
yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource,
data: {
flags: DiscordTypes.MessageFlags.Ephemeral
}
}}
const spaceID = await createSpace.ensureSpace(guild)
const roomID = await createRoom.ensureRoom(channel.id)
@ -55,24 +72,17 @@ async function _interact({data, channel, guild_id}, {api}) {
spaceMember = await api.getStateEvent(spaceID, "m.room.member", mxid)
} catch (e) {}
if (spaceMember && spaceMember.membership === "invite") {
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
return yield {editOriginalInteractionResponse: {
content: `\`${mxid}\` already has an invite, which they haven't accepted yet.`,
}}
}
// Invite Matrix user if not in space
if (!spaceMember || spaceMember.membership !== "join") {
await api.inviteToRoom(spaceID, mxid)
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `You invited \`${mxid}\` to the server.`
}
}
return yield {editOriginalInteractionResponse: {
content: `You invited \`${mxid}\` to the server.`
}}
}
// The Matrix user *is* in the space, maybe we want to invite them to this channel?
@ -81,32 +91,24 @@ async function _interact({data, channel, guild_id}, {api}) {
roomMember = await api.getStateEvent(roomID, "m.room.member", mxid)
} catch (e) {}
if (!roomMember || (roomMember.membership !== "join" && roomMember.membership !== "invite")) {
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`,
flags: DiscordTypes.MessageFlags.Ephemeral,
return yield {editOriginalInteractionResponse: {
content: `\`${mxid}\` is already in this server. Would you like to additionally invite them to this specific channel?`,
components: [{
type: DiscordTypes.ComponentType.ActionRow,
components: [{
type: DiscordTypes.ComponentType.ActionRow,
components: [{
type: DiscordTypes.ComponentType.Button,
custom_id: "invite_channel",
style: DiscordTypes.ButtonStyle.Primary,
label: "Sure",
}]
type: DiscordTypes.ComponentType.Button,
custom_id: "invite_channel",
style: DiscordTypes.ButtonStyle.Primary,
label: "Sure",
}]
}
}
}]
}}
}
// The Matrix user *is* in the space and in the channel.
return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${mxid}\` is already in this server and this channel.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
return yield {editOriginalInteractionResponse: {
content: `\`${mxid}\` is already in this server and this channel.`,
}}
}
/**
@ -133,7 +135,14 @@ async function _interactButton({channel, message}, {api}) {
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction */
async function interact(interaction) {
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api}))
for await (const response of _interact(interaction, {api})) {
if (response.createInteractionResponse) {
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
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)
}
}
}
/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */

View file

@ -4,19 +4,32 @@ const {db, discord} = require("../../passthrough")
const {MatrixServerError} = require("../../matrix/mreq")
const {_interact, _interactButton} = require("./invite")
/**
* @template T
* @param {AsyncIterable<T>} ai
* @returns {Promise<T[]>}
*/
async function fromAsync(ai) {
const result = []
for await (const value of ai) {
result.push(value)
}
return result
}
test("invite: checks for missing matrix ID", async t => {
const msg = await _interact({
const msgs = await fromAsync(_interact({
data: {
options: []
},
channel: discord.channels.get("0"),
guild_id: "112760669178241024"
}, {})
t.equal(msg.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
}, {}))
t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
})
test("invite: checks for invalid matrix ID", async t => {
const msg = await _interact({
const msgs = await fromAsync(_interact({
data: {
options: [{
name: "user",
@ -26,13 +39,28 @@ test("invite: checks for invalid matrix ID", async t => {
},
channel: discord.channels.get("0"),
guild_id: "112760669178241024"
}, {})
t.equal(msg.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
}, {}))
t.equal(msgs[0].createInteractionResponse.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
})
test("invite: checks if guild exists", async t => { // it might not exist if the application was added with applications.commands scope and not bot scope
const msgs = await fromAsync(_interact({
data: {
options: [{
name: "user",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "@cadence:cadence.moe"
}]
},
channel: discord.channels.get("0"),
guild_id: "0"
}, {}))
t.match(msgs[0].createInteractionResponse.data.content, /there is no bot presence in the server/)
})
test("invite: checks if channel exists or is autocreatable", async t => {
db.prepare("UPDATE guild_active SET autocreate = 0").run()
const msg = await _interact({
const msgs = await fromAsync(_interact({
data: {
options: [{
name: "user",
@ -42,14 +70,14 @@ test("invite: checks if channel exists or is autocreatable", async t => {
},
channel: discord.channels.get("498323546729086986"),
guild_id: "112760669178241024"
}, {})
t.equal(msg.data.content, "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.")
}, {}))
t.equal(msgs[0].createInteractionResponse.data.content, "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.")
db.prepare("UPDATE guild_active SET autocreate = 1").run()
})
test("invite: checks if user is already invited to space", async t => {
let called = 0
const msg = await _interact({
const msgs = await fromAsync(_interact({
data: {
options: [{
name: "user",
@ -72,14 +100,14 @@ test("invite: checks if user is already invited to space", async t => {
}
}
}
})
t.equal(msg.data.content, "`@cadence:cadence.moe` already has an invite, which they haven't accepted yet.")
}))
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` already has an invite, which they haven't accepted yet.")
t.equal(called, 1)
})
test("invite: invites if user is not in space", async t => {
let called = 0
const msg = await _interact({
const msgs = await fromAsync(_interact({
data: {
options: [{
name: "user",
@ -104,14 +132,14 @@ test("invite: invites if user is not in space", async t => {
t.equal(mxid, "@cadence:cadence.moe")
}
}
})
t.equal(msg.data.content, "You invited `@cadence:cadence.moe` to the server.")
}))
t.equal(msgs[1].editOriginalInteractionResponse.content, "You invited `@cadence:cadence.moe` to the server.")
t.equal(called, 2)
})
test("invite: prompts to invite to room (if never joined)", async t => {
let called = 0
const msg = await _interact({
const msgs = await fromAsync(_interact({
data: {
options: [{
name: "user",
@ -137,14 +165,14 @@ test("invite: prompts to invite to room (if never joined)", async t => {
}
}
}
})
t.equal(msg.data.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
}))
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
t.equal(called, 2)
})
test("invite: prompts to invite to room (if left)", async t => {
let called = 0
const msg = await _interact({
const msgs = await fromAsync(_interact({
data: {
options: [{
name: "user",
@ -173,8 +201,8 @@ test("invite: prompts to invite to room (if left)", async t => {
}
}
}
})
t.equal(msg.data.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
}))
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
t.equal(called, 2)
})
@ -200,7 +228,7 @@ test("invite button: invites to room when button clicked", async t => {
test("invite: no-op if in room and space", async t => {
let called = 0
const msg = await _interact({
const msgs = await fromAsync(_interact({
data: {
options: [{
name: "user",
@ -222,7 +250,7 @@ test("invite: no-op if in room and space", async t => {
}
}
}
})
t.equal(msg.data.content, "`@cadence:cadence.moe` is already in this server and this channel.")
}))
t.equal(msgs[1].editOriginalInteractionResponse.content, "`@cadence:cadence.moe` is already in this server and this channel.")
t.equal(called, 2)
})

View file

@ -1,7 +1,7 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, db, select, from} = require("../../passthrough")
const {discord, sync, from} = require("../../passthrough")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")

View file

@ -2,34 +2,41 @@
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const {discord, sync, db, select, from} = require("../../passthrough")
const {discord, sync, select, from} = 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")
/**
* @param {DiscordTypes.APIContextMenuGuildInteraction} interaction
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
* @param {{api: typeof api}} di
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/
async function _interact({data, channel, guild_id}) {
const row = select("event_message", ["event_id", "source"], {message_id: data.target_id}).get()
assert(row)
async function* _interact({data, guild_id}, {api}) {
// Get message info
const row = from("event_message")
.join("message_channel", "message_id")
.select("event_id", "source", "channel_id")
.where({message_id: data.target_id})
.get()
// Can't operate on Discord users
if (row.source === 1) { // discord
return {
if (!row || row.source === 1) { // not bridged or sent by a discord user
return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `This command is only meaningful for Matrix users.`,
content: `The permissions command can only be used on Matrix users.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
}}
}
// Get the message sender, the person that will be inspected/edited
const eventID = row.event_id
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
const roomID = select("channel_room", "room_id", {channel_id: row.channel_id}).pluck().get()
assert(roomID)
const event = await api.getEvent(roomID, eventID)
const sender = event.sender
@ -45,16 +52,16 @@ async function _interact({data, channel, guild_id}) {
// Administrators equal to the bot cannot be demoted
if (userPower >= 100) {
return {
return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`${sender}\` has administrator permissions. This cannot be edited.`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
}
}}
}
return {
yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: `Showing permissions for \`${sender}\`. Click to edit.`,
@ -82,13 +89,15 @@ async function _interact({data, channel, guild_id}) {
}
]
}
}
}}
}
/**
* @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction
* @param {{api: typeof api}} di
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/
async function interactEdit({data, id, token, guild_id, message}) {
async function* _interactEdit({data, guild_id, message}, {api}) {
// Get the person that will be inspected/edited
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
assert(mxid)
@ -96,13 +105,13 @@ async function interactEdit({data, id, token, guild_id, message}) {
const permission = data.values[0]
const power = permission === "moderator" ? 50 : 0
await discord.snow.interaction.createInteractionResponse(id, token, {
yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.UpdateMessage,
data: {
content: `Updating \`${mxid}\` to **${permission}**, please wait...`,
components: []
}
})
}}
// Get the space, where the power levels will be inspected/edited
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
@ -112,17 +121,40 @@ async function interactEdit({data, id, token, guild_id, message}) {
await api.setUserPowerCascade(spaceID, mxid, power)
// ACK
await discord.snow.interaction.editOriginalInteractionResponse(discord.application.id, token, {
yield {editOriginalInteractionResponse: {
content: `Updated \`${mxid}\` to **${permission}**.`,
components: []
})
}}
}
/* c8 ignore start */
/** @param {DiscordTypes.APIContextMenuGuildInteraction} interaction */
async function interact(interaction) {
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
for await (const response of _interact(interaction, {api})) {
if (response.createInteractionResponse) {
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
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)
}
}
}
/** @param {DiscordTypes.APIMessageComponentSelectMenuInteraction} interaction */
async function interactEdit(interaction) {
for await (const response of _interactEdit(interaction, {api})) {
if (response.createInteractionResponse) {
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
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.interactEdit = interactEdit
module.exports._interact = _interact
module.exports._interactEdit = _interactEdit

View file

@ -0,0 +1,199 @@
const {test} = require("supertape")
const DiscordTypes = require("discord-api-types/v10")
const {select, db} = require("../../passthrough")
const {_interact, _interactEdit} = require("./permissions")
/**
* @template T
* @param {AsyncIterable<T>} ai
* @returns {Promise<T[]>}
*/
async function fromAsync(ai) {
const result = []
for await (const value of ai) {
result.push(value)
}
return result
}
test("permissions: checks if message is bridged", async t => {
const msgs = await fromAsync(_interact({
data: {
target_id: "0"
},
guild_id: "0"
}, {}))
t.equal(msgs.length, 1)
t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.")
})
test("permissions: checks if message is sent by a matrix user", async t => {
const msgs = await fromAsync(_interact({
data: {
target_id: "1126786462646550579"
},
guild_id: "112760669178241024"
}, {}))
t.equal(msgs.length, 1)
t.equal(msgs[0].createInteractionResponse.data.content, "The permissions command can only be used on Matrix users.")
})
test("permissions: reports permissions of selected matrix user (implicit default)", async t => {
let called = 0
const msgs = await fromAsync(_interact({
data: {
target_id: "1128118177155526666"
},
guild_id: "112760669178241024"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
sender: "@cadence:cadence.moe"
}
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {}
}
}
}
}))
t.equal(msgs.length, 1)
t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.")
t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[0], {label: "Default", value: "default", default: true})
t.equal(called, 2)
})
test("permissions: reports permissions of selected matrix user (moderator)", async t => {
let called = 0
const msgs = await fromAsync(_interact({
data: {
target_id: "1128118177155526666"
},
guild_id: "112760669178241024"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
sender: "@cadence:cadence.moe"
}
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@cadence:cadence.moe": 50
}
}
}
}
}))
t.equal(msgs.length, 1)
t.equal(msgs[0].createInteractionResponse.data.content, "Showing permissions for `@cadence:cadence.moe`. Click to edit.")
t.deepEqual(msgs[0].createInteractionResponse.data.components[0].components[0].options[1], {label: "Moderator", value: "moderator", default: true})
t.equal(called, 2)
})
test("permissions: reports permissions of selected matrix user (admin)", async t => {
let called = 0
const msgs = await fromAsync(_interact({
data: {
target_id: "1128118177155526666"
},
guild_id: "112760669178241024"
}, {
api: {
async getEvent(roomID, eventID) {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
t.equal(eventID, "$Ij3qo7NxMA4VPexlAiIx2CB9JbsiGhJeyt-2OvkAUe4")
return {
sender: "@cadence:cadence.moe"
}
},
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@cadence:cadence.moe": 100
}
}
}
}
}))
t.equal(msgs.length, 1)
t.equal(msgs[0].createInteractionResponse.data.content, "`@cadence:cadence.moe` has administrator permissions. This cannot be edited.")
t.notOk(msgs[0].createInteractionResponse.data.components)
t.equal(called, 2)
})
test("permissions: can update user to moderator", async t => {
let called = 0
const msgs = await fromAsync(_interactEdit({
data: {
target_id: "1128118177155526666",
values: ["moderator"]
},
message: {
content: "Showing permissions for `@cadence:cadence.moe`. Click to edit."
},
guild_id: "112760669178241024"
}, {
api: {
async setUserPowerCascade(roomID, mxid, power) {
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
t.equal(mxid, "@cadence:cadence.moe")
t.equal(power, 50)
}
}
}))
t.equal(msgs.length, 2)
t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **moderator**, please wait...")
t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **moderator**.")
t.equal(called, 1)
})
test("permissions: can update user to default", async t => {
let called = 0
const msgs = await fromAsync(_interactEdit({
data: {
target_id: "1128118177155526666",
values: ["default"]
},
message: {
content: "Showing permissions for `@cadence:cadence.moe`. Click to edit."
},
guild_id: "112760669178241024"
}, {
api: {
async setUserPowerCascade(roomID, mxid, power) {
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
t.equal(mxid, "@cadence:cadence.moe")
t.equal(power, 0)
}
}
}))
t.equal(msgs.length, 2)
t.equal(msgs[0].createInteractionResponse.data.content, "Updating `@cadence:cadence.moe` to **default**, please wait...")
t.equal(msgs[1].editOriginalInteractionResponse.content, "Updated `@cadence:cadence.moe` to **default**.")
t.equal(called, 1)
})

View file

@ -3,32 +3,37 @@
const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, db, select} = require("../../passthrough")
const {id: botID} = require("../../../addbot")
const {InteractionMethods} = require("snowtransfer")
/** @type {import("../../d2m/actions/create-space")} */
const createSpace = sync.require("../../d2m/actions/create-space")
/**
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction
* @param {{createSpace: typeof createSpace}} di
* @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
*/
async function interact({id, token, data, guild_id}) {
async function* _interact({data, guild_id}, {createSpace}) {
// Check guild is bridged
const current = select("guild_space", "privacy_level", {guild_id}).pluck().get()
if (current == null) return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
if (current == null) {
return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
}}
}
// Get input level
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
const options = data.options
const input = options?.[0].value || ""
const input = options?.[0]?.value || ""
const levels = ["invite", "link", "directory"]
const level = levels.findIndex(x => input === x)
if (level === -1) {
return discord.snow.interaction.createInteractionResponse(id, token, {
return yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "**Usage: `/privacy <level>`**. This will set who can join the space on Matrix-side. There are three levels:"
@ -38,22 +43,37 @@ async function interact({id, token, data, guild_id}) {
+ `\n**Current privacy level: \`${levels[current]}\`**`,
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}}
}
await discord.snow.interaction.createInteractionResponse(id, token, {
yield {createInteractionResponse: {
type: DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource,
data: {
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}}
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(level, guild_id)
await createSpace.syncSpaceFully(guild_id) // this is inefficient but OK to call infrequently on user request
await discord.snow.interaction.editOriginalInteractionResponse(botID, token, {
yield {editOriginalInteractionResponse: {
content: `Privacy level updated to \`${levels[level]}\`.`
})
}}
}
/* c8 ignore start */
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
async function interact(interaction) {
for await (const response of _interact(interaction, {createSpace})) {
if (response.createInteractionResponse) {
// TODO: Test if it is reasonable to remove `await` from these calls. Or zip these calls with the next interaction iteration and use Promise.all.
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

View file

@ -0,0 +1,86 @@
const {test} = require("supertape")
const DiscordTypes = require("discord-api-types/v10")
const {select, db} = require("../../passthrough")
const {_interact} = require("./privacy")
/**
* @template T
* @param {AsyncIterable<T>} ai
* @returns {Promise<T[]>}
*/
async function fromAsync(ai) {
const result = []
for await (const value of ai) {
result.push(value)
}
return result
}
test("privacy: checks if guild is bridged", async t => {
const msgs = await fromAsync(_interact({
data: {
options: []
},
guild_id: "0"
}, {}))
t.equal(msgs.length, 1)
t.equal(msgs[0].createInteractionResponse.data.content, "This server isn't bridged to Matrix, so you can't set the Matrix privacy level.")
})
test("privacy: reports usage if there is no parameter", async t => {
const msgs = await fromAsync(_interact({
data: {
options: []
},
guild_id: "112760669178241024"
}, {}))
t.equal(msgs.length, 1)
t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/)
})
test("privacy: reports usage for invalid parameter", async t => {
const msgs = await fromAsync(_interact({
data: {
options: [
{
name: "level",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "info"
}
]
},
guild_id: "112760669178241024"
}, {}))
t.equal(msgs.length, 1)
t.match(msgs[0].createInteractionResponse.data.content, /Usage: `\/privacy/)
})
test("privacy: updates setting and calls syncSpace for valid parameter", async t => {
let called = 0
const msgs = await fromAsync(_interact({
data: {
options: [
{
name: "level",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "directory"
}
]
},
guild_id: "112760669178241024"
}, {
createSpace: {
async syncSpaceFully(guildID) {
called++
t.equal(guildID, "112760669178241024")
}
}
}))
t.equal(msgs.length, 2)
t.equal(msgs[0].createInteractionResponse.type, DiscordTypes.InteractionResponseType.DeferredChannelMessageWithSource)
t.equal(msgs[1].editOriginalInteractionResponse.content, "Privacy level updated to `directory`.")
t.equal(called, 1)
t.equal(select("guild_space", "privacy_level", {guild_id: "112760669178241024"}).pluck().get(), 2)
// Undo database changes
db.prepare("UPDATE guild_space SET privacy_level = 0 WHERE guild_id = ?").run("112760669178241024")
})

View file

@ -1,7 +1,7 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, db, select, from} = require("../../passthrough")
const {discord, sync, select, from} = require("../../passthrough")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")

View file

@ -1,8 +1,4 @@
const {test} = require("supertape")
const data = require("../../../test/data")
const DiscordTypes = require("discord-api-types/v10")
const {db, discord} = require("../../passthrough")
const {MatrixServerError} = require("../../matrix/mreq")
const {_interact} = require("./reactions")
test("reactions: checks if message is bridged", async t => {

View file

@ -60,7 +60,7 @@ async function createRoom(content) {
*/
async function joinRoom(roomIDOrAlias, mxid) {
/** @type {Ty.R.RoomJoined} */
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid))
const root = await mreq.mreq("POST", path(`/client/v3/join/${roomIDOrAlias}`, mxid), {})
return root.room_id
}
@ -123,6 +123,17 @@ function getJoinedMembers(roomID) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/joined_members`)
}
/**
* "Get the list of members for this room." This includes joined, invited, knocked, left, and banned members unless a filter is provided.
* The endpoint also supports `at` and `not_membership` URL parameters, but they are not exposed in this wrapper yet.
* @param {string} roomID
* @param {"join" | "invite" | "knock" | "leave" | "ban"} [membership] The kind of membership to filter for. Only one choice allowed.
* @returns {Promise<{chunk: Ty.Event.Outer<Ty.Event.M_Room_Member>[]}>}
*/
function getMembers(roomID, membership) {
return mreq.mreq("GET", `/client/v3/rooms/${roomID}/members`, undefined, {membership})
}
/**
* @param {string} roomID
* @param {{from?: string, limit?: any}} pagination
@ -339,6 +350,7 @@ module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState
module.exports.getStateEvent = getStateEvent
module.exports.getJoinedMembers = getJoinedMembers
module.exports.getMembers = getMembers
module.exports.getHierarchy = getHierarchy
module.exports.getFullHierarchy = getFullHierarchy
module.exports.getRelations = getRelations

View file

@ -45,6 +45,8 @@ mixin matrix(row, radio=false, badge="")
else
.s-user-card--link.fs-body1
a(href=`https://matrix.to/#/${row.room_id}`)= row.nick || row.name
if row.join_rule === "invite"
+badge-private
block body
if !guild_id && session.data.managedGuilds
@ -91,12 +93,15 @@ block body
div
-
let size = 105
let src = new URL(`https://api.qrserver.com/v1/create-qr-code/?qzone=1&format=svg&size=${size}x${size}`)
src.searchParams.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`)
img(width=size height=size src=src.toString())
let p = new URLSearchParams()
p.set("data", `https://bridge.cadence.moe/invite?nonce=${nonce}`)
img(width=size height=size src=`/qr?${p}`)
h2.mt48.fs-headline1 Linked channels
h2.mt48.fs-headline1 Moderation
h2.mt48.fs-headline1 Matrix setup
h3.mt32.fs-category Linked channels
-
function getPosition(channel) {
let position = 0
@ -118,6 +123,12 @@ block body
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => [0, 5].includes(c.type))
unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
let unlinkedRooms = rooms.filter(r => !linkedRoomIDs.includes(r.room_id) && !r.room_type)
// https://discord.com/developers/docs/topics/threads#active-archived-threads
// need to filter out linked archived threads from unlinkedRooms, will just do that by comparing against the name
unlinkedRooms = unlinkedRooms.filter(r => !r.name.match(/^\[(🔒)?⛓️\]/))
.s-card.bs-sm.p0
.s-table-container
table.s-table.s-table__bx-simple
@ -139,11 +150,62 @@ block body
p.s-description If you want, OOYE can automatically create new Matrix rooms and link them when an unlinked Discord channel is spoken in.
- let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id)
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" hx-swap="none" checked=value)
input.s-toggle-switch.order-last#autocreate(name="autocreate" type="checkbox" hx-post="/api/autocreate" hx-indicator="#autocreate-loading" hx-disabled-elt="this" checked=value)
.is-loading#autocreate-loading
h3.mt32.fs-category Privacy level
.s-card
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="this")
input(type="hidden" name="guild_id" value=guild_id)
.d-flex.ai-center.mb4
label.s-label.fl-grow1
| How people can join on Matrix
span.is-loading#privacy-level-loading
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental.d-grid.gx16.ai-center(style="grid-template-columns: auto 1fr")
input(type="radio" name="level" value="directory" id="privacy-level-directory" checked=(privacy_level === 2))
label.d-flex.gx8.jc-center.grid--row-start3(for="privacy-level-directory")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconInternationalSm
.fl-grow1 Directory
input(type="radio" name="level" value="link" id="privacy-level-link" checked=(privacy_level === 1))
label.d-flex.gx8.jc-center.grid--row-start2(for="privacy-level-link")
!= icons.Icons.IconPlusSm
!= icons.Icons.IconLinkSm
.fl-grow1 Link
input(type="radio" name="level" value="invite" id="privacy-level-invite" checked=(privacy_level === 0))
label.d-flex.gx8.jc-center.grid--row-start1(for="privacy-level-invite")
svg.svg-icon(width="14" height="14" viewBox="0 0 14 14")
!= icons.Icons.IconLockSm
.fl-grow1 Invite
p.s-description.m0 In-app direct invite from another user
p.s-description.m0 Shareable invite links, like Discord
p.s-description.m0 Publicly listed in directory, like Discord server discovery
//-
fieldset.s-check-group
legend.s-label How people can join on Matrix
.s-check-control
input.s-radio(type="radio" name="privacy-level" id="privacy-level-invite" value="invite" checked)
label.s-label(for="privacy-level-invite")
| Invite
p.s-description In-app direct invite on Matrix; invite command on Discord; invite form on web
.s-check-control
input.s-radio(type="radio" name="privacy-level" id="privacy-level-link" value="link")
label.s-label(for="privacy-level-link")
| Link
p.s-description All of the above, and shareable invite links (like Discord)
.s-check-control
input.s-radio(type="radio" name="privacy-level" id="privacy-level-directory" value="directory")
label.s-label(for="privacy-level-directory")
| Public
p.s-description All of the above, and publicly visible in the Matrix space directory (like Server Discovery)
h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(method="post" action="/api/link")
form.d-flex.g16.ai-start(hx-post="/api/privacy-level" hx-trigger="submit" hx-disabled-elt="this")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" id=channel.id value=channel.id)
@ -152,8 +214,14 @@ block body
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
.s-empty-state.p8 I don't know how to get the Matrix room list yet...
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" id=room.room_id value=room.room_id)
label.s-btn.s-btn__muted.ta-left.truncate(for=room.room_id)
+matrix(room, true)
else
.s-empty-state.p8 All Matrix rooms are linked.
input(type="hidden" name="guild_id" value=guild_id)
div
button.s-btn.s-btn__icon.s-btn__filled
!= icons.Icons.IconLink
= ` Connect`
button.s-btn.s-btn__icon.s-btn__filled.htmx-indicator
!= icons.Icons.IconMerge
= ` Link`

View file

@ -4,7 +4,7 @@ block body
.s-page-title.mb24
h1.s-page-title--header Bridge a Discord server
.d-grid.grid__2.g24
.d-grid.g24.grid__2(class="sm:grid__1")
.s-card.bs-md.d-flex.fd-column
h2 Easy mode
p Add the bot to your Discord server.

View file

@ -26,6 +26,10 @@ html(lang="en")
--theme-dark-primary-color-s: 53%;
--theme-dark-primary-color-l: 63%;
}
.s-toggle-switch.s-toggle-switch__multiple.s-toggle-switch__incremental input[type="radio"]:checked ~ label:not(.s-toggle-switch--label-off) {
--_ts-multiple-bg: var(--green-400);
--_ts-multiple-fc: var(--white);
}
body.themed.theme-system
header.s-topbar
.s-topbar--skip-link(href="#content") Skip to main content
@ -53,7 +57,7 @@ html(lang="en")
li(role="menuitem")
a.s-topbar--item.s-user-card.d-flex.p4(href=`/guild?guild_id=${guild.id}`)
+guild(guild)
.mx-auto.w100.wmx9.py24.px8#content
.mx-auto.w100.wmx9.py24.px8.fs-body1#content
block body
script.
document.querySelectorAll("[popovertarget]").forEach(e => {

View file

@ -3,7 +3,7 @@ extends includes/template.pug
block body
if !isValid
.s-empty-state.wmx4.p48
!= icons.Spots.SpotAlertXL
!= icons.Spots.SpotExpireXL
p This QR code has expired.
p Refresh the guild management page to generate a new one.

View file

@ -1,15 +1,25 @@
// @ts-check
const assert = require("assert/strict")
const {z} = require("zod")
const {defineEventHandler, sendRedirect, useSession, createError, readValidatedBody} = require("h3")
const {as, db} = require("../../passthrough")
const {as, db, sync} = require("../../passthrough")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../d2m/actions/create-space")} */
const createSpace = sync.require("../../d2m/actions/create-space")
/** @type {["invite", "link", "directory"]} */
const levels = ["invite", "link", "directory"]
const schema = {
autocreate: z.object({
guild_id: z.string(),
autocreate: z.string().optional()
}),
privacyLevel: z.object({
guild_id: z.string(),
level: z.enum(levels)
})
}
@ -19,5 +29,17 @@ as.router.post("/api/autocreate", defineEventHandler(async event => {
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id)
return sendRedirect(event, `/guild?guild_id=${parsedBody.guild_id}`, 302)
return null // 204
}))
as.router.post("/api/privacy-level", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.privacyLevel.parse)
const session = await useSession(event, {password: reg.as_token})
if (!(session.data.managedGuilds || []).includes(parsedBody.guild_id)) throw createError({status: 403, message: "Forbidden", data: "Can't change settings for a guild you don't have Manage Server permissions in"})
const i = levels.indexOf(parsedBody.level)
assert.notEqual(i, -1)
db.prepare("UPDATE guild_space SET privacy_level = ? WHERE guild_id = ?").run(i, parsedBody.guild_id)
await createSpace.syncSpaceFully(parsedBody.guild_id) // this is inefficient but OK to call infrequently on user request
return null // 204
}))

View file

@ -36,14 +36,18 @@ const validNonce = new LRUCache({max: 200})
as.router.get("/guild", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
const nonce = randomUUID()
if (guild_id) {
// Security note: the nonce alone is valid for updating the guild
// We have not verified the user has sufficient permissions in the guild at generation time
// These permissions are checked later during page rendering and the generated nonce is only revealed if the permissions are sufficient
validNonce.set(nonce, guild_id)
const session = await useSession(event, {password: reg.as_token})
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get()
if (!guild_id || !row || !discord.guilds.has(guild_id) || !session.data.managedGuilds || !session.data.managedGuilds.includes(guild_id)) {
return pugSync.render(event, "guild.pug", {guild_id})
}
return pugSync.render(event, "guild.pug", {nonce})
const nonce = randomUUID()
validNonce.set(nonce, guild_id)
const mods = await api.getStateEvent(row.space_id, "m.room.power_levels", "")
const banned = await api.getMembers(row.space_id, "ban")
const rooms = await api.getFullHierarchy(row.space_id)
return pugSync.render(event, "guild.pug", {guild_id, nonce, mods, banned, rooms, ...row})
}))
as.router.get("/invite", defineEventHandler(async event => {

63
src/web/routes/link.js Normal file
View file

@ -0,0 +1,63 @@
// @ts-check
const {z} = require("zod")
const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3")
const Ty = require("../../types")
const {discord, db, as, sync, select, from} = require("../../passthrough")
/** @type {import("../../d2m/actions/create-space")} */
const createSpace = sync.require("../../d2m/actions/create-space")
/** @type {import("../../d2m/actions/create-room")} */
const createRoom = sync.require("../../d2m/actions/create-room")
const {reg} = require("../../matrix/read-registration")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
const schema = {
link: z.object({
guild_id: z.string(),
matrix: z.string(),
discord: z.string()
})
}
as.router.post("/api/link", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.link.parse)
const session = await useSession(event, {password: reg.as_token})
// Check guild ID or nonce
const guildID = parsedBody.guild_id
if (!(session.data.managedGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check guild is bridged
const guild = discord.guilds.get(guildID)
if (!guild) throw createError({status: 400, message: "Bad Request", data: "Discord guild does not exist or bot has not joined it"})
const spaceID = await createSpace.ensureSpace(guild)
// Check channel exists
const channel = discord.channels.get(parsedBody.discord)
if (!channel) throw createError({status: 400, message: "Bad Request", data: "Discord channel does not exist"})
// Check channel and room are not already bridged
const row = from("channel_room").select("channel_id", "room_id").and("WHERE channel_id = ? OR room_id = ?").get(parsedBody.discord, parsedBody.matrix)
if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} and room ID ${row.room_id} are already bridged and cannot be reused`})
// Check room exists and bridge is joined and bridge has PL 100
const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
/** @type {Ty.Event.M_Room_Member} */
const memberEvent = await api.getStateEvent(parsedBody.matrix, "m.room.member", self)
if (memberEvent.membership !== "join") throw createError({status: 400, message: "Bad Request", data: "Matrix room does not exist"})
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelsStateContent = await api.getStateEvent(parsedBody.matrix, "m.room.power_levels", "")
const selfPowerLevel = powerLevelsStateContent.users?.[self] || powerLevelsStateContent.users_default || 0
if (selfPowerLevel < (powerLevelsStateContent.state_default || 50) || selfPowerLevel < 100) throw createError({status: 400, message: "Bad Request", data: "OOYE needs power level 100 (admin) in the target Matrix room"})
// Insert database entry
db.prepare("INSERT INTO channel_room (channel_id, room_id, name, guild_id) VALUES (?, ?, ?, ?)").run(parsedBody.discord, parsedBody.matrix, channel.name, guildID)
// Sync room data and space child
createRoom.syncRoom(parsedBody.discord)
return null // 204
}))

19
src/web/routes/qr.js Normal file
View file

@ -0,0 +1,19 @@
// @ts-check
const {z} = require("zod")
const {defineEventHandler, getValidatedQuery} = require("h3")
const {as} = require("../../passthrough")
const uqr = require("uqr")
const schema = {
qr: z.object({
data: z.string().max(128)
})
}
as.router.get("/qr", defineEventHandler(async event => {
const {data} = await getValidatedQuery(event, schema.qr.parse)
return new Response(uqr.renderSVG(data, {pixelSize: 3}), {headers: {"content-type": "image/svg+xml"}})
}))

View file

@ -23,9 +23,11 @@ pugSync.createRoute(as.router, "/ok", "ok.pug")
sync.require("./routes/download-matrix")
sync.require("./routes/download-discord")
sync.require("./routes/invite")
sync.require("./routes/guild-settings")
sync.require("./routes/invite")
sync.require("./routes/link")
sync.require("./routes/oauth")
sync.require("./routes/qr")
// Files

View file

@ -9,7 +9,6 @@ const {reg} = require("./src/matrix/read-registration")
const passthrough = require("./src/passthrough")
const db = new sqlite("ooye.db")
/** @type {import("heatsync").default} */ // @ts-ignore
const sync = new HeatSync()
Object.assign(passthrough, {sync, db})

View file

@ -2129,6 +2129,193 @@ module.exports = {
mention_everyone: false,
tts: false
}
},
forwarded_image: { type: 0,
content: "",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2024-10-16T22:25:01.973000+00:00",
edited_timestamp: null,
flags: 16384,
components: [],
id: "1296237495993892916",
channel_id: "112760669178241024",
author: {
id: "113340068197859328",
username: "kumaccino",
avatar: "a8829abe66866d7797b36f0bfac01086",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: null,
global_name: "kumaccino",
avatar_decoration_data: null,
banner_color: null,
clan: null
},
pinned: false,
mention_everyone: false,
tts: false,
message_reference: {
type: 1,
channel_id: "1019762340922663022",
message_id: "1019779830469894234"
},
position: 0,
message_snapshots: [
{
message: {
type: 0,
content: "",
mentions: [],
mention_roles: [],
attachments: [
{
id: "1296237494987133070",
filename: "100km.gif",
size: 2965649,
url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300,
height: 300,
content_type: "image/gif"
}
],
embeds: [],
timestamp: "2022-09-15T01:20:58.177000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
}
]
},
constructed_forwarded_message: { type: 0,
content: "",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2024-10-16T22:25:01.973000+00:00",
edited_timestamp: null,
flags: 16384,
components: [],
id: "1296237495993892916",
channel_id: "112760669178241024",
author: {
id: "113340068197859328",
username: "kumaccino",
avatar: "a8829abe66866d7797b36f0bfac01086",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: null,
global_name: "kumaccino",
avatar_decoration_data: null,
banner_color: null,
clan: null
},
pinned: false,
mention_everyone: false,
tts: false,
message_reference: {
type: 1,
channel_id: "176333891320283136",
message_id: "1191567971970191490"
},
position: 0,
message_snapshots: [
{
message: {
type: 0,
content: "What's cooking, good looking? <:hipposcope:393635038903926784>",
mentions: [],
mention_roles: [],
attachments: [
{
id: "1296237494987133070",
filename: "100km.gif",
size: 2965649,
url: "https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", proxy_url: "https://media.discordapp.net/attachments/112760669178241024/1296237494987133070/100km.gif?ex=67118ebd&is=67103d3d&hm=8ed76d424f92f11366989f2ebc713d4f8206706ef712571e934da45b59944f77&", width: 300,
height: 300,
content_type: "image/gif"
}
],
embeds: [{
type: "rich",
title: "This man is 100 km away from your house",
author: {
name: "This man"
},
fields: [{
name: "Distance away",
value: "99 km"
}, {
name: "Distance away",
value: "98 km"
}]
}],
timestamp: "2022-09-15T01:20:58.177000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
}
]
},
constructed_forwarded_text: { type: 0,
content: "What's cooking everybody ‼️",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2024-10-16T22:25:01.973000+00:00",
edited_timestamp: null,
flags: 16384,
components: [],
id: "1296237495993892916",
channel_id: "112760669178241024",
author: {
id: "113340068197859328",
username: "kumaccino",
avatar: "a8829abe66866d7797b36f0bfac01086",
discriminator: "0",
public_flags: 128,
flags: 128,
banner: null,
accent_color: null,
global_name: "kumaccino",
avatar_decoration_data: null,
banner_color: null,
clan: null
},
pinned: false,
mention_everyone: false,
tts: false,
message_reference: {
type: 1,
channel_id: "497161350934560778",
message_id: "0"
},
position: 0,
message_snapshots: [
{
message: {
type: 0,
content: "What's cooking, good looking?",
mentions: [],
mention_roles: [],
attachments: [],
embeds: [],
timestamp: "2022-09-15T01:20:58.177000+00:00",
edited_timestamp: null,
flags: 0,
components: []
}
}
]
}
},
pk_message: {

View file

@ -64,7 +64,8 @@ INSERT INTO message_channel (message_id, channel_id) VALUES
('1273204543739396116', '687028734322147344'),
('1273743950028607530', '1100319550446252084'),
('1278002262400176128', '1100319550446252084'),
('1278001833876525057', '1100319550446252084');
('1278001833876525057', '1100319550446252084'),
('1191567971970191490', '176333891320283136');
INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part, reaction_part, source) VALUES
('$X16nfVks1wsrhq4E9SSLiqrf2N8KD0erD0scZG7U5xg', 'm.room.message', 'm.text', '1126786462646550579', 0, 0, 1),
@ -103,7 +104,8 @@ INSERT INTO event_message (event_id, event_type, event_subtype, message_id, part
('$qmyjr-ISJtnOM5WTWLI0fT7uSlqRLgpyin2d2NCglCU', 'm.room.message', 'm.text', '1273204543739396116', 0, 0, 0),
('$W1nsDhNIojWrcQOdnOD9RaEvrz2qyZErQoNhPRs1nK4', 'm.room.message', 'm.text', '1273743950028607530', 0, 0, 0),
('$UTqiL3Zj3FC4qldxRLggN1fhygpKl8sZ7XGY5f9MNbF', 'm.room.message', 'm.text', '1278002262400176128', 0, 0, 1),
('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1);
('$aLVZyiC3HlOu-prCSIaXlQl68I8leUdnPFiCwkgn6qM', 'm.room.message', 'm.text', '1278001833876525057', 0, 0, 1),
('$tBIT8mO7XTTCgIINyiAIy6M2MSoPAdJenRl_RLyYuaE', 'm.room.message', 'm.text', '1191567971970191490', 0, 0, 1);
INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/attachments/497161332244742154/1124628646431297546/image.png', 'mxc://cadence.moe/qXoZktDqNtEGuOCZEADAMvhM'),
@ -123,7 +125,8 @@ INSERT INTO file (discord_url, mxc_url) VALUES
('https://cdn.discordapp.com/emojis/288858540888686602.png', 'mxc://cadence.moe/mwZaCtRGAQQyOItagDeCocEO'),
('https://cdn.discordapp.com/attachments/112760669178241024/1197621094786531358/Ins_1960637570.mp4', 'mxc://cadence.moe/kMqLycqMURhVpwleWkmASpnU'),
('https://cdn.discordapp.com/attachments/1099031887500034088/1112476845502365786/voice-message.ogg', 'mxc://cadence.moe/MRRPDggXQMYkrUjTpxQbmcxB'),
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP');
('https://cdn.discordapp.com/attachments/122155380120748034/1174514575220158545/the.yml', 'mxc://cadence.moe/HnQIYQmmlIKwOQsbFsIGpzPP'),
('https://cdn.discordapp.com/attachments/112760669178241024/1296237494987133070/100km.gif', 'mxc://cadence.moe/qDAotmebTfEIfsAIVCEZptLh');
INSERT INTO emoji (emoji_id, name, animated, mxc_url) VALUES
('230201364309868544', 'hippo', 0, 'mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC'),

View file

@ -25,7 +25,6 @@ reg.as_token = "baby"
reg.hs_token = "baby"
reg.ooye.bridge_origin = "https://bridge.example.org"
/** @type {import("heatsync").default} */ // @ts-ignore
const sync = new HeatSync({watchFS: false})
const discord = {
@ -140,5 +139,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not
require("../src/m2d/converters/emoji-sheet.test")
require("../src/discord/interactions/invite.test")
require("../src/discord/interactions/matrix-info.test")
require("../src/discord/interactions/permissions.test")
require("../src/discord/interactions/privacy.test")
require("../src/discord/interactions/reactions.test")
})()