diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index a639a6bc..d9493df5 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -26,7 +26,7 @@ limitations under the License. */ import { Mjolnir } from "../Mjolnir"; -import { execStatusCommand } from "./StatusCommand"; +import { showJoinsStatus } from "./JoinsCommand"; import { execDumpRulesCommand, execRulesMatchingCommand } from "./DumpRulesCommand"; import { LogService, RichReply } from "matrix-bot-sdk"; import { htmlEscape } from "../utils"; @@ -70,6 +70,7 @@ import "./interface-manager/MatrixPresentations"; import "./HijackRoomCommand"; import "./Ban"; import "./Unban"; +import "./StatusCommand"; export const COMMAND_PREFIX = "!mjolnir"; @@ -82,8 +83,8 @@ export async function handleCommand(roomId: string, event: { content: { body: st const tokens = tokenize(cmd.replace("#", "\\#")).slice(/* get rid of ["!mjolnir", command] */ 2); try { - if (parts.length === 1 || parts[1] === 'status') { - return await execStatusCommand(roomId, event, mjolnir, parts.slice(2)); + if (parts.length === 1 || parts[1] === 'joins') { + return await showJoinsStatus(roomId, event, mjolnir, parts.slice(/* ["joins"] */ 2)); } else if (parts[1] === 'rules' && parts.length === 4 && parts[2] === 'matching') { return await execRulesMatchingCommand(roomId, event, mjolnir, parts[3]) } else if (parts[1] === 'rules') { diff --git a/src/commands/JoinsCommand.ts b/src/commands/JoinsCommand.ts new file mode 100644 index 00000000..3d145ca8 --- /dev/null +++ b/src/commands/JoinsCommand.ts @@ -0,0 +1,105 @@ +/** + * Copyright (C) 2022 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2019-2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + * + * However, this file is modified and the modifications in this file + * are NOT distributed, contributed, committed, or licensed under the Apache License. + */ + +import { Mjolnir } from "../Mjolnir"; +import { RichReply } from "matrix-bot-sdk"; +import { htmlEscape, parseDuration } from "../utils"; +import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; + +const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); +const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); + + +/** + * Show the most recent joins to a room. + * + * Seems like this command never worked how it was expected to + * "100 day" without quotes is 2 parts, so if you wrote them like the examples + * then you would have 4 parts? + * + * For now I will copy this as it were, but this needs fixing + * https://github.com/Gnuxie/Draupnir/issues/19 + */ +export async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: Mjolnir, args: string[]) { + const targetRoomAliasOrId = args[0]; + const maxAgeArg = args[1] || "1 day"; + const maxEntriesArg = args[2] = "200"; + const { html, text } = await (async () => { + if (!targetRoomAliasOrId) { + return { + html: "Missing arg: room id", + text: "Missing arg: `room id`" + }; + } + const maxAgeMS = parseDuration(maxAgeArg); + if (!maxAgeMS) { + return { + html: "Invalid duration. Example: 1.5 days or 10 minutes", + text: "Invalid duration. Example: `1.5 days` or `10 minutes`", + } + } + const maxEntries = Number.parseInt(maxEntriesArg, 10); + if (!maxEntries) { + return { + html: "Invalid number of entries. Example: 200", + text: "Invalid number of entries. Example: `200`", + } + } + const minDate = new Date(Date.now() - maxAgeMS); + const HUMANIZER_OPTIONS = { + // Reduce "1 day" => "1day" to simplify working with CSV. + spacer: "", + // Reduce "1 day, 2 hours" => "1.XXX day" to simplify working with CSV. + largest: 1, + }; + const maxAgeHumanReadable = HUMANIZER.humanize(maxAgeMS, HUMANIZER_OPTIONS); + let targetRoomId; + try { + targetRoomId = await mjolnir.client.resolveRoom(targetRoomAliasOrId); + } catch (ex) { + return { + html: `Cannot resolve room ${htmlEscape(targetRoomAliasOrId)}.`, + text: `Cannot resolve room \`${targetRoomAliasOrId}\`.` + } + } + const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); + const htmlFragments = []; + const textFragments = []; + for (let join of joins) { + const durationHumanReadable = HUMANIZER.humanize(Date.now() - join.timestamp, HUMANIZER_OPTIONS); + htmlFragments.push(`
  • ${htmlEscape(join.userId)}: ${durationHumanReadable}
  • `); + textFragments.push(`- ${join.userId}: ${durationHumanReadable}`); + } + return { + html: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries): `, + text: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}` + } + })(); + const reply = RichReply.createFor(destinationRoomId, event, text, html); + reply["msgtype"] = "m.notice"; + return mjolnir.client.sendMessage(destinationRoomId, reply); +} diff --git a/src/commands/StatusCommand.ts b/src/commands/StatusCommand.ts deleted file mode 100644 index acc1f84f..00000000 --- a/src/commands/StatusCommand.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Copyright (C) 2022 Gnuxie - * All rights reserved. - * - * This file is modified and is NOT licensed under the Apache License. - * This modified file incorperates work from mjolnir - * https://github.com/matrix-org/mjolnir - * which included the following license notice: - -Copyright 2019-2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - * - * However, this file is modified and the modifications in this file - * are NOT distributed, contributed, committed, or licensed under the Apache License. - */ - -import { Mjolnir, STATE_CHECKING_PERMISSIONS, STATE_NOT_STARTED, STATE_RUNNING, STATE_SYNCING } from "../Mjolnir"; -import { RichReply } from "matrix-bot-sdk"; -import { htmlEscape, parseDuration } from "../utils"; -import { HumanizeDurationLanguage, HumanizeDuration } from "humanize-duration-ts"; -import PolicyList from "../models/PolicyList"; -import { PACKAGE_JSON, SOFTWARE_VERSION } from "../config"; - -const HUMANIZE_LAG_SERVICE: HumanizeDurationLanguage = new HumanizeDurationLanguage(); -const HUMANIZER: HumanizeDuration = new HumanizeDuration(HUMANIZE_LAG_SERVICE); - -// !mjolnir -export async function execStatusCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - switch (parts[0]) { - case undefined: - case 'mjolnir': - return showMjolnirStatus(roomId, event, mjolnir); - case 'joins': - return showJoinsStatus(roomId, event, mjolnir, parts.slice(/* ["joins"] */ 1)); - case 'protection': - return showProtectionStatus(roomId, event, mjolnir, parts.slice(/* ["protection"] */ 1)); - default: - throw new Error(`Invalid status command: ${htmlEscape(parts[0])}`); - } -} - -async function showMjolnirStatus(roomId: string, event: any, mjolnir: Mjolnir) { - // Display the status of Mjölnir. - let html = ""; - let text = ""; - - const state = mjolnir.state; - - switch (state) { - case STATE_NOT_STARTED: - html += "Running: ❌ (not started)
    "; - text += "Running: ❌ (not started)\n"; - break; - case STATE_CHECKING_PERMISSIONS: - html += "Running: ❌ (checking own permissions)
    "; - text += "Running: ❌ (checking own permissions)\n"; - break; - case STATE_SYNCING: - html += "Running: ❌ (syncing lists)
    "; - text += "Running: ❌ (syncing lists)\n"; - break; - case STATE_RUNNING: - html += "Running:
    "; - text += "Running: ✅\n"; - break; - default: - html += "Running: ❌ (unknown state)
    "; - text += "Running: ❌ (unknown state)\n"; - break; - } - - html += `Protected rooms: ${mjolnir.protectedRoomsTracker.getProtectedRooms().length}
    `; - text += `Protected rooms: ${mjolnir.protectedRoomsTracker.getProtectedRooms().length}\n`; - - // Append list information - const renderPolicyLists = (header: string, lists: PolicyList[]) => { - html += `${header}:
    "; - } - const subscribedLists = mjolnir.policyListManager.lists.filter(list => !mjolnir.explicitlyProtectedRooms.includes(list.roomId)); - renderPolicyLists("Subscribed policy lists", subscribedLists); - const subscribedAndProtectedLists = mjolnir.policyListManager.lists.filter(list => mjolnir.explicitlyProtectedRooms.includes(list.roomId)); - renderPolicyLists("Subscribed and protected policy lists", subscribedAndProtectedLists); - - html += `Version: ${SOFTWARE_VERSION}
    `; - text += `Version: ${SOFTWARE_VERSION}\n`; - - html += `Repository: ${PACKAGE_JSON['repository'] ?? 'Unknown'}`; - text += `Repository: ${PACKAGE_JSON['repository'] ?? 'Unknown'}`; - - const reply = RichReply.createFor(roomId, event, text, html); - reply["msgtype"] = "m.notice"; - return mjolnir.client.sendMessage(roomId, reply); -} - -async function showProtectionStatus(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { - const protectionName = parts[0]; - const protection = mjolnir.protectionManager.getProtection(protectionName); - let text; - let html; - if (!protection) { - text = html = "Unknown protection"; - } else { - const status = await protection.statusCommand(mjolnir, parts.slice(1)); - if (status) { - text = status.text; - html = status.html; - } else { - text = ""; - html = "<no status>"; - } - } - const reply = RichReply.createFor(roomId, event, text, html); - reply["msgtype"] = "m.notice"; - await mjolnir.client.sendMessage(roomId, reply); -} - -/** - * Show the most recent joins to a room. - */ -async function showJoinsStatus(destinationRoomId: string, event: any, mjolnir: Mjolnir, args: string[]) { - const targetRoomAliasOrId = args[0]; - const maxAgeArg = args[1] || "1 day"; - const maxEntriesArg = args[2] = "200"; - const { html, text } = await (async () => { - if (!targetRoomAliasOrId) { - return { - html: "Missing arg: room id", - text: "Missing arg: `room id`" - }; - } - const maxAgeMS = parseDuration(maxAgeArg); - if (!maxAgeMS) { - return { - html: "Invalid duration. Example: 1.5 days or 10 minutes", - text: "Invalid duration. Example: `1.5 days` or `10 minutes`", - } - } - const maxEntries = Number.parseInt(maxEntriesArg, 10); - if (!maxEntries) { - return { - html: "Invalid number of entries. Example: 200", - text: "Invalid number of entries. Example: `200`", - } - } - const minDate = new Date(Date.now() - maxAgeMS); - const HUMANIZER_OPTIONS = { - // Reduce "1 day" => "1day" to simplify working with CSV. - spacer: "", - // Reduce "1 day, 2 hours" => "1.XXX day" to simplify working with CSV. - largest: 1, - }; - const maxAgeHumanReadable = HUMANIZER.humanize(maxAgeMS, HUMANIZER_OPTIONS); - let targetRoomId; - try { - targetRoomId = await mjolnir.client.resolveRoom(targetRoomAliasOrId); - } catch (ex) { - return { - html: `Cannot resolve room ${htmlEscape(targetRoomAliasOrId)}.`, - text: `Cannot resolve room \`${targetRoomAliasOrId}\`.` - } - } - const joins = mjolnir.roomJoins.getUsersInRoom(targetRoomId, minDate, maxEntries); - const htmlFragments = []; - const textFragments = []; - for (let join of joins) { - const durationHumanReadable = HUMANIZER.humanize(Date.now() - join.timestamp, HUMANIZER_OPTIONS); - htmlFragments.push(`
  • ${htmlEscape(join.userId)}: ${durationHumanReadable}
  • `); - textFragments.push(`- ${join.userId}: ${durationHumanReadable}`); - } - return { - html: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):
      ${htmlFragments.join()}
    `, - text: `${joins.length} recent joins (cut at ${maxAgeHumanReadable} ago / ${maxEntries} entries):\n${textFragments.join("\n")}` - } - })(); - const reply = RichReply.createFor(destinationRoomId, event, text, html); - reply["msgtype"] = "m.notice"; - return mjolnir.client.sendMessage(destinationRoomId, reply); -} - diff --git a/src/commands/StatusCommand.tsx b/src/commands/StatusCommand.tsx new file mode 100644 index 00000000..09472b8c --- /dev/null +++ b/src/commands/StatusCommand.tsx @@ -0,0 +1,187 @@ +/** + * Copyright (C) 2022 Gnuxie + * All rights reserved. + * + * This file is modified and is NOT licensed under the Apache License. + * This modified file incorperates work from mjolnir + * https://github.com/matrix-org/mjolnir + * which included the following license notice: + +Copyright 2019-2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + * + * However, this file is modified and the modifications in this file + * are NOT distributed, contributed, committed, or licensed under the Apache License. + */ + +import { STATE_CHECKING_PERMISSIONS, STATE_NOT_STARTED, STATE_RUNNING, STATE_SYNCING } from "../Mjolnir"; +import { RichReply } from "matrix-bot-sdk"; +import PolicyList from "../models/PolicyList"; +import { PACKAGE_JSON, SOFTWARE_VERSION } from "../config"; +import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { findPresentationType, parameters, RestDescription } from "./interface-manager/ParameterParsing"; +import { MjolnirContext } from "./CommandHandler"; +import { CommandError, CommandResult } from "./interface-manager/Validation"; +import { defineMatrixInterfaceAdaptor, MatrixContext, MatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; +import { MatrixSendClient } from "../MatrixEmitter"; +import { JSXFactory } from "./interface-manager/JSXFactory"; +import { Protection } from "../protections/IProtection"; +import { tickCrossRenderer } from "./interface-manager/MatrixHelpRenderer"; +import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; + +defineInterfaceCommand({ + designator: ["status"], + table: "mjolnir", + parameters: parameters([]), + command: async function () { return CommandResult.Ok(mjolnirStatusInfo.call(this)) }, + summary: "Show the status of the bot." +}) + +export interface ListInfo { + shortcode: string, + roomRef: string, + roomId: string, + serverRules: number, + userRules: number, + roomRules: number, +} + +export interface StatusInfo { + state: string, // a small description of the state of Mjolnir + numberOfProtectedRooms: number, + subscribedLists: ListInfo[], + subscribedAndProtectedLists: ListInfo[], + version: string, + repository: string +} + + +function mjolnirStatusInfo(this: MjolnirContext): StatusInfo { + const listInfo = (list: PolicyList): ListInfo => { + return { + shortcode: list.listShortcode, + roomRef: list.roomRef, + roomId: list.roomId, + serverRules: list.serverRules.length, + userRules: list.userRules.length, + roomRules: list.roomRules.length, + } + } + return { + state: this.mjolnir.state, + numberOfProtectedRooms: this.mjolnir.protectedRoomsTracker.getProtectedRooms().length, + subscribedLists: this.mjolnir.policyListManager.lists + .filter(list => !this.mjolnir.explicitlyProtectedRooms.includes(list.roomId)) + .map(listInfo), + subscribedAndProtectedLists: this.mjolnir.policyListManager.lists + .filter(list => this.mjolnir.explicitlyProtectedRooms.includes(list.roomId)) + .map(listInfo), + version: SOFTWARE_VERSION, + repository: PACKAGE_JSON['repository'] ?? 'Unknown' + } +} + +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "status"), + renderer: async function (this: MatrixInterfaceAdaptor, client: MatrixSendClient, commandRoomId: string, event: any, result: CommandResult): Promise { + const renderState = (state: StatusInfo['state']) => { + const notRunning = (text: string) => { + return Running: ❌ (${text})
    + }; + switch (state) { + case STATE_NOT_STARTED: + return notRunning('not started'); + case STATE_CHECKING_PERMISSIONS: + return notRunning('checking own permission'); + case STATE_SYNCING: + return notRunning('syncing lists'); + case STATE_RUNNING: + return Running:
    + default: + return notRunning('unknown state'); + } + }; + const renderPolicyLists = (header: string, lists: ListInfo[]) => { + const listInfo = lists.map(list => { + return
  • + {list.shortcode} @ {list.roomId} + (rules: {list.serverRules} servers, {list.userRules} users, {list.roomRules} rooms) +
  • + }); + return + {header}
    +
      + {listInfo.length === 0 ?
    • None
    • : listInfo} +
    +
    + }; + const info = result.ok; + + await renderMatrixAndSend( + {renderState(info.state)} + Protected Rooms: {info.numberOfProtectedRooms}
    + {renderPolicyLists('Subscribed policy lists', info.subscribedLists)} + {renderPolicyLists('Subscribed and protected policy lists', info.subscribedAndProtectedLists)} + Version: {info.version}
    + Repository: {info.repository}
    +
    , + commandRoomId, + event, + client); + } +}); + +defineInterfaceCommand({ + designator: ["status", "protection"], + table: "mjolnir", + parameters: parameters([ + { + name: "protection name", + acceptor: findPresentationType("string") + }, + ], + new RestDescription( + "subcommand", + findPresentationType("string") + )), + command: async function ( + this: MjolnirContext, _keywords, protectionName: string, ...subcommands: string[] + ): Promise>>> { + const protection = this.mjolnir.protectionManager.getProtection(protectionName); + if (!protection) { + return CommandError.Result(`Unknown protection ${protectionName}`); + } + return CommandResult.Ok(await protection.statusCommand(this.mjolnir, subcommands)) + }, + summary: "Show the status of a protection." +}) + +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "status", "protection"), + renderer: async function(client, commandRoomId, event, result) { + tickCrossRenderer.call(this, ...arguments); + if (result.isErr()) { + return; // tickCrossRenderer will handle it. + } + const status = result.ok; + const reply = RichReply.createFor( + commandRoomId, + event, + status?.text ?? "", + status?.html ?? "<no status>" + ); + reply["msgtype"] = "m.notice"; + await client.sendMessage(commandRoomId, reply); + } +}) diff --git a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts index 9c8a4f69..02b5fae6 100644 --- a/src/commands/interface-manager/MatrixInterfaceAdaptor.ts +++ b/src/commands/interface-manager/MatrixInterfaceAdaptor.ts @@ -53,7 +53,7 @@ type RendererSignature>) => Promise; -export class MatrixInterfaceAdaptor implements InterfaceAcceptor { +export class MatrixInterfaceAdaptor implements InterfaceAcceptor { public readonly isPromptable = true; constructor( public readonly interfaceCommand: InterfaceCommand, diff --git a/test/integration/roomMembersTest.ts b/test/integration/roomMembersTest.ts index 493f9c7a..3ad23cc2 100644 --- a/test/integration/roomMembersTest.ts +++ b/test/integration/roomMembersTest.ts @@ -311,7 +311,7 @@ describe("Test: Testing RoomMemberManager", function() { // Initially, the command should show that same result. for (let roomId of roomIds) { const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => { - const command = `!mjolnir status joins ${roomId}`; + const command = `!mjolnir joins ${roomId}`; return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); }); const body = reply["content"]?.["body"] as string; @@ -329,7 +329,7 @@ describe("Test: Testing RoomMemberManager", function() { const joined = manager.getUsersInRoom(roomId, start, 100); assert.equal(joined.length, SAMPLE_SIZE / 2 /* half of the users */ + 1 /* mjolnir */, "We should now see all joined users in the room"); const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => { - const command = `!mjolnir status joins ${roomId}`; + const command = `!mjolnir joins ${roomId}`; return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); }); const body = reply["content"]?.["body"] as string; @@ -362,7 +362,7 @@ describe("Test: Testing RoomMemberManager", function() { for (let i = 0; i < roomIds.length; ++i) { const roomId = roomIds[i]; const reply = await getFirstReply(mjolnir.matrixEmitter, mjolnir.managementRoomId, () => { - const command = `!mjolnir status joins ${roomId}`; + const command = `!mjolnir joins ${roomId}`; return this.moderator.sendMessage(mjolnir.managementRoomId, { msgtype: 'm.text', body: command }); }); const body = reply["content"]?.["body"] as string;