From 70c71105a1c027bc0ce24a2a2689568a69148966 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 13 Feb 2026 00:24:31 +0100 Subject: [PATCH] Initial commit --- Dockerfile | 23 ++ SETUP.md | 45 +++ docker-compose.yml | 28 ++ requirements.txt | 7 + src/discord_bridge.py | 141 +++++++ src/main.py | 778 ++++++++++++++++++++++++++++++++++++++ templates/dashboard.html | 795 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 1817 insertions(+) create mode 100644 Dockerfile create mode 100644 SETUP.md create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 src/discord_bridge.py create mode 100644 src/main.py create mode 100644 templates/dashboard.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e65a1a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY src/ ./src/ +COPY templates/ ./templates/ +COPY static/ ./static/ + +# Environment variables (will be overridden at runtime) +ENV VIKUNJA_URL=http://10.10.10.10:3456/api/v1 +ENV VIKUNJA_TOKEN="" +ENV HA_URL=https://homeassistant.daddelolymp.de +ENV HA_TOKEN="" +ENV WEATHER_LOCATION=Leverkusen + +EXPOSE 8080 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..da462b4 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,45 @@ +# Daily Briefing Dashboard Setup + +## Location +- **Source Code:** `/home/sam/.openclaw/workspace/projects/daily-briefing` +- **Language:** Python (FastAPI) +- **Tech Stack:** Tailwind CSS, WebSocket, Jinja2, Uvicorn, Nginx (in some variants, but currently FastAPI directly). + +## Deployment +The project is containerized using Docker. + +### Files +- **Dockerfile**: Uses `python:3.11-slim`, installs requirements, and runs uvicorn. +- **docker-compose.yml**: Manages the container, ports, and environment variables. + +### Port & Access +- **Port:** 8080 (Mapped from container 8080) +- **URL:** [http://localhost:8080](http://localhost:8080) or [http://10.10.10.198:8080](http://10.10.10.198:8080) + +### Restart Policy +- Set to `always` in `docker-compose.yml`. + +## Management Commands +Since the user 'sam' is not currently in the 'docker' group for the current session, use `sg docker -c "..."` for commands if you hit permission issues, or ensure the group is properly applied. + +**Start/Rebuild:** +```bash +docker-compose up -d --build +``` + +**Stop:** +```bash +docker-compose down +``` + +**Logs:** +```bash +docker-compose logs -f +``` + +## Features +- Weather for Leverkusen/Croatia. +- Home Assistant integration. +- Vikunja task management. +- Live system metrics (CPU/RAM). +- Discord bridge for notifications. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..21704b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.8' + +services: + daily-briefing: + build: . + container_name: daily-briefing + ports: + - "8080:8080" + environment: + - VIKUNJA_URL=http://10.10.10.10:3456/api/v1 + - VIKUNJA_TOKEN=tk_dfaf845721a9fabe0656960ab77fd57cba127f8d + - HA_URL=https://homeassistant.daddelolymp.de + - HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkZmM4M2Q5OTZiMDE0Mzg3YWEzZTMwYzkzYTNhNTRjNiIsImlhdCI6MTc3MDU5Mjk3NiwiZXhwIjoyMDg1OTUyOTc2fQ.fnldrKNQwVdz275-omj93FldpywEpfPQSq8VLcmcyu4 + - WEATHER_LOCATION=Leverkusen + - OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789 + # Discord Integration + - DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1470494144953581652/7g3rq2p-ynwTR9KyUhYwubIZL75NQkOR_xnXOvSsuY72qwUjmsSokfSS3Y0wae2veMem + - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-} + - DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID:-} + extra_hosts: + - "host.docker.internal:host-gateway" + restart: always + networks: + - briefing-network + +networks: + briefing-network: + driver: bridge diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0b32976 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +jinja2==3.1.2 +httpx==0.25.2 +python-multipart==0.0.6 +websockets==12.0 +psutil==5.9.6 diff --git a/src/discord_bridge.py b/src/discord_bridge.py new file mode 100644 index 0000000..9e1e9e9 --- /dev/null +++ b/src/discord_bridge.py @@ -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] diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..25f343e --- /dev/null +++ b/src/main.py @@ -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) diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..9f970b5 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,795 @@ + + + + + + Daily Briefing | Live + + + + + + +
+
+
+
+
+ 🦞 +
+
+

Daily Briefing

+
+ LIVE + +
+
+
+ + + + +
+ +
+
+
+
+ + +
+ + +
+ +
+
+

+ + + + Wetter Leverkusen +

