from __future__ import annotations import asyncio import json import logging from collections.abc import Callable, Awaitable from yuxi.channels.models import ChannelMessage, EventType from yuxi.channels.adapters.signal.client import RpcClient from yuxi.channels.adapters.signal.event_queue import OrderedEventQueue from yuxi.channels.adapters.signal.normalize import ( build_dedup_key, check_and_add_dedup, check_debounce, is_own_message, is_sync_message, parse_signal_delete, parse_signal_message, parse_signal_reaction, ) from yuxi.channels.adapters.signal.sse_reconnect import sse_event_stream logger = logging.getLogger(__name__) class SSEMonitor: def __init__( self, rpc_client: RpcClient, account_number: str, account_uuid: str | None = None, ignore_attachments: bool = False, ignore_stories: bool = False, debounce_interval_ms: int = 0, duplicate_reaction_check: bool = True, event_queue: OrderedEventQueue | None = None, sent_message_cache: dict[str, dict] | None = None, ): self._rpc = rpc_client self._account = account_number self._account_uuid = account_uuid self._ignore_attachments = ignore_attachments self._ignore_stories = ignore_stories self._debounce_interval_ms = debounce_interval_ms self._duplicate_reaction_check = duplicate_reaction_check self._task: asyncio.Task | None = None self._message_handler: Callable[[ChannelMessage], Awaitable[None]] | None = None self._running = False self._reaction_seen: set[str] = set() self._event_queue = event_queue self._sent_message_cache = sent_message_cache if self._event_queue is not None: self._event_queue.set_handler(self._dispatch_from_queue) def on_message(self, handler: Callable[[ChannelMessage], Awaitable[None]]) -> None: self._message_handler = handler async def start(self) -> None: self._running = True if self._event_queue is not None: await self._event_queue.start() url = f"{self._rpc.base_url}/api/v1/events" self._task = asyncio.create_task(sse_event_stream(url, {"account": self._account}, self._handle_sse_event)) async def stop(self) -> None: self._running = False if self._task: self._task.cancel() try: await self._task except asyncio.CancelledError: pass if self._event_queue is not None: await self._event_queue.stop() async def _dispatch_from_queue(self, event: dict) -> None: msg = self._parse_event(event) if msg and self._message_handler: await self._message_handler(msg) async def _handle_sse_event(self, event_data: str) -> None: try: data = json.loads(event_data) except json.JSONDecodeError: logger.warning(f"Failed to parse SSE event: {event_data[:200]}") return if is_sync_message(data): return if self._ignore_stories and self._is_story_event(data): return if is_own_message(data, self._account, self._account_uuid): return self._check_sent_message_cache(data) dedup_key = build_dedup_key(data, self._account) if dedup_key and check_and_add_dedup(dedup_key): return conversation = data.get("dataMessage", {}).get("groupInfo", {}).get("groupId") or data.get("envelope", {}).get( "source", "" ) if conversation and check_debounce(conversation, self._debounce_interval_ms): return if self._ignore_attachments: data = self._strip_attachments(data) if self._is_receipt_event(data): self._log_receipt(data) return if self._event_queue is not None: ts = self._extract_timestamp(data) await self._event_queue.push(data, timestamp=ts) return msg = self._parse_event(data) if msg and self._message_handler: await self._message_handler(msg) def _parse_event(self, data: dict) -> ChannelMessage | None: msg: ChannelMessage | None = None if self._is_message_event(data): msg = parse_signal_message(data) elif self._is_edit_message_event(data): msg = parse_signal_message(self._extract_edit_data(data)) if msg: msg.event_type = EventType.MESSAGE_UPDATED elif self._is_reaction_event(data): if self._duplicate_reaction_check: dedup_key = self._build_reaction_dedup_key(data) if dedup_key and dedup_key in self._reaction_seen: return None if dedup_key: self._reaction_seen.add(dedup_key) msg = parse_signal_reaction(data) elif self._is_delete_event(data): msg = parse_signal_delete(data) return msg def _check_sent_message_cache(self, data: dict) -> None: if not self._sent_message_cache: return dm = data.get("dataMessage", {}) ts = dm.get("timestamp") if not ts: return for msg_id, entry in self._sent_message_cache.items(): if str(ts) in msg_id: sender = data.get("envelope", {}).get("source", "unknown") logger.info( f"[Signal] Sent message delivery confirmed: ts={ts}, " f"recipient={entry.get('recipient', 'unknown')}, sender={sender}" ) break @staticmethod def _extract_timestamp(data: dict) -> int: dm = data.get("dataMessage", {}) ts = dm.get("timestamp", 0) if ts: return ts reaction = data.get("reaction", {}) ts = reaction.get("targetSentTimestamp", 0) if ts: return ts delete_msg = data.get("deleteMessage", {}) ts = delete_msg.get("targetSentTimestamp", 0) return ts def _log_receipt(self, data: dict) -> None: receipt = data.get("receiptMessage", {}) receipt_type = receipt.get("type", "UNKNOWN") timestamps = receipt.get("timestamps", []) source = data.get("envelope", {}).get("source", "unknown") logger.debug( f"Signal receipt: type={receipt_type}, from={source}, " f"timestamps_count={len(timestamps)}, " f"timestamp_range={min(timestamps) if timestamps else 'N/A'}-{max(timestamps) if timestamps else 'N/A'}" ) @staticmethod def _build_reaction_dedup_key(data: dict) -> str | None: envelope = data.get("envelope", {}) reaction = data.get("reaction", {}) message_id = str(reaction.get("targetSentTimestamp", "")) sender_id = envelope.get("source", "") emoji = reaction.get("emoji", "") group_info = reaction.get("groupInfo", {}) or data.get("dataMessage", {}).get("groupInfo", {}) or {} group_id = group_info.get("groupId", "") if isinstance(group_info, dict) else "" if not message_id or not sender_id: return None return f"reaction:{message_id}:{sender_id}:{emoji}:{group_id}" @staticmethod def _is_message_event(data: dict) -> bool: return "envelope" in data and "dataMessage" in data @staticmethod def _is_edit_message_event(data: dict) -> bool: envelope = data.get("envelope", {}) return "editMessage" in envelope and "dataMessage" in envelope.get("editMessage", {}) @staticmethod def _extract_edit_data(data: dict) -> dict: edit_msg = data.get("envelope", {}).get("editMessage", {}) return { "envelope": data.get("envelope", {}), "dataMessage": edit_msg.get("dataMessage", {}), } @staticmethod def _is_reaction_event(data: dict) -> bool: return "envelope" in data and "reaction" in data @staticmethod def _is_delete_event(data: dict) -> bool: return "envelope" in data and "deleteMessage" in data @staticmethod def _is_receipt_event(data: dict) -> bool: return "envelope" in data and "receiptMessage" in data @staticmethod def _is_story_event(data: dict) -> bool: return "envelope" in data and "storyMessage" in data @staticmethod def _strip_attachments(data: dict) -> dict: data_msg = data.get("dataMessage") if isinstance(data_msg, dict) and "attachments" in data_msg: data_msg = {**data_msg, "attachments": []} data = {**data, "dataMessage": data_msg} return data