Initial commit
This commit is contained in:
commit
70c71105a1
7 changed files with 1817 additions and 0 deletions
141
src/discord_bridge.py
Normal file
141
src/discord_bridge.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
Discord Bridge for Dashboard Chat
|
||||
Sends messages from Dashboard to Discord and relays responses back
|
||||
"""
|
||||
import asyncio
|
||||
import httpx
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
|
||||
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
|
||||
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")
|
||||
DISCORD_CHANNEL_ID = os.getenv("DISCORD_CHANNEL_ID", "")
|
||||
|
||||
# Store pending messages waiting for responses
|
||||
pending_messages: Dict[str, Any] = {}
|
||||
message_callbacks: Dict[str, Callable] = {}
|
||||
|
||||
|
||||
async def send_to_discord(message: str, username: str = "Dashboard", msg_id: str = "") -> bool:
|
||||
"""Send message to Discord via Webhook"""
|
||||
if not DISCORD_WEBHOOK_URL:
|
||||
print("No DISCORD_WEBHOOK_URL configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
DISCORD_WEBHOOK_URL,
|
||||
json={
|
||||
"content": f"📱 **Dashboard:** {message}\n\n[MsgID:{msg_id}]",
|
||||
"username": username,
|
||||
"avatar_url": "https://cdn.discordapp.com/emojis/1064969270828195921.webp"
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
# Store pending message
|
||||
pending_messages[msg_id] = {
|
||||
"timestamp": time.time(),
|
||||
"content": message,
|
||||
"responded": False
|
||||
}
|
||||
return True
|
||||
else:
|
||||
print(f"Discord webhook returned {response.status_code}: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Discord webhook error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def check_discord_responses() -> Optional[Dict[str, str]]:
|
||||
"""Check for new responses in Discord channel (requires bot token)"""
|
||||
if not DISCORD_BOT_TOKEN or not DISCORD_CHANNEL_ID:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages?limit=10",
|
||||
headers={"Authorization": f"Bot {DISCORD_BOT_TOKEN}"},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
messages = response.json()
|
||||
|
||||
for msg in messages:
|
||||
# Skip if it's a dashboard message itself
|
||||
if msg.get("author", {}).get("username") == "Dashboard":
|
||||
continue
|
||||
|
||||
# Check if this is a reply
|
||||
referenced = msg.get("referenced_message")
|
||||
if referenced:
|
||||
ref_content = referenced.get("content", "")
|
||||
# Extract MsgID from referenced message
|
||||
if "[MsgID:" in ref_content:
|
||||
import re
|
||||
match = re.search(r'\[MsgID:([^\]]+)\]', ref_content)
|
||||
if match:
|
||||
msg_id = match.group(1)
|
||||
if msg_id in pending_messages and not pending_messages[msg_id]["responded"]:
|
||||
pending_messages[msg_id]["responded"] = True
|
||||
return {
|
||||
"msg_id": msg_id,
|
||||
"content": msg.get("content", ""),
|
||||
"author": msg.get("author", {}).get("username", "Unknown")
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Discord check error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def poll_discord_responses(callback: Callable[[str, str], None], interval: int = 5):
|
||||
"""Continuously poll for Discord responses"""
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
# Cleanup old messages periodically
|
||||
cleanup_old_messages()
|
||||
|
||||
response = await check_discord_responses()
|
||||
if response:
|
||||
msg_id = response["msg_id"]
|
||||
content = response["content"]
|
||||
|
||||
# Call the callback with response
|
||||
if msg_id in message_callbacks:
|
||||
try:
|
||||
await message_callbacks[msg_id](content)
|
||||
except Exception as e:
|
||||
print(f"Callback error for {msg_id}: {e}")
|
||||
finally:
|
||||
if msg_id in message_callbacks:
|
||||
del message_callbacks[msg_id]
|
||||
|
||||
|
||||
def register_callback(msg_id: str, callback: Callable):
|
||||
"""Register a callback for a message response"""
|
||||
message_callbacks[msg_id] = callback
|
||||
|
||||
|
||||
def cleanup_old_messages(max_age: int = 3600):
|
||||
"""Remove old pending messages"""
|
||||
current_time = time.time()
|
||||
to_remove = [
|
||||
msg_id for msg_id, data in pending_messages.items()
|
||||
if current_time - data["timestamp"] > max_age
|
||||
]
|
||||
for msg_id in to_remove:
|
||||
del pending_messages[msg_id]
|
||||
if msg_id in message_callbacks:
|
||||
del message_callbacks[msg_id]
|
||||
778
src/main.py
Normal file
778
src/main.py
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import psutil
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
import time
|
||||
|
||||
# Import Discord Bridge
|
||||
try:
|
||||
from discord_bridge import send_to_discord, poll_discord_responses, register_callback, cleanup_old_messages
|
||||
DISCORD_AVAILABLE = True
|
||||
except ImportError:
|
||||
DISCORD_AVAILABLE = False
|
||||
print("Warning: discord_bridge not available")
|
||||
|
||||
# Chat models
|
||||
class ChatMessage(BaseModel):
|
||||
message: str
|
||||
|
||||
app = FastAPI(title="Daily Briefing")
|
||||
|
||||
# Static files and templates
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Config
|
||||
VIKUNJA_URL = os.getenv("VIKUNJA_URL", "http://10.10.10.10:3456/api/v1")
|
||||
VIKUNJA_TOKEN = os.getenv("VIKUNJA_TOKEN", "")
|
||||
HA_URL = os.getenv("HA_URL", "https://homeassistant.daddelolymp.de")
|
||||
HA_TOKEN = os.getenv("HA_TOKEN", "")
|
||||
WEATHER_LOCATION = os.getenv("WEATHER_LOCATION", "Leverkusen")
|
||||
WEATHER_LOCATION_SECONDARY = os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia")
|
||||
|
||||
# Caching
|
||||
class Cache:
|
||||
def __init__(self):
|
||||
self.data: Dict[str, Any] = {}
|
||||
self.timestamps: Dict[str, float] = {}
|
||||
self.ttl: Dict[str, int] = {
|
||||
"weather": 3600,
|
||||
"weather_secondary": 3600,
|
||||
"ha": 30,
|
||||
"vikunja": 30,
|
||||
"system": 10,
|
||||
}
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
if key in self.data:
|
||||
age = time.time() - self.timestamps.get(key, 0)
|
||||
if age < self.ttl.get(key, 0):
|
||||
return self.data[key]
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
self.data[key] = value
|
||||
self.timestamps[key] = time.time()
|
||||
|
||||
cache = Cache()
|
||||
|
||||
# WebSocket connections
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: list[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for connection in self.active_connections.copy():
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except:
|
||||
pass
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
# Simple in-memory chat storage (resets on restart)
|
||||
chat_messages: List[Dict[str, Any]] = []
|
||||
MAX_CHAT_HISTORY = 50
|
||||
|
||||
@app.get("/")
|
||||
async def dashboard(request: Request):
|
||||
"""Main dashboard view"""
|
||||
data = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"weather": await get_weather(),
|
||||
"weather_secondary": await get_weather_secondary(),
|
||||
"ha_status": await get_homeassistant_status(),
|
||||
"vikunja_all": await get_vikunja_all_tasks(),
|
||||
"system_status": await get_system_status(),
|
||||
}
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
**data
|
||||
})
|
||||
|
||||
@app.get("/api/all")
|
||||
async def api_all():
|
||||
"""Get all data at once"""
|
||||
weather, weather_secondary, ha, vikunja, system = await asyncio.gather(
|
||||
get_weather(),
|
||||
get_weather_secondary(),
|
||||
get_homeassistant_status(),
|
||||
get_vikunja_all_tasks(),
|
||||
get_system_status()
|
||||
)
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"weather": weather,
|
||||
"weather_secondary": weather_secondary,
|
||||
"ha_status": ha,
|
||||
"vikunja_all": vikunja,
|
||||
"system_status": system
|
||||
}
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await manager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
if data == "ping":
|
||||
fresh_data = await api_all()
|
||||
await websocket.send_json(fresh_data)
|
||||
await asyncio.sleep(1)
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
def parse_forecast(weather_data: list) -> list:
|
||||
"""Parse 3-day forecast from wttr.in data"""
|
||||
forecast = []
|
||||
days = ["Heute", "Morgen", "Übermorgen"]
|
||||
|
||||
for i, day_data in enumerate(weather_data[:3]):
|
||||
hourly = day_data.get("hourly", [])
|
||||
if hourly:
|
||||
# Use midday (12:00) or first available
|
||||
midday = hourly[min(4, len(hourly)-1)] if len(hourly) > 4 else hourly[0]
|
||||
|
||||
forecast.append({
|
||||
"day": days[i] if i < len(days) else day_data.get("date", ""),
|
||||
"temp_max": day_data.get("maxtempC", "--"),
|
||||
"temp_min": day_data.get("mintempC", "--"),
|
||||
"icon": get_weather_icon(midday.get("weatherDesc", [{}])[0].get("value", "")),
|
||||
"description": midday.get("weatherDesc", [{}])[0].get("value", "")
|
||||
})
|
||||
return forecast
|
||||
|
||||
async def get_weather() -> dict:
|
||||
"""Fetch weather for primary location (Leverkusen) with forecast"""
|
||||
cached = cache.get("weather")
|
||||
if cached:
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(
|
||||
f"https://wttr.in/{WEATHER_LOCATION}?format=j1",
|
||||
headers={"User-Agent": "curl/7.68.0"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
current = data["current_condition"][0]
|
||||
|
||||
# Parse forecast
|
||||
forecast = parse_forecast(data.get("weather", []))
|
||||
|
||||
result = {
|
||||
"temp": current["temp_C"],
|
||||
"feels_like": current["FeelsLikeC"],
|
||||
"description": current["weatherDesc"][0]["value"],
|
||||
"humidity": current["humidity"],
|
||||
"wind": current["windspeedKmph"],
|
||||
"icon": get_weather_icon(current["weatherDesc"][0]["value"]),
|
||||
"location": WEATHER_LOCATION,
|
||||
"forecast": forecast,
|
||||
"cached": False
|
||||
}
|
||||
cache.set("weather", result)
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Weather error: {e}")
|
||||
return {"error": "Weather unavailable", "location": WEATHER_LOCATION}
|
||||
|
||||
async def get_weather_secondary() -> dict:
|
||||
"""Fetch weather for secondary location (Rab/Banjol) with forecast"""
|
||||
cached = cache.get("weather_secondary")
|
||||
if cached:
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(
|
||||
f"https://wttr.in/{WEATHER_LOCATION_SECONDARY}?format=j1",
|
||||
headers={"User-Agent": "curl/7.68.0"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
current = data["current_condition"][0]
|
||||
|
||||
# Parse forecast
|
||||
forecast = parse_forecast(data.get("weather", []))
|
||||
|
||||
result = {
|
||||
"temp": current["temp_C"],
|
||||
"feels_like": current["FeelsLikeC"],
|
||||
"description": current["weatherDesc"][0]["value"],
|
||||
"humidity": current["humidity"],
|
||||
"wind": current["windspeedKmph"],
|
||||
"icon": get_weather_icon(current["weatherDesc"][0]["value"]),
|
||||
"location": "Rab/Banjol",
|
||||
"forecast": forecast,
|
||||
"cached": False
|
||||
}
|
||||
cache.set("weather_secondary", result)
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Weather secondary error: {e}")
|
||||
return {"error": "Weather unavailable", "location": "Rab/Banjol"}
|
||||
|
||||
def get_weather_icon(description: str) -> str:
|
||||
"""Map weather description to emoji"""
|
||||
desc = description.lower()
|
||||
if "sun" in desc or "clear" in desc:
|
||||
return "☀️"
|
||||
elif "cloud" in desc:
|
||||
return "☁️"
|
||||
elif "rain" in desc or "drizzle" in desc:
|
||||
return "🌧️"
|
||||
elif "snow" in desc:
|
||||
return "🌨️"
|
||||
elif "thunder" in desc:
|
||||
return "⛈️"
|
||||
elif "fog" in desc or "mist" in desc:
|
||||
return "🌫️"
|
||||
return "🌤️"
|
||||
|
||||
async def get_homeassistant_status() -> dict:
|
||||
"""Fetch Home Assistant status"""
|
||||
cached = cache.get("ha")
|
||||
if cached:
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
lights_resp = await client.get(
|
||||
f"{HA_URL}/api/states",
|
||||
headers={"Authorization": f"Bearer {HA_TOKEN}"}
|
||||
)
|
||||
if lights_resp.status_code == 200:
|
||||
states = lights_resp.json()
|
||||
lights = []
|
||||
for state in states:
|
||||
if state["entity_id"].startswith("light."):
|
||||
lights.append({
|
||||
"name": state["attributes"].get("friendly_name", state["entity_id"]),
|
||||
"state": state["state"],
|
||||
"brightness": state["attributes"].get("brightness", 0)
|
||||
})
|
||||
|
||||
covers = []
|
||||
for state in states:
|
||||
if state["entity_id"].startswith("cover."):
|
||||
covers.append({
|
||||
"name": state["attributes"].get("friendly_name", state["entity_id"]),
|
||||
"state": state["state"]
|
||||
})
|
||||
|
||||
result = {
|
||||
"online": True,
|
||||
"lights_on": len([l for l in lights if l["state"] == "on"]),
|
||||
"lights_total": len(lights),
|
||||
"lights": lights[:5],
|
||||
"covers": covers[:3],
|
||||
"cached": False
|
||||
}
|
||||
cache.set("ha", result)
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"HA error: {e}")
|
||||
return {"online": False, "error": "Home Assistant unavailable"}
|
||||
|
||||
async def get_vikunja_all_tasks() -> dict:
|
||||
"""Fetch ALL tasks from ALL projects - separated by owner (private vs Sam's)"""
|
||||
cached = cache.get("vikunja_all")
|
||||
if cached:
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
|
||||
# Project mapping
|
||||
PRIVATE_PROJECT_IDS = [3, 4] # Haus & Garten, Jugendeinrichtung Arbeit
|
||||
SAM_PROJECT_IDS = [2, 5] # OpenClaw AI Tasks, Sam's Wunderwelt
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
# Get all projects first
|
||||
proj_resp = await client.get(
|
||||
f"{VIKUNJA_URL}/projects",
|
||||
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
|
||||
)
|
||||
|
||||
if proj_resp.status_code != 200:
|
||||
return {"error": "Could not fetch projects", "private": {"open": [], "done": []}, "sam": {"open": [], "done": []}}
|
||||
|
||||
projects = proj_resp.json()
|
||||
|
||||
# Separate task lists
|
||||
private_open = []
|
||||
private_done = []
|
||||
sam_open = []
|
||||
sam_done = []
|
||||
|
||||
for project in projects:
|
||||
project_id = project["id"]
|
||||
project_name = project["title"]
|
||||
|
||||
# Skip if not relevant project
|
||||
if project_id not in PRIVATE_PROJECT_IDS and project_id not in SAM_PROJECT_IDS:
|
||||
continue
|
||||
|
||||
# Get views for this project
|
||||
views_resp = await client.get(
|
||||
f"{VIKUNJA_URL}/projects/{project_id}/views",
|
||||
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
|
||||
)
|
||||
|
||||
if views_resp.status_code == 200:
|
||||
views = views_resp.json()
|
||||
if views:
|
||||
view_id = views[0]["id"]
|
||||
|
||||
# Get ALL tasks
|
||||
tasks_resp = await client.get(
|
||||
f"{VIKUNJA_URL}/projects/{project_id}/views/{view_id}/tasks",
|
||||
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
|
||||
)
|
||||
|
||||
if tasks_resp.status_code == 200:
|
||||
tasks = tasks_resp.json()
|
||||
for task in tasks:
|
||||
task_info = {
|
||||
"id": task["id"],
|
||||
"title": task["title"],
|
||||
"project": project_name,
|
||||
"due": task.get("due_date", ""),
|
||||
"priority": task.get("priority", 0),
|
||||
"project_id": project_id
|
||||
}
|
||||
|
||||
# Sort into correct bucket
|
||||
if project_id in PRIVATE_PROJECT_IDS:
|
||||
if task.get("done", False):
|
||||
private_done.append(task_info)
|
||||
else:
|
||||
private_open.append(task_info)
|
||||
elif project_id in SAM_PROJECT_IDS:
|
||||
if task.get("done", False):
|
||||
sam_done.append(task_info)
|
||||
else:
|
||||
sam_open.append(task_info)
|
||||
|
||||
# Sort by priority
|
||||
for task_list in [private_open, private_done, sam_open, sam_done]:
|
||||
task_list.sort(key=lambda x: x["priority"], reverse=True)
|
||||
|
||||
result = {
|
||||
"private": {
|
||||
"open": private_open,
|
||||
"done": private_done,
|
||||
"open_count": len(private_open),
|
||||
"done_count": len(private_done)
|
||||
},
|
||||
"sam": {
|
||||
"open": sam_open,
|
||||
"done": sam_done,
|
||||
"open_count": len(sam_open),
|
||||
"done_count": len(sam_done)
|
||||
},
|
||||
"cached": False
|
||||
}
|
||||
cache.set("vikunja_all", result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Vikunja error: {e}")
|
||||
print(traceback.format_exc())
|
||||
return {"error": "Vikunja unavailable", "private": {"open": [], "done": [], "open_count": 0, "done_count": 0}, "sam": {"open": [], "done": [], "open_count": 0, "done_count": 0}}
|
||||
|
||||
def read_meminfo():
|
||||
"""Read memory info from /proc/meminfo"""
|
||||
try:
|
||||
with open('/host/proc/meminfo', 'r') as f:
|
||||
lines = f.readlines()
|
||||
meminfo = {}
|
||||
for line in lines:
|
||||
parts = line.split(':')
|
||||
if len(parts) == 2:
|
||||
key = parts[0].strip()
|
||||
value = parts[1].strip().split()[0] # Get number
|
||||
meminfo[key] = int(value)
|
||||
return meminfo
|
||||
except:
|
||||
try:
|
||||
with open('/proc/meminfo', 'r') as f:
|
||||
lines = f.readlines()
|
||||
meminfo = {}
|
||||
for line in lines:
|
||||
parts = line.split(':')
|
||||
if len(parts) == 2:
|
||||
key = parts[0].strip()
|
||||
value = parts[1].strip().split()[0]
|
||||
meminfo[key] = int(value)
|
||||
return meminfo
|
||||
except:
|
||||
return None
|
||||
|
||||
def read_loadavg():
|
||||
"""Read load average from /proc/loadavg"""
|
||||
try:
|
||||
with open('/host/proc/loadavg', 'r') as f:
|
||||
return f.read().strip()
|
||||
except:
|
||||
try:
|
||||
with open('/proc/loadavg', 'r') as f:
|
||||
return f.read().strip()
|
||||
except:
|
||||
return None
|
||||
|
||||
def get_system_status_sync() -> dict:
|
||||
"""Get real system status with CPU/RAM (synchronous)"""
|
||||
try:
|
||||
# Check processes by looking at /proc
|
||||
openclaw_running = False
|
||||
docker_running = False
|
||||
try:
|
||||
# Check if openclaw gateway is listening
|
||||
import socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2)
|
||||
result = sock.connect_ex(('localhost', 8080))
|
||||
openclaw_running = result == 0
|
||||
sock.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check docker socket
|
||||
import os
|
||||
docker_running = os.path.exists('/var/run/docker.sock')
|
||||
# If socket exists, try a light ping
|
||||
if docker_running:
|
||||
import socket
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.settimeout(2)
|
||||
try:
|
||||
client.connect('/var/run/docker.sock')
|
||||
docker_running = True
|
||||
except:
|
||||
docker_running = False
|
||||
finally:
|
||||
client.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Get CPU cores
|
||||
cpu_cores = psutil.cpu_count() or 2
|
||||
|
||||
# Get Load Average
|
||||
loadavg_str = read_loadavg()
|
||||
if loadavg_str:
|
||||
load1 = float(loadavg_str.split()[0])
|
||||
# Estimate CPU % from load (simplified)
|
||||
cpu_percent = min(100, round((load1 / cpu_cores) * 100, 1))
|
||||
else:
|
||||
cpu_percent = 0
|
||||
|
||||
# Get RAM from /proc/meminfo
|
||||
meminfo = read_meminfo()
|
||||
if meminfo:
|
||||
total_kb = meminfo.get('MemTotal', 0)
|
||||
available_kb = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
|
||||
used_kb = total_kb - available_kb
|
||||
|
||||
total_gb = round(total_kb / (1024 * 1024), 1)
|
||||
used_gb = round(used_kb / (1024 * 1024), 1)
|
||||
ram_percent = round((used_kb / total_kb) * 100, 1) if total_kb > 0 else 0
|
||||
else:
|
||||
total_gb = 0
|
||||
used_gb = 0
|
||||
ram_percent = 0
|
||||
|
||||
return {
|
||||
"openclaw": {"running": True, "status": "running"},
|
||||
"docker": {"running": True, "status": "running"},
|
||||
"cpu": {
|
||||
"percent": cpu_percent,
|
||||
"cores": cpu_cores,
|
||||
"load1": round(load1, 2) if loadavg_str else 0
|
||||
},
|
||||
"ram": {
|
||||
"percent": ram_percent,
|
||||
"used_gb": used_gb,
|
||||
"total_gb": total_gb
|
||||
},
|
||||
"briefing_version": "1.2.0-live",
|
||||
"cached": False
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"System status error: {e}")
|
||||
import traceback
|
||||
print(traceback.format_exc())
|
||||
return {
|
||||
"openclaw": {"running": True, "status": "running"},
|
||||
"docker": {"running": True, "status": "running"},
|
||||
"cpu": {"percent": 0, "cores": 2, "load1": 0},
|
||||
"ram": {"percent": 0, "used_gb": 0, "total_gb": 0},
|
||||
"error": str(e),
|
||||
"briefing_version": "1.2.0-live"
|
||||
}
|
||||
|
||||
async def get_system_status() -> dict:
|
||||
"""Get real system status with CPU/RAM"""
|
||||
cached = cache.get("system")
|
||||
if cached:
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
|
||||
# Run synchronous psutil operations in thread pool
|
||||
result = await asyncio.to_thread(get_system_status_sync)
|
||||
cache.set("system", result)
|
||||
return result
|
||||
|
||||
# Chat WebSocket connections
|
||||
class ChatConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: list[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_to_client(self, websocket: WebSocket, message: dict):
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
except:
|
||||
pass
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
for connection in self.active_connections.copy():
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except:
|
||||
pass
|
||||
|
||||
chat_manager = ChatConnectionManager()
|
||||
pending_chat_responses: Dict[str, Any] = {}
|
||||
|
||||
|
||||
@app.websocket("/ws/chat")
|
||||
async def chat_websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket for real-time chat"""
|
||||
await chat_manager.connect(websocket)
|
||||
try:
|
||||
# Send chat history
|
||||
await websocket.send_json({"type": "history", "messages": chat_messages})
|
||||
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
|
||||
if data.get("type") == "message":
|
||||
user_msg = data.get("content", "")
|
||||
|
||||
# Store user message
|
||||
msg_entry = {
|
||||
"id": str(int(time.time() * 1000)),
|
||||
"role": "user",
|
||||
"content": user_msg,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
chat_messages.append(msg_entry)
|
||||
|
||||
# Keep only last N messages
|
||||
if len(chat_messages) > MAX_CHAT_HISTORY:
|
||||
chat_messages.pop(0)
|
||||
|
||||
# Broadcast to all connected clients
|
||||
await chat_manager.broadcast({"type": "message", "message": msg_entry})
|
||||
|
||||
# Forward to OpenClaw Gateway
|
||||
asyncio.create_task(forward_to_openclaw(msg_entry["id"], user_msg))
|
||||
|
||||
except WebSocketDisconnect:
|
||||
chat_manager.disconnect(websocket)
|
||||
except Exception as e:
|
||||
print(f"Chat WebSocket error: {e}")
|
||||
chat_manager.disconnect(websocket)
|
||||
|
||||
|
||||
async def forward_to_openclaw(msg_id: str, message: str):
|
||||
"""Forward message to OpenClaw Gateway and/or Discord"""
|
||||
gateway_url = os.getenv("OPENCLAW_GATEWAY_URL", "http://host.docker.internal:18789")
|
||||
discord_sent = False
|
||||
openclaw_sent = False
|
||||
|
||||
# Try OpenClaw Gateway first
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
# Option 1: Try OpenClaw Gateway API
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{gateway_url}/api/inject",
|
||||
json={
|
||||
"text": message,
|
||||
"source": "dashboard",
|
||||
"reply_to": f"dashboard:{msg_id}"
|
||||
},
|
||||
timeout=60.0
|
||||
)
|
||||
if response.status_code == 200:
|
||||
openclaw_sent = True
|
||||
except Exception as e:
|
||||
print(f"OpenClaw inject failed: {e}")
|
||||
except Exception as e:
|
||||
print(f"OpenClaw connection failed: {e}")
|
||||
|
||||
# Send to Discord as backup/alternative
|
||||
if DISCORD_AVAILABLE:
|
||||
try:
|
||||
discord_sent = await send_to_discord(message, "Dashboard", msg_id)
|
||||
if discord_sent:
|
||||
# Register callback for Discord response
|
||||
async def on_discord_response(content: str):
|
||||
await add_assistant_response(msg_id, content)
|
||||
register_callback(msg_id, on_discord_response)
|
||||
except Exception as e:
|
||||
print(f"Discord send failed: {e}")
|
||||
|
||||
# If neither worked, show error
|
||||
if not openclaw_sent and not discord_sent:
|
||||
await add_assistant_response(msg_id, "⚠️ Konnte keine Verbindung herstellen. Bitte versuch es später nochmal.")
|
||||
elif discord_sent and not openclaw_sent:
|
||||
# Discord works but OpenClaw doesn't - show pending message
|
||||
pending_chat_responses[msg_id] = {
|
||||
"status": "pending_discord",
|
||||
"message": message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
async def add_assistant_response(reply_to_id: str, content: str):
|
||||
"""Add assistant response to chat history"""
|
||||
msg_entry = {
|
||||
"id": str(int(time.time() * 1000)),
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"reply_to": reply_to_id,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
chat_messages.append(msg_entry)
|
||||
|
||||
# Keep only last N messages
|
||||
if len(chat_messages) > MAX_CHAT_HISTORY:
|
||||
chat_messages.pop(0)
|
||||
|
||||
# Remove from pending
|
||||
if reply_to_id in pending_chat_responses:
|
||||
del pending_chat_responses[reply_to_id]
|
||||
|
||||
# Broadcast to all connected clients
|
||||
await chat_manager.broadcast({"type": "message", "message": msg_entry})
|
||||
|
||||
|
||||
@app.post("/api/chat/webhook")
|
||||
async def chat_webhook(request: Request):
|
||||
"""Webhook for OpenClaw to send responses back"""
|
||||
data = await request.json()
|
||||
|
||||
reply_to = data.get("reply_to", "")
|
||||
content = data.get("text", "")
|
||||
|
||||
if reply_to.startswith("dashboard:"):
|
||||
msg_id = reply_to.replace("dashboard:", "")
|
||||
await add_assistant_response(msg_id, content)
|
||||
return {"status": "ok"}
|
||||
|
||||
# General message without reply_to
|
||||
await add_assistant_response("general", content)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def api_chat(msg: ChatMessage):
|
||||
"""HTTP fallback for chat messages"""
|
||||
msg_id = str(int(time.time() * 1000))
|
||||
|
||||
# Store user message
|
||||
chat_messages.append({
|
||||
"id": msg_id,
|
||||
"role": "user",
|
||||
"content": msg.message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# Keep only last N messages
|
||||
if len(chat_messages) > MAX_CHAT_HISTORY:
|
||||
chat_messages.pop(0)
|
||||
|
||||
# Forward to OpenClaw (non-blocking)
|
||||
asyncio.create_task(forward_to_openclaw(msg_id, msg.message))
|
||||
|
||||
return {"status": "accepted", "message_id": msg_id}
|
||||
|
||||
|
||||
@app.get("/api/chat/history")
|
||||
async def api_chat_history():
|
||||
"""Get chat history"""
|
||||
return {"messages": chat_messages}
|
||||
|
||||
|
||||
@app.get("/api/chat/pending/{msg_id}")
|
||||
async def api_chat_pending(msg_id: str):
|
||||
"""Check if a response is pending"""
|
||||
if msg_id in pending_chat_responses:
|
||||
return {"status": "pending"}
|
||||
# Check if response exists in history
|
||||
for msg in chat_messages:
|
||||
if msg.get("reply_to") == msg_id:
|
||||
return {"status": "completed", "message": msg}
|
||||
return {"status": "not_found"}
|
||||
|
||||
|
||||
# Background task to broadcast updates
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(broadcast_updates())
|
||||
# Start Discord polling if available
|
||||
if DISCORD_AVAILABLE:
|
||||
asyncio.create_task(start_discord_polling())
|
||||
|
||||
async def start_discord_polling():
|
||||
"""Start polling for Discord responses"""
|
||||
try:
|
||||
await poll_discord_responses(lambda msg_id, content: None, interval=5)
|
||||
except Exception as e:
|
||||
print(f"Discord polling failed: {e}")
|
||||
|
||||
async def broadcast_updates():
|
||||
"""Broadcast updates every 30 seconds"""
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
if manager.active_connections:
|
||||
fresh_data = await api_all()
|
||||
await manager.broadcast(fresh_data)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||
Loading…
Add table
Add a link
Reference in a new issue