Compare commits

...

19 commits

Author SHA1 Message Date
ab69eab8a4 Fix tests for new link space error message 2025-11-01 21:01:15 +09:00
717dc185e5 Misc. fixes for remote join 2025-11-01 20:50:36 +09:00
7932f8af85 Add "please try invite" message when joinRoom in /api/link-space fails 2025-11-01 20:50:36 +09:00
0776cc6ccd Fill in more of reg for other people to test with 2025-11-01 20:50:36 +09:00
e7b4dfea9c Fix /api/link-space joinRoom() for remote spaces 2025-11-01 20:50:36 +09:00
ea08e16963 Update tests for new types and code path 2025-11-01 20:50:36 +09:00
1efd301e1d Cleanup 2025-11-01 20:50:36 +09:00
dc7b444086 Fix matrix api joinRoom() for remote rooms
When using self-service mode and trying to link with a remote matrix
room (room not in the same HS as the bridge user), then we need to add
the "via" HSs to join the room with, or else it fails.

We get it from the "m.space.child" in the "children_state" of the space
hierarchy.

It seems like the "via" information can also be stored in the
"m.space.parent" in the states of the room, but hopefully this shouldn't
be needed in sane implementations
2025-11-01 20:50:36 +09:00
255e166e8c Better message when remote emojis unavailable 2025-10-31 16:22:32 +13:00
d4f4664c25 Fix retrying m->d message deletions 2025-10-23 23:09:14 +11:00
3de762d428 Fix stickers that don't provide content type 2025-10-12 12:17:20 -06:00
cffd3c9f2e Fix converting discord channel links 2025-10-10 12:26:01 -06:00
5b7433de32 Make tests time zone independent 2025-10-07 14:09:50 -05:00
7916f82b55 Change thread started message (closes #61) 2025-10-07 14:09:42 -05:00
7905802825 Allow customising port in setup 2025-10-07 00:48:06 -05:00
3891506163 Roll back snowtransfer to avoid issue with pins 2025-10-07 00:46:44 -05:00
d8e6de62e5 Keep sim_proxy profile data up to date 2025-09-08 16:26:16 +12:00
5a152b87b8 I guess mentions is an optional property too 2025-09-08 12:37:19 +12:00
a968bacffd Update discord-markdown
Interpret channel URLs the same as a channel #mention
2025-09-03 00:00:02 +12:00
19 changed files with 282 additions and 75 deletions

66
package-lock.json generated
View file

@ -10,7 +10,7 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.6.5", "@cloudrac3r/discord-markdown": "^2.6.7",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/in-your-element": "^1.1.1",
@ -119,9 +119,9 @@
} }
}, },
"node_modules/@chriscdn/promise-semaphore": { "node_modules/@chriscdn/promise-semaphore": {
"version": "3.0.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.0.1.tgz", "resolved": "https://registry.npmjs.org/@chriscdn/promise-semaphore/-/promise-semaphore-3.1.1.tgz",
"integrity": "sha512-fVlCnoYE4hDzpcYRPtmN7dmcpmd2zxyPWjyfjIKI9Y+gsI7rwZSkjtuwMi8HFtlkSmNh8L7Zr37hdqeL13sYrw==", "integrity": "sha512-ALLLLYlPfd/QZLptcVi6HQRK1zaCDWZoqYYw+axLmCatFs4gVTSZ5nqlyxwFe4qwR/K84HvOMa9hxda881FqMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@cloudcmd/stub": { "node_modules/@cloudcmd/stub": {
@ -225,9 +225,9 @@
} }
}, },
"node_modules/@cloudrac3r/discord-markdown": { "node_modules/@cloudrac3r/discord-markdown": {
"version": "2.6.5", "version": "2.6.7",
"resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.5.tgz", "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.6.7.tgz",
"integrity": "sha512-B4uQNsyva5JNW0CVYkcunMQwWfrok1Hd5FYww/cWcvb98zp/pJdJfE3hoRl9EbnxNK2l62IJQ9j8HmssMFHJ9Q==", "integrity": "sha512-bWLmBYWaNEDcQfZHDz4jaAxLKA9161ruEnHo3ms6kfRw8uYku/Uz7U1xTmQ2dQF/q1PiuBvM9I37pLiotlQj8A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"simple-markdown": "^0.7.3" "simple-markdown": "^0.7.3"
@ -949,9 +949,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stackoverflow/stacks": { "node_modules/@stackoverflow/stacks": {
"version": "2.8.3", "version": "2.8.4",
"resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.3.tgz", "resolved": "https://registry.npmjs.org/@stackoverflow/stacks/-/stacks-2.8.4.tgz",
"integrity": "sha512-ZGBeuXJC7moK/f+lgl2dCAW85etD/RO0DNubocdH2qzpJMuuGXX0GMeEAfrTOe+B00I8E1OqTnS1cpkqGdHBdQ==", "integrity": "sha512-FfA7Bw7a0AQrMw3/bG6G4BUrZ698F7Cdk6HkR9T7jdaufORkiX5d16wI4j4b5Sqm1FwkaZAF+ZSKLL1w0tAsew==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus": "^3.2.2",
@ -1107,9 +1107,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.17.1", "version": "22.18.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
"integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1452,18 +1452,30 @@
} }
}, },
"node_modules/cloudstorm": { "node_modules/cloudstorm": {
"version": "0.14.0", "version": "0.14.1",
"resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.0.tgz", "resolved": "https://registry.npmjs.org/cloudstorm/-/cloudstorm-0.14.1.tgz",
"integrity": "sha512-EgjMGxb2Z+L6Acti6DzL/bEbR495AIqPThyW4DaG6Jpvd0ZuM5eC13EiyxV8wlqAME612QO2LjqbhkdXn/327Q==", "integrity": "sha512-x95WCKg818E1rE1Ru45NPD3RoIq0pg3WxwvF0GE7Eq07pAeLcjSRqM1lUmbmfjdOqZrWdSRYA1NETVZ8QhVrIA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord-api-types": "^0.38.12", "discord-api-types": "^0.38.21",
"snowtransfer": "^0.14.2" "snowtransfer": "^0.15.0"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
} }
}, },
"node_modules/cloudstorm/node_modules/snowtransfer": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/snowtransfer/-/snowtransfer-0.15.0.tgz",
"integrity": "sha512-kEDGKtFiH5nSkHsDZonEUuDx99lUasJoZ7AGrgvE8HzVG59vjvqc//C+pjWj4DuJqTj4Q+Z1L/M/MYNim8F2VA==",
"license": "MIT",
"dependencies": {
"discord-api-types": "^0.38.21"
},
"engines": {
"node": ">=16.15.0"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -1616,9 +1628,9 @@
} }
}, },
"node_modules/discord-api-types": { "node_modules/discord-api-types": {
"version": "0.38.19", "version": "0.38.22",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.19.tgz", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.22.tgz",
"integrity": "sha512-NUNMTgjYrgxt7wrTNEqnEez4hIAYbfyBpsjxT5gW7+82GjQCPDZvN+em6t+4/P5kGWnnwDa4ci070BV7eI6GbA==", "integrity": "sha512-2gnYrgXN3yTlv2cKBISI/A8btZwsSZLwKpIQXeI1cS8a7W7wP3sFVQOm3mPuuinTD8jJCKGPGNH399zE7Un1kA==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"scripts/actions/documentation" "scripts/actions/documentation"
@ -3076,9 +3088,9 @@
} }
}, },
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.3", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chownr": "^1.1.1", "chownr": "^1.1.1",
@ -3447,9 +3459,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.0.17", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.5.tgz",
"integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", "integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View file

