using System.Text.Json; using OpenMetaverse; using SecondBotEvents.Config; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Swan; using StackExchange.Redis; using Betalgo.Ranul.OpenAI.Managers; using Betalgo.Ranul.OpenAI; using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; using Betalgo.Ranul.OpenAI.ObjectModels; namespace SecondBotEvents.Services { public class ChatGptService : BotServices { public new ChatGptConfig myConfig = null; public ChatGptService(EventsSecondBot setMaster) : base(setMaster) { myConfig = new ChatGptConfig(master.fromEnv, master.fromFolder); if (myConfig.GetEnabled() == false) { return; } } protected int chatHistorySize = 5; protected int localchatRateLimit = 3; protected int groupchatRateLimit = 3; protected int imchatRateLimit = 3; protected ConnectionMultiplexer redis = null; protected IDatabase redisDb = null; public override void Start(bool updateEnabled = false, bool setEnabledTo = false) { if (updateEnabled == true) { myConfig.setEnabled(setEnabledTo); } if (myConfig.GetEnabled() == false) { Stop(); return; } if(myConfig.GetUseRedis() == true) { try { ConfigurationOptions configRedis = new() { EndPoints = { myConfig.GetRedisSource() } }; redis = ConnectionMultiplexer.Connect(configRedis); redisDb = redis.GetDatabase(); } catch (Exception ex) { LogFormater.Warn("Redis failed to connect:" + ex.Message); } } chatHistorySize = InRange(3,10,myConfig.GetChatHistoryMessages()); localchatRateLimit = InRange(1, 10, myConfig.GetLocalchatRateLimiter()); groupchatRateLimit = InRange(1, 10, myConfig.GetGroupReplyRateLimiter()); imchatRateLimit = InRange(1, 10, myConfig.GetImReplyRateLimiter()); running = true; master.BotClientNoticeEvent += BotClientRestart; } protected static int InRange(int value, int min, int max) { if (value < min) return min; else if(value > max) return max; return value; } public override void Stop() { if (running == true) { running = false; LogFormater.Info("ChatGpt [Stopping]"); if (master != null) { master.BotClientNoticeEvent -= BotClientRestart; } if (GetClient() != null) { if (GetClient().Network != null) { GetClient().Network.SimConnected -= BotLoggedIn; } if (GetClient().Self != null) { GetClient().Self.IM -= BotImMessage; GetClient().Self.ChatFromSimulator -= BotLocalchat; } } } } public override string Status() { if (myConfig == null) { return "No Config"; } else if (myConfig.GetEnabled() == false) { return "Disabled"; } upkeep(); if (chatHistoryAI.Count > 0) { return "Enabled running: " + chatHistoryAI.Count.ToString() + " chat windows"; } return "Enabled No chat history"; } readonly string[] hard_blocked_agents = ["secondlife", "second life"]; protected void BotLocalchat(object o, ChatEventArgs e) { if (e.SourceID == GetClient().Self.AgentID) { return; } if (myConfig.GetLocalchatReply() == false) { return; } switch (e.Type) { case ChatType.OwnerSay: case ChatType.Whisper: case ChatType.Normal: case ChatType.Shout: case ChatType.RegionSayTo: { if (hard_blocked_agents.Contains(e.FromName.ToLowerInvariant()) == true) { break; } if (e.SourceType == ChatSourceType.Object) { break; } else if (e.SourceType == ChatSourceType.System) { break; } if (e.Type == ChatType.OwnerSay) { break; } // trigger localchat GetAiReply(localchatRateLimit, UUID.Zero, UUID.Zero, e.FromName, e.Message, false, false); break; } default: { break; } } } protected void BotImMessage(object o, InstantMessageEventArgs e) { if (e.IM.FromAgentID == GetClient().Self.AgentID) { return; } switch (e.IM.Dialog) { case InstantMessageDialog.MessageFromObject: { // object IM break; } case InstantMessageDialog.MessageFromAgent: // shared with SessionSend case InstantMessageDialog.SessionSend: { if (master.DataStoreService.GetIsGroup(e.IM.IMSessionID) == false) { // trigger avatar IM if (myConfig.GetAllowImReplys() == false) { break; } if (myConfig.GetImReplyFriendsOnly() == true) { if (GetClient().Friends.FriendList.ContainsKey(e.IM.FromAgentID) == false) { break; } } // request GPT reply to avatar IM GetAiReply(imchatRateLimit, e.IM.FromAgentID, e.IM.FromAgentID, e.IM.FromAgentName, e.IM.Message, true); break; } // trigger group IM if (myConfig.GetAllowGroupReplys() == true) { break; } if (myConfig.GetGroupReplyForGroup() != e.IM.IMSessionID.ToString()) { break; } GetAiReply(groupchatRateLimit, e.IM.FromAgentID, e.IM.FromAgentID, e.IM.FromAgentName, e.IM.Message, false, true); break; } default: { break; } } } protected Dictionary>> chatHistoryAI = []; protected Dictionary ChatHistoryLastAccessed = []; protected Dictionary ChatRateLimiter = []; protected long lastUpkeep = 0; protected void upkeep() { long dif = SecondbotHelpers.UnixTimeNow() - lastUpkeep; if (dif < 30) { return; // upkeep not needed } lastUpkeep = SecondbotHelpers.UnixTimeNow(); // check for expired chat windows (longer than 2 mins from last message) lock (ChatHistoryLastAccessed) lock (chatHistoryAI) lock (ChatRateLimiter) { List needcleaning = []; long now = SecondbotHelpers.UnixTimeNow(); foreach (KeyValuePair entry in ChatHistoryLastAccessed) { dif = now - entry.Value; if(dif > (myConfig.GetChatHistoryTimeout()*60)) { needcleaning.Add(entry.Key); } } foreach(UUID a in needcleaning) { chatHistoryAI.Remove(a); ChatHistoryLastAccessed.Remove(a); ChatRateLimiter.Remove(a); } } } protected bool RedisLive() { // are we using redis? if (myConfig.GetUseRedis() == false) { return false; // not using redis no need to do any more checks } else if (redis == null) { return false; // redis is dead } else if (redisDb == null) { return false; // no connection to DB } else if (redis.IsConnected == false) { // redis is down, use local storage return false; } return true; } protected List> GetHistoryFromStorage(UUID store,bool avatarchat,bool groupchat) { // are we using redis? if (RedisLive() == false) { return []; } // is redis enabled for this message type? if ((myConfig.GetRedisImchat() == true) && (avatarchat == true)) { // we need to read from redis for this IM chat window return ReadChatFromRedis(store); } else if ((myConfig.GetRedisGroupchat() == true) && (groupchat == true)) { // we need to read from redis for this group chat window return ReadChatFromRedis(store); } else if(myConfig.GetRedisLocalchat() == true) { // we need to read from redis for this local chat window return ReadChatFromRedis(store); } // Redis is not enabled for this message type, use local storage return []; } protected List> ReadChatFromRedis(UUID store) { try { RedisKey readkey = new(myConfig.GetRedisPrefix() + store.Guid.ToString()); if (redisDb.KeyExists(readkey) == false) { string rawstring = redisDb.StringGet(readkey); if (rawstring == null) { return chatHistoryAI[store]; // nothing in memory use the default store } redisDb.KeyExpire(readkey, TimeSpan.FromMinutes(myConfig.GetRedisMaxageMins())); // update the expire value return JsonSerializer.Deserialize>>(rawstring); } return NoRedisStoreFound(store); } catch (Exception ex) { LogFormater.Warn("Redis failed to unpack json using default store:" + ex.Message); return NoRedisStoreFound(store); } } protected List> NoRedisStoreFound(UUID store) { if (chatHistoryAI.ContainsKey(store) == false) { return []; } return chatHistoryAI[store]; } protected List> GetHistory(UUID store, bool avatarchat, bool groupchat, string talkingto) { // update access markers if (ChatHistoryLastAccessed.ContainsKey(store) == false) { ChatHistoryLastAccessed.Add(store, 0); } ChatHistoryLastAccessed[store] = SecondbotHelpers.UnixTimeNow(); List> history = GetHistoryFromStorage(store, avatarchat, groupchat); if(history.Count == 0) { // no history loaded from redis, check in memory if (chatHistoryAI.ContainsKey(store) == false) { // totaly new chat build it out chatHistoryAI.Add(store, []); string yourname = GetClient().Self.FirstName; if (myConfig.GetCustomName() != "") { yourname = myConfig.GetCustomName(); } if (avatarchat == true) { history.Add(new KeyValuePair("system", "You are " + yourname + ", " + myConfig.GetChatPrompt() + ", you are talking to " + talkingto + ".")); } else if (groupchat == true) { history.Add(new KeyValuePair("system", "You are " + yourname + ", " + myConfig.GetChatPrompt() + ", you are talking to a group of people.")); } else { history.Add(new KeyValuePair("system", "You are " + yourname + ", " + myConfig.GetChatPrompt() + ", you are talking to people in a public place.")); } // history ready, save to redis if in use if(StoreHistory(store, history, avatarchat, groupchat) == false) { // unable to save to redis, use local storage chatHistoryAI[store] = history; return history; } } else { history = chatHistoryAI[store]; } return history; } return history; } protected bool StoreHistory(UUID store, List> history, bool avatarchat, bool groupchat) { if (RedisLive() == false) { return false; } bool allowStore = false; if ((myConfig.GetRedisImchat() == true) && (avatarchat == true)) { allowStore = true; } else if ((myConfig.GetRedisGroupchat() == true) && (groupchat == true)) { // we need to read from redis for this group chat window allowStore = true; } else if (myConfig.GetRedisLocalchat() == true) { // we need to read from redis for this local chat window allowStore = true; } if(allowStore == false) { return false; } int maxsize = InRange(1, 9999, myConfig.GetRedisCountLocal()); if(avatarchat == true) { maxsize = InRange(1, 9999, myConfig.GetRedisCountIm()); } else if(groupchat == true) { maxsize = InRange(1, 9999, myConfig.GetRedisCountGroup()); } // trim the history using targeted max values while (history.Count > maxsize + 1) { history.RemoveAt(1); } RedisKey writekey = new(myConfig.GetRedisPrefix() + store.Guid.ToString()); try { string savestring = JsonSerializer.Serialize(history, JsonOptions.UnsafeRelaxed); if (savestring == null) { LogFormater.Warn("Redis failed to convert history into savable format"); return false; } RedisValue storeme = new(savestring); if(redisDb.StringSet(writekey, storeme) == true) { return redisDb.KeyExpire(writekey, TimeSpan.FromMinutes(myConfig.GetRedisMaxageMins())); } return false; } catch (Exception ex) { LogFormater.Warn("Redis failed to save into store:" + ex.Message); return false; } } protected List> AddHistory(UUID store, bool avatarchat, bool groupchat, string talkingto, string role, string message) { List> history = GetHistory(store, avatarchat, groupchat, talkingto); history.Add(new KeyValuePair(role, message)); if(StoreHistory(store, history, avatarchat, groupchat) == false) // try and save using redis { // unable to save to redis, write to local storage return BasicSaveHistory(store, history); } return history; } protected List> BasicSaveHistory(UUID store, List> history) { // trim the history using basic config while (history.Count > chatHistorySize + 1) { history.RemoveAt(1); } chatHistoryAI[store] = history; return history; } protected async void GetAiReply(int ratelimiter, UUID replyTo, UUID user, string name, string message, bool avatarchat = false, bool groupchat = false) { bool allowedChat = true; lock(ChatRateLimiter) { if (ChatRateLimiter.ContainsKey(user) == false) { ChatRateLimiter.Add(user, 0); } long dif = SecondbotHelpers.UnixTimeNow() - ChatRateLimiter[user]; if(dif <= ratelimiter) { allowedChat = false; } } if(allowedChat == false) { return; } lock (ChatRateLimiter) { ChatRateLimiter[user] = SecondbotHelpers.UnixTimeNow() + 1; } List messages = []; lock (chatHistoryAI) lock (ChatHistoryLastAccessed) { // convert history into the AI format while adding the new message try { foreach (KeyValuePair entry in AddHistory(user, avatarchat, groupchat, name, "user", "" + name + " says " + message)) { string role = entry.Key.ToLower(); if (role != "system" && role != "user" && role != "assistant") { throw new ArgumentException("Invalid role provided in history. Role must be 'system', 'user', or 'assistant'."); } string thismessage = entry.Value ?? throw new ArgumentException("Message can not be empty"); if (role == "system") messages.Add(ChatMessage.FromSystem(thismessage)); else if (role == "user") messages.Add(ChatMessage.FromUser(thismessage)); else if (role == "assistant") messages.Add(ChatMessage.FromAssistant(thismessage)); } } catch (Exception ex) { if (myConfig.GetShowDebug() == true) { LogFormater.Warn("error building AI request:" + ex.Message); } return; } } if(messages.Count == 0) { LogFormater.Warn("No messages given"); return; } try { string replyMessage = ""; var openAiService = new OpenAIService(new OpenAIOptions() { ApiKey = myConfig.GetApiKey(), }); if((myConfig.GetOrganizationId() != null) && (myConfig.GetOrganizationId() != "none")) { openAiService = new OpenAIService(new OpenAIOptions() { ApiKey = myConfig.GetApiKey(), Organization = myConfig.GetOrganizationId(), }); } if(myConfig.GetProvider() != "openai") { openAiService = new OpenAIService(new OpenAIOptions() { ApiKey = myConfig.GetApiKey(), Organization = myConfig.GetOrganizationId(), BaseDomain= myConfig.GetProvider(), }); } ChatCompletionCreateResponse completionResult = null; if (myConfig.GetProvider() != "openai") { completionResult = await openAiService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest { Messages = messages, Model = myConfig.GetUseModel(), }); } else { completionResult = await openAiService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest { Messages = messages, Model = OpenAIModels.GetModel(myConfig.GetUseModel()) }); } if (completionResult == null) { return; } if (completionResult.Successful) { replyMessage = completionResult.Choices.First().Message.Content; } if (replyMessage != "") { lock (chatHistoryAI) lock (ChatHistoryLastAccessed) { AddHistory(user, avatarchat, groupchat, name, "assistant", replyMessage); } if (myConfig.GetFakeTypeDelay() == true) { if ((avatarchat == false) && (groupchat == false)) { GetClient().Self.AnimationStart(Animations.TYPE, true); } double timespanwait = EstimateTypingTime(replyMessage, 0.4); if (timespanwait > 3) { timespanwait = 3; } else if (timespanwait < 1) { timespanwait = 1; } await Task.Delay(TimeSpan.FromSeconds(timespanwait)); } if (avatarchat == true) { GetClient().Self.InstantMessage(replyTo, replyMessage); } else if (groupchat == true) { GetClient().Self.InstantMessageGroup(replyTo, replyMessage); } else { GetClient().Self.Chat(replyMessage, 0, ChatType.Normal); if (myConfig.GetFakeTypeDelay() == true) { GetClient().Self.AnimationStop(Animations.TYPE, true); } } } } catch (Exception ex) { if (myConfig.GetShowDebug() == true) { LogFormater.Warn("An error occurred:" + ex.Message); } } } private static readonly Random _random = new(); public static double EstimateTypingTime(string input, double randomizationFactor) { // Average typing speed in characters per minute double typingSpeedCPM = 200.0; // Calculate the length of the input string int length = input.Length; // Calculate the base time in minutes double baseTimeInMinutes = length / typingSpeedCPM; // Convert minutes to seconds double baseTimeInSeconds = baseTimeInMinutes * 60; if(baseTimeInSeconds > 3) { baseTimeInSeconds = 3; } // Calculate the randomized adjustment // randomizationFactor of 0 results in no change // randomizationFactor of 1 results in instant typing (0 seconds) double adjustment = _random.NextDouble() * randomizationFactor * baseTimeInSeconds; // Apply the randomization factor double estimatedTimeInSeconds = baseTimeInSeconds - adjustment; // Ensure the time is not negative return Math.Max(estimatedTimeInSeconds, 0); } protected void BotClientRestart(object o, BotClientNotice e) { if (e.isStart == false) { return; } LogFormater.Info("ChatGpt [Attached to new client]"); GetClient().Network.LoggedOut += BotLoggedOut; GetClient().Network.SimConnected += BotLoggedIn; } protected void BotLoggedOut(object o, LoggedOutEventArgs e) { GetClient().Network.SimConnected += BotLoggedIn; LogFormater.Info("ChatGpt [Waiting for connect]"); } protected void BotLoggedIn(object o, SimConnectedEventArgs e) { GetClient().Network.SimConnected -= BotLoggedIn; GetClient().Self.IM += BotImMessage; GetClient().Self.ChatFromSimulator += BotLocalchat; LogFormater.Info("ChatGpt [accepting chat input]"); } } public class OpenAIModels { public static string GetModel(string input) { if (input == "gpt-3.5-turbo") { return Models.Gpt_3_5_Turbo; } else if (input == "gpt-4-mini") { return Models.Gpt_4o_mini; } else if (input == "gpt-4-turbo") { return Models.Gpt_4_turbo; } return input; } } }