UI for linking existing space

This commit is contained in:
Cadence Ember
2025-02-04 02:45:38 +13:00
parent 3d0609f8f1
commit d45a0bdc10
15 changed files with 293 additions and 116 deletions

View File

@@ -69,3 +69,9 @@ So here's all the technical changes needed to support self-service in v3:
- When bot is added through "self-service" web button, REPLACE INTO state 0. - 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. - Event dispatcher will only ensureRoom if the guild_active state is 1.
- createRoom will only create other dependencies if the guild is autocreate. - createRoom will only create other dependencies if the guild is autocreate.
## Enough with your theory. How do rooms actually get bridged now?
After clicking the easy mode button on web and adding the bot to a server, it will create new Matrix rooms on-demand when any invite features are used (web or command) OR just when any message is sent on Discord.
Alternatively, pressing the self-service mode button and adding the bot to a server will prompt the web user to link it with a space. After doing so, they'll be on the standard guild management page where they can invite to the space and manually link rooms. Nothing will be autocreated.

View File

@@ -4,6 +4,9 @@ CREATE TABLE "invite" (
"mxid" TEXT NOT NULL, "mxid" TEXT NOT NULL,
"room_id" TEXT NOT NULL, "room_id" TEXT NOT NULL,
"type" TEXT, "type" TEXT,
"name" TEXT,
"topic" TEXT,
"avatar" TEXT,
PRIMARY KEY("mxid","room_id") PRIMARY KEY("mxid","room_id")
) WITHOUT ROWID; ) WITHOUT ROWID;

View File

