mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-14 16:14:53 +09:00
Add a watch list and read state to exp_reports
Players on the watch list are announced to every online admin on every instance when they join, and the join is posted to the Discord and JSON webhooks. The Reports page has a second table for the watch list with an Add button and a Delete button per row. Reports can be marked read or unread from the web interface. Each report row also has an Add to watch list button which opens the add form filled in with the player and reason. Setting exp_reports.watch_unread_reports treats players with unread reports as watched. Reports now store created_at_ms because marking them read changes updated_at_ms. Old reports.json files migrate on load with updated_at_ms as the creation time. The report webhook moved from the datastore update listener to the create handler so reading a report is not posted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82abc2c332
commit
44ae3a5f0e
14 files changed
+826
-43
No files matched your search
+119
-10
@@ -1,8 +1,13 @@
|
||||
import type { ControllerPluginContext } from "@clusterio/controller";
|
||||
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** Reports saved before the read state was added used updated_at_ms as the creation time. */
|
||||
function migrateReports(rawJson: unknown[]) {
|
||||
return rawJson.map((report: any) => ({ created_at_ms: report.updated_at_ms ?? 0, ...report }));
|
||||
}
|
||||
|
||||
export default async function (context: ControllerPluginContext) {
|
||||
const { controller, logger, plugin } = context;
|
||||
const databaseDirectory = controller.config.get("controller.database_directory");
|
||||
@@ -10,6 +15,13 @@ export default async function (context: ControllerPluginContext) {
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_reports", "reports.json"),
|
||||
messages.ReportRecord.fromJSON.bind(messages.ReportRecord),
|
||||
migrateReports,
|
||||
).bootstrap()
|
||||
);
|
||||
const watchList = new lib.SubscribableDatastore(
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_reports", "watch_list.json"),
|
||||
messages.WatchListRecord.fromJSON.bind(messages.WatchListRecord),
|
||||
).bootstrap()
|
||||
);
|
||||
|
||||
@@ -19,15 +31,20 @@ export default async function (context: ControllerPluginContext) {
|
||||
return playerName === undefined ? all : all.filter(report => report.playerName === playerName);
|
||||
}
|
||||
|
||||
/** Name of the user behind a control connection. */
|
||||
function controlUserName(src: lib.Address) {
|
||||
return controller.wsServer.controlConnections.get(src.id)!.user.name;
|
||||
}
|
||||
|
||||
/** Post a new report to the configured webhooks, failures are logged rather than failing the report. */
|
||||
function sendWebhooks(report: messages.ReportRecord) {
|
||||
function sendReportWebhooks(report: messages.ReportRecord) {
|
||||
const discordUrl = controller.config.get("exp_reports.discord_webhook_url");
|
||||
if (discordUrl) {
|
||||
postWebhook(discordUrl, {
|
||||
embeds: [{
|
||||
title: "Player reported",
|
||||
color: 0xffcc00,
|
||||
timestamp: new Date(report.updatedAtMs).toISOString(),
|
||||
timestamp: new Date(report.createdAtMs).toISOString(),
|
||||
fields: [
|
||||
{ name: "Player", value: report.playerName, inline: true },
|
||||
{ name: "By", value: report.byPlayerName, inline: true },
|
||||
@@ -44,6 +61,34 @@ export default async function (context: ControllerPluginContext) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Post a watched player joining to the configured webhooks. */
|
||||
function sendJoinWebhooks(event: messages.WatchedPlayerJoinedEvent) {
|
||||
const discordUrl = controller.config.get("exp_reports.discord_webhook_url");
|
||||
if (discordUrl) {
|
||||
const entry = event.watchListEntry;
|
||||
postWebhook(discordUrl, {
|
||||
embeds: [{
|
||||
title: "Watched player joined",
|
||||
color: 0xff6600,
|
||||
timestamp: new Date().toISOString(),
|
||||
fields: [
|
||||
{ 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 => ({
|
||||
name: `Unread report by ${report.byPlayerName}`, value: report.reason,
|
||||
})),
|
||||
],
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
const jsonUrl = controller.config.get("exp_reports.json_webhook_url");
|
||||
if (jsonUrl) {
|
||||
postWebhook(jsonUrl, { type: "watched_player_joined", ...event.toJSON() });
|
||||
}
|
||||
}
|
||||
|
||||
async function postWebhook(url: string, body: unknown) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@@ -59,18 +104,44 @@ export default async function (context: ControllerPluginContext) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tell every instance and the webhooks when a watched player joins. */
|
||||
function playerJoined(instance: InstanceRecord, playerName: string) {
|
||||
const watchListEntry = watchList.get(playerName);
|
||||
const unreadReports = controller.config.get("exp_reports.watch_unread_reports")
|
||||
? listReports(playerName).filter(report => !report.isRead)
|
||||
: [];
|
||||
if (!watchListEntry && !unreadReports.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = new messages.WatchedPlayerJoinedEvent(
|
||||
playerName, instance.config.get("instance.name"), watchListEntry, unreadReports,
|
||||
);
|
||||
controller.sendTo("allInstances", event);
|
||||
sendJoinWebhooks(event);
|
||||
}
|
||||
|
||||
controller.subscriptions.handle(messages.ReportUpdatedEvent, async request => {
|
||||
const updates = [...reports.values()].filter(report => report.updatedAtMs > request.lastRequestTimeMs);
|
||||
return updates.length ? new messages.ReportUpdatedEvent(updates) : null;
|
||||
});
|
||||
|
||||
// Reports are immutable, so any update which is not a deletion is a new report
|
||||
controller.subscriptions.handle(messages.WatchListUpdatedEvent, async request => {
|
||||
const updates = [...watchList.values()].filter(entry => entry.updatedAtMs > request.lastRequestTimeMs);
|
||||
return updates.length ? new messages.WatchListUpdatedEvent(updates) : null;
|
||||
});
|
||||
|
||||
reports.on("update", updates => {
|
||||
controller.subscriptions.broadcast(new messages.ReportUpdatedEvent(updates));
|
||||
for (const report of updates) {
|
||||
if (!report.isDeleted) {
|
||||
sendWebhooks(report);
|
||||
}
|
||||
});
|
||||
|
||||
watchList.on("update", updates => {
|
||||
controller.subscriptions.broadcast(new messages.WatchListUpdatedEvent(updates));
|
||||
});
|
||||
|
||||
controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
|
||||
if (event.type === "join") {
|
||||
playerJoined(instance, event.name);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -90,7 +161,7 @@ export default async function (context: ControllerPluginContext) {
|
||||
let byPlayerName = request.byPlayerName;
|
||||
let instanceName = "";
|
||||
if (src.type === lib.Address.control) {
|
||||
byPlayerName = controller.wsServer.controlConnections.get(src.id)!.user.name;
|
||||
byPlayerName = controlUserName(src);
|
||||
} else {
|
||||
const instance = controller.instances.get(src.id);
|
||||
instanceName = instance ? instance.config.get("instance.name") : String(src.id);
|
||||
@@ -111,8 +182,11 @@ export default async function (context: ControllerPluginContext) {
|
||||
id = Math.random() * 2 ** 31 | 0;
|
||||
}
|
||||
|
||||
const report = new messages.ReportRecord(id, request.playerName, byPlayerName, request.reason.trim(), instanceName);
|
||||
const report = new messages.ReportRecord(
|
||||
id, request.playerName, byPlayerName, request.reason.trim(), instanceName, Date.now(),
|
||||
);
|
||||
reports.set(report);
|
||||
sendReportWebhooks(report);
|
||||
return report;
|
||||
});
|
||||
|
||||
@@ -124,7 +198,42 @@ export default async function (context: ControllerPluginContext) {
|
||||
reports.delete(report);
|
||||
});
|
||||
|
||||
controller.handle(messages.ReportSetReadRequest, async (request: messages.ReportSetReadRequest) => {
|
||||
const report = reports.getMutable(request.id);
|
||||
if (!report) {
|
||||
throw new lib.RequestError(`Report with ID ${request.id} does not exist`);
|
||||
}
|
||||
report.isRead = request.isRead;
|
||||
reports.set(report);
|
||||
});
|
||||
|
||||
controller.handle(messages.WatchListAddRequest, async (request: messages.WatchListAddRequest, src: lib.Address) => {
|
||||
const playerName = request.playerName.trim();
|
||||
if (!playerName) {
|
||||
throw new lib.RequestError("A player name is required");
|
||||
}
|
||||
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 entry = new messages.WatchListRecord(playerName, request.reason.trim(), controlUserName(src), Date.now());
|
||||
watchList.set(entry);
|
||||
return entry;
|
||||
});
|
||||
|
||||
controller.handle(messages.WatchListRemoveRequest, async (request: messages.WatchListRemoveRequest) => {
|
||||
const entry = watchList.getMutable(request.playerName);
|
||||
if (!entry) {
|
||||
throw new lib.RequestError(`${request.playerName} is not on the watch list`);
|
||||
}
|
||||
watchList.delete(entry);
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
await reports.save();
|
||||
await watchList.save();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user