Updated plugin after the last global update in June.

Ember.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core.Libraries;
using Oxide.Core.Plugins;
using UnityEngine;

namespace Oxide.Plugins {
	[Info ("Ember", "Mkekala", "2.0.1")]
	[Description ("Integrates Ember actions, ban management & role sync with Rust")]
	public class Ember : RustPlugin {
		#region Configuration
		private class PluginConfig {
			public string Host = "http://127.0.0.1";
			public string Token;
			public LogLevel LogLevel = LogLevel.Info;

			[JsonProperty (PropertyName = "_MapImageParamsComment")]
			public string MapImageParamsComment = "Параметры генерации карты (RustMapApi). Загружается в store при подключении сервера.";

			public float MapImageScale = 0.5f;
			public string MapImageName = "Icons";

			[JsonProperty (PropertyName = "_MapImageScale_desc")]
			public string MapImageScaleDesc = "Масштаб карты (0.0–1.0). Разрешение = MapImageScale × размер мира. 0.5 = половина размера карты в пикселях.";

			[JsonProperty (PropertyName = "_MapImageName_desc")]
			public string MapImageNameDesc = "Icons — карта с иконками монументов (порты, аэродром, военка и т.д.). Default — карта без иконок (только рельеф).";
		}

		private enum LogLevel { Trace, Debug, Info, Warning, Error };

		private PluginConfig config;

		private class IntegrationSettings {
			[JsonProperty ("ban_log")]
			public bool banLog;
			[JsonProperty ("role_sync")]
			public RoleSyncSettings roleSync = new ();
		}

		private class RoleSyncSettings {
			[JsonProperty ("create")]
			public bool Create;
			[JsonProperty ("send")]
			public bool Send;
			[JsonProperty ("receive")]
			public bool Receive;
		}

		private IntegrationSettings settings = new ();
		#endregion

		#region Constants
		private const string commandNamespace = "ember";

		private static readonly string[] permissions = {
			"ban",
			"unban",
		};

		private static readonly string[] userHooks = {
			"OnUserBanned",
			"OnUserUnbanned",
			"OnUserGroupAdded",
			"OnUserGroupRemoved",
		};

		private const float retryApiInitIntervalSeconds = 60f,
			maxPollTimerDelayUserConnectingSeconds = 5f;

		private const BindingFlags selfReflectionFlags = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.NonPublic;

		private const string mapImageBlobPath = "map.jpg";
		private const RustMapApiEncodingMode mapImageEncoding = RustMapApiEncodingMode.Jpg;
		private enum RustMapApiEncodingMode { Jpg = 1, Png = 2 };
		#endregion

		#region Variables
		private bool connected;
		private Dictionary<string, Action<JToken>> actionHandlers = new ();
		private Dictionary<string, string> connectingUsers = new ();
		private Timer retryApiInitTimer;
		private TimerManager.TimerProxy pollTimer;

		[PluginReference]
		private Plugin RustMapApi;

		private string _cmdBanName;
		private string _cmdUnbanName;
		#endregion

		#region Domain methods
		#region Polling/action flow
		private void AddConnectingUser (string steamid, string name = "") {
			if (connectingUsers.ContainsKey (steamid))
				return;

			connectingUsers.Add (steamid, name);

			if (connected) RunPollWithin (maxPollTimerDelayUserConnectingSeconds);
		}

		private void RunPoll () {
			var connectingUsersBatch = Interlocked.Exchange (ref connectingUsers, new ());
			int playerCount = connectingUsersBatch.Count + BasePlayer.activePlayerList.Count;

			LogMessage ($"Poll invoked with {playerCount} player(s)", LogLevel.Trace);

			if (playerCount == 0)
				return;

			List<string> names = new ();
			foreach (string name in connectingUsersBatch.Values)
				names.Add (name.Replace (",", ""));

			List<string> connectedUserIds = new ();

			foreach (BasePlayer ply in BasePlayer.activePlayerList)
				if (!connectingUsersBatch.ContainsKey (ply.UserIDString))
					connectedUserIds.Add (ply.UserIDString);
				else playerCount -= 1;

			JObject payload = JObject.FromObject (new {
				players = new {
					steam = new {
						connecting = new {
							ids = string.Join (",", connectingUsersBatch.Keys),
							names = string.Join (",", names),
						},
						connected = new {
							ids = string.Join (",", connectedUserIds)
						}
					}
				}
			});
			
			LogMessage (string.Format (lang.GetMessage ("ConsolePolling", this), playerCount));

			var failureHandler = (int code, string response) => {
				LogMessage (string.Format (lang.GetMessage ("ConsoleApiRequestFailed", this), code, response), LogLevel.Error);

				if (connectingUsersBatch.Count > 0)
					LogMessage ($"Queuing poll retry for {connectingUsersBatch.Count} connecting player(s)", LogLevel.Debug);
				foreach (var (steamid, name) in connectingUsersBatch.Select ((u) => (u.Key, u.Value)))
					AddConnectingUser (steamid, name);
			};

			var successHandler = (JToken response) => {
				foreach (JToken action in response as JArray)
					DispatchAction (action);
			};

			HttpRequest (RequestMethod.POST, "poll", payload, successHandler, failureHandler);
		}

		private void RunPollWithin (float seconds) {
			LogMessage (string.Format ("Timer interval: {0}s, time remaining: {1}s, run within: {2}s", pollTimer.DefaultInterval, pollTimer.Remaining (), seconds), LogLevel.Trace);
			pollTimer.InvokeWithin (seconds);
		}

		private void DispatchAction (JToken action) {
			string type = (string) action["type"];

			LogMessage (action.ToString (), LogLevel.Trace);

			if (!actionHandlers.ContainsKey (type)) {
				LogMessage (string.Format (lang.GetMessage ("ActionUnrecognized", this), type), LogLevel.Warning);
				return;
			}

			LogMessage (string.Format (lang.GetMessage ("ActionDispatching", this), type), LogLevel.Debug);

			var handler = actionHandlers[type];

			string error = string.Empty;

			try {
				handler (action["payload"]);
			} catch (Exception ex) {
				error = ex.Message;

				LogMessage (string.Format ("Action execution failed for type \"{0}\". Error: {1}", type, error), LogLevel.Warning);
			}

			var execuctionId = (float?) action["execution_id"];
			if (execuctionId != null)
				HttpRequest (RequestMethod.POST, $"actions/executions/{execuctionId}/finish", JObject.FromObject (new { error }));
		}
		#endregion

		#region Player endpoints
		public void Ban (string offenderSteamid, string durationMinutes, string reason, bool global, string adminSteamid, BasePlayer caller) {
			string scope = global ? "global" : null;
			
			var parameters = JObject.FromObject (new {
				scope,
				reason,
				duration_minutes = durationMinutes,
			});

			if (adminSteamid != null)
				parameters["admin_steam_account_id"] = adminSteamid;
			
			string url = $"players/steam/{offenderSteamid}/bans";

			var failureHandler = (int code, string response) => {
				if (caller != null)
					SendLogMessage (caller, string.Format (lang.GetMessage ("PlayerBanFailed", this, caller.UserIDString), offenderSteamid), LogLevel.Warning);
			};

			var successHandler = (JToken _) => {
				LogMessage (string.Format (lang.GetMessage ("PlayerBanned", this), offenderSteamid));

				if (caller != null)
					SendLogMessage (caller, string.Format (lang.GetMessage ("PlayerBanned", this, caller.UserIDString), offenderSteamid));
			};

			HttpRequest (RequestMethod.POST, url, parameters, successHandler, failureHandler, "Ban");
		}

		public void Unban (string offenderSteamid, BasePlayer caller) {
			string url = $"players/steam/{offenderSteamid}/bans";

			var failureHandler = (int code, string response) => {
				if (caller != null)
					SendLogMessage (caller, string.Format (lang.GetMessage ("PlayerUnbanFailed", this, caller.UserIDString), offenderSteamid), LogLevel.Warning);
			};

			var successHandler = (JToken _) => {
				string actor = caller?.UserIDString ?? "console";

				LogMessage (string.Format (lang.GetMessage ("PlayerUnbanned", this), offenderSteamid, actor));

				if (caller != null)
					SendLogMessage (caller, string.Format (lang.GetMessage ("PlayerUnbanned", this, caller.UserIDString), offenderSteamid, actor));
			};

			HttpRequest (RequestMethod.DELETE, url, null, successHandler, failureHandler, "Unban");
		}

		private void PostRole (string steamid, string role) {
			if (role == "default")
				return;

			string url = $"players/steam/{steamid}/roles";
			JObject parameters = new (new JProperty ("name", role));

			var successHandler = (JToken _) =>
				LogMessage (string.Format (lang.GetMessage ("ConsoleRoleAdded", this), role, steamid));

			HttpRequest (RequestMethod.POST, url, parameters, successHandler, null, "RoleAdd");
		}

		private void DeleteRole (string steamid, string role) {
			string url = $"players/steam/{steamid}/roles/{role}";

			var successHandler = (JToken _) =>
				LogMessage (string.Format (lang.GetMessage ("ConsoleRoleRevoked", this), role, steamid));

			HttpRequest (RequestMethod.DELETE, url, null, successHandler, null, "RoleRevoke");
		}
		#endregion
		#endregion

