diff --git a/backend/package/yuxi/channels/adapters/nostr/session.py b/backend/package/yuxi/channels/adapters/nostr/session.py new file mode 100644 index 00000000..e7002b20 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/nostr/session.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from yuxi.channels.models import ChannelMessage + + +def resolve_thread_key(message: ChannelMessage) -> str: + identity = message.identity + chat_id = identity.channel_chat_id + + if chat_id.startswith("dm:"): + pubkey = chat_id.removeprefix("dm:") + return f"nostr:dm:{pubkey}" + if chat_id.startswith("channel:"): + channel = chat_id.removeprefix("channel:") + return f"nostr:channel:{channel}" + return f"nostr:unknown:{chat_id}" + + +def resolve_chat_id(message: ChannelMessage) -> str: + return message.identity.channel_chat_id + + +def resolve_agent_route(message: ChannelMessage, default_agent_id: str = "default") -> str: + thread_key = resolve_thread_key(message) + return f"agent:{default_agent_id}:{thread_key}" + + +def normalize_target(target: str) -> str: + if target.startswith("nostr:"): + return target + return f"nostr:{target}" diff --git a/backend/package/yuxi/channels/adapters/nostr/thread_simulator.py b/backend/package/yuxi/channels/adapters/nostr/thread_simulator.py new file mode 100644 index 00000000..8616642b --- /dev/null +++ b/backend/package/yuxi/channels/adapters/nostr/thread_simulator.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Any + + +class NostrThreadSimulator: + def __init__(self, relay_pool: Any = None, max_depth: int = 10): + self._relay_pool = relay_pool + self._event_cache: dict[str, dict] = {} + self._reply_chains: dict[str, list[str]] = {} + self._max_depth = max_depth + + async def build_thread_from_event(self, event: dict) -> list[dict]: + chain: list[dict] = [] + current = event + + for _ in range(self._max_depth): + chain.append(current) + parent_id = self._get_parent_event_id(current) + if not parent_id: + break + parent = await self._fetch_event(parent_id) + if not parent: + break + current = parent + + return list(reversed(chain)) + + @staticmethod + def _get_parent_event_id(event: dict) -> str | None: + tags: list[list[str]] = event.get("tags", []) + for tag in tags: + if tag[0] == "e": + if len(tag) >= 4 and tag[3] == "reply": + return tag[1] + if len(tag) >= 2: + return tag[1] + return None + + @staticmethod + def resolve_e_tags(event: dict) -> list[str]: + tags: list[list[str]] = event.get("tags", []) + return [tag[1] for tag in tags if tag[0] == "e" and len(tag) > 1] + + @staticmethod + def resolve_p_tags(event: dict) -> list[str]: + tags: list[list[str]] = event.get("tags", []) + return [tag[1] for tag in tags if tag[0] == "p" and len(tag) > 1] + + def _is_root_event(self, event: dict) -> bool: + tags: list[list[str]] = event.get("tags", []) + for tag in tags: + if tag[0] == "e" and len(tag) >= 4 and tag[3] == "root": + return False + return True + + async def _fetch_event(self, event_id: str) -> dict | None: + if event_id in self._event_cache: + return self._event_cache[event_id] + + if self._relay_pool: + try: + event = await self._relay_pool.query_event(event_id) + if event: + self._event_cache[event_id] = event + return event + except Exception: + pass + return None