7 Commits

Author SHA1 Message Date
Salad Dais
0cc3397402 Improve inventory handling 2025-07-15 01:53:24 +00:00
Salad Dais
0c2dfd3213 Pass EQ messages off to session message handler as well 2025-07-14 07:43:56 +00:00
Salad Dais
e119181e3f Handle RemoveInventoryObjects message 2025-07-14 07:43:36 +00:00
Salad Dais
64c7265578 Beautify JSON responses 2025-07-14 03:56:27 +00:00
Salad Dais
eb652152f5 Update some flags 2025-07-14 03:56:16 +00:00
Salad Dais
cd03dd4fdd Fix duplication not handling update messages properly 2025-07-07 23:54:26 +00:00
Salad Dais
056e142347 Add API for duplicating inventory folders / items 2025-07-07 22:52:38 +00:00
11 changed files with 130 additions and 7 deletions

View File

@@ -485,6 +485,25 @@ class InventoryContainerBase(InventoryNodeBase):
if x.parent_id == self.node_id
)
@property
def descendents(self) -> List[InventoryNodeBase]:
new_children: List[InventoryNodeBase] = [self]
descendents = []
while new_children:
to_check = new_children[:]
new_children.clear()
for obj in to_check:
if isinstance(obj, InventoryContainerBase):
for child in obj.children:
if child in descendents:
continue
new_children.append(child)
descendents.append(child)
else:
if obj not in descendents:
descendents.append(obj)
return descendents
def __getitem__(self, item: Union[int, str]) -> InventoryNodeBase:
if isinstance(item, int):
return self.children[item]
@@ -607,6 +626,9 @@ class InventoryItem(InventoryNodeBase):
name: Optional[str] = schema_field(SchemaMultilineStr, default=None)
desc: Optional[str] = schema_field(SchemaMultilineStr, default=None)
metadata: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None, include_none=True)
"""Specifically for script metadata, generally just experience info"""
thumbnail: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None, include_none=False)
"""Generally just a dict with the thumbnail UUID in it"""
creation_date: Optional[dt.datetime] = schema_field(SchemaDate, llsd_name="created_at", default=None)
__hash__ = InventoryNodeBase.__hash__

View File

