Files
Shadowbroker/backend/services/openclaw_watchdog.py
T
Shadowbroker cfbeabda1e Feat/gt analytics openclaw (#392)
* feat(telegram): auto-translate OSINT channel posts to English

Cherry-picked from @Bobpick PR #391 (telegram-only slice): server-side translation during fetch, SHOW ORIGINAL toggle in TelegramOsintPopup, and on-demand /api/telegram-feed?lang=.

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(gt): experimental Derived OSINT analytics with lean-node safeguards

Cherry-picked from @Bobpick PR #391 (GT + OpenClaw slice): Bayesian strategic-risk engine, map overlay, OpenClaw commands, and telegram_rhetoric watchdog. Off by default (GT_ANALYTICS_ENABLED=false, gt_risk layer false). 1 vCPU nodes get cgroup detection, UI warning on layer toggle, and lean profile that skips scheduled ingest/Louvain unless GT_ANALYTICS_ACK_LOW_CPU=true. Backtest HUD removed from dashboard (OpenClaw/API regression only).

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Robert Pickett <bobpickettsr@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:05:46 -06:00

649 lines
23 KiB
Python

"""OpenClaw Watchdog — alert triggers that push to the agent automatically.
The agent registers watches (track a callsign, geofence a zone, monitor a
keyword in news). The watchdog runs in a background thread, checks telemetry
on each cycle, and pushes matching alerts as tasks via the command channel.
This is the missing piece between "polling 60MB" and "getting woken up when
something matters."
"""
from __future__ import annotations
import logging
import math
import threading
import time
import uuid
from typing import Any
logger = logging.getLogger(__name__)
_lock = threading.Lock()
_watches: dict[str, dict[str, Any]] = {} # watch_id -> watch definition
_fired: dict[str, float] = {} # watch_id -> last fire timestamp (debounce)
_seen_posts: dict[str, set[str]] = {} # watch_id -> seen Telegram post ids/links
_running = False
_stop_event = threading.Event()
_TELEGRAM_SEEN_MAX = 500
# Minimum seconds between re-firing the same watch
DEBOUNCE_S = 60.0
# How often the watchdog checks telemetry
POLL_INTERVAL_S = 15.0
_FLIGHT_LAYERS = (
"tracked_flights",
"military_flights",
"private_jets",
"private_flights",
"commercial_flights",
)
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in km."""
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
# ---------------------------------------------------------------------------
# Watch CRUD
# ---------------------------------------------------------------------------
def add_watch(watch_type: str, params: dict[str, Any]) -> dict[str, Any]:
"""Register a new watch. Returns the watch definition with ID."""
watch_id = str(uuid.uuid4())[:8]
watch = {
"id": watch_id,
"type": watch_type,
"params": params,
"created_at": time.time(),
"fires": 0,
}
with _lock:
_watches[watch_id] = watch
_ensure_running()
return watch
def remove_watch(watch_id: str) -> dict[str, Any]:
"""Remove a watch by ID."""
with _lock:
removed = _watches.pop(watch_id, None)
_fired.pop(watch_id, None)
_seen_posts.pop(watch_id, None)
if removed:
return {"ok": True, "removed": removed}
return {"ok": False, "detail": f"watch '{watch_id}' not found"}
def list_watches() -> list[dict[str, Any]]:
"""List all active watches."""
with _lock:
return list(_watches.values())
def clear_watches() -> dict[str, Any]:
"""Remove all watches."""
with _lock:
count = len(_watches)
_watches.clear()
_fired.clear()
_seen_posts.clear()
return {"ok": True, "cleared": count}
# ---------------------------------------------------------------------------
# Watch evaluation
# ---------------------------------------------------------------------------
def _evaluate_watches() -> list[dict[str, Any]]:
"""Check all watches against current telemetry. Returns list of alerts."""
with _lock:
watches = list(_watches.values())
if not watches:
return []
# Load telemetry once for all watches
try:
from services.telemetry import get_cached_telemetry, get_cached_slow_telemetry
fast = get_cached_telemetry() or {}
slow = get_cached_slow_telemetry() or {}
except Exception:
return []
alerts = []
now = time.time()
for watch in watches:
wid = watch["id"]
# Debounce
with _lock:
last = _fired.get(wid, 0)
if now - last < DEBOUNCE_S:
continue
try:
alert = _check_watch(watch, fast, slow)
if alert:
with _lock:
_fired[wid] = now
if wid in _watches:
_watches[wid]["fires"] = _watches[wid].get("fires", 0) + 1
alerts.append({"watch_id": wid, "watch_type": watch["type"], **alert})
except Exception as e:
logger.warning("Watch %s evaluation error: %s", wid, e)
return alerts
def _check_watch(watch: dict, fast: dict, slow: dict) -> dict[str, Any] | None:
"""Evaluate a single watch against telemetry. Returns alert dict or None."""
wtype = watch["type"]
params = watch["params"]
if wtype == "track_aircraft":
return _check_track_aircraft(params, fast)
if wtype == "track_callsign":
return _check_track_callsign(params, fast)
if wtype == "track_registration":
return _check_track_registration(params, fast)
if wtype == "track_ship":
return _check_track_ship(params, fast)
if wtype == "track_entity":
return _check_track_entity(params)
if wtype == "geofence":
return _check_geofence(params, fast)
if wtype == "keyword":
return _check_keyword(watch["id"], params, fast, slow)
if wtype == "telegram_rhetoric":
return _check_telegram_rhetoric(watch["id"], params, slow)
if wtype == "prediction_market":
return _check_prediction_market(params, slow)
return None
def _norm(value: Any) -> str:
return str(value or "").strip().lower()
def _iter_flights(fast: dict) -> list[dict[str, Any]]:
flights: list[dict[str, Any]] = []
for layer in ("flights", *_FLIGHT_LAYERS):
items = fast.get(layer, [])
if isinstance(items, dict):
items = items.get("items", []) or items.get("results", []) or items.get("flights", [])
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict):
flight = dict(item)
flight.setdefault("source_layer", layer)
flights.append(flight)
return flights
def _flight_payload(flight: dict[str, Any]) -> dict[str, Any]:
return {
"callsign": flight.get("callsign") or flight.get("flight") or flight.get("call"),
"registration": flight.get("registration") or flight.get("r"),
"icao24": flight.get("icao24"),
"owner": flight.get("owner") or flight.get("operator") or flight.get("alert_operator"),
"lat": flight.get("lat") or flight.get("latitude"),
"lng": flight.get("lng") or flight.get("lon") or flight.get("longitude"),
"altitude": flight.get("alt_baro") or flight.get("altitude") or flight.get("alt"),
"speed": flight.get("gs") or flight.get("speed"),
"heading": flight.get("track") or flight.get("heading"),
"type": flight.get("t") or flight.get("type") or flight.get("aircraft_type"),
"source_layer": flight.get("source_layer"),
}
def _check_track_aircraft(params: dict, fast: dict) -> dict | None:
"""Track aircraft by callsign, registration, ICAO24, owner/operator, or query."""
callsign = _norm(params.get("callsign"))
registration = _norm(params.get("registration"))
icao24 = _norm(params.get("icao24"))
owner = _norm(params.get("owner") or params.get("operator"))
query = _norm(params.get("query") or params.get("name"))
if not any((callsign, registration, icao24, owner, query)):
return None
for flight in _iter_flights(fast):
values = {
"callsign": _norm(flight.get("callsign") or flight.get("flight") or flight.get("call")),
"registration": _norm(flight.get("registration") or flight.get("r")),
"icao24": _norm(flight.get("icao24")),
"owner": _norm(flight.get("owner") or flight.get("operator") or flight.get("alert_operator")),
"type": _norm(flight.get("type") or flight.get("t") or flight.get("aircraft_type")),
}
haystack = " ".join(v for v in values.values() if v)
if callsign and callsign not in values["callsign"]:
continue
if registration and registration not in values["registration"]:
continue
if icao24 and icao24 != values["icao24"]:
continue
if owner and owner not in values["owner"]:
continue
if query and not all(token in haystack for token in query.split()):
continue
label = values["callsign"] or values["registration"] or values["icao24"] or query
return {
"alert": f"Aircraft {label.upper()} spotted",
"data": _flight_payload(flight),
}
return None
def _check_track_callsign(params: dict, fast: dict) -> dict | None:
"""Track a specific aircraft by callsign."""
target = str(params.get("callsign", "")).upper().strip()
if not target:
return None
for flight in _iter_flights(fast):
cs = str(flight.get("callsign", "") or flight.get("flight", "") or "").upper().strip()
if cs == target:
return {
"alert": f"Aircraft {target} spotted",
"data": _flight_payload(flight),
}
return None
def _check_track_registration(params: dict, fast: dict) -> dict | None:
"""Track a specific aircraft by registration (tail number)."""
target = str(params.get("registration", "")).upper().strip()
if not target:
return None
for flight in _iter_flights(fast):
reg = str(flight.get("r") or flight.get("registration") or "").upper().strip()
if reg == target:
return {
"alert": f"Aircraft {target} spotted",
"data": _flight_payload(flight),
}
return None
def _check_track_ship(params: dict, fast: dict) -> dict | None:
"""Track a ship by MMSI or name."""
target_mmsi = str(params.get("mmsi", "")).strip()
target_imo = str(params.get("imo", "")).strip()
target_name = str(params.get("name", "")).upper().strip()
target_owner = str(params.get("owner", "") or params.get("operator", "")).upper().strip()
target_query = str(params.get("query", "")).upper().strip()
if not any((target_mmsi, target_imo, target_name, target_owner, target_query)):
return None
ships = fast.get("ships", [])
if isinstance(ships, dict):
ships = ships.get("vessels", [])
for ship in ships:
mmsi = str(ship.get("mmsi", "")).strip()
imo = str(ship.get("imo", "")).strip()
name = str(ship.get("name", "") or ship.get("shipName", "") or "").upper().strip()
owner = str(ship.get("yacht_owner", "") or ship.get("owner", "")).upper().strip()
callsign = str(ship.get("callsign", "")).upper().strip()
haystack = " ".join(v for v in (name, owner, callsign, mmsi, imo) if v)
if (
(target_mmsi and mmsi == target_mmsi)
or (target_imo and imo == target_imo)
or (target_name and target_name in name)
or (target_owner and target_owner in owner)
or (target_query and all(token in haystack for token in target_query.split()))
):
return {
"alert": f"Ship {name or mmsi} spotted",
"data": {
"mmsi": mmsi,
"imo": imo,
"name": name,
"owner": owner,
"lat": ship.get("lat") or ship.get("latitude"),
"lng": ship.get("lng") or ship.get("lon") or ship.get("longitude"),
"speed": ship.get("speed"),
"heading": ship.get("heading") or ship.get("course"),
"type": ship.get("shipType") or ship.get("type"),
},
}
return None
def _check_track_entity(params: dict) -> dict | None:
"""Generic fallback watch using the compact universal search index."""
query = str(params.get("query", "") or params.get("name", "")).strip()
if not query:
return None
layers = params.get("layers") if isinstance(params.get("layers"), (list, tuple)) else None
try:
from services.telemetry import find_entity
result = find_entity(
query=query,
entity_type=str(params.get("entity_type", "") or ""),
layers=layers,
limit=3,
)
except Exception:
return None
best = result.get("best_match")
if not isinstance(best, dict):
return None
return {
"alert": f"Entity {best.get('label') or query} found",
"data": best,
}
def _check_geofence(params: dict, fast: dict) -> dict | None:
"""Alert when any entity enters a geographic zone."""
center_lat = float(params.get("lat", 0))
center_lng = float(params.get("lng", 0))
radius_km = float(params.get("radius_km", 50))
entity_types = params.get("entity_types", ["flights", "ships"])
matches = []
for etype in entity_types:
etype_norm = str(etype or "").strip().lower()
if etype_norm in {"flights", "flight", "aircraft", "planes", "plane", "jets"}:
items = _iter_flights(fast)
else:
items = fast.get(etype_norm, [])
if isinstance(items, dict):
items = items.get("vessels", items.get("items", []))
if not isinstance(items, list):
continue
for item in items:
lat = item.get("lat") or item.get("latitude")
lng = item.get("lng") or item.get("lon") or item.get("longitude")
if lat is None or lng is None:
continue
try:
dist = _haversine_km(center_lat, center_lng, float(lat), float(lng))
except (ValueError, TypeError):
continue
if dist <= radius_km:
label = (item.get("callsign") or item.get("flight") or
item.get("name") or item.get("shipName") or
item.get("mmsi") or item.get("id") or "unknown")
matches.append({
"label": str(label),
"type": etype,
"lat": float(lat),
"lng": float(lng),
"distance_km": round(dist, 1),
})
if matches:
return {
"alert": f"{len(matches)} entities inside geofence ({radius_km}km radius)",
"data": {"center": {"lat": center_lat, "lng": center_lng},
"radius_km": radius_km, "matches": matches[:20]},
}
return None
def _telegram_post_id(post: dict[str, Any]) -> str:
return str(post.get("id") or post.get("link") or "").strip()
def _mark_seen_posts(watch_id: str, post_ids: list[str]) -> None:
clean = [pid for pid in post_ids if pid]
if not clean:
return
with _lock:
seen = _seen_posts.setdefault(watch_id, set())
seen.update(clean)
if len(seen) > _TELEGRAM_SEEN_MAX:
_seen_posts[watch_id] = set(list(seen)[-_TELEGRAM_SEEN_MAX:])
def _is_seen_post(watch_id: str, post_id: str) -> bool:
if not post_id:
return False
with _lock:
return post_id in _seen_posts.get(watch_id, set())
def _check_keyword(watch_id: str, params: dict, fast: dict, slow: dict) -> dict | None:
"""Alert when a keyword appears in news, GDELT, or Telegram OSINT."""
keyword = str(params.get("keyword", "")).lower().strip()
if not keyword:
return None
include_telegram = params.get("include_telegram", True)
if isinstance(include_telegram, str):
include_telegram = include_telegram.strip().lower() not in {"0", "false", "no", "off"}
matches = []
new_telegram_ids: list[str] = []
for article in slow.get("news", []):
title = str(article.get("title", "") or "").lower()
desc = str(article.get("description", "") or article.get("summary", "") or "").lower()
if keyword in title or keyword in desc:
matches.append({
"source": "news",
"title": article.get("title", ""),
"url": article.get("url") or article.get("link"),
})
for event in slow.get("gdelt", []):
text = str(event.get("title", "") or event.get("sourceurl", "") or "").lower()
if keyword in text:
matches.append({
"source": "gdelt",
"title": event.get("title", ""),
"url": event.get("sourceurl"),
})
if include_telegram:
from services.telegram_osint_text import (
iter_telegram_posts,
keyword_matches_telegram_post,
telegram_post_match_entry,
)
for post in iter_telegram_posts(slow.get("telegram_osint")):
if not keyword_matches_telegram_post(post, keyword):
continue
post_id = _telegram_post_id(post)
if _is_seen_post(watch_id, post_id):
continue
entry = telegram_post_match_entry(post)
matches.append(entry)
if post_id:
new_telegram_ids.append(post_id)
if matches:
if new_telegram_ids:
_mark_seen_posts(watch_id, new_telegram_ids)
sources = sorted({str(match.get("source") or "unknown") for match in matches})
return {
"alert": f"Keyword '{keyword}' found in {len(matches)} items ({', '.join(sources)})",
"data": {"keyword": keyword, "matches": matches[:10]},
}
return None
def _check_telegram_rhetoric(watch_id: str, params: dict, slow: dict) -> dict | None:
"""Alert on new high-risk Telegram OSINT posts (optionally keyword/channel filtered)."""
min_risk = int(params.get("min_risk_score", 7) or 7)
min_risk = max(1, min(min_risk, 10))
raw_keywords = params.get("keywords") or params.get("keyword") or []
if isinstance(raw_keywords, str):
raw_keywords = [part.strip() for part in raw_keywords.split(",") if part.strip()]
keywords = [str(item).lower().strip() for item in raw_keywords if str(item).strip()]
raw_channels = params.get("channels") or params.get("channel") or []
if isinstance(raw_channels, str):
raw_channels = [part.strip() for part in raw_channels.split(",") if part.strip()]
channels = [str(item).lower().strip().lstrip("@") for item in raw_channels if str(item).strip()]
from services.telegram_osint_text import (
iter_telegram_posts,
keyword_matches_telegram_post,
telegram_post_match_entry,
)
matches = []
new_post_ids: list[str] = []
for post in iter_telegram_posts(slow.get("telegram_osint")):
try:
risk = int(post.get("risk_score") or 0)
except (TypeError, ValueError):
risk = 0
if risk < min_risk:
continue
channel = str(post.get("channel") or "").lower().strip()
source = str(post.get("source") or "").lower().strip()
if channels and channel not in channels and not any(ch in source for ch in channels):
continue
if keywords and not any(keyword_matches_telegram_post(post, kw) for kw in keywords):
continue
post_id = _telegram_post_id(post)
if _is_seen_post(watch_id, post_id):
continue
entry = telegram_post_match_entry(post)
matches.append(entry)
if post_id:
new_post_ids.append(post_id)
if not matches:
return None
_mark_seen_posts(watch_id, new_post_ids)
top = max(int(match.get("risk_score") or 0) for match in matches)
return {
"alert": (
f"Telegram rhetoric alert: {len(matches)} new post(s) at LVL {top}/10"
+ (f" (min {min_risk})" if min_risk > 1 else "")
),
"data": {
"min_risk_score": min_risk,
"keywords": keywords,
"channels": channels,
"matches": matches[:10],
},
}
def _check_prediction_market(params: dict, slow: dict) -> dict | None:
"""Alert on prediction market movements."""
query = str(params.get("query", "")).lower().strip()
threshold = float(params.get("threshold", 0)) # 0 = any change
markets = slow.get("prediction_markets", [])
matches = []
for market in markets:
title = str(market.get("title", "") or market.get("question", "") or "").lower()
if query and query not in title:
continue
prob = market.get("probability") or market.get("lastTradePrice") or market.get("yes_price")
if prob is not None:
try:
prob = float(prob)
except (ValueError, TypeError):
continue
if threshold and prob >= threshold:
matches.append({
"title": market.get("title") or market.get("question"),
"probability": prob,
})
elif not threshold:
matches.append({
"title": market.get("title") or market.get("question"),
"probability": prob,
})
if matches:
return {
"alert": f"{len(matches)} prediction markets match",
"data": {"query": query, "matches": matches[:10]},
}
return None
# ---------------------------------------------------------------------------
# Background loop
# ---------------------------------------------------------------------------
def _push_ws_alert(alert: dict) -> None:
"""Push an alert to connected WebSocket agents (thread-safe bridge)."""
try:
import asyncio
from routers.ai_intel import broadcast_to_agents
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(broadcast_to_agents({
"type": "alert",
"alert": alert,
}))
else:
loop.run_until_complete(broadcast_to_agents({
"type": "alert",
"alert": alert,
}))
except Exception:
pass # WS broadcast is best-effort, channel.push_task is the fallback
def _watchdog_loop():
"""Background thread that evaluates watches and pushes alerts."""
global _running
logger.info("OpenClaw watchdog started")
while not _stop_event.is_set():
try:
alerts = _evaluate_watches()
if alerts:
from services.openclaw_channel import channel
for alert in alerts:
channel.push_task("alert", alert)
_push_ws_alert(alert)
logger.info("Watchdog alert pushed: %s", alert.get("alert", ""))
except Exception as e:
logger.warning("Watchdog cycle error: %s", e)
_stop_event.wait(POLL_INTERVAL_S)
_running = False
logger.info("OpenClaw watchdog stopped")
def _ensure_running():
"""Start the watchdog thread if not already running."""
global _running
with _lock:
if _running:
return
_running = True
_stop_event.clear()
threading.Thread(target=_watchdog_loop, daemon=True, name="openclaw-watchdog").start()
def stop_watchdog():
"""Stop the watchdog thread."""
_stop_event.set()