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; } } } }