SmartestHome/digest-engine/ingest/signal_ingest.py

98 lines
3.4 KiB
Python

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