主要变更: 1. 重构导入顺序,统一模块导入规范 2. 提取通用方法到session模块,减少代码重复 3. 为缓存类添加线程/异步锁,修复并发安全问题 4. 新增入站处理器和发送管理器模块,拆分业务逻辑 5. 优化凭证队列,改为异步实现 6. 移除废弃的SSE_POLLING能力标识 7. 修复轮询投票解析逻辑 8. 优化Markdown转换规则,避免格式冲突 9. 完善连接控制器的异常处理 10. 新增发送静默消息的API支持
430 lines
17 KiB
Python
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")
|