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