@@ -44,6 +44,8 @@ export type Models = {
mxid: string mxid: string
room_id: string room_id: string
type: string | null type: string | null
name: string | null
avatar: string | null
} }
lottie: { lottie: {

View File

@@ -64,7 +64,9 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
}] }]
} }
] ]
}]) }]).catch(e => {
console.error(e)
})
async function dispatchInteraction(interaction) { async function dispatchInteraction(interaction) {
const interactionId = interaction.data.custom_id || interaction.data.name const interactionId = interaction.data.custom_id || interaction.data.name

View File

@@ -208,6 +208,26 @@ async event => {
await api.ackEvent(event) await api.ackEvent(event)
})) }))
function getFromInviteRoomState(inviteRoomState, nskey, key) {
if (!Array.isArray(inviteRoomState)) return null
for (const event of inviteRoomState) {
if (event.type === nskey && event.state_key === "") {
return event.content[key]
}
}
return null
}
sync.addTemporaryListener(as, "type:m.space.child", guard("m.space.child",
/**
* @param {Ty.Event.StateOuter<Ty.Event.M_Space_Child>} event
*/
async event => {
if (Array.isArray(event.content.via) && event.content.via.length) { // space child is being added
await api.joinRoom(event.state_key).catch(() => {}) // try to join if able, it's okay if it doesn't want, bot will still respond to invites
}
}))
sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member", sync.addTemporaryListener(as, "type:m.room.member", guard("m.room.member",
/** /**
* @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event * @param {Ty.Event.StateOuter<Ty.Event.M_Room_Member>} event
@@ -217,9 +237,14 @@ async event => {
if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) { if (event.content.membership === "invite" && event.state_key === `@${reg.sender_localpart}:${reg.ooye.server_name}`) {
// We were invited to a room. We should join, and register the invite details for future reference in web. // We were invited to a room. We should join, and register the invite details for future reference in web.
const name = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.name", "name")
const topic = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.topic", "topic")
const avatar = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.avatar", "url")
const creationType = getFromInviteRoomState(event.unsigned?.invite_room_state, "m.room.create", "type")
if (!name) return await api.leaveRoomWithReason(event.room_id, "Please only invite me to rooms that have a name/avatar set. Update the room details and reinvite!")
await api.joinRoom(event.room_id) await api.joinRoom(event.room_id)
const creation = await api.getStateEvent(event.room_id, "m.room.create", "") db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type, name, topic, avatar) VALUES (?, ?, ?, ?, ?, ?)").run(event.sender, event.room_id, creationType, name, topic, avatar)
db.prepare("INSERT OR IGNORE INTO invite (mxid, room_id, type) VALUES (?, ?, ?)").run(event.sender, event.room_id, creation.type || null) if (avatar) utils.getPublicUrlForMxc(avatar) // make sure it's available in the media_proxy allowed URLs
} }
if (utils.eventSenderIsFromDiscord(event.state_key)) return if (utils.eventSenderIsFromDiscord(event.state_key)) return

View File

@@ -82,6 +82,16 @@ async function leaveRoom(roomID, mxid) {
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {}) await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {})
} }
/**
* @param {string} roomID
* @param {string} reason
* @param {string} [mxid]
*/
async function leaveRoomWithReason(roomID, reason, mxid) {
console.log(`[api] leave: ${roomID}: ${mxid}, because ${reason}`)
await mreq.mreq("POST", path(`/client/v3/rooms/${roomID}/leave`, mxid), {reason})
}
/** /**
* @param {string} roomID * @param {string} roomID
* @param {string} eventID * @param {string} eventID
@@ -404,6 +414,7 @@ module.exports.createRoom = createRoom
module.exports.joinRoom = joinRoom module.exports.joinRoom = joinRoom
module.exports.inviteToRoom = inviteToRoom module.exports.inviteToRoom = inviteToRoom
module.exports.leaveRoom = leaveRoom module.exports.leaveRoom = leaveRoom
module.exports.leaveRoomWithReason = leaveRoomWithReason
module.exports.getEvent = getEvent module.exports.getEvent = getEvent
module.exports.getEventForTimestamp = getEventForTimestamp module.exports.getEventForTimestamp = getEventForTimestamp
module.exports.getAllState = getAllState module.exports.getAllState = getAllState

5
src/types.d.ts vendored
View File

@@ -281,6 +281,11 @@ export namespace Event {
users_default?: number users_default?: number
} }
export type M_Space_Child = {
via?: string[]
suggested?: boolean
}
export type M_Reaction = { export type M_Reaction = {
"m.relates_to": { "m.relates_to": {
rel_type: "m.annotation" rel_type: "m.annotation"

View File

@@ -71,23 +71,24 @@ block body
div div
!= svg != svg
h2.mt48.fs-headline1 Matrix setup if space_id
h2.mt48.fs-headline1 Matrix setup
h3.mt32.fs-category Linked channels h3.mt32.fs-category Linked channels
.s-card.bs-sm.p0 .s-card.bs-sm.p0
form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.") form.s-table-container(method="post" action="/api/unlink" hx-confirm="Do you want to unlink these channels?\nIt may take a moment to clean up Matrix resources.")
input(type="hidden" name="guild_id" value=guild_id) input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple table.s-table.s-table__bx-simple
each row in linkedChannelsWithDetails each row in linkedChannelsWithDetails
tr tr
td.w40: +discord(row.channel) td.w40: +discord(row.channel)
td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm td.p2: button.s-btn.s-btn__muted.s-btn__xs(name="channel_id" value=row.channel.id hx-post="/api/unlink" hx-trigger="click" hx-disabled-elt="this")!= icons.Icons.IconLinkSm
td: +matrix(row) td: +matrix(row)
else else
tr tr
td(colspan="3") td(colspan="3")
.s-empty-state No channels linked between Discord and Matrix yet... .s-empty-state No channels linked between Discord and Matrix yet...
h3.mt32.fs-category Auto-create h3.mt32.fs-category Auto-create
.s-card .s-card
@@ -97,95 +98,96 @@ 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. 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() - let value = !!select("guild_active", "autocreate", {guild_id}).pluck().get()
input(type="hidden" name="guild_id" value=guild_id) 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" 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 autocomplete="off")
#autocreate-loading #autocreate-loading
h3.mt32.fs-category Privacy level if space_id
.s-card h3.mt32.fs-category Privacy level
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input") .s-card
form(hx-post="/api/privacy-level" hx-trigger="change" hx-indicator="#privacy-level-loading" hx-disabled-elt="input")
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#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
h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required 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) 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#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
h3.mt32.fs-category Manually link channels
form.d-flex.g16.ai-start(hx-post="/api/link" hx-trigger="submit" hx-disabled-elt="input, button" hx-indicator="#link-button")
.fl-grow2.s-btn-group.fd-column.w40
each channel in unlinkedChannels
input.s-btn--radio(type="radio" name="discord" required id=channel.id value=channel.id)
label.s-btn.s-btn__muted.ta-left.truncate(for=channel.id)
+discord(channel, true, "Announcement")
else
.s-empty-state.p8 All Discord channels are linked.
.fl-grow1.s-btn-group.fd-column.w30
each room in unlinkedRooms
input.s-btn--radio(type="radio" name="matrix" required 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#link-button
!= icons.Icons.IconMerge
= ` Link`
details.mt48
summary Debug room list
.d-grid.grid__2.gx24
div div
h3.mt24 Channels button.s-btn.s-btn__icon.s-btn__filled#link-button
p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked. != icons.Icons.IconMerge
div = ` Link`
h3.mt24 Rooms
p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked. details.mt48
div summary Debug room list
h3.mt24 Unavailable channels: Deleted from Discord .d-grid.grid__2.gx24
.s-card.p0 div
ul.my8.ml24 h3.mt24 Channels
each row in removedUncachedChannels p Channels are read from the channel_room table and then merged with the discord.channels memory cache to make the merged list. Anything in memory cache that's not in channel_room is considered unlinked.
li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`)= row.nick || row.name div
h3.mt24 Unavailable channels: Wrong type h3.mt24 Rooms
.s-card.p0 p Rooms use the same merged list as channels, based on augmented channel_room data. Then, rooms are read from the space. Anything in the space that's not merged is considered unlinked.
ul.my8.ml24 div
each row in removedWrongTypeChannels h3.mt24 Unavailable channels: Deleted from Discord
li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`) (#{row.type}) #{row.name} .s-card.p0
div- // Rooms ul.my8.ml24
h3.mt24 Unavailable rooms: Already linked each row in removedUncachedChannels
.s-card.p0 li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`)= row.nick || row.name
ul.my8.ml24 h3.mt24 Unavailable channels: Wrong type
each row in removedLinkedRooms .s-card.p0
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name ul.my8.ml24
h3.mt24 Unavailable rooms: Wrong type each row in removedWrongTypeChannels
.s-card.p0 li: a(href=`https://discord.com/channels/${guild_id}/${row.channel_id}`) (#{row.type}) #{row.name}
ul.my8.ml24 div- // Rooms
each row in removedWrongTypeRooms h3.mt24 Unavailable rooms: Already linked
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name} .s-card.p0
h3.mt24 Unavailable rooms: Archived thread ul.my8.ml24
.s-card.p0 each row in removedLinkedRooms
ul.my8.ml24 li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name
each row in removedArchivedThreadRooms h3.mt24 Unavailable rooms: Wrong type
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name .s-card.p0
ul.my8.ml24
each row in removedWrongTypeRooms
li: a(href=`https://matrix.to/#/${row.room_id}`) (#{row.room_type}) #{row.name}
h3.mt24 Unavailable rooms: Archived thread
.s-card.p0
ul.my8.ml24
each row in removedArchivedThreadRooms
li: a(href=`https://matrix.to/#/${row.room_id}`)= row.name

View File

@@ -1,7 +1,7 @@
extends includes/template.pug extends includes/template.pug
block body block body
if !managed if !session.data.user_id
.s-empty-state.wmx4.p48 .s-empty-state.wmx4.p48
!= icons.Spots.SpotEmptyXL != icons.Spots.SpotEmptyXL
p You need to log in to manage your servers. p You need to log in to manage your servers.

View File

@@ -0,0 +1,52 @@
extends includes/template.pug
mixin space(space)
.s-user-card.flex__1
span.s-avatar.s-avatar__32.s-user-card--avatar
if space.avatar
img.s-avatar--image(src=mUtils.getPublicUrlForMxc(space.avatar))
else
.s-avatar--letter.bg-silver-400.bar-md(aria-hidden="true")= space.name[0]
.s-user-card--info.ai-start
strong= space.name
if space.topic
ul.s-user-card--awards
li space.topic
block body
.s-notice.s-notice__info.d-flex.g16
div
!= icons.Icons.IconInfo
div
strong You picked self-service mode
.mt4 To complete setup, you need to manually choose a Matrix space to link with #[strong= guild.name].
h3.mt32.fs-category Choose a space
form.s-card.bs-sm.p0.s-table-container.bar-md(method="post" action="/api/link-space")
input(type="hidden" name="guild_id" value=guild_id)
table.s-table.s-table__bx-simple
each space in spaces
tr
td.p0: +space(space)
td: button.s-btn(name="space_id" value=space.room_id hx-post="/api/link-space" hx-trigger="click" hx-disabled-elt="this") Link with this space
else
if session.data.mxid
tr
- const self = `@${reg.sender_localpart}:${reg.ooye.server_name}`
td.p16 On Matrix, invite #[code.s-code-block: a.s-link(href=`https://matrix.to/#/${self}` target="_blank")= self] to a space. Then you can pick it from this list.
else
tr
td.d-flex.ai-center.pl16.g16
| You need to log in with Matrix first.
a.s-btn.s-btn__matrix.s-btn__outlined(href=rel("/log-in-with-matrix")) Log in with Matrix
h3.mt48.fs-category Auto-create
.s-card
form.d-flex.ai-center.g8(method="post" action="/api/autocreate" hx-post="/api/autocreate" hx-indicator="#easy-mode-button")
input(type="hidden" name="guild_id" value=guild_id)
input(type="hidden" name="autocreate" value="true")
label.s-label.fl-grow1
| Changed your mind?
p.s-description If you want, OOYE can create and manage the Matrix space so you don't have to.
button.s-btn.s-btn__outlined#easy-mode-button Use easy mode

View File

@@ -2,9 +2,9 @@
const assert = require("assert/strict") const assert = require("assert/strict")
const {z} = require("zod") const {z} = require("zod")
const {defineEventHandler, useSession, createError, readValidatedBody} = require("h3") const {defineEventHandler, useSession, createError, readValidatedBody, getRequestHeader, setResponseHeader, sendRedirect} = require("h3")
const {as, db, sync} = require("../../passthrough") const {as, db, sync, select} = require("../../passthrough")
const {reg} = require("../../matrix/read-registration") const {reg} = require("../../matrix/read-registration")
/** @type {import("../../d2m/actions/create-space")} */ /** @type {import("../../d2m/actions/create-space")} */
@@ -29,6 +29,17 @@ as.router.post("/api/autocreate", defineEventHandler(async event => {
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).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"}) if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).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) db.prepare("UPDATE guild_active SET autocreate = ? WHERE guild_id = ?").run(+!!parsedBody.autocreate, parsedBody.guild_id)
// If showing a partial page due to incomplete setup, need to refresh the whole page to show the alternate version
const spaceID = select("guild_space", "space_id", {guild_id: parsedBody.guild_id}).pluck().get()
if (!spaceID) {
if (getRequestHeader(event, "HX-Request")) {
setResponseHeader(event, "HX-Refresh", "true")
} else {
return sendRedirect(event, "", 302)
}
}
return null // 204 return null // 204
})) }))

