Files
Unraid-AppOps-Gateway-Public/backend/app/api/routes_voice.py
T

389 lines
14 KiB
Python

from __future__ import annotations
from typing import Any, Callable
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from ..connectors.authentik import AuthentikConnector
from ..connectors.deluge import DelugeConnector
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.plex import PlexConnector
from ..connectors.prowlarr import ProwlarrConnector
from ..connectors.radarr import RadarrConnector
from ..connectors.sonarr import SonarrConnector
from ..connectors.tautulli import TautulliConnector
from ..connectors.unraid import UnraidConnector
from ..connectors.widefrog import WidefrogConnector
from ..core.redaction import redact_message
from ..db.session import get_session
from ..services.approval_service import create_approval
from ..services.audit_logger import log_event
from ..services.container_restart_service import execute_container_restart
from ..services.policy_engine import evaluate_policy
from ..services import media_pipeline_service, notification_service, safety_center_service
from ..services.setup_service import apply_stored_connector_settings, get_setup_status
from ..services import voice_catalog
router = APIRouter(prefix='/api/voice')
def _handle_voice_action(
session: Session,
action: str,
target: str,
risk: str,
steps: list[str] | None = None,
reader: Callable[[], Any] | None = None,
):
policy = evaluate_policy(session=session, action=action, target=target, source='voice')
decision = policy['decision']
risk_level = policy.get('risk', risk)
normalized_steps = steps or []
if decision == 'blocked':
audit = log_event(
session,
source='voice',
identity='voice',
action_id=action,
target=target,
risk=risk_level,
policy_decision=decision,
reason=policy['reason'],
result='blocked',
execution_plan={'plan': normalized_steps, 'policyReason': policy['reason']},
redacted_metadata={'policyId': policy.get('policy_id')},
)
return {
'success': False,
'decision': 'blocked',
'status': 'failed',
'policy_decision': decision,
'result': 'blocked',
'message': policy['reason'],
'auditId': audit.id,
}
if decision == 'approval_required':
approval = create_approval(
session,
action=action,
target=target,
source='voice',
risk=risk_level,
reason=policy['reason'],
requester='voice',
)
audit = log_event(
session,
source='voice',
identity='voice',
action_id=action,
target=target,
risk=risk_level,
policy_decision=decision,
reason=policy['reason'],
result='approval_requested',
execution_plan={'plan': normalized_steps, 'approvalId': approval.id},
redacted_metadata={'approval_id': approval.id},
)
return {
'success': False,
'decision': 'approval_required',
'status': 'failed',
'policy_decision': decision,
'result': 'approval_requested',
'message': 'Action requires approval before execution.',
'approvalId': approval.id,
'auditId': audit.id,
}
if decision == 'allowed':
try:
data = reader() if reader else None
except Exception as exc: # noqa: BLE001 - voice must return a governed, redacted failure.
message = redact_message(str(exc))
audit = log_event(
session, source='voice', identity='voice', action_id=action, target=target,
risk=risk_level, policy_decision=decision, reason=policy['reason'], result='failed',
execution_plan={'plan': normalized_steps}, redacted_metadata={'error': message[:200]},
)
return {
'success': False, 'decision': decision, 'status': 'failed',
'policy_decision': decision, 'result': 'failed', 'message': message, 'auditId': audit.id,
}
audit = log_event(
session,
source='voice',
identity='voice',
action_id=action,
target=target,
risk=risk_level,
policy_decision=decision,
reason=policy['reason'],
result='success',
execution_plan={'plan': normalized_steps, 'policyReason': policy['reason']},
)
response = {
'success': True,
'decision': 'allowed',
'status': 'success',
'policy_decision': decision,
'result': 'success',
'message': f'Voice action {action} allowed for {target}.',
'auditId': audit.id,
}
if data is not None:
response['data'] = data
return response
return {
'success': False,
'decision': 'blocked',
'status': 'failed',
'policy_decision': 'blocked',
'result': 'blocked',
'message': 'Action policy outcome unsupported.',
}
@router.get('/commands')
def voice_commands(session: Session = Depends(get_session)):
return voice_catalog.list_commands(session)
@router.get('/home-assistant/export')
def voice_export(request: Request, session: Session = Depends(get_session)):
apply_stored_connector_settings(session)
return voice_catalog.home_assistant_export(session, str(request.base_url).rstrip('/'))
@router.get('/home-assistant/export/rest-commands')
def voice_export_rest_commands(request: Request, session: Session = Depends(get_session)):
return {'yaml': voice_catalog.rest_commands_yaml(voice_catalog.list_commands(session), str(request.base_url).rstrip('/'))}
@router.get('/home-assistant/export/scripts')
def voice_export_scripts(session: Session = Depends(get_session)):
return {'yaml': voice_catalog.scripts_yaml(voice_catalog.list_commands(session))}
@router.get('/home-assistant/export/dashboard')
def voice_export_dashboard(session: Session = Depends(get_session)):
return {'yaml': voice_catalog.dashboard_yaml(voice_catalog.list_commands(session))}
@router.post('/test-intent')
def test_intent(payload: dict | None = None, session: Session = Depends(get_session)):
text = str((payload or {}).get('input') or (payload or {}).get('phrase') or '').strip()
return voice_catalog.test_intent(session, text)
@router.post('/commands/{command_id}/dry-run')
def dry_run_command(command_id: str, session: Session = Depends(get_session)):
return voice_catalog.dry_run(session, command_id)
@router.post('/commands/{command_id}/request-approval')
def request_command_approval(command_id: str, session: Session = Depends(get_session)):
return voice_catalog.request_approval(session, command_id)
@router.post('/actions/container/{app_id}/restart')
def restart_container(app_id: str, session: Session = Depends(get_session)):
return execute_container_restart(
session=session,
action='container.restart',
target=app_id,
source='voice',
risk='medium',
steps=['Resolve container', 'Restart container', 'Voice post-check'],
)
@router.post('/actions/unraid/health')
def unraid_health(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='unraid.health_summary',
target='unraid',
risk='low',
steps=['Read unraid health summary'],
reader=lambda: {
'array': UnraidConnector().get_array_status(),
'metrics': UnraidConnector().get_system_metrics(),
'docker': UnraidConnector().get_docker_service_status(),
},
)
@router.post('/actions/setup/status')
def setup_status(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='setup.status',
target='appops',
risk='low',
steps=['Read AppOps setup status'],
reader=lambda: get_setup_status(session),
)
@router.post('/actions/safety/summary')
def safety_summary(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='safety.summary',
target='safety',
risk='low',
steps=['Read Safety Center summary'],
reader=lambda: safety_center_service.get_summary(session),
)
@router.post('/actions/media-pipeline/check')
def media_pipeline_check(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='media_pipeline.check',
target='media-pipeline',
risk='low',
steps=['Run read-only media pipeline check'],
reader=lambda: media_pipeline_service.summary(session),
)
@router.post('/actions/notifications/summary')
def notifications_summary(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='notifications.summary',
target='notifications',
risk='low',
steps=['Read notification summary'],
reader=lambda: notification_service.summary(session),
)
@router.post('/actions/notifications/evaluate/safety')
def evaluate_safety_notifications(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='notifications.evaluate_safety',
target='notifications',
risk='medium',
steps=['Evaluate safety notification rules'],
)
@router.post('/actions/apps/widefrog/health')
def widefrog_health(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='widefrog.health',
target='widefrog',
risk='low',
steps=['Read Widefrog health'],
reader=lambda: WidefrogConnector().health(),
)
@router.post('/actions/apps/deluge/status')
def deluge_status(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='deluge.session_status',
target='deluge',
risk='low',
steps=['Read Deluge session status'],
reader=lambda: DelugeConnector().session_status(),
)
@router.post('/actions/apps/plex/activity')
def plex_activity(session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='plex.active_sessions',
target='plex',
risk='low',
steps=['Read Plex active sessions'],
reader=lambda: PlexConnector().active_sessions(),
)
@router.post('/actions/unraid/storage')
def unraid_storage(session: Session = Depends(get_session)):
connector = UnraidConnector()
return _handle_voice_action(
session, 'unraid.health_summary', 'unraid', 'low', ['Read storage health'],
reader=lambda: {'disks': connector.get_disk_warnings(), 'cache': connector.get_cache_status(), 'parity': connector.get_parity_status()},
)
@router.post('/actions/unraid/vms')
def unraid_vms(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'unraid.health_summary', 'unraid', 'low', ['Read VM inventory'], reader=lambda: UnraidConnector().get_vm_summary())
@router.post('/actions/apps/sonarr/health')
def sonarr_health(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'sonarr.health', 'sonarr', 'low', ['Read Sonarr health'], reader=lambda: SonarrConnector().health())
@router.post('/actions/apps/sonarr/queue')
def sonarr_queue(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'sonarr.queue_status', 'sonarr', 'low', ['Read Sonarr queue'], reader=lambda: SonarrConnector().queue_status())
@router.post('/actions/apps/radarr/health')
def radarr_health(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'radarr.health', 'radarr', 'low', ['Read Radarr health'], reader=lambda: RadarrConnector().health())
@router.post('/actions/apps/radarr/queue')
def radarr_queue(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'radarr.queue_status', 'radarr', 'low', ['Read Radarr queue'], reader=lambda: RadarrConnector().queue_status())
@router.post('/actions/apps/prowlarr/indexers')
def prowlarr_indexers(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'prowlarr.indexer_health', 'prowlarr', 'low', ['Read Prowlarr indexer health'], reader=lambda: ProwlarrConnector().indexer_health())
@router.post('/actions/apps/deluge/stalled')
def deluge_stalled(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'deluge.stalled_downloads', 'deluge', 'low', ['Read stalled downloads'], reader=lambda: DelugeConnector().stalled_downloads())
@router.post('/actions/apps/plex/recent')
def plex_recent(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'plex.recent_media', 'plex', 'low', ['Read recent Plex media'], reader=lambda: PlexConnector().recent_media())
@router.post('/actions/apps/tautulli/activity')
def tautulli_activity(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'tautulli.activity', 'tautulli', 'low', ['Read Tautulli activity'], reader=lambda: TautulliConnector().activity())
@router.post('/actions/security/exposure')
def exposure_summary(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'npm.exposure_summary', 'nginx_proxy_manager', 'low', ['Read public exposure'], reader=lambda: NginxProxyManagerConnector().exposure_summary())
@router.post('/actions/security/authentik')
def authentik_health(session: Session = Depends(get_session)):
return _handle_voice_action(session, 'authentik.health', 'authentik', 'low', ['Read Authentik health'], reader=lambda: AuthentikConnector().health())
@router.post('/actions/playbooks/{playbook_id}/run')
def run_playbook(playbook_id: str, session: Session = Depends(get_session)):
return _handle_voice_action(
session=session,
action='playbook.run',
target=playbook_id,
risk='medium',
steps=['Run playbook'],
)