from __future__ import annotations import json import os from collections.abc import AsyncIterator 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.models import ( ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, HealthStatus, ) 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 .auth import authenticate_ship from .client import UrbitClient from .doctor import create_legacy_private_network_doctor_contract, apply_doctor_migrations from .format import build_channel_path, build_dm_channel_id, build_poke_payload from .monitor import SSEDeduplicator, UrbitSSEManager, _graph_update_to_channel_message from .probe import probe_ship from .rate_limiter import RateLimiter from .send import ( 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 .settings import SettingsStore from .discovery import ChannelDiscovery from .history import MessageCache from .summarizer import Summarizer @register_builtin_adapter class UrbitAdapter(BaseChannelAdapter): channel_id = "urbit" channel_type = ChannelType.URBIT text_chunk_limit = 10000 supports_markdown = False supports_streaming = True streaming_modes = ["off", "partial"] max_media_size_mb = 100 # reactions, edit, unsend 为 ForcePilot 独有增强能力(OpenClaw/Tlon 不支持) capabilities = ChannelCapabilities( chat_types=["direct", "group", "thread"], media=True, reply=True, reactions=True, edit=True, unsend=True, threads=True, supports_markdown=False, supports_streaming=True, streaming_modes=["off", "partial"], text_chunk_limit=10000, max_media_size_mb=100, ) meta = ChannelMeta(id="urbit", label="Urbit") def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._client: UrbitClient | None = None self._ship_code: str = "" self._ship_url: str = "" self._circuit_breaker = CircuitBreaker(failure_threshold=5) self._sse_manager: UrbitSSEManager | None = None self._active_subscriptions: set[str] = set() self._dedup = SSEDeduplicator() rate_limit_config = self.config.get("rate_limit", {}) self._rate_limiter = RateLimiter( limit=rate_limit_config.get("max_per_minute", 30), window=rate_limit_config.get("window_s", 60.0), ) self._approval_system = ApprovalSystem( owner_ship=self.config.get("owner_ship", ""), ) self._account_manager = AccountManager(self.config.get("accounts")) self._invite_manager = InviteManager( auto_accept_groups=self.config.get("autoAcceptGroupInvites", False), group_invite_allowlist=self.config.get("groupInviteAllowlist", []), auto_accept_dm=self.config.get("autoAcceptDmInvites", False), dm_allowlist=self.config.get("dm_allowlist", []), ) self._thread_manager = ThreadManager( max_history=self.config.get("thread", {}).get("max_history", 20), context_lines=self.config.get("thread", {}).get("context_lines", 10), ) self._channel_auth = ChannelAuthorization(self.config) self._settings_store: SettingsStore | None = None self._session_warning_sent: set[str] = set() self._channel_discovery: ChannelDiscovery | None = None self._message_cache = MessageCache() self._summarizer = Summarizer(self._message_cache) @property def status(self) -> str: return self._status.value def _resolve_ship_code(self) -> str: return self.config.get("ship_code", "") or os.environ.get("URBIT_SHIP_CODE", "") async def _check_rate_limit(self) -> DeliveryResult | None: if not await self._rate_limiter.wait_and_acquire(timeout=5.0): return DeliveryResult(success=False, error="Rate limit exceeded, please retry later") return None async def pre_connect(self) -> dict[str, Any]: ship_url = self.config.get("ship_url", "") or os.environ.get("URBIT_SHIP_URL", "") ship_name = self.config.get("ship_name", "").lstrip("~") or os.environ.get("URBIT_SHIP_NAME", "").lstrip("~") errors: list[str] = [] if not ship_url: errors.append("ship_url is required in config") elif not ship_url.startswith(("http://", "https://")): errors.append(f"ship_url must start with http:// or https://, got: {ship_url}") if not ship_name: errors.append("ship_name is required in config") ship_code = self._resolve_ship_code() if not ship_code: errors.append("ship_code is required (set URBIT_SHIP_CODE env or config.ship_code)") if errors: raise ChannelAuthenticationError("; ".join(errors)) return {"ship_url": ship_url, "ship_name": ship_name} async def _do_reauth(self) -> None: if self._client and self._ship_code: from .auth import authenticate_ship await authenticate_ship(self._client, self._ship_code) async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return self._status = ChannelStatus.CONNECTING logger.info(f"[Urbit] Starting channel '{self.channel_id}'...") try: self._ship_url = self.config.get("ship_url", "http://localhost:8080") or os.environ.get( "URBIT_SHIP_URL", "http://localhost:8080" ) ship_name = self.config.get("ship_name", "").lstrip("~") or os.environ.get("URBIT_SHIP_NAME", "").lstrip( "~" ) if not ship_name: raise ChannelAuthenticationError("ship_name is required in config") self._ship_code = self._resolve_ship_code() if not self._ship_code: raise ChannelAuthenticationError("ship_code is required (set URBIT_SHIP_CODE env or config.ship_code)") doctor_migrations = create_legacy_private_network_doctor_contract(self.config) if doctor_migrations: self.config = apply_doctor_migrations(self.config, doctor_migrations) self._client = UrbitClient( ship_url=self._ship_url, ship_name=ship_name, timeout=self.config.get("timeout", 15.0), ) await self._client.start() await authenticate_ship(self._client, self._ship_code) await probe_ship(self._client) if self._settings_store is None and self._client: self._settings_store = SettingsStore(self._client) await self._settings_store.load(self.config) self._thread_manager.bind_settings_store(self._settings_store) await self._thread_manager.restore_from_store() migrations = await self._settings_store.build_settings_migrations() if migrations: logger.info(f"[Urbit] Running {len(migrations)} config→Settings Store migrations...") for mig in migrations: await self._settings_store.poke_migration(mig["bucket_key"], mig["entry_key"], mig["value"]) self._apply_settings_to_runtime() await self._approval_system.load_from_settings_store(self._client) self._sse_manager = UrbitSSEManager( client=self._client, ship_url=self._ship_url, on_message=self._handle_sse_message, status_check=lambda: self._status == ChannelStatus.CONNECTED, reconnect_delay=self.config.get("sse", {}).get("reconnect_delay_s", 5), dedup=self._dedup, bot_ship_name=ship_name, reauth_callback=self._do_reauth, thread_manager=self._thread_manager, message_cache=self._message_cache, ) await self._sse_manager.start( { "chat": "/~/channel/chat-store", "channels": "/~/channel/channels-server", "contacts": "/~/channel/contacts-store", "foreigns": "/~/channel/groups-store", "groups-ui": "/~/channel/groups-ui", "settings": "/~/channel/settings-store", } ) self._sse_manager.set_foreign_update_handler(self._invite_manager.handle_foreign_update) self._sse_manager.set_settings_update_handler(self._handle_settings_update) self._sse_manager.set_groups_ui_update_handler(self._handle_groups_ui_update) self._channel_discovery = ChannelDiscovery( client=self._client, auto_discover=self.config.get("autoDiscoverChannels", False), group_channels=self.config.get("groupChannels"), settings_store=self._settings_store, ) await self._channel_discovery.start() self._status = ChannelStatus.CONNECTED logger.info( f"[Urbit] Channel '{self.channel_id}' connected to {self._ship_url} (Ship: ~{self._client.ship_name})" ) except ChannelAuthenticationError: self._status = ChannelStatus.ERROR raise except ChannelConnectionError: self._status = ChannelStatus.ERROR raise except Exception as e: self._status = ChannelStatus.ERROR logger.error(f"[Urbit] Failed to start channel '{self.channel_id}': {e}") raise async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[Urbit] Stopping channel '{self.channel_id}'...") try: if self._channel_discovery: await self._channel_discovery.stop() self._channel_discovery = None if self._sse_manager: await self._sse_manager.stop() self._sse_manager = None if self._client: await self._client.stop() self._client = None self._active_subscriptions.clear() self._status = ChannelStatus.DISCONNECTED logger.info(f"[Urbit] Channel '{self.channel_id}' stopped") except Exception as e: logger.error(f"[Urbit] Error stopping channel '{self.channel_id}': {e}") self._status = ChannelStatus.ERROR async def send(self, response: ChannelResponse) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Client not initialized") if self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") if rate_limit_result := await self._check_rate_limit(): return rate_limit_result response.metadata = dict(response.metadata) response.metadata.setdefault("host_ship", self._client.ship_name) retry_config = self.config.get("retry", {}) if len(response.content) > self.text_chunk_limit: return await self._send_chunked(response, retry_config) try: return await self._circuit_breaker.call( lambda: send_poke(self._client, response, retry_config, ship_code=self._ship_code) ) except CircuitBreakerOpenError: return DeliveryResult( success=False, error="Circuit breaker is open, Urbit Ship unavailable", ) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def _send_chunked(self, response: ChannelResponse, retry_config: dict[str, Any]) -> DeliveryResult: content = response.content chunk_size = self.text_chunk_limit chunks: list[str] = [] while content: chunks.append(content[:chunk_size]) content = content[chunk_size:] last_result = DeliveryResult(success=False, error="No chunks sent") for i, chunk in enumerate(chunks): chunk_response = ChannelResponse( identity=response.identity, content=chunk, message_type=response.message_type, attachments=response.attachments if i == len(chunks) - 1 else [], reply_to_message_id=response.reply_to_message_id if i == 0 else None, metadata={**response.metadata, "chunk_index": i, "chunk_total": len(chunks)}, ) try: result = await self._circuit_breaker.call( lambda cr=chunk_response: send_poke(self._client, cr, retry_config, ship_code=self._ship_code) ) except CircuitBreakerOpenError: return DeliveryResult( success=False, error="Circuit breaker is open during chunked send", ) except Exception as e: result = DeliveryResult(success=False, error=str(e)) if result.success: last_result = result elif i == 0: return result elif i < len(chunks) - 1: logger.warning(f"[Urbit] Chunk {i + 1}/{len(chunks)} failed: {result.error}") return last_result async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._client or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") metadata = {"chat_type": "group", "host_ship": self._client.ship_name} retry_config = self.config.get("retry", {}) try: return await self._circuit_breaker.call( lambda: send_reaction_poke( self._client, chat_id, msg_id, emoji, metadata, retry_config, ship_code=self._ship_code, ) ) except CircuitBreakerOpenError: return DeliveryResult( success=False, error="Circuit breaker is open, Urbit Ship unavailable", ) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_del_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._client or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") metadata = {"chat_type": "group", "host_ship": self._client.ship_name} retry_config = self.config.get("retry", {}) try: return await self._circuit_breaker.call( lambda: send_del_reaction_poke( self._client, chat_id, msg_id, emoji, metadata, retry_config, ship_code=self._ship_code, ) ) except CircuitBreakerOpenError: return DeliveryResult( success=False, error="Circuit breaker is open, Urbit Ship unavailable", ) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._client or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") metadata = {"chat_type": "group", "host_ship": self._client.ship_name} retry_config = self.config.get("retry", {}) try: return await self._circuit_breaker.call( lambda: send_edit_poke( self._client, chat_id, msg_id, content, metadata, retry_config, ship_code=self._ship_code, ) ) except CircuitBreakerOpenError: return DeliveryResult( success=False, error="Circuit breaker is open, Urbit Ship unavailable", ) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._client or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") metadata = {"chat_type": "group", "host_ship": self._client.ship_name} retry_config = self.config.get("retry", {}) try: return await self._circuit_breaker.call( lambda: send_delete_poke( self._client, chat_id, msg_id, metadata, retry_config, ship_code=self._ship_code, ) ) except CircuitBreakerOpenError: return DeliveryResult( success=False, error="Circuit breaker is open, Urbit Ship unavailable", ) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: if not self._client or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Not connected") 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 metadata: dict[str, str] = { "chat_type": "group", "group_name": chat_id, "host_ship": self._client.ship_name, } channel_path = build_channel_path(**{**metadata, "chat_type": "group"}) host_ship = self._client.ship_name media = build_media_content(media_type, str(data)) payload = build_poke_payload( host_ship=host_ship, content="", channel_path=channel_path, media_content=media, ) response = ChannelResponse( identity=self._build_stream_identity(chat_id, ""), content="", metadata=metadata, ) retry_config = self.config.get("retry", {}) try: return await self._circuit_breaker.call( lambda: _send_media_poke(self._client, response, payload, retry_config, ship_code=self._ship_code) ) except CircuitBreakerOpenError: return DeliveryResult( success=False, error="Circuit breaker is open, Urbit Ship unavailable", ) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def receive(self) -> AsyncIterator[ChannelMessage]: return yield # type: ignore[misc] def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage: return _graph_update_to_channel_message(raw, self._client.ship_name if self._client else "unknown") def format_outbound(self, response: ChannelResponse) -> dict[str, Any]: chat_type = response.metadata.get("chat_type", "group") host_ship = response.metadata.get("host_ship", "") channel_path = build_channel_path(**{**response.metadata, "chat_type": chat_type}) return build_poke_payload( host_ship=host_ship, content=response.content, channel_path=channel_path, continuation=response.metadata.get("continuation", False), reply_to=response.reply_to_message_id, chat_type=chat_type, ) async def health_check(self) -> HealthStatus: if not self._client: return HealthStatus( status="unhealthy", last_error="Client not initialized", metadata={"adapter_status": self._status.value}, ) try: await probe_ship(self._client) return HealthStatus( status="healthy", metadata={ "ship_url": self._ship_url, "ship_name": f"~{self._client.ship_name}", "adapter_status": self._status.value, }, last_connected_at=utc_now_naive(), ) except ChannelConnectionError as e: return HealthStatus( status="degraded", last_error=str(e), metadata={"adapter_status": self._status.value}, ) except Exception as e: return HealthStatus( status="unhealthy", last_error=str(e), metadata={"adapter_status": self._status.value}, ) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: streaming_mode = self.config.get("streaming", "off") if streaming_mode == "off": if finished: identity = self._build_stream_identity(chat_id, msg_id) response = ChannelResponse( identity=identity, content=chunk, metadata={"chat_type": "group", "host_ship": self._client.ship_name if self._client else ""}, ) return await self.send(response) return DeliveryResult(success=True) identity = self._build_stream_identity(chat_id, msg_id) response = ChannelResponse( identity=identity, content=chunk, metadata={ "chat_type": "group", "host_ship": self._client.ship_name if self._client else "", "continuation": not finished, }, ) return await self.send(response) async def _handle_sse_message(self, message: ChannelMessage) -> None: dm_policy = self.config.get("dm_policy", "pairing") group_policy = self.config.get("group_policy", "open") chat_type = message.metadata.get("chat_type", "group") sender_ship = message.metadata.get("urbit_ship", "") channel_chat_id = message.identity.channel_chat_id if self._approval_system.is_ship_blocked(sender_ship): logger.debug(f"[Urbit] Ignoring message from blocked ship ~{sender_ship}") return owner_ship = self.config.get("owner_ship", "").lstrip("~") if message.content and message.content.strip().startswith("/"): is_owner = owner_ship and sender_ship.lower() == owner_ship.lower() if not is_owner: logger.info( f"[Urbit] Slash command rejected: " f"~{sender_ship} [user] in {channel_chat_id}: {message.content[:80]}" ) return role_tag = "owner" if is_owner else "user" message.content = f"~{sender_ship} [{role_tag}] in {channel_chat_id}:\n{message.content}" logger.debug(f"[Urbit] Slash command authorized: ~{sender_ship} [{role_tag}]") if chat_type == "direct": unsafe_participants = detect_unsafe_session(message) if unsafe_participants: owner_ship = self.config.get("owner_ship", "").lstrip("~") session_id = f"{sender_ship}:{channel_chat_id}" if owner_ship and session_id not in self._session_warning_sent: self._session_warning_sent.add(session_id) await self._send_dm_to_ship( owner_ship, f"[Unsafe Session Alert] DM session has " f"{len(unsafe_participants)} participants: " f"{', '.join('~' + p for p in unsafe_participants)}", ) if self._approval_system.is_approval_command(message.content): owner_ship = self.config.get("owner_ship", "").lstrip("~") if owner_ship and sender_ship.lower() == owner_ship.lower(): result = self._approval_system.handle_admin_command(message.content, sender_ship) if result: await self._send_dm_to_ship(owner_ship, result) content_lower = message.content.strip().lower() if content_lower.startswith("unblock "): ship = message.content.strip().split(" ", 1)[1].strip() await send_unblock_ship_poke(self._client, ship, ship_code=self._ship_code) return if not self._check_dm_policy(sender_ship, dm_policy): logger.warning(f"[Urbit] DM from ~{sender_ship} blocked by dm_policy='{dm_policy}'") await self._maybe_create_approval(sender_ship, message) return if chat_type == "group": if not self._channel_auth.is_channel_allowed(channel_chat_id, sender_ship): logger.warning(f"[Urbit] Channel message from ~{sender_ship} blocked in {channel_chat_id}") await self._maybe_create_channel_approval(sender_ship, message) return if not self._check_group_policy(sender_ship, group_policy): logger.warning(f"[Urbit] Group message from ~{sender_ship} blocked by group_policy='{group_policy}'") return message.metadata["session_route"] = resolve_session_route(message) summary_info = await self._summarizer.auto_detect_and_respond(channel_chat_id, message.content) if summary_info: message.metadata["summarization"] = summary_info await self._handle_message(message) async def _maybe_create_approval(self, sender_ship: str, message: ChannelMessage) -> None: owner_ship = self.config.get("owner_ship", "").lstrip("~") if not owner_ship: return channel_id = message.identity.channel_chat_id if self._approval_system.has_duplicate_pending("dm", sender_ship, channel_id): logger.debug(f"[Urbit] Duplicate DM approval request from ~{sender_ship}, skipping") return approval = self._approval_system.create_pending( approval_type="dm", requester_ship=sender_ship, channel_id=channel_id, message_content=message.content, ) notification = self._approval_system.build_owner_notification(approval) instructions = self._approval_system.build_approval_instructions(approval.id) combined = f"{notification}\n\n{instructions}" await self._send_dm_to_ship(owner_ship, combined) async def _maybe_create_channel_approval(self, sender_ship: str, message: ChannelMessage) -> None: owner_ship = self.config.get("owner_ship", "").lstrip("~") if not owner_ship: return channel_id = message.identity.channel_chat_id if self._approval_system.has_duplicate_pending("channel", sender_ship, channel_id): logger.debug(f"[Urbit] Duplicate channel approval request from ~{sender_ship}, skipping") return approval = self._approval_system.create_pending( approval_type="channel", requester_ship=sender_ship, channel_id=channel_id, message_content=message.content, ) notification = self._approval_system.build_owner_notification(approval) instructions = self._approval_system.build_approval_instructions(approval.id) combined = f"[Channel Message Approval]\n{notification}\n\n{instructions}" await self._send_dm_to_ship(owner_ship, combined) async def _handle_settings_update(self, settings_update: dict[str, Any]) -> None: if not self._settings_store: return action = settings_update.get("action", "") bucket_key = settings_update.get("bucket_key", "") entry_key = settings_update.get("entry_key", "") value = settings_update.get("value") try: if action == "put" and value is not None: prev = self._settings_store.get(bucket_key, {}) if isinstance(prev, dict): merged = {**prev, entry_key: value} else: merged = {entry_key: value} self._settings_store.merge_key(bucket_key, merged) logger.debug(f"[Urbit] Settings merged: {bucket_key}/{entry_key}") elif action == "del": prev = self._settings_store.get(bucket_key, {}) if isinstance(prev, dict): prev.pop(entry_key, None) self._settings_store.merge_key(bucket_key, prev) logger.debug(f"[Urbit] Settings removed: {bucket_key}/{entry_key}") self._apply_settings_to_runtime() except Exception as e: logger.warning(f"[Urbit] Settings merge error: {e}") def _apply_settings_to_runtime(self) -> None: if not self._settings_store: return store_data = self._settings_store.get_all() dm_allowlist = store_data.get("dmAllowlist") if dm_allowlist is not None and isinstance(dm_allowlist, list): self.config["dm_allowlist"] = dm_allowlist group_channels = store_data.get("groupChannels") if group_channels is not None and isinstance(group_channels, list): self.config["groupChannels"] = group_channels if self._channel_discovery: for ch in group_channels: self._channel_discovery.add_channel(ch) channel_rules_raw = store_data.get("channelRules") if channel_rules_raw is not None: if isinstance(channel_rules_raw, str): try: channel_rules = json.loads(channel_rules_raw) except json.JSONDecodeError: logger.warning("[Urbit] Failed to parse channelRules JSON") channel_rules = None else: channel_rules = channel_rules_raw if isinstance(channel_rules, dict): self.config.setdefault("authorization", {}) self.config["authorization"]["channel_rules"] = channel_rules self._channel_auth = ChannelAuthorization(self.config) pending_approvals_raw = store_data.get("pendingApprovals") if pending_approvals_raw is not None: if isinstance(pending_approvals_raw, str): try: pending_approvals = json.loads(pending_approvals_raw) except json.JSONDecodeError: pending_approvals = None else: pending_approvals = pending_approvals_raw if isinstance(pending_approvals, list): for item in pending_approvals: if isinstance(item, dict) and item.get("status") == "pending": sid = item.get("id", "") if sid and not self._approval_system._pending.get(sid): from .approval import PendingApproval approval = PendingApproval( id=sid, approval_type=item.get("type", "dm"), requester_ship=item.get("requester", ""), channel_id=item.get("channel", ""), message_content=item.get("content", ""), created_at=item.get("created_at", 0), status="pending", ) self._approval_system._pending[sid] = approval logger.debug("[Urbit] Runtime config updated from Settings Store hot-reload") async def _handle_groups_ui_update(self, update: dict[str, Any]) -> None: action = update.get("action", "") group_path = update.get("group_path", "") or update.get("group_id", "") if action in ("add", "join") and group_path: logger.info(f"[Urbit] Groups-UI {action}: {group_path}") channels = update.get("channels", []) if isinstance(channels, list): for ch in channels: if isinstance(ch, str) and ch.startswith("chat/") and self._channel_discovery: self._channel_discovery.add_channel(ch) if self._channel_discovery: try: await self._channel_discovery.persist_group_channels() except Exception as e: logger.warning(f"[Urbit] Failed to persist group channels after groups-ui update: {e}") if action == "kick" and group_path and self._channel_discovery: logger.info(f"[Urbit] Groups-UI kick: {group_path}") async def _send_dm_to_ship(self, ship: str, content: str) -> None: if not self._client: return if await self._check_rate_limit(): logger.warning(f"[Urbit] DM to ~{ship} rate-limited, notification skipped") return from yuxi.channels.models import ChannelIdentity identity = ChannelIdentity( channel_id="urbit", channel_type=ChannelType.URBIT, channel_user_id=f"~{ship}", channel_chat_id=build_dm_channel_id(ship, self._client.ship_name), ) fake_response = ChannelResponse( identity=identity, content=content, metadata={"chat_type": "direct", "host_ship": self._client.ship_name, "target_ship": ship}, ) try: await send_poke(self._client, fake_response, {}, ship_code=self._ship_code) except Exception as e: logger.error(f"[Urbit] Failed to send DM to ~{ship}: {e}") def _check_dm_policy(self, sender_ship: str, policy: str) -> bool: owner_ship = self.config.get("owner_ship", "").lstrip("~") if owner_ship and sender_ship.lower() == owner_ship.lower(): return True match policy: case "open": return True case "disabled": return False case "pairing": return self._approval_system.has_approved(sender_ship) case "allowlist": allow_from = self.config.get("dm_allowlist", []) or self.config.get("allow_from", []) return f"urbit:{sender_ship}" in allow_from case _: return False async def fetch_thread_history(self, resource_path: str, parent_id: str) -> list[dict[str, Any]]: if not self._client or not self._thread_manager: return [] return await self._thread_manager.fetch_thread_history( self._client, self._client.ship_name, resource_path, parent_id, ) def build_thread_context(self, history: list[dict[str, Any]]) -> str: if not self._thread_manager: return "" return self._thread_manager.build_thread_context(history) async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: ship = channel_user_id.lstrip("~") return { "id": f"urbit:{ship}", "username": ship, "display_name": f"~{ship}", "channel_type": self.channel_type.value, } async def download_media(self, file_id: str) -> bytes: from .probe import validate_urbit_url is_valid, warnings = validate_urbit_url(file_id) if not is_valid: raise ValueError(f"SSRF blocked: invalid URL - {'; '.join(warnings)}") if self._client and file_id.startswith(self._client.base_url): r = await self._client.http.get(file_id, timeout=30.0) r.raise_for_status() return r.content import httpx url = file_id async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: r = await client.get(url) r.raise_for_status() return r.content def _check_group_policy(self, sender_ship: str, policy: str) -> bool: match policy: case "open": return True case "disabled": return False case "allowlist": group_allow = self.config.get("group_allow_from", []) if f"urbit:{sender_ship}" in group_allow: return True groups_config = self.config.get("groups", {}) for group_config in groups_config.values(): per_allow = group_config.get("allow_from", []) if f"urbit:{sender_ship}" in per_allow: return True return False case _: return False