		#region Utilities
		private void CallUntilReturns<T> (Func<T> callback, T value, float intervalSeconds = 5f, int maxRetries = 10)
		{
			if (maxRetries <= 0) {
				LogMessage ("CallUntilReturns: достигнут лимит попыток, останавливаем", LogLevel.Warning);
				return;
			}
			if (callback ().Equals (value))
				return;
			
			timer.In (intervalSeconds, () => CallUntilReturns (callback, value, intervalSeconds, maxRetries - 1));
		}

		private static List<BasePlayer> GetPlayersByName (string name) {
			List<BasePlayer> matches = new List<BasePlayer> ();
			string nameLower = name.ToLower ();

			foreach (BasePlayer ply in BasePlayer.activePlayerList)
				if (ply.displayName.ToLower ().Contains (nameLower))
					matches.Add (ply);

			return matches;
		}

		private static BasePlayer GetPlayerBySteamID (string steamid) {
			foreach (BasePlayer ply in BasePlayer.activePlayerList)
				if (ply.UserIDString == steamid)
					return ply;

			return null;
		}

		#region Reflection
		private static string GetMethodCommandName (string methodName) {
			return typeof (Ember).GetMethod (methodName, selfReflectionFlags)
				.GetCustomAttribute<ConsoleCommandAttribute> ().Command;
		}
		#endregion
		
		#region Timers
		protected class TimerManager {
			protected static readonly Oxide.Core.Libraries.Timer CoreTimer =
				Oxide.Core.Interface.Oxide.GetLibrary<Oxide.Core.Libraries.Timer> ("Timer");

			protected readonly Plugin Plugin;

			public TimerManager (Plugin plugin) {
				Plugin = plugin;
			}

			public TimerProxy Repeat (float interval, int repetitions, Action callback) {
				return new TimerProxy (Plugin, CoreTimer, interval, repetitions, callback);
			}

			public class TimerProxy {
				protected readonly Oxide.Core.Libraries.Timer.TimerInstance Instance;
				protected float StartedAt;

				public float DefaultInterval;
				public bool Destroyed => Instance.Destroyed;

				public TimerProxy (Plugin plugin, Oxide.Core.Libraries.Timer coreTimer, float interval, int repetitions, Action callback) {
					Action callbackProxy = () => {
						RestoreInterval ();
						callback ();
					};

					Instance = coreTimer.Repeat (interval, repetitions, callbackProxy, plugin);
					StartedAt = Oxide.Core.Interface.Oxide.Now;
					DefaultInterval = Instance.Delay;
				}

				protected void RestoreInterval () {
					if (Instance.Delay == DefaultInterval)
						return;

					Reset (DefaultInterval, Instance.Repetitions);
				}

				protected void Reset (float interval = -1, int repetitions = 1) {
					Instance.Reset (interval, repetitions);
					StartedAt = Oxide.Core.Interface.Oxide.Now;
				}

				public float Remaining () {
					if (Destroyed)
						return -1f;

					return Instance.Delay - (Oxide.Core.Interface.Oxide.Now - StartedAt) % Instance.Delay;
				}

				public void InvokeWithin (float seconds) {
					if (seconds <= 0f || Remaining () <= seconds)
						return;

					Reset (seconds, Instance.Repetitions);
				}

				public void Destroy () {
					Instance.DestroyToPool ();
				}
			}
		}
		#endregion

		#region Logging
		private void LogMessage (string message, LogLevel level = LogLevel.Info, [CallerMemberName] string callerName = "", [CallerLineNumber] int callerLine = -1) {
			if (level < config.LogLevel)
				return;

			if (level == LogLevel.Trace)
				message = string.Format ("[{0}:{1}] {2}", callerLine, callerName, message);

			message = string.Format ("[{0}] {1}", level.ToString (), message);

			if (level < LogLevel.Warning)
				Puts (message);
			else if (level < LogLevel.Error)
				PrintWarning (message);
			else
				PrintError (message);
		}
		
		private void SendLogMessage (BasePlayer player, string messageKey, LogLevel level = LogLevel.Info) {
			string message = string.Format (
				"{1}<color={0}>{2}</color>",
				GetLogLevelColor (level),
				lang.GetMessage ("DebugChatPrefix", this),
				lang.GetMessage (messageKey, this, player.UserIDString)
			);

			PrintToConsole (player, message);
			PrintToChat (player, message);
		}

		private void SendCommandReplies (ConsoleSystem.Arg arg, string messageKey, LogLevel level = LogLevel.Info) {
			BasePlayer player = arg.Player ();

			string format = player ? "<color={0}>{1}</color>" : "{1}",
				message = string.Format (
					format,
					GetLogLevelColor (level),
					lang.GetMessage (messageKey, this, player?.UserIDString)
				);

			SendReply (arg, message); // Caller console
			if (player) SendReply (player, message); // Caller chat
		}

		private static string GetLogLevelColor (LogLevel level = LogLevel.Info) {
			if (level >= LogLevel.Error)
				return "red";
			if (level >= LogLevel.Warning)
				return "orange";

			return "white";
		}
		#endregion

		#region Web API
		private void HttpRequest (RequestMethod method, string path, JObject parameters, Action<JToken> onSuccess = null, Action<int, string> onFailure = null, string failureMessageKeySuffix = "") {
			var headers = new Dictionary<string, string> { { "Authorization", "Bearer " + config.Token }, { "Accept", "application/json" } };
			string url = config.Host + "/api/game/" + path;
			string body = null;

			if (parameters != null && method != RequestMethod.GET) {
				body = parameters.ToString ();
				headers.Add ("Content-Type", "application/json");
			}

			LogMessage (string.Format ("HTTP Request: {0} {1} {2}", method, url, body?.Truncate (5000)), LogLevel.Trace);

			webrequest.Enqueue (
				url,
				body,
				(code, response) => {
					if (code < 200 || code > 299 || response == null ||
						(!TryParseJson (response, out JToken json) && response != string.Empty)
					) {
						if (failureMessageKeySuffix != string.Empty)
							LogMessage (string.Format (lang.GetMessage ("ConsoleApiRequestFailed" + failureMessageKeySuffix, this), code, response), LogLevel.Warning);

						onFailure?.Invoke (code, response);

						return;
					}

					LogMessage (string.Format ("HTTP Response: {0} {1} {2} {3}", method, url, code, json?.ToString ()), LogLevel.Trace);

					onSuccess?.Invoke (json ?? new JObject ());
				},
				this,
				method,
				headers
			);
		}
		
		private static string NormalizeHostUri (string uri) { 
			return uri.TrimEnd (new Char[] { '/' });
		}

		private static bool TryParseJson<T> (string payload, out T result) where T : JToken {
			result = null;

			try {
				result = (T) JToken.Parse (payload);
				return true;
			} catch (JsonReaderException) {
				return false;
			}
		}
		#endregion
		#endregion

		#region Init methods
		protected override void LoadConfig () {
			base.LoadConfig ();

			config = Config.ReadObject<PluginConfig> ();

			string normalizedHost = NormalizeHostUri (config.Host);

			if (config.Host != normalizedHost) {
				config.Host = normalizedHost;
				Config.WriteObject (config);
			}
		}

		protected override void LoadDefaultConfig () {
			Config.WriteObject (new PluginConfig (), true);
		}

		public void RegisterActionHandler (string type, Action<JToken> handler) {
			LogMessage ($"Registering action \"{type}\"", LogLevel.Debug);

			actionHandlers[type] = handler;
		}

		private void RegisterActionHandlers () {
			RegisterActionHandler ("command", HandleCommandAction);
			RegisterActionHandler ("roles", HandleRolesAction);
			RegisterActionHandler ("settings", HandleSettingsAction);
		}

		private void RegisterPermissions () {
			foreach (string suffix in permissions) {
				string name = string.Format ("{0}.{1}", Title.ToLower (), suffix);
				permission.RegisterPermission (name, this);
			}
		}

		private void InitPollTimer (float interval = 60f) {
			if (pollTimer?.Destroyed == false)
				return;

			pollTimer = new TimerManager (this).Repeat (interval, -1, RunPoll);
		}

		private void SetPollInterval (float interval) {
			if (interval < 0f || (pollTimer?.Destroyed == false && pollTimer.DefaultInterval == interval))
				return;

			LogMessage (string.Format (lang.GetMessage ("PollIntervalSetting", this), interval), LogLevel.Info);

			if (pollTimer?.Destroyed != false) {
				InitPollTimer (interval);
			} else {
				pollTimer.DefaultInterval = interval;
				pollTimer.InvokeWithin (interval);
			}
		}

		private void DeinitApi () {
			pollTimer?.Destroy ();

			foreach (string hook in userHooks)
				Unsubscribe (hook);

			connected = false;
		}

