主要变更: 1. 重构导入顺序,统一模块导入规范 2. 提取通用方法到session模块,减少代码重复 3. 为缓存类添加线程/异步锁,修复并发安全问题 4. 新增入站处理器和发送管理器模块,拆分业务逻辑 5. 优化凭证队列,改为异步实现 6. 移除废弃的SSE_POLLING能力标识 7. 修复轮询投票解析逻辑 8. 优化Markdown转换规则,避免格式冲突 9. 完善连接控制器的异常处理 10. 新增发送静默消息的API支持
693 lines
28 KiB
Python
693 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
|
|
from yuxi.channels.base import BaseChannelAdapter
|
|
from yuxi.channels.capabilities import ChannelCapabilities, TTSCapabilities, TTSVoiceCapabilities
|
|
from yuxi.channels.exceptions import ChannelNotConnectedError
|
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker
|
|
from yuxi.channels.meta import ChannelMeta as DisplayMeta
|
|
from yuxi.channels.models import (
|
|
ChannelMessage,
|
|
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 .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_cache import InboundMessageCache
|
|
from .inbound_processor import InboundProcessor
|
|
from .inbound_pipeline import InboundPipeline, PipelineAction
|
|
from .monitor import WhatsAppMonitor
|
|
from .pairing import PairingManager
|
|
from .per_dm_config import PerDmConfig
|
|
from .reactions.ack_reaction import AckReactionManager
|
|
from .reactions.reaction_level import ReactionLevelController
|
|
from .security import WhatsAppSecurityPolicy
|
|
from .send_manager import SendManager
|
|
from .sent_message_cache import SentMessageCache
|
|
from .session import jid_to_thread_key, normalize_phone, resolve_session_scope
|
|
from .stream.lane_delivery import LaneDelivery
|
|
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,
|
|
supports_broadcast=False,
|
|
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._self_jid: str | None = None
|
|
self._on_connection_change: Callable[[dict[str, Any]], Awaitable[None]] | None = None
|
|
self._recent_messages: dict[str, dict[str, Any]] = {}
|
|
self._last_inbound_at: float | None = None
|
|
self._last_message_at: float | None = None
|
|
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="whatsapp")
|
|
self._stream_buffers: dict[str, str] = {}
|
|
self._stream_chunk_counters: dict[str, int] = {}
|
|
self._stream_status_msg_ids: dict[str, str] = {}
|
|
self._last_message_tracker: list[float | None] = [None]
|
|
|
|
self._send_mgr = SendManager(
|
|
bridge=self._bridge,
|
|
circuit_breaker=self._circuit_breaker,
|
|
sent_message_cache=self._sent_message_cache,
|
|
echo_filter=self._echo_filter,
|
|
debouncer=self._debouncer,
|
|
reaction_level=self._reaction_level,
|
|
error_policy=self._error_policy,
|
|
lane_delivery=self._lane_delivery,
|
|
config=cfg,
|
|
stream_buffers=self._stream_buffers,
|
|
stream_chunk_counters=self._stream_chunk_counters,
|
|
stream_status_msg_ids=self._stream_status_msg_ids,
|
|
recent_messages=self._recent_messages,
|
|
last_message_tracker=self._last_message_tracker,
|
|
)
|
|
self._inbound_processor = InboundProcessor(
|
|
echo_filter=self._echo_filter,
|
|
deduplicator=self._deduplicator,
|
|
button_deduplicator=self._button_deduplicator,
|
|
security=self._security,
|
|
self_chat_mode=self._self_chat_mode,
|
|
per_group_config=self._per_group_config,
|
|
per_dm_config=self._per_dm_config,
|
|
pairing=self._pairing,
|
|
self_jid=None,
|
|
)
|
|
self._inbound_pipeline = self._inbound_processor.build_pipeline()
|
|
|
|
async def connect(self) -> None:
|
|
if self._status in (ChannelStatus.CONNECTED, ChannelStatus.CONNECTING):
|
|
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:
|
|
return await self._send_mgr.send(response, silent)
|
|
|
|
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
|
|
return await self._send_mgr.send_media(chat_id, media_type, data)
|
|
|
|
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
|
|
return await self._send_mgr.send_reaction(chat_id, msg_id, emoji)
|
|
|
|
async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
|
return await self._send_mgr.remove_reaction(chat_id, msg_id)
|
|
|
|
async def send_reaction_from_action(self, action: dict[str, Any]) -> DeliveryResult:
|
|
return await self._send_mgr.send_reaction_from_action(action)
|
|
|
|
async def download_media(self, file_id: str) -> bytes:
|
|
return await self._send_mgr.download_media(file_id)
|
|
|
|
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
|
|
return await self._send_mgr.send_stream_chunk(chat_id, msg_id, chunk, finished)
|
|
|
|
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
|
|
return await self._send_mgr.edit_message(chat_id, msg_id, content)
|
|
|
|
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
|
return await self._send_mgr.delete_message(chat_id, msg_id)
|
|
|
|
async def send_poll(self, chat_id: str, name: str, options: list[str], selectable_count: int = 1) -> DeliveryResult:
|
|
return await self._send_mgr.send_poll(chat_id, name, options, selectable_count)
|
|
|
|
async def send_location(
|
|
self,
|
|
chat_id: str,
|
|
latitude: float,
|
|
longitude: float,
|
|
name: str = "",
|
|
address: str = "",
|
|
) -> DeliveryResult:
|
|
return await self._send_mgr.send_location(chat_id, latitude, longitude, name, address)
|
|
|
|
async def send_contact(
|
|
self,
|
|
chat_id: str,
|
|
contacts: list[dict[str, str]],
|
|
) -> DeliveryResult:
|
|
return await self._send_mgr.send_contact(chat_id, contacts)
|
|
|
|
async def send_sticker(
|
|
self,
|
|
chat_id: str,
|
|
sticker_path: str,
|
|
reply_to: str | None = None,
|
|
) -> DeliveryResult:
|
|
return await self._send_mgr.send_sticker(chat_id, sticker_path, 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._send_mgr.send_buttons(chat_id, text, buttons, title, 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._send_mgr.send_list_message(chat_id, text, sections, title, footer, button_text)
|
|
|
|
def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
|
|
return _normalize_inbound(raw, self.channel_id)
|
|
|
|
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 .auth_resolve import resolve_auth_dir
|
|
from .logout_security import perform_logout_cleanup
|
|
|
|
auth_dir = resolve_auth_dir(self.channel_id, self.config)
|
|
perform_logout_cleanup(auth_dir)
|
|
|
|
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:
|
|
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:
|
|
self._inbound_processor._self_jid = self._self_jid
|
|
return self._inbound_processor.build_pipeline()
|
|
|
|
def _setup_monitor_handler(self) -> None:
|
|
self._monitor.on_raw_message(self._handle_sse_event)
|
|
|
|
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 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 .auth_resolve import resolve_auth_dir
|
|
from .logout_security import validate_credential_freshness
|
|
|
|
auth_dir = resolve_auth_dir(self.channel_id, self.config)
|
|
if not validate_credential_freshness(auth_dir):
|
|
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 .auth_resolve import resolve_auth_dir
|
|
from .logout_security import perform_logout_cleanup
|
|
|
|
result = await self._bridge.logout()
|
|
auth_dir = resolve_auth_dir(self.channel_id, self.config)
|
|
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:
|
|
base = None
|
|
if "@g.us" in chat_id:
|
|
base = self._per_group_config.system_prompt(chat_id)
|
|
else:
|
|
sender = chat_id.split("@")[0] if "@" in chat_id else chat_id
|
|
base = self._per_dm_config.system_prompt(sender)
|
|
return self._inject_reaction_guidance(base)
|
|
|
|
def resolve_system_prompt_for_group(self, group_jid: str) -> str | None:
|
|
base = self._per_group_config.system_prompt(group_jid)
|
|
return self._inject_reaction_guidance(base)
|
|
|
|
def resolve_system_prompt_for_direct(self, phone: str) -> str | None:
|
|
base = self._per_dm_config.system_prompt(phone)
|
|
return self._inject_reaction_guidance(base)
|
|
|
|
def _inject_reaction_guidance(self, base: str | None) -> str | None:
|
|
guidance = self.config.get("agentReactionGuidance")
|
|
if not guidance:
|
|
return base
|
|
guidance_text = f"\n\nYou may use WhatsApp reactions to respond with emoji when appropriate. {guidance}"
|
|
if base:
|
|
return base + guidance_text
|
|
return guidance_text.strip()
|
|
|
|
def resolve_quoted_message_key(self, chat_id: str, quoted_msg_id: str | None = None) -> dict | None:
|
|
return self._inbound_cache.resolve_quoted_message_key(chat_id, quoted_msg_id)
|
|
|
|
@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
|