diff --git a/backend/package/yuxi/channels/adapters/urbit/__init__.py b/backend/package/yuxi/channels/adapters/urbit/__init__.py index 0568c39a..6a0c8f41 100644 --- a/backend/package/yuxi/channels/adapters/urbit/__init__.py +++ b/backend/package/yuxi/channels/adapters/urbit/__init__.py @@ -1,15 +1,15 @@ -from yuxi.channels.adapters.urbit.adapter import UrbitAdapter from yuxi.channels.adapters.urbit.accounts import AccountManager, MultiAccountConnectionManager -from yuxi.channels.adapters.urbit.invites import InviteManager +from yuxi.channels.adapters.urbit.adapter import UrbitAdapter +from yuxi.channels.adapters.urbit.config_ui_hints import get_categories, get_config_ui_hints, get_hints_by_category from yuxi.channels.adapters.urbit.directory import DirectoryQuery from yuxi.channels.adapters.urbit.dual_plugin import ( - UrbitSetupPlugin, - UrbitAccountPlugin, DualPluginRegistry, + UrbitAccountPlugin, + UrbitSetupPlugin, get_global_registry, ) -from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient, with_http_poke_api, create_http_poke_api -from yuxi.channels.adapters.urbit.config_ui_hints import get_config_ui_hints, get_categories, get_hints_by_category +from yuxi.channels.adapters.urbit.invites import InviteManager +from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient, create_http_poke_api, with_http_poke_api __all__ = [ "UrbitAdapter", diff --git a/backend/package/yuxi/channels/adapters/urbit/adapter.py b/backend/package/yuxi/channels/adapters/urbit/adapter.py index e8fe4e41..0be8648c 100644 --- a/backend/package/yuxi/channels/adapters/urbit/adapter.py +++ b/backend/package/yuxi/channels/adapters/urbit/adapter.py @@ -7,12 +7,12 @@ from typing import Any from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities -from yuxi.channels.meta import ChannelMeta -from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelConnectionError, ) +from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError +from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( ChannelMessage, ChannelResponse, @@ -25,32 +25,32 @@ from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger +from .accounts import AccountManager +from .approval import ApprovalSystem from .auth import authenticate_ship +from .authorization import ChannelAuthorization from .client import UrbitClient -from .doctor import create_legacy_private_network_doctor_contract, apply_doctor_migrations +from .discovery import ChannelDiscovery +from .doctor import apply_doctor_migrations, create_legacy_private_network_doctor_contract from .format import build_channel_path, build_dm_channel_id, build_poke_payload +from .history import MessageCache +from .invites import InviteManager from .monitor import SSEDeduplicator, UrbitSSEManager, _graph_update_to_channel_message from .probe import probe_ship from .rate_limiter import RateLimiter from .send import ( + _send_media_poke, + send_del_reaction_poke, + send_delete_poke, + send_edit_poke, send_poke, send_reaction_poke, - send_del_reaction_poke, - send_edit_poke, - send_delete_poke, send_unblock_ship_poke, - _send_media_poke, ) -from .session import resolve_session_route, detect_unsafe_session -from .approval import ApprovalSystem -from .accounts import AccountManager -from .invites import InviteManager -from .threads import ThreadManager -from .authorization import ChannelAuthorization +from .session import detect_unsafe_session, resolve_session_route from .settings import SettingsStore -from .discovery import ChannelDiscovery -from .history import MessageCache from .summarizer import Summarizer +from .threads import ThreadManager @register_builtin_adapter @@ -192,6 +192,10 @@ class UrbitAdapter(BaseChannelAdapter): await probe_ship(self._client) + self._invite_manager._client = self._client + self._invite_manager._ship_name = self._client.ship_name + self._invite_manager._ship_code = self._ship_code + if self._settings_store is None and self._client: self._settings_store = SettingsStore(self._client) await self._settings_store.load(self.config) @@ -469,7 +473,7 @@ class UrbitAdapter(BaseChannelAdapter): if rate_limit_result := await self._check_rate_limit(): return rate_limit_result - from .format import build_channel_path, build_poke_payload, build_media_content + from .format import build_channel_path, build_media_content, build_poke_payload metadata: dict[str, str] = { "chat_type": "group", diff --git a/backend/package/yuxi/channels/adapters/urbit/approval.py b/backend/package/yuxi/channels/adapters/urbit/approval.py index c3b22181..1e62f8b0 100644 --- a/backend/package/yuxi/channels/adapters/urbit/approval.py +++ b/backend/package/yuxi/channels/adapters/urbit/approval.py @@ -198,7 +198,6 @@ class ApprovalSystem: return None async def persist_to_settings_store(self, store: SettingsStore) -> None: - from .settings import SettingsStore store_data: dict[str, Any] = { "pending_approvals": [], diff --git a/backend/package/yuxi/channels/adapters/urbit/directory.py b/backend/package/yuxi/channels/adapters/urbit/directory.py index 14722e8d..9e7c7ea6 100644 --- a/backend/package/yuxi/channels/adapters/urbit/directory.py +++ b/backend/package/yuxi/channels/adapters/urbit/directory.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/channels/adapters/urbit/doctor.py b/backend/package/yuxi/channels/adapters/urbit/doctor.py index e6eaca1d..d8776002 100644 --- a/backend/package/yuxi/channels/adapters/urbit/doctor.py +++ b/backend/package/yuxi/channels/adapters/urbit/doctor.py @@ -4,7 +4,6 @@ from typing import Any from yuxi.utils.logging_config import logger - _LEGACY_KEY_MAP: dict[str, str] = { "allow_private_network": "network.dangerously_allow_private_network", "allowPrivateNetwork": "network.dangerouslyAllowPrivateNetwork", diff --git a/backend/package/yuxi/channels/adapters/urbit/dual_plugin.py b/backend/package/yuxi/channels/adapters/urbit/dual_plugin.py index cda3d1fd..16527620 100644 --- a/backend/package/yuxi/channels/adapters/urbit/dual_plugin.py +++ b/backend/package/yuxi/channels/adapters/urbit/dual_plugin.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/channels/adapters/urbit/format.py b/backend/package/yuxi/channels/adapters/urbit/format.py index 8caa6a3e..602b2f89 100644 --- a/backend/package/yuxi/channels/adapters/urbit/format.py +++ b/backend/package/yuxi/channels/adapters/urbit/format.py @@ -53,7 +53,7 @@ def parse_graph_update(raw: dict[str, Any]) -> dict[str, Any]: additions = graph_update.get("additions", {}) if additions: - result["content"] = _extract_graph_content(additions) + result["content"] = _extract_graph_content(additions, resource_type=result.get("resource_type", "chat")) if "reaction" in graph_update: result["reaction"] = graph_update["reaction"] @@ -61,11 +61,17 @@ def parse_graph_update(raw: dict[str, Any]) -> dict[str, Any]: return result -def _extract_graph_content(additions: dict[str, Any]) -> str: +def _extract_graph_content(additions: dict[str, Any], resource_type: str = "chat") -> str: parts: list[str] = [] for node_data in additions.values(): post = node_data.get("post", {}) contents = post.get("contents", []) + + if resource_type == "diary": + title = post.get("title", "") + if title: + parts.append(f"\U0001f4dd {title}\n") + for item in contents: if "text" in item: parts.append(item["text"]) diff --git a/backend/package/yuxi/channels/adapters/urbit/history.py b/backend/package/yuxi/channels/adapters/urbit/history.py index 32d61c89..c001c41e 100644 --- a/backend/package/yuxi/channels/adapters/urbit/history.py +++ b/backend/package/yuxi/channels/adapters/urbit/history.py @@ -2,8 +2,7 @@ from __future__ import annotations import asyncio from collections import OrderedDict -from typing import Any, TYPE_CHECKING - +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: pass @@ -62,6 +61,14 @@ class MessageCache: async with self._lock: return msg_id in self._sent_ids + async def update_message(self, msg_id: str, new_content: str) -> bool: + async with self._lock: + for channel_cache in self._cache.values(): + if msg_id in channel_cache: + channel_cache[msg_id]["content"] = new_content + return True + return False + async def clear_channel(self, channel_id: str) -> None: async with self._lock: self._cache.pop(channel_id, None) diff --git a/backend/package/yuxi/channels/adapters/urbit/invites.py b/backend/package/yuxi/channels/adapters/urbit/invites.py index 4f3997d5..7d144aca 100644 --- a/backend/package/yuxi/channels/adapters/urbit/invites.py +++ b/backend/package/yuxi/channels/adapters/urbit/invites.py @@ -1,9 +1,12 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from yuxi.utils.logging_config import logger +if TYPE_CHECKING: + from .client import UrbitClient + class InviteManager: def __init__( @@ -12,12 +15,18 @@ class InviteManager: group_invite_allowlist: list[str] | None = None, auto_accept_dm: bool = False, dm_allowlist: list[str] | None = None, + client: UrbitClient | None = None, + ship_name: str = "", + ship_code: str = "", ): self.auto_accept_groups = auto_accept_groups self.group_invite_allowlist = [s.lstrip("~").lower() for s in (group_invite_allowlist or [])] self.auto_accept_dm = auto_accept_dm self.dm_allowlist = [s.lstrip("~").lower() for s in (dm_allowlist or [])] self._pending_invites: dict[str, dict[str, Any]] = {} + self._client = client + self._ship_name = ship_name + self._ship_code = ship_code def should_auto_accept_group_invite(self, inviter_ship: str) -> bool: if not self.auto_accept_groups: @@ -64,7 +73,48 @@ class InviteManager: return {"action": "left", "group": group, "ship": ship} async def _accept_invite(self, group: str, inviter: str) -> None: - logger.info(f"[Urbit] Would accept group invite: {group} from ~{inviter}") + if not self._client: + logger.warning(f"[Urbit] Cannot accept group invite {group} from ~{inviter}: client not available") + return + + logger.info(f"[Urbit] Accepting group invite: {group} from ~{inviter}") + + try: + payload = { + "action": "poke", + "ship": self._ship_name, + "app": "groups", + "mark": "groups-action", + "json": { + "join": { + "group": group, + "ship": f"~{inviter.lstrip('~')}", + } + }, + } + + r = await self._client.put( + "/~/channel/groups-0", + json=payload, + timeout=10.0, + ) + + if r.status_code == 401 and self._ship_code: + from .auth import refresh_session_if_needed + + await refresh_session_if_needed(self._client, self._ship_code) + payload["ship"] = self._client.ship_name + r = await self._client.put( + "/~/channel/groups-0", + json=payload, + timeout=10.0, + ) + + r.raise_for_status() + logger.info(f"[Urbit] Successfully accepted group invite: {group}") + + except Exception as e: + logger.error(f"[Urbit] Failed to accept group invite {group} from ~{inviter}: {e}") def get_pending_invites(self) -> list[dict[str, Any]]: return list(self._pending_invites.values()) diff --git a/backend/package/yuxi/channels/adapters/urbit/monitor.py b/backend/package/yuxi/channels/adapters/urbit/monitor.py index f85b84c9..7db2db79 100644 --- a/backend/package/yuxi/channels/adapters/urbit/monitor.py +++ b/backend/package/yuxi/channels/adapters/urbit/monitor.py @@ -6,9 +6,9 @@ import random import re import time from collections import OrderedDict -from datetime import datetime, UTC -from typing import Any, TYPE_CHECKING from collections.abc import Callable +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any import httpx @@ -22,18 +22,18 @@ from yuxi.channels.models import ( ) from yuxi.utils.logging_config import logger -from .format import ( - parse_graph_update, - ensure_ship_tilde, - extract_graph_mentions, - extract_graph_attachments, -) from .cites import parse_cite +from .format import ( + ensure_ship_tilde, + extract_graph_attachments, + extract_graph_mentions, + parse_graph_update, +) if TYPE_CHECKING: from .client import UrbitClient - from .threads import ThreadManager from .history import MessageCache + from .threads import ThreadManager _MAX_RECONNECT_DELAY = 30.0 _INITIAL_RECONNECT_DELAY = 1.0 @@ -41,7 +41,7 @@ _MAX_RECONNECT_COUNT = 10 _MAX_CONSECUTIVE_ERRORS = 5 _JITTER = 0.1 _ACK_INTERVAL = 20 -_ALL_PATTERN = re.compile(r"\b@(all|everyone|channel|here)\b", re.IGNORECASE) +_ALL_PATTERN = re.compile(r"(?:^|\s)@(all|everyone|channel|here)\b", re.IGNORECASE) class SSEDeduplicator: @@ -228,13 +228,9 @@ class UrbitSSEManager: logger.warning(f"[Urbit] SSE '{label}' connection lost: {e}. Reconnecting in {delay:.1f}s...") except Exception as e: consecutive_errors += 1 - logger.error( - f"[Urbit] SSE '{label}' unexpected error (#{consecutive_errors}): {e}" - ) + logger.error(f"[Urbit] SSE '{label}' unexpected error (#{consecutive_errors}): {e}") if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS: - logger.critical( - f"[Urbit] SSE '{label}' aborted after {consecutive_errors} consecutive errors" - ) + logger.critical(f"[Urbit] SSE '{label}' aborted after {consecutive_errors} consecutive errors") break reconnect_count += 1 @@ -264,7 +260,7 @@ class UrbitSSEManager: edit = graph_update.get("edit") if edit and isinstance(edit, dict): - self._handle_edit_event(edit, channel_id, label) + await self._handle_edit_event(edit, channel_id, label) return index = str(graph_update.get("index", "")) @@ -336,12 +332,46 @@ class UrbitSSEManager: except Exception as e: logger.error(f"[Urbit] SSE event processing error: {e}") - def _handle_edit_event(self, edit: dict[str, Any], channel_id: str, label: str) -> None: + async def _handle_edit_event(self, edit: dict[str, Any], channel_id: str, label: str) -> None: index = edit.get("index", "") content_blocks = edit.get("content", []) extracted = self._extract_edit_content(content_blocks) - if extracted: - logger.info(f"[Urbit] SSE edit event at index={index}: content updated ({len(extracted)} chars)") + if not extracted: + return + + logger.info(f"[Urbit] SSE edit event at index={index}: content updated ({len(extracted)} chars)") + + resource = edit.get("resource", {}) + resource_path = resource.get("path", "") + resource_type = resource.get("type", "chat") + author = edit.get("author", "") + + chat_type = ChatType.DIRECT if resource_type == "dm" else ChatType.GROUP + identity = ChannelIdentity( + channel_id="urbit", + channel_type=ChannelType.URBIT, + channel_user_id=ensure_ship_tilde(author), + channel_chat_id=resource_path or "unknown", + channel_message_id=str(index) if index else None, + ) + + edit_msg = ChannelMessage( + identity=identity, + chat_type=chat_type, + content=extracted, + message_type="edit", + metadata={ + "urbit_resource_type": resource_type, + "urbit_ship": author, + "chat_type": "direct" if resource_type == "dm" else "group", + "edit_index": index, + }, + ) + + asyncio.create_task(self._on_message(edit_msg)) + + if self._message_cache and index: + await self._message_cache.update_message(str(index), extracted) @staticmethod def _extract_edit_content(contents: list[dict[str, Any]]) -> str: @@ -640,9 +670,9 @@ def _detect_bot_mentions(msg: ChannelMessage, bot_ship: str) -> MentionsInfo | N mentioned = False bot_with_tilde = f"~{bot_ship}" - if re.search(rf"\b@{bot_ship}\b", content, re.IGNORECASE): + if re.search(rf"(?:^|\s)@{bot_ship}\b", content, re.IGNORECASE): mentioned = True - if re.search(rf"\b{bot_with_tilde}\b", content, re.IGNORECASE): + if re.search(rf"(?:^|\s){bot_with_tilde}\b", content, re.IGNORECASE): mentioned = True if not mentioned and "additions" in str(msg.metadata): additions = msg.metadata.get("raw_additions", {}) diff --git a/backend/package/yuxi/channels/adapters/urbit/poke_api.py b/backend/package/yuxi/channels/adapters/urbit/poke_api.py index 62551845..59db5cff 100644 --- a/backend/package/yuxi/channels/adapters/urbit/poke_api.py +++ b/backend/package/yuxi/channels/adapters/urbit/poke_api.py @@ -1,8 +1,8 @@ from __future__ import annotations -from contextlib import asynccontextmanager -from typing import Any, TYPE_CHECKING from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/channels/adapters/urbit/rate_limiter.py b/backend/package/yuxi/channels/adapters/urbit/rate_limiter.py index d1ce5b76..5ab92db8 100644 --- a/backend/package/yuxi/channels/adapters/urbit/rate_limiter.py +++ b/backend/package/yuxi/channels/adapters/urbit/rate_limiter.py @@ -15,6 +15,8 @@ class RateLimiter: self._lock = asyncio.Lock() async def acquire(self) -> bool: + if self._limit <= 0: + return False async with self._lock: now = time.monotonic() elapsed = now - self._last_refill @@ -31,6 +33,8 @@ class RateLimiter: return False async def wait_and_acquire(self, timeout: float = 30.0) -> bool: + if self._limit <= 0: + return False deadline = time.monotonic() + timeout while time.monotonic() < deadline: if await self.acquire(): diff --git a/backend/package/yuxi/channels/adapters/urbit/scry.py b/backend/package/yuxi/channels/adapters/urbit/scry.py index b019c169..c47f8f03 100644 --- a/backend/package/yuxi/channels/adapters/urbit/scry.py +++ b/backend/package/yuxi/channels/adapters/urbit/scry.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any import httpx diff --git a/backend/package/yuxi/channels/adapters/urbit/send.py b/backend/package/yuxi/channels/adapters/urbit/send.py index 4cd3fcda..a91fd3c4 100644 --- a/backend/package/yuxi/channels/adapters/urbit/send.py +++ b/backend/package/yuxi/channels/adapters/urbit/send.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import random from collections.abc import AsyncIterator -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any import httpx diff --git a/backend/package/yuxi/channels/adapters/urbit/session.py b/backend/package/yuxi/channels/adapters/urbit/session.py index 1aeb5b30..19412fdd 100644 --- a/backend/package/yuxi/channels/adapters/urbit/session.py +++ b/backend/package/yuxi/channels/adapters/urbit/session.py @@ -66,10 +66,7 @@ def clear_unsafe_sessions() -> None: def cleanup_expired_sessions() -> int: now = time.monotonic() - expired = [ - key for key, ts in _session_last_access.items() - if now - ts > _SESSION_TTL_S - ] + expired = [key for key, ts in _session_last_access.items() if now - ts > _SESSION_TTL_S] for key in expired: _unsafe_sessions.pop(key, None) _session_last_access.pop(key, None) diff --git a/backend/package/yuxi/channels/adapters/urbit/settings.py b/backend/package/yuxi/channels/adapters/urbit/settings.py index 3de1b961..cf367428 100644 --- a/backend/package/yuxi/channels/adapters/urbit/settings.py +++ b/backend/package/yuxi/channels/adapters/urbit/settings.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/channels/adapters/urbit/summarizer.py b/backend/package/yuxi/channels/adapters/urbit/summarizer.py index 34eed831..4ee2c8a5 100644 --- a/backend/package/yuxi/channels/adapters/urbit/summarizer.py +++ b/backend/package/yuxi/channels/adapters/urbit/summarizer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/channels/adapters/urbit/targets.py b/backend/package/yuxi/channels/adapters/urbit/targets.py index 18bf0398..6cea2512 100644 --- a/backend/package/yuxi/channels/adapters/urbit/targets.py +++ b/backend/package/yuxi/channels/adapters/urbit/targets.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING - if TYPE_CHECKING: pass diff --git a/backend/package/yuxi/channels/adapters/urbit/threads.py b/backend/package/yuxi/channels/adapters/urbit/threads.py index e11cec33..b635e119 100644 --- a/backend/package/yuxi/channels/adapters/urbit/threads.py +++ b/backend/package/yuxi/channels/adapters/urbit/threads.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/channels/adapters/urbit/upload.py b/backend/package/yuxi/channels/adapters/urbit/upload.py index 207d6bef..29428943 100644 --- a/backend/package/yuxi/channels/adapters/urbit/upload.py +++ b/backend/package/yuxi/channels/adapters/urbit/upload.py @@ -4,7 +4,7 @@ import hashlib import os import re import time -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any import httpx @@ -129,7 +129,7 @@ async def _upload_via_s3( try: import hmac - from datetime import datetime, UTC + from datetime import UTC, datetime date_short = datetime.now(UTC).strftime("%Y%m%d") date_long = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") diff --git a/backend/package/yuxi/channels/adapters/urbit/vision.py b/backend/package/yuxi/channels/adapters/urbit/vision.py index 6b819640..2d8f3d9c 100644 --- a/backend/package/yuxi/channels/adapters/urbit/vision.py +++ b/backend/package/yuxi/channels/adapters/urbit/vision.py @@ -1,7 +1,7 @@ from __future__ import annotations import base64 -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from yuxi.utils.logging_config import logger