这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
923 lines
37 KiB
Python
923 lines
37 KiB
Python
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
|