diff --git a/src/Conference.ts b/src/Conference.ts index c154503..70d07c1 100644 --- a/src/Conference.ts +++ b/src/Conference.ts @@ -59,6 +59,9 @@ import { IScheduleBackend } from "./backends/IScheduleBackend"; import { PentaBackend } from "./backends/penta/PentaBackend"; import { setUnion } from "./utils/sets"; import { ConferenceMatrixClient } from "./ConferenceMatrixClient"; +import { Gauge } from "prom-client"; + +const attendeeTotalGauge = new Gauge({ name: "confbot_attendee_total", help: "The number of attendees across all rooms."}); export class Conference { private rootSpace: Space | null; @@ -88,9 +91,21 @@ export class Conference { [personId: string]: IPerson; } = {}; + private membersInRooms: Record = {}; + + private memberRecalculationPromise = Promise.resolve(); + private membershipRecalculationQueue = new Set(); + constructor(public readonly backend: IScheduleBackend, public readonly id: string, public readonly client: ConferenceMatrixClient, private readonly config: IConfig) { this.client.on("room.event", async (roomId: string, event) => { - if (event['type'] === 'm.room.member' && event['content']?.['third_party_invite']) { + if (event.type !== 'm.room.member' && event.state_key !== undefined) { + return; + } + + // On any member event, recaulculate the membership. + this.enqueueRecalculateRoomMembership(roomId); + + if (event['content']?.['third_party_invite']) { const emailInviteToken = event['content']['third_party_invite']['signed']?.['token']; const emailInvite = await this.client.getRoomStateEvent(roomId, "m.room.third_party_invite", emailInviteToken); if (emailInvite[RS_3PID_PERSON_ID]) { @@ -215,32 +230,38 @@ export class Conference { switch (locatorEvent[RSC_ROOM_KIND_FLAG]) { case RoomKind.ConferenceSpace: this.rootSpace = new Space(roomId, this.client); + this.recalculateRoomMembership(roomId); break; case RoomKind.ConferenceDb: this.dbRoom = new MatrixRoom(roomId, this.client, this); + this.recalculateRoomMembership(roomId); break; case RoomKind.Auditorium: const auditoriumId = locatorEvent[RSC_AUDITORIUM_ID]; if (this.backend.auditoriums.has(auditoriumId)) { this.auditoriums[auditoriumId] = new Auditorium(roomId, this.backend.auditoriums.get(auditoriumId)!, this.client, this); + this.recalculateRoomMembership(roomId); } break; case RoomKind.AuditoriumBackstage: const auditoriumBsId = locatorEvent[RSC_AUDITORIUM_ID]; if (this.backend.auditoriums.has(auditoriumBsId)) { this.auditoriumBackstages[auditoriumBsId] = new AuditoriumBackstage(roomId, this.backend.auditoriums.get(auditoriumBsId)!, this.client, this); + this.recalculateRoomMembership(roomId); } break; case RoomKind.Talk: const talkId = locatorEvent[RSC_TALK_ID]; if (this.backend.talks.has(talkId)) { this.talks[talkId] = new Talk(roomId, this.backend.talks.get(talkId)!, this.client, this); + this.recalculateRoomMembership(roomId); } break; case RoomKind.SpecialInterest: const interestId = locatorEvent[RSC_SPECIAL_INTEREST_ID]; if (this.backend.interestRooms.has(interestId)) { this.interestRooms[interestId] = new InterestRoom(roomId, this.client, this, interestId, this.config.conference.prefixes); + this.recalculateRoomMembership(roomId); } break; default: @@ -868,4 +889,47 @@ export class Conference { return []; } + + /** + * Recalculate the number of joined and left users in a room, + * and then update the total count for the conference. + * + * Prefer to call `enqueueRecalculateRoomMembership` as it will + * queue and debounce calls appropriately. + * + * @param roomId The roomId to recalculate. + */ + private async recalculateRoomMembership(roomId: string) { + try { + const myUserId = await this.client.getUserId(); + const members = await this.client.getAllRoomMembers(roomId); + const joinedOrLeftMembers = members.filter(m => m.effectiveMembership === "join" || m.effectiveMembership === "leave").map(m => m.stateKey); + this.membersInRooms[roomId] = joinedOrLeftMembers; + const total = new Set(Object.values(this.membersInRooms).flat()); + total.delete(myUserId); + total.delete(this.config.moderatorUserId); + attendeeTotalGauge.set(total.size); + } catch (ex) { + LogService.warn("Conference", `Failed to recalculate room membership for ${roomId}`, ex); + } + } + + /** + * Queue up a call to `recalculateRoomMembership`. + * @param roomId The roomId to recalculate. + * @returns A promise that resolves when the call has been made. + */ + private async enqueueRecalculateRoomMembership(roomId: string) { + // We are already expecting to process this room OR are not interested in this room. + if (this.membershipRecalculationQueue.has(roomId) || !this.membersInRooms[roomId]) { + return; + } + + this.membershipRecalculationQueue.add(roomId); + // We ensure that recalculations are linear. + return this.memberRecalculationPromise = this.memberRecalculationPromise.then(() => { + this.membershipRecalculationQueue.delete(roomId); + return this.recalculateRoomMembership(roomId); + }) + } }