mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-06-28 23:52:06 +00:00
704bb660c2
* Refactor Matrix event listener in Mjolnir and ManagedMjolnir. closes https://github.com/matrix-org/mjolnir/issues/411. Issue #411 says that we have to be careful about room.join, but this was before we figured how to make matrix-appservice-bridge echo events sent by its own intents. * Remove MatrixClientListener since it isn't actually needed. * Protect which config values can be used for ManagedMjolnirs. * Introduce MatrixSendClient so listeners aren't accidentally added to a MatrixClient instead of MatrixEmitter. * doc * Move provisioned mjolnir config to src/config. This just aids maintance so whenever someone goes to change the config of the bot they will see this and update it. * doc for matrix intent listener.
270 lines
8.2 KiB
TypeScript
270 lines
8.2 KiB
TypeScript
import { MatrixEmitter } from "./MatrixEmitter";
|
|
|
|
enum Action {
|
|
Join,
|
|
Leave,
|
|
Other
|
|
}
|
|
|
|
const LEAVE_OR_BAN = ['leave', 'ban'];
|
|
|
|
/**
|
|
* Storing a join event.
|
|
*
|
|
* We use `timestamp`:
|
|
* - to avoid maintaining tens of thousands of in-memory `Date` objects;
|
|
* - to ensure immutability.
|
|
*/
|
|
export class Join {
|
|
constructor(
|
|
public readonly userId: string,
|
|
public readonly timestamp: number
|
|
) { }
|
|
}
|
|
|
|
/**
|
|
* A data structure maintaining a list of joins since the start of Mjölnir.
|
|
*
|
|
* This data structure is optimized for lookup up of recent joins.
|
|
*/
|
|
class RoomMembers {
|
|
/**
|
|
* The list of recent joins, ranked from oldest to most recent.
|
|
*
|
|
* Note that a user may show up in both `_joinsByTimestamp` and `_leaves`, in which case
|
|
* they have both joined and left recently. Compare the date of the latest
|
|
* leave event (in `_leaves`) to the date of the join to determine whether
|
|
* the user is still present.
|
|
*
|
|
* Note that a user may show up more than once in `_joinsByTimestamp` if they have
|
|
* left and rejoined.
|
|
*/
|
|
private _joinsByTimestamp: Join[] = [];
|
|
private _joinsByUser: Map<string /* user id */, number /* timestamp */> = new Map();
|
|
|
|
/**
|
|
* The list of recent leaves.
|
|
*
|
|
* If a user rejoins and leaves again, the latest leave event overwrites
|
|
* the oldest.
|
|
*/
|
|
private _leaves: Map<string /* user id */, number /* timestamp */> = new Map();
|
|
|
|
/**
|
|
* Record a join.
|
|
*/
|
|
public join(userId: string, timestamp: number) {
|
|
this._joinsByTimestamp.push(new Join(userId, timestamp));
|
|
this._joinsByUser.set(userId, timestamp);
|
|
}
|
|
|
|
/**
|
|
* Record a leave.
|
|
*/
|
|
public leave(userId: string, timestamp: number) {
|
|
if (!this._joinsByUser.has(userId)) {
|
|
// No need to record a leave for a user we didn't see joining.
|
|
return;
|
|
}
|
|
this._leaves.set(userId, timestamp);
|
|
this._joinsByUser.delete(userId);
|
|
}
|
|
|
|
/**
|
|
* Run a cleanup on the data structure.
|
|
*/
|
|
public cleanup() {
|
|
if (this._leaves.size === 0) {
|
|
// Nothing to do.
|
|
return;
|
|
}
|
|
this._joinsByTimestamp = this._joinsByTimestamp.filter(join => this.isStillValid(join));
|
|
this._leaves = new Map();
|
|
}
|
|
|
|
/**
|
|
* Determine whether a `join` is still valid or has been superseded by a `leave`.
|
|
*
|
|
* @returns true if the `join` is still valid.
|
|
*/
|
|
private isStillValid(join: Join): boolean {
|
|
const leaveTS = this._leaves.get(join.userId);
|
|
if (!leaveTS) {
|
|
// The user never left.
|
|
return true;
|
|
}
|
|
if (leaveTS > join.timestamp) {
|
|
// The user joined, then left, ignore this join.
|
|
return false;
|
|
}
|
|
// The user had left, but this is a more recent re-join.
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Return a subset of the list of all the members, with their join date.
|
|
*
|
|
* @param since Only return members who have last joined at least as
|
|
* recently as `since`.
|
|
* @param max Only return at most `max` numbers.
|
|
* @returns A list of up to `max` members joined since `since`, ranked
|
|
* from most recent join to oldest join.
|
|
*/
|
|
public members(since: Date, max: number): Join[] {
|
|
const result = [];
|
|
const ts = since.getTime();
|
|
// Spurious joins are legal, let's deduplicate them.
|
|
const users = new Set();
|
|
for (let i = this._joinsByTimestamp.length - 1; i >= 0; --i) {
|
|
if (result.length > max) {
|
|
// We have enough entries, let's return immediately.
|
|
return result;
|
|
}
|
|
const join = this._joinsByTimestamp[i];
|
|
if (join.timestamp < ts) {
|
|
// We have reached an older entry, everything will be `< since`,
|
|
// we won't find any other join to return.
|
|
return result;
|
|
}
|
|
if (this.isStillValid(join) && !users.has(join.userId)) {
|
|
// This entry is still valid, we'll need to return it.
|
|
result.push(join);
|
|
users.add(join.userId);
|
|
}
|
|
}
|
|
// We have reached the startup of Mjölnir.
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Return the join date of a user.
|
|
*
|
|
* @returns a `Date` if the user is currently in the room and has joined
|
|
* since the start of Mjölnir, `null` otherwise.
|
|
*/
|
|
public get(userId: string): Date | null {
|
|
let ts = this._joinsByUser.get(userId);
|
|
if (!ts) {
|
|
return null;
|
|
}
|
|
return new Date(ts);
|
|
}
|
|
}
|
|
|
|
export class RoomMemberManager {
|
|
private perRoom: Map<string /* room id */, RoomMembers> = new Map();
|
|
private readonly cbHandleEvent;
|
|
constructor(private client: MatrixEmitter) {
|
|
// Listen for join events.
|
|
this.cbHandleEvent = this.handleEvent.bind(this);
|
|
client.on("room.event", this.cbHandleEvent);
|
|
}
|
|
|
|
/**
|
|
* Start listening to join/leave events in a room.
|
|
*/
|
|
public addRoom(roomId: string) {
|
|
if (this.perRoom.has(roomId)) {
|
|
// Nothing to do.
|
|
return;
|
|
}
|
|
this.perRoom.set(roomId, new RoomMembers());
|
|
}
|
|
|
|
/**
|
|
* Stop listening to join/leave events in a room.
|
|
*
|
|
* Cleanup any remaining data on join/leave events.
|
|
*/
|
|
public removeRoom(roomId: string) {
|
|
this.perRoom.delete(roomId);
|
|
}
|
|
|
|
public cleanup(roomId: string) {
|
|
this.perRoom.get(roomId)?.cleanup();
|
|
}
|
|
|
|
/**
|
|
* Dispose of this object.
|
|
*/
|
|
public dispose() {
|
|
this.client.off("room.event", this.cbHandleEvent);
|
|
}
|
|
|
|
/**
|
|
* Return the date at which user `userId` has joined room `roomId`, or `null` if
|
|
* that user has joined the room before Mjölnir started watching it.
|
|
*
|
|
* @param roomId The id of the room we're interested in.
|
|
* @param userId The id of the user we're interested in.
|
|
* @returns a Date if Mjölnir has witnessed the user joining the room,
|
|
* `null` otherwise. The latter may happen either if the user has joined
|
|
* the room before Mjölnir or if the user is not currently in the room.
|
|
*/
|
|
public getUserJoin(user: { roomId: string, userId: string }): Date | null {
|
|
const { roomId, userId } = user;
|
|
const ts = this.perRoom.get(roomId)?.get(userId) || null;
|
|
if (!ts) {
|
|
return null;
|
|
}
|
|
return new Date(ts);
|
|
}
|
|
|
|
/**
|
|
* Get the users in a room, ranked by most recently joined to oldest join.
|
|
*
|
|
* Only the users who have joined since the start of Mjölnir are returned.
|
|
*/
|
|
public getUsersInRoom(roomId: string, since: Date, max = 100): Join[] {
|
|
const inRoom = this.perRoom.get(roomId);
|
|
if (!inRoom) {
|
|
return [];
|
|
}
|
|
return inRoom.members(since, max);
|
|
}
|
|
|
|
/**
|
|
* Record join/leave events.
|
|
*/
|
|
public async handleEvent(roomId: string, event: any, now?: Date) {
|
|
if (event['type'] !== 'm.room.member') {
|
|
// Not a join/leave event.
|
|
return;
|
|
}
|
|
|
|
const members = this.perRoom.get(roomId);
|
|
if (!members) {
|
|
// Not a room we are watching.
|
|
return;
|
|
}
|
|
const userId = event['state_key'];
|
|
if (!userId) {
|
|
// Ill-formed event.
|
|
return;
|
|
}
|
|
|
|
const userState = event['content']['membership'];
|
|
const prevMembership = event['unsigned']?.['prev_content']?.['membership'] || "leave";
|
|
|
|
// We look at the previous membership to filter out profile changes
|
|
let action;
|
|
if (userState === 'join' && prevMembership !== "join") {
|
|
action = Action.Join;
|
|
} else if (LEAVE_OR_BAN.includes(userState) && !LEAVE_OR_BAN.includes(prevMembership)) {
|
|
action = Action.Leave;
|
|
} else {
|
|
action = Action.Other;
|
|
}
|
|
switch (action) {
|
|
case Action.Other:
|
|
// Nothing to do.
|
|
return;
|
|
case Action.Join:
|
|
members.join(userId, now ? now.getTime() : Date.now());
|
|
break;
|
|
case Action.Leave:
|
|
members.leave(userId, now ? now.getTime() : Date.now());
|
|
break;
|
|
}
|
|
}
|
|
} |