Иконка ресурса

FREE NewsBroadcaster : 1.7.0 1.7.0

Нет прав для скачивания
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] ...

Автор
15FPS
Скачиваний
0
Просмотры
1
Первый выпуск
Обновление
Оценка
0.00 звёзд 0 оценок
Сверху