mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-10 19:14:54 +02:00
1602 lines
65 KiB
Python
1602 lines
65 KiB
Python
"""OpenClaw Bidirectional Command Channel.
|
|
|
|
Provides an authenticated command channel between OpenClaw agents and
|
|
ShadowBroker. Supports both directions:
|
|
|
|
Agent → SB: Commands (get_telemetry, place_pin, etc.)
|
|
SB → Agent: Tasks/alerts pushed by the operator
|
|
|
|
Current transport:
|
|
HMAC Direct: Commands travel via HMAC-SHA256 authenticated HTTP.
|
|
Body integrity is bound into the signature (P1A).
|
|
No end-to-end encryption — relies on TLS for wire privacy.
|
|
|
|
Future (not yet implemented):
|
|
MLS E2EE: Planned upgrade to route commands via Wormhole DM with
|
|
MLS forward secrecy. Not currently wired into this channel.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import concurrent.futures
|
|
import logging
|
|
import secrets
|
|
import threading
|
|
import time
|
|
from collections import OrderedDict
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Persistent thread pool — avoids per-command ThreadPoolExecutor overhead
|
|
# ---------------------------------------------------------------------------
|
|
_executor = concurrent.futures.ThreadPoolExecutor(
|
|
max_workers=4, thread_name_prefix="openclaw-cmd"
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Queue limits
|
|
# ---------------------------------------------------------------------------
|
|
MAX_PENDING_COMMANDS = 64
|
|
MAX_PENDING_TASKS = 32
|
|
COMMAND_RESULT_TTL = 300 # 5 minutes
|
|
TASK_TTL = 600 # 10 minutes
|
|
COMMAND_TIMEOUT = 30 # seconds — hard cap per command execution
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command allowlists (keyed by access tier)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
READ_COMMANDS = frozenset({
|
|
"get_telemetry",
|
|
"get_slow_telemetry",
|
|
"get_summary",
|
|
"get_report",
|
|
"get_layer_slice",
|
|
"find_flights",
|
|
"find_ships",
|
|
"find_entity",
|
|
"correlate_entity",
|
|
"brief_area",
|
|
"what_changed",
|
|
"search_telemetry",
|
|
"search_news",
|
|
"entities_near",
|
|
"get_sigint_totals",
|
|
"get_prediction_markets",
|
|
"get_ai_pins",
|
|
"get_correlations",
|
|
"channel_status",
|
|
"list_watches",
|
|
"timemachine_list",
|
|
"timemachine_config",
|
|
"get_layers",
|
|
# SAR layer reads
|
|
"sar_status",
|
|
"sar_anomalies_recent",
|
|
"sar_anomalies_near",
|
|
"sar_scene_search",
|
|
"sar_coverage_for_aoi",
|
|
"sar_aoi_list",
|
|
"sar_pin_click",
|
|
# Analysis zones (OpenClaw map overlays)
|
|
"list_analysis_zones",
|
|
})
|
|
|
|
WRITE_COMMANDS = frozenset({
|
|
"place_pin",
|
|
"inject_data",
|
|
"take_snapshot",
|
|
"delete_pin",
|
|
"timemachine_playback",
|
|
"create_layer",
|
|
"update_layer",
|
|
"delete_layer",
|
|
"refresh_feed",
|
|
"add_watch",
|
|
"track_entity",
|
|
"watch_area",
|
|
"remove_watch",
|
|
"clear_watches",
|
|
"show_satellite",
|
|
"show_sentinel",
|
|
# SAR layer writes
|
|
"sar_aoi_add",
|
|
"sar_aoi_remove",
|
|
"sar_pin_from_anomaly",
|
|
"sar_watch_anomaly",
|
|
"sar_focus_aoi",
|
|
# Analysis zones (OpenClaw map overlays)
|
|
"place_analysis_zone",
|
|
"delete_analysis_zone",
|
|
"clear_analysis_zones",
|
|
})
|
|
|
|
|
|
def allowed_commands(access_tier: str) -> frozenset[str]:
|
|
"""Return the set of commands allowed for the given access tier."""
|
|
if access_tier == "full":
|
|
return READ_COMMANDS | WRITE_COMMANDS
|
|
return READ_COMMANDS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tier detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_tier_cache: dict[str, Any] | None = None
|
|
_tier_cache_ts: float = 0
|
|
_TIER_CACHE_TTL = 30 # seconds — tier changes are rare, avoid per-command imports
|
|
|
|
|
|
def detect_tier() -> dict[str, Any]:
|
|
"""Detect which communication tier is currently in use.
|
|
|
|
The command channel currently operates exclusively over HMAC-authenticated
|
|
HTTP (Tier 1). MLS E2EE (Tier 2) is planned but not yet wired into
|
|
command dispatch — detect_tier never returns tier 2 until that work
|
|
is complete.
|
|
|
|
Results are cached for 30s to avoid expensive dynamic imports on every
|
|
command submission.
|
|
|
|
Returns:
|
|
{tier: 1, reason: str, transport: str, forward_secrecy: False,
|
|
sealed_sender: False, mls_upgrade_available: bool}
|
|
"""
|
|
global _tier_cache, _tier_cache_ts
|
|
now = time.time()
|
|
if _tier_cache is not None and (now - _tier_cache_ts) < _TIER_CACHE_TTL:
|
|
return _tier_cache
|
|
|
|
mls_upgrade_available = False
|
|
transport = "unknown"
|
|
try:
|
|
from services.wormhole_supervisor import get_wormhole_state, transport_tier_from_state
|
|
state = get_wormhole_state()
|
|
transport = transport_tier_from_state(state) or "unknown"
|
|
|
|
if transport == "private_strong":
|
|
try:
|
|
from services.privacy_core_client import PrivacyCoreClient
|
|
client = PrivacyCoreClient.load()
|
|
if client:
|
|
from services.openclaw_bridge import get_agent_public_info
|
|
info = get_agent_public_info()
|
|
if info.get("bootstrapped"):
|
|
# Infrastructure is present but channel dispatch does
|
|
# not use it yet — flag for UI without overclaiming.
|
|
mls_upgrade_available = True
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
result = {
|
|
"tier": 1,
|
|
"reason": "HMAC-authenticated HTTP — commands are signed but not end-to-end encrypted",
|
|
"transport": transport,
|
|
"forward_secrecy": False,
|
|
"sealed_sender": False,
|
|
"mls_upgrade_available": mls_upgrade_available,
|
|
}
|
|
_tier_cache = result
|
|
_tier_cache_ts = now
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command & Task entries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class CommandEntry:
|
|
"""A command submitted by the agent."""
|
|
|
|
__slots__ = ("id", "cmd", "args", "submitted_at", "status", "result",
|
|
"completed_at", "tier")
|
|
|
|
def __init__(self, cmd: str, args: dict[str, Any], tier: int = 1):
|
|
self.id: str = f"cmd_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
|
|
self.cmd = cmd
|
|
self.args = dict(args or {})
|
|
self.submitted_at = time.time()
|
|
self.status = "pending" # pending → executing → completed | failed
|
|
self.result: dict[str, Any] | None = None
|
|
self.completed_at: float = 0
|
|
self.tier = tier
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
d: dict[str, Any] = {
|
|
"id": self.id,
|
|
"cmd": self.cmd,
|
|
"status": self.status,
|
|
"submitted_at": self.submitted_at,
|
|
"tier": self.tier,
|
|
}
|
|
if self.result is not None:
|
|
d["result"] = self.result
|
|
if self.completed_at:
|
|
d["completed_at"] = self.completed_at
|
|
return d
|
|
|
|
|
|
class TaskEntry:
|
|
"""A task pushed by the operator to the agent."""
|
|
|
|
__slots__ = ("id", "task_type", "payload", "created_at", "picked_up",
|
|
"picked_up_at")
|
|
|
|
def __init__(self, task_type: str, payload: dict[str, Any]):
|
|
self.id: str = f"task_{int(time.time() * 1000)}_{secrets.token_hex(4)}"
|
|
self.task_type = task_type # alert, request, sync, custom
|
|
self.payload = dict(payload or {})
|
|
self.created_at = time.time()
|
|
self.picked_up = False
|
|
self.picked_up_at: float = 0
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"type": self.task_type,
|
|
"payload": self.payload,
|
|
"created_at": self.created_at,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Channel singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class CommandChannel:
|
|
"""Bidirectional command channel between OpenClaw agent and ShadowBroker."""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.RLock()
|
|
# Agent → SB
|
|
self._commands: OrderedDict[str, CommandEntry] = OrderedDict()
|
|
# SB → Agent
|
|
self._tasks: OrderedDict[str, TaskEntry] = OrderedDict()
|
|
self._stats = {
|
|
"commands_received": 0,
|
|
"commands_executed": 0,
|
|
"commands_failed": 0,
|
|
"tasks_pushed": 0,
|
|
"tasks_picked_up": 0,
|
|
}
|
|
|
|
def _prune_expired(self) -> None:
|
|
"""Remove completed commands past TTL and expired tasks."""
|
|
now = time.time()
|
|
# Prune completed/failed commands
|
|
expired_cmds = [
|
|
cid for cid, entry in self._commands.items()
|
|
if entry.status in ("completed", "failed")
|
|
and entry.completed_at
|
|
and (now - entry.completed_at) > COMMAND_RESULT_TTL
|
|
]
|
|
for cid in expired_cmds:
|
|
self._commands.pop(cid, None)
|
|
# Prune expired tasks
|
|
expired_tasks = [
|
|
tid for tid, entry in self._tasks.items()
|
|
if (now - entry.created_at) > TASK_TTL
|
|
]
|
|
for tid in expired_tasks:
|
|
self._tasks.pop(tid, None)
|
|
|
|
# -- Agent → SB: Command submission -----------------------------------
|
|
|
|
def submit_command(self, cmd: str, args: dict[str, Any],
|
|
access_tier: str = "restricted") -> dict[str, Any]:
|
|
"""Submit a command from the agent.
|
|
|
|
Returns the command ID for tracking, or an error.
|
|
"""
|
|
cmd = str(cmd or "").strip().lower()
|
|
if not cmd:
|
|
return {"ok": False, "detail": "empty command"}
|
|
|
|
allowed = allowed_commands(access_tier)
|
|
if cmd not in allowed:
|
|
if cmd in WRITE_COMMANDS and access_tier != "full":
|
|
return {
|
|
"ok": False,
|
|
"detail": f"command '{cmd}' requires full access tier",
|
|
}
|
|
return {"ok": False, "detail": f"unknown command: {cmd}"}
|
|
|
|
tier_info = detect_tier()
|
|
|
|
with self._lock:
|
|
self._prune_expired()
|
|
pending = sum(
|
|
1 for e in self._commands.values() if e.status == "pending"
|
|
)
|
|
if pending >= MAX_PENDING_COMMANDS:
|
|
return {"ok": False, "detail": "command queue full"}
|
|
|
|
entry = CommandEntry(cmd, args, tier=tier_info["tier"])
|
|
self._commands[entry.id] = entry
|
|
self._stats["commands_received"] += 1
|
|
|
|
# Execute with timeout protection
|
|
self._execute_command(entry)
|
|
|
|
return {
|
|
"ok": True,
|
|
"command_id": entry.id,
|
|
"tier": tier_info["tier"],
|
|
"status": entry.status,
|
|
"result": entry.result,
|
|
}
|
|
|
|
def submit_batch(
|
|
self,
|
|
commands: list[dict[str, Any]],
|
|
access_tier: str = "restricted",
|
|
) -> dict[str, Any]:
|
|
"""Submit multiple commands in one call and return all results.
|
|
|
|
Each element should be {"cmd": str, "args": dict}.
|
|
Commands execute concurrently in the shared thread pool, so
|
|
independent queries (e.g. find_flights + search_news) overlap
|
|
instead of serialising behind N HTTP round-trips.
|
|
|
|
Returns {"ok": True, "results": [...], "tier": int}.
|
|
"""
|
|
MAX_BATCH = 20
|
|
if not commands:
|
|
return {"ok": False, "detail": "empty batch"}
|
|
if len(commands) > MAX_BATCH:
|
|
return {"ok": False, "detail": f"batch too large (max {MAX_BATCH})"}
|
|
|
|
tier_info = detect_tier()
|
|
allowed = allowed_commands(access_tier)
|
|
# Pre-allocate results in input order so the caller can match
|
|
# result[i] to command[i] by index.
|
|
results: list[dict[str, Any]] = [None] * len(commands) # type: ignore[list-item]
|
|
entries_with_index: list[tuple[int, CommandEntry]] = []
|
|
|
|
with self._lock:
|
|
self._prune_expired()
|
|
pending = sum(
|
|
1 for e in self._commands.values() if e.status == "pending"
|
|
)
|
|
if pending + len(commands) > MAX_PENDING_COMMANDS:
|
|
return {"ok": False, "detail": "command queue full"}
|
|
|
|
# Validate all commands, recording their original index
|
|
for idx, item in enumerate(commands):
|
|
cmd = str(item.get("cmd", "")).strip().lower()
|
|
args = item.get("args") or {}
|
|
if not cmd:
|
|
results[idx] = {"cmd": cmd, "ok": False, "detail": "empty command"}
|
|
continue
|
|
if cmd not in allowed:
|
|
detail = (f"command '{cmd}' requires full access tier"
|
|
if cmd in WRITE_COMMANDS and access_tier != "full"
|
|
else f"unknown command: {cmd}")
|
|
results[idx] = {"cmd": cmd, "ok": False, "detail": detail}
|
|
continue
|
|
entry = CommandEntry(cmd, args, tier=tier_info["tier"])
|
|
entries_with_index.append((idx, entry))
|
|
with self._lock:
|
|
self._commands[entry.id] = entry
|
|
self._stats["commands_received"] += 1
|
|
|
|
# Execute valid commands concurrently
|
|
if entries_with_index:
|
|
future_to_idx: dict[concurrent.futures.Future, tuple[int, CommandEntry]] = {
|
|
_executor.submit(_dispatch_command, entry.cmd, entry.args): (idx, entry)
|
|
for idx, entry in entries_with_index
|
|
}
|
|
for future in concurrent.futures.as_completed(
|
|
future_to_idx, timeout=COMMAND_TIMEOUT + 5
|
|
):
|
|
idx, entry = future_to_idx[future]
|
|
entry.status = "executing"
|
|
try:
|
|
entry.result = future.result(timeout=0)
|
|
entry.status = "completed"
|
|
self._stats["commands_executed"] += 1
|
|
except concurrent.futures.TimeoutError:
|
|
entry.result = {
|
|
"ok": False,
|
|
"detail": f"command timed out after {COMMAND_TIMEOUT}s",
|
|
}
|
|
entry.status = "failed"
|
|
self._stats["commands_failed"] += 1
|
|
except Exception as exc:
|
|
entry.result = {"ok": False, "detail": str(exc)}
|
|
entry.status = "failed"
|
|
self._stats["commands_failed"] += 1
|
|
entry.completed_at = time.time()
|
|
results[idx] = {
|
|
"cmd": entry.cmd,
|
|
"command_id": entry.id,
|
|
"ok": entry.status == "completed",
|
|
"status": entry.status,
|
|
"result": entry.result,
|
|
}
|
|
|
|
return {
|
|
"ok": True,
|
|
"results": results,
|
|
"tier": tier_info["tier"],
|
|
"count": len(results),
|
|
}
|
|
|
|
def _execute_command(self, entry: CommandEntry) -> None:
|
|
"""Execute a command with timeout protection."""
|
|
entry.status = "executing"
|
|
try:
|
|
future = _executor.submit(_dispatch_command, entry.cmd, entry.args)
|
|
result = future.result(timeout=COMMAND_TIMEOUT)
|
|
entry.result = result
|
|
entry.status = "completed"
|
|
self._stats["commands_executed"] += 1
|
|
except concurrent.futures.TimeoutError:
|
|
entry.result = {
|
|
"ok": False,
|
|
"detail": f"command timed out after {COMMAND_TIMEOUT}s",
|
|
}
|
|
entry.status = "failed"
|
|
self._stats["commands_failed"] += 1
|
|
logger.warning("Command %s timed out after %ds", entry.cmd, COMMAND_TIMEOUT)
|
|
except Exception as exc:
|
|
entry.result = {"ok": False, "detail": str(exc)}
|
|
entry.status = "failed"
|
|
self._stats["commands_failed"] += 1
|
|
logger.warning("Command %s failed: %s", entry.cmd, exc)
|
|
entry.completed_at = time.time()
|
|
|
|
def get_command_result(self, command_id: str) -> dict[str, Any] | None:
|
|
"""Get result for a specific command."""
|
|
with self._lock:
|
|
entry = self._commands.get(command_id)
|
|
if entry is None:
|
|
return None
|
|
return entry.to_dict()
|
|
|
|
def get_completed_commands(self) -> list[dict[str, Any]]:
|
|
"""Get all completed/failed command results (destructive read)."""
|
|
with self._lock:
|
|
self._prune_expired()
|
|
results = []
|
|
consumed = []
|
|
for cid, entry in self._commands.items():
|
|
if entry.status in ("completed", "failed"):
|
|
results.append(entry.to_dict())
|
|
consumed.append(cid)
|
|
for cid in consumed:
|
|
self._commands.pop(cid, None)
|
|
return results
|
|
|
|
# -- SB → Agent: Task push --------------------------------------------
|
|
|
|
def push_task(self, task_type: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Operator pushes a task to the agent."""
|
|
task_type = str(task_type or "custom").strip().lower()
|
|
if task_type not in ("alert", "request", "sync", "custom"):
|
|
return {"ok": False, "detail": f"invalid task type: {task_type}"}
|
|
|
|
with self._lock:
|
|
self._prune_expired()
|
|
pending = sum(1 for t in self._tasks.values() if not t.picked_up)
|
|
if pending >= MAX_PENDING_TASKS:
|
|
return {"ok": False, "detail": "task queue full"}
|
|
|
|
entry = TaskEntry(task_type, payload)
|
|
self._tasks[entry.id] = entry
|
|
self._stats["tasks_pushed"] += 1
|
|
|
|
return {"ok": True, "task_id": entry.id}
|
|
|
|
def poll_tasks(self) -> list[dict[str, Any]]:
|
|
"""Agent picks up pending tasks (destructive read)."""
|
|
with self._lock:
|
|
self._prune_expired()
|
|
tasks = []
|
|
for tid, entry in list(self._tasks.items()):
|
|
if not entry.picked_up:
|
|
entry.picked_up = True
|
|
entry.picked_up_at = time.time()
|
|
tasks.append(entry.to_dict())
|
|
self._stats["tasks_picked_up"] += 1
|
|
# Remove picked-up tasks
|
|
consumed = [
|
|
tid for tid, entry in self._tasks.items() if entry.picked_up
|
|
]
|
|
for tid in consumed:
|
|
self._tasks.pop(tid, None)
|
|
return tasks
|
|
|
|
# -- Status ------------------------------------------------------------
|
|
|
|
def status(self) -> dict[str, Any]:
|
|
"""Return channel status for the operator."""
|
|
tier_info = detect_tier()
|
|
with self._lock:
|
|
self._prune_expired()
|
|
pending_commands = sum(
|
|
1 for e in self._commands.values()
|
|
if e.status in ("pending", "executing")
|
|
)
|
|
completed_commands = sum(
|
|
1 for e in self._commands.values()
|
|
if e.status in ("completed", "failed")
|
|
)
|
|
pending_tasks = sum(
|
|
1 for t in self._tasks.values() if not t.picked_up
|
|
)
|
|
return {
|
|
"ok": True,
|
|
**tier_info,
|
|
"pending_commands": pending_commands,
|
|
"completed_commands": completed_commands,
|
|
"pending_tasks": pending_tasks,
|
|
"stats": dict(self._stats),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Compact response helper — reuses the Time Machine compressed_v1 schema.
|
|
#
|
|
# When an agent passes ``compact=true`` (or ``format="compact"``) on any
|
|
# command that returns full telemetry, we reduce each layer to positional
|
|
# + identity fields and strip None values. This cuts JSON parse time and
|
|
# token count on the agent side without removing information the agent
|
|
# actually uses for map reasoning.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _wants_compact(args: dict[str, Any]) -> bool:
|
|
"""True if the agent requested compact/compressed response formatting."""
|
|
if not isinstance(args, dict):
|
|
return False
|
|
if args.get("compact") is True:
|
|
return True
|
|
fmt = args.get("format")
|
|
if isinstance(fmt, str) and fmt.lower() in ("compact", "compressed", "compressed_v1"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _compact_telemetry_dict(data: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Apply the compressed_v1 schema to every layer in a telemetry dict.
|
|
|
|
Non-layer keys (metadata like ``last_updated``, ``freshness``, scalar
|
|
totals) are passed through untouched. Unknown layers fall back to
|
|
the generic id/lat/lng/name projection from ``_compress_entity``.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return data or {}
|
|
try:
|
|
from routers.ai_intel import _compress_layer_data
|
|
except Exception:
|
|
return data # compression module unavailable — return as-is
|
|
result: dict[str, Any] = {}
|
|
for key, val in data.items():
|
|
# Metadata / scalars pass through.
|
|
if not isinstance(val, (list, dict)):
|
|
result[key] = val
|
|
continue
|
|
# sigint is a dict-of-lists; _compress_layer_data handles that shape.
|
|
if isinstance(val, list) or key == "sigint":
|
|
try:
|
|
result[key] = _compress_layer_data(key, val)
|
|
except Exception:
|
|
result[key] = val
|
|
else:
|
|
result[key] = val
|
|
return result
|
|
|
|
|
|
def _compact_result_entry(entry: Any) -> Any:
|
|
"""Tighten a single search-result dict for compact output.
|
|
|
|
Query commands (find_flights, find_ships, entities_near, search_*)
|
|
already return projected dicts — so the main wins here are:
|
|
dropping empty strings / None values, and rounding lat/lng to 3
|
|
decimals to match the compressed_v1 precision budget. Non-dict
|
|
entries pass through unchanged.
|
|
"""
|
|
if not isinstance(entry, dict):
|
|
return entry
|
|
out: dict[str, Any] = {}
|
|
for k, v in entry.items():
|
|
if v is None:
|
|
continue
|
|
if isinstance(v, str) and not v:
|
|
continue
|
|
if k in ("lat", "lng") and isinstance(v, (int, float)):
|
|
out[k] = round(float(v), 3)
|
|
else:
|
|
out[k] = v
|
|
return out
|
|
|
|
|
|
def _compact_query_result(result: Any) -> Any:
|
|
"""Apply compact projection to a query-command result payload.
|
|
|
|
Shape is typically ``{"results": [...], "version": N, "truncated": bool}``.
|
|
Non-dict payloads and unrecognized shapes pass through.
|
|
"""
|
|
if not isinstance(result, dict):
|
|
return result
|
|
results = result.get("results")
|
|
if not isinstance(results, list):
|
|
return result
|
|
out = dict(result)
|
|
out["results"] = [_compact_result_entry(r) for r in results]
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
"""Route a command to the appropriate AI Intel function.
|
|
|
|
All commands execute synchronously and return a result dict.
|
|
Commands run in an isolated thread (via _execute_command) so they
|
|
do not need or touch the caller's event loop.
|
|
"""
|
|
if cmd == "get_telemetry":
|
|
from services.telemetry import get_cached_telemetry_refs
|
|
data = get_cached_telemetry_refs()
|
|
if _wants_compact(args):
|
|
data = _compact_telemetry_dict(data)
|
|
return {"ok": True, "data": data, "format": "compressed_v1"}
|
|
return {"ok": True, "data": data}
|
|
|
|
if cmd == "get_slow_telemetry":
|
|
from services.telemetry import get_cached_slow_telemetry_refs
|
|
data = get_cached_slow_telemetry_refs()
|
|
if _wants_compact(args):
|
|
data = _compact_telemetry_dict(data)
|
|
return {"ok": True, "data": data, "format": "compressed_v1"}
|
|
return {"ok": True, "data": data}
|
|
|
|
if cmd == "get_summary":
|
|
from services.telemetry import get_telemetry_summary
|
|
summary = get_telemetry_summary()
|
|
return {"ok": True, "data": summary, "version": summary.get("version")}
|
|
|
|
if cmd == "get_layer_slice":
|
|
from services.telemetry import get_layer_slice
|
|
layers = args.get("layers") or []
|
|
slv = args.get("since_layer_versions")
|
|
result = get_layer_slice(
|
|
layers=layers if isinstance(layers, (list, tuple)) else [],
|
|
limit_per_layer=args.get("limit_per_layer"),
|
|
since_version=args.get("since_version"),
|
|
since_layer_versions=slv if isinstance(slv, dict) else None,
|
|
)
|
|
if _wants_compact(args) and isinstance(result, dict):
|
|
inner = result.get("layers")
|
|
if isinstance(inner, dict):
|
|
result = dict(result)
|
|
result["layers"] = _compact_telemetry_dict(inner)
|
|
result["format"] = "compressed_v1"
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "find_flights":
|
|
from services.telemetry import find_flights
|
|
result = find_flights(
|
|
query=str(args.get("query", "") or ""),
|
|
callsign=str(args.get("callsign", "") or ""),
|
|
registration=str(args.get("registration", "") or ""),
|
|
icao24=str(args.get("icao24", "") or ""),
|
|
owner=str(args.get("owner", "") or ""),
|
|
categories=args.get("categories") if isinstance(args.get("categories"), (list, tuple)) else None,
|
|
limit=args.get("limit", 25),
|
|
)
|
|
if _wants_compact(args):
|
|
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "find_ships":
|
|
from services.telemetry import find_ships
|
|
result = find_ships(
|
|
query=str(args.get("query", "") or ""),
|
|
mmsi=str(args.get("mmsi", "") or ""),
|
|
imo=str(args.get("imo", "") or ""),
|
|
name=str(args.get("name", "") or ""),
|
|
limit=args.get("limit", 25),
|
|
)
|
|
if _wants_compact(args):
|
|
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "find_entity":
|
|
from services.telemetry import find_entity
|
|
result = find_entity(
|
|
query=str(args.get("query", "") or ""),
|
|
entity_type=str(args.get("entity_type", "") or args.get("type", "") or ""),
|
|
callsign=str(args.get("callsign", "") or ""),
|
|
registration=str(args.get("registration", "") or args.get("tail_number", "") or ""),
|
|
icao24=str(args.get("icao24", "") or ""),
|
|
mmsi=str(args.get("mmsi", "") or ""),
|
|
imo=str(args.get("imo", "") or ""),
|
|
name=str(args.get("name", "") or ""),
|
|
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
|
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
|
limit=args.get("limit", 10),
|
|
)
|
|
if _wants_compact(args):
|
|
compact = dict(result)
|
|
compact["results"] = [_compact_result_entry(r) for r in result.get("results", [])]
|
|
if isinstance(result.get("best_match"), dict):
|
|
compact["best_match"] = _compact_result_entry(result["best_match"])
|
|
return {"ok": True, "data": compact, "format": "compressed_v1"}
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "correlate_entity":
|
|
from services.telemetry import correlate_entity
|
|
result = correlate_entity(
|
|
query=str(args.get("query", "") or ""),
|
|
entity_type=str(args.get("entity_type", "") or args.get("type", "") or ""),
|
|
callsign=str(args.get("callsign", "") or ""),
|
|
registration=str(args.get("registration", "") or args.get("tail_number", "") or ""),
|
|
icao24=str(args.get("icao24", "") or ""),
|
|
mmsi=str(args.get("mmsi", "") or ""),
|
|
imo=str(args.get("imo", "") or ""),
|
|
name=str(args.get("name", "") or ""),
|
|
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
|
radius_km=args.get("radius_km", 100),
|
|
limit=args.get("limit", 10),
|
|
)
|
|
if _wants_compact(args):
|
|
compact = dict(result)
|
|
if isinstance(compact.get("lookup"), dict):
|
|
compact["lookup"] = dict(compact["lookup"])
|
|
compact["lookup"]["results"] = [
|
|
_compact_result_entry(r) for r in compact["lookup"].get("results", [])
|
|
]
|
|
if isinstance(compact["lookup"].get("best_match"), dict):
|
|
compact["lookup"]["best_match"] = _compact_result_entry(compact["lookup"]["best_match"])
|
|
if isinstance(compact.get("entity"), dict):
|
|
compact["entity"] = _compact_result_entry(compact["entity"])
|
|
return {"ok": True, "data": compact, "format": "compressed_v1"}
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "search_telemetry":
|
|
from services.telemetry import search_telemetry
|
|
result = search_telemetry(
|
|
query=str(args.get("query", "") or ""),
|
|
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
|
limit=args.get("limit", 25),
|
|
)
|
|
if _wants_compact(args):
|
|
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "search_news":
|
|
from services.telemetry import search_news
|
|
result = search_news(
|
|
query=str(args.get("query", "") or ""),
|
|
limit=args.get("limit", 10),
|
|
include_gdelt=bool(args.get("include_gdelt", True)),
|
|
)
|
|
if _wants_compact(args):
|
|
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "brief_area":
|
|
from services.telemetry import entities_near, search_news, get_layer_slice
|
|
lat = args.get("lat")
|
|
lng = args.get("lng") if args.get("lng") is not None else args.get("lon")
|
|
if lat is None or lng is None:
|
|
return {"ok": False, "detail": "lat and lng required"}
|
|
radius_km = args.get("radius_km", 50)
|
|
entity_types = args.get("entity_types") if isinstance(args.get("entity_types"), (list, tuple)) else None
|
|
nearby = entities_near(
|
|
lat=lat,
|
|
lng=lng,
|
|
radius_km=radius_km,
|
|
entity_types=entity_types,
|
|
limit=args.get("limit", 25),
|
|
)
|
|
topic = str(args.get("query", "") or args.get("topic", "") or "").strip()
|
|
news = search_news(query=topic, limit=10) if topic else {"results": [], "truncated": False}
|
|
layers = ["weather_alerts", "earthquakes", "internet_outages", "sar_anomalies"]
|
|
context = get_layer_slice(layers=layers, limit_per_layer=args.get("context_limit", 10))
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"center": {"lat": float(lat), "lng": float(lng)},
|
|
"radius_km": float(radius_km),
|
|
"nearby": nearby,
|
|
"topic_news": news,
|
|
"context_layers": context,
|
|
},
|
|
}
|
|
|
|
if cmd == "what_changed":
|
|
from services.telemetry import get_layer_slice, get_telemetry_summary
|
|
layers = args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else []
|
|
if not layers:
|
|
return {"ok": True, "data": get_telemetry_summary()}
|
|
since_layer_versions = args.get("since_layer_versions")
|
|
result = get_layer_slice(
|
|
layers=layers,
|
|
limit_per_layer=args.get("limit_per_layer", 25),
|
|
since_version=args.get("since_version"),
|
|
since_layer_versions=since_layer_versions if isinstance(since_layer_versions, dict) else None,
|
|
)
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "entities_near":
|
|
from services.telemetry import entities_near
|
|
lat = args.get("lat")
|
|
lng = args.get("lng")
|
|
if lat is None or lng is None:
|
|
return {"ok": False, "detail": "lat and lng required"}
|
|
result = entities_near(
|
|
lat=lat,
|
|
lng=lng,
|
|
radius_km=args.get("radius_km", 50),
|
|
entity_types=args.get("entity_types") if isinstance(args.get("entity_types"), (list, tuple)) else None,
|
|
limit=args.get("limit", 25),
|
|
)
|
|
if _wants_compact(args):
|
|
return {"ok": True, "data": _compact_query_result(result), "format": "compressed_v1"}
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "get_report":
|
|
from services.telemetry import get_cached_telemetry_refs, get_cached_slow_telemetry_refs
|
|
fast = get_cached_telemetry_refs()
|
|
slow = get_cached_slow_telemetry_refs()
|
|
if _wants_compact(args):
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"fast": _compact_telemetry_dict(fast),
|
|
"slow": _compact_telemetry_dict(slow),
|
|
},
|
|
"format": "compressed_v1",
|
|
}
|
|
return {"ok": True, "data": {"fast": fast, "slow": slow}}
|
|
|
|
if cmd == "get_sigint_totals":
|
|
from services.telemetry import get_cached_telemetry_refs
|
|
data = get_cached_telemetry_refs()
|
|
sigint = data.get("sigint", {}) if data else {}
|
|
totals = {}
|
|
for key in ("meshtastic", "aprs", "js8call"):
|
|
items = sigint.get(key, [])
|
|
totals[key] = len(items) if isinstance(items, list) else 0
|
|
return {"ok": True, "data": totals}
|
|
|
|
if cmd == "get_prediction_markets":
|
|
from services.telemetry import get_cached_slow_telemetry_refs
|
|
slow = get_cached_slow_telemetry_refs()
|
|
markets = slow.get("prediction_markets", []) if slow else []
|
|
return {"ok": True, "data": markets}
|
|
|
|
if cmd == "get_ai_pins":
|
|
from services.ai_intel_store import get_all_intel_pins
|
|
pins = get_all_intel_pins()
|
|
return {"ok": True, "data": pins}
|
|
|
|
if cmd == "get_layers":
|
|
from services.ai_intel_store import get_intel_layers
|
|
layers = get_intel_layers()
|
|
return {"ok": True, "data": layers}
|
|
|
|
if cmd == "get_correlations":
|
|
from services.fetchers._store import get_latest_data_subset_refs
|
|
snap = get_latest_data_subset_refs("correlations")
|
|
return {"ok": True, "data": snap.get("correlations") or []}
|
|
|
|
if cmd == "channel_status":
|
|
return channel.status()
|
|
|
|
if cmd == "list_watches":
|
|
from services.openclaw_watchdog import list_watches
|
|
return {"ok": True, "data": list_watches()}
|
|
|
|
# -- Write commands (full access only) ---------------------------------
|
|
|
|
if cmd == "place_pin":
|
|
from services.ai_intel_store import add_intel_pin
|
|
pin = add_intel_pin(args)
|
|
return {"ok": True, "data": pin}
|
|
|
|
if cmd == "delete_pin":
|
|
pin_id = str(args.get("id", "") or args.get("pin_id", "")).strip()
|
|
if not pin_id:
|
|
return {"ok": False, "detail": "pin id required"}
|
|
from services.ai_intel_store import delete_intel_pin
|
|
result = delete_intel_pin(pin_id)
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "inject_data":
|
|
layer = str(args.get("layer", "")).strip()
|
|
items = args.get("items", [])
|
|
if not layer or not items:
|
|
return {"ok": False, "detail": "layer and items required"}
|
|
from services.ai_intel_store import inject_layer_data
|
|
result = inject_layer_data(layer, items)
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "create_layer":
|
|
from services.ai_intel_store import create_intel_layer
|
|
name = str(args.get("name", "")).strip()
|
|
if not name:
|
|
return {"ok": False, "detail": "layer name required"}
|
|
layer = create_intel_layer(args)
|
|
return {"ok": True, "data": layer}
|
|
|
|
if cmd == "update_layer":
|
|
layer_id = str(args.get("layer_id", "") or args.get("id", "")).strip()
|
|
if not layer_id:
|
|
return {"ok": False, "detail": "layer_id required"}
|
|
from services.ai_intel_store import update_intel_layer
|
|
result = update_intel_layer(layer_id, args)
|
|
if result is None:
|
|
return {"ok": False, "detail": f"layer '{layer_id}' not found"}
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "delete_layer":
|
|
layer_id = str(args.get("layer_id", "") or args.get("id", "")).strip()
|
|
if not layer_id:
|
|
return {"ok": False, "detail": "layer_id required"}
|
|
from services.ai_intel_store import delete_intel_layer
|
|
removed = delete_intel_layer(layer_id)
|
|
return {"ok": True, "data": {"layer_id": layer_id, "pins_removed": removed}}
|
|
|
|
if cmd == "refresh_feed":
|
|
layer_id = str(args.get("layer_id", "") or args.get("id", "")).strip()
|
|
if not layer_id:
|
|
return {"ok": False, "detail": "layer_id required"}
|
|
from services.ai_intel_store import get_intel_layers
|
|
layers = get_intel_layers()
|
|
target = next((l for l in layers if l["id"] == layer_id), None)
|
|
if target is None:
|
|
return {"ok": False, "detail": f"layer '{layer_id}' not found"}
|
|
if not target.get("feed_url"):
|
|
return {"ok": False, "detail": "layer has no feed URL"}
|
|
from services.feed_ingester import _fetch_layer_feed
|
|
_fetch_layer_feed(target)
|
|
# Re-fetch for updated state
|
|
layers = get_intel_layers()
|
|
updated = next((l for l in layers if l["id"] == layer_id), target)
|
|
return {"ok": True, "data": updated}
|
|
|
|
if cmd == "take_snapshot":
|
|
from routers.ai_intel import _take_snapshot_internal
|
|
layers = args.get("layers") or []
|
|
compress = args.get("compress", True)
|
|
result = _take_snapshot_internal(
|
|
layers=layers if layers else None,
|
|
profile="openclaw",
|
|
compress=compress,
|
|
)
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "timemachine_list":
|
|
from routers.ai_intel import _snapshots, _snapshots_lock
|
|
from services.node_settings import read_node_settings
|
|
tm_on = read_node_settings().get("timemachine_enabled", False)
|
|
with _snapshots_lock:
|
|
recent = [
|
|
{"id": s["id"], "timestamp": s["timestamp"],
|
|
"format": s.get("format", "full"),
|
|
"layers": s["layers"], "layer_counts": s["layer_counts"]}
|
|
for s in _snapshots[-20:]
|
|
]
|
|
return {"ok": True, "data": recent, "enabled": tm_on,
|
|
"notice": None if tm_on else "Time Machine auto-snapshots are currently OFF. "
|
|
"The operator can enable them in Settings > Protocol. "
|
|
"Warn the user: ~68 MB/day (~2 GB/month) storage cost."}
|
|
|
|
if cmd == "timemachine_playback":
|
|
snapshot_id = str(args.get("snapshot_id", "")).strip()
|
|
if not snapshot_id:
|
|
return {"ok": False, "detail": "snapshot_id required"}
|
|
from routers.ai_intel import _snapshots, _snapshots_lock, _expand_compressed_entity
|
|
with _snapshots_lock:
|
|
target = None
|
|
for snap in _snapshots:
|
|
if snap["id"] == snapshot_id:
|
|
target = snap
|
|
break
|
|
if target is None:
|
|
return {"ok": False, "detail": f"snapshot '{snapshot_id}' not found"}
|
|
data = target.get("data", {})
|
|
if target.get("format") == "compressed_v1":
|
|
expanded = {}
|
|
for layer, items in data.items():
|
|
if isinstance(items, list):
|
|
expanded[layer] = [_expand_compressed_entity(layer, e) for e in items]
|
|
else:
|
|
expanded[layer] = items
|
|
data = expanded
|
|
return {"ok": True, "data": {
|
|
"snapshot_id": target["id"], "timestamp": target["timestamp"],
|
|
"mode": "playback", "layers": target["layers"], "data": data,
|
|
}}
|
|
|
|
if cmd == "timemachine_config":
|
|
from routers.ai_intel import _timemachine_config
|
|
from services.node_settings import read_node_settings
|
|
tm_on = read_node_settings().get("timemachine_enabled", False)
|
|
return {"ok": True, "data": {
|
|
**_timemachine_config,
|
|
"enabled": tm_on,
|
|
"storage_notice": "Time Machine auto-snapshots use ~68 MB/day (~2 GB/month) of compressed storage. "
|
|
"This feature is OFF by default. The operator must explicitly enable it in Settings > Protocol. "
|
|
"Always inform the user of the storage cost before recommending they turn it on.",
|
|
}}
|
|
|
|
# -- Watchdog commands (write access — agent sets up its own alerts) ----
|
|
|
|
if cmd == "add_watch":
|
|
from services.openclaw_watchdog import add_watch
|
|
watch_type = str(args.get("type", "")).strip()
|
|
if not watch_type:
|
|
return {"ok": False, "detail": "watch type required (track_aircraft, track_callsign, track_registration, track_ship, track_entity, geofence, keyword, prediction_market)"}
|
|
watch_params = args.get("params", {})
|
|
if not watch_params:
|
|
# Allow flat args (e.g. {type: "track_callsign", callsign: "N189AM"})
|
|
watch_params = {k: v for k, v in args.items() if k not in ("type", "params")}
|
|
result = add_watch(watch_type, watch_params)
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "track_entity":
|
|
from services.openclaw_watchdog import add_watch
|
|
from services.telemetry import find_entity
|
|
|
|
query = str(args.get("query", "") or args.get("name", "") or "").strip()
|
|
entity_type = str(args.get("entity_type", "") or args.get("type", "") or "").strip().lower()
|
|
lookup = find_entity(
|
|
query=query,
|
|
entity_type=entity_type,
|
|
callsign=str(args.get("callsign", "") or ""),
|
|
registration=str(args.get("registration", "") or args.get("tail_number", "") or ""),
|
|
icao24=str(args.get("icao24", "") or ""),
|
|
mmsi=str(args.get("mmsi", "") or ""),
|
|
imo=str(args.get("imo", "") or ""),
|
|
name=str(args.get("name", "") or ""),
|
|
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
|
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
|
limit=5,
|
|
)
|
|
best = lookup.get("best_match") if isinstance(lookup.get("best_match"), dict) else {}
|
|
group = str(best.get("group", "") or entity_type).lower()
|
|
params = {
|
|
"query": query or best.get("label") or best.get("name") or "",
|
|
"entity_type": entity_type or group,
|
|
"callsign": args.get("callsign") or best.get("callsign") or (best.get("label") if group == "aircraft" else "") or "",
|
|
"registration": args.get("registration") or args.get("tail_number") or best.get("registration") or (best.get("id") if group == "aircraft" else "") or "",
|
|
"icao24": (
|
|
args.get("icao24")
|
|
or best.get("icao24")
|
|
or (best.get("id") if group == "aircraft" else "")
|
|
),
|
|
"mmsi": args.get("mmsi") or best.get("mmsi") or "",
|
|
"imo": args.get("imo") or best.get("imo") or "",
|
|
"name": args.get("name") or best.get("name") or best.get("label") or "",
|
|
"owner": args.get("owner") or args.get("operator") or best.get("owner") or "",
|
|
}
|
|
if group == "aircraft" or entity_type in {"aircraft", "plane", "flight", "jet", "helicopter"} or any(params.get(k) for k in ("callsign", "registration", "icao24")):
|
|
watch_type = "track_aircraft"
|
|
elif group == "maritime" or entity_type in {"ship", "ships", "vessel", "boat", "yacht", "maritime"} or any(params.get(k) for k in ("mmsi", "imo")):
|
|
watch_type = "track_ship"
|
|
else:
|
|
watch_type = "track_entity"
|
|
if isinstance(args.get("layers"), (list, tuple)):
|
|
params["layers"] = list(args.get("layers") or [])
|
|
result = add_watch(watch_type, {k: v for k, v in params.items() if v not in (None, "")})
|
|
return {"ok": True, "data": {"watch": result, "watch_type": watch_type, "initial_lookup": lookup}}
|
|
|
|
if cmd == "watch_area":
|
|
from services.openclaw_watchdog import add_watch
|
|
lat = args.get("lat")
|
|
lng = args.get("lng") if args.get("lng") is not None else args.get("lon")
|
|
if lat is None or lng is None:
|
|
return {"ok": False, "detail": "lat and lng required"}
|
|
entity_types = args.get("entity_types")
|
|
if not isinstance(entity_types, (list, tuple)):
|
|
entity_types = ["aircraft", "ships"]
|
|
params = {
|
|
"lat": float(lat),
|
|
"lng": float(lng),
|
|
"radius_km": float(args.get("radius_km", 50) or 50),
|
|
"entity_types": list(entity_types),
|
|
}
|
|
if args.get("label"):
|
|
params["label"] = str(args.get("label"))
|
|
result = add_watch("geofence", params)
|
|
return {"ok": True, "data": result}
|
|
|
|
if cmd == "remove_watch":
|
|
from services.openclaw_watchdog import remove_watch
|
|
watch_id = str(args.get("id", "") or args.get("watch_id", "")).strip()
|
|
if not watch_id:
|
|
return {"ok": False, "detail": "watch id required"}
|
|
return remove_watch(watch_id)
|
|
|
|
if cmd == "clear_watches":
|
|
from services.openclaw_watchdog import clear_watches
|
|
return clear_watches()
|
|
|
|
# -- Display commands (agent shows imagery to user) ----------------------
|
|
|
|
if cmd == "show_satellite":
|
|
lat = args.get("lat")
|
|
lng = args.get("lng")
|
|
if lat is None or lng is None:
|
|
return {"ok": False, "detail": "lat and lng required"}
|
|
try:
|
|
lat, lng = float(lat), float(lng)
|
|
except (ValueError, TypeError):
|
|
return {"ok": False, "detail": "lat/lng must be numbers"}
|
|
# Fetch satellite imagery
|
|
from services.sentinel_search import search_sentinel2_scene
|
|
scene = search_sentinel2_scene(lat, lng)
|
|
# Push display action to frontend
|
|
from routers.ai_intel import push_agent_action
|
|
push_agent_action({
|
|
"action": "show_image",
|
|
"source": "sentinel2",
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"sentinel2": scene,
|
|
"caption": str(args.get("caption", "")) or None,
|
|
})
|
|
return {"ok": True, "data": {
|
|
"displayed": True,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"scene": scene,
|
|
}}
|
|
|
|
if cmd == "show_sentinel":
|
|
lat = args.get("lat")
|
|
lng = args.get("lng")
|
|
if lat is None or lng is None:
|
|
return {"ok": False, "detail": "lat and lng required"}
|
|
try:
|
|
lat, lng = float(lat), float(lng)
|
|
except (ValueError, TypeError):
|
|
return {"ok": False, "detail": "lat/lng must be numbers"}
|
|
preset = str(args.get("preset", "TRUE-COLOR")).upper()
|
|
if preset not in ("TRUE-COLOR", "FALSE-COLOR", "NDVI", "MOISTURE-INDEX"):
|
|
preset = "TRUE-COLOR"
|
|
# Build a Sentinel Hub Process API image URL via the existing backend proxy.
|
|
# The frontend will need CDSE credentials to be configured.
|
|
# For the agent, we generate the tile request params so the frontend can fetch it.
|
|
from routers.ai_intel import push_agent_action
|
|
push_agent_action({
|
|
"action": "show_image",
|
|
"source": "sentinel_hub",
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"preset": preset,
|
|
"caption": str(args.get("caption", "")) or None,
|
|
})
|
|
return {"ok": True, "data": {
|
|
"displayed": True,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"preset": preset,
|
|
"note": "Image will display if user has Copernicus CDSE credentials configured. "
|
|
"Falls back to Sentinel-2 STAC (free) if not.",
|
|
}}
|
|
|
|
# -- SAR layer commands ------------------------------------------------
|
|
# Read-only commands return data even when Mode B is disabled — the
|
|
# status payload tells the agent how to enable it.
|
|
|
|
if cmd == "sar_status":
|
|
from services.sar.sar_config import (
|
|
catalog_enabled as _sar_catalog_enabled,
|
|
openclaw_enabled as _sar_openclaw_enabled,
|
|
products_fetch_status,
|
|
require_private_tier_for_publish,
|
|
)
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled (MESH_SAR_OPENCLAW_ENABLED=false)"}
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"catalog_enabled": _sar_catalog_enabled(),
|
|
"products": products_fetch_status(),
|
|
"require_private_tier": require_private_tier_for_publish(),
|
|
},
|
|
}
|
|
|
|
if cmd == "sar_anomalies_recent":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
from services.fetchers._store import get_latest_data_subset_refs
|
|
snap = get_latest_data_subset_refs("sar_anomalies")
|
|
items = list(snap.get("sar_anomalies") or [])
|
|
kind = str(args.get("kind", "") or "").strip()
|
|
if kind:
|
|
items = [a for a in items if a.get("kind") == kind]
|
|
limit = int(args.get("limit", 50) or 50)
|
|
return {"ok": True, "data": items[:limit]}
|
|
|
|
if cmd == "sar_anomalies_near":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
lat = args.get("lat")
|
|
lng = args.get("lng") if args.get("lng") is not None else args.get("lon")
|
|
if lat is None or lng is None:
|
|
return {"ok": False, "detail": "lat and lng required"}
|
|
try:
|
|
lat_f = float(lat)
|
|
lng_f = float(lng)
|
|
except (TypeError, ValueError):
|
|
return {"ok": False, "detail": "lat/lng must be numeric"}
|
|
radius_km = float(args.get("radius_km", 50) or 50)
|
|
from services.fetchers._store import get_latest_data_subset_refs
|
|
from services.sar.sar_aoi import haversine_km
|
|
snap = get_latest_data_subset_refs("sar_anomalies")
|
|
matches = []
|
|
for a in (snap.get("sar_anomalies") or []):
|
|
try:
|
|
d = haversine_km(lat_f, lng_f, float(a.get("lat", 0.0)), float(a.get("lon", 0.0)))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if d <= radius_km:
|
|
a2 = dict(a)
|
|
a2["distance_km"] = round(d, 2)
|
|
matches.append(a2)
|
|
matches.sort(key=lambda x: x.get("distance_km", 0))
|
|
limit = int(args.get("limit", 25) or 25)
|
|
return {"ok": True, "data": matches[:limit]}
|
|
|
|
if cmd == "sar_scene_search":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
from services.fetchers._store import get_latest_data_subset_refs
|
|
snap = get_latest_data_subset_refs("sar_scenes")
|
|
items = list(snap.get("sar_scenes") or [])
|
|
aoi_id = str(args.get("aoi_id", "") or "").strip().lower()
|
|
if aoi_id:
|
|
items = [s for s in items if (s.get("aoi_id") or "").lower() == aoi_id]
|
|
limit = int(args.get("limit", 50) or 50)
|
|
return {"ok": True, "data": items[:limit]}
|
|
|
|
if cmd == "sar_coverage_for_aoi":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
from services.fetchers._store import get_latest_data_subset_refs
|
|
snap = get_latest_data_subset_refs("sar_aoi_coverage")
|
|
coverage = list(snap.get("sar_aoi_coverage") or [])
|
|
aoi_id = str(args.get("aoi_id", "") or "").strip().lower()
|
|
if aoi_id:
|
|
coverage = [c for c in coverage if (c.get("aoi_id") or "").lower() == aoi_id]
|
|
return {"ok": True, "data": coverage}
|
|
|
|
if cmd == "sar_aoi_list":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
from services.sar.sar_aoi import load_aois
|
|
return {"ok": True, "data": [a.to_dict() for a in load_aois(force=True)]}
|
|
|
|
if cmd == "sar_aoi_add":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
try:
|
|
from services.sar.sar_aoi import SarAoi, add_aoi
|
|
aoi = SarAoi(
|
|
id=str(args.get("id", "")).strip().lower(),
|
|
name=str(args.get("name", "")).strip() or str(args.get("id", "")),
|
|
description=str(args.get("description", "")).strip(),
|
|
center_lat=float(args.get("center_lat", args.get("lat", 0.0))),
|
|
center_lon=float(args.get("center_lon", args.get("lon", 0.0))),
|
|
radius_km=float(args.get("radius_km", 25.0)),
|
|
polygon=args.get("polygon") if isinstance(args.get("polygon"), list) else None,
|
|
category=str(args.get("category", "watchlist")).strip().lower() or "watchlist",
|
|
)
|
|
except (TypeError, ValueError) as exc:
|
|
return {"ok": False, "detail": f"invalid AOI: {exc}"}
|
|
if not aoi.id:
|
|
return {"ok": False, "detail": "AOI id required"}
|
|
add_aoi(aoi)
|
|
return {"ok": True, "data": aoi.to_dict()}
|
|
|
|
if cmd == "sar_aoi_remove":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
from services.sar.sar_aoi import remove_aoi
|
|
aoi_id = str(args.get("id", "") or args.get("aoi_id", "")).strip().lower()
|
|
if not aoi_id:
|
|
return {"ok": False, "detail": "aoi id required"}
|
|
removed = remove_aoi(aoi_id)
|
|
return {"ok": True, "data": {"removed": removed, "id": aoi_id}}
|
|
|
|
if cmd == "sar_pin_from_anomaly":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
anomaly_id = str(args.get("anomaly_id", "")).strip()
|
|
if not anomaly_id:
|
|
return {"ok": False, "detail": "anomaly_id required"}
|
|
from services.fetchers._store import get_latest_data_subset_refs
|
|
snap = get_latest_data_subset_refs("sar_anomalies")
|
|
match = next(
|
|
(a for a in (snap.get("sar_anomalies") or []) if a.get("anomaly_id") == anomaly_id),
|
|
None,
|
|
)
|
|
if match is None:
|
|
return {"ok": False, "detail": f"anomaly '{anomaly_id}' not found"}
|
|
from services.ai_intel_store import add_intel_pin
|
|
kind = match.get("kind", "sar_anomaly")
|
|
pin_args = {
|
|
"lat": match.get("lat", 0.0),
|
|
"lng": match.get("lon", 0.0),
|
|
"label": str(args.get("label") or f"SAR {kind}")[:200],
|
|
"category": "sar",
|
|
"description": str(
|
|
args.get("description")
|
|
or f"{kind} (mag={match.get('magnitude')} {match.get('magnitude_unit','')})"
|
|
),
|
|
"source": match.get("solver", "sar"),
|
|
"source_url": match.get("source_url", ""),
|
|
"confidence": float(match.get("confidence", 0.5)),
|
|
"metadata": {
|
|
"anomaly_id": anomaly_id,
|
|
"evidence_hash": match.get("evidence_hash"),
|
|
"stack_id": match.get("stack_id"),
|
|
"constellation": match.get("source_constellation"),
|
|
"first_seen": match.get("first_seen"),
|
|
"last_seen": match.get("last_seen"),
|
|
},
|
|
}
|
|
pin = add_intel_pin(pin_args)
|
|
return {"ok": True, "data": pin}
|
|
|
|
if cmd == "sar_pin_click":
|
|
# Return the full detail payload that the map popup shows when a
|
|
# user clicks a SAR anomaly pin. Lets OpenClaw "inspect" a pin
|
|
# programmatically without screen-scraping the popup.
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
anomaly_id = str(args.get("anomaly_id", "") or args.get("id", "")).strip()
|
|
if not anomaly_id:
|
|
return {"ok": False, "detail": "anomaly_id required"}
|
|
from services.fetchers._store import get_latest_data_subset_refs
|
|
snap = get_latest_data_subset_refs("sar_anomalies")
|
|
anomaly = next(
|
|
(a for a in (snap.get("sar_anomalies") or []) if a.get("anomaly_id") == anomaly_id),
|
|
None,
|
|
)
|
|
if anomaly is None:
|
|
return {"ok": False, "detail": f"anomaly '{anomaly_id}' not found"}
|
|
# Pull AOI metadata + recent scenes over the same AOI, mirroring
|
|
# the detail popup the operator would see.
|
|
aoi_id = str(anomaly.get("aoi_id") or "").lower()
|
|
aoi_meta: dict[str, Any] | None = None
|
|
recent_scenes: list[dict[str, Any]] = []
|
|
if aoi_id:
|
|
try:
|
|
from services.sar.sar_aoi import load_aois
|
|
match = next((a for a in load_aois() if a.id.lower() == aoi_id), None)
|
|
if match is not None:
|
|
aoi_meta = match.to_dict()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
scenes_snap = get_latest_data_subset_refs("sar_scenes")
|
|
all_scenes = list(scenes_snap.get("sar_scenes") or [])
|
|
recent_scenes = [
|
|
s for s in all_scenes if (s.get("aoi_id") or "").lower() == aoi_id
|
|
][:10]
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"anomaly": anomaly,
|
|
"aoi": aoi_meta,
|
|
"recent_scenes": recent_scenes,
|
|
},
|
|
}
|
|
|
|
if cmd == "sar_focus_aoi":
|
|
# Fly the user's map to an AOI's center (and optionally open its
|
|
# detail popup via selectedEntity semantics on the frontend side).
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
aoi_id = str(args.get("aoi_id", "") or args.get("id", "")).strip().lower()
|
|
if not aoi_id:
|
|
return {"ok": False, "detail": "aoi_id required"}
|
|
try:
|
|
from services.sar.sar_aoi import load_aois
|
|
match = next((a for a in load_aois() if a.id.lower() == aoi_id), None)
|
|
except Exception as exc:
|
|
return {"ok": False, "detail": f"aoi load failed: {exc}"}
|
|
if match is None:
|
|
return {"ok": False, "detail": f"aoi '{aoi_id}' not found"}
|
|
try:
|
|
zoom = float(args.get("zoom", 8.0))
|
|
except (TypeError, ValueError):
|
|
zoom = 8.0
|
|
from routers.ai_intel import push_agent_action
|
|
push_agent_action({
|
|
"action": "fly_to",
|
|
"source": "sar_focus_aoi",
|
|
"lat": float(match.center_lat),
|
|
"lng": float(match.center_lon),
|
|
"zoom": zoom,
|
|
"aoi_id": match.id,
|
|
"caption": f"AOI: {match.name}",
|
|
})
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"dispatched": True,
|
|
"aoi": match.to_dict(),
|
|
},
|
|
}
|
|
|
|
if cmd == "sar_watch_anomaly":
|
|
from services.sar.sar_config import openclaw_enabled as _sar_openclaw_enabled
|
|
if not _sar_openclaw_enabled():
|
|
return {"ok": False, "detail": "SAR OpenClaw integration disabled"}
|
|
try:
|
|
from services.openclaw_watchdog import add_watch
|
|
except ImportError:
|
|
return {"ok": False, "detail": "watchdog module unavailable"}
|
|
aoi_id = str(args.get("aoi_id", "")).strip().lower()
|
|
kind = str(args.get("kind", "")).strip()
|
|
if not aoi_id:
|
|
return {"ok": False, "detail": "aoi_id required"}
|
|
watch_params = {
|
|
"label": str(args.get("label") or f"SAR watch {aoi_id}"),
|
|
"aoi_id": aoi_id,
|
|
"kind": kind,
|
|
"min_magnitude": float(args.get("min_magnitude", 0.0) or 0.0),
|
|
}
|
|
result = add_watch("sar_anomaly", watch_params)
|
|
return {"ok": True, "data": result}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Analysis zones — OpenClaw map overlays (yellow squares with reports)
|
|
# ------------------------------------------------------------------
|
|
|
|
if cmd == "list_analysis_zones":
|
|
from services.analysis_zone_store import list_zones
|
|
return {"ok": True, "data": {"zones": list_zones()}}
|
|
|
|
if cmd == "place_analysis_zone":
|
|
from services.analysis_zone_store import create_zone
|
|
lat = args.get("lat")
|
|
lng = args.get("lng")
|
|
if lat is None or lng is None:
|
|
return {"ok": False, "detail": "lat and lng required"}
|
|
title = str(args.get("title", "Analysis Zone")).strip()
|
|
body = str(args.get("body", "")).strip()
|
|
if not body:
|
|
return {"ok": False, "detail": "body (analysis text) required"}
|
|
zone = create_zone(
|
|
lat=float(lat),
|
|
lng=float(lng),
|
|
title=title,
|
|
body=body,
|
|
category=str(args.get("category", "analysis")).strip().lower(),
|
|
severity=str(args.get("severity", "medium")).strip().lower(),
|
|
cell_size_deg=float(args.get("cell_size_deg", 1.0) or 1.0),
|
|
ttl_hours=float(args.get("ttl_hours", 0) or 0),
|
|
source="openclaw",
|
|
drivers=args.get("drivers"),
|
|
)
|
|
return {"ok": True, "data": {"zone": zone}}
|
|
|
|
if cmd == "delete_analysis_zone":
|
|
from services.analysis_zone_store import delete_zone
|
|
zone_id = str(args.get("zone_id", "") or args.get("id", "")).strip()
|
|
if not zone_id:
|
|
return {"ok": False, "detail": "zone_id required"}
|
|
removed = delete_zone(zone_id)
|
|
if not removed:
|
|
return {"ok": False, "detail": "zone not found"}
|
|
return {"ok": True, "data": {"removed": zone_id}}
|
|
|
|
if cmd == "clear_analysis_zones":
|
|
from services.analysis_zone_store import clear_zones
|
|
count = clear_zones(source="openclaw")
|
|
return {"ok": True, "data": {"removed_count": count}}
|
|
|
|
return {"ok": False, "detail": f"unhandled command: {cmd}"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cover traffic for command channel polling
|
|
# ---------------------------------------------------------------------------
|
|
# When high-privacy mode is active, the channel emits synthetic poll
|
|
# responses at fixed intervals so an observer watching the HTTP cadence
|
|
# cannot distinguish active agent sessions from idle ones.
|
|
#
|
|
# Design mirrors mesh_rns._cover_loop: fixed interval + jitter, no adaptive
|
|
# backoff (S8A ruling: expanding the interval when real traffic is present
|
|
# leaks activity state).
|
|
#
|
|
# This is response-surface only — cover polls return the same JSON shape as
|
|
# real polls but with empty result arrays. No relay internals are touched.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
COVER_POLL_INTERVAL = 10 # seconds between synthetic polls
|
|
COVER_POLL_JITTER = (0.7, 1.3) # multiplier range
|
|
|
|
_cover_poll_enabled = False
|
|
_cover_poll_thread = None
|
|
|
|
|
|
def _is_high_privacy_channel() -> bool:
|
|
"""Check if high-privacy mode is active (same check as mesh cover loop)."""
|
|
try:
|
|
from services.config import get_settings
|
|
settings = get_settings()
|
|
return bool(getattr(settings, "MESH_RNS_HIGH_PRIVACY", False))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _cover_poll_loop() -> None:
|
|
"""Daemon thread that generates synthetic poll cadence.
|
|
|
|
Records synthetic poll events in the channel stats so an external
|
|
observer sees uniform poll timing regardless of agent activity.
|
|
"""
|
|
import random
|
|
|
|
while _cover_poll_enabled:
|
|
try:
|
|
if not _is_high_privacy_channel():
|
|
time.sleep(3)
|
|
continue
|
|
# Synthetic poll — same shape as real poll response but empty.
|
|
# This touches only the stats counter, not the queue.
|
|
with channel._lock:
|
|
channel._stats.setdefault("cover_polls", 0)
|
|
channel._stats["cover_polls"] += 1
|
|
jitter = random.uniform(*COVER_POLL_JITTER)
|
|
time.sleep(COVER_POLL_INTERVAL * jitter)
|
|
except Exception:
|
|
time.sleep(5)
|
|
|
|
|
|
def start_cover_poll() -> None:
|
|
"""Start the cover poll daemon if not already running."""
|
|
global _cover_poll_enabled, _cover_poll_thread
|
|
if _cover_poll_thread and _cover_poll_thread.is_alive():
|
|
return
|
|
_cover_poll_enabled = True
|
|
_cover_poll_thread = threading.Thread(
|
|
target=_cover_poll_loop, daemon=True, name="openclaw-cover-poll"
|
|
)
|
|
_cover_poll_thread.start()
|
|
logger.info("OpenClaw cover poll daemon started (interval=%ds)", COVER_POLL_INTERVAL)
|
|
|
|
|
|
def stop_cover_poll() -> None:
|
|
"""Stop the cover poll daemon."""
|
|
global _cover_poll_enabled
|
|
_cover_poll_enabled = False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
channel = CommandChannel()
|