refactor: Remove !ai command prefix, natural language only

- Remove all !ai command handling (help, models, set-model, search, etc)
- Remove legacy user_keys system (WildFiles API key storage)
- Remove docs connect/disconnect commands
- Bot now responds to all DM messages and @mentions naturally
- Settings managed exclusively via matrixhost.eu portal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-03-02 12:54:37 +02:00
parent 4bed67ac7f
commit 3c3eb196e1

221
bot.py
View File

@@ -67,7 +67,6 @@ CREDS_FILE = os.path.join(STORE_PATH, "credentials.json")
LITELLM_URL = os.environ.get("LITELLM_BASE_URL", "") LITELLM_URL = os.environ.get("LITELLM_BASE_URL", "")
LITELLM_KEY = os.environ.get("LITELLM_API_KEY", "not-needed") LITELLM_KEY = os.environ.get("LITELLM_API_KEY", "not-needed")
DEFAULT_MODEL = os.environ.get("DEFAULT_MODEL", "claude-sonnet") DEFAULT_MODEL = os.environ.get("DEFAULT_MODEL", "claude-sonnet")
USER_KEYS_FILE = os.environ.get("USER_KEYS_FILE", "/data/user_keys.json")
MEMORY_SERVICE_URL = os.environ.get("MEMORY_SERVICE_URL", "http://memory-service:8090") MEMORY_SERVICE_URL = os.environ.get("MEMORY_SERVICE_URL", "http://memory-service:8090")
CONFLUENCE_URL = os.environ.get("CONFLUENCE_BASE_URL", "") CONFLUENCE_URL = os.environ.get("CONFLUENCE_BASE_URL", "")
CONFLUENCE_USER = os.environ.get("CONFLUENCE_USER", "") CONFLUENCE_USER = os.environ.get("CONFLUENCE_USER", "")
@@ -308,18 +307,12 @@ ATLASSIAN_NOT_CONNECTED_MSG = (
"to use Jira and Confluence features." "to use Jira and Confluence features."
) )
HELP_TEXT = """**AI Bot Commands** HELP_TEXT = """**AI Bot**
- `!ai help` — Show this help Just write naturally — I'll respond to all messages in DMs.
- `!ai models` — List available models In group chats, @mention me to get my attention.
- `!ai set-model <model>` — Set model for this room
- `!ai search <query>` — Search your documents (auto-connected via MatrixHost) I can search your documents, translate messages, and analyze images.
- `!ai docs connect <key>` — Connect with a custom document API key (optional) Manage settings at [matrixhost.eu/settings](https://matrixhost.eu/settings)."""
- `!ai docs disconnect` — Disconnect custom document API key
- `!ai auto-rename on|off` — Auto-rename room based on conversation topic
- `!ai forget` — Delete all memories the bot has about you
- `!ai memories` — Show what the bot remembers about you
- **Translate**: Forward a message to this DM — bot detects language and offers translation
- **@mention the bot** or start with `!ai` for a regular AI response"""
class DocumentRAG: class DocumentRAG:
@@ -875,7 +868,6 @@ 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 (legacy)
self._documents_cache: dict[str, str | None] = {} # matrix_user_id -> connected status self._documents_cache: dict[str, str | None] = {} # matrix_user_id -> connected status
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
@@ -884,27 +876,8 @@ class Bot:
self._loaded_rooms: set[str] = set() # rooms where we've loaded state self._loaded_rooms: set[str] = set() # rooms where we've loaded state
self._sync_token_received = False self._sync_token_received = False
self._verifications: dict[str, dict] = {} # txn_id -> verification state self._verifications: dict[str, dict] = {} # txn_id -> verification state
self._pending_connects: dict[str, str] = {} # matrix_user_id -> device_code
self._room_document_context: dict[str, list[dict]] = {} # room_id -> [{type, filename, text, timestamp}, ...] self._room_document_context: dict[str, list[dict]] = {} # room_id -> [{type, filename, text, timestamp}, ...]
@staticmethod
def _load_user_keys() -> dict[str, str]:
if os.path.exists(USER_KEYS_FILE):
try:
with open(USER_KEYS_FILE) as f:
return json.load(f)
except Exception:
logger.warning("Failed to load user keys file, starting fresh")
return {}
def _save_user_keys(self):
try:
os.makedirs(os.path.dirname(USER_KEYS_FILE), exist_ok=True)
with open(USER_KEYS_FILE, "w") as f:
json.dump(self.user_keys, f)
except Exception:
logger.exception("Failed to save user keys")
async def _has_documents(self, matrix_user_id: str) -> bool: async def _has_documents(self, matrix_user_id: str) -> bool:
"""Check if user has documents via MatrixHost portal API. """Check if user has documents via MatrixHost portal API.
@@ -1337,15 +1310,6 @@ class Bot:
await self._load_room_settings(room.room_id) await self._load_room_settings(room.room_id)
body = event.body.strip() body = event.body.strip()
# Command handling
if body.startswith("!ai "):
cmd = body[4:].strip()
await self._handle_command(room, cmd, event)
return
if body == "!ai":
await self._send_text(room.room_id, HELP_TEXT)
return
# In DMs (2 members), respond to all messages; in groups, require @mention # In DMs (2 members), respond to all messages; in groups, require @mention
is_dm = room.member_count == 2 is_dm = room.member_count == 2
if not is_dm: if not is_dm:
@@ -1824,179 +1788,6 @@ class Bot:
logger.exception("Text file decode failed") logger.exception("Text file decode failed")
return "" return ""
async def _handle_command(self, room, cmd: str, event=None):
if cmd == "help":
await self._send_text(room.room_id, HELP_TEXT)
elif cmd == "docs connect" or cmd.startswith("docs connect "):
api_key = cmd[12:].strip() if cmd.startswith("docs connect ") else ""
await self._handle_connect(room, api_key, event)
elif cmd == "docs disconnect":
await self._handle_disconnect(room, event)
elif cmd == "models":
if not self.llm:
await self._send_text(room.room_id, "LLM not configured.")
return
try:
models = await self.llm.models.list()
names = sorted(m.id for m in models.data)
current = self.room_models.get(room.room_id, DEFAULT_MODEL)
text = "**Available models:**\n"
text += "\n".join(f"- `{n}` {'← current' if n == current else ''}" for n in names)
await self._send_text(room.room_id, text)
except Exception:
logger.exception("Failed to list models")
await self._send_text(room.room_id, "Failed to fetch model list.")
elif cmd.startswith("set-model "):
model = cmd[10:].strip()
if not model:
await self._send_text(room.room_id, "Usage: `!ai set-model <model-name>`")
return
self.room_models[room.room_id] = model
# Persist in room state for cross-restart persistence
try:
await self.client.room_put_state(
room.room_id, MODEL_STATE_TYPE, {"model": model}, state_key="",
)
except Exception:
logger.debug("Could not persist model to room state", exc_info=True)
await self._send_text(room.room_id, f"Model set to `{model}` for this room.")
elif cmd.startswith("auto-rename "):
setting = cmd[12:].strip().lower()
if setting not in ("on", "off"):
await self._send_text(room.room_id, "Usage: `!ai auto-rename on|off`")
return
enabled = setting == "on"
if enabled:
self.auto_rename_rooms.add(room.room_id)
else:
self.auto_rename_rooms.discard(room.room_id)
try:
await self.client.room_put_state(
room.room_id, RENAME_STATE_TYPE,
{"enabled": enabled}, state_key="",
)
except Exception:
logger.debug("Could not persist auto-rename to room state", exc_info=True)
status = "enabled" if enabled else "disabled"
await self._send_text(room.room_id, f"Auto-rename **{status}** for this room.")
elif cmd == "forget":
sender = event.sender if event else None
if sender:
deleted = await self.memory.delete_user(sender)
await self._send_text(room.room_id, f"All my memories about you have been deleted ({deleted} facts removed).")
else:
await self._send_text(room.room_id, "Could not identify user.")
elif cmd == "memories":
sender = event.sender if event else None
if sender:
memories = await self.memory.list_all(sender)
if memories:
text = f"**I remember {len(memories)} things about you:**\n"
text += "\n".join(f"- {m['fact']}" for m in memories)
else:
text = "I don't have any memories about you yet."
await self._send_text(room.room_id, text)
else:
await self._send_text(room.room_id, "Could not identify user.")
elif cmd.startswith("search "):
query = cmd[7:].strip()
if not query:
await self._send_text(room.room_id, "Usage: `!ai search <query>`")
return
sender = event.sender if event else None
user_api_key = self.user_keys.get(sender) if sender else None
has_docs = await self._has_documents(sender) if sender else False
if not has_docs:
await self._send_text(room.room_id, "Documents not available. Manage your documents at [matrixhost.eu/documents](https://matrixhost.eu/documents).")
return
results = await self.rag.search(query, top_k=5, matrix_user_id=sender)
if not results:
await self._send_text(room.room_id, "No documents found.")
return
await self._send_text(room.room_id, self.rag.format_context(results))
else:
# Treat unknown commands as AI prompts
if self.llm:
sender = event.sender if event else None
await self.client.room_typing(room.room_id, typing_state=True)
try:
await self._respond_with_ai(room, cmd, sender=sender)
finally:
await self.client.room_typing(room.room_id, typing_state=False)
else:
await self._send_text(room.room_id, f"Unknown command: `{cmd}`\n\n{HELP_TEXT}")
async def _handle_connect(self, room, api_key: str, event=None):
"""Handle !ai connect — SSO device flow, or !ai connect <key> as fallback."""
sender = event.sender if event else None
if not self.rag.base_url:
await self._send_text(room.room_id, "Document search is not configured.")
return
# Fallback: direct API key provided
if api_key:
# Redact the message containing the API key for security
if event:
try:
await self.client.room_redact(room.room_id, event.event_id, reason="API key redacted for security")
except Exception:
logger.debug("Could not redact connect message", exc_info=True)
stats = await self.rag.validate_key(api_key)
if stats is None:
await self._send_text(room.room_id, "Invalid API key. Please check and try again.")
return
self.user_keys[sender] = api_key
self._save_user_keys()
org_name = stats.get("organization", "unknown")
total = stats.get("total_documents", 0)
await self._send_text(
room.room_id,
f"Documents connected (org: **{org_name}**, {total} documents). "
f"Your documents are now searchable.",
)
logger.info("User %s connected WildFiles key (org: %s)", sender, org_name)
return
# Documents are managed via MatrixHost portal
if sender:
has_docs = await self._has_documents(sender)
if has_docs:
await self._send_text(
room.room_id,
"Documents are connected via your MatrixHost account. "
"Manage documents at [matrixhost.eu/documents](https://matrixhost.eu/documents).",
)
return
await self._send_text(
room.room_id,
"Upload documents at [matrixhost.eu/documents](https://matrixhost.eu/documents) "
"to enable AI-powered document search.",
)
async def _handle_disconnect(self, room, event=None):
"""Handle !ai disconnect — legacy, documents managed via portal now."""
sender = event.sender if event else None
if sender and sender in self.user_keys:
del self.user_keys[sender]
self._save_user_keys()
await self._send_text(room.room_id, "Legacy document key removed.")
logger.info("User %s removed legacy WildFiles key", sender)
else:
await self._send_text(room.room_id, "Documents are managed at [matrixhost.eu/documents](https://matrixhost.eu/documents).")
async def _brave_search(self, query: str, count: int = 5) -> str: async def _brave_search(self, query: str, count: int = 5) -> str:
"""Call Brave Search API and return formatted results.""" """Call Brave Search API and return formatted results."""
if not BRAVE_API_KEY: if not BRAVE_API_KEY: