Files
link/patches/BridgeModerationAudit.cs
Claude 8751151abc docs: move docs to RunicGateway/docs, repoint all references
Extracted docs/ (ADMIN_CONTROLS, INTEGRATION, PLAN, PROTOCOL_2, RESEARCH,
SHARD_PREREQS) into the central RunicGateway/docs repo under link/, with
full commit history preserved via git filter-repo.

The source cites these design docs by section throughout, so every in-repo
reference (C# + Rust comments, Bridge.cfg, and the READMEs) is repointed at
the new docs-repo URL. README references are rendered as markdown links; a
Documentation pointer section is added to the top-level README.

Docs repo: https://gitea.whitlocktech.com/RunicGateway/docs
2026-07-18 00:08:34 -05:00

134 lines
5.4 KiB
C#

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 https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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 &lt;target&gt; ('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 "&lt;level&gt; &lt;from&gt; ('acct') banning|kicking
/// &lt;target&gt; ('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());
}
}
}