Files
GIA/core/clients/transport.py
2026-03-08 22:08:55 +00:00

1225 lines
42 KiB
Python

import asyncio
import base64
import io
import os
import secrets
import shutil
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote_plus
import aiohttp
import orjson
import qrcode
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.cache import cache
from core.clients import signalapi
from core.messaging import media_bridge
from core.security.attachments import (
validate_attachment_metadata,
validate_attachment_url,
)
from core.transports.capabilities import supports, unsupported_reason
from core.util import logs
log = logs.get_logger("transport")
_RUNTIME_STATE_TTL = 60 * 60 * 24
_RUNTIME_COMMANDS_TTL = 60 * 15
_RUNTIME_COMMAND_RESULT_TTL = 60
_BRIDGE_MAP_TTL = 60 * 60 * 24 * 14
_RUNTIME_CLIENTS: dict[str, Any] = {}
def _service_key(service: str) -> str:
return str(service or "").strip().lower()
def _capability_checks_enabled() -> bool:
return bool(getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True))
def _runtime_key(service: str) -> str:
return f"gia:service:runtime:{_service_key(service)}"
def _runtime_commands_key(service: str) -> str:
return f"gia:service:commands:{_service_key(service)}"
def _runtime_command_result_key(service: str, command_id: str) -> str:
return f"gia:service:command-result:{_service_key(service)}:{command_id}"
def _runtime_command_cancel_key(service: str, command_id: str) -> str:
return f"gia:service:command-cancel:{_service_key(service)}:{command_id}"
def _runtime_command_meta_key(service: str, command_id: str) -> str:
return f"gia:service:command-meta:{_service_key(service)}:{command_id}"
def _bridge_map_key(user_id: int, person_id: int, service: str) -> str:
return f"gia:bridge:map:{int(user_id)}:{int(person_id)}:{_service_key(service)}"
def _gateway_base(service: str) -> str:
key = f"{service.upper()}_HTTP_URL"
default = f"http://{service}:8080"
return str(getattr(settings, key, default)).rstrip("/")
def _as_qr_png(data: str) -> bytes:
image = qrcode.make(data)
stream = io.BytesIO()
image.save(stream, format="PNG")
return stream.getvalue()
def _parse_timestamp(data: Any):
if isinstance(data, dict):
ts = data.get("timestamp")
if ts:
return ts
return None
def _attachment_has_inline_content(attachment: dict | None) -> bool:
value = (attachment or {}).get("content")
return isinstance(value, (bytes, bytearray, memoryview))
def _normalize_inline_content(attachment: dict) -> dict:
row = dict(attachment or {})
content = row.get("content")
if isinstance(content, memoryview):
row["content"] = content.tobytes()
elif isinstance(content, bytearray):
row["content"] = bytes(content)
if isinstance(row.get("content"), bytes) and not row.get("size"):
row["size"] = len(row["content"])
return row
async def prepare_outbound_attachments(service: str, attachments: list | None) -> list:
"""
Resolve outbound attachment refs into payloads once, in parallel.
This is the shared media-prep layer for XMPP -> {Signal, WhatsApp} sends,
so attachment performance improvements live in one place.
TODO: Stream per-attachment send as each payload resolves (as_completed)
to reduce first-byte latency for large media batches.
"""
rows = [dict(att or {}) for att in (attachments or [])]
if not rows:
return []
async def _resolve(row: dict):
if _attachment_has_inline_content(row):
return _normalize_inline_content(row)
fetched = await fetch_attachment(service, row)
if not fetched:
return row
merged = dict(row)
merged.update(dict(fetched or {}))
return _normalize_inline_content(merged)
return await asyncio.gather(*[_resolve(row) for row in rows])
def register_runtime_client(service: str, client: Any):
"""
Register an in-process runtime client (UR process).
"""
_RUNTIME_CLIENTS[_service_key(service)] = client
def get_runtime_client(service: str):
return _RUNTIME_CLIENTS.get(_service_key(service))
def get_runtime_state(service: str) -> dict[str, Any]:
return dict(cache.get(_runtime_key(service)) or {})
def update_runtime_state(service: str, **updates):
"""
Persist runtime state to shared cache so web/UI process can read it.
"""
key = _runtime_key(service)
state = dict(cache.get(key) or {})
state.update(updates)
state["updated_at"] = int(time.time())
cache.set(key, state, timeout=_RUNTIME_STATE_TTL)
return state
def record_bridge_mapping(
*,
user_id: int,
person_id: int,
service: str,
xmpp_message_id: str = "",
xmpp_ts: int | None = None,
upstream_message_id: str = "",
upstream_author: str = "",
upstream_ts: int | None = None,
text_preview: str = "",
local_message_id: str = "",
):
key = _bridge_map_key(user_id, person_id, service)
rows = list(cache.get(key) or [])
now_ts = int(time.time() * 1000)
entry = {
"xmpp_message_id": str(xmpp_message_id or "").strip(),
"xmpp_ts": int(xmpp_ts or 0),
"upstream_message_id": str(upstream_message_id or "").strip(),
"upstream_author": str(upstream_author or "").strip(),
"upstream_ts": int(upstream_ts or 0),
"text_preview": str(text_preview or "").strip()[:1000],
"local_message_id": str(local_message_id or "").strip(),
"updated_at": now_ts,
}
if not entry["xmpp_message_id"] and not entry["upstream_message_id"]:
if entry["upstream_ts"] <= 0 and entry["xmpp_ts"] <= 0:
return None
deduped = []
for row in rows:
same_xmpp = bool(entry["xmpp_message_id"]) and (
str((row or {}).get("xmpp_message_id") or "").strip()
== entry["xmpp_message_id"]
)
same_upstream = bool(entry["upstream_message_id"]) and (
str((row or {}).get("upstream_message_id") or "").strip()
== entry["upstream_message_id"]
)
if same_xmpp or same_upstream:
continue
deduped.append(dict(row or {}))
deduped.append(entry)
if len(deduped) > 2000:
deduped = deduped[-2000:]
cache.set(key, deduped, timeout=_BRIDGE_MAP_TTL)
log.debug(
"reaction-bridge map-write service=%s user=%s person=%s xmpp_id=%s upstream_id=%s upstream_ts=%s local_id=%s rows=%s",
service,
user_id,
person_id,
entry.get("xmpp_message_id") or "-",
entry.get("upstream_message_id") or "-",
entry.get("upstream_ts") or 0,
entry.get("local_message_id") or "-",
len(deduped),
)
return entry
def resolve_bridge_from_xmpp(
*, user_id: int, person_id: int, service: str, xmpp_message_id: str
):
target_id = str(xmpp_message_id or "").strip()
if not target_id:
return None
key = _bridge_map_key(user_id, person_id, service)
rows = list(cache.get(key) or [])
for row in reversed(rows):
if str((row or {}).get("xmpp_message_id") or "").strip() == target_id:
log.debug(
"reaction-bridge resolve-xmpp-hit service=%s user=%s person=%s xmpp_id=%s upstream_id=%s",
service,
user_id,
person_id,
target_id,
str((row or {}).get("upstream_message_id") or "-").strip(),
)
return dict(row or {})
log.debug(
"reaction-bridge resolve-xmpp-miss service=%s user=%s person=%s xmpp_id=%s rows=%s",
service,
user_id,
person_id,
target_id,
len(rows),
)
return None
def resolve_bridge_from_upstream(
*,
user_id: int,
person_id: int,
service: str,
upstream_message_id: str = "",
upstream_ts: int | None = None,
):
key = _bridge_map_key(user_id, person_id, service)
rows = list(cache.get(key) or [])
target_id = str(upstream_message_id or "").strip()
if target_id:
for row in reversed(rows):
if str((row or {}).get("upstream_message_id") or "").strip() == target_id:
log.debug(
"reaction-bridge resolve-upstream-id-hit service=%s user=%s person=%s upstream_id=%s xmpp_id=%s",
service,
user_id,
person_id,
target_id,
str((row or {}).get("xmpp_message_id") or "-").strip(),
)
return dict(row or {})
target_ts = int(upstream_ts or 0)
if target_ts > 0:
best = None
best_gap = None
for row in rows:
row_ts = int((row or {}).get("upstream_ts") or 0)
if row_ts <= 0:
continue
gap = abs(row_ts - target_ts)
row_updated = int((row or {}).get("updated_at") or 0)
best_updated = int((best or {}).get("updated_at") or 0) if best else 0
if (
best is None
or gap < best_gap
or (gap == best_gap and row_updated > best_updated)
):
best = dict(row or {})
best_gap = gap
if best is not None and best_gap is not None and best_gap <= 15_000:
log.debug(
"reaction-bridge resolve-upstream-ts-hit service=%s user=%s person=%s target_ts=%s gap_ms=%s picked_xmpp_id=%s picked_upstream_ts=%s",
service,
user_id,
person_id,
target_ts,
best_gap,
str((best or {}).get("xmpp_message_id") or "-").strip(),
int((best or {}).get("upstream_ts") or 0),
)
return best
log.debug(
"reaction-bridge resolve-upstream-miss service=%s user=%s person=%s upstream_id=%s upstream_ts=%s rows=%s",
service,
user_id,
person_id,
target_id or "-",
target_ts,
len(rows),
)
return None
def resolve_bridge_from_text_hint(
*, user_id: int, person_id: int, service: str, text_hint: str
):
hint = str(text_hint or "").strip().lower()
if not hint:
return None
key = _bridge_map_key(user_id, person_id, service)
rows = list(cache.get(key) or [])
for row in reversed(rows):
preview = str((row or {}).get("text_preview") or "").strip().lower()
if preview and (preview == hint or hint in preview):
return dict(row or {})
return None
def enqueue_runtime_command(
service: str, action: str, payload: dict | None = None
) -> str:
service_key = _service_key(service)
command_id = secrets.token_hex(12)
command = {
"id": command_id,
"action": str(action or "").strip(),
"payload": dict(payload or {}),
"created_at": int(time.time()),
}
key = _runtime_commands_key(service_key)
queued = list(cache.get(key) or [])
queued.append(command)
# Keep queue bounded to avoid unbounded growth.
if len(queued) > 200:
queued = queued[-200:]
cache.set(key, queued, timeout=_RUNTIME_COMMANDS_TTL)
cache.set(
_runtime_command_meta_key(service_key, command_id),
{
"created_at": int(command.get("created_at") or int(time.time())),
"action": str(command.get("action") or ""),
},
timeout=_RUNTIME_COMMANDS_TTL,
)
return command_id
def pop_runtime_command(service: str) -> dict[str, Any] | None:
service_key = _service_key(service)
key = _runtime_commands_key(service_key)
queued = list(cache.get(key) or [])
if not queued:
return None
command = dict(queued.pop(0) or {})
cache.set(key, queued, timeout=_RUNTIME_COMMANDS_TTL)
return command
def set_runtime_command_result(
service: str, command_id: str, result: dict | None = None
):
service_key = _service_key(service)
result_key = _runtime_command_result_key(service_key, command_id)
payload = dict(result or {})
payload.setdefault("completed_at", int(time.time()))
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
cache.delete(_runtime_command_meta_key(service_key, command_id))
def cancel_runtime_command(service: str, command_id: str):
"""Mark a runtime command as cancelled and set a result so waiters are released."""
service_key = _service_key(service)
result_key = _runtime_command_result_key(service_key, command_id)
cancel_key = _runtime_command_cancel_key(service_key, command_id)
payload = {"ok": False, "error": "cancelled", "completed_at": int(time.time())}
cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
cache.set(cancel_key, True, timeout=60)
cache.delete(_runtime_command_meta_key(service_key, command_id))
return True
def runtime_command_age_seconds(service: str, command_id: str) -> float | None:
service_key = _service_key(service)
meta = cache.get(_runtime_command_meta_key(service_key, command_id))
if not isinstance(meta, dict):
return None
try:
created_at = int(meta.get("created_at") or 0)
except Exception:
created_at = 0
if created_at <= 0:
return None
return max(0.0, time.time() - created_at)
def cancel_runtime_commands_for_recipient(service: str, recipient: str) -> list[str]:
"""Cancel any queued runtime commands for the given recipient and return their ids."""
service_key = _service_key(service)
key = _runtime_commands_key(service_key)
queued = list(cache.get(key) or [])
cancelled = []
for cmd in list(queued):
payload = dict(cmd.get("payload") or {})
if str(payload.get("recipient") or "").strip() == str(recipient or "").strip():
cmd_id = str(cmd.get("id") or "").strip()
if cmd_id:
cancel_runtime_command(service_key, cmd_id)
cancelled.append(cmd_id)
return cancelled
async def wait_runtime_command_result(
service: str, command_id: str, timeout: float = 20.0
):
service_key = _service_key(service)
result_key = _runtime_command_result_key(service_key, command_id)
deadline = time.monotonic() + max(0.1, float(timeout or 0.0))
while time.monotonic() < deadline:
payload = cache.get(result_key)
if payload is not None:
cache.delete(result_key)
if isinstance(payload, dict):
return dict(payload)
return {}
await asyncio.sleep(0.2)
return None
def list_accounts(service: str):
"""
Return account identifiers for service UI list.
"""
service_key = _service_key(service)
state = get_runtime_state(service_key)
accounts = state.get("accounts") or []
if service_key == "whatsapp" and not accounts:
contacts = state.get("contacts") or []
recovered = []
seen = set()
for row in contacts:
if not isinstance(row, dict):
continue
candidate = str(row.get("identifier") or row.get("jid") or "").strip()
if not candidate or candidate in seen:
continue
seen.add(candidate)
recovered.append(candidate)
if recovered:
accounts = recovered
update_runtime_state(service_key, accounts=recovered)
if isinstance(accounts, list):
return accounts
return []
def _account_key(value: str) -> str:
raw = str(value or "").strip().lower()
if "@" in raw:
raw = raw.split("@", 1)[0]
return raw
def _wipe_signal_cli_local_state() -> bool:
"""
Best-effort local signal-cli state reset for json-rpc deployments where
REST account delete endpoints are unavailable.
"""
config_roots = []
base_dir = getattr(settings, "BASE_DIR", None)
if base_dir:
config_roots.append(str(Path(base_dir) / "signal-cli-config"))
config_roots.extend(
[
"/code/signal-cli-config",
"/signal-cli-config",
"/home/.local/share/signal-cli",
]
)
removed_any = False
seen_roots = set()
for root in config_roots:
root = str(root or "").strip()
if not root or root in seen_roots:
continue
seen_roots.add(root)
if not os.path.isdir(root):
continue
try:
entries = os.listdir(root)
except Exception:
continue
for entry in entries:
if not entry:
continue
# Keep runtime configuration scaffold; wipe account/pairing state.
if entry in {"jsonrpc2.yml", "jsonrpc.yml"}:
continue
path = os.path.join(root, entry)
if os.path.isdir(path):
try:
shutil.rmtree(path)
removed_any = True
except Exception:
continue
else:
try:
os.remove(path)
removed_any = True
except Exception:
continue
return removed_any
def unlink_account(service: str, account: str) -> bool:
service_key = _service_key(service)
account_value = str(account or "").strip()
if not account_value:
return False
if service_key == "signal":
import requests
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
"/"
)
target = quote_plus(account_value)
unlinked = False
for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
try:
response = requests.delete(f"{base}{path}", timeout=20)
if response.ok:
unlinked = True
break
except Exception:
continue
if unlinked:
return True
wiped = _wipe_signal_cli_local_state()
if not wiped:
return False
# Best-effort verification: if the REST API still reports the same account,
# the runtime likely still holds active linked state and the UI should not
# claim relink is ready yet.
remaining_accounts = list_accounts("signal")
for row in remaining_accounts:
if isinstance(row, dict):
candidate = (
row.get("number")
or row.get("id")
or row.get("jid")
or row.get("account")
)
else:
candidate = row
if _account_key(str(candidate or "")) == _account_key(account_value):
return False
return True
if service_key in {"whatsapp", "instagram"}:
state = get_runtime_state(service_key)
key = _account_key(account_value)
raw_accounts = state.get("accounts") or []
accounts = []
for row in raw_accounts:
value = str(row or "").strip()
if not value:
continue
if _account_key(value) == key:
continue
accounts.append(value)
raw_contacts = state.get("contacts") or []
contacts = []
for row in raw_contacts:
if not isinstance(row, dict):
continue
identifier = str(row.get("identifier") or "").strip()
jid = str(row.get("jid") or "").strip()
if _account_key(identifier) == key or _account_key(jid) == key:
continue
contacts.append(row)
update_runtime_state(
service_key,
accounts=accounts,
contacts=contacts,
connected=bool(accounts),
pair_status=("connected" if accounts else ""),
pair_qr="",
warning=(
"" if accounts else "Account unlinked. Add account to link again."
),
last_event="account_unlinked",
last_error="",
)
return True
return False
def get_service_warning(service: str) -> str:
service_key = _service_key(service)
if service_key == "signal":
return ""
state = get_runtime_state(service_key)
warning = str(state.get("warning") or "").strip()
if warning:
return warning
if not state.get("connected"):
return (
f"{service_key.title()} runtime is not connected yet. "
"Start UR with the service enabled, open Services -> "
f"{service_key.title()} -> Add Account, then scan the QR from "
"WhatsApp Linked Devices."
)
return ""
def request_pairing(service: str, device_name: str = ""):
"""
Mark a runtime pairing request so UR clients can refresh QR/pair state.
"""
service_key = _service_key(service)
if service_key not in {"whatsapp", "instagram"}:
return
state = get_runtime_state(service_key)
existing_accounts = state.get("accounts") or []
is_connected = bool(state.get("connected"))
pair_status = str(state.get("pair_status") or "").strip().lower()
if existing_accounts and (is_connected or pair_status == "connected"):
update_runtime_state(
service_key,
warning="Account already linked.",
pair_status="connected",
pair_qr="",
)
return
device = str(device_name or "GIA Device").strip() or "GIA Device"
update_runtime_state(
service_key,
pair_device=device,
pair_requested_at=int(time.time()),
pair_status="pending",
pair_qr="",
pair_request_source="web",
)
async def _gateway_json(method: str, url: str, payload=None):
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
request = getattr(session, method.lower())
async with request(url, json=payload) as response:
body = await response.read()
if not body:
return response.status, None
try:
return response.status, orjson.loads(body)
except Exception:
return response.status, None
async def _normalize_gateway_attachment(service: str, row: dict, session):
normalized = dict(row or {})
content = normalized.get("content")
if isinstance(content, memoryview):
content = content.tobytes()
if isinstance(content, bytes):
filename, content_type = validate_attachment_metadata(
filename=normalized.get("filename") or "attachment.bin",
content_type=normalized.get("content_type") or "application/octet-stream",
size=normalized.get("size") or len(content),
)
blob_key = media_bridge.put_blob(
service=service,
content=content,
filename=filename,
content_type=content_type,
)
return {
"blob_key": blob_key,
"filename": filename,
"content_type": content_type,
"size": normalized.get("size") or len(content),
}
if normalized.get("blob_key"):
return normalized
source_url = normalized.get("url")
if source_url:
try:
safe_url = validate_attachment_url(source_url)
async with session.get(safe_url) as response:
if response.status == 200:
payload = await response.read()
filename, content_type = validate_attachment_metadata(
filename=normalized.get("filename")
or safe_url.rstrip("/").split("/")[-1]
or "attachment.bin",
content_type=normalized.get("content_type")
or response.headers.get(
"Content-Type", "application/octet-stream"
),
size=normalized.get("size") or len(payload),
)
blob_key = media_bridge.put_blob(
service=service,
content=payload,
filename=filename,
content_type=content_type,
)
return {
"blob_key": blob_key,
"filename": filename,
"content_type": content_type,
"size": normalized.get("size") or len(payload),
}
except Exception as exc:
log.warning(
"%s attachment fetch failed for %s: %s",
service,
source_url,
exc,
)
return normalized
async def _gateway_send(service: str, recipient: str, text=None, attachments=None):
base = _gateway_base(service)
url = f"{base}/v1/send"
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as media_session:
normalized_attachments = await asyncio.gather(
*[
_normalize_gateway_attachment(service, dict(att or {}), media_session)
for att in (attachments or [])
]
)
data = {
"recipient": recipient,
"text": text or "",
"attachments": normalized_attachments,
}
status, payload = await _gateway_json("post", url, data)
if 200 <= status < 300:
ts = _parse_timestamp(payload)
return ts if ts else True
log.warning("%s gateway send failed (%s): %s", service, status, payload)
return False
async def _gateway_typing(service: str, recipient: str, started: bool):
base = _gateway_base(service)
action = "start" if started else "stop"
url = f"{base}/v1/typing/{action}"
payload = {"recipient": recipient}
status, _ = await _gateway_json("post", url, payload)
if 200 <= status < 300:
return True
return False
async def send_message_raw(
service: str,
recipient: str,
text=None,
attachments=None,
metadata: dict | None = None,
):
"""
Unified outbound send path used by models/views/UR.
"""
service_key = _service_key(service)
if _capability_checks_enabled() and not supports(service_key, "send"):
reason = unsupported_reason(service_key, "send")
log.warning(
"capability-check failed service=%s feature=send: %s",
service_key,
reason,
)
return False
if service_key == "signal":
prepared_attachments = await prepare_outbound_attachments(
service_key, attachments or []
)
meta = dict(metadata or {})
result = await signalapi.send_message_raw(
recipient,
text,
prepared_attachments,
metadata=meta,
)
xmpp_source_id = str(meta.get("xmpp_source_id") or "").strip()
if xmpp_source_id and result:
from core.models import PersonIdentifier
identifier_row = await sync_to_async(
lambda: PersonIdentifier.objects.filter(
service="signal",
identifier=recipient,
)
.select_related("user", "person")
.first()
)()
if identifier_row is not None:
record_bridge_mapping(
user_id=identifier_row.user_id,
person_id=identifier_row.person_id,
service="signal",
xmpp_message_id=xmpp_source_id,
xmpp_ts=int(meta.get("xmpp_source_ts") or 0),
upstream_message_id="",
upstream_author=str(meta.get("upstream_author") or ""),
upstream_ts=int(result) if isinstance(result, int) else 0,
text_preview=str(meta.get("xmpp_body") or text or ""),
local_message_id=str(meta.get("legacy_message_id") or ""),
)
from core.messaging import history
await history.save_bridge_ref(
user=identifier_row.user,
identifier=identifier_row,
source_service="signal",
local_message_id=str(meta.get("legacy_message_id") or ""),
local_ts=int(meta.get("xmpp_source_ts") or 0),
xmpp_message_id=xmpp_source_id,
upstream_message_id="",
upstream_author=str(meta.get("upstream_author") or ""),
upstream_ts=int(result) if isinstance(result, int) else 0,
)
return result
if service_key == "whatsapp":
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "send_message_raw"):
try:
runtime_result = await runtime_client.send_message_raw(
recipient,
text=text,
attachments=attachments or [],
metadata=dict(metadata or {}),
)
if runtime_result is not False and runtime_result is not None:
return runtime_result
except Exception as exc:
log.warning("%s runtime send failed: %s", service_key, exc)
# Web/UI process cannot access UR in-process runtime client directly.
# Hand off send to UR via shared cache command queue.
command_attachments = []
for att in (attachments or []):
row = dict(att or {})
# Keep payload cache-friendly and avoid embedding raw bytes.
for key in ("content",):
row.pop(key, None)
command_attachments.append(row)
command_id = enqueue_runtime_command(
service_key,
"send_message_raw",
{
"recipient": recipient,
"text": text or "",
"attachments": command_attachments,
"metadata": dict(metadata or {}),
},
)
command_result = await wait_runtime_command_result(
service_key,
command_id,
timeout=20.0,
)
if isinstance(command_result, dict):
if command_result.get("ok"):
ts = _parse_timestamp(command_result)
return ts if ts else True
err = str(command_result.get("error") or "").strip()
log.warning("whatsapp queued send failed: %s", err or "unknown")
return False
log.warning("whatsapp queued send timed out waiting for runtime result")
return False
if service_key == "instagram":
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "send_message_raw"):
try:
runtime_result = await runtime_client.send_message_raw(
recipient,
text=text,
attachments=attachments or [],
metadata=dict(metadata or {}),
)
if runtime_result is not False and runtime_result is not None:
return runtime_result
except Exception as exc:
log.warning("%s runtime send failed: %s", service_key, exc)
return await _gateway_send(
service_key,
recipient,
text=text,
attachments=attachments or [],
)
if service_key == "xmpp":
raise NotImplementedError("Direct XMPP send is handled by the XMPP client.")
raise NotImplementedError(f"Unsupported service: {service}")
async def send_reaction(
service: str,
recipient: str,
*,
emoji: str,
target_message_id: str = "",
target_timestamp: int | None = None,
target_author: str = "",
remove: bool = False,
):
service_key = _service_key(service)
if _capability_checks_enabled() and not supports(service_key, "reactions"):
reason = unsupported_reason(service_key, "reactions")
log.warning(
"capability-check failed service=%s feature=reactions: %s",
service_key,
reason,
)
return False
if not str(emoji or "").strip() and not remove:
return False
if service_key == "signal":
log.debug(
"reaction-bridge send service=signal recipient=%s target_ts=%s target_author=%s remove=%s",
recipient,
int(target_timestamp or 0),
str(target_author or recipient),
bool(remove),
)
return await signalapi.send_reaction(
recipient_uuid=recipient,
emoji=str(emoji or ""),
target_timestamp=target_timestamp,
target_author=str(target_author or recipient),
remove=remove,
)
if service_key == "whatsapp":
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "send_reaction"):
try:
log.debug(
"reaction-bridge send service=whatsapp runtime recipient=%s target_id=%s target_ts=%s remove=%s",
recipient,
str(target_message_id or "") or "-",
int(target_timestamp or 0),
bool(remove),
)
result = await runtime_client.send_reaction(
recipient,
emoji=str(emoji or ""),
target_message_id=str(target_message_id or ""),
target_timestamp=(int(target_timestamp) if target_timestamp else 0),
remove=bool(remove),
)
if result:
return True
except Exception as exc:
log.warning("%s runtime reaction failed: %s", service_key, exc)
command_id = enqueue_runtime_command(
service_key,
"send_reaction",
{
"recipient": recipient,
"emoji": str(emoji or ""),
"target_message_id": str(target_message_id or ""),
"target_timestamp": int(target_timestamp or 0),
"remove": bool(remove),
},
)
command_result = await wait_runtime_command_result(
service_key,
command_id,
timeout=20.0,
)
log.debug(
"reaction-bridge send service=whatsapp queued-result ok=%s command_id=%s",
bool(isinstance(command_result, dict) and command_result.get("ok")),
command_id,
)
return bool(isinstance(command_result, dict) and command_result.get("ok"))
return False
async def start_typing(service: str, recipient: str):
service_key = _service_key(service)
if _capability_checks_enabled() and not supports(service_key, "typing"):
log.warning(
"capability-check failed service=%s feature=typing: %s",
service_key,
unsupported_reason(service_key, "typing"),
)
return False
if service_key == "signal":
await signalapi.start_typing(recipient)
return True
if service_key == "whatsapp":
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "start_typing"):
try:
result = await runtime_client.start_typing(recipient)
if result:
return True
except Exception as exc:
log.warning("%s runtime start_typing failed: %s", service_key, exc)
return False
if service_key == "instagram":
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "start_typing"):
try:
result = await runtime_client.start_typing(recipient)
if result:
return True
except Exception as exc:
log.warning("%s runtime start_typing failed: %s", service_key, exc)
return await _gateway_typing(service_key, recipient, started=True)
return False
async def stop_typing(service: str, recipient: str):
service_key = _service_key(service)
if _capability_checks_enabled() and not supports(service_key, "typing"):
log.warning(
"capability-check failed service=%s feature=typing: %s",
service_key,
unsupported_reason(service_key, "typing"),
)
return False
if service_key == "signal":
await signalapi.stop_typing(recipient)
return True
if service_key == "whatsapp":
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "stop_typing"):
try:
result = await runtime_client.stop_typing(recipient)
if result:
return True
except Exception as exc:
log.warning("%s runtime stop_typing failed: %s", service_key, exc)
return False
if service_key == "instagram":
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "stop_typing"):
try:
result = await runtime_client.stop_typing(recipient)
if result:
return True
except Exception as exc:
log.warning("%s runtime stop_typing failed: %s", service_key, exc)
return await _gateway_typing(service_key, recipient, started=False)
return False
async def fetch_attachment(service: str, attachment_ref: dict):
"""
Fetch attachment bytes from a source service or URL.
"""
service_key = _service_key(service)
if service_key == "signal":
attachment_id = attachment_ref.get("id") or attachment_ref.get("attachment_id")
if attachment_id:
return await signalapi.fetch_signal_attachment(attachment_id)
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "fetch_attachment"):
try:
from_runtime = await runtime_client.fetch_attachment(attachment_ref)
if from_runtime:
return from_runtime
except Exception as exc:
log.warning("%s runtime attachment fetch failed: %s", service_key, exc)
direct_url = attachment_ref.get("url")
blob_key = attachment_ref.get("blob_key")
if blob_key:
return media_bridge.get_blob(blob_key)
if direct_url:
safe_url = validate_attachment_url(direct_url)
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(safe_url) as response:
if response.status != 200:
return None
content = await response.read()
filename, content_type = validate_attachment_metadata(
filename=attachment_ref.get("filename")
or safe_url.rstrip("/").split("/")[-1]
or "attachment.bin",
content_type=response.headers.get(
"Content-Type",
attachment_ref.get("content_type", "application/octet-stream"),
),
size=len(content),
)
return {
"content": content,
"content_type": content_type,
"filename": filename,
"size": len(content),
}
return None
def _qr_from_runtime_state(service: str) -> bytes | None:
state = get_runtime_state(service)
qr_payload = str(state.get("pair_qr") or "").strip()
if not qr_payload:
return None
if qr_payload.startswith("data:image/") and "," in qr_payload:
_, b64_data = qr_payload.split(",", 1)
try:
return base64.b64decode(b64_data)
except Exception:
return None
return _as_qr_png(qr_payload)
def get_link_qr(service: str, device_name: str):
"""
Returns PNG bytes for account-linking QR.
- Signal: uses signal-cli REST endpoint.
- WhatsApp/Instagram: runtime QR from shared state when available.
Falls back to local pairing token QR in development.
"""
service_key = _service_key(service)
device = (device_name or "GIA Device").strip()
if service_key == "signal":
import requests
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
"/"
)
response = requests.get(
f"{base}/v1/qrcodelink",
params={"device_name": device},
timeout=5,
)
response.raise_for_status()
return response.content
if service_key in {"whatsapp", "instagram"}:
runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "get_link_qr_png"):
try:
image_bytes = runtime_client.get_link_qr_png(device)
if image_bytes:
return image_bytes
except Exception:
pass
cached = _qr_from_runtime_state(service_key)
if cached:
return cached
if service_key == "whatsapp":
state = get_runtime_state(service_key)
existing_accounts = state.get("accounts") or []
pair_status = str(state.get("pair_status") or "").strip().lower()
if existing_accounts and (
bool(state.get("connected")) or pair_status == "connected"
):
raise RuntimeError(
"WhatsApp account already linked in this runtime. "
"Only one active linked device is supported. "
"Unlink the current account first, then add a new one."
)
raise RuntimeError(
"Neonize has not provided a pairing QR yet. "
"Ensure UR is running with WHATSAPP_ENABLED=true and retry."
)
token = secrets.token_urlsafe(24)
uri = f"gia://{service_key}/link?device={device}&token={token}"
update_runtime_state(
service_key,
pair_device=device,
pair_requested_at=int(time.time()),
warning=(
"Waiting for runtime pairing QR. "
"If this persists, check UR logs and Neonize session state."
),
)
return _as_qr_png(uri)
raise NotImplementedError(f"Unsupported service for QR linking: {service}")
def image_bytes_to_base64(image_bytes: bytes) -> str:
return base64.b64encode(image_bytes).decode("utf-8")