From f9094f6fca936a5f29bbcbd3a1fcbf64aac446dc Mon Sep 17 00:00:00 2001 From: RebeccaNod1 Date: Sat, 13 Jun 2026 04:30:13 -0600 Subject: [PATCH] feat: persist pending group invites and resolve names, restore custom avatar methods --- Dockerfile | 4 +- .../LibreMetaverse.StructuredData.csproj | 4 +- .../LibreMetaverse.Types.csproj | 4 +- LibreMetaverse/GridManager.cs | 6 +- LibreMetaverse/LibreMetaverse.csproj | 4 +- SecondBotEvents/Commands/Animation.cs | 426 +++++++++++++++++- SecondBotEvents/Commands/Avatars.cs | 190 +++++++- SecondBotEvents/Commands/Core.cs | 8 + SecondBotEvents/Commands/Friends.cs | 39 +- SecondBotEvents/Commands/Group.cs | 61 ++- SecondBotEvents/Commands/Info.cs | 15 +- SecondBotEvents/Commands/ParcelCommands.cs | 17 +- SecondBotEvents/Config/BasicConfig.cs | 26 ++ SecondBotEvents/Config/BotRejoinConfig.cs | 20 + SecondBotEvents/Config/HttpConfig.cs | 8 +- SecondBotEvents/EventsSecondBot.cs | 10 + SecondBotEvents/SecondBotEvents.csproj | 2 +- SecondBotEvents/Services/BotClientService.cs | 124 ++++- SecondBotEvents/Services/CommandsService.cs | 2 +- SecondBotEvents/Services/DataStoreService.cs | 410 ++++++++++++++++- SecondBotEvents/Services/DialogService.cs | 18 + SecondBotEvents/Services/DiscordService.cs | 4 +- SecondBotEvents/Services/HttpService.cs | 12 +- SecondBotEvents/Services/RabbitService.cs | 3 +- 24 files changed, 1350 insertions(+), 67 deletions(-) create mode 100644 SecondBotEvents/Config/BotRejoinConfig.cs diff --git a/Dockerfile b/Dockerfile index feaeccf..2a79c63 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base +FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base WORKDIR /app -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["NuGet.Config", "."] COPY ["SecondBotEvents/SecondBotEvents.csproj", "SecondBotEvents/"] diff --git a/LibreMetaverse.StructuredData/LibreMetaverse.StructuredData.csproj b/LibreMetaverse.StructuredData/LibreMetaverse.StructuredData.csproj index 254a72e..34b052f 100644 --- a/LibreMetaverse.StructuredData/LibreMetaverse.StructuredData.csproj +++ b/LibreMetaverse.StructuredData/LibreMetaverse.StructuredData.csproj @@ -1,9 +1,9 @@ - + LibreMetaverse.StructuredData LibreMetaverse structured data library LibreMetaverse.StructuredData - netstandard2.0;net8.0;net9.0 + netstandard2.0;net8.0 Library LibreMetaverse.StructuredData true diff --git a/LibreMetaverse.Types/LibreMetaverse.Types.csproj b/LibreMetaverse.Types/LibreMetaverse.Types.csproj index fbd2d4a..b747656 100644 --- a/LibreMetaverse.Types/LibreMetaverse.Types.csproj +++ b/LibreMetaverse.Types/LibreMetaverse.Types.csproj @@ -1,9 +1,9 @@ - + LibreMetaverse.Types LibreMetaverse.Types LibreMetaverse type library - netstandard2.0;net8.0;net9.0 + netstandard2.0;net8.0 Library LibreMetaverse true diff --git a/LibreMetaverse/GridManager.cs b/LibreMetaverse/GridManager.cs index 9b2eb77..0526623 100644 --- a/LibreMetaverse/GridManager.cs +++ b/LibreMetaverse/GridManager.cs @@ -375,11 +375,11 @@ namespace OpenMetaverse public ulong TimeOfDay { get; private set; } /// A dictionary of all the regions, indexed by region name - internal Dictionary Regions = new Dictionary(); + public Dictionary Regions = new Dictionary(); /// A dictionary of all the regions, indexed by region handle - internal Dictionary RegionsByHandle = new Dictionary(); + public Dictionary RegionsByHandle = new Dictionary(); /// A dictionary of regions by region handle - internal Dictionary RegionsByUUID = new Dictionary(); + public Dictionary RegionsByUUID = new Dictionary(); private readonly GridClient Client; diff --git a/LibreMetaverse/LibreMetaverse.csproj b/LibreMetaverse/LibreMetaverse.csproj index d983dec..71a8dff 100644 --- a/LibreMetaverse/LibreMetaverse.csproj +++ b/LibreMetaverse/LibreMetaverse.csproj @@ -1,4 +1,4 @@ - + LibreMetaverse LibreMetaverse @@ -12,7 +12,7 @@ true snupkg $(NoWarn);CS0419;CS8981;CS1591;CS1574 - netstandard2.0;net8.0;net9.0 + netstandard2.0;net8.0 AnyCPU ..\bin\ diff --git a/SecondBotEvents/Commands/Animation.cs b/SecondBotEvents/Commands/Animation.cs index 53a715a..6894855 100644 --- a/SecondBotEvents/Commands/Animation.cs +++ b/SecondBotEvents/Commands/Animation.cs @@ -1,4 +1,5 @@ -using OpenMetaverse; +using OpenMetaverse; +using OpenMetaverse.Assets; using SecondBotEvents.Services; using System; using System.Collections.Generic; @@ -7,8 +8,90 @@ using System.Linq; namespace SecondBotEvents.Commands { [ClassInfo("Animations and Gestures")] - internal class AnimationCommands(EventsSecondBot setmaster) : CommandsAPI(setmaster) + public class AnimationCommands(EventsSecondBot setmaster) : CommandsAPI(setmaster) { + private static readonly Dictionary gestureTriggerCache = new Dictionary(); + private static readonly Dictionary gestureReplaceCache = new Dictionary(); + private static bool isPreCachingStarted = false; + private static bool isPreCachingActive = false; + private static readonly string cacheFilePath = "data/gesture_cache.json"; + private static bool isCacheLoaded = false; + private static readonly object cacheLock = new object(); + + public class GestureCacheEntry + { + public string Trigger { get; set; } + public string ReplaceWith { get; set; } + } + + private static void LoadGestureCache() + { + lock (cacheLock) + { + if (isCacheLoaded) return; + isCacheLoaded = true; + + try + { + System.IO.Directory.CreateDirectory("data"); + if (System.IO.File.Exists(cacheFilePath)) + { + string json = System.IO.File.ReadAllText(cacheFilePath); + var loaded = System.Text.Json.JsonSerializer.Deserialize>(json); + if (loaded != null) + { + lock (gestureTriggerCache) + { + foreach (var kvp in loaded) + { + if (UUID.TryParse(kvp.Key, out UUID uuid)) + { + gestureTriggerCache[uuid] = kvp.Value.Trigger; + gestureReplaceCache[uuid] = kvp.Value.ReplaceWith; + } + } + } + } + } + } + catch (Exception ex) + { + LogFormater.Warn("Error loading gesture cache: " + ex.Message); + } + } + } + + private static void SaveGestureCache() + { + lock (cacheLock) + { + try + { + System.IO.Directory.CreateDirectory("data"); + var dump = new Dictionary(); + lock (gestureTriggerCache) + { + foreach (var kvp in gestureTriggerCache) + { + string replaceWith = ""; + gestureReplaceCache.TryGetValue(kvp.Key, out replaceWith); + dump[kvp.Key.ToString()] = new GestureCacheEntry + { + Trigger = kvp.Value, + ReplaceWith = replaceWith ?? "" + }; + } + } + string json = System.Text.Json.JsonSerializer.Serialize(dump); + System.IO.File.WriteAllText(cacheFilePath, json); + } + catch (Exception ex) + { + LogFormater.Warn("Error saving gesture cache: " + ex.Message); + } + } + } + [About("Toggles if animation requests from this avatar (used for remote poseballs) are accepted")] [ReturnHints("Granted perm animation")] [ReturnHints("Removed perm animation")] @@ -25,7 +108,7 @@ namespace SecondBotEvents.Commands // @todo accept storage return Failure("@todo"); } - + [About("Attempts to play a gesture \n using the inventory uuid")] [ReturnHintsFailure("Error with gesture inventory uuid")] [ReturnHints("Accepted")] @@ -41,8 +124,10 @@ namespace SecondBotEvents.Commands InventoryItem itm = GetClient().Inventory.FetchItem(gestureUUID, GetClient().Self.AgentID, TimeSpan.FromSeconds(15)); if (itm == null) { - return BasicReply("unable to get getsture from inventory", [gesture]); + return BasicReply("unable to get gesture from inventory", [gesture]); } + LogFormater.Info($"Playing gesture: {itm.Name} ({itm.AssetUUID})"); + GetClient().Self.ActivateGesture(itm.UUID, itm.AssetUUID); GetClient().Self.PlayGesture(itm.AssetUUID); return BasicReply("Accepted", [gesture]); } @@ -61,6 +146,339 @@ namespace SecondBotEvents.Commands return BasicReply("Accepted", [gesture]); } + [About("Plays a built-in animation by UUID or name")] + [ArgHints("animation", "UUID or name of the animation", "STRING")] + [CmdTypeDo()] + public object PlayAnimation(string animation) + { + UUID animID; + if (!UUID.TryParse(animation, out animID)) + { + var field = typeof(Animations).GetField(animation.ToUpper(), System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (field != null) + { + animID = (UUID)field.GetValue(null); + } + else + { + return Failure("Invalid animation name or UUID"); + } + } + GetClient().Self.AnimationStart(animID, true); + return BasicReply("Accepted", [animation]); + } + + private string GetGestureTrigger(InventoryItem itm) + { + LoadGestureCache(); + lock (gestureTriggerCache) + { + if (gestureTriggerCache.TryGetValue(itm.AssetUUID, out string cachedTrigger)) + { + return cachedTrigger; + } + } + + AssetGesture gesture = null; + System.Threading.AutoResetEvent gotAsset = new System.Threading.AutoResetEvent(false); + + GetClient().Assets.RequestAsset(itm.AssetUUID, AssetType.Gesture, true, + delegate(AssetDownload transfer, Asset asset) + { + if (transfer.Success) + { + gesture = (AssetGesture)asset; + } + gotAsset.Set(); + } + ); + + // Wait 500ms max (gestures are tiny and take < 50ms usually) + gotAsset.WaitOne(TimeSpan.FromMilliseconds(500), false); + + if (gesture != null && gesture.Decode()) + { + string trigger = gesture.Trigger.Trim().ToLowerInvariant(); + string replaceWith = gesture.ReplaceWith ?? ""; + lock (gestureTriggerCache) + { + gestureTriggerCache[itm.AssetUUID] = trigger; + gestureReplaceCache[itm.AssetUUID] = replaceWith; + } + if (!isPreCachingActive) + { + SaveGestureCache(); + } + return trigger; + } + + return null; + } + + private void StartPreCachingGestures() + { + LoadGestureCache(); + if (isPreCachingStarted) return; + isPreCachingStarted = true; + + System.Threading.ThreadPool.QueueUserWorkItem(delegate + { + try + { + List allGestures = new List(); + UUID gestureFolder = GetClient().Inventory.FindFolderForType(AssetType.Gesture); + if (gestureFolder != UUID.Zero) + { + List contents = GetClient().Inventory.FolderContents(gestureFolder, GetClient().Self.AgentID, false, true, InventorySortOrder.ByName, TimeSpan.FromSeconds(15)); + if (contents != null) + { + foreach (InventoryBase item in contents) + { + if (item is InventoryItem invItem && invItem.InventoryType == InventoryType.Gesture) + { + allGestures.Add(invItem); + } + } + } + } + + Inventory store = GetClient().Inventory.GetStore; + lock (store.Items) + { + foreach (var node in store.Items.Values) + { + if (node.Data is InventoryItem item && item.InventoryType == InventoryType.Gesture) + { + if (!allGestures.Any(g => g.UUID == item.UUID)) + { + allGestures.Add(item); + } + } + } + } + + isPreCachingActive = true; + foreach (var item in allGestures) + { + GetGestureTrigger(item); + } + } + catch (Exception ex) + { + LogFormater.Warn("Error pre-caching gestures: " + ex.Message); + } + finally + { + isPreCachingActive = false; + SaveGestureCache(); + } + }); + } + + private InventoryItem FindGestureInInventory(string search) + { + StartPreCachingGestures(); + + search = search.Trim().ToLowerInvariant(); + string searchWithSlash = search.StartsWith("/") ? search : "/" + search; + string searchWithoutSlash = search.StartsWith("/") ? search.Substring(1) : search; + + List allGestures = new List(); + + // 1. Gather all gestures from default Gestures folder + UUID gestureFolder = GetClient().Inventory.FindFolderForType(AssetType.Gesture); + if (gestureFolder != UUID.Zero) + { + List contents = GetClient().Inventory.FolderContents(gestureFolder, GetClient().Self.AgentID, false, true, InventorySortOrder.ByName, TimeSpan.FromSeconds(15)); + if (contents != null) + { + foreach (InventoryBase item in contents) + { + if (item is InventoryItem invItem && invItem.InventoryType == InventoryType.Gesture) + { + allGestures.Add(invItem); + } + } + } + } + + // 2. Gather from general inventory store cache + Inventory store = GetClient().Inventory.GetStore; + lock (store.Items) + { + foreach (var node in store.Items.Values) + { + if (node.Data is InventoryItem item && item.InventoryType == InventoryType.Gesture) + { + if (!allGestures.Any(g => g.UUID == item.UUID)) + { + allGestures.Add(item); + } + } + } + } + + // 3. Step A: Search for exact name match first (super fast, no asset download) + foreach (var item in allGestures) + { + string itemName = item.Name.Trim().ToLowerInvariant(); + if (itemName == searchWithSlash || itemName == searchWithoutSlash) + { + return item; + } + } + + // 4. Step B: Search for trigger match (downloads/decodes asset, then checks cache) + InventoryItem bestMatch = null; + int bestScore = -9999; + + string botFirstName = GetClient().Self.FirstName.ToLowerInvariant(); + bool isFemaleBot = botFirstName.Contains("girl") || botFirstName.Contains("female") || botFirstName.Contains("lady") || botFirstName.Contains("miss") || botFirstName.Contains("woman") || botFirstName.Contains("queen") || botFirstName.Contains("princess") || botFirstName.Contains("she"); + bool isMaleBot = botFirstName.Contains("boy") || botFirstName.Contains("male") || botFirstName.Contains("guy") || botFirstName.Contains("man") || botFirstName.Contains("king") || botFirstName.Contains("prince") || botFirstName.Contains("he"); + + foreach (var item in allGestures) + { + string trigger = GetGestureTrigger(item); + if (trigger != null) + { + string triggerWithSlash = trigger.StartsWith("/") ? trigger : "/" + trigger; + string triggerWithoutSlash = trigger.StartsWith("/") ? trigger.Substring(1) : trigger; + if (trigger == search || triggerWithSlash == search || triggerWithoutSlash == search || + trigger == searchWithSlash || trigger == searchWithoutSlash) + { + int score = 0; + string itemName = item.Name.ToLowerInvariant(); + + if (isFemaleBot) + { + if (itemName.Contains("female") || itemName.Contains("girl") || itemName.Contains("woman") || itemName.Contains("lady") || itemName.Contains("she")) + score += 10; + if (itemName.Contains("male") || itemName.Contains("boy") || itemName.Contains("man") || itemName.Contains("guy") || itemName.Contains("he")) + score -= 10; + } + else if (isMaleBot) + { + if (itemName.Contains("male") || itemName.Contains("boy") || itemName.Contains("man") || itemName.Contains("guy") || itemName.Contains("he")) + score += 10; + if (itemName.Contains("female") || itemName.Contains("girl") || itemName.Contains("woman") || itemName.Contains("lady") || itemName.Contains("she")) + score -= 10; + } + + if (score > bestScore) + { + bestScore = score; + bestMatch = item; + } + } + } + } + + if (bestMatch != null) + { + return bestMatch; + } + + return null; + } + + [About("Plays a gesture by name from the inventory")] + [ArgHints("gesture", "name of the gesture to play", "STRING")] + [CmdTypeDo()] + public object PlayGestureByName(string gesture) + { + string search = gesture.Trim().ToLowerInvariant(); + + // First try to match the exact string (like "clap" or ":-D") + InventoryItem foundGesture = FindGestureInInventory(search); + + // If no exact match, try splitting into words (natural speak matching, like "i am a sad :-(") + if (foundGesture == null) + { + string[] words = search.Split(new[] { ' ', ',', '.', '!', '?' }, StringSplitOptions.RemoveEmptyEntries); + foreach (string word in words) + { + foundGesture = FindGestureInInventory(word); + if (foundGesture != null) + break; + } + } + + if (foundGesture == null) + { + return Failure("Gesture not found in inventory"); + } + + GetClient().Self.ActivateGesture(foundGesture.UUID, foundGesture.AssetUUID); + GetClient().Self.PlayGesture(foundGesture.AssetUUID); + + string replaceWith = ""; + lock (gestureTriggerCache) + { + gestureReplaceCache.TryGetValue(foundGesture.AssetUUID, out replaceWith); + } + + master.DataStoreService.AddCommandToHistory(true, "PlayGestureByName", [gesture]); + return new + { + command = "PlayGestureByName", + args = new[] { gesture }, + reply = "Accepted", + status = true, + replace_with = replaceWith ?? "", + matched_trigger = GetGestureTrigger(foundGesture) ?? "" + }; + } + + + [About("Activates a gesture in the bot's inventory")] + [ArgHints("gesture", "inventory uuid of the gesture", "UUID")] + [CmdTypeDo()] + public object ActivateGesture(string gesture) + { + if (UUID.TryParse(gesture, out UUID gestureUUID) == false) return Failure("Invaild UUID"); + InventoryItem itm = GetClient().Inventory.FetchItem(gestureUUID, GetClient().Self.AgentID, TimeSpan.FromSeconds(15)); + if (itm == null) return Failure("unable to get gesture from inventory"); + GetClient().Self.ActivateGesture(itm.UUID, itm.AssetUUID); + return BasicReply("Ok"); + } + + [About("Deactivates a gesture in the bot's inventory")] + [ArgHints("gesture", "inventory uuid of the gesture", "UUID")] + [CmdTypeDo()] + public object DeactivateGesture(string gesture) + { + if (UUID.TryParse(gesture, out UUID gestureUUID) == false) return Failure("Invaild UUID"); + GetClient().Self.DeactivateGesture(gestureUUID); + return BasicReply("Ok"); + } + + [About("Clears the saved gesture cache and runs pre-caching again to pick up new or changed gestures")] + [CmdTypeDo()] + public object RecacheGestures() + { + lock (cacheLock) + { + try + { + if (System.IO.File.Exists(cacheFilePath)) + { + System.IO.File.Delete(cacheFilePath); + } + } + catch {} + + lock (gestureTriggerCache) + { + gestureTriggerCache.Clear(); + gestureReplaceCache.Clear(); + } + isCacheLoaded = false; + isPreCachingStarted = false; + } + StartPreCachingGestures(); + return BasicReply("Recaching started"); + } + [About("Resets the animation stack for the bot")] [ReturnHints("Accepted - X stopped animations")] [CmdTypeDo()] diff --git a/SecondBotEvents/Commands/Avatars.cs b/SecondBotEvents/Commands/Avatars.cs index d3bf5ee..e6e8b20 100644 --- a/SecondBotEvents/Commands/Avatars.cs +++ b/SecondBotEvents/Commands/Avatars.cs @@ -193,6 +193,7 @@ namespace SecondBotEvents.Commands Avatar.AvatarProperties props = reply.Value; props.ProfileImage = textureUUID; GetClient().Self.UpdateProfile(props); + master.DataStoreService.UpdateAvatarProperties(GetClient().Self.AgentID, props); return BasicReply("ok"); } @@ -230,7 +231,7 @@ namespace SecondBotEvents.Commands { return Failure("Invaild avatar uuid", [avatar]); } - KeyValuePair> reply = master.DataStoreService.GetAvatarPicks(avataruuid); + KeyValuePair> reply = master.DataStoreService.GetAvatarPicksDetailed(avataruuid); if (reply.Key == false) { return Failure("Requesting avatar details [Retry later]", [avatar]); @@ -259,7 +260,7 @@ namespace SecondBotEvents.Commands [ReturnHints("Requesting avatar details [Retry later]")] [ReturnHintsFailure("about text to short")] [ReturnHintsFailure("about text to long")] - [ArgHints("abouttext", "text to use in the about me area, length 10 to 300", "Text", "Hello world!")] + [ArgHints("abouttext", "text to use in the about me area, length 10 to 510 bytes", "Text", "Hello world!")] [CmdTypeSet()] public object SetProfileAbout(string abouttext) { @@ -267,7 +268,7 @@ namespace SecondBotEvents.Commands { return Failure("About text to short", [abouttext]); } - else if (abouttext.Length > 300) + else if (System.Text.Encoding.UTF8.GetByteCount(abouttext) > 510) { return Failure("About text to long", [abouttext]); } @@ -279,6 +280,7 @@ namespace SecondBotEvents.Commands Avatar.AvatarProperties props = reply.Value; props.AboutText = abouttext; GetClient().Self.UpdateProfile(props); + master.DataStoreService.UpdateAvatarProperties(GetClient().Self.AgentID, props); return BasicReply("ok"); } @@ -335,5 +337,187 @@ namespace SecondBotEvents.Commands } return BasicReply(master.DataStoreService.GetAvatarUUID(name)); } + + [About("Sets the bot display name")] + [CmdTypeSet()] + public object SetDisplayName(string newname) + { + if (string.IsNullOrWhiteSpace(newname)) + { + return Failure("Display name cannot be empty"); + } + GetClient().Self.SetDisplayName(GetClient().Self.Name, newname); + return BasicReply("ok"); + } + + [About("Sets the bot first life profile text and image")] + [CmdTypeSet()] + public object SetFirstLife(string text, string image) + { + if (UUID.TryParse(image, out UUID imageUUID) == false) + { + return Failure("Invalid image uuid"); + } + KeyValuePair reply = master.DataStoreService.GetAvatarAvatarProperties(GetClient().Self.AgentID); + if (reply.Key == false) + { + return Failure("Requesting avatar details [Retry later]"); + } + Avatar.AvatarProperties props = reply.Value; + props.FirstLifeText = text; + props.FirstLifeImage = imageUUID; + GetClient().Self.UpdateProfile(props); + master.DataStoreService.UpdateAvatarProperties(GetClient().Self.AgentID, props); + return BasicReply("ok"); + } + + [About("Creates or updates a profile pick")] + [CmdTypeSet()] + public object SetAvatarPick(string id, string name, string desc, string snapshot, string sim, string pos) + { + if (UUID.TryParse(id, out UUID pickUUID) == false) + { + return Failure("Invalid pick uuid"); + } + if (UUID.TryParse(snapshot, out UUID snapshotUUID) == false) + { + snapshotUUID = UUID.Zero; + } + Vector3 position = Vector3.Zero; + if (Vector3.TryParse(pos, out Vector3 parsedPos)) + { + position = parsedPos; + } + Vector3d globalPos = GetClient().Self.GlobalPosition; + if (!string.IsNullOrWhiteSpace(sim) && sim == GetClient().Network.CurrentSim.Name) + { + Utils.LongToUInts(GetClient().Network.CurrentSim.Handle, out uint x, out uint y); + globalPos = new Vector3d(x + position.X, y + position.Y, position.Z); + } + GetClient().Self.PickInfoUpdate(pickUUID, false, UUID.Zero, name, globalPos, snapshotUUID, desc); + master.DataStoreService.ClearAvatarPicksCache(GetClient().Self.AgentID); + return BasicReply("ok"); + } + + [About("Deletes a profile pick")] + [CmdTypeSet()] + public object DeleteAvatarPick(string id) + { + if (UUID.TryParse(id, out UUID pickUUID) == false) + { + return Failure("Invalid pick uuid"); + } + GetClient().Self.PickDelete(pickUUID); + master.DataStoreService.ClearAvatarPicksCache(GetClient().Self.AgentID); + return BasicReply("ok"); + } + + [About("Creates or updates a classified ad")] + [CmdTypeSet()] + public object SetAvatarClassified(string id, string name, string desc, string snapshot, string category, string price, string autorenew) + { + if (UUID.TryParse(id, out UUID classifiedUUID) == false) + { + return Failure("Invalid classified uuid"); + } + if (UUID.TryParse(snapshot, out UUID snapshotUUID) == false) + { + snapshotUUID = UUID.Zero; + } + int adPrice = 50; + int.TryParse(price, out adPrice); + bool renew = autorenew.ToLower() == "true"; + + DirectoryManager.ClassifiedCategories cat = DirectoryManager.ClassifiedCategories.Shopping; + if (Enum.TryParse(category, out DirectoryManager.ClassifiedCategories parsedCat)) + { + cat = parsedCat; + } + + GetClient().Self.UpdateClassifiedInfo(classifiedUUID, cat, snapshotUUID, adPrice, name, desc, renew); + master.DataStoreService.ClearAvatarClassifiedsCache(GetClient().Self.AgentID); + return BasicReply("ok"); + } + + [About("Deletes a classified ad")] + [CmdTypeSet()] + public object DeleteAvatarClassified(string id) + { + if (UUID.TryParse(id, out UUID classifiedUUID) == false) + { + return Failure("Invalid classified uuid"); + } + GetClient().Self.DeleteClassified(classifiedUUID); + master.DataStoreService.ClearAvatarClassifiedsCache(GetClient().Self.AgentID); + return BasicReply("ok"); + } + + [About("Requests the given avatars profile classifieds")] + [CmdTypeGet()] + public object GetAvatarClassifieds(string avatar) + { + ProcessAvatar(avatar); + if (avataruuid == UUID.Zero) + { + return Failure("Invaild avatar uuid", [avatar]); + } + KeyValuePair> reply = master.DataStoreService.GetAvatarClassifiedsDetailed(avataruuid); + if (reply.Key == false) + { + return Failure("Requesting avatar details [Retry later]", [avatar]); + } + return BasicReply(JsonSerializer.Serialize(reply.Value, JsonOptions.UnsafeRelaxed)); + } + + [About("Requests the given avatars first life profile text")] + [CmdTypeGet()] + public object GetFirstLifeText(string avatar) + { + ProcessAvatar(avatar); + if (avataruuid == UUID.Zero) + { + return Failure("Invaild avatar uuid", [avatar]); + } + KeyValuePair reply = master.DataStoreService.GetAvatarAvatarProperties(avataruuid); + if (reply.Key == false) + { + return Failure("Requesting avatar details [Retry later]", [avatar]); + } + return BasicReply(reply.Value.FirstLifeText.ToString()); + } + + [About("Requests the given avatars first life profile image")] + [CmdTypeGet()] + public object GetFirstLifeImage(string avatar) + { + ProcessAvatar(avatar); + if (avataruuid == UUID.Zero) + { + return Failure("Invaild avatar uuid", [avatar]); + } + KeyValuePair reply = master.DataStoreService.GetAvatarAvatarProperties(avataruuid); + if (reply.Key == false) + { + return Failure("Requesting avatar details [Retry later]", [avatar]); + } + return BasicReply(reply.Value.FirstLifeImage.ToString()); + } + + [About("Requests the given avatars profile groups")] + [CmdTypeGet()] + public object GetAvatarGroups(string avatar) + { + ProcessAvatar(avatar); + if (avataruuid == UUID.Zero) + { + return Failure("Invaild avatar uuid", [avatar]); + } + KeyValuePair> reply = master.DataStoreService.GetAvatarGroups(avataruuid); + if (reply.Key == false) + { + return Failure("Requesting avatar details [Retry later]", [avatar]); + } + return BasicReply(JsonSerializer.Serialize(reply.Value, JsonOptions.UnsafeRelaxed)); + } } } diff --git a/SecondBotEvents/Commands/Core.cs b/SecondBotEvents/Commands/Core.cs index d5b54c6..415f154 100644 --- a/SecondBotEvents/Commands/Core.cs +++ b/SecondBotEvents/Commands/Core.cs @@ -20,6 +20,14 @@ namespace SecondBotEvents.Commands { return BasicReply(GetClient().Self.Name); } + + [About("Returns the bot's UUID")] + [ReturnHints("Bot UUID")] + [CmdTypeGet()] + public object GetBotUUID() + { + return BasicReply(GetClient().Self.AgentID.ToString()); + } } public class CommandLibCall diff --git a/SecondBotEvents/Commands/Friends.cs b/SecondBotEvents/Commands/Friends.cs index fe159d9..5d19f82 100644 --- a/SecondBotEvents/Commands/Friends.cs +++ b/SecondBotEvents/Commands/Friends.cs @@ -44,21 +44,48 @@ namespace SecondBotEvents.Commands { Dictionary FriendListCopy = GetClient().Friends.FriendList.ToDictionary(k => k.Key, v => v.Value); List< FriendListEntry > CleanedFriendsList = []; - int index = 0; foreach (FriendInfo A in FriendListCopy.Values) { FriendListEntry entry = new() { id = A.UUID.ToString(), name = A.Name, - online = A.IsOnline + online = A.IsOnline, + canSeeMeOnline = A.CanSeeMeOnline, + canSeeMeOnMap = A.CanSeeMeOnMap, + canModifyMyObjects = A.CanModifyMyObjects, + canSeeThemOnline = A.CanSeeThemOnline, + canSeeThemOnMap = A.CanSeeThemOnMap, + canModifyTheirObjects = A.CanModifyTheirObjects }; CleanedFriendsList.Add(entry); - index++; } return BasicReply(JsonSerializer.Serialize(CleanedFriendsList, JsonOptions.UnsafeRelaxed)); } + [About("Updates specific rights granted to a friend")] + [ArgHints("avatar", "The avatar to change rights for","AVATAR")] + [ArgHints("rights", "The exact integer mask of FriendRights to grant","INT")] + [CmdTypeSet()] + public object FriendSetRights(string avatar, string rights) + { + ProcessAvatar(avatar); + if (avataruuid == UUID.Zero) + { + return Failure("avatar lookup", [avatar, rights]); + } + if (int.TryParse(rights, out int mask) == false) + { + return Failure("invalid rights mask", [avatar, rights]); + } + if (GetClient().Friends.FriendList.ContainsKey(avataruuid) == false) + { + return Failure("Not A friend", [avatar, rights]); + } + GetClient().Friends.GrantRights(avataruuid, (FriendRights)mask); + return BasicReply("rights_updated", [avatar, rights]); + } + [About("Updates the friend perms for avatar avatar to State \n if true grants (Online/Map/Modify) perms")] [ArgHints("avatar", "Who we are talking about","AVATAR")] [ArgHints("state", "true: Grant perms, false: Remove perms","BOOL")] @@ -186,5 +213,11 @@ namespace SecondBotEvents.Commands public string name; public string id; public bool online = false; + public bool canSeeMeOnline = false; + public bool canSeeMeOnMap = false; + public bool canModifyMyObjects = false; + public bool canSeeThemOnline = false; + public bool canSeeThemOnMap = false; + public bool canModifyTheirObjects = false; } } diff --git a/SecondBotEvents/Commands/Group.cs b/SecondBotEvents/Commands/Group.cs index 54c336c..a0de98d 100644 --- a/SecondBotEvents/Commands/Group.cs +++ b/SecondBotEvents/Commands/Group.cs @@ -2,6 +2,7 @@ using System.Text.Json; using OpenMetaverse; using SecondBotEvents.Services; using System.Collections.Generic; +using System.Threading; namespace SecondBotEvents.Commands { @@ -354,24 +355,63 @@ namespace SecondBotEvents.Commands return BasicReply("Working on it"); } - [About("Requests the roles for the selected group")] - [ReturnHints("GroupRoleDetails object")] - [ReturnHintsFailure("Group is not currently known")] + [About("Returns a list of roles for a group")] + [ReturnHints("dictionary uuid:name")] [ReturnHintsFailure("Invaild group UUID")] [ReturnHintsFailure("Updating")] [ArgHints("group", "the group to get roles from", "UUID")] + [ArgHints("botOnly", "only return roles the bot is in", "bool")] + [ArgHints("force", "force refresh from server", "bool")] [CmdTypeGet()] - public object GetGroupRoles(string group) + public object GetGroupRoles(string group, string botOnly = "false", string force = "false") { if (UUID.TryParse(group, out UUID groupuuid) == false) { return Failure("Invaild group UUID", [group]); } - if (GetClient().Groups.GroupName2KeyCache.ContainsKey(groupuuid) == false) + + bool doForce = force.ToLower() == "true"; + Dictionary allRoles = master.DataStoreService.GetGroupRoles(groupuuid, doForce); + + if (botOnly.ToLower() == "true") { - return Failure("Unknown group", [group]); + Dictionary botRoles = []; + AutoResetEvent fetchEvent = new(false); + void callback(object sender, GroupRolesMembersReplyEventArgs e) + { + if (e.GroupID == groupuuid) + { + foreach (KeyValuePair pair in e.RolesMembers) + { + if (pair.Value == GetClient().Self.AgentID) + { + if (allRoles.ContainsKey(pair.Key)) + { + botRoles[pair.Key] = allRoles[pair.Key]; + } + } + } + fetchEvent.Set(); + } + } + GetClient().Groups.GroupRoleMembersReply += callback; + GetClient().Groups.RequestGroupRolesMembers(groupuuid); + fetchEvent.WaitOne(2000, false); + GetClient().Groups.GroupRoleMembersReply -= callback; + + // Ensure 'Everyone' role is included if we missed it + foreach(var role in allRoles) + { + if (role.Value.ToLower() == "everyone" || role.Key == UUID.Zero) + { + if (!botRoles.ContainsKey(role.Key)) botRoles[role.Key] = role.Value; + } + } + + return BasicReply(JsonSerializer.Serialize(botRoles, JsonOptions.UnsafeRelaxed)); } - return BasicReply(JsonSerializer.Serialize(master.DataStoreService.GetGroupRoles(groupuuid), JsonOptions.UnsafeRelaxed)); + + return BasicReply(JsonSerializer.Serialize(allRoles, JsonOptions.UnsafeRelaxed)); } [About("fetchs a list of all groups with unread messages")] @@ -483,5 +523,12 @@ namespace SecondBotEvents.Commands master.DataStoreService.RemovePendingGroupInvite(groupuuid); return BasicReply("Done"); } + + [About("Gets the currently active group and title")] + [CmdTypeGet()] + public object GetGroupStatus() + { + return BasicReply(GetClient().Self.ActiveGroup.ToString(), [master.DataStoreService.GetActiveGroupTitle(), master.DataStoreService.GetGroupName(GetClient().Self.ActiveGroup)]); + } } } diff --git a/SecondBotEvents/Commands/Info.cs b/SecondBotEvents/Commands/Info.cs index 154b00e..a1273c9 100644 --- a/SecondBotEvents/Commands/Info.cs +++ b/SecondBotEvents/Commands/Info.cs @@ -179,11 +179,22 @@ namespace SecondBotEvents.Commands { return Failure("Error not in a sim"); } - Dictionary pos = new() + uint sittingOn = GetClient().Self.SittingOn; + string sittingOnUUID = "00000000-0000-0000-0000-000000000000"; + if (sittingOn != 0) + { + if (GetClient().Network.CurrentSim.ObjectsPrimitives.TryGetValue(sittingOn, out Primitive prim)) + { + sittingOnUUID = prim.ID.ToString(); + } + } + + Dictionary pos = new() { { "x", (int)Math.Round(GetClient().Self.SimPosition.X) }, { "y", (int)Math.Round(GetClient().Self.SimPosition.Y) }, - { "z", (int)Math.Round(GetClient().Self.SimPosition.Z) } + { "z", (int)Math.Round(GetClient().Self.SimPosition.Z) }, + { "sitting_on", sittingOnUUID } }; return BasicReply(JsonSerializer.Serialize(pos, JsonOptions.UnsafeRelaxed)); } diff --git a/SecondBotEvents/Commands/ParcelCommands.cs b/SecondBotEvents/Commands/ParcelCommands.cs index 12c643b..e768c97 100644 --- a/SecondBotEvents/Commands/ParcelCommands.cs +++ b/SecondBotEvents/Commands/ParcelCommands.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using OpenMetaverse; using OpenMetaverse.ImportExport.Collada14; using SecondBotEvents.Services; @@ -86,6 +86,21 @@ namespace SecondBotEvents.Commands return BasicReply(test.ToString()); } + [About("Gets the current parcel's snapshot UUID")] + [ReturnHints("snapshot uuid")] + [ReturnHintsFailure("Error not in a sim")] + [ReturnHintsFailure("Parcel data not ready")] + [CmdTypeGet()] + public object GetCurrentParcelSnapshot() + { + KeyValuePair tests = SetupCurrentParcel(); + if (tests.Key == false) + { + return Failure(tests.Value); + } + return BasicReply(targetparcel.SnapshotID.ToString()); + } + [About("Requests the bot update its list of parcels ready for other commands")] [ReturnHints("ok")] [CmdTypeDo()] diff --git a/SecondBotEvents/Config/BasicConfig.cs b/SecondBotEvents/Config/BasicConfig.cs index 3301744..7990b66 100644 --- a/SecondBotEvents/Config/BasicConfig.cs +++ b/SecondBotEvents/Config/BasicConfig.cs @@ -16,6 +16,14 @@ namespace SecondBotEvents.Config settings.Add("DefaultHoverHeight"); settings.Add("MAC"); settings.Add("Id0"); + settings.Add("StartLocation"); + settings.Add("ReconnectDelaySecs"); + settings.Add("MaxReconnectDelaySecs"); + } + + public string GetStartLocation() + { + return ReadSettingAsString("StartLocation", "last"); } public double GetDefaultHoverHeight() @@ -67,5 +75,23 @@ namespace SecondBotEvents.Config { return ReadSettingAsString("Id0", ""); } + + /// + /// Base reconnect delay in seconds. Doubles on each consecutive failure (exponential backoff). + /// Default: 120 seconds. + /// + public int GetReconnectDelaySecs() + { + return ReadSettingAsInt("ReconnectDelaySecs", 120); + } + + /// + /// Maximum reconnect delay cap in seconds for exponential backoff. + /// Default: 600 seconds (10 minutes). + /// + public int GetMaxReconnectDelaySecs() + { + return ReadSettingAsInt("MaxReconnectDelaySecs", 600); + } } } diff --git a/SecondBotEvents/Config/BotRejoinConfig.cs b/SecondBotEvents/Config/BotRejoinConfig.cs new file mode 100644 index 0000000..fde4b97 --- /dev/null +++ b/SecondBotEvents/Config/BotRejoinConfig.cs @@ -0,0 +1,20 @@ +using System; + +namespace SecondBotEvents.Config +{ + /// + /// Configuration for bot reconnection jitter timings. + /// + public class BotRejoinConfig + { + /// + /// Minimum jitter (in milliseconds) before a bot attempts to reconnect after a restart. + /// + public int JitterMinMs { get; set; } = 5000; // 5 seconds + + /// + /// Maximum jitter (in milliseconds) before a bot attempts to reconnect after a restart. + /// + public int JitterMaxMs { get; set; } = 30000; // 30 seconds + } +} diff --git a/SecondBotEvents/Config/HttpConfig.cs b/SecondBotEvents/Config/HttpConfig.cs index 297b75b..323f408 100644 --- a/SecondBotEvents/Config/HttpConfig.cs +++ b/SecondBotEvents/Config/HttpConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; @@ -10,6 +10,7 @@ namespace SecondBotEvents.Config { filename = "http"; settings.Add("Enabled"); + settings.Add("Port"); settings.Add("HideStatusOutput"); settings.Add("DisableCommandValidation"); } @@ -23,6 +24,11 @@ namespace SecondBotEvents.Config { return ReadSettingAsBool("DisableCommandValidation", false); } + + public int GetPort() + { + return ReadSettingAsInt("Port", 80); + } } } \ No newline at end of file diff --git a/SecondBotEvents/EventsSecondBot.cs b/SecondBotEvents/EventsSecondBot.cs index 8082bf9..60c78df 100644 --- a/SecondBotEvents/EventsSecondBot.cs +++ b/SecondBotEvents/EventsSecondBot.cs @@ -11,6 +11,16 @@ namespace SecondBotEvents { static void Main(string[] args) { + // Force initialize OpenMetaverse logger early to avoid log4net repository registration errors + try + { + var _ = OpenMetaverse.Logger.LogInstance; + } + catch (Exception ex) + { + Console.WriteLine($"[Startup] Warning: Failed to initialize OpenMetaverse logger: {ex.Message}"); + } + #if (DEBUG) new WikiMake(); #endif diff --git a/SecondBotEvents/SecondBotEvents.csproj b/SecondBotEvents/SecondBotEvents.csproj index 65c763d..d3e908b 100644 --- a/SecondBotEvents/SecondBotEvents.csproj +++ b/SecondBotEvents/SecondBotEvents.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net8.0 SecondBotEvents.Startup git rev-parse --short HEAD False diff --git a/SecondBotEvents/Services/BotClientService.cs b/SecondBotEvents/Services/BotClientService.cs index 16b9b2c..8c4e5e9 100644 --- a/SecondBotEvents/Services/BotClientService.cs +++ b/SecondBotEvents/Services/BotClientService.cs @@ -12,8 +12,12 @@ namespace SecondBotEvents.Services { public GridClient client = null; public BasicConfig basicCfg = null; +private BotRejoinConfig rejoinCfg; protected bool LogoutExpected = false; + protected int consecutiveLoginFailures = 0; + protected DateTime lastConnectTime = DateTime.MinValue; + protected bool isRegionRestartScheduled = false; public void flagLogoutExpected() { LogoutExpected = true; @@ -40,16 +44,21 @@ namespace SecondBotEvents.Services public BotClientService(EventsSecondBot setMaster) : base(setMaster) { basicCfg = new BasicConfig(master.fromEnv, master.fromFolder); + rejoinCfg = new BotRejoinConfig(); } - protected void RestartTimer(object o, ElapsedEventArgs e) - { - AutoRestartLoginTimer.Stop(); - AutoRestartLoginTimer = null; - LogFormater.Info("Recovery underway - waiting 4 secs then restarting bot"); - Thread.Sleep(4000); - Restart(); - } + protected void RestartTimer(object o, ElapsedEventArgs e) + { + AutoRestartLoginTimer?.Stop(); + AutoRestartLoginTimer = null; + // Ensure logger is initialized early to avoid "logger not registered" errors + var _ = OpenMetaverse.Logger.LogInstance; + // Apply jitter before restart + int jitter = new Random().Next(rejoinCfg.JitterMinMs, rejoinCfg.JitterMaxMs); + LogFormater.Info($"Recovery sequence: Waiting {jitter}ms before restart (jitter applied)..."); + Thread.Sleep(jitter); + Restart(); + } public bool IsLoaded() { @@ -90,10 +99,56 @@ namespace SecondBotEvents.Services ResetClient(); } + protected void HandleUnexpectedDisconnect(string reason) + { + if (getLogoutExpected()) return; + + int baseDelay = basicCfg.GetReconnectDelaySecs(); + int maxDelay = basicCfg.GetMaxReconnectDelaySecs(); + + if (lastConnectTime != DateTime.MinValue && (DateTime.UtcNow - lastConnectTime).TotalSeconds > 60) + { + consecutiveLoginFailures = 0; + } + lastConnectTime = DateTime.MinValue; + + int delaySecs = (int)Math.Min(baseDelay * Math.Pow(2, consecutiveLoginFailures), maxDelay); + if (isRegionRestartScheduled) + { + // Force a minimum 15 minutes (900 seconds) delay if a region restart is scheduled + delaySecs = Math.Max(delaySecs, 900); + } + + consecutiveLoginFailures++; + LogFormater.Warn($"{reason} - retrying in {delaySecs}s (attempt {consecutiveLoginFailures})..."); + StartRecoveryTimer(delaySecs * 1000); + } + + protected void BotAlertMessage(object o, AlertMessageEventArgs e) + { + LogFormater.Info("Client service ~ Alert message: " + e.Message); + if (e.Message != null && e.Message.IndexOf("restart", StringComparison.OrdinalIgnoreCase) >= 0) + { + LogFormater.Warn("Detected upcoming region restart. Initiating graceful logout to prevent limbo state."); + isRegionRestartScheduled = true; + flagLogoutExpected(); + if (client != null && client.Network != null) + { + client.Network.Logout(); + } + // Directly start recovery timer with 15 minutes delay + StartRecoveryTimer(900 * 1000); + } + } + protected void BotLoggedOut(object o, LoggedOutEventArgs e) { LogFormater.Info("Client service ~ Logged out"); - master.TriggerBotClientEvent(true,!getLogoutExpected()); + if (!getLogoutExpected()) + { + HandleUnexpectedDisconnect("Unexpected logout"); + } + master.TriggerBotClientEvent(true, !getLogoutExpected()); } protected void BotSimConnected(object o, SimConnectedEventArgs e) @@ -103,28 +158,59 @@ namespace SecondBotEvents.Services AutoRestartLoginTimer.Stop(); AutoRestartLoginTimer = null; } + isRegionRestartScheduled = false; + lastConnectTime = DateTime.UtcNow; goodticks = 0; - LogFormater.Info("Client service ~ Connected to sim: "+e.Simulator.Name); + LogFormater.Info("Client service ~ Connected to sim: " + e.Simulator.Name); } protected void BotSimDisconnected(object o, SimDisconnectedEventArgs e) { goodticks = 0; LogFormater.Info("Client service ~ Disconnected from sim: " + e.Simulator.Name); + if (!getLogoutExpected()) + { + HandleUnexpectedDisconnect("Unexpected sim disconnect"); + } } protected void BotDisconnected(object o, DisconnectedEventArgs e) { LogFormater.Info("Client service ~ Network disconnected: " + e.Message); + if (!getLogoutExpected()) + { + HandleUnexpectedDisconnect("Unexpected network disconnect: " + e.Message); + } + } + + protected void StartRecoveryTimer(int intervalMs = 5000) + { + if (AutoRestartLoginTimer == null) + { + AutoRestartLoginTimer = new Timer + { + Interval = intervalMs, + AutoReset = false + }; + AutoRestartLoginTimer.Elapsed += RestartTimer; + } + else + { + AutoRestartLoginTimer.Interval = intervalMs; + } + AutoRestartLoginTimer.Stop(); + AutoRestartLoginTimer.Start(); } protected void BotLoginStatus(object o, LoginProgressEventArgs e) - { - if(e.FailReason != "") + { + if (e.Status == LoginStatus.Failed || !string.IsNullOrEmpty(e.FailReason)) { - LogFormater.Info("Client service ~ {FAILED} Login status: " + e.FailReason.ToString()); + string reason = string.IsNullOrEmpty(e.FailReason) ? e.Message : e.FailReason; + LogFormater.Info("Client service ~ {FAILED} Login status: " + reason); client.Network.Logout(); ResetClient(); + HandleUnexpectedDisconnect("Login failed: " + reason); return; } LogFormater.Info("Client service ~ Login status: " + e.Status.ToString()); @@ -209,12 +295,18 @@ namespace SecondBotEvents.Services client.Network.Disconnected += BotDisconnected; client.Network.SimDisconnected += BotSimDisconnected; client.Network.LoginProgress += BotLoginStatus; + client.Self.AlertMessage += BotAlertMessage; + client.Self.AgentDataReply += (s, e) => + { + master.DataStoreService.SetActiveGroupTitle(e.GroupTitle); + }; } public void Login() { ResetClient(); goodticks = 0; + LogoutExpected = false; master.TriggerBotClientEvent(false, false); LoginParams loginParams = new( client, @@ -241,6 +333,12 @@ namespace SecondBotEvents.Services loginParams.ID0 = customId0; } + string customStart = basicCfg.GetStartLocation(); + if (!string.IsNullOrEmpty(customStart)) + { + loginParams.Start = customStart; + } + AutoRestartLoginTimer.Start(); client.Network.BeginLogin(loginParams); } diff --git a/SecondBotEvents/Services/CommandsService.cs b/SecondBotEvents/Services/CommandsService.cs index a9d77f9..edfebd2 100644 --- a/SecondBotEvents/Services/CommandsService.cs +++ b/SecondBotEvents/Services/CommandsService.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; using OpenMetaverse; using OpenMetaverse.ImportExport.Collada14; using SecondBotEvents.Config; diff --git a/SecondBotEvents/Services/DataStoreService.cs b/SecondBotEvents/Services/DataStoreService.cs index d4673b3..8cf8b26 100644 --- a/SecondBotEvents/Services/DataStoreService.cs +++ b/SecondBotEvents/Services/DataStoreService.cs @@ -42,6 +42,7 @@ namespace SecondBotEvents.Services public DataStoreService(EventsSecondBot setMaster) : base(setMaster) { myConfig = new DataStoreConfig(master.fromEnv,master.fromFolder); + LoadPendingGroupInvites(); } protected Dictionary avatarsName2Key = []; @@ -456,12 +457,11 @@ namespace SecondBotEvents.Services } } - public Dictionary GetGroupRoles(UUID group) + public Dictionary GetGroupRoles(UUID group, bool force = false) { Dictionary reply = []; - if (groupRoles.ContainsKey(group) == false) + if (force || groupRoles.ContainsKey(group) == false) { - AutoResetEvent fetchEvent = new(false); void callback(object sender, GroupRolesDataReplyEventArgs e) { @@ -675,6 +675,49 @@ namespace SecondBotEvents.Services return KnownAvatar(name, UUID.Zero); } + private static readonly string groupInvitesFilePath = "data/pending_group_invites.json"; + + private void SavePendingGroupInvites() + { + try + { + string dir = System.IO.Path.GetDirectoryName(groupInvitesFilePath); + if (!string.IsNullOrEmpty(dir) && !System.IO.Directory.Exists(dir)) + { + System.IO.Directory.CreateDirectory(dir); + } + string json = JsonSerializer.Serialize(PendingGroupInvites, JsonOptions.UnsafeRelaxed); + System.IO.File.WriteAllText(groupInvitesFilePath, json); + } + catch (Exception ex) + { + LogFormater.Crit("Failed to save pending group invites: " + ex.Message); + } + } + + private void LoadPendingGroupInvites() + { + try + { + if (System.IO.File.Exists(groupInvitesFilePath)) + { + string json = System.IO.File.ReadAllText(groupInvitesFilePath); + var loaded = JsonSerializer.Deserialize>(json); + if (loaded != null) + { + lock (PendingGroupInvites) + { + PendingGroupInvites = loaded; + } + } + } + } + catch (Exception ex) + { + LogFormater.Crit("Failed to load pending group invites: " + ex.Message); + } + } + public void AddPendingGroupInvite(UUID groupID, UUID agentID, string agentName, string message) { lock (PendingGroupInvites) @@ -684,14 +727,50 @@ namespace SecondBotEvents.Services { return; } + + string groupName = "Unknown Group"; + if (GetClient().Groups.GroupName2KeyCache.TryGetValue(groupID, out string resolvedCacheName)) + { + groupName = resolvedCacheName; + } + else if (groupsKey2Name.TryGetValue(groupID, out string cachedName)) + { + groupName = cachedName; + } + else + { + AutoResetEvent fetchEvent = new(false); + void callback(object sender, GroupNamesEventArgs e) + { + if (e.GroupNames.TryGetValue(groupID, out string resolvedName)) + { + groupName = resolvedName; + fetchEvent.Set(); + } + } + GetClient().Groups.GroupNamesReply += callback; + GetClient().Groups.RequestGroupName(groupID); + fetchEvent.WaitOne(1500, false); + GetClient().Groups.GroupNamesReply -= callback; + + if (groupName != "Unknown Group") + { + lock (groupsKey2Name) + { + groupsKey2Name[groupID] = groupName; + } + } + } + PendingGroupInvites.Add(new GroupInvite { GroupID = groupID, - GroupName = "Unknown Group", // Will be updated on lookup + GroupName = groupName, AgentID = agentID, AgentName = agentName, Message = message }); + SavePendingGroupInvites(); } } @@ -708,6 +787,7 @@ namespace SecondBotEvents.Services lock (PendingGroupInvites) { PendingGroupInvites.RemoveAll(i => i.GroupID == groupID); + SavePendingGroupInvites(); } } @@ -1156,12 +1236,35 @@ namespace SecondBotEvents.Services } - Dictionary avprops = []; - Dictionary> avpicks = []; + protected string activeGroupTitle = ""; + protected Dictionary avprops = []; + protected Dictionary> avpicks = []; + protected Dictionary> avclassifieds = []; + protected Dictionary> avpicksDetails = []; + protected Dictionary> avclassifiedsDetails = []; + protected Dictionary> avgroups = []; + + public void ClearAvatarPicksCache(UUID agentID) + { + avpicks.Remove(agentID); + lock (avpicksDetails) + { + avpicksDetails.Remove(agentID); + } + } + + public void ClearAvatarClassifiedsCache(UUID agentID) + { + avclassifieds.Remove(agentID); + lock (avclassifiedsDetails) + { + avclassifiedsDetails.Remove(agentID); + } + } public KeyValuePair> GetAvatarPicks(UUID agentID) { - if (avpicks.ContainsKey(agentID) == false) + if (!avpicks.ContainsKey(agentID)) { GetClient().Avatars.RequestAvatarPicks(agentID); return new KeyValuePair>(false, []); @@ -1169,21 +1272,259 @@ namespace SecondBotEvents.Services return new KeyValuePair>(true, avpicks[agentID]); } + public KeyValuePair> GetAvatarPicksDetailed(UUID agentID) + { + if (!avpicks.ContainsKey(agentID)) + { + GetClient().Avatars.RequestAvatarPicks(agentID); + return new KeyValuePair>(false, []); + } + lock (avpicksDetails) + { + if (!avpicksDetails.ContainsKey(agentID)) + { + avpicksDetails.Add(agentID, []); + foreach (UUID key in avpicks[agentID].Keys) + { + GetClient().Avatars.RequestPickInfo(agentID, key); + } + } + else + { + foreach (UUID pickID in new List(avpicksDetails[agentID].Keys)) + { + ProfilePick value = avpicksDetails[agentID][pickID]; + if (!string.IsNullOrEmpty(value.SimName) || !(value.PosGlobal.X > 0.0) || !(value.PosGlobal.Y > 0.0)) + { + continue; + } + float localX; + float localY; + ulong handle = Helpers.GlobalPosToRegionHandle((float)value.PosGlobal.X, (float)value.PosGlobal.Y, out localX, out localY); + if (GetClient().Grid.RegionsByHandle.TryGetValue(handle, out var value2)) + { + value.SimName = value2.Name; + avpicksDetails[agentID][pickID] = value; + continue; + } + Task.Run(delegate + { + if (GetClient().Grid.GetGridRegion(handle, GridLayerType.Terrain, out var region)) + { + lock (avpicksDetails) + { + if (avpicksDetails.ContainsKey(agentID) && avpicksDetails[agentID].ContainsKey(pickID)) + { + ProfilePick value3 = avpicksDetails[agentID][pickID]; + value3.SimName = region.Name; + avpicksDetails[agentID][pickID] = value3; + } + } + } + }); + } + } + return new KeyValuePair>(true, avpicksDetails[agentID]); + } + } + protected void AvatarPicksReply(object sender, AvatarPicksReplyEventArgs e) { lock (avpicks) { - if (avpicks.ContainsKey(e.AvatarID) == false) + if (!avpicks.ContainsKey(e.AvatarID)) { avpicks.Add(e.AvatarID, []); } avpicks[e.AvatarID] = e.Picks; + lock (avpicksDetails) + { + if (!avpicksDetails.ContainsKey(e.AvatarID)) + { + avpicksDetails.Add(e.AvatarID, []); + } + else + { + List list = []; + foreach (UUID key in avpicksDetails[e.AvatarID].Keys) + { + if (!e.Picks.ContainsKey(key)) + { + list.Add(key); + } + } + foreach (UUID item in list) + { + avpicksDetails[e.AvatarID].Remove(item); + } + } + foreach (UUID key2 in e.Picks.Keys) + { + GetClient().Avatars.RequestPickInfo(e.AvatarID, key2); + } + } } } - public KeyValuePair GetAvatarAvatarProperties(UUID agentID) + protected void PickInfoReply(object sender, PickInfoReplyEventArgs e) { - if (avprops.ContainsKey(agentID) == false) + lock (avpicksDetails) + { + UUID avatarID = e.Pick.CreatorID; + if (avatarID == UUID.Zero) + { + lock (avpicks) + { + foreach (KeyValuePair> avpick in avpicks) + { + if (avpick.Value.ContainsKey(e.PickID)) + { + avatarID = avpick.Key; + break; + } + } + } + } + if (avatarID == UUID.Zero) + { + return; + } + if (!avpicksDetails.ContainsKey(avatarID)) + { + avpicksDetails.Add(avatarID, []); + } + ProfilePick pick = e.Pick; + if (string.IsNullOrEmpty(pick.SimName) && pick.PosGlobal.X > 0.0 && pick.PosGlobal.Y > 0.0) + { + float localX; + float localY; + ulong handle = Helpers.GlobalPosToRegionHandle((float)pick.PosGlobal.X, (float)pick.PosGlobal.Y, out localX, out localY); + if (GetClient().Grid.RegionsByHandle.TryGetValue(handle, out var value)) + { + pick.SimName = value.Name; + } + else + { + Task.Run(delegate + { + if (GetClient().Grid.GetGridRegion(handle, GridLayerType.Terrain, out var region)) + { + lock (avpicksDetails) + { + if (avpicksDetails.ContainsKey(avatarID) && avpicksDetails[avatarID].ContainsKey(e.PickID)) + { + ProfilePick value2 = avpicksDetails[avatarID][e.PickID]; + value2.SimName = region.Name; + avpicksDetails[avatarID][e.PickID] = value2; + } + } + } + }); + } + } + avpicksDetails[avatarID][e.PickID] = pick; + } + } + + public KeyValuePair> GetAvatarClassifieds(UUID agentID) + { + if (!avclassifieds.ContainsKey(agentID)) + { + GetClient().Avatars.RequestAvatarClassified(agentID); + return new KeyValuePair>(false, []); + } + return new KeyValuePair>(true, avclassifieds[agentID]); + } + + public KeyValuePair> GetAvatarClassifiedsDetailed(UUID agentID) + { + if (!avclassifieds.ContainsKey(agentID)) + { + GetClient().Avatars.RequestAvatarClassified(agentID); + return new KeyValuePair>(false, []); + } + lock (avclassifiedsDetails) + { + if (!avclassifiedsDetails.ContainsKey(agentID)) + { + avclassifiedsDetails.Add(agentID, []); + foreach (UUID key in avclassifieds[agentID].Keys) + { + GetClient().Avatars.RequestClassifiedInfo(agentID, key); + } + } + return new KeyValuePair>(true, avclassifiedsDetails[agentID]); + } + } + + protected void AvatarClassifiedsReply(object sender, AvatarClassifiedReplyEventArgs e) + { + lock (avclassifieds) + { + if (!avclassifieds.ContainsKey(e.AvatarID)) + { + avclassifieds.Add(e.AvatarID, []); + } + avclassifieds[e.AvatarID] = e.Classifieds; + lock (avclassifiedsDetails) + { + if (!avclassifiedsDetails.ContainsKey(e.AvatarID)) + { + avclassifiedsDetails.Add(e.AvatarID, []); + } + else + { + List list = []; + foreach (UUID key in avclassifiedsDetails[e.AvatarID].Keys) + { + if (!e.Classifieds.ContainsKey(key)) + { + list.Add(key); + } + } + foreach (UUID item in list) + { + avclassifiedsDetails[e.AvatarID].Remove(item); + } + } + foreach (UUID key2 in e.Classifieds.Keys) + { + GetClient().Avatars.RequestClassifiedInfo(e.AvatarID, key2); + } + } + } + } + + protected void ClassifiedInfoReply(object sender, ClassifiedInfoReplyEventArgs e) + { + lock (avclassifiedsDetails) + { + UUID uUID = UUID.Zero; + lock (avclassifieds) + { + foreach (KeyValuePair> avclassified in avclassifieds) + { + if (avclassified.Value.ContainsKey(e.ClassifiedID)) + { + uUID = avclassified.Key; + break; + } + } + } + if (uUID != UUID.Zero) + { + if (!avclassifiedsDetails.ContainsKey(uUID)) + { + avclassifiedsDetails.Add(uUID, []); + } + avclassifiedsDetails[uUID][e.ClassifiedID] = e.Classified; + } + } + } + + public KeyValuePair GetAvatarAvatarProperties(UUID agentID) + { + if (!avprops.ContainsKey(agentID)) { GetClient().Avatars.RequestAvatarProperties(agentID); return new KeyValuePair(false, new Avatar.AvatarProperties()); @@ -1191,15 +1532,51 @@ namespace SecondBotEvents.Services return new KeyValuePair(true, avprops[agentID]); } + public void UpdateAvatarProperties(UUID agentID, Avatar.AvatarProperties properties) + { + lock (avprops) + { + avprops[agentID] = properties; + } + } + protected void AvatarDetailsReply(object sender, AvatarPropertiesReplyEventArgs e) { - if(avprops.ContainsKey(e.AvatarID) == false) + if (!avprops.ContainsKey(e.AvatarID)) { - avprops.Add(e.AvatarID,new Avatar.AvatarProperties()); + avprops.Add(e.AvatarID, new Avatar.AvatarProperties()); } avprops[e.AvatarID] = e.Properties; } + public KeyValuePair> GetAvatarGroups(UUID agentID) + { + if (!avgroups.ContainsKey(agentID)) + { + GetClient().Avatars.RequestAvatarProperties(agentID); + return new KeyValuePair>(false, []); + } + return new KeyValuePair>(true, avgroups[agentID]); + } + + protected void AvatarGroupsReplyCallback(object sender, AvatarGroupsReplyEventArgs e) + { + lock (avgroups) + { + avgroups[e.AvatarID] = e.Groups; + } + } + + public string GetActiveGroupTitle() + { + return activeGroupTitle; + } + + public void SetActiveGroupTitle(string title) + { + activeGroupTitle = title; + } + protected void AttachEventsAfterDelay() { GetClient().Groups.CurrentGroups += GroupCurrent; @@ -1215,7 +1592,11 @@ namespace SecondBotEvents.Services GetClient().Estate.EstateBansReply += EstateBans; GetClient().Friends.FriendFoundReply += FindFriendReply; GetClient().Avatars.AvatarPropertiesReply += AvatarDetailsReply; + GetClient().Avatars.AvatarGroupsReply += AvatarGroupsReplyCallback; GetClient().Avatars.AvatarPicksReply += AvatarPicksReply; + GetClient().Avatars.AvatarClassifiedReply += AvatarClassifiedsReply; + GetClient().Avatars.PickInfoReply += PickInfoReply; + GetClient().Avatars.ClassifiedInfoReply += ClassifiedInfoReply; GetClient().Groups.RequestCurrentGroups(); _ = GetClient().Self.RetrieveInstantMessages(); if(myConfig.GetPrefetchInventory() == true) @@ -1591,6 +1972,11 @@ namespace SecondBotEvents.Services GetClient().Estate.EstateBansReply -= EstateBans; GetClient().Friends.FriendFoundReply -= FindFriendReply; GetClient().Avatars.AvatarPropertiesReply -= AvatarDetailsReply; + GetClient().Avatars.AvatarGroupsReply -= AvatarGroupsReplyCallback; + GetClient().Avatars.AvatarPicksReply -= AvatarPicksReply; + GetClient().Avatars.AvatarClassifiedReply -= AvatarClassifiedsReply; + GetClient().Avatars.PickInfoReply -= PickInfoReply; + GetClient().Avatars.ClassifiedInfoReply -= ClassifiedInfoReply; } } diff --git a/SecondBotEvents/Services/DialogService.cs b/SecondBotEvents/Services/DialogService.cs index 8eb1b9a..5815997 100644 --- a/SecondBotEvents/Services/DialogService.cs +++ b/SecondBotEvents/Services/DialogService.cs @@ -263,6 +263,7 @@ namespace SecondBotEvents.Services { GetClient().Self.ScriptDialog -= DialogWindowEvent; GetClient().Self.LoadURL -= LoadUrlEvent; + GetClient().Self.AlertMessage -= BotAlertMessage; } } @@ -273,6 +274,23 @@ namespace SecondBotEvents.Services // attach events GetClient().Self.ScriptDialog += DialogWindowEvent; GetClient().Self.LoadURL += LoadUrlEvent; + GetClient().Self.AlertMessage += BotAlertMessage; + } + + public void Attach() + { + botConnected = true; + // attach events + GetClient().Self.ScriptDialog += DialogWindowEvent; + GetClient().Self.LoadURL += LoadUrlEvent; + GetClient().Self.AlertMessage += BotAlertMessage; + } + + protected void BotAlertMessage(object sender, AlertMessageEventArgs e) + { + string message = " [SYSTEM] ALERT: " + e.Message; + master.DataStoreService.BotRecordLocalchatReply(message, "[SYSTEM] "); + LogFormater.Info("[AlertMessage] " + e.Message); } protected void LoadUrlEvent(object sender, LoadUrlEventArgs e) diff --git a/SecondBotEvents/Services/DiscordService.cs b/SecondBotEvents/Services/DiscordService.cs index 1c54c64..39c80de 100644 --- a/SecondBotEvents/Services/DiscordService.cs +++ b/SecondBotEvents/Services/DiscordService.cs @@ -1,4 +1,4 @@ -using Discord; +using Discord; using Discord.Rest; using Discord.WebSocket; using OpenMetaverse; @@ -1300,7 +1300,7 @@ namespace SecondBotEvents.Services SocketRole role = server.GetRole(roleid); Dictionary settings = new() { - { "Config|Color", String.Join(",", [role.Color.R, role.Color.G, role.Color.B]) }, + { "Config|Color", $"{role.Color.R},{role.Color.G},{role.Color.B}" }, { "Config|Name", role.Name }, { "Info|AssignedCount", role.Members.ToString() }, { "Perm|AttachFiles", role.Permissions.AttachFiles.ToString() }, diff --git a/SecondBotEvents/Services/HttpService.cs b/SecondBotEvents/Services/HttpService.cs index 42a72c3..f483771 100644 --- a/SecondBotEvents/Services/HttpService.cs +++ b/SecondBotEvents/Services/HttpService.cs @@ -127,9 +127,11 @@ namespace SecondBotEvents.Services private void CreateWebServer() { - Swan.Logging.Logger.UnregisterLogger(); + // Swan.Logging.Logger.UnregisterLogger(); + string hostName = System.Net.Dns.GetHostName(); + int port = myConfig.GetPort(); HTTPendpoint = new WebServer(o => o - .WithUrlPrefix("http://*:80") + .WithUrlPrefix($"http://{hostName}:{port}/") .WithMode(HttpListenerMode.EmbedIO)) .WithCors() .WithWebApi("/api", SerializationCallback, m => m.WithController(() => new HttpWebBot(master))) @@ -137,7 +139,7 @@ namespace SecondBotEvents.Services .WithModule(new ActionModule("/", HttpVerbs.Any, ctx => ctx.SendDataAsync(new { Message = "Error" })) ); HTTPendpoint.RunAsync(); - LogFormater.Info("HTTP service [Enabled] on port 80"); + LogFormater.Info($"HTTP service [Enabled] on {hostName}:{port}"); } protected string JsonInput(IHttpContext httpContext) @@ -399,9 +401,9 @@ namespace SecondBotEvents.Services public string Run([FormField] string commandName, [FormField] string args, [FormField] string signing, [FormField] string unixtime) { string[] myArgs = []; - if(args != null) + if(!string.IsNullOrEmpty(args)) { - myArgs = args.Split("~#~", StringSplitOptions.RemoveEmptyEntries); + myArgs = args.Split("~#~", StringSplitOptions.None); } if(int.TryParse(unixtime, out int cmdUnixtime) == false) { diff --git a/SecondBotEvents/Services/RabbitService.cs b/SecondBotEvents/Services/RabbitService.cs index a8e8979..60ca542 100644 --- a/SecondBotEvents/Services/RabbitService.cs +++ b/SecondBotEvents/Services/RabbitService.cs @@ -1,4 +1,4 @@ -using SecondBotEvents.Config; +using SecondBotEvents.Config; using RabbitMQ.Client; using System; using System.Text; @@ -209,6 +209,7 @@ namespace SecondBotEvents.Services connection.CloseAsync().Await(); connection = null; } + channels.Clear(); var endpoints = new System.Collections.Generic.List { new AmqpTcpEndpoint(myConfig.GetHostIP(),myConfig.GetHostPort()), };