Provide notice showing how a BanList has changed after updating.

Only shows changes to lists made by other accounts (than the one used by Mjolnir).
Displays when rules are added, removed and modified by either replacing the state event or redacting them.
This commit is contained in:
gnuxie 2021-11-12 18:35:44 +00:00
parent 1bf3ecac6d
commit 9c47fc917a
2 changed files with 177 additions and 15 deletions

View File

@ -26,7 +26,7 @@ import {
UserID
} from "matrix-bot-sdk";
import BanList, { ALL_RULE_TYPES } from "./models/BanList";
import BanList, { ALL_RULE_TYPES, ListRuleChange, RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/BanList";
import { applyServerAcls } from "./actions/ApplyAcl";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler";
@ -614,7 +614,8 @@ export class Mjolnir {
*/
public async syncLists(verbose = true) {
for (const list of this.banLists) {
await list.updateList();
const changes = await list.updateList();
await this.printBanlistChanges(changes, list, true);
}
let hadErrors = false;
@ -638,17 +639,19 @@ export class Mjolnir {
}
}
public async syncListForRoom(roomId: string) {
let updated = false;
for (const list of this.banLists) {
if (list.roomId !== roomId) continue;
await list.updateList();
updated = true;
}
if (!updated) return;
/**
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
* @param policyRoomId The room with a policy list which we will check for changes and apply them to all protected rooms.
* @returns When all of the protected rooms have been updated.
*/
public async syncWithPolicyRoom(policyRoomId: string): Promise<void> {
const banList = this.banLists.find(list => list.roomId === policyRoomId);
if (banList === undefined) return;
const changes = await banList.updateList();
await this.printBanlistChanges(changes, banList, true);
let hadErrors = false;
const aclErrors = await applyServerAcls(this.banLists, Object.keys(this.protectedRooms), this);
const banErrors = await applyUserBans(this.banLists, Object.keys(this.protectedRooms), this);
const redactionErrors = await this.processRedactionQueue();
@ -685,7 +688,7 @@ export class Mjolnir {
// themselves.
if (this.banLists.map(b => b.roomId).includes(roomId)) {
if (ALL_RULE_TYPES.includes(event['type'])) {
await this.syncListForRoom(roomId);
await this.syncWithPolicyRoom(roomId);
}
}
@ -732,6 +735,52 @@ export class Mjolnir {
}
}
/**
* Print the changes to a banlist to the management room.
* @param changes A list of changes that have been made to a particular ban list.
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
*/
private async printBanlistChanges(changes: ListRuleChange[], list: BanList, ignoreSelf = false): Promise<boolean> {
if (ignoreSelf) {
const sender = await this.client.getUserId();
changes = changes.filter(change => change.sender !== sender);
}
if (changes.length <= 0) return false;
let html = "";
let text = "";
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
for (const change of changes) {
const rule = change.rule;
let ruleKind: string = rule.kind;
if (ruleKind === RULE_USER) {
ruleKind = 'user';
} else if (ruleKind === RULE_SERVER) {
ruleKind = 'server';
} else if (ruleKind === RULE_ROOM) {
ruleKind = 'room';
}
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation)}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
}
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(config.managementRoom, message);
return true;
}
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
if (errors.length <= 0) return false;

View File

@ -35,17 +35,88 @@ export function ruleTypeToStable(rule: string, unstable = true): string|null {
return null;
}
export enum ChangeType {
Added = "ADDED",
Removed = "REMOVED",
Modified = "MODIFIED"
}
export interface ListRuleChange {
readonly changeType: ChangeType,
/**
* State event that caused the change.
* If the rule was redacted, this will be the redacted version of the event.
*/
readonly event: any,
/**
* The sender that caused the change.
* The original event sender unless the change is because `event` was redacted. When the change is `event` being redacted
* this will be the user who caused the redaction.
*/
readonly sender: string,
/**
* The current rule represented by the event.
* If the rule has been removed, then this will show what the rule was.
*/
readonly rule: ListRule,
/**
* The previous state that has been changed. Only (and always) provided when the change type is `ChangeType.Removed` or `Modified`.
* This will be a copy of the same event as `event` when a redaction has occurred and this will show its unredacted state.
*/
readonly previousState?: any,
}
/**
* The BanList caches all of the rules that are active in a policy room so Mjolnir can refer to when applying bans etc.
* This cannot be used to update events in the modeled room, it is a readonly model of the policy room.
*/
export default class BanList {
private rules: ListRule[] = [];
private shortcode: string|null = null;
// A map of state events indexed first by state type and then state keys.
private state: Map<string, Map<string, any>> = new Map();
/**
* Construct a BanList, does not synchronize with the room.
* @param roomId The id of the policy room, i.e. a room containing MSC2313 policies.
* @param roomRef A sharable/clickable matrix URL that refers to the room.
* @param client A matrix client that is used to read the state of the room when `updateList` is called.
*/
constructor(public readonly roomId: string, public readonly roomRef, private client: MatrixClient) {
}
/**
* The code that can be used to refer to this banlist in Mjolnir commands.
*/
public get listShortcode(): string {
return this.shortcode || '';
}
/**
* Lookup the current rules cached for the list.
* @param stateType The event type e.g. m.room.rule.user.
* @param stateKey The state key e.g. entity:@bad:matrix.org
* @returns A state event if present or null.
*/
private getState(stateType: string, stateKey: string) {
return this.state.get(stateType)?.get(stateKey);
}
/**
* Store this state event as part of the active room state for this BanList (used to cache rules).
* @param stateType The event type e.g. m.room.rule.user.
* @param stateKey The state key e.g. entity:@bad:matrix.org
* @param event A state event to store.
*/
private setState(stateType: string, stateKey: string, event: any): void {
let typeTable = this.state.get(stateType);
if (typeTable) {
typeTable.set(stateKey, event);
} else {
this.state.set(stateType, new Map().set(stateKey, event));
}
}
public set listShortcode(newShortcode: string) {
const currentShortcode = this.shortcode;
this.shortcode = newShortcode;
@ -71,8 +142,14 @@ export default class BanList {
return [...this.serverRules, ...this.userRules, ...this.roomRules];
}
public async updateList() {
/**
* Synchronise the model with the room representing the ban list by reading the current state of the room
* and updating the model to reflect the room.
* @returns A description of any rules that were added, modified or removed from the list as a result of this update.
*/
public async updateList(): Promise<ListRuleChange[]> {
this.rules = [];
let changes: ListRuleChange[] = [];
const state = await this.client.getRoomState(this.roomId);
for (const event of state) {
@ -96,6 +173,37 @@ export default class BanList {
continue; // invalid/unknown
}
const previousState = this.getState(event['type'], event['state_key']);
this.setState(event['type'], event['state_key'], event);
const changeType: null|ChangeType = (() => {
if (!previousState) {
return ChangeType.Added;
} else if (previousState['event_id'] === event['event_id']) {
if (event['unsigned']?.['redacted_because']) {
return ChangeType.Removed;
} else {
// Nothing has changed.
return null;
}
} else {
// Then the policy has been modified in some other way, possibly 'soft' redacted by a new event with empty content...
if (Object.keys(event['content']).length === 0) {
return ChangeType.Removed;
} else {
return ChangeType.Modified;
}
}
})();
// If we haven't got any information about what the rule used to be, then it wasn't a valid rule to begin with
// and so will not have been used. Removing a rule like this therefore results in no change.
if (changeType === ChangeType.Removed && previousState?.unsigned?.rule) {
const sender = event.unsigned['redacted_because'] ? event.unsigned['redacted_because']['sender'] : event.sender;
changes.push({changeType, event, sender, rule: previousState.unsigned.rule,
... previousState ? {previousState} : {} });
// Event has no content and cannot be parsed as a ListRule.
continue;
}
// It's a rule - parse it
const content = event['content'];
if (!content) continue;
@ -107,8 +215,13 @@ export default class BanList {
if (!entity || !recommendation) {
continue;
}
this.rules.push(new ListRule(entity, recommendation, reason, kind));
const rule = new ListRule(entity, recommendation, reason, kind);
event.unsigned.rule = rule;
if (changeType) {
changes.push({rule, changeType, event, sender: event.sender, ... previousState ? {previousState} : {} });
}
this.rules.push(rule);
}
return changes;
}
}