View File

@@ -8,7 +8,7 @@ const {LRUCache} = require("lru-cache")
const Ty = require("../../types") const Ty = require("../../types")
const uqr = require("uqr") const uqr = require("uqr")
const {discord, as, sync, select} = require("../../passthrough") const {discord, as, sync, select, from, db} = require("../../passthrough")
/** @type {import("../pug-sync")} */ /** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync") const pugSync = sync.require("../pug-sync")
/** @type {import("../../d2m/actions/create-space")} */ /** @type {import("../../d2m/actions/create-space")} */
@@ -109,15 +109,21 @@ function getChannelRoomsLinks(guildID, rooms) {
as.router.get("/guild", defineEventHandler(async event => { as.router.get("/guild", defineEventHandler(async event => {
const {guild_id} = await getValidatedQuery(event, schema.guild.parse) const {guild_id} = await getValidatedQuery(event, schema.guild.parse)
const session = await useSession(event, {password: reg.as_token}) const session = await useSession(event, {password: reg.as_token})
const row = select("guild_space", ["space_id", "privacy_level"], {guild_id}).get() const row = from("guild_active").join("guild_space", "guild_id", "left").select("space_id", "privacy_level", "autocreate").where({guild_id}).get()
// @ts-ignore // @ts-ignore
const guild = discord.guilds.get(guild_id) const guild = discord.guilds.get(guild_id)
// Permission problems // Permission problems
if (!guild_id || !guild || !(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id)) { if (!guild_id || !guild || !(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guild_id) || !row) {
return pugSync.render(event, "guild_access_denied.pug", {guild_id}) return pugSync.render(event, "guild_access_denied.pug", {guild_id})
} }
// Self-service guild that hasn't been linked yet - needs a special page encouraging the link flow
if (!row.space_id && row.autocreate === 0) {
const spaces = db.prepare("SELECT room_id, type, name, avatar FROM invite LEFT JOIN guild_space ON invite.room_id = guild_space.space_id WHERE mxid = ? AND space_id IS NULL and type = 'm.space'").all(session.data.mxid)
return pugSync.render(event, "guild_not_linked.pug", {guild, guild_id, spaces})
}
const nonce = randomUUID() const nonce = randomUUID()
validNonce.set(nonce, guild_id) validNonce.set(nonce, guild_id)
@@ -128,10 +134,10 @@ as.router.get("/guild", defineEventHandler(async event => {
const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`) const svg = generatedSvg.replace(/viewBox="0 0 ([0-9]+) ([0-9]+)"/, `data-nonce="${nonce}" width="$1" height="$2" $&`)
assert.notEqual(svg, generatedSvg) assert.notEqual(svg, generatedSvg)
// Unlinked guild // Easy mode guild that hasn't been linked yet - need to remove elements that would require an existing space
if (!row) { if (!row.space_id) {
const links = getChannelRoomsLinks(guild_id, []) const links = getChannelRoomsLinks(guild_id, [])
return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links}) return pugSync.render(event, "guild.pug", {guild, guild_id, svg, ...links, ...row})
} }
// Linked guild // Linked guild

View File

@@ -18,6 +18,7 @@ test("web guild: access denied when not logged in", async t => {
test("web guild: asks to select guild if not selected", async t => { test("web guild: asks to select guild if not selected", async t => {
const content = await router.test("get", "/guild", { const content = await router.test("get", "/guild", {
sessionData: { sessionData: {
user_id: "1",
managedGuilds: [] managedGuilds: []
}, },
}) })
@@ -27,6 +28,7 @@ test("web guild: asks to select guild if not selected", async t => {
test("web guild: access denied when guild id messed up", async t => { test("web guild: access denied when guild id messed up", async t => {
const content = await router.test("get", "/guild?guild_id=1", { const content = await router.test("get", "/guild?guild_id=1", {
sessionData: { sessionData: {
user_id: "1",
managedGuilds: [] managedGuilds: []
}, },
}) })
@@ -43,6 +45,7 @@ test("web invite: access denied with invalid nonce", async t => {
test("web guild: can view unbridged guild", async t => { test("web guild: can view unbridged guild", async t => {
const content = await router.test("get", "/guild?guild_id=66192955777486848", { const content = await router.test("get", "/guild?guild_id=66192955777486848", {
sessionData: { sessionData: {
user_id: "1",
managedGuilds: ["66192955777486848"] managedGuilds: ["66192955777486848"]
}, },
api: { api: {

View File

@@ -16,6 +16,10 @@ const {reg} = require("../../matrix/read-registration")
const api = sync.require("../../matrix/api") const api = sync.require("../../matrix/api")
const schema = { const schema = {
linkSpace: z.object({
guild_id: z.string(),
space_id: z.string()
}),
link: z.object({ link: z.object({
guild_id: z.string(), guild_id: z.string(),
matrix: z.string(), matrix: z.string(),
@@ -27,6 +31,48 @@ const schema = {
}) })
} }
as.router.post("/api/link-space", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.linkSpace.parse)
const session = await useSession(event, {password: reg.as_token})
// Check guild ID
const guildID = parsedBody.guild_id
if (!(session.data.managedGuilds || []).concat(session.data.matrixGuilds || []).includes(guildID)) throw createError({status: 403, message: "Forbidden", data: "Can't edit a guild you don't have Manage Server permissions in"})
// Check space ID
if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"})
const spaceID = parsedBody.space_id
const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "No past invitations detected from your Matrix account for that space."})
// Check they are not already bridged
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`})
// Check space 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(spaceID, "m.room.member", self)
if (memberEvent.membership !== "join") throw createError({status: 400, message: "Bad Request", data: "Matrix space does not exist"})
/** @type {Ty.Event.M_Power_Levels} */
const powerLevelsStateContent = await api.getStateEvent(spaceID, "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 space"})
// Check inviting user is a moderator in the space
const invitingPowerLevel = powerLevelsStateContent.users?.[session.data.mxid] || powerLevelsStateContent.users_default || 0
if (invitingPowerLevel < (powerLevelsStateContent.state_default || 50)) throw createError({status: 403, message: "Forbidden", data: `You need to be at least power level 50 (moderator) in the target Matrix space to use OOYE, but you are currently power level ${invitingPowerLevel}.`})
// Insert database entry
db.transaction(() => {
db.prepare("INSERT INTO guild_space (guild_id, space_id) VALUES (?, ?)").run(guildID, spaceID)
db.prepare("DELETE FROM invite WHERE room_id = ?").run(spaceID)
})()
setResponseHeader(event, "HX-Refresh", "true")
return null // 204
}))
as.router.post("/api/link", defineEventHandler(async event => { as.router.post("/api/link", defineEventHandler(async event => {
const parsedBody = await readValidatedBody(event, schema.link.parse) const parsedBody = await readValidatedBody(event, schema.link.parse)
const session = await useSession(event, {password: reg.as_token}) const session = await useSession(event, {password: reg.as_token})

View File

@@ -7,15 +7,18 @@ const {defineEventHandler, defaultContentType, getRequestHeader, setResponseHead
const icons = require("@stackoverflow/stacks-icons") const icons = require("@stackoverflow/stacks-icons")
const DiscordTypes = require("discord-api-types/v10") const DiscordTypes = require("discord-api-types/v10")
const dUtils = require("../discord/utils") const dUtils = require("../discord/utils")
const reg = require("../matrix/read-registration")
const {sync, discord, as, select} = require("../passthrough") const {sync, discord, as, select} = require("../passthrough")
/** @type {import("./pug-sync")} */ /** @type {import("./pug-sync")} */
const pugSync = sync.require("./pug-sync") const pugSync = sync.require("./pug-sync")
/** @type {import("../m2d/converters/utils")} */
const mUtils = sync.require("../m2d/converters/utils")
const {id} = require("../../addbot") const {id} = require("../../addbot")
// Pug // Pug
pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, icons}) pugSync.addGlobals({id, h3, discord, select, DiscordTypes, dUtils, mUtils, icons, reg: reg.reg})
pugSync.createRoute(as.router, "/", "home.pug") pugSync.createRoute(as.router, "/", "home.pug")
pugSync.createRoute(as.router, "/ok", "ok.pug") pugSync.createRoute(as.router, "/ok", "ok.pug")