		private void TryInitApi () {
			retryApiInitTimer?.Destroy ();

			if (string.IsNullOrEmpty (config.Token)) {
				LogMessage ("Token unconfigured; not attempting web API init", LogLevel.Debug);
				return;
			}
			
			LogMessage (lang.GetMessage ("ConsoleApiConnectionChecking", this));

			JObject payload = JObject.FromObject (new {
				actions = new[] { "settings" },
				metadata = GetServerMetadata (),
			});

			var failureHandler = (int code, string response) => {
				LogMessage (string.Format (lang.GetMessage ("ConsoleApiRequestFailed", this), code, response), LogLevel.Error);

				DeinitApi ();

				foreach (BasePlayer player in BasePlayer.activePlayerList)
					if (player.net.connection.authLevel >= 1)
						SendLogMessage (player, "NoApiConnection", LogLevel.Error);

				if (code < 400 || code > 499)
					retryApiInitTimer = timer.Once (retryApiInitIntervalSeconds, () => TryInitApi ());
			};

			var successHandler = (JToken json) => {
				if (json.Count () == 0) {
					failureHandler (-1, string.Empty);
					return;
				}

				foreach (JToken action in json["actions"])
					DispatchAction (action);

				if (pollTimer?.Destroyed != false)
					InitPollTimer ();

				foreach (string hook in userHooks)
					Subscribe (hook);

				connected = true;

				LogMessage (lang.GetMessage ("ConsoleApiConnectionSuccess", this));

				foreach (BasePlayer player in BasePlayer.activePlayerList)
					if (player.net.connection.authLevel >= 2)
						SendLogMessage (player, "ConsoleApiConnectionSuccess");
			};

			HttpRequest (RequestMethod.POST, "meta", payload, successHandler, failureHandler);
		}

		private object GetServerMetadata () {
			bool customMap = World.CanLoadFromUrl () && !World.Url.Contains ("facepunch.com");
			uint? seed = World.Seed;
			uint size = World.Size;
			string worldName = World.Name.ToString ();

			DateTime? nextWipeUtc = WipeTimer.serverinstance?.GetWipeTime (DateTime.UtcNow).UtcDateTime;
			DateTime wipedAtUtc = SaveRestore.SaveCreatedTime.ToUniversalTime ();

			return new {
				integration_version = Version.ToString (),
				map = new {
					seed = !customMap ? seed : null,
					size,
					name = worldName,
					file_url = World.CanLoadFromUrl () ? World.Url : null,
					image_blob_path = RustMapApi != null ? mapImageBlobPath : null,
				},
				game = new
				{
					mode = BaseGameMode.GetActiveGameMode (true)?.shortname ?? "vanilla",
					ConVar.Server.maxplayers,
					protocol = Rust.Protocol.printable,
					wipe = new
					{
						id = SaveRestore.WipeId,
						began_at = wipedAtUtc.ToString ("O"),
						ends_at = nextWipeUtc?.ToString ("O"),
					},
				},
			};
		}
		#endregion

		#region Hooks
		void Init () {
			_cmdBanName = GetMethodCommandName (nameof (CmdBan));
			_cmdUnbanName = GetMethodCommandName (nameof (CmdUnban));
			RegisterPermissions ();
			RegisterActionHandlers ();
		}

		void OnServerInitialized (bool initial)
		{
			TryInitApi ();

			if (RustMapApi && initial) {
				CallUntilReturns (() => {
					if (!connected || !IsRustMapApiReady ())
						return false;

					TryUploadMapImage ();
					return true;
				}, true);
			}
		}

		void OnUserBanned (string name, string id, string ipAddress, string reason) {
			if (settings.banLog)
				Ban (id, "0", reason, false, null, null);
		}

		void OnUserUnbanned (string name, string id, string ipAddress) {
			if (settings.banLog)
				Unban (id, null);
		}

		void OnUserGroupAdded (string id, string groupName) {
			if (!settings.roleSync.Send)
				return;

			LogMessage ($"Syncing group addition for SteamID {id}, group \"{groupName}\"", LogLevel.Trace);
			PostRole (id, groupName);
		}

		void OnUserGroupRemoved (string id, string groupName) {
			if (!settings.roleSync.Send)
				return;

			LogMessage ($"Syncing group removal for SteamID {id}, group \"{groupName}\"", LogLevel.Trace);
			DeleteRole (id, groupName);
		}

		void OnPlayerConnected (BasePlayer player) {
			AddConnectingUser (player.UserIDString, player.displayName);

			if (!connected)
				if (player.net.connection.authLevel >= 1)
					SendLogMessage (player, "NoApiConnection", LogLevel.Error);
		}
		#endregion

		#region Action handlers
		private void HandleCommandAction (JToken payload) {
			var command = (string) payload;
			LogMessage (string.Format (lang.GetMessage ("ActionCommandRunning", this), command));

			rust.RunServerCommand (command);
		}

		private void HandleRolesAction (JToken payload) {
			if (!settings.roleSync.Receive && !settings.roleSync.Send)
				return;

			string steamid = (string) payload["player_steam_account_id"];
			if (string.IsNullOrEmpty (steamid)) {
				LogMessage ("HandleRolesAction: player_steam_account_id is null or empty", LogLevel.Warning);
				return;
			}

			BasePlayer player = GetPlayerBySteamID (steamid);
			string name = player?.displayName ?? "Unknown";

			LogMessage (string.Format (lang.GetMessage ("ConsoleSyncingRoles", this), name, steamid));

			if (settings.roleSync.Receive == true) {
				JToken grantToken = payload["grant"];
				if (grantToken != null && grantToken.Type == JTokenType.Array) {
					foreach (JToken roleToken in grantToken) {
						string role = roleToken?.ToString ();
						if (string.IsNullOrEmpty (role))
							continue;

						if (!permission.GroupExists (role)) {
							if (settings.roleSync.Create) {
								LogMessage (string.Format (lang.GetMessage ("ConsoleCreatingGroup", this), role));
								permission.CreateGroup (role, role, 0);
							} else {
								continue;
							}
						} else if (permission.UserHasGroup (steamid, role))
							continue;

						LogMessage (string.Format (lang.GetMessage ("ConsoleGrantingGroup", this), role, name, steamid));
						permission.AddUserGroup (steamid, role);

						if (player != null)
							SendReply (player, string.Format (lang.GetMessage ("GroupGranted", this, player.UserIDString), role));
					}
				}

				JToken revokeToken = payload["revoke"];
				if (revokeToken != null && revokeToken.Type == JTokenType.Array) {
					foreach (JToken roleToken in revokeToken) {
						string role = roleToken?.ToString ();
						if (string.IsNullOrEmpty (role))
							continue;

						if (!permission.UserHasGroup (steamid, role))
							continue;

						LogMessage (string.Format (lang.GetMessage ("ConsoleRevokingGroup", this), role, name, steamid));
						permission.RemoveUserGroup (steamid, role);

						if (player != null)
							SendReply (player, string.Format (lang.GetMessage ("GroupRevoked", this, player.UserIDString), role));
					}
				}
			}

			if (settings.roleSync.Send == true) {
				JToken exhaustiveToken = payload["exhaustive"];
				bool exhaustive = exhaustiveToken != null && exhaustiveToken.Type == JTokenType.Boolean && (bool) exhaustiveToken;

				if (exhaustive) {
					JToken grantToken = payload["grant"];
					if (grantToken != null && grantToken.Type == JTokenType.Array) {
						string[] roles = grantToken.ToObject<string[]> ();

						foreach (string group in permission.GetGroups ())
							if (permission.UserHasGroup (steamid, group))
								if (Array.IndexOf (roles, group) == -1)
									PostRole (steamid, group);
					}
				}
			}
		}

		private void HandleSettingsAction (JToken payload) {
			settings = payload?.ToObject<IntegrationSettings> () ?? new ();

			var intervalSec = (float?) payload["poll_interval_seconds"];
			LogMessage (string.Format ("Poll interval: {0}s", intervalSec ?? -1f), LogLevel.Trace);
			if (intervalSec != null) SetPollInterval ((float) intervalSec);
		}
		#endregion

		#region Console commands
		[ConsoleCommand ($"{commandNamespace}.ban")]
		private void CmdBan (ConsoleSystem.Arg arg) {
			string name = "ban", usage = lang.GetMessage ("CommandUsageBan", this, arg.Player ()?.UserIDString), permissionSuffix = "ban";
			const int minArgs = 3, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				BasePlayer admin = arg.Player ();

				string offenderSteamid = "";
				string arg0 = arg.Args[0].ToString();
				if (arg0.IsSteamId ()) {
					offenderSteamid = arg0;
				} else {
					List<BasePlayer> offenderMatches = GetPlayersByName (arg0);
					if (offenderMatches.Count > 0) {
						if (offenderMatches.Count == 1) {
							offenderSteamid = offenderMatches.First ().UserIDString;
						} else {
							SendCommandReplies (arg, lang.GetMessage ("MultiplePlayersFound", this, admin?.UserIDString));
							return;
						}
					} else {
						SendCommandReplies (arg, string.Format (lang.GetMessage ("NoPlayersFoundByName", this, admin?.UserIDString), arg0));
						return;
					}
				}

				if (!int.TryParse (arg.Args[1].ToString(), out int durationMinutes)) {
					SendCommandReplies (arg, lang.GetMessage ("InvalidTime", this, admin?.UserIDString));
					return;
				}

				BasePlayer offender = GetPlayerBySteamID (offenderSteamid);

				if (offender != null)
					offender.Kick (lang.GetMessage ("Banned", this, offender.UserIDString));

				bool global = arg.Args.Length == 4 && arg.Args[3].ToString() == "true";
				Ban (offenderSteamid, arg.Args[1].ToString(), arg.Args[2].ToString(), global, admin?.UserIDString, admin);
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel, permissionSuffix);
		}
		
