ForcePilot/backend/package/yuxi/channels/adapters/whatsapp/send_manager.py
Kris 5c3611ff19 refactor(whatsapp): 整理WhatsApp适配器代码结构并修复多线程安全问题
主要变更:
1. 重构导入顺序,统一模块导入规范
2. 提取通用方法到session模块,减少代码重复
3. 为缓存类添加线程/异步锁,修复并发安全问题
4. 新增入站处理器和发送管理器模块,拆分业务逻辑
5. 优化凭证队列,改为异步实现
6. 移除废弃的SSE_POLLING能力标识
7. 修复轮询投票解析逻辑
8. 优化Markdown转换规则,避免格式冲突
9. 完善连接控制器的异常处理
10. 新增发送静默消息的API支持
2026-05-13 16:17:30 +08:00

430 lines
17 KiB
Python

from __future__ import annotations
import asyncio
import os
import tempfile
import time
from typing import Any
import aiohttp
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.models import DeliveryResult, ChannelResponse
from yuxi.utils.logging_config import logger
from .format import format_outbound as _format_outbound
from .markdown import markdown_to_whatsapp, text_sanitizer
from .media import _MEDIA_SUFFIX_MAP, cleanup_temp_file
from .media import download_media as _download_media
from .network_errors import ErrorCategory, classify_error
from .send import chunk_message
class SendManager:
def __init__(
self,
bridge,
circuit_breaker: CircuitBreaker,
sent_message_cache,
echo_filter,
debouncer,
reaction_level,
error_policy,
lane_delivery,
config: dict[str, Any],
stream_buffers: dict[str, str],
stream_chunk_counters: dict[str, int],
stream_status_msg_ids: dict[str, str],
recent_messages: dict[str, dict[str, Any]],
last_message_tracker: list[float | None],
):
self._bridge = bridge
self._circuit_breaker = circuit_breaker
self._sent_message_cache = sent_message_cache
self._echo_filter = echo_filter
self._debouncer = debouncer
self._reaction_level = reaction_level
self._error_policy = error_policy
self._lane_delivery = lane_delivery
self._config = config
self._stream_buffers = stream_buffers
self._stream_chunk_counters = stream_chunk_counters
self._stream_status_msg_ids = stream_status_msg_ids
self._recent_messages = recent_messages
self._last_message_tracker = last_message_tracker
self._prefix = config.get("messagePrefix", "")
self._chunk_mode = config.get("chunkMode", "length")
self._reply_to_mode = config.get("replyToMode", "first")
self._block_streaming = config.get("blockStreaming", False)
self._default_to = config.get("defaultTo")
self.text_chunk_limit = 4000
self.supports_markdown = True
@property
def last_message_at(self) -> float | None:
return self._last_message_tracker[0] if self._last_message_tracker else None
@last_message_at.setter
def last_message_at(self, value: float | None) -> None:
if self._last_message_tracker:
self._last_message_tracker[0] = value
async def send(self, response: ChannelResponse, silent: bool = False) -> DeliveryResult:
jid = response.identity.channel_chat_id
if not jid and self._default_to:
jid = self._default_to
reply_to = response.reply_to_message_id
if self._reply_to_mode == "off":
reply_to = None
if self._debouncer.should_throttle(jid):
remaining = self._debouncer.window_remaining(jid)
logger.warning(f"[WhatsApp] Throttled message to {jid} (retry in {remaining:.1f}s)")
return DeliveryResult(
success=False,
error=f"Rate limited, retry in {remaining:.1f}s",
)
payload = _format_outbound(response)
content = payload.get("content", response.content)
content = text_sanitizer(content)
if self.supports_markdown and getattr(response, "content_format", "") == "markdown":
try:
content = markdown_to_whatsapp(content)
except Exception as e:
logger.warning(f"[WhatsApp] Markdown parse failed, using plain text: {e}")
if self._prefix:
content = f"{self._prefix} {content}"
self._echo_filter.record_outbound(jid, content)
self._debouncer.record_send(jid)
self.last_message_at = time.monotonic()
async def _do_send() -> DeliveryResult:
send_content = content
if len(send_content) > self.text_chunk_limit and self._reply_to_mode != "batched":
chunks = chunk_message(send_content, self.text_chunk_limit, mode=self._chunk_mode)
results = []
for chunk in chunks:
use_reply = reply_to if self._reply_to_mode == "all" else (reply_to if len(results) == 0 else None)
result = await self._bridge.send_message(
jid=jid,
content=chunk,
reply_to=use_reply,
silent=silent,
)
results.append(result)
return results[0] if results else DeliveryResult(success=False, error="No chunks")
if self._reply_to_mode == "batched" and len(send_content) > self.text_chunk_limit:
send_content = send_content[: self.text_chunk_limit - 3] + "..."
return await self._send_with_retry(jid, send_content, reply_to, silent)
try:
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
suffix = _MEDIA_SUFFIX_MAP.get(media_type, "")
if isinstance(data, bytes):
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
f.write(data)
tmp_path = f.name
elif isinstance(data, str) and os.path.exists(data):
tmp_path = data
else:
return DeliveryResult(success=False, error=f"Unsupported media data type: {type(data)}")
try:
return await self._bridge.send_media(
jid=chat_id,
media_type=media_type,
media_path=tmp_path,
)
finally:
if isinstance(data, bytes) and os.path.exists(tmp_path):
cleanup_temp_file(tmp_path)
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if not emoji:
return await self._bridge.send_reaction(
jid=chat_id,
message_id=msg_id,
emoji="",
)
if not self._reaction_level.can_send_reaction():
return DeliveryResult(success=False, error="Reactions disabled by reactionLevel config")
return await self._bridge.send_reaction(
jid=chat_id,
message_id=msg_id,
emoji=emoji,
)
async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult:
return await self.send_reaction(chat_id, msg_id, "")
async def send_reaction_from_action(self, action: dict[str, Any]) -> DeliveryResult:
chat_jid = action.get("chatJid") or action.get("to") or action.get("chat_id", "")
msg_id = action.get("messageId") or action.get("msg_id", "")
emoji = action.get("emoji", "")
remove = action.get("remove", False)
participant = action.get("participant", "")
if participant and "@g.us" not in chat_jid:
chat_jid = participant
if remove or not emoji:
return await self.send_reaction(chat_jid, msg_id, "")
return await self.send_reaction(chat_jid, msg_id, emoji)
async def download_media(self, file_id: str) -> bytes:
raw = self._recent_messages.get(file_id)
if not raw:
raise ValueError(f"Message context not found for file_id: {file_id}")
remote_jid = raw.get("key", {}).get("remoteJid", "")
message = raw.get("message", {})
return await _download_media(self._bridge, remote_jid, file_id, message)
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
stream_cfg = self._config.get("streaming", {})
if not isinstance(stream_cfg, dict):
stream_cfg = {}
if finished:
status_msg_id = self._stream_status_msg_ids.pop(chat_id, None)
if status_msg_id:
try:
await self._bridge.delete_message(jid=chat_id, message_id=status_msg_id)
except Exception:
logger.debug(f"[WhatsApp] Failed to delete stream status message {status_msg_id}", exc_info=True)
self._stream_chunk_counters.pop(chat_id, None)
content = text_sanitizer(chunk)
if self.supports_markdown:
content = markdown_to_whatsapp(content)
if self._lane_delivery.reasoning_enabled:
lane_chunks = self._lane_delivery.split_lane_aware(content)
messages = self._lane_delivery.format_for_whatsapp(lane_chunks)
results = []
for msg in messages:
result = await self._bridge.send_message(jid=chat_id, content=msg)
results.append(result)
return results[0] if results else DeliveryResult(success=False, error="No messages")
return await self._bridge.send_message(
jid=chat_id,
content=content,
)
self._stream_chunk_counters[chat_id] = self._stream_chunk_counters.get(chat_id, 0) + 1
await self._bridge.send_presence(
jid=chat_id,
presence="composing",
)
stream_status_updates = stream_cfg.get("streamStatusUpdates", False)
status_interval = stream_cfg.get("streamStatusInterval", 5)
if stream_status_updates and self._stream_chunk_counters[chat_id] % status_interval == 0:
try:
status_text = stream_cfg.get("streamStatusText", "Processing...")
status_result = await self._bridge.send_message(jid=chat_id, content=status_text)
if status_result.success and status_result.message_id:
old_status = self._stream_status_msg_ids.get(chat_id)
if old_status:
try:
await self._bridge.delete_message(jid=chat_id, message_id=old_status)
except Exception:
pass
self._stream_status_msg_ids[chat_id] = status_result.message_id
except Exception:
logger.debug(f"[WhatsApp] Failed to send stream status message to {chat_id}", exc_info=True)
block_cfg = stream_cfg.get("block", {})
if not isinstance(block_cfg, dict):
block_cfg = {}
block_enabled = block_cfg.get("enabled", True)
block_coalesce = block_cfg.get("coalesce", False)
min_chars = block_cfg.get("coalesce_min_chars", 1500)
max_chars = block_cfg.get("coalesce_min_chars", 4096)
if not block_enabled:
return DeliveryResult(success=True)
if block_coalesce:
self._stream_buffers.setdefault(chat_id, "")
self._stream_buffers[chat_id] += chunk
if len(self._stream_buffers[chat_id]) >= min_chars:
buffered = self._stream_buffers.pop(chat_id, "")
content = text_sanitizer(buffered[:max_chars])
return await self._bridge.send_message(jid=chat_id, content=content)
return DeliveryResult(success=True)
if len(chunk) >= min_chars:
content = text_sanitizer(chunk[:max_chars])
return await self._bridge.send_message(jid=chat_id, content=content)
return DeliveryResult(success=True)
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
return DeliveryResult(success=False, error="WhatsApp does not support editing messages")
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
return await self._bridge.delete_message(
jid=chat_id,
message_id=msg_id,
)
async def send_poll(self, chat_id: str, name: str, options: list[str], selectable_count: int = 1) -> DeliveryResult:
if len(options) < 2:
return DeliveryResult(success=False, error="Poll requires at least 2 options")
if len(options) > 12:
return DeliveryResult(success=False, error=f"Poll supports max 12 options, got {len(options)}")
return await self._bridge.create_poll(
jid=chat_id,
name=name,
options=options,
selectable_count=selectable_count,
)
async def send_location(
self,
chat_id: str,
latitude: float,
longitude: float,
name: str = "",
address: str = "",
) -> DeliveryResult:
return await self._bridge.send_location(
jid=chat_id,
latitude=latitude,
longitude=longitude,
name=name,
address=address,
)
async def send_contact(
self,
chat_id: str,
contacts: list[dict[str, str]],
) -> DeliveryResult:
return await self._bridge.send_contact(
jid=chat_id,
contacts=contacts,
)
async def send_sticker(
self,
chat_id: str,
sticker_path: str,
reply_to: str | None = None,
) -> DeliveryResult:
return await self._bridge.send_sticker(
jid=chat_id,
sticker_path=sticker_path,
reply_to=reply_to,
)
async def send_buttons(
self,
chat_id: str,
text: str,
buttons: list[dict[str, str]],
title: str = "",
footer: str = "",
) -> DeliveryResult:
return await self._bridge.send_buttons(
jid=chat_id,
text=text,
buttons=buttons,
title=title,
footer=footer,
)
async def send_list_message(
self,
chat_id: str,
text: str,
sections: list[dict[str, Any]],
title: str = "",
footer: str = "",
button_text: str = "Select",
) -> DeliveryResult:
return await self._bridge.send_list_message(
jid=chat_id,
text=text,
sections=sections,
title=title,
footer=footer,
button_text=button_text,
)
async def _send_with_retry(
self,
jid: str,
content: str,
reply_to: str | None = None,
silent: bool = False,
max_attempts: int = 3,
) -> DeliveryResult:
last_error: str | None = None
for attempt in range(1, max_attempts + 1):
try:
result = await self._bridge.send_message(
jid=jid,
content=content,
reply_to=reply_to,
silent=silent,
)
if result.success and result.message_id:
self._sent_message_cache.put(
result.message_id,
jid,
{"content": content[:200], "reply_to": reply_to},
)
if result.success:
return result
last_error = result.error or "unknown"
except aiohttp.ClientConnectorError as e:
last_error = f"Bridge connection failed: {e}"
logger.warning(
f"[WhatsApp] send attempt {attempt}/{max_attempts} failed - bridge connection error for {jid}: {e}"
)
except TimeoutError as e:
last_error = str(e)
logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed - timeout for {jid}: {e}")
except Exception as e:
last_error = str(e)
error_cat = classify_error(str(e))
if error_cat in (ErrorCategory.PERMANENT, ErrorCategory.AUTH):
logger.warning(
f"[WhatsApp] send failed for {jid} - {error_cat.value} error (not retrying): {e}"
)
break
logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed for {jid}: {e}")
if attempt < max_attempts:
error_cat = classify_error(last_error or "")
if error_cat == ErrorCategory.RATE_LIMIT:
delay_ms = 2000 * attempt
else:
delay_ms = 500 * attempt
logger.info(f"[WhatsApp] Retrying send to {jid} in {delay_ms}ms (attempt {attempt})")
await asyncio.sleep(delay_ms / 1000)
logger.error(f"[WhatsApp] send failed after {max_attempts} attempts for {jid}")
if self._error_policy.should_notify(jid, last_error or "send_failed"):
last_error = self._error_policy.format_error_message(last_error or "send failed")
return DeliveryResult(success=False, error=last_error or "send failed")