Files
Hippolyzer/hippolyzer/lib/proxy/inventory_manager.py
2025-06-07 10:00:03 +00:00

188 lines
7.7 KiB
Python

import asyncio
import datetime as dt
import functools
import logging
from typing import *
from hippolyzer.lib.base import llsd
from hippolyzer.lib.base.helpers import get_mtime, create_logged_task
from hippolyzer.lib.client.inventory_manager import InventoryManager
from hippolyzer.lib.proxy.http_flow import HippoHTTPFlow
from hippolyzer.lib.proxy.viewer_settings import iter_viewer_cache_dirs
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.inventory import InventoryCategory, InventoryNodeBase
from hippolyzer.lib.base.message.message import Message, Block
from hippolyzer.lib.base.inventory import InventoryItem
from hippolyzer.lib.base.templates import AssetType, InventoryType, WearableType
from hippolyzer.lib.base.network.transport import Direction
if TYPE_CHECKING:
from hippolyzer.lib.proxy.sessions import Session
LOG = logging.getLogger(__name__)
class ProxyInventoryManager(InventoryManager):
_session: "Session"
def __init__(self, session: "Session"):
# These handlers all need their processing deferred until the cache has been loaded.
# Since cache is loaded asynchronously, the viewer may get ahead of us due to parsing
# the cache faster and start requesting inventory details we can't do anything with yet.
self._handle_update_create_inventory_item = self._wrap_with_cache_defer(
self._handle_update_create_inventory_item
)
self._handle_remove_inventory_item = self._wrap_with_cache_defer(
self._handle_remove_inventory_item
)
self._handle_remove_inventory_folder = self._wrap_with_cache_defer(
self._handle_remove_inventory_folder
)
self._handle_bulk_update_inventory = self._wrap_with_cache_defer(
self._handle_bulk_update_inventory
)
self._handle_move_inventory_item = self._wrap_with_cache_defer(
self._handle_move_inventory_item
)
self._handle_move_inventory_folder = self._wrap_with_cache_defer(
self._handle_move_inventory_folder
)
self.process_aisv3_response = self._wrap_with_cache_defer(
self.process_aisv3_response
)
# Base constructor after, because it registers handlers to specific methods, which need to
# be wrapped before we call they're registered. Handlers are registered by method reference,
# not by name!
super().__init__(session)
session.http_message_handler.subscribe("InventoryAPIv3", self._handle_aisv3_flow)
newest_cache = None
newest_timestamp = dt.datetime(year=1970, month=1, day=1, tzinfo=dt.timezone.utc)
# So consumers know when the inventory should be complete
self.cache_loaded: asyncio.Event = asyncio.Event()
self._cache_deferred_calls: List[Tuple[Callable[..., None], Tuple]] = []
# Look for the newest version of the cached inventory and use that.
# Not foolproof, but close enough if we're not sure what viewer is being used.
for cache_dir in iter_viewer_cache_dirs():
inv_cache_path = cache_dir / (str(session.agent_id) + ".inv.llsd.gz")
if inv_cache_path.exists():
mod = get_mtime(inv_cache_path)
if not mod:
continue
mod_ts = dt.datetime.fromtimestamp(mod, dt.timezone.utc)
if mod_ts <= newest_timestamp:
continue
newest_cache = inv_cache_path
if newest_cache:
cache_load_fut = asyncio.ensure_future(asyncio.to_thread(self.load_cache, newest_cache))
# Meh. Don't care if it fails.
cache_load_fut.add_done_callback(lambda *args: self.cache_loaded.set())
create_logged_task(self._apply_deferred_after_loaded(), "Apply deferred inventory", LOG)
else:
self.cache_loaded.set()
async def _apply_deferred_after_loaded(self):
await self.cache_loaded.wait()
LOG.info("Applying deferred inventory calls")
deferred_calls = self._cache_deferred_calls[:]
self._cache_deferred_calls.clear()
for func, args in deferred_calls:
try:
func(*args)
except:
LOG.exception("Failed to apply deferred inventory call")
def _wrap_with_cache_defer(self, func: Callable[..., None]):
@functools.wraps(func)
def wrapped(*inner_args):
if not self.cache_loaded.is_set():
self._cache_deferred_calls.append((func, inner_args))
else:
func(*inner_args)
return wrapped
def _handle_aisv3_flow(self, flow: HippoHTTPFlow):
if flow.response.status_code < 200 or flow.response.status_code > 300:
# Probably not a success
return
content_type = flow.response.headers.get("Content-Type", "")
if "llsd" not in content_type:
# Okay, probably still some kind of error...
return
# Try and add anything from the response into the model
self.process_aisv3_response(llsd.parse(flow.response.content))
async def create_folder(
self,
parent: InventoryCategory | UUID,
name: str,
type: int = -1,
cat_id: UUID | None = None
) -> InventoryCategory:
cat = await super().create_folder(parent, name, type, cat_id)
await self._session.main_region.circuit.send_reliable(self._craft_update_message(cat))
return cat
async def create_item(
self,
parent: UUID | InventoryCategory,
name: str,
type: AssetType,
inv_type: InventoryType,
wearable_type: WearableType,
transaction_id: UUID,
next_mask: int = 0x7FffFFff,
description: str = '',
) -> InventoryItem:
item = await super().create_item(
parent=parent,
name=name,
type=type,
inv_type=inv_type,
wearable_type=wearable_type,
transaction_id=transaction_id,
next_mask=next_mask,
description=description,
)
await self._session.main_region.circuit.send_reliable(self._craft_update_message(item))
return item
async def update(self, node: InventoryNodeBase, data: dict) -> None:
await super().update(node, data)
await self._session.main_region.circuit.send_reliable(self._craft_update_message(node))
async def move(self, node: InventoryNodeBase, new_parent: UUID | InventoryCategory) -> None:
await super().move(node, new_parent)
await self._session.main_region.circuit.send_reliable(self._craft_update_message(node))
def _craft_removal_message(self, node: InventoryNodeBase) -> Message:
is_folder = True
if isinstance(node, InventoryItem):
is_folder = False
msg = Message(
"RemoveInventoryFolder" if is_folder else "RemoveInventoryItem",
Block("AgentData", AgentID=self._session.agent_id, SessionID=self._session.id),
direction=Direction.IN,
)
if is_folder:
msg.add_block(Block("FolderData", FolderID=node.node_id))
else:
msg.add_block(Block("InventoryData", ItemID=node.node_id))
return msg
def _craft_update_message(self, node: InventoryNodeBase):
is_folder = True
if isinstance(node, InventoryItem):
is_folder = False
return Message(
"BulkUpdateInventory",
Block("AgentData", AgentID=self._session.agent_id, TransactionID=UUID.random()),
node.to_folder_data() if is_folder else node.to_inventory_data("ItemData"), # type: ignore
direction=Direction.IN,
)