feat(admin): forward in-game moderation to the website (bidirectional audit)
Phase C / §5.5: so the site's moderation log is complete regardless of origin, in-game uses of the write-plane verbs are forwarded as admin.audit (origin:"in-game"). - patches/commandlogging-event.patch: adds CommandLogging.OnWrite, raised in WriteLine before the m_Enabled guard so it fires even when file logging is off. Scripts-layer file -> dynamic build, no core rebuild. - patches/BridgeModerationAudit.cs: subscriber. Taps OnWrite for resolved ban/kick (parsing the target from the log line) and EventSink.Command for [bcast. Lives in patches/ (not overlay/) because it references OnWrite, which only exists post-patch — same rule as BridgeVendorSale.cs. - tools/scaffolding/BridgeAuditProbe.cs: gated headless verification. Verified live: a genuine [bcast plus simulated ban/kick log lines produced admin.audit frames with origin=in-game, actor, and the target parsed (seed_010); a non-moderation line was correctly ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
133
patches/BridgeModerationAudit.cs
Normal file
133
patches/BridgeModerationAudit.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Forwards IN-GAME uses of the write-plane verbs to the website as admin.audit
|
||||||
|
/// (origin=in-game), so the site's moderation log is complete regardless of whether an action
|
||||||
|
/// came from the website or a staff member in the game client. See docs/ADMIN_CONTROLS.md §5.5.
|
||||||
|
///
|
||||||
|
/// Two sources, mirroring how the shard records each:
|
||||||
|
/// - ban / kick: resolved with their target inside the stock generic command, which logs a
|
||||||
|
/// line via CommandLogging.WriteLine. We tap the new CommandLogging.OnWrite event and
|
||||||
|
/// parse the "... banning|kicking <target> ('acct')" line for action and target.
|
||||||
|
/// - broadcast: [bcast carries its message as command args and hits no target, so
|
||||||
|
/// EventSink.Command already sees it whole; we reshape it.
|
||||||
|
///
|
||||||
|
/// Not in overlay/: it references CommandLogging.OnWrite, which exists only after
|
||||||
|
/// patches/commandlogging-event.patch is applied. Shipping it in overlay/ would break the
|
||||||
|
/// build on an unpatched install — the same reason BridgeVendorSale.cs lives in patches/.
|
||||||
|
///
|
||||||
|
/// Runs on the Core thread (both sources raise synchronously in the command path). Every body
|
||||||
|
/// is wrapped: a bridge exception must never escape into a staff command.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeModerationAudit
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CommandLogging.OnWrite += OnCommandLog; // ban / kick (resolved, with target)
|
||||||
|
EventSink.Command += OnStaffCommand; // broadcast (carries its message)
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] in-game moderation audit attached");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The stock ban/kick commands log "<level> <from> ('acct') banning|kicking
|
||||||
|
/// <target> ('acct')" (Commands.cs KickCommand). Match the verb, take the target's
|
||||||
|
/// account from the trailing "('acct')", and forward. Non-moderation lines are ignored.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnCommandLog(Mobile from, string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (from == null || text == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string action;
|
||||||
|
int at;
|
||||||
|
|
||||||
|
if ((at = text.IndexOf(" banning ", StringComparison.Ordinal)) >= 0)
|
||||||
|
action = "ban";
|
||||||
|
else if ((at = text.IndexOf(" kicking ", StringComparison.Ordinal)) >= 0)
|
||||||
|
action = "kick";
|
||||||
|
else
|
||||||
|
return;
|
||||||
|
|
||||||
|
var tail = text.Substring(at + 9); // past " banning " / " kicking "
|
||||||
|
Emit(action, from, ExtractAccount(tail), text);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] mod-audit log parse threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>[bcast / [bc / [b — a staff broadcast. Its message is the command args.</summary>
|
||||||
|
private static void OnStaffCommand(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (e == null || e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var cmd = e.Command;
|
||||||
|
if (cmd == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
cmd = cmd.ToLowerInvariant();
|
||||||
|
if (cmd != "bcast" && cmd != "bc" && cmd != "b")
|
||||||
|
return;
|
||||||
|
|
||||||
|
Emit("broadcast", e.Mobile, null, e.ArgString);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] mod-audit command threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pulls the account from a CommandLogging.Format rendering's trailing "('account')".</summary>
|
||||||
|
private static string ExtractAccount(string formatted)
|
||||||
|
{
|
||||||
|
if (formatted == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int open = formatted.LastIndexOf("('", StringComparison.Ordinal);
|
||||||
|
if (open < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int close = formatted.IndexOf("')", open, StringComparison.Ordinal);
|
||||||
|
if (close < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return formatted.Substring(open + 2, close - (open + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits admin.audit with origin=in-game. The actor is the staff member's account name
|
||||||
|
/// (no "web:" prefix — that, plus the origin field, is how the website tells the two
|
||||||
|
/// sources apart). `detail` carries the raw context so nothing is lost if a target could
|
||||||
|
/// not be parsed.
|
||||||
|
/// </summary>
|
||||||
|
private static void Emit(string action, Mobile actor, string target, string detail)
|
||||||
|
{
|
||||||
|
var acct = actor.Account as Account;
|
||||||
|
var actorName = acct != null ? acct.Username : actor.Name;
|
||||||
|
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("admin.audit")
|
||||||
|
.Str("origin", "in-game")
|
||||||
|
.Str("action", action)
|
||||||
|
.Str("actor", actorName)
|
||||||
|
.Str("target", target)
|
||||||
|
.Str("detail", detail)
|
||||||
|
.End());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,24 @@ Both patches are `git`-format and verified with `git apply --check` against stoc
|
|||||||
|
|
||||||
Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
|
Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
|
||||||
|
|
||||||
|
## In-game moderation audit (admin controls §5.5)
|
||||||
|
|
||||||
|
So the website's moderation log stays complete, in-game uses of the write-plane verbs are forwarded to it as `admin.audit` (`origin:"in-game"`). Broadcasts already surface through `EventSink.Command`, but resolved bans/kicks only carry their target inside the command's own `CommandLogging.WriteLine` call — which has no event to subscribe to. One small change fixes that:
|
||||||
|
|
||||||
|
| Item | Target | What |
|
||||||
|
|------|--------|------|
|
||||||
|
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | Adds a `public static event Action<Mobile,string> OnWrite`, raised in `WriteLine` **before** the `m_Enabled` guard so it fires even when file logging is off. |
|
||||||
|
| `BridgeModerationAudit.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber: taps `OnWrite` for ban/kick (parsing the target out of the log line) and `EventSink.Command` for `[bcast`, emitting `admin.audit`. **Not** in `overlay/` because it references `CommandLogging.OnWrite`, which does not exist until the patch is applied. |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <servuo root>
|
||||||
|
git apply --check patches/commandlogging-event.patch # dry run
|
||||||
|
git apply patches/commandlogging-event.patch
|
||||||
|
cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/BridgeModerationAudit.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
`Logging.cs` is a **Scripts** file, so this is picked up by the dynamic script build — no core/solution rebuild needed (unlike the Phase 7 `EventSink.cs` patch). Verified end-to-end with `tools/scaffolding/BridgeAuditProbe.cs` (gated by `Bridge.AuditProbeOnStart`): a genuine `[bcast` plus simulated ban/kick log lines produced the expected `admin.audit` frames, target parsed, with non-moderation lines ignored.
|
||||||
|
|
||||||
## Note on `Scripts.csproj`
|
## Note on `Scripts.csproj`
|
||||||
|
|
||||||
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
|
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
|
||||||
|
|||||||
33
patches/commandlogging-event.patch
Normal file
33
patches/commandlogging-event.patch
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
--- a/Scripts/Commands/Logging.cs
|
||||||
|
+++ b/Scripts/Commands/Logging.cs
|
||||||
|
@@ -75,16 +75,27 @@
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ /// <summary>
|
||||||
|
+ /// Raised for every staff command log line — even when file logging is disabled — so an
|
||||||
|
+ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to
|
||||||
|
+ /// forward moderation actions (ban/kick, with the resolved target) to the website.
|
||||||
|
+ /// </summary>
|
||||||
|
+ public static event Action<Mobile, string> OnWrite;
|
||||||
|
+
|
||||||
|
public static void WriteLine(Mobile from, string format, params object[] args)
|
||||||
|
{
|
||||||
|
- if (!m_Enabled)
|
||||||
|
- return;
|
||||||
|
-
|
||||||
|
WriteLine(from, String.Format(format, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteLine(Mobile from, string text)
|
||||||
|
{
|
||||||
|
+ var onWrite = OnWrite;
|
||||||
|
+ if (onWrite != null)
|
||||||
|
+ {
|
||||||
|
+ try { onWrite(from, text); }
|
||||||
|
+ catch { }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
if (!m_Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
71
tools/scaffolding/BridgeAuditProbe.cs
Normal file
71
tools/scaffolding/BridgeAuditProbe.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the in-game moderation-audit forwarding (BridgeModerationAudit) without a game
|
||||||
|
/// client, so the CommandLogging.OnWrite patch and the admin.audit normalizer can be verified
|
||||||
|
/// end-to-end from a stub sidecar.
|
||||||
|
///
|
||||||
|
/// - Broadcast is a *genuine* trigger: CommandSystem.Handle runs [bcast, which raises
|
||||||
|
/// EventSink.Command exactly as a staff keystroke would.
|
||||||
|
/// - Ban/kick can't complete headlessly (they arm a target cursor with no client to click),
|
||||||
|
/// so we call CommandLogging.WriteLine with the stock KickCommand line format — the same
|
||||||
|
/// call that command makes at Commands.cs:1211, which is the point we tap.
|
||||||
|
/// - A non-moderation log line confirms the normalizer ignores everything else.
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Never deployed. Gated behind Bridge.AuditProbeOnStart (absent in a
|
||||||
|
/// shipped Bridge.cfg, so Config.Get returns false and it never runs in production).
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeAuditProbe
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (Config.Get("Bridge.AuditProbeOnStart", false))
|
||||||
|
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var staffAcct = Accounting.Accounts.GetAccount("whitlocktech") as Account;
|
||||||
|
var targetAcct = Accounting.Accounts.GetAccount("seed_010") as Account;
|
||||||
|
|
||||||
|
var from = staffAcct == null ? null : staffAcct[0];
|
||||||
|
var target = targetAcct == null ? null : targetAcct[0];
|
||||||
|
|
||||||
|
if (from == null || target == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[AuditProbe] need whitlocktech + seed_010 chars; seed the world first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] genuine broadcast via [bcast ...");
|
||||||
|
CommandSystem.Handle(from, CommandSystem.Prefix + "bcast in-game audit probe");
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] simulating a resolved ban log line ...");
|
||||||
|
CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
|
||||||
|
from.AccessLevel, CommandLogging.Format(from), "banning", CommandLogging.Format(target));
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] simulating a resolved kick log line ...");
|
||||||
|
CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
|
||||||
|
from.AccessLevel, CommandLogging.Format(from), "kicking", CommandLogging.Format(target));
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] a non-moderation line (should be ignored) ...");
|
||||||
|
CommandLogging.WriteLine(from, "{0} {1} used command '{2}'",
|
||||||
|
from.AccessLevel, CommandLogging.Format(from), "Go 1 1 0");
|
||||||
|
|
||||||
|
Console.WriteLine("[AuditProbe] done");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[AuditProbe] FAILED: " + ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user