Merge pull request 'feat(bridge): protocol 12 — BetterChat titles and styles, popups (phase 17)' (#12) from feat/phase-17-integrations into edge

Reviewed-on: #12
This commit is contained in:
2026-09-25 23:31:02 +00:00
2 changed files with 798 additions and 10 deletions

View File

@@ -26,12 +26,13 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
# Current: 10 — the transport, the read path, identity, the permission mirror,
# Current: 12 — the transport, the read path, identity, the permission mirror,
# configuration from the site, first-party clans, a raid frame that names who
# lives there, the event leases, the world verbs events place and give back, and
# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16),
# and the map: its picture and everything that moves on it (PROTOCOL.md §17).
protocol = 11
# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the
# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18).
protocol = 12
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
@@ -54,5 +55,9 @@ min_oxide_version = "2.0.7585"
# The uMod **Clans** plugin is deliberately NOT listed (docs/modules/rust/PLAN.md
# D47). Teams come from Rust's own clan system, which that plugin never touches,
# so requiring it would split a server's players across two unrelated clan
# systems. It belongs to phase 17's optional tier.
requires_plugins = ["Kits", "PopupNotifications", "ZoneManager"]
# systems. Phase 17 dropped the adapter it was meant to get (D134).
#
# **PopupNotifications is not listed either** (D141). It is optional: a popup
# asked of a server without it is refused with a reason, and chat is the default.
# BetterChat is optional in the same way — titles and group styles wait for it.
requires_plugins = ["Kits", "ZoneManager"]

View File

@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
/// </summary>
private const int ProtocolVersion = 11;
private const int ProtocolVersion = 12;
/// <summary>
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -947,6 +947,12 @@ namespace Oxide.Plugins
HandleChatSay(cmd, reqId);
break;
case "titles.set":
// Protocol 12. The chat titles players have earned, as one whole set that
// replaces the last (PLAN.md §33).
HandleTitlesSet(cmd, reqId);
break;
case "map.info":
// Protocol 11 (PLAN.md §30). What this map is and where its picture comes from.
HandleMapInfo(reqId);
@@ -1130,6 +1136,10 @@ namespace Oxide.Plugins
// A server that has never saved has no created time. Absent, not zero.
}
// Protocol 12: which of the optional mods this server has, so Admin → Rust can say why
// a title or a popup is not showing before anybody goes looking (§33.2).
status["integrations"] = IntegrationsBody();
return status;
}
@@ -1362,6 +1372,12 @@ namespace Oxide.Plugins
// The map's sets are seeded here for the same reason: a hotload hears no spawns.
SeedMapSets();
// The titles this plugin last held, if they are this wipe's (D144), and then the getter
// handed to BetterChat, if it is here. When it loads later, or reloads and forgets us,
// OnPluginLoaded does this again (§33.1).
LoadTitles();
RegisterTitles("load");
// Started here rather than in Init: this hook fires both at the end of server startup
// and immediately on a hotload, so it is the one place that runs exactly once per
// load with the world available either way.
@@ -2865,7 +2881,13 @@ namespace Oxide.Plugins
RetireGrant,
RetireGroup,
ScanPermission,
ScanGroup
ScanGroup,
/// <summary>Protocol 12: a group's BetterChat style (D138).</summary>
EnsureChatStyle,
/// <summary>Protocol 12: a styled group's BetterChat group, gone from the site (D139).</summary>
RetireChatGroup
}
private struct PermOp
@@ -2950,6 +2972,42 @@ namespace Oxide.Plugins
public int CreditsWithdrawn;
public bool CreditsWaiting;
public readonly List<string> CreditsUnresolved = new List<string>();
/// <summary>
/// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
/// was not, no style op is compiled and the report says <c>loaded: false</c> and
/// nothing else: the permissions sync exactly as they did before.
/// </summary>
public bool ChatLoaded;
public string ChatVersion;
/// <summary>The fields the site wants on each styled group.</summary>
public readonly Dictionary<string, List<ChatFieldWant>> ChatStyles =
new Dictionary<string, List<ChatFieldWant>>();
public int ChatApplied;
public int ChatSaved;
public int ChatSame;
public readonly List<object> ChatDrift = new List<object>();
public readonly List<object> ChatRemoved = new List<object>();
public readonly List<object> ChatFailed = new List<object>();
}
/// <summary>
/// One BetterChat field the site wants on a group (protocol 12): the value, and the
/// value this site last pushed there.
/// </summary>
private sealed class ChatFieldWant
{
public string Field;
public string Value;
/// <summary>
/// What the site last pushed, or null for a field it has never pushed. A game value
/// that is neither this nor the one wanted is somebody's hand edit (D138).
/// </summary>
public string Expect;
}
private PermSyncJob _permJob;
@@ -3062,6 +3120,10 @@ namespace Oxide.Plugins
SetId = StringField(cmd, "setId")
};
Plugin betterChat = BetterChatPlugin();
job.ChatLoaded = betterChat != null;
job.ChatVersion = betterChat != null ? betterChat.Version.ToString() : null;
// ── The desired set ────────────────────────────────────────────────
//
// Order matters and it is not cosmetic: a group has to exist before anything can be
@@ -3115,6 +3177,20 @@ namespace Oxide.Plugins
// A group with no members still has to be scanned: the interesting case is a
// member the site did not put there.
Bucket(job.DesiredMembers, name);
// Protocol 12: the group's BetterChat style. After the group's own ops, so the
// Oxide group BetterChat's members are read from exists first. With BetterChat
// absent nothing is compiled, and the report says why (§33.2).
if (job.ChatLoaded)
{
List<ChatFieldWant> style = ChatStyle(job, name, group);
if (style.Count > 0)
{
job.ChatStyles[name] = style;
job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureChatStyle, Subject = name });
}
}
}
foreach (Dictionary<string, object> grant in Rows(cmd, "grants"))
@@ -3169,6 +3245,12 @@ namespace Oxide.Plugins
case "group":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) });
break;
case "chat-group":
// Only with BetterChat here to ask. Otherwise the report says `loaded:
// false`, and the site keeps the retirement for a sync that can act on it.
if (job.ChatLoaded)
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireChatGroup, Subject = Lower(subject) });
break;
}
}
@@ -3448,6 +3530,14 @@ namespace Oxide.Plugins
case PermOpKind.ScanGroup:
ScanGroup(job, op.Subject);
break;
case PermOpKind.EnsureChatStyle:
ApplyChatStyle(job, op.Subject);
break;
case PermOpKind.RetireChatGroup:
RemoveChatGroup(job, op.Subject);
break;
}
}
@@ -3695,6 +3785,23 @@ namespace Oxide.Plugins
report["creditsWaiting"] = job.CreditsWaiting;
}
// Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this
// plugin predates styles" — the second has no `chat` at all.
var chat = new Dictionary<string, object> { { "loaded", job.ChatLoaded } };
if (job.ChatLoaded)
{
chat["version"] = job.ChatVersion;
chat["applied"] = job.ChatApplied;
chat["saved"] = job.ChatSaved;
chat["same"] = job.ChatSame;
chat["drift"] = job.ChatDrift;
chat["removed"] = job.ChatRemoved;
chat["failed"] = job.ChatFailed;
}
report["chat"] = chat;
if (!string.IsNullOrEmpty(job.SetId))
report["setId"] = job.SetId;
@@ -3705,7 +3812,9 @@ namespace Oxide.Plugins
$"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " +
$"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " +
$"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " +
$"pending={job.Pending.Count} notLanded={job.NotLanded.Count} at={DateTime.UtcNow:HH:mm:ss}Z";
$"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " +
$"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " +
$"at={DateTime.UtcNow:HH:mm:ss}Z";
EmitFrame(report);
}
@@ -3788,6 +3897,11 @@ namespace Oxide.Plugins
if (plugin != null && plugin != this && plugin.Name == "ZoneManager")
NextTick(() => RecreateZones("ZoneManager reloaded"));
// BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has
// forgotten our getter. Registering again is the only way back (§33.1).
if (plugin != null && plugin != this && plugin.Name == "BetterChat")
NextTick(() => RegisterTitles("BetterChat loaded"));
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
@@ -3801,6 +3915,9 @@ namespace Oxide.Plugins
{
MarkHook("OnPluginUnloaded");
if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false;
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
@@ -8236,6 +8353,44 @@ namespace Oxide.Plugins
return;
}
// Protocol 12: where the line goes, and the voice it is said in (D140, D141). A caller
// from protocol 11 sends neither and is answered exactly as before.
string delivery = StringField(cmd, "delivery");
if (string.IsNullOrEmpty(delivery))
delivery = DeliveryChat;
if (delivery != DeliveryChat && delivery != DeliveryPopup)
{
EmitRewardError("chat.error", reqId, "malformed",
$"a line is delivered to chat or to a popup, not to '{delivery}'");
return;
}
string format = StringField(cmd, "format");
if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1))
{
EmitRewardError("chat.error", reqId, "malformed",
$"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters");
return;
}
Plugin popup = null;
if (delivery == DeliveryPopup)
{
popup = PopupPlugin();
// R3: a refusal with a reason, never a line silently said somewhere else.
if (popup == null)
{
EmitRewardError("chat.error", reqId, "popup-unavailable",
"PopupNotifications is not loaded on this server, so a popup cannot be shown — install it, or deliver this line to chat");
return;
}
}
var stale = new List<string>();
foreach (KeyValuePair<string, long> kv in _chatKeys)
@@ -8251,15 +8406,29 @@ namespace Oxide.Plugins
if (!repeat)
{
Server.Broadcast(message);
// A popup ignores the format: its panel is not a chat line. A null player is
// everybody connected, and a duration of 0 is the plugin's own default. Nothing
// queues for a player who is offline (§33.1).
string line = popup != null || format == null ? message : Voice(format, message);
if (popup != null)
popup.Call("CreatePopupNotification", line, null, 0f);
else
Server.Broadcast(line);
// Logged, because the game logs neither a broadcast nor a popup, and a line the
// website put in front of every player deserves a record on the host (§33.3).
Puts($"said ({delivery}): {line}");
_chatKeys[key] = now;
_lastRewardSummary = $"said a line ({message.Length} chars) to {BasePlayer.activePlayerList.Count} players";
_lastRewardSummary = $"said a line ({message.Length} chars, {delivery}{(format != null ? ", voiced" : "")}) to {BasePlayer.activePlayerList.Count} players";
}
Dictionary<string, object> reply = Frame("chat.ok", "reply");
reply["said"] = !repeat;
reply["repeat"] = repeat;
reply["players"] = BasePlayer.activePlayerList.Count;
reply["delivery"] = delivery;
if (reqId != null)
reply["reqId"] = reqId;
@@ -8267,6 +8436,620 @@ namespace Oxide.Plugins
EmitFrame(reply);
}
// ── The optional mods (protocol 12) ───────────────────────────────────────
//
// BetterChat and PopupNotifications are optional (PLAN.md §33, D141). Each is looked up at
// the moment it is needed, never held from our load, because an operator can load or
// unload either at any time; and every call into one that is missing answers with a
// reason instead of doing nothing quietly (R3).
private const string DeliveryChat = "chat";
private const string DeliveryPopup = "popup";
/// <summary>The most titles one set carries. The site has its own, lower, limit.</summary>
private const int TitlesMax = 500;
/// <summary>
/// The longest one player's title string may be. The site caps a title's text at 24
/// characters (§33.4); this bounds what a player with every title, in markup, can hold.
/// </summary>
private const int TitleTextMax = 1024;
/// <summary>The longest voice format a chat line may carry (§33.2).</summary>
private const int ChatFormatMax = 512;
/// <summary>
/// The chat titles players have earned on this server, by Steam id, already in
/// BetterChat's markup. The website composes them from the wipe's standings and pushes
/// the whole set (<c>titles.set</c>).
///
/// <para>
/// <b>Kept on disk as well (D144).</b> The site re-sends on a new boot id or wipe id, and
/// a hot reload of this plugin — an update, an <c>oxide.reload</c> — is neither: the boot
/// id names the server process. Without the file a reload empties every title until the
/// standings next move. A file from another wipe is not loaded, because titles rank the
/// wipe they were earned on.
/// </para>
///
/// <para>
/// <b>Swapped, never edited.</b> BetterChat reads this through <see cref="TitleFor"/> on
/// every chat line, and another plugin can make it do so from its own thread through
/// BetterChat's API. A dictionary written while it is read can throw or answer wrong; a
/// reference replaced whole is read either before the swap or after it.
/// </para>
/// </summary>
private volatile Dictionary<ulong, string> _titles = new Dictionary<ulong, string>();
/// <summary>The getter BetterChat holds, kept so every registration hands it the same delegate.</summary>
private Func<Oxide.Core.Libraries.Covalence.IPlayer, string> _titleGetter;
/// <summary>
/// Whether the BetterChat loaded right now holds our getter. Cleared when BetterChat
/// unloads: its registrations live on the instance, so a reloaded BetterChat has
/// forgotten us until <see cref="OnPluginLoaded"/> registers again (§33.1).
/// </summary>
private bool _titlesRegistered;
/// <summary>The last title set, for <c>rg.titles</c>. Diagnostics only.</summary>
private string _lastTitlesSummary = "none yet";
/// <summary>
/// The twelve fields of a BetterChat group (D138), by the name its API takes, with
/// BetterChat 5.2.15's default for each. <c>Title</c> is absent: its default is the
/// group's own name in brackets (<see cref="ChatDefault"/>).
/// </summary>
private static readonly Dictionary<string, string> ChatFieldDefaults =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "Priority", "0" },
{ "Title", null },
{ "TitleColor", "#55aaff" },
{ "TitleSize", "15" },
{ "TitleHidden", "false" },
{ "TitleHiddenIfNotPrimary", "false" },
{ "UsernameColor", "#55aaff" },
{ "UsernameSize", "15" },
{ "MessageColor", "white" },
{ "MessageSize", "15" },
{ "ChatFormat", "{Title} {Username}: {Message}" },
{ "ConsoleFormat", "{Title} {Username}: {Message}" }
};
private Plugin BetterChatPlugin()
{
return plugins.Find("BetterChat");
}
private Plugin PopupPlugin()
{
return plugins.Find("PopupNotifications");
}
/// <summary>
/// BetterChat's third-party title getter. It runs <b>on the chat path</b>, once per line
/// and again whenever another plugin asks BetterChat to format one, so it does one
/// lookup and nothing else: no socket, no file, no allocation (R15). A player with no
/// title gets null, which BetterChat skips.
/// </summary>
private string TitleFor(Oxide.Core.Libraries.Covalence.IPlayer player)
{
if (player == null)
return null;
ulong id;
string title;
return ulong.TryParse(player.Id, out id) && _titles.TryGetValue(id, out title) ? title : null;
}
/// <summary>Main thread. Hands BetterChat our title getter, if BetterChat is here.</summary>
private void RegisterTitles(string why)
{
Plugin betterChat = BetterChatPlugin();
if (betterChat == null)
{
_titlesRegistered = false;
return;
}
if (_titleGetter == null)
_titleGetter = TitleFor;
// Void, so the answer is null either way. `rg.titles <steamId>` is how a rig proves
// BetterChat renders what this handed it.
betterChat.Call("API_RegisterThirdPartyTitle", this, _titleGetter);
_titlesRegistered = true;
if (why != "load")
Puts($"titles: registered with BetterChat again ({why}), holding {_titles.Count}");
}
/// <summary>
/// Main thread. Replaces the titles this server holds with the set the site sent. A
/// malformed entry refuses the whole set rather than being skipped: the set is whole by
/// contract, and half of one is a set nobody sent.
/// </summary>
private void HandleTitlesSet(Dictionary<string, object> cmd, string reqId)
{
object raw;
var list = cmd.TryGetValue("titles", out raw) ? raw as Newtonsoft.Json.Linq.JArray : null;
if (list == null)
{
EmitTitlesError(reqId, "malformed", "a title set carries a titles array");
return;
}
if (list.Count > TitlesMax)
{
EmitTitlesError(reqId, "too-large", $"this server holds at most {TitlesMax} titles, not {list.Count}");
return;
}
var next = new Dictionary<ulong, string>();
foreach (Newtonsoft.Json.Linq.JToken item in list)
{
var row = item as Newtonsoft.Json.Linq.JObject;
string steamId = row != null ? JsonText(row["steamId"]) : null;
string text = row != null ? JsonText(row["text"]) : null;
ulong id;
if (!ulong.TryParse(steamId, out id) || string.IsNullOrEmpty(text) || text.Length > TitleTextMax)
{
EmitTitlesError(reqId, "malformed",
$"every title names a Steam id and carries text of at most {TitleTextMax} characters");
return;
}
next[id] = text;
}
_titles = next;
SaveTitles();
// A BetterChat that arrived without us hearing it load is picked up here, which also
// makes a restart of either plugin heal on the site's next push.
if (!_titlesRegistered && BetterChatPlugin() != null)
RegisterTitles("title set");
bool shown = _titlesRegistered && BetterChatPlugin() != null;
_lastTitlesSummary = $"{next.Count} titles, betterChat={shown}, at={DateTime.UtcNow:HH:mm:ss}Z";
Dictionary<string, object> reply = Frame("titles.ok", "reply");
reply["count"] = next.Count;
reply["betterChat"] = shown;
string setId = StringField(cmd, "setId");
if (!string.IsNullOrEmpty(setId))
reply["setId"] = setId;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>What <c>titles.json</c> holds: the set, and the wipe it was pushed on.</summary>
private sealed class TitleFile
{
public string WipeId;
public Dictionary<string, string> Titles = new Dictionary<string, string>();
}
private string TitlesPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "titles.json");
}
/// <summary>Main thread. Written on every set the site pushes — a set arrives only when it changed.</summary>
private void SaveTitles()
{
var file = new TitleFile { WipeId = _wipeId };
foreach (KeyValuePair<ulong, string> kv in _titles)
file.Titles[kv.Key.ToString()] = kv.Value;
WriteDataFile(TitlesPath(), file, "titles");
}
/// <summary>
/// Main thread, from <c>OnServerInitialized</c> — the first moment the wipe id is real.
/// A file from another wipe, or with no wipe, is left unread: the site sends this wipe's.
/// </summary>
private void LoadTitles()
{
try
{
string path = TitlesPath();
if (!File.Exists(path))
return;
var file = JsonConvert.DeserializeObject<TitleFile>(File.ReadAllText(path));
if (file == null || file.Titles == null || string.IsNullOrEmpty(_wipeId) || file.WipeId != _wipeId)
return;
var loaded = new Dictionary<ulong, string>();
foreach (KeyValuePair<string, string> kv in file.Titles)
{
ulong id;
if (ulong.TryParse(kv.Key, out id) && !string.IsNullOrEmpty(kv.Value))
loaded[id] = kv.Value;
}
_titles = loaded;
_lastTitlesSummary = $"{loaded.Count} titles from disk, at={DateTime.UtcNow:HH:mm:ss}Z";
}
catch (Exception ex)
{
Puts($"could not read the titles: {Flatten(ex.Message)}");
}
}
private void EmitTitlesError(string reqId, string reason, string message)
{
Dictionary<string, object> frame = Frame("titles.error", "reply");
frame["reason"] = reason;
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
_lastTitlesSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
/// <summary>A JSON string or number as text, or null for anything else.</summary>
private static string JsonText(Newtonsoft.Json.Linq.JToken token)
{
if (token == null)
return null;
switch (token.Type)
{
case Newtonsoft.Json.Linq.JTokenType.String:
return (string)token;
case Newtonsoft.Json.Linq.JTokenType.Integer:
return token.ToString();
case Newtonsoft.Json.Linq.JTokenType.Boolean:
return (bool)token ? "true" : "false";
default:
return null;
}
}
/// <summary>
/// A chat line in a voice (D140): the site's format with the words put into it, turned
/// into the game's rich text. Said by us, with no player as its sender, so it works
/// whether or not BetterChat is loaded.
/// </summary>
private string Voice(string format, string message)
{
// BetterChat's own rule for a player's words (its GetOutput), for the same reason: the
// line is ours to style and the words inside it are not.
if (message.Contains("[#") || message.Contains("[+"))
message = message.Replace("[", string.Empty).Replace("]", string.Empty);
return covalence.FormatText(format.Replace("{message}", message));
}
private static int Occurrences(string text, string needle)
{
int count = 0;
for (int at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal))
count++;
return count;
}
private Dictionary<string, object> IntegrationsBody()
{
return new Dictionary<string, object>
{
{ "betterChat", IntegrationBody(BetterChatPlugin()) },
{ "popupNotifications", IntegrationBody(PopupPlugin()) }
};
}
/// <summary>A version only for a plugin that is loaded. Absent, not empty.</summary>
private static Dictionary<string, object> IntegrationBody(Plugin plugin)
{
var body = new Dictionary<string, object> { { "loaded", plugin != null } };
if (plugin != null)
body["version"] = plugin.Version.ToString();
return body;
}
// ---- group styles (D138, D139, D143) ----
/// <summary>
/// The BetterChat fields a sync asks for on one group. A field this build does not
/// know, or a value that is not a string, number or boolean, is reported failed and
/// left out rather than guessed at.
/// </summary>
private List<ChatFieldWant> ChatStyle(PermSyncJob job, string group, Dictionary<string, object> row)
{
var style = new List<ChatFieldWant>();
object raw;
if (!row.TryGetValue("chat", out raw))
return style;
var fields = raw as Newtonsoft.Json.Linq.JObject;
if (fields == null)
return style;
foreach (Newtonsoft.Json.Linq.JProperty field in fields.Properties())
{
var want = field.Value as Newtonsoft.Json.Linq.JObject;
string value = want != null ? JsonText(want["value"]) : null;
if (!ChatFieldDefaults.ContainsKey(field.Name) || value == null)
{
job.ChatFailed.Add(new Dictionary<string, object>
{
{ "group", group },
{ "field", field.Name },
{ "reason", "malformed" }
});
continue;
}
style.Add(new ChatFieldWant
{
Field = field.Name,
Value = value,
Expect = JsonText(want["expect"])
});
}
return style;
}
/// <summary>
/// Main thread. Puts the site's style on one BetterChat group, a field at a time, and
/// leaves alone any field somebody changed by hand.
///
/// <para>
/// A field is written when the game still holds what the site last pushed there, or
/// holds BetterChat's default and the site has never pushed it. Anything else is a hand
/// edit: it is not written, and it is reported with the game's value (D138, R2). A group
/// this sync had to create is written whole, because nobody can have edited it yet.
/// </para>
///
/// <para>
/// <b>The save is the part the API does not do.</b> <c>API_SetGroupField</c> changes a
/// field in memory and never writes BetterChat's file, so on its next reload the style
/// would revert and read as drift. BetterChat's own <c>chat group set</c> does write
/// it, and writes every group, so it is run once per group touched with a value that
/// has not changed (D143).
/// </para>
/// </summary>
private void ApplyChatStyle(PermSyncJob job, string group)
{
Plugin betterChat = BetterChatPlugin();
if (betterChat == null)
{
ChatFailure(job, group, null, "betterchat-unloaded");
return;
}
bool created = false;
if (!ChatGroupExists(betterChat, group))
{
// Saves on its own.
betterChat.Call("API_AddGroup", group);
if (!ChatGroupExists(betterChat, group))
{
ChatFailure(job, group, null, "not-created");
return;
}
created = true;
}
Dictionary<string, object> game = ChatGroupFields(betterChat, group);
bool touched = false;
foreach (ChatFieldWant want in job.ChatStyles[group])
{
string now = ChatFieldValue(game, want.Field);
if (now == want.Value)
{
job.ChatSame++;
continue;
}
bool ours = created || now == want.Expect || (want.Expect == null && now == ChatDefault(group, want.Field));
if (!ours)
{
job.ChatDrift.Add(new Dictionary<string, object>
{
{ "group", group },
{ "field", want.Field },
{ "game", now }
});
continue;
}
// The typed answer is why each field goes through the API (D143): `Success`,
// `InvalidField` or `InvalidValue`, as a BetterChat enum this plugin cannot name —
// so read by its name. Null is a group that is not there, or a value BetterChat's
// parser threw on without catching (a number too big for an int).
object result = betterChat.Call("API_SetGroupField", group, want.Field, want.Value);
string answer = result != null ? result.ToString() : null;
if (answer != "Success")
{
ChatFailure(job, group, want.Field, answer ?? "no-answer");
continue;
}
job.ChatApplied++;
touched = true;
}
if (!touched)
return;
game = ChatGroupFields(betterChat, group);
string priority = ChatFieldValue(game, "Priority");
ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "set", group, "Priority", priority);
// Read back rather than trusted: the command has no answer a plugin can read, and a
// console path that did something else would otherwise be counted as a save.
if (ChatFieldValue(ChatGroupFields(betterChat, group), "Priority") == priority)
job.ChatSaved++;
else
ChatFailure(job, group, "Priority", "not-saved");
}
/// <summary>
/// Main thread. Removes a BetterChat group the site no longer styles (D139). There is
/// no API for it, only BetterChat's own <c>chat group remove</c>, run as the server
/// console. A group already gone is removed: the retirement is idempotent.
/// </summary>
private void RemoveChatGroup(PermSyncJob job, string group)
{
Plugin betterChat = BetterChatPlugin();
if (betterChat == null)
{
ChatFailure(job, group, null, "betterchat-unloaded");
return;
}
if (ChatGroupExists(betterChat, group))
ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "remove", group);
if (ChatGroupExists(betterChat, group))
ChatFailure(job, group, null, "still-present");
else
job.ChatRemoved.Add(group);
}
private static void ChatFailure(PermSyncJob job, string group, string field, string reason)
{
var row = new Dictionary<string, object> { { "group", group }, { "reason", reason } };
if (field != null)
row["field"] = field;
job.ChatFailed.Add(row);
}
private static bool ChatGroupExists(Plugin betterChat, string group)
{
object exists = betterChat.Call("API_GroupExists", group);
return exists is bool && (bool)exists;
}
private static Dictionary<string, object> ChatGroupFields(Plugin betterChat, string group)
{
return betterChat.Call("API_GetGroupFields", group) as Dictionary<string, object>
?? new Dictionary<string, object>();
}
/// <summary>
/// A field's value as the text the site compares: <c>true</c>/<c>false</c> for a
/// boolean, never C#'s <c>True</c>, and an invariant number.
/// </summary>
private static string ChatFieldValue(Dictionary<string, object> fields, string field)
{
object value = null;
foreach (KeyValuePair<string, object> kv in fields)
{
if (string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase))
{
value = kv.Value;
break;
}
}
if (value == null)
return null;
if (value is bool)
return (bool)value ? "true" : "false";
if (value is IFormattable)
return ((IFormattable)value).ToString(null, System.Globalization.CultureInfo.InvariantCulture);
return value.ToString();
}
/// <summary>
/// BetterChat's default for a field on a new group. A group's title defaults to its own
/// name in brackets, except <c>default</c>'s, which is <c>[Player]</c>.
/// </summary>
private static string ChatDefault(string group, string field)
{
if (string.Equals(field, "Title", StringComparison.OrdinalIgnoreCase))
return group == "default" ? "[Player]" : "[" + group + "]";
string value;
return ChatFieldDefaults.TryGetValue(field, out value) ? value : null;
}
/// <summary>
/// The titles this server holds, from the server console. Given a Steam id it also
/// prints what BetterChat would render for that player — which is how a rig proves a
/// title without a person typing in chat (§33.3).
/// </summary>
[ConsoleCommand("rg.titles")]
private void CmdTitles(ConsoleSystem.Arg arg)
{
Plugin betterChat = BetterChatPlugin();
var lines = new List<string>
{
$"protocol={ProtocolVersion} titles={_titles.Count} " +
$"betterChat={(betterChat != null ? betterChat.Version.ToString() : "absent")} " +
$"registered={(_titlesRegistered && betterChat != null)}",
$"last set: {_lastTitlesSummary}"
};
// `GetString`, not `Args[0]`: current Rust hands a console command StringViews.
string steamId = arg.GetString(0, null);
if (!string.IsNullOrEmpty(steamId))
{
ulong id;
string held;
lines.Add(ulong.TryParse(steamId, out id) && _titles.TryGetValue(id, out held)
? $"held: {held}"
: "held: (none)");
Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId);
if (betterChat == null)
lines.Add("rendered: (BetterChat is not loaded)");
else if (known == null)
lines.Add("rendered: (this server has never seen that player)");
else
lines.Add("rendered: " + (betterChat.Call("API_GetFormattedMessage", known, "(rg.titles)", false) as string ?? "(no answer)"));
}
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
}
/// <summary>What events are counting and what rewards are held, from the server console.</summary>
[ConsoleCommand("rg.rewards")]
private void CmdRewards(ConsoleSystem.Arg arg)