Skip to content

Commit

Permalink
Add command for managing spaces (add room, remove room, list rooms)
Browse files Browse the repository at this point in the history
  • Loading branch information
ba1uev committed Jul 10, 2023
1 parent 42acdc2 commit 65e22a2
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 4 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Expand Up @@ -12,3 +12,6 @@ build

# dotenv environment variables file
.env

# system
.DS_Store
11 changes: 7 additions & 4 deletions src/admin-api/index.ts
Expand Up @@ -9,6 +9,7 @@ import {
RoomInfoShort,
RoomMembersResponse,
RoomPowerLevelsEvent,
RoomStateResponse,
UserAccountResponse,
UserAccountShort,
UserAccountsResponse,
Expand Down Expand Up @@ -50,12 +51,14 @@ class AdminApi {
})) as RoomDeletionResponse
}

async getRoomState(roomId: string): Promise<RoomStateResponse> {
return (await this.makeRequest("GET", `/v1/rooms/${roomId}/state`)) as RoomStateResponse
}

async getRoomPowerLevelsEvent(roomId: string): Promise<RoomPowerLevelsEvent | null> {
try {
const data = (await this.makeRequest("GET", `/v1/rooms/${roomId}/state`)) as {
state: RoomPowerLevelsEvent[]
}
const powerLevelEvent = data.state.find((x) => x.type === "m.room.power_levels")
const data = await this.getRoomState(roomId)
const powerLevelEvent = data.state.find((x) => x.type === "m.room.power_levels") as unknown as RoomPowerLevelsEvent
if (!powerLevelEvent) {
return null
}
Expand Down
19 changes: 19 additions & 0 deletions src/admin-api/types.ts
Expand Up @@ -53,6 +53,25 @@ export type RoomDeletionResponse = {
new_room_id: string | null
}

export type RoomStateEvent = {
content: Record<string, any>
origin_server_ts: number
room_id: string
sender: string
state_key: string
type: string
unsigned: Record<string, any>
event_id: string
user_id: string
age: number
replaces_state: string
prev_content: Record<string, any>
}

export type RoomStateResponse = {
state: RoomStateEvent[]
}

export type RoomPowerLevelsEvent = {
content: {
ban: number
Expand Down
5 changes: 5 additions & 0 deletions src/bot.ts
Expand Up @@ -3,6 +3,7 @@ import { LogService, MatrixClient, MatrixProfileInfo, MessageEvent, UserID } fro
import { LIST_ROOMS_COMMAND, runListRoomsCommand } from "src/commands/list-rooms"
import { LIST_SPACES_COMMAND, runListSpacesCommand } from "src/commands/list-spaces"
import { commandPrefix } from "src/constants"
import config from "src/config/env"

import { BULK_INVITE_COMMAND, runBulkInviteCommand } from "./commands/bulk-invite"
import { DEACTIVATE_USER_COMMAND, runDeactivateUserCommand } from "./commands/deactivate-user"
Expand All @@ -11,6 +12,7 @@ import { runHelpCommand } from "./commands/help"
import { INVITE_COMMAND, runInviteCommand } from "./commands/invite"
import { INVITE_ROOM, runInviteRoomCommand } from "./commands/invite-room"
import { PROMOTE_COMMAND, runPromoteCommand } from "./commands/promote"
import { runSpaceCommand, SPACE_COMMAND } from "./commands/space"
import { CommandError } from "./utils"

/* This is the maximum allowed time between time on matrix server
Expand Down Expand Up @@ -63,6 +65,7 @@ export default class Bot {
if (event.isRedacted) return // Ignore redacted events that come through
if (event.sender === this.userId) return // Ignore ourselves
if (event.messageType !== "m.text") return // Ignore non-text messages
if (config.ADMIN_ROOM_ID !== roomId) return // Ignore messages outside of the admin room

/* Ensure that the event is a command before going on. We allow people to ping
the bot as well as using our COMMAND_PREFIX. */
Expand Down Expand Up @@ -108,6 +111,8 @@ export default class Bot {
return await runDeleteRoomCommand(roomId, event, args, this.client)
case DEACTIVATE_USER_COMMAND:
return await runDeactivateUserCommand(roomId, event, args, this.client)
case SPACE_COMMAND:
return await runSpaceCommand(roomId, event, args, this.client)
default:
return await runHelpCommand(roomId, event, this.client)
}
Expand Down
13 changes: 13 additions & 0 deletions src/commands/help.ts
Expand Up @@ -8,6 +8,7 @@ import { defaultGroups, INVITE_COMMAND } from "src/commands/invite"
import { LIST_ROOMS_COMMAND } from "src/commands/list-rooms"
import { LIST_SPACES_COMMAND } from "src/commands/list-spaces"
import { PROMOTE_COMMAND } from "src/commands/promote"
import { SPACE_COMMAND } from "src/commands/space"
import config from "src/config/env"
import { groupedRooms } from "src/config/rooms"
import { commandPrefix } from "src/constants"
Expand All @@ -34,6 +35,18 @@ ${commandPrefix} ${LIST_SPACES_COMMAND}
--------------------------------------------------
${commandPrefix} ${SPACE_COMMAND} <spaceId> [list | add | remove] [<roomId>]
A command for managing spacess. See examples below.
<spaceId> - Matrix room id or alias for the space
<roomId> - Matrix room id or alias
Examples:
- "${commandPrefix} ${SPACE_COMMAND} !abcd:${config.MATRIX_SERVER_DOMAIN} list" – List all rooms in the "!abcd:..." space
- "${commandPrefix} ${SPACE_COMMAND} !abcd:${config.MATRIX_SERVER_DOMAIN} add !efgh:${config.MATRIX_SERVER_DOMAIN}" – Add the "!efgh:..." room to the "!abcd:..." space
- "${commandPrefix} ${SPACE_COMMAND} !abcd:${config.MATRIX_SERVER_DOMAIN} remove !efgh:${config.MATRIX_SERVER_DOMAIN}" – Remove the "!efgh:..." room from the "!abcd:..." space
--------------------------------------------------
${commandPrefix} ${INVITE_COMMAND} <userId> [<group> | <roomId>]
Invite user to a group of rooms.
<userId> - Matrix user id @username:${config.MATRIX_SERVER_DOMAIN}
Expand Down
112 changes: 112 additions & 0 deletions src/commands/space.ts
@@ -0,0 +1,112 @@
import htmlEscape from "escape-html"
import { LogService, MatrixClient, MessageEvent, MessageEventContent } from "matrix-bot-sdk"

import config from "src/config/env"
import { CommandError, resolveRoomAlias, sendMessage } from "src/utils"

import { adminApi } from "../admin-api"
import { AxiosError } from "axios"
import { RoomInfoShort } from "../admin-api/types"

const moduleName = "SpaceCommand"
export const SPACE_COMMAND = "space"

export async function runSpaceCommand(
roomId: string,
event: MessageEvent<MessageEventContent>,
args: string[],
client: MatrixClient,
): Promise<string> {
// 1. Retrive and validate arguments
const [, spaceRoomIdOrAlias, operator, targetRoomIdOrAlias] = args
if (!spaceRoomIdOrAlias) {
throw new CommandError(`Missing space room id argument`)
}
const spaceRoomId = await resolveRoomAlias(client, spaceRoomIdOrAlias)
if (!spaceRoomId) {
throw new CommandError(`The provided space handle does not represent a space`)
}
if (!["add", "remove", "list"].includes(operator)) {
throw new CommandError(`Invalid operator. Should be "add", "remove", or "list"`)
}
let targetRoomId: string | null
if (operator === "add" || operator === "remove") {
if (!targetRoomIdOrAlias) {
throw new CommandError(`Missing target room id argument`)
}
targetRoomId = await resolveRoomAlias(client, targetRoomIdOrAlias)
if (!targetRoomId) {
throw new CommandError(`The provided target room handle does not represent a room`)
}
}

// 2. Execute a command
switch (operator) {
case "list": {
await listSpace(roomId, client, spaceRoomId)
break
}
case "add": {
await addRoomInSpace(roomId, client, spaceRoomId, targetRoomId!)
break
}
case "remove": {
await removeRoomFromSpace(roomId, client, spaceRoomId, targetRoomId!)
break
}
}

return ""
}

async function listSpace(roomId: string, client: MatrixClient, spaceRoomId: string) {
const rooms = await adminApi.getRooms()
const roomsById = rooms.reduce((acc, x) => ({ ...acc, [x.room_id]: x }), {} as Record<string, RoomInfoShort>)
const space = roomsById[spaceRoomId]
if (!space) {
return sendMessage(client, roomId, `Space not found`)
}
const spaceRoomState = await adminApi
.getRoomState(space.room_id)
.catch((err: AxiosError<{ error: string; errcode: string }>) => {
if (err.response?.data?.error) {
throw new CommandError(err.response.data.error)
}
throw new CommandError(`Can't get "${space.name}" space state`)
})
const childRoomsMessage = spaceRoomState.state
.filter((x) => x.type === "m.space.child" && x.content?.via)
.map((x) => roomsById[x.state_key])
.filter(Boolean)
.map((x) => `${x.name} (${x.room_id})${x.room_type === "m.space" ? " [SPACE]" : ""}`)
.join("\n")

const html = `"${space.name}" space rooms:<br /><pre>${htmlEscape(childRoomsMessage)}</pre>`
return await client.sendHtmlText(roomId, html)
}

async function addRoomInSpace(roomId: string, client: MatrixClient, spaceRoomId: string, targetRoomId: string) {
const space = await client.getSpace(spaceRoomId).catch(() => null)
if (!space) {
throw new CommandError(`Space with id "${spaceRoomId}" not found`)
}
const spaceRoomInfo = await adminApi.getRoomInfo(spaceRoomId)
const targetRoomInfo = await adminApi.getRoomInfo(targetRoomId)
await space.addChildRoom(targetRoomId, { via: [config.MATRIX_SERVER_DOMAIN], suggested: false })
const message = `Room "${targetRoomInfo?.name}" has been added to the space "${spaceRoomInfo?.name}"`
await sendMessage(client, roomId, message)
LogService.info(moduleName, message)
}

async function removeRoomFromSpace(roomId: string, client: MatrixClient, spaceRoomId: string, targetRoomId: string) {
const space = await client.getSpace(spaceRoomId).catch(() => null)
if (!space) {
throw new CommandError(`Space with id "${spaceRoomId}" not found`)
}
const spaceRoomInfo = await adminApi.getRoomInfo(spaceRoomId)
const targetRoomInfo = await adminApi.getRoomInfo(targetRoomId)
await space.removeChildRoom(targetRoomId)
const message = `Room "${targetRoomInfo?.name}" has been removed from the space "${spaceRoomInfo?.name}"`
await sendMessage(client, roomId, message)
LogService.info(moduleName, message)
}
13 changes: 13 additions & 0 deletions src/utils.ts
Expand Up @@ -121,3 +121,16 @@ export const matrixRoomAliasRegex = new RegExp(
export const matrixRoomIdRegex = new RegExp(`^!([A-Za-z0-9]+):${config.MATRIX_SERVER_DOMAIN.replace(/\./g, ".")}$`)

export const matrixUserIdRegex = new RegExp(`^@([A-Za-z0-9_.-]+):${config.MATRIX_SERVER_DOMAIN.replace(/\./g, ".")}$`)

export async function resolveRoomAlias(client: MatrixClient, roomIdOrAlias: string): Promise<string | null> {
let roomId = roomIdOrAlias
if (matrixRoomAliasRegex.test(roomIdOrAlias)) {
try {
roomId = (await client.resolveRoom(roomIdOrAlias)) as string
} catch (e) {
return null
// throw new CommandError(`The provided room handle does not represent a room`)
}
}
return roomId
}

0 comments on commit 65e22a2

Please sign in to comment.