98 lines
3.1 KiB
Python
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
|