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.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, 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 .accounts import AccountManager from .approval import ApprovalSystem from .auth import authenticate_ship from .authorization import ChannelAuthorization from .client import UrbitClient 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_unblock_ship_poke, ) from .session import detect_unsafe_session, resolve_session_route from .settings import SettingsStore from .summarizer import Summarizer from .threads import ThreadManager @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) 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) 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_media_content, build_poke_payload 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