import logging from dataclasses import dataclass from typing import Any from yuxi.channel.extensions.signal.client import SignalRpcClient from yuxi.channel.extensions.signal.format import ( SignalTextStyle, chunk_text, format_text_styles_to_rpc, markdown_to_signal_formatted_text, ) from yuxi.channel.extensions.signal.normalize import parse_signal_target logger = logging.getLogger(__name__) @dataclass class SignalAttachment: filename: str content_type: str width: int = 0 height: int = 0 caption: str | None = None def to_rpc_params(self) -> dict: p: dict = {"filename": self.filename, "contentType": self.content_type} if self.width: p["width"] = self.width if self.height: p["height"] = self.height if self.caption: p["caption"] = self.caption return p def _build_mentions(mention_uuids: list[str], text: str) -> list[dict]: result = [] for uuid_str in mention_uuids: idx = text.find(uuid_str) if idx >= 0: result.append({"uuid": uuid_str, "start": idx, "length": len(uuid_str)}) return result async def send_signal_message( client: SignalRpcClient, to: str, text: str, *, account: str | None = None, attachments: list[str] | list[SignalAttachment] | None = None, reply_to_timestamp: int | None = None, quote_author: str | None = None, quote_text: str | None = None, quote_attachments: list[dict] | None = None, quote_title: str | None = None, mention: list[str] | None = None, edit_timestamp: int | None = None, expires_in_seconds: int | None = None, preview: dict | None = None, is_view_once: bool = False, ) -> dict | None: parsed = parse_signal_target(to) if not parsed: raise ValueError(f"Invalid Signal target: {to}") kind, target = parsed _, styled_spans = markdown_to_signal_formatted_text(text) text_styles = format_text_styles_to_rpc(styled_spans) params: dict[str, Any] = {} if account: params["account"] = account if kind == "groupId": params["groupId"] = target else: params["recipient"] = target if attachments: params["attachments"] = [ a.to_rpc_params() if isinstance(a, SignalAttachment) else a for a in attachments ] content = text if edit_timestamp: params["message"] = content params["editTimestamp"] = edit_timestamp if text_styles: params["text-style"] = text_styles else: params["message"] = content if text_styles: params["text-style"] = text_styles if reply_to_timestamp: params["quoteTimestamp"] = reply_to_timestamp if quote_author: params["quoteAuthor"] = quote_author if quote_text: params["quoteMessage"] = quote_text if quote_attachments: params["quoteAttachments"] = quote_attachments if quote_title: params["quoteTitle"] = quote_title if mention: params["mentions"] = _build_mentions(mention, text) if expires_in_seconds: params["expiresInSeconds"] = expires_in_seconds if preview: params["preview"] = [preview] if is_view_once: params["isViewOnce"] = True try: result = await client.call("send", params, account=account) return result except Exception: logger.exception("Failed to send Signal message to %s", to) raise async def send_signal_message_chunked( client: SignalRpcClient, to: str, text: str, *, account: str | None = None, chunk_limit: int = 4000, **kwargs, ) -> list[dict | None]: edit_timestamp = kwargs.get("edit_timestamp") if edit_timestamp: result = await send_signal_message(client, to, text, account=account, **kwargs) return [result] chunks = chunk_text(text, chunk_limit) results = [] for chunk in chunks: result = await send_signal_message(client, to, chunk, account=account, **kwargs) results.append(result) return results async def send_signal_remote_delete( client: SignalRpcClient, recipient: str, timestamp: int, *, group_id: str | None = None, account: str | None = None, ) -> None: params: dict = {"recipient": recipient, "timestamp": timestamp} if group_id: params["groupId"] = group_id if account: params["account"] = account await client.call("remoteDelete", params, account=account) async def send_signal_typing( client: SignalRpcClient, to: str, *, account: str | None = None, stop: bool = False, ) -> None: parsed = parse_signal_target(to) if not parsed: raise ValueError(f"Invalid Signal target: {to}") kind, target = parsed params: dict[str, Any] = {} if account: params["account"] = account if kind == "groupId": params["groupId"] = target else: params["recipient"] = target if stop: params["stop"] = True await client.call("sendTyping", params, account=account) async def send_signal_receipt( client: SignalRpcClient, sender: str, timestamp: int, *, account: str | None = None, receipt_type: str = "read", ) -> None: params: dict[str, Any] = { "sender": sender, "timestamp": timestamp, "receiptType": receipt_type, } if account: params["account"] = account await client.call("sendReceipt", params, account=account) async def send_signal_reaction( client: SignalRpcClient, recipient: str, target_timestamp: int, emoji: str, *, target_author: str | None = None, target_author_uuid: str | None = None, group_id: str | None = None, remove: bool = False, account: str | None = None, ) -> None: params: dict[str, Any] = { "recipient": recipient, "targetTimestamp": target_timestamp, } if emoji: params["reaction"] = emoji if remove: params["remove"] = True if target_author: params["targetAuthor"] = target_author if target_author_uuid: params["targetAuthorUuid"] = target_author_uuid if group_id: params["groupId"] = group_id if account: params["account"] = account await client.call("sendReaction", params, account=account) async def send_signal_sticker( client: SignalRpcClient, to: str, pack_id: str, sticker_id: int, *, account: str | None = None, ) -> None: parsed = parse_signal_target(to) if not parsed: raise ValueError(f"Invalid Signal target: {to}") kind, target = parsed params: dict[str, Any] = {"sticker": {"packId": pack_id, "stickerId": sticker_id}} if account: params["account"] = account if kind == "groupId": params["groupId"] = target else: params["recipient"] = target await client.call("send", params, account=account) async def send_signal_contacts( client: SignalRpcClient, to: str, contacts: list[dict], *, account: str | None = None, ) -> None: parsed = parse_signal_target(to) if not parsed: raise ValueError(f"Invalid Signal target: {to}") kind, target = parsed params: dict[str, Any] = {"contacts": contacts} if account: params["account"] = account if kind == "groupId": params["groupId"] = target else: params["recipient"] = target await client.call("sendContacts", params, account=account) async def send_sync_request( client: SignalRpcClient, *, account: str | None = None, ) -> None: params: dict[str, Any] = {} if account: params["account"] = account await client.call("sendSyncRequest", params, account=account) async def send_group_v2_info( client: SignalRpcClient, group_id: str, *, account: str | None = None, ) -> None: params: dict[str, Any] = {"groupId": group_id} if account: params["account"] = account await client.call("sendGroupV2Info", params, account=account)