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

356 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import os
import time
from typing import Any, TYPE_CHECKING
from yuxi.channels.models import (
ChannelResponse,
DeliveryResult,
MessageType,
)
from yuxi.utils.logging_config import logger
from .bridge import BridgeClient
from .chunking import chunk_text
from .rate_limiter import RateLimiter
if TYPE_CHECKING:
from yuxi.channels.infra.circuit_breaker import CircuitBreaker
class OutboundSequencer:
def __init__(self):
self._seq = 0
self._last_seq_per_chat: dict[str, int] = {}
def next(self, chat_id: str | None = None) -> int:
self._seq += 1
seq = self._seq
if chat_id:
self._last_seq_per_chat[chat_id] = seq
return seq
def last_for_chat(self, chat_id: str) -> int:
return self._last_seq_per_chat.get(chat_id, 0)
def reset(self) -> None:
self._seq = 0
self._last_seq_per_chat.clear()
async def send_with_retry(
bridge: BridgeClient,
response: ChannelResponse,
config: dict[str, Any],
rate_limiter: RateLimiter | None = None,
) -> DeliveryResult:
chunk_limit = config.get("text_chunk_limit", 2000)
chunks = _chunk_response_content(response, chunk_limit)
if not chunks:
chunks = [_build_send_payload(response, config)]
last_result = DeliveryResult(success=True)
for chunk_payload in chunks:
last_result = await _send_one(bridge, chunk_payload, config, rate_limiter)
if not last_result.success:
return last_result
return last_result
async def _send_one(
bridge: BridgeClient,
payload: dict[str, Any],
config: dict[str, Any],
rate_limiter: RateLimiter | None = None,
) -> DeliveryResult:
retry_config = config.get("retry", {})
max_attempts = retry_config.get("attempts", 3)
min_delay = retry_config.get("min_delay_ms", 500) / 1000
max_delay = retry_config.get("max_delay_ms", 10000) / 1000
last_error = None
for attempt in range(max_attempts):
try:
if rate_limiter:
await rate_limiter.check_and_wait()
result = await bridge.send_message(payload)
if result.success:
return result
last_error = result.error
except Exception as e:
last_error = str(e)
logger.warning(f"[ZaloUser] Send attempt {attempt + 1}/{max_attempts} failed: {e}")
if attempt < max_attempts - 1:
delay = min(min_delay * (2**attempt), max_delay)
await asyncio.sleep(delay)
return DeliveryResult(success=False, error=last_error or "Max retries exceeded")
def _chunk_response_content(response: ChannelResponse, chunk_limit: int) -> list[dict[str, Any]]:
if response.message_type != MessageType.TEXT:
return []
if len(response.content) <= chunk_limit:
return []
from .text_styles import chunk_styled_message, has_markdown_syntax, markdown_to_zalo_styles
if has_markdown_syntax(response.content):
styled = markdown_to_zalo_styles(response.content)
styled_chunks = chunk_styled_message(styled, chunk_limit)
results: list[dict[str, Any]] = []
for i, chunk in enumerate(styled_chunks):
payload = {
"conversation_id": response.identity.channel_chat_id,
"message_type": "text",
"text": chunk.plain_text,
"styled_paragraphs": chunk.to_dict(),
}
if i == 0 and response.reply_to_message_id:
payload["quote_message_id"] = response.reply_to_message_id
results.append(payload)
return results
text_chunks = chunk_text(response.content, chunk_limit)
results: list[dict[str, Any]] = []
for i, chunk_text_val in enumerate(text_chunks):
chunk_response = ChannelResponse(
identity=response.identity,
message_type=MessageType.TEXT,
content=chunk_text_val,
)
if i == 0 and response.reply_to_message_id:
chunk_response.reply_to_message_id = response.reply_to_message_id
if response.metadata:
chunk_response.metadata = response.metadata
payload = _build_send_payload(chunk_response, {})
results.append(payload)
return results
def _build_send_payload(response: ChannelResponse, config: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {
"conversation_id": response.identity.channel_chat_id,
"message_type": response.message_type.value,
"text": response.content,
}
reply_mode = config.get("reply_to_mode", "first")
if reply_mode != "off" and response.reply_to_message_id:
payload["quote_message_id"] = response.reply_to_message_id
custom_styles = response.metadata.get("custom_text_styles")
if custom_styles and isinstance(custom_styles, list):
payload["custom_text_styles"] = custom_styles
_ATTACHMENT_TYPES: dict[MessageType, str] = {
MessageType.IMAGE: "image",
MessageType.VIDEO: "video",
MessageType.AUDIO: "audio",
MessageType.FILE: "file",
}
attachment_bridge_type = _ATTACHMENT_TYPES.get(response.message_type)
if attachment_bridge_type and response.attachments:
att = response.attachments[0]
payload["attachments"] = [
{
"type": attachment_bridge_type,
"url": att.url,
}
]
if response.message_type == MessageType.FILE and att.filename:
payload["attachments"][0]["filename"] = att.filename
if response.metadata.get("chat_type") == "group" and response.metadata.get("mention_user_ids"):
payload["mention_user_ids"] = response.metadata["mention_user_ids"]
if response.metadata.get("disable_notification"):
payload["disable_notification"] = True
if response.metadata.get("ttl"):
payload["ttl"] = response.metadata["ttl"]
return payload
async def send_typing(bridge: BridgeClient, conversation_id: str) -> DeliveryResult:
try:
await bridge.send_typing(conversation_id)
return DeliveryResult(success=True, metadata={"action": "typing"})
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_delivered_event(bridge: BridgeClient, conversation_id: str, msg_id: str) -> DeliveryResult:
try:
await bridge.send_delivered(conversation_id, msg_id)
return DeliveryResult(success=True, metadata={"action": "delivered"})
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_seen_event(bridge: BridgeClient, conversation_id: str, msg_id: str) -> DeliveryResult:
try:
await bridge.send_seen(conversation_id, msg_id)
return DeliveryResult(success=True, metadata={"action": "seen"})
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_link(
bridge: BridgeClient,
conversation_id: str,
url: str,
caption: str = "",
) -> DeliveryResult:
try:
payload: dict[str, Any] = {
"conversation_id": conversation_id,
"message_type": "link",
"url": url,
}
if caption:
payload["text"] = caption
return await bridge.send_message(payload)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_audio(
bridge: BridgeClient,
conversation_id: str,
audio_url: str,
caption: str = "",
file_name: str | None = None,
) -> DeliveryResult:
try:
derived_name = file_name or _derive_file_name(audio_url, "audio", "audio.mp3")
if caption:
cap_result = await bridge.send_message(
{
"conversation_id": conversation_id,
"message_type": "text",
"text": caption,
}
)
if not cap_result.success:
return cap_result
voice_result = await bridge.send_voice_attachment(conversation_id, audio_url, derived_name)
return voice_result
except Exception as e:
return DeliveryResult(success=False, error=str(e))
def _derive_file_name(url_or_path: str, content_type: str, fallback: str) -> str:
name = os.path.basename(url_or_path.split("?")[0])
if name and "." in name:
return name
ext_map = {
"audio": ".mp3",
"video": ".mp4",
"image": ".jpg",
}
ext = ext_map.get(content_type, ".bin")
return f"{fallback.split('.')[0]}{ext}"
async def send_stream_chunks(
bridge: BridgeClient,
conversation_id: str,
stream_generator: Any,
rate_limiter: RateLimiter | None = None,
sequencer: OutboundSequencer | None = None,
reasoning_generator: Any = None,
reasoning_prefix: str = "\U0001f9e0 Reasoning:\n",
circuit_breaker: CircuitBreaker | None = None,
) -> DeliveryResult:
if circuit_breaker is not None:
from yuxi.channels.infra.circuit_breaker import CircuitState
if circuit_breaker.state == CircuitState.OPEN:
return DeliveryResult(success=False, error="Circuit breaker open")
first_chunk = None
try:
first_chunk = await stream_generator.__anext__()
except StopAsyncIteration:
return DeliveryResult(success=True)
if rate_limiter:
await rate_limiter.check_and_wait()
reasoning_text = ""
if reasoning_generator is not None:
reasoning_parts: list[str] = []
try:
async for r_chunk in reasoning_generator:
reasoning_parts.append(r_chunk)
except Exception:
pass
if reasoning_parts:
reasoning_text = reasoning_prefix + "".join(reasoning_parts) + "\n\n"
try:
seq_no = sequencer.next(conversation_id) if sequencer else 0
send_text = reasoning_text + first_chunk
send_payload: dict[str, Any] = {
"conversation_id": conversation_id,
"message_type": "text",
"text": send_text,
}
if sequencer:
send_payload["send_seq"] = seq_no
initial_result = await _send_one(bridge, send_payload, {}, rate_limiter)
except Exception:
initial_result = DeliveryResult(success=False, error="Initial stream send failed")
if not initial_result.success:
return initial_result
message_id = initial_result.message_id
accumulated: list[str] = [first_chunk]
last_send = time.monotonic()
consecutive_edit_failures = 0
async for chunk in stream_generator:
accumulated.append(chunk)
now = time.monotonic()
if len(accumulated) % 3 == 0 and (now - last_send) > 0.5:
try:
if rate_limiter:
await rate_limiter.check_and_wait()
edit_text = reasoning_text + "".join(accumulated)
edit_payload: dict[str, Any] = {"conversation_id": conversation_id, "text": edit_text}
if sequencer:
edit_payload["send_seq"] = sequencer.next(conversation_id)
await bridge.post("/messages/edit", json=edit_payload)
last_send = now
consecutive_edit_failures = 0
except Exception:
consecutive_edit_failures += 1
if consecutive_edit_failures >= 3:
logger.warning("[ZaloUser] Stream edit failed 3 consecutive times, stopping updates")
break
if len(accumulated) > 1 and consecutive_edit_failures < 3:
try:
if rate_limiter:
await rate_limiter.check_and_wait()
edit_text = reasoning_text + "".join(accumulated)
edit_payload: dict[str, Any] = {"conversation_id": conversation_id, "text": edit_text}
if sequencer:
edit_payload["send_seq"] = sequencer.next(conversation_id)
await bridge.post("/messages/edit", json=edit_payload)
except Exception:
logger.warning("[ZaloUser] Final stream flush edit failed")
return DeliveryResult(success=True, message_id=message_id)