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.exceptions import ChannelNotConnectedError from yuxi.channels.meta import ChannelMeta as DisplayMeta from yuxi.channels.models import ( ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, HealthStatus, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger from .bridge import BaileysBridge from .channel_meta import ChannelMeta from .connection_controller import ConnectionController, ConnectionState from .credential_queue import CredentialQueue from .debounce import MessageDebouncer 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 .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 .sent_message_cache import SentMessageCache from .session import jid_to_thread_key, normalize_phone, resolve_session_scope from .stream.lane_delivery import LaneDelivery from .vision.sticker_vision import StickerVision from .watchdog import Watchdog @register_builtin_adapter class WhatsAppAdapter(BaseChannelAdapter): channel_id = "whatsapp" channel_type = ChannelType.WHATSAPP meta = DisplayMeta( id="whatsapp", label="WhatsApp", selection_label="WhatsApp", docs_path="docs/channels/whatsapp", docs_label="WhatsApp 文档", blurb="WhatsApp Bridge 适配器,通过 Baileys 协议连接 WhatsApp", order=9, aliases=["wa"], system_image="whatsapp", markdown_capable=True, exposure="public", ) channel_meta = ChannelMeta() text_chunk_limit = 4000 supports_markdown = True supports_streaming = True streaming_modes = ["off", "typing_indicator"] max_media_size_mb = 100 capabilities = ChannelCapabilities( chat_types=["direct", "group"], polls=True, reactions=True, unsend=True, reply=True, media=True, supports_markdown=True, supports_streaming=True, streaming_modes=["off", "typing_indicator"], text_chunk_limit=4000, max_media_size_mb=100, block_streaming=True, tts=TTSCapabilities( voice=TTSVoiceCapabilities( synthesis_target="voice-note", transcodes_audio=True, enabled=False, ) ), ) def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) cfg = config or {} self._status = ChannelStatus.DISCONNECTED self._bridge = BaileysBridge(cfg) self._monitor = WhatsAppMonitor(cfg, self._bridge) self._connection_ctrl = ConnectionController() self._heartbeat = HeartbeatManager(self._bridge) self._watchdog = Watchdog(timeout_seconds=cfg.get("watchdogTimeout", 30.0)) self._security = WhatsAppSecurityPolicy(cfg) self._pairing = PairingManager() self._reaction_level = ReactionLevelController(cfg) self._ack_reaction = AckReactionManager(cfg) self._per_group_config = PerGroupConfig.from_config(cfg) self._per_dm_config = PerDmConfig.from_config(cfg) self._error_policy = ErrorPolicyConfig.from_config(cfg) self._block_streaming = cfg.get("blockStreaming", False) self._chunk_mode = cfg.get("chunkMode", "length") self._send_read_receipts = cfg.get("sendReadReceipts", False) self._reply_to_mode = cfg.get("replyToMode", "first") self._lane_delivery = LaneDelivery(cfg) self._sticker_vision = StickerVision(cfg) self._self_chat_mode = cfg.get("selfChatMode", False) self._default_to = cfg.get("defaultTo") self._contact_directory = ContactDirectory.from_config(cfg) self._401_retry_pending = False self._sent_message_cache = SentMessageCache( max_size=500, ttl_seconds=cfg.get("sentMessageCacheTtl", 3600), ) self._inbound_cache = InboundMessageCache( max_size=500, ttl_seconds=600, ) self._deduplicator = MessageDeduplicator(ttl_seconds=cfg.get("dedupeTtl", 300)) self._button_deduplicator = ButtonDeduplicator(ttl_seconds=cfg.get("buttonDedupeTtl", 5.0)) self._echo_filter = EchoFilter(ttl_seconds=cfg.get("echoTtl", 10.0)) self._debouncer = MessageDebouncer( window_seconds=cfg.get("debounceWindow", 2.0), 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 async def connect(self) -> None: if self._status in (ChannelStatus.CONNECTED, ChannelStatus.CONNECTING): return self._status = ChannelStatus.CONNECTING self._connection_ctrl.transition(ConnectionState.CONNECTING) logger.info(f"[WhatsApp] Starting channel '{self.config.get('name', self.channel_id)}'") try: await self._bridge.start() except Exception as e: self._status = ChannelStatus.ERROR self._connection_ctrl.transition(ConnectionState.DISCONNECTED) logger.error(f"[WhatsApp] Bridge start failed: {e}") raise ChannelNotConnectedError() from e health = await self._bridge.health_check() if health.status == "healthy": self._self_jid = health.metadata.get("jid", "") logger.info(f"[WhatsApp] Connected as {self._self_jid}") self._connection_ctrl.transition(ConnectionState.ACTIVE) self._setup_monitor_handler() self._heartbeat.on_unhealthy(self._on_bridge_unhealthy) self._watchdog.on_timeout(self._on_watchdog_timeout) await self._monitor.start() await self._heartbeat.start() await self._watchdog.start() self._status = ChannelStatus.CONNECTED return self._status = ChannelStatus.CONNECTING self._connection_ctrl.transition(ConnectionState.QR_PENDING) logger.info("[WhatsApp] Waiting for QR login (call pre_connect first)") async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[WhatsApp] Stopping channel '{self.config.get('name', self.channel_id)}'") await self._watchdog.stop() await self._heartbeat.stop() await self._monitor.stop() await self._bridge.stop() self._status = ChannelStatus.DISCONNECTED self._connection_ctrl.transition(ConnectionState.DISCONNECTED) self._self_jid = None async def pre_connect(self, force: bool = False) -> dict: if not force: health = await self._bridge.health_check() if health.status == "healthy": self._self_jid = health.metadata.get("jid", "") self._connection_ctrl.transition(ConnectionState.ACTIVE) return {"status": "logged_in", "jid": self._self_jid} qr_result = await self._bridge.get_qr() if qr_data := qr_result.get("qr"): self._connection_ctrl.transition(ConnectionState.QR_PENDING) return {"status": "pending_scan", "qr_base64": qr_data, "qr_type": "png_base64"} 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) 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: 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", ) 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, ) def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage: return _normalize_inbound(raw, self.channel_id) def format_outbound(self, response: ChannelResponse) -> Any: return _format_outbound(response) async def health_check(self) -> HealthStatus: bridge_health = await self._bridge.health_check() if self._status != ChannelStatus.CONNECTED: return HealthStatus( status="degraded", metadata={ "adapter_status": self._status.value, "connection_state": self._connection_ctrl.state.value, "bridge": bridge_health.metadata, }, ) return HealthStatus( status=bridge_health.status, latency_ms=bridge_health.latency_ms, last_error=bridge_health.last_error, last_connected_at=utc_now_naive() if bridge_health.status == "healthy" else None, metadata={ "jid": self._self_jid or "", "adapter_status": self._status.value, "connection_state": self._connection_ctrl.state.value, "watchdog_alive": self._watchdog.is_alive(), "last_inbound_at": self._last_inbound_at, "last_message_at": self._last_message_at, **bridge_health.metadata, }, ) def on_connection_change(self, handler: Callable[[dict[str, Any]], Awaitable[None]]) -> None: self._on_connection_change = handler async def _handle_sse_event(self, raw_payload: dict[str, Any]) -> None: self._watchdog.feed() event_type = raw_payload.get("type", "") if event_type == "connection": status = raw_payload.get("status", "") reason = raw_payload.get("reason", "") if status == "close" and reason in ("logged_out_401", "logged_out"): logger.warning("[WhatsApp] Bridge reported logged out (401)") if not self._401_retry_pending: self._401_retry_pending = True logger.info("[WhatsApp] 401 detected, checking if transient (wait 5s)...") await asyncio.sleep(5.0) health = await self._bridge.health_check() if health.status == "healthy": self._401_retry_pending = False self._connection_ctrl.transition(ConnectionState.ACTIVE) self._self_jid = health.metadata.get("jid", self._self_jid) logger.info("[WhatsApp] 401 was transient, connection recovered") return logger.warning("[WhatsApp] 401 confirmed as permanent, cleaning up") 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 auth_dir = resolve_auth_dir(self.channel_id, self.config) perform_logout_cleanup(auth_dir) if self._on_connection_change: await self._on_connection_change( {"status": "logged_out", "reason": reason, "code": raw_payload.get("statusCode")} ) return self._connection_ctrl.transition( ConnectionState.ACTIVE if raw_payload.get("status") == "open" else ConnectionState.DISCONNECTED ) if self._on_connection_change: await self._on_connection_change(raw_payload) if self._connection_ctrl.state == ConnectionState.ACTIVE: logger.info("[WhatsApp] Connection opened") if self._status != ChannelStatus.CONNECTED: health = await self._bridge.health_check() self._self_jid = health.metadata.get("jid", self._self_jid) self._status = ChannelStatus.CONNECTED return if event_type == "qr": self._connection_ctrl.transition(ConnectionState.QR_PENDING) logger.info("[WhatsApp] QR code event received") return if self._message_handler is None: return result = await self._inbound_pipeline.process(raw_payload) if result.action != PipelineAction.ACCEPT: self._echo_filter.clear() return self._last_inbound_at = time.monotonic() is_from_me = raw_payload.get("key", {}).get("fromMe", False) if is_from_me and not self._self_chat_mode: return msg_id = raw_payload.get("key", {}).get("id", "") if is_from_me and self._self_chat_mode: modified = raw_payload.copy() modified["key"] = {**raw_payload.get("key", {}), "fromMe": False} channel_msg = self.normalize_inbound(modified) channel_msg.metadata["from_me"] = True else: channel_msg = self.normalize_inbound(raw_payload) chat_id = channel_msg.identity.channel_chat_id is_group = "@g.us" in chat_id if msg_id and channel_msg.attachments: self._recent_messages[msg_id] = raw_payload if len(self._recent_messages) > 200: oldest = next(iter(self._recent_messages)) del self._recent_messages[oldest] if not is_from_me and msg_id: msg_content = raw_payload.get("message", {}) text = ( msg_content.get("conversation", "") or (msg_content.get("extendedTextMessage") or {}).get("text", "") or (msg_content.get("imageMessage") or {}).get("caption", "") or (msg_content.get("videoMessage") or {}).get("caption", "") or (msg_content.get("documentMessage") or {}).get("caption", "") ) self._inbound_cache.put(msg_id, chat_id, text) if not is_from_me and self._ack_reaction.can_ack(chat_id, is_group): asyncio.ensure_future(self._send_ack_reaction(chat_id, msg_id)) if not is_from_me and self._send_read_receipts and msg_id: asyncio.ensure_future(self._send_read_receipt(chat_id, msg_id)) if self._sticker_vision.enabled and channel_msg.attachments: asyncio.ensure_future(self._enrich_with_vision(channel_msg, raw_payload)) await self._message_handler(channel_msg) async def _send_ack_reaction(self, chat_id: str, msg_id: str) -> None: if not msg_id: return try: await self._bridge.send_reaction( jid=chat_id, message_id=msg_id, emoji=self._ack_reaction.emoji, ) self._ack_reaction.record_ack(chat_id) except Exception: logger.debug(f"[WhatsApp] ACK reaction failed for {msg_id}", exc_info=True) async def _send_read_receipt(self, chat_id: str, msg_id: str) -> None: if not msg_id: return try: await self._bridge.send_read_receipt(jid=chat_id, message_ids=[msg_id]) except Exception: logger.debug(f"[WhatsApp] Read receipt failed for {msg_id}", exc_info=True) async def _enrich_with_vision(self, channel_msg: ChannelMessage, raw_payload: dict) -> None: try: for attachment in channel_msg.attachments: if attachment.type in ("image", "sticker") and attachment.url: description = await self._sticker_vision.describe_sticker(attachment.url) if description: channel_msg.metadata["vision_description"] = description logger.debug(f"[WhatsApp] Vision enriched: {description[:100]}") break elif attachment.type == "audio" and attachment.url: transcription = await self._sticker_vision.transcribe_audio(attachment.url) if transcription: channel_msg.metadata["audio_transcription"] = transcription logger.debug(f"[WhatsApp] Audio transcription: {transcription[:100]}") break except Exception: 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) def _setup_monitor_handler(self) -> None: self._monitor.on_raw_message(self._handle_sse_event) async def _on_bridge_unhealthy(self, error: str) -> None: logger.warning(f"[WhatsApp] Bridge unhealthy for extended period: {error}, attempting recovery") self._status = ChannelStatus.ERROR self._connection_ctrl.transition(ConnectionState.RECONNECTING) if self._on_connection_change: await self._on_connection_change( { "status": "error", "error": f"Bridge unhealthy: {error}", } ) async def _on_watchdog_timeout(self) -> None: logger.warning("[WhatsApp] Watchdog timeout, bridge may be unresponsive") self._status = ChannelStatus.ERROR self._connection_ctrl.transition(ConnectionState.RECONNECTING) if self._on_connection_change: await self._on_connection_change( { "status": "error", "error": "Watchdog timeout - bridge unresponsive", } ) 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" try: avatar_url = await self._bridge.get_profile_picture(jid) except Exception: avatar_url = None return { "phone": phone, "jid": jid, "avatar_url": avatar_url, } async def get_groups(self) -> dict[str, Any]: return await self._bridge.get_groups() async def get_group_info(self, group_jid: str) -> dict[str, Any]: return await self._bridge.get_group_info(group_jid) async def wait_scan(self, timeout: float = 120.0) -> dict[str, Any]: 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 auth_dir = resolve_auth_dir(self.channel_id, self.config) if not validate_credential_freshness(auth_dir): return False try: health = await self._bridge.health_check() return health.status == "healthy" except Exception: return False async def get_qr_status(self) -> dict[str, Any]: 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 result = await self._bridge.logout() auth_dir = resolve_auth_dir(self.channel_id, self.config) perform_logout_cleanup(auth_dir) self._connection_ctrl.transition(ConnectionState.LOGGED_OUT) return result async def get_message_history(self, jid: str, limit: int = 50, before: str | None = None) -> dict[str, Any]: return await self._bridge.get_message_history(jid, limit=limit, before=before) def thread_key_for(self, jid: str) -> str: scope = resolve_session_scope(self.config) return jid_to_thread_key(self.channel_id, jid, scope) def pair_user(self, phone_number: str, code: str, timeout: float = 300) -> None: self._pairing.create_pair_request(phone_number, code, timeout) def confirm_pair(self, phone_number: str) -> bool: return self._pairing.confirm_pair(phone_number) def is_paired(self, phone_number: str) -> bool: return self._pairing.is_paired(phone_number) def add_to_allowlist(self, phone_number: str) -> None: self._security.add_to_allow_list(phone_number) def remove_from_allowlist(self, phone_number: str) -> bool: return self._security.remove_from_allow_list(phone_number) @property def security_policy(self) -> WhatsAppSecurityPolicy: return self._security @property def pairing_manager(self) -> PairingManager: return self._pairing @property def connection_controller(self) -> ConnectionController: return self._connection_ctrl @property def last_inbound_at(self) -> float | None: return self._last_inbound_at @property def last_message_at(self) -> float | None: return self._last_message_at def resolve_system_prompt(self, chat_id: str) -> str | 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) def resolve_system_prompt_for_group(self, group_jid: str) -> str | None: return self._per_group_config.system_prompt(group_jid) def resolve_system_prompt_for_direct(self, phone: str) -> str | None: return self._per_dm_config.system_prompt(phone) 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) @property def inbound_cache(self) -> InboundMessageCache: return self._inbound_cache @property def contact_directory(self) -> ContactDirectory: return self._contact_directory @property def self_chat_mode(self) -> bool: return self._self_chat_mode @property def default_target(self) -> str | None: return self._default_to @property def agent_reaction_guidance(self) -> str | None: return self.config.get("agentReactionGuidance") def resolve_inbound_debounce_ms(self, account_id: str | None = None) -> float: accounts_cfg = self.config.get("accounts", {}) if account_id and isinstance(accounts_cfg, dict): acct = accounts_cfg.get(account_id, {}) if isinstance(acct, dict) and "debounceWindow" in acct: return float(acct["debounceWindow"]) * 1000 return self.config.get("debounceWindow", 2.0) * 1000 @property def has_capability(self): return self.channel_meta.has_capability