Phase 2 of docs/ADMIN_CONTROLS.md: surface the in-game help-page queue to the
website.
- BridgePages.cs: the queue has no EventSink, so it is polled (PageSweepSeconds,
default 5s) and diffed, keyed by sender serial (one page per player) ->
page.new / page.updated / page.closed. Inbound pages.snapshot -> pages.list;
page.respond delivers a staff reply to the player (online: a gump now; offline:
queued for next login; shows as "Staff") and can close; page.close removes it.
- BridgeConfig/Bridge.cfg: PageSweepSeconds. BridgeBoot: reload re-arms the poll,
status reports it.
- sidecar/src/web.rs: GET /pages, POST /pages/{id}/respond, POST /pages/{id}/close.
- INTEGRATION.md: page events (§4) and endpoints (§6).
- tools/scaffolding/BridgePageProbe.cs: gated headless verification.
Verified live (probe-seeded tickets): snapshot returns the queue, the poll emits
page.new for both and page.closed on removal, respond -> 200, close removes the
page, unknown page -> 404.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
421 lines
14 KiB
C#
421 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
|
|
using Server.Accounting;
|
|
using Server.Engines.Help;
|
|
|
|
namespace Server.Custom.Bridge
|
|
{
|
|
/// <summary>
|
|
/// The in-game help-page (support ticket) queue, surfaced to the website.
|
|
///
|
|
/// A player who uses the Help button creates a <see cref="PageEntry"/> — 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 <see cref="BridgeSweeps"/> — it is polled and diffed:
|
|
/// a page appearing emits <c>page.new</c>, one leaving emits <c>page.closed</c>, and a
|
|
/// handled-state change emits <c>page.updated</c>. The whole open queue is also available on
|
|
/// demand via the <c>pages.snapshot</c> 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 <c>page.respond</c> 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; <c>page.close</c> just removes it. Both run on the Core thread.
|
|
/// </summary>
|
|
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<int, Seen> _seen = new Dictionary<int, Seen>();
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>Stops and recreates the poll timer from current config. Called by `[bridge reload`.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Seeds _seen from the current queue without emitting, so a restart/reload does not
|
|
/// re-announce pages already open.</summary>
|
|
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<int, PageEntry>();
|
|
|
|
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<int> gone = null;
|
|
|
|
foreach (var kv in _seen)
|
|
{
|
|
if (!cur.ContainsKey(kv.Key))
|
|
{
|
|
if (gone == null)
|
|
gone = new List<int>();
|
|
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<string, object> 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());
|
|
}
|
|
|
|
/// <summary>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).</summary>
|
|
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 ----
|
|
|
|
/// <summary>page.respond {reqId, pageId, message, close?}. Delivers a staff response to the
|
|
/// player and optionally closes the page.</summary>
|
|
private static void OnRespond(Dictionary<string, object> 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");
|
|
}
|
|
}
|
|
|
|
/// <summary>page.close {reqId, pageId}. Removes the page from the queue.</summary>
|
|
private static void OnClose(Dictionary<string, object> 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<string, object> 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;
|
|
}
|
|
}
|
|
}
|
|
}
|