""" Async client for signal-cli's JSON-RPC WebSocket daemon. Start signal-cli daemon with: signal-cli --config ~/.local/share/signal-cli -a +YOURPHONE daemon --json-rpc """ import asyncio import json import logging import uuid from collections.abc import AsyncIterator, Callable, Coroutine from typing import Any import websockets from websockets.asyncio.client import ClientConnection import config logger = logging.getLogger(__name__) MessageHandler = Callable[[str, str], Coroutine[Any, Any, None]] # Signature: async def handler(sender_number: str, text: str) -> None class SignalClient: def __init__(self) -> None: self._ws: ClientConnection | None = None self._uri = f"ws://{config.SIGNAL_HOST}:{config.SIGNAL_PORT}" async def connect(self) -> None: self._ws = await websockets.connect(self._uri) logger.info("Connected to signal-cli at %s", self._uri) async def close(self) -> None: if self._ws: await self._ws.close() async def send_message(self, recipient: str, text: str) -> None: if not self._ws: raise RuntimeError("Not connected") payload = { "jsonrpc": "2.0", "method": "send", "id": str(uuid.uuid4()), "params": { "account": config.SIGNAL_PHONE, "recipient": [recipient], "message": text, }, } await self._ws.send(json.dumps(payload)) logger.debug("Sent to %s: %s", recipient, text[:80]) async def _messages(self) -> AsyncIterator[dict]: """Yield parsed JSON-RPC frames from the WebSocket.""" async for raw in self._ws: try: yield json.loads(raw) except json.JSONDecodeError: logger.warning("Non-JSON frame: %s", raw[:200]) async def listen(self, handler: MessageHandler) -> None: """ Dispatch incoming Signal messages to *handler* indefinitely. Reconnects automatically on transient errors. """ while True: try: await self.connect() async for frame in self._messages(): sender, text = _extract_message(frame) if sender and text: asyncio.create_task(handler(sender, text)) except (websockets.ConnectionClosed, OSError) as exc: logger.warning("Connection lost (%s), reconnecting in 5 s…", exc) await asyncio.sleep(5) def _extract_message(frame: dict) -> tuple[str | None, str | None]: """Return (sender_number, text) from a receive notification, or (None, None).""" if frame.get("method") != "receive": return None, None envelope = frame.get("params", {}).get("envelope", {}) data = envelope.get("dataMessage", {}) if not data: return None, None text: str = data.get("message", "").strip() sender: str = envelope.get("sourceNumber", "") # Ignore empty messages, reactions, or messages with no text if not text or not sender: return None, None return sender, text