ForcePilot/backend/package/yuxi/channels/adapters/urbit/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

923 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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