"""Signal ingestion via a signal-cli JSON-RPC daemon. signal-cli is NOT containerised by digest-engine — assume a separate compose service (e.g. `signal-cli`, image `bbernhard/signal-cli-rest-api` or a hand-rolled `signal-cli daemon --http`) already linked as a secondary device to the real account, reachable on the compose network at SIGNAL_CLI_URL (e.g. http://signal-cli:8080). Method names/params follow https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc `receive` drains the account's server-side envelope queue. That drain is protocol-mandated to read anything at all (Signal has no "peek" primitive) and is the single exception permitted by the read-only rule — it sends no message, sets no read receipt (`sendReadReceipts` is left at its default off) and mutates no conversation. """ import logging import os import uuid from datetime import datetime, timedelta, timezone import requests LOG = logging.getLogger(__name__) RPC_PATH = "/api/v1/rpc" def _envelope_to_record(envelope): data_message = envelope.get("dataMessage") or {} body = data_message.get("message") if not body: return None timestamp_ms = envelope.get("timestamp") or data_message.get("timestamp") sent_at = ( datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc) if timestamp_ms else None ) group_info = data_message.get("groupInfo") or {} return { "source": "signal", "from": envelope.get("sourceName") or envelope.get("sourceNumber") or envelope.get("source"), "group": group_info.get("groupName") or group_info.get("groupId"), "timestamp": sent_at.isoformat() if sent_at else None, "body": body, } def fetch(lookback_hours): base_url = os.environ.get("SIGNAL_CLI_URL", "").strip().rstrip("/") account = os.environ.get("SIGNAL_ACCOUNT", "").strip() timeout = float(os.environ.get("SIGNAL_RECEIVE_TIMEOUT", "10")) if not base_url: LOG.warning("signal: SIGNAL_CLI_URL not set, skipping") return [] params = {"timeout": timeout, "sendReadReceipts": False} if account: params["account"] = account payload = { "jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": "receive", "params": params, } try: response = requests.post(base_url + RPC_PATH, json=payload, timeout=timeout + 15) response.raise_for_status() result = response.json() except Exception: LOG.warning("signal: JSON-RPC call to %s failed, returning nothing", base_url, exc_info=True) return [] if "error" in result: LOG.warning("signal: daemon returned an error: %s", result["error"]) return [] since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours) messages = [] for item in result.get("result") or []: try: envelope = item.get("envelope") if isinstance(item, dict) else None if not envelope: continue record = _envelope_to_record(envelope) if not record: continue if record["timestamp"] and datetime.fromisoformat(record["timestamp"]) < since: continue messages.append(record) except Exception: LOG.warning("signal: could not parse an envelope, skipping", exc_info=True) LOG.info("signal: %d message(s) in the last %sh", len(messages), lookback_hours) return messages