namespace Aeshnidae.DiscordRelay; /// /// One Discord destination: which webhook to POST to, and how this mod should /// identify itself when it does. /// public class ChannelSettings { /// Relay this in-game channel at all. Turning it off leaves the URL in place. public bool Enabled { get; set; } = true; /// /// Full https://discord.com/api/webhooks/<id>/<token> URL. Empty means /// "not wired up yet" and the channel is skipped silently. /// /// Treat this as a credential - it is unauthenticated write access to that Discord /// channel for anyone holding it. It only ever lives in Settings.json in the /// deployed mod folder, which Mods\.gitignore excludes, so it stays out of git. /// Rotate it in Discord (Edit Channel - Integrations) if it leaks. /// public string WebhookUrl { get; set; } = ""; /// /// Overrides the webhook's own display name on every post. Empty leaves whatever /// the webhook is called in Discord, which is usually what you want. /// Discord rejects names containing "discord" and anything over 80 characters. /// public string Username { get; set; } = ""; } /// /// Per-mod configuration, read from Settings.json in the deployed mod folder /// (next to the dll, not in source). Written with defaults on first run so it is /// easy to find, and openable in-game with "/mod settings Aeshnidae.DiscordRelay". /// public class Settings { public const string FileName = "Settings.json"; /// Master switch. Off means nothing is queued and nothing is posted. public bool Enabled { get; set; } = true; /// /// Keyed by ACE's name. Anything not listed here is not /// relayed, so adding "LFG" or "Roleplay" with a webhook is all it takes to widen /// the bridge - no code change. /// public Dictionary Channels { get; set; } = new(StringComparer.OrdinalIgnoreCase) { ["General"] = new(), ["Trade"] = new(), }; /// /// How long queued lines are allowed to accumulate before a post goes out. /// This is the rate limiter: Discord allows roughly 5 requests per 2 seconds per /// webhook, and batching a busy Trade channel into one post every couple of /// seconds stays comfortably clear of that no matter how loud the server gets. /// public double BatchSeconds { get; set; } = 2.0; /// /// Backstop for Discord being unreachable. Once a channel's queue is this deep, /// new lines are dropped rather than buffered forever - a chat bridge is not worth /// an unbounded allocation on a live server. /// public int MaxQueuedPerChannel { get; set; } = 200; /// Longest relayed message body, in characters. Longer ones are truncated. public int MaxMessageLength { get; set; } = 400; /// /// Line template. "{name}" is the speaking character, "{message}" the (escaped, /// truncated) text, "{channel}" the ChatType name. /// public string LineFormat { get; set; } = "**{name}**: {message}"; /// Character names never relayed. Case-insensitive; useful for staff bots. public string[] IgnoredPlayers { get; set; } = Array.Empty(); /// Log every relayed line to the server log as well. Noisy; off by default. public bool LogRelayed { get; set; } = false; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; /// /// The destination for a chat type, or null if this channel is not bridged. /// Society sub-channels collapse onto "Society" so one entry covers all three. /// public ChannelSettings? For(ChatType chatType) { var key = Key(chatType); if (!Channels.TryGetValue(key, out var channel) || channel is null) return null; return channel.Enabled && !string.IsNullOrWhiteSpace(channel.WebhookUrl) ? channel : null; } /// Settings key for a chat type: the enum name, with the three society variants merged. public static string Key(ChatType chatType) => chatType switch { ChatType.SocietyCelHan or ChatType.SocietyEldWeb or ChatType.SocietyRadBlo => nameof(ChatType.Society), _ => chatType.ToString(), }; public bool IsIgnored(string? name) => !string.IsNullOrEmpty(name) && IgnoredPlayers.Any(p => string.Equals(p, name, StringComparison.OrdinalIgnoreCase)); public static Settings Load(string modPath) { var path = Path.Combine(modPath, FileName); try { if (File.Exists(path)) { var loaded = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings(); // A dictionary deserialised from JSON gets the default comparer, so // "general" would miss the "General" entry. Rebuild it case-insensitive. loaded.Channels = new Dictionary(loaded.Channels ?? new(), StringComparer.OrdinalIgnoreCase); loaded.IgnoredPlayers ??= Array.Empty(); return loaded; } var defaults = new Settings(); defaults.Save(modPath); ModManager.Log($"[{Mod.Name}] wrote default settings to {path} - add your webhook URLs there, then /discordrelay-reload"); return defaults; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn); return new Settings(); } } public void Save(string modPath) { try { File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error); } } }