@ -19,7 +19,7 @@
}, },
"dependencies": { "dependencies": {
"@chriscdn/promise-semaphore": "^3.0.1", "@chriscdn/promise-semaphore": "^3.0.1",
"@cloudrac3r/discord-markdown": "^2.6.5", "@cloudrac3r/discord-markdown": "^2.6.7",
"@cloudrac3r/giframe": "^0.4.3", "@cloudrac3r/giframe": "^0.4.3",
"@cloudrac3r/html-template-tag": "^5.0.1", "@cloudrac3r/html-template-tag": "^5.0.1",
"@cloudrac3r/in-your-element": "^1.1.1", "@cloudrac3r/in-your-element": "^1.1.1",

View file

@ -120,16 +120,28 @@ function defineEchoHandler() {
/** @type {string} */ // @ts-ignore /** @type {string} */ // @ts-ignore
const serverOrigin = await serverOriginPrompt.run() const serverOrigin = await serverOriginPrompt.run()
console.log("OOYE has its own web server. It needs to be accessible on the public internet.")
console.log("What port would you like OOYE to use? You can connect your reverse proxy to this port later.")
/** @type {{socket: string | number}} */
const portResponse = await prompt({
type: "input",
name: "socket",
message: "Web server port",
initial: "6693"
})
portResponse.socket = +portResponse.socket || portResponse.socket // convert to number if numeric
const app = createApp() const app = createApp()
app.use(defineEchoHandler()) app.use(defineEchoHandler())
const server = createServer(toNodeListener(app)) const server = createServer(toNodeListener(app))
await server.listen(6693) await server.listen(portResponse.socket)
console.log("OOYE has its own web server. It needs to be accessible on the public internet.") console.log("Now you need to enter a public URL that OOYE's web server will live on.")
console.log("You need to enter a public URL where you will be able to host this web server.") console.log("Set up your reverse proxy so that this URL accesses OOYE.")
console.log("OOYE listens on localhost:6693, so you will probably have to set up a reverse proxy.")
console.log("Examples: https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix") console.log("Examples: https://gitdab.com/cadence/out-of-your-element/src/branch/main/docs/get-started.md#appendix")
console.log("Now listening on port 6693. Feel free to send some test requests.") if (typeof portResponse.socket === "number") {
console.log(`Now listening on http://localhost:${portResponse.socket}. Feel free to send some test requests.`)
}
/** @type {{bridge_origin: string}} */ /** @type {{bridge_origin: string}} */
const bridgeOriginResponse = await prompt({ const bridgeOriginResponse = await prompt({
type: "input", type: "input",
@ -255,6 +267,7 @@ function defineEchoHandler() {
reg = { reg = {
...template, ...template,
url: bridgeOriginResponse.bridge_origin, url: bridgeOriginResponse.bridge_origin,
...portResponse,
ooye: { ooye: {
...template.ooye, ...template.ooye,
...bridgeOriginResponse, ...bridgeOriginResponse,

View file

@ -146,7 +146,7 @@ async function syncUser(messageID, author, roomID, shouldActuallySync) {
try { try {
// API lookup // API lookup
var pkMessage = await fetchMessage(messageID) var pkMessage = await fetchMessage(messageID)
db.prepare("INSERT OR IGNORE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username) db.prepare("REPLACE INTO sim_proxy (user_id, proxy_owner_id, displayname) VALUES (?, ?, ?)").run(pkMessage.member.uuid, pkMessage.sender, author.username)
} catch (e) { } catch (e) {
// Fall back to offline cache // Fall back to offline cache
const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get() const senderMxid = from("sim_proxy").join("sim", "user_id").join("sim_member", "mxid").where({displayname: author.username, room_id: roomID}).pluck("mxid").get()

View file

@ -33,9 +33,10 @@ function getDiscordParseCallbacks(message, guild, useHTML) {
user: node => { user: node => {
const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get() const mxid = select("sim", "mxid", {user_id: node.id}).pluck().get()
const interaction = message.interaction_metadata || message.interaction const interaction = message.interaction_metadata || message.interaction
const username = message.mentions.find(ment => ment.id === node.id)?.username const username = message.mentions?.find(ment => ment.id === node.id)?.username
|| message.referenced_message?.mentions.find(ment => ment.id === node.id)?.username || message.referenced_message?.mentions?.find(ment => ment.id === node.id)?.username
|| (interaction?.user.id === node.id ? interaction.user.username : null) || (interaction?.user.id === node.id ? interaction.user.username : null)
|| (message.author.id === node.id ? message.author.username : null)
|| node.id || node.id
if (mxid && useHTML) { if (mxid && useHTML) {
return `<a href="https://matrix.to/#/${mxid}">@${username}</a>` return `<a href="https://matrix.to/#/${mxid}">@${username}</a>`
@ -407,13 +408,13 @@ async function messageToEvent(message, guild, options = {}, di) {
async function transformParsedVia(parsed) { async function transformParsedVia(parsed) {
for (const node of parsed) { for (const node of parsed) {
if (node.type === "discordChannel") { if (node.type === "discordChannel" || node.type === "discordChannelLink") {
node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get() node.row = select("channel_room", ["room_id", "name", "nick"], {channel_id: node.id}).get()
if (node.row?.room_id) { if (node.row?.room_id) {
node.via = await getViaServersMemo(node.row.room_id) node.via = await getViaServersMemo(node.row.room_id)
} }
} }
;for (const maybeChildNodesArray of [node, node.content, node.items]) { for (const maybeChildNodesArray of [node, node.content, node.items]) {
if (Array.isArray(maybeChildNodesArray)) { if (Array.isArray(maybeChildNodesArray)) {
await transformParsedVia(maybeChildNodesArray) await transformParsedVia(maybeChildNodesArray)
} }
@ -610,7 +611,7 @@ async function messageToEvent(message, guild, options = {}, di) {
const event = invite.guild_scheduled_event const event = invite.guild_scheduled_event
if (!event) continue // the event ID provided was not valid if (!event) continue // the event ID provided was not valid
const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric"}) // 9 June at 3:00 pm NZT const formatter = new Intl.DateTimeFormat("en-NZ", {month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "shortGeneric", timeZone: reg.ooye.time_zone}) // 9 June at 3:00 pm NZT
const rep = new mxUtils.MatrixStringBuilder() const rep = new mxUtils.MatrixStringBuilder()
// Add time // Add time

View file

@ -100,6 +100,44 @@ test("message2event: simple room mention", async t => {
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each") t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
}) })
test("message2event: simple room link", async t => {
let called = 0
const events = await messageToEvent(data.message.simple_room_link, data.guild.general, {}, {
api: {
async getStateEvent(roomID, type, key) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
t.equal(type, "m.room.power_levels")
t.equal(key, "")
return {
users: {
"@_ooye_bot:cadence.moe": 100
}
}
},
async getJoinedMembers(roomID) {
called++
t.equal(roomID, "!BnKuBPCvyfOkhcUjEu:cadence.moe")
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",
"m.mentions": {},
msgtype: "m.text",
body: "#worm-farm",
format: "org.matrix.custom.html",
formatted_body: '<a href="https://matrix.to/#/!BnKuBPCvyfOkhcUjEu:cadence.moe?via=cadence.moe&via=matrix.org">#worm-farm</a>'
}])
t.equal(called, 2, "should call getStateEvent and getJoinedMembers once each")
})
test("message2event: nicked room mention", async t => { test("message2event: nicked room mention", async t => {
let called = 0 let called = 0
const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, { const events = await messageToEvent(data.message.nicked_room_mention, data.guild.general, {}, {

View file

@ -32,13 +32,10 @@ async function threadToAnnouncement(parentRoomID, threadRoomID, creatorMxid, thr
const template = creatorMxid ? "started a thread:" : "Thread started:" const template = creatorMxid ? "started a thread:" : "Thread started:"
const via = await mxUtils.getViaServersQuery(threadRoomID, di.api) const via = await mxUtils.getViaServersQuery(threadRoomID, di.api)
let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}` let body = `${template} ${thread.name} https://matrix.to/#/${threadRoomID}?${via.toString()}`
let html = `${template} <a href="https://matrix.to/#/${threadRoomID}?${via.toString()}">${thread.name}</a>`
return { return {
msgtype, msgtype,
body, body,
format: "org.matrix.custom.html",
formatted_body: html,
"m.mentions": {}, "m.mentions": {},
...context ...context
} }

View file

@ -55,8 +55,6 @@ test("thread2announcement: no known creator, no branched from event", async t =>
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {} "m.mentions": {}
}) })
}) })
@ -69,8 +67,6 @@ test("thread2announcement: known creator, no branched from event", async t => {
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.emote", msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {} "m.mentions": {}
}) })
}) })
@ -95,8 +91,6 @@ test("thread2announcement: no known creator, branched from discord event", async
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {}, "m.mentions": {},
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {
@ -126,8 +120,6 @@ test("thread2announcement: known creator, branched from discord event", async t
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.emote", msgtype: "m.emote",
body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "started a thread: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `started a thread: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": {}, "m.mentions": {},
"m.relates_to": { "m.relates_to": {
"m.in_reply_to": { "m.in_reply_to": {
@ -157,8 +149,6 @@ test("thread2announcement: no known creator, branched from matrix event", async
t.deepEqual(content, { t.deepEqual(content, {
msgtype: "m.text", msgtype: "m.text",
body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org", body: "Thread started: test thread https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org",
format: "org.matrix.custom.html",
formatted_body: `Thread started: <a href="https://matrix.to/#/!thread?via=cadence.moe&via=matrix.org">test thread</a>`,
"m.mentions": { "m.mentions": {
user_ids: ["@cadence:cadence.moe"] user_ids: ["@cadence:cadence.moe"]
}, },

View file

@ -7,6 +7,8 @@ const {sync} = require("../../passthrough")
const emojiSheetConverter = sync.require("../converters/emoji-sheet") const emojiSheetConverter = sync.require("../converters/emoji-sheet")
/** @type {import("../../matrix/api")} */ /** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/mreq")} */
const mreq = sync.require("../../matrix/mreq")
/** /**
* Downloads the emoji from the web and converts to uncompressed PNG data. * Downloads the emoji from the web and converts to uncompressed PNG data.
@ -19,6 +21,10 @@ async function getAndConvertEmoji(mxc) {
// If we were using connection pooling, we would be forced to download the entire GIF. // If we were using connection pooling, we would be forced to download the entire GIF.
// So we set no agent to ensure we are not connection pooling. // So we set no agent to ensure we are not connection pooling.
const res = await api.getMedia(mxc, {signal: abortController.signal}) const res = await api.getMedia(mxc, {signal: abortController.signal})
if (res.status !== 200) {
const root = await res.json()
throw new mreq.MatrixServerError(root, {mxc})
}
const readable = stream.Readable.fromWeb(res.body) const readable = stream.Readable.fromWeb(res.body)
return emojiSheetConverter.convertImageStream(readable, () => { return emojiSheetConverter.convertImageStream(readable, () => {
abortController.abort() abortController.abort()

View file

@ -13,10 +13,12 @@ const utils = sync.require("../converters/utils")
*/ */
async function deleteMessage(event) { async function deleteMessage(event) {
const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all() const rows = from("event_message").join("message_channel", "message_id").select("channel_id", "message_id").where({event_id: event.redacts}).all()
if (!rows.length) return
for (const row of rows) { for (const row of rows) {
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(row.message_id)
await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason) await discord.snow.channel.deleteMessage(row.channel_id, row.message_id, event.content.reason)
db.prepare("DELETE FROM event_message WHERE message_id = ?").run(row.message_id)
} }
db.prepare("DELETE FROM message_channel WHERE message_id = ?").run(rows[0].message_id)
} }
/** /**

View file

@ -5,9 +5,8 @@ const {join} = require("path")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {id} = require("../../../addbot")
async function setupEmojis() { async function setupEmojis() {
const {id} = require("../../../addbot")
const {discord, db} = passthrough const {discord, db} = passthrough
const emojis = await discord.snow.assets.getAppEmojis(id) const emojis = await discord.snow.assets.getAppEmojis(id)
for (const name of ["L1", "L2"]) { for (const name of ["L1", "L2"]) {

View file

@ -11,6 +11,7 @@ const entities = require("entities")
const passthrough = require("../../passthrough") const passthrough = require("../../passthrough")
const {sync, db, discord, select, from} = passthrough const {sync, db, discord, select, from} = passthrough
const {reg} = require("../../matrix/read-registration")
/** @type {import("../converters/utils")} */ /** @type {import("../converters/utils")} */
const mxUtils = sync.require("../converters/utils") const mxUtils = sync.require("../converters/utils")
/** @type {import("../../discord/utils")} */ /** @type {import("../../discord/utils")} */
@ -238,7 +239,8 @@ function convertEmoji(mxcUrl, nameForGuess, allowSpriteSheetIndicator, allowLink
if (!found) row = null if (!found) row = null
} }
// Or, if we don't have an emoji right now, we search for the name instead. // Or, if we don't have an emoji right now, we search for the name instead.
if (!row && nameForGuess) { const isLocalMxc = mxcUrl?.match(/^mxc:\/\/([^/]+)/)?.[1] === reg.ooye.server_name
if (!row && nameForGuess && isLocalMxc) {
const nameForGuessLower = nameForGuess.toLowerCase() const nameForGuessLower = nameForGuess.toLowerCase()
for (const guild of discord.guilds.values()) { for (const guild of discord.guilds.values()) {
/** @type {{name: string, id: string, animated: number}[]} */ /** @type {{name: string, id: string, animated: number}[]} */

View file

@ -22,7 +22,11 @@ function path(p, mxid, otherParams = {}) {
const u = new URL(p, "http://localhost") const u = new URL(p, "http://localhost")
if (mxid) u.searchParams.set("user_id", mxid) if (mxid) u.searchParams.set("user_id", mxid)
for (const entry of Object.entries(otherParams)) { for (const entry of Object.entries(otherParams)) {
if (entry[1] != undefined) { if (Array.isArray(entry[1])) {
for (const element of entry[1]) {
u.searchParams.append(entry[0], element)
}
} else if (entry[1] != undefined) {
u.searchParams.set(entry[0], entry[1]) u.searchParams.set(entry[0], entry[1])
} }
} }
@ -62,11 +66,14 @@ async function createRoom(content) {
} }
/** /**
* @param {string} roomIDOrAlias
* @param {string?} [mxid]
* @param {string[]?} [via]
* @returns {Promise<string>} room ID * @returns {Promise<string>} room ID
*/ */
async function joinRoom(roomIDOrAlias, mxid) { async function joinRoom(roomIDOrAlias, mxid, via) {
/** @type {Ty.R.RoomJoined} */ /** @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, {via}), {})
return root.room_id return root.room_id
} }
@ -384,7 +391,9 @@ async function getMedia(mxc, init = {}) {
}, },
...init ...init
}) })
if (init.method !== "HEAD") {
assert(res.body) assert(res.body)
}
// @ts-ignore // @ts-ignore
return res return res
} }

View file

@ -24,3 +24,7 @@ test("api path: real world mxid", t => {
test("api path: extras number works", t => { test("api path: extras number works", t => {
t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120") t.equal(path(`/client/v3/rooms/!example/timestamp_to_event`, null, {ts: 1687324651120}), "/client/v3/rooms/!example/timestamp_to_event?ts=1687324651120")
}) })
test("api path: multiple via params", t => {
t.equal(path(`/client/v3/rooms/!example/join`, null, {via: ["cadence.moe", "matrix.org"], ts: 1687324651120}), "/client/v3/rooms/!example/join?via=cadence.moe&via=matrix.org&ts=1687324651120")
})

11
src/types.d.ts vendored
View file

@ -31,6 +31,7 @@ export type AppServiceRegistrationConfig = {
discord_origin?: string discord_origin?: string
discord_cdn_origin?: string, discord_cdn_origin?: string,
web_password: string web_password: string
time_zone?: string
} }
old_bridge?: { old_bridge?: {
as_token: string as_token: string
@ -148,6 +149,14 @@ export namespace Event {
prev_content?: any prev_content?: any
} }
export type Outer_StrippedChildStateEvent = {
type: string
state_key: string
sender: string
origin_server_ts: number
content: any
}
export type M_Room_Message = { export type M_Room_Message = {
msgtype: "m.text" | "m.emote" msgtype: "m.text" | "m.emote"
body: string body: string
@ -344,7 +353,7 @@ export namespace R {
export type Hierarchy = { export type Hierarchy = {
avatar_url?: string avatar_url?: string
canonical_alias?: string canonical_alias?: string
children_state: {} children_state: Event.Outer_StrippedChildStateEvent[]
guest_can_join: boolean guest_can_join: boolean
join_rule?: string join_rule?: string
name?: string name?: string

View file

@ -75,11 +75,14 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID) const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID)
if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`}) if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`})
const inviteSender = select("invite", "mxid", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
const via = [ inviteSender?.match(/:(.*)/)?.[1] ?? "" ]
// Check space exists and bridge is joined // Check space exists and bridge is joined
try { try {
await api.joinRoom(parsedBody.space_id) await api.joinRoom(parsedBody.space_id, null, via)
} catch (e) { } catch (e) {
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: ${e.errcode} - ${e.message})`})
} }
// Check bridge has PL 100 // Check bridge has PL 100
@ -134,19 +137,33 @@ as.router.post("/api/link", defineEventHandler(async event => {
if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`}) if (row) throw createError({status: 400, message: "Bad Request", data: `Channel ID ${row.channel_id} or room ID ${parsedBody.matrix} are already bridged and cannot be reused`})
// Check room is part of the guild's space // Check room is part of the guild's space
let found = false let foundRoom = false
/** @type {string[]?} */
let foundVia = null
for await (const room of api.generateFullHierarchy(spaceID)) { for await (const room of api.generateFullHierarchy(spaceID)) {
// When finding a space during iteration, look at space's children state, because we need a `via` to join the room (when we find it later)
for (const state of room.children_state) {
if (state.type === "m.space.child" && state.state_key === parsedBody.matrix) {
foundVia = state.content.via
}
}
// When finding a room during iteration, see if it was the requested room (to confirm that the room is in the space)
if (room.room_id === parsedBody.matrix && !room.room_type) { if (room.room_id === parsedBody.matrix && !room.room_type) {
found = true foundRoom = true
break
} }
if (foundRoom && foundVia) break
} }
if (!found) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"}) if (!foundRoom) throw createError({status: 400, message: "Bad Request", data: "Matrix room needs to be part of the bridged space"})
// Check room exists and bridge is joined // Check room exists and bridge is joined
try { try {
await api.joinRoom(parsedBody.matrix) await api.joinRoom(parsedBody.matrix, null, foundVia)
} catch (e) { } catch (e) {
if (!foundVia) {
throw createError({status: 400, message: "Unable To Join", data: `Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: ${e.errcode} - ${e.message})`})
}
throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`}) throw createError({status: 403, message: e.errcode, data: `${e.errcode} - ${e.message}`})
} }

View file

@ -77,7 +77,7 @@ test("web link space: check that OOYE is joined", async t => {
} }
} }
})) }))
t.equal(error.data, "M_FORBIDDEN - not allowed to join I guess") t.equal(error.data, "Unable to join the requested Matrix space. Please invite the bridge to the space and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)")
t.equal(called, 1) t.equal(called, 1)
}) })
@ -360,7 +360,7 @@ test("web link room: check that room is part of space (not in hierarchy)", async
t.equal(called, 1) t.equal(called, 1)
}) })
test("web link room: check that bridge can join room", async t => { test("web link room: check that bridge can join room (notices lack of via and asks for invite instead)", async t => {
let called = 0 let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", { const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: { sessionData: {
@ -381,7 +381,55 @@ test("web link room: check that bridge can join room", async t => {
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield { yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: {}, children_state: [],
guest_can_join: false,
num_joined_members: 2
}
/* c8 ignore next */
}
}
}))
t.equal(error.data, "Unable to join the requested Matrix room. Please invite the bridge to the room and try again. (Server said: M_FORBIDDEN - not allowed to join I guess)")
t.equal(called, 2)
})
test("web link room: check that bridge can join room (uses via for join attempt)", async t => {
let called = 0
const [error] = await tryToCatch(() => router.test("post", "/api/link", {
sessionData: {
managedGuilds: ["665289423482519565"]
},
body: {
discord: "665310973967597573",
matrix: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
guild_id: "665289423482519565"
},
api: {
async joinRoom(roomID, _, via) {
called++
t.deepEqual(via, ["cadence.moe", "hashi.re"])
throw new MatrixServerError({errcode: "M_FORBIDDEN", error: "not allowed to join I guess"})
},
async *generateFullHierarchy(spaceID) {
called++
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: [],
guest_can_join: false,
num_joined_members: 2
}
yield {
room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe",
children_state: [{
type: "m.space.child",
state_key: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
sender: "@elliu:hashi.re",
content: {
via: ["cadence.moe", "hashi.re"]
},
origin_server_ts: 0
}],
guest_can_join: false, guest_can_join: false,
num_joined_members: 2 num_joined_members: 2
} }
@ -414,7 +462,7 @@ test("web link room: check that bridge has PL 100 in target room (event missing)
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield { yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: {}, children_state: [],
guest_can_join: false, guest_can_join: false,
num_joined_members: 2 num_joined_members: 2
} }
@ -454,7 +502,7 @@ test("web link room: check that bridge has PL 100 in target room (users default)
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield { yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: {}, children_state: [],
guest_can_join: false, guest_can_join: false,
num_joined_members: 2 num_joined_members: 2
} }
@ -494,7 +542,7 @@ test("web link room: successfully calls createRoom", async t => {
t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(spaceID, "!zTMspHVUBhFLLSdmnS:cadence.moe")
yield { yield {
room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe", room_id: "!NDbIqNpJyPvfKRnNcr:cadence.moe",
children_state: {}, children_state: [],
guest_can_join: false, guest_can_join: false,
num_joined_members: 2 num_joined_members: 2
} }

View file

@ -1398,6 +1398,63 @@ module.exports = {
attachments: [], attachments: [],
guild_id: "112760669178241024" guild_id: "112760669178241024"
}, },
simple_room_link: {
type: 0,
tts: false,
timestamp: "2023-07-10T20:04:25.939000+00:00",
referenced_message: null,
pinned: false,
nonce: "1128054139385806848",
mentions: [],
mention_roles: [],
mention_everyone: false,
member: {
roles: [
"112767366235959296", "118924814567211009",
"204427286542417920", "199995902742626304",
"222168467627835392", "238028326281805825",
"259806643414499328", "265239342648131584",
"271173313575780353", "287733611912757249",
"225744901915148298", "305775031223320577",
"318243902521868288", "348651574924541953",
"349185088157777920", "378402925128712193",
"392141548932038658", "393912152173576203",
"482860581670486028", "495384759074160642",
"638988388740890635", "373336013109461013",
"530220455085473813", "454567553738473472",
"790724320824655873", "1123518980456452097",
"1040735082610167858", "695946570482450442",
"1123460940935991296", "849737964090556488"
],
premium_since: null,
pending: false,
nick: null,
mute: false,
joined_at: "2015-11-11T09:55:40.321000+00:00",
flags: 0,
deaf: false,
communication_disabled_until: null,
avatar: null
},
id: "1128054143064494233",
flags: 0,
embeds: [],
edited_timestamp: null,
content: "https://discord.com/channels/112760669178241024/1100319550446252084",
components: [],
channel_id: "266767590641238027",
author: {
username: "kumaccino",
public_flags: 128,
id: "113340068197859328",
global_name: "kumaccino",
discriminator: "0",
avatar_decoration: null,
avatar: "b48302623a12bc7c59a71328f72ccb39"
},
attachments: [],
guild_id: "112760669178241024"
},
nicked_room_mention: { nicked_room_mention: {
type: 0, type: 0,
tts: false, tts: false,

View file

@ -17,6 +17,8 @@ const {reg} = require("../src/matrix/read-registration")
reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby" reg.ooye.discord_token = "Njg0MjgwMTkyNTUzODQ0NzQ3.Xl3zlw.baby"
reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded reg.ooye.server_origin = "https://matrix.cadence.moe" // so that tests will pass even when hard-coded
reg.ooye.server_name = "cadence.moe" reg.ooye.server_name = "cadence.moe"
reg.ooye.namespace_prefix = "_ooye_"
reg.sender_localpart = "_ooye_bot"
reg.id = "baby" reg.id = "baby"
reg.as_token = "don't actually take authenticated actions on the server" reg.as_token = "don't actually take authenticated actions on the server"
reg.hs_token = "don't actually take authenticated actions on the server" reg.hs_token = "don't actually take authenticated actions on the server"
@ -25,6 +27,7 @@ reg.namespaces = {
aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}] aliases: [{regex: "#_ooye_.*:cadence.moe", exclusive: true}]
} }
reg.ooye.bridge_origin = "https://bridge.example.org" reg.ooye.bridge_origin = "https://bridge.example.org"
reg.ooye.time_zone = "Pacific/Auckland"
const sync = new HeatSync({watchFS: false}) const sync = new HeatSync({watchFS: false})