SecondBot/SecondBotEvents/Services/ChatGptService.cs
Madpeter 008cb44e24 Use UnsafeRelaxed JSON serialization globally
Replaces all usages of JsonSerializer.Serialize with the UnsafeRelaxed option via a new JsonOptions.UnsafeRelaxed static property. This ensures consistent relaxed escaping for all JSON output, improving compatibility with consumers expecting unescaped JSON. Refactors affected command, service, and static classes to use the new option.
2025-10-05 20:07:42 +01:00

714 lines
27 KiB
C#

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<UUID, List<KeyValuePair<string, string>>> chatHistoryAI = [];
protected Dictionary<UUID, long> ChatHistoryLastAccessed = [];
protected Dictionary<UUID, long> 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<UUID> needcleaning = [];
long now = SecondbotHelpers.UnixTimeNow();
foreach (KeyValuePair<UUID, long> 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<KeyValuePair<string, string>> 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<KeyValuePair<string, string>> 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<List<KeyValuePair<string, string>>>(rawstring);
}
return NoRedisStoreFound(store);
}
catch (Exception ex)
{
LogFormater.Warn("Redis failed to unpack json using default store:" + ex.Message);
return NoRedisStoreFound(store);
}
}
protected List<KeyValuePair<string, string>> NoRedisStoreFound(UUID store)
{
if (chatHistoryAI.ContainsKey(store) == false)
{
return [];
}
return chatHistoryAI[store];
}
protected List<KeyValuePair<string, string>> 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<KeyValuePair<string, string>> 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() != "<!FIRSTNAME!>")
{
yourname = myConfig.GetCustomName();
}
if (avatarchat == true)
{
history.Add(new KeyValuePair<string, string>("system", "You are " + yourname + ", " + myConfig.GetChatPrompt() + ", you are talking to " + talkingto + "."));
}
else if (groupchat == true)
{
history.Add(new KeyValuePair<string, string>("system", "You are " + yourname + ", " + myConfig.GetChatPrompt() + ", you are talking to a group of people."));
}
else
{
history.Add(new KeyValuePair<string, string>("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<KeyValuePair<string, string>> 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<KeyValuePair<string, string>> AddHistory(UUID store, bool avatarchat, bool groupchat, string talkingto, string role, string message)
{
List<KeyValuePair<string, string>> history = GetHistory(store, avatarchat, groupchat, talkingto);
history.Add(new KeyValuePair<string, string>(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<KeyValuePair<string, string>> BasicSaveHistory(UUID store, List<KeyValuePair<string, string>> 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<ChatMessage> messages = [];
lock (chatHistoryAI) lock (ChatHistoryLastAccessed)
{
// convert history into the AI format while adding the new message
try
{
foreach (KeyValuePair<string, string> 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;
}
}
}