Skip to content

Commit

Permalink
Allow configuration of multiple moderator users to be invited to all …
Browse files Browse the repository at this point in the history
…rooms. (#234)

* Allow configuration of multiple moderator users to be invited to all rooms

* Fix SPECIAL_INTEREST_CREATION_TEMPLATE

Issue unrelated to this PR, probably been broken for a while

* Add basic E2E tests for what rooms are created and where the moderator is invited

* Throw startup error when old option supplied
  • Loading branch information
reivilibre authored Dec 17, 2024
1 parent ffc1a02 commit 9d3d191
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 87 deletions.
6 changes: 3 additions & 3 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ idServerDomain: "vector.im"
# result in emails or other communications to the user.
idServerBrand: "vector-im"

# This is the user ID of the moderator for all public-facing rooms the bot creates.
# Note that this user will be granted power level 100 (the highest) in every room
# These are the user IDs of the moderators for all public-facing rooms the bot creates.
# Note that these users will be granted power level 100 (the highest) in every room
# and be invited.
moderatorUserId: "@moderator:example.org"
moderatorUserIds: ["@moderator:example.org"]

# Settings for how the bot should represent livestreams.
livestream:
Expand Down
183 changes: 140 additions & 43 deletions spec/basic.spec.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,146 @@
import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test";
import { describe, it, beforeEach, afterEach, expect } from "@jest/globals";

describe('Basic test setup', () => {
let testEnv: E2ETestEnv;
beforeEach(async () => {
testEnv = await E2ETestEnv.createTestEnv({
fixture: 'basic-conference',
});
const welcomeMsg = testEnv.waitForMessage();
await testEnv.setUp();
console.log((await welcomeMsg).event.content.body.startsWith('WECOME!'));
}, E2ESetupTestTimeout);
afterEach(() => {
return testEnv?.tearDown();
});
it('should start up successfully', async () => {
const { event } = await testEnv.sendAdminCommand('!conference status');
console.log(event.content.body);
// Check that we're generally okay.
expect(event.content.body).toMatch('Scheduled tasks yet to run: 0');
expect(event.content.body).toMatch('Schedule source healthy: true');
async function buildConference(testEnv: E2ETestEnv): Promise<void> {
let spaceBuilt,
supportRoomsBuilt,
conferenceBuilt = false;
const waitForFinish = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() =>
reject(
new Error(
`Build incomplete. spaceBuild: ${spaceBuilt}, supportRoomsBuilt: ${supportRoomsBuilt}, conferenceBuilt: ${conferenceBuilt}`
)
),
30000
);
testEnv.adminClient.on("room.message", (_, event) => {
if (event.content.body.includes("Your conference's space is at")) {
spaceBuilt = true;
} else if (
event.content.body.includes("Support rooms have been created")
) {
supportRoomsBuilt = true;
} else if (event.content.body.includes("CONFERENCE BUILT")) {
conferenceBuilt = true;
}

if (spaceBuilt && supportRoomsBuilt && conferenceBuilt) {
resolve();
clearTimeout(timeout);
}
});
it('should be able to build successfully', async () => {
let spaceBuilt, supportRoomsBuilt, conferenceBuilt = false;
const waitForFinish = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(
`Build incomplete. spaceBuild: ${spaceBuilt}, supportRoomsBuilt: ${supportRoomsBuilt}, conferenceBuilt: ${conferenceBuilt}`
)), 30000);
testEnv.adminClient.on('room.message', (_, event) => {
if (event.content.body.includes("Your conference's space is at")) {
spaceBuilt = true;
} else if (event.content.body.includes("Support rooms have been created")) {
supportRoomsBuilt = true;
} else if (event.content.body.includes("CONFERENCE BUILT")) {
conferenceBuilt = true;
}

if (spaceBuilt && supportRoomsBuilt && conferenceBuilt) {
resolve();
clearTimeout(timeout);
}
})
});
await testEnv.sendAdminCommand('!conference build');
await waitForFinish;
// TODO: Now test that all the expected rooms are there.
});
await testEnv.sendAdminCommand("!conference build");
await waitForFinish;
}

function describeLocator(locator: any): string {
let out = `(${locator.conferenceId}) ${locator.kind}`;
for (let key of Object.keys(locator).sort()) {
if (key !== "conferenceId" && key !== "kind") {
out += ` ${key}=${locator[key]}`;
}
}
return out;
}

