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