ForcePilot/backend/package/yuxi/channels/adapters/urbit/adapter.py

927 lines
38 KiB
Python
Raw Normal View History

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