From 0b081d9e1f70afbac43460296363520ccd2e2f71 Mon Sep 17 00:00:00 2001 From: RebeccaNod1 Date: Wed, 4 Nov 2026 17:03:11 -0700 Subject: [PATCH] Update SecondBot services, commands (Info, Inventory, Interaction), and DataStore enhancements --- LibreMetaverse/NetworkManager.cs | 2 +- LibreMetaverse/Settings.cs | 4 +- SecondBotEvents/Commands/Avatars.cs | 42 +- SecondBotEvents/Commands/Chat.cs | 11 + SecondBotEvents/Commands/Group.cs | 2 + SecondBotEvents/Commands/Info.cs | 1872 ++++++++++++++++- SecondBotEvents/Commands/Inventory.cs | 178 ++ SecondBotEvents/Commands/Self.cs | 38 +- SecondBotEvents/Services/BotClientService.cs | 8 +- SecondBotEvents/Services/CommandsService.cs | 32 +- SecondBotEvents/Services/DataStoreService.cs | 291 ++- SecondBotEvents/Services/DialogService.cs | 25 +- SecondBotEvents/Services/DiscordService.cs | 9 +- SecondBotEvents/Services/HomeboundService.cs | 24 +- .../Services/InteractionService.cs | 159 +- SecondBotEvents/Static/Helpers.cs | 17 +- 16 files changed, 2624 insertions(+), 90 deletions(-) diff --git a/LibreMetaverse/NetworkManager.cs b/LibreMetaverse/NetworkManager.cs index d94b10d..98c84ab 100644 --- a/LibreMetaverse/NetworkManager.cs +++ b/LibreMetaverse/NetworkManager.cs @@ -1334,7 +1334,7 @@ namespace OpenMetaverse IPEndPoint endPoint = new IPEndPoint(ip, port); - if (FindSimulator(endPoint) != null) return; + if (FindSimulator(endPoint) != null) continue; if (Connect(ip, port, handle, false, null, t.RegionSizeX, t.RegionSizeY) == null) { diff --git a/LibreMetaverse/Settings.cs b/LibreMetaverse/Settings.cs index 3cf976c..acd65d7 100644 --- a/LibreMetaverse/Settings.cs +++ b/LibreMetaverse/Settings.cs @@ -181,7 +181,7 @@ namespace OpenMetaverse /// Enable/disable storing terrain heightmaps in the /// TerrainManager - public bool STORE_LAND_PATCHES = false; + public bool STORE_LAND_PATCHES = true; /// Enable/disable sending agent updates public bool SEND_AGENT_UPDATES = true; @@ -208,7 +208,7 @@ namespace OpenMetaverse /// Should we connect to multiple sims? This will allow /// viewing into neighboring simulators and sim crossings /// (Experimental) - public bool MULTIPLE_SIMS = false; + public bool MULTIPLE_SIMS = true; /// If true, all object update packets will be decoded in to /// native objects. If false, only updates for our own agent will be diff --git a/SecondBotEvents/Commands/Avatars.cs b/SecondBotEvents/Commands/Avatars.cs index e6e8b20..e88af55 100644 --- a/SecondBotEvents/Commands/Avatars.cs +++ b/SecondBotEvents/Commands/Avatars.cs @@ -80,11 +80,27 @@ namespace SecondBotEvents.Commands List BetterNearMe = []; Utils.LongToUInts(GetClient().Network.CurrentSim.Handle, out uint mySimX, out uint mySimY); - Vector3 myGlobalPos = new Vector3(mySimX + GetClient().Self.SimPosition.X, mySimY + GetClient().Self.SimPosition.Y, GetClient().Self.SimPosition.Z); + Vector3 myLocalPos = GetClient().Self.SimPosition; - foreach (var sim in GetClient().Network.Simulators) + List sims = []; + try { + lock (GetClient().Network.Simulators) + { + sims = [.. GetClient().Network.Simulators]; + } + } + catch + { + sims = [GetClient().Network.CurrentSim]; + } + + foreach (var sim in sims) + { + if (sim == null || !sim.Connected) continue; Utils.LongToUInts(sim.Handle, out uint simX, out uint simY); + float offsetX = (long)simX - (long)mySimX; + float offsetY = (long)simY - (long)mySimY; HashSet processedAvatars = []; List avcopy = [.. sim.ObjectsAvatars.Values]; @@ -100,21 +116,20 @@ namespace SecondBotEvents.Commands localPos = parentObj.Position + (av.Position * parentObj.Rotation); } - Vector3 globalPos = new Vector3(simX + localPos.X, simY + localPos.Y, localPos.Z); - double dist = Math.Round(Vector3.Distance(globalPos, myGlobalPos), 2); + float relX = offsetX + localPos.X; + float relY = offsetY + localPos.Y; + Vector3 relPos = new Vector3(relX, relY, localPos.Z); + double dist = Math.Round(Vector3.Distance(relPos, myLocalPos), 2); if (dist > 150) continue; - float relX = (simX - mySimX) + localPos.X; - float relY = (simY - mySimY) + localPos.Y; - NearMeDetails details = new() { id = av.ID.ToString(), name = av.Name, x = (int)Math.Round(relX), y = (int)Math.Round(relY), - z = (int)Math.Round(globalPos.Z), + z = (int)Math.Round(localPos.Z), range = dist }; BetterNearMe.Add(details); @@ -126,14 +141,13 @@ namespace SecondBotEvents.Commands if (avID == GetClient().Self.AgentID || processedAvatars.Contains(avID)) continue; Vector3 localPos = kvp.Value; - Vector3 globalPos = new Vector3(simX + localPos.X, simY + localPos.Y, localPos.Z); - double dist = Math.Round(Vector3.Distance(globalPos, myGlobalPos), 2); + float relX = offsetX + localPos.X; + float relY = offsetY + localPos.Y; + Vector3 relPos = new Vector3(relX, relY, localPos.Z); + double dist = Math.Round(Vector3.Distance(relPos, myLocalPos), 2); if (dist > 150) continue; - float relX = (simX - mySimX) + localPos.X; - float relY = (simY - mySimY) + localPos.Y; - string avName = master.DataStoreService.GetAvatarName(avID); if (avName == "lookup" || string.IsNullOrEmpty(avName)) avName = "Unknown"; @@ -143,7 +157,7 @@ namespace SecondBotEvents.Commands name = avName, x = (int)Math.Round(relX), y = (int)Math.Round(relY), - z = (int)Math.Round(globalPos.Z), + z = (int)Math.Round(localPos.Z), range = dist }; BetterNearMe.Add(details); diff --git a/SecondBotEvents/Commands/Chat.cs b/SecondBotEvents/Commands/Chat.cs index 1ea7778..571a100 100644 --- a/SecondBotEvents/Commands/Chat.cs +++ b/SecondBotEvents/Commands/Chat.cs @@ -98,6 +98,17 @@ namespace SecondBotEvents.Commands } master.DataStoreService.OpenChatWindow(false, avataruuid, avataruuid); master.BotClient.SendIM(avataruuid, message); + string senderName = "{BOT} " + GetClient().Self.Name; + master.DataStoreService.RecordAvatarIm(avataruuid, senderName, message); + if (master.DiscordService != null && master.DiscordService.isRunning()) + { + string avName = master.DataStoreService.GetAvatarName(avataruuid); + if (string.IsNullOrEmpty(avName) || avName == "lookup") + { + avName = avatar; + } + master.DiscordService.AvatarIMChat(avataruuid, avName, message, GetClient().Self.Name); + } return BasicReply("ok", [avatar, message]); } diff --git a/SecondBotEvents/Commands/Group.cs b/SecondBotEvents/Commands/Group.cs index a0de98d..44dd7f7 100644 --- a/SecondBotEvents/Commands/Group.cs +++ b/SecondBotEvents/Commands/Group.cs @@ -495,6 +495,8 @@ namespace SecondBotEvents.Commands return Failure("Unknown group", [group]); } GetClient().Self.InstantMessageGroup(groupUUID, message); + string senderName = "{BOT} " + GetClient().Self.Name; + master.DataStoreService.RecordGroupChat(groupUUID, senderName, message); return BasicReply("Sending"); } [About("fetchs a list of all pending group invitations")] diff --git a/SecondBotEvents/Commands/Info.cs b/SecondBotEvents/Commands/Info.cs index a1273c9..8394050 100644 --- a/SecondBotEvents/Commands/Info.cs +++ b/SecondBotEvents/Commands/Info.cs @@ -1,10 +1,19 @@ using System.Text.Json; using OpenMetaverse; +using OpenMetaverse.Assets; +using OpenMetaverse.Imaging; +using OpenMetaverse.Rendering; +using OpenMetaverse.StructuredData; +using CoreJ2K; using SecondBotEvents.Services; using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; +using System.IO.Compression; using System.Linq; using System.Threading; +using System.Xml.Linq; namespace SecondBotEvents.Commands { @@ -251,19 +260,1880 @@ namespace SecondBotEvents.Commands string name = prim.Properties?.Name; if (string.IsNullOrEmpty(name)) name = "Object #" + prim.LocalID; + int primCount = GetClient().Network.CurrentSim.ObjectsPrimitives.Values.Count(p => p.ID == prim.ID || p.ParentID == prim.LocalID); + + prim.Rotation.GetEulerAngles(out float roll, out float pitch, out float yaw); + float rotX = (float)Math.Round(roll * (180.0f / (float)Math.PI), 2); + float rotY = (float)Math.Round(pitch * (180.0f / (float)Math.PI), 2); + float rotZ = (float)Math.Round(yaw * (180.0f / (float)Math.PI), 2); + + UUID primOwner = prim.Properties?.OwnerID ?? UUID.Zero; + bool canBotModify = false; + if (primOwner != UUID.Zero) + { + if (primOwner == GetClient().Self.AgentID) + { + canBotModify = true; + } + else if (GetClient().Friends.FriendList.TryGetValue(primOwner, out FriendInfo friend) && friend.CanModifyTheirObjects) + { + canBotModify = true; + } + } + reply.Add(new { name = name, + description = prim.Properties?.Description ?? "", uuid = prim.ID.ToString(), local_id = prim.LocalID, dist = Math.Round(dist, 1), pos = prim.Position.ToString(), - is_sitting = (prim.LocalID == sittingOn) + pos_x = Math.Round(prim.Position.X, 3), + pos_y = Math.Round(prim.Position.Y, 3), + pos_z = Math.Round(prim.Position.Z, 3), + scale_x = Math.Round(prim.Scale.X, 3), + scale_y = Math.Round(prim.Scale.Y, 3), + scale_z = Math.Round(prim.Scale.Z, 3), + rot_x = rotX, + rot_y = rotY, + rot_z = rotZ, + flag_physical = (prim.Flags & PrimFlags.Physics) != 0, + flag_temporary = (prim.Flags & PrimFlags.Temporary) != 0, + flag_phantom = (prim.Flags & PrimFlags.Phantom) != 0, + physics_shape = prim.PhysicsProps != null ? prim.PhysicsProps.PhysicsShapeType.ToString() : "Prim", + has_light = prim.Light != null, + light_color = prim.Light != null ? prim.Light.Color.ToString() : "", + light_intensity = prim.Light != null ? Math.Round(prim.Light.Intensity, 2) : 0, + light_radius = prim.Light != null ? Math.Round(prim.Light.Radius, 2) : 0, + light_falloff = prim.Light != null ? Math.Round(prim.Light.Falloff, 2) : 0, + has_flexi = prim.Flexible != null, + flexi_softness = prim.Flexible != null ? prim.Flexible.Softness : 0, + flexi_gravity = prim.Flexible != null ? Math.Round(prim.Flexible.Gravity, 2) : 0, + flexi_drag = prim.Flexible != null ? Math.Round(prim.Flexible.Drag, 2) : 0, + flexi_wind = prim.Flexible != null ? Math.Round(prim.Flexible.Wind, 2) : 0, + flexi_tension = prim.Flexible != null ? Math.Round(prim.Flexible.Tension, 2) : 0, + is_sitting = (prim.LocalID == sittingOn), + owner_id = prim.Properties?.OwnerID.ToString() ?? "00000000-0000-0000-0000-000000000000", + creator_id = prim.Properties?.CreatorID.ToString() ?? "00000000-0000-0000-0000-000000000000", + last_owner_id = prim.Properties?.LastOwnerID.ToString() ?? "00000000-0000-0000-0000-000000000000", + group_id = prim.Properties?.GroupID.ToString() ?? "00000000-0000-0000-0000-000000000000", + click_action = prim.ClickAction.ToString(), + prims = primCount > 0 ? primCount : 1, + can_bot_modify = canBotModify, + perm_anyone_move = (prim.Properties != null && (prim.Properties.Permissions.EveryoneMask & PermissionMask.Move) != 0), + perm_anyone_copy = (prim.Properties != null && (prim.Properties.Permissions.EveryoneMask & PermissionMask.Copy) != 0), + perm_next_modify = (prim.Properties != null && (prim.Properties.Permissions.NextOwnerMask & PermissionMask.Modify) != 0), + perm_next_copy = (prim.Properties != null && (prim.Properties.Permissions.NextOwnerMask & PermissionMask.Copy) != 0), + perm_next_transfer = (prim.Properties != null && (prim.Properties.Permissions.NextOwnerMask & PermissionMask.Transfer) != 0), + sale_price = prim.Properties?.SalePrice ?? 0, + sale_type = prim.Properties?.SaleType.ToString() ?? "Not", + tex_default_id = prim.Textures?.DefaultTexture?.TextureID.ToString() ?? "00000000-0000-0000-0000-000000000000", + tex_glow = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.Glow, 3) : 0, + tex_transparency = prim.Textures?.DefaultTexture != null ? Math.Round((1.0f - prim.Textures.DefaultTexture.RGBA.A) * 100.0f, 1) : 0, + tex_shiny = prim.Textures?.DefaultTexture?.Shiny.ToString() ?? "None", + tex_bump = prim.Textures?.DefaultTexture?.Bump.ToString() ?? "None", + tex_fullbright = prim.Textures?.DefaultTexture?.Fullbright ?? false, + tex_mapping = prim.Textures?.DefaultTexture?.TexMapType.ToString() ?? "Default", + tex_repeat_u = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.RepeatU, 4) : 1.0, + tex_repeat_v = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.RepeatV, 4) : 1.0, + tex_offset_u = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.OffsetU, 4) : 0.0, + tex_offset_v = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.OffsetV, 4) : 0.0, + tex_rotation = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.Rotation * (180.0f / (float)Math.PI), 2) : 0.0, + tex_color = prim.Textures?.DefaultTexture != null ? $"#{((byte)(prim.Textures.DefaultTexture.RGBA.R * 255)):X2}{((byte)(prim.Textures.DefaultTexture.RGBA.G * 255)):X2}{((byte)(prim.Textures.DefaultTexture.RGBA.B * 255)):X2}" : "#FFFFFF", + material_type = prim.PrimData.Material.ToString(), + prim_type = prim.Type.ToString(), + sculpt_type = prim.Sculpt != null ? prim.Sculpt.Type.ToString() : "None", + sculpt_uuid = prim.Sculpt != null ? prim.Sculpt.SculptTexture.ToString() : "00000000-0000-0000-0000-000000000000", + phys_density = prim.PhysicsProps != null ? Math.Round(prim.PhysicsProps.Density, 1) : 1000.0, + phys_friction = prim.PhysicsProps != null ? Math.Round(prim.PhysicsProps.Friction, 2) : 0.6, + phys_gravity = prim.PhysicsProps != null ? Math.Round(prim.PhysicsProps.GravityMultiplier, 2) : 1.0, + phys_restitution = prim.PhysicsProps != null ? Math.Round(prim.PhysicsProps.Restitution, 2) : 0.5, + light_cutoff = prim.Light != null ? Math.Round(prim.Light.Cutoff, 2) : 0.0 }); } } return BasicReply(JsonSerializer.Serialize(reply, JsonOptions.UnsafeRelaxed)); } + + [About("Fetches contents (Task Inventory) inside a specified object")] + [ArgHints("target", "The UUID or LocalID of the object", "STRING")] + [CmdTypeGet()] + public object GetObjectContents(string target) + { + if (GetClient().Network.CurrentSim == null) return Failure("Not in a sim"); + + string cleanTarget = (target ?? "").Trim('"', ' ', '\t', '\r', '\n'); + uint localID = 0; + UUID objectID = UUID.Zero; + + if (UUID.TryParse(cleanTarget, out objectID)) + { + var prim = GetClient().Network.CurrentSim.ObjectsPrimitives.Values.FirstOrDefault(p => p.ID == objectID); + if (prim != null) + { + localID = prim.LocalID; + } + } + else if (uint.TryParse(cleanTarget, out localID)) + { + if (GetClient().Network.CurrentSim.ObjectsPrimitives.TryGetValue(localID, out Primitive prim)) + { + objectID = prim.ID; + } + } + + // Fallback search across all simulator prims + if (objectID != UUID.Zero && localID == 0) + { + foreach (var p in GetClient().Network.CurrentSim.ObjectsPrimitives.Values) + { + if (p.ID == objectID) + { + localID = p.LocalID; + break; + } + } + } + else if (localID != 0 && objectID == UUID.Zero) + { + if (GetClient().Network.CurrentSim.ObjectsPrimitives.TryGetValue(localID, out Primitive prim)) + { + objectID = prim.ID; + } + } + + if (objectID == UUID.Zero || localID == 0) + { + return Failure($"Object '{cleanTarget}' not found in current sim (UUID={objectID}, LocalID={localID})"); + } + + var items = GetClient().Inventory.GetTaskInventory(objectID, localID, TimeSpan.FromSeconds(6)); + if (items == null) return BasicReply("[]"); + + var replyList = items.Select(item => { + string permsStr = ""; + string itemType = item.GetType().Name.Replace("Inventory", ""); + if (item is InventoryItem invItem) + { + var p = invItem.Permissions.NextOwnerMask; + var noMod = (p & PermissionMask.Modify) == PermissionMask.None; + var noCopy = (p & PermissionMask.Copy) == PermissionMask.None; + var noXfer = (p & PermissionMask.Transfer) == PermissionMask.None; + + List restr = new List(); + if (noMod) restr.Add("no modify"); + if (noCopy) restr.Add("no copy"); + if (noXfer) restr.Add("no transfer"); + if (restr.Count > 0) permsStr = " (" + string.Join(") (", restr) + ")"; + + if (invItem.InventoryType != InventoryType.Unknown) + { + itemType = invItem.InventoryType.ToString(); + } + else if (invItem.AssetType != AssetType.Unknown) + { + itemType = invItem.AssetType.ToString(); + } + } + + string itemName = item.Name ?? ""; + try { itemName = Uri.UnescapeDataString(itemName); } catch {} + + return new { + name = itemName, + uuid = item.UUID.ToString(), + type = itemType, + perms = permsStr + }; + }).ToList(); + + return BasicReply(JsonSerializer.Serialize(replyList, JsonOptions.UnsafeRelaxed)); + } + + [About("Fetches all prims in the linkset for a specified object")] + [ArgHints("target", "The UUID or LocalID of the object", "STRING")] + [CmdTypeGet()] + public object GetLinksetPrims(string target) + { + if (GetClient().Network.CurrentSim == null) return Failure("Not in a sim"); + + string cleanTarget = (target ?? "").Trim('"', ' ', '\t', '\r', '\n'); + uint localID = 0; + UUID objectID = UUID.Zero; + + if (UUID.TryParse(cleanTarget, out objectID)) + { + var prim = GetClient().Network.CurrentSim.ObjectsPrimitives.Values.FirstOrDefault(p => p.ID == objectID); + if (prim != null) localID = prim.LocalID; + } + else if (uint.TryParse(cleanTarget, out localID)) + { + if (GetClient().Network.CurrentSim.ObjectsPrimitives.TryGetValue(localID, out Primitive prim)) + { + objectID = prim.ID; + } + } + + if (objectID == UUID.Zero && localID == 0) return Failure($"Object '{cleanTarget}' not found"); + + Primitive targetPrim = GetClient().Network.CurrentSim.ObjectsPrimitives.Values.FirstOrDefault(p => p.ID == objectID || p.LocalID == localID); + if (targetPrim == null) return Failure("Target prim not found"); + + Primitive rootPrim = targetPrim; + if (targetPrim.ParentID != 0) + { + var parent = GetClient().Network.CurrentSim.ObjectsPrimitives.Values.FirstOrDefault(p => p.LocalID == targetPrim.ParentID); + if (parent != null) rootPrim = parent; + } + + var linksetPrims = GetClient().Network.CurrentSim.ObjectsPrimitives.Values + .Where(p => p.ID == rootPrim.ID || p.ParentID == rootPrim.LocalID) + .OrderBy(p => p.ID == rootPrim.ID ? 0 : 1) + .ThenBy(p => p.LocalID) + .ToList(); + + var missingProps = linksetPrims.Where(p => p.Properties == null).Select(p => p.LocalID).ToArray(); + if (missingProps.Length > 0) + { + GetClient().Objects.SelectObjects(GetClient().Network.CurrentSim, missingProps); + Thread.Sleep(400); + } + + uint sittingOn = GetClient().Self.SittingOn; + int linkNum = 1; + List replyList = []; + + foreach (var prim in linksetPrims) + { + string name = prim.Properties?.Name; + if (string.IsNullOrEmpty(name)) name = (prim.ID == rootPrim.ID ? "Root Prim #" : "Child Prim #") + prim.LocalID; + + prim.Rotation.GetEulerAngles(out float roll, out float pitch, out float yaw); + float rotX = (float)Math.Round(roll * (180.0f / (float)Math.PI), 2); + float rotY = (float)Math.Round(pitch * (180.0f / (float)Math.PI), 2); + float rotZ = (float)Math.Round(yaw * (180.0f / (float)Math.PI), 2); + + UUID primOwner = prim.Properties?.OwnerID ?? rootPrim.Properties?.OwnerID ?? UUID.Zero; + bool canBotModify = false; + if (primOwner != UUID.Zero) + { + if (primOwner == GetClient().Self.AgentID) canBotModify = true; + else if (GetClient().Friends.FriendList.TryGetValue(primOwner, out FriendInfo friend) && friend.CanModifyTheirObjects) canBotModify = true; + } + + replyList.Add(new { + link_num = linkNum++, + is_root = (prim.ID == rootPrim.ID), + name = name, + description = prim.Properties?.Description ?? "", + uuid = prim.ID.ToString(), + local_id = prim.LocalID, + pos = prim.Position.ToString(), + pos_x = Math.Round(prim.Position.X, 3), + pos_y = Math.Round(prim.Position.Y, 3), + pos_z = Math.Round(prim.Position.Z, 3), + scale_x = Math.Round(prim.Scale.X, 3), + scale_y = Math.Round(prim.Scale.Y, 3), + scale_z = Math.Round(prim.Scale.Z, 3), + rot_x = rotX, + rot_y = rotY, + rot_z = rotZ, + flag_physical = (prim.Flags & PrimFlags.Physics) != 0, + flag_temporary = (prim.Flags & PrimFlags.Temporary) != 0, + flag_phantom = (prim.Flags & PrimFlags.Phantom) != 0, + physics_shape = prim.PhysicsProps != null ? prim.PhysicsProps.PhysicsShapeType.ToString() : "Prim", + has_light = prim.Light != null, + light_color = prim.Light != null ? prim.Light.Color.ToString() : "", + light_intensity = prim.Light != null ? Math.Round(prim.Light.Intensity, 2) : 0, + light_radius = prim.Light != null ? Math.Round(prim.Light.Radius, 2) : 0, + light_falloff = prim.Light != null ? Math.Round(prim.Light.Falloff, 2) : 0, + has_flexi = prim.Flexible != null, + flexi_softness = prim.Flexible != null ? prim.Flexible.Softness : 0, + flexi_gravity = prim.Flexible != null ? Math.Round(prim.Flexible.Gravity, 2) : 0, + flexi_drag = prim.Flexible != null ? Math.Round(prim.Flexible.Drag, 2) : 0, + flexi_wind = prim.Flexible != null ? Math.Round(prim.Flexible.Wind, 2) : 0, + flexi_tension = prim.Flexible != null ? Math.Round(prim.Flexible.Tension, 2) : 0, + is_sitting = (prim.LocalID == sittingOn), + owner_id = primOwner.ToString(), + creator_id = prim.Properties?.CreatorID.ToString() ?? rootPrim.Properties?.CreatorID.ToString() ?? "00000000-0000-0000-0000-000000000000", + click_action = prim.ClickAction.ToString(), + can_bot_modify = canBotModify, + tex_default_id = prim.Textures?.DefaultTexture?.TextureID.ToString() ?? "00000000-0000-0000-0000-000000000000", + tex_glow = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.Glow, 3) : 0, + tex_transparency = prim.Textures?.DefaultTexture != null ? Math.Round((1.0f - prim.Textures.DefaultTexture.RGBA.A) * 100.0f, 1) : 0, + tex_shiny = prim.Textures?.DefaultTexture?.Shiny.ToString() ?? "None", + tex_bump = prim.Textures?.DefaultTexture?.Bump.ToString() ?? "None", + tex_fullbright = prim.Textures?.DefaultTexture?.Fullbright ?? false, + tex_mapping = prim.Textures?.DefaultTexture?.TexMapType.ToString() ?? "Default", + tex_repeat_u = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.RepeatU, 4) : 1.0, + tex_repeat_v = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.RepeatV, 4) : 1.0, + tex_offset_u = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.OffsetU, 4) : 0.0, + tex_offset_v = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.OffsetV, 4) : 0.0, + tex_rotation = prim.Textures?.DefaultTexture != null ? Math.Round(prim.Textures.DefaultTexture.Rotation * (180.0f / (float)Math.PI), 2) : 0.0, + tex_color = prim.Textures?.DefaultTexture != null ? $"#{((byte)(prim.Textures.DefaultTexture.RGBA.R * 255)):X2}{((byte)(prim.Textures.DefaultTexture.RGBA.G * 255)):X2}{((byte)(prim.Textures.DefaultTexture.RGBA.B * 255)):X2}" : "#FFFFFF", + material_type = prim.PrimData.Material.ToString(), + prim_type = prim.Type.ToString(), + sculpt_type = prim.Sculpt != null ? prim.Sculpt.Type.ToString() : "None", + sculpt_uuid = prim.Sculpt != null ? prim.Sculpt.SculptTexture.ToString() : "00000000-0000-0000-0000-000000000000", + phys_density = prim.PhysicsProps != null ? Math.Round(prim.PhysicsProps.Density, 1) : 1000.0, + phys_friction = prim.PhysicsProps != null ? Math.Round(prim.PhysicsProps.Friction, 2) : 0.6, + phys_gravity = prim.PhysicsProps != null ? Math.Round(prim.PhysicsProps.GravityMultiplier, 2) : 1.0, + phys_restitution = prim.PhysicsProps != null ? Math.Round(prim.PhysicsProps.Restitution, 2) : 0.5, + light_cutoff = prim.Light != null ? Math.Round(prim.Light.Cutoff, 2) : 0.0 + }); + } + + return BasicReply(JsonSerializer.Serialize(replyList, JsonOptions.UnsafeRelaxed)); + } + + [About("Fetches the 256x256 terrain heightmap (sampled at 128x128) and water height of the current region")] + [ReturnHints("A JSON object with sim, water_height, min_height, max_height, patches_loaded, grid_size, and heights array")] + [CmdTypeGet()] + public object GetSimTerrain() + { + Simulator sim = GetClient().Network.CurrentSim; + if (sim == null) return Failure("Error not in a sim"); + + float waterHeight = sim.WaterHeight > 0 ? sim.WaterHeight : 20.0f; + int patchesLoaded = 0; + float sumHeight = 0f; + int validPoints = 0; + + if (sim.Terrain != null) + { + for (int i = 0; i < sim.Terrain.Length; i++) + { + if (sim.Terrain[i]?.Data != null) + { + patchesLoaded++; + for (int j = 0; j < sim.Terrain[i].Data.Length; j++) + { + sumHeight += sim.Terrain[i].Data[j]; + validPoints++; + } + } + } + } + + float fallbackHeight = validPoints > 0 ? (sumHeight / validPoints) : waterHeight; + int gridSize = 128; + double[] heights = new double[gridSize * gridSize]; + float minH = float.MaxValue; + float maxH = float.MinValue; + + // Precompute nearest valid patch index for any missing patches so there are no holes + int[] nearestPatchIdx = new int[256]; + for (int py = 0; py < 16; py++) + { + for (int px = 0; px < 16; px++) + { + int idx = py * 16 + px; + if (sim.Terrain != null && idx < sim.Terrain.Length && sim.Terrain[idx]?.Data != null) + { + nearestPatchIdx[idx] = idx; + } + else + { + int bestIdx = -1; + int bestDistSq = int.MaxValue; + if (sim.Terrain != null) + { + for (int ny = 0; ny < 16; ny++) + { + for (int nx = 0; nx < 16; nx++) + { + int nIdx = ny * 16 + nx; + if (nIdx < sim.Terrain.Length && sim.Terrain[nIdx]?.Data != null) + { + int dSq = (nx - px) * (nx - px) + (ny - py) * (ny - py); + if (dSq < bestDistSq) + { + bestDistSq = dSq; + bestIdx = nIdx; + } + } + } + } + } + nearestPatchIdx[idx] = bestIdx; + } + } + } + + for (int gy = 0; gy < gridSize; gy++) + { + int sy = (int)Math.Round(gy * 255.0 / (gridSize - 1)); + if (sy > 255) sy = 255; + for (int gx = 0; gx < gridSize; gx++) + { + int sx = (int)Math.Round(gx * 255.0 / (gridSize - 1)); + if (sx > 255) sx = 255; + + float h = fallbackHeight; + if (sim.TerrainHeightAtPoint(sx, sy, out float ptH)) + { + h = ptH; + } + else if (sim.Terrain != null) + { + int px = sx / 16; + int py = sy / 16; + int nIdx = nearestPatchIdx[py * 16 + px]; + if (nIdx >= 0 && sim.Terrain[nIdx]?.Data != null) + { + int npx = nIdx % 16; + int npy = nIdx / 16; + int cx = npx < px ? 15 : (npx > px ? 0 : (sx % 16)); + int cy = npy < py ? 15 : (npy > py ? 0 : (sy % 16)); + h = sim.Terrain[nIdx].Data[cy * 16 + cx]; + } + } + + if (h < minH) minH = h; + if (h > maxH) maxH = h; + heights[gy * gridSize + gx] = Math.Round(h, 2); + } + } + + if (minH == float.MaxValue) { minH = waterHeight; maxH = waterHeight; } + + int bx = Math.Clamp((int)Math.Round(GetClient().Self.SimPosition.X), 0, 255); + int by = Math.Clamp((int)Math.Round(GetClient().Self.SimPosition.Y), 0, 255); + float botGround = fallbackHeight; + if (sim.TerrainHeightAtPoint(bx, by, out float bgH)) botGround = bgH; + + // Collect structural prims (walls, floors, roofs, platforms, buildings, trees) from CurrentSim AND neighboring sims + List<(float score, object data)> structCandidates = []; + List neighborTerrains = []; + try + { + List simsToScan = [sim]; + try + { + lock (GetClient().Network.Simulators) + { + foreach (var ns in GetClient().Network.Simulators) + { + if (ns != null && ns.Connected && ns != sim && !simsToScan.Contains(ns)) + simsToScan.Add(ns); + } + } + } + catch { } + + uint curOriginX = (uint)(sim.Handle >> 32); + uint curOriginY = (uint)(sim.Handle & 0xFFFFFFFF); + Vector3 selfPos = GetClient().Self.SimPosition; + + foreach (Simulator s in simsToScan) + { + uint sOriginX = (uint)(s.Handle >> 32); + uint sOriginY = (uint)(s.Handle & 0xFFFFFFFF); + float offsetX = (long)sOriginX - (long)curOriginX; + float offsetY = (long)sOriginY - (long)curOriginY; + + if (Math.Abs(offsetX) > 256f || Math.Abs(offsetY) > 256f) + continue; + + // If this is a neighboring sim, extract its 32x32 terrain heightmap if patches are available + if (s != sim && s.Terrain != null) + { + int nPatches = 0; + for (int i = 0; i < 256; i++) + { + if (s.Terrain[i]?.Data != null) nPatches++; + } + if (nPatches > 0) + { + int nGrid = 32; + double[] nHeights = new double[nGrid * nGrid]; + float nWater = s.WaterHeight > 0 ? s.WaterHeight : waterHeight; + for (int gy = 0; gy < nGrid; gy++) + { + int sy = Math.Clamp((int)Math.Round(gy * 255.0 / (nGrid - 1)), 0, 255); + for (int gx = 0; gx < nGrid; gx++) + { + int sx = Math.Clamp((int)Math.Round(gx * 255.0 / (nGrid - 1)), 0, 255); + float nh = nWater; + if (s.TerrainHeightAtPoint(sx, sy, out float th) && th > 0.01f) + nh = th; + nHeights[gy * nGrid + gx] = Math.Round(nh, 2); + } + } + neighborTerrains.Add(new + { + dx = (int)Math.Round(offsetX / 256f), + dy = (int)Math.Round(offsetY / 256f), + sim = s.Name, + patches = nPatches, + grid_size = nGrid, + heights = nHeights + }); + } + } + + Dictionary primsCopy; + try + { + primsCopy = s.ObjectsPrimitives.ToDictionary(k => k.Key, v => v.Value); + } + catch { continue; } + + foreach (var kvp in primsCopy) + { + Primitive p = kvp.Value; + if (p == null) continue; + if ((p.Flags & PrimFlags.Temporary) != 0) continue; + + Vector3 worldPos = p.Position; + Quaternion worldRot = p.Rotation; + + Primitive rootParent = null; + if (p.ParentID != 0) + { + if (!primsCopy.TryGetValue(p.ParentID, out rootParent) || rootParent == null) + continue; // Skip avatar attachments or orphaned child prims + worldPos = rootParent.Position + (p.Position * rootParent.Rotation); + worldRot = rootParent.Rotation * p.Rotation; + } + + if (p.PrimData.PCode == PCode.Grass) continue; + + if (worldPos.X < -10f || worldPos.X > 266f || worldPos.Y < -10f || worldPos.Y > 266f || worldPos.Z < 0f || worldPos.Z > 4096f) + continue; + + float sx = p.Scale.X, sy = p.Scale.Y, sz = p.Scale.Z; + float maxDim = Math.Max(sx, Math.Max(sy, sz)); + float minDim = Math.Min(sx, Math.Min(sy, sz)); + float midDim = (sx + sy + sz) - maxDim - minDim; + + // Structural size: >= 1.8m longest side, >= 0.5m second side, <= 128m to skip huge sim-surround domes + if (maxDim < 1.8f || midDim < 0.5f || maxDim > 128.0f) + continue; + + float alpha = 1.0f; + string hexColor = "#c8d0dc"; + byte rByte = 200, gByte = 208, bByte = 220; + if (p.Textures?.DefaultTexture != null) + { + var rgba = p.Textures.DefaultTexture.RGBA; + alpha = rgba.A; + if (alpha < 0.08f) continue; // Skip invisible collision/sensor prims + rByte = (byte)Math.Clamp((int)(rgba.R * 255), 45, 240); + gByte = (byte)Math.Clamp((int)(rgba.G * 255), 45, 240); + bByte = (byte)Math.Clamp((int)(rgba.B * 255), 45, 240); + hexColor = $"#{rByte:X2}{gByte:X2}{bByte:X2}"; + } + + string shapeType = "box"; + string objName = (p.Properties?.Name ?? rootParent?.Properties?.Name ?? "").ToLowerInvariant(); + bool isFoliageName = objName.Contains("tree") || objName.Contains("foliage") || objName.Contains("bush") || + objName.Contains("shrub") || objName.Contains("leaves") || objName.Contains("canopy") || + objName.Contains("pine") || objName.Contains("willow") || objName.Contains("maple") || + objName.Contains("birch") || objName.Contains("oak") || objName.Contains("palm") || + objName.Contains("plant") || objName.Contains("fern"); + bool isFoliageTint = (gByte >= 80 && gByte >= bByte + 30 && rByte <= gByte + 50); + bool isBulkyCanopy = (minDim >= 3.5f && maxDim >= 6.0f && isFoliageTint) || + (minDim >= 5.0f && maxDim >= 10.0f && ((p.Flags & PrimFlags.Phantom) != 0 || p.Type == PrimType.Sculpt)); + + if (p.PrimData.PCode == PCode.Tree || p.PrimData.PCode == PCode.NewTree || p.Flexible != null || isFoliageName || isBulkyCanopy) + { + shapeType = "tree"; + } + else if (p.Type == PrimType.Cylinder) + { + shapeType = "cyl"; + } + else if (p.Type == PrimType.Sphere) + { + shapeType = "sphere"; + } + + float wx = worldPos.X + offsetX; + float wy = worldPos.Y + offsetY; + float distToBot = Vector3.Distance(selfPos, new Vector3(wx, wy, worldPos.Z)); + float score = (maxDim * midDim) / (1.0f + distToBot * 0.03f); + + structCandidates.Add((score, new + { + t = shapeType, + x = Math.Round(wx, 2), + y = Math.Round(wy, 2), + z = Math.Round(worldPos.Z, 2), + sx = Math.Round(sx, 2), + sy = Math.Round(sy, 2), + sz = Math.Round(sz, 2), + qx = Math.Round(worldRot.X, 3), + qy = Math.Round(worldRot.Y, 3), + qz = Math.Round(worldRot.Z, 3), + qw = Math.Round(worldRot.W, 3), + c = hexColor, + a = Math.Round(alpha, 2) + })); + } + } + } + catch { } + + var structures = structCandidates + .OrderByDescending(c => c.score) + .Take(950) + .Select(c => c.data) + .ToList(); + + var result = new + { + sim = sim.Name, + water_height = Math.Round(waterHeight, 2), + min_height = Math.Round(minH, 2), + max_height = Math.Round(maxH, 2), + bot_ground_height = Math.Round(botGround, 2), + patches_loaded = patchesLoaded, + grid_size = gridSize, + heights = heights, + neighbors = neighborTerrains, + structures = structures + }; + + return BasicReply(JsonSerializer.Serialize(result, JsonOptions.UnsafeRelaxed)); + } + + private static readonly ConcurrentDictionary _avatarMeshCache = new(); + private static readonly ConcurrentDictionary _pngTextureCache = new(); + private static Dictionary _cachedBoneWorldPos = null; + private static HashSet _cachedTrueBones = null; + + private static void EnsureSkeletonLoaded() + { + if (_cachedBoneWorldPos != null && _cachedTrueBones != null) return; + var bonePos = new Dictionary(StringComparer.OrdinalIgnoreCase); + var trueBones = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + string[] candidatePaths = + [ + System.IO.Path.Combine(Settings.RESOURCE_DIR, "character", "avatar_skeleton.xml"), + "/app/linden/character/avatar_skeleton.xml", + System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "linden", "character", "avatar_skeleton.xml") + ]; + string skelPath = candidatePaths.FirstOrDefault(File.Exists); + if (skelPath != null) + { + XDocument doc = XDocument.Load(skelPath); + void WalkElement(XElement el, Vector3 parentWorldPos) + { + string name = el.Attribute("name")?.Value; + string posStr = el.Attribute("pos")?.Value ?? "0 0 0"; + var parts = posStr.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); + Vector3 localPos = Vector3.Zero; + if (parts.Length >= 3 && + float.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float px) && + float.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float py) && + float.TryParse(parts[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float pz)) + { + localPos = new Vector3(px, py, pz); + } + Vector3 curWorld = parentWorldPos + localPos; + if (!string.IsNullOrEmpty(name)) + { + bonePos[name] = curWorld; + if (el.Name.LocalName == "bone") + trueBones.Add(name); + } + foreach (var child in el.Elements()) + { + if (child.Name.LocalName == "bone" || child.Name.LocalName == "collision_volume") + WalkElement(child, curWorld); + } + } + var rootBone = doc.Root?.Element("bone"); + if (rootBone != null) WalkElement(rootBone, Vector3.Zero); + } + } + catch { } + + if (!bonePos.ContainsKey("mPelvis")) bonePos["mPelvis"] = new Vector3(0f, 0f, 1.067f); + if (!bonePos.ContainsKey("mTorso")) bonePos["mTorso"] = new Vector3(0f, 0f, 1.151f); + if (!bonePos.ContainsKey("mChest")) bonePos["mChest"] = new Vector3(-0.015f, 0f, 1.356f); + if (!bonePos.ContainsKey("mNeck")) bonePos["mNeck"] = new Vector3(-0.025f, 0f, 1.607f); + if (!bonePos.ContainsKey("mHead")) bonePos["mHead"] = new Vector3(-0.025f, 0f, 1.683f); + if (!bonePos.ContainsKey("mSkull")) bonePos["mSkull"] = new Vector3(-0.025f, 0f, 1.762f); + if (!bonePos.ContainsKey("mEyeLeft")) bonePos["mEyeLeft"] = new Vector3(0.073f, 0.036f, 1.762f); + if (!bonePos.ContainsKey("mEyeRight")) bonePos["mEyeRight"] = new Vector3(0.073f, -0.036f, 1.762f); + + _cachedBoneWorldPos = bonePos; + _cachedTrueBones = trueBones; + } + + private static string LookupInventoryItemName(GridClient client, UUID itemUuid) + { + if (itemUuid == UUID.Zero) return null; + try + { + Inventory store = client?.Inventory?.GetStore; + if (store != null && store.Items.TryGetValue(itemUuid, out InventoryNode node) && node?.Data is InventoryItem invItem) + return invItem.Name; + } + catch { } + return null; + } + + private static Vector3 GetAttachmentBoneWorldPos(AttachmentPoint pt, Dictionary bonePosMap = null) + { + EnsureSkeletonLoaded(); + string boneName = pt switch + { + AttachmentPoint.Chest => "mChest", + AttachmentPoint.Skull => "mSkull", + AttachmentPoint.LeftShoulder => "mCollarLeft", + AttachmentPoint.RightShoulder => "mCollarRight", + AttachmentPoint.LeftHand or AttachmentPoint.LeftHandRing => "mWristLeft", + AttachmentPoint.RightHand or AttachmentPoint.RightHandRing or AttachmentPoint.Default => "mWristRight", + AttachmentPoint.LeftFoot or AttachmentPoint.LeftHindFoot => "mAnkleLeft", + AttachmentPoint.RightFoot or AttachmentPoint.RightHindFoot => "mAnkleRight", + AttachmentPoint.Spine or AttachmentPoint.Stomach => "mTorso", + AttachmentPoint.Pelvis or AttachmentPoint.Root or AttachmentPoint.TailBase or AttachmentPoint.Groin => "mPelvis", + AttachmentPoint.Mouth or AttachmentPoint.Chin or AttachmentPoint.Nose or AttachmentPoint.Jaw or AttachmentPoint.Tongue => "mHead", + AttachmentPoint.LeftEar or AttachmentPoint.AltLeftEar => "mHead", + AttachmentPoint.RightEar or AttachmentPoint.AltRightEar => "mHead", + AttachmentPoint.LeftEyeball or AttachmentPoint.AltLeftEye => "mEyeLeft", + AttachmentPoint.RightEyeball or AttachmentPoint.AltRightEye => "mEyeRight", + AttachmentPoint.RightUpperArm => "mShoulderRight", + AttachmentPoint.RightForearm => "mElbowRight", + AttachmentPoint.LeftUpperArm => "mShoulderLeft", + AttachmentPoint.LeftForearm => "mElbowLeft", + AttachmentPoint.RightHip or AttachmentPoint.RightUpperLeg => "mHipRight", + AttachmentPoint.RightLowerLeg => "mKneeRight", + AttachmentPoint.LeftHip or AttachmentPoint.LeftUpperLeg => "mHipLeft", + AttachmentPoint.LeftLowerLeg => "mKneeLeft", + AttachmentPoint.LeftPec or AttachmentPoint.RightPec or AttachmentPoint.LeftWing or AttachmentPoint.RightWing => "mChest", + AttachmentPoint.Neck => "mNeck", + _ => "mPelvis" + }; + var map = bonePosMap ?? _cachedBoneWorldPos; + return (map != null && map.TryGetValue(boneName, out Vector3 p)) ? p : new Vector3(0f, 0f, 1.067f); + } + + private static string[] GetPreferredBonesForAttachment(string itemName, AttachmentPoint pt) + { + string name = (itemName ?? "").ToLowerInvariant(); + + // 1. Head / Face / Eyes / Mouth / Teeth / Tongue / Ears / Piercings / Glasses + if (name.Contains("head") || name.Contains("face") || name.Contains("eye") || name.Contains("lash") || + name.Contains("brow") || name.Contains("teeth") || name.Contains("mouth") || name.Contains("tongue") || + name.Contains("earring") || name.Contains("glasses") || name.Contains("piercing") || + pt is AttachmentPoint.Chin or AttachmentPoint.Mouth or AttachmentPoint.Nose or AttachmentPoint.Jaw or + AttachmentPoint.Tongue or AttachmentPoint.LeftEar or AttachmentPoint.RightEar or + AttachmentPoint.LeftEyeball or AttachmentPoint.RightEyeball or AttachmentPoint.AltLeftEye or AttachmentPoint.AltRightEye) + { + return ["mHead", "mSkull", "mNeck", "mChest", "mPelvis"]; + } + + // 2. Neck / Collar / Choker / Extender + if (name.Contains("neck") || name.Contains("collar") || name.Contains("choker") || pt == AttachmentPoint.Neck) + { + return ["mNeck", "mChest", "mHead", "mPelvis"]; + } + + // 3. Hair / Hat / Helmet / Wig + if (name.Contains("hair") || name.Contains("hat") || name.Contains("helmet") || name.Contains("wig") || pt == AttachmentPoint.Skull) + { + return ["mSkull", "mHead", "mNeck", "mChest", "mPelvis"]; + } + + // 4. Feet / Shoes / Boots / Socks + if (name.Contains("shoe") || name.Contains("boot") || name.Contains("sneaker") || name.Contains("heel") || + name.Contains("foot") || name.Contains("feet") || name.Contains("sock") || + pt is AttachmentPoint.LeftFoot or AttachmentPoint.RightFoot or AttachmentPoint.LeftLowerLeg or AttachmentPoint.RightLowerLeg) + { + if (pt is AttachmentPoint.LeftFoot or AttachmentPoint.LeftLowerLeg || name.Contains("left") || name.Contains(" - l") || name.Contains("[l]")) + return ["mAnkleLeft", "mKneeLeft", "mHipLeft", "mPelvis"]; + if (pt is AttachmentPoint.RightFoot or AttachmentPoint.RightLowerLeg || name.Contains("right") || name.Contains(" - r") || name.Contains("[r]")) + return ["mAnkleRight", "mKneeRight", "mHipRight", "mPelvis"]; + return ["mAnkleLeft", "mAnkleRight", "mKneeLeft", "mKneeRight", "mHipLeft", "mHipRight", "mPelvis"]; + } + + // 5. Hands / Gloves / Rings / Bracelets + if (name.Contains("glove") || name.Contains("ring") || name.Contains("bracelet") || name.Contains("nail") || + (name.Contains("hand") && !name.Contains("handle") && !name.Contains("bag") && !name.Contains("prop"))) + { + if (pt is AttachmentPoint.LeftHand or AttachmentPoint.LeftHandRing or AttachmentPoint.LeftForearm || name.Contains("left") || name.Contains(" - l") || name.Contains("[l]")) + return ["mWristLeft", "mElbowLeft", "mShoulderLeft", "mChest", "mPelvis"]; + if (pt is AttachmentPoint.RightHand or AttachmentPoint.RightHandRing or AttachmentPoint.RightForearm || name.Contains("right") || name.Contains(" - r") || name.Contains("[r]")) + return ["mWristRight", "mElbowRight", "mShoulderRight", "mChest", "mPelvis"]; + return ["mWristLeft", "mWristRight", "mElbowLeft", "mElbowRight", "mChest", "mPelvis"]; + } + + // 6. Upper Body / Tops / Shirts / Jackets / Pecs + if (name.Contains("shirt") || name.Contains("top") || name.Contains("jacket") || name.Contains("sweater") || + name.Contains("vest") || name.Contains("bra") || pt is AttachmentPoint.Chest or AttachmentPoint.LeftPec or AttachmentPoint.RightPec) + { + return ["mChest", "mTorso", "mPelvis"]; + } + + // 7. Lower Body / Pants / Shorts / Skirts / Pelvis + if (name.Contains("pant") || name.Contains("short") || name.Contains("skirt") || name.Contains("thong") || + name.Contains("jean") || name.Contains("undie") || pt is AttachmentPoint.Pelvis or AttachmentPoint.Groin) + { + return ["mPelvis", "mTorso", "mHipLeft", "mHipRight"]; + } + + // Default fallback + return + [ + "mPelvis", "mTorso", "mChest", "mNeck", "mHead", "mSkull", + "mSpine1", "mSpine2", "mSpine3", "mSpine4", + "mHipLeft", "mHipRight", "mKneeLeft", "mKneeRight", "mAnkleLeft", "mAnkleRight", + "mCollarLeft", "mCollarRight", "mShoulderLeft", "mShoulderRight", "mElbowLeft", "mElbowRight", "mWristLeft", "mWristRight" + ]; + } + + private static bool IsHudAttachmentPoint(AttachmentPoint pt) + { + return pt is AttachmentPoint.HUDCenter2 or AttachmentPoint.HUDTopRight or AttachmentPoint.HUDTop + or AttachmentPoint.HUDTopLeft or AttachmentPoint.HUDCenter or AttachmentPoint.HUDBottomLeft + or AttachmentPoint.HUDBottom or AttachmentPoint.HUDBottomRight; + } + + private static bool TryGetMatrix16(OSD osd, int index, out float[] mat) + { + mat = null; + if (osd is not OSDArray arr) return false; + if (index >= 0 && index < arr.Count && arr[index] is OSDArray subArr && subArr.Count >= 16) + { + mat = new float[16]; + for (int i = 0; i < 16; i++) mat[i] = (float)subArr[i].AsReal(); + return true; + } + if (index == 0 && arr.Count >= 16 && arr[0] is not OSDArray) + { + mat = new float[16]; + for (int i = 0; i < 16; i++) mat[i] = (float)arr[i].AsReal(); + return true; + } + if (arr.Count >= (index + 1) * 16 && arr[0] is not OSDArray) + { + mat = new float[16]; + for (int i = 0; i < 16; i++) mat[i] = (float)arr[index * 16 + i].AsReal(); + return true; + } + return false; + } + + private static string FloatsToBase64(float[] arr) + { + byte[] bytes = new byte[arr.Length * 4]; + Buffer.BlockCopy(arr, 0, bytes, 0, bytes.Length); + return Convert.ToBase64String(bytes); + } + + private static string UShortsToBase64(ushort[] arr) + { + byte[] bytes = new byte[arr.Length * 2]; + Buffer.BlockCopy(arr, 0, bytes, 0, bytes.Length); + return Convert.ToBase64String(bytes); + } + + private static object LoadLlmPart(string fileName, string partName, string texUuid, string bakeName, Vector3 offset) + { + try + { + string[] dirs = + [ + System.IO.Path.Combine(Settings.RESOURCE_DIR, "character"), + "/app/linden/character", + System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "linden", "character") + ]; + string dir = dirs.FirstOrDefault(d => File.Exists(System.IO.Path.Combine(d, fileName))); + if (dir == null) return null; + + var lm = new LindenMesh(partName); + lm.LoadMesh(System.IO.Path.Combine(dir, fileName)); + if (lm.NumVertices == 0 || lm.NumFaces == 0) return null; + + float[] pos = new float[lm.NumVertices * 3]; + float[] norm = new float[lm.NumVertices * 3]; + float[] uv = new float[lm.NumVertices * 2]; + for (int i = 0; i < lm.NumVertices; i++) + { + var v = lm.Vertices[i]; + pos[i * 3] = v.Coord.X + offset.X; + pos[i * 3 + 1] = v.Coord.Y + offset.Y; + pos[i * 3 + 2] = v.Coord.Z + offset.Z; + norm[i * 3] = v.Normal.X; + norm[i * 3 + 1] = v.Normal.Y; + norm[i * 3 + 2] = v.Normal.Z; + uv[i * 2] = v.TexCoord.X; + uv[i * 2 + 1] = v.TexCoord.Y; + } + ushort[] idx = new ushort[lm.NumFaces * 3]; + for (int f = 0; f < lm.NumFaces; f++) + { + idx[f * 3] = (ushort)lm.Faces[f].Indices[0]; + idx[f * 3 + 1] = (ushort)lm.Faces[f].Indices[1]; + idx[f * 3 + 2] = (ushort)lm.Faces[f].Indices[2]; + } + return new + { + name = partName, + tex = texUuid ?? "", + bake = bakeName ?? "", + v_count = lm.NumVertices, + b64_pos = FloatsToBase64(pos), + b64_norm = FloatsToBase64(norm), + b64_uv = FloatsToBase64(uv), + b64_idx = UShortsToBase64(idx) + }; + } + catch + { + return null; + } + } + + [About("Returns decoded 3D avatar meshes, baked texture UUIDs, and worn attachment metadata for the bot")] + [CmdTypeGet()] + public object GetBotAvatarRender() + { + var client = GetClient(); + if (client?.Network?.CurrentSim == null) return Failure("Not in a sim"); + + EnsureSkeletonLoaded(); + UUID botID = client.Self.AgentID; + Avatar botAvatar = client.Network.CurrentSim.ObjectsAvatars.Values.FirstOrDefault(a => a.ID == botID); + uint botLocalID = botAvatar?.LocalID ?? client.Self.LocalID; + + // Transparent / invisible placeholder textures & empty server bake hashes in SL + var invisibleTextures = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "8dcd4a48-2d37-4909-9f78-f7a9eb4ef903", // Default transparent texture + "f54a0c32-3cd1-d49a-5b4f-7b792bebc204", // Invisible alpha texture + "3a367d1c-bef1-6d43-7595-e88c1e3aadb3", // Full-alpha transparent bake hash + "76bf1429-475f-d122-8a32-b52716b43e99" // Empty universal/aux bake hash + }; + + // Download missing wearable assets for worn wearables (especially Universal wearables for BOM) + var wornWearables = client.Appearance.GetWearables().ToList(); + var missingWearables = wornWearables.Where(w => w.Asset == null && w.AssetID != UUID.Zero).ToList(); + if (missingWearables.Count > 0) + { + using var wCde = new CountdownEvent(missingWearables.Count); + foreach (var w in missingWearables) + { + client.Assets.RequestAsset(w.AssetID, w.AssetType, true, delegate (AssetDownload transfer, Asset asset) + { + try + { + if (transfer.Success && asset is AssetWearable aw) + { + w.Asset = aw; + w.Asset.Decode(); + } + } + catch { } + finally { wCde.Signal(); } + }); + } + wCde.Wait(TimeSpan.FromSeconds(3)); + } + + string wearableAux1 = ""; + string wearableAux2 = ""; + string wearableAux3 = ""; + string wearableLeftArm = ""; + string wearableLeftLeg = ""; + + foreach (var w in wornWearables) + { + if (w.Asset?.Textures != null) + { + if (string.IsNullOrEmpty(wearableAux1) && + (w.Asset.Textures.TryGetValue(AvatarTextureIndex.Aux1Tattoo, out UUID t1) || + w.Asset.Textures.TryGetValue(AvatarTextureIndex.Aux1Baked, out t1)) && + t1 != UUID.Zero && !invisibleTextures.Contains(t1.ToString())) + { + wearableAux1 = t1.ToString(); + } + + if (string.IsNullOrEmpty(wearableAux2) && + (w.Asset.Textures.TryGetValue(AvatarTextureIndex.Aux2Tattoo, out UUID t2) || + w.Asset.Textures.TryGetValue(AvatarTextureIndex.Aux2Baked, out t2)) && + t2 != UUID.Zero && !invisibleTextures.Contains(t2.ToString())) + { + wearableAux2 = t2.ToString(); + } + + if (string.IsNullOrEmpty(wearableAux3) && + (w.Asset.Textures.TryGetValue(AvatarTextureIndex.Aux3Tattoo, out UUID t3) || + w.Asset.Textures.TryGetValue(AvatarTextureIndex.Aux3Baked, out t3)) && + t3 != UUID.Zero && !invisibleTextures.Contains(t3.ToString())) + { + wearableAux3 = t3.ToString(); + } + + if (string.IsNullOrEmpty(wearableLeftArm) && + (w.Asset.Textures.TryGetValue(AvatarTextureIndex.LeftArmTattoo, out UUID tla) || + w.Asset.Textures.TryGetValue(AvatarTextureIndex.LeftArmBaked, out tla)) && + tla != UUID.Zero && !invisibleTextures.Contains(tla.ToString())) + { + wearableLeftArm = tla.ToString(); + } + + if (string.IsNullOrEmpty(wearableLeftLeg) && + (w.Asset.Textures.TryGetValue(AvatarTextureIndex.LeftLegTattoo, out UUID tll) || + w.Asset.Textures.TryGetValue(AvatarTextureIndex.LegLegBaked, out tll)) && + tll != UUID.Zero && !invisibleTextures.Contains(tll.ToString())) + { + wearableLeftLeg = tll.ToString(); + } + } + } + + string GetBakeTex(AvatarTextureIndex idx, AvatarTextureIndex fallbackIdx = AvatarTextureIndex.Unknown) + { + UUID id = UUID.Zero; + var face = botAvatar?.Textures?.GetFace((uint)idx); + if (face != null && face.TextureID != UUID.Zero && face.TextureID != AppearanceManager.DEFAULT_AVATAR_TEXTURE) + id = face.TextureID; + if (id == UUID.Zero && fallbackIdx != AvatarTextureIndex.Unknown) + { + var fbFace = botAvatar?.Textures?.GetFace((uint)fallbackIdx); + if (fbFace != null && fbFace.TextureID != UUID.Zero && fbFace.TextureID != AppearanceManager.DEFAULT_AVATAR_TEXTURE) + id = fbFace.TextureID; + } + return id == UUID.Zero ? "" : id.ToString(); + } + + string headBake = GetBakeTex(AvatarTextureIndex.HeadBaked, AvatarTextureIndex.HeadBodypaint); + string upperBake = GetBakeTex(AvatarTextureIndex.UpperBaked, AvatarTextureIndex.UpperBodypaint); + string lowerBake = GetBakeTex(AvatarTextureIndex.LowerBaked, AvatarTextureIndex.LowerBodypaint); + string eyesBake = GetBakeTex(AvatarTextureIndex.EyesBaked, AvatarTextureIndex.EyesIris); + string skirtBake = GetBakeTex(AvatarTextureIndex.SkirtBaked, AvatarTextureIndex.Skirt); + string hairBake = GetBakeTex(AvatarTextureIndex.HairBaked, AvatarTextureIndex.Hair); + string leftArmBake = GetBakeTex(AvatarTextureIndex.LeftArmBaked); + if (string.IsNullOrEmpty(leftArmBake) || invisibleTextures.Contains(leftArmBake)) + leftArmBake = !string.IsNullOrEmpty(wearableLeftArm) ? wearableLeftArm : upperBake; + + string leftLegBake = GetBakeTex(AvatarTextureIndex.LegLegBaked); + if (string.IsNullOrEmpty(leftLegBake) || invisibleTextures.Contains(leftLegBake)) + leftLegBake = !string.IsNullOrEmpty(wearableLeftLeg) ? wearableLeftLeg : lowerBake; + + string aux1Bake = GetBakeTex(AvatarTextureIndex.Aux1Baked); + if (string.IsNullOrEmpty(aux1Bake) || invisibleTextures.Contains(aux1Bake)) + aux1Bake = !string.IsNullOrEmpty(wearableAux1) ? wearableAux1 : headBake; + + string aux2Bake = GetBakeTex(AvatarTextureIndex.Aux2Baked); + if (string.IsNullOrEmpty(aux2Bake) || invisibleTextures.Contains(aux2Bake)) + aux2Bake = !string.IsNullOrEmpty(wearableAux2) ? wearableAux2 : headBake; + + string aux3Bake = GetBakeTex(AvatarTextureIndex.Aux3Baked); + if (string.IsNullOrEmpty(aux3Bake) || invisibleTextures.Contains(aux3Bake)) + aux3Bake = !string.IsNullOrEmpty(wearableAux3) ? wearableAux3 : headBake; + + // Bakes-on-Mesh (BoM) magic UUID lookup -> (bakeName, realTextureUUID) + var bomMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["5a9f4a74-30f2-821c-b88d-70499d3e7183"] = ("head", headBake), + ["ae2de45c-d252-50b8-5c6e-19f39ce79317"] = ("upper", upperBake), + ["24daea5f-0539-cfcf-047f-fbc40b2786ba"] = ("lower", lowerBake), + ["52cc6bb6-2ee5-e632-d3ad-50197b1dcb8a"] = ("eyes", eyesBake), + ["43529ce8-7faa-ad92-165a-bc4078371687"] = ("skirt", skirtBake), + ["09aac1fb-6bce-0bee-7d44-caac6dbb6c63"] = ("hair", hairBake), + ["ff62763f-d60a-9855-890b-0c96f8f8cd98"] = ("leftarm", leftArmBake), + ["8e915e25-31d1-cc95-ae08-d58a47488251"] = ("leftleg", leftLegBake), + ["9742065b-19b5-297c-858a-29711d539043"] = ("aux1", aux1Bake), + ["03642e83-2bd1-4eb9-34b4-4c47ed586d2d"] = ("aux2", aux2Bake), + ["26deb8f9-f618-ef6f-13c2-2b2c99765604"] = ("aux3", aux3Bake) + }; + + // 1. Base System Avatar Meshes (.llm) + var baseMeshes = new List(); + var mHead = LoadLlmPart("avatar_head.llm", "head", headBake, "head", Vector3.Zero); + if (mHead != null) baseMeshes.Add(mHead); + var mUpper = LoadLlmPart("avatar_upper_body.llm", "upper_body", upperBake, "upper", Vector3.Zero); + if (mUpper != null) baseMeshes.Add(mUpper); + var mLower = LoadLlmPart("avatar_lower_body.llm", "lower_body", lowerBake, "lower", Vector3.Zero); + if (mLower != null) baseMeshes.Add(mLower); + var mEyeL = LoadLlmPart("avatar_eye.llm", "eye_left", eyesBake, "eyes", _cachedBoneWorldPos["mEyeLeft"]); + if (mEyeL != null) baseMeshes.Add(mEyeL); + var mEyeR = LoadLlmPart("avatar_eye.llm", "eye_right", eyesBake, "eyes", _cachedBoneWorldPos["mEyeRight"]); + if (mEyeR != null) baseMeshes.Add(mEyeR); + if (!string.IsNullOrEmpty(skirtBake)) + { + var mSkirt = LoadLlmPart("avatar_skirt.llm", "skirt", skirtBake, "skirt", Vector3.Zero); + if (mSkirt != null) baseMeshes.Add(mSkirt); + } + + // 2. Worn Wearables Metadata + var wearablesList = new List(); + try + { + foreach (var wd in wornWearables) + { + string wName = LookupInventoryItemName(client, wd.ItemID) ?? wd.Asset?.Name ?? wd.WearableType.ToString(); + var wTexs = new Dictionary(); + if (wd.Asset?.Textures != null) + { + foreach (var kvp in wd.Asset.Textures) + wTexs[kvp.Key.ToString()] = kvp.Value.ToString(); + } + wearablesList.Add(new + { + type = wd.WearableType.ToString(), + name = wName, + item_uuid = wd.ItemID.ToString(), + asset_uuid = wd.AssetID.ToString(), + textures = wTexs + }); + } + } + catch { } + + // 3. Find all attached prims on the bot + var allPrims = client.Network.CurrentSim.ObjectsPrimitives.Values.ToList(); + var rootAttachments = allPrims.Where(p => botLocalID != 0 && p.ParentID == botLocalID).ToList(); + + // Collect all mesh asset UUIDs needed across all non-HUD attachments & their linkset children + var meshUuidsToFetch = new HashSet(); + foreach (var rootPrim in rootAttachments) + { + if (IsHudAttachmentPoint(rootPrim.PrimData.AttachmentPoint)) continue; + var linkset = new List { rootPrim }; + linkset.AddRange(allPrims.Where(cp => cp.ParentID == rootPrim.LocalID)); + foreach (var p in linkset) + { + if (p.Sculpt != null && p.Sculpt.Type == SculptType.Mesh && p.Sculpt.SculptTexture != UUID.Zero) + { + if (!_avatarMeshCache.ContainsKey(p.Sculpt.SculptTexture)) + meshUuidsToFetch.Add(p.Sculpt.SculptTexture); + } + } + } + + if (meshUuidsToFetch.Count > 0) + { + using var cde = new CountdownEvent(meshUuidsToFetch.Count); + foreach (UUID meshId in meshUuidsToFetch) + { + client.Assets.RequestMesh(meshId, (success, meshAsset) => + { + try + { + if (success && meshAsset != null && meshAsset.Decode()) + _avatarMeshCache[meshId] = meshAsset; + } + catch { } + finally + { + try { cde.Signal(); } catch { } + } + }); + } + cde.Wait(5500); + } + + EnsureSkeletonLoaded(); + var avatarBoneWorldPos = new Dictionary(_cachedBoneWorldPos, StringComparer.OrdinalIgnoreCase); + + // Scan non-HUD attachments to find primary worn mesh body and extract bone overrides + int maxBodyVertCount = 0; + OSDMap primaryBodySkin = null; + + foreach (var rootPrim in rootAttachments) + { + if (IsHudAttachmentPoint(rootPrim.PrimData.AttachmentPoint)) continue; + var linkset = new List { rootPrim }; + linkset.AddRange(allPrims.Where(cp => cp.ParentID == rootPrim.LocalID)); + foreach (var p in linkset) + { + if (p.Sculpt == null || p.Sculpt.Type != SculptType.Mesh || p.Sculpt.SculptTexture == UUID.Zero) continue; + if (!_avatarMeshCache.TryGetValue(p.Sculpt.SculptTexture, out AssetMesh meshAsset) || meshAsset?.MeshData == null) continue; + if (meshAsset.MeshData.TryGetValue("skin", out OSD skinOsd) && skinOsd is OSDMap skinMap) + { + if (skinMap.TryGetValue("joint_names", out OSD jnOsd) && jnOsd is OSDArray jnArr && + skinMap.TryGetValue("inverse_bind_matrix", out OSD ibmOsd) && ibmOsd is OSDArray) + { + bool hasPelvis = false; + bool hasNeckOrChest = false; + for (int j = 0; j < jnArr.Count; j++) + { + string jn = jnArr[j].AsString(); + if (string.Equals(jn, "mPelvis", StringComparison.OrdinalIgnoreCase)) hasPelvis = true; + if (string.Equals(jn, "mNeck", StringComparison.OrdinalIgnoreCase) || string.Equals(jn, "mChest", StringComparison.OrdinalIgnoreCase)) hasNeckOrChest = true; + } + if (hasPelvis && hasNeckOrChest) + { + int vCount = 0; + if (meshAsset.MeshData.TryGetValue("high_lod", out OSD hlOsd) && hlOsd is OSDArray subMeshes) + { + foreach (var sm in subMeshes) + { + if (sm is OSDMap smMap && smMap.TryGetValue("Position", out OSD pOsd)) + { + byte[] pb = pOsd; + if (pb != null) vCount += pb.Length / 6; + } + } + } + if (vCount > maxBodyVertCount && vCount > 800) + { + maxBodyVertCount = vCount; + primaryBodySkin = skinMap; + } + } + } + } + } + } + + if (primaryBodySkin != null && + primaryBodySkin.TryGetValue("joint_names", out OSD pbJnOsd) && pbJnOsd is OSDArray pbJnArr && + primaryBodySkin.TryGetValue("inverse_bind_matrix", out OSD pbIbmOsd) && pbIbmOsd is OSDArray) + { + int pelvisIdx = -1; + for (int j = 0; j < pbJnArr.Count; j++) + { + if (string.Equals(pbJnArr[j].AsString(), "mPelvis", StringComparison.OrdinalIgnoreCase)) + { + pelvisIdx = j; + break; + } + } + if (pelvisIdx >= 0 && TryGetMatrix16(pbIbmOsd, pelvisIdx, out float[] pelvisIbm)) + { + Vector3 bodyBindPelvis = new(-pelvisIbm[12], -pelvisIbm[13], -pelvisIbm[14]); + Vector3 worldPelvis = _cachedBoneWorldPos["mPelvis"]; + + for (int j = 0; j < pbJnArr.Count; j++) + { + string jName = pbJnArr[j].AsString(); + if (_cachedBoneWorldPos.ContainsKey(jName) && TryGetMatrix16(pbIbmOsd, j, out float[] boneIbm)) + { + Vector3 bodyBindPos = new(-boneIbm[12], -boneIbm[13], -boneIbm[14]); + avatarBoneWorldPos[jName] = worldPelvis + (bodyBindPos - bodyBindPelvis); + } + } + + if (avatarBoneWorldPos.TryGetValue("mNeck", out Vector3 curNeck) && _cachedBoneWorldPos.TryGetValue("mNeck", out Vector3 defNeck)) + { + Vector3 deltaNeck = curNeck - defNeck; + avatarBoneWorldPos["mHead"] = _cachedBoneWorldPos["mHead"] + deltaNeck; + avatarBoneWorldPos["mSkull"] = _cachedBoneWorldPos["mSkull"] + deltaNeck; + avatarBoneWorldPos["mEyeLeft"] = _cachedBoneWorldPos["mEyeLeft"] + deltaNeck; + avatarBoneWorldPos["mEyeRight"] = _cachedBoneWorldPos["mEyeRight"] + deltaNeck; + } + } + } + + var attachmentsMeta = new List(); + var decodedAttachmentMeshes = new List(); + + foreach (var rootPrim in rootAttachments) + { + AttachmentPoint attachPt = rootPrim.PrimData.AttachmentPoint; + bool isHud = IsHudAttachmentPoint(attachPt); + + string invUuid = ""; + if (rootPrim.NameValues != null) + { + foreach (var nv in rootPrim.NameValues) + { + if (nv.Name == "AttachItemID" && nv.Value != null) + { + invUuid = nv.Value.ToString(); + break; + } + } + } + + string itemName = rootPrim.Properties?.Name; + if (string.IsNullOrEmpty(itemName) && UUID.TryParse(invUuid, out UUID parsedInvId)) + { + itemName = LookupInventoryItemName(client, parsedInvId); + } + if (string.IsNullOrEmpty(itemName)) + { + client.Objects.RequestObjectPropertiesFamily(client.Network.CurrentSim, rootPrim.ID); + itemName = $"Attachment ({attachPt})"; + } + + var linkset = new List { rootPrim }; + linkset.AddRange(allPrims.Where(cp => cp.ParentID == rootPrim.LocalID)); + + bool anyRigged = false; + int meshFacesAdded = 0; + + var linksetDebug = new List(); + if (!isHud) + { + Vector3 attachBonePos = GetAttachmentBoneWorldPos(attachPt, avatarBoneWorldPos); + int primIdx = 0; + foreach (var p in linkset) + { + primIdx++; + string sculptTexStr = (p.Sculpt != null && p.Sculpt.SculptTexture != UUID.Zero) ? p.Sculpt.SculptTexture.ToString() : "none"; + if (p.Sculpt == null || p.Sculpt.Type != SculptType.Mesh || p.Sculpt.SculptTexture == UUID.Zero) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} ({p.LocalID}): Non-mesh sculpt={sculptTexStr} type={p.Sculpt?.Type}"); + continue; + } + if (!_avatarMeshCache.TryGetValue(p.Sculpt.SculptTexture, out AssetMesh meshAsset) || meshAsset?.MeshData == null) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} ({p.LocalID}): Mesh {sculptTexStr} not in cache/null"); + continue; + } + + bool isRigged = false; + float[] bsm = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + float[] refIbm = null; + Vector3 refBoneWorldPos = Vector3.Zero; + float pelvisZOffset = 0f; + + string chosenBoneName = ""; + var boneBindMap = new Dictionary(); + bool hasAltIbm = false; + + if (meshAsset.MeshData.TryGetValue("skin", out OSD skinOsd) && skinOsd is OSDMap skinMap) + { + isRigged = true; + anyRigged = true; + hasAltIbm = skinMap.ContainsKey("alt_inverse_bind_matrix"); + if (skinMap.TryGetValue("bind_shape_matrix", out OSD bsmOsd) && TryGetMatrix16(bsmOsd, 0, out float[] parsedBsm)) + bsm = parsedBsm; + if (skinMap.TryGetValue("pelvis_offset", out OSD poOsd)) + pelvisZOffset = (float)poOsd.AsReal(); + + if (skinMap.TryGetValue("joint_names", out OSD jnOsd) && jnOsd is OSDArray jnArr && + skinMap.TryGetValue("inverse_bind_matrix", out OSD ibmOsd)) + { + var jointNames = new List(jnArr.Count); + for (int j = 0; j < jnArr.Count; j++) + { + string jName = jnArr[j].AsString(); + jointNames.Add(jName); + if (jName is "mPelvis" or "mTorso" or "mChest" or "mNeck" or "mHead" or "mSkull") + { + if (TryGetMatrix16(ibmOsd, j, out float[] jm)) + { + float[] altM = null; + if (skinMap.TryGetValue("alt_inverse_bind_matrix", out OSD altOsd)) + TryGetMatrix16(altOsd, j, out altM); + boneBindMap[jName] = [jm[12], jm[13], jm[14], altM != null ? altM[12] : 0f, altM != null ? altM[13] : 0f, altM != null ? altM[14] : 0f]; + } + } + } + + string[] preferredRefBones = GetPreferredBonesForAttachment(itemName, attachPt); + int chosenIdx = -1; + foreach (string pref in preferredRefBones) + { + int idx = jointNames.FindIndex(n => string.Equals(n, pref, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0 && avatarBoneWorldPos.ContainsKey(jointNames[idx])) + { + chosenIdx = idx; + break; + } + } + if (chosenIdx < 0) + { + for (int j = 0; j < jointNames.Count; j++) + { + if (_cachedTrueBones.Contains(jointNames[j]) && avatarBoneWorldPos.ContainsKey(jointNames[j])) + { + chosenIdx = j; + break; + } + } + } + if (chosenIdx >= 0 && TryGetMatrix16(ibmOsd, chosenIdx, out float[] parsedIbm)) + { + chosenBoneName = jointNames[chosenIdx]; + refIbm = parsedIbm; + refBoneWorldPos = avatarBoneWorldPos[chosenBoneName]; + } + } + } + + // Always use high_lod because SL mesh creators frequently upload 4-vertex dummy triangles in medium_lod + OSD facesOsd = meshAsset.MeshData.TryGetValue("high_lod", out OSD hl) ? hl : null; + if (facesOsd is not OSDArray subMeshes) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} ({p.LocalID}): No high_lod submeshes"); + continue; + } + + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} ({p.LocalID}): sculpt={sculptTexStr} rigged={isRigged} subMeshes={subMeshes.Count}"); + + Vector3 primPos = p == rootPrim ? rootPrim.Position : (rootPrim.Position + (p.Position * rootPrim.Rotation)); + Quaternion primRot = p == rootPrim ? rootPrim.Rotation : (rootPrim.Rotation * p.Rotation); + Vector3 primScale = p.Scale; + + for (int faceNr = 0; faceNr < subMeshes.Count; faceNr++) + { + if (subMeshes[faceNr] is not OSDMap subMesh) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: not OSDMap"); + continue; + } + if (subMesh.TryGetValue("NoGeometry", out OSD noGeom) && noGeom.AsBoolean()) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: NoGeometry"); + continue; + } + if (!subMesh.TryGetValue("Position", out OSD posOsd) || !subMesh.TryGetValue("TriangleList", out OSD triOsd)) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: Missing pos/tri OSD"); + continue; + } + + var teFace = p.Textures?.GetFace((uint)faceNr) ?? p.Textures?.DefaultTexture; + float alpha = teFace != null ? teFace.RGBA.A : 1.0f; + string rawFaceTex = (teFace != null && teFace.TextureID != UUID.Zero) ? teFace.TextureID.ToString() : ""; + string faceTex = rawFaceTex; + + if (alpha < 0.02f) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: SKIPPED Alpha low ({alpha:F2}) rawTex={rawFaceTex}"); + continue; + } + + if (invisibleTextures.Contains(faceTex)) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: SKIPPED In invisibleTextures ({faceTex})"); + continue; + } + + string faceBake = ""; + if (bomMap.TryGetValue(faceTex, out var bomTarget)) + { + faceBake = bomTarget.bake; + faceTex = bomTarget.tex; + if (string.IsNullOrEmpty(faceTex) || invisibleTextures.Contains(faceTex)) + { + // Fallback to primary body part bake so we NEVER discard body/head/ear geometry + if (faceBake is "aux1" or "aux2" or "aux3" or "head") + { + faceTex = headBake; + faceBake = "head"; + } + else if (faceBake is "leftarm" or "upper") + { + faceTex = upperBake; + faceBake = "upper"; + } + else if (faceBake is "leftleg" or "lower") + { + faceTex = lowerBake; + faceBake = "lower"; + } + } + } + + if (string.IsNullOrEmpty(faceTex) || invisibleTextures.Contains(faceTex)) + { + if (invisibleTextures.Contains(rawFaceTex)) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: SKIPPED Explicit invisible raw texture ({rawFaceTex})"); + continue; + } + faceTex = headBake; + } + + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: ACCEPTED alpha={alpha:F2} rawTex={rawFaceTex} finalTex={faceTex} bake={faceBake}"); + + string hexColor = "#ffffff"; + if (teFace != null) + { + byte rB = (byte)Math.Clamp((int)(teFace.RGBA.R * 255), 0, 255); + byte gB = (byte)Math.Clamp((int)(teFace.RGBA.G * 255), 0, 255); + byte bB = (byte)Math.Clamp((int)(teFace.RGBA.B * 255), 0, 255); + hexColor = $"#{rB:X2}{gB:X2}{bB:X2}"; + } + + Vector3 posMin = new(-0.5f, -0.5f, -0.5f); + Vector3 posMax = new(0.5f, 0.5f, 0.5f); + if (subMesh.TryGetValue("PositionDomain", out OSD pdOsd) && pdOsd is OSDMap pdMap) + { + posMin = pdMap["Min"]; + posMax = pdMap["Max"]; + } + + Vector2 uvMin = Vector2.Zero, uvMax = Vector2.Zero; + byte[] uvBytes = null; + if (subMesh.TryGetValue("TexCoord0", out OSD tcOsd) && subMesh.TryGetValue("TexCoord0Domain", out OSD tcdOsd) && tcdOsd is OSDMap tcdMap) + { + uvBytes = tcOsd; + uvMin = tcdMap["Min"]; + uvMax = tcdMap["Max"]; + } + + byte[] posBytes = posOsd; + byte[] norBytes = subMesh.TryGetValue("Normal", out OSD nOsd) ? (byte[])nOsd : null; + byte[] triBytes = triOsd; + + int vCount = posBytes.Length / 6; + if (vCount == 0 || triBytes.Length < 6) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: vCount 0 or tri < 6"); + continue; + } + + float[] outPos = new float[vCount * 3]; + float[] outNorm = new float[vCount * 3]; + float[] outUv = new float[vCount * 2]; + float minWX = float.MaxValue, maxWX = float.MinValue; + float minWY = float.MaxValue, maxWY = float.MinValue; + float minWZ = float.MaxValue, maxWZ = float.MinValue; + float minBZ = float.MaxValue, maxBZ = float.MinValue; + + for (int vi = 0; vi < vCount; vi++) + { + int bOff = vi * 6; + float vx = Utils.UInt16ToFloat(Utils.BytesToUInt16(posBytes, bOff), posMin.X, posMax.X); + float vy = Utils.UInt16ToFloat(Utils.BytesToUInt16(posBytes, bOff + 2), posMin.Y, posMax.Y); + float vz = Utils.UInt16ToFloat(Utils.BytesToUInt16(posBytes, bOff + 4), posMin.Z, posMax.Z); + + float nx = 0f, ny = 0f, nz = 1f; + if (norBytes != null && norBytes.Length >= bOff + 6) + { + nx = Utils.UInt16ToFloat(Utils.BytesToUInt16(norBytes, bOff), posMin.X, posMax.X); + ny = Utils.UInt16ToFloat(Utils.BytesToUInt16(norBytes, bOff + 2), posMin.Y, posMax.Y); + nz = Utils.UInt16ToFloat(Utils.BytesToUInt16(norBytes, bOff + 4), posMin.Z, posMax.Z); + } + + float wx, wy, wz, wnx, wny, wnz; + if (isRigged) + { + float bx = vx * bsm[0] + vy * bsm[4] + vz * bsm[8] + bsm[12]; + float by = vx * bsm[1] + vy * bsm[5] + vz * bsm[9] + bsm[13]; + float bz = vx * bsm[2] + vy * bsm[6] + vz * bsm[10] + bsm[14]; + if (bz < minBZ) minBZ = bz; if (bz > maxBZ) maxBZ = bz; + + wnx = nx * bsm[0] + ny * bsm[4] + nz * bsm[8]; + wny = nx * bsm[1] + ny * bsm[5] + nz * bsm[9]; + wnz = nx * bsm[2] + ny * bsm[6] + nz * bsm[10]; + + if (refIbm != null) + { + wx = (bx * refIbm[0] + by * refIbm[4] + bz * refIbm[8] + refIbm[12]) + refBoneWorldPos.X; + wy = (bx * refIbm[1] + by * refIbm[5] + bz * refIbm[9] + refIbm[13]) + refBoneWorldPos.Y; + wz = (bx * refIbm[2] + by * refIbm[6] + bz * refIbm[10] + refIbm[14]) + refBoneWorldPos.Z + pelvisZOffset; + + float rnx = wnx * refIbm[0] + wny * refIbm[4] + wnz * refIbm[8]; + float rny = wnx * refIbm[1] + wny * refIbm[5] + wnz * refIbm[9]; + float rnz = wnx * refIbm[2] + wny * refIbm[6] + wnz * refIbm[10]; + wnx = rnx; wny = rny; wnz = rnz; + } + else + { + wx = bx; + wy = by; + wz = bz + 1.067f + pelvisZOffset; + } + } + else + { + Vector3 scaled = new(vx * primScale.X, vy * primScale.Y, vz * primScale.Z); + Vector3 rotated = scaled * primRot; + Vector3 finalP = attachBonePos + primPos + rotated; + wx = finalP.X; wy = finalP.Y; wz = finalP.Z; + + Vector3 rotN = new Vector3(nx, ny, nz) * primRot; + wnx = rotN.X; wny = rotN.Y; wnz = rotN.Z; + } + + float nLen = (float)Math.Sqrt(wnx * wnx + wny * wny + wnz * wnz); + if (nLen > 1e-4f) { wnx /= nLen; wny /= nLen; wnz /= nLen; } + + if (wx < minWX) minWX = wx; if (wx > maxWX) maxWX = wx; + if (wy < minWY) minWY = wy; if (wy > maxWY) maxWY = wy; + if (wz < minWZ) minWZ = wz; if (wz > maxWZ) maxWZ = wz; + + outPos[vi * 3] = wx; + outPos[vi * 3 + 1] = wy; + outPos[vi * 3 + 2] = wz; + outNorm[vi * 3] = wnx; + outNorm[vi * 3 + 1] = wny; + outNorm[vi * 3 + 2] = wnz; + + int uvOff = vi * 4; + if (uvBytes != null && uvBytes.Length >= uvOff + 4) + { + float u = Utils.UInt16ToFloat(Utils.BytesToUInt16(uvBytes, uvOff), uvMin.X, uvMax.X); + float v = Utils.UInt16ToFloat(Utils.BytesToUInt16(uvBytes, uvOff + 2), uvMin.Y, uvMax.Y); + if (teFace != null) + { + u = (u - 0.5f) * teFace.RepeatU + teFace.OffsetU + 0.5f; + v = (v - 0.5f) * teFace.RepeatV + teFace.OffsetV + 0.5f; + } + outUv[vi * 2] = u; + outUv[vi * 2 + 1] = v; + } + } + + // Skip microscopic hidden root/script prims (< 8mm or <= 36 vertices with < 3.5cm box) + float maxExtent = Math.Max(maxWX - minWX, Math.Max(maxWY - minWY, maxWZ - minWZ)); + if (maxExtent < 0.008f || (vCount <= 36 && (maxExtent < 0.035f || (linkset.Count > 1 && vCount <= 8)))) + { + if (itemName.Contains("CEYLON") || itemName.Contains("HEAD")) + linksetDebug.Add($"Prim {primIdx} F{faceNr}: maxExtent small ({maxExtent:F3}, vCount={vCount})"); + continue; + } + + decodedAttachmentMeshes.Add(new + { + name = itemName, + attach_point = attachPt.ToString(), + is_rigged = isRigged, + chosen_bone = chosenBoneName, + has_alt_ibm = hasAltIbm, + bz_min = Math.Round(minBZ, 3), + bz_max = Math.Round(maxBZ, 3), + bone_bind = boneBindMap, + tex = faceTex, + bake = faceBake, + color = hexColor, + alpha = Math.Round(alpha, 2), + v_count = vCount, + b64_pos = FloatsToBase64(outPos), + b64_norm = FloatsToBase64(outNorm), + b64_uv = FloatsToBase64(outUv), + b64_idx = Convert.ToBase64String(triBytes) + }); + meshFacesAdded++; + } + } + } + + attachmentsMeta.Add(new + { + uuid = rootPrim.ID.ToString(), + inv_uuid = invUuid, + name = itemName, + point = attachPt.ToString(), + is_hud = isHud, + is_rigged = anyRigged, + prim_count = linkset.Count, + mesh_faces = meshFacesAdded, + debug = linksetDebug + }); + } + + var response = new + { + avatar_uuid = botID.ToString(), + avatar_name = client.Self.Name, + bakes = new + { + head = headBake, + upper = upperBake, + lower = lowerBake, + eyes = eyesBake, + skirt = skirtBake, + hair = hairBake, + leftarm = leftArmBake, + leftleg = leftLegBake, + aux1 = aux1Bake, + aux2 = aux2Bake, + aux3 = aux3Bake + }, + wearables = wearablesList, + attachments = attachmentsMeta, + base_meshes = baseMeshes, + meshes = decodedAttachmentMeshes + }; + + return BasicReply(JsonSerializer.Serialize(response, JsonOptions.UnsafeRelaxed)); + } + + [About("Downloads an SL texture or server-baked avatar texture and decodes it from JPEG2000 to base64 PNG")] + [ArgHints("textureUuid", "UUID of the texture", "UUID")] + [ArgHints("bakeName", "Optional server bake slot name (head, upper, lower, eyes, skirt, hair, or none)", "Text", "none")] + [CmdTypeGet()] + public object GetBotTexturePng(string textureUuid, string bakeName) + { + if (!UUID.TryParse(textureUuid, out UUID texId) || texId == UUID.Zero) + return Failure("Invalid texture UUID"); + + string cleanBake = (bakeName ?? "").Trim().ToLowerInvariant(); + if (cleanBake == "none" || cleanBake == "null" || cleanBake == "-") cleanBake = ""; + + string cacheKey = $"{texId}_{cleanBake}_v2"; + if (_pngTextureCache.TryGetValue(cacheKey, out byte[] cachedPng)) + return BasicReply(Convert.ToBase64String(cachedPng)); + + var client = GetClient(); + byte[] j2kBytes = null; + + if (client.Assets.Cache.HasAsset(texId)) + j2kBytes = client.Assets.Cache.GetCachedAssetBytes(texId); + + if ((j2kBytes == null || j2kBytes.Length < 16) && !string.IsNullOrEmpty(cleanBake)) + { + using var mreBake = new ManualResetEventSlim(false); + client.Assets.RequestServerBakedImage(client.Self.AgentID, texId, cleanBake, (state, asset) => + { + if (state == TextureRequestState.Finished && asset?.AssetData != null && asset.AssetData.Length > 16) + j2kBytes = asset.AssetData; + if (state is TextureRequestState.Finished or TextureRequestState.NotFound or TextureRequestState.Timeout or TextureRequestState.Aborted) + mreBake.Set(); + }); + mreBake.Wait(6000); + } + + if (j2kBytes == null || j2kBytes.Length < 16) + { + using var mreNorm = new ManualResetEventSlim(false); + client.Assets.RequestImage(texId, ImageType.Normal, (state, asset) => + { + if (state == TextureRequestState.Finished && asset?.AssetData != null && asset.AssetData.Length > 16) + j2kBytes = asset.AssetData; + if (state is TextureRequestState.Finished or TextureRequestState.NotFound or TextureRequestState.Timeout or TextureRequestState.Aborted) + mreNorm.Set(); + }); + mreNorm.Wait(6500); + } + + if (j2kBytes == null || j2kBytes.Length < 16) + return Failure("Texture download timed out or not found"); + + try + { + byte[] pngBytes = DecodeJ2kToPng(j2kBytes, cleanBake); + if (pngBytes == null || pngBytes.Length == 0) + return Failure("Failed to decode J2K image"); + + if (_pngTextureCache.Count > 160) _pngTextureCache.Clear(); + _pngTextureCache[cacheKey] = pngBytes; + return BasicReply(Convert.ToBase64String(pngBytes)); + } + catch (Exception ex) + { + return Failure("J2K decode error: " + ex.Message); + } + } + + private static byte[] DecodeJ2kToPng(byte[] j2kBytes, string cleanBake = "") + { + var pImg = J2kImage.FromBytes(j2kBytes); + var mImg = new ManagedImage(pImg); + int srcW = mImg.Width, srcH = mImg.Height; + if (srcW <= 0 || srcH <= 0) return null; + + int step = 1; + while (srcW / step > 512 || srcH / step > 512) step *= 2; + int dstW = Math.Max(1, srcW / step); + int dstH = Math.Max(1, srcH / step); + + bool hasColor = (mImg.Channels & ManagedImage.ImageChannels.Color) != 0 && mImg.Red != null && mImg.Green != null && mImg.Blue != null; + bool hasGray = (mImg.Channels & ManagedImage.ImageChannels.Gray) != 0 && mImg.Red != null; + bool hasAlpha = (mImg.Channels & ManagedImage.ImageChannels.Alpha) != 0 && mImg.Alpha != null; + bool isOpaqueBake = cleanBake is "head" or "upper" or "lower" or "eyes" or "leftarm" or "leftleg" or "aux1" or "aux2" or "aux3"; + + byte[] scanlines = new byte[dstH * (1 + dstW * 4)]; + int pos = 0; + for (int y = 0; y < dstH; y++) + { + scanlines[pos++] = 1; // PNG Filter 1 (Sub) for much better compression + int sy = Math.Min(srcH - 1, y * step); + int rowOffset = sy * srcW; + byte prevR = 0, prevG = 0, prevB = 0, prevA = 0; + for (int x = 0; x < dstW; x++) + { + int sx = Math.Min(srcW - 1, x * step); + int idx = rowOffset + sx; + byte r, g, b, a; + if (hasColor) + { + r = mImg.Red[idx]; + g = mImg.Green[idx]; + b = mImg.Blue[idx]; + } + else if (hasGray) + { + r = g = b = mImg.Red[idx]; + } + else + { + r = g = b = 255; + } + a = hasAlpha ? mImg.Alpha[idx] : (byte)255; + if (isOpaqueBake && a > 0) + a = 255; + + scanlines[pos++] = unchecked((byte)(r - prevR)); + scanlines[pos++] = unchecked((byte)(g - prevG)); + scanlines[pos++] = unchecked((byte)(b - prevB)); + scanlines[pos++] = unchecked((byte)(a - prevA)); + prevR = r; prevG = g; prevB = b; prevA = a; + } + } + return EncodeRgbaToPng(dstW, dstH, scanlines); + } + + private static byte[] EncodeRgbaToPng(int width, int height, byte[] rgbaScanlines) + { + using var ms = new MemoryStream(); + ms.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }, 0, 8); + + void WriteChunk(string type, byte[] data) + { + byte[] lenBytes = BitConverter.GetBytes(System.Net.IPAddress.HostToNetworkOrder(data.Length)); + ms.Write(lenBytes, 0, 4); + byte[] typeBytes = System.Text.Encoding.ASCII.GetBytes(type); + ms.Write(typeBytes, 0, 4); + if (data.Length > 0) ms.Write(data, 0, data.Length); + + uint crc = 0xFFFFFFFF; + for (int i = 0; i < 4; i++) + { + crc ^= typeBytes[i]; + for (int k = 0; k < 8; k++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : (crc >> 1); + } + for (int i = 0; i < data.Length; i++) + { + crc ^= data[i]; + for (int k = 0; k < 8; k++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : (crc >> 1); + } + crc ^= 0xFFFFFFFF; + byte[] crcBytes = BitConverter.GetBytes(System.Net.IPAddress.HostToNetworkOrder(unchecked((int)crc))); + ms.Write(crcBytes, 0, 4); + } + + byte[] ihdr = new byte[13]; + Buffer.BlockCopy(BitConverter.GetBytes(System.Net.IPAddress.HostToNetworkOrder(width)), 0, ihdr, 0, 4); + Buffer.BlockCopy(BitConverter.GetBytes(System.Net.IPAddress.HostToNetworkOrder(height)), 0, ihdr, 4, 4); + ihdr[8] = 8; + ihdr[9] = 6; + ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; + WriteChunk("IHDR", ihdr); + + byte[] idatData; + using (var zms = new MemoryStream()) + { + using (var zs = new ZLibStream(zms, CompressionLevel.Fastest, true)) + { + zs.Write(rgbaScanlines, 0, rgbaScanlines.Length); + } + idatData = zms.ToArray(); + } + WriteChunk("IDAT", idatData); + WriteChunk("IEND", Array.Empty()); + return ms.ToArray(); + } } public class SculptysInfo diff --git a/SecondBotEvents/Commands/Inventory.cs b/SecondBotEvents/Commands/Inventory.cs index 80d6baf..1a2bf28 100644 --- a/SecondBotEvents/Commands/Inventory.cs +++ b/SecondBotEvents/Commands/Inventory.cs @@ -1301,6 +1301,184 @@ namespace SecondBotEvents.Commands } return UUID.Zero; } + + [About("Gets the list of pending approval inventory items")] + [CmdTypeGet()] + public object GetPendingInventory() + { + var interaction = master.InteractionService; + if (interaction == null) + { + return Failure("Interaction service not found"); + } + UUID pendingFolderUUID = interaction.GetOrCreatePendingFolder(); + + List contents = GetClient().Inventory.FolderContents( + pendingFolderUUID, + GetClient().Self.AgentID, + true, + true, + InventorySortOrder.ByName, + TimeSpan.FromSeconds(15) + ); + + var items = new List>(); + if (contents != null) + { + foreach (var item in contents) + { + if (item is InventoryItem itm) + { + var entry = new Dictionary + { + { "uuid", itm.UUID.ToString() }, + { "name", itm.Name }, + { "type", itm.AssetType.ToString() }, + { "description", itm.Description } + }; + items.Add(entry); + } + } + } + return BasicReply(JsonSerializer.Serialize(items, JsonOptions.UnsafeRelaxed)); + } + + [About("Resolves a pending inventory item by either accepting it or declining it")] + [ArgHints("itemUuid", "The UUID of the item to resolve", "UUID")] + [ArgHints("action", "Accept or Decline", "Text")] + [CmdTypeDo()] + public object ResolvePendingItem(string itemUuid, string action) + { + if (!UUID.TryParse(itemUuid, out UUID targetItemUUID)) + { + return Failure("Invalid item UUID"); + } + + var store = GetClient().Inventory.GetStore; + if (!store.Items.ContainsKey(targetItemUUID)) + { + return Failure("Item not found in inventory cache"); + } + + InventoryItem item = store.Items[targetItemUUID].Data as InventoryItem; + if (item == null) + { + return Failure("Target is not an inventory item"); + } + + if (action.Equals("Accept", StringComparison.OrdinalIgnoreCase)) + { + UUID destFolderUUID = GetClient().Inventory.FindFolderForType(item.AssetType); + GetClient().Inventory.MoveItem(targetItemUUID, destFolderUUID); + return BasicReply("Accepted and moved item to default folder"); + } + else if (action.Equals("Decline", StringComparison.OrdinalIgnoreCase)) + { + GetClient().Inventory.RemoveItem(targetItemUUID); + return BasicReply("Declined and deleted item"); + } + else + { + return Failure("Invalid action. Must be 'Accept' or 'Decline'"); + } + } + + [About("Touches/clicks a worn attachment by its inventory item UUID")] + [ArgHints("itemUuid", "The inventory UUID of the worn attachment to touch", "UUID")] + [CmdTypeDo()] + public object TouchAttachment(string itemUuid) + { + if (!UUID.TryParse(itemUuid, out UUID targetUUID)) + { + return Failure("Invalid inventory item UUID"); + } + + var client = GetClient(); + UUID botID = client.Self.AgentID; + + // Find only the BOT's own avatar + Avatar botAvatar = null; + foreach (var av in client.Network.CurrentSim.ObjectsAvatars.Values) + { + if (av.ID == botID) { botAvatar = av; break; } + } + if (botAvatar == null) + { + return Failure("Bot avatar not found in sim"); + } + + foreach (var attachment in botAvatar.GetChildren(client)) + { + bool matched = false; + + // Primary: check NameValues for AttachItemID (set by SL server for attached objects) + if (attachment.NameValues != null) + { + foreach (var nv in attachment.NameValues) + { + if (nv.Name == "AttachItemID" && nv.Value != null) + { + if (UUID.TryParse(nv.Value.ToString(), out UUID attachInvUUID) && attachInvUUID == targetUUID) + { + matched = true; + break; + } + } + } + } + + // Fallback: direct object UUID match (rare but possible) + if (!matched && attachment.ID == targetUUID) matched = true; + + if (matched) + { + client.Objects.ClickObject(client.Network.CurrentSim, attachment.LocalID); + return BasicReply("Touched attachment localID=" + attachment.LocalID + " objectUUID=" + attachment.ID); + } + } + + return Failure("Attachment not found - use GetAttachmentDebug to inspect available attachments"); + } + + [About("Returns debug info for all attachments on the bot avatar - use this to diagnose TouchAttachment issues")] + [CmdTypeGet()] + public object GetAttachmentDebug() + { + var client = GetClient(); + UUID botID = client.Self.AgentID; + + Avatar botAvatar = null; + foreach (var av in client.Network.CurrentSim.ObjectsAvatars.Values) + { + if (av.ID == botID) { botAvatar = av; break; } + } + + if (botAvatar == null) return Failure("Bot avatar not found in sim"); + + var results = new List>(); + foreach (var attachment in botAvatar.GetChildren(client)) + { + var entry = new Dictionary + { + { "objectUUID", attachment.ID.ToString() }, + { "localID", attachment.LocalID.ToString() }, + { "parentID", attachment.ParentID.ToString() } + }; + + if (attachment.Properties != null) + entry["name"] = attachment.Properties.Name ?? ""; + + if (attachment.NameValues != null) + { + foreach (var nv in attachment.NameValues) + entry["nv_" + nv.Name] = nv.Value?.ToString() ?? ""; + } + + results.Add(entry); + } + + return BasicReply(JsonSerializer.Serialize(results, JsonOptions.UnsafeRelaxed)); + } } public class InventoryNodeEntry diff --git a/SecondBotEvents/Commands/Self.cs b/SecondBotEvents/Commands/Self.cs index 791e7f7..55525cd 100644 --- a/SecondBotEvents/Commands/Self.cs +++ b/SecondBotEvents/Commands/Self.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System; +using System.Text.Json; using OpenMetaverse; using OpenMetaverse.ImportExport.Collada14; using SecondBotEvents.Services; @@ -273,6 +274,41 @@ namespace SecondBotEvents.Commands return BasicReply("Removed perm: " + flag + " Sticky: " + stickyflag.ToString(), [avatar, flag, state, sticky]); } + [About("Clears all active RLV restrictions on the bot")] + [ReturnHints("ok")] + [CmdTypeDo()] + public object RLVClearAll() + { + if (master.RLV == null) + { + return Failure("RLV service is not running"); + } + lock (master.RLV.Rules) + { + master.RLV.Rules.Clear(); + } + return BasicReply("ok"); + } + + [About("Clears a specific active RLV restriction behavior on the bot")] + [ArgHints("behaviour", "The restriction behavior to clear", "Text", "unsit")] + [ReturnHints("ok")] + [CmdTypeDo()] + public object RLVClearBehaviour(string behaviour) + { + if (master.RLV == null) + { + return Failure("RLV service is not running"); + } + lock (master.RLV.Rules) + { + master.RLV.Rules.RemoveAll(r => r.Behaviour.Equals(behaviour, StringComparison.CurrentCultureIgnoreCase) || + (behaviour.Equals("stand", StringComparison.CurrentCultureIgnoreCase) && r.Behaviour.Equals("unsit", StringComparison.CurrentCultureIgnoreCase)) || + (behaviour.Equals("unsit", StringComparison.CurrentCultureIgnoreCase) && r.Behaviour.Equals("stand", StringComparison.CurrentCultureIgnoreCase))); + } + return BasicReply("ok", [behaviour]); + } + } diff --git a/SecondBotEvents/Services/BotClientService.cs b/SecondBotEvents/Services/BotClientService.cs index 8c4e5e9..e4da68c 100644 --- a/SecondBotEvents/Services/BotClientService.cs +++ b/SecondBotEvents/Services/BotClientService.cs @@ -166,8 +166,12 @@ private BotRejoinConfig rejoinCfg; protected void BotSimDisconnected(object o, SimDisconnectedEventArgs e) { - goodticks = 0; LogFormater.Info("Client service ~ Disconnected from sim: " + e.Simulator.Name); + if (client?.Network?.CurrentSim != null && e.Simulator != client.Network.CurrentSim && client.Network.CurrentSim.Connected) + { + return; + } + goodticks = 0; if (!getLogoutExpected()) { HandleUnexpectedDisconnect("Unexpected sim disconnect"); @@ -290,6 +294,8 @@ private BotRejoinConfig rejoinCfg; { client = null; client = new GridClient(); + client.Settings.STORE_LAND_PATCHES = true; + client.Settings.MULTIPLE_SIMS = true; client.Network.SimConnected += BotSimConnected; client.Network.LoggedOut += BotLoggedOut; client.Network.Disconnected += BotDisconnected; diff --git a/SecondBotEvents/Services/CommandsService.cs b/SecondBotEvents/Services/CommandsService.cs index edfebd2..c940570 100644 --- a/SecondBotEvents/Services/CommandsService.cs +++ b/SecondBotEvents/Services/CommandsService.cs @@ -145,13 +145,13 @@ namespace SecondBotEvents.Services if(myConfig.GetObjectMasterOptout() == true) { string ownername = master.DataStoreService.GetAvatarName(e.IM.FromAgentID); - requireSigning = !myConfig.GetMastersCSV().Contains(ownername); - if ((requireSigning == false) && (myConfig.GetCheckDotNames() == true)) + requireSigning = !IsMaster(ownername); + if (requireSigning && myConfig.GetCheckDotNames()) { string[] bits = e.IM.FromAgentName.ToLower().Split('.'); if (bits.Length == 2) { - requireSigning = myConfig.GetMastersCSV().Contains(bits[0].FirstCharToUpper() + " " + bits[1].FirstCharToUpper()); + requireSigning = !IsMaster(bits[0].FirstCharToUpper() + " " + bits[1].FirstCharToUpper()); } } } @@ -170,13 +170,13 @@ namespace SecondBotEvents.Services } master.DataStoreService.AddAvatar(e.IM.FromAgentID, e.IM.FromAgentName); requireSigning = false; - acceptMessage = myConfig.GetMastersCSV().Contains(e.IM.FromAgentName); + acceptMessage = IsMaster(e.IM.FromAgentName); if ((acceptMessage == false) && (myConfig.GetCheckDotNames() == true)) { string[] bits = e.IM.FromAgentName.ToLower().Split('.'); if (bits.Length == 2) { - acceptMessage = myConfig.GetMastersCSV().Contains(bits[0].FirstCharToUpper()+" " + bits[1].FirstCharToUpper()); + acceptMessage = IsMaster(bits[0].FirstCharToUpper()+" " + bits[1].FirstCharToUpper()); } } break; @@ -193,20 +193,26 @@ namespace SecondBotEvents.Services CommandInterfaceCaller(e.IM.Message, requireSigning); } - public bool AvatarUUIDIsMaster(UUID avatar) + public bool IsMaster(string name) { - foreach(string name in myConfig.GetMastersCSV()) + if (string.IsNullOrEmpty(name)) return false; + foreach (string masterName in myConfig.GetMastersCSV()) { - string find = master.DataStoreService.GetAvatarUUID(name); - if (find != "lookup") + if (SecondbotHelpers.IsNameMatch(masterName, name)) { - if(avatar.ToString() == find) - { - return true; - } + return true; } } + return false; + } + public bool AvatarUUIDIsMaster(UUID avatar) + { + string name = master.DataStoreService.GetAvatarName(avatar); + if (name != "lookup") + { + return IsMaster(name); + } return false; } diff --git a/SecondBotEvents/Services/DataStoreService.cs b/SecondBotEvents/Services/DataStoreService.cs index 8cf8b26..ca8277e 100644 --- a/SecondBotEvents/Services/DataStoreService.cs +++ b/SecondBotEvents/Services/DataStoreService.cs @@ -221,6 +221,10 @@ namespace SecondBotEvents.Services { continue; } + if (chatWindowsOwner.ContainsValue(entry.Value) == true) + { + continue; + } clearLinks.Add(entry.Value, entry.Key); } lock (avatarLastUsed) lock (avatarsName2Key) lock(avatarsKey2Displayname) @@ -406,25 +410,167 @@ namespace SecondBotEvents.Services public string GetGroupMemberRank(UUID group, UUID avatar) { + bool needFetch = false; lock (groupMembers) { if (groupMembers.ContainsKey(group) == false || groupMembers[group].ContainsKey(avatar) == false) { - LogFormater.Info($"[DEBUG] GetGroupMemberRank: Returning Guest. GroupKnown: {groupMembers.ContainsKey(group)}, AvatarKnown: {(groupMembers.ContainsKey(group) ? groupMembers[group].ContainsKey(avatar).ToString() : "N/A")} for Group: {group}, Avatar: {avatar}"); + needFetch = true; + } + } + + if (needFetch) + { + AutoResetEvent fetchEvent = new(false); + void callback(object sender, GroupMembersReplyEventArgs e) + { + if (e.GroupID == group) + { + fetchEvent.Set(); + } + } + + try + { + GetClient().Groups.GroupMembersReply += callback; + GetClient().Groups.RequestGroupMembers(group); + fetchEvent.WaitOne(3000, false); + } + catch (Exception ex) + { + LogFormater.Warn($"GetGroupMemberRank exception during RequestGroupMembers: {ex.Message}"); + } + finally + { + GetClient().Groups.GroupMembersReply -= callback; + } + } + + lock (groupMembers) + { + if (groupMembers.ContainsKey(group) == false || groupMembers[group].ContainsKey(avatar) == false) + { + LogFormater.Info($"[DEBUG] GetGroupMemberRank: Returning Guest after fetch attempt. GroupKnown: {groupMembers.ContainsKey(group)}, AvatarKnown: {(groupMembers.ContainsKey(group) ? groupMembers[group].ContainsKey(avatar).ToString() : "N/A")} for Group: {group}, Avatar: {avatar}"); return "Guest"; } + GroupMember m = groupMembers[group][avatar]; if (m.IsOwner == true) return "Owner"; - if (m.Title != null && (m.Title.Contains("Officer") || m.Title.Contains("Officer"))) return "Officer"; - - // Also check roles if title didn't match - if (groupRoles.ContainsKey(group)) + + // Check for Owner powers (DeleteRole, ChangeActions, ChangeIdentity) + GroupPowers ownerPowers = GroupPowers.DeleteRole | GroupPowers.ChangeActions | GroupPowers.ChangeIdentity; + if ((m.Powers & ownerPowers) == ownerPowers) return "Owner"; + + if (string.IsNullOrEmpty(m.Title) == false) { - // This is a bit complex as we need member-to-role mapping which we don't have cached yet - // For now, Owner and Title check is a good start. + if (m.Title.Contains("Owner", StringComparison.OrdinalIgnoreCase) || m.Title.Contains("Founder", StringComparison.OrdinalIgnoreCase)) + { + return "Owner"; + } } - return "Member"; } + + // Fetch group roles and role-member mapping to check assigned group roles + try + { + // Fetch group roles + Dictionary rolesDict = new(); + AutoResetEvent rolesFetchEvent = new(false); + void rolesCallback(object sender, GroupRolesDataReplyEventArgs e) + { + if (e.GroupID == group) + { + rolesDict = e.Roles; + rolesFetchEvent.Set(); + } + } + + GetClient().Groups.GroupRoleDataReply += rolesCallback; + GetClient().Groups.RequestGroupRoles(group); + rolesFetchEvent.WaitOne(2000, false); + GetClient().Groups.GroupRoleDataReply -= rolesCallback; + + // Fetch group role-members map + List> roleMembersList = new(); + AutoResetEvent membersFetchEvent = new(false); + void roleMembersCallback(object sender, GroupRolesMembersReplyEventArgs e) + { + if (e.GroupID == group) + { + roleMembersList = e.RolesMembers; + membersFetchEvent.Set(); + } + } + + GetClient().Groups.GroupRoleMembersReply += roleMembersCallback; + GetClient().Groups.RequestGroupRolesMembers(group); + membersFetchEvent.WaitOne(2000, false); + GetClient().Groups.GroupRoleMembersReply -= roleMembersCallback; + + // Check if avatar belongs to any Owner or Officer role + HashSet ownerRoleIDs = new(); + HashSet officerRoleIDs = new(); + + foreach (KeyValuePair r in rolesDict) + { + string rName = r.Value.Name ?? ""; + string rTitle = r.Value.Title ?? ""; + GroupPowers rPowers = r.Value.Powers; + + GroupPowers ownerPowers = GroupPowers.DeleteRole | GroupPowers.ChangeActions | GroupPowers.ChangeIdentity; + if (rName.Equals("Owners", StringComparison.OrdinalIgnoreCase) || + rName.Equals("Owner", StringComparison.OrdinalIgnoreCase) || + rTitle.Contains("Owner", StringComparison.OrdinalIgnoreCase) || + (rPowers & ownerPowers) == ownerPowers) + { + ownerRoleIDs.Add(r.Value.ID); + } + else if (rName.Contains("Officer", StringComparison.OrdinalIgnoreCase) || rTitle.Contains("Officer", StringComparison.OrdinalIgnoreCase)) + { + officerRoleIDs.Add(r.Value.ID); + } + } + + bool isOwnerRole = false; + bool isOfficerRole = false; + + foreach (KeyValuePair pair in roleMembersList) + { + if (pair.Value == avatar) // pair.Value = MemberID, pair.Key = RoleID + { + if (ownerRoleIDs.Contains(pair.Key)) + { + isOwnerRole = true; + break; + } + if (officerRoleIDs.Contains(pair.Key)) + { + isOfficerRole = true; + } + } + } + + if (isOwnerRole) return "Owner"; + if (isOfficerRole) return "Officer"; + } + catch (Exception ex) + { + LogFormater.Warn($"GetGroupMemberRank role check exception: {ex.Message}"); + } + + lock (groupMembers) + { + if (groupMembers.ContainsKey(group) && groupMembers[group].ContainsKey(avatar)) + { + GroupMember m = groupMembers[group][avatar]; + if (string.IsNullOrEmpty(m.Title) == false && m.Title.Contains("Officer", StringComparison.OrdinalIgnoreCase)) + { + return "Officer"; + } + } + } + + return "Member"; } public string RequestLoginToken(UUID avatar) @@ -582,6 +728,40 @@ namespace SecondBotEvents.Services } } + public void RecordAvatarIm(UUID avatarId, string fromName, string message) + { + lock (chatWindows) + { + UUID sessionID = UUID.Zero; + foreach (KeyValuePair kvp in chatWindowsOwner) + { + if (kvp.Value == avatarId) + { + sessionID = kvp.Key; + break; + } + } + if (sessionID == UUID.Zero) + { + sessionID = avatarId; + OpenChatWindow(false, sessionID, avatarId); + } + RecordChat(sessionID, fromName, message); + } + } + + public void RecordGroupChat(UUID groupId, string fromName, string message) + { + lock (chatWindows) + { + if (!chatWindows.ContainsKey(groupId)) + { + OpenChatWindow(true, groupId, groupId); + } + RecordChat(groupId, fromName, message); + } + } + public void CloseChat(UUID owner) { lock (chatWindows) @@ -862,6 +1042,7 @@ namespace SecondBotEvents.Services { if (av.Key == avatarId) { + AddAvatar(av.Key, av.Value); hasReply = true; fetchEvent.Set(); } @@ -918,15 +1099,11 @@ namespace SecondBotEvents.Services public bool KnownAvatar(string name, UUID avatarId) { SyncAvatarsHashMap(); - if (avatarId == UUID.Zero) - { - return false; - } - if(KnownAvatarUUID(avatarId) == true) + if (avatarId != UUID.Zero && KnownAvatarUUID(avatarId) == true) { return true; } - if(KnownAvatarName(name) == true) + if (!string.IsNullOrEmpty(name) && KnownAvatarName(name) == true) { return true; } @@ -1829,21 +2006,93 @@ namespace SecondBotEvents.Services displayNameFinder(id); return; } - avatarsName2Key.Add(name, id); - avatarLastUsed.Add(id, SecondbotHelpers.UnixTimeNow()); - avatarsKey2Displayname.Add(id, "?"); + avatarsName2Key[name] = id; + avatarLastUsed[id] = SecondbotHelpers.UnixTimeNow(); + if (avatarsKey2Displayname.ContainsKey(id) == false) + { + avatarsKey2Displayname.Add(id, "?"); + } displayNameFinder(id); } } public string GetDisplayName(UUID id) { - requestDisplayName(id); - if (avatarsKey2Displayname.ContainsKey(id) == false) + if (avatarsKey2Displayname.ContainsKey(id) && avatarsKey2Displayname[id] != "?" && !string.IsNullOrEmpty(avatarsKey2Displayname[id])) { - return "?"; + return avatarsKey2Displayname[id]; } - return avatarsKey2Displayname[id]; + lock (avatarsKey2Displayname) + { + if (!avatarsKey2Displayname.ContainsKey(id)) + { + avatarsKey2Displayname[id] = "?"; + } + } + AutoResetEvent waitEvent = new(false); + try + { + _ = GetClient().Avatars.GetDisplayNames(new List { id }, (success, agents, badIds) => + { + if (success && agents != null && agents.Length > 0) + { + foreach (var ag in agents) + { + if (ag.ID == id) + { + lock (avatarsKey2Displayname) lock (avatarsName2Key) + { + if (!string.IsNullOrEmpty(ag.DisplayName)) + { + avatarsKey2Displayname[id] = ag.DisplayName; + } + if (!string.IsNullOrEmpty(ag.LegacyFullName)) + { + avatarsName2Key[ag.LegacyFullName] = id; + } + } + break; + } + } + } + waitEvent.Set(); + }); + waitEvent.WaitOne(3000); + } + catch { } + + // Fallback to RequestAgentProfile if GetDisplayNames didn't resolve + if (!avatarsKey2Displayname.ContainsKey(id) || avatarsKey2Displayname[id] == "?") + { + AutoResetEvent profileWait = new(false); + try + { + _ = GetClient().Avatars.RequestAgentProfile(id, (status, properties) => + { + if (properties != null && !string.IsNullOrEmpty(properties.DisplayName)) + { + lock (avatarsKey2Displayname) + { + avatarsKey2Displayname[id] = properties.DisplayName; + } + } + profileWait.Set(); + }); + profileWait.WaitOne(2000); + } + catch { } + } + + if (avatarsKey2Displayname.ContainsKey(id) && avatarsKey2Displayname[id] != "?" && !string.IsNullOrEmpty(avatarsKey2Displayname[id])) + { + return avatarsKey2Displayname[id]; + } + string avName = GetAvatarName(id); + if (!string.IsNullOrEmpty(avName) && avName != "lookup") + { + return avName; + } + return "?"; } protected void AvatarUUIDName(object o, UUIDNameReplyEventArgs e) diff --git a/SecondBotEvents/Services/DialogService.cs b/SecondBotEvents/Services/DialogService.cs index 5815997..44a6c4d 100644 --- a/SecondBotEvents/Services/DialogService.cs +++ b/SecondBotEvents/Services/DialogService.cs @@ -195,6 +195,18 @@ namespace SecondBotEvents.Services { return; } + + // Deduplication check: ignore duplicate dialog from same object with same message if already waiting + CleanUp(); + foreach (var existing in DialogWindows.Values) + { + if (existing.ObjectID == e.ObjectID && existing.Message == e.Message && existing.Channel == e.Channel) + { + LogFormater.Info($"[DialogService] Ignoring duplicate dialog from {e.ObjectName}"); + return; + } + } + int windowid = nextDialogWindow; nextDialogWindow++; if(nextDialogWindow > 999) @@ -251,13 +263,14 @@ namespace SecondBotEvents.Services return; } LogFormater.Info("Dialog service [Attached to new client]"); + GetClient().Network.LoggedOut -= BotLoggedOut; GetClient().Network.LoggedOut += BotLoggedOut; + GetClient().Network.SimConnected -= BotLoggedIn; GetClient().Network.SimConnected += BotLoggedIn; } protected void BotLoggedOut(object o, LoggedOutEventArgs e) { - GetClient().Network.SimConnected += BotLoggedIn; LogFormater.Info("Dialog service [waiting for new client]"); if (GetClient() != null) { @@ -271,18 +284,24 @@ namespace SecondBotEvents.Services { GetClient().Network.SimConnected -= BotLoggedIn; botConnected = true; - // attach events + // attach events cleanly + GetClient().Self.ScriptDialog -= DialogWindowEvent; GetClient().Self.ScriptDialog += DialogWindowEvent; + GetClient().Self.LoadURL -= LoadUrlEvent; GetClient().Self.LoadURL += LoadUrlEvent; + GetClient().Self.AlertMessage -= BotAlertMessage; GetClient().Self.AlertMessage += BotAlertMessage; } public void Attach() { botConnected = true; - // attach events + // attach events cleanly + GetClient().Self.ScriptDialog -= DialogWindowEvent; GetClient().Self.ScriptDialog += DialogWindowEvent; + GetClient().Self.LoadURL -= LoadUrlEvent; GetClient().Self.LoadURL += LoadUrlEvent; + GetClient().Self.AlertMessage -= BotAlertMessage; GetClient().Self.AlertMessage += BotAlertMessage; } diff --git a/SecondBotEvents/Services/DiscordService.cs b/SecondBotEvents/Services/DiscordService.cs index 39c80de..d415449 100644 --- a/SecondBotEvents/Services/DiscordService.cs +++ b/SecondBotEvents/Services/DiscordService.cs @@ -358,6 +358,7 @@ namespace SecondBotEvents.Services "discordapi", "GroupBan", null, [.. args], 0, "none", false, 0, "", false), message); return; } + string groupSender = "{BOT} " + (myConfig.GethideChatterName() ? GetClient().Self.Name : message.Author.Username); if (myConfig.GethideChatterName() == false) { GetClient().Self.InstantMessageGroup(groupuuid, message.Author.Username + ": " + message.CleanContent); @@ -366,6 +367,7 @@ namespace SecondBotEvents.Services { GetClient().Self.InstantMessageGroup(groupuuid, message.CleanContent); } + master.DataStoreService.RecordGroupChat(groupuuid, groupSender, message.CleanContent); _ = message.DeleteAsync(); } @@ -444,6 +446,7 @@ namespace SecondBotEvents.Services GetClient().Self.SendTeleportLure(avataruuid); return; } + string botSender = "{BOT} " + (myConfig.GethideChatterName() ? GetClient().Self.Name : message.Author.Username); if (myConfig.GethideChatterName() == false) { master.DataStoreService.GetAvatarName(avataruuid); // add avatar to lookup service just in case. @@ -453,6 +456,7 @@ namespace SecondBotEvents.Services { GetClient().Self.InstantMessage(avataruuid, message.CleanContent); } + master.DataStoreService.RecordAvatarIm(avataruuid, botSender, message.CleanContent); _ = MarkMessage((IUserMessage)message, "✅"); } @@ -819,9 +823,10 @@ namespace SecondBotEvents.Services SendMessageToChannel(groupname, GroupPrefill(group), name + ": " + message, "group"); } - protected void AvatarIMChat(UUID avatar, string name, string message) + public void AvatarIMChat(UUID avatar, string name, string message, string sender = null) { - SendMessageToChannel(name.ToLower().Replace(" ",""), "avatar=" + avatar.ToString() + "||| !close !offertp !clear", name+":"+message, "im"); + string displaySender = string.IsNullOrEmpty(sender) ? name : sender; + SendMessageToChannel(name.ToLower().Replace(" ",""), "avatar=" + avatar.ToString() + "||| !close !offertp !clear", displaySender + ": " + message, "im"); } protected void ObjectIMChat(string objectName, string message) diff --git a/SecondBotEvents/Services/HomeboundService.cs b/SecondBotEvents/Services/HomeboundService.cs index e5d8fd8..eea4b50 100644 --- a/SecondBotEvents/Services/HomeboundService.cs +++ b/SecondBotEvents/Services/HomeboundService.cs @@ -1,8 +1,9 @@ -using Discord.Rest; +using Discord.Rest; using OpenMetaverse; using SecondBotEvents.Config; using System; using System.Linq; +using System.Threading; namespace SecondBotEvents.Services { @@ -25,6 +26,7 @@ namespace SecondBotEvents.Services protected bool softDisable = false; protected bool closeToHome = false; protected bool softDisableSit = false; + protected Timer? _homeboundTimer = null; public void MarkTeleport() { @@ -334,19 +336,27 @@ namespace SecondBotEvents.Services return; } Stop(); - if(master.RLV.Enabled == true) - { - myConfig.setEnabled(false); - LogFormater.Info("Homebound Service [Disabled RLV enabled]"); - return; - } running = true; master.BotClientNoticeEvent += BotClientRestart; + _homeboundTimer?.Dispose(); + _homeboundTimer = new Timer((state) => + { + if (running && botConnected && softDisable == false) + { + try + { + Tick(); + } + catch { } + } + }, null, 5000, 5000); LogFormater.Info("Homebound Service [Starting]"); } public override void Stop() { + _homeboundTimer?.Dispose(); + _homeboundTimer = null; if(running == true) { LogFormater.Info("Homebound Service [Stopping]"); diff --git a/SecondBotEvents/Services/InteractionService.cs b/SecondBotEvents/Services/InteractionService.cs index a72cea1..45b3933 100644 --- a/SecondBotEvents/Services/InteractionService.cs +++ b/SecondBotEvents/Services/InteractionService.cs @@ -13,6 +13,27 @@ namespace SecondBotEvents.Services { protected new InteractionConfig myConfig; protected bool botConnected = false; + private readonly Dictionary _pendingOffers = []; + + public UUID GetOrCreatePendingFolder() + { + var client = GetClient(); + if (client == null || client.Inventory == null || client.Inventory.Store == null || client.Inventory.Store.RootFolder == null) + { + return UUID.Zero; + } + var store = client.Inventory.Store; + var rootContent = store.GetContents(store.RootFolder.UUID); + foreach (var baseItem in rootContent) + { + if (baseItem is InventoryFolder folder && folder.Name.Equals("#Pending Approval", StringComparison.OrdinalIgnoreCase)) + { + return folder.UUID; + } + } + return client.Inventory.CreateFolder(store.RootFolder.UUID, "#Pending Approval"); + } + public InteractionService(EventsSecondBot setMaster) : base(setMaster) { myConfig = new InteractionConfig(master.fromEnv,master.fromFolder); @@ -70,17 +91,22 @@ namespace SecondBotEvents.Services string ObjectOwnerName = master.DataStoreService.GetAvatarName(avataruuid); string whyAccepted = "rejected request"; misc.Add("ObjectOwner", ObjectOwnerName); - if (master.CommandsService.myConfig.GetMastersCSV().Contains(avatarName) == true) + foreach (string masterName in master.CommandsService.myConfig.GetMastersCSV()) { - whyAccepted = "on owners list"; - accepted = true; + if (SecondbotHelpers.IsNameMatch(masterName, avatarName)) + { + whyAccepted = "on owners list"; + accepted = true; + break; + } + if (SecondbotHelpers.IsNameMatch(masterName, ObjectOwnerName)) + { + whyAccepted = "Object Owner is on master list"; + accepted = true; + break; + } } - else if (master.CommandsService.myConfig.GetMastersCSV().Contains(ObjectOwnerName) == true) - { - whyAccepted = "Object Owner is on master list"; - accepted = true; - } - else if (ConfigAcceptLevel == "Anyone") + if (ConfigAcceptLevel == "Anyone") { whyAccepted = "Accepting from anyone"; accepted = true; @@ -95,10 +121,15 @@ namespace SecondBotEvents.Services string[] bits = avatarName.ToLower().Split('.'); if (bits.Length == 2) { - accepted = master.CommandsService.myConfig.GetMastersCSV().Contains(bits[0].FirstCharToUpper() + " " + bits[1].FirstCharToUpper()); - if (accepted == true) + string dotName = bits[0].FirstCharToUpper() + " " + bits[1].FirstCharToUpper(); + foreach (string masterName in master.CommandsService.myConfig.GetMastersCSV()) { - whyAccepted = "on owners list [dot check]"; + if (SecondbotHelpers.IsNameMatch(masterName, dotName)) + { + accepted = true; + whyAccepted = "on owners list [dot check]"; + break; + } } } if (accepted == false) @@ -106,10 +137,15 @@ namespace SecondBotEvents.Services bits = ObjectOwnerName.ToLower().Split('.'); if (bits.Length == 2) { - accepted = master.CommandsService.myConfig.GetMastersCSV().Contains(bits[0].FirstCharToUpper() + " " + bits[1].FirstCharToUpper()); - if (accepted == true) + string dotName = bits[0].FirstCharToUpper() + " " + bits[1].FirstCharToUpper(); + foreach (string masterName in master.CommandsService.myConfig.GetMastersCSV()) { - whyAccepted = "Object Owner is on master list [dot check]"; + if (SecondbotHelpers.IsNameMatch(masterName, dotName)) + { + accepted = true; + whyAccepted = "Object Owner is on master list [dot check]"; + break; + } } } } @@ -249,6 +285,26 @@ namespace SecondBotEvents.Services protected void BotInventoryAdd(object o, InventoryObjectAddedEventArgs e) { InventoryItem F = (InventoryItem)e.Obj; + UUID pendingFolder = GetOrCreatePendingFolder(); + if (F.ParentUUID == pendingFolder) + { + (string SenderName, UUID SenderID) senderDetails = ("Unknown", UUID.Zero); + bool found = false; + lock (_pendingOffers) + { + if (_pendingOffers.TryGetValue(F.UUID, out senderDetails)) + { + found = true; + _pendingOffers.Remove(F.UUID); + } + } + if (found) + { + F.Description = $"Sent by: {senderDetails.SenderName} ({senderDetails.SenderID})"; + GetClient().Inventory.RequestUpdateItem(F); + } + } + Dictionary details = new() { { "itemname", e.Obj.Name }, @@ -264,17 +320,66 @@ namespace SecondBotEvents.Services protected void BotInventoryOffer(object o, InventoryObjectOfferedEventArgs e) { + UUID ownerID = e.Offer.FromAgentID; + string ownerName = e.Offer.FromAgentName; + + if (e.FromTask) + { + var sim = GetClient().Network.CurrentSim; + var prim = sim?.ObjectsPrimitives.Values.FirstOrDefault(p => p.ID == e.Offer.FromAgentID); + if (prim != null) + { + ownerID = prim.OwnerID; + } + else + { + System.Threading.AutoResetEvent propertiesEvent = new(false); + EventHandler handler = (sender, args) => + { + if (args.Properties.ObjectID == e.Offer.FromAgentID) + { + ownerID = args.Properties.OwnerID; + propertiesEvent.Set(); + } + }; + GetClient().Objects.ObjectPropertiesFamily += handler; + GetClient().Objects.RequestObjectPropertiesFamily(GetClient().Network.CurrentSim, e.Offer.FromAgentID); + propertiesEvent.WaitOne(1000); + GetClient().Objects.ObjectPropertiesFamily -= handler; + } + ownerName = master.DataStoreService.GetAvatarName(ownerID); + } + Dictionary details = new() { { "transactionid", e.Offer.IMSessionID.ToString() }, { "itemtype", e.AssetType.ToString() }, { "message", e.Offer.Message.ToString() }, { "fromscript", e.FromTask.ToString() }, - { "targetfolder", e.FolderID.ToString() } + { "targetfolder", e.FolderID.ToString() }, + { "objectid", e.Offer.FromAgentID.ToString() }, + { "objectname", e.Offer.FromAgentName } }; - if (ProcessRequest(myConfig.GetAcceptInventory(),myConfig.GetInventoryTransferLevel(),e.Offer.FromAgentName, "InventoryOffer", e.Offer.FromAgentID, details) == false) + if (ProcessRequest(myConfig.GetAcceptInventory(),myConfig.GetInventoryTransferLevel(), ownerName, "InventoryOffer", ownerID, details) == false) { - e.Accept = false; + if (myConfig.GetAcceptInventory()) + { + UUID pendingFolder = GetOrCreatePendingFolder(); + e.FolderID = pendingFolder; + e.Accept = true; + lock (_pendingOffers) + { + _pendingOffers[e.ObjectID] = (ownerName, ownerID); + } + details.Add("pending", "true"); + details.Add("itemuuid", e.ObjectID.ToString()); + details.Add("itemname", "?"); + JsonOuputCleaner(details, "InventoryOffer"); + } + else + { + e.Accept = false; + } return; } e.Accept = true; @@ -323,6 +428,7 @@ namespace SecondBotEvents.Services protected async void BotLoggedIn(object o, SimConnectedEventArgs e) { await awaitstable(); + GetOrCreatePendingFolder(); GetClient().Network.SimConnected -= BotLoggedIn; GetClient().Friends.FriendshipOffered += BotFriendRequested; GetClient().Inventory.InventoryObjectOffered += BotInventoryOffer; @@ -364,12 +470,19 @@ namespace SecondBotEvents.Services master.BotClientNoticeEvent -= BotClientRestart; if (master.BotClient != null) { - if (GetClient() != null) + var client = GetClient(); + if (client != null) { - GetClient().Friends.FriendshipOffered -= BotFriendRequested; - GetClient().Inventory.InventoryObjectOffered -= BotInventoryOffer; - GetClient().Inventory.Store.InventoryObjectAdded -= BotInventoryAdd; - GetClient().Groups.GroupInvitation -= BotGroupInviteOffer; + if (client.Friends != null) + client.Friends.FriendshipOffered -= BotFriendRequested; + if (client.Inventory != null) + { + client.Inventory.InventoryObjectOffered -= BotInventoryOffer; + if (client.Inventory.Store != null) + client.Inventory.Store.InventoryObjectAdded -= BotInventoryAdd; + } + if (client.Groups != null) + client.Groups.GroupInvitation -= BotGroupInviteOffer; } } } diff --git a/SecondBotEvents/Static/Helpers.cs b/SecondBotEvents/Static/Helpers.cs index 5162591..530f47c 100644 --- a/SecondBotEvents/Static/Helpers.cs +++ b/SecondBotEvents/Static/Helpers.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -180,5 +180,20 @@ namespace SecondBotEvents } return false; } + + public static bool IsNameMatch(string configuredName, string incomingName) + { + if (configuredName == null || incomingName == null) return false; + + string c = configuredName.Trim().ToLowerInvariant(); + string i = incomingName.Trim().ToLowerInvariant(); + + if (c == i) return true; + + if (c.EndsWith(" resident")) c = c.Substring(0, c.Length - 9).Trim(); + if (i.EndsWith(" resident")) i = i.Substring(0, i.Length - 9).Trim(); + + return c == i; + } } }