From 5c3611ff19318f6a835bfe55f6f3a125db55b35e Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Wed, 13 May 2026 16:17:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor(whatsapp):=20=E6=95=B4=E7=90=86WhatsAp?= =?UTF-8?q?p=E9=80=82=E9=85=8D=E5=99=A8=E4=BB=A3=E7=A0=81=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=A4=9A=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: 1. 重构导入顺序,统一模块导入规范 2. 提取通用方法到session模块,减少代码重复 3. 为缓存类添加线程/异步锁,修复并发安全问题 4. 新增入站处理器和发送管理器模块,拆分业务逻辑 5. 优化凭证队列,改为异步实现 6. 移除废弃的SSE_POLLING能力标识 7. 修复轮询投票解析逻辑 8. 优化Markdown转换规则,避免格式冲突 9. 完善连接控制器的异常处理 10. 新增发送静默消息的API支持 --- .../channels/adapters/whatsapp/__init__.py | 52 +-- .../adapters/whatsapp/accounts/__init__.py | 2 +- .../channels/adapters/whatsapp/adapter.py | 404 ++++------------- .../adapters/whatsapp/approve/__init__.py | 4 +- .../whatsapp/approve/approval_callbacks.py | 2 +- .../adapters/whatsapp/baileys-bridge/send.js | 10 +- .../yuxi/channels/adapters/whatsapp/bridge.py | 6 + .../adapters/whatsapp/channel_meta.py | 1 - .../whatsapp/connection_controller.py | 5 +- .../adapters/whatsapp/credential_queue.py | 114 +++-- .../yuxi/channels/adapters/whatsapp/format.py | 9 +- .../adapters/whatsapp/inbound_cache.py | 90 ++-- .../adapters/whatsapp/inbound_processor.py | 113 +++++ .../channels/adapters/whatsapp/markdown.py | 20 +- .../yuxi/channels/adapters/whatsapp/poll.py | 18 +- .../adapters/whatsapp/reactions/__init__.py | 2 +- .../adapters/whatsapp/send_manager.py | 429 ++++++++++++++++++ .../adapters/whatsapp/sent_message_cache.py | 38 +- .../channels/adapters/whatsapp/session.py | 3 + .../whatsapp/vision/sticker_vision.py | 3 +- 20 files changed, 840 insertions(+), 485 deletions(-) create mode 100644 backend/package/yuxi/channels/adapters/whatsapp/inbound_processor.py create mode 100644 backend/package/yuxi/channels/adapters/whatsapp/send_manager.py diff --git a/backend/package/yuxi/channels/adapters/whatsapp/__init__.py b/backend/package/yuxi/channels/adapters/whatsapp/__init__.py index b1f48518..6488ae2f 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/__init__.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/__init__.py @@ -1,14 +1,11 @@ +from yuxi.channels.adapters.whatsapp.accounts import MultiAccountManager, merge_account_config, resolve_default_account from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter -from yuxi.channels.adapters.whatsapp.security import ( - DmPolicy, - GroupPolicy, - WhatsAppSecurityPolicy, -) -from yuxi.channels.adapters.whatsapp.pairing import PairingManager, PairRequest -from yuxi.channels.adapters.whatsapp.inbound_pipeline import ( - InboundPipeline, - PipelineAction, - PipelineResult, +from yuxi.channels.adapters.whatsapp.approve import ( + ApprovalCallbackManager, + ApprovalHandler, + ApprovalRequest, + ApprovalStatus, + ExecApprovals, ) from yuxi.channels.adapters.whatsapp.channel_meta import ( ChannelCapability, @@ -19,27 +16,30 @@ from yuxi.channels.adapters.whatsapp.connection_controller import ( ConnectionController, ConnectionState, ) -from yuxi.channels.adapters.whatsapp.accounts import MultiAccountManager, merge_account_config, resolve_default_account -from yuxi.channels.adapters.whatsapp.reactions import ReactionLevel, ReactionLevelController, AckReactionManager -from yuxi.channels.adapters.whatsapp.directory import PerGroupConfig, GroupEntry -from yuxi.channels.adapters.whatsapp.per_dm_config import PerDmConfig +from yuxi.channels.adapters.whatsapp.directory import GroupEntry, PerGroupConfig from yuxi.channels.adapters.whatsapp.error_policy import ErrorPolicy, ErrorPolicyConfig -from yuxi.channels.adapters.whatsapp.network_errors import ErrorCategory, classify_error, is_recoverable -from yuxi.channels.adapters.whatsapp.sent_message_cache import SentMessageCache from yuxi.channels.adapters.whatsapp.group_gating import GroupGating -from yuxi.channels.adapters.whatsapp.approve import ( - ExecApprovals, - ApprovalRequest, - ApprovalStatus, - ApprovalHandler, - ApprovalCallbackManager, -) -from yuxi.channels.adapters.whatsapp.voice import transcoder, VoiceNoteSender -from yuxi.channels.adapters.whatsapp.vision import StickerCache, StickerVision from yuxi.channels.adapters.whatsapp.health import DoctorDiagnostic, StatusIssueCollector +from yuxi.channels.adapters.whatsapp.inbound_pipeline import ( + InboundPipeline, + PipelineAction, + PipelineResult, +) +from yuxi.channels.adapters.whatsapp.network_errors import ErrorCategory, classify_error, is_recoverable +from yuxi.channels.adapters.whatsapp.pairing import PairingManager, PairRequest +from yuxi.channels.adapters.whatsapp.per_dm_config import PerDmConfig +from yuxi.channels.adapters.whatsapp.reactions import AckReactionManager, ReactionLevel, ReactionLevelController +from yuxi.channels.adapters.whatsapp.security import ( + DmPolicy, + GroupPolicy, + WhatsAppSecurityPolicy, +) +from yuxi.channels.adapters.whatsapp.sent_message_cache import SentMessageCache from yuxi.channels.adapters.whatsapp.setup.setup_wizard import setup_wizard -from yuxi.channels.adapters.whatsapp.ui import InteractiveDispatcher from yuxi.channels.adapters.whatsapp.targets import TargetResolver +from yuxi.channels.adapters.whatsapp.ui import InteractiveDispatcher +from yuxi.channels.adapters.whatsapp.vision import StickerCache, StickerVision +from yuxi.channels.adapters.whatsapp.voice import VoiceNoteSender, transcoder __all__ = [ "WhatsAppAdapter", diff --git a/backend/package/yuxi/channels/adapters/whatsapp/accounts/__init__.py b/backend/package/yuxi/channels/adapters/whatsapp/accounts/__init__.py index 925f27f8..8db40a23 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/accounts/__init__.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/accounts/__init__.py @@ -1,6 +1,6 @@ -from yuxi.channels.adapters.whatsapp.accounts.accounts import MultiAccountManager from yuxi.channels.adapters.whatsapp.accounts.account_config import merge_account_config from yuxi.channels.adapters.whatsapp.accounts.account_selection import resolve_default_account +from yuxi.channels.adapters.whatsapp.accounts.accounts import MultiAccountManager __all__ = [ "MultiAccountManager", diff --git a/backend/package/yuxi/channels/adapters/whatsapp/adapter.py b/backend/package/yuxi/channels/adapters/whatsapp/adapter.py index 97412bdf..7a65f73a 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/adapter.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/adapter.py @@ -1,17 +1,15 @@ from __future__ import annotations import asyncio -import os -import tempfile import time from collections.abc import Awaitable, Callable from typing import Any -import aiohttp from yuxi.channels.base import BaseChannelAdapter -from yuxi.channels.capabilities import ChannelCapabilities, TTSVoiceCapabilities, TTSCapabilities +from yuxi.channels.capabilities import ChannelCapabilities, TTSCapabilities, TTSVoiceCapabilities from yuxi.channels.exceptions import ChannelNotConnectedError +from yuxi.channels.infra.circuit_breaker import CircuitBreaker from yuxi.channels.meta import ChannelMeta as DisplayMeta from yuxi.channels.models import ( ChannelMessage, @@ -34,22 +32,20 @@ from .dedupe import ButtonDeduplicator, MessageDeduplicator from .directory import PerGroupConfig from .directory_peers import ContactDirectory from .echo_filter import EchoFilter -from .inbound_cache import InboundMessageCache from .error_policy import ErrorPolicyConfig from .format import format_outbound as _format_outbound from .format import normalize_inbound as _normalize_inbound from .heartbeat import HeartbeatManager -from .inbound_pipeline import InboundPipeline, PipelineAction, PipelineResult -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 .inbound_cache import InboundMessageCache +from .inbound_processor import InboundProcessor +from .inbound_pipeline import InboundPipeline, PipelineAction from .monitor import WhatsAppMonitor from .pairing import PairingManager from .per_dm_config import PerDmConfig from .reactions.ack_reaction import AckReactionManager from .reactions.reaction_level import ReactionLevelController -from .security import DmPolicy, WhatsAppSecurityPolicy -from .send import chunk_message +from .security import WhatsAppSecurityPolicy +from .send_manager import SendManager from .sent_message_cache import SentMessageCache from .session import jid_to_thread_key, normalize_phone, resolve_session_scope from .stream.lane_delivery import LaneDelivery @@ -91,6 +87,7 @@ class WhatsAppAdapter(BaseChannelAdapter): media=True, supports_markdown=True, supports_streaming=True, + supports_broadcast=False, streaming_modes=["off", "typing_indicator"], text_chunk_limit=4000, max_media_size_mb=100, @@ -146,12 +143,45 @@ class WhatsAppAdapter(BaseChannelAdapter): max_calls=cfg.get("debounceMaxCalls", 3), ) self._creds_queue: CredentialQueue | None = None - self._inbound_pipeline = self._build_inbound_pipeline() self._self_jid: str | None = None self._on_connection_change: Callable[[dict[str, Any]], Awaitable[None]] | None = None self._recent_messages: dict[str, dict[str, Any]] = {} self._last_inbound_at: float | None = None self._last_message_at: float | None = None + self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="whatsapp") + self._stream_buffers: dict[str, str] = {} + self._stream_chunk_counters: dict[str, int] = {} + self._stream_status_msg_ids: dict[str, str] = {} + self._last_message_tracker: list[float | None] = [None] + + self._send_mgr = SendManager( + bridge=self._bridge, + circuit_breaker=self._circuit_breaker, + sent_message_cache=self._sent_message_cache, + echo_filter=self._echo_filter, + debouncer=self._debouncer, + reaction_level=self._reaction_level, + error_policy=self._error_policy, + lane_delivery=self._lane_delivery, + config=cfg, + stream_buffers=self._stream_buffers, + stream_chunk_counters=self._stream_chunk_counters, + stream_status_msg_ids=self._stream_status_msg_ids, + recent_messages=self._recent_messages, + last_message_tracker=self._last_message_tracker, + ) + self._inbound_processor = InboundProcessor( + echo_filter=self._echo_filter, + deduplicator=self._deduplicator, + button_deduplicator=self._button_deduplicator, + security=self._security, + self_chat_mode=self._self_chat_mode, + per_group_config=self._per_group_config, + per_dm_config=self._per_dm_config, + pairing=self._pairing, + self_jid=None, + ) + self._inbound_pipeline = self._inbound_processor.build_pipeline() async def connect(self) -> None: if self._status in (ChannelStatus.CONNECTED, ChannelStatus.CONNECTING): @@ -219,165 +249,34 @@ class WhatsAppAdapter(BaseChannelAdapter): return {"status": "error", "error": "QR generation failed"} 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}") - - prefix = self.config.get("messagePrefix", "") - if prefix: - content = f"{prefix} {content}" - - self._echo_filter.record_outbound(jid, content) - self._debouncer.record_send(jid) - self._last_message_at = time.monotonic() - - if len(content) > self.text_chunk_limit and self._reply_to_mode != "batched": - chunks = chunk_message(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(content) > self.text_chunk_limit: - content = content[: self.text_chunk_limit - 3] + "..." - - return await self._send_with_retry(jid, content, reply_to, silent) + return await self._send_mgr.send(response, silent) 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) + return await self._send_mgr.send_media(chat_id, media_type, data) 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, - ) + return await self._send_mgr.send_reaction(chat_id, msg_id, emoji) async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult: - return await self.send_reaction(chat_id, msg_id, "") + return await self._send_mgr.remove_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) + return await self._send_mgr.send_reaction_from_action(action) 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) + return await self._send_mgr.download_media(file_id) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: - if finished: - 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, - ) - return await self._bridge.send_presence( - jid=chat_id, - presence="composing", - ) + return await self._send_mgr.send_stream_chunk(chat_id, msg_id, chunk, finished) 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") + return await self._send_mgr.edit_message(chat_id, msg_id, content) 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, - ) + return await self._send_mgr.delete_message(chat_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, - ) + return await self._send_mgr.send_poll(chat_id, name, options, selectable_count) async def send_location( self, @@ -387,23 +286,14 @@ class WhatsAppAdapter(BaseChannelAdapter): name: str = "", address: str = "", ) -> DeliveryResult: - return await self._bridge.send_location( - jid=chat_id, - latitude=latitude, - longitude=longitude, - name=name, - address=address, - ) + return await self._send_mgr.send_location(chat_id, latitude, longitude, name, 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, - ) + return await self._send_mgr.send_contact(chat_id, contacts) async def send_sticker( self, @@ -411,11 +301,7 @@ class WhatsAppAdapter(BaseChannelAdapter): 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, - ) + return await self._send_mgr.send_sticker(chat_id, sticker_path, reply_to) async def send_buttons( self, @@ -425,13 +311,7 @@ class WhatsAppAdapter(BaseChannelAdapter): title: str = "", footer: str = "", ) -> DeliveryResult: - return await self._bridge.send_buttons( - jid=chat_id, - text=text, - buttons=buttons, - title=title, - footer=footer, - ) + return await self._send_mgr.send_buttons(chat_id, text, buttons, title, footer) async def send_list_message( self, @@ -442,14 +322,7 @@ class WhatsAppAdapter(BaseChannelAdapter): 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, - ) + return await self._send_mgr.send_list_message(chat_id, text, sections, title, footer, button_text) def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage: return _normalize_inbound(raw, self.channel_id) @@ -512,8 +385,8 @@ class WhatsAppAdapter(BaseChannelAdapter): self._401_retry_pending = False self._connection_ctrl.transition(ConnectionState.LOGGED_OUT) - from .logout_security import perform_logout_cleanup from .auth_resolve import resolve_auth_dir + from .logout_security import perform_logout_cleanup auth_dir = resolve_auth_dir(self.channel_id, self.config) perform_logout_cleanup(auth_dir) @@ -547,7 +420,6 @@ class WhatsAppAdapter(BaseChannelAdapter): result = await self._inbound_pipeline.process(raw_payload) if result.action != PipelineAction.ACCEPT: - self._echo_filter.clear() return self._last_inbound_at = time.monotonic() @@ -638,87 +510,8 @@ class WhatsAppAdapter(BaseChannelAdapter): logger.debug("[WhatsApp] Vision enrichment failed", exc_info=True) def _build_inbound_pipeline(self) -> InboundPipeline: - pipeline = InboundPipeline() - pipeline.add_step(self._check_echo, "echo_filter") - pipeline.add_step(self._check_dedupe, "dedupe") - pipeline.add_step(self._check_button_dedupe, "button_dedupe") - pipeline.add_step(self._check_security, "security") - return pipeline - - def _check_echo(self, payload: dict) -> PipelineResult: - key = payload.get("key", {}) - msg = payload.get("message", {}) - remote_jid = key.get("remoteJid", "") - text = msg.get("conversation", "") - if not text: - text = (msg.get("extendedTextMessage") or {}).get("text", "") - - if self._echo_filter.is_echo(remote_jid, text): - return PipelineResult(action=PipelineAction.DROP, reason="echo") - return PipelineResult(action=PipelineAction.ACCEPT) - - def _check_dedupe(self, payload: dict) -> PipelineResult: - msg_id = payload.get("key", {}).get("id", "") - if not msg_id: - return PipelineResult(action=PipelineAction.ACCEPT) - if self._deduplicator.is_duplicate(msg_id): - return PipelineResult(action=PipelineAction.DROP, reason="duplicate") - return PipelineResult(action=PipelineAction.ACCEPT) - - def _check_button_dedupe(self, payload: dict) -> PipelineResult: - msg = payload.get("message", {}) - button_msg = msg.get("buttonsResponseMessage") or msg.get("templateButtonReplyMessage") - if not button_msg: - return PipelineResult(action=PipelineAction.ACCEPT) - - sender = payload.get("key", {}).get("remoteJid", "") - button_id = button_msg.get("selectedButtonId", "") - if not button_id: - button_id = button_msg.get("selectedId", "") - - if not button_id: - return PipelineResult(action=PipelineAction.ACCEPT) - - if self._button_deduplicator.is_duplicate(sender, button_id): - return PipelineResult(action=PipelineAction.DROP, reason="button_duplicate") - return PipelineResult(action=PipelineAction.ACCEPT) - - def _check_security(self, payload: dict) -> PipelineResult: - from_me = payload.get("key", {}).get("fromMe", False) - if from_me: - if self._self_chat_mode: - return PipelineResult(action=PipelineAction.ACCEPT) - return PipelineResult(action=PipelineAction.DROP, reason="self_message") - - remote_jid = payload.get("key", {}).get("remoteJid", "") - sender = remote_jid.split("@")[0] - - if "@g.us" in remote_jid: - if not self._per_group_config.is_enabled(remote_jid): - return PipelineResult(action=PipelineAction.DROP, reason="group_disabled") - if self._per_group_config.require_mention(remote_jid): - msg = payload.get("message", {}) - ext_text = msg.get("extendedTextMessage", {}) - context_info = ext_text.get("contextInfo", {}) - mentioned_jids = context_info.get("mentionedJid", []) or [] - if not any(self._self_jid and mj == self._self_jid for mj in mentioned_jids): - return PipelineResult(action=PipelineAction.DROP, reason="require_mention_not_met") - allowed, reason = self._security.check_group_access(remote_jid) - if not allowed: - logger.debug(f"[WhatsApp] Group access denied for {remote_jid}: {reason}") - return PipelineResult(action=PipelineAction.DROP, reason=reason or "group_blocked") - else: - if not self._per_dm_config.is_enabled(sender): - return PipelineResult(action=PipelineAction.DROP, reason="dm_disabled") - if self._security.dm_policy == DmPolicy.PAIRING and not self._pairing.is_paired(sender): - logger.debug(f"[WhatsApp] DM pairing required for {sender}") - return PipelineResult(action=PipelineAction.DROP, reason="dm_not_paired") - allowed, reason = self._security.check_dm_access(sender) - if not allowed: - logger.debug(f"[WhatsApp] DM access denied for {sender}: {reason}") - return PipelineResult(action=PipelineAction.DROP, reason=reason or "dm_blocked") - - return PipelineResult(action=PipelineAction.ACCEPT) + self._inbound_processor._self_jid = self._self_jid + return self._inbound_processor.build_pipeline() def _setup_monitor_handler(self) -> None: self._monitor.on_raw_message(self._handle_sse_event) @@ -747,55 +540,6 @@ class WhatsAppAdapter(BaseChannelAdapter): } ) - 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) - logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed for {jid}: {e}") - - if attempt < max_attempts: - 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") - async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: phone = normalize_phone(channel_user_id) jid = f"{phone}@s.whatsapp.net" @@ -819,8 +563,8 @@ class WhatsAppAdapter(BaseChannelAdapter): return await self._bridge.wait_scan(timeout) async def detect_whatsapp_linked(self) -> bool: - from .logout_security import validate_credential_freshness from .auth_resolve import resolve_auth_dir + from .logout_security import validate_credential_freshness auth_dir = resolve_auth_dir(self.channel_id, self.config) if not validate_credential_freshness(auth_dir): @@ -835,8 +579,8 @@ class WhatsAppAdapter(BaseChannelAdapter): return await self._bridge.get_qr_status() async def logout(self) -> dict[str, Any]: - from .logout_security import perform_logout_cleanup from .auth_resolve import resolve_auth_dir + from .logout_security import perform_logout_cleanup result = await self._bridge.logout() auth_dir = resolve_auth_dir(self.channel_id, self.config) @@ -887,16 +631,30 @@ class WhatsAppAdapter(BaseChannelAdapter): return self._last_message_at def resolve_system_prompt(self, chat_id: str) -> str | None: + base = None if "@g.us" in chat_id: - return self._per_group_config.system_prompt(chat_id) - sender = chat_id.split("@")[0] if "@" in chat_id else chat_id - return self._per_dm_config.system_prompt(sender) + base = self._per_group_config.system_prompt(chat_id) + else: + sender = chat_id.split("@")[0] if "@" in chat_id else chat_id + base = self._per_dm_config.system_prompt(sender) + return self._inject_reaction_guidance(base) def resolve_system_prompt_for_group(self, group_jid: str) -> str | None: - return self._per_group_config.system_prompt(group_jid) + base = self._per_group_config.system_prompt(group_jid) + return self._inject_reaction_guidance(base) def resolve_system_prompt_for_direct(self, phone: str) -> str | None: - return self._per_dm_config.system_prompt(phone) + base = self._per_dm_config.system_prompt(phone) + return self._inject_reaction_guidance(base) + + def _inject_reaction_guidance(self, base: str | None) -> str | None: + guidance = self.config.get("agentReactionGuidance") + if not guidance: + return base + guidance_text = f"\n\nYou may use WhatsApp reactions to respond with emoji when appropriate. {guidance}" + if base: + return base + guidance_text + return guidance_text.strip() def resolve_quoted_message_key(self, chat_id: str, quoted_msg_id: str | None = None) -> dict | None: return self._inbound_cache.resolve_quoted_message_key(chat_id, quoted_msg_id) diff --git a/backend/package/yuxi/channels/adapters/whatsapp/approve/__init__.py b/backend/package/yuxi/channels/adapters/whatsapp/approve/__init__.py index 799b0161..f13b26f3 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/approve/__init__.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/approve/__init__.py @@ -1,6 +1,6 @@ -from yuxi.channels.adapters.whatsapp.approve.exec_approvals import ExecApprovals, ApprovalRequest, ApprovalStatus -from yuxi.channels.adapters.whatsapp.approve.approval_handler import ApprovalHandler from yuxi.channels.adapters.whatsapp.approve.approval_callbacks import ApprovalCallbackManager +from yuxi.channels.adapters.whatsapp.approve.approval_handler import ApprovalHandler +from yuxi.channels.adapters.whatsapp.approve.exec_approvals import ApprovalRequest, ApprovalStatus, ExecApprovals __all__ = [ "ExecApprovals", diff --git a/backend/package/yuxi/channels/adapters/whatsapp/approve/approval_callbacks.py b/backend/package/yuxi/channels/adapters/whatsapp/approve/approval_callbacks.py index abdf1d94..315477d7 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/approve/approval_callbacks.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/approve/approval_callbacks.py @@ -1,7 +1,7 @@ from __future__ import annotations -from typing import Any from collections.abc import Callable +from typing import Any from yuxi.utils.logging_config import logger diff --git a/backend/package/yuxi/channels/adapters/whatsapp/baileys-bridge/send.js b/backend/package/yuxi/channels/adapters/whatsapp/baileys-bridge/send.js index f01d7eb5..84d2b3cf 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/baileys-bridge/send.js +++ b/backend/package/yuxi/channels/adapters/whatsapp/baileys-bridge/send.js @@ -12,7 +12,7 @@ function buildSendRoutes(app, getSock, notifySSE) { return res.json({ success: false, error: 'Not connected' }); } - const { jid, content, reply_to } = req.body; + const { jid, content, reply_to, silent } = req.body; const options = {}; if (reply_to) { options.quoted = { @@ -21,7 +21,13 @@ function buildSendRoutes(app, getSock, notifySSE) { }; } - const result = await sock.sendMessage(jid, { text: content }, options); + const msgContent = { text: content }; + + if (silent) { + msgContent.contextInfo = { isSilent: true }; + } + + const result = await sock.sendMessage(jid, msgContent, options); res.json({ success: true, message_id: result?.key?.id || null, diff --git a/backend/package/yuxi/channels/adapters/whatsapp/bridge.py b/backend/package/yuxi/channels/adapters/whatsapp/bridge.py index f0174e3a..5ccde942 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/bridge.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/bridge.py @@ -117,6 +117,12 @@ class BaileysBridge(ExternalProcessManager): except TimeoutError: self._process.kill() await self._process.wait() + if self._process: + if self._process.stdout: + self._process.stdout.close() + if self._process.stderr: + self._process.stderr.close() + self._process = None logger.info("Baileys bridge stopped") async def health_check(self) -> HealthStatus: diff --git a/backend/package/yuxi/channels/adapters/whatsapp/channel_meta.py b/backend/package/yuxi/channels/adapters/whatsapp/channel_meta.py index 94f36d3e..4220875b 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/channel_meta.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/channel_meta.py @@ -53,7 +53,6 @@ class WhatsAppChannelMeta: ChannelCapability.GROUP, ChannelCapability.QR_LOGIN, ChannelCapability.PAIRING, - ChannelCapability.SSE_POLLING, ] ) inbound_rate_limit: int = 60 diff --git a/backend/package/yuxi/channels/adapters/whatsapp/connection_controller.py b/backend/package/yuxi/channels/adapters/whatsapp/connection_controller.py index 69fe49bc..750b8c7e 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/connection_controller.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/connection_controller.py @@ -55,7 +55,10 @@ class ConnectionController: for listener in self._state_listeners: try: if asyncio.iscoroutinefunction(listener): - asyncio.ensure_future(listener(old, new_state)) + task = asyncio.create_task(listener(old, new_state)) + task.add_done_callback( + lambda t: logger.exception("ConnectionController listener failed") if t.exception() else None + ) else: listener(old, new_state) except Exception: diff --git a/backend/package/yuxi/channels/adapters/whatsapp/credential_queue.py b/backend/package/yuxi/channels/adapters/whatsapp/credential_queue.py index 8a2ad706..c901d362 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/credential_queue.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/credential_queue.py @@ -1,7 +1,7 @@ from __future__ import annotations +import asyncio import json -import threading import time from pathlib import Path from typing import Any @@ -12,105 +12,119 @@ from yuxi.utils.logging_config import logger class CredentialQueue: def __init__(self, auth_dir: Path, debounce_ms: int = 500): self._auth_dir = auth_dir - self._lock = threading.Lock() + self._lock = asyncio.Lock() self._write_count = 0 self._debounce_ms = debounce_ms self._pending_creds: dict[str, Any] | None = None self._last_write_time = 0.0 - self._debounce_timer: threading.Timer | None = None + self._debounce_task: asyncio.Task | None = None - def write_auth(self, creds: dict[str, Any]) -> int: + async def write_auth(self, creds: dict[str, Any]) -> int: + loop = asyncio.get_running_loop() now = time.monotonic() - with self._lock: + async with self._lock: self._write_count += 1 count = self._write_count if now - self._last_write_time < self._debounce_ms / 1000.0: self._pending_creds = creds - self._schedule_debounced_write() + self._schedule_debounced_write(loop) return count self._last_write_time = now self._pending_creds = None - return self._do_write(creds, count) + return await self._do_write(creds, count) - def _schedule_debounced_write(self) -> None: - if self._debounce_timer is not None: - self._debounce_timer.cancel() + def _schedule_debounced_write(self, loop: asyncio.AbstractEventLoop) -> None: + if self._debounce_task is not None: + self._debounce_task.cancel() - self._debounce_timer = threading.Timer( - self._debounce_ms / 1000.0, - self._flush_debounced, - ) - self._debounce_timer.daemon = True - self._debounce_timer.start() + async def _delayed_flush(): + await asyncio.sleep(self._debounce_ms / 1000.0) + await self._flush_debounced() - def _flush_debounced(self) -> None: - with self._lock: + self._debounce_task = loop.create_task(_delayed_flush()) + + async def _flush_debounced(self) -> None: + async with self._lock: creds = self._pending_creds self._pending_creds = None - self._debounce_timer = None + self._debounce_task = None if creds is None: return count = self._write_count - self._do_write(creds, count) + await self._do_write(creds, count) - def _do_write(self, creds: dict[str, Any], count: int) -> int: + async def _do_write(self, creds: dict[str, Any], count: int) -> int: path = self._auth_dir / "creds.json" backup = self._auth_dir / "creds.json.bak" tmp_path = self._auth_dir / "creds.json.tmp" try: data = json.dumps(creds, indent=2, ensure_ascii=False) - tmp_path.write_text(data, encoding="utf-8") - if path.exists(): - try: - path.replace(backup) - except OSError: - pass - tmp_path.replace(path) - logger.debug(f"CredentialQueue: wrote creds (#{count}) to {path}") + + def _sync_write() -> int: + tmp_path.write_text(data, encoding="utf-8") + if path.exists(): + try: + path.replace(backup) + except OSError: + pass + tmp_path.replace(path) + return count + + result = await asyncio.to_thread(_sync_write) + logger.debug(f"CredentialQueue: wrote creds (#{result}) to {path}") + return result except (OSError, TypeError) as e: logger.error(f"CredentialQueue: write failed (#{count}): {e}") raise - return count - def read_auth(self) -> dict[str, Any] | None: + async def read_auth(self) -> dict[str, Any] | None: path = self._auth_dir / "creds.json" backup = self._auth_dir / "creds.json.bak" - try: + + def _sync_read(): if not path.exists(): return None - return json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as e: - logger.error(f"CredentialQueue: read failed: {e}") - if backup.exists(): - try: - data = json.loads(backup.read_text(encoding="utf-8")) - backup.replace(path) - logger.info("CredentialQueue: restored creds from backup") - return data - except (OSError, json.JSONDecodeError) as e2: - logger.error(f"CredentialQueue: backup recovery failed: {e2}") - return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + logger.error(f"CredentialQueue: read failed: {e}") + if backup.exists(): + try: + data = json.loads(backup.read_text(encoding="utf-8")) + backup.replace(path) + logger.info("CredentialQueue: restored creds from backup") + return data + except (OSError, json.JSONDecodeError) as e2: + logger.error(f"CredentialQueue: backup recovery failed: {e2}") + return None - def clear_auth(self) -> bool: - with self._lock: + return await asyncio.to_thread(_sync_read) + + async def clear_auth(self) -> bool: + async with self._lock: self._pending_creds = None - if self._debounce_timer is not None: - self._debounce_timer.cancel() - self._debounce_timer = None + if self._debounce_task is not None: + self._debounce_task.cancel() + self._debounce_task = None path = self._auth_dir / "creds.json" backup = self._auth_dir / "creds.json.bak" - try: + + def _sync_clear(): if path.exists(): if backup.exists(): backup.unlink() path.rename(backup) logger.info(f"CredentialQueue: cleared creds, backup at {backup}") return True + + try: + result = await asyncio.to_thread(_sync_clear) + return result except OSError as e: logger.error(f"CredentialQueue: clear failed: {e}") return False diff --git a/backend/package/yuxi/channels/adapters/whatsapp/format.py b/backend/package/yuxi/channels/adapters/whatsapp/format.py index e0a10967..82056d67 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/format.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/format.py @@ -14,6 +14,7 @@ from yuxi.channels.models import ( MessageType, ) +from .session import _extract_sender_number from .structured_context import ContextSource, StructuredContextEntry, UntrustedStructuredContext _PROTOCOL_REVOKE = 0 @@ -153,8 +154,8 @@ def _normalize_poll_vote(poll_update: dict, raw_payload: dict, channel_id: str) selected = vote_info.get("selectedOptions", []) if isinstance(selected, list): selected_options = selected - elif isinstance(vote_info, list): - selected_options = vote_info + elif isinstance(vote_info, list): + selected_options = vote_info option_names = [o.get("name", str(o)) if isinstance(o, dict) else str(o) for o in selected_options] vote_text = f"[Poll Vote] {' | '.join(option_names)}" if option_names else "[Poll Vote]" @@ -370,10 +371,6 @@ def _jid_to_chat_type(jid: str) -> ChatType: return ChatType.DIRECT -def _extract_sender_number(jid: str) -> str: - return jid.split("@")[0] - - def _build_structured_context(msg: dict, content_type: str, msg_id: str | None) -> UntrustedStructuredContext: context = UntrustedStructuredContext() diff --git a/backend/package/yuxi/channels/adapters/whatsapp/inbound_cache.py b/backend/package/yuxi/channels/adapters/whatsapp/inbound_cache.py index 52e07dd5..e0877fbc 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/inbound_cache.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/inbound_cache.py @@ -2,6 +2,7 @@ from __future__ import annotations import time from collections import OrderedDict +from threading import Lock from typing import Any from yuxi.utils.logging_config import logger @@ -20,57 +21,62 @@ class InboundMessageCache: self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict() self._max_size = max_size self._ttl = ttl_seconds + self._lock = Lock() def put(self, msg_id: str, jid: str, content: str, metadata: dict[str, Any] | None = None) -> None: if not msg_id: return - self._cache[msg_id] = { - "jid": jid, - "content": content[:500], - "timestamp": time.time(), - "metadata": metadata or {}, - } - self._cache.move_to_end(msg_id) - self._evict() + with self._lock: + self._cache[msg_id] = { + "jid": jid, + "content": content[:500], + "timestamp": time.time(), + "metadata": metadata or {}, + } + self._cache.move_to_end(msg_id) + self._evict() def get(self, msg_id: str) -> dict[str, Any] | None: - entry = self._cache.get(msg_id) - if entry is None: - return None - if time.time() - entry["timestamp"] > self._ttl: - self._cache.pop(msg_id, None) - return None - return entry + with self._lock: + entry = self._cache.get(msg_id) + if entry is None: + return None + if time.time() - entry["timestamp"] > self._ttl: + self._cache.pop(msg_id, None) + return None + return entry def lookup_inbound_meta(self, target_jid: str, target_msg_id: str | None = None) -> dict[str, Any] | None: - if target_msg_id: - exact = self.get(target_msg_id) - if exact: - return exact + with self._lock: + if target_msg_id: + exact = self._cache.get(target_msg_id) + if exact and time.time() - exact["timestamp"] <= self._ttl: + return exact - target_is_group = _is_group_jid(target_jid) - candidates: list[tuple[str, dict[str, Any], float]] = [] + target_is_group = _is_group_jid(target_jid) + candidates: list[tuple[str, dict[str, Any], float]] = [] - now = time.time() - for msg_id, entry in self._cache.items(): - if now - entry["timestamp"] > self._ttl: - continue - entry_jid = entry.get("jid", "") - if target_is_group != _is_group_jid(entry_jid): - continue - candidates.append((msg_id, entry, entry["timestamp"])) - if len(candidates) >= 10: - break + now = time.time() + for msg_id, entry in self._cache.items(): + if now - entry["timestamp"] > self._ttl: + continue + entry_jid = entry.get("jid", "") + if target_is_group != _is_group_jid(entry_jid): + continue + candidates.append((msg_id, entry, entry["timestamp"])) + if len(candidates) >= 10: + break - if not candidates: - return None + if not candidates: + return None - candidates.sort(key=lambda x: x[2], reverse=True) - best_msg_id, best_entry, _ = candidates[0] - logger.debug( - f"InboundMessageCache: fuzzy match for {target_jid} -> msg_id={best_msg_id}, candidates={len(candidates)}" - ) - return best_entry + candidates.sort(key=lambda x: x[2], reverse=True) + best_msg_id, best_entry, _ = candidates[0] + logger.debug( + f"InboundMessageCache: fuzzy match for {target_jid} " + f"-> msg_id={best_msg_id}, candidates={len(candidates)}" + ) + return best_entry def resolve_quoted_message_key(self, target_jid: str, quoted_msg_id: str | None = None) -> dict[str, Any] | None: meta = None @@ -98,7 +104,9 @@ class InboundMessageCache: del self._cache[oldest] def clear(self) -> None: - self._cache.clear() + with self._lock: + self._cache.clear() def __len__(self) -> int: - return len(self._cache) + with self._lock: + return len(self._cache) diff --git a/backend/package/yuxi/channels/adapters/whatsapp/inbound_processor.py b/backend/package/yuxi/channels/adapters/whatsapp/inbound_processor.py new file mode 100644 index 00000000..1086ab40 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/whatsapp/inbound_processor.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from yuxi.utils.logging_config import logger + +from .inbound_pipeline import InboundPipeline, PipelineAction, PipelineResult +from .security import DmPolicy + + +class InboundProcessor: + def __init__( + self, + echo_filter, + deduplicator, + button_deduplicator, + security, + self_chat_mode: bool, + per_group_config, + per_dm_config, + pairing, + self_jid: str | None = None, + ): + self._echo_filter = echo_filter + self._deduplicator = deduplicator + self._button_deduplicator = button_deduplicator + self._security = security + self._self_chat_mode = self_chat_mode + self._per_group_config = per_group_config + self._per_dm_config = per_dm_config + self._pairing = pairing + self._self_jid = self_jid + + def build_pipeline(self) -> InboundPipeline: + pipeline = InboundPipeline() + pipeline.add_step(self._check_echo, "echo_filter") + pipeline.add_step(self._check_dedupe, "dedupe") + pipeline.add_step(self._check_button_dedupe, "button_dedupe") + pipeline.add_step(self._check_security, "security") + return pipeline + + def _check_echo(self, payload: dict) -> PipelineResult: + key = payload.get("key", {}) + msg = payload.get("message", {}) + remote_jid = key.get("remoteJid", "") + text = msg.get("conversation", "") + if not text: + text = (msg.get("extendedTextMessage") or {}).get("text", "") + + if self._echo_filter.is_echo(remote_jid, text): + return PipelineResult(action=PipelineAction.DROP, reason="echo") + return PipelineResult(action=PipelineAction.ACCEPT) + + def _check_dedupe(self, payload: dict) -> PipelineResult: + msg_id = payload.get("key", {}).get("id", "") + if not msg_id: + return PipelineResult(action=PipelineAction.ACCEPT) + if self._deduplicator.is_duplicate(msg_id): + return PipelineResult(action=PipelineAction.DROP, reason="duplicate") + return PipelineResult(action=PipelineAction.ACCEPT) + + def _check_button_dedupe(self, payload: dict) -> PipelineResult: + msg = payload.get("message", {}) + button_msg = msg.get("buttonsResponseMessage") or msg.get("templateButtonReplyMessage") + if not button_msg: + return PipelineResult(action=PipelineAction.ACCEPT) + + sender = payload.get("key", {}).get("remoteJid", "") + button_id = button_msg.get("selectedButtonId", "") + if not button_id: + button_id = button_msg.get("selectedId", "") + + if not button_id: + return PipelineResult(action=PipelineAction.ACCEPT) + + if self._button_deduplicator.is_duplicate(sender, button_id): + return PipelineResult(action=PipelineAction.DROP, reason="button_duplicate") + return PipelineResult(action=PipelineAction.ACCEPT) + + def _check_security(self, payload: dict) -> PipelineResult: + from_me = payload.get("key", {}).get("fromMe", False) + if from_me: + if self._self_chat_mode: + return PipelineResult(action=PipelineAction.ACCEPT) + return PipelineResult(action=PipelineAction.DROP, reason="self_message") + + remote_jid = payload.get("key", {}).get("remoteJid", "") + sender = remote_jid.split("@")[0] + + if "@g.us" in remote_jid: + if not self._per_group_config.is_enabled(remote_jid): + return PipelineResult(action=PipelineAction.DROP, reason="group_disabled") + if self._per_group_config.require_mention(remote_jid): + msg = payload.get("message", {}) + ext_text = msg.get("extendedTextMessage", {}) + context_info = ext_text.get("contextInfo", {}) + mentioned_jids = context_info.get("mentionedJid", []) or [] + if not any(self._self_jid and mj == self._self_jid for mj in mentioned_jids): + return PipelineResult(action=PipelineAction.DROP, reason="require_mention_not_met") + allowed, reason = self._security.check_group_access(remote_jid) + if not allowed: + logger.debug(f"[WhatsApp] Group access denied for {remote_jid}: {reason}") + return PipelineResult(action=PipelineAction.DROP, reason=reason or "group_blocked") + else: + if not self._per_dm_config.is_enabled(sender): + return PipelineResult(action=PipelineAction.DROP, reason="dm_disabled") + if self._security.dm_policy == DmPolicy.PAIRING and not self._pairing.is_paired(sender): + logger.debug(f"[WhatsApp] DM pairing required for {sender}") + return PipelineResult(action=PipelineAction.DROP, reason="dm_not_paired") + allowed, reason = self._security.check_dm_access(sender) + if not allowed: + logger.debug(f"[WhatsApp] DM access denied for {sender}: {reason}") + return PipelineResult(action=PipelineAction.DROP, reason=reason or "dm_blocked") + + return PipelineResult(action=PipelineAction.ACCEPT) diff --git a/backend/package/yuxi/channels/adapters/whatsapp/markdown.py b/backend/package/yuxi/channels/adapters/whatsapp/markdown.py index 485118bb..e7890b8b 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/markdown.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/markdown.py @@ -51,14 +51,20 @@ def markdown_to_whatsapp(md_text: str) -> str: text = _convert_tables(text) - text = re.sub(r"\*\*\*(.+?)\*\*\*", r"*_\1_*", text) - text = re.sub(r"___(.+?)___", r"*_\1_*", text) + # All formatting -> temp markers to prevent cross-contamination + text = re.sub(r"\*\*\*(.+?)\*\*\*", r"\1", text) + text = re.sub(r"___(.+?)___", r"\1", text) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + text = re.sub(r"(?\1", text) + text = re.sub(r"(?\1", text) - text = re.sub(r"\*\*(.+?)\*\*", r"*\1*", text) - text = re.sub(r"__(.+?)__", r"*\1*", text) - - text = re.sub(r"(?", "*_") + text = text.replace("", "_*") + text = text.replace("", "*") + text = text.replace("", "*") + text = text.replace("", "_") + text = text.replace("", "_") text = re.sub(r"~~(.+?)~~", r"~\1~", text) diff --git a/backend/package/yuxi/channels/adapters/whatsapp/poll.py b/backend/package/yuxi/channels/adapters/whatsapp/poll.py index 706e3de4..b5318ca6 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/poll.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/poll.py @@ -13,6 +13,8 @@ from yuxi.channels.models import ( ) from yuxi.utils.logging_config import logger +from .session import _extract_sender_number + def normalize_poll_input(poll: dict[str, Any], max_options: int = 12) -> dict[str, Any] | None: name = poll.get("name", "").strip() @@ -25,6 +27,14 @@ def normalize_poll_input(poll: dict[str, Any], max_options: int = 12) -> dict[st options = [o.strip() for o in options.split(",") if o.strip()] options = [o.strip() for o in options if o.strip()] + seen = set() + unique_options = [] + for o in options: + if o not in seen: + seen.add(o) + unique_options.append(o) + options = unique_options + if len(options) < 2: logger.warning("Poll normalization: less than 2 valid options") return None @@ -64,8 +74,8 @@ def parse_poll_vote(raw_payload: dict[str, Any], channel_id: str) -> ChannelMess selected = vote_info.get("selectedOptions", []) if isinstance(selected, list): selected_options = selected - elif isinstance(vote_info, list): - selected_options = vote_info + elif isinstance(vote_info, list): + selected_options = vote_info option_names = [o.get("name", str(o)) if isinstance(o, dict) else str(o) for o in selected_options] vote_text = f"[Poll Vote] {' | '.join(option_names)}" if option_names else "[Poll Vote]" @@ -107,7 +117,3 @@ def _jid_to_chat_type(jid: str) -> ChatType: if "@g.us" in jid: return ChatType.GROUP return ChatType.DIRECT - - -def _extract_sender_number(jid: str) -> str: - return jid.split("@")[0] diff --git a/backend/package/yuxi/channels/adapters/whatsapp/reactions/__init__.py b/backend/package/yuxi/channels/adapters/whatsapp/reactions/__init__.py index 275586bd..600afd0c 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/reactions/__init__.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/reactions/__init__.py @@ -1,5 +1,5 @@ -from yuxi.channels.adapters.whatsapp.reactions.reaction_level import ReactionLevel, ReactionLevelController from yuxi.channels.adapters.whatsapp.reactions.ack_reaction import AckReactionManager +from yuxi.channels.adapters.whatsapp.reactions.reaction_level import ReactionLevel, ReactionLevelController __all__ = [ "ReactionLevel", diff --git a/backend/package/yuxi/channels/adapters/whatsapp/send_manager.py b/backend/package/yuxi/channels/adapters/whatsapp/send_manager.py new file mode 100644 index 00000000..eaae20d4 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/whatsapp/send_manager.py @@ -0,0 +1,429 @@ +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") diff --git a/backend/package/yuxi/channels/adapters/whatsapp/sent_message_cache.py b/backend/package/yuxi/channels/adapters/whatsapp/sent_message_cache.py index fd112e33..8d38a555 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/sent_message_cache.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/sent_message_cache.py @@ -2,6 +2,7 @@ from __future__ import annotations import time from collections import OrderedDict +from threading import Lock from typing import Any from yuxi.utils.logging_config import logger @@ -12,26 +13,29 @@ class SentMessageCache: self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict() self._max_size = max_size self._ttl = ttl_seconds + self._lock = Lock() def put(self, msg_id: str, jid: str, metadata: dict[str, Any] | None = None) -> None: if not msg_id: return - self._cache[msg_id] = { - "jid": jid, - "timestamp": time.time(), - "metadata": metadata or {}, - } - self._cache.move_to_end(msg_id) - self._evict() + with self._lock: + self._cache[msg_id] = { + "jid": jid, + "timestamp": time.time(), + "metadata": metadata or {}, + } + self._cache.move_to_end(msg_id) + self._evict() def get(self, msg_id: str) -> dict[str, Any] | None: - entry = self._cache.get(msg_id) - if entry is None: - return None - if time.time() - entry["timestamp"] > self._ttl: - self._cache.pop(msg_id, None) - return None - return entry + with self._lock: + entry = self._cache.get(msg_id) + if entry is None: + return None + if time.time() - entry["timestamp"] > self._ttl: + self._cache.pop(msg_id, None) + return None + return entry def get_jid(self, msg_id: str) -> str | None: entry = self.get(msg_id) @@ -47,8 +51,10 @@ class SentMessageCache: del self._cache[oldest] def clear(self) -> None: - self._cache.clear() + with self._lock: + self._cache.clear() logger.info("SentMessageCache: cleared") def __len__(self) -> int: - return len(self._cache) + with self._lock: + return len(self._cache) diff --git a/backend/package/yuxi/channels/adapters/whatsapp/session.py b/backend/package/yuxi/channels/adapters/whatsapp/session.py index 003e7730..cf83a87c 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/session.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/session.py @@ -13,6 +13,9 @@ def _extract_sender_number(jid: str) -> str: return jid.split("@")[0] +extract_sender_number = _extract_sender_number + + def jid_to_chat_type(jid: str) -> str: if "@g.us" in jid: return "group" diff --git a/backend/package/yuxi/channels/adapters/whatsapp/vision/sticker_vision.py b/backend/package/yuxi/channels/adapters/whatsapp/vision/sticker_vision.py index dfe30f43..17aa9347 100644 --- a/backend/package/yuxi/channels/adapters/whatsapp/vision/sticker_vision.py +++ b/backend/package/yuxi/channels/adapters/whatsapp/vision/sticker_vision.py @@ -1,8 +1,9 @@ from __future__ import annotations -import aiohttp from typing import Any +import aiohttp + from yuxi.utils.logging_config import logger