diff --git a/config/default.yaml b/config/default.yaml index e675f12..40aa582 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -9,3 +9,11 @@ dataPath: "/data/storage" # Whether the bot should autojoin rooms it is invited to or not autojoin: true + +# The room ID where people can use the bot. The bot has no access controls, so +# anyone in this room can use the bot - secure your room! +managementRoom: "#moderators:example.org" + +# A list of ban lists to follow (matrix.to URLs) +banLists: + - "https://matrix.to/#/#sample-ban-list:t2bot.io" # S.A.M.P.L.E. diff --git a/src/BanList.ts b/src/BanList.ts index 2158c9e..a4a385f 100644 --- a/src/BanList.ts +++ b/src/BanList.ts @@ -14,27 +14,53 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient, Permalinks } from "matrix-bot-sdk"; +import { MatrixClient } from "matrix-bot-sdk"; +import { ListRule } from "./ListRule"; + +export const RULE_USER = "m.room.rule.user"; +export const RULE_ROOM = "m.room.rule.room"; +export const RULE_SERVER = "m.room.rule.server"; + +export const USER_RULE_TYPES = [RULE_USER, "org.matrix.mjolnir.rule.user"]; +export const ROOM_RULE_TYPES = [RULE_ROOM, "org.matrix.mjolnir.rule.room"]; +export const SERVER_RULE_TYPES = [RULE_SERVER, "org.matrix.mjolnir.rule.server"]; +export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES]; export default class BanList { - private viaServers: string[] = []; + public rules: ListRule[] = []; - constructor(private roomRef: string, private client: MatrixClient) { - if (this.roomRef.startsWith("https")) { - const parts = Permalinks.parseUrl(this.roomRef); - this.roomRef = parts.roomIdOrAlias; + constructor(public readonly roomId: string, public readonly roomRef, private client: MatrixClient) { + } - if (parts.viaServers) this.viaServers = parts.viaServers; + public async updateList() { + this.rules = []; + + const state = await this.client.getRoomState(this.roomId); + for (const event of state) { + if (event['state_key'] === '' || !ALL_RULE_TYPES.includes(event['type'])) { + continue; + } + + // It's a rule - parse it + const content = event['content']; + if (!content) continue; + + const entity = content['entity']; + const recommendation = content['recommendation']; + const reason = content['reason']; + + if (!entity || !recommendation || !reason) { + continue; + } + + this.rules.push(new ListRule(entity, recommendation, reason)); } - - client.resolveRoom(this.roomRef).then(roomId => this.roomRef = roomId); } - public async ensureJoined(): Promise { - await this.client.joinRoom(this.roomRef, this.viaServers); + public getRuleFor(entity: string): ListRule { + for (const rule of this.rules) { + if (rule.isMatch(entity)) return rule; + } + return null; } - - // TODO: Update list - // TODO: Match checking - } diff --git a/src/ListRule.ts b/src/ListRule.ts index 126894a..69f8128 100644 --- a/src/ListRule.ts +++ b/src/ListRule.ts @@ -14,17 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixGlob } from "matrix-bot-sdk/lib/MatrixGlob"; + export const RECOMMENDATION_BAN = "m.ban"; export const RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"]; export class ListRule { - constructor(public entity: string, private action: string, public reason: string) { - // TODO: Convert entity to regex + + private glob: MatrixGlob; + + constructor(public readonly entity: string, private action: string, public readonly reason: string) { + this.glob = new MatrixGlob(entity); } public get recommendation(): string { if (RECOMMENDATION_BAN_TYPES.includes(this.action)) return RECOMMENDATION_BAN; } - // TODO: Match checking functions + public isMatch(entity: string): boolean { + return this.glob.test(entity); + } } diff --git a/src/config.ts b/src/config.ts index f11afbb..8dd4fde 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,8 @@ interface IConfig { accessToken: string; dataPath: string; autojoin: boolean; + managementRoom: string; + banLists: string[]; // matrix.to urls } export default config; diff --git a/src/index.ts b/src/index.ts index 1d629fc..7d8cc26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,27 +19,103 @@ import { AutojoinRoomsMixin, LogService, MatrixClient, + Permalinks, RichConsoleLogger, SimpleFsStorageProvider } from "matrix-bot-sdk"; import config from "./config"; +import BanList, { ALL_RULE_TYPES } from "./BanList"; LogService.setLogger(new RichConsoleLogger()); const storage = new SimpleFsStorageProvider(path.join(config.dataPath, "bot.json")); const client = new MatrixClient(config.homeserverUrl, config.accessToken, storage); +const lists: BanList[] = []; +let managementRoomId = ""; if (config.autojoin) { AutojoinRoomsMixin.setupOnClient(client); } +client.on("room.event", async (roomId, event) => { + if (!event['state_key']) return; // we also don't do anything with state events that have no state key + + if (ALL_RULE_TYPES.includes(event['type'])) { + for (const list of lists) { + if (list.roomId !== roomId) continue; + await list.updateList(); + // TODO: Re-apply ACLs as needed + } + } else if (event['type'] === "m.room.member") { + // TODO: Check membership against ban lists + } +}); + client.on("room.message", async (roomId, event) => { if (!event['content']) return; const content = event['content']; - if (content['msgtype'] === 'm.text' && content['body'] === '!mjolnir') { - await client.sendNotice(roomId, "Hello world!"); + if (content['msgtype'] === "m.text" && content['body'] === "!mjolnir") { + await client.sendReadReceipt(roomId, event['event_id']); + return printStatus(roomId); } }); -client.start().then(() => LogService.info("index", "Bot started!")); + +(async function () { + // Ensure we're in all the rooms we expect to be in + const joinedRooms = await client.getJoinedRooms(); + for (const roomRef of config.banLists) { + const permalink = Permalinks.parseUrl(roomRef); + if (!permalink.roomIdOrAlias) continue; + + const roomId = await client.resolveRoom(permalink.roomIdOrAlias); + if (!joinedRooms.includes(roomId)) { + await client.joinRoom(permalink.roomIdOrAlias, permalink.viaServers); + } + + const list = new BanList(roomId, roomRef, client); + await list.updateList(); + lists.push(list); + } + + // Ensure we're also in the management room + managementRoomId = await client.joinRoom(config.managementRoom); + await client.sendNotice(managementRoomId, "Mjolnir is starting up. Use !mjolnir to query status."); + + // TODO: Check permissions for mjolnir in protected rooms + // TODO: Complain about permission changes in protected rooms (including after power levels change) + + await client.start(); + LogService.info("index", "Bot started!") +})(); + +async function printStatus(roomId: string) { + const rooms = await client.getJoinedRooms(); + + let html = ""; + let text = ""; + + // Append header information first + html += "Running:
"; + text += "Running: ✅\n"; + html += `Protected rooms: ${rooms.length}
`; + text += `Protected rooms: ${rooms.length}\n`; + + // Append list information + html += "Subscribed lists:
"; + + const message = { + msgtype: "m.notice", + body: text, + format: "org.matrix.custom.html", + formatted_body: html, + }; + return client.sendMessage(roomId, message); +}