Files
Unraid-AppOps-Gateway-Public/backend/app/services/voice_catalog.py
T

583 lines
26 KiB
Python

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