from __future__ import annotations import asyncio import base64 import logging import uuid from yuxi.channels.models import DeliveryResult from yuxi.channels.adapters.signal.client import RpcClient, RpcError from yuxi.channels.adapters.signal.format import ( FormattedText, StyleRange, clamp_styles_to_length, split_text, ) from yuxi.channels.adapters.signal.normalize import normalize_target logger = logging.getLogger(__name__) GROUP_PREFIX = "group:" def _make_message_id(timestamp: int | str | None) -> str | None: if not timestamp: return None short_uuid = uuid.uuid4().hex[:8] return f"{timestamp}:{short_uuid}" class SignalSender: MAX_TEXT_LENGTH = 4000 MAX_RETRIES = 3 RETRY_BASE_DELAY = 0.5 CACHE_TTL = 3600 CACHE_MAX_SIZE = 1000 _sent_message_cache: dict[str, dict] = {} _cache_access_order: list[str] = [] def __init__(self, rpc_client: RpcClient, account_number: str): self._rpc = rpc_client self._account = account_number @staticmethod def _recipient_param(target: str) -> dict: normalized = normalize_target(target) if normalized.startswith(GROUP_PREFIX): return {"groupId": normalized} return {"recipient": normalized} def _cache_sent(self, msg_id: str, recipient: str) -> None: self._prune_cache() if len(self._sent_message_cache) >= self.CACHE_MAX_SIZE: oldest = self._cache_access_order.pop(0) self._sent_message_cache.pop(oldest, None) self._sent_message_cache[msg_id] = { "recipient": normalize_target(recipient), "timestamp": asyncio.get_event_loop().time(), } self._cache_access_order.append(msg_id) def _get_cached(self, msg_id: str) -> dict | None: entry = self._sent_message_cache.get(msg_id) if entry is None: return None now = asyncio.get_event_loop().time() if now - entry["timestamp"] > self.CACHE_TTL: self._sent_message_cache.pop(msg_id, None) if msg_id in self._cache_access_order: self._cache_access_order.remove(msg_id) return None if msg_id in self._cache_access_order: self._cache_access_order.remove(msg_id) self._cache_access_order.append(msg_id) return entry @classmethod def _prune_cache(cls) -> None: now = asyncio.get_event_loop().time() expired = [k for k, v in cls._sent_message_cache.items() if now - v["timestamp"] > cls.CACHE_TTL] for k in expired: cls._sent_message_cache.pop(k, None) if k in cls._cache_access_order: cls._cache_access_order.remove(k) async def _call_with_retry(self, method: str, params: dict) -> dict: last_error = None for attempt in range(self.MAX_RETRIES): try: return await self._rpc.call(method, params) except RpcError: raise except Exception as e: last_error = e if attempt < self.MAX_RETRIES - 1: delay = self.RETRY_BASE_DELAY * (2**attempt) await asyncio.sleep(delay) raise last_error # type: ignore[misc] async def send_text( self, recipient: str, message_body: str, reply_to_id: str | None = None, formatted_body: FormattedText | None = None, chunk_mode: str = "newline", text_mode: str = "markdown", ) -> DeliveryResult: if text_mode == "plain" and formatted_body is not None: return await self._send_formatted_text(recipient, formatted_body, reply_to_id, chunk_mode) if formatted_body is not None and text_mode == "markdown": return await self._send_formatted_text(recipient, formatted_body, reply_to_id, chunk_mode) chunks = split_text(message_body, self.MAX_TEXT_LENGTH, chunk_mode) last_message_id = None for i, chunk in enumerate(chunks): params = { "account": self._account, **self._recipient_param(recipient), "messageBody": chunk, } if reply_to_id and i == 0: params["quoteTimestamp"] = int(reply_to_id) try: result = await self._call_with_retry("send", params) last_message_id = _make_message_id(result.get("timestamp")) if last_message_id: self._cache_sent(last_message_id, recipient) except Exception as e: logger.error(f"Failed to send message to {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) return DeliveryResult(success=True, message_id=last_message_id) async def _send_formatted_text( self, recipient: str, formatted: FormattedText, reply_to_id: str | None = None, chunk_mode: str = "newline", ) -> DeliveryResult: chunks = split_text(formatted.body, self.MAX_TEXT_LENGTH, chunk_mode) last_message_id = None offset = 0 for i, chunk in enumerate(chunks): chunk_styles = _extract_chunk_styles(formatted.styles, offset, len(chunk)) clamped = clamp_styles_to_length(chunk_styles, chunk) params: dict = { "account": self._account, **self._recipient_param(recipient), "messageBody": chunk, } if clamped: params["styledBody"] = chunk params["styles"] = [{"start": s.start, "length": s.length, "style": s.style} for s in clamped] if reply_to_id and i == 0: params["quoteTimestamp"] = int(reply_to_id) try: result = await self._call_with_retry("send", params) last_message_id = _make_message_id(result.get("timestamp")) if last_message_id: self._cache_sent(last_message_id, recipient) except Exception as e: logger.error(f"Failed to send formatted message to {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) offset += len(chunk) return DeliveryResult(success=True, message_id=last_message_id) async def send_media( self, recipient: str, media_data: bytes, media_type: str, filename: str | None = None, caption: str | None = None, ) -> DeliveryResult: content_type_map = { "image": "image/jpeg", "video": "video/mp4", "audio": "audio/ogg", "file": "application/octet-stream", } params = { "account": self._account, **self._recipient_param(recipient), "messageBody": caption or "", "attachments": [ { "contentType": content_type_map.get(media_type, "application/octet-stream"), "filename": filename or "attachment", "data": base64.b64encode(media_data).decode("utf-8"), } ], } try: result = await self._rpc.call("send", params) return DeliveryResult( success=True, message_id=_make_message_id(result.get("timestamp")), ) except Exception as e: logger.error(f"Failed to send media to {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) async def send_reaction( self, recipient: str, target_author: str, target_sent_timestamp: int, reaction: str, remove: bool = False, ) -> DeliveryResult: method = "removeReaction" if remove else "sendReaction" params = { "account": self._account, **self._recipient_param(recipient), "targetAuthor": normalize_target(target_author), "targetSentTimestamp": target_sent_timestamp, "reaction": reaction, } try: result = await self._rpc.call(method, params) return DeliveryResult( success=True, message_id=_make_message_id(result.get("timestamp")), ) except Exception as e: logger.error(f"Failed to send reaction to {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) async def send_typing_indicator(self, recipient: str) -> DeliveryResult: try: await self._rpc.call( "sendTyping", { "account": self._account, **self._recipient_param(recipient), }, ) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_read_receipt(self, recipient: str, timestamps: list[int]) -> DeliveryResult: try: await self._rpc.call( "sendReadReceipt", { "account": self._account, **self._recipient_param(recipient), "timestamps": timestamps, }, ) return DeliveryResult(success=True) except Exception as e: return DeliveryResult(success=False, error=str(e)) async def edit_message( self, recipient: str, target_author: str, target_sent_timestamp: int, new_body: str, ) -> DeliveryResult: try: result = await self._rpc.call( "editMessage", { "account": self._account, **self._recipient_param(recipient), "targetAuthor": normalize_target(target_author), "targetSentTimestamp": target_sent_timestamp, "newMessageBody": new_body, }, ) return DeliveryResult( success=True, message_id=_make_message_id(result.get("timestamp")), ) except Exception as e: logger.warning(f"editMessage failed for {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) async def delete_message(self, recipient: str, timestamps: list[int]) -> DeliveryResult: try: await self._rpc.call( "remoteDelete", { "account": self._account, **self._recipient_param(recipient), "timestamps": timestamps, }, ) return DeliveryResult(success=True) except Exception as e: logger.error(f"Failed to delete message for {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) async def send_sticker( self, recipient: str, sticker_pack_id: str, sticker_id: int, ) -> DeliveryResult: try: params = { "account": self._account, **self._recipient_param(recipient), "stickerPackId": sticker_pack_id, "stickerId": sticker_id, } result = await self._rpc.call("sendSticker", params) return DeliveryResult( success=True, message_id=_make_message_id(result.get("timestamp")), ) except Exception as e: logger.error(f"Failed to send sticker to {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) async def send_silent_message( self, recipient: str, message_body: str, reply_to_id: str | None = None, ) -> DeliveryResult: try: params = { "account": self._account, **self._recipient_param(recipient), "messageBody": message_body, "disableNotification": True, } if reply_to_id: params["quoteTimestamp"] = int(reply_to_id) result = await self._rpc.call("send", params) return DeliveryResult( success=True, message_id=_make_message_id(result.get("timestamp")), ) except Exception as e: logger.error(f"Failed to send silent message to {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) async def pin_message( self, recipient: str, message_timestamp: int, ) -> DeliveryResult: try: await self._rpc.call( "pinMessage", { "account": self._account, **self._recipient_param(recipient), "targetSentTimestamp": message_timestamp, }, ) return DeliveryResult(success=True) except Exception as e: logger.error(f"Failed to pin message for {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) async def unpin_message(self, recipient: str) -> DeliveryResult: try: await self._rpc.call( "unpinMessage", { "account": self._account, **self._recipient_param(recipient), }, ) return DeliveryResult(success=True) except Exception as e: logger.error(f"Failed to unpin message for {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) async def send_voice( self, recipient: str, audio_data: bytes, duration_ms: int = 0, ) -> DeliveryResult: try: params = { "account": self._account, **self._recipient_param(recipient), "messageBody": "", "attachments": [ { "contentType": "audio/ogg", "filename": "voice.ogg", "data": base64.b64encode(audio_data).decode("utf-8"), } ], } result = await self._rpc.call("send", params) return DeliveryResult( success=True, message_id=_make_message_id(result.get("timestamp")), ) except Exception as e: logger.error(f"Failed to send voice to {recipient}: {e}") return DeliveryResult(success=False, error=str(e)) def _extract_chunk_styles(styles: list[StyleRange], offset: int, chunk_len: int) -> list[StyleRange]: chunk_end = offset + chunk_len result: list[StyleRange] = [] for s in styles: s_end = s.start + s.length if s_end <= offset or s.start >= chunk_end: continue new_start = max(0, s.start - offset) new_end = min(chunk_len, s_end - offset) if new_end > new_start: result.append(StyleRange(start=new_start, length=new_end - new_start, style=s.style)) return result