feat: Auto-connect Documents via MatrixHost portal, rebrand WildFiles

Connect the Matrix AI bot to customer WildFiles orgs via the MatrixHost
portal API instead of requiring manual !ai wildfiles connect. The bot
now auto-resolves the user document org on every message, enabling
seamless RAG document search for all MatrixHost customers.

- Add _get_wildfiles_org() with portal API lookup and session cache
- Update DocumentRAG.search() to accept org_slug (no API key needed)
- Add DocumentRAG.get_org_stats() for org-based stats
- Update context building to use portal org lookup with legacy fallback
- Add !ai docs connect/disconnect aliases
- Rebrand all user-facing messages from WildFiles to Documents
- !ai wildfiles connect now checks portal first, shows auto-connect msg

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-03-02 06:41:09 +02:00
parent fecf99ef60
commit d905f6ca6f

117
bot.py
View File

@@ -314,9 +314,9 @@ HELP_TEXT = """**AI Bot Commands**
- `!ai help` — Show this help - `!ai help` — Show this help
- `!ai models` — List available models - `!ai models` — List available models
- `!ai set-model <model>` — Set model for this room - `!ai set-model <model>` — Set model for this room
- `!ai search <query>` — Search documents (WildFiles) - `!ai search <query>` — Search your documents (auto-connected via MatrixHost)
- `!ai wildfiles connect` — Connect your WildFiles account (opens browser approval) - `!ai docs connect <key>` — Connect with a custom document API key (optional)
- `!ai wildfiles disconnect` — Disconnect your WildFiles account - `!ai docs disconnect` — Disconnect custom document API key
- `!ai auto-rename on|off` — Auto-rename room based on conversation topic - `!ai auto-rename on|off` — Auto-rename room based on conversation topic
- `!ai forget` — Delete all memories the bot has about you - `!ai forget` — Delete all memories the bot has about you
- `!ai memories` — Show what the bot remembers about you - `!ai memories` — Show what the bot remembers about you
@@ -330,14 +330,17 @@ class DocumentRAG:
def __init__(self, base_url: str, org: str): def __init__(self, base_url: str, org: str):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.org = org self.org = org
self.enabled = bool(base_url and org) self.enabled = bool(base_url)
async def search(self, query: str, top_k: int = 3, api_key: str | None = None) -> list[dict]: async def search(self, query: str, top_k: int = 3, api_key: str | None = None, org_slug: str | None = None) -> list[dict]:
if not api_key: org = org_slug or self.org
if not org and not api_key:
return [] return []
try: try:
headers = {"X-API-Key": api_key} headers = {}
body = {"query": query, "limit": top_k} if api_key:
headers["X-API-Key"] = api_key
body = {"query": query, "limit": top_k, "organization": org}
async with httpx.AsyncClient(timeout=15.0) as client: async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post( resp = await client.post(
f"{self.base_url}/api/v1/rag/search", f"{self.base_url}/api/v1/rag/search",
@@ -368,6 +371,22 @@ class DocumentRAG:
logger.debug("WildFiles key validation failed", exc_info=True) logger.debug("WildFiles key validation failed", exc_info=True)
return None return None
async def get_org_stats(self, org_slug: str) -> dict | None:
"""Get stats for an org by slug. Returns stats dict or None."""
if not self.base_url:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{self.base_url}/api/v1/rag/stats",
params={"organization": org_slug},
)
resp.raise_for_status()
return resp.json()
except Exception:
logger.debug("WildFiles org stats failed for %s", org_slug, exc_info=True)
return None
def format_context(self, results: list[dict]) -> str: def format_context(self, results: list[dict]) -> str:
if not results: if not results:
return "" return ""
@@ -888,7 +907,8 @@ class Bot:
self.memory = MemoryClient(MEMORY_SERVICE_URL) self.memory = MemoryClient(MEMORY_SERVICE_URL)
self.atlassian = AtlassianClient(PORTAL_URL, BOT_API_KEY) self.atlassian = AtlassianClient(PORTAL_URL, BOT_API_KEY)
self.llm = AsyncOpenAI(base_url=LITELLM_URL, api_key=LITELLM_KEY) if LITELLM_URL else None self.llm = AsyncOpenAI(base_url=LITELLM_URL, api_key=LITELLM_KEY) if LITELLM_URL else None
self.user_keys: dict[str, str] = self._load_user_keys() # matrix_user_id -> api_key self.user_keys: dict[str, str] = self._load_user_keys() # matrix_user_id -> api_key (legacy)
self._wildfiles_org_cache: dict[str, str | None] = {} # matrix_user_id -> org_slug (from portal)
self.room_models: dict[str, str] = {} # room_id -> model name self.room_models: dict[str, str] = {} # room_id -> model name
self.auto_rename_rooms: set[str] = set() # rooms with auto-rename enabled self.auto_rename_rooms: set[str] = set() # rooms with auto-rename enabled
self._recent_images: dict[str, tuple[str, str, float]] = {} # room_id -> (b64, mime, timestamp) self._recent_images: dict[str, tuple[str, str, float]] = {} # room_id -> (b64, mime, timestamp)
@@ -917,6 +937,39 @@ class Bot:
except Exception: except Exception:
logger.exception("Failed to save user keys") logger.exception("Failed to save user keys")
async def _get_wildfiles_org(self, matrix_user_id: str) -> str | None:
"""Get user's WildFiles org slug via MatrixHost portal API.
Auto-provisions a WildFiles org if the user has a MatrixHost account.
Falls back to legacy user_keys for backward compat.
Results are cached per session.
"""
if matrix_user_id in self._wildfiles_org_cache:
return self._wildfiles_org_cache[matrix_user_id]
# Try portal API (auto-provisions org if needed)
if self.atlassian.enabled: # reuses same portal_url + bot_api_key
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{self.atlassian.portal_url}/api/bot/tokens",
params={"matrix_user_id": matrix_user_id, "provider": "wildfiles"},
headers={"Authorization": f"Bearer {self.atlassian.bot_api_key}"},
)
resp.raise_for_status()
data = resp.json()
if data.get("connected"):
org_slug = data["org_slug"]
self._wildfiles_org_cache[matrix_user_id] = org_slug
logger.debug("Resolved WildFiles org %s for %s via portal", org_slug, matrix_user_id)
return org_slug
except Exception:
logger.debug("Portal WildFiles org lookup failed for %s", matrix_user_id, exc_info=True)
# No portal result — cache as None to avoid repeated lookups
self._wildfiles_org_cache[matrix_user_id] = None
return None
async def start(self): async def start(self):
# Restore existing session or create new one # Restore existing session or create new one
if os.path.exists(CREDS_FILE): if os.path.exists(CREDS_FILE):
@@ -1813,11 +1866,14 @@ class Bot:
if cmd == "help": if cmd == "help":
await self._send_text(room.room_id, HELP_TEXT) await self._send_text(room.room_id, HELP_TEXT)
elif cmd == "wildfiles connect" or cmd.startswith("wildfiles connect "): elif cmd == "wildfiles connect" or cmd.startswith("wildfiles connect ") or cmd == "docs connect" or cmd.startswith("docs connect "):
if cmd.startswith("docs connect"):
api_key = cmd[12:].strip() if cmd.startswith("docs connect ") else ""
else:
api_key = cmd[18:].strip() if cmd.startswith("wildfiles connect ") else "" api_key = cmd[18:].strip() if cmd.startswith("wildfiles connect ") else ""
await self._handle_connect(room, api_key, event) await self._handle_connect(room, api_key, event)
elif cmd == "wildfiles disconnect": elif cmd == "wildfiles disconnect" or cmd == "docs disconnect":
await self._handle_disconnect(room, event) await self._handle_disconnect(room, event)
elif cmd == "models": elif cmd == "models":
@@ -1898,10 +1954,11 @@ class Bot:
return return
sender = event.sender if event else None sender = event.sender if event else None
user_api_key = self.user_keys.get(sender) if sender else None user_api_key = self.user_keys.get(sender) if sender else None
if not user_api_key: user_org_slug = await self._get_wildfiles_org(sender) if sender else None
await self._send_text(room.room_id, "WildFiles not connected. Use `!ai wildfiles connect` first.") if not user_api_key and not user_org_slug:
await self._send_text(room.room_id, "Documents not available. Manage your documents at [matrixhost.eu/documents](https://matrixhost.eu/documents).")
return return
results = await self.rag.search(query, top_k=5, api_key=user_api_key) results = await self.rag.search(query, top_k=5, api_key=user_api_key, org_slug=user_org_slug)
if not results: if not results:
await self._send_text(room.room_id, "No documents found.") await self._send_text(room.room_id, "No documents found.")
return return
@@ -1924,7 +1981,7 @@ class Bot:
sender = event.sender if event else None sender = event.sender if event else None
if not self.rag.base_url: if not self.rag.base_url:
await self._send_text(room.room_id, "WildFiles is not configured.") await self._send_text(room.room_id, "Document search is not configured.")
return return
# Fallback: direct API key provided # Fallback: direct API key provided
@@ -1947,13 +2004,26 @@ class Bot:
total = stats.get("total_documents", 0) total = stats.get("total_documents", 0)
await self._send_text( await self._send_text(
room.room_id, room.room_id,
f"Connected to WildFiles (org: **{org_name}**, {total} documents). " f"Documents connected (org: **{org_name}**, {total} documents). "
f"Your documents are now searchable.", f"Your documents are now searchable.",
) )
logger.info("User %s connected WildFiles key (org: %s)", sender, org_name) logger.info("User %s connected WildFiles key (org: %s)", sender, org_name)
return return
# SSO device authorization flow # Check if user already has auto-provisioned org via MatrixHost portal
if sender:
org_slug = await self._get_wildfiles_org(sender)
if org_slug:
stats = await self.rag.get_org_stats(org_slug)
total = stats.get("total_documents", 0) if stats else 0
await self._send_text(
room.room_id,
f"Documents are already connected via your MatrixHost account (org: **{org_slug}**, {total} documents). "
f"Manage documents at [matrixhost.eu/documents](https://matrixhost.eu/documents).",
)
return
# SSO device authorization flow (fallback for non-MatrixHost users)
if sender and sender in self._pending_connects: if sender and sender in self._pending_connects:
await self._send_text(room.room_id, "A connect flow is already in progress. Please complete or wait for it to expire.") await self._send_text(room.room_id, "A connect flow is already in progress. Please complete or wait for it to expire.")
return return
@@ -1974,7 +2044,7 @@ class Bot:
await self._send_text( await self._send_text(
room.room_id, room.room_id,
f"To connect WildFiles, visit:\n\n" f"To connect documents, visit:\n\n"
f"**{verification_url}**\n\n" f"**{verification_url}**\n\n"
f"and enter code: **{user_code}**\n\n" f"and enter code: **{user_code}**\n\n"
f"_This link expires in 10 minutes._", f"_This link expires in 10 minutes._",
@@ -1990,10 +2060,10 @@ class Bot:
if sender and sender in self.user_keys: if sender and sender in self.user_keys:
del self.user_keys[sender] del self.user_keys[sender]
self._save_user_keys() self._save_user_keys()
await self._send_text(room.room_id, "Disconnected from WildFiles. Using default search.") await self._send_text(room.room_id, "Custom document key removed. Using default document search.")
logger.info("User %s disconnected WildFiles key", sender) logger.info("User %s disconnected WildFiles key", sender)
else: else:
await self._send_text(room.room_id, "No WildFiles account connected.") await self._send_text(room.room_id, "No custom document key connected.")
async def _poll_device_auth(self, room_id: str, sender: str, device_code: str): async def _poll_device_auth(self, room_id: str, sender: str, device_code: str):
"""Poll WildFiles for device auth approval (5s interval, 10 min max).""" """Poll WildFiles for device auth approval (5s interval, 10 min max)."""
@@ -2017,7 +2087,7 @@ class Bot:
self._save_user_keys() self._save_user_keys()
await self._send_text( await self._send_text(
room_id, room_id,
f"Connected to WildFiles (org: **{org_slug}**). Your documents are now searchable.", f"Documents connected (org: **{org_slug}**). Your documents are now searchable.",
) )
logger.info("User %s connected via device auth (org: %s)", sender, org_slug) logger.info("User %s connected via device auth (org: %s)", sender, org_slug)
return return
@@ -2173,9 +2243,10 @@ class Bot:
# Rewrite query using conversation context for better RAG search # Rewrite query using conversation context for better RAG search
search_query = await self._rewrite_query(user_message, history, model) search_query = await self._rewrite_query(user_message, history, model)
# WildFiles document context (use per-user API key if available) # WildFiles document context (portal org auto-provision, legacy API key fallback)
user_api_key = self.user_keys.get(sender) if sender else None user_api_key = self.user_keys.get(sender) if sender else None
doc_results = await self.rag.search(search_query, api_key=user_api_key) user_org_slug = await self._get_wildfiles_org(sender) if sender else None
doc_results = await self.rag.search(search_query, api_key=user_api_key, org_slug=user_org_slug)
doc_context = self.rag.format_context(doc_results) doc_context = self.rag.format_context(doc_results)
if doc_context: if doc_context:
logger.info("RAG found %d docs for: %s (original: %s)", len(doc_results), search_query[:50], user_message[:50]) logger.info("RAG found %d docs for: %s (original: %s)", len(doc_results), search_query[:50], user_message[:50])