docs: update project docs, NC Talk guide, Tina persona, and gitignore
- CLAUDE.md: add new auth/onboarding files to directory map, update security section (JWT/bcrypt/invite details), expand recently completed - README.md: fix Web UI auth description, add User Management section - TODO__Agents.md: mark NC Talk docs and auth/onboarding complete, update Holly onboarding plan to reflect single-instance multi-user approach - docs/NEXTCLOUD_TALK_BOT.md: complete guide — occ commands, nginx config, clarify incoming vs outgoing HMAC difference, multi-user note, full troubleshooting table - home/holly/persona/tina/: flesh out all four persona files with real content (DCC name origin, metal music, reading, foster cats, Holly's profile) - .gitignore: exclude home/**/auth.json, invite.json, profile.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from google.auth.transport import requests as google_requests
|
||||
from google.oauth2 import id_token
|
||||
from context_loader import load_context
|
||||
from llm_client import complete
|
||||
from session_logger import log_turn
|
||||
@@ -10,45 +12,104 @@ from config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels/google-chat")
|
||||
|
||||
# Workspace Add-on Chat apps: JWT is issued by accounts.google.com.
|
||||
# (Legacy standalone Chat bots used chat@system.gserviceaccount.com — different format.)
|
||||
_GOOGLE_ISSUER = "https://accounts.google.com"
|
||||
|
||||
|
||||
def _msg(text: str) -> dict:
|
||||
"""Wrap a text reply in the Workspace Add-on hostAppDataAction format.
|
||||
|
||||
Standalone Chat apps use {"text": "..."} directly, but Workspace Add-on
|
||||
Chat apps require the hostAppDataAction wrapper for Google Chat to render
|
||||
the response as a bot message.
|
||||
"""
|
||||
return {
|
||||
"hostAppDataAction": {
|
||||
"chatDataAction": {
|
||||
"createMessageAction": {
|
||||
"message": {"text": text}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _verify_system_id_token(token: str) -> None:
|
||||
"""Verify the systemIdToken from authorizationEventObject.
|
||||
|
||||
For Workspace Add-on Chat apps Google sends the token in the request body
|
||||
at body["authorizationEventObject"]["systemIdToken"], not in the
|
||||
Authorization header.
|
||||
|
||||
Claims verified:
|
||||
iss = "https://accounts.google.com"
|
||||
aud = settings.google_chat_audience (the endpoint URL)
|
||||
"""
|
||||
try:
|
||||
claims = id_token.verify_oauth2_token(
|
||||
token,
|
||||
google_requests.Request(),
|
||||
audience=settings.google_chat_audience,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Google Chat JWT verification failed: %s", exc)
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
if claims.get("iss") != _GOOGLE_ISSUER:
|
||||
logger.warning("Google Chat JWT wrong issuer: %s", claims.get("iss"))
|
||||
raise HTTPException(status_code=401, detail="Wrong issuer")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def receive(request: Request):
|
||||
body = await request.json()
|
||||
event_type = body.get("type")
|
||||
|
||||
if event_type == "ADDED_TO_SPACE":
|
||||
space_type = body.get("space", {}).get("type", "")
|
||||
greeting = f"✨ Hello! I'm {settings.agent_name}. Send me a message and I'll do my best to help."
|
||||
# Verify the systemIdToken embedded in the request body
|
||||
if settings.google_chat_audience:
|
||||
token = body.get("authorizationEventObject", {}).get("systemIdToken", "")
|
||||
if not token:
|
||||
logger.warning("Google Chat: missing systemIdToken")
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
_verify_system_id_token(token)
|
||||
|
||||
chat = body.get("chat", {})
|
||||
|
||||
# Event type is inferred from which payload key is present — there is no
|
||||
# top-level "type" field in the Workspace Add-on event format.
|
||||
if "addedToSpacePayload" in chat:
|
||||
space_type = chat["addedToSpacePayload"].get("space", {}).get("type", "")
|
||||
if space_type == "DM":
|
||||
greeting = f"✨ Hello! I'm {settings.agent_name}. What can I help you with?"
|
||||
return {"text": greeting}
|
||||
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.")
|
||||
|
||||
if event_type == "REMOVED_FROM_SPACE":
|
||||
if "removedFromSpacePayload" in chat:
|
||||
return Response(status_code=200)
|
||||
|
||||
if event_type != "MESSAGE":
|
||||
if "messagePayload" not in chat:
|
||||
logger.info("Google Chat: unhandled event keys: %s", list(chat.keys()))
|
||||
return Response(status_code=200)
|
||||
|
||||
message = body.get("message", {})
|
||||
sender = message.get("sender", {})
|
||||
space = body.get("space", {})
|
||||
payload = chat["messagePayload"]
|
||||
message = payload.get("message", {})
|
||||
space = payload.get("space", {})
|
||||
user = chat.get("user", {})
|
||||
|
||||
# argumentText strips the @BotName mention in Spaces; fall back to full text in DMs
|
||||
user_text = (message.get("argumentText") or message.get("text", "")).strip()
|
||||
if not user_text:
|
||||
return Response(status_code=200)
|
||||
|
||||
sender_display = sender.get("displayName", "User")
|
||||
# argumentText strips @BotName mentions in Spaces; fall back to full text in DMs
|
||||
user_text = (message.get("argumentText") or message.get("text", "")).strip()
|
||||
sender_display = user.get("displayName", "User")
|
||||
space_name = space.get("name", "unknown")
|
||||
space_type = space.get("type", "")
|
||||
|
||||
# Session keyed per space — one conversation per DM or Space
|
||||
session_id = "gc_" + space_name.replace("/", "_")
|
||||
logger.info("Google Chat message from %s in %s (%s): %r",
|
||||
sender_display, space_name, space_type, user_text[:80])
|
||||
|
||||
logger.info("Google Chat message from %s in %s (%s)", sender_display, space_name, space_type)
|
||||
if not user_text:
|
||||
logger.warning("Google Chat: empty user_text, ignoring")
|
||||
return Response(status_code=200)
|
||||
|
||||
session_id = "gc_" + space_name.replace("/", "_")
|
||||
system_prompt = load_context(settings.default_tier)
|
||||
history = load_session(session_id)
|
||||
history = load_session(session_id)
|
||||
history.append({"role": "user", "content": user_text})
|
||||
|
||||
try:
|
||||
@@ -62,13 +123,14 @@ async def receive(request: Request):
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Google Chat request timed out for session %s", session_id)
|
||||
return {"text": "⏳ Still thinking — this is taking a bit longer than usual. Try again in a moment."}
|
||||
return _msg("⏳ Still thinking — this is taking a bit longer than usual. Try again in a moment.")
|
||||
except Exception as e:
|
||||
logger.error("Google Chat error for session %s: %s", session_id, e)
|
||||
return {"text": f"⚠️ Something went wrong on my end. Try again shortly."}
|
||||
return _msg("⚠️ Something went wrong on my end. Try again shortly.")
|
||||
|
||||
logger.info("Google Chat LLM responded via %s (%d chars)", actual_backend, len(response_text))
|
||||
history.append({"role": "assistant", "content": response_text})
|
||||
save_session(session_id, history)
|
||||
log_turn(session_id, user_text, response_text)
|
||||
|
||||
return {"text": response_text}
|
||||
return _msg(response_text)
|
||||
|
||||
Reference in New Issue
Block a user