+ {% if weather.cached %}cached{% endif %} +
+ {% if weather.error %} +
{{ weather.error }}
+ {% else %} +
+
+
{{ weather.temp }}°
+
Gefühlt {{ weather.feels_like }}°
+
+
{{ weather.icon }}
+
+
+ {{ weather.description }} + 💧 {{ weather.humidity }}% +
+ + {% if weather.forecast %} +
+
+ {% for day in weather.forecast %} +
+
{{ day.day }}
+
{{ day.icon }}
+
{{ day.temp_max }}°
+
{{ day.temp_min }}°
+
+ {% endfor %} +
+
+ {% endif %} + {% endif %} +
+ + +
+
+

+ + + + Wetter Rab/Banjol 🇭🇷 +

+ {% if weather_secondary.cached %}cached{% endif %} +
+ {% if weather_secondary.error %} +
{{ weather_secondary.error }}
+ {% else %} +
+
+
{{ weather_secondary.temp }}°
+
Gefühlt {{ weather_secondary.feels_like }}°
+
+
{{ weather_secondary.icon }}
+
+
+ {{ weather_secondary.description }} + 💧 {{ weather_secondary.humidity }}% +
+ + {% if weather_secondary.forecast %} +
+
+ {% for day in weather_secondary.forecast %} +
+
{{ day.day }}
+
{{ day.icon }}
+
{{ day.temp_max }}°
+
{{ day.temp_min }}°
+
+ {% endfor %} +
+
+ {% endif %} + {% endif %} +
+
+ + +
+ +
+
+

+ + + + System Status +

+ {% if system_status.cached %}cached{% endif %} +
+ + + + +
+ +
+
+ CPU ({{ system_status.cpu.cores }} cores) + {{ system_status.cpu.percent }}% +
+
+
+
+
+ +
+
+ RAM + + {{ system_status.ram.used_gb }}/{{ system_status.ram.total_gb }} GB ({{ system_status.ram.percent }}%) + +
+
+
+
+
+
+ +
+ v{{ system_status.briefing_version }} +
+
+ + +
+
+

+ + + + Home Assistant +

+ {% if ha_status.cached %}cached{% endif %} +
+ {% if ha_status.online %} +
+
+ Lampen an + {{ ha_status.lights_on }}/{{ ha_status.lights_total }} +
+
+ {% for light in ha_status.lights %} +
+ {{ light.name }} + + {{ "●" if light.state == 'on' else "○" }} + +
+ {% endfor %} +
+ {% if ha_status.covers %} +
+
Rolläden
+ {% for cover in ha_status.covers %} +
+ {{ cover.name }} + {{ cover.state }} +
+ {% endfor %} +
+ {% endif %} +
+ {% else %} +
+ + Offline +
+
{{ ha_status.error }}
+ {% endif %} +
+
+ + +
+
+

+ + + + Private Aufgaben +

+
+
+ {{ vikunja_all.private.open_count }} + offen +
+
+ {{ vikunja_all.private.done_count }} + erledigt +
+ {% if vikunja_all.cached %}cached{% endif %} +
+
+ + +
+ + +
+ + +
+ {% if vikunja_all.private.open %} + {% for task in vikunja_all.private.open %} +
+ +
+ {{ task.title }} +
+ {{ task.project }} + {% if task.priority > 0 %} + ★ {{ task.priority }} + {% endif %} +
+
+
+ {% endfor %} + {% else %} +
Keine offenen Aufgaben 🎉
+ {% endif %} +
+ + + +
+ + +
+
+

+ + + + Sam's Aufgaben +

+
+
+ {{ vikunja_all.sam.open_count }} + offen +
+
+ {{ vikunja_all.sam.done_count }} + erledigt +
+ {% if vikunja_all.cached %}cached{% endif %} +
+
+ + +
+ + +
+ + +
+ {% if vikunja_all.sam.open %} + {% for task in vikunja_all.sam.open %} +
+ +
+ {{ task.title }} +
+ {{ task.project }} + {% if task.priority > 0 %} + ★ {{ task.priority }} + {% endif %} +
+
+
+ {% endfor %} + {% else %} +
Keine offenen Aufgaben 🎉
+ {% endif %} +
+ + + +
+ + +
+
+

+ + + + Chat mit Sam 🤖 +

+
+ + +
+
+ + +
+
+
🤖
+
+ Hey! Ich bin Sam. Schreib mir hier direkt – ich antworte so schnell ich kann. +
+
+
+ + +
+ + +
+
+ + +
+

+ + + + Quick Actions +

+ +
+ +
+ + + +