diff --git a/exp_reports/README.md b/exp_reports/README.md index 19bd7188..f4df70b1 100644 --- a/exp_reports/README.md +++ b/exp_reports/README.md @@ -4,7 +4,7 @@ Lets players report each other in game or from the web interface. Reports are ke Each report has the reported player, the player who made it, a reason, the instance it was made on and the time. A player can only report the same player once. Reports can't be edited, only marked as read or unread from the web interface, and deleted. Nothing is stored in the Lua state, every command sends a request to the controller and the answer is printed once it arrives. -The plugin also keeps a watch list in `database/exp_reports/watch_list.json`. When a player on it joins any instance, every admin online on every instance is told who joined where and why they are watched, and the join is posted to the webhooks. With `exp_reports.watch_unread_reports` set, players with unread reports are treated as if they were on the watch list, and their unread reports are listed with the join. +The plugin also keeps a watch list in `database/exp_reports/watch_list.json`. When a player on it joins any instance, every admin online on every instance is told who joined where and why they are watched, and the join is posted to the webhooks. Names on the watch list are case insensitive, like Factorio and Clusterio user names. With `exp_reports.watch_unread_reports` set, players with unread reports are treated as if they were on the watch list, and their unread reports are listed with the join. ## Config @@ -15,8 +15,9 @@ All fields are on the controller. | `exp_reports.discord_webhook_url` | | Discord channel webhook which new reports and watched player joins are posted to as an embed. | | `exp_reports.json_webhook_url` | | URL which new reports and watched player joins are posted to as JSON. | | `exp_reports.watch_unread_reports` | `false` | Treat players with unread reports as if they were on the watch list. | +| `exp_reports.announce_to_all_instances` | `true` | Tell admins on every instance when a watched player joins. When off only admins on the instance they joined are told. | -Only new reports and watched player joins are posted. Deleting or reading a report and editing the watch list are not. The JSON webhook gets `{ "type": "report_created", "report": { ... } }` with the report in the same form as `reports.json`, or `{ "type": "watched_player_joined", "player_name", "instance_name", "watch_list_entry", "unread_reports" }`. `watch_list_entry` is left out when the player is only watched for unread reports, and `unread_reports` is empty unless `exp_reports.watch_unread_reports` is set. A webhook that fails is logged as a warning and the report is still made. +Only new reports and watched player joins are posted. Deleting or reading a report and editing the watch list are not. The JSON webhook gets `{ "type": "report_created", "report": { ... } }` with the report in the same form as `reports.json`, or `{ "type": "watched_player_joined", "player_name", "instance_name", "watch_list_entry", "unread_reports" }`. `watch_list_entry` is left out when the player is only watched for unread reports, and `unread_reports` is empty unless `exp_reports.watch_unread_reports` is set. Unread reports are newest first. The Discord embed lists the three newest and counts the rest in its footer. A webhook that fails is logged as a warning and the report is still made. ## Permissions diff --git a/exp_reports/controller.ts b/exp_reports/controller.ts index d400f999..8d7e2a47 100644 --- a/exp_reports/controller.ts +++ b/exp_reports/controller.ts @@ -75,10 +75,13 @@ export default async function (context: ControllerPluginContext) { { name: "Player", value: event.playerName, inline: true }, { name: "Instance", value: event.instanceName, inline: true }, ...entry ? [{ name: `Watch list, added by ${entry.byPlayerName}`, value: entry.reason }] : [], - ...event.unreadReports.map(report => ({ + ...event.unreadReports.slice(0, 3).map(report => ({ name: `Unread report by ${report.byPlayerName}`, value: report.reason, })), ], + ...event.unreadReports.length > 3 ? { + footer: { text: `+${event.unreadReports.length - 3} more unread reports` }, + } : {}, }], }); } @@ -104,11 +107,14 @@ export default async function (context: ControllerPluginContext) { } } - /** Tell every instance and the webhooks when a watched player joins. */ + /** Tell the instances and the webhooks when a watched player joins, names are case insensitive. */ function playerJoined(instance: InstanceRecord, playerName: string) { - const watchListEntry = watchList.get(playerName); + const key = playerName.toLowerCase(); + const watchListEntry = watchList.get(key); const unreadReports = controller.config.get("exp_reports.watch_unread_reports") - ? listReports(playerName).filter(report => !report.isRead) + ? [...reports.values()] + .filter(report => !report.isRead && report.playerName.toLowerCase() === key) + .sort((a, b) => b.createdAtMs - a.createdAtMs) : []; if (!watchListEntry && !unreadReports.length) { return; @@ -117,7 +123,11 @@ export default async function (context: ControllerPluginContext) { const event = new messages.WatchedPlayerJoinedEvent( playerName, instance.config.get("instance.name"), watchListEntry, unreadReports, ); - controller.sendTo("allInstances", event); + if (controller.config.get("exp_reports.announce_to_all_instances")) { + controller.sendTo("allInstances", event); + } else { + controller.sendTo({ instanceId: instance.id }, event); + } sendJoinWebhooks(event); } @@ -215,8 +225,9 @@ export default async function (context: ControllerPluginContext) { if (!request.reason.trim()) { throw new lib.RequestError("A reason is required"); } - if (watchList.has(playerName)) { - throw new lib.RequestError(`${playerName} is already on the watch list`); + const existing = watchList.get(playerName.toLowerCase()); + if (existing) { + throw new lib.RequestError(`${existing.playerName} is already on the watch list`); } const entry = new messages.WatchListRecord(playerName, request.reason.trim(), controlUserName(src), Date.now()); @@ -225,14 +236,14 @@ export default async function (context: ControllerPluginContext) { }); controller.handle(messages.WatchListRemoveRequest, async (request: messages.WatchListRemoveRequest) => { - const entry = watchList.getMutable(request.playerName); + const entry = watchList.getMutable(request.playerName.toLowerCase()); if (!entry) { throw new lib.RequestError(`${request.playerName} is not on the watch list`); } watchList.delete(entry); }); - controller.hooks.shutdown.attach(plugin.name, async () => { + controller.hooks.save.attach(plugin.name, async () => { await reports.save(); await watchList.save(); }); diff --git a/exp_reports/index.ts b/exp_reports/index.ts index 3ee1cce7..85205e61 100644 --- a/exp_reports/index.ts +++ b/exp_reports/index.ts @@ -6,6 +6,7 @@ declare module "@clusterio/lib" { "exp_reports.discord_webhook_url": string | null; "exp_reports.json_webhook_url": string | null; "exp_reports.watch_unread_reports": boolean; + "exp_reports.announce_to_all_instances": boolean; } export interface Permissions { "exp_reports.report.get": never; @@ -124,6 +125,12 @@ export const plugin: lib.PluginDeclaration = { type: "boolean", initialValue: false, }, + "exp_reports.announce_to_all_instances": { + title: "Announce to All Instances", + description: "Tell admins on every instance when a watched player joins, instead of only the instance they joined.", + type: "boolean", + initialValue: true, + }, }, webEntrypoint: "./web", diff --git a/exp_reports/messages.ts b/exp_reports/messages.ts index 0e94a460..6b528436 100644 --- a/exp_reports/messages.ts +++ b/exp_reports/messages.ts @@ -89,8 +89,9 @@ export class WatchListRecord { public isDeleted: boolean = false, ) {} + /** Player names are case insensitive in Factorio and Clusterio. */ get id() { - return this.playerName; + return this.playerName.toLowerCase(); } static jsonSchema = Type.Object({ @@ -400,10 +401,12 @@ export class WatchListRemoveRequest { /** * A watched player joined an instance, sent to every instance so admins - * everywhere see it. + * everywhere see it, or only to the one they joined when + * exp_reports.announce_to_all_instances is off. * * The player is watched because of their watch list entry, their unread - * reports when exp_reports.watch_unread_reports is set, or both. + * reports when exp_reports.watch_unread_reports is set, or both. Unread + * reports are newest first. */ export class WatchedPlayerJoinedEvent { declare ["constructor"]: typeof WatchedPlayerJoinedEvent; diff --git a/exp_reports/test/controller.test.js b/exp_reports/test/controller.test.js index 61d3d4e9..94fddca7 100644 --- a/exp_reports/test/controller.test.js +++ b/exp_reports/test/controller.test.js @@ -98,10 +98,10 @@ t.test("entrypoint", async t2 => { const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() }); const controller = new Controller(logger, [], controllerConfig); await entrypoint({ controller, logger, plugin: { name: "exp_reports" }, metrics: undefined }); - for (const hook of ["playerEvent", "shutdown"]) { + for (const hook of ["playerEvent", "save"]) { t2.ok([...controller.hooks[hook].attached].includes("exp_reports"), `attached to ${hook}`); } - await controller.hooks.shutdown.invoke(); + await controller.hooks.save.invoke(); }); t.test("controller plugin", t2 => { @@ -223,10 +223,13 @@ t.test("controller plugin", t2 => { t3.strictSame((await watchSubscription(0)).updates, [entry], "subscribers get the entry"); await t3.rejects(watch("bob", "again"), { message: "bob is already on the watch list" }); + await t3.rejects( + watch("BOB", "again"), { message: "bob is already on the watch list" }, "names are case insensitive", + ); await t3.rejects(watch(" ", "spam"), { message: "A player name is required" }); await t3.rejects(watch("carol", " "), { message: "A reason is required" }); - await unwatch("bob"); + await unwatch("Bob"); t3.ok( state.broadcasts.some(event => event instanceof messages.WatchListUpdatedEvent && event.updates[0].isDeleted), "subscribers are told it was removed", @@ -260,6 +263,49 @@ t.test("controller plugin", t2 => { t3.strictSame(state.posts[1].body, { type: "watched_player_joined", ...message.toJSON() }); }); + t2.test("watch list names match joins case insensitively", async t3 => { + const { controller, watch, create, join, state } = await startPlugin(t3); + const entry = await watch("Bob", "griefing"); + await join("bOB"); + t3.strictSame(state.sent[0].message.watchListEntry, entry, "the entry is found"); + t3.strictSame(state.sent[0].message.playerName, "bOB", "the event names the player as they joined"); + + state.sent.length = 0; + controller.config.set("exp_reports.watch_unread_reports", true); + await create("carol", "spam", "alice"); + await join("CAROL"); + t3.strictSame(state.sent[0].message.unreadReports.length, 1, "unread reports are found"); + }); + + t2.test("the join goes only to the joined instance when announce_to_all_instances is off", async t3 => { + const { controller, watch, join, state } = await startPlugin(t3); + await watch("bob", "griefing"); + controller.config.set("exp_reports.announce_to_all_instances", false); + await join("bob"); + t3.strictSame(state.sent[0].dst, { instanceId: 1 }); + }); + + t2.test("the discord embed lists the three newest unread reports and counts the rest", async t3 => { + const { create, join, state } = await startPlugin(t3, { config: { + "exp_reports.discord_webhook_url": "https://discord.example/hook", + "exp_reports.watch_unread_reports": true, + } }); + for (const [index, reporter] of ["a", "b", "c", "d", "e"].entries()) { + const report = await create("bob", `reason ${index}`, reporter); + report.createdAtMs = 1000 + index; + } + state.posts.length = 0; + await join("bob"); + + const embed = state.posts[0].body.embeds[0]; + t3.strictSame( + embed.fields.slice(2).map(field => field.value), ["reason 4", "reason 3", "reason 2"], + "the newest three are listed", + ); + t3.strictSame(embed.footer, { text: "+2 more unread reports" }); + t3.strictSame(state.sent[0].message.unreadReports.length, 5, "the instances still get every report"); + }); + t2.test("unread reports count as the watch list only when configured", async t3 => { const { controller, create, setRead, join, state } = await startPlugin(t3); const read = await create("bob", "spam", "alice"); diff --git a/exp_reports/test/messages.test.js b/exp_reports/test/messages.test.js index 83c7587d..f543aae7 100644 --- a/exp_reports/test/messages.test.js +++ b/exp_reports/test/messages.test.js @@ -30,7 +30,7 @@ t.test("class WatchListRecord", t2 => { [0, 12345], // updatedAtMs [false, true], // isDeleted )); - t2.equal(sampleEntry.id, "bob", "the player name is the id"); + t2.equal(new messages.WatchListRecord("Bob", "griefing", "alice").id, "bob", "the lower case player name is the id"); t2.end(); }); diff --git a/exp_reports/web/components/ReportsPage.tsx b/exp_reports/web/components/ReportsPage.tsx index 2675b029..385bddf0 100644 --- a/exp_reports/web/components/ReportsPage.tsx +++ b/exp_reports/web/components/ReportsPage.tsx @@ -96,6 +96,7 @@ function ReportsTable(props: { onWatch: (values: WatchListValues) => void }) { const tableState = useTableQueryState({ namespace: "report", defaultSortKey: "time", + defaultSortOrder: "descend", pagination: { defaultPageSize: 50 }, }); const playerSearch = useColumnSearch(tableState, "player", report => report.playerName, "Search players"); @@ -157,7 +158,6 @@ function ReportsTable(props: { onWatch: (values: WatchListValues) => void }) { render: (_, report) => new Date(report.createdAtMs).toLocaleString(), sorter: (a, b) => a.createdAtMs - b.createdAtMs, sortOrder: tableState.sortOrder("time"), - defaultSortOrder: "descend", }, { title: "Status", @@ -175,7 +175,7 @@ function ReportsTable(props: { onWatch: (values: WatchListValues) => void }) { } {canWatch &&