refactor(whatsapp): 整理WhatsApp适配器代码结构并修复多线程安全问题
主要变更: 1. 重构导入顺序,统一模块导入规范 2. 提取通用方法到session模块,减少代码重复 3. 为缓存类添加线程/异步锁,修复并发安全问题 4. 新增入站处理器和发送管理器模块,拆分业务逻辑 5. 优化凭证队列,改为异步实现 6. 移除废弃的SSE_POLLING能力标识 7. 修复轮询投票解析逻辑 8. 优化Markdown转换规则,避免格式冲突 9. 完善连接控制器的异常处理 10. 新增发送静默消息的API支持
This commit is contained in:
parent
f551745ec2
commit
5c3611ff19
@ -1,14 +1,11 @@
|
||||
from yuxi.channels.adapters.whatsapp.accounts import MultiAccountManager, merge_account_config, resolve_default_account
|
||||
from yuxi.channels.adapters.whatsapp.adapter import WhatsAppAdapter
|
||||
from yuxi.channels.adapters.whatsapp.security import (
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
WhatsAppSecurityPolicy,
|
||||
)
|
||||
from yuxi.channels.adapters.whatsapp.pairing import PairingManager, PairRequest
|
||||
from yuxi.channels.adapters.whatsapp.inbound_pipeline import (
|
||||
InboundPipeline,
|
||||
PipelineAction,
|
||||
PipelineResult,
|
||||
from yuxi.channels.adapters.whatsapp.approve import (
|
||||
ApprovalCallbackManager,
|
||||
ApprovalHandler,
|
||||
ApprovalRequest,
|
||||
ApprovalStatus,
|
||||
ExecApprovals,
|
||||
)
|
||||
from yuxi.channels.adapters.whatsapp.channel_meta import (
|
||||
ChannelCapability,
|
||||
@ -19,27 +16,30 @@ from yuxi.channels.adapters.whatsapp.connection_controller import (
|
||||
ConnectionController,
|
||||
ConnectionState,
|
||||
)
|
||||
from yuxi.channels.adapters.whatsapp.accounts import MultiAccountManager, merge_account_config, resolve_default_account
|
||||
from yuxi.channels.adapters.whatsapp.reactions import ReactionLevel, ReactionLevelController, AckReactionManager
|
||||
from yuxi.channels.adapters.whatsapp.directory import PerGroupConfig, GroupEntry
|
||||
from yuxi.channels.adapters.whatsapp.per_dm_config import PerDmConfig
|
||||
from yuxi.channels.adapters.whatsapp.directory import GroupEntry, PerGroupConfig
|
||||
from yuxi.channels.adapters.whatsapp.error_policy import ErrorPolicy, ErrorPolicyConfig
|
||||
from yuxi.channels.adapters.whatsapp.network_errors import ErrorCategory, classify_error, is_recoverable
|
||||
from yuxi.channels.adapters.whatsapp.sent_message_cache import SentMessageCache
|
||||
from yuxi.channels.adapters.whatsapp.group_gating import GroupGating
|
||||
from yuxi.channels.adapters.whatsapp.approve import (
|
||||
ExecApprovals,
|
||||
ApprovalRequest,
|
||||
ApprovalStatus,
|
||||
ApprovalHandler,
|
||||
ApprovalCallbackManager,
|
||||
)
|
||||
from yuxi.channels.adapters.whatsapp.voice import transcoder, VoiceNoteSender
|
||||
from yuxi.channels.adapters.whatsapp.vision import StickerCache, StickerVision
|
||||
from yuxi.channels.adapters.whatsapp.health import DoctorDiagnostic, StatusIssueCollector
|
||||
from yuxi.channels.adapters.whatsapp.inbound_pipeline import (
|
||||
InboundPipeline,
|
||||
PipelineAction,
|
||||
PipelineResult,
|
||||
)
|
||||
from yuxi.channels.adapters.whatsapp.network_errors import ErrorCategory, classify_error, is_recoverable
|
||||
from yuxi.channels.adapters.whatsapp.pairing import PairingManager, PairRequest
|
||||
from yuxi.channels.adapters.whatsapp.per_dm_config import PerDmConfig
|
||||
from yuxi.channels.adapters.whatsapp.reactions import AckReactionManager, ReactionLevel, ReactionLevelController
|
||||
from yuxi.channels.adapters.whatsapp.security import (
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
WhatsAppSecurityPolicy,
|
||||
)
|
||||
from yuxi.channels.adapters.whatsapp.sent_message_cache import SentMessageCache
|
||||
from yuxi.channels.adapters.whatsapp.setup.setup_wizard import setup_wizard
|
||||
from yuxi.channels.adapters.whatsapp.ui import InteractiveDispatcher
|
||||
from yuxi.channels.adapters.whatsapp.targets import TargetResolver
|
||||
from yuxi.channels.adapters.whatsapp.ui import InteractiveDispatcher
|
||||
from yuxi.channels.adapters.whatsapp.vision import StickerCache, StickerVision
|
||||
from yuxi.channels.adapters.whatsapp.voice import VoiceNoteSender, transcoder
|
||||
|
||||
__all__ = [
|
||||
"WhatsAppAdapter",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from yuxi.channels.adapters.whatsapp.accounts.accounts import MultiAccountManager
|
||||
from yuxi.channels.adapters.whatsapp.accounts.account_config import merge_account_config
|
||||
from yuxi.channels.adapters.whatsapp.accounts.account_selection import resolve_default_account
|
||||
from yuxi.channels.adapters.whatsapp.accounts.accounts import MultiAccountManager
|
||||
|
||||
__all__ = [
|
||||
"MultiAccountManager",
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from yuxi.channels.base import BaseChannelAdapter
|
||||
from yuxi.channels.capabilities import ChannelCapabilities, TTSVoiceCapabilities, TTSCapabilities
|
||||
from yuxi.channels.capabilities import ChannelCapabilities, TTSCapabilities, TTSVoiceCapabilities
|
||||
from yuxi.channels.exceptions import ChannelNotConnectedError
|
||||
from yuxi.channels.infra.circuit_breaker import CircuitBreaker
|
||||
from yuxi.channels.meta import ChannelMeta as DisplayMeta
|
||||
from yuxi.channels.models import (
|
||||
ChannelMessage,
|
||||
@ -34,22 +32,20 @@ from .dedupe import ButtonDeduplicator, MessageDeduplicator
|
||||
from .directory import PerGroupConfig
|
||||
from .directory_peers import ContactDirectory
|
||||
from .echo_filter import EchoFilter
|
||||
from .inbound_cache import InboundMessageCache
|
||||
from .error_policy import ErrorPolicyConfig
|
||||
from .format import format_outbound as _format_outbound
|
||||
from .format import normalize_inbound as _normalize_inbound
|
||||
from .heartbeat import HeartbeatManager
|
||||
from .inbound_pipeline import InboundPipeline, PipelineAction, PipelineResult
|
||||
from .markdown import markdown_to_whatsapp, text_sanitizer
|
||||
from .media import _MEDIA_SUFFIX_MAP, cleanup_temp_file
|
||||
from .media import download_media as _download_media
|
||||
from .inbound_cache import InboundMessageCache
|
||||
from .inbound_processor import InboundProcessor
|
||||
from .inbound_pipeline import InboundPipeline, PipelineAction
|
||||
from .monitor import WhatsAppMonitor
|
||||
from .pairing import PairingManager
|
||||
from .per_dm_config import PerDmConfig
|
||||
from .reactions.ack_reaction import AckReactionManager
|
||||
from .reactions.reaction_level import ReactionLevelController
|
||||
from .security import DmPolicy, WhatsAppSecurityPolicy
|
||||
from .send import chunk_message
|
||||
from .security import WhatsAppSecurityPolicy
|
||||
from .send_manager import SendManager
|
||||
from .sent_message_cache import SentMessageCache
|
||||
from .session import jid_to_thread_key, normalize_phone, resolve_session_scope
|
||||
from .stream.lane_delivery import LaneDelivery
|
||||
@ -91,6 +87,7 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
media=True,
|
||||
supports_markdown=True,
|
||||
supports_streaming=True,
|
||||
supports_broadcast=False,
|
||||
streaming_modes=["off", "typing_indicator"],
|
||||
text_chunk_limit=4000,
|
||||
max_media_size_mb=100,
|
||||
@ -146,12 +143,45 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
max_calls=cfg.get("debounceMaxCalls", 3),
|
||||
)
|
||||
self._creds_queue: CredentialQueue | None = None
|
||||
self._inbound_pipeline = self._build_inbound_pipeline()
|
||||
self._self_jid: str | None = None
|
||||
self._on_connection_change: Callable[[dict[str, Any]], Awaitable[None]] | None = None
|
||||
self._recent_messages: dict[str, dict[str, Any]] = {}
|
||||
self._last_inbound_at: float | None = None
|
||||
self._last_message_at: float | None = None
|
||||
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="whatsapp")
|
||||
self._stream_buffers: dict[str, str] = {}
|
||||
self._stream_chunk_counters: dict[str, int] = {}
|
||||
self._stream_status_msg_ids: dict[str, str] = {}
|
||||
self._last_message_tracker: list[float | None] = [None]
|
||||
|
||||
self._send_mgr = SendManager(
|
||||
bridge=self._bridge,
|
||||
circuit_breaker=self._circuit_breaker,
|
||||
sent_message_cache=self._sent_message_cache,
|
||||
echo_filter=self._echo_filter,
|
||||
debouncer=self._debouncer,
|
||||
reaction_level=self._reaction_level,
|
||||
error_policy=self._error_policy,
|
||||
lane_delivery=self._lane_delivery,
|
||||
config=cfg,
|
||||
stream_buffers=self._stream_buffers,
|
||||
stream_chunk_counters=self._stream_chunk_counters,
|
||||
stream_status_msg_ids=self._stream_status_msg_ids,
|
||||
recent_messages=self._recent_messages,
|
||||
last_message_tracker=self._last_message_tracker,
|
||||
)
|
||||
self._inbound_processor = InboundProcessor(
|
||||
echo_filter=self._echo_filter,
|
||||
deduplicator=self._deduplicator,
|
||||
button_deduplicator=self._button_deduplicator,
|
||||
security=self._security,
|
||||
self_chat_mode=self._self_chat_mode,
|
||||
per_group_config=self._per_group_config,
|
||||
per_dm_config=self._per_dm_config,
|
||||
pairing=self._pairing,
|
||||
self_jid=None,
|
||||
)
|
||||
self._inbound_pipeline = self._inbound_processor.build_pipeline()
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._status in (ChannelStatus.CONNECTED, ChannelStatus.CONNECTING):
|
||||
@ -219,165 +249,34 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
return {"status": "error", "error": "QR generation failed"}
|
||||
|
||||
async def send(self, response: ChannelResponse, silent: bool = False) -> DeliveryResult:
|
||||
jid = response.identity.channel_chat_id
|
||||
if not jid and self._default_to:
|
||||
jid = self._default_to
|
||||
reply_to = response.reply_to_message_id
|
||||
|
||||
if self._reply_to_mode == "off":
|
||||
reply_to = None
|
||||
|
||||
if self._debouncer.should_throttle(jid):
|
||||
remaining = self._debouncer.window_remaining(jid)
|
||||
logger.warning(f"[WhatsApp] Throttled message to {jid} (retry in {remaining:.1f}s)")
|
||||
return DeliveryResult(
|
||||
success=False,
|
||||
error=f"Rate limited, retry in {remaining:.1f}s",
|
||||
)
|
||||
|
||||
payload = _format_outbound(response)
|
||||
content = payload.get("content", response.content)
|
||||
content = text_sanitizer(content)
|
||||
|
||||
if self.supports_markdown and getattr(response, "content_format", "") == "markdown":
|
||||
try:
|
||||
content = markdown_to_whatsapp(content)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WhatsApp] Markdown parse failed, using plain text: {e}")
|
||||
|
||||
prefix = self.config.get("messagePrefix", "")
|
||||
if prefix:
|
||||
content = f"{prefix} {content}"
|
||||
|
||||
self._echo_filter.record_outbound(jid, content)
|
||||
self._debouncer.record_send(jid)
|
||||
self._last_message_at = time.monotonic()
|
||||
|
||||
if len(content) > self.text_chunk_limit and self._reply_to_mode != "batched":
|
||||
chunks = chunk_message(content, self.text_chunk_limit, mode=self._chunk_mode)
|
||||
results = []
|
||||
for chunk in chunks:
|
||||
use_reply = reply_to if self._reply_to_mode == "all" else (reply_to if len(results) == 0 else None)
|
||||
result = await self._bridge.send_message(
|
||||
jid=jid,
|
||||
content=chunk,
|
||||
reply_to=use_reply,
|
||||
silent=silent,
|
||||
)
|
||||
results.append(result)
|
||||
return results[0] if results else DeliveryResult(success=False, error="No chunks")
|
||||
|
||||
if self._reply_to_mode == "batched" and len(content) > self.text_chunk_limit:
|
||||
content = content[: self.text_chunk_limit - 3] + "..."
|
||||
|
||||
return await self._send_with_retry(jid, content, reply_to, silent)
|
||||
return await self._send_mgr.send(response, silent)
|
||||
|
||||
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
|
||||
suffix = _MEDIA_SUFFIX_MAP.get(media_type, "")
|
||||
if isinstance(data, bytes):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
|
||||
f.write(data)
|
||||
tmp_path = f.name
|
||||
elif isinstance(data, str) and os.path.exists(data):
|
||||
tmp_path = data
|
||||
else:
|
||||
return DeliveryResult(success=False, error=f"Unsupported media data type: {type(data)}")
|
||||
|
||||
try:
|
||||
return await self._bridge.send_media(
|
||||
jid=chat_id,
|
||||
media_type=media_type,
|
||||
media_path=tmp_path,
|
||||
)
|
||||
finally:
|
||||
if isinstance(data, bytes) and os.path.exists(tmp_path):
|
||||
cleanup_temp_file(tmp_path)
|
||||
return await self._send_mgr.send_media(chat_id, media_type, data)
|
||||
|
||||
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
|
||||
if not emoji:
|
||||
return await self._bridge.send_reaction(
|
||||
jid=chat_id,
|
||||
message_id=msg_id,
|
||||
emoji="",
|
||||
)
|
||||
if not self._reaction_level.can_send_reaction():
|
||||
return DeliveryResult(success=False, error="Reactions disabled by reactionLevel config")
|
||||
return await self._bridge.send_reaction(
|
||||
jid=chat_id,
|
||||
message_id=msg_id,
|
||||
emoji=emoji,
|
||||
)
|
||||
return await self._send_mgr.send_reaction(chat_id, msg_id, emoji)
|
||||
|
||||
async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
||||
return await self.send_reaction(chat_id, msg_id, "")
|
||||
return await self._send_mgr.remove_reaction(chat_id, msg_id)
|
||||
|
||||
async def send_reaction_from_action(self, action: dict[str, Any]) -> DeliveryResult:
|
||||
chat_jid = action.get("chatJid") or action.get("to") or action.get("chat_id", "")
|
||||
msg_id = action.get("messageId") or action.get("msg_id", "")
|
||||
emoji = action.get("emoji", "")
|
||||
remove = action.get("remove", False)
|
||||
participant = action.get("participant", "")
|
||||
|
||||
if participant and "@g.us" not in chat_jid:
|
||||
chat_jid = participant
|
||||
|
||||
if remove or not emoji:
|
||||
return await self.send_reaction(chat_jid, msg_id, "")
|
||||
return await self.send_reaction(chat_jid, msg_id, emoji)
|
||||
return await self._send_mgr.send_reaction_from_action(action)
|
||||
|
||||
async def download_media(self, file_id: str) -> bytes:
|
||||
raw = self._recent_messages.get(file_id)
|
||||
if not raw:
|
||||
raise ValueError(f"Message context not found for file_id: {file_id}")
|
||||
|
||||
remote_jid = raw.get("key", {}).get("remoteJid", "")
|
||||
message = raw.get("message", {})
|
||||
return await _download_media(self._bridge, remote_jid, file_id, message)
|
||||
return await self._send_mgr.download_media(file_id)
|
||||
|
||||
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
|
||||
if finished:
|
||||
content = text_sanitizer(chunk)
|
||||
if self.supports_markdown:
|
||||
content = markdown_to_whatsapp(content)
|
||||
|
||||
if self._lane_delivery.reasoning_enabled:
|
||||
lane_chunks = self._lane_delivery.split_lane_aware(content)
|
||||
messages = self._lane_delivery.format_for_whatsapp(lane_chunks)
|
||||
results = []
|
||||
for msg in messages:
|
||||
result = await self._bridge.send_message(jid=chat_id, content=msg)
|
||||
results.append(result)
|
||||
return results[0] if results else DeliveryResult(success=False, error="No messages")
|
||||
|
||||
return await self._bridge.send_message(
|
||||
jid=chat_id,
|
||||
content=content,
|
||||
)
|
||||
return await self._bridge.send_presence(
|
||||
jid=chat_id,
|
||||
presence="composing",
|
||||
)
|
||||
return await self._send_mgr.send_stream_chunk(chat_id, msg_id, chunk, finished)
|
||||
|
||||
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
|
||||
return DeliveryResult(success=False, error="WhatsApp does not support editing messages")
|
||||
return await self._send_mgr.edit_message(chat_id, msg_id, content)
|
||||
|
||||
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
||||
return await self._bridge.delete_message(
|
||||
jid=chat_id,
|
||||
message_id=msg_id,
|
||||
)
|
||||
return await self._send_mgr.delete_message(chat_id, msg_id)
|
||||
|
||||
async def send_poll(self, chat_id: str, name: str, options: list[str], selectable_count: int = 1) -> DeliveryResult:
|
||||
if len(options) < 2:
|
||||
return DeliveryResult(success=False, error="Poll requires at least 2 options")
|
||||
if len(options) > 12:
|
||||
return DeliveryResult(success=False, error=f"Poll supports max 12 options, got {len(options)}")
|
||||
return await self._bridge.create_poll(
|
||||
jid=chat_id,
|
||||
name=name,
|
||||
options=options,
|
||||
selectable_count=selectable_count,
|
||||
)
|
||||
return await self._send_mgr.send_poll(chat_id, name, options, selectable_count)
|
||||
|
||||
async def send_location(
|
||||
self,
|
||||
@ -387,23 +286,14 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
name: str = "",
|
||||
address: str = "",
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_location(
|
||||
jid=chat_id,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
name=name,
|
||||
address=address,
|
||||
)
|
||||
return await self._send_mgr.send_location(chat_id, latitude, longitude, name, address)
|
||||
|
||||
async def send_contact(
|
||||
self,
|
||||
chat_id: str,
|
||||
contacts: list[dict[str, str]],
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_contact(
|
||||
jid=chat_id,
|
||||
contacts=contacts,
|
||||
)
|
||||
return await self._send_mgr.send_contact(chat_id, contacts)
|
||||
|
||||
async def send_sticker(
|
||||
self,
|
||||
@ -411,11 +301,7 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
sticker_path: str,
|
||||
reply_to: str | None = None,
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_sticker(
|
||||
jid=chat_id,
|
||||
sticker_path=sticker_path,
|
||||
reply_to=reply_to,
|
||||
)
|
||||
return await self._send_mgr.send_sticker(chat_id, sticker_path, reply_to)
|
||||
|
||||
async def send_buttons(
|
||||
self,
|
||||
@ -425,13 +311,7 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
title: str = "",
|
||||
footer: str = "",
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_buttons(
|
||||
jid=chat_id,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
title=title,
|
||||
footer=footer,
|
||||
)
|
||||
return await self._send_mgr.send_buttons(chat_id, text, buttons, title, footer)
|
||||
|
||||
async def send_list_message(
|
||||
self,
|
||||
@ -442,14 +322,7 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
footer: str = "",
|
||||
button_text: str = "Select",
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_list_message(
|
||||
jid=chat_id,
|
||||
text=text,
|
||||
sections=sections,
|
||||
title=title,
|
||||
footer=footer,
|
||||
button_text=button_text,
|
||||
)
|
||||
return await self._send_mgr.send_list_message(chat_id, text, sections, title, footer, button_text)
|
||||
|
||||
def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
|
||||
return _normalize_inbound(raw, self.channel_id)
|
||||
@ -512,8 +385,8 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
self._401_retry_pending = False
|
||||
|
||||
self._connection_ctrl.transition(ConnectionState.LOGGED_OUT)
|
||||
from .logout_security import perform_logout_cleanup
|
||||
from .auth_resolve import resolve_auth_dir
|
||||
from .logout_security import perform_logout_cleanup
|
||||
|
||||
auth_dir = resolve_auth_dir(self.channel_id, self.config)
|
||||
perform_logout_cleanup(auth_dir)
|
||||
@ -547,7 +420,6 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
|
||||
result = await self._inbound_pipeline.process(raw_payload)
|
||||
if result.action != PipelineAction.ACCEPT:
|
||||
self._echo_filter.clear()
|
||||
return
|
||||
|
||||
self._last_inbound_at = time.monotonic()
|
||||
@ -638,87 +510,8 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
logger.debug("[WhatsApp] Vision enrichment failed", exc_info=True)
|
||||
|
||||
def _build_inbound_pipeline(self) -> InboundPipeline:
|
||||
pipeline = InboundPipeline()
|
||||
pipeline.add_step(self._check_echo, "echo_filter")
|
||||
pipeline.add_step(self._check_dedupe, "dedupe")
|
||||
pipeline.add_step(self._check_button_dedupe, "button_dedupe")
|
||||
pipeline.add_step(self._check_security, "security")
|
||||
return pipeline
|
||||
|
||||
def _check_echo(self, payload: dict) -> PipelineResult:
|
||||
key = payload.get("key", {})
|
||||
msg = payload.get("message", {})
|
||||
remote_jid = key.get("remoteJid", "")
|
||||
text = msg.get("conversation", "")
|
||||
if not text:
|
||||
text = (msg.get("extendedTextMessage") or {}).get("text", "")
|
||||
|
||||
if self._echo_filter.is_echo(remote_jid, text):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="echo")
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
def _check_dedupe(self, payload: dict) -> PipelineResult:
|
||||
msg_id = payload.get("key", {}).get("id", "")
|
||||
if not msg_id:
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
if self._deduplicator.is_duplicate(msg_id):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="duplicate")
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
def _check_button_dedupe(self, payload: dict) -> PipelineResult:
|
||||
msg = payload.get("message", {})
|
||||
button_msg = msg.get("buttonsResponseMessage") or msg.get("templateButtonReplyMessage")
|
||||
if not button_msg:
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
sender = payload.get("key", {}).get("remoteJid", "")
|
||||
button_id = button_msg.get("selectedButtonId", "")
|
||||
if not button_id:
|
||||
button_id = button_msg.get("selectedId", "")
|
||||
|
||||
if not button_id:
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
if self._button_deduplicator.is_duplicate(sender, button_id):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="button_duplicate")
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
def _check_security(self, payload: dict) -> PipelineResult:
|
||||
from_me = payload.get("key", {}).get("fromMe", False)
|
||||
if from_me:
|
||||
if self._self_chat_mode:
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="self_message")
|
||||
|
||||
remote_jid = payload.get("key", {}).get("remoteJid", "")
|
||||
sender = remote_jid.split("@")[0]
|
||||
|
||||
if "@g.us" in remote_jid:
|
||||
if not self._per_group_config.is_enabled(remote_jid):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="group_disabled")
|
||||
if self._per_group_config.require_mention(remote_jid):
|
||||
msg = payload.get("message", {})
|
||||
ext_text = msg.get("extendedTextMessage", {})
|
||||
context_info = ext_text.get("contextInfo", {})
|
||||
mentioned_jids = context_info.get("mentionedJid", []) or []
|
||||
if not any(self._self_jid and mj == self._self_jid for mj in mentioned_jids):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="require_mention_not_met")
|
||||
allowed, reason = self._security.check_group_access(remote_jid)
|
||||
if not allowed:
|
||||
logger.debug(f"[WhatsApp] Group access denied for {remote_jid}: {reason}")
|
||||
return PipelineResult(action=PipelineAction.DROP, reason=reason or "group_blocked")
|
||||
else:
|
||||
if not self._per_dm_config.is_enabled(sender):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="dm_disabled")
|
||||
if self._security.dm_policy == DmPolicy.PAIRING and not self._pairing.is_paired(sender):
|
||||
logger.debug(f"[WhatsApp] DM pairing required for {sender}")
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="dm_not_paired")
|
||||
allowed, reason = self._security.check_dm_access(sender)
|
||||
if not allowed:
|
||||
logger.debug(f"[WhatsApp] DM access denied for {sender}: {reason}")
|
||||
return PipelineResult(action=PipelineAction.DROP, reason=reason or "dm_blocked")
|
||||
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
self._inbound_processor._self_jid = self._self_jid
|
||||
return self._inbound_processor.build_pipeline()
|
||||
|
||||
def _setup_monitor_handler(self) -> None:
|
||||
self._monitor.on_raw_message(self._handle_sse_event)
|
||||
@ -747,55 +540,6 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
}
|
||||
)
|
||||
|
||||
async def _send_with_retry(
|
||||
self,
|
||||
jid: str,
|
||||
content: str,
|
||||
reply_to: str | None = None,
|
||||
silent: bool = False,
|
||||
max_attempts: int = 3,
|
||||
) -> DeliveryResult:
|
||||
last_error: str | None = None
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
result = await self._bridge.send_message(
|
||||
jid=jid,
|
||||
content=content,
|
||||
reply_to=reply_to,
|
||||
silent=silent,
|
||||
)
|
||||
if result.success and result.message_id:
|
||||
self._sent_message_cache.put(
|
||||
result.message_id,
|
||||
jid,
|
||||
{"content": content[:200], "reply_to": reply_to},
|
||||
)
|
||||
if result.success:
|
||||
return result
|
||||
last_error = result.error or "unknown"
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
last_error = f"Bridge connection failed: {e}"
|
||||
logger.warning(
|
||||
f"[WhatsApp] send attempt {attempt}/{max_attempts} failed - bridge connection error for {jid}: {e}"
|
||||
)
|
||||
except TimeoutError as e:
|
||||
last_error = str(e)
|
||||
logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed - timeout for {jid}: {e}")
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed for {jid}: {e}")
|
||||
|
||||
if attempt < max_attempts:
|
||||
delay_ms = 500 * attempt
|
||||
logger.info(f"[WhatsApp] Retrying send to {jid} in {delay_ms}ms (attempt {attempt})")
|
||||
|
||||
await asyncio.sleep(delay_ms / 1000)
|
||||
|
||||
logger.error(f"[WhatsApp] send failed after {max_attempts} attempts for {jid}")
|
||||
if self._error_policy.should_notify(jid, last_error or "send_failed"):
|
||||
last_error = self._error_policy.format_error_message(last_error or "send failed")
|
||||
return DeliveryResult(success=False, error=last_error or "send failed")
|
||||
|
||||
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
|
||||
phone = normalize_phone(channel_user_id)
|
||||
jid = f"{phone}@s.whatsapp.net"
|
||||
@ -819,8 +563,8 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
return await self._bridge.wait_scan(timeout)
|
||||
|
||||
async def detect_whatsapp_linked(self) -> bool:
|
||||
from .logout_security import validate_credential_freshness
|
||||
from .auth_resolve import resolve_auth_dir
|
||||
from .logout_security import validate_credential_freshness
|
||||
|
||||
auth_dir = resolve_auth_dir(self.channel_id, self.config)
|
||||
if not validate_credential_freshness(auth_dir):
|
||||
@ -835,8 +579,8 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
return await self._bridge.get_qr_status()
|
||||
|
||||
async def logout(self) -> dict[str, Any]:
|
||||
from .logout_security import perform_logout_cleanup
|
||||
from .auth_resolve import resolve_auth_dir
|
||||
from .logout_security import perform_logout_cleanup
|
||||
|
||||
result = await self._bridge.logout()
|
||||
auth_dir = resolve_auth_dir(self.channel_id, self.config)
|
||||
@ -887,16 +631,30 @@ class WhatsAppAdapter(BaseChannelAdapter):
|
||||
return self._last_message_at
|
||||
|
||||
def resolve_system_prompt(self, chat_id: str) -> str | None:
|
||||
base = None
|
||||
if "@g.us" in chat_id:
|
||||
return self._per_group_config.system_prompt(chat_id)
|
||||
sender = chat_id.split("@")[0] if "@" in chat_id else chat_id
|
||||
return self._per_dm_config.system_prompt(sender)
|
||||
base = self._per_group_config.system_prompt(chat_id)
|
||||
else:
|
||||
sender = chat_id.split("@")[0] if "@" in chat_id else chat_id
|
||||
base = self._per_dm_config.system_prompt(sender)
|
||||
return self._inject_reaction_guidance(base)
|
||||
|
||||
def resolve_system_prompt_for_group(self, group_jid: str) -> str | None:
|
||||
return self._per_group_config.system_prompt(group_jid)
|
||||
base = self._per_group_config.system_prompt(group_jid)
|
||||
return self._inject_reaction_guidance(base)
|
||||
|
||||
def resolve_system_prompt_for_direct(self, phone: str) -> str | None:
|
||||
return self._per_dm_config.system_prompt(phone)
|
||||
base = self._per_dm_config.system_prompt(phone)
|
||||
return self._inject_reaction_guidance(base)
|
||||
|
||||
def _inject_reaction_guidance(self, base: str | None) -> str | None:
|
||||
guidance = self.config.get("agentReactionGuidance")
|
||||
if not guidance:
|
||||
return base
|
||||
guidance_text = f"\n\nYou may use WhatsApp reactions to respond with emoji when appropriate. {guidance}"
|
||||
if base:
|
||||
return base + guidance_text
|
||||
return guidance_text.strip()
|
||||
|
||||
def resolve_quoted_message_key(self, chat_id: str, quoted_msg_id: str | None = None) -> dict | None:
|
||||
return self._inbound_cache.resolve_quoted_message_key(chat_id, quoted_msg_id)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from yuxi.channels.adapters.whatsapp.approve.exec_approvals import ExecApprovals, ApprovalRequest, ApprovalStatus
|
||||
from yuxi.channels.adapters.whatsapp.approve.approval_handler import ApprovalHandler
|
||||
from yuxi.channels.adapters.whatsapp.approve.approval_callbacks import ApprovalCallbackManager
|
||||
from yuxi.channels.adapters.whatsapp.approve.approval_handler import ApprovalHandler
|
||||
from yuxi.channels.adapters.whatsapp.approve.exec_approvals import ApprovalRequest, ApprovalStatus, ExecApprovals
|
||||
|
||||
__all__ = [
|
||||
"ExecApprovals",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ function buildSendRoutes(app, getSock, notifySSE) {
|
||||
return res.json({ success: false, error: 'Not connected' });
|
||||
}
|
||||
|
||||
const { jid, content, reply_to } = req.body;
|
||||
const { jid, content, reply_to, silent } = req.body;
|
||||
const options = {};
|
||||
if (reply_to) {
|
||||
options.quoted = {
|
||||
@ -21,7 +21,13 @@ function buildSendRoutes(app, getSock, notifySSE) {
|
||||
};
|
||||
}
|
||||
|
||||
const result = await sock.sendMessage(jid, { text: content }, options);
|
||||
const msgContent = { text: content };
|
||||
|
||||
if (silent) {
|
||||
msgContent.contextInfo = { isSilent: true };
|
||||
}
|
||||
|
||||
const result = await sock.sendMessage(jid, msgContent, options);
|
||||
res.json({
|
||||
success: true,
|
||||
message_id: result?.key?.id || null,
|
||||
|
||||
@ -117,6 +117,12 @@ class BaileysBridge(ExternalProcessManager):
|
||||
except TimeoutError:
|
||||
self._process.kill()
|
||||
await self._process.wait()
|
||||
if self._process:
|
||||
if self._process.stdout:
|
||||
self._process.stdout.close()
|
||||
if self._process.stderr:
|
||||
self._process.stderr.close()
|
||||
self._process = None
|
||||
logger.info("Baileys bridge stopped")
|
||||
|
||||
async def health_check(self) -> HealthStatus:
|
||||
|
||||
@ -53,7 +53,6 @@ class WhatsAppChannelMeta:
|
||||
ChannelCapability.GROUP,
|
||||
ChannelCapability.QR_LOGIN,
|
||||
ChannelCapability.PAIRING,
|
||||
ChannelCapability.SSE_POLLING,
|
||||
]
|
||||
)
|
||||
inbound_rate_limit: int = 60
|
||||
|
||||
@ -55,7 +55,10 @@ class ConnectionController:
|
||||
for listener in self._state_listeners:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(listener):
|
||||
asyncio.ensure_future(listener(old, new_state))
|
||||
task = asyncio.create_task(listener(old, new_state))
|
||||
task.add_done_callback(
|
||||
lambda t: logger.exception("ConnectionController listener failed") if t.exception() else None
|
||||
)
|
||||
else:
|
||||
listener(old, new_state)
|
||||
except Exception:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@ -12,105 +12,119 @@ from yuxi.utils.logging_config import logger
|
||||
class CredentialQueue:
|
||||
def __init__(self, auth_dir: Path, debounce_ms: int = 500):
|
||||
self._auth_dir = auth_dir
|
||||
self._lock = threading.Lock()
|
||||
self._lock = asyncio.Lock()
|
||||
self._write_count = 0
|
||||
self._debounce_ms = debounce_ms
|
||||
self._pending_creds: dict[str, Any] | None = None
|
||||
self._last_write_time = 0.0
|
||||
self._debounce_timer: threading.Timer | None = None
|
||||
self._debounce_task: asyncio.Task | None = None
|
||||
|
||||
def write_auth(self, creds: dict[str, Any]) -> int:
|
||||
async def write_auth(self, creds: dict[str, Any]) -> int:
|
||||
loop = asyncio.get_running_loop()
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
async with self._lock:
|
||||
self._write_count += 1
|
||||
count = self._write_count
|
||||
|
||||
if now - self._last_write_time < self._debounce_ms / 1000.0:
|
||||
self._pending_creds = creds
|
||||
self._schedule_debounced_write()
|
||||
self._schedule_debounced_write(loop)
|
||||
return count
|
||||
|
||||
self._last_write_time = now
|
||||
self._pending_creds = None
|
||||
|
||||
return self._do_write(creds, count)
|
||||
return await self._do_write(creds, count)
|
||||
|
||||
def _schedule_debounced_write(self) -> None:
|
||||
if self._debounce_timer is not None:
|
||||
self._debounce_timer.cancel()
|
||||
def _schedule_debounced_write(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
if self._debounce_task is not None:
|
||||
self._debounce_task.cancel()
|
||||
|
||||
self._debounce_timer = threading.Timer(
|
||||
self._debounce_ms / 1000.0,
|
||||
self._flush_debounced,
|
||||
)
|
||||
self._debounce_timer.daemon = True
|
||||
self._debounce_timer.start()
|
||||
async def _delayed_flush():
|
||||
await asyncio.sleep(self._debounce_ms / 1000.0)
|
||||
await self._flush_debounced()
|
||||
|
||||
def _flush_debounced(self) -> None:
|
||||
with self._lock:
|
||||
self._debounce_task = loop.create_task(_delayed_flush())
|
||||
|
||||
async def _flush_debounced(self) -> None:
|
||||
async with self._lock:
|
||||
creds = self._pending_creds
|
||||
self._pending_creds = None
|
||||
self._debounce_timer = None
|
||||
self._debounce_task = None
|
||||
if creds is None:
|
||||
return
|
||||
count = self._write_count
|
||||
|
||||
self._do_write(creds, count)
|
||||
await self._do_write(creds, count)
|
||||
|
||||
def _do_write(self, creds: dict[str, Any], count: int) -> int:
|
||||
async def _do_write(self, creds: dict[str, Any], count: int) -> int:
|
||||
path = self._auth_dir / "creds.json"
|
||||
backup = self._auth_dir / "creds.json.bak"
|
||||
tmp_path = self._auth_dir / "creds.json.tmp"
|
||||
try:
|
||||
data = json.dumps(creds, indent=2, ensure_ascii=False)
|
||||
tmp_path.write_text(data, encoding="utf-8")
|
||||
if path.exists():
|
||||
try:
|
||||
path.replace(backup)
|
||||
except OSError:
|
||||
pass
|
||||
tmp_path.replace(path)
|
||||
logger.debug(f"CredentialQueue: wrote creds (#{count}) to {path}")
|
||||
|
||||
def _sync_write() -> int:
|
||||
tmp_path.write_text(data, encoding="utf-8")
|
||||
if path.exists():
|
||||
try:
|
||||
path.replace(backup)
|
||||
except OSError:
|
||||
pass
|
||||
tmp_path.replace(path)
|
||||
return count
|
||||
|
||||
result = await asyncio.to_thread(_sync_write)
|
||||
logger.debug(f"CredentialQueue: wrote creds (#{result}) to {path}")
|
||||
return result
|
||||
except (OSError, TypeError) as e:
|
||||
logger.error(f"CredentialQueue: write failed (#{count}): {e}")
|
||||
raise
|
||||
return count
|
||||
|
||||
def read_auth(self) -> dict[str, Any] | None:
|
||||
async def read_auth(self) -> dict[str, Any] | None:
|
||||
path = self._auth_dir / "creds.json"
|
||||
backup = self._auth_dir / "creds.json.bak"
|
||||
try:
|
||||
|
||||
def _sync_read():
|
||||
if not path.exists():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.error(f"CredentialQueue: read failed: {e}")
|
||||
if backup.exists():
|
||||
try:
|
||||
data = json.loads(backup.read_text(encoding="utf-8"))
|
||||
backup.replace(path)
|
||||
logger.info("CredentialQueue: restored creds from backup")
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError) as e2:
|
||||
logger.error(f"CredentialQueue: backup recovery failed: {e2}")
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.error(f"CredentialQueue: read failed: {e}")
|
||||
if backup.exists():
|
||||
try:
|
||||
data = json.loads(backup.read_text(encoding="utf-8"))
|
||||
backup.replace(path)
|
||||
logger.info("CredentialQueue: restored creds from backup")
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError) as e2:
|
||||
logger.error(f"CredentialQueue: backup recovery failed: {e2}")
|
||||
return None
|
||||
|
||||
def clear_auth(self) -> bool:
|
||||
with self._lock:
|
||||
return await asyncio.to_thread(_sync_read)
|
||||
|
||||
async def clear_auth(self) -> bool:
|
||||
async with self._lock:
|
||||
self._pending_creds = None
|
||||
if self._debounce_timer is not None:
|
||||
self._debounce_timer.cancel()
|
||||
self._debounce_timer = None
|
||||
if self._debounce_task is not None:
|
||||
self._debounce_task.cancel()
|
||||
self._debounce_task = None
|
||||
|
||||
path = self._auth_dir / "creds.json"
|
||||
backup = self._auth_dir / "creds.json.bak"
|
||||
try:
|
||||
|
||||
def _sync_clear():
|
||||
if path.exists():
|
||||
if backup.exists():
|
||||
backup.unlink()
|
||||
path.rename(backup)
|
||||
logger.info(f"CredentialQueue: cleared creds, backup at {backup}")
|
||||
return True
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_sync_clear)
|
||||
return result
|
||||
except OSError as e:
|
||||
logger.error(f"CredentialQueue: clear failed: {e}")
|
||||
return False
|
||||
|
||||
@ -14,6 +14,7 @@ from yuxi.channels.models import (
|
||||
MessageType,
|
||||
)
|
||||
|
||||
from .session import _extract_sender_number
|
||||
from .structured_context import ContextSource, StructuredContextEntry, UntrustedStructuredContext
|
||||
|
||||
_PROTOCOL_REVOKE = 0
|
||||
@ -153,8 +154,8 @@ def _normalize_poll_vote(poll_update: dict, raw_payload: dict, channel_id: str)
|
||||
selected = vote_info.get("selectedOptions", [])
|
||||
if isinstance(selected, list):
|
||||
selected_options = selected
|
||||
elif isinstance(vote_info, list):
|
||||
selected_options = vote_info
|
||||
elif isinstance(vote_info, list):
|
||||
selected_options = vote_info
|
||||
|
||||
option_names = [o.get("name", str(o)) if isinstance(o, dict) else str(o) for o in selected_options]
|
||||
vote_text = f"[Poll Vote] {' | '.join(option_names)}" if option_names else "[Poll Vote]"
|
||||
@ -370,10 +371,6 @@ def _jid_to_chat_type(jid: str) -> ChatType:
|
||||
return ChatType.DIRECT
|
||||
|
||||
|
||||
def _extract_sender_number(jid: str) -> str:
|
||||
return jid.split("@")[0]
|
||||
|
||||
|
||||
def _build_structured_context(msg: dict, content_type: str, msg_id: str | None) -> UntrustedStructuredContext:
|
||||
context = UntrustedStructuredContext()
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
@ -20,57 +21,62 @@ class InboundMessageCache:
|
||||
self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
||||
self._max_size = max_size
|
||||
self._ttl = ttl_seconds
|
||||
self._lock = Lock()
|
||||
|
||||
def put(self, msg_id: str, jid: str, content: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
if not msg_id:
|
||||
return
|
||||
self._cache[msg_id] = {
|
||||
"jid": jid,
|
||||
"content": content[:500],
|
||||
"timestamp": time.time(),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
self._cache.move_to_end(msg_id)
|
||||
self._evict()
|
||||
with self._lock:
|
||||
self._cache[msg_id] = {
|
||||
"jid": jid,
|
||||
"content": content[:500],
|
||||
"timestamp": time.time(),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
self._cache.move_to_end(msg_id)
|
||||
self._evict()
|
||||
|
||||
def get(self, msg_id: str) -> dict[str, Any] | None:
|
||||
entry = self._cache.get(msg_id)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.time() - entry["timestamp"] > self._ttl:
|
||||
self._cache.pop(msg_id, None)
|
||||
return None
|
||||
return entry
|
||||
with self._lock:
|
||||
entry = self._cache.get(msg_id)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.time() - entry["timestamp"] > self._ttl:
|
||||
self._cache.pop(msg_id, None)
|
||||
return None
|
||||
return entry
|
||||
|
||||
def lookup_inbound_meta(self, target_jid: str, target_msg_id: str | None = None) -> dict[str, Any] | None:
|
||||
if target_msg_id:
|
||||
exact = self.get(target_msg_id)
|
||||
if exact:
|
||||
return exact
|
||||
with self._lock:
|
||||
if target_msg_id:
|
||||
exact = self._cache.get(target_msg_id)
|
||||
if exact and time.time() - exact["timestamp"] <= self._ttl:
|
||||
return exact
|
||||
|
||||
target_is_group = _is_group_jid(target_jid)
|
||||
candidates: list[tuple[str, dict[str, Any], float]] = []
|
||||
target_is_group = _is_group_jid(target_jid)
|
||||
candidates: list[tuple[str, dict[str, Any], float]] = []
|
||||
|
||||
now = time.time()
|
||||
for msg_id, entry in self._cache.items():
|
||||
if now - entry["timestamp"] > self._ttl:
|
||||
continue
|
||||
entry_jid = entry.get("jid", "")
|
||||
if target_is_group != _is_group_jid(entry_jid):
|
||||
continue
|
||||
candidates.append((msg_id, entry, entry["timestamp"]))
|
||||
if len(candidates) >= 10:
|
||||
break
|
||||
now = time.time()
|
||||
for msg_id, entry in self._cache.items():
|
||||
if now - entry["timestamp"] > self._ttl:
|
||||
continue
|
||||
entry_jid = entry.get("jid", "")
|
||||
if target_is_group != _is_group_jid(entry_jid):
|
||||
continue
|
||||
candidates.append((msg_id, entry, entry["timestamp"]))
|
||||
if len(candidates) >= 10:
|
||||
break
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: x[2], reverse=True)
|
||||
best_msg_id, best_entry, _ = candidates[0]
|
||||
logger.debug(
|
||||
f"InboundMessageCache: fuzzy match for {target_jid} -> msg_id={best_msg_id}, candidates={len(candidates)}"
|
||||
)
|
||||
return best_entry
|
||||
candidates.sort(key=lambda x: x[2], reverse=True)
|
||||
best_msg_id, best_entry, _ = candidates[0]
|
||||
logger.debug(
|
||||
f"InboundMessageCache: fuzzy match for {target_jid} "
|
||||
f"-> msg_id={best_msg_id}, candidates={len(candidates)}"
|
||||
)
|
||||
return best_entry
|
||||
|
||||
def resolve_quoted_message_key(self, target_jid: str, quoted_msg_id: str | None = None) -> dict[str, Any] | None:
|
||||
meta = None
|
||||
@ -98,7 +104,9 @@ class InboundMessageCache:
|
||||
del self._cache[oldest]
|
||||
|
||||
def clear(self) -> None:
|
||||
self._cache.clear()
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
with self._lock:
|
||||
return len(self._cache)
|
||||
|
||||
@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
from .inbound_pipeline import InboundPipeline, PipelineAction, PipelineResult
|
||||
from .security import DmPolicy
|
||||
|
||||
|
||||
class InboundProcessor:
|
||||
def __init__(
|
||||
self,
|
||||
echo_filter,
|
||||
deduplicator,
|
||||
button_deduplicator,
|
||||
security,
|
||||
self_chat_mode: bool,
|
||||
per_group_config,
|
||||
per_dm_config,
|
||||
pairing,
|
||||
self_jid: str | None = None,
|
||||
):
|
||||
self._echo_filter = echo_filter
|
||||
self._deduplicator = deduplicator
|
||||
self._button_deduplicator = button_deduplicator
|
||||
self._security = security
|
||||
self._self_chat_mode = self_chat_mode
|
||||
self._per_group_config = per_group_config
|
||||
self._per_dm_config = per_dm_config
|
||||
self._pairing = pairing
|
||||
self._self_jid = self_jid
|
||||
|
||||
def build_pipeline(self) -> InboundPipeline:
|
||||
pipeline = InboundPipeline()
|
||||
pipeline.add_step(self._check_echo, "echo_filter")
|
||||
pipeline.add_step(self._check_dedupe, "dedupe")
|
||||
pipeline.add_step(self._check_button_dedupe, "button_dedupe")
|
||||
pipeline.add_step(self._check_security, "security")
|
||||
return pipeline
|
||||
|
||||
def _check_echo(self, payload: dict) -> PipelineResult:
|
||||
key = payload.get("key", {})
|
||||
msg = payload.get("message", {})
|
||||
remote_jid = key.get("remoteJid", "")
|
||||
text = msg.get("conversation", "")
|
||||
if not text:
|
||||
text = (msg.get("extendedTextMessage") or {}).get("text", "")
|
||||
|
||||
if self._echo_filter.is_echo(remote_jid, text):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="echo")
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
def _check_dedupe(self, payload: dict) -> PipelineResult:
|
||||
msg_id = payload.get("key", {}).get("id", "")
|
||||
if not msg_id:
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
if self._deduplicator.is_duplicate(msg_id):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="duplicate")
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
def _check_button_dedupe(self, payload: dict) -> PipelineResult:
|
||||
msg = payload.get("message", {})
|
||||
button_msg = msg.get("buttonsResponseMessage") or msg.get("templateButtonReplyMessage")
|
||||
if not button_msg:
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
sender = payload.get("key", {}).get("remoteJid", "")
|
||||
button_id = button_msg.get("selectedButtonId", "")
|
||||
if not button_id:
|
||||
button_id = button_msg.get("selectedId", "")
|
||||
|
||||
if not button_id:
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
if self._button_deduplicator.is_duplicate(sender, button_id):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="button_duplicate")
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
|
||||
def _check_security(self, payload: dict) -> PipelineResult:
|
||||
from_me = payload.get("key", {}).get("fromMe", False)
|
||||
if from_me:
|
||||
if self._self_chat_mode:
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="self_message")
|
||||
|
||||
remote_jid = payload.get("key", {}).get("remoteJid", "")
|
||||
sender = remote_jid.split("@")[0]
|
||||
|
||||
if "@g.us" in remote_jid:
|
||||
if not self._per_group_config.is_enabled(remote_jid):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="group_disabled")
|
||||
if self._per_group_config.require_mention(remote_jid):
|
||||
msg = payload.get("message", {})
|
||||
ext_text = msg.get("extendedTextMessage", {})
|
||||
context_info = ext_text.get("contextInfo", {})
|
||||
mentioned_jids = context_info.get("mentionedJid", []) or []
|
||||
if not any(self._self_jid and mj == self._self_jid for mj in mentioned_jids):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="require_mention_not_met")
|
||||
allowed, reason = self._security.check_group_access(remote_jid)
|
||||
if not allowed:
|
||||
logger.debug(f"[WhatsApp] Group access denied for {remote_jid}: {reason}")
|
||||
return PipelineResult(action=PipelineAction.DROP, reason=reason or "group_blocked")
|
||||
else:
|
||||
if not self._per_dm_config.is_enabled(sender):
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="dm_disabled")
|
||||
if self._security.dm_policy == DmPolicy.PAIRING and not self._pairing.is_paired(sender):
|
||||
logger.debug(f"[WhatsApp] DM pairing required for {sender}")
|
||||
return PipelineResult(action=PipelineAction.DROP, reason="dm_not_paired")
|
||||
allowed, reason = self._security.check_dm_access(sender)
|
||||
if not allowed:
|
||||
logger.debug(f"[WhatsApp] DM access denied for {sender}: {reason}")
|
||||
return PipelineResult(action=PipelineAction.DROP, reason=reason or "dm_blocked")
|
||||
|
||||
return PipelineResult(action=PipelineAction.ACCEPT)
|
||||
@ -51,14 +51,20 @@ def markdown_to_whatsapp(md_text: str) -> str:
|
||||
|
||||
text = _convert_tables(text)
|
||||
|
||||
text = re.sub(r"\*\*\*(.+?)\*\*\*", r"*_\1_*", text)
|
||||
text = re.sub(r"___(.+?)___", r"*_\1_*", text)
|
||||
# All formatting -> temp markers to prevent cross-contamination
|
||||
text = re.sub(r"\*\*\*(.+?)\*\*\*", r"<BI>\1</BI>", text)
|
||||
text = re.sub(r"___(.+?)___", r"<BI>\1</BI>", text)
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<B>\1</B>", text)
|
||||
text = re.sub(r"__(.+?)__", r"<B>\1</B>", text)
|
||||
text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<I>\1</I>", text)
|
||||
text = re.sub(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", r"<I>\1</I>", text)
|
||||
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"*\1*", text)
|
||||
text = re.sub(r"__(.+?)__", r"*\1*", text)
|
||||
|
||||
text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"_\1_", text)
|
||||
text = re.sub(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", r"_\1_", text)
|
||||
text = text.replace("<BI>", "*_")
|
||||
text = text.replace("</BI>", "_*")
|
||||
text = text.replace("<B>", "*")
|
||||
text = text.replace("</B>", "*")
|
||||
text = text.replace("<I>", "_")
|
||||
text = text.replace("</I>", "_")
|
||||
|
||||
text = re.sub(r"~~(.+?)~~", r"~\1~", text)
|
||||
|
||||
|
||||
@ -13,6 +13,8 @@ from yuxi.channels.models import (
|
||||
)
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
from .session import _extract_sender_number
|
||||
|
||||
|
||||
def normalize_poll_input(poll: dict[str, Any], max_options: int = 12) -> dict[str, Any] | None:
|
||||
name = poll.get("name", "").strip()
|
||||
@ -25,6 +27,14 @@ def normalize_poll_input(poll: dict[str, Any], max_options: int = 12) -> dict[st
|
||||
options = [o.strip() for o in options.split(",") if o.strip()]
|
||||
|
||||
options = [o.strip() for o in options if o.strip()]
|
||||
seen = set()
|
||||
unique_options = []
|
||||
for o in options:
|
||||
if o not in seen:
|
||||
seen.add(o)
|
||||
unique_options.append(o)
|
||||
options = unique_options
|
||||
|
||||
if len(options) < 2:
|
||||
logger.warning("Poll normalization: less than 2 valid options")
|
||||
return None
|
||||
@ -64,8 +74,8 @@ def parse_poll_vote(raw_payload: dict[str, Any], channel_id: str) -> ChannelMess
|
||||
selected = vote_info.get("selectedOptions", [])
|
||||
if isinstance(selected, list):
|
||||
selected_options = selected
|
||||
elif isinstance(vote_info, list):
|
||||
selected_options = vote_info
|
||||
elif isinstance(vote_info, list):
|
||||
selected_options = vote_info
|
||||
|
||||
option_names = [o.get("name", str(o)) if isinstance(o, dict) else str(o) for o in selected_options]
|
||||
vote_text = f"[Poll Vote] {' | '.join(option_names)}" if option_names else "[Poll Vote]"
|
||||
@ -107,7 +117,3 @@ def _jid_to_chat_type(jid: str) -> ChatType:
|
||||
if "@g.us" in jid:
|
||||
return ChatType.GROUP
|
||||
return ChatType.DIRECT
|
||||
|
||||
|
||||
def _extract_sender_number(jid: str) -> str:
|
||||
return jid.split("@")[0]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from yuxi.channels.adapters.whatsapp.reactions.reaction_level import ReactionLevel, ReactionLevelController
|
||||
from yuxi.channels.adapters.whatsapp.reactions.ack_reaction import AckReactionManager
|
||||
from yuxi.channels.adapters.whatsapp.reactions.reaction_level import ReactionLevel, ReactionLevelController
|
||||
|
||||
__all__ = [
|
||||
"ReactionLevel",
|
||||
|
||||
429
backend/package/yuxi/channels/adapters/whatsapp/send_manager.py
Normal file
429
backend/package/yuxi/channels/adapters/whatsapp/send_manager.py
Normal file
@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
||||
from yuxi.channels.models import DeliveryResult, ChannelResponse
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
from .format import format_outbound as _format_outbound
|
||||
from .markdown import markdown_to_whatsapp, text_sanitizer
|
||||
from .media import _MEDIA_SUFFIX_MAP, cleanup_temp_file
|
||||
from .media import download_media as _download_media
|
||||
from .network_errors import ErrorCategory, classify_error
|
||||
from .send import chunk_message
|
||||
|
||||
|
||||
class SendManager:
|
||||
def __init__(
|
||||
self,
|
||||
bridge,
|
||||
circuit_breaker: CircuitBreaker,
|
||||
sent_message_cache,
|
||||
echo_filter,
|
||||
debouncer,
|
||||
reaction_level,
|
||||
error_policy,
|
||||
lane_delivery,
|
||||
config: dict[str, Any],
|
||||
stream_buffers: dict[str, str],
|
||||
stream_chunk_counters: dict[str, int],
|
||||
stream_status_msg_ids: dict[str, str],
|
||||
recent_messages: dict[str, dict[str, Any]],
|
||||
last_message_tracker: list[float | None],
|
||||
):
|
||||
self._bridge = bridge
|
||||
self._circuit_breaker = circuit_breaker
|
||||
self._sent_message_cache = sent_message_cache
|
||||
self._echo_filter = echo_filter
|
||||
self._debouncer = debouncer
|
||||
self._reaction_level = reaction_level
|
||||
self._error_policy = error_policy
|
||||
self._lane_delivery = lane_delivery
|
||||
self._config = config
|
||||
self._stream_buffers = stream_buffers
|
||||
self._stream_chunk_counters = stream_chunk_counters
|
||||
self._stream_status_msg_ids = stream_status_msg_ids
|
||||
self._recent_messages = recent_messages
|
||||
self._last_message_tracker = last_message_tracker
|
||||
|
||||
self._prefix = config.get("messagePrefix", "")
|
||||
self._chunk_mode = config.get("chunkMode", "length")
|
||||
self._reply_to_mode = config.get("replyToMode", "first")
|
||||
self._block_streaming = config.get("blockStreaming", False)
|
||||
self._default_to = config.get("defaultTo")
|
||||
self.text_chunk_limit = 4000
|
||||
self.supports_markdown = True
|
||||
|
||||
@property
|
||||
def last_message_at(self) -> float | None:
|
||||
return self._last_message_tracker[0] if self._last_message_tracker else None
|
||||
|
||||
@last_message_at.setter
|
||||
def last_message_at(self, value: float | None) -> None:
|
||||
if self._last_message_tracker:
|
||||
self._last_message_tracker[0] = value
|
||||
|
||||
async def send(self, response: ChannelResponse, silent: bool = False) -> DeliveryResult:
|
||||
jid = response.identity.channel_chat_id
|
||||
if not jid and self._default_to:
|
||||
jid = self._default_to
|
||||
reply_to = response.reply_to_message_id
|
||||
|
||||
if self._reply_to_mode == "off":
|
||||
reply_to = None
|
||||
|
||||
if self._debouncer.should_throttle(jid):
|
||||
remaining = self._debouncer.window_remaining(jid)
|
||||
logger.warning(f"[WhatsApp] Throttled message to {jid} (retry in {remaining:.1f}s)")
|
||||
return DeliveryResult(
|
||||
success=False,
|
||||
error=f"Rate limited, retry in {remaining:.1f}s",
|
||||
)
|
||||
|
||||
payload = _format_outbound(response)
|
||||
content = payload.get("content", response.content)
|
||||
content = text_sanitizer(content)
|
||||
|
||||
if self.supports_markdown and getattr(response, "content_format", "") == "markdown":
|
||||
try:
|
||||
content = markdown_to_whatsapp(content)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WhatsApp] Markdown parse failed, using plain text: {e}")
|
||||
|
||||
if self._prefix:
|
||||
content = f"{self._prefix} {content}"
|
||||
|
||||
self._echo_filter.record_outbound(jid, content)
|
||||
self._debouncer.record_send(jid)
|
||||
self.last_message_at = time.monotonic()
|
||||
|
||||
async def _do_send() -> DeliveryResult:
|
||||
send_content = content
|
||||
if len(send_content) > self.text_chunk_limit and self._reply_to_mode != "batched":
|
||||
chunks = chunk_message(send_content, self.text_chunk_limit, mode=self._chunk_mode)
|
||||
results = []
|
||||
for chunk in chunks:
|
||||
use_reply = reply_to if self._reply_to_mode == "all" else (reply_to if len(results) == 0 else None)
|
||||
result = await self._bridge.send_message(
|
||||
jid=jid,
|
||||
content=chunk,
|
||||
reply_to=use_reply,
|
||||
silent=silent,
|
||||
)
|
||||
results.append(result)
|
||||
return results[0] if results else DeliveryResult(success=False, error="No chunks")
|
||||
|
||||
if self._reply_to_mode == "batched" and len(send_content) > self.text_chunk_limit:
|
||||
send_content = send_content[: self.text_chunk_limit - 3] + "..."
|
||||
|
||||
return await self._send_with_retry(jid, send_content, reply_to, silent)
|
||||
|
||||
try:
|
||||
return await self._circuit_breaker.call(_do_send)
|
||||
except CircuitBreakerOpenError:
|
||||
return DeliveryResult(success=False, error="Circuit breaker open")
|
||||
|
||||
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
|
||||
suffix = _MEDIA_SUFFIX_MAP.get(media_type, "")
|
||||
if isinstance(data, bytes):
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
|
||||
f.write(data)
|
||||
tmp_path = f.name
|
||||
elif isinstance(data, str) and os.path.exists(data):
|
||||
tmp_path = data
|
||||
else:
|
||||
return DeliveryResult(success=False, error=f"Unsupported media data type: {type(data)}")
|
||||
|
||||
try:
|
||||
return await self._bridge.send_media(
|
||||
jid=chat_id,
|
||||
media_type=media_type,
|
||||
media_path=tmp_path,
|
||||
)
|
||||
finally:
|
||||
if isinstance(data, bytes) and os.path.exists(tmp_path):
|
||||
cleanup_temp_file(tmp_path)
|
||||
|
||||
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
|
||||
if not emoji:
|
||||
return await self._bridge.send_reaction(
|
||||
jid=chat_id,
|
||||
message_id=msg_id,
|
||||
emoji="",
|
||||
)
|
||||
if not self._reaction_level.can_send_reaction():
|
||||
return DeliveryResult(success=False, error="Reactions disabled by reactionLevel config")
|
||||
return await self._bridge.send_reaction(
|
||||
jid=chat_id,
|
||||
message_id=msg_id,
|
||||
emoji=emoji,
|
||||
)
|
||||
|
||||
async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
||||
return await self.send_reaction(chat_id, msg_id, "")
|
||||
|
||||
async def send_reaction_from_action(self, action: dict[str, Any]) -> DeliveryResult:
|
||||
chat_jid = action.get("chatJid") or action.get("to") or action.get("chat_id", "")
|
||||
msg_id = action.get("messageId") or action.get("msg_id", "")
|
||||
emoji = action.get("emoji", "")
|
||||
remove = action.get("remove", False)
|
||||
participant = action.get("participant", "")
|
||||
|
||||
if participant and "@g.us" not in chat_jid:
|
||||
chat_jid = participant
|
||||
|
||||
if remove or not emoji:
|
||||
return await self.send_reaction(chat_jid, msg_id, "")
|
||||
return await self.send_reaction(chat_jid, msg_id, emoji)
|
||||
|
||||
async def download_media(self, file_id: str) -> bytes:
|
||||
raw = self._recent_messages.get(file_id)
|
||||
if not raw:
|
||||
raise ValueError(f"Message context not found for file_id: {file_id}")
|
||||
|
||||
remote_jid = raw.get("key", {}).get("remoteJid", "")
|
||||
message = raw.get("message", {})
|
||||
return await _download_media(self._bridge, remote_jid, file_id, message)
|
||||
|
||||
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
|
||||
stream_cfg = self._config.get("streaming", {})
|
||||
if not isinstance(stream_cfg, dict):
|
||||
stream_cfg = {}
|
||||
|
||||
if finished:
|
||||
status_msg_id = self._stream_status_msg_ids.pop(chat_id, None)
|
||||
if status_msg_id:
|
||||
try:
|
||||
await self._bridge.delete_message(jid=chat_id, message_id=status_msg_id)
|
||||
except Exception:
|
||||
logger.debug(f"[WhatsApp] Failed to delete stream status message {status_msg_id}", exc_info=True)
|
||||
|
||||
self._stream_chunk_counters.pop(chat_id, None)
|
||||
|
||||
content = text_sanitizer(chunk)
|
||||
if self.supports_markdown:
|
||||
content = markdown_to_whatsapp(content)
|
||||
|
||||
if self._lane_delivery.reasoning_enabled:
|
||||
lane_chunks = self._lane_delivery.split_lane_aware(content)
|
||||
messages = self._lane_delivery.format_for_whatsapp(lane_chunks)
|
||||
results = []
|
||||
for msg in messages:
|
||||
result = await self._bridge.send_message(jid=chat_id, content=msg)
|
||||
results.append(result)
|
||||
return results[0] if results else DeliveryResult(success=False, error="No messages")
|
||||
|
||||
return await self._bridge.send_message(
|
||||
jid=chat_id,
|
||||
content=content,
|
||||
)
|
||||
|
||||
self._stream_chunk_counters[chat_id] = self._stream_chunk_counters.get(chat_id, 0) + 1
|
||||
|
||||
await self._bridge.send_presence(
|
||||
jid=chat_id,
|
||||
presence="composing",
|
||||
)
|
||||
|
||||
stream_status_updates = stream_cfg.get("streamStatusUpdates", False)
|
||||
status_interval = stream_cfg.get("streamStatusInterval", 5)
|
||||
if stream_status_updates and self._stream_chunk_counters[chat_id] % status_interval == 0:
|
||||
try:
|
||||
status_text = stream_cfg.get("streamStatusText", "Processing...")
|
||||
status_result = await self._bridge.send_message(jid=chat_id, content=status_text)
|
||||
if status_result.success and status_result.message_id:
|
||||
old_status = self._stream_status_msg_ids.get(chat_id)
|
||||
if old_status:
|
||||
try:
|
||||
await self._bridge.delete_message(jid=chat_id, message_id=old_status)
|
||||
except Exception:
|
||||
pass
|
||||
self._stream_status_msg_ids[chat_id] = status_result.message_id
|
||||
except Exception:
|
||||
logger.debug(f"[WhatsApp] Failed to send stream status message to {chat_id}", exc_info=True)
|
||||
|
||||
block_cfg = stream_cfg.get("block", {})
|
||||
if not isinstance(block_cfg, dict):
|
||||
block_cfg = {}
|
||||
|
||||
block_enabled = block_cfg.get("enabled", True)
|
||||
block_coalesce = block_cfg.get("coalesce", False)
|
||||
min_chars = block_cfg.get("coalesce_min_chars", 1500)
|
||||
max_chars = block_cfg.get("coalesce_min_chars", 4096)
|
||||
|
||||
if not block_enabled:
|
||||
return DeliveryResult(success=True)
|
||||
|
||||
if block_coalesce:
|
||||
self._stream_buffers.setdefault(chat_id, "")
|
||||
self._stream_buffers[chat_id] += chunk
|
||||
if len(self._stream_buffers[chat_id]) >= min_chars:
|
||||
buffered = self._stream_buffers.pop(chat_id, "")
|
||||
content = text_sanitizer(buffered[:max_chars])
|
||||
return await self._bridge.send_message(jid=chat_id, content=content)
|
||||
return DeliveryResult(success=True)
|
||||
|
||||
if len(chunk) >= min_chars:
|
||||
content = text_sanitizer(chunk[:max_chars])
|
||||
return await self._bridge.send_message(jid=chat_id, content=content)
|
||||
|
||||
return DeliveryResult(success=True)
|
||||
|
||||
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
|
||||
return DeliveryResult(success=False, error="WhatsApp does not support editing messages")
|
||||
|
||||
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
||||
return await self._bridge.delete_message(
|
||||
jid=chat_id,
|
||||
message_id=msg_id,
|
||||
)
|
||||
|
||||
async def send_poll(self, chat_id: str, name: str, options: list[str], selectable_count: int = 1) -> DeliveryResult:
|
||||
if len(options) < 2:
|
||||
return DeliveryResult(success=False, error="Poll requires at least 2 options")
|
||||
if len(options) > 12:
|
||||
return DeliveryResult(success=False, error=f"Poll supports max 12 options, got {len(options)}")
|
||||
return await self._bridge.create_poll(
|
||||
jid=chat_id,
|
||||
name=name,
|
||||
options=options,
|
||||
selectable_count=selectable_count,
|
||||
)
|
||||
|
||||
async def send_location(
|
||||
self,
|
||||
chat_id: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
name: str = "",
|
||||
address: str = "",
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_location(
|
||||
jid=chat_id,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
name=name,
|
||||
address=address,
|
||||
)
|
||||
|
||||
async def send_contact(
|
||||
self,
|
||||
chat_id: str,
|
||||
contacts: list[dict[str, str]],
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_contact(
|
||||
jid=chat_id,
|
||||
contacts=contacts,
|
||||
)
|
||||
|
||||
async def send_sticker(
|
||||
self,
|
||||
chat_id: str,
|
||||
sticker_path: str,
|
||||
reply_to: str | None = None,
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_sticker(
|
||||
jid=chat_id,
|
||||
sticker_path=sticker_path,
|
||||
reply_to=reply_to,
|
||||
)
|
||||
|
||||
async def send_buttons(
|
||||
self,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
buttons: list[dict[str, str]],
|
||||
title: str = "",
|
||||
footer: str = "",
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_buttons(
|
||||
jid=chat_id,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
title=title,
|
||||
footer=footer,
|
||||
)
|
||||
|
||||
async def send_list_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
sections: list[dict[str, Any]],
|
||||
title: str = "",
|
||||
footer: str = "",
|
||||
button_text: str = "Select",
|
||||
) -> DeliveryResult:
|
||||
return await self._bridge.send_list_message(
|
||||
jid=chat_id,
|
||||
text=text,
|
||||
sections=sections,
|
||||
title=title,
|
||||
footer=footer,
|
||||
button_text=button_text,
|
||||
)
|
||||
|
||||
async def _send_with_retry(
|
||||
self,
|
||||
jid: str,
|
||||
content: str,
|
||||
reply_to: str | None = None,
|
||||
silent: bool = False,
|
||||
max_attempts: int = 3,
|
||||
) -> DeliveryResult:
|
||||
last_error: str | None = None
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
result = await self._bridge.send_message(
|
||||
jid=jid,
|
||||
content=content,
|
||||
reply_to=reply_to,
|
||||
silent=silent,
|
||||
)
|
||||
if result.success and result.message_id:
|
||||
self._sent_message_cache.put(
|
||||
result.message_id,
|
||||
jid,
|
||||
{"content": content[:200], "reply_to": reply_to},
|
||||
)
|
||||
if result.success:
|
||||
return result
|
||||
last_error = result.error or "unknown"
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
last_error = f"Bridge connection failed: {e}"
|
||||
logger.warning(
|
||||
f"[WhatsApp] send attempt {attempt}/{max_attempts} failed - bridge connection error for {jid}: {e}"
|
||||
)
|
||||
except TimeoutError as e:
|
||||
last_error = str(e)
|
||||
logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed - timeout for {jid}: {e}")
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
error_cat = classify_error(str(e))
|
||||
if error_cat in (ErrorCategory.PERMANENT, ErrorCategory.AUTH):
|
||||
logger.warning(
|
||||
f"[WhatsApp] send failed for {jid} - {error_cat.value} error (not retrying): {e}"
|
||||
)
|
||||
break
|
||||
logger.warning(f"[WhatsApp] send attempt {attempt}/{max_attempts} failed for {jid}: {e}")
|
||||
|
||||
if attempt < max_attempts:
|
||||
error_cat = classify_error(last_error or "")
|
||||
if error_cat == ErrorCategory.RATE_LIMIT:
|
||||
delay_ms = 2000 * attempt
|
||||
else:
|
||||
delay_ms = 500 * attempt
|
||||
logger.info(f"[WhatsApp] Retrying send to {jid} in {delay_ms}ms (attempt {attempt})")
|
||||
|
||||
await asyncio.sleep(delay_ms / 1000)
|
||||
|
||||
logger.error(f"[WhatsApp] send failed after {max_attempts} attempts for {jid}")
|
||||
if self._error_policy.should_notify(jid, last_error or "send_failed"):
|
||||
last_error = self._error_policy.format_error_message(last_error or "send failed")
|
||||
return DeliveryResult(success=False, error=last_error or "send failed")
|
||||
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
@ -12,26 +13,29 @@ class SentMessageCache:
|
||||
self._cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
||||
self._max_size = max_size
|
||||
self._ttl = ttl_seconds
|
||||
self._lock = Lock()
|
||||
|
||||
def put(self, msg_id: str, jid: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
if not msg_id:
|
||||
return
|
||||
self._cache[msg_id] = {
|
||||
"jid": jid,
|
||||
"timestamp": time.time(),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
self._cache.move_to_end(msg_id)
|
||||
self._evict()
|
||||
with self._lock:
|
||||
self._cache[msg_id] = {
|
||||
"jid": jid,
|
||||
"timestamp": time.time(),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
self._cache.move_to_end(msg_id)
|
||||
self._evict()
|
||||
|
||||
def get(self, msg_id: str) -> dict[str, Any] | None:
|
||||
entry = self._cache.get(msg_id)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.time() - entry["timestamp"] > self._ttl:
|
||||
self._cache.pop(msg_id, None)
|
||||
return None
|
||||
return entry
|
||||
with self._lock:
|
||||
entry = self._cache.get(msg_id)
|
||||
if entry is None:
|
||||
return None
|
||||
if time.time() - entry["timestamp"] > self._ttl:
|
||||
self._cache.pop(msg_id, None)
|
||||
return None
|
||||
return entry
|
||||
|
||||
def get_jid(self, msg_id: str) -> str | None:
|
||||
entry = self.get(msg_id)
|
||||
@ -47,8 +51,10 @@ class SentMessageCache:
|
||||
del self._cache[oldest]
|
||||
|
||||
def clear(self) -> None:
|
||||
self._cache.clear()
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
logger.info("SentMessageCache: cleared")
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
with self._lock:
|
||||
return len(self._cache)
|
||||
|
||||
@ -13,6 +13,9 @@ def _extract_sender_number(jid: str) -> str:
|
||||
return jid.split("@")[0]
|
||||
|
||||
|
||||
extract_sender_number = _extract_sender_number
|
||||
|
||||
|
||||
def jid_to_chat_type(jid: str) -> str:
|
||||
if "@g.us" in jid:
|
||||
return "group"
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user