mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-11 23:54:52 +09:00
Save both datastores on the save hook so autosave covers them, the controller also runs it at shutdown. Watch list names are case insensitive, matching Factorio and Clusterio users. The new exp_reports.announce_to_all_instances config, on by default, can limit the join message to the instance the player joined. The Discord embed lists the three newest unread reports and counts the rest in the footer, since Discord rejects embeds with more than 25 fields. Both tables now sort newest first by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
8.9 KiB
TypeScript
251 lines
8.9 KiB
TypeScript
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");
|
|
const reports = new lib.SubscribableDatastore(
|
|
...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()
|
|
);
|
|
|
|
/** 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);
|
|
}
|
|
|
|
/** 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 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.createdAtMs).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() });
|
|
}
|
|
}
|
|
|
|
/** 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.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` },
|
|
} : {},
|
|
}],
|
|
});
|
|
}
|
|
|
|
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, {
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
/** Tell the instances and the webhooks when a watched player joins, names are case insensitive. */
|
|
function playerJoined(instance: InstanceRecord, playerName: string) {
|
|
const key = playerName.toLowerCase();
|
|
const watchListEntry = watchList.get(key);
|
|
const unreadReports = controller.config.get("exp_reports.watch_unread_reports")
|
|
? [...reports.values()]
|
|
.filter(report => !report.isRead && report.playerName.toLowerCase() === key)
|
|
.sort((a, b) => b.createdAtMs - a.createdAtMs)
|
|
: [];
|
|
if (!watchListEntry && !unreadReports.length) {
|
|
return;
|
|
}
|
|
|
|
const event = new messages.WatchedPlayerJoinedEvent(
|
|
playerName, instance.config.get("instance.name"), watchListEntry, unreadReports,
|
|
);
|
|
if (controller.config.get("exp_reports.announce_to_all_instances")) {
|
|
controller.sendTo("allInstances", event);
|
|
} else {
|
|
controller.sendTo({ instanceId: instance.id }, 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;
|
|
});
|
|
|
|
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));
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
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 = controlUserName(src);
|
|
} 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, Date.now(),
|
|
);
|
|
reports.set(report);
|
|
sendReportWebhooks(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.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");
|
|
}
|
|
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());
|
|
watchList.set(entry);
|
|
return entry;
|
|
});
|
|
|
|
controller.handle(messages.WatchListRemoveRequest, async (request: messages.WatchListRemoveRequest) => {
|
|
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.save.attach(plugin.name, async () => {
|
|
await reports.save();
|
|
await watchList.save();
|
|
});
|
|
}
|