feat: NCT orchestrator support + Home Assistant webhook
nextcloud_talk.py: - Fix missing import hmac / import hashlib (NameError bug in _verify_signature) - Add orchestrator routing when channels.json "tools": true — sends "⏳ Working on it…" immediately, then runs the full tool loop and replies with the result; checkpoint case gets a web UI confirmation note - Read tier and role from channel config (defaults: default_tier / "chat") - Pass cfg through to _process_message homeassistant.py (new): - POST /webhook/ha/{username}/{webhook_id} - Auth: webhook_id path segment matched against channels.json - Accepts JSON or form-encoded body from HA automations - Builds natural-language task from payload (uses "message" key if present, otherwise serialises full body as context) - Same orchestrator/direct dispatch as NCT - Delivers response via notify() — NC Talk, web push, or configured channel - Session key: ha_{username} for continuity across HA events - Registered in main.py; /webhook/ prefix already public in auth_middleware channels.json schema addition: "homeassistant": { "webhook_id": "your-secret-id", "persona": "inara", "tier": 2, "role": "chat", "tools": false } Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
|
|||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from auth_middleware import SessionAuthMiddleware
|
from auth_middleware import SessionAuthMiddleware
|
||||||
from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator
|
from routers import chat, google_chat, nextcloud_talk, homeassistant, files, distill, auth, orchestrator
|
||||||
from routers import ui, onboarding, settings, help, auth_google, local_llm, push, audit, usage
|
from routers import ui, onboarding, settings, help, auth_google, local_llm, push, audit, usage
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ app.add_middleware(SessionAuthMiddleware)
|
|||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
app.include_router(google_chat.router)
|
app.include_router(google_chat.router)
|
||||||
app.include_router(nextcloud_talk.router)
|
app.include_router(nextcloud_talk.router)
|
||||||
|
app.include_router(homeassistant.router)
|
||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
app.include_router(distill.router)
|
app.include_router(distill.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
|||||||
192
cortex/routers/homeassistant.py
Normal file
192
cortex/routers/homeassistant.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
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", []))
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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)
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
|
||||||
|
|
||||||
from auth_utils import get_user_channels
|
from auth_utils import get_user_channels, get_user_gemini_key, get_user_role, get_tool_policy
|
||||||
from context_loader import load_context
|
from context_loader import load_context
|
||||||
from llm_client import complete
|
from llm_client import complete
|
||||||
from notification import _send_nct_message
|
from notification import _send_nct_message
|
||||||
@@ -13,6 +15,9 @@ from session_logger import log_turn
|
|||||||
from session_store import load as load_session, save as save_session
|
from session_store import load as load_session, save as save_session
|
||||||
from config import settings
|
from config import settings
|
||||||
import event_bus
|
import event_bus
|
||||||
|
import model_registry
|
||||||
|
import orchestrator_engine
|
||||||
|
import openai_orchestrator
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
@@ -50,15 +55,19 @@ async def _process_message(
|
|||||||
nextcloud_url: str,
|
nextcloud_url: str,
|
||||||
secret: str,
|
secret: str,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
|
cfg: dict,
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.info("NCT process: token=%s user=%s text=%r", conversation_token, actor_name, user_text)
|
logger.info("NCT process: token=%s user=%s text=%r", conversation_token, actor_name, user_text)
|
||||||
|
|
||||||
set_context(username, persona_name)
|
set_context(username, persona_name)
|
||||||
|
|
||||||
session_id = f"nct_{username}_{conversation_token}"
|
tier = cfg.get("tier") or settings.default_tier
|
||||||
system_prompt = load_context(settings.default_tier)
|
role = cfg.get("role", "chat")
|
||||||
history = load_session(session_id)
|
use_tools = cfg.get("tools", False)
|
||||||
history.append({"role": "user", "content": user_text})
|
|
||||||
|
session_id = f"nct_{username}_{conversation_token}"
|
||||||
|
history = load_session(session_id)
|
||||||
|
session_msgs = list(history) # snapshot before we append
|
||||||
|
|
||||||
await event_bus.publish({
|
await event_bus.publish({
|
||||||
"type": "nct_message",
|
"type": "nct_message",
|
||||||
@@ -68,11 +77,69 @@ async def _process_message(
|
|||||||
"actor": actor_name,
|
"actor": actor_name,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
backend = "unknown"
|
||||||
try:
|
try:
|
||||||
response_text, backend = await asyncio.wait_for(
|
if use_tools:
|
||||||
complete(system_prompt=system_prompt, messages=history),
|
await _send_reply(conversation_token, "⏳ Working on it…", nextcloud_url, secret)
|
||||||
timeout=timeout,
|
|
||||||
)
|
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", []))
|
||||||
|
|
||||||
|
if orch_model and orch_model.get("type") == "local_openai":
|
||||||
|
result = await openai_orchestrator.run(
|
||||||
|
task=user_text,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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=user_text,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = result.response
|
||||||
|
backend = result.backend
|
||||||
|
|
||||||
|
if result.checkpoint:
|
||||||
|
response_text += "\n\n_(This action requires confirmation — use the web UI to approve or deny.)_"
|
||||||
|
|
||||||
|
else:
|
||||||
|
system_prompt = load_context(tier)
|
||||||
|
history_for_llm = list(session_msgs) + [{"role": "user", "content": user_text}]
|
||||||
|
response_text, backend = await asyncio.wait_for(
|
||||||
|
complete(system_prompt=system_prompt, messages=history_for_llm),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning("NCT timeout for %s", conversation_token)
|
logger.warning("NCT timeout for %s", conversation_token)
|
||||||
await _send_reply(conversation_token, "⏳ Still thinking — this is taking longer than usual.", nextcloud_url, secret)
|
await _send_reply(conversation_token, "⏳ Still thinking — this is taking longer than usual.", nextcloud_url, secret)
|
||||||
@@ -83,6 +150,8 @@ async def _process_message(
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info("NCT LLM responded via %s (%d chars)", backend, len(response_text))
|
logger.info("NCT LLM responded via %s (%d chars)", backend, len(response_text))
|
||||||
|
|
||||||
|
history.append({"role": "user", "content": user_text})
|
||||||
history.append({"role": "assistant", "content": response_text})
|
history.append({"role": "assistant", "content": response_text})
|
||||||
save_session(session_id, history)
|
save_session(session_id, history)
|
||||||
log_turn(session_id, user_text, response_text)
|
log_turn(session_id, user_text, response_text)
|
||||||
@@ -163,6 +232,6 @@ async def nextcloud_talk_webhook(username: str, request: Request, background_tas
|
|||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
_process_message,
|
_process_message,
|
||||||
conversation_token, user_text, actor_name,
|
conversation_token, user_text, actor_name,
|
||||||
username, persona_name, nextcloud_url, secret, timeout,
|
username, persona_name, nextcloud_url, secret, timeout, cfg,
|
||||||
)
|
)
|
||||||
return Response(status_code=200)
|
return Response(status_code=200)
|
||||||
|
|||||||
Reference in New Issue
Block a user