qrate/signal_client.py

98 lines
3.1 KiB
Python

"""
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