"""Signal channel implementation using signal-cli JSON RPC.""" import asyncio import json import logging import re import subprocess from collections import deque from dataclasses import dataclass from datetime import datetime from typing import Any from ..base import Channel, ChannelError, RawIncoming from ..capabilities import SIGNAL as SIGNAL_CAPS from ..config import BaseChannelConfig logger = logging.getLogger(__name__) @dataclass class SignalConfig(BaseChannelConfig): phone_number: str = "" cli_path: str = "signal-cli" config_dir: str | None = None rpc_port: int = 7583 text_chunk_limit: int = 4096 class SignalChannel(Channel): """Signal channel using signal-cli JSON RPC.""" name = "signal" capabilities = SIGNAL_CAPS _non_retryable_patterns = ( *Channel._non_retryable_patterns, "unregistered", "auth", ) def __init__(self, config: SignalConfig): super().__init__(config) self._reader: asyncio.StreamReader | None = None self._writer: asyncio.StreamWriter | None = None self._rpc_id = 0 self._daemon_proc = None # Pending RPC responses: rpc_id -> Future self._pending_rpcs: dict[int, asyncio.Future] = {} # Cache message_id → sender for reaction targetAuthor (bounded) self._msg_senders: dict[str, str] = {} self._msg_senders_order: deque = deque(maxlen=200) self._listen_task: asyncio.Task | None = None async def start(self) -> None: if not self.config.phone_number: raise ChannelError("Signal phone_number is required") # Try to start signal-cli daemon if not already running await self._ensure_daemon() try: # Connect to JSON RPC socket await self._connect() except Exception: # If connect fails after daemon was started, clean up the daemon await self._cleanup() raise self._running = True logger.info(f"Signal channel started (phone: {self.config.phone_number})") # Listen for incoming messages in background task # (start() must return so that run() can iterate receive()) self._listen_task = asyncio.create_task(self._listen_loop()) async def _cleanup(self) -> None: if self._listen_task: self._listen_task.cancel() self._listen_task = None # Cancel any pending RPC futures for fut in self._pending_rpcs.values(): if not fut.done(): fut.cancel() self._pending_rpcs.clear() if self._writer: self._writer.close() try: await self._writer.wait_closed() except Exception: pass self._writer = None self._reader = None if self._daemon_proc: self._daemon_proc.terminate() self._daemon_proc = None logger.info("Signal channel stopped") async def _ensure_daemon(self) -> None: """Start signal-cli daemon if not already running.""" try: _reader, writer = await asyncio.wait_for( asyncio.open_connection("localhost", self.config.rpc_port), timeout=2, ) writer.close() await writer.wait_closed() logger.info("signal-cli daemon already running") return except (TimeoutError, ConnectionRefusedError, OSError): pass # Start daemon cmd = [self.config.cli_path, "-u", self.config.phone_number] if self.config.config_dir: cmd.extend(["--config", self.config.config_dir]) cmd.extend( [ "daemon", "--tcp", f"localhost:{self.config.rpc_port}", "--no-receive-stdout", ] ) logger.info(f"Starting signal-cli daemon: {' '.join(cmd)}") try: self._daemon_proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except FileNotFoundError: raise ChannelError( f"signal-cli not found at '{self.config.cli_path}'. " "Install: https://github.com/AsamK/signal-cli" ) from None # Wait for daemon to be ready for _ in range(30): await asyncio.sleep(1) try: _reader, writer = await asyncio.open_connection( "localhost", self.config.rpc_port, ) writer.close() await writer.wait_closed() logger.info("signal-cli daemon started") return except (ConnectionRefusedError, OSError): continue raise ChannelError("signal-cli daemon failed to start within 30s") async def _connect(self) -> None: """Connect to signal-cli JSON RPC socket.""" try: self._reader, self._writer = await asyncio.open_connection( "localhost", self.config.rpc_port, ) except Exception as e: raise ChannelError(f"Cannot connect to signal-cli: {e}") from e async def _listen_loop(self) -> None: """Listen for incoming JSON RPC notifications and responses.""" while self._running and self._reader: try: line = await self._reader.readline() if not line: break data = json.loads(line.decode()) # Dispatch RPC response if it has an 'id' matching a pending call rpc_id = data.get("id") if rpc_id is not None and rpc_id in self._pending_rpcs: fut = self._pending_rpcs.pop(rpc_id) if not fut.done(): if "error" in data: fut.set_exception( RuntimeError(f"signal-cli RPC error: {data['error']}") ) else: fut.set_result(data.get("result")) continue await self._handle_rpc(data) except asyncio.CancelledError: break except json.JSONDecodeError: continue except Exception as e: logger.error(f"Signal listen error: {e}") # Reconnect if self._running: await asyncio.sleep(2) try: await self._connect() except Exception: logger.warning("Signal reconnect failed, exiting listen loop") break async def _handle_rpc(self, data: dict) -> None: """Handle a JSON RPC message from signal-cli.""" method = data.get("method", "") if method != "receive": return params = data.get("params", {}) envelope = params.get("envelope", {}) source = envelope.get("source") or envelope.get("sourceUuid") or "" source_number = envelope.get("sourceNumber") or source source_name = envelope.get("sourceName") or "" timestamp = envelope.get("timestamp", 0) # Ignore messages from self if ( source_number == self.config.phone_number or source == self.config.phone_number ): logger.debug("Ignoring message from self") return # Data message (text) data_msg = envelope.get("dataMessage", {}) if data_msg: text = data_msg.get("message", "") group_info = data_msg.get("groupInfo", {}) is_group = bool(group_info) chat_id = ( group_info.get("groupId", source_number) if is_group else source_number ) msg_ts = data_msg.get("timestamp", timestamp) media_paths: list[str] = [] annotations: list[str] = [] _VOICE_TYPES = { "audio/aac", "audio/ogg", "audio/mp4", "audio/mpeg", "audio/opus", } attachments = data_msg.get("attachments", []) for att in attachments: att_size = att.get("size", 0) att_name = att.get("filename", "attachment") att_file = att.get("file") # signal-cli provides local path content_type = att.get("contentType", "") is_voice = content_type in _VOICE_TYPES or att.get("voiceNote", False) media_label = "voice" if is_voice else "attachment" if att_file: from pathlib import Path as _Path att_path = _Path(att_file) if att_path.exists(): from ..base import MAX_ATTACHMENT_BYTES if att_path.stat().st_size > MAX_ATTACHMENT_BYTES: annotations.append( f"[{media_label}: {att_name} - too large ({att_path.stat().st_size} bytes)]" ) else: local = self._media_path(f"signal_{att_name}") import shutil shutil.copy2(str(att_path), str(local)) media_paths.append(str(local)) annotations.append(f"[{media_label}: {local}]") else: annotations.append( f"[{media_label}: {att_name} - file not found]" ) elif att_size: too_large = self._check_attachment_size(att_size, att_name) if too_large: annotations.append(too_large) else: annotations.append(f"[{media_label}: {att_name}]") if not text and not media_paths and not annotations: if not attachments: return # Had attachments but none downloaded successfully if not annotations: text = "[attachment]" try: ts = datetime.fromtimestamp(msg_ts / 1000) if msg_ts else datetime.now() except (ValueError, TypeError, OSError): ts = datetime.now() was_mentioned = not is_group # DMs always pass if is_group: mentions = data_msg.get("mentions", []) for m in mentions: if ( m.get("uuid") == self.config.phone_number or m.get("number") == self.config.phone_number ): was_mentioned = True break # Cache message_id → sender for reaction targetAuthor self._cache_msg_sender(str(msg_ts), source_number) logger.info( "Signal message from %s: %s", source_number, text[:50] if text else "[media]", ) await self._enqueue_raw( RawIncoming( sender_id=source_number, chat_id=chat_id, text=text, content_annotations=annotations, media_files=media_paths, timestamp=ts, message_id=str(msg_ts), is_group=is_group, was_mentioned=was_mentioned, metadata={ "chat_id": chat_id, "source_name": source_name, "sender_id": source_number, "backend": "signal", }, ) ) # ── Typing indicator ──────────────────────────────────────────── async def _send_typing_action(self, chat_id: str) -> None: """Send typing indicator via signal-cli JSON RPC.""" params: dict[str, Any] = { "account": self.config.phone_number, } if self._is_group_id(chat_id): params["groupId"] = chat_id else: params["recipient"] = [chat_id] try: await self._rpc_call("sendTyping", params) except Exception: pass # typing indicator is best-effort # ── ACK reaction ───────────────────────────────────────────── def _cache_msg_sender(self, message_id: str, sender: str) -> None: """Store message_id → sender mapping for reaction targetAuthor.""" if len(self._msg_senders) >= 200: oldest = self._msg_senders_order.popleft() self._msg_senders.pop(oldest, None) self._msg_senders[message_id] = sender self._msg_senders_order.append(message_id) async def _send_ack_reaction( self, chat_id: str, message_id: str, emoji: str = "👀" ) -> None: """Send an acknowledgment reaction via signal-cli sendReaction.""" target_author = self._msg_senders.get(message_id, "") if not target_author: return # cannot send reaction without knowing the original sender try: params: dict[str, Any] = { "account": self.config.phone_number, "emoji": emoji, "targetAuthor": target_author, "targetTimestamp": int(message_id), } if self._is_group_id(chat_id): params["groupId"] = chat_id else: params["recipient"] = [chat_id] await self._rpc_call("sendReaction", params) except Exception as e: logger.debug(f"Signal ack reaction failed: {e}") async def _remove_ack_reaction( self, chat_id: str, message_id: str, emoji: str = "👀" ) -> None: """Remove ACK reaction via signal-cli sendReaction --remove.""" target_author = self._msg_senders.get(message_id, "") if not target_author: return try: params: dict[str, Any] = { "account": self.config.phone_number, "emoji": emoji, "targetAuthor": target_author, "targetTimestamp": int(message_id), "remove": True, } if self._is_group_id(chat_id): params["groupId"] = chat_id else: params["recipient"] = [chat_id] await self._rpc_call("sendReaction", params) except Exception as e: logger.debug(f"Signal remove ACK reaction failed: {e}") # ── Send ────────────────────────────────────────────────────── @staticmethod def _is_group_id(chat_id: str) -> bool: """Return True if *chat_id* looks like a Signal group ID. Group IDs are base64-encoded strings (e.g. ``"aB3d...=="``). Individual recipients are either phone numbers (``"+1234..."``) or UUIDs (``"817ab5e9-..."``) — neither of which is a group. """ return not chat_id.startswith("+") and "-" not in chat_id def _is_ready(self) -> bool: return self._writer is not None and not self._writer.is_closing() async def _rpc_call( self, method: str, params: dict, timeout: float = 10.0 ) -> dict | None: """Send a JSON RPC call to signal-cli and wait for the response.""" if not self._writer: return None self._rpc_id += 1 rpc_id = self._rpc_id request = { "jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params, } # Register a Future before sending so the listen loop can resolve it loop = asyncio.get_running_loop() fut: asyncio.Future = loop.create_future() self._pending_rpcs[rpc_id] = fut line = json.dumps(request) + "\n" self._writer.write(line.encode()) await self._writer.drain() try: return await asyncio.wait_for(fut, timeout=timeout) except TimeoutError: self._pending_rpcs.pop(rpc_id, None) logger.warning(f"Signal RPC '{method}' timed out after {timeout}s") return None async def _send_chunk( self, chat_id, formatted_text, raw_text, reply_to, metadata, ): # Determine if group or individual params: dict[str, Any] = { "message": raw_text, "account": self.config.phone_number, } if self._is_group_id(chat_id): params["groupId"] = chat_id else: params["recipient"] = [chat_id] await self._rpc_call("send", params) # ── Mention stripping ──────────────────────────────────────────── def _strip_mention(self, text: str) -> str: """Strip bot mention from Signal messages. Signal mentions are embedded as special objects that reference the phone number. The text contains a placeholder character (U+FFFC) at the mention position. """ phone = self.config.phone_number if phone: # Remove phone number if directly mentioned as text text = re.sub(rf"@?{re.escape(phone)}\s*", "", text).strip() # Remove Unicode Object Replacement Character used as mention placeholder text = text.replace("\ufffc", "").strip() return text # ── Media send ──────────────────────────────────────────────── async def _send_media_impl( self, recipient: str, file_path: str, caption: str = "", metadata: dict | None = None, ) -> bool: """Send a media file via signal-cli JSON RPC. Uses the "send" RPC method with the attachments parameter. """ chat_id = self._resolve_media_chat_id(recipient, metadata) params: dict[str, Any] = { "account": self.config.phone_number, "attachments": [file_path], } if caption: params["message"] = caption if self._is_group_id(chat_id): params["groupId"] = chat_id else: params["recipient"] = [chat_id] await self._rpc_call("send", params) return True