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.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.directory import DirectoryQuery
from yuxi.channels.adapters.urbit.dual_plugin import ( from yuxi.channels.adapters.urbit.dual_plugin import (
UrbitSetupPlugin,
UrbitAccountPlugin,
DualPluginRegistry, DualPluginRegistry,
UrbitAccountPlugin,
UrbitSetupPlugin,
get_global_registry, 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.invites import InviteManager
from yuxi.channels.adapters.urbit.config_ui_hints import get_config_ui_hints, get_categories, get_hints_by_category from yuxi.channels.adapters.urbit.poke_api import HttpPokeApiClient, create_http_poke_api, with_http_poke_api
__all__ = [ __all__ = [
"UrbitAdapter", "UrbitAdapter",

View File

@ -7,12 +7,12 @@ from typing import Any
from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities 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 ( from yuxi.channels.exceptions import (
ChannelAuthenticationError, ChannelAuthenticationError,
ChannelConnectionError, ChannelConnectionError,
) )
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import ( from yuxi.channels.models import (
ChannelMessage, ChannelMessage,
ChannelResponse, 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.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger from yuxi.utils.logging_config import logger
from .accounts import AccountManager
from .approval import ApprovalSystem
from .auth import authenticate_ship from .auth import authenticate_ship
from .authorization import ChannelAuthorization
from .client import UrbitClient 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 .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 .monitor import SSEDeduplicator, UrbitSSEManager, _graph_update_to_channel_message
from .probe import probe_ship from .probe import probe_ship
from .rate_limiter import RateLimiter from .rate_limiter import RateLimiter
from .send import ( from .send import (
_send_media_poke,
send_del_reaction_poke,
send_delete_poke,
send_edit_poke,
send_poke, send_poke,
send_reaction_poke, send_reaction_poke,
send_del_reaction_poke,
send_edit_poke,
send_delete_poke,
send_unblock_ship_poke, send_unblock_ship_poke,
_send_media_poke,
) )
from .session import resolve_session_route, detect_unsafe_session from .session import detect_unsafe_session, resolve_session_route
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 .settings import SettingsStore
from .discovery import ChannelDiscovery
from .history import MessageCache
from .summarizer import Summarizer from .summarizer import Summarizer
from .threads import ThreadManager
@register_builtin_adapter @register_builtin_adapter
@ -192,6 +192,10 @@ class UrbitAdapter(BaseChannelAdapter):
await probe_ship(self._client) 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: if self._settings_store is None and self._client:
self._settings_store = SettingsStore(self._client) self._settings_store = SettingsStore(self._client)
await self._settings_store.load(self.config) await self._settings_store.load(self.config)
@ -469,7 +473,7 @@ class UrbitAdapter(BaseChannelAdapter):
if rate_limit_result := await self._check_rate_limit(): if rate_limit_result := await self._check_rate_limit():
return rate_limit_result 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] = { metadata: dict[str, str] = {
"chat_type": "group", "chat_type": "group",

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, TYPE_CHECKING from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger 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", {}) additions = graph_update.get("additions", {})
if 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: if "reaction" in graph_update:
result["reaction"] = graph_update["reaction"] result["reaction"] = graph_update["reaction"]
@ -61,11 +61,17 @@ def parse_graph_update(raw: dict[str, Any]) -> dict[str, Any]:
return result 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] = [] parts: list[str] = []
for node_data in additions.values(): for node_data in additions.values():
post = node_data.get("post", {}) post = node_data.get("post", {})
contents = post.get("contents", []) contents = post.get("contents", [])
if resource_type == "diary":
title = post.get("title", "")
if title:
parts.append(f"\U0001f4dd {title}\n")
for item in contents: for item in contents:
if "text" in item: if "text" in item:
parts.append(item["text"]) parts.append(item["text"])

View File

@ -2,8 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict from collections import OrderedDict
from typing import Any, TYPE_CHECKING from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@ -62,6 +61,14 @@ class MessageCache:
async with self._lock: async with self._lock:
return msg_id in self._sent_ids 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 def clear_channel(self, channel_id: str) -> None:
async with self._lock: async with self._lock:
self._cache.pop(channel_id, None) self._cache.pop(channel_id, None)

View File

@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import TYPE_CHECKING, Any
from yuxi.utils.logging_config import logger from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from .client import UrbitClient
class InviteManager: class InviteManager:
def __init__( def __init__(
@ -12,12 +15,18 @@ class InviteManager:
group_invite_allowlist: list[str] | None = None, group_invite_allowlist: list[str] | None = None,
auto_accept_dm: bool = False, auto_accept_dm: bool = False,
dm_allowlist: list[str] | None = None, dm_allowlist: list[str] | None = None,
client: UrbitClient | None = None,
ship_name: str = "",
ship_code: str = "",
): ):
self.auto_accept_groups = auto_accept_groups self.auto_accept_groups = auto_accept_groups
self.group_invite_allowlist = [s.lstrip("~").lower() for s in (group_invite_allowlist or [])] self.group_invite_allowlist = [s.lstrip("~").lower() for s in (group_invite_allowlist or [])]
self.auto_accept_dm = auto_accept_dm self.auto_accept_dm = auto_accept_dm
self.dm_allowlist = [s.lstrip("~").lower() for s in (dm_allowlist or [])] self.dm_allowlist = [s.lstrip("~").lower() for s in (dm_allowlist or [])]
self._pending_invites: dict[str, dict[str, Any]] = {} 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: def should_auto_accept_group_invite(self, inviter_ship: str) -> bool:
if not self.auto_accept_groups: if not self.auto_accept_groups:
@ -64,7 +73,48 @@ class InviteManager:
return {"action": "left", "group": group, "ship": ship} return {"action": "left", "group": group, "ship": ship}
async def _accept_invite(self, group: str, inviter: str) -> None: 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]]: def get_pending_invites(self) -> list[dict[str, Any]]:
return list(self._pending_invites.values()) return list(self._pending_invites.values())

View File

@ -6,9 +6,9 @@ import random
import re import re
import time import time
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime, UTC
from typing import Any, TYPE_CHECKING
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import httpx import httpx
@ -22,18 +22,18 @@ from yuxi.channels.models import (
) )
from yuxi.utils.logging_config import logger 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 .cites import parse_cite
from .format import (
ensure_ship_tilde,
extract_graph_attachments,
extract_graph_mentions,
parse_graph_update,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from .client import UrbitClient from .client import UrbitClient
from .threads import ThreadManager
from .history import MessageCache from .history import MessageCache
from .threads import ThreadManager
_MAX_RECONNECT_DELAY = 30.0 _MAX_RECONNECT_DELAY = 30.0
_INITIAL_RECONNECT_DELAY = 1.0 _INITIAL_RECONNECT_DELAY = 1.0
@ -41,7 +41,7 @@ _MAX_RECONNECT_COUNT = 10
_MAX_CONSECUTIVE_ERRORS = 5 _MAX_CONSECUTIVE_ERRORS = 5
_JITTER = 0.1 _JITTER = 0.1
_ACK_INTERVAL = 20 _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: class SSEDeduplicator:
@ -228,13 +228,9 @@ class UrbitSSEManager:
logger.warning(f"[Urbit] SSE '{label}' connection lost: {e}. Reconnecting in {delay:.1f}s...") logger.warning(f"[Urbit] SSE '{label}' connection lost: {e}. Reconnecting in {delay:.1f}s...")
except Exception as e: except Exception as e:
consecutive_errors += 1 consecutive_errors += 1
logger.error( logger.error(f"[Urbit] SSE '{label}' unexpected error (#{consecutive_errors}): {e}")
f"[Urbit] SSE '{label}' unexpected error (#{consecutive_errors}): {e}"
)
if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS: if consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
logger.critical( logger.critical(f"[Urbit] SSE '{label}' aborted after {consecutive_errors} consecutive errors")
f"[Urbit] SSE '{label}' aborted after {consecutive_errors} consecutive errors"
)
break break
reconnect_count += 1 reconnect_count += 1
@ -264,7 +260,7 @@ class UrbitSSEManager:
edit = graph_update.get("edit") edit = graph_update.get("edit")
if edit and isinstance(edit, dict): if edit and isinstance(edit, dict):
self._handle_edit_event(edit, channel_id, label) await self._handle_edit_event(edit, channel_id, label)
return return
index = str(graph_update.get("index", "")) index = str(graph_update.get("index", ""))
@ -336,12 +332,46 @@ class UrbitSSEManager:
except Exception as e: except Exception as e:
logger.error(f"[Urbit] SSE event processing error: {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", "") index = edit.get("index", "")
content_blocks = edit.get("content", []) content_blocks = edit.get("content", [])
extracted = self._extract_edit_content(content_blocks) extracted = self._extract_edit_content(content_blocks)
if extracted: if not extracted:
logger.info(f"[Urbit] SSE edit event at index={index}: content updated ({len(extracted)} chars)") 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 @staticmethod
def _extract_edit_content(contents: list[dict[str, Any]]) -> str: 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 mentioned = False
bot_with_tilde = f"~{bot_ship}" 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 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 mentioned = True
if not mentioned and "additions" in str(msg.metadata): if not mentioned and "additions" in str(msg.metadata):
additions = msg.metadata.get("raw_additions", {}) additions = msg.metadata.get("raw_additions", {})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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