describe("Basic test setup", () => {
let testEnv: E2ETestEnv;
beforeEach(async () => {
testEnv = await E2ETestEnv.createTestEnv({
fixture: "basic-conference",
});
const welcomeMsg = testEnv.waitForMessage();
await testEnv.setUp();
console.log((await welcomeMsg).event.content.body.startsWith("WECOME!"));
}, E2ESetupTestTimeout);
afterEach(() => {
return testEnv?.tearDown();
});
it("should start up successfully", async () => {
const { event } = await testEnv.sendAdminCommand("!conference status");
console.log(event.content.body);
// Check that we're generally okay.
expect(event.content.body).toMatch("Scheduled tasks yet to run: 0");
expect(event.content.body).toMatch("Schedule source healthy: true");
});
it("should be able to build successfully", async () => {
await buildConference(testEnv);

// Now test that all the expected rooms are there.
// We will match against the 'locator' state events to identify the rooms.

const joinedRoomIds = await testEnv.confbotClient.getJoinedRooms();
console.debug("joined room IDs: ", joinedRoomIds);

const allLocators: string[] = [];
let roomsWithoutLocators = 0;

for (const joinedRoomId of joinedRoomIds) {
if (joinedRoomId == testEnv.opts.config?.managementRoom) {
// The management room is not interesting
continue;
}
try {
const roomLocator = await testEnv.confbotClient.getRoomStateEvent(
joinedRoomId,
"org.matrix.confbot.locator",
""
);
allLocators.push(describeLocator(roomLocator));
} catch (error) {
// This room doesn't have a locator
console.warn("room without locator: ", joinedRoomId);
roomsWithoutLocators += 1;
}
}

expect(allLocators.sort()).toMatchInlineSnapshot(`
[
"(test-conf) auditorium auditoriumId=main_stream",
"(test-conf) auditorium_backstage auditoriumId=main_stream",
"(test-conf) conference",
"(test-conf) conference_space",
"(test-conf) talk talkId=1",
]
`);
// TODO understand/explain why there are 6 rooms without locators
expect(roomsWithoutLocators).toBe(6);
});

it("should invite the moderator users to relevant rooms", async () => {
await buildConference(testEnv);

// List of rooms that we expect the moderator user to be invited to
const rooms = [
// `#test-conf:${testEnv.homeserver.domain}`, -- not invited to the root space
`#main_stream:${testEnv.homeserver.domain}`,
// `#main_stream-backstage:${testEnv.homeserver.domain}` -- not invited to the backstage,
`#talk-1:${testEnv.homeserver.domain}`,
];
const moderatorUserId = `@modbot:${testEnv.homeserver.domain}`;

for (let roomAlias of rooms) {
const roomId = await testEnv.confbotClient.resolveRoom(roomAlias);
let moderatorMembershipInRoom: any;
try {
moderatorMembershipInRoom =
await testEnv.confbotClient.getRoomStateEvent(
roomId,
"m.room.member",
moderatorUserId
);
} catch (err) {
const state = JSON.stringify(
await testEnv.confbotClient.getRoomState(roomId)
);
throw new Error(
`No m.room.member for ${moderatorUserId} in ${roomId} (${roomAlias}): ${state}`
);
}
expect(moderatorMembershipInRoom.membership).toBe("invite");
}
});
});
5 changes: 3 additions & 2 deletions spec/util/e2e-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export class E2ETestEnv {
}
},
},
moderatorUserId: `@modbot:${homeserver.domain}`,
moderatorUserIds: [`@modbot:${homeserver.domain}`],
webserver: {
address: '0.0.0.0',
port: 0,
Expand All @@ -226,13 +226,14 @@ export class E2ETestEnv {
...providedConfig,
};
const conferenceBot = await ConferenceBot.start(config);
return new E2ETestEnv(homeserver, conferenceBot, adminUser.client, opts, tmpDir, config);
return new E2ETestEnv(homeserver, conferenceBot, adminUser.client, confBotOpts.client, opts, tmpDir, config);
}

private constructor(
public readonly homeserver: ComplementHomeServer,
public confBot: ConferenceBot,
public readonly adminClient: MatrixClient,
public readonly confbotClient: MatrixClient,
public readonly opts: Opts,
private readonly dataDir: string,
private readonly config: IConfig,
Expand Down
24 changes: 15 additions & 9 deletions src/Conference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export class Conference {
this.client,
mergeWithCreationTemplate(AUDITORIUM_BACKSTAGE_CREATION_TEMPLATE, {
room_alias_name: (new RoomAlias(alias)).localpart,
invite: [this.config.moderatorUserId],
invite: this.config.moderatorUserIds,
}),
);
await rootSpace.addChildRoom(roomId);
Expand Down Expand Up @@ -418,7 +418,7 @@ export class Conference {
subspace = await this.client.createSpace({
isPublic: true,
name: name,
invites: [this.config.moderatorUserId],
invites: this.config.moderatorUserIds,
});
this.subspaces[subspaceId] = subspace;

Expand All @@ -433,9 +433,11 @@ export class Conference {
roomId: subspace.roomId,
} as IStoredSubspace);

// Grants PL100 to the moderator in the subspace.
// Grants PL100 to the moderators in the subspace.
// We can't do this directly with `createSpace` unfortunately, as we could for plain rooms.
await this.client.setUserPowerLevel(this.config.moderatorUserId, subspace.roomId, 100);
for (let moderator of this.config.moderatorUserIds) {
await this.client.setUserPowerLevel(moderator, subspace.roomId, 100);
}
} else {
subspace = this.subspaces[subspaceId];
}
Expand Down Expand Up @@ -466,7 +468,7 @@ export class Conference {
);
} else {
// Create a new interest room.
roomId = await safeCreateRoom(this.client, mergeWithCreationTemplate(SPECIAL_INTEREST_CREATION_TEMPLATE(this.config.moderatorUserId), {
roomId = await safeCreateRoom(this.client, mergeWithCreationTemplate(SPECIAL_INTEREST_CREATION_TEMPLATE(this.config.moderatorUserIds), {
creation_content: {
[RSC_CONFERENCE_ID]: this.id,
[RSC_SPECIAL_INTEREST_ID]: interestRoom.id,
Expand Down Expand Up @@ -556,7 +558,7 @@ export class Conference {

await parentSpace.addChildSpace(audSpace, { order: `auditorium-${auditorium.id}` });

const roomId = await safeCreateRoom(this.client, mergeWithCreationTemplate(AUDITORIUM_CREATION_TEMPLATE(this.config.moderatorUserId), {
const roomId = await safeCreateRoom(this.client, mergeWithCreationTemplate(AUDITORIUM_CREATION_TEMPLATE(this.config.moderatorUserIds), {
creation_content: {
[RSC_CONFERENCE_ID]: this.id,
[RSC_AUDITORIUM_ID]: auditorium.id,
Expand Down Expand Up @@ -614,7 +616,7 @@ export class Conference {
}

if (!this.talks[talk.id]) {
roomId = await safeCreateRoom(this.client, mergeWithCreationTemplate(TALK_CREATION_TEMPLATE(this.config.moderatorUserId), {
roomId = await safeCreateRoom(this.client, mergeWithCreationTemplate(TALK_CREATION_TEMPLATE(this.config.moderatorUserIds), {
name: talk.title,
creation_content: {
[RSC_CONFERENCE_ID]: this.id,
Expand Down Expand Up @@ -829,7 +831,9 @@ export class Conference {
// we'll be unable to do promotions/demotions in the future.
const pls = await this.client.getRoomStateEvent(roomId, "m.room.power_levels", "");
pls['users'][await this.client.getUserId()] = 100;
pls['users'][this.config.moderatorUserId] = 100;
for (let moderator of this.config.moderatorUserIds) {
pls['users'][moderator] = 100;
}
for (const userId of mxids) {
if (pls['users'][userId]) continue;
pls['users'][userId] = 50;
Expand Down Expand Up @@ -894,7 +898,9 @@ export class Conference {
this.membersInRooms[roomId] = joinedOrLeftMembers;
const total = new Set(Object.values(this.membersInRooms).flat());
total.delete(myUserId);
total.delete(this.config.moderatorUserId);
for (let moderator of this.config.moderatorUserIds) {
total.delete(moderator);
}
attendeeTotalGauge.set(total.size);
} catch (ex) {
LogService.warn("Conference", `Failed to recalculate room membership for ${roomId}`, ex);
Expand Down
7 changes: 6 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ export interface IConfig {
managementRoom: string;
idServerDomain?: string;
idServerBrand?: string;
moderatorUserId: string;

// Legacy option that causes a startup error when supplied.
// Removed in favour of `moderatorUserIds`.
moderatorUserId?: string;

moderatorUserIds: string[];
livestream: {
auditoriumUrl: string;
talkUrl: string;
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export class ConferenceBot {
if (!RunMode[config.mode]) {
throw Error(`Incorrect mode '${config.mode}'`);
}

if (config.moderatorUserId !== undefined) {
throw new Error("The `moderatorUserId` config option has been replaced by `moderatorUserIds` that takes a list.");
}

const storage = new SimpleFsStorageProvider(path.join(config.dataPath, "bot.json"));
const client = await ConferenceMatrixClient.create(config, storage);
client.impersonateUserId(config.userId);
Expand Down
Loading

0 comments on commit 9d3d191

Please sign in to comment.