Compare commits
31 commits
v3.0-beta4
...
main
Author | SHA1 | Date | |
---|---|---|---|
bf01db13d6 | |||
4f040e40d6 | |||
e00ce22aad | |||
88a232fb4a | |||
a35860cb15 | |||
bded9296af | |||
559d9329f2 | |||
7ff2a38cdb | |||
a63d173a9a | |||
07d6eb3c12 | |||
15e5b17b0d | |||
14115c0e06 | |||
0d8b9d5705 | |||
1b539cfa64 | |||
b23b818192 | |||
49948ae2c1 | |||
ac165845d7 | |||
cce432aeee | |||
e5f7c7fdcb | |||
4167a01ed1 | |||
c127923f4d | |||
da5525a542 | |||
6f7ed829b8 | |||
5a86c07eb9 | |||
4287a329f5 | |||
086e8cdc25 | |||
9f9d1f615e | |||
3662ee5db6 | |||
d72b162fe7 | |||
b79b010568 | |||
f77602afa6 |
41 changed files with 1279 additions and 281 deletions
|
@ -61,8 +61,6 @@ So there will be 3 states of whether a guild is self-service or not. At first, i
|
|||
|
||||
Pressing buttons on web or using the /invite command on a guild will insert a row into guild_active, allowing it to be bridged.
|
||||
|
||||
One more thing. Before v3, when a Matrix room was autocreated it would autocreate the space as well, if it needed to. But now, since nothing will be created until the user takes an action, the guild will always be created directly in response to a request. So room creation can now trust that the guild exists already.
|
||||
|
||||
So here's all the technical changes needed to support self-service in v3:
|
||||
|
||||
- New guild_active table showing whether, and how, a guild is bridged.
|
||||
|
@ -70,5 +68,4 @@ So here's all the technical changes needed to support self-service in v3:
|
|||
- When bot is added through "easy mode" web button, REPLACE INTO state 1 and ensureSpace.
|
||||
- When bot is added through "self-service" web button, REPLACE INTO state 0.
|
||||
- Event dispatcher will only ensureRoom if the guild_active state is 1.
|
||||
- createRoom can trust that the space exists because we check that in a calling function.
|
||||
- createRoom will only create other dependencies if the guild is autocreate.
|
||||
|
|
15
package-lock.json
generated
15
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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})
|
||||
|
@ -69,10 +68,7 @@ async function uploadAutoEmoji(snow, guild, name, filename) {
|
|||
return emoji
|
||||
}
|
||||
|
||||
async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||
if (!url.match(/^https?:\/\//)) return "Must be a URL"
|
||||
if (url.match(/\/$/)) return "Must not end with a slash"
|
||||
process.stdout.write(magenta(" checking, please wait..."))
|
||||
async function suggestWellKnown(serverUrlPrompt, url, otherwise) {
|
||||
try {
|
||||
var json = await fetch(`${url}/.well-known/matrix/client`).then(res => res.json())
|
||||
let baseURL = json["m.homeserver"].base_url.replace(/\/$/, "")
|
||||
|
@ -81,19 +77,28 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
|||
return `Did you mean: ${bold(baseURL)}? (Enter to accept)`
|
||||
}
|
||||
} catch (e) {}
|
||||
return otherwise
|
||||
}
|
||||
|
||||
async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
||||
if (!url.match(/^https?:\/\//)) return "Must be a URL"
|
||||
if (url.match(/\/$/)) return "Must not end with a slash"
|
||||
process.stdout.write(magenta(" checking, please wait..."))
|
||||
try {
|
||||
var res = await fetch(`${url}/_matrix/client/versions`)
|
||||
if (res.status !== 200) {
|
||||
return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`)
|
||||
}
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
if (res.status !== 200) return `There is no Matrix server at that URL (${url}/_matrix/client/versions returned ${res.status})`
|
||||
try {
|
||||
var json = await res.json()
|
||||
if (!Array.isArray(json?.versions) || !json.versions.includes("v1.11")) {
|
||||
return `OOYE needs Matrix version v1.11, but ${url} doesn't support this`
|
||||
}
|
||||
} catch (e) {
|
||||
return `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`
|
||||
return suggestWellKnown(serverUrlPrompt, url, `There is no Matrix server at that URL (${url}/_matrix/client/versions is not JSON)`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
@ -231,7 +236,6 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
|||
// Done with user prompts, reg is now guaranteed to be valid
|
||||
const api = require("../src/matrix/api")
|
||||
const file = require("../src/matrix/file")
|
||||
const utils = require("../src/m2d/converters/utils")
|
||||
const DiscordClient = require("../src/d2m/discord-client")
|
||||
const discord = new DiscordClient(reg.ooye.discord_token, "no")
|
||||
passthrough.discord = discord
|
||||
|
@ -268,35 +272,17 @@ async function validateHomeserverOrigin(serverUrlPrompt, url) {
|
|||
|
||||
const mxid = `@${reg.sender_localpart}:${reg.ooye.server_name}`
|
||||
|
||||
// ensure registration is correctly set...
|
||||
assert(reg.sender_localpart.startsWith(reg.ooye.namespace_prefix), "appservice's localpart must be in the namespace it controls")
|
||||
assert(utils.eventSenderIsFromDiscord(mxid), "appservice's mxid must be in the namespace it controls")
|
||||
assert(reg.ooye.server_origin.match(/^https?:\/\//), "server origin must start with http or https")
|
||||
assert.notEqual(reg.ooye.server_origin.slice(-1), "/", "server origin must not end in slash")
|
||||
const botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString()
|
||||
assert(botID.match(/^[0-9]{10,}$/), "discord token must follow the correct format")
|
||||
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
|
||||
|
||||
console.log("✅ Configuration looks good...")
|
||||
|
||||
// database ddl...
|
||||
await migrate.migrate(db)
|
||||
|
||||
// add initial rows to database, like adding the bot to sim...
|
||||
const botID = Buffer.from(reg.ooye.discord_token.split(".")[0], "base64").toString()
|
||||
db.prepare("INSERT OR IGNORE INTO sim (user_id, sim_name, localpart, mxid) VALUES (?, ?, ?, ?)").run(botID, reg.sender_localpart.slice(reg.ooye.namespace_prefix.length), reg.sender_localpart, mxid)
|
||||
|
||||
console.log("✅ Database is ready...")
|
||||
|
||||
// ensure appservice bot user is registered...
|
||||
try {
|
||||
await api.register(reg.sender_localpart)
|
||||
} catch (e) {
|
||||
if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") {
|
||||
// "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice.
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
await api.register(reg.sender_localpart)
|
||||
|
||||
// upload initial images...
|
||||
const avatarUrl = await file.uploadDiscordFileToMxc("https://cadence.moe/friends/out_of_your_element.png")
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -15,6 +15,8 @@ const api = sync.require("../../matrix/api")
|
|||
const ks = sync.require("../../matrix/kstate")
|
||||
/** @type {import("../../discord/utils")} */
|
||||
const utils = sync.require("../../discord/utils")
|
||||
/** @type {import("./create-space")} */
|
||||
const createSpace = sync.require("./create-space")
|
||||
|
||||
/**
|
||||
* There are 3 levels of room privacy:
|
||||
|
@ -93,11 +95,9 @@ function convertNameAndTopic(channel, guild, customName) {
|
|||
async function channelToKState(channel, guild, di) {
|
||||
// @ts-ignore
|
||||
const parentChannel = discord.channels.get(channel.parent_id)
|
||||
const guildRow = select("guild_space", ["space_id", "privacy_level"], {guild_id: guild.id}).get()
|
||||
assert(guildRow)
|
||||
|
||||
/** Used for membership/permission checks. */
|
||||
let guildSpaceID = guildRow.space_id
|
||||
const guildSpaceID = await createSpace.ensureSpace(guild)
|
||||
/** Used as the literal parent on Matrix, for categorisation. Will be the same as `guildSpaceID` unless it's a forum channel's thread, in which case a different space is used to group those threads. */
|
||||
let parentSpaceID = guildSpaceID
|
||||
if (parentChannel?.type === DiscordTypes.ChannelType.GuildForum) {
|
||||
|
@ -117,7 +117,8 @@ async function channelToKState(channel, guild, di) {
|
|||
avatarEventContent.url = {$url: file.guildIcon(guild)}
|
||||
}
|
||||
|
||||
const privacyLevel = guildRow.privacy_level
|
||||
const privacyLevel = select("guild_space", "privacy_level", {guild_id: guild.id}).pluck().get()
|
||||
assert(privacyLevel != null) // already ensured the space exists
|
||||
let history_visibility = PRIVACY_ENUMS.ROOM_HISTORY_VISIBILITY[privacyLevel]
|
||||
if (channel["thread_metadata"]) history_visibility = "world_readable"
|
||||
|
||||
|
|
|
@ -93,7 +93,7 @@ async function _syncSpace(guild, shouldActuallySync) {
|
|||
|
||||
if (!row) {
|
||||
const autocreate = select("guild_active", "autocreate", {guild_id: guild.id}).pluck().get()
|
||||
assert.equal(autocreate, 1, `refusing to implicitly create guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`)
|
||||
assert.equal(autocreate, 1, `refusing to implicitly create a space for guild ${guild.id}. set the guild_active data first before calling ensureSpace/syncSpace.`)
|
||||
|
||||
const creation = (async () => {
|
||||
const guildKState = await guildToKState(guild, createRoom.DEFAULT_PRIVACY_LEVEL) // New spaces will have to use the default privacy level; we obviously can't look up the existing entry
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,13 +170,13 @@ 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: {
|
||||
mimetype: attachment.content_type,
|
||||
size: attachment.size,
|
||||
duration: attachment.duration_secs ? attachment.duration_secs * 1000 : undefined
|
||||
duration: attachment.duration_secs ? Math.round(attachment.duration_secs * 1000) : undefined
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -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
|
||||
|
|
|
@ -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,10 +572,10 @@ 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,
|
||||
duration: 3960,
|
||||
mimetype: "audio/ogg",
|
||||
size: 10584,
|
||||
},
|
||||
|
@ -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&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&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",
|
||||
}
|
||||
])
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
5
src/db/migrations/0015-add-guild-id-to-channel-room.sql
Normal file
5
src/db/migrations/0015-add-guild-id-to-channel-room.sql
Normal file
|
@ -0,0 +1,5 @@
|
|||
BEGIN TRANSACTION;
|
||||
|
||||
ALTER TABLE channel_room ADD COLUMN guild_id TEXT;
|
||||
|
||||
COMMIT;
|
|
@ -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 */
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
199
src/discord/interactions/permissions.test.js
Normal file
199
src/discord/interactions/permissions.test.js
Normal 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)
|
||||
})
|
|
@ -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
|
||||
|
|
86
src/discord/interactions/privacy.test.js
Normal file
86
src/discord/interactions/privacy.test.js
Normal 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")
|
||||
})
|
|
@ -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")
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -30,9 +30,7 @@ const NEWLINE_ELEMENTS = BLOCK_ELEMENTS.concat(["BR"])
|
|||
*/
|
||||
function eventSenderIsFromDiscord(sender) {
|
||||
// If it's from a user in the bridge's namespace, then it originated from discord
|
||||
// This includes messages sent by the appservice's bot user, because that is what's used for webhooks
|
||||
// TODO: It would be nice if bridge system messages wouldn't trigger this check and could be bridged from matrix to discord, while webhook reflections would remain ignored...
|
||||
// TODO that only applies to the above todo: But you'd have to watch out for the /icon command, where the bridge bot would set the room avatar, and that shouldn't be reflected into the room a second time.
|
||||
// This could include messages sent by the appservice's bot user, because that is what's used for webhooks
|
||||
if (userRegex.some(x => sender.match(x))) {
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -35,14 +35,21 @@ function path(p, mxid, otherParams = {}) {
|
|||
|
||||
/**
|
||||
* @param {string} username
|
||||
* @returns {Promise<Ty.R.Registered>}
|
||||
*/
|
||||
function register(username) {
|
||||
async function register(username) {
|
||||
console.log(`[api] register: ${username}`)
|
||||
return mreq.mreq("POST", "/client/v3/register", {
|
||||
type: "m.login.application_service",
|
||||
username
|
||||
})
|
||||
try {
|
||||
await mreq.mreq("POST", "/client/v3/register", {
|
||||
type: "m.login.application_service",
|
||||
username
|
||||
})
|
||||
} catch (e) {
|
||||
if (e.errcode === "M_USER_IN_USE" || e.data?.error === "Internal server error") {
|
||||
// "Internal server error" is the only OK error because older versions of Synapse say this if you try to register the same username twice.
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -60,7 +67,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 +130,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 +357,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
|
||||
|
|
|
@ -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,33 +93,16 @@ 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
|
||||
|
||||
-
|
||||
function getPosition(channel) {
|
||||
let position = 0
|
||||
let looking = channel
|
||||
while (looking.parent_id) {
|
||||
looking = discord.channels.get(looking.parent_id)
|
||||
position = looking.position * 1000
|
||||
}
|
||||
if (channel.position) position += channel.position
|
||||
return position
|
||||
}
|
||||
let channelIDs = discord.guildChannelMap.get(guild_id)
|
||||
h2.mt48.fs-headline1 Matrix setup
|
||||
|
||||
let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all()
|
||||
let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})).filter(c => c.channel)
|
||||
let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
|
||||
linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
|
||||
h3.mt32.fs-category Linked channels
|
||||
|
||||
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))
|
||||
.s-card.bs-sm.p0
|
||||
.s-table-container
|
||||
table.s-table.s-table__bx-simple
|
||||
|
@ -139,11 +124,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 +188,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`
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
}))
|
||||
|
|
|
@ -5,6 +5,7 @@ const {z} = require("zod")
|
|||
const {defineEventHandler, sendRedirect, useSession, createError, getValidatedQuery, readValidatedBody} = require("h3")
|
||||
const {randomUUID} = require("crypto")
|
||||
const {LRUCache} = require("lru-cache")
|
||||
const Ty = require("../../types")
|
||||
|
||||
const {discord, as, sync, select} = require("../../passthrough")
|
||||
/** @type {import("../pug-sync")} */
|
||||
|
@ -34,16 +35,68 @@ const schema = {
|
|||
/** @type {LRUCache<string, string>} nonce to guild id */
|
||||
const validNonce = new LRUCache({max: 200})
|
||||
|
||||
/**
|
||||
* @param {string} guildID
|
||||
* @param {Ty.R.Hierarchy[]} rooms
|
||||
*/
|
||||
function getChannelRoomsLinks(guildID, rooms) {
|
||||
function getPosition(channel) {
|
||||
let position = 0
|
||||
let looking = channel
|
||||
while (looking.parent_id) {
|
||||
looking = discord.channels.get(looking.parent_id)
|
||||
position = looking.position * 1000
|
||||
}
|
||||
if (channel.position) position += channel.position
|
||||
return position
|
||||
}
|
||||
|
||||
let channelIDs = discord.guildChannelMap.get(guildID)
|
||||
assert(channelIDs)
|
||||
|
||||
let linkedChannels = select("channel_room", ["channel_id", "room_id", "name", "nick"], {channel_id: channelIDs}).all()
|
||||
let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c})).filter(c => c.channel)
|
||||
let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
|
||||
linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
|
||||
|
||||
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
|
||||
let unlinkedChannels = unlinkedChannelIDs.map(c => discord.channels.get(c)).filter(c => 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 && !r.name.match(/^\[(🔒)?⛓️\]/))
|
||||
|
||||
return {linkedChannelsWithDetails, unlinkedChannels, unlinkedRooms}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// Permission problems
|
||||
if (!guild_id || !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)
|
||||
|
||||
// Unlinked guild
|
||||
if (!row) {
|
||||
const links = getChannelRoomsLinks(guild_id, [])
|
||||
return pugSync.render(event, "guild.pug", {guild_id, nonce, ...links})
|
||||
}
|
||||
|
||||
// Linked guild
|
||||
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)
|
||||
const links = getChannelRoomsLinks(guild_id, rooms)
|
||||
return pugSync.render(event, "guild.pug", {guild_id, nonce, mods, banned, ...links, ...row})
|
||||
}))
|
||||
|
||||
as.router.get("/invite", defineEventHandler(async event => {
|
||||
|
|
63
src/web/routes/link.js
Normal file
63
src/web/routes/link.js
Normal 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
|
||||
}))
|
|
@ -86,7 +86,7 @@ as.router.get("/oauth", defineEventHandler(async event => {
|
|||
// Set auto-create for the guild
|
||||
// @ts-ignore
|
||||
if (managedGuilds.includes(parsedQuery.data.guild_id)) {
|
||||
db.prepare("INSERT OR IGNORE INTO guild_active (guild_id, autocreate) VALUES (?, ?)").run(parsedQuery.data.guild_id, +!session.data.selfService)
|
||||
db.prepare("REPLACE INTO guild_active (guild_id, autocreate) VALUES (?, ?)").run(parsedQuery.data.guild_id, +!session.data.selfService)
|
||||
}
|
||||
|
||||
if (parsedQuery.data.guild_id) {
|
||||
|
|
19
src/web/routes/qr.js
Normal file
19
src/web/routes/qr.js
Normal 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"}})
|
||||
}))
|
|
@ -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
|
||||
|
||||
|
|
1
start.js
1
start.js
|
@ -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})
|
||||
|
|
187
test/data.js
187
test/data.js
|
@ -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: {
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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")
|
||||
})()
|
||||
|
|
Loading…
Reference in a new issue