diff --git a/hippolyzer/lib/base/inventory.py b/hippolyzer/lib/base/inventory.py index 811a934..00c2cbc 100644 --- a/hippolyzer/lib/base/inventory.py +++ b/hippolyzer/lib/base/inventory.py @@ -11,6 +11,7 @@ from __future__ import annotations import abc +import asyncio import dataclasses import datetime as dt import inspect @@ -202,6 +203,7 @@ class InventoryModel(InventoryBase): def __init__(self): self.nodes: Dict[UUID, InventoryNodeBase] = {} self.root: Optional[InventoryContainerBase] = None + self.any_dirty = asyncio.Event() @classmethod def from_reader(cls, reader: StringIO, read_header=False) -> InventoryModel: @@ -246,6 +248,12 @@ def all_containers(self) -> Iterable[InventoryContainerBase]: if isinstance(node, InventoryContainerBase): yield node + @property + def dirty_categories(self) -> Iterable[InventoryCategory]: + for node in self.nodes: + if isinstance(node, InventoryCategory) and node.version == InventoryCategory.VERSION_NONE: + yield node + @property def all_items(self) -> Iterable[InventoryItem]: for node in self.nodes.values(): @@ -273,6 +281,29 @@ def add(self, node: InventoryNodeBase): if node.parent_id == UUID.ZERO: self.root = node node.model = weakref.proxy(self) + return node + + def update(self, node: InventoryNodeBase, update_fields: Optional[Iterable[str]] = None) -> InventoryNodeBase: + """Update an existing node, optionally only updating specific fields""" + if node.node_id not in self.nodes: + raise KeyError(f"{node.node_id} not in the inventory model") + + orig_node = self.nodes[node.node_id] + if node.__class__ != orig_node.__class__: + raise ValueError(f"Tried to update {orig_node!r} from non-matching {node!r}") + + if not update_fields: + # Update everything but the model parameter + update_fields = set(node._get_fields_dict().keys()) - {"model"} + for field_name in update_fields: + setattr(orig_node, field_name, getattr(node, field_name)) + return orig_node + + def upsert(self, node: InventoryNodeBase, update_fields: Optional[Iterable[str]] = None) -> InventoryNodeBase: + """Add or update a node""" + if node.node_id in self.nodes: + return self.update(node, update_fields) + return self.add(node) def unlink(self, node: InventoryNodeBase, single_only: bool = False) -> Sequence[InventoryNodeBase]: """Unlink a node and its descendants from the tree, returning the removed nodes""" @@ -313,6 +344,10 @@ def get_differences(self, other: InventoryModel) -> InventoryDifferences: removed=removed_in_other, ) + def flag_if_dirty(self): + if any(self.dirty_categories): + self.any_dirty.set() + def __getitem__(self, item: UUID) -> InventoryNodeBase: return self.nodes[item] @@ -562,7 +597,8 @@ def to_inventory_data(self) -> Block: def from_inventory_data(cls, block: Block): return cls( item_id=block["ItemID"], - parent_id=block["ParentID"], + # Might be under one of two names + parent_id=block.get("ParentID", block["FolderID"]), permissions=InventoryPermissions( creator_id=block["CreatorID"], owner_id=block["OwnerID"], @@ -573,7 +609,8 @@ def from_inventory_data(cls, block: Block): everyone_mask=block["EveryoneMask"], next_owner_mask=block["NextOwnerMask"], ), - asset_id=block["AssetID"], + # May be missing in UpdateInventoryItem + asset_id=block.get("AssetID"), type=AssetType(block["Type"]), inv_type=InventoryType(block["InvType"]), flags=block["Flags"], diff --git a/hippolyzer/lib/client/inventory_manager.py b/hippolyzer/lib/client/inventory_manager.py index 63bc08e..524a51e 100644 --- a/hippolyzer/lib/client/inventory_manager.py +++ b/hippolyzer/lib/client/inventory_manager.py @@ -8,6 +8,7 @@ from hippolyzer.lib.base import llsd from hippolyzer.lib.base.datatypes import UUID from hippolyzer.lib.base.inventory import InventoryModel, InventoryCategory, InventoryItem +from hippolyzer.lib.base.message.message import Message from hippolyzer.lib.base.templates import AssetType, FolderType from hippolyzer.lib.client.state import BaseClientSession @@ -20,6 +21,10 @@ def __init__(self, session: BaseClientSession): self._session = session self.model: InventoryModel = InventoryModel() self._load_skeleton() + 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("UpdateInventoryItem", self._handle_update_inventory_item) + self._session.message_handler.subscribe("RemoveInventoryItem", self._handle_remove_inventory_item) def _load_skeleton(self): assert not self.model.nodes @@ -68,10 +73,8 @@ def load_cache(self, path: Union[str, Path]): # Cached cat isn't the same as what the inv server says it should be, can't use it. if cached_cat.version != skel_versions.get(cached_cat.cat_id): continue - if existing_cat: - # Remove the category so that we can replace it, but leave any children in place - self.model.unlink(existing_cat, single_only=True) - self.model.add(cached_cat) + # Update any existing category in-place, or add if not present + self.model.upsert(cached_cat) # Any items in this category in our cache file are usable and should be added loaded_cat_ids.add(cached_cat.cat_id) @@ -81,10 +84,13 @@ def load_cache(self, path: Union[str, Path]): if cached_item.item_id in self.model: continue # The parent category didn't have a cache hit against the inventory skeleton, can't add! + # We don't even know if this item would be in the current version of it's parent cat! if cached_item.parent_id not in loaded_cat_ids: continue self.model.add(cached_item) + self.model.flag_if_dirty() + def _parse_cache(self, path: Union[str, Path]) -> Tuple[List[InventoryCategory], List[InventoryItem]]: """Warning, may be incredibly slow due to llsd.parse_notation() behavior""" categories: List[InventoryCategory] = [] @@ -112,3 +118,44 @@ def _parse_cache(self, path: Union[str, Path]) -> Tuple[List[InventoryCategory], else: LOG.warning(f"Unknown node type in inv cache: {node_llsd!r}") return categories, items + + def _handle_bulk_update_inventory(self, msg: Message): + any_cats = False + for folder_block in msg["FolderData"]: + if folder_block["FolderID"] == UUID.ZERO: + continue + any_cats = True + self.model.upsert( + InventoryCategory.from_folder_data(folder_block), + # Don't clobber version, we only want to fetch the folder if it's new + # and hasn't just moved. + update_fields={"parent_id", "name", "pref_type"}, + ) + for item_block in msg["ItemData"]: + if item_block["ItemID"] == UUID.ZERO: + continue + self.model.upsert(InventoryItem.from_inventory_data(item_block)) + + if any_cats: + self.model.flag_if_dirty() + + def _validate_recipient(self, recipient: UUID): + if self._session.agent_id != recipient: + raise ValueError(f"AgentID Mismatch {self._session.agent_id} != {recipient}") + + def _handle_update_create_inventory_item(self, msg: Message): + self._validate_recipient(msg["AgentData"]["AgentID"]) + for inventory_block in msg["InventoryData"]: + self.model.upsert(InventoryItem.from_inventory_data(inventory_block)) + + def _handle_update_inventory_item(self, msg: Message): + self._validate_recipient(msg["AgentData"]["AgentID"]) + for inventory_block in msg["InventoryData"]: + self.model.update(InventoryItem.from_inventory_data(inventory_block)) + + def _handle_remove_inventory_item(self, msg: Message): + self._validate_recipient(msg["AgentData"]["AgentID"]) + for inventory_block in msg["InventoryData"]: + node = self.model.get(inventory_block["ItemID"]) + if node: + self.model.unlink(node)