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

@@ -3,14 +3,16 @@ import logging
from fastapi import APIRouter, HTTPException, Request, Response
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels/google-chat")
router = APIRouter()
# Workspace Add-on Chat apps: JWT is issued by accounts.google.com.
# (Legacy standalone Chat bots used chat@system.gserviceaccount.com — different format.)
@@ -35,7 +37,7 @@ def _msg(text: str) -> dict:
}
def _verify_system_id_token(token: str) -> None:
def _verify_system_id_token(token: str, audience: str) -> None:
"""Verify the systemIdToken from authorizationEventObject.
For Workspace Add-on Chat apps Google sends the token in the request body
@@ -44,13 +46,13 @@ def _verify_system_id_token(token: str) -> None:
Claims verified:
iss = "https://accounts.google.com"
aud = settings.google_chat_audience (the endpoint URL)
aud = the per-user audience from channels.json (the endpoint URL)
"""
try:
claims = id_token.verify_oauth2_token(
token,
google_requests.Request(),
audience=settings.google_chat_audience,
audience=audience,
)
except Exception as exc:
logger.warning("Google Chat JWT verification failed: %s", exc)
@@ -60,17 +62,30 @@ def _verify_system_id_token(token: str) -> None:
raise HTTPException(status_code=401, detail="Wrong issuer")
@router.post("")
async def receive(request: Request):
@router.post("/channels/google-chat/{username}")
async def receive(username: str, request: Request):
channels = get_user_channels(username)
cfg = channels.get("google_chat")
if not cfg:
logger.warning("Google Chat: no channel config for user %r", username)
raise HTTPException(status_code=404, detail="Channel not configured for this user")
persona_name = cfg.get("persona", "inara")
audience = cfg.get("audience", "")
backend = cfg.get("backend", settings.primary_backend)
timeout = cfg.get("timeout", 25)
set_context(username, persona_name)
body = await request.json()
# Verify the systemIdToken embedded in the request body
if settings.google_chat_audience:
if audience:
token = body.get("authorizationEventObject", {}).get("systemIdToken", "")
if not token:
logger.warning("Google Chat: missing systemIdToken")
logger.warning("Google Chat: missing systemIdToken for %s", username)
raise HTTPException(status_code=401, detail="Missing token")
_verify_system_id_token(token)
_verify_system_id_token(token, audience)
chat = body.get("chat", {})
@@ -79,8 +94,8 @@ async def receive(request: Request):
if "addedToSpacePayload" in chat:
space_type = chat["addedToSpacePayload"].get("space", {}).get("type", "")
if space_type == "DM":
return _msg(f"✨ Hello! I'm {settings.agent_name}. What can I help you with?")
return _msg(f"✨ Hello! I'm {settings.agent_name}. Send me a message and I'll do my best to help.")
return _msg(f"✨ Hello! I'm {persona_name.capitalize()}. What can I help you with?")
return _msg(f"✨ Hello! I'm {persona_name.capitalize()}. Send me a message and I'll do my best to help.")
if "removedFromSpacePayload" in chat:
return Response(status_code=200)
@@ -89,10 +104,10 @@ async def receive(request: Request):
logger.info("Google Chat: unhandled event keys: %s", list(chat.keys()))
return Response(status_code=200)
payload = chat["messagePayload"]
message = payload.get("message", {})
space = payload.get("space", {})
user = chat.get("user", {})
payload = chat["messagePayload"]
message = payload.get("message", {})
space = payload.get("space", {})
user = chat.get("user", {})
# argumentText strips @BotName mentions in Spaces; fall back to full text in DMs
user_text = (message.get("argumentText") or message.get("text", "")).strip()
@@ -107,7 +122,7 @@ async def receive(request: Request):
logger.warning("Google Chat: empty user_text, ignoring")
return Response(status_code=200)
session_id = "gc_" + space_name.replace("/", "_")
session_id = f"gc_{username}_{space_name.replace('/', '_')}"
system_prompt = load_context(settings.default_tier)
history = load_session(session_id)
history.append({"role": "user", "content": user_text})
@@ -117,9 +132,9 @@ async def receive(request: Request):
complete(
system_prompt=system_prompt,
messages=history,
model=settings.google_chat_backend,
model=backend,
),
timeout=settings.google_chat_timeout,
timeout=timeout,
)
except asyncio.TimeoutError:
logger.warning("Google Chat request timed out for session %s", session_id)