refactor(urbit): 整理导入顺序并修复多处代码细节

1.  统一调整多个文件的导入排序,将TYPE_CHECKING相关导入放在正确位置
2.  修复rate_limiter中当限制数<=0时直接返回false的逻辑
3.  为message_cache新增更新消息内容的方法
4.  重构extract_graph_content支持日记类型内容提取
5.  调整@提及匹配的正则表达式,避免误匹配
6.  完善invite_manager,添加客户端和凭据支持并实现自动接受群邀请逻辑
7.  调整adapter.py中的导入顺序和初始化逻辑
8.  修复monitor中的编辑事件处理,改为异步处理并实现消息更新缓存
9.  调整datetime导入顺序,统一使用UTC在前的格式
This commit is contained in:
Kris 2026-05-13 16:16:27 +08:00
parent 18d1ea2aac
commit 80e3f66974
21 changed files with 164 additions and 69 deletions

View File

@ -1,15 +1,15 @@
from yuxi.channels.adapters.urbit.adapter import UrbitAdapter
from yuxi.channels.adapters.urbit.accounts import AccountManager, MultiAccountConnectionManager
from yuxi.channels.adapters.urbit.invites import InviteManager
from yuxi.channels.adapters.urbit.adapter import UrbitAdapter
from yuxi.channels.adapters.urbit.config_ui_hints import get_categories, get_config_ui_hints, get_hints_by_category
from yuxi.channels.adapters.urbit.directory import DirectoryQuery
from yuxi.channels.adapters.urbit.dual_plugin import (
UrbitSetupPlugin,
UrbitAccountPlugin,
DualPluginRegistry,
UrbitAccountPlugin,
UrbitSetupPlugin,
get_global_registry,
)
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient, with_http_poke_api, create_http_poke_api
from yuxi.channels.adapters.urbit.config_ui_hints import get_config_ui_hints, get_categories, get_hints_by_category
from yuxi.channels.adapters.urbit.invites import InviteManager
from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient, create_http_poke_api, with_http_poke_api
__all__ = [
"UrbitAdapter",

View File

@ -7,12 +7,12 @@ 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.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
@ -25,32 +25,32 @@ 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 .doctor import create_legacy_private_network_doctor_contract, apply_doctor_migrations
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_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 .session import detect_unsafe_session, resolve_session_route
from .settings import SettingsStore
from .discovery import ChannelDiscovery
from .history import MessageCache
from .summarizer import Summarizer
from .threads import ThreadManager
@register_builtin_adapter
@ -192,6 +192,10 @@ class UrbitAdapter(BaseChannelAdapter):
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)
@ -469,7 +473,7 @@ class UrbitAdapter(BaseChannelAdapter):
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
from .format import build_channel_path, build_media_content, build_poke_payload
metadata: dict[str, str] = {
"chat_type": "group",

View File

@ -198,7 +198,6 @@ class ApprovalSystem:
return None
async def persist_to_settings_store(self, store: SettingsStore) -> None:
from .settings import SettingsStore
store_data: dict[str, Any] = {
"pending_approvals": [],

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger

View File

@ -4,7 +4,6 @@ from typing import Any
from yuxi.utils.logging_config import logger
_LEGACY_KEY_MAP: dict[str, str] = {
"allow_private_network": "network.dangerously_allow_private_network",
"allowPrivateNetwork": "network.dangerouslyAllowPrivateNetwork",

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger

View File

@ -53,7 +53,7 @@ def parse_graph_update(raw: dict[str, Any]) -> dict[str, Any]:
additions = graph_update.get("additions", {})
if additions:
result["content"] = _extract_graph_content(additions)
result["content"] = _extract_graph_content(additions, resource_type=result.get("resource_type", "chat"))
if "reaction" in graph_update:
result["reaction"] = graph_update["reaction"]
@ -61,11 +61,17 @@ def parse_graph_update(raw: dict[str, Any]) -> dict[str, Any]:
return result
def _extract_graph_content(additions: dict[str, Any]) -> str:
def _extract_graph_content(additions: dict[str, Any], resource_type: str = "chat") -> str:
parts: list[str] = []
for node_data in additions.values():
post = node_data.get("post", {})
contents = post.get("contents", [])
if resource_type == "diary":
title = post.get("title", "")
if title:
parts.append(f"\U0001f4dd {title}\n")
for item in contents:
if "text" in item:
parts.append(item["text"])

View File

@ -2,8 +2,7 @@ from __future__ import annotations
import asyncio
from collections import OrderedDict
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
pass
@ -62,6 +61,14 @@ class MessageCache:
async with self._lock:
return msg_id in self._sent_ids
async def update_message(self, msg_id: str, new_content: str) -> bool:
async with self._lock:
for channel_cache in self._cache.values():
if msg_id in channel_cache:
channel_cache[msg_id]["content"] = new_content
return True
return False
async def clear_channel(self, channel_id: str) -> None:
async with self._lock:
self._cache.pop(channel_id, None)

View File

@ -1,9 +1,12 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from .client import UrbitClient
class InviteManager:
def __init__(
@ -12,12 +15,18 @@ class InviteManager:
group_invite_allowlist: list[str] | None = None,
auto_accept_dm: bool = False,
dm_allowlist: list[str] | None = None,
client: UrbitClient | None = None,
ship_name: str = "",
ship_code: str = "",
):
self.auto_accept_groups = auto_accept_groups
self.group_invite_allowlist = [s.lstrip("~").lower() for s in (group_invite_allowlist or [])]
self.auto_accept_dm = auto_accept_dm
self.dm_allowlist = [s.lstrip("~").lower() for s in (dm_allowlist or [])]
self._pending_invites: dict[str, dict[str, Any]] = {}
self._client = client
self._ship_name = ship_name
self._ship_code = ship_code
def should_auto_accept_group_invite(self, inviter_ship: str) -> bool:
if not self.auto_accept_groups:
@ -64,7 +73,48 @@ class InviteManager:
return {"action": "left", "group": group, "ship": ship}
async def _accept_invite(self, group: str, inviter: str) -> None:
logger.info(f"[Urbit] Would accept group invite: {group} from ~{inviter}")
if not self._client:
logger.warning(f"[Urbit] Cannot accept group invite {group} from ~{inviter}: client not available")
return
logger.info(f"[Urbit] Accepting group invite: {group} from ~{inviter}")
try:
payload = {
"action": "poke",
"ship": self._ship_name,
"app": "groups",
"mark": "groups-action",
"json": {
"join": {
"group": group,
"ship": f"~{inviter.lstrip('~')}",
}
},
}
r = await self._client.put(
"/~/channel/groups-0",
json=payload,
timeout=10.0,
)
if r.status_code == 401 and self._ship_code:
from .auth import refresh_session_if_needed
await refresh_session_if_needed(self._client, self._ship_code)
payload["ship"] = self._client.ship_name
r = await self._client.put(
"/~/channel/groups-0",
json=payload,
timeout=10.0,
)
r.raise_for_status()
logger.info(f"[Urbit] Successfully accepted group invite: {group}")
except Exception as e:
logger.error(f"[Urbit] Failed to accept group invite {group} from ~{inviter}: {e}")
def get_pending_invites(self) -> list[dict[str, Any]]:
return list(self._pending_invites.values())

View File

@ -6,9 +6,9 @@ import random
import re
import time
from collections import OrderedDict
from datetime import datetime, UTC
from typing import Any, TYPE_CHECKING
from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import httpx
@ -22,18 +22,18 @@ from yuxi.channels.models import (
)
from yuxi.utils.logging_config import logger
from .format import (
parse_graph_update,
ensure_ship_tilde,
extract_graph_mentions,
extract_graph_attachments,
)
from .cites import parse_cite
from .format import (
ensure_ship_tilde,
extract_graph_attachments,
extract_graph_mentions,
parse_graph_update,
)
if TYPE_CHECKING:
from .client import UrbitClient
from .threads import ThreadManager
from .history import MessageCache
from .threads import ThreadManager
_MAX_RECONNECT_DELAY = 30.0
_INITIAL_RECONNECT_DELAY = 1.0
@ -41,7 +41,7 @@ _MAX_RECONNECT_COUNT = 10
_MAX_CONSECUTIVE_ERRORS = 5
_JITTER = 0.1
_ACK_INTERVAL = 20
_ALL_PATTERN = re.compile(r"\b@(all|everyone|channel|here)\b", re.IGNORECASE)
_ALL_PATTERN = re.compile(r"(?:^|\s)@(all|everyone|channel|here)\b", re.IGNORECASE)
class SSEDeduplicator:
@ -228,13 +228,9 @@ class UrbitSSEManager:
logger.warning(f"[Urbit] SSE '{label}' connection lost: {e}. Reconnecting in {delay:.1f}s...")
except Exception as e:
consecutive_errors += 1
logger.error(
f"[Urbit] SSE '{label}' unexpected error (#{consecutive_errors}): {e}"
)
logger.error(f"[Urbit] SSE '{label}' unexpected error (#{consecutive_errors}): {e}")
if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
logger.critical(
f"[Urbit] SSE '{label}' aborted after {consecutive_errors} consecutive errors"
)
logger.critical(f"[Urbit] SSE '{label}' aborted after {consecutive_errors} consecutive errors")
break
reconnect_count += 1
@ -264,7 +260,7 @@ class UrbitSSEManager:
edit = graph_update.get("edit")
if edit and isinstance(edit, dict):
self._handle_edit_event(edit, channel_id, label)
await self._handle_edit_event(edit, channel_id, label)
return
index = str(graph_update.get("index", ""))
@ -336,12 +332,46 @@ class UrbitSSEManager:
except Exception as e:
logger.error(f"[Urbit] SSE event processing error: {e}")
def _handle_edit_event(self, edit: dict[str, Any], channel_id: str, label: str) -> None:
async def _handle_edit_event(self, edit: dict[str, Any], channel_id: str, label: str) -> None:
index = edit.get("index", "")
content_blocks = edit.get("content", [])
extracted = self._extract_edit_content(content_blocks)
if extracted:
logger.info(f"[Urbit] SSE edit event at index={index}: content updated ({len(extracted)} chars)")
if not extracted:
return
logger.info(f"[Urbit] SSE edit event at index={index}: content updated ({len(extracted)} chars)")
resource = edit.get("resource", {})
resource_path = resource.get("path", "")
resource_type = resource.get("type", "chat")
author = edit.get("author", "")
chat_type = ChatType.DIRECT if resource_type == "dm" else ChatType.GROUP
identity = ChannelIdentity(
channel_id="urbit",
channel_type=ChannelType.URBIT,
channel_user_id=ensure_ship_tilde(author),
channel_chat_id=resource_path or "unknown",
channel_message_id=str(index) if index else None,
)
edit_msg = ChannelMessage(
identity=identity,
chat_type=chat_type,
content=extracted,
message_type="edit",
metadata={
"urbit_resource_type": resource_type,
"urbit_ship": author,
"chat_type": "direct" if resource_type == "dm" else "group",
"edit_index": index,
},
)
asyncio.create_task(self._on_message(edit_msg))
if self._message_cache and index:
await self._message_cache.update_message(str(index), extracted)
@staticmethod
def _extract_edit_content(contents: list[dict[str, Any]]) -> str:
@ -640,9 +670,9 @@ def _detect_bot_mentions(msg: ChannelMessage, bot_ship: str) -> MentionsInfo | N
mentioned = False
bot_with_tilde = f"~{bot_ship}"
if re.search(rf"\b@{bot_ship}\b", content, re.IGNORECASE):
if re.search(rf"(?:^|\s)@{bot_ship}\b", content, re.IGNORECASE):
mentioned = True
if re.search(rf"\b{bot_with_tilde}\b", content, re.IGNORECASE):
if re.search(rf"(?:^|\s){bot_with_tilde}\b", content, re.IGNORECASE):
mentioned = True
if not mentioned and "additions" in str(msg.metadata):
additions = msg.metadata.get("raw_additions", {})

View File

@ -1,8 +1,8 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any, TYPE_CHECKING
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger

View File

@ -15,6 +15,8 @@ class RateLimiter:
self._lock = asyncio.Lock()
async def acquire(self) -> bool:
if self._limit <= 0:
return False
async with self._lock:
now = time.monotonic()
elapsed = now - self._last_refill
@ -31,6 +33,8 @@ class RateLimiter:
return False
async def wait_and_acquire(self, timeout: float = 30.0) -> bool:
if self._limit <= 0:
return False
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if await self.acquire():

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import httpx

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import random
from collections.abc import AsyncIterator
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import httpx

View File

@ -66,10 +66,7 @@ def clear_unsafe_sessions() -> None:
def cleanup_expired_sessions() -> int:
now = time.monotonic()
expired = [
key for key, ts in _session_last_access.items()
if now - ts > _SESSION_TTL_S
]
expired = [key for key, ts in _session_last_access.items() if now - ts > _SESSION_TTL_S]
for key in expired:
_unsafe_sessions.pop(key, None)
_session_last_access.pop(key, None)

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger

View File

@ -2,7 +2,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger

View File

@ -4,7 +4,7 @@ import hashlib
import os
import re
import time
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import httpx
@ -129,7 +129,7 @@ async def _upload_via_s3(
try:
import hmac
from datetime import datetime, UTC
from datetime import UTC, datetime
date_short = datetime.now(UTC).strftime("%Y%m%d")
date_long = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")

View File

@ -1,7 +1,7 @@
from __future__ import annotations
import base64
from typing import Any, TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger