C#:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Libraries;
using Oxide.Core.Plugins;
using Oxide.Game.Rust.Cui;
using UnityEngine;
namespace Oxide.Plugins {
[Info("NewsBroadcaster", "DEDA", "1.7.0")]
[Description("Clean, modern news broadcaster with notifications")]
public class NewsBroadcaster : RustPlugin {
#region Fields & Constants
[PluginReference]
private Plugin ImageLibrary, Notify, ServerRewards, Economics;
private const string LayerName = "NewsBroadcasterUI";
private const string NotificationLayer = "NewsNotificationUI";
private const string ConfirmLayer = "NewsConfirmUI";
private const string DataFile = "NewsBroadcaster_Data";
private const string ConfigBackupFile = "NewsBroadcaster_ConfigBackup";
private const string PermPrefix = "newsbroadcaster.";
private const string PermAdmin = PermPrefix + "admin";
private const string PermView = PermPrefix + "view";
private static readonly Regex CommandSplitRegex = new Regex(@"[\""].+?[\""]|[^ ]+", RegexOptions.Compiled);
private static readonly Regex LinkRegex = new Regex(@"(https?://|www\.)\S+", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex MultiSpaceRegex = new Regex(@"[ \t]{2,}", RegexOptions.Compiled);
private static readonly Regex BlankLinesRegex = new Regex(@"\n{3,}", RegexOptions.Compiled);
private static readonly Regex PermSuffixRegex = new Regex(@"^[a-z0-9_]{1,32}$", RegexOptions.Compiled);
private static readonly Regex FileNameRegex = new Regex(@"[^A-Za-z0-9_\-]", RegexOptions.Compiled);
private static readonly Regex RelativeTimeRegex = new Regex(@"^\+\s*(\d+)\s*([mhdw])$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private ConfigData config;
private StoredData storedData;
private List<Announcement> announcements = new List<Announcement>();
private Dictionary<ulong, Timer> autoCloseTimers = new Dictionary<ulong, Timer>();
private Dictionary<ulong, Timer> notificationTimers = new Dictionary<ulong, Timer>();
private HashSet<ulong> playersWithUiOpen = new HashSet<ulong>();
private Dictionary<ulong, Announcement> activeEditors = new Dictionary<ulong, Announcement>();
private Dictionary<ulong, string> activeEditorIds = new Dictionary<ulong, string>();
private Dictionary<ulong, ReadRewardState> readRewardTimers = new Dictionary<ulong, ReadRewardState>();
private Dictionary<ulong, HashSet<string>> adminSelections = new Dictionary<ulong, HashSet<string>>();
private Dictionary<ulong, ArchiveFilter> archiveFilters = new Dictionary<ulong, ArchiveFilter>();
// Permissions created on demand by per-announcement audience gating.
private readonly HashSet<string> registeredCustomPerms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Writes to the data file are debounced: bulk edits and incidental
// updates (likes, read marks, last-seen) set the flag, a timer flushes.
private bool dataDirty;
private Timer saveTimer;
private Timer scheduleTimer;
private static readonly string InvariantDateFormat = "yyyy-MM-dd HH:mm";
private const int MaxContentChars = 32768;
private const int MaxTitleChars = 120;
private const int MaxUrlChars = 512;
private const int BodyWrapCharacters = 64;
private const int DiscordEmbedDescriptionLimit = 4000;
private const int DiscordEmbedTitleLimit = 250;
private const float SaveDebounceSeconds = 20f;
private const float ScheduleTickSeconds = 30f;
#endregion
#region Data Structures
class StoredData {
public List<Announcement> Announcements = new List<Announcement>();
public Dictionary<ulong, long> LastSeenNews = new Dictionary<ulong, long>();
}
class Announcement {
public string Id;
public string Title;
public string ImageUrl;
public string Text;
public string Date;
public string Author;
public AnnouncementType Type;
public long Timestamp;
public bool Pinned;
// 0 = publish immediately. Otherwise the announcement stays hidden
// from players until this tick is reached, then broadcasts once.
public long PublishAt;
// 0 = never expires. Otherwise the announcement drops out of the
// player-facing archive once this tick passes (admins still see it).
public long ExpiresAt;
// Drafts are never broadcast and never visible to players.
public bool Draft;
// Set to mark this announcement as already broadcast, so the
// scheduler does not re-announce it after a restart.
public bool Broadcast;
// Empty = every player with newsbroadcaster.view. Otherwise a
// permission suffix: only holders of "newsbroadcaster.<suffix>" see it.
public string Audience = "";
public HashSet<ulong> LikedPlayers = new HashSet<ulong>();
public HashSet<ulong> ReadByPlayers = new HashSet<ulong>();
public HashSet<ulong> ReadRewardedPlayers = new HashSet<ulong>();
public HashSet<ulong> LikeRewardedPlayers = new HashSet<ulong>();
}
class ReadRewardState {
public string AnnId;
public Timer Timer;
}
// Per-player archive view state: which type, unread-only, and free-text search.
class ArchiveFilter {
public AnnouncementType? Type;
public bool UnreadOnly;
public string Search = "";
public bool IsDefault => Type == null && !UnreadOnly && string.IsNullOrEmpty(Search);
}
enum AnnouncementType {
Info, Warning, Alert, Event, Update
}
// Lifecycle state derived from Draft / PublishAt / ExpiresAt.
enum AnnouncementStatus {
Live, Draft, Scheduled, Expired
}
class ConfigData {
public GeneralSettings General {
get;
set;
}
= new GeneralSettings();
public NotificationSettings Notification {
get;
set;
}
= new NotificationSettings();
public DiscordSettings Discord {
get;
set;
}
= new DiscordSettings();
public RewardSettings Rewards {
get;
set;
}
= new RewardSettings();
public string SelectedTheme {
get;
set;
}
= "Default";
public Dictionary<string, UIColors> Themes {
get;
set;
}
= new Dictionary<string, UIColors>();
[JsonIgnore]
public UIColors Colors {
get {
if (Themes == null || Themes.Count == 0) return new UIColors();
if (Themes.TryGetValue(SelectedTheme, out var theme)) return theme;
return Themes.Values.First();
}
}
}
class GeneralSettings {
public int AutoCloseSeconds {
get;
set;
}
= 15;
public bool EnableAutoClose {
get;
set;
}
= true;
public bool ShowNewsOnConnect {
get;
set;
}
= true;
public string ServerName {
get;
set;
}
= "SERVER NEWS";
public int AnnouncementsPerPage {
get;
set;
}
= 5;
public int MaxStoredAnnouncements {
get;
set;
}
= 50;
// Send an unread-count chat summary on connect instead of forcing
// the popup open. Requires ShowNewsOnConnect.
public bool UnreadSummaryOnConnect {
get;
set;
}
= false;
}
class NotificationSettings {
public bool Enabled {
get;
set;
}
= true;
public bool UseNotifyPlugin {
get;
set;
}
= false;
public int NotifyType {
get;
set;
}
= 0;
public string Position {
get;
set;
}
= "Right";
public int Duration {
get;
set;
}
= 8;
public string NotificationSound {
get;
set;
}
= "assets/bundled/prefabs/fx/notice/loot.drag.fx.prefab";
}
class DiscordSettings {
public bool Enabled {
get;
set;
}
= false;
public string WebhookUrl {
get;
set;
}
= "";
public string BotName {
get;
set;
}
= "Server News";
public string RoleMention {
get;
set;
}
= "";
}
class RewardItem {
public string Shortname {
get;
set;
}
= "";
public int Amount {
get;
set;
}
= 1;
public ulong SkinId {
get;
set;
}
= 0;
}
class RewardBundle {
[JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<RewardItem> Items {
get;
set;
}
= new List<RewardItem>();
public int Points {
get;
set;
}
= 0;
public double Currency {
get;
set;
}
= 0;
}
class RewardSettings {
public bool EnableReadReward {
get;
set;
}
= false;
public int ReadDelaySeconds {
get;
set;
}
= 5;
public RewardBundle ReadRewards {
get;
set;
}
= new RewardBundle {
Items = new List<RewardItem> {
new RewardItem {
Shortname = "scrap", Amount = 5
}
}
}
;
public bool EnableLikeReward {
get;
set;
}
= false;
public RewardBundle LikeRewards {
get;
set;
}
= new RewardBundle {
Items = new List<RewardItem> {
new RewardItem {
Shortname = "scrap", Amount = 10
}
}
}
;
public bool NotifyOnReward {
get;
set;
}
= true;
public string PointsLabel {
get;
set;
}
= "RP";
public string CurrencyLabel {
get;
set;
}
= "coins";
}
class UIColors {
public string PanelBg {
get;
set;
}
= "0.07 0.08 0.10 0.97";
public string HeaderBg {
get;
set;
}
= "0.04 0.05 0.06 0.55";
public string ContentBg {
get;
set;
}
= "0.13 0.14 0.16 0.55";
public string ButtonPrimary {
get;
set;
}
= "0.36 0.71 1.00 0.95";
public string ButtonSecondary {
get;
set;
}
= "0.18 0.19 0.22 0.85";
public string TextTitle {
get;
set;
}
= "0.97 0.97 0.98 1.0";
public string TextNormal {
get;
set;
}
= "0.85 0.86 0.88 1.0";
public string TextMuted {
get;
set;
}
= "0.55 0.57 0.62 1.0";
}
#endregion
#region Localization
protected override void LoadDefaultMessages() {
lang.RegisterMessages(new Dictionary<string, string> {
["NoPermissionCommand"]
= "You do not have permission to use this command.", ["NoPermissionView"] = "You do not have permission to view news.", ["NoNewsHistory"] = "No news history available.", ["NewsBroadcasted"] = "News broadcasted!", ["ArchiveTitle"] = "ARCHIVE", ["ReadMore"] = "READ >", ["ViewArchive"] = "VIEW ARCHIVE", ["PostedBy"] = "POSTED BY", ["NewAnnouncement"] = "NEW ANNOUNCEMENT", ["Close"] = "CLOSE", ["Previous"] = "< PREVIOUS", ["Next"] = "NEXT >", ["Page"] = "PAGE {0} / {1}", ["AdminControl"] = "ADMIN CONTROL", ["NewPost"] = "+ NEW POST", ["Themes"] = "THEMES", ["NoAnnouncementsYet"] = "No announcements yet.\nClick '+ NEW POST' to create one.", ["CreateAnnouncement"] = "CREATE NEW ANNOUNCEMENT", ["EditAnnouncement"] = "EDIT ANNOUNCEMENT", ["AnnouncementTitle"] = "ANNOUNCEMENT TITLE", ["ImageUrl"] = "IMAGE URL (leave empty for no image)", ["AnnouncementType"] = "ANNOUNCEMENT TYPE (click to cycle)", ["ContentBody"] = "CONTENT BODY", ["ContentBodyHint"] = "Supports \\n for line breaks. Long content is supported and displayed with paged scrolling.", ["SaveBroadcast"] = "✔ SAVE & BROADCAST", ["Cancel"] = "✕ CANCEL", ["SelectTheme"] = "SELECT THEME", ["Active"] = "(ACTIVE)", ["Unknown"] = "UNKNOWN", ["EditButton"] = "EDIT", ["DelButton"] = "DEL", ["DeleteAnnouncement"] = "DELETE ANNOUNCEMENT", ["DeleteConfirmBody"] = "\"{0}\"\nThis cannot be undone.", ["ConfirmDelete"] = "✓ DELETE", ["EditTargetGone"] = "The announcement you were editing was removed before you could save.", ["AnnouncementSavedNew"] = "Announcement saved and broadcasted to all players!", ["AnnouncementUpdated"] = "Announcement updated.", ["TitleRequired"] = "Announcement title cannot be empty.", ["RewardRead"] = "Thanks for reading the news! Reward: {0}", ["RewardLike"] = "Thanks for the like! Reward: {0}", ["PinButton"] = "PIN", ["UnpinButton"] = "UNPIN", ["PinnedBadge"] = "PINNED", ["SelectedCount"] = "{0} SELECTED", ["BulkDelete"] = "✕ DELETE", ["BulkPin"] = "PIN ALL", ["BulkUnpin"] = "UNPIN ALL", ["ClearSelection"] = "CLEAR", ["SelectPageToggle"] = "SELECT PAGE", ["BulkDeleteTitle"] = "DELETE SELECTED", ["BulkDeleteBody"] = "Delete {0} announcement(s)?\nThis cannot be undone.", ["BulkDeleted"] = "Deleted {0} announcement(s).", ["BulkPinned"] = "Pinned {0} announcement(s).", ["BulkUnpinned"] = "Unpinned {0} announcement(s).", ["NavAnnouncements"] = "ANNOUNCEMENTS", ["BrandTop"] = "NEWS", ["BrandBottom"] = "BROADCASTER", ["ThemeHint"] = "Pick a theme — it applies instantly to every menu.", ["ArchiveEmpty"] = "No announcements have been posted yet.", ["LikesReads"] = "<color=#e0556b>❤</color> {0} {1} reads", ["ByAuthor"] = "by {0}", ["ByAuthorDate"] = "by {0} · {1}", ["StatPosts"] = "POSTS", ["StatPinned"] = "PINNED", ["StatLikes"] = "LIKES", ["StatReads"] = "READS", ["UnreadBadge"] = "UNREAD", ["UnreadSummary"] = "You have {0} unread announcement(s). Type /news to read them.", ["MarkedAllRead"] = "Marked {0} announcement(s) as read.", ["NothingToMark"] = "You have no unread announcements.", ["StatusLive"] = "LIVE", ["StatusDraft"] = "DRAFT", ["StatusScheduled"] = "SCHEDULED", ["StatusExpired"] = "EXPIRED", ["PublishAtLabel"] = "PUBLISH AT (empty = now)", ["ExpiresAtLabel"] = "EXPIRES AT (empty = never)", ["AudienceLabel"] = "AUDIENCE (empty = everyone)", ["DraftLabel"] = "DRAFT", ["DraftOn"] = "DRAFT — NOT VISIBLE", ["DraftOff"] = "PUBLISHED", ["ScheduleHint"] = "Use 2026-01-31 18:00 or a relative offset like +2h, +3d, +1w.", ["AudienceHint"] = "A permission suffix, e.g. \"vip\" grants newsbroadcaster.vip.", ["InvalidDate"] = "Could not read the date '{0}'. Use 2026-01-31 18:00 or +2h / +3d / +1w.", ["InvalidAudience"] = "Invalid audience '{0}'. Use letters, digits and underscores only ('admin' and 'view' are reserved).", ["ExpiryBeforePublish"] = "The expiry time must be later than the publish time.", ["SavedScheduled"] = "Announcement scheduled for {0}.", ["SavedDraft"] = "Draft saved. It is not visible to players yet.", ["FilterAll"] = "ALL", ["FilterUnread"] = "UNREAD", ["ClearFilters"] = "CLEAR", ["MarkAllRead"] = "MARK ALL READ", ["SearchPlaceholder"] = "SEARCH TITLE OR TEXT...", ["NoMatches"] = "No announcements match this filter.", ["ShowingCount"] = "{0} OF {1}", ["PlayerNotFound"] = "Player not found.", ["NoAnnouncementsStored"] = "No announcements stored.", ["DeletedAnnouncement"] = "Deleted announcement: '{0}'", ["InvalidIndex"] = "Invalid index. Use 'news.list' to see all announcements with their indices.", ["TriggeredFor"] = "News popup triggered for {0}", ["NoAnnouncementsAvailable"] = "No announcements available.", ["ThemeSet"] = "Theme set to: {0}", ["ThemeNotFound"] = "Theme '{0}' was not found in the configuration.", ["ThemeAvailable"] = "Available themes: {0}", ["UsageShow"] = "Usage: news.show \"Title\" \"ImageURL\" \"Text\" [Type]", ["UsageTrigger"] = "Usage: news.trigger <SteamID/Name> [NewsIndex]", ["UsageDelete"] = "Usage: news.delete <index>", ["UsageSetTheme"] = "Usage: news.admin.settheme \"ThemeName\"", ["UsageImport"] = "Usage: news.import <filename> [merge|replace]", ["ExportDone"] = "Exported {0} announcement(s) to oxide/data/{1}.json", ["ImportDone"] = "Imported {0} announcement(s) ({1} skipped as duplicates).", ["ImportEmpty"] = "No announcements found in oxide/data/{0}.json", ["ImportFailed"] = "Could not read oxide/data/{0}.json — {1}"
}
, this);
}
private string Msg(string key, BasePlayer player = null, params object[] args) {
var message = lang.GetMessage(key, this, player?.UserIDString);
return args != null && args.Length > 0 ? string.Format(message, args) : message;
}
private string NormalizeBodyText(string value) {
if (string.IsNullOrEmpty(value)) return string.Empty;
value = value.Replace("\r\n", "\n").Replace("\r", "\n");
// Only the documented "\n" escape becomes a line break. The old "/n"
// alias also matched ordinary text ("w/newbies", "and/not") and
// corrupted it, so it is no longer honoured.
value = value.Replace("\\n", "\n");
value = LinkRegex.Replace(value, string.Empty);
value = MultiSpaceRegex.Replace(value, " ");
value = BlankLinesRegex.Replace(value, "\n\n");
return value.Trim();
}
private List<string> BuildBodyDisplayLines(string text, int wrapChars = BodyWrapCharacters) {
if (wrapChars < 16) wrapChars = 16;
text = NormalizeBodyText(text);
var lines = new List<string>();
if (string.IsNullOrEmpty(text)) {
lines.Add(string.Empty);
return lines;
}
var rawLines = text.Split('\n');
foreach (var rawLine in rawLines) {
if (string.IsNullOrEmpty(rawLine)) {
lines.Add(string.Empty);
continue;
}
var working = rawLine;
while (working.Length > wrapChars) {
int take = wrapChars;
int lastSpace = working.LastIndexOf(' ', Math.Min(wrapChars - 1, working.Length - 1), Math.Min(wrapChars, working.Length));
if (lastSpace > 15) take = lastSpace;
lines.Add(working.Substring(0, take).TrimEnd());
working = working.Substring(Math.Min(working.Length, take)).TrimStart();
}
lines.Add(working);
}
if (lines.Count == 0) lines.Add(string.Empty);
return lines;
}
private static string NewAnnouncementId() => Guid.NewGuid().ToString("N");
private Announcement FindById(string id) {
if (string.IsNullOrEmpty(id)) return null;
for (int i = 0; i < announcements.Count; i++) if (string.Equals(announcements[i].Id, id, StringComparison.Ordinal)) return announcements[i];
return null;
}
private int FindIndexById(string id) {
if (string.IsNullOrEmpty(id)) return -1;
for (int i = 0; i < announcements.Count; i++) if (string.Equals(announcements[i].Id, id, StringComparison.Ordinal)) return i;
return -1;
}
private HashSet<string> GetAdminSelection(ulong userId) {
if (!adminSelections.TryGetValue(userId, out var set)) {
set = new HashSet<string>(StringComparer.Ordinal);
adminSelections[userId] = set;
}
return set;
}
private void PruneAdminSelection(ulong userId) {
if (!adminSelections.TryGetValue(userId, out var set) || set.Count == 0) return;
var liveIds = new HashSet<string>(announcements.Select(a => a.Id), StringComparer.Ordinal);
set.RemoveWhere(id => !liveIds.Contains(id));
}
private List<Announcement> GetDisplayOrder() {
return announcements .OrderByDescending(a => a.Pinned) .ThenByDescending(a => a.Timestamp) .ToList();
}
private Dictionary<string, object> BuildHookData(Announcement ann) {
if (ann == null) return null;
return new Dictionary<string, object> {
["id"]
= ann.Id, ["title"] = ann.Title, ["author"] = ann.Author, ["type"] = ann.Type.ToString(), ["timestamp"] = ann.Timestamp, ["date"] = ann.Date, ["text"] = ann.Text, ["imageUrl"] = ann.ImageUrl, ["likes"] = ann.LikedPlayers?.Count ?? 0, ["pinned"] = ann.Pinned, ["draft"] = ann.Draft, ["status"] = StatusOf(ann).ToString(), ["publishAt"] = ann.PublishAt, ["expiresAt"] = ann.ExpiresAt, ["audience"] = ann.Audience ?? string.Empty
}
;
}
// CUI anchors are parsed with invariant formatting. Interpolating a float
// directly picks up the server's culture, so a comma-decimal locale
// (de/fr/lv/ru hosts) would emit "0,51 0.8" and break the layout.
private static string A(float x, float y) {
return x.ToString("0.#####", CultureInfo.InvariantCulture) + " " + y.ToString("0.#####", CultureInfo.InvariantCulture);
}
private static string Truncate(string value, int max) {
if (string.IsNullOrEmpty(value) || value.Length <= max) return value ?? string.Empty;
return max <= 3 ? value.Substring(0, max) : value.Substring(0, max - 3).TrimEnd() + "...";
}
#region Access helpers
private BasePlayer PlayerFrom(ConsoleSystem.Arg arg) => arg?.Connection?.player as BasePlayer;
private bool HasAdmin(BasePlayer player) {
if (player == null) return false;
return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermAdmin);
}
// Server console and RCON (no connection) are always allowed.
private bool HasAdmin(ConsoleSystem.Arg arg) {
if (arg?.Connection == null) return true;
return arg.IsAdmin || permission.UserHasPermission(arg.Connection.userid.ToString(), PermAdmin);
}
private bool HasView(BasePlayer player) {
if (player == null) return false;
return permission.UserHasPermission(player.UserIDString, PermView) || HasAdmin(player);
}
// Rejects the plugin's own reserved suffixes so a custom audience can
// never widen itself into newsbroadcaster.admin.
private static bool IsValidAudience(string suffix) {
if (string.IsNullOrEmpty(suffix)) return true;
suffix = suffix.ToLowerInvariant();
if (suffix == "admin" || suffix == "view") return false;
return PermSuffixRegex.IsMatch(suffix);
}
private static string FullPermission(string suffix) {
if (string.IsNullOrEmpty(suffix)) return null;
suffix = suffix.Trim().ToLowerInvariant();
return IsValidAudience(suffix) ? PermPrefix + suffix : null;
}
private void EnsurePermissionRegistered(string suffix) {
string full = FullPermission(suffix);
if (full == null) return;
if (!registeredCustomPerms.Add(full)) return;
permission.RegisterPermission(full, this);
}
#endregion
#region Lifecycle helpers
private static AnnouncementStatus StatusOf(Announcement ann, long now) {
if (ann == null) return AnnouncementStatus.Expired;
if (ann.Draft) return AnnouncementStatus.Draft;
if (ann.PublishAt > 0 && ann.PublishAt > now) return AnnouncementStatus.Scheduled;
if (ann.ExpiresAt > 0 && ann.ExpiresAt <= now) return AnnouncementStatus.Expired;
return AnnouncementStatus.Live;
}
private static AnnouncementStatus StatusOf(Announcement ann) => StatusOf(ann, DateTime.UtcNow.Ticks);
// Pinned first, then newest first. Used for every list the plugin renders.
private static int CompareForDisplay(Announcement a, Announcement b) {
if (a.Pinned != b.Pinned) return a.Pinned ? -1 : 1;
return b.Timestamp.CompareTo(a.Timestamp);
}
private bool CanSee(BasePlayer player, Announcement ann, long now) {
if (player == null || ann == null) return false;
if (StatusOf(ann, now) != AnnouncementStatus.Live) return false;
string perm = FullPermission(ann.Audience);
if (perm == null) return true;
return permission.UserHasPermission(player.UserIDString, perm) || HasAdmin(player);
}
// Everything this player is allowed to read right now, in display order.
private List<Announcement> GetVisibleFor(BasePlayer player) {
long now = DateTime.UtcNow.Ticks;
var list = new List<Announcement>();
for (int i = 0; i < announcements.Count; i++) if (CanSee(player, announcements[i], now)) list.Add(announcements[i]);
list.Sort(CompareForDisplay);
return list;
}
private int CountUnread(BasePlayer player) {
int unread = 0;
var visible = GetVisibleFor(player);
for (int i = 0; i < visible.Count; i++) if (!(visible[i].ReadByPlayers?.Contains(player.userID) ?? false)) unread++;
return unread;
}
private static readonly string[] AcceptedDateFormats = {
"yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-ddTHH:mm", "yyyy-MM-dd"
}
;
// Accepts "" / "-" (unset), a relative offset ("+2h", "+3d", "+1w"),
// or an absolute server-local timestamp ("2026-09-20 18:00").
private bool TryParseWhen(string value, out long ticks) {
ticks = 0;
if (string.IsNullOrEmpty(value)) return true;
value = value.Trim();
if (value.Length == 0 || value == "-" || value == "0") return true;
var rel = RelativeTimeRegex.Match(value);
if (rel.Success) {
int amount;
if (!int.TryParse(rel.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out amount) || amount <= 0) return false;
TimeSpan span;
switch (rel.Groups[2].Value.ToLowerInvariant()) {
case "m" : span = TimeSpan.FromMinutes(amount);
break;
case "h" : span = TimeSpan.FromHours(amount);
break;
case "d" : span = TimeSpan.FromDays(amount);
break;
default : span = TimeSpan.FromDays(amount * 7d);
break;
}
try {
ticks = DateTime.UtcNow.Add(span).Ticks;
}
catch {
return false;
}
return true;
}
DateTime parsed;
if (DateTime.TryParseExact(value, AcceptedDateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out parsed)) {
try {
ticks = DateTime.SpecifyKind(parsed, DateTimeKind.Local).ToUniversalTime().Ticks;
}
catch {
return false;
}
return true;
}
return false;
}
// Renders a UTC tick count as server-local wall time.
private static string FormatWhen(long ticks) {
if (ticks <= 0) return string.Empty;
try {
return new DateTime(ticks, DateTimeKind.Utc).ToLocalTime().ToString(InvariantDateFormat, CultureInfo.InvariantCulture);
}
catch {
return string.Empty;
}
}
// Prefers the stored timestamp so the date always matches the clock;
// falls back to the legacy pre-formatted string for old data.
private static string DisplayDate(Announcement ann) {
if (ann == null) return string.Empty;
string formatted = FormatWhen(ann.Timestamp);
return string.IsNullOrEmpty(formatted) ? (ann.Date ?? string.Empty) : formatted;
}
private ArchiveFilter GetArchiveFilter(ulong userId) {
ArchiveFilter filter;
if (!archiveFilters.TryGetValue(userId, out filter)) {
filter = new ArchiveFilter();
archiveFilters[userId] = filter;
}
return filter;
}
#endregion
#endregion
#region Configuration
protected override void LoadConfig() {
base.LoadConfig();
try {
bool needsSave = false;
JObject raw = null;
try {
raw = Config.ReadObject<JObject>();
}
catch {
}
if (raw != null && MigrateLegacyRewardArrays(raw)) needsSave = true;
if (raw != null && raw["Rewards"] == null) needsSave = true;
config = raw != null ? raw.ToObject<ConfigData>() : Config.ReadObject<ConfigData>();
if (config == null) throw new Exception();
if (config.Rewards != null) {
int collapsed = 0;
collapsed += DeduplicateConsecutiveRewardItems(config.Rewards.ReadRewards?.Items);
collapsed += DeduplicateConsecutiveRewardItems(config.Rewards.LikeRewards?.Items);
if (collapsed > 0) {
PrintWarning($"Cleaned up {collapsed} duplicate reward item(s) from config (pre-1.1.1 deserialization bug).");
needsSave = true;
}
}
if (config.Themes == null) config.Themes = new Dictionary<string, UIColors>();
if (config.Themes.Count == 0) {
UIColors legacyColors = ExtractLegacyColors(raw);
config.Themes["Default"] = legacyColors ?? new UIColors();
config.Themes["Dark"] = new UIColors {
PanelBg = "0.04 0.04 0.05 0.98", HeaderBg = "0.02 0.02 0.03 0.55", ContentBg = "0.09 0.09 0.10 0.55", ButtonPrimary = "0.85 0.85 0.88 0.95", ButtonSecondary = "0.14 0.14 0.16 0.85", TextTitle = "0.97 0.97 0.97 1.0", TextNormal = "0.78 0.78 0.80 1.0", TextMuted = "0.48 0.48 0.52 1.0"
}
;
config.Themes["Ocean"] = new UIColors {
PanelBg = "0.05 0.09 0.12 0.97", HeaderBg = "0.02 0.05 0.07 0.55", ContentBg = "0.08 0.13 0.17 0.55", ButtonPrimary = "0.20 0.78 0.95 0.95", ButtonSecondary = "0.10 0.20 0.26 0.85", TextTitle = "0.95 0.98 1.00 1.0", TextNormal = "0.82 0.90 0.94 1.0", TextMuted = "0.48 0.62 0.72 1.0"
}
;
config.Themes["Rust"] = new UIColors {
PanelBg = "0.10 0.08 0.07 0.97", HeaderBg = "0.06 0.05 0.04 0.55", ContentBg = "0.16 0.13 0.11 0.55", ButtonPrimary = "0.95 0.40 0.18 0.95", ButtonSecondary = "0.22 0.18 0.15 0.85", TextTitle = "0.97 0.93 0.88 1.0", TextNormal = "0.84 0.78 0.72 1.0", TextMuted = "0.60 0.52 0.45 1.0"
}
;
config.Themes["Midnight"] = new UIColors {
PanelBg = "0.06 0.05 0.10 0.97", HeaderBg = "0.03 0.02 0.06 0.55", ContentBg = "0.11 0.10 0.16 0.55", ButtonPrimary = "0.62 0.40 0.98 0.95", ButtonSecondary = "0.17 0.16 0.22 0.85", TextTitle = "0.97 0.96 1.00 1.0", TextNormal = "0.82 0.81 0.88 1.0", TextMuted = "0.55 0.54 0.65 1.0"
}
;
config.Themes["Forest"] = new UIColors {
PanelBg = "0.06 0.09 0.07 0.97", HeaderBg = "0.03 0.05 0.04 0.55", ContentBg = "0.10 0.14 0.11 0.55", ButtonPrimary = "0.40 0.85 0.55 0.95", ButtonSecondary = "0.14 0.19 0.16 0.85", TextTitle = "0.94 0.98 0.94 1.0", TextNormal = "0.80 0.88 0.82 1.0", TextMuted = "0.50 0.62 0.54 1.0"
}
;
config.SelectedTheme = "Default";
needsSave = true;
}
if (ClampConfig()) needsSave = true;
if (needsSave) SaveConfig();
}
catch (Exception ex) {
// Never silently replace an admin's config: stash whatever was on
// disk into oxide/data so the values can be recovered by hand.
try {
var broken = Config.ReadObject<JObject>();
if (broken != null) {
Interface.Oxide.DataFileSystem.WriteObject(ConfigBackupFile, broken);
PrintError($"Could not read the config ({ex.Message}). A copy was saved to oxide/data/{ConfigBackupFile}.json and defaults were applied.");
}
else PrintError($"Could not read the config ({ex.Message}). Defaults were applied.");
}
catch {
PrintError($"Could not read the config ({ex.Message}). Defaults were applied.");
}
LoadDefaultConfig();
ClampConfig();
SaveConfig();
}
}
// Values that would divide by zero, wipe stored data, or create
// zero-length timers are pulled back into a usable range.
private bool ClampConfig() {
bool changed = false;
if (config.General == null) {
config.General = new GeneralSettings();
changed = true;
}
if (config.Notification == null) {
config.Notification = new NotificationSettings();
changed = true;
}
if (config.Discord == null) {
config.Discord = new DiscordSettings();
changed = true;
}
if (config.Rewards == null) {
config.Rewards = new RewardSettings();
changed = true;
}
changed |= ClampInt(config.General.AnnouncementsPerPage, 1, 12, v => config.General.AnnouncementsPerPage = v, "General.AnnouncementsPerPage");
changed |= ClampInt(config.General.MaxStoredAnnouncements, 1, 5000, v => config.General.MaxStoredAnnouncements = v, "General.MaxStoredAnnouncements");
changed |= ClampInt(config.General.AutoCloseSeconds, 1, 3600, v => config.General.AutoCloseSeconds = v, "General.AutoCloseSeconds");
changed |= ClampInt(config.Notification.Duration, 1, 3600, v => config.Notification.Duration = v, "Notification.Duration");
changed |= ClampInt(config.Rewards.ReadDelaySeconds, 1, 3600, v => config.Rewards.ReadDelaySeconds = v, "Rewards.ReadDelaySeconds");
return changed;
}
private bool ClampInt(int current, int min, int max, Action<int> assign, string label) {
int clamped = Mathf.Clamp(current, min, max);
if (clamped == current) return false;
assign(clamped);
PrintWarning($"Config value {label} was {current}, which is out of range — using {clamped} instead (allowed: {min}-{max}).");
return true;
}
private static bool MigrateLegacyRewardArrays(JObject raw) {
var rewards = raw?["Rewards"] as JObject;
if (rewards == null) return false;
bool changed = false;
foreach (var key in new[] {
"ReadRewards", "LikeRewards"
}
) {
if (rewards[key] is JArray legacyItems) {
rewards[key] = new JObject {
["Items"]
= legacyItems, ["Points"] = 0, ["Currency"] = 0.0
}
;
changed = true;
}
}
return changed;
}
private static int DeduplicateConsecutiveRewardItems(List<RewardItem> items) {
if (items == null || items.Count < 2) return 0;
int removed = 0;
for (int i = items.Count - 1; i > 0; i--) {
var a = items[i];
var b = items[i - 1];
if (a == null || b == null) continue;
if (string.Equals(a.Shortname, b.Shortname, StringComparison.Ordinal) && a.Amount == b.Amount && a.SkinId == b.SkinId) {
items.RemoveAt(i);
removed++;
}
}
return removed;
}
private static UIColors ExtractLegacyColors(JObject raw) {
try {
var colorsToken = raw?["Colors"];
if (colorsToken == null || colorsToken.Type != JTokenType.Object) return null;
return colorsToken.ToObject<UIColors>();
}
catch {
return null;
}
}
protected override void SaveConfig() => Config.WriteObject(config, true);
protected override void LoadDefaultConfig() {
config = new ConfigData();
config.Themes = new Dictionary<string, UIColors> {
["Default"]
= new UIColors(), ["Dark"] = new UIColors {
PanelBg = "0.04 0.04 0.05 0.98", HeaderBg = "0.02 0.02 0.03 0.55", ContentBg = "0.09 0.09 0.10 0.55", ButtonPrimary = "0.85 0.85 0.88 0.95", ButtonSecondary = "0.14 0.14 0.16 0.85", TextTitle = "0.97 0.97 0.97 1.0", TextNormal = "0.78 0.78 0.80 1.0", TextMuted = "0.48 0.48 0.52 1.0"
}
, ["Ocean"] = new UIColors {
PanelBg = "0.05 0.09 0.12 0.97", HeaderBg = "0.02 0.05 0.07 0.55", ContentBg = "0.08 0.13 0.17 0.55", ButtonPrimary = "0.20 0.78 0.95 0.95", ButtonSecondary = "0.10 0.20 0.26 0.85", TextTitle = "0.95 0.98 1.00 1.0", TextNormal = "0.82 0.90 0.94 1.0", TextMuted = "0.48 0.62 0.72 1.0"
}
, ["Rust"] = new UIColors {
PanelBg = "0.10 0.08 0.07 0.97", HeaderBg = "0.06 0.05 0.04 0.55", ContentBg = "0.16 0.13 0.11 0.55", ButtonPrimary = "0.95 0.40 0.18 0.95", ButtonSecondary = "0.22 0.18 0.15 0.85", TextTitle = "0.97 0.93 0.88 1.0", TextNormal = "0.84 0.78 0.72 1.0", TextMuted = "0.60 0.52 0.45 1.0"
}
, ["Midnight"] = new UIColors {
PanelBg = "0.06 0.05 0.10 0.97", HeaderBg = "0.03 0.02 0.06 0.55", ContentBg = "0.11 0.10 0.16 0.55", ButtonPrimary = "0.62 0.40 0.98 0.95", ButtonSecondary = "0.17 0.16 0.22 0.85", TextTitle = "0.97 0.96 1.00 1.0", TextNormal = "0.82 0.81 0.88 1.0", TextMuted = "0.55 0.54 0.65 1.0"
}
, ["Forest"] = new UIColors {
PanelBg = "0.06 0.09 0.07 0.97", HeaderBg = "0.03 0.05 0.04 0.55", ContentBg = "0.10 0.14 0.11 0.55", ButtonPrimary = "0.40 0.85 0.55 0.95", ButtonSecondary = "0.14 0.19 0.16 0.85", TextTitle = "0.94 0.98 0.94 1.0", TextNormal = "0.80 0.88 0.82 1.0", TextMuted = "0.50 0.62 0.54 1.0"
}
}
;
}
#endregion
#region Data Management
private void OnServerInitialized() {
RegisterAllImages();
RegisterAudiencePermissions();
// Publishes anything whose scheduled time has arrived and retires
// anything that has expired while the server was down.
scheduleTimer = timer.Every(ScheduleTickSeconds, ProcessScheduled);
ProcessScheduled();
}
// ImageLibrary may load after this plugin; re-import when it appears.
private void OnPluginLoaded(Plugin plugin) {
if (plugin != null && plugin.Name == "ImageLibrary") RegisterAllImages();
}
private void RegisterAudiencePermissions() {
for (int i = 0; i < announcements.Count; i++) EnsurePermissionRegistered(announcements[i].Audience);
}
private void RegisterAllImages() {
if (ImageLibrary == null) return;
var imageUrls = announcements .Where(x => !string.IsNullOrEmpty(x.ImageUrl)) .Select(x => x.ImageUrl) .Distinct() .ToDictionary(x => x, x => x);
if (imageUrls.Count > 0) ImageLibrary.Call("ImportImageList", Title, imageUrls, 0UL, true);
}
// Writes the data file immediately. Use for anything an admin would
// expect to survive a crash straight away (create / edit / delete).
private void SaveAnnouncements() {
dataDirty = false;
if (saveTimer != null && !saveTimer.Destroyed) saveTimer.Destroy();
saveTimer = null;
TrimStoredAnnouncements();
PruneLastSeen();
announcements = storedData.Announcements;
Interface.Oxide.DataFileSystem.WriteObject(DataFile, storedData);
}
// Queues a write instead of performing one. Used by high-frequency,
// low-value updates (likes, read marks, last-seen) so a busy server
// does not re-serialise the whole data file dozens of times a minute.
private void MarkDataDirty() {
dataDirty = true;
if (saveTimer != null && !saveTimer.Destroyed) return;
saveTimer = timer.Once(SaveDebounceSeconds, () => {
saveTimer = null;
if (dataDirty) SaveAnnouncements();
}
);
}
// Keeps the newest N, but never culls a pinned announcement to make
// room — pinned posts (rules, wipe schedule) are the ones that must stay.
private void TrimStoredAnnouncements() {
int max = config.General.MaxStoredAnnouncements;
if (max < 1) max = 1;
if (storedData.Announcements.Count <= max) return;
var ordered = new List<Announcement>(storedData.Announcements);
ordered.Sort((a, b) => {
int ap = TrimPriority(a), bp = TrimPriority(b);
if (ap != bp) return bp.CompareTo(ap);
return b.Timestamp.CompareTo(a.Timestamp);
}
);
var keep = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < ordered.Count && keep.Count < max; i++) keep.Add(ordered[i].Id);
// Rebuild in the original order so "newest first" insertion still holds.
storedData.Announcements = storedData.Announcements.Where(a => keep.Contains(a.Id)).ToList();
}
// Pinned posts, drafts and anything still queued to publish survive the
// cull: silently deleting a scheduled announcement before it fires, or
// the server rules post, is never what the admin meant by a size cap.
private static int TrimPriority(Announcement a) {
if (a.Pinned) return 3;
if (a.Draft) return 2;
if (a.PublishAt > 0 && !a.Broadcast) return 2;
return 0;
}
// Drops last-seen markers that sit behind every retained announcement:
// those players are already treated as fully behind, so the entry is
// dead weight that otherwise grows by one row per player, forever.
private void PruneLastSeen() {
if (storedData.LastSeenNews == null || storedData.LastSeenNews.Count == 0) return;
long oldest = long.MaxValue;
for (int i = 0; i < storedData.Announcements.Count; i++) {
long ts = storedData.Announcements[i].Timestamp;
if (ts > 0 && ts < oldest) oldest = ts;
}
if (oldest == long.MaxValue) return;
List<ulong> stale = null;
foreach (var kv in storedData.LastSeenNews) {
if (kv.Value >= oldest) continue;
(stale ?? (stale = new List<ulong>())).Add(kv.Key);
}
if (stale == null) return;
for (int i = 0; i < stale.Count; i++) storedData.LastSeenNews.Remove(stale[i]);
}
// Broadcasts an announcement to everyone currently allowed to read it.
private void PublishToPlayers(Announcement ann) {
if (ann == null) return;
long now = DateTime.UtcNow.Ticks;
foreach (var p in BasePlayer.activePlayerList) {
if (p == null || !p.IsConnected) continue;
if (!CanSee(p, ann, now)) continue;
if (config.Notification.Enabled) ShowNotification(p, ann);
else ShowPopup(p, ann, false, true);
}
}
// Fires scheduled announcements once their time arrives.
private void ProcessScheduled() {
if (announcements == null || announcements.Count == 0) return;
long now = DateTime.UtcNow.Ti
// ... [Code preview truncated due to length. Full source in attached file] ...