- New /settings/tools page: max_risk selector (low/medium/high) + per-tool override dropdowns (Default / Force include / Force exclude) for all 58 tools grouped by category with color-coded risk badges; JS updates Auto status live - get_tools_for_role() + get_openai_tools_for_role() now accept max_risk, whitelist, blacklist; _apply_risk_policy() handles the filtering logic - get_risk_policy() helper in auth_utils reads from tool_policy.json - Risk policy wired through orchestrator.py, openai_orchestrator.py, orchestrator_engine.py, nextcloud_talk.py, homeassistant.py - Tools nav link added to settings.html and notifications.html - CLAUDE.md and ARCH__SYSTEM.md updated: tool count 50→58, risk system docs, tool access control three-layer model documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
7.0 KiB
Python
200 lines
7.0 KiB
Python
"""
|
|
Home Assistant webhook router — POST /webhook/ha/{username}/{webhook_id}
|
|
|
|
Receives event payloads from HA automations and routes them to Inara.
|
|
Auth: the webhook_id in the URL acts as the shared secret (same model HA uses).
|
|
Response is delivered async via notify() — NC Talk, web push, etc.
|
|
|
|
channels.json schema:
|
|
"homeassistant": {
|
|
"webhook_id": "your-secret-id",
|
|
"persona": "inara",
|
|
"tier": 2,
|
|
"role": "chat",
|
|
"tools": false
|
|
}
|
|
|
|
HA automation example (rest_command):
|
|
rest_command:
|
|
cortex_notify:
|
|
url: "https://cortex.dgrzone.com/webhook/ha/scott/your-secret-id"
|
|
method: POST
|
|
content_type: "application/json"
|
|
payload: '{"message": "{{message}}", "entity_id": "{{entity_id}}", "state": "{{state}}"}'
|
|
|
|
automation:
|
|
trigger:
|
|
- trigger: state
|
|
entity_id: binary_sensor.front_door
|
|
to: "on"
|
|
action:
|
|
- action: rest_command.cortex_notify
|
|
data:
|
|
message: "Front door opened"
|
|
entity_id: "binary_sensor.front_door"
|
|
state: "on"
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
|
|
|
|
from auth_utils import get_user_channels, get_user_gemini_key, get_user_role, get_tool_policy, get_risk_policy
|
|
from context_loader import load_context
|
|
from llm_client import complete
|
|
from notification import notify
|
|
from persona import set_context
|
|
from session_logger import log_turn
|
|
from session_store import load as load_session, save as save_session
|
|
from config import settings
|
|
import event_bus
|
|
import model_registry
|
|
import orchestrator_engine
|
|
import openai_orchestrator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
def _build_task(body: dict) -> str:
|
|
"""Turn an HA event payload into a natural-language prompt for Inara."""
|
|
if "message" in body:
|
|
msg = str(body["message"])
|
|
extras = {k: body[k] for k in ("entity_id", "state", "trigger", "event", "area") if k in body}
|
|
if extras:
|
|
msg += "\n\nContext: " + json.dumps(extras)
|
|
return msg
|
|
return "Home Assistant event:\n" + json.dumps(body, indent=2)
|
|
|
|
|
|
async def _process_event(username: str, body: dict, cfg: dict) -> None:
|
|
persona_name = cfg.get("persona", "inara")
|
|
tier = cfg.get("tier") or settings.default_tier
|
|
role = cfg.get("role", "chat")
|
|
use_tools = cfg.get("tools", False)
|
|
|
|
set_context(username, persona_name)
|
|
|
|
task = _build_task(body)
|
|
session_id = f"ha_{username}"
|
|
history = load_session(session_id)
|
|
session_msgs = list(history)
|
|
|
|
logger.info("HA event for %s: %r", username, task[:80])
|
|
|
|
backend = "unknown"
|
|
try:
|
|
if use_tools:
|
|
role_cfg = model_registry.get_role_config(username, role)
|
|
system_prompt = load_context(
|
|
tier,
|
|
role_append=role_cfg.get("system_append", ""),
|
|
inject_datetime=role_cfg.get("inject_datetime", True),
|
|
inject_mode=role_cfg.get("inject_mode", True),
|
|
)
|
|
orch_model = model_registry.get_model_for_role(username, "orchestrator")
|
|
user_role_val = get_user_role(username)
|
|
tool_list = role_cfg.get("tools")
|
|
policy = get_tool_policy(username)
|
|
c_allow = set(policy.get("allow", []))
|
|
c_deny = set(policy.get("deny", []))
|
|
max_risk, risk_wl, risk_bl = get_risk_policy(username)
|
|
|
|
if orch_model and orch_model.get("type") == "local_openai":
|
|
result = await openai_orchestrator.run(
|
|
task=task,
|
|
system_prompt=system_prompt,
|
|
session_messages=session_msgs or None,
|
|
model_cfg=orch_model,
|
|
user_role=user_role_val,
|
|
tool_list=tool_list,
|
|
confirm_allow=c_allow,
|
|
confirm_deny=c_deny,
|
|
max_risk=max_risk,
|
|
risk_whitelist=risk_wl,
|
|
risk_blacklist=risk_bl,
|
|
)
|
|
else:
|
|
gemini_key = (
|
|
(orch_model.get("api_key") if orch_model else None)
|
|
or get_user_gemini_key(username)
|
|
)
|
|
result = await orchestrator_engine.run(
|
|
task=task,
|
|
system_prompt=system_prompt,
|
|
session_messages=session_msgs or None,
|
|
respond_with_claude=True,
|
|
gemini_api_key=gemini_key,
|
|
model_name=orch_model.get("model_name") if orch_model else None,
|
|
response_role=role,
|
|
user_role=user_role_val,
|
|
tool_list=tool_list,
|
|
confirm_allow=c_allow,
|
|
confirm_deny=c_deny,
|
|
max_risk=max_risk,
|
|
risk_whitelist=risk_wl,
|
|
risk_blacklist=risk_bl,
|
|
)
|
|
response_text = result.response
|
|
backend = result.backend
|
|
|
|
else:
|
|
system_prompt = load_context(tier)
|
|
msgs = list(session_msgs) + [{"role": "user", "content": task}]
|
|
response_text, backend = await complete(system_prompt=system_prompt, messages=msgs)
|
|
|
|
except Exception as e:
|
|
logger.error("HA event error for %s: %s", username, e)
|
|
return
|
|
|
|
logger.info("HA response via %s (%d chars)", backend, len(response_text))
|
|
|
|
history.append({"role": "user", "content": task})
|
|
history.append({"role": "assistant", "content": response_text})
|
|
save_session(session_id, history)
|
|
log_turn(session_id, task, response_text)
|
|
|
|
await event_bus.publish({
|
|
"type": "ha_event",
|
|
"session_id": session_id,
|
|
"response": response_text,
|
|
"backend": backend,
|
|
})
|
|
|
|
await notify(username, response_text)
|
|
|
|
|
|
@router.post("/webhook/ha/{username}/{webhook_id}")
|
|
async def ha_webhook(
|
|
username: str,
|
|
webhook_id: str,
|
|
request: Request,
|
|
background_tasks: BackgroundTasks,
|
|
) -> Response:
|
|
"""Receive an event from a Home Assistant automation and route it to Inara."""
|
|
channels = get_user_channels(username)
|
|
cfg = channels.get("homeassistant")
|
|
if not cfg:
|
|
raise HTTPException(status_code=404, detail="Channel not configured")
|
|
|
|
if webhook_id != cfg.get("webhook_id", ""):
|
|
logger.warning("HA webhook: bad webhook_id for user %r", username)
|
|
raise HTTPException(status_code=401, detail="Invalid webhook ID")
|
|
|
|
content_type = request.headers.get("content-type", "")
|
|
if "application/json" in content_type:
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
|
else:
|
|
form = await request.form()
|
|
body = dict(form)
|
|
|
|
if not body:
|
|
return Response(status_code=200)
|
|
|
|
background_tasks.add_task(_process_event, username, body, cfg)
|
|
return Response(status_code=200)
|