mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-12 07:54:52 +09:00
The instance plugins of exp_groups, exp_reports, exp_roles, exp_scenario and exp_server_ups, and the controller plugins of exp_reports and exp_scenario, were classes only to hold their handlers. They are now init functions with closures, like clusterio's own plugins. The exp_roles and exp_groups controller plugins stay classes because exp_scenario reaches their datastores through get(controller). AutoRestart stays a class because its tests step check() with a fake clock. Tests spy on handle() and the hooks to call the handlers. The reports webhook tests stub fetch instead of replacing postWebhook, and the feature reconcile test writes the stored records to disk before start. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
4.5 KiB
TypeScript
131 lines
4.5 KiB
TypeScript
import type { ControllerPluginContext } from "@clusterio/controller";
|
|
import * as lib from "@clusterio/lib";
|
|
import * as messages from "./messages.js";
|
|
import * as path from "node:path";
|
|
|
|
export default async function (context: ControllerPluginContext) {
|
|
const { controller, logger, plugin } = context;
|
|
const databaseDirectory = controller.config.get("controller.database_directory");
|
|
const reports = new lib.SubscribableDatastore(
|
|
...await new lib.JsonIdDatastoreProvider(
|
|
path.join(databaseDirectory, "exp_reports", "reports.json"),
|
|
messages.ReportRecord.fromJSON.bind(messages.ReportRecord),
|
|
).bootstrap()
|
|
);
|
|
|
|
/** Every report, or only those against one player. */
|
|
function listReports(playerName?: string) {
|
|
const all = [...reports.values()];
|
|
return playerName === undefined ? all : all.filter(report => report.playerName === playerName);
|
|
}
|
|
|
|
/** Post a new report to the configured webhooks, failures are logged rather than failing the report. */
|
|
function sendWebhooks(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(),
|
|
fields: [
|
|
{ name: "Player", value: report.playerName, inline: true },
|
|
{ name: "By", value: report.byPlayerName, inline: true },
|
|
{ name: "Instance", value: report.instanceName || "Web UI", inline: true },
|
|
{ name: "Reason", value: report.reason },
|
|
],
|
|
}],
|
|
});
|
|
}
|
|
|
|
const jsonUrl = controller.config.get("exp_reports.json_webhook_url");
|
|
if (jsonUrl) {
|
|
postWebhook(jsonUrl, { type: "report_created", report: report.toJSON() });
|
|
}
|
|
}
|
|
|
|
async function postWebhook(url: string, body: unknown) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!response.ok) {
|
|
logger.warn(`Webhook ${url} responded with ${response.status}`);
|
|
}
|
|
} catch (err: any) {
|
|
logger.warn(`Webhook ${url} failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
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
|
|
reports.on("update", updates => {
|
|
controller.subscriptions.broadcast(new messages.ReportUpdatedEvent(updates));
|
|
for (const report of updates) {
|
|
if (!report.isDeleted) {
|
|
sendWebhooks(report);
|
|
}
|
|
}
|
|
});
|
|
|
|
controller.handle(messages.ReportListRequest, async (request: messages.ReportListRequest) => (
|
|
listReports(request.playerName)
|
|
));
|
|
|
|
controller.handle(messages.ReportGetRequest, async (request: messages.ReportGetRequest) => {
|
|
const report = reports.get(request.id);
|
|
if (!report) {
|
|
throw new lib.RequestError(`Report with ID ${request.id} does not exist`);
|
|
}
|
|
return report;
|
|
});
|
|
|
|
controller.handle(messages.ReportCreateRequest, async (request: messages.ReportCreateRequest, src: lib.Address) => {
|
|
let byPlayerName = request.byPlayerName;
|
|
let instanceName = "";
|
|
if (src.type === lib.Address.control) {
|
|
byPlayerName = controller.wsServer.controlConnections.get(src.id)!.user.name;
|
|
} else {
|
|
const instance = controller.instances.get(src.id);
|
|
instanceName = instance ? instance.config.get("instance.name") : String(src.id);
|
|
}
|
|
|
|
if (!byPlayerName) {
|
|
throw new lib.RequestError("A report needs the name of the player making it");
|
|
}
|
|
if (!request.reason.trim()) {
|
|
throw new lib.RequestError("A report needs a reason");
|
|
}
|
|
if (listReports(request.playerName).some(report => report.byPlayerName === byPlayerName)) {
|
|
throw new lib.RequestError(`${byPlayerName} has already reported ${request.playerName}`);
|
|
}
|
|
|
|
let id = Math.random() * 2 ** 31 | 0;
|
|
while (reports.has(id)) {
|
|
id = Math.random() * 2 ** 31 | 0;
|
|
}
|
|
|
|
const report = new messages.ReportRecord(id, request.playerName, byPlayerName, request.reason.trim(), instanceName);
|
|
reports.set(report);
|
|
return report;
|
|
});
|
|
|
|
controller.handle(messages.ReportDeleteRequest, async (request: messages.ReportDeleteRequest) => {
|
|
const report = reports.getMutable(request.id);
|
|
if (!report) {
|
|
throw new lib.RequestError(`Report with ID ${request.id} does not exist`);
|
|
}
|
|
reports.delete(report);
|
|
});
|
|
|
|
controller.hooks.shutdown.attach(plugin.name, async () => {
|
|
await reports.save();
|
|
});
|
|
}
|