using System; using Server.Accounting; using Server.Commands; using Server.Mobiles; namespace Server.Custom.Bridge { /// /// 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. /// 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"); } /// /// 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. /// 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); } } /// [bcast / [bc / [b โ€” a staff broadcast. Its message is the command args. 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); } } /// Pulls the account from a CommandLogging.Format rendering's trailing "('account')". 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)); } /// /// 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. /// 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()); } } }