		[ConsoleCommand ($"{commandNamespace}.unban")]
		private void CmdUnban (ConsoleSystem.Arg arg) {
			const string name = "unban", usage = "<SteamID64>", permissionSuffix = "unban";
			const int minArgs = 1, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				BasePlayer admin = arg.Player ();
				string offenderSteamid = arg.Args[0].ToString();

				if (!offenderSteamid.IsSteamId ()) {
					SendCommandReplies (arg, string.Format (lang.GetMessage ("InvalidArgValue", this, admin.UserIDString), "SteamID64", offenderSteamid), LogLevel.Warning);
					return;
				}

				Unban (offenderSteamid, admin);
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel, permissionSuffix);
		}

		[ConsoleCommand ($"{commandNamespace}.connect")]
		private void CmdConnect (ConsoleSystem.Arg arg) {
			const string name = "connect", usage = "<\"URL\"> <\"token\">";
			const int minArgs = 2, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				string connectUrl = arg.Args[0].ToString();
				if (!Uri.TryCreate (connectUrl, UriKind.Absolute, out Uri uri)) {
					SendReply (arg, string.Format (lang.GetMessage ("InvalidArgValue", this), "url", connectUrl));
					return;
				}

				LoadConfig ();
				config.Host = NormalizeHostUri (uri.ToString ());
				config.Token = arg.Args[1].ToString();
				Config.WriteObject (config);

				if (arg.Connection != null)
					SendReply (arg, lang.GetMessage ("ConsoleApiConnectionChecking", this));

				TryInitApi ();
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel);
		}

		[ConsoleCommand ($"{commandNamespace}.loglevel")]
		private void CmdLogLevel (ConsoleSystem.Arg arg) {
			const string name = "loglevel", usage = "[\"level\"]";
			const int minArgs = 0, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				if (!arg.HasArgs ()) {
					SendReply (arg, string.Format (lang.GetMessage ("ConfigOptionSet", this), "LogLevel", config.LogLevel));
					return;
				}

				string levelStr = arg.Args[0].ToString();
				if (!Enum.TryParse (levelStr, true, out LogLevel level)) {
					SendReply (arg, string.Format (lang.GetMessage ("InvalidArgValue", this), "level", levelStr));
					return;
				}

				LoadConfig ();
				config.LogLevel = level;
				Config.WriteObject (config);

				SendReply (arg, string.Format (lang.GetMessage ("ConfigOptionSet", this), "LogLevel", level));
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel);
		}

		[ConsoleCommand ($"{commandNamespace}.pollwithin")]
		private void CmdPollWithin (ConsoleSystem.Arg arg) {
			const string name = "pollwithin", usage = "<\"seconds\">";
			const int minArgs = 1, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				if (!connected) {
					SendReply (arg, lang.GetMessage ("NoApiConnection", this));
					return;
				}

				RunPollWithin (float.Parse (arg.Args[0].ToString()));
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel);
		}

		private void HandleConsoleCommand (ConsoleSystem.Arg arg, string name, string usage, Action<ConsoleSystem.Arg> handler, int minArgs = 0, int minAuthLevel = 2, string permissionSuffix = null) {
			string namespacedCommand = $"{commandNamespace}.{name}";

			if (arg.Connection != null) {
				BasePlayer player = arg.Player ();
				string namespacedPermission = permissionSuffix != null ?
					$"{Title.ToLower ()}.{permissionSuffix}" : null;

				if (player.net.connection.authLevel < minAuthLevel && !permission.UserHasPermission (player.UserIDString, namespacedPermission)) {
					SendCommandReplies (arg, string.Format (lang.GetMessage ("CommandNoPermission", this, player.UserIDString), namespacedCommand), LogLevel.Warning);
					return;
				}
			}

			if (minArgs > 0 && (!arg.HasArgs () || arg.Args.Length < minArgs)) {
				string format = $"{lang.GetMessage ("InvalidArgs", this)}. {lang.GetMessage ("CommandUsage", this)}",
					fullUsageSyntax = $"{namespacedCommand} {usage}";

				SendCommandReplies (arg, string.Format (format, fullUsageSyntax), LogLevel.Warning);
				return;
			}

			handler (arg);
		}
		#endregion

		#region Chat commands
		[ChatCommand ("ban")]
		private void ChatCmdBan (BasePlayer player, string _command, string[] args) {
			player.SendConsoleCommand (_cmdBanName, args);
		}

		[ChatCommand ("unban")]
		private void ChatCmdUnban (BasePlayer player, string _command, string[] args) {
			player.SendConsoleCommand (_cmdUnbanName, args);
		}
		#endregion

		#region Plugin interop
		#region Rust Map Api
		private bool IsRustMapApiReady () {
			bool isReady = RustMapApi?.Call<bool> ("IsReady") ?? false;
			if (!isReady) LogMessage ($"Rust Map Api is not ready", LogLevel.Trace);
			return isReady;
		}

		private bool TryUploadMapImage () {
			if (!IsRustMapApiReady ())
				return false;

			string mapName = !string.IsNullOrEmpty (config.MapImageName) ? config.MapImageName : "Icons";
			int resolution = (int) (config.MapImageScale * World.Size);

			object response = RustMapApi.Call ("CreatePluginImage", this, mapName, resolution, (int) mapImageEncoding);
			if (response is string) {
				LogMessage ($"Rust Map Api call failed: {response}", LogLevel.Warning);
				return false;
			}

			var map = response as Hash<string, object>;
			if (map?["image"] is byte[] mapBytes) {
				LogMessage ($"Map image size: {mapBytes.Length} bytes", LogLevel.Trace);
			} else {
				LogMessage ($"Image missing from Rust Map Api response", LogLevel.Warning);
				return false;
			}

			// Кодирование и отправка в фоновом потоке — не блокирует игровой сервер
			Task.Run (() => {
				try {
					string base64 = Convert.ToBase64String (mapBytes);
					string body = "{\"visibility\":\"public\",\"encoding\":\"base64\",\"content\":\"" + base64 + "\"}"; 
					NextTick (() => {
						var headers = new Dictionary<string, string> {
							{ "Authorization", "Bearer " + config.Token },
							{ "Accept", "application/json" },
							{ "Content-Type", "application/json" }
						};
						string url = config.Host + "/api/game/blobs/" + mapImageBlobPath;
						webrequest.Enqueue (url, body, (code, response) => {
							if (code < 200 || code > 299)
								LogMessage ($"Map upload failed: {code} {response}", LogLevel.Warning);
							else
								LogMessage ("Map image uploaded successfully", LogLevel.Debug);
						}, this, RequestMethod.PUT, headers);
					});
				} catch (Exception ex) {
					NextTick (() => LogMessage ($"Map image encoding failed: {ex.Message}", LogLevel.Warning));
				}
			});
			return true;
		}
		#endregion
		#endregion

		#region Localization
		protected override void LoadDefaultMessages () {
			lang.RegisterMessages (new Dictionary<string, string> {
				{ "ActionCommandRunning", "Running action command: {0}"},
				{ "ActionDispatching", "Handling action \"{0}\""},
				{ "ActionUnrecognized", "Unrecognized action \"{0}\""},
				{ "Banned", "You've been banned from the server" },
				{ "CommandUsage", "Usage: {0}" },
				{ "CommandUsageBan", "<name/SteamID64> <duration:minutes, 0=permanent> \"<reason>\" [global:false/true]" },
				{ "CommandNoPermission", "Insufficient permissions for running command \"{0}\"" },
				{ "ConfigOptionSet", "Configuration option {0} set to {1}" },
				{ "ConsoleApiConnectionChecking", "Checking connection to web API" },
				{ "ConsoleApiConnectionSuccess", "Connection established and token validated successfully" },
				{ "ConsoleApiRequestFailed", "Web API request failed. Code: {0}. Response: {1}" },
				{ "ConsoleApiRequestFailedBan", "Failed to ban user. Code: {0}. Response: {1}" },
				{ "ConsoleApiRequestFailedRoleAdd", "Failed to add role \"{0}\". Code: {1}. Response: {2}" },
				{ "ConsoleApiRequestFailedRoleRevoke", "Failed to revoke role \"{0}\". Code: {1}. Response: {2}" },
				{ "ConsoleApiRequestFailedUnban", "Failed to unban user. Code: {0}. Response: {1}" },
				{ "ConsoleCreatingGroup", "Creating group \"{0}\"" },
				{ "ConsoleGrantingGroup", "Granting the \"{0}\" group to {1} ({2})" },
				{ "ConsolePolling", "Polling with {0} player(s)" },
				{ "ConsoleRevokingGroup", "Revoking the \"{0}\" group from {1} ({2})" },
				{ "ConsoleRoleAdded", "Role \"{0}\" added for {1}" },
				{ "ConsoleRoleRevoked", "Role \"{0}\" revoked from {1}" },
				{ "ConsoleSyncingRoles", "Syncing roles for {0} ({1})" },
				{ "DebugChatPrefix", "[Ember] " },
				{ "GroupGranted", "You've been granted the {0} group" },
				{ "GroupRevoked", "Your {0} group has been revoked" },
				{ "InvalidArgs", "Invalid arguments" },
				{ "InvalidArgValue", "Invalid value for argument {0}: \"{1}\"" },
				{ "InvalidTime", "Time must be a number, 0 for permanent" },
				{ "MultiplePlayersFound", "Multiple players found, please be more specific" },
				{ "NoApiConnection", "The plugin is not connected to the web API. Check the server console for details" },
				{ "NoPlayersFoundByName", "Player not found by name \"{0}\"" },
				{ "PlayerBanFailed", "Failed to ban player {0}" },
				{ "PlayerBanned", "Player {0} banned" },
				{ "PlayerUnbanFailed", "Failed to unban player {0}" },
				{ "PlayerUnbanned", "Player {0} unbanned by {1}" },
				{ "PollIntervalSetting", "Setting the polling interval to {0} second(s)" },
			}, this, "en");

			LogMessage ("Default messages created", LogLevel.Debug);
		}
		#endregion
	}
}​

