feat: per-user channel config for Google Chat and Nextcloud Talk

- New endpoints: POST /channels/google-chat/{username} and /webhook/nextcloud/{username}
- Channel secrets/config live in home/{username}/channels.json (gitignored)
- auth_utils: get_user_channels() helper reads channels.json
- Both routers load persona, audience/secret, backend, timeout per user;
  set_context() wires the correct persona before building the system prompt
- Removed server-level channel settings from config.py and .env —
  no user gets a channel until they create their own channels.json
- .gitignore: home/**/channels.json added

To migrate: update Google Chat Add-on webhook URL to /channels/google-chat/{username}
and re-register NC Talk bot at /webhook/nextcloud/{username}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-29 13:02:45 -04:00
parent 496da58f58
commit 93f7f44e51
5 changed files with 105 additions and 56 deletions

View File

@@ -8,11 +8,13 @@ import secrets
import httpx
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
from config import settings
from auth_utils import get_user_channels
from context_loader import load_context
from llm_client import complete
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
logger = logging.getLogger(__name__)
@@ -26,29 +28,29 @@ if not logger.handlers:
router = APIRouter()
def _verify_signature(body: bytes, random_header: str, sig_header: str) -> bool:
def _verify_signature(body: bytes, random_header: str, sig_header: str, secret: str) -> bool:
"""Nextcloud signs requests with HMAC-SHA256(key=secret, msg=random+body)."""
expected = hmac.new(
settings.nextcloud_talk_bot_secret.encode(),
secret.encode(),
(random_header + body.decode("utf-8", errors="replace")).encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig_header.lower())
async def _send_reply(conversation_token: str, message: str) -> None:
async def _send_reply(conversation_token: str, message: str, nextcloud_url: str, secret: str) -> None:
"""Post a message to Nextcloud Talk as the bot."""
url = (
f"{settings.nextcloud_url}/ocs/v2.php/apps/spreed/api/v1"
f"{nextcloud_url}/ocs/v2.php/apps/spreed/api/v1"
f"/bot/{conversation_token}/message"
)
# NC Talk verifies HMAC over (random + message_text), NOT the raw body.
# See BotController::getBotFromHeaders → checksumVerificationService::validateRequest($random, $sig, $secret, $message)
body_dict = {"message": message}
body_dict = {"message": message}
body_bytes = json.dumps(body_dict, ensure_ascii=False).encode("utf-8")
random_str = secrets.token_hex(32)
sig = hmac.new(
settings.nextcloud_talk_bot_secret.encode(),
secret.encode(),
(random_str + message).encode("utf-8"),
hashlib.sha256,
).hexdigest()
@@ -72,9 +74,21 @@ async def _send_reply(conversation_token: str, message: str) -> None:
logger.error("NCT reply error: %s", e)
async def _process_message(conversation_token: str, user_text: str, actor_name: str) -> None:
async def _process_message(
conversation_token: str,
user_text: str,
actor_name: str,
username: str,
persona_name: str,
nextcloud_url: str,
secret: str,
timeout: int,
) -> None:
logger.info("NCT process: token=%s user=%s text=%r", conversation_token, actor_name, user_text)
session_id = f"nct_{conversation_token}"
set_context(username, persona_name)
session_id = f"nct_{username}_{conversation_token}"
system_prompt = load_context(settings.default_tier)
history = load_session(session_id)
history.append({"role": "user", "content": user_text})
@@ -90,15 +104,15 @@ async def _process_message(conversation_token: str, user_text: str, actor_name:
try:
response_text, backend = await asyncio.wait_for(
complete(system_prompt=system_prompt, messages=history),
timeout=settings.nextcloud_talk_timeout,
timeout=timeout,
)
except asyncio.TimeoutError:
logger.warning("NCT timeout for %s", conversation_token)
await _send_reply(conversation_token, "⏳ Still thinking — this is taking longer than usual.")
await _send_reply(conversation_token, "⏳ Still thinking — this is taking longer than usual.", nextcloud_url, secret)
return
except Exception as e:
logger.error("NCT LLM error for %s: %s", conversation_token, e)
await _send_reply(conversation_token, "⚠️ Something went wrong on my end.")
await _send_reply(conversation_token, "⚠️ Something went wrong on my end.", nextcloud_url, secret)
return
logger.info("NCT LLM responded via %s (%d chars)", backend, len(response_text))
@@ -114,22 +128,33 @@ async def _process_message(conversation_token: str, user_text: str, actor_name:
"backend": backend,
})
await _send_reply(conversation_token, response_text)
await _send_reply(conversation_token, response_text, nextcloud_url, secret)
@router.post("/inara-nextcloud-talk-webhook")
async def nextcloud_talk_webhook(request: Request, background_tasks: BackgroundTasks):
body = await request.body()
@router.post("/webhook/nextcloud/{username}")
async def nextcloud_talk_webhook(username: str, request: Request, background_tasks: BackgroundTasks):
channels = get_user_channels(username)
cfg = channels.get("nextcloud")
if not cfg:
logger.warning("NCT webhook: no channel config for user %r", username)
raise HTTPException(status_code=404, detail="Channel not configured for this user")
if not settings.nextcloud_talk_bot_secret:
logger.error("nextcloud_talk_bot_secret not configured")
persona_name = cfg.get("persona", "inara")
nextcloud_url = cfg.get("url", "")
secret = cfg.get("bot_secret", "")
timeout = cfg.get("timeout", 55)
if not secret:
logger.error("NCT webhook: bot_secret missing for user %r", username)
return Response(status_code=500)
body = await request.body()
random_header = request.headers.get("X-Nextcloud-Talk-Random", "")
sig_header = request.headers.get("X-Nextcloud-Talk-Signature", "")
if not _verify_signature(body, random_header, sig_header):
logger.warning("NCT webhook: signature mismatch")
if not _verify_signature(body, random_header, sig_header, secret):
logger.warning("NCT webhook: signature mismatch for %s", username)
raise HTTPException(status_code=401, detail="Invalid signature")
try:
@@ -153,12 +178,12 @@ async def nextcloud_talk_webhook(request: Request, background_tasks: BackgroundT
conversation_token = target.get("id", "")
try:
content = json.loads(obj.get("content", "{}"))
content = json.loads(obj.get("content", "{}"))
user_text = content.get("message", "").strip()
except (json.JSONDecodeError, AttributeError):
user_text = (obj.get("name") or obj.get("content", "")).strip()
mention_prefix = f"@{settings.agent_name.lower()}"
mention_prefix = f"@{persona_name.lower()}"
if user_text.lower().startswith(mention_prefix):
user_text = user_text[len(mention_prefix):].strip()
@@ -168,5 +193,9 @@ async def nextcloud_talk_webhook(request: Request, background_tasks: BackgroundT
actor_name = actor.get("name", "User")
logger.info("NCT message from %s in %s: %r", actor_name, conversation_token, user_text[:60])
background_tasks.add_task(_process_message, conversation_token, user_text, actor_name)
background_tasks.add_task(
_process_message,
conversation_token, user_text, actor_name,
username, persona_name, nextcloud_url, secret, timeout,
)
return Response(status_code=200)