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:
221
bot.py
221
bot.py
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user