@@ -57,6 +57,7 @@ _ASSET_TYPE_BIDI: BiDiDict[str] = BiDiDict({
@se.enum_field_serializer("AssetUploadComplete", "AssetBlock", "Type")
@se.enum_field_serializer("UpdateCreateInventoryItem", "InventoryData", "Type")
@se.enum_field_serializer("CreateInventoryItem", "InventoryBlock", "Type")
@se.enum_field_serializer("LinkInventoryItem", "InventoryBlock", "Type")
@se.enum_field_serializer("RezObject", "InventoryData", "Type")
@se.enum_field_serializer("RezScript", "InventoryBlock", "Type")
@se.enum_field_serializer("UpdateTaskInventory", "InventoryData", "Type")
@@ -143,6 +144,7 @@ _INV_TYPE_BIDI: BiDiDict[str] = BiDiDict({
@se.enum_field_serializer("UpdateCreateInventoryItem", "InventoryData", "InvType")
@se.enum_field_serializer("CreateInventoryItem", "InventoryBlock", "InvType")
@se.enum_field_serializer("LinkInventoryItem", "InventoryBlock", "InvType")
@se.enum_field_serializer("RezObject", "InventoryData", "InvType")
@se.enum_field_serializer("RezScript", "InventoryBlock", "InvType")
@se.enum_field_serializer("UpdateTaskInventory", "InventoryData", "InvType")
@@ -1982,6 +1984,7 @@ class RegionFlags(IntFlag):
ALLOW_VOICE = 1 << 28
BLOCK_PARCEL_SEARCH = 1 << 29
DENY_AGEUNVERIFIED = 1 << 30
DENY_BOTS = 1 << 31
@se.flag_field_serializer("RegionHandshakeReply", "RegionInfo", "Flags")

View File

@@ -330,7 +330,7 @@ class HippoClientSession(BaseClientSession):
super().__init__(id, secure_session_id, agent_id, circuit_code, session_manager, login_data=login_data)
self.http_session = session_manager.http_session
self.objects = ClientWorldObjectManager(proxify(self), session_manager.settings, None)
self.inventory_manager = InventoryManager(proxify(self))
self.inventory = InventoryManager(proxify(self))
self.transport: Optional[SocketUDPTransport] = None
self.protocol: Optional[HippoClientProtocol] = None
self.message_handler.take_by_default = False

View File

@@ -6,16 +6,18 @@ import gzip
import itertools
import logging
from pathlib import Path
from typing import Union, List, Tuple, Set, Sequence
from typing import Union, List, Tuple, Set, Sequence, Dict, TYPE_CHECKING
from hippolyzer.lib.base import llsd
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.inventory import InventoryModel, InventoryCategory, InventoryItem, InventoryNodeBase
from hippolyzer.lib.base.message.message import Message, Block
from hippolyzer.lib.base.templates import AssetType, FolderType, InventoryType, Permissions
from hippolyzer.lib.client.state import BaseClientSession
from hippolyzer.lib.base.templates import WearableType
if TYPE_CHECKING:
from hippolyzer.lib.client.state import BaseClientSession
LOG = logging.getLogger(__name__)
@@ -38,6 +40,7 @@ class InventoryManager:
self._session.message_handler.subscribe("BulkUpdateInventory", self._handle_bulk_update_inventory)
self._session.message_handler.subscribe("UpdateCreateInventoryItem", self._handle_update_create_inventory_item)
self._session.message_handler.subscribe("RemoveInventoryItem", self._handle_remove_inventory_item)
self._session.message_handler.subscribe("RemoveInventoryObjects", self._handle_remove_inventory_objects)
self._session.message_handler.subscribe("RemoveInventoryFolder", self._handle_remove_inventory_folder)
self._session.message_handler.subscribe("MoveInventoryItem", self._handle_move_inventory_item)
self._session.message_handler.subscribe("MoveInventoryFolder", self._handle_move_inventory_folder)
@@ -178,6 +181,17 @@ class InventoryManager:
if node:
self.model.unlink(node)
def _handle_remove_inventory_objects(self, msg: Message):
self._validate_recipient(msg["AgentData"]["AgentID"])
for item_block in msg.get_blocks("ItemData", []):
node = self.model.get(item_block["ItemID"])
if node:
self.model.unlink(node)
for folder_block in msg.get_blocks("FolderData", []):
node = self.model.get(folder_block["FolderID"])
if node:
self.model.unlink(node)
def _handle_move_inventory_item(self, msg: Message):
for inventory_block in msg["InventoryData"]:
node = self.model.get(inventory_block["ItemID"])
@@ -341,6 +355,58 @@ class InventoryManager:
await self._session.main_region.circuit.send_reliable(msg)
node.parent_id = new_parent
async def copy(self, node: InventoryNodeBase, destination: UUID | InventoryCategory, contents: bool = True)\
-> InventoryItem | InventoryCategory:
destination = _get_node_id(destination)
if isinstance(node, InventoryItem):
with self._session.main_region.message_handler.subscribe_async(
("BulkUpdateInventory",),
# Not ideal, but there doesn't seem to be an easy way to determine the transaction ID,
# and using the callback ID seems a bit crap.
predicate=lambda x: x["ItemData"]["Name"] == node.name,
take=False,
) as get_msg:
await self._session.main_region.circuit.send_reliable(Message(
'CopyInventoryItem',
Block('AgentData', AgentID=self._session.agent_id, SessionID=self._session.id),
Block(
'InventoryData',
CallbackID=0,
OldAgentID=self._session.agent_id,
OldItemID=node.item_id,
NewFolderID=destination,
NewName=b''
)
))
msg = await asyncio.wait_for(get_msg(), 5.0)
# BulkInventoryUpdate message may not have already been handled internally, do it manually.
self._handle_bulk_update_inventory(msg)
# Now pull the item out of the inventory
new_item = self.model.get(msg["ItemData"]["ItemID"])
assert new_item is not None
return new_item # type: ignore
elif isinstance(node, InventoryCategory):
# Keep a list of the original descendents in case we're copy a folder within itself
to_copy = list(node.descendents)
# There's not really any way to "copy" a category, we just create a new one with the same properties.
new_cat = await self.create_folder(destination, node.name, node.pref_type)
if contents:
cat_lookup: Dict[UUID, UUID] = {node.node_id: new_cat.node_id}
# Recreate the category hierarchy first, keeping note of the new category IDs.
for node in to_copy:
if isinstance(node, InventoryCategory):
new_parent = cat_lookup[node.parent_id]
cat_lookup[node.node_id] = (await self.copy(node, new_parent, contents=False)).node_id
# Items have to be explicitly copied individually
for node in to_copy:
if isinstance(node, InventoryItem):
new_parent = cat_lookup[node.parent_id]
await self.copy(node, new_parent, contents=False)
return new_cat
else:
raise ValueError(f"Unknown node type: {node!r}")
async def update(self, node: InventoryNodeBase, data: dict) -> None:
path = f"/category/{node.node_id}"
if isinstance(node, InventoryItem):

View File

@@ -15,7 +15,7 @@ from typing import *
from hippolyzer.lib.base.datatypes import UUID, Vector3
from hippolyzer.lib.base.helpers import proxify
from hippolyzer.lib.base.inventory import InventoryItem, InventoryModel
from hippolyzer.lib.base.inventory import InventoryItem, InventoryModel, InventoryObject
from hippolyzer.lib.base.message.message import Block, Message
from hippolyzer.lib.base.message.message_handler import MessageHandler
from hippolyzer.lib.base.message.msgtypes import PacketFlags
@@ -229,7 +229,18 @@ class ClientObjectManager:
async def request_object_inv_via_cap(self, obj: Object) -> List[InventoryItem]:
async with self._region.caps_client.get("RequestTaskInventory", params={"task_id": obj.FullID}) as resp:
resp.raise_for_status()
return [InventoryItem.from_llsd(x) for x in (await resp.read_llsd())["contents"]]
all_items = [InventoryItem.from_llsd(x) for x in (await resp.read_llsd())["contents"]]
# Synthesize the Contents directory so the items can have a parent
parent = InventoryObject(
obj_id=obj.FullID,
name="Contents",
)
model = InventoryModel()
model.add(parent)
for item in all_items:
model.add(item)
return all_items
async def request_object_inv_via_xfer(self, obj: Object) -> List[InventoryItem]:
session = self._region.session()
@@ -370,6 +381,10 @@ class ClientWorldObjectManager:
futs.extend(region_mgr.request_object_properties(region_objs))
return futs
async def request_object_inv(self, obj: Object) -> List[InventoryItem]:
region_mgr = self._get_region_manager(obj.RegionHandle)
return await region_mgr.request_object_inv(obj)
async def load_ancestors(self, obj: Object, wait_time: float = 1.0):
"""
Ensure that the entire chain of parents above this object is loaded

View File

@@ -18,6 +18,7 @@ from hippolyzer.lib.base.network.caps_client import CapsClient
from hippolyzer.lib.base.network.transport import ADDR_TUPLE
from hippolyzer.lib.base.objects import handle_to_global_pos
from hippolyzer.lib.base.xfer_manager import XferManager
from hippolyzer.lib.client.inventory_manager import InventoryManager
from hippolyzer.lib.client.object_manager import ClientObjectManager, ClientWorldObjectManager
@@ -91,6 +92,7 @@ class BaseClientSession(abc.ABC):
region_by_handle: Callable[[int], Optional[BaseClientRegion]]
region_by_circuit_addr: Callable[[ADDR_TUPLE], Optional[BaseClientRegion]]
objects: ClientWorldObjectManager
inventory: InventoryManager
login_data: Dict[str, Any]
REGION_CLS = Type[BaseClientRegion]

View File

@@ -341,10 +341,15 @@ class MITMProxyEventManager:
msg.sender = region.circuit_addr
msg.direction = Direction.IN
try:
session.message_handler.handle(msg)
except:
LOG.exception("Failed while handling EQ message for session")
try:
region.message_handler.handle(msg)
except:
LOG.exception("Failed while handling EQ message")
LOG.exception("Failed while handling EQ message for region")
handle_event = AddonManager.handle_eq_event(session, region, event)
if handle_event is True:

View File

@@ -158,6 +158,12 @@ class ProxyInventoryManager(InventoryManager):
await super().move(node, new_parent)
await self._session.main_region.circuit.send_reliable(self._craft_update_message(node))
async def copy(self, node: InventoryNodeBase, destination: UUID | InventoryCategory, contents: bool = True)\
-> InventoryCategory | InventoryItem:
ret_node = await super().copy(node, destination, contents)
await self._session.main_region.circuit.send_reliable(self._craft_update_message(node))
return ret_node
def _craft_removal_message(self, node: InventoryNodeBase) -> Message:
is_folder = True
if isinstance(node, InventoryItem):

View File

@@ -7,6 +7,7 @@ import copy
import fnmatch
import gzip
import io
import json
import logging
import pickle
import re
@@ -507,6 +508,8 @@ class HTTPMessageLogEntry(AbstractMessageLogEntry):
raise
elif any(content_type.startswith(x) for x in ("application/xml", "text/xml")):
beautified = self._format_xml(message.content)
elif "json" in content_type:
beautified = json.dumps(json.loads(message.content), indent=2)
except:
LOG.exception("Failed to beautify message")

View File

@@ -34,6 +34,7 @@ if TYPE_CHECKING:
class Session(BaseClientSession):
regions: MutableSequence[ProxiedRegion]
inventory: ProxyInventoryManager
region_by_handle: Callable[[int], Optional[ProxiedRegion]]
region_by_circuit_addr: Callable[[ADDR_TUPLE], Optional[ProxiedRegion]]
main_region: Optional[ProxiedRegion]

View File

@@ -159,7 +159,7 @@ class TestHippoClient(unittest.IsolatedAsyncioTestCase):
async def test_inventory_manager(self):
await self._log_client_in(self.client)
self.assertEqual(self.client.session.inventory_manager.model.root.node_id, UUID(int=4))
self.assertEqual(self.client.session.inventory.model.root.node_id, UUID(int=4))
async def test_resend_suppression(self):
"""Make sure the client only handles the first seen copy of a reliable message"""