from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlmodel import Session from ..core.config import settings from .policy_engine import evaluate_policy VOICE_COMMANDS: list[dict[str, Any]] = [ { "id": "unraid.health", "label": "Check Unraid", "spokenPhraseNl": "controleer unraid", "spokenPhraseEn": "check unraid", "category": "Infrastructure", "endpoint": "/api/voice/actions/unraid/health", "method": "POST", "payload": {}, "action": "unraid.health_summary", "target": "unraid", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_unraid", "homeAssistantScriptName": "appops_check_unraid", "description": "Read-only Unraid health summary through the voice policy path.", "safetyNotes": ["Read-only", "No mover, disk or array mutation"], }, { "id": "appops.setup.check", "label": "Check AppOps setup", "spokenPhraseNl": "controleer appops setup", "spokenPhraseEn": "check appops setup", "category": "Gateway", "endpoint": "/api/voice/actions/setup/status", "method": "POST", "payload": {}, "action": "setup.status", "target": "appops", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_setup", "homeAssistantScriptName": "appops_check_setup", "description": "Read-only setup readiness check for the AppOps Gateway.", "safetyNotes": ["Read-only", "Reports setup state only"], }, { "id": "safety.summary", "label": "Check Safety", "spokenPhraseNl": "controleer veiligheid", "spokenPhraseEn": "check safety", "category": "Safety", "endpoint": "/api/voice/actions/safety/summary", "method": "POST", "payload": {}, "action": "safety.summary", "target": "safety", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_safety", "homeAssistantScriptName": "appops_check_safety", "description": "Read-only Safety Center summary.", "safetyNotes": ["Read-only", "No remediation execution"], }, { "id": "media.pipeline.check", "label": "Check Media Pipeline", "spokenPhraseNl": "controleer media pipeline", "spokenPhraseEn": "check media pipeline", "category": "Media", "endpoint": "/api/voice/actions/media-pipeline/check", "method": "POST", "payload": {}, "action": "media_pipeline.check", "target": "media-pipeline", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_media_pipeline", "homeAssistantScriptName": "appops_check_media_pipeline", "description": "Runs the read-only media pipeline diagnostic check path.", "safetyNotes": ["Read-only connector summaries", "No media add/delete actions"], }, { "id": "apps.widefrog.health", "label": "Check Widefrog", "spokenPhraseNl": "controleer widefrog", "spokenPhraseEn": "check widefrog", "category": "Apps", "endpoint": "/api/voice/actions/apps/widefrog/health", "method": "POST", "payload": {}, "action": "widefrog.health", "target": "widefrog", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_widefrog", "homeAssistantScriptName": "appops_check_widefrog", "description": "Read-only Widefrog connector health.", "safetyNotes": ["Read-only", "No retry or scan"], }, { "id": "apps.deluge.status", "label": "Check Downloads", "spokenPhraseNl": "controleer downloads", "spokenPhraseEn": "check downloads", "category": "Downloads", "endpoint": "/api/voice/actions/apps/deluge/status", "method": "POST", "payload": {}, "action": "deluge.session_status", "target": "deluge", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_downloads", "homeAssistantScriptName": "appops_check_downloads", "description": "Read-only Deluge/download status summary.", "safetyNotes": ["Read-only", "No pause, resume or delete-with-data"], }, { "id": "apps.plex.activity", "label": "Check Plex Activity", "spokenPhraseNl": "controleer plex activiteit", "spokenPhraseEn": "check plex activity", "category": "Media", "endpoint": "/api/voice/actions/apps/plex/activity", "method": "POST", "payload": {}, "action": "plex.active_sessions", "target": "plex", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_plex_activity", "homeAssistantScriptName": "appops_check_plex_activity", "description": "Read-only Plex active stream summary.", "safetyNotes": ["Read-only", "No library scan or session kill"], }, { "id": "notifications.summary", "label": "Check Notifications", "spokenPhraseNl": "controleer notificaties", "spokenPhraseEn": "check notifications", "category": "Notifications", "endpoint": "/api/voice/actions/notifications/summary", "method": "POST", "payload": {}, "action": "notifications.summary", "target": "notifications", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_notifications", "homeAssistantScriptName": "appops_check_notifications", "description": "Read-only notification rule/channel summary.", "safetyNotes": ["Read-only", "Does not send Home Assistant notifications"], }, { "id": "unraid.storage", "label": "Check Storage", "spokenPhraseNl": "controleer opslag", "spokenPhraseEn": "check storage", "category": "Infrastructure", "endpoint": "/api/voice/actions/unraid/storage", "method": "POST", "payload": {}, "action": "unraid.health_summary", "target": "unraid", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_storage", "homeAssistantScriptName": "appops_check_storage", "description": "Returns live disk, cache and parity status without changing storage.", "safetyNotes": ["Read-only", "No mover, format or array action"], }, { "id": "unraid.vms", "label": "Check Virtual Machines", "spokenPhraseNl": "controleer virtuele machines", "spokenPhraseEn": "check virtual machines", "category": "Infrastructure", "endpoint": "/api/voice/actions/unraid/vms", "method": "POST", "payload": {}, "action": "unraid.health_summary", "target": "unraid", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_vms", "homeAssistantScriptName": "appops_check_vms", "description": "Returns the VM inventory and power states visible to the Unraid API.", "safetyNotes": ["Read-only", "Does not start or stop a VM"], }, { "id": "apps.sonarr.health", "label": "Check Sonarr", "spokenPhraseNl": "controleer sonarr", "spokenPhraseEn": "check sonarr", "category": "Media", "endpoint": "/api/voice/actions/apps/sonarr/health", "method": "POST", "payload": {}, "action": "sonarr.health", "target": "sonarr", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_sonarr", "homeAssistantScriptName": "appops_check_sonarr", "description": "Returns Sonarr health without modifying series.", "safetyNotes": ["Read-only"], }, { "id": "apps.sonarr.queue", "label": "Check Sonarr Queue", "spokenPhraseNl": "controleer sonarr wachtrij", "spokenPhraseEn": "check sonarr queue", "category": "Media", "endpoint": "/api/voice/actions/apps/sonarr/queue", "method": "POST", "payload": {}, "action": "sonarr.queue_status", "target": "sonarr", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_sonarr_queue", "homeAssistantScriptName": "appops_check_sonarr_queue", "description": "Returns the current Sonarr queue summary.", "safetyNotes": ["Read-only"], }, { "id": "apps.radarr.health", "label": "Check Radarr", "spokenPhraseNl": "controleer radarr", "spokenPhraseEn": "check radarr", "category": "Media", "endpoint": "/api/voice/actions/apps/radarr/health", "method": "POST", "payload": {}, "action": "radarr.health", "target": "radarr", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_radarr", "homeAssistantScriptName": "appops_check_radarr", "description": "Returns Radarr health without modifying movies.", "safetyNotes": ["Read-only"], }, { "id": "apps.radarr.queue", "label": "Check Radarr Queue", "spokenPhraseNl": "controleer radarr wachtrij", "spokenPhraseEn": "check radarr queue", "category": "Media", "endpoint": "/api/voice/actions/apps/radarr/queue", "method": "POST", "payload": {}, "action": "radarr.queue_status", "target": "radarr", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_radarr_queue", "homeAssistantScriptName": "appops_check_radarr_queue", "description": "Returns the current Radarr queue summary.", "safetyNotes": ["Read-only"], }, { "id": "apps.prowlarr.indexers", "label": "Check Prowlarr Indexers", "spokenPhraseNl": "controleer prowlarr indexers", "spokenPhraseEn": "check prowlarr indexers", "category": "Media", "endpoint": "/api/voice/actions/apps/prowlarr/indexers", "method": "POST", "payload": {}, "action": "prowlarr.indexer_health", "target": "prowlarr", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_prowlarr", "homeAssistantScriptName": "appops_check_prowlarr", "description": "Returns Prowlarr indexer health and failures.", "safetyNotes": ["Read-only"], }, { "id": "apps.deluge.stalled", "label": "Check Stalled Downloads", "spokenPhraseNl": "controleer vastgelopen downloads", "spokenPhraseEn": "check stalled downloads", "category": "Downloads", "endpoint": "/api/voice/actions/apps/deluge/stalled", "method": "POST", "payload": {}, "action": "deluge.stalled_downloads", "target": "deluge", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_stalled_downloads", "homeAssistantScriptName": "appops_check_stalled_downloads", "description": "Returns stalled Deluge downloads without changing them.", "safetyNotes": ["Read-only", "No delete-with-data"], }, { "id": "apps.plex.recent", "label": "Check Recent Plex Media", "spokenPhraseNl": "wat is recent toegevoegd aan plex", "spokenPhraseEn": "check recent plex media", "category": "Media", "endpoint": "/api/voice/actions/apps/plex/recent", "method": "POST", "payload": {}, "action": "plex.recent_media", "target": "plex", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_plex_recent", "homeAssistantScriptName": "appops_check_plex_recent", "description": "Returns recently added Plex media.", "safetyNotes": ["Read-only"], }, { "id": "apps.tautulli.activity", "label": "Check Tautulli Activity", "spokenPhraseNl": "controleer tautulli activiteit", "spokenPhraseEn": "check tautulli activity", "category": "Media", "endpoint": "/api/voice/actions/apps/tautulli/activity", "method": "POST", "payload": {}, "action": "tautulli.activity", "target": "tautulli", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_tautulli", "homeAssistantScriptName": "appops_check_tautulli", "description": "Returns current Tautulli stream and user activity.", "safetyNotes": ["Read-only"], }, { "id": "security.exposure", "label": "Check Public Exposure", "spokenPhraseNl": "controleer publieke blootstelling", "spokenPhraseEn": "check public exposure", "category": "Safety", "endpoint": "/api/voice/actions/security/exposure", "method": "POST", "payload": {}, "action": "npm.exposure_summary", "target": "nginx_proxy_manager", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_exposure", "homeAssistantScriptName": "appops_check_exposure", "description": "Returns Nginx Proxy Manager exposure and certificate posture.", "safetyNotes": ["Read-only", "No proxy mutation"], }, { "id": "security.authentik", "label": "Check Authentik", "spokenPhraseNl": "controleer authentik", "spokenPhraseEn": "check authentik", "category": "Safety", "endpoint": "/api/voice/actions/security/authentik", "method": "POST", "payload": {}, "action": "authentik.health", "target": "authentik", "risk": "low", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_check_authentik", "homeAssistantScriptName": "appops_check_authentik", "description": "Returns Authentik health without changing identity configuration.", "safetyNotes": ["Read-only", "No identity mutation"], }, { "id": "container.widefrog.restart", "label": "Restart Widefrog", "spokenPhraseNl": "herstart widefrog", "spokenPhraseEn": "restart widefrog", "category": "Apps", "endpoint": "/api/voice/actions/container/widefrog/restart", "method": "POST", "payload": {}, "action": "container.restart", "target": "widefrog", "risk": "medium", "enabledByDefault": True, "homeAssistantRestCommandName": "appops_restart_widefrog", "homeAssistantScriptName": "appops_restart_widefrog", "description": "Policy-aware Widefrog container restart.", "safetyNotes": ["Only exported while policy allows voice restart", "Audited", "No stack redeploy"], }, { "id": "playbooks.fix-widefrog.run", "label": "Run Fix Widefrog Playbook", "spokenPhraseNl": "voer widefrog herstel uit", "spokenPhraseEn": "run fix widefrog playbook", "category": "Playbooks", "endpoint": "/api/voice/actions/playbooks/fix-widefrog/run", "method": "POST", "payload": {}, "action": "playbook.run", "target": "fix-widefrog", "risk": "medium", "enabledByDefault": False, "homeAssistantRestCommandName": "appops_run_fix_widefrog_playbook", "homeAssistantScriptName": "appops_run_fix_widefrog_playbook", "description": "Requests policy evaluation for the Widefrog recovery playbook.", "safetyNotes": ["Approval may be required", "No direct connector call before policy"], }, { "id": "notifications.evaluate.safety", "label": "Evaluate Safety Notifications", "spokenPhraseNl": "evalueer veiligheidsnotificaties", "spokenPhraseEn": "evaluate safety notifications", "category": "Notifications", "endpoint": "/api/voice/actions/notifications/evaluate/safety", "method": "POST", "payload": {}, "action": "notifications.evaluate_safety", "target": "notifications", "risk": "medium", "enabledByDefault": False, "homeAssistantRestCommandName": "appops_evaluate_safety_notifications", "homeAssistantScriptName": "appops_evaluate_safety_notifications", "description": "Policy-aware safety notification evaluation.", "safetyNotes": ["May create notification events", "Does not expose HOME_ASSISTANT_TOKEN"], }, ] BLOCKED_VOICE_EXAMPLES = [ { "id": "container.authentik.restart", "label": "Restart Authentik direct command", "action": "container.restart", "target": "authentik", "reason": "Critical identity service restart is not exported as direct voice command.", }, { "id": "bulk-download-pause", "label": "Pause all downloads", "target": "deluge", "reason": "Bulk download actions require explicit operator control.", }, { "id": "stack-redeploy", "label": "Stack redeploy", "target": "stack", "reason": "Redeploy changes runtime topology and is not exported to voice.", }, { "id": "unraid-mover-start", "label": "Unraid mover", "target": "unraid", "reason": "High-impact storage maintenance is blocked for voice.", }, { "id": "volume-delete", "label": "Delete/prune/volume", "target": "volume", "reason": "Data destructive operations are blocked.", }, ] def list_commands(session: Session) -> list[dict[str, Any]]: exported_at = datetime.now(UTC).isoformat() commands = [] for command in VOICE_COMMANDS: policy = evaluate_policy(session, action=command["action"], target=command["target"], source="voice") decision = str(policy["decision"]) if decision == "blocked": continue commands.append( { **_public_command(command), "policyDecision": decision, "requiresApproval": decision == "approval_required", "lastExportedAt": exported_at, } ) return commands def command_by_id(session: Session, command_id: str) -> dict[str, Any] | None: return next((command for command in list_commands(session) if command["id"] == command_id), None) def test_intent(session: Session, text: str) -> dict[str, Any]: normalized = " ".join(text.lower().strip().split()) if not normalized: return {"status": "unknown", "executed": False, "message": "No spoken phrase supplied."} for command in list_commands(session): phrases = [ str(command.get("spokenPhraseNl", "")).lower(), str(command.get("spokenPhraseEn", "")).lower(), str(command.get("label", "")).lower(), ] if normalized in phrases or any(phrase and phrase in normalized for phrase in phrases): return dry_run(session, command["id"]) return {"status": "unknown", "executed": False, "message": "No allowlisted voice command matched."} def dry_run(session: Session, command_id: str) -> dict[str, Any]: command = command_by_id(session, command_id) if not command: return {"status": "unknown", "executed": False, "message": "Voice command is not allowlisted or is blocked."} policy = evaluate_policy(session, action=command["action"], target=command["target"], source="voice") return { "status": "matched", "executed": False, "command": command, "policyPreview": { "decision": policy["decision"], "reason": policy["reason"], "risk": policy.get("risk", command["risk"]), }, "request": { "method": command["method"], "endpoint": command["endpoint"], "payload": command["payload"], }, } def request_approval(session: Session, command_id: str) -> dict[str, Any]: command = command_by_id(session, command_id) if not command: return {"success": False, "status": "unknown", "message": "Voice command is not allowlisted or is blocked."} policy = evaluate_policy(session, action=command["action"], target=command["target"], source="voice") if policy["decision"] == "allowed": return {"success": False, "status": "not_required", "message": "Command is allowed and does not need approval.", "command": command} if policy["decision"] == "blocked": return {"success": False, "status": "blocked", "message": policy["reason"], "command": command} from .approval_service import create_approval approval = create_approval( session, action=command["action"], target=command["target"], source="voice", risk=str(policy.get("risk", command["risk"])), reason=policy["reason"], requester="voice", ) return {"success": True, "status": "approval_requested", "approvalId": approval.id, "command": command} def home_assistant_export(session: Session, base_url: str = 'http://appops:1216') -> dict[str, Any]: commands = list_commands(session) normalized_base_url = base_url.rstrip('/') return { "yaml": "\n\n".join([rest_commands_yaml(commands, normalized_base_url), scripts_yaml(commands), dashboard_yaml(commands), secrets_yaml()]), "restCommandsYaml": rest_commands_yaml(commands, normalized_base_url), "scriptsYaml": scripts_yaml(commands), "dashboardYaml": dashboard_yaml(commands), "secretsYaml": secrets_yaml(), "commands": commands, "blocked": BLOCKED_VOICE_EXAMPLES, "endpoints": [ { "name": command["label"], "path": command["endpoint"], "method": command["method"], "policyDecision": command["policyDecision"], "requiresApproval": command["requiresApproval"], } for command in commands ], "token": { "name": "APP_OPS_VOICE_TOKEN", "status": "required", "homeAssistantSecret": "appops_voice_authorization", "note": "HOME_ASSISTANT_TOKEN is only for AppOps-to-Home-Assistant notification delivery.", }, "publicBaseUrl": normalized_base_url, "homeAssistantUrl": str(settings.apis.get('HOME_ASSISTANT_URL') or '').rstrip('/'), "setupGuide": [ {"id": "token", "title": "Bewaar de AppOps bearer header", "detail": "Plaats 'Bearer ' plus APP_OPS_VOICE_TOKEN als appops_voice_authorization in Home Assistant secrets.yaml."}, {"id": "rest", "title": "Installeer rest_command", "detail": "Voeg het rest_command-blok toe aan configuration.yaml of een Home Assistant package."}, {"id": "scripts", "title": "Installeer de scripts", "detail": "Voeg het script-blok toe en herlaad scripts of herstart Home Assistant."}, {"id": "test", "title": "Test eerst read-only", "detail": "Voer Check AppOps setup handmatig uit en controleer AppOps Audit."}, {"id": "gemini", "title": "Koppel Gemini aan Assist", "detail": "Configureer Google Gemini Conversation met Control Home Assistant ingeschakeld en exposeer alleen de gewenste AppOps-scripts aan Assist."}, {"id": "phone", "title": "Gebruik de Home Assistant Assist-pipeline", "detail": "Selecteer de Gemini conversation agent in je Assist-pipeline en start die pipeline vanuit de Home Assistant Companion-app op je telefoon."}, ], } def rest_commands_yaml(commands: list[dict[str, Any]], base_url: str = 'http://appops:1216') -> str: lines = ["rest_command:"] for command in commands: lines.extend( [ f" {command['homeAssistantRestCommandName']}:", f" url: \"{base_url.rstrip('/')}{command['endpoint']}\"", f" method: {command['method'].lower()}", " headers:", " Authorization: !secret appops_voice_authorization", " Content-Type: \"application/json\"", " payload: '{}'", ] ) return "\n".join(lines) def scripts_yaml(commands: list[dict[str, Any]]) -> str: lines = ["script:"] for command in commands: lines.extend( [ f" {command['homeAssistantScriptName']}:", f" alias: \"{command['label']}\"", f" description: \"{command['description']} Tell the user the returned AppOps status and never claim execution beyond the response.\"", " mode: single", " sequence:", " - action: rest_command." + command["homeAssistantRestCommandName"], " response_variable: appops_result", " - variables:", " appops_response:", " status: \"{{ appops_result.status | default(0) }}\"", " content: \"{{ appops_result.content | default('{}') }}\"", " - stop: \"AppOps request completed\"", " response_variable: appops_response", ] ) return "\n".join(lines) def dashboard_yaml(commands: list[dict[str, Any]]) -> str: lines = [ "# Lovelace dashboard snippet", "type: entities", "title: AppOps Voice Commands", "entities:", ] for command in commands: lines.append(f" - entity: script.{command['homeAssistantScriptName']}") lines.append(f" name: \"{command['label']}\"") return "\n".join(lines) def secrets_yaml() -> str: return 'secrets.yaml:\n appops_voice_authorization: "Bearer PASTE_APP_OPS_VOICE_TOKEN_HERE"' def _public_command(command: dict[str, Any]) -> dict[str, Any]: public = {key: value for key, value in command.items() if key not in {"action", "target"}} public["action"] = command["action"] public["target"] = command["target"] return public