这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
935 lines
38 KiB
Python
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
|