ForcePilot/backend/package/yuxi/channels/adapters/whatsapp/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

935 lines
38 KiB
Python

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