The above repair plan will result in the transmission of blank player data to the website database

Hi ive been running this one since the last update and ive not had any issues

Ember.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core.Libraries;
using Oxide.Core.Plugins;
using UnityEngine;

namespace Oxide.Plugins {
	[Info ("Ember", "Mkekala", "2.0.1")]
	[Description ("Integrates Ember actions, ban management & role sync with Rust")]
	public class Ember : RustPlugin {
		#region Configuration
		private class PluginConfig {
			public string Host = "http://*.*.*.*";
			public string Token;
			public LogLevel LogLevel = LogLevel.Info;
		}

		private enum LogLevel { Trace, Debug, Info, Warning, Error };

		private PluginConfig config;

		private class IntegrationSettings {
			[JsonProperty ("ban_log")]
			public bool banLog;
			[JsonProperty ("role_sync")]
			public RoleSyncSettings roleSync = new ();
		}

		private class RoleSyncSettings {
			[JsonProperty ("create")]
			public bool Create;
			[JsonProperty ("send")]
			public bool Send;
			[JsonProperty ("receive")]
			public bool Receive;
		}

		private IntegrationSettings settings = new ();
		#endregion

		#region Constants
		private const string commandNamespace = "ember";

		private static readonly string[] permissions = {
			"ban",
			"unban",
		};

		private static readonly string[] userHooks = {
			"OnUserBanned",
			"OnUserUnbanned",
			"OnUserGroupAdded",
			"OnUserGroupRemoved",
		};

		private const float retryApiInitIntervalSeconds = 60f,
			maxPollTimerDelayUserConnectingSeconds = 5f;

		private const BindingFlags selfReflectionFlags = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.NonPublic;

		private const string mapImageBlobPath = "map.jpg";
		private const RustMapApiEncodingMode mapImageEncoding = RustMapApiEncodingMode.Jpg;
		private enum RustMapApiEncodingMode { Jpg = 1, Png = 2 };
		private const float mapImageScale = 1f;
		#endregion

		#region Variables
		private bool connected;
		private Dictionary<string, Action<JToken>> actionHandlers = new ();
		private Dictionary<string, string> connectingUsers = new ();
		private Timer retryApiInitTimer;
		private TimerManager.TimerProxy pollTimer;

		[PluginReference]
		private Plugin RustMapApi;
		#endregion

		#region Domain methods
		#region Polling/action flow
		private void AddConnectingUser (string steamid, string name = "") {
			if (connectingUsers.ContainsKey (steamid))
				return;

			connectingUsers.Add (steamid, name);

			if (connected) RunPollWithin (maxPollTimerDelayUserConnectingSeconds);
		}

		private void RunPoll () {
			var connectingUsersBatch = Interlocked.Exchange (ref connectingUsers, new ());
			int playerCount = connectingUsersBatch.Count + BasePlayer.activePlayerList.Count;

			LogMessage ($"Poll invoked with {playerCount} player(s)", LogLevel.Trace);

			if (playerCount == 0)
				return;

			List<string> names = new ();
			foreach (string name in connectingUsersBatch.Values)
				names.Add (name.Replace (",", ""));

			List<string> connectedUserIds = new ();

			foreach (BasePlayer ply in BasePlayer.activePlayerList)
				if (!connectingUsersBatch.ContainsKey (ply.UserIDString))
					connectedUserIds.Add (ply.UserIDString);
				else playerCount -= 1;

			JObject payload = JObject.FromObject (new {
				players = new {
					steam = new {
						connecting = new {
							ids = string.Join (",", connectingUsersBatch.Keys),
							names = string.Join (",", names),
						},
						connected = new {
							ids = string.Join (",", connectedUserIds)
						}
					}
				}
			});
			
			LogMessage (string.Format (lang.GetMessage ("ConsolePolling", this), playerCount));

			var failureHandler = (int code, string response) => {
				LogMessage (string.Format (lang.GetMessage ("ConsoleApiRequestFailed", this), code, response), LogLevel.Error);

				if (connectingUsersBatch.Count > 0)
					LogMessage ($"Queuing poll retry for {connectingUsersBatch.Count} connecting player(s)", LogLevel.Debug);
				foreach (var (steamid, name) in connectingUsersBatch.Select ((u) => (u.Key, u.Value)))
					AddConnectingUser (steamid, name);
			};

			var successHandler = (JToken response) => {
				foreach (JToken action in response as JArray)
					DispatchAction (action);
			};

			HttpRequest (RequestMethod.POST, "poll", payload, successHandler, failureHandler);
		}

		private void RunPollWithin (float seconds) {
			LogMessage (string.Format ("Timer interval: {0}s, time remaining: {1}s, run within: {2}s", pollTimer.DefaultInterval, pollTimer.Remaining (), seconds), LogLevel.Trace);
			pollTimer.InvokeWithin (seconds);
		}

		private void DispatchAction (JToken action) {
			string type = (string) action["type"];

			LogMessage (action.ToString (), LogLevel.Trace);

			if (!actionHandlers.ContainsKey (type)) {
				LogMessage (string.Format (lang.GetMessage ("ActionUnrecognized", this), type), LogLevel.Warning);
				return;
			}

			LogMessage (string.Format (lang.GetMessage ("ActionDispatching", this), type), LogLevel.Debug);

			var handler = actionHandlers[type];

			string error = string.Empty;

			try {
				handler (action["payload"]);
			} catch (Exception ex) {
				error = ex.Message;

				LogMessage (string.Format ("Action execution failed for type \"{0}\". Error: {1}", type, error), LogLevel.Warning);
			}

			var execuctionId = (float?) action["execution_id"];
			if (execuctionId != null)
				HttpRequest (RequestMethod.POST, $"actions/executions/{execuctionId}/finish", JObject.FromObject (new { error }));
		}
		#endregion

		#region Player endpoints
		public void Ban (string offenderSteamid, string durationMinutes, string reason, bool global, string adminSteamid, BasePlayer caller) {
			string scope = global ? "global" : null;
			
			var parameters = JObject.FromObject (new {
				scope,
				reason,
				duration_minutes = durationMinutes,
			});

			if (adminSteamid != null)
				parameters["admin_steam_account_id"] = adminSteamid;
			
			string url = $"players/steam/{offenderSteamid}/bans";

			var failureHandler = (int code, string response) => {
				if (caller != null)
					SendLogMessage (caller, string.Format (lang.GetMessage ("PlayerBanFailed", this, caller.UserIDString), offenderSteamid), LogLevel.Warning);
			};

			var successHandler = (JToken _) => {
				LogMessage (string.Format (lang.GetMessage ("PlayerBanned", this), offenderSteamid));

				if (caller != null)
					SendLogMessage (caller, string.Format (lang.GetMessage ("PlayerBanned", this, caller.UserIDString), offenderSteamid));
			};

			HttpRequest (RequestMethod.POST, url, parameters, successHandler, failureHandler, "Ban");
		}

		public void Unban (string offenderSteamid, BasePlayer caller) {
			string url = $"players/steam/{offenderSteamid}/bans";

			var failureHandler = (int code, string response) => {
				if (caller != null)
					SendLogMessage (caller, string.Format (lang.GetMessage ("PlayerUnbanFailed", this, caller.UserIDString), offenderSteamid), LogLevel.Warning);
			};

			var successHandler = (JToken _) => {
				string actor = caller?.UserIDString ?? "console";

				LogMessage (string.Format (lang.GetMessage ("PlayerUnbanned", this), offenderSteamid, actor));

				if (caller != null)
					SendLogMessage (caller, string.Format (lang.GetMessage ("PlayerUnbanned", this, caller.UserIDString), offenderSteamid, actor));
			};

			HttpRequest (RequestMethod.DELETE, url, null, successHandler, failureHandler, "Unban");
		}

