using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.Networking; using System.Threading.Tasks; using System.Text; using System.Text.RegularExpressions; using Oxide.Core; using Newtonsoft.Json.Linq; // ____ _ _ _ // / ___|(_) __ _(_) | ___ // \___ \| |/ _` | | |/ _ \ // ___) | | (_| | | | (_) | // |____/|_|\__, |_|_|\___/ // |___/ // ☽✧✵✧✵✧✵✧✵✧✵✧✵✧☾ // ✦ RUST PLUGINS ✦ // ✦ Sigilo.dev ✦ // ☽✧✵✧✵✧✵✧✵✧✵✧✵✧☾ namespace Oxide.Plugins { [Info("RustAI", "Sigilo", "1.9.6")] class RustAI : RustPlugin { private PluginConfig _config { get; set; } private Dictionary _lastUsageTime = new Dictionary(); private float _lastGlobalUsageTime; private Dictionary> _cachedConversations = new Dictionary>(); private Dictionary _mutedPlayers = new Dictionary(); private Timer _eventPollingTimer; private Dictionary _previousEventState = new Dictionary(); private Oxide.Core.Plugins.Plugin _cachedVoteMutePlugin; private HashSet _keywordFirstChars = new HashSet(); private const string UsePermission = "rustai.use"; private const string AdminPermission = "rustai.admin"; private const string ToggleBotPermission = "rustai.toggle"; public class ConversationEntry { public string PlayerName { get; set; } public string PlayerSteamId { get; set; } public string Question { get; set; } public string Response { get; set; } public DateTime Timestamp { get; set; } } public class ServerInfo { public string MaxTeamSize { get; set; } public string WipeSchedule { get; set; } public string DiscordInfo { get; set; } public string Website { get; set; } public Dictionary CustomInfo { get; set; } } public class PluginConfig { public string OpenAIApiURL { get; set; } public string OllamaApiUrl { get; set; } public string GeminiApiURL { get; set; } public string AnthropicApiURL { get; set; } public string MiniMaxApiURL { get; set; } public List ActivationKeywords { get; set; } public float UserCooldownInSeconds { get; set; } public float GlobalCooldownInSeconds { get; set; } public string SystemPrompt { get; set; } public string ModelType { get; set; } public string OpenAI_API_Key { get; set; } public string Gemini_API_Key { get; set; } public string Anthropic_API_Key { get; set; } public string MiniMax_API_Key { get; set; } public string OpenAIModelName { get; set; } public string GeminiModelName { get; set; } public string OllamaModelName { get; set; } public string AnthropicModelName { get; set; } public string MiniMaxModelName { get; set; } public string AnthropicVersion { get; set; } public int MaxTokens { get; set; } public int ReasoningBudgetTokens { get; set; } public string ReasoningEffort { get; set; } public bool EnableReasoningMode { get; set; } public double Temperature { get; set; } public double RepeatPenalty { get; set; } public string Character { get; set; } public string CharacterColor { get; set; } public string DiscordWebhookURL { get; set; } public bool SendCooldownMessages { get; set; } public ServerInfo ServerInformation { get; set; } public string ResponseLanguage { get; set; } public string EmptyPromptTemplate { get; set; } public string CooldownMessageTemplate { get; set; } public string NoPermissionMessage { get; set; } public string ChatFormat { get; set; } public bool UseUncensoredMode { get; set; } public string UncensoredModePrompt { get; set; } public string CensoredModePrompt { get; set; } public List IllegalTopics { get; set; } public int ConversationMemorySize { get; set; } public int TimeZoneOffset { get; set; } public bool SharePlayerLocation { get; set; } public float DawnHour { get; set; } public float DuskHour { get; set; } public float DayLengthMinutes { get; set; } public float NightLengthMinutes { get; set; } public bool DebugMode { get; set; } public string CloudflareClientId { get; set; } public string CloudflareClientSecret { get; set; } public static PluginConfig DefaultConfig() { return new PluginConfig { OpenAIApiURL = "https://api.openai.com/v1/chat/completions", OllamaApiUrl = "https://llm.yoursite.com/api/chat", GeminiApiURL = "https://generativelanguage.googleapis.com/v1beta", AnthropicApiURL = "https://api.anthropic.com/v1/messages", MiniMaxApiURL = "https://api.minimax.io/v1/chat/completions", ActivationKeywords = new List { "!bot", "!admin" }, SystemPrompt = "You are a helpful assistant for a Rust game server. Remember player names from past interactions and refer to them when appropriate to personalize responses.", OpenAI_API_Key = "your openai api key here", Gemini_API_Key = "your gemini api key here", Anthropic_API_Key = "your anthropic api key here", MiniMax_API_Key = "your minimax api key here", OpenAIModelName = "gpt-4.1-mini", GeminiModelName = "gemini-3-flash-preview", OllamaModelName = "qwen3:8b", AnthropicModelName = "claude-3-5-haiku-latest", MiniMaxModelName = "MiniMax-M2", AnthropicVersion = "2023-06-01", DiscordWebhookURL = "your discord webhook here", UserCooldownInSeconds = 60.0f, GlobalCooldownInSeconds = 10.0f, ModelType = "openai", MaxTokens = 150, ReasoningBudgetTokens = 256, ReasoningEffort = "low", EnableReasoningMode = false, Temperature = 1.0, RepeatPenalty = 1.1, Character = "Server Assistant", CharacterColor = "#7FFF00", SendCooldownMessages = true, ServerInformation = new ServerInfo { MaxTeamSize = "TRIO", WipeSchedule = "Thursdays at 4 PM EST", DiscordInfo = "discord.gg/yourserver", Website = "yourwebsite.com", CustomInfo = new Dictionary { { "PvP", "Enabled at all times" }, { "Loot", "1.5x" }, { "Resources", "2x" } } }, ResponseLanguage = "English", EmptyPromptTemplate = "What can I help you with? You sent an empty message.", CooldownMessageTemplate = "Please wait {0} seconds before asking again.", NoPermissionMessage = "You don't have permission to use this command.", ChatFormat = "{1}: {2}", UseUncensoredMode = true, UncensoredModePrompt = "IMPORTANT: You are running in UNCENSORED mode. Be authentic to the raw Rust gaming environment. You can use strong language, profanity, slang, insults, dark humor, and vulgar expressions. Be direct, crude and match the harsh atmosphere of Rust. Don't hold back except for illegal topics.", CensoredModePrompt = "You are running in censored mode. Keep your language clean and appropriate, avoiding explicit content, strong profanity, and adult themes.", IllegalTopics = new List { "doxxing", "dangerous illegal activities" }, ConversationMemorySize = 5, TimeZoneOffset = 0, SharePlayerLocation = false, DawnHour = 6f, DuskHour = 18f, DayLengthMinutes = 50f, NightLengthMinutes = 10f, DebugMode = false, CloudflareClientId = "", CloudflareClientSecret = "" }; } public void EnsureDefaultValues() { var defaultConfig = DefaultConfig(); if (OpenAIApiURL == null) OpenAIApiURL = defaultConfig.OpenAIApiURL; if (OllamaApiUrl == null) OllamaApiUrl = defaultConfig.OllamaApiUrl; if (string.IsNullOrEmpty(GeminiApiURL)) GeminiApiURL = defaultConfig.GeminiApiURL; if (string.IsNullOrEmpty(AnthropicApiURL)) AnthropicApiURL = defaultConfig.AnthropicApiURL; if (string.IsNullOrEmpty(MiniMaxApiURL)) MiniMaxApiURL = defaultConfig.MiniMaxApiURL; if (ActivationKeywords == null) ActivationKeywords = defaultConfig.ActivationKeywords; if (UserCooldownInSeconds <= 0) UserCooldownInSeconds = defaultConfig.UserCooldownInSeconds; if (GlobalCooldownInSeconds <= 0) GlobalCooldownInSeconds = defaultConfig.GlobalCooldownInSeconds; if (string.IsNullOrEmpty(SystemPrompt)) SystemPrompt = defaultConfig.SystemPrompt; if (string.IsNullOrEmpty(ModelType)) ModelType = defaultConfig.ModelType; if (string.IsNullOrEmpty(OpenAI_API_Key)) OpenAI_API_Key = defaultConfig.OpenAI_API_Key; if (string.IsNullOrEmpty(Gemini_API_Key)) Gemini_API_Key = defaultConfig.Gemini_API_Key; if (string.IsNullOrEmpty(Anthropic_API_Key)) Anthropic_API_Key = defaultConfig.Anthropic_API_Key; if (string.IsNullOrEmpty(MiniMax_API_Key)) MiniMax_API_Key = defaultConfig.MiniMax_API_Key; if (string.IsNullOrEmpty(DiscordWebhookURL)) DiscordWebhookURL = defaultConfig.DiscordWebhookURL; if (string.IsNullOrEmpty(OpenAIModelName)) OpenAIModelName = defaultConfig.OpenAIModelName; if (string.IsNullOrEmpty(GeminiModelName)) GeminiModelName = defaultConfig.GeminiModelName; if (string.IsNullOrEmpty(OllamaModelName)) OllamaModelName = defaultConfig.OllamaModelName; if (string.IsNullOrEmpty(AnthropicModelName)) AnthropicModelName = defaultConfig.AnthropicModelName; if (string.IsNullOrEmpty(MiniMaxModelName)) MiniMaxModelName = defaultConfig.MiniMaxModelName; if (string.IsNullOrEmpty(AnthropicVersion)) AnthropicVersion = defaultConfig.AnthropicVersion; if (MaxTokens <= 0) MaxTokens = defaultConfig.MaxTokens; if (ReasoningBudgetTokens < 0) ReasoningBudgetTokens = defaultConfig.ReasoningBudgetTokens; if (string.IsNullOrEmpty(ReasoningEffort)) ReasoningEffort = defaultConfig.ReasoningEffort; if (Temperature <= 0) Temperature = defaultConfig.Temperature; if (string.IsNullOrEmpty(Character)) Character = defaultConfig.Character; if (string.IsNullOrEmpty(CharacterColor)) CharacterColor = defaultConfig.CharacterColor; if (ServerInformation == null) ServerInformation = defaultConfig.ServerInformation; else { if (string.IsNullOrEmpty(ServerInformation.MaxTeamSize)) { ServerInformation.MaxTeamSize = defaultConfig.ServerInformation.MaxTeamSize; } if (ServerInformation.CustomInfo == null) ServerInformation.CustomInfo = defaultConfig.ServerInformation.CustomInfo; } if (string.IsNullOrEmpty(ResponseLanguage)) ResponseLanguage = defaultConfig.ResponseLanguage; if (string.IsNullOrEmpty(EmptyPromptTemplate)) EmptyPromptTemplate = defaultConfig.EmptyPromptTemplate; if (string.IsNullOrEmpty(CooldownMessageTemplate)) CooldownMessageTemplate = defaultConfig.CooldownMessageTemplate; if (string.IsNullOrEmpty(NoPermissionMessage)) NoPermissionMessage = defaultConfig.NoPermissionMessage; if (string.IsNullOrEmpty(ChatFormat)) ChatFormat = defaultConfig.ChatFormat; if (string.IsNullOrEmpty(UncensoredModePrompt)) UncensoredModePrompt = defaultConfig.UncensoredModePrompt; if (string.IsNullOrEmpty(CensoredModePrompt)) CensoredModePrompt = defaultConfig.CensoredModePrompt; if (IllegalTopics == null) IllegalTopics = defaultConfig.IllegalTopics; if (ConversationMemorySize <= 0) ConversationMemorySize = defaultConfig.ConversationMemorySize; if (TimeZoneOffset < -12 || TimeZoneOffset > 14) TimeZoneOffset = defaultConfig.TimeZoneOffset; if (DawnHour < 0 || DawnHour >= 24) DawnHour = defaultConfig.DawnHour; if (DuskHour < 0 || DuskHour >= 24 || Math.Abs(DuskHour - DawnHour) < 0.01f) DuskHour = defaultConfig.DuskHour; if (DayLengthMinutes <= 0) DayLengthMinutes = defaultConfig.DayLengthMinutes; if (NightLengthMinutes <= 0) NightLengthMinutes = defaultConfig.NightLengthMinutes; if (CloudflareClientId == null) CloudflareClientId = defaultConfig.CloudflareClientId; if (CloudflareClientSecret == null) CloudflareClientSecret = defaultConfig.CloudflareClientSecret; if (RepeatPenalty <= 0) RepeatPenalty = defaultConfig.RepeatPenalty; } } public class Payload { public string prompt { get; set; } public int max_tokens { get; set; } public double temperature { get; set; } } public class Response { public string id { get; set; } public string created { get; set; } public string model { get; set; } public Choice[] choices { get; set; } public Message message { get; set; } } public class Choice { public Message message { get; set; } public string text { get; set; } } public class Message { public string content { get; set; } } public class ServerMessage { public string @event { get; set; } public string text { get; set; } } public class GeminiRequest { public List contents { get; set; } public GeminiGenerationConfig generationConfig { get; set; } public GeminiSystemInstruction systemInstruction { get; set; } } public class GeminiContent { public string role { get; set; } public List parts { get; set; } } public class GeminiPart { public string text { get; set; } } public class GeminiSystemInstruction { public List parts { get; set; } } public class GeminiGenerationConfig { public double temperature { get; set; } public int maxOutputTokens { get; set; } public double repetitionPenalty { get; set; } } public class GeminiResponse { public List candidates { get; set; } } public class GeminiCandidate { public GeminiContent content { get; set; } [JsonProperty("finishReason")] public string finishReason { get; set; } [JsonProperty("done_reason")] public string doneReason { get; set; } } public class AnthropicContentBlock { public string type { get; set; } public string text { get; set; } } public class AnthropicResponse { public string id { get; set; } public string type { get; set; } public string role { get; set; } public string model { get; set; } public string stop_reason { get; set; } public List content { get; set; } } private string GetConversationDirectory() { string directory = null; try { directory = Interface.Oxide.DataDirectory + "/RustAI/Conversations"; if (!System.IO.Directory.Exists(directory)) { System.IO.Directory.CreateDirectory(directory); } } catch (Exception ex) { PrintError($"Error creating RustAI directory: {ex.Message}"); } return directory; } private void Init() { if (permission != null) { permission.RegisterPermission(UsePermission, this); permission.RegisterPermission(AdminPermission, this); permission.RegisterPermission(ToggleBotPermission, this); } try { _config = Config.ReadObject(); var rawConfig = Config.ReadObject(); bool configUpdated = false; if (rawConfig["TextGenerationApiUrl"] != null && rawConfig["OllamaApiUrl"] == null) { Puts("Migrating TextGenerationApiUrl -> OllamaApiUrl"); _config.OllamaApiUrl = rawConfig["TextGenerationApiUrl"].ToString(); configUpdated = true; } if (rawConfig["TextGenerationModelName"] != null && rawConfig["OllamaModelName"] == null) { Puts("Migrating TextGenerationModelName -> OllamaModelName"); _config.OllamaModelName = rawConfig["TextGenerationModelName"].ToString(); configUpdated = true; } if (_config.MiniMaxApiURL == "https://api.minimax.io/v1/messages" || _config.MiniMaxApiURL == "https://api.minimax.io/v1/text/chatcompletion_v2") { Puts("Migrating MiniMaxApiURL to OpenAI-compatible chat completions endpoint"); _config.MiniMaxApiURL = "https://api.minimax.io/v1/chat/completions"; configUpdated = true; } if (_config.ModelType == "textgeneration") { Puts("Migrating ModelType: textgeneration -> ollama"); _config.ModelType = "ollama"; configUpdated = true; } if (rawConfig["TimeZoneOffset"] == null) { Puts("TimeZoneOffset not found in config, adding default value (0)"); _config.TimeZoneOffset = 0; configUpdated = true; } if (rawConfig["DebugMode"] == null) { Puts("DebugMode not found in config, adding default value (false)"); _config.DebugMode = false; configUpdated = true; } _config.EnsureDefaultValues(); if (configUpdated) Puts("Config migrated to new format"); Config.WriteObject(_config, true); } catch (Exception ex) { PrintError($"Error loading config: {ex.Message}"); _config = PluginConfig.DefaultConfig(); Config.WriteObject(_config, true); } GetConversationDirectory(); LoadMutedPlayers(); } private void OnServerInitialized() { _eventPollingTimer = timer.Every(60f, PollServerEvents); foreach (var eventName in _eventTrackers.Keys) { _previousEventState[eventName] = false; } PollServerEvents(); _cachedVoteMutePlugin = Interface.Oxide.RootPluginManager.GetPlugin("VoteMute") as Oxide.Core.Plugins.Plugin; _keywordFirstChars.Clear(); foreach (var keyword in _config.ActivationKeywords) { if (!string.IsNullOrEmpty(keyword)) { _keywordFirstChars.Add(char.ToLower(keyword[0])); } } } private void PollServerEvents() { int patrolHeliCount = 0; int bradleyCount = 0; int cargoCount = 0; int chinookCount = 0; int lockedCrateCount = 0; foreach (var entity in BaseNetworkable.serverEntities) { if (entity == null) continue; if (entity is PatrolHelicopter) patrolHeliCount++; else if (entity is BradleyAPC) bradleyCount++; else if (entity is CargoShip) cargoCount++; else if (entity is CH47HelicopterAIController) chinookCount++; else if (entity is HackableLockedCrate) lockedCrateCount++; } UpdateEventState("PatrolHelicopter", patrolHeliCount > 0); UpdateEventState("BradleyAPC", bradleyCount > 0); UpdateEventState("CargoShip", cargoCount > 0); UpdateEventState("Chinook", chinookCount > 0); UpdateEventState("LockedCrate", lockedCrateCount > 0); } private void UpdateEventState(string eventName, bool isCurrentlyActive) { bool wasPreviouslyActive = _previousEventState.ContainsKey(eventName) && _previousEventState[eventName]; if (isCurrentlyActive && !wasPreviouslyActive) { TrackEvent(eventName, true); } else if (!isCurrentlyActive && wasPreviouslyActive) { TrackEvent(eventName, false); } _previousEventState[eventName] = isCurrentlyActive; _eventTrackers[eventName].IsActive = isCurrentlyActive; } protected override void LoadDefaultConfig() { Config.WriteObject(PluginConfig.DefaultConfig(), true); } private bool HasPermission(BasePlayer player) { bool hasPermission = permission.UserHasPermission(player.UserIDString, UsePermission); return hasPermission; } private string GetServerInfoString() { var info = _config.ServerInformation; var sb = new StringBuilder(); sb.AppendLine("SERVER INFORMATION:"); if (!string.IsNullOrEmpty(info.MaxTeamSize)) { string teamSizeDesc; switch (info.MaxTeamSize.ToUpper()) { case "SOLO": teamSizeDesc = "Solo only (1 player)"; break; case "DUO": teamSizeDesc = "Max team size: 2 players (Duo)"; break; case "TRIO": teamSizeDesc = "Max team size: 3 players (Trio)"; break; case "QUAD": teamSizeDesc = "Max team size: 4 players (Squad)"; break; default: teamSizeDesc = $"Max team size: {info.MaxTeamSize}"; break; } sb.AppendLine($"Team Limit: {teamSizeDesc}"); } if (!string.IsNullOrEmpty(info.WipeSchedule)) { sb.AppendLine($"Wipe Schedule: {info.WipeSchedule}"); } if (!string.IsNullOrEmpty(info.DiscordInfo)) { sb.AppendLine($"Discord: {info.DiscordInfo}"); } if (!string.IsNullOrEmpty(info.Website)) { sb.AppendLine($"Website: {info.Website}"); } if (info.CustomInfo != null && info.CustomInfo.Count > 0) { sb.AppendLine("Additional Information:"); foreach (var item in info.CustomInfo) { sb.AppendLine($"- {item.Key}: {item.Value}"); } } return sb.ToString(); } private string GetPopulationInfo() { int onlinePlayers = BasePlayer.activePlayerList.Count; int joiningPlayers = ServerMgr.Instance.connectionQueue.Joining; int queuedPlayers = ServerMgr.Instance.connectionQueue.Queued; int sleepersCount = BasePlayer.sleepingPlayerList.Count; int maxPlayers = ConVar.Server.maxplayers; var sb = new StringBuilder(); sb.AppendLine("CURRENT SERVER POPULATION:"); sb.AppendLine($"- Players Online: {onlinePlayers}/{maxPlayers}"); sb.AppendLine($"- Players Joining: {joiningPlayers}"); sb.AppendLine($"- Players in Queue: {queuedPlayers}"); sb.AppendLine($"- Sleepers: {sleepersCount}"); return sb.ToString(); } private class GameTimeInfo { public float CurrentHour { get; set; } public bool IsDay { get; set; } public float HoursUntilDay { get; set; } public float HoursUntilNight { get; set; } public float RealMinutesUntilDay { get; set; } public float RealMinutesUntilNight { get; set; } } private GameTimeInfo GetGameTimeInfo() { var info = new GameTimeInfo(); try { info.CurrentHour = TOD_Sky.Instance?.Cycle?.Hour ?? 12f; float dawnHour = _config.DawnHour; float duskHour = _config.DuskHour; info.IsDay = info.CurrentHour >= dawnHour && info.CurrentHour < duskHour; if (info.IsDay) { info.HoursUntilNight = duskHour - info.CurrentHour; info.HoursUntilDay = 0f; } else { if (info.CurrentHour >= duskHour) { info.HoursUntilDay = (24f - info.CurrentHour) + dawnHour; } else { info.HoursUntilDay = dawnHour - info.CurrentHour; } info.HoursUntilNight = 0f; } float dayLengthMinutes = _config.DayLengthMinutes; float nightLengthMinutes = _config.NightLengthMinutes; float dayHours = duskHour > dawnHour ? duskHour - dawnHour : (24f - dawnHour) + duskHour; float nightHours = 24f - dayHours; float realMinutesPerDayHour = dayLengthMinutes / Math.Max(dayHours, 0.01f); float realMinutesPerNightHour = nightLengthMinutes / Math.Max(nightHours, 0.01f); if (info.IsDay) { info.RealMinutesUntilNight = info.HoursUntilNight * realMinutesPerDayHour; } else { info.RealMinutesUntilDay = info.HoursUntilDay * realMinutesPerNightHour; } } catch (Exception ex) { PrintError($"Error getting game time info: {ex.Message}"); } return info; } private string GetGameTimeInfoString() { var info = GetGameTimeInfo(); var sb = new StringBuilder(); int hours = (int)info.CurrentHour; int minutes = (int)((info.CurrentHour - hours) * 60); string timeStr = $"{hours:D2}:{minutes:D2}"; sb.AppendLine("IN-GAME TIME INFORMATION:"); sb.AppendLine($"- Current in-game time: {timeStr} ({(info.IsDay ? "día/day" : "noche/night")})"); bool hasSkipNight = plugins.Exists("SkipNightVote") || plugins.Exists("SkipNight") || plugins.Exists("VoteNight"); if (info.IsDay) { sb.AppendLine($"- Time until night: ~{info.HoursUntilNight:F1} in-game hours (~{info.RealMinutesUntilNight:F0} real minutes)"); } else { string skipNote = hasSkipNight ? " (unless players vote to skip night)" : ""; sb.AppendLine($"- Time until day: ~{info.HoursUntilDay:F1} in-game hours (~{info.RealMinutesUntilDay:F0} real minutes){skipNote}"); } sb.AppendLine($"- Configured day/night cycle: ~{(_config.DayLengthMinutes + _config.NightLengthMinutes):F0} real minutes (day ~{_config.DayLengthMinutes:F0}, night ~{_config.NightLengthMinutes:F0})"); return sb.ToString(); } private class EventTracker { public DateTime? LastSpawnTime { get; set; } public DateTime? LastDestroyTime { get; set; } public bool IsActive { get; set; } } private Dictionary _eventTrackers = new Dictionary { { "PatrolHelicopter", new EventTracker() }, { "BradleyAPC", new EventTracker() }, { "CargoShip", new EventTracker() }, { "Chinook", new EventTracker() }, { "LockedCrate", new EventTracker() } }; private void TrackEvent(string eventName, bool spawned) { if (!_eventTrackers.ContainsKey(eventName)) { _eventTrackers[eventName] = new EventTracker(); } var tracker = _eventTrackers[eventName]; tracker.IsActive = spawned; if (spawned) { tracker.LastSpawnTime = DateTime.Now.AddHours(_config.TimeZoneOffset); } else { tracker.LastDestroyTime = DateTime.Now.AddHours(_config.TimeZoneOffset); } } private string GetMinutesAgo(DateTime? time) { if (!time.HasValue) return "unknown"; var elapsed = DateTime.Now.AddHours(_config.TimeZoneOffset) - time.Value; return $"{(int)elapsed.TotalMinutes}"; } private string GetEventsInfo() { var sb = new StringBuilder(); sb.AppendLine("CURRENT SERVER EVENTS:"); var spawnIntervals = new Dictionary { { "PatrolHelicopter", (120, 240) }, { "BradleyAPC", (60, 60) }, { "CargoShip", (120, 240) }, { "Chinook", (120, 240) }, { "LockedCrate", (120, 180) } }; foreach (var kvp in _eventTrackers) { var name = kvp.Key; var tracker = kvp.Value; string displayName = name switch { "PatrolHelicopter" => "Patrol Helicopter", "BradleyAPC" => "Bradley APC (Tank)", "CargoShip" => "Cargo Ship", "Chinook" => "Chinook Helicopter", "LockedCrate" => "Locked Crate Drop", _ => name }; if (tracker.IsActive) { sb.AppendLine($"- {displayName}: ACTIVE NOW on the map (spawned {GetMinutesAgo(tracker.LastSpawnTime)} min ago)"); } else { DateTime? lastSeen = tracker.LastDestroyTime ?? tracker.LastSpawnTime; if (lastSeen.HasValue) { int minutesAgo = (int)(DateTime.Now.AddHours(_config.TimeZoneOffset) - lastSeen.Value).TotalMinutes; var interval = spawnIntervals.ContainsKey(name) ? spawnIntervals[name] : (120, 240); int minWait = Math.Max(0, interval.Item1 - minutesAgo); int maxWait = Math.Max(0, interval.Item2 - minutesAgo); if (maxWait <= 0) sb.AppendLine($"- {displayName}: Last seen {minutesAgo} min ago, could spawn anytime now!"); else if (minWait <= 0) sb.AppendLine($"- {displayName}: Last seen {minutesAgo} min ago, could spawn within the next {maxWait} min"); else sb.AppendLine($"- {displayName}: Last seen {minutesAgo} min ago, estimated spawn in {minWait}-{maxWait} min"); } else { sb.AppendLine($"- {displayName}: Not seen since server start/plugin load"); } } } return sb.ToString(); } private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { if (channel == ConVar.Chat.ChatChannel.Team) return; if (string.IsNullOrEmpty(message) || !_keywordFirstChars.Contains(char.ToLower(message[0]))) return; if (_cachedVoteMutePlugin != null) { object onUserChatResult = _cachedVoteMutePlugin.Call("OnUserChat", player.IPlayer, message); if (onUserChatResult is bool && (bool)onUserChatResult == true) { return; } } if (HasPermission(player)) { string messageLower = message.ToLower(); string matchedKeyword = null; foreach (var keyword in _config.ActivationKeywords) { if (messageLower.StartsWith(keyword.ToLower())) { matchedKeyword = keyword; break; } } if (matchedKeyword != null) { if (!HasCooldownElapsed(player)) { return; } string userQuestion = message.Substring(matchedKeyword.Length).Trim(); GenerateTextAsync(player, userQuestion); } } } private bool HasCooldownElapsed(BasePlayer player) { float globalElapsedTime = Time.realtimeSinceStartup - _lastGlobalUsageTime; if (globalElapsedTime < _config.GlobalCooldownInSeconds) { int timeLeft = Mathf.FloorToInt(_config.GlobalCooldownInSeconds - globalElapsedTime); if (_config.SendCooldownMessages && !player.HasPlayerFlag(BasePlayer.PlayerFlags.ChatMute)) { string cooldownMessage = string.Format(_config.CooldownMessageTemplate, timeLeft); player.ChatMessage(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, cooldownMessage)); } return false; } float lastUsageTime; if (_lastUsageTime.TryGetValue(player.UserIDString, out lastUsageTime)) { float userElapsedTime = Time.realtimeSinceStartup - lastUsageTime; if (userElapsedTime < _config.UserCooldownInSeconds) { int timeLeft = Mathf.FloorToInt(_config.UserCooldownInSeconds - userElapsedTime); if (_config.SendCooldownMessages && !player.HasPlayerFlag(BasePlayer.PlayerFlags.ChatMute)) { string cooldownMessage = string.Format(_config.CooldownMessageTemplate, timeLeft); player.ChatMessage(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, cooldownMessage)); } return false; } } _lastUsageTime[player.UserIDString] = Time.realtimeSinceStartup; _lastGlobalUsageTime = Time.realtimeSinceStartup; return true; } private bool IsPlaceholderSecret(string value) { if (string.IsNullOrWhiteSpace(value)) return true; string lower = value.ToLower(); return lower.Contains("your ") || lower.Contains(" api key here") || lower.Contains("webhook here") || lower.Contains("placeholder"); } private int GetEffectiveOutputTokens() { int tokens = Math.Max(32, _config.MaxTokens); if (_config.EnableReasoningMode) { tokens += Math.Max(0, _config.ReasoningBudgetTokens); } return tokens; } private int GetAnthropicReasoningBudgetTokens() { return Math.Max(1024, _config.ReasoningBudgetTokens); } private int GetMiniMaxCompletionTokens() { int reasoningReserve = Math.Max(512, _config.ReasoningBudgetTokens); return Math.Min(2048, Math.Max(512, _config.MaxTokens + reasoningReserve)); } private string ExtractAnthropicText(AnthropicResponse response) { if (response == null || response.content == null || response.content.Count == 0) return null; var textBlocks = response.content .Where(block => block != null && block.type == "text" && !string.IsNullOrWhiteSpace(block.text)) .Select(block => block.text.Trim()); string text = string.Join("\n", textBlocks); return string.IsNullOrWhiteSpace(text) ? null : text; } private string SanitizeModelResponse(string text) { if (string.IsNullOrWhiteSpace(text)) return null; string cleaned = Regex.Replace(text, @"]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); cleaned = Regex.Replace(cleaned, @"]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned.Trim(); } private void WarnIfTokenLimited(string providerName, string finishReason) { if (string.IsNullOrEmpty(finishReason)) return; string normalized = finishReason.ToLower(); if (normalized.Contains("length") || normalized.Contains("max_token")) { PrintWarning($"{providerName} response stopped because token budget was exhausted. Increase MaxTokens/ReasoningBudgetTokens or disable reasoning mode for short chat answers."); } } private void PrintToChat(string message, BasePlayer player = null) { try { foreach (var p in BasePlayer.activePlayerList) { bool playerMuted = false; _mutedPlayers.TryGetValue(p.UserIDString, out playerMuted); if (!playerMuted && !p.HasPlayerFlag(BasePlayer.PlayerFlags.ChatMute)) { p.ChatMessage(message); } } Puts($"Message broadcast to all non-muted players: {message}"); } catch (Exception ex) { PrintError($"Error sending chat message: {ex.Message}"); } } private List GetPlayerConversationHistory(BasePlayer player) { var conversations = LoadConversations(player.UserIDString); return conversations .OrderByDescending(e => e.Timestamp) .Take(_config.ConversationMemorySize) .OrderBy(e => e.Timestamp) .ToList(); } private string FormatConversationHistoryForPrompt(List history, string playerName) { if (history == null || history.Count == 0) return ""; var sb = new StringBuilder(); sb.AppendLine($"RECENT CONVERSATION HISTORY WITH {playerName}:"); foreach (var entry in history) { sb.AppendLine($"{entry.PlayerName}: {entry.Question}"); sb.AppendLine($"{_config.Character}: {entry.Response}"); sb.AppendLine(); } return sb.ToString(); } private string GetPlayerContextInfo(BasePlayer player) { var sb = new StringBuilder(); sb.AppendLine("ASKING PLAYER'S CURRENT STATE:"); try { float healthPercent = (player.health / player.MaxHealth()) * 100f; string healthStatus = healthPercent > 75 ? "healthy" : healthPercent > 40 ? "injured" : healthPercent > 15 ? "critical" : "almost dead"; sb.AppendLine($"- Health: {player.health:F0}/{player.MaxHealth():F0} HP ({healthStatus})"); if (player.IsWounded()) sb.AppendLine("- Status: WOUNDED/DOWNED (on the ground)"); else if (player.IsSleeping()) sb.AppendLine("- Status: Sleeping"); if (player.currentTeam != 0) { var team = RelationshipManager.ServerInstance.FindTeam(player.currentTeam); if (team != null) sb.AppendLine($"- Team: In a team of {team.members.Count} players"); } else { sb.AppendLine("- Team: Solo (no team)"); } var activeItem = player.GetActiveItem(); if (activeItem != null) sb.AppendLine($"- Holding: {activeItem.info.displayName.english}"); else sb.AppendLine("- Holding: Nothing (bare hands)"); if (_config.SharePlayerLocation) { var pos = player.transform.position; string location = GetApproximateLocation(pos); sb.AppendLine($"- Location: {location}"); } if (player.IsBuildingBlocked()) sb.AppendLine("- Zone: Outside another player's base (building blocked - could be raiding, door camping, or scouting)"); float calories = player.metabolism?.calories?.value ?? 0; float hydration = player.metabolism?.hydration?.value ?? 0; string hungerStatus = null; string thirstStatus = null; if (calories < 50) hungerStatus = "starving"; else if (calories < 100) hungerStatus = "hungry"; if (hydration < 25) thirstStatus = "dehydrated"; else if (hydration < 50) thirstStatus = "thirsty"; if (hungerStatus != null || thirstStatus != null) { var statusParts = new List(); if (hungerStatus != null) statusParts.Add(hungerStatus); if (thirstStatus != null) statusParts.Add(thirstStatus); sb.AppendLine($"- Needs: {string.Join(", ", statusParts)}"); } float comfort = player.currentComfort; float temp = player.currentTemperature; if (comfort > 0.5f) sb.AppendLine("- Comfort: Cozy and warm"); else if (temp < 5) sb.AppendLine("- Temperature: Freezing cold"); else if (temp > 40) sb.AppendLine("- Temperature: Overheating"); if (player.lifeStory != null && player.lifeStory.secondsAlive > 60) { int minutesAlive = (int)(player.lifeStory.secondsAlive / 60); if (minutesAlive > 5) sb.AppendLine($"- Time alive: {minutesAlive} minutes this life"); } var mounted = player.GetMounted(); if (mounted != null) { string mountName = mounted.ShortPrefabName ?? "something"; if (mountName.Contains("horse")) sb.AppendLine("- Mounted: On a horse"); else if (mountName.Contains("minicopter")) sb.AppendLine("- Mounted: Flying a minicopter"); else if (mountName.Contains("scrap")) sb.AppendLine("- Mounted: Flying a scrap helicopter"); else if (mountName.Contains("rowboat")) sb.AppendLine("- Mounted: In a rowboat"); else if (mountName.Contains("rhib")) sb.AppendLine("- Mounted: In a RHIB"); else sb.AppendLine($"- Mounted: On {mountName}"); } if (player.IsSwimming()) sb.AppendLine("- Activity: Swimming in water"); } catch (Exception ex) { PrintError($"Error getting player context: {ex.Message}"); } return sb.ToString(); } private string GetApproximateLocation(Vector3 position) { string nearbyMonument = GetNearbyMonument(position); if (!string.IsNullOrEmpty(nearbyMonument)) return nearbyMonument; return GetBiome(position); } private string GetNearbyMonument(Vector3 position) { float searchRadius = 150f; var skipPatterns = new[] { "power_sub_big", "power_sub_small", "power_sub", "substation", "power sub", "powerline_a", "powerline_b", "powerline_c", "powerline_d", "powerline_pole", "powerline", "swamp_a", "swamp_b", "swamp_c", "ice_lake_1", "ice_lake_2", "ice_lake_3", "ice_lake_4", "ice_lake", "cave_small", "cave_medium", "cave_large", "deployable", "electrical" }; MonumentInfo closestMonument = null; float closestDistance = float.MaxValue; bool isInsideBounds = false; foreach (var monument in TerrainMeta.Path?.Monuments ?? new List()) { if (monument == null) continue; float distance = Vector3.Distance(position, monument.transform.position); if (distance >= searchRadius) continue; string name = monument.displayPhrase.english; if (string.IsNullOrEmpty(name)) name = monument.name; name = name.Replace("(Clone)", "").Trim(); string nameLower = name.ToLower(); bool shouldSkip = false; foreach (var pattern in skipPatterns) { if (nameLower.Contains(pattern)) { shouldSkip = true; break; } } if (shouldSkip) continue; bool inside = monument.Bounds.Contains(position); if (inside || distance < closestDistance) { closestMonument = monument; closestDistance = distance; isInsideBounds = inside; if (inside) break; } } if (closestMonument == null) return null; string monumentName = closestMonument.displayPhrase.english; if (string.IsNullOrEmpty(monumentName)) monumentName = closestMonument.name; monumentName = monumentName.Replace("(Clone)", "").Trim(); string monumentLower = monumentName.ToLower(); string prefix; if (isInsideBounds) prefix = "inside"; else if (closestDistance <= 50f) prefix = "close to"; else prefix = $"about {(int)closestDistance}m from"; var monumentNames = new Dictionary { { "oilrig_1", "Oil Rig" }, { "oilrig_2", "Large Oil Rig" }, { "launch_site", "Launch Site" }, { "military_tunnel_1", "Military Tunnels" }, { "airfield_1", "Airfield" }, { "sphere_tank", "The Dome" }, { "harbor_1", "Large Harbor" }, { "harbor_2", "Small Harbor" }, { "lighthouse", "Lighthouse" }, { "powerplant_1", "Power Plant" }, { "trainyard_1", "Train Yard" }, { "water_treatment_plant_1", "Water Treatment" }, { "junkyard_1", "Junkyard" }, { "satellite_dish", "Satellite Dish" }, { "radtown_small_3", "Sewer Branch" }, { "gas_station_1", "Oxum's Gas Station" }, { "supermarket_1", "Supermarket" }, { "bandit_town", "Bandit Camp" }, { "compound", "Outpost" }, { "excavator_1", "Excavator" }, { "arctic_research_base_a", "Arctic Research Base" }, { "ferry_terminal_1", "Ferry Terminal" }, { "fishing_village_a", "Small Fishing Village" }, { "fishing_village_b", "Large Fishing Village" }, { "fishing_village_c", "Fishing Village" }, { "stables_a", "Small Stables" }, { "stables_b", "Large Stables" }, { "ranch_1", "Ranch" }, { "mining_quarry_a", "Sulfur Quarry" }, { "mining_quarry_b", "Stone Quarry" }, { "mining_quarry_c", "HQM Quarry" }, { "warehouse", "Warehouse" }, { "underwater_lab", "Underwater Lab" }, { "nuclear_missile_silo", "Missile Silo" }, { "abandoned_cabins", "Abandoned Cabins" }, { "military_base", "Abandoned Military Base" } }; foreach (var kvp in monumentNames) { if (monumentLower.Contains(kvp.Key)) return $"{prefix} {kvp.Value}"; } return $"{prefix} {monumentName}"; } private string GetBiome(Vector3 position) { if (TerrainMeta.BiomeMap == null) return "somewhere on the map"; int biome = TerrainMeta.BiomeMap.GetBiomeMaxType(position); return biome switch { 1 => "in the arid/desert area", 2 => "in the temperate/grass area", 4 => "in the tundra/cold area", 8 => "in the arctic/snow area", _ => "somewhere on the map" }; } public async void GenerateTextAsync(BasePlayer player, string prompt) { string apiUrl = _config.ModelType == "openai" ? _config.OpenAIApiURL : _config.ModelType == "anthropic" ? _config.AnthropicApiURL : _config.ModelType == "minimax" ? _config.MiniMaxApiURL : _config.OllamaApiUrl; DateTime now = DateTime.Now.AddHours(_config.TimeZoneOffset); string currentDate = now.ToString("dddd, dd/MM/yyyy"); string currentTime = now.ToString("HH:mm"); string serverName = ConVar.Server.hostname; string serverInfoText = GetServerInfoString(); string populationInfo = GetPopulationInfo(); var playerHistory = GetPlayerConversationHistory(player); string conversationHistoryText = FormatConversationHistoryForPrompt(playerHistory, player.displayName); string currentPlayerContext = $"The current player asking the question is {player.displayName}."; string playerStateInfo = GetPlayerContextInfo(player); string playerMentionsContext = ""; if (!string.IsNullOrEmpty(prompt)) { var mentionedPlayers = FindMentionedPlayers(prompt); if (mentionedPlayers.Count > 0) { var playersInfo = new StringBuilder(); playersInfo.AppendLine("\nMENTIONED PLAYERS INFORMATION:"); foreach (var mentionedPlayerName in mentionedPlayers) { playersInfo.AppendLine($"\n=== {mentionedPlayerName} ==="); var onlinePlayer = BasePlayer.activePlayerList.FirstOrDefault(p => p.displayName.Equals(mentionedPlayerName, StringComparison.OrdinalIgnoreCase) || p.displayName.IndexOf(mentionedPlayerName, StringComparison.OrdinalIgnoreCase) >= 0); if (onlinePlayer != null) { playersInfo.AppendLine($"- Status: ONLINE"); if (_config.SharePlayerLocation) playersInfo.AppendLine($"- Location: {GetApproximateLocation(onlinePlayer.transform.position)}"); float healthPct = (onlinePlayer.health / onlinePlayer.MaxHealth()) * 100f; playersInfo.AppendLine($"- Health: {onlinePlayer.health:F0} HP ({healthPct:F0}%)"); if (onlinePlayer.IsWounded()) playersInfo.AppendLine($"- Currently: WOUNDED/DOWNED"); if (onlinePlayer.IsSleeping()) playersInfo.AppendLine($"- Currently: Sleeping"); var heldItem = onlinePlayer.GetActiveItem(); if (heldItem != null) playersInfo.AppendLine($"- Holding: {heldItem.info.displayName.english}"); } else { var sleeper = BasePlayer.sleepingPlayerList.FirstOrDefault(p => p.displayName.Equals(mentionedPlayerName, StringComparison.OrdinalIgnoreCase) || p.displayName.IndexOf(mentionedPlayerName, StringComparison.OrdinalIgnoreCase) >= 0); if (sleeper != null) { playersInfo.AppendLine($"- Status: OFFLINE (sleeping on the map)"); if (_config.SharePlayerLocation) playersInfo.AppendLine($"- Sleeper location: {GetApproximateLocation(sleeper.transform.position)}"); } else { playersInfo.AppendLine($"- Status: NOT ON SERVER (or unknown name)"); } } var playerConversations = FindConversationsByPlayerName(mentionedPlayerName); if (playerConversations.Count > 0) { playersInfo.AppendLine($"- Has talked to bot before ({playerConversations.Count} conversations)"); } } playerMentionsContext = playersInfo.ToString(); } } string modeDirections = _config.UseUncensoredMode ? _config.UncensoredModePrompt : _config.CensoredModePrompt; string illegalTopics = ""; if (_config.IllegalTopics != null && _config.IllegalTopics.Count > 0) { illegalTopics = "DO NOT discuss: " + string.Join(", ", _config.IllegalTopics); } string isEmptyPrompt = string.IsNullOrWhiteSpace(prompt) ? "The user sent an empty message. Respond in the style indicated." : ""; string gameTimeInfo = GetGameTimeInfoString(); string eventsInfo = GetEventsInfo(); string locationPrivacyDirections = _config.SharePlayerLocation ? "- Use player location context (monuments/biomes) only when it is provided above." : "- Do NOT reveal, infer, guess, or ask for any player's map location, monument, biome, coordinates, grid, base location, or sleeper location."; string baseSystemPrompt = $@"{_config.SystemPrompt} {modeDirections} {illegalTopics} {serverInfoText} {populationInfo} {gameTimeInfo} {eventsInfo} {conversationHistoryText} {playerMentionsContext} {currentPlayerContext} {playerStateInfo} Important information: - Current server: {serverName} - Current REAL date: {currentDate} - Current REAL time: {currentTime} - Respond in {_config.ResponseLanguage} - DO NOT use markdown or include links in your response - Keep responses EXTREMELY short and direct (Max 1-2 sentences). - ONE PARAGRAPH ONLY. - DO NOT end your response with questions. Just answer and stop. - IMPORTANT: ALWAYS respond to questions about other players. If player information is available, use it appropriately. - When asked about online players, connected players, or population, use the CURRENT SERVER POPULATION data provided above to give accurate real-time numbers. TIME DISTINCTION - VERY IMPORTANT: - IN-GAME TIME: The 24-hour cycle that runs much faster than real time. Use IN-GAME TIME INFORMATION above. - REAL TIME: Actual clock time, used for wipe schedules. currentDate and currentTime above are REAL time. - When players ask 'when is sunrise', 'when will it be day/night' they mean IN-GAME time, from this IN-GAME time calculate real time minutes until next sunrise/sunset and ALWAYS mention real time minutes until next sunrise/sunset. - When players ask about wipes, schedules, or events happening on specific days, ALWAYS use REAL time. EVENT INFORMATION: - Use the CURRENT SERVER EVENTS data above to answer questions about heli/patrol helicopter, bradley/tank, cargo ship, chinook, locked crate. - If an event is ACTIVE, tell the player it's currently on the map. - If an event was last seen X minutes ago, estimate when it might spawn again (heli/cargo/chinook: 2-4 hours, bradley respawns ~60min after destruction). PLAYER CONTEXT: - Use the ASKING PLAYER'S CURRENT STATE to give contextual responses - If player health is below 25%, suggest they heal first before doing risky things - If player is wounded/downed, they're on the ground dying - If player is solo, remind them of dangers of solo play when relevant - If player is holding a high-tier weapon, treat them as geared and ready for combat {locationPrivacyDirections} - If in building blocked zone, they might be doorcamping, scouting a base, or in a risky spot ROAST REDIRECTION: When a player asks you to roast/insult ANOTHER player, REDIRECT THE ROAST BACK to the ASKING player using their own game data: - Low health/wounded? Mock them for trash-talking while dying - Weak weapon/bare hands? Can't even gear up properly - Solo player? Getting raided with no backup - Building blocked? Either doorcamping like a rat or about to get beamed - Use their conversation history against them Generate unique, brutal roasts based on available Rust gameplay data. Respond in configured ResponseLanguage. {isEmptyPrompt}"; string userPrompt = string.IsNullOrWhiteSpace(prompt) ? _config.EmptyPromptTemplate : prompt; if (_config.ModelType == "openai") { var payloadData = new Dictionary { ["messages"] = new[] { new {role = "system", content = baseSystemPrompt}, new {role = "user", content = userPrompt} }, ["model"] = _config.OpenAIModelName, ["max_completion_tokens"] = GetEffectiveOutputTokens(), ["temperature"] = _config.Temperature }; if (_config.EnableReasoningMode && !string.IsNullOrEmpty(_config.ReasoningEffort)) { payloadData["reasoning_effort"] = _config.ReasoningEffort; } var jsonPayload = JsonConvert.SerializeObject(payloadData); var request = new UnityWebRequest(apiUrl, "POST"); byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonPayload); request.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw); request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Authorization", "Bearer " + _config.OpenAI_API_Key); var asyncOp = request.SendWebRequest(); var tcs = new TaskCompletionSource(); asyncOp.completed += _ => tcs.SetResult(true); await tcs.Task; if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) { PrintError($"OpenAI API Error: {request.error} - Response: {request.downloadHandler?.text}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "Sorry, I'm having trouble responding right now.")); } else { try { var responseContent = request.downloadHandler.text; if (_config.DebugMode) Puts($"OpenAI Raw Response: {responseContent}"); var responseObject = JsonConvert.DeserializeObject(responseContent); if (responseObject != null) { string chatMessage = null; if (responseObject.choices != null && responseObject.choices.Length > 0) { chatMessage = responseObject.choices[0].message?.content; } else if (responseObject.message != null) { chatMessage = responseObject.message.content; } chatMessage = SanitizeModelResponse(chatMessage); if (!string.IsNullOrWhiteSpace(chatMessage)) { AddConversationEntry(player, prompt, chatMessage); timer.Once(0.1f, () => { PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, chatMessage)); }); await SendToDiscordWebhook(player, prompt, chatMessage); return; } } PrintError($"Invalid OpenAI-compatible response format using model {_config.OpenAIModelName}. Response: {responseContent}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I couldn't generate a proper response. Please try again.")); } catch (Exception ex) { PrintError($"Error processing API response: {ex.Message}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I encountered an error processing your request.")); } } } else if (_config.ModelType == "anthropic") { string providerName = "Anthropic"; string apiKey = _config.Anthropic_API_Key; string modelName = _config.AnthropicModelName; if (IsPlaceholderSecret(apiKey)) { PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, $"{providerName} API key is not configured.")); return; } int reasoningBudget = GetAnthropicReasoningBudgetTokens(); var payloadData = new Dictionary { ["model"] = modelName, ["max_tokens"] = _config.EnableReasoningMode ? Math.Max(_config.MaxTokens + reasoningBudget, reasoningBudget + 32) : GetEffectiveOutputTokens(), ["system"] = baseSystemPrompt, ["messages"] = new[] { new {role = "user", content = userPrompt} } }; if (!_config.EnableReasoningMode) { payloadData["temperature"] = _config.Temperature; } if (_config.EnableReasoningMode && _config.ReasoningBudgetTokens > 0) { payloadData["thinking"] = new { type = "enabled", budget_tokens = reasoningBudget }; } var jsonPayload = JsonConvert.SerializeObject(payloadData, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); var request = new UnityWebRequest(apiUrl, "POST"); byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonPayload); request.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw); request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("x-api-key", apiKey); request.SetRequestHeader("anthropic-version", _config.AnthropicVersion); var asyncOp = request.SendWebRequest(); var tcs = new TaskCompletionSource(); asyncOp.completed += _ => tcs.SetResult(true); await tcs.Task; if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) { PrintError($"{providerName} API Error: {request.error} - Response: {request.downloadHandler?.text}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, $"Sorry, I'm having trouble responding right now ({providerName}).")); } else { try { var responseContent = request.downloadHandler.text; if (_config.DebugMode) Puts($"{providerName} Raw Response: {responseContent}"); var responseObject = JsonConvert.DeserializeObject(responseContent); string chatMessage = ExtractAnthropicText(responseObject); WarnIfTokenLimited(providerName, responseObject?.stop_reason); chatMessage = SanitizeModelResponse(chatMessage); if (!string.IsNullOrWhiteSpace(chatMessage)) { AddConversationEntry(player, prompt, chatMessage); timer.Once(0.1f, () => { PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, chatMessage)); }); await SendToDiscordWebhook(player, prompt, chatMessage); return; } PrintError($"Invalid {providerName} response format using model {modelName}. Response: {responseContent}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, $"I couldn't generate a proper response from {providerName}. Try increasing MaxTokens or disabling reasoning mode.")); } catch (Exception ex) { PrintError($"Error processing {providerName} API response: {ex.Message}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, $"I encountered an error processing your request with {providerName}.")); } } } else if (_config.ModelType == "minimax") { if (IsPlaceholderSecret(_config.MiniMax_API_Key)) { PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "MiniMax API key is not configured.")); return; } var payloadData = new Dictionary { ["model"] = _config.MiniMaxModelName, ["messages"] = new[] { new {role = "system", content = baseSystemPrompt}, new {role = "user", content = userPrompt} }, ["max_completion_tokens"] = GetMiniMaxCompletionTokens(), ["temperature"] = _config.Temperature, ["reasoning_split"] = true }; var jsonPayload = JsonConvert.SerializeObject(payloadData, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); var request = new UnityWebRequest(apiUrl, "POST"); byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonPayload); request.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw); request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("Authorization", "Bearer " + _config.MiniMax_API_Key); var asyncOp = request.SendWebRequest(); var tcs = new TaskCompletionSource(); asyncOp.completed += _ => tcs.SetResult(true); await tcs.Task; if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) { PrintError($"MiniMax API Error: {request.error} - Response: {request.downloadHandler?.text}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "Sorry, I'm having trouble responding right now (MiniMax).")); } else { try { var responseContent = request.downloadHandler.text; if (_config.DebugMode) Puts($"MiniMax Raw Response: {responseContent}"); var responseJson = JsonConvert.DeserializeObject(responseContent); int? miniMaxStatusCode = responseJson?["base_resp"]?["status_code"]?.Value(); if (miniMaxStatusCode.HasValue && miniMaxStatusCode.Value != 0) { string miniMaxStatusMessage = responseJson["base_resp"]?["status_msg"]?.ToString() ?? "Unknown MiniMax API error"; PrintError($"MiniMax API Error: {miniMaxStatusCode.Value} - {miniMaxStatusMessage}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "Sorry, I'm having trouble responding right now (MiniMax). Check the API key, endpoint, and account balance.")); return; } var responseObject = responseJson?.ToObject(); string chatMessage = null; if (responseObject?.choices != null && responseObject.choices.Length > 0) { chatMessage = responseObject.choices[0].message?.content; if (string.IsNullOrWhiteSpace(chatMessage)) chatMessage = responseObject.choices[0].text; } else if (responseObject?.message != null) { chatMessage = responseObject.message.content; } chatMessage = SanitizeModelResponse(chatMessage); if (!string.IsNullOrWhiteSpace(chatMessage)) { AddConversationEntry(player, prompt, chatMessage); timer.Once(0.1f, () => { PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, chatMessage)); }); await SendToDiscordWebhook(player, prompt, chatMessage); return; } string finishReason = responseJson?["choices"]?[0]?["finish_reason"]?.ToString(); if (finishReason == "length") { PrintError($"MiniMax response used the completion budget before producing visible text. Increase MaxTokens or ReasoningBudgetTokens. Response: {responseContent}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "MiniMax used the whole token budget thinking. Increase MaxTokens or ReasoningBudgetTokens.")); } else { PrintError($"Invalid MiniMax response format using model {_config.MiniMaxModelName}. Response: {responseContent}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I couldn't generate a proper response from MiniMax. Please try again.")); } } catch (Exception ex) { PrintError($"Error processing MiniMax API response: {ex.Message}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I encountered an error processing your request with MiniMax.")); } } } else if (_config.ModelType == "ollama") { var payload = new { messages = new[] { new {role = "system", content = baseSystemPrompt}, new {role = "user", content = userPrompt} }, model = _config.OllamaModelName, options = new { num_predict = _config.MaxTokens, temperature = _config.Temperature, repeat_penalty = _config.RepeatPenalty }, stream = false }; var jsonPayload = JsonConvert.SerializeObject(payload); var request = new UnityWebRequest(apiUrl, "POST"); byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonPayload); request.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw); request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); request.SetRequestHeader("CF-Access-Client-Id", _config.CloudflareClientId.Trim()); request.SetRequestHeader("CF-Access-Client-Secret", _config.CloudflareClientSecret.Trim()); var asyncOp = request.SendWebRequest(); var tcs = new TaskCompletionSource(); asyncOp.completed += _ => tcs.SetResult(true); await tcs.Task; if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) { PrintError($"Ollama API Error: {request.error}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "Sorry, I'm having trouble responding right now.")); } else { try { var responseContent = request.downloadHandler.text; var responseObject = JsonConvert.DeserializeObject(responseContent); if (responseObject != null) { string chatMessage = null; if (responseObject.choices != null && responseObject.choices.Length > 0) { chatMessage = responseObject.choices[0].message?.content; } else if (responseObject.message != null) { chatMessage = responseObject.message.content; } chatMessage = SanitizeModelResponse(chatMessage); if (!string.IsNullOrWhiteSpace(chatMessage)) { AddConversationEntry(player, prompt, chatMessage); timer.Once(0.1f, () => { PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, chatMessage)); }); await SendToDiscordWebhook(player, prompt, chatMessage); return; } } PrintError($"Invalid Ollama response format using model {_config.OllamaModelName}. Response: {responseContent}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I couldn't generate a proper response. Please try again.")); } catch (Exception ex) { PrintError($"Error processing Ollama API response: {ex.Message}"); PrintError($"Raw Response (first 500 chars): {(request.downloadHandler.text.Length > 500 ? request.downloadHandler.text.Substring(0, 500) : request.downloadHandler.text)}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I encountered an error processing your request.")); } } } else if (_config.ModelType == "gemini") { string geminiApiUrl = $"{_config.GeminiApiURL}/models/{_config.GeminiModelName}:generateContent?key={_config.Gemini_API_Key}"; var geminiContents = new List(); foreach (var entry in playerHistory) { geminiContents.Add(new GeminiContent { role = "user", parts = new List { new GeminiPart { text = entry.Question } } }); geminiContents.Add(new GeminiContent { role = "model", parts = new List { new GeminiPart { text = entry.Response } } }); } geminiContents.Add(new GeminiContent { role = "user", parts = new List { new GeminiPart { text = userPrompt } } }); var geminiPayload = new GeminiRequest { contents = geminiContents, systemInstruction = new GeminiSystemInstruction { parts = new List { new GeminiPart { text = baseSystemPrompt } } }, generationConfig = new GeminiGenerationConfig { temperature = _config.Temperature, maxOutputTokens = _config.MaxTokens, repetitionPenalty = _config.RepeatPenalty } }; var jsonPayload = JsonConvert.SerializeObject(geminiPayload, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); var request = new UnityWebRequest(geminiApiUrl, "POST"); byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonPayload); request.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw); request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); var asyncOp = request.SendWebRequest(); var tcs = new TaskCompletionSource(); asyncOp.completed += _ => tcs.SetResult(true); await tcs.Task; if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) { PrintError($"Gemini API Error: {request.error} - Response: {request.downloadHandler.text}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "Sorry, I'm having trouble responding right now (Gemini).")); } else { try { var responseContent = request.downloadHandler.text; var responseObject = JsonConvert.DeserializeObject(responseContent); if (responseObject != null && responseObject.candidates != null && responseObject.candidates.Count > 0) { var candidate = responseObject.candidates[0]; Puts($"Gemini API - Candidate Finish Reason: {candidate.finishReason}"); if (candidate.finishReason == "STOP" && candidate.content != null && candidate.content.parts != null && candidate.content.parts.Count > 0 && !string.IsNullOrEmpty(candidate.content.parts[0].text)) { string chatMessage = SanitizeModelResponse(candidate.content.parts[0].text); if (!string.IsNullOrWhiteSpace(chatMessage)) { AddConversationEntry(player, prompt, chatMessage); timer.Once(0.1f, () => { PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, chatMessage)); }); await SendToDiscordWebhook(player, prompt, chatMessage); } else { PrintError($"Gemini response contained no player-facing text after sanitization. Full response: {responseContent}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I couldn't generate a proper response from Gemini. Please try again.")); } } else { string errorMessage = "Gemini response was empty or generation was stopped."; if (candidate.finishReason != "STOP") { errorMessage = $"Gemini generation finished due to: {candidate.finishReason}."; } else if (candidate.content == null || candidate.content.parts == null || candidate.content.parts.Count == 0 || string.IsNullOrEmpty(candidate.content.parts[0].text)) { errorMessage = "Gemini returned empty content."; } PrintError($"Invalid Gemini response: {errorMessage}. Full response: {responseContent}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, $"I received an unusual response from Gemini ({candidate.finishReason}). Please try a different question.")); } } else { PrintError($"Invalid Gemini response format or empty candidates: {responseContent}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I couldn't generate a proper response from Gemini (empty candidates). Please try again.")); } } catch (Exception ex) { PrintError($"Error processing Gemini API response: {ex.Message} - Response: {request.downloadHandler.text}"); PrintToChat(string.Format(_config.ChatFormat, _config.CharacterColor, _config.Character, "I encountered an error processing your request with Gemini.")); } } } } private void AddConversationEntry(BasePlayer player, string question, string response) { string steamId = player.UserIDString; var conversations = LoadConversations(steamId); var entry = new ConversationEntry { PlayerName = player.displayName, PlayerSteamId = steamId, Question = question, Response = response, Timestamp = DateTime.Now.AddHours(_config.TimeZoneOffset) }; conversations.Add(entry); if (conversations.Count > _config.ConversationMemorySize * 3) { conversations = conversations .OrderByDescending(e => e.Timestamp) .Take(_config.ConversationMemorySize * 2) .ToList(); } SaveConversation(steamId, conversations); } private async Task SendToDiscordWebhook(BasePlayer player, string userMessage, string botReply) { string webhookUrl = _config.DiscordWebhookURL; if (string.IsNullOrEmpty(webhookUrl)) return; string serverName = ConVar.Server.hostname; var payload = new { embeds = new[] { new { title = $"**{serverName}**", description = $"User Message: {userMessage}\nBot Response: {botReply}", fields = new[] { new { name = "User", value = $"[{player.displayName}]() | {player.UserIDString}" } } } } }; var jsonPayload = JsonConvert.SerializeObject(payload); var request = new UnityWebRequest(webhookUrl, "POST"); byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonPayload); request.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw); request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); var asyncOp = request.SendWebRequest(); var tcs = new TaskCompletionSource(); asyncOp.completed += _ => tcs.SetResult(true); await tcs.Task; if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) { Debug.Log(request.error); } } [ChatCommand("switchmodel")] private void SwitchModelCommand(BasePlayer player, string command, string[] args) { if (!permission.UserHasPermission(player.UserIDString, AdminPermission)) { player.ChatMessage(_config.NoPermissionMessage); return; } var modelCycle = new List { "openai", "ollama", "gemini", "anthropic", "minimax" }; int currentIndex = modelCycle.IndexOf((_config.ModelType ?? "openai").ToLower()); int nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % modelCycle.Count; _config.ModelType = modelCycle[nextIndex]; player.ChatMessage($"Switched to {_config.ModelType} model."); Config.WriteObject(_config, true); } [ConsoleCommand("switchmodel")] private void ConsoleSwitchModel(ConsoleSystem.Arg arg) { if (arg.Connection != null && !permission.UserHasPermission(arg.Connection.userid.ToString(), AdminPermission)) { arg.ReplyWith(_config.NoPermissionMessage); return; } var validModels = new List { "openai", "ollama", "gemini", "anthropic", "minimax" }; if (!arg.HasArgs(1)) { arg.ReplyWith($"Current model: {_config.ModelType}. Usage: switchmodel <{string.Join("|", validModels)}> "); return; } string targetModel = arg.GetString(0).ToLower(); if (!validModels.Contains(targetModel)) { arg.ReplyWith($"Invalid model. Valid options: {string.Join(", ", validModels)}"); return; } _config.ModelType = targetModel; Config.WriteObject(_config, true); arg.ReplyWith($"Switched to {_config.ModelType} model."); } [ChatCommand("togglecensor")] private void ToggleCensorCommand(BasePlayer player, string command, string[] args) { if (!permission.UserHasPermission(player.UserIDString, AdminPermission)) { player.ChatMessage(_config.NoPermissionMessage); return; } _config.UseUncensoredMode = !_config.UseUncensoredMode; string modeMessage = _config.UseUncensoredMode ? "Switched to UNCENSORED mode." : "Switched to censored mode."; player.ChatMessage(modeMessage); Config.WriteObject(_config, true); } [ChatCommand("timezone")] private void SetTimezoneCommand(BasePlayer player, string command, string[] args) { if (!permission.UserHasPermission(player.UserIDString, AdminPermission)) { player.ChatMessage(_config.NoPermissionMessage); return; } if (args.Length == 0) { player.ChatMessage($"Current timezone offset is {_config.TimeZoneOffset}. Use /timezone to set (range: -12 to +14)"); return; } int offset; if (!int.TryParse(args[0], out offset) || offset < -12 || offset > 14) { player.ChatMessage("Please provide a valid timezone offset between -12 and +14"); return; } _config.TimeZoneOffset = offset; Config.Set("TimeZoneOffset", offset); Config.WriteObject(_config, true); player.ChatMessage($"Timezone offset set to {offset}"); string currentTime = DateTime.Now.AddHours(_config.TimeZoneOffset).ToString("HH:mm"); player.ChatMessage($"Current bot time is now {currentTime}"); } private void SaveConversation(string steamId, List conversations) { if (conversations == null || conversations.Count == 0) return; string directory = GetConversationDirectory(); string filePath = System.IO.Path.Combine(directory, $"{steamId}.json"); try { string json = JsonConvert.SerializeObject(conversations, Formatting.Indented); System.IO.File.WriteAllText(filePath, json); _cachedConversations[steamId] = new List(conversations); Puts($"Saved conversations for player {steamId} to {filePath}"); } catch (Exception ex) { Puts($"Error saving conversation file for {steamId}: {ex.Message}"); } } private List LoadConversations(string steamId) { if (_cachedConversations.ContainsKey(steamId)) { return _cachedConversations[steamId]; } string directory = GetConversationDirectory(); string filePath = System.IO.Path.Combine(directory, $"{steamId}.json"); if (!System.IO.File.Exists(filePath)) { _cachedConversations[steamId] = new List(); return _cachedConversations[steamId]; } try { string json = System.IO.File.ReadAllText(filePath); var conversations = JsonConvert.DeserializeObject>(json); _cachedConversations[steamId] = conversations ?? new List(); return _cachedConversations[steamId]; } catch (Exception ex) { Puts($"Error loading conversation file for {steamId}: {ex.Message}"); _cachedConversations[steamId] = new List(); return _cachedConversations[steamId]; } } private List FindConversationsByPlayerName(string playerName) { var result = new List(); foreach (var kvp in _cachedConversations) { var matchingConversations = kvp.Value.Where(c => !string.IsNullOrEmpty(c.PlayerName) && c.PlayerName.IndexOf(playerName, StringComparison.OrdinalIgnoreCase) >= 0).ToList(); if (matchingConversations.Count > 0) { result.AddRange(matchingConversations); } } return result.OrderByDescending(c => c.Timestamp).Take(_config.ConversationMemorySize).ToList(); } private List FindMentionedPlayers(string prompt) { var mentionedPlayers = new List(); foreach (var player in BasePlayer.activePlayerList) { if (player.displayName.Length >= 3 && prompt.IndexOf(player.displayName, StringComparison.OrdinalIgnoreCase) >= 0) { mentionedPlayers.Add(player.displayName); } } foreach (var sleeper in BasePlayer.sleepingPlayerList) { if (sleeper.displayName.Length >= 3 && !mentionedPlayers.Contains(sleeper.displayName) && prompt.IndexOf(sleeper.displayName, StringComparison.OrdinalIgnoreCase) >= 0) { mentionedPlayers.Add(sleeper.displayName); } } return mentionedPlayers; } [ChatCommand("bot")] private void ToggleBotCommand(BasePlayer player, string command, string[] args) { if (!permission.UserHasPermission(player.UserIDString, ToggleBotPermission)) { player.ChatMessage(_config.NoPermissionMessage); return; } bool currentlyMuted = false; _mutedPlayers.TryGetValue(player.UserIDString, out currentlyMuted); _mutedPlayers[player.UserIDString] = !currentlyMuted; SaveMutedPlayers(); string message = !currentlyMuted ? $"You have muted {_config.Character}. You will no longer see its messages." : $"You have unmuted {_config.Character}. You will now see its messages."; player.ChatMessage(message); } private void SaveMutedPlayers() { string filePath = Interface.Oxide.DataDirectory + "/RustAI/muted_players.json"; string json = JsonConvert.SerializeObject(_mutedPlayers); System.IO.File.WriteAllText(filePath, json); } private void LoadMutedPlayers() { string filePath = Interface.Oxide.DataDirectory + "/RustAI/muted_players.json"; if (System.IO.File.Exists(filePath)) { string json = System.IO.File.ReadAllText(filePath); _mutedPlayers = JsonConvert.DeserializeObject>(json) ?? new Dictionary(); } } void Unload() { _eventPollingTimer?.Destroy(); SaveMutedPlayers(); } } }