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

444 lines
13 KiB
Python

from __future__ import annotations
import asyncio
import random
from collections.abc import AsyncIterator
from typing import Any, TYPE_CHECKING
import httpx
from yuxi.channels.exceptions import ChannelAuthenticationError
from yuxi.channels.models import ChannelResponse, DeliveryResult
from yuxi.utils.logging_config import logger
from .auth import refresh_session_if_needed
from .format import build_channel_path, build_chat_action_payload, build_poke_payload
if TYPE_CHECKING:
from .client import UrbitClient
_DEFAULT_RETRY_CONFIG: dict[str, Any] = {
"attempts": 3,
"min_delay_ms": 500,
"max_delay_ms": 30000,
"jitter": 0.1,
}
async def _retry_with_backoff(
max_retries: int,
min_delay_ms: int,
max_delay_ms: int,
jitter: float,
operation_name: str,
) -> AsyncIterator[int]:
for attempt in range(max_retries):
yield attempt
if attempt < max_retries - 1:
delay = min(max_delay_ms / 1000, (min_delay_ms / 1000) * (2**attempt))
delay += random.uniform(0, delay * jitter)
logger.warning(f"[Urbit] {operation_name} retry {attempt + 1}/{max_retries} after {delay:.1f}s")
await asyncio.sleep(delay)
async def _handle_401_if_needed(
r: httpx.Response,
attempt: int,
max_retries: int,
client: UrbitClient,
ship_code: str,
payload: dict[str, Any],
) -> bool:
if r.status_code == 401 and attempt < max_retries - 1:
if ship_code:
await refresh_session_if_needed(client, ship_code)
payload["ship"] = client.ship_name
return True
return False
async def send_poke(
client: UrbitClient,
response: ChannelResponse,
retry_config: dict[str, Any] | None = None,
*,
ship_code: str = "",
) -> DeliveryResult:
cfg = {**_DEFAULT_RETRY_CONFIG, **(retry_config or {})}
max_retries = cfg["attempts"]
min_delay_ms = cfg["min_delay_ms"]
max_delay_ms = cfg["max_delay_ms"]
jitter = cfg["jitter"]
chat_type = response.metadata.get("chat_type", "group")
channel_path = build_channel_path(**{**response.metadata, "chat_type": chat_type})
host_ship = client.ship_name
payload = 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,
)
last_error = None
async for attempt in _retry_with_backoff(max_retries, min_delay_ms, max_delay_ms, jitter, "Send"):
try:
r = await client.put(
f"/~/channel/{_build_channel_id(response)}",
json=payload,
timeout=15.0,
)
if await _handle_401_if_needed(r, attempt, max_retries, client, ship_code, payload):
payload["json"]["envelope"]["author"] = f"~{client.ship_name}"
continue
r.raise_for_status()
return DeliveryResult(success=True)
except httpx.HTTPStatusError as e:
last_error = f"Urbit poke failed ({e.response.status_code})"
if e.response.status_code in (404, 403):
return DeliveryResult(success=False, error=last_error)
except httpx.RequestError as e:
last_error = f"Urbit Ship unreachable: {e}"
except ChannelAuthenticationError:
last_error = "Session recovery failed"
return DeliveryResult(success=False, error=last_error or "Send failed after retries")
async def send_reaction_poke(
client: UrbitClient,
chat_id: str,
msg_id: str,
emoji: str,
metadata: dict[str, Any] | None = None,
retry_config: dict[str, Any] | None = None,
*,
ship_code: str = "",
) -> DeliveryResult:
return await _send_chat_action(
client=client,
chat_id=chat_id,
action="add-react",
msg_id=msg_id,
metadata=metadata,
retry_config=retry_config,
emoji=emoji,
ship_code=ship_code,
)
async def send_del_reaction_poke(
client: UrbitClient,
chat_id: str,
msg_id: str,
emoji: str,
metadata: dict[str, Any] | None = None,
retry_config: dict[str, Any] | None = None,
*,
ship_code: str = "",
) -> DeliveryResult:
return await _send_chat_action(
client=client,
chat_id=chat_id,
action="del-react",
msg_id=msg_id,
metadata=metadata,
retry_config=retry_config,
emoji=emoji,
ship_code=ship_code,
)
async def send_edit_poke(
client: UrbitClient,
chat_id: str,
msg_id: str,
content: str,
metadata: dict[str, Any] | None = None,
retry_config: dict[str, Any] | None = None,
*,
ship_code: str = "",
) -> DeliveryResult:
return await _send_chat_action(
client=client,
chat_id=chat_id,
action="edit",
msg_id=msg_id,
metadata=metadata,
retry_config=retry_config,
content=content,
ship_code=ship_code,
)
async def send_delete_poke(
client: UrbitClient,
chat_id: str,
msg_id: str,
metadata: dict[str, Any] | None = None,
retry_config: dict[str, Any] | None = None,
*,
ship_code: str = "",
) -> DeliveryResult:
return await _send_chat_action(
client=client,
chat_id=chat_id,
action="del",
msg_id=msg_id,
metadata=metadata,
retry_config=retry_config,
ship_code=ship_code,
)
async def send_block_ship_poke(
client: UrbitClient,
ship: str,
*,
ship_code: str = "",
) -> DeliveryResult:
target = ship.lstrip("~")
payload = {
"action": "poke",
"ship": client.ship_name,
"app": "chat",
"mark": "chat-block-ship",
"json": {"ship": f"~{target}"},
}
try:
r = await client.put(
"/~/channel/chat-0",
json=payload,
timeout=10.0,
)
if r.status_code == 401 and ship_code:
from .auth import refresh_session_if_needed
await refresh_session_if_needed(client, ship_code)
payload["ship"] = client.ship_name
r = await client.put(
"/~/channel/chat-0",
json=payload,
timeout=10.0,
)
r.raise_for_status()
logger.info(f"[Urbit] Ship blocked via poke: ~{target}")
return DeliveryResult(success=True)
except httpx.HTTPStatusError as e:
return DeliveryResult(success=False, error=f"block-ship failed ({e.response.status_code})")
except httpx.RequestError as e:
return DeliveryResult(success=False, error=f"Urbit Ship unreachable: {e}")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_unblock_ship_poke(
client: UrbitClient,
ship: str,
*,
ship_code: str = "",
) -> DeliveryResult:
target = ship.lstrip("~")
payload = {
"action": "poke",
"ship": client.ship_name,
"app": "chat",
"mark": "chat-unblock-ship",
"json": {"ship": f"~{target}"},
}
try:
r = await client.put(
"/~/channel/chat-0",
json=payload,
timeout=10.0,
)
if r.status_code == 401 and ship_code:
from .auth import refresh_session_if_needed
await refresh_session_if_needed(client, ship_code)
payload["ship"] = client.ship_name
r = await client.put(
"/~/channel/chat-0",
json=payload,
timeout=10.0,
)
r.raise_for_status()
logger.info(f"[Urbit] Ship unblocked via poke: ~{target}")
return DeliveryResult(success=True)
except httpx.HTTPStatusError as e:
return DeliveryResult(success=False, error=f"unblock-ship failed ({e.response.status_code})")
except httpx.RequestError as e:
return DeliveryResult(success=False, error=f"Urbit Ship unreachable: {e}")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def _send_chat_action(
*,
client: UrbitClient,
chat_id: str,
action: str,
msg_id: str,
metadata: dict[str, Any] | None = None,
retry_config: dict[str, Any] | None = None,
emoji: str | None = None,
content: str | None = None,
ship_code: str = "",
) -> DeliveryResult:
cfg = {**_DEFAULT_RETRY_CONFIG, **(retry_config or {})}
max_retries = cfg["attempts"]
min_delay_ms = cfg["min_delay_ms"]
max_delay_ms = cfg["max_delay_ms"]
jitter = cfg["jitter"]
meta = metadata or {}
chat_type = meta.get("chat_type", "group")
channel_path = build_channel_path(**{**meta, "chat_type": chat_type})
host_ship = client.ship_name
payload = build_chat_action_payload(
host_ship=host_ship,
channel_path=channel_path,
action=action,
msg_id=msg_id,
emoji=emoji,
content=content,
)
last_error = None
async for attempt in _retry_with_backoff(
max_retries, min_delay_ms, max_delay_ms, jitter, f"Chat action '{action}'"
):
try:
r = await client.put(
f"/~/channel/{chat_id}",
json=payload,
timeout=10.0,
)
if await _handle_401_if_needed(r, attempt, max_retries, client, ship_code, payload):
continue
r.raise_for_status()
return DeliveryResult(success=True)
except httpx.HTTPStatusError as e:
last_error = f"Urbit chat action '{action}' failed ({e.response.status_code})"
if e.response.status_code in (404, 403):
return DeliveryResult(success=False, error=last_error)
except httpx.RequestError as e:
last_error = f"Urbit Ship unreachable: {e}"
except ChannelAuthenticationError:
last_error = "Session recovery failed"
return DeliveryResult(success=False, error=last_error or f"Chat action '{action}' failed after retries")
async def _send_media_poke(
client: UrbitClient,
response: ChannelResponse,
payload: dict[str, Any],
retry_config: dict[str, Any] | None = None,
*,
ship_code: str = "",
) -> DeliveryResult:
cfg = {**_DEFAULT_RETRY_CONFIG, **(retry_config or {})}
max_retries = cfg["attempts"]
min_delay_ms = cfg["min_delay_ms"]
max_delay_ms = cfg["max_delay_ms"]
jitter = cfg["jitter"]
last_error = None
async for attempt in _retry_with_backoff(max_retries, min_delay_ms, max_delay_ms, jitter, "Media send"):
try:
r = await client.put(
f"/~/channel/{_build_channel_id(response)}",
json=payload,
timeout=15.0,
)
if await _handle_401_if_needed(r, attempt, max_retries, client, ship_code, payload):
continue
r.raise_for_status()
return DeliveryResult(success=True)
except httpx.HTTPStatusError as e:
last_error = f"Urbit media send failed ({e.response.status_code})"
if e.response.status_code in (404, 403):
return DeliveryResult(success=False, error=last_error)
except httpx.RequestError as e:
last_error = f"Urbit Ship unreachable: {e}"
except ChannelAuthenticationError:
last_error = "Session recovery failed"
return DeliveryResult(success=False, error=last_error or "Media send failed after retries")
async def send_chat_action(
client: UrbitClient,
chat_id: str,
metadata: dict[str, Any],
action: str = "typing",
*,
ship_code: str = "",
) -> DeliveryResult:
meta = metadata or {}
chat_type = meta.get("chat_type", "group")
channel_path = build_channel_path(**{**meta, "chat_type": chat_type})
host_ship = client.ship_name
payload = build_chat_action_payload(
host_ship=host_ship,
channel_path=channel_path,
action=action,
msg_id="",
)
last_error = None
async for attempt in _retry_with_backoff(3, 500, 30000, 0.1, f"Chat action '{action}'"):
try:
r = await client.put(
f"/~/channel/{chat_id}",
json=payload,
timeout=10.0,
)
if await _handle_401_if_needed(r, attempt, 3, client, ship_code, payload):
continue
r.raise_for_status()
return DeliveryResult(success=True)
except httpx.HTTPStatusError as e:
last_error = f"Chat action '{action}' failed ({e.response.status_code})"
return DeliveryResult(success=False, error=last_error)
except httpx.RequestError as e:
last_error = f"Urbit Ship unreachable: {e}"
except ChannelAuthenticationError:
last_error = "Session recovery failed"
return DeliveryResult(success=False, error=last_error or f"Chat action '{action}' failed after retries")
def _build_channel_id(response: ChannelResponse) -> str:
meta = response.metadata
chat_type = meta.get("chat_type", "group")
if chat_type == "direct":
target = meta.get("target_ship", "").lstrip("~")
host = meta.get("host_ship", "").lstrip("~")
return f"~{target}/dm--{host}"
host = meta.get("host_ship", "").lstrip("~")
group = meta.get("group_name", "unknown")
ch_type = meta.get("channel_type", "chat")
return f"~{host}/{group}/{ch_type}"