using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Reflection; // There is no other way I swear (check Fields region) using System.Text; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; using Oxide.Ext.Discord.Entities.Messages.Embeds; using Oxide.Ext.Discord.Entities.Permissions; using Oxide.Ext.Discord.Entities.Webhooks; using UnityEngine; using Global = ConVar.Global; namespace Oxide.Plugins { [Info("RebootScheduler", "HandyS11", "1.0.1")] [Description("Restart your Rust server on schedule or when updates comes out")] internal sealed class RebootScheduler : RustPlugin { #region Fields private Configuration _config; private UnityRestart _restart; private static RebootScheduler Instance; private bool IsRestartingNative => ServerMgr.Instance.Restarting; private readonly FieldInfo _nativeRestartRoutine = typeof(ServerMgr).GetField( "restartCoroutine", BindingFlags.Instance | BindingFlags.NonPublic ); #endregion #region Permission private static class Permission { public const string Admin = "rebootscheduler.admin"; } #endregion #region Configuration private sealed class Configuration { [JsonProperty(PropertyName = "Default chat avatar")] public ulong ChatAvatarId { get; set; } [JsonProperty(PropertyName = "Enable UpdateNotice plugin (required for hooks)")] public bool EnableUpdateNotice { get; set; } [JsonProperty(PropertyName = "Hooks configuration (require UpdateNotice)")] public HooksConfig HooksConfig { get; set; } [JsonProperty(PropertyName = "Restart messages cooldown")] public int[] RestartMessageCooldown { get; set; } [JsonProperty(PropertyName = "Enable daily restart")] public bool EnableDailyRestart { get; set; } [JsonProperty(PropertyName = "Daily restart time (13:30:00 as example for 1:30 pm UTC)")] public string DailyRestartTime { get; set; } [JsonProperty(PropertyName = "Daily restart cooldown (for message visibility)")] public int DailyRestartCooldown { get; set; } [JsonProperty(PropertyName = "Enable discord notifications")] public bool EnableDiscordNotification { get; set; } [JsonProperty(PropertyName = "Discord webhook url")] public string DiscordWebhook { get; set; } [JsonProperty(PropertyName = "Discord role id to mention (0 = no mention)")] public ulong DiscordRole { get; set; } } private sealed class HooksConfig { [JsonProperty(PropertyName = "When the Server Restart (COOLDOWN | DAILY_TIME)")] public string Method { get; set; } [JsonProperty(PropertyName = "Cooldown time before restart (in seconds)")] public int CooldownTime { get; set; } [JsonProperty(PropertyName = "Enable restart OnCarbonUpdate")] public bool EnableOnCarbonUpdate { get; set; } [JsonProperty(PropertyName = "Enable restart OnOxideUpdate")] public bool EnableOnOxideUpdate { get; set; } [JsonProperty(PropertyName = "Enable restart OnServerUpdate")] public bool EnableOnServerUpdate { get; set; } } private Configuration GetDefaultConfig() { return new Configuration { ChatAvatarId = 0, EnableUpdateNotice = true, HooksConfig = new HooksConfig { Method = "COOLDOWN", CooldownTime = 300, EnableOnCarbonUpdate = false, EnableOnOxideUpdate = true, EnableOnServerUpdate = true, }, RestartMessageCooldown = new[] { 3600, 1800, 900, 300, 120, 60, 30, 10, 5, 4, 3, 2, 1 }, EnableDailyRestart = false, DailyRestartTime = "04:00:00", DailyRestartCooldown = 300, EnableDiscordNotification = false, DiscordWebhook = "https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks", DiscordRole = 0 }; } protected override void LoadConfig() { base.LoadConfig(); Instance = this; try { _config = Config.ReadObject(); } catch (Exception exception) { PrintError(exception.ToString()); Puts("Configuration has been reset!"); _config = GetDefaultConfig(); } SaveConfig(); } protected override void LoadDefaultConfig() { _config = GetDefaultConfig(); } protected override void SaveConfig() { Config.WriteObject(_config, true); } #endregion #region Oxide Hooks void Init() { permission.RegisterPermission(Permission.Admin, this); } void OnServerInitialized() { _restart = ServerMgr.Instance.gameObject.AddComponent(); AddCovalenceCommand(Command.Prefix, nameof(SendWipeCommand)); } private void Loaded() { if (UpdateNotice != null) return; PrintWarning(GetMessage(MessageKey.UpdateNoticeMissing)); _config.EnableUpdateNotice = false; } void Unload() { if (IsRestartingNative) CancelNativeRestart(); UnityEngine.Object.Destroy(_restart); Instance = null; } #endregion #region UpdateNotice Hooks void OnCarbonUpdate(string version) { Puts($"Carbon got updated! - {version}"); if (!_config.HooksConfig.EnableOnCarbonUpdate) return; ScheduleUpdateRestart(RestartReason.CarbonUpdate); } void OnOxideUpdate(string version) { Puts($"Oxide got updated! - {version}"); if (!_config.HooksConfig.EnableOnOxideUpdate) return; ScheduleUpdateRestart(RestartReason.OxideUpdate); } void OnServerUpdate(string version) { Puts($"Server got updated! - {version}"); if (!_config.HooksConfig.EnableOnServerUpdate) return; ScheduleUpdateRestart(RestartReason.ServerUpdate); } #endregion #region Functions private void KickPlayers() { foreach (var player in BasePlayer.allPlayerList.ToList()) { player.Kick(GetMessage(MessageKey.KickReason, player.UserIDString)); } } private void CancelNativeRestart() { var routine = (IEnumerator)_nativeRestartRoutine.GetValue(ServerMgr.Instance); ServerMgr.Instance.StopCoroutine(routine); _nativeRestartRoutine.SetValue(ServerMgr.Instance, null); } private void ScheduleUpdateRestart(RestartReason reason) { switch (_config.HooksConfig.Method) { case "COOLDOWN": _restart.DoRestart(DateTime.Now.AddSeconds(_config.HooksConfig.CooldownTime), reason); break; case "DAILY_TIME": { if (_restart.IsRestarting) return; if (_config.EnableDailyRestart) return; var time = ParseTime(_config.DailyRestartTime); if (time == null) { Puts(GetMessage(MessageKey.WrongTimeFormat)); return; } _restart.DoRestart(time.Value, reason); break; } } } private static class Status { public const string NO_PLANNED = "No planned restart"; public const string RESTARTING_NATIVE = "Restarting (Native)"; public const string RESTARTING = "Restarting"; } private string GetStatus(out DateTime? restartTime) { restartTime = null; if (IsRestartingNative) { return Status.RESTARTING_NATIVE; } if (!_restart.IsRestarting) return Status.NO_PLANNED; restartTime = _restart.RestartTime; return Status.RESTARTING; } public enum RestartStatus { Canceled, Initialized, Now, Test, } private void SendRestartToDiscord(RestartStatus status, int? secondsLeft = null) { string desc; DiscordColor color; switch (status) { case RestartStatus.Canceled: desc = "The server restart has been canceled!"; color = new DiscordColor(10181046); break; case RestartStatus.Initialized: desc = $"The server will restart in {FormatTime(secondsLeft)}!"; color = new DiscordColor(15105570); break; case RestartStatus.Now: desc = "The server is restarting!"; color = new DiscordColor(16711686); break; case RestartStatus.Test: desc = "This is a test message!"; color = new DiscordColor(1752220); break; default: throw new ArgumentOutOfRangeException(nameof(status), status, null); } SendWebhookToDiscord(_config.DiscordWebhook, new WebhookCreateMessage() { AvatarUrl = "https://i.imgur.com/O7s0Z1i.png", Username = "RebootScheduler", Content = (_config.DiscordRole != 0) ? $"<@&{_config.DiscordRole}>" : "", Embeds = new List { new DiscordEmbed { Title = ConVar.Server.hostname, Description = desc, Color = color, Thumbnail = new EmbedThumbnail() { Url = "https://i.imgur.com/O7s0Z1i.png" }, Fields = new List { new EmbedField { Name = "Restart time", Value = _restart.RestartTime?.ToString("dd/MM/yyyy - HH:mm:ss UTC"), Inline = true }, new EmbedField { Name = "Reason", Value = _restart.Reason.ToString(), Inline = true } }, Footer = new EmbedFooter() { Text = "RebootScheduler", IconUrl = "https://i.imgur.com/O7s0Z1i.png" } } } }); } #endregion #region Restart public enum RestartReason { ApiCall, CarbonUpdate, DailyRestart, OxideUpdate, ServerAdmin, ServerUpdate, } private sealed class UnityRestart : MonoBehaviour { #region Fields private IEnumerator _restartRoutine = null; private bool _hasBeenSent = false; public DateTime? RestartTime { get; private set; } public RestartReason? Reason { get; private set; } public bool IsRestarting { get; private set; } #endregion #region Unity hooks void Start() { if (!Instance._config.EnableDailyRestart) return; var time = ParseTime(Instance._config.DailyRestartTime); if (time == null) { Instance.Puts(Instance.GetMessage(MessageKey.WrongTimeFormat)); return; } RestartTime = time.Value; Reason = RestartReason.DailyRestart; DoRestart(time.Value, RestartReason.DailyRestart); } void OnDestroy() { if (IsRestarting) { CancelRestart(); } } #endregion #region Functions public void DoRestart(DateTime restartTime, RestartReason reason) { var secondsLeft = (int)(restartTime - DateTime.Now).TotalSeconds; RestartTime = restartTime; Reason = reason; IsRestarting = true; OnRestartInit(secondsLeft, reason); _restartRoutine = InitRestartRoutine(secondsLeft); StartCoroutine(_restartRoutine); } private IEnumerator InitRestartRoutine(int totalSecondsLeft) { while (totalSecondsLeft > 0) { var nextCd = GetNextCountdownValue(Mathf.CeilToInt(totalSecondsLeft - 1f)); yield return new WaitForSecondsRealtime(totalSecondsLeft - nextCd); OnRestartTick(nextCd); totalSecondsLeft = nextCd; } RestartNow(); } private void RestartNow() { OnRestartNow(); Instance.KickPlayers(); Global.quit(null); } public void CancelRestart() { OnRestartCancel(); StopCoroutine(_restartRoutine); Cleanup(); } private int GetNextCountdownValue(int secondsLeft) { var next = Instance._config.RestartMessageCooldown.FirstOrDefault(p => p <= secondsLeft); if (next < 0) next = 0; return next; } private void Cleanup() { _restartRoutine = null; RestartTime = null; Reason = null; IsRestarting = false; } #endregion #region Hooks definition private void OnRestartInit(int secondsLeft, RestartReason reason) { Interface.CallHook("OnRestartInit", secondsLeft, reason); if (Instance._config.EnableDiscordNotification) Instance.SendRestartToDiscord(RestartStatus.Initialized, secondsLeft); Instance.SendGlobalMessage(Instance.GetCustomMessage(MessageKey.RestartGlobalMessage, null, FormatTime(secondsLeft), Reason)); } private void OnRestartTick(int secondsLeft) { Interface.CallHook("OnRestartTick", secondsLeft); Instance.SendGlobalMessage(Instance.GetCustomMessage(MessageKey.RestartGlobalMessageShort, null, FormatTime(secondsLeft))); if (!Instance._config.EnableDiscordNotification || secondsLeft >= 10 || _hasBeenSent) return; Instance.SendRestartToDiscord(RestartStatus.Now); _hasBeenSent = true; } private void OnRestartNow() { Interface.CallHook("OnRestartNow"); } private void OnRestartCancel() { Interface.CallHook("OnRestartCancel"); if (Instance._config.EnableDiscordNotification) Instance.SendRestartToDiscord(RestartStatus.Canceled); Instance.SendGlobalMessage(Instance.GetMessage(MessageKey.RestartCancelMessage)); } #endregion } #endregion #region Helper private void SendGlobalMessage(string message) { Puts(message); Server.Broadcast(message, _config.ChatAvatarId); } private string GetCustomMessage(string messageKey, string playerId = null, params object[] data) { try { var template = lang.GetMessage(messageKey, this, playerId); return string.Format(template, data); } catch (Exception exception) { PrintError(exception.ToString()); throw; } } private bool HasPermission(IPlayer player, string perm) => permission.UserHasPermission(player.Id, perm); private static DateTime? ParseTime(string str) { if (int.TryParse(str, out var seconds)) return DateTime.Now.AddSeconds(seconds); if (!TimeSpan.TryParseExact(str, @"hh\:mm\:ss", CultureInfo.InvariantCulture, TimeSpanStyles.None, out var time)) return null; return time < DateTime.Now.TimeOfDay ? DateTime.Now.Date.AddDays(1).Add(time) : DateTime.Now.Date.Add(time); } private static string FormatTime(int? seconds) { if (!seconds.HasValue) return "DNF"; var hours = seconds / 3600; var minutes = (seconds % 3600) / 60; var secondsRemaining = seconds % 60; var result = new StringBuilder(); if (hours > 0) { result.Append(hours).Append("h"); if (minutes > 0 || secondsRemaining > 0) result.Append(" "); } if (minutes > 0) { result.Append(minutes).Append("m"); if (secondsRemaining > 0) result.Append(" "); } if (secondsRemaining > 0 || (hours == 0 && minutes == 0)) result.Append(secondsRemaining).Append("s"); return result.ToString(); } #region Discord private readonly Dictionary _headers = new() { {"Content-Type", "application/json"} }; private void SendWebhookToDiscord(string url, WebhookCreateMessage webHook) { var payload = JsonConvert.SerializeObject(webHook, Formatting.None, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); webrequest.Enqueue(url, payload, (code, response) => { if (code is 200 or 204) return; if (response == null) PrintWarning($"Discord didn't respond. Error Code: {code}"); else Puts($"Discord respond with: {response} Payload: {payload}"); }, this, RequestMethod.POST, _headers); } #endregion #endregion #region Commands private static class Command { public const string Prefix = "rs"; public const string Cancel = "cancel"; public const string Discord = "discord"; public const string Help = "help"; public const string Restart = "restart"; public const string Status = "status"; } private bool SendWipeCommand(IPlayer player, string cmd, string[] args) { if (args == null) throw new ArgumentNullException(nameof(args)); if (!HasPermission(player, Permission.Admin)) { player.Message(GetMessage(MessageKey.NoPermission)); return true; } if (args.Length == 0) { player.Message(GetMessage(MessageKey.Help)); return true; } switch (args[0]) { case Command.Cancel: if (IsRestartingNative) { CancelNativeRestart(); player.Message(GetMessage(MessageKey.NativeRestartCancel, player.Id)); break; } if (_restart.IsRestarting) { _restart.CancelRestart(); player.Message(GetMessage(MessageKey.RestartCancelMessage, player.Id)); break; } player.Message(GetMessage(MessageKey.NoRestartOnGoing, player.Id)); break; case Command.Discord: SendRestartToDiscord(RestartStatus.Test); player.Message("Test message sent to discord!"); break; case Command.Help: player.Message(GetMessage(MessageKey.Help, player.Id)); break; case Command.Restart: if (args.Length == 1) { _restart.DoRestart(DateTime.Now.AddSeconds(12), RestartReason.ServerAdmin); player.Message(GetMessage(MessageKey.RestartInitialized, player.Id)); break; } if (args.Length == 2) { var time = ParseTime(args[1]); if (time == null) player.Message(GetMessage(MessageKey.WrongTimeFormat, player.Id)); else _restart.DoRestart(time.Value.AddSeconds(2), RestartReason.ServerAdmin); player.Message(GetMessage(MessageKey.RestartInitialized, player.Id)); break; } player.Message(GetMessage(MessageKey.WrongNumberOfArgument, player.Id)); break; case Command.Status: var status = GetStatus(out var restartTime); player.Message(restartTime.HasValue ? GetCustomMessage(MessageKey.StatusWithTime, player.Id, status, restartTime) : GetCustomMessage(MessageKey.Status, player.Id, status)); break; default: player.Message(GetMessage(MessageKey.UnknownCommand, player.Id)); break; } return true; } #endregion #region Localization private static class MessageKey { public const string Help = "Help"; public const string KickReason = "KickReason"; public const string NativeRestartCancel = "NativeRestartCancel"; public const string NoPermission = "NoPermission"; public const string NoRestartOnGoing = "NoRestartOnGoing"; public const string RestartCancelMessage = "RestartCancelMessage"; public const string RestartGlobalMessage = "RestartGlobalMessage"; public const string RestartGlobalMessageShort = "RestartGlobalMessageShort"; public const string RestartInitialized = "RestartInitialized"; public const string Status = "Status"; public const string StatusWithTime = "StatusWithTime"; public const string UnknownCommand = "UnknownCommand"; public const string UpdateNoticeMissing = "UpdateNoticeMissing"; public const string WrongNumberOfArgument = "WrongNumberOfElements"; public const string WrongTimeFormat = "WrongTimeFormat"; } protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { [MessageKey.Help] = "\nCommands:\t\t\t\tExplanations:\n\n- rs cancel\t\t\t\tCancel the ongoing restart\n- rs discord\t\t\t\tSend a test message to discord\n- rs help\t\t\t\tDisplay the help message\n- rs restart