diff --git a/docs/ADMIN_CONTROLS.md b/docs/ADMIN_CONTROLS.md index 30af617..08345e0 100644 --- a/docs/ADMIN_CONTROLS.md +++ b/docs/ADMIN_CONTROLS.md @@ -140,7 +140,9 @@ It turns "a staff member must be logged into the game to see the queue" into "th > - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event. > - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored. > -> **Phase 1 + the bidirectional-audit slice are complete.** Remaining is downstream (website UI + moderation log) and the later Phase 2 (help-page queue) / Phase 3 work. +> **Phase 2 — help-page queue: built and live-verified.** `BridgePages.cs` polls the queue (`PageSweepSeconds`, default 5s) → `page.new`/`page.updated`/`page.closed`; inbound `pages.snapshot`/`page.respond`/`page.close`; sidecar `GET /pages` + `POST /pages/{id}/respond|close`; `INTEGRATION.md` §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both `page.new` emits, respond, close (→ page removed), `page.closed` emit, and 404 on an unknown page. +> +> **Phase 1 + bidirectional audit + Phase 2 are complete.** Remaining: downstream website UI (moderation log + support-queue view), then Phase 3 (mute/notes/teleport/save). **Wire in, in order:** diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 17d46db..3a37309 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -185,6 +185,15 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s |------|--------|-------| | `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. | +#### Help-page (support) queue +| kind | fields | notes | +|------|--------|-------| +| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). | +| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). | +| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). | + +The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close. + --- ## 5. REST — read queries @@ -351,6 +360,31 @@ Each applied action also emits an unsolicited **`admin.audit`** frame on the Web `origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation by staff in the game client surfaces the same way with `origin:"in-game"`. +### Help-page (support) queue + +Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own +roles, like the moderation endpoints above. + +``` +GET /pages # the open queue, newest state +POST /pages/{pageId}/respond { "message":"...", "close": false } +POST /pages/{pageId}/close +``` + +- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new` + event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live + with the `page.new` / `page.updated` / `page.closed` events. +- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if + they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass + `"close": true` to resolve the page in the same call. → **200** `page.ok`. +- **close** removes the page from the queue. → **200** `page.ok`. +- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**. + +```json +POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true } +→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true } +``` + ### History (from the sidecar's database) ``` diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index 7a60eeb..949273c 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -19,6 +19,11 @@ StatSweepSeconds=30 DecaySweepSeconds=60 EconomySweepSeconds=300 +# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this +# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a +# support queue; the full open queue is also available on demand via pages.snapshot. +PageSweepSeconds=5 + # Shown to a player when they run [link. The website page where they enter the code. LinkUrl=https://yoursite/link diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index b6fda4e..4456653 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -159,6 +159,7 @@ namespace Server.Custom.Bridge case "reload": BridgeConfig.Load(); BridgeSweeps.Rearm(); + BridgePages.Rearm(); e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); break; @@ -181,6 +182,7 @@ namespace Server.Custom.Bridge BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped, BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors); e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status()); break; } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index ece27ff..480ed47 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -17,6 +17,7 @@ namespace Server.Custom.Bridge public static int StatSweepSeconds { get; private set; } public static int DecaySweepSeconds { get; private set; } public static int EconomySweepSeconds { get; private set; } + public static int PageSweepSeconds { get; private set; } public static string LinkUrl { get; private set; } @@ -50,6 +51,9 @@ namespace Server.Custom.Bridge StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30); DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60); EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300); + PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5); + if (PageSweepSeconds < 1) + PageSweepSeconds = 1; LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link"); diff --git a/overlay/Scripts/Custom/Bridge/BridgePages.cs b/overlay/Scripts/Custom/Bridge/BridgePages.cs new file mode 100644 index 0000000..ff3a541 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgePages.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Text; + +using Server.Accounting; +using Server.Engines.Help; + +namespace Server.Custom.Bridge +{ + /// + /// The in-game help-page (support ticket) queue, surfaced to the website. + /// + /// A player who uses the Help button creates a — sender, message, + /// type, location, and (once a staffer claims it) a handler. The queue lives in memory with + /// no EventSink, so — like the sweeps in — it is polled and diffed: + /// a page appearing emits page.new, one leaving emits page.closed, and a + /// handled-state change emits page.updated. The whole open queue is also available on + /// demand via the pages.snapshot request (the backfill a dashboard uses on connect). + /// + /// A page is keyed by its sender's serial: the queue enforces one page per sender + /// (PageQueue.Contains), so the sender serial is a stable page id. + /// + /// Inbound page.respond delivers a message to the player exactly as an in-game staff + /// response does (online: a gump now; offline: queued for next login), optionally closing the + /// page; page.close just removes it. Both run on the Core thread. + /// + public static class BridgePages + { + private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static Timer _timer; + private static long _sweeps, _new, _closed, _updated; + + private struct Seen + { + public long SentMs; + public bool Handled; + } + + // sender serial -> last-seen page identity. Core-thread only. + private static readonly Dictionary _seen = new Dictionary(); + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("pages.snapshot", OnSnapshot); + BridgeBoot.RegisterHandler("page.respond", OnRespond); + BridgeBoot.RegisterHandler("page.close", OnClose); + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + Baseline(); + Rearm(); + } + + /// Stops and recreates the poll timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + if (_timer != null) + { + _timer.Stop(); + _timer = null; + } + + var iv = TimeSpan.FromSeconds(BridgeConfig.PageSweepSeconds); + _timer = Timer.DelayCall(iv, iv, Sweep); + } + + public static string Status() + { + return String.Format( + "pages(sweeps={0} new={1} closed={2} updated={3} open={4})", + _sweeps, _new, _closed, _updated, _seen.Count); + } + + /// Seeds _seen from the current queue without emitting, so a restart/reload does not + /// re-announce pages already open. + private static void Baseline() + { + _seen.Clear(); + + foreach (PageEntry e in PageQueue.List) + { + if (e == null || e.Sender == null) + continue; + + _seen[e.Sender.Serial.Value] = new Seen { SentMs = ToMs(e.Sent), Handled = e.Handler != null }; + } + } + + // ---- poll ---- + + private static void Sweep() + { + try + { + _sweeps++; + + var cur = new Dictionary(); + + foreach (PageEntry e in PageQueue.List) + { + if (e == null || e.Sender == null) + continue; + + cur[e.Sender.Serial.Value] = e; + } + + // Closed: keys in _seen no longer present. + if (_seen.Count > 0) + { + List gone = null; + + foreach (var kv in _seen) + { + if (!cur.ContainsKey(kv.Key)) + { + if (gone == null) + gone = new List(); + gone.Add(kv.Key); + } + } + + if (gone != null) + { + foreach (var id in gone) + { + EmitClosed(id); + _seen.Remove(id); + } + } + } + + // New / replaced / handled-state changed. + foreach (var kv in cur) + { + var e = kv.Value; + long sentMs = ToMs(e.Sent); + bool handled = e.Handler != null; + + Seen prev; + if (!_seen.TryGetValue(kv.Key, out prev)) + { + EmitNew(e); + } + else if (prev.SentMs != sentMs) + { + // Same sender, different page (they cancelled and re-paged within a tick). + EmitClosed(kv.Key); + EmitNew(e); + } + else if (prev.Handled != handled) + { + EmitUpdated(e); + } + + _seen[kv.Key] = new Seen { SentMs = sentMs, Handled = handled }; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] page sweep threw: {0}", ex.Message); + } + } + + // ---- outbound ---- + + private static void EmitNew(PageEntry e) + { + _new++; + var sb = BridgeJson.Begin("page.new").Str("pageId", PageId(e)); + AppendPageTail(sb, e); + BridgeLink.Emit(sb.End()); + } + + private static void EmitUpdated(PageEntry e) + { + _updated++; + var sb = BridgeJson.Begin("page.updated").Str("pageId", PageId(e)); + AppendPageTail(sb, e); + BridgeLink.Emit(sb.End()); + } + + private static void EmitClosed(int serial) + { + _closed++; + BridgeLink.Emit(BridgeJson.Begin("page.closed") + .Str("pageId", "0x" + serial.ToString("X")) + .End()); + } + + private static void OnSnapshot(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + var sb = BridgeJson.Begin("pages.list"); + if (reqId != null) + sb.Str("reqId", reqId); + + sb.Append(",\"pages\":["); + + bool first = true; + foreach (PageEntry e in PageQueue.List) + { + if (e == null || e.Sender == null) + continue; + + if (!first) + sb.Append(','); + first = false; + + sb.Append("{\"pageId\":\"0x").Append(e.Sender.Serial.Value.ToString("X")).Append('"'); + AppendPageTail(sb, e); + sb.Append('}'); + } + + sb.Append(']'); + BridgeLink.Emit(sb.End()); + } + + /// Appends every page field except the opening pageId, each comma-prefixed, so it + /// works both after Begin(...) (events) and after a manual `{"pageId":..` (snapshot array). + private static void AppendPageTail(StringBuilder sb, PageEntry e) + { + sb.Append(",\"sender\":"); + WriteSender(sb, e.Sender); + + sb.Str("type", e.Type.ToString()); + sb.Str("message", e.Message ?? ""); + sb.Str("map", e.PageMap == null ? null : e.PageMap.Name); + sb.Num("x", e.PageLocation.X); + sb.Num("y", e.PageLocation.Y); + sb.Num("z", e.PageLocation.Z); + sb.Num("sentMs", ToMs(e.Sent)); + sb.Bool("handled", e.Handler != null); + + if (e.Handler != null) + sb.Str("handler", e.Handler.Name); + } + + private static void WriteSender(StringBuilder sb, Mobile m) + { + if (m == null) + { + sb.Append("null"); + return; + } + + sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"'); + sb.Append(",\"name\":"); + BridgeJson.Escape(sb, m.Name ?? ""); + + var acct = m.Account as Account; + if (acct != null) + { + sb.Append(",\"acct\":"); + BridgeJson.Escape(sb, acct.Username); + + var webId = BridgeAccountLink.WebIdFor(acct); + if (webId != null) + { + sb.Append(",\"webId\":"); + BridgeJson.Escape(sb, webId); + } + } + + sb.Append('}'); + } + + // ---- inbound ---- + + /// page.respond {reqId, pageId, message, close?}. Delivers a staff response to the + /// player and optionally closes the page. + private static void OnRespond(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var pageId = BridgeJson.GetString(o, "pageId"); + var message = BridgeJson.GetString(o, "message"); + bool close = GetBool(o, "close"); + + if (String.IsNullOrEmpty(message)) + { + Err(reqId, "respond", pageId, "missing message"); + return; + } + + var e = Find(pageId); + if (e == null) + { + Err(reqId, "respond", pageId, "unknown page"); + return; + } + + try + { + // Same delivery as an in-game staff response: a null handler shows as "Staff". + // ResponseEntry queues for an offline sender; SendGump delivers now if online. + var re = new ResponseEntry(e.Sender, null, message); + re.SendGump(); + + if (close) + PageQueue.Remove(e); + + Ok(reqId, "respond", pageId, close); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] page.respond threw: {0}", ex.Message); + Err(reqId, "respond", pageId, "internal error"); + } + } + + /// page.close {reqId, pageId}. Removes the page from the queue. + private static void OnClose(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var pageId = BridgeJson.GetString(o, "pageId"); + + var e = Find(pageId); + if (e == null) + { + Err(reqId, "close", pageId, "unknown page"); + return; + } + + try + { + PageQueue.Remove(e); + Ok(reqId, "close", pageId, true); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] page.close threw: {0}", ex.Message); + Err(reqId, "close", pageId, "internal error"); + } + } + + private static void Ok(string reqId, string action, string pageId, bool closed) + { + var sb = BridgeJson.Begin("page.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action); + if (pageId != null) sb.Str("pageId", pageId); + sb.Bool("closed", closed); + BridgeLink.Emit(sb.End()); + } + + private static void Err(string reqId, string action, string pageId, string reason) + { + var sb = BridgeJson.Begin("page.error"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action); + if (pageId != null) sb.Str("pageId", pageId); + sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + + // ---- helpers ---- + + private static PageEntry Find(string pageId) + { + int serial; + if (!TryParseSerial(pageId, out serial)) + return null; + + foreach (PageEntry e in PageQueue.List) + { + if (e != null && e.Sender != null && e.Sender.Serial.Value == serial) + return e; + } + + return null; + } + + private static string PageId(PageEntry e) + { + return "0x" + e.Sender.Serial.Value.ToString("X"); + } + + private static long ToMs(DateTime dt) + { + return (long)(dt.ToUniversalTime() - Epoch).TotalMilliseconds; + } + + private static bool GetBool(Dictionary o, string key) + { + object v; + if (o != null && o.TryGetValue(key, out v) && v is bool) + return (bool)v; + return false; + } + + private static bool TryParseSerial(string s, out int value) + { + value = 0; + if (String.IsNullOrEmpty(s)) + return false; + + try + { + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + value = Convert.ToInt32(s.Substring(2), 16); + else + value = Convert.ToInt32(s, 10); + + return true; + } + catch + { + return false; + } + } + } +} diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 435f8d8..4f0c9dd 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -63,6 +63,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/admin/ban", post(admin_ban)) .route("/admin/unban", post(admin_unban)) .route("/admin/broadcast", post(admin_broadcast)) + // Help-page (support) queue: snapshot the open queue, respond to / close a page. + .route("/pages", get(pages_list)) + .route("/pages/:id/respond", post(page_respond)) + .route("/pages/:id/close", post(page_close)) // History, read from SQLite rather than the shard. .route("/history", get(history)) .route("/economy", get(economy)) @@ -330,6 +334,45 @@ async fn admin_broadcast(State(st): State, Json(body): Json) -> admin_call(&st, "admin.broadcast", body).await } +// ---- help-page queue handlers ---- + +/// The open help-page queue, correlated on reqId. Returns a pages.list. +async fn pages_list(State(st): State) -> impl IntoResponse { + let req_id = st.rpc.next_req_id(); + let cmd = json!({"kind": "pages.snapshot", "reqId": req_id}); + respond(st.rpc.call(&st.shard, cmd, &req_id).await) +} + +/// Body: {"message":"...","close":}. Delivers a staff response to the player. +async fn page_respond( + State(st): State, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let message = body.get("message").and_then(|m| m.as_str()).unwrap_or_default(); + if message.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "message is required"})), + ); + } + let close = body.get("close").and_then(|c| c.as_bool()).unwrap_or(false); + + let req_id = st.rpc.next_req_id(); + let cmd = json!({ + "kind": "page.respond", "reqId": req_id, + "pageId": id, "message": message, "close": close + }); + respond(st.rpc.call(&st.shard, cmd, &req_id).await) +} + +/// Removes a page from the queue. +async fn page_close(State(st): State, Path(id): Path) -> impl IntoResponse { + let req_id = st.rpc.next_req_id(); + let cmd = json!({"kind": "page.close", "reqId": req_id, "pageId": id}); + respond(st.rpc.call(&st.shard, cmd, &req_id).await) +} + // ---- query handlers ---- async fn char_by_slot( diff --git a/tools/scaffolding/BridgePageProbe.cs b/tools/scaffolding/BridgePageProbe.cs new file mode 100644 index 0000000..3e518d5 --- /dev/null +++ b/tools/scaffolding/BridgePageProbe.cs @@ -0,0 +1,61 @@ +using System; + +using Server.Accounting; +using Server.Engines.Help; + +namespace Server.Custom +{ + /// + /// Puts a couple of genuine PageEntry tickets into the help-page queue so BridgePages + /// (poll/stream + snapshot + respond/close) can be verified without a game client. + /// + /// The enqueue is real (PageQueue.Enqueue). The only accommodation for the missing client: + /// each entry's InternalTimer would remove the page on its first tick because the sender has + /// no NetState (PageQueue.cs:167 treats "no NetState" as a logout), so we call PageEntry.Stop + /// to keep the ticket in the queue for the test. Everything the bridge does — detect, snapshot, + /// respond, close — then operates on real queue entries. + /// + /// Test scaffolding. Never deployed. Gated behind Bridge.PageProbeOnStart. + /// + public static class BridgePageProbe + { + public static void Initialize() + { + if (Config.Get("Bridge.PageProbeOnStart", false)) + EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run); + } + + private static void Run() + { + try + { + Enqueue("seed_030", "My quest is stuck, please help.", PageType.Stuck); + Enqueue("seed_031", "Found a bug with a player vendor.", PageType.Bug); + Console.WriteLine("[PageProbe] done"); + } + catch (Exception ex) + { + Console.WriteLine("[PageProbe] FAILED: " + ex); + } + } + + private static void Enqueue(string account, string message, PageType type) + { + var acct = Accounting.Accounts.GetAccount(account) as Account; + var sender = acct == null ? null : acct[0]; + + if (sender == null) + { + Console.WriteLine("[PageProbe] {0} has no character in slot 0; seed the world first", account); + return; + } + + var entry = new PageEntry(sender, message, type); + PageQueue.Enqueue(entry); + entry.Stop(); // keep it in the queue despite the offline sender + + Console.WriteLine("[PageProbe] enqueued {0} page for {1} (0x{2:X})", + type, account, sender.Serial.Value); + } + } +}