		private void PostRole (string steamid, string role) {
			if (role == "default")
				return;

			string url = $"players/steam/{steamid}/roles";
			JObject parameters = new (new JProperty ("name", role));

			var successHandler = (JToken _) =>
				LogMessage (string.Format (lang.GetMessage ("ConsoleRoleAdded", this), role, steamid));

			HttpRequest (RequestMethod.POST, url, parameters, successHandler, null, "RoleAdd");
		}

		private void DeleteRole (string steamid, string role) {
			string url = $"players/steam/{steamid}/roles/{role}";

			var successHandler = (JToken _) =>
				LogMessage (string.Format (lang.GetMessage ("ConsoleRoleRevoked", this), role, steamid));

			HttpRequest (RequestMethod.DELETE, url, null, successHandler, null, "RoleRevoke");
		}
		#endregion
		#endregion

		#region Utilities
		private void CallUntilReturns<T> (Func<T> callback, T value, float intervalSeconds = 5f)
		{
			if (callback ().Equals (value))
				return;
			
			timer.In (intervalSeconds, () => CallUntilReturns (callback, value, intervalSeconds));
		}

		private static List<BasePlayer> GetPlayersByName (string name) {
			List<BasePlayer> matches = new List<BasePlayer> ();

			foreach (BasePlayer ply in BasePlayer.activePlayerList)
				if (ply.displayName.ToLower ().Contains (name.ToLower ()))
					matches.Add (ply);

			if (matches.Count () > 0)
				return matches;

			return null;
		}

		private static BasePlayer GetPlayerBySteamID (string steamid) {
			foreach (BasePlayer ply in BasePlayer.activePlayerList)
				if (ply.UserIDString == steamid)
					return ply;

			return null;
		}

		#region Reflection
		private static string GetMethodCommandName (string methodName) {
			return typeof (Ember).GetMethod (methodName, selfReflectionFlags)
				.GetCustomAttribute<ConsoleCommandAttribute> ().Command;
		}
		#endregion
		
		#region Timers
		protected class TimerManager {
			protected static readonly Oxide.Core.Libraries.Timer CoreTimer =
				Oxide.Core.Interface.Oxide.GetLibrary<Oxide.Core.Libraries.Timer> ("Timer");

			protected readonly Plugin Plugin;

			public TimerManager (Plugin plugin) {
				Plugin = plugin;
			}

			public TimerProxy Repeat (float interval, int repetitions, Action callback) {
				return new TimerProxy (Plugin, CoreTimer, interval, repetitions, callback);
			}

			public class TimerProxy {
				protected readonly Oxide.Core.Libraries.Timer.TimerInstance Instance;
				protected float StartedAt;

				public float DefaultInterval;
				public bool Destroyed => Instance.Destroyed;

				public TimerProxy (Plugin plugin, Oxide.Core.Libraries.Timer coreTimer, float interval, int repetitions, Action callback) {
					Action callbackProxy = () => {
						RestoreInterval ();
						callback ();
					};

					Instance = coreTimer.Repeat (interval, repetitions, callbackProxy, plugin);
					StartedAt = Oxide.Core.Interface.Oxide.Now;
					DefaultInterval = Instance.Delay;
				}

				protected void RestoreInterval () {
					if (Instance.Delay == DefaultInterval)
						return;

					Reset (DefaultInterval, Instance.Repetitions);
				}

				protected void Reset (float interval = -1, int repetitions = 1) {
					Instance.Reset (interval, repetitions);
					StartedAt = Oxide.Core.Interface.Oxide.Now;
				}

				public float Remaining () {
					if (Destroyed)
						return -1f;

					return Instance.Delay - (Oxide.Core.Interface.Oxide.Now - StartedAt) % Instance.Delay;
				}

				public void InvokeWithin (float seconds) {
					if (seconds <= 0f || Remaining () <= seconds)
						return;

					Reset (seconds, Instance.Repetitions);
				}

				public void Destroy () {
					Instance.DestroyToPool ();
				}
			}
		}
		#endregion

		#region Logging
		private void LogMessage (string message, LogLevel level = LogLevel.Info, [CallerMemberName] string callerName = "", [CallerLineNumber] int callerLine = -1) {
			if (level < config.LogLevel)
				return;

			if (level == LogLevel.Trace)
				message = string.Format ("[{0}:{1}] {2}", callerLine, callerName, message);

			message = string.Format ("[{0}] {1}", level.ToString (), message);

			if (level < LogLevel.Warning)
				Puts (message);
			else if (level < LogLevel.Error)
				PrintWarning (message);
			else
				PrintError (message);
		}
		
		private void SendLogMessage (BasePlayer player, string messageKey, LogLevel level = LogLevel.Info) {
			string message = string.Format (
				"{1}<color={0}>{2}</color>",
				GetLogLevelColor (level),
				lang.GetMessage ("DebugChatPrefix", this),
				lang.GetMessage (messageKey, this, player.UserIDString)
			);

			PrintToConsole (player, message);
			PrintToChat (player, message);
		}

		private void SendCommandReplies (ConsoleSystem.Arg arg, string messageKey, LogLevel level = LogLevel.Info) {
			BasePlayer player = arg.Player ();

			string format = player ? "<color={0}>{1}</color>" : "{1}",
				message = string.Format (
					format,
					GetLogLevelColor (level),
					lang.GetMessage (messageKey, this, player?.UserIDString)
				);

			SendReply (arg, message); // Caller console
			if (player) SendReply (player, message); // Caller chat
		}

		private static string GetLogLevelColor (LogLevel level = LogLevel.Info) {
			if (level >= LogLevel.Error)
				return "red";
			if (level >= LogLevel.Warning)
				return "orange";

			return "white";
		}
		#endregion

		#region Web API
		private void HttpRequest (RequestMethod method, string path, JObject parameters, Action<JToken> onSuccess = null, Action<int, string> onFailure = null, string failureMessageKeySuffix = "") {
			var headers = new Dictionary<string, string> { { "Authorization", "Bearer " + config.Token }, { "Accept", "application/json" } };
			string url = config.Host + "/api/game/" + path;
			string body = null;

			if (parameters != null && method != RequestMethod.GET) {
				body = parameters.ToString ();
				headers.Add ("Content-Type", "application/json");
			}

			LogMessage (string.Format ("HTTP Request: {0} {1} {2}", method, url, body?.Truncate (5000)), LogLevel.Trace);

			webrequest.Enqueue (
				url,
				body,
				(code, response) => {
					if (code < 200 || code > 299 || response == null ||
						(!TryParseJson (response, out JToken json) && response != string.Empty)
					) {
						if (failureMessageKeySuffix != string.Empty)
							LogMessage (string.Format (lang.GetMessage ("ConsoleApiRequestFailed" + failureMessageKeySuffix, this), code, response), LogLevel.Warning);

						onFailure?.Invoke (code, response);

						return;
					}

					LogMessage (string.Format ("HTTP Response: {0} {1} {2} {3}", method, url, code, json?.ToString ()), LogLevel.Trace);

					onSuccess?.Invoke (json ?? new JObject ());
				},
				this,
				method,
				headers
			);
		}
		
		private static string NormalizeHostUri (string uri) { 
			return uri.TrimEnd (new Char[] { '/' });
		}

		private static bool TryParseJson<T> (string payload, out T result) where T : JToken {
			result = null;

			try {
				result = (T) JToken.Parse (payload);
				return true;
			} catch (JsonReaderException) {
				return false;
			}
		}
		#endregion
		#endregion

		#region Init methods
		protected override void LoadConfig () {
			base.LoadConfig ();

			config = Config.ReadObject<PluginConfig> ();

			string normalizedHost = NormalizeHostUri (config.Host);

			if (config.Host != normalizedHost) {
				config.Host = normalizedHost;
				Config.WriteObject (config);
			}
		}

		protected override void LoadDefaultConfig () {
			Config.WriteObject (new PluginConfig (), true);
		}

		public void RegisterActionHandler (string type, Action<JToken> handler) {
			LogMessage ($"Registering action \"{type}\"", LogLevel.Debug);

			actionHandlers[type] = handler;
		}

		private void RegisterActionHandlers () {
			RegisterActionHandler ("command", HandleCommandAction);
			RegisterActionHandler ("roles", HandleRolesAction);
			RegisterActionHandler ("settings", HandleSettingsAction);
		}

		private void RegisterPermissions () {
			foreach (string suffix in permissions) {
				string name = string.Format ("{0}.{1}", Title.ToLower (), suffix);
				permission.RegisterPermission (name, this);
			}
		}

		private void InitPollTimer (float interval = 60f) {
			if (pollTimer?.Destroyed == false)
				return;

			pollTimer = new TimerManager (this).Repeat (interval, -1, RunPoll);
		}

		private void SetPollInterval (float interval) {
			if (interval < 0f || (pollTimer?.Destroyed == false && pollTimer.DefaultInterval == interval))
				return;

			LogMessage (string.Format (lang.GetMessage ("PollIntervalSetting", this), interval), LogLevel.Info);

			if (pollTimer?.Destroyed != false) {
				InitPollTimer (interval);
			} else {
				pollTimer.DefaultInterval = interval;
				pollTimer.InvokeWithin (interval);
			}
		}

