mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-05 11:57:56 +02:00
cfbeabda1e
* 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>
649 lines
23 KiB
Python
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()
|