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