		private void DeinitApi () {
			pollTimer?.Destroy ();

			foreach (string hook in userHooks)
				Unsubscribe (hook);

			connected = false;
		}

		private void TryInitApi () {
			retryApiInitTimer?.Destroy ();

			if (string.IsNullOrEmpty (config.Token)) {
				LogMessage ("Token unconfigured; not attempting web API init", LogLevel.Debug);
				return;
			}
			
			LogMessage (lang.GetMessage ("ConsoleApiConnectionChecking", this));

			JObject payload = JObject.FromObject (new {
				actions = new[] { "settings" },
				metadata = GetServerMetadata (),
			});

			var failureHandler = (int code, string response) => {
				LogMessage (string.Format (lang.GetMessage ("ConsoleApiRequestFailed", this), code, response), LogLevel.Error);

				DeinitApi ();

				foreach (BasePlayer player in BasePlayer.activePlayerList)
					if (player.net.connection.authLevel >= 1)
						SendLogMessage (player, "NoApiConnection", LogLevel.Error);

				if (code < 400 || code > 499)
					retryApiInitTimer = timer.Once (retryApiInitIntervalSeconds, () => TryInitApi ());
			};

			var successHandler = (JToken json) => {
				if (json.Count () == 0) {
					failureHandler (-1, string.Empty);
					return;
				}

				foreach (JToken action in json["actions"])
					DispatchAction (action);

				if (pollTimer?.Destroyed != false)
					InitPollTimer ();

				foreach (string hook in userHooks)
					Subscribe (hook);

				connected = true;

				LogMessage (lang.GetMessage ("ConsoleApiConnectionSuccess", this));

				foreach (BasePlayer player in BasePlayer.activePlayerList)
					if (player.net.connection.authLevel >= 2)
						SendLogMessage (player, "ConsoleApiConnectionSuccess");
			};

			HttpRequest (RequestMethod.POST, "meta", payload, successHandler, failureHandler);
		}

		private object GetServerMetadata () {
			bool customMap = World.CanLoadFromUrl () && !World.Url.Contains ("facepunch.com");
			uint? seed = World.Seed;
			uint size = World.Size;
			string worldName = World.Name.ToString ();

			DateTime? nextWipeUtc = WipeTimer.serverinstance?.GetWipeTime (DateTime.UtcNow).UtcDateTime;
			DateTime wipedAtUtc = SaveRestore.SaveCreatedTime.ToUniversalTime ();

			return new {
				integration_version = Version.ToString (),
				map = new {
					seed = !customMap ? seed : null,
					size,
					name = worldName,
					file_url = World.CanLoadFromUrl () ? World.Url : null,
					image_blob_path = RustMapApi != null ? mapImageBlobPath : null,
				},
				game = new
				{
					mode = BaseGameMode.GetActiveGameMode (true)?.shortname ?? "vanilla",
					ConVar.Server.maxplayers,
					protocol = Rust.Protocol.printable,
					wipe = new
					{
						id = SaveRestore.WipeId,
						began_at = wipedAtUtc.ToString ("O"),
						ends_at = nextWipeUtc?.ToString ("O"),
					},
				},
			};
		}
		#endregion

		#region Hooks
		void Init () {
			RegisterPermissions ();
			RegisterActionHandlers ();
		}

		void OnServerInitialized (bool initial)
		{
			TryInitApi ();

			if (RustMapApi && initial) {
				CallUntilReturns (() => {
					if (!connected || !IsRustMapApiReady ())
						return false;

					TryUploadMapImage ();
					return true;
				}, true);
			}
		}

		void OnUserBanned (string name, string id, string ipAddress, string reason) {
			if (settings.banLog)
				Ban (id, "0", reason, false, null, null);
		}

		void OnUserUnbanned (string name, string id, string ipAddress) {
			if (settings.banLog)
				Unban (id, null);
		}

		void OnUserGroupAdded (string id, string groupName) {
			if (!settings.roleSync.Send)
				return;

			LogMessage ($"Syncing group addition for SteamID {id}, group \"{groupName}\"", LogLevel.Trace);
			PostRole (id, groupName);
		}

		void OnUserGroupRemoved (string id, string groupName) {
			if (!settings.roleSync.Send)
				return;

			LogMessage ($"Syncing group removal for SteamID {id}, group \"{groupName}\"", LogLevel.Trace);
			DeleteRole (id, groupName);
		}

		void OnPlayerConnected (BasePlayer player) {
			AddConnectingUser (player.UserIDString, player.displayName);

			if (!connected)
				if (player.net.connection.authLevel >= 1)
					SendLogMessage (player, "NoApiConnection", LogLevel.Error);
		}
		#endregion

		#region Action handlers
		private void HandleCommandAction (JToken payload) {
			var command = (string) payload;
			LogMessage (string.Format (lang.GetMessage ("ActionCommandRunning", this), command));

			rust.RunServerCommand (command);
		}

		private void HandleRolesAction (JToken payload) {
			if (!settings.roleSync.Receive && !settings.roleSync.Send)
				return;

			string steamid = (string) payload["player_steam_account_id"];
			BasePlayer player = GetPlayerBySteamID (steamid);
			string name = player?.displayName ?? "Unknown";

			LogMessage (string.Format (lang.GetMessage ("ConsoleSyncingRoles", this), name, steamid));

			if (settings.roleSync.Receive == true) {
				foreach (string role in payload["grant"]) {
					if (!permission.GroupExists (role)) {
						if (settings.roleSync.Create) {
							LogMessage (string.Format (lang.GetMessage ("ConsoleCreatingGroup", this), role));
							permission.CreateGroup (role, role, 0);
						} else {
							continue;
						}
					} else if (permission.UserHasGroup (steamid, role))
						continue;

					LogMessage (string.Format (lang.GetMessage ("ConsoleGrantingGroup", this), role, name, steamid));
					permission.AddUserGroup (steamid, role);

					if (player != null)
						SendReply (player, string.Format (lang.GetMessage ("GroupGranted", this, player.UserIDString), role));
				}

				foreach (string role in payload["revoke"]) {
					if (!permission.UserHasGroup (steamid, role))
						continue;

					LogMessage (string.Format (lang.GetMessage ("ConsoleRevokingGroup", this), role, name, steamid));
					permission.RemoveUserGroup (steamid, role);

					if (player != null)
						SendReply (player, string.Format (lang.GetMessage ("GroupRevoked", this, player.UserIDString), role));
				}
			}

			if (settings.roleSync.Send == true && (bool) payload["exhaustive"] == true) {
				string[] roles = payload["grant"].ToObject<string[]> ();

				foreach (string group in permission.GetGroups ())
					if (permission.UserHasGroup (steamid, group))
						if (Array.IndexOf (roles, group) == -1)
							PostRole (steamid, group);
			}
		}

		private void HandleSettingsAction (JToken payload) {
			settings = payload?.ToObject<IntegrationSettings> () ?? new ();

			var intervalSec = (float?) payload["poll_interval_seconds"];
			LogMessage (string.Format ("Poll interval: {0}s", intervalSec ?? -1f), LogLevel.Trace);
			if (intervalSec != null) SetPollInterval ((float) intervalSec);
		}
		#endregion

		#region Console commands
		[ConsoleCommand ($"{commandNamespace}.ban")]
		private void CmdBan (ConsoleSystem.Arg arg) {
			string name = "ban", usage = lang.GetMessage ("CommandUsageBan", this, arg.Player ()?.UserIDString), permissionSuffix = "ban";
			const int minArgs = 3, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				BasePlayer admin = arg.Player ();

				string offenderSteamid = "";
				if (((string) arg.Args[0]).IsSteamId ()) {
					offenderSteamid = (string) arg.Args[0];
				} else {
					List<BasePlayer> offenderMatches = GetPlayersByName ((string) arg.Args[0]);
					if (offenderMatches != null) {
						if (offenderMatches.Count () == 1) {
							offenderSteamid = offenderMatches.First ().UserIDString;
						} else {
							SendCommandReplies (arg, lang.GetMessage ("MultiplePlayersFound", this, admin?.UserIDString));
							return;
						}
					} else {
						SendCommandReplies (arg, string.Format (lang.GetMessage ("NoPlayersFoundByName", this, admin?.UserIDString), (string) arg.Args[0]));
						return;
					}
				}

				if (!int.TryParse ((string) arg.Args[1], out int durationMinutes)) {
					SendCommandReplies (arg, lang.GetMessage ("InvalidTime", this, admin?.UserIDString));
					return;
				}

				BasePlayer offender = GetPlayerBySteamID (offenderSteamid);

				if (offender != null)
					offender.Kick (lang.GetMessage ("Banned", this, offender.UserIDString));

				bool global = arg.Args.Length == 4 && arg.Args[3] == "true";
				Ban (offenderSteamid, (string) arg.Args[1], (string) arg.Args[2], global, admin?.UserIDString, admin);
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel, permissionSuffix);
		}
		
		[ConsoleCommand ($"{commandNamespace}.unban")]
		private void CmdUnban (ConsoleSystem.Arg arg) {
			const string name = "unban", usage = "<SteamID64>", permissionSuffix = "unban";
			const int minArgs = 1, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				BasePlayer admin = arg.Player ();
				string offenderSteamid = (string) arg.Args[0];

				if (!offenderSteamid.IsSteamId ()) {
					SendCommandReplies (arg, string.Format (lang.GetMessage ("InvalidArgValue", this, admin.UserIDString), "SteamID64", offenderSteamid), LogLevel.Warning);
					return;
				}

				Unban (offenderSteamid, admin);
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel, permissionSuffix);
		}

		[ConsoleCommand ($"{commandNamespace}.connect")]
		private void CmdConnect (ConsoleSystem.Arg arg) {
			const string name = "connect", usage = "<\"URL\"> <\"token\">";
			const int minArgs = 2, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				if (!Uri.TryCreate ((string) arg.Args[0], UriKind.Absolute, out Uri uri)) {
					SendReply (arg, string.Format (lang.GetMessage ("InvalidArgValue", this), "url", (string) arg.Args[0]));
					return;
				}

				LoadConfig ();
				config.Host = NormalizeHostUri (uri.ToString ());
				config.Token = (string) arg.Args[1];
				Config.WriteObject (config);

				if (arg.Connection != null)
					SendReply (arg, lang.GetMessage ("ConsoleApiConnectionChecking", this));

				TryInitApi ();
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel);
		}

		[ConsoleCommand ($"{commandNamespace}.loglevel")]
		private void CmdLogLevel (ConsoleSystem.Arg arg) {
			const string name = "loglevel", usage = "[\"level\"]";
			const int minArgs = 0, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				if (!arg.HasArgs ()) {
					SendReply (arg, string.Format (lang.GetMessage ("ConfigOptionSet", this), "LogLevel", config.LogLevel));
					return;
				}

				if (!Enum.TryParse ((string) arg.Args[0], true, out LogLevel level)) {
					SendReply (arg, string.Format (lang.GetMessage ("InvalidArgValue", this), "level", (string) arg.Args[0]));
					return;
				}

				LoadConfig ();
				config.LogLevel = level;
				Config.WriteObject (config);

				SendReply (arg, string.Format (lang.GetMessage ("ConfigOptionSet", this), "LogLevel", level));
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel);
		}

		[ConsoleCommand ($"{commandNamespace}.pollwithin")]
		private void CmdPollWithin (ConsoleSystem.Arg arg) {
			const string name = "pollwithin", usage = "<\"seconds\">";
			const int minArgs = 1, minAuthLevel = 2;

			var handler = (ConsoleSystem.Arg arg) => {
				if (!connected) {
					SendReply (arg, lang.GetMessage ("NoApiConnection", this));
					return;
				}

				RunPollWithin (float.Parse ((string) arg.Args[0]));
			};

			HandleConsoleCommand (arg, name, usage, handler, minArgs, minAuthLevel);
		}

		private void HandleConsoleCommand (ConsoleSystem.Arg arg, string name, string usage, Action<ConsoleSystem.Arg> handler, int minArgs = 0, int minAuthLevel = 2, string permissionSuffix = null) {
			string namespacedCommand = $"{commandNamespace}.{name}";

			if (arg.Connection != null) {
				BasePlayer player = arg.Player ();
				string namespacedPermission = permissionSuffix != null ?
					$"{Title.ToLower ()}.{permissionSuffix}" : null;

				if (player.net.connection.authLevel < minAuthLevel && !permission.UserHasPermission (player.UserIDString, namespacedPermission)) {
					SendCommandReplies (arg, string.Format (lang.GetMessage ("CommandNoPermission", this, player.UserIDString), namespacedCommand), LogLevel.Warning);
					return;
				}
			}

			if (minArgs > 0 && (!arg.HasArgs () || arg.Args.Length < minArgs)) {
				string format = $"{lang.GetMessage ("InvalidArgs", this)}. {lang.GetMessage ("CommandUsage", this)}",
					fullUsageSyntax = $"{namespacedCommand} {usage}";

				SendCommandReplies (arg, string.Format (format, fullUsageSyntax), LogLevel.Warning);
				return;
			}

			handler (arg);
		}
		#endregion

		#region Chat commands
		[ChatCommand ("ban")]
		private void ChatCmdBan (BasePlayer player, string _command, string[] args) {
			player.SendConsoleCommand (GetMethodCommandName (nameof (CmdBan)), args);
		}

		[ChatCommand ("unban")]
		private void ChatCmdUnban (BasePlayer player, string _command, string[] args) {
			player.SendConsoleCommand (GetMethodCommandName (nameof (CmdUnban)), args);
		}
		#endregion

		#region Plugin interop
		#region Rust Map Api
		private bool IsRustMapApiReady () {
			bool isReady = RustMapApi?.Call<bool> ("IsReady") ?? false;
			if (!isReady) LogMessage ($"Rust Map Api is not ready", LogLevel.Trace);
			return isReady;
		}

		private bool TryUploadMapImage () {
			if (!IsRustMapApiReady ())
				return false;

			string mapName = "Default";
			int resolution = (int) (mapImageScale * World.Size);

			object response = RustMapApi.Call ("CreatePluginImage", this, mapName, resolution, (int) mapImageEncoding);
			if (response is string) {
				LogMessage ($"Rust Map Api call failed: {response}", LogLevel.Warning);
				return false;
			}

			var map = response as Hash<string, object>;
			if (map?["image"] is byte[] mapBytes) {
				LogMessage ($"Map image size: {mapBytes.Length} bytes", LogLevel.Trace);
			} else {
				LogMessage ($"Image missing from Rust Map Api response", LogLevel.Warning);
				return false;
			}

			JObject payload = JObject.FromObject (new {
				visibility = "public",
				encoding = "base64",
				content = Convert.ToBase64String (mapBytes),
			});

			HttpRequest (RequestMethod.PUT, $"blobs/{mapImageBlobPath}", payload);
			return true;
		}
		#endregion
		#endregion

		#region Localization
		protected override void LoadDefaultMessages () {
			lang.RegisterMessages (new Dictionary<string, string> {
				{ "ActionCommandRunning", "Running action command: {0}"},
				{ "ActionDispatching", "Handling action \"{0}\""},
				{ "ActionUnrecognized", "Unrecognized action \"{0}\""},
				{ "Banned", "You've been banned from the server" },
				{ "CommandUsage", "Usage: {0}" },
				{ "CommandUsageBan", "<name/SteamID64> <duration:minutes, 0=permanent> \"<reason>\" [global:false/true]" },
				{ "CommandNoPermission", "Insufficient permissions for running command \"{0}\"" },
				{ "ConfigOptionSet", "Configuration option {0} set to {1}" },
				{ "ConsoleApiConnectionChecking", "Checking connection to web API" },
				{ "ConsoleApiConnectionSuccess", "Connection established and token validated successfully" },
				{ "ConsoleApiRequestFailed", "Web API request failed. Code: {0}. Response: {1}" },
				{ "ConsoleApiRequestFailedBan", "Failed to ban user. Code: {0}. Response: {1}" },
				{ "ConsoleApiRequestFailedRoleAdd", "Failed to add role \"{0}\". Code: {1}. Response: {2}" },
				{ "ConsoleApiRequestFailedRoleRevoke", "Failed to revoke role \"{0}\". Code: {1}. Response: {2}" },
				{ "ConsoleApiRequestFailedUnban", "Failed to unban user. Code: {0}. Response: {1}" },
				{ "ConsoleCreatingGroup", "Creating group \"{0}\"" },
				{ "ConsoleGrantingGroup", "Granting the \"{0}\" group to {1} ({2})" },
				{ "ConsolePolling", "Polling with {0} player(s)" },
				{ "ConsoleRevokingGroup", "Revoking the \"{0}\" group from {1} ({2})" },
				{ "ConsoleRoleAdded", "Role \"{0}\" added for {1}" },
				{ "ConsoleRoleRevoked", "Role \"{0}\" revoked from {1}" },
				{ "ConsoleSyncingRoles", "Syncing roles for {0} ({1})" },
				{ "DebugChatPrefix", "[Ember] " },
				{ "GroupGranted", "You've been granted the {0} group" },
				{ "GroupRevoked", "Your {0} group has been revoked" },
				{ "InvalidArgs", "Invalid arguments" },
				{ "InvalidArgValue", "Invalid value for argument {0}: \"{1}\"" },
				{ "InvalidTime", "Time must be a number, 0 for permanent" },
				{ "MultiplePlayersFound", "Multiple players found, please be more specific" },
				{ "NoApiConnection", "The plugin is not connected to the web API. Check the server console for details" },
				{ "NoPlayersFoundByName", "Player not found by name \"{0}\"" },
				{ "PlayerBanFailed", "Failed to ban player {0}" },
				{ "PlayerBanned", "Player {0} banned" },
				{ "PlayerUnbanFailed", "Failed to unban player {0}" },
				{ "PlayerUnbanned", "Player {0} unbanned by {1}" },
				{ "PollIntervalSetting", "Setting the polling interval to {0} second(s)" },
			}, this, "en");

			LogMessage ("Default messages created", LogLevel.Debug);
		}
		#endregion
	}
}

Thank you very much. I have identified the issue and it was caused by another plugin.