feat(chatlogger): multi-tenant + Jarvis tool integration

Phase 2 of the chatlogger feature.

Multi-tenant:
- chatlogger-bot.service -> chatlogger-bot@.service (systemd template)
- /opt/chatlogger/<instance>.env per bot, all share /opt/chatlogger/chats.db
- existing bot migrated: chatlogger-bot@arakawa.service
- adding a second group bot is now: cp arakawa.env <name>.env, edit token+chats,
  systemctl enable --now chatlogger-bot@<name>

Jarvis (hausmeister-bot) tool:
- homelab-ai-bot/tools/chatlog.py (autodiscovered by tool_loader.py)
- 4 tools: chatlog_list_chats, chatlog_recent, chatlog_search, chatlog_since
- Reads SQLite directly via sys.path += /opt/homelab-brain/chatlogger
- SYSTEM_PROMPT_EXTRA tells the LLM when to use these (Concierge, Airbnb,
  Listing, 'aktueller Stand', 'wer hat zugesagt', 'muss ich reagieren').

Tested live: hausmeister-bot loads 48 tools (44 + 4 new), all chatlog
handlers callable, returns real messages from the @arakawa_concierge_bot
recording of -1003924901022.
This commit is contained in:
Homelab Cursor 2026-05-01 12:13:00 +02:00
parent e1be45399e
commit d82b6f97ef
3 changed files with 255 additions and 4 deletions

View file

@ -1,13 +1,14 @@
[Unit]
Description=Arakawa Concierge — passive Telegram group chat logger
Description=Chatlogger bot instance %i (passive Telegram group chat logger)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/homelab-brain/chatlogger
EnvironmentFile=/opt/chatlogger/env
EnvironmentFile=/opt/chatlogger/%i.env
Environment=PYTHONUNBUFFERED=1
Environment=CHATLOGGER_INSTANCE=%i
ExecStart=/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/chatlogger_bot.py
Restart=always
RestartSec=10

View file

@ -58,6 +58,7 @@ def _allowed_chats() -> set[int]:
CONFIG = {
"instance": _env("CHATLOGGER_INSTANCE", "default"),
"token": _env("ARAKAWA_BOT_TOKEN"),
"db_path": _env("ARAKAWA_BOT_DB", "/opt/chatlogger/chats.db"),
"admin_user_id": _env_int("ARAKAWA_BOT_ADMIN_USER_ID", 0),
@ -342,8 +343,8 @@ def build_app() -> Application:
store = Store(CONFIG["db_path"])
allowed = _allowed_chats()
LOG.info(
"Starting chatlogger: db=%s allowed_chats=%s admin=%s",
CONFIG["db_path"], sorted(allowed), CONFIG["admin_user_id"],
"Starting chatlogger[%s]: db=%s allowed_chats=%s admin=%s",
CONFIG["instance"], CONFIG["db_path"], sorted(allowed), CONFIG["admin_user_id"],
)
app = Application.builder().token(CONFIG["token"]).build()

View file

@ -0,0 +1,249 @@
"""Hausmeister-Bot tool — read access to the chatlogger SQLite database.
This file is INSTALLED into /opt/homelab-ai-bot/tools/chatlog.py
(autodiscovered by tool_loader.py). It does NOT need a restart of the
chatlogger bot it only reads from the shared SQLite file
(/opt/chatlogger/chats.db) using the Store class from the chatlogger
package, which sits next to the hausmeister-bot in the same repo.
Three LLM-callable functions:
- chatlog_list_chats() enumerate known group chats
- chatlog_recent(limit, chat) most recent N messages
- chatlog_search(query, chat) FTS5 search across messages
- chatlog_since(minutes, chat) time window
"""
from __future__ import annotations
import os
import sys
import time
from typing import Any
# The chatlogger package sits next to homelab-ai-bot in the repo.
# Both directories are bind-mounted into CT 116 (mp0 and mp1).
_CHATLOGGER_PATH = "/opt/homelab-brain/chatlogger"
if _CHATLOGGER_PATH not in sys.path:
sys.path.insert(0, _CHATLOGGER_PATH)
from store import Store, format_messages # noqa: E402
DB_PATH = os.environ.get("CHATLOGGER_DB", "/opt/chatlogger/chats.db")
TZ_OFFSET_HOURS = int(os.environ.get("CHATLOGGER_TZ_OFFSET_HOURS", "7"))
_store: Store | None = None
def _get_store() -> Store:
global _store
if _store is None:
_store = Store(DB_PATH)
return _store
def _resolve_chat(chat_filter: str | int | None) -> int | None:
"""Map a free-form chat hint (substring of title, or chat_id) to a real chat_id.
Returns None when no filter or no match (callers treat None = 'all chats').
"""
if chat_filter is None or chat_filter == "":
return None
try:
return int(chat_filter)
except (TypeError, ValueError):
pass
needle = str(chat_filter).lower().strip()
for c in _get_store().chats():
title = (c.get("title") or "").lower()
if needle in title:
return c["chat_id"]
return None
# ---------------------------------------------------------------- Handlers
def handle_chatlog_list_chats(**_kw) -> str:
rows = _get_store().chats()
if not rows:
return "Keine Chats geloggt."
lines = ["Bekannte Chats:"]
for r in rows:
last = time.strftime(
"%Y-%m-%d %H:%M",
time.gmtime(r["last_ts"] + TZ_OFFSET_HOURS * 3600),
)
lines.append(
f"- {r['title'] or '?'} (id={r['chat_id']}, {r['type']}, "
f"{r['n']} Nachrichten, zuletzt {last})"
)
return "\n".join(lines)
def handle_chatlog_recent(limit: int = 30, chat: str | int | None = None, **_kw) -> str:
limit = max(1, min(200, int(limit or 30)))
chat_id = _resolve_chat(chat)
msgs = _get_store().recent(chat_id=chat_id, limit=limit)
if not msgs:
if chat and chat_id is None:
return f"Kein Chat gefunden, der zu '{chat}' passt."
return "Keine Nachrichten gespeichert."
header = _header(chat_id, len(msgs), label="letzte")
return header + "\n" + format_messages(msgs, tz_offset_hours=TZ_OFFSET_HOURS)
def handle_chatlog_search(query: str, chat: str | int | None = None, limit: int = 20, **_kw) -> str:
if not query or not str(query).strip():
return "Suchbegriff fehlt."
limit = max(1, min(200, int(limit or 20)))
chat_id = _resolve_chat(chat)
msgs = _get_store().search(query=str(query), chat_id=chat_id, limit=limit)
if not msgs:
return f"Keine Treffer für '{query}'."
header = _header(chat_id, len(msgs), label=f"Treffer für '{query}'")
return header + "\n" + format_messages(msgs, tz_offset_hours=TZ_OFFSET_HOURS)
def handle_chatlog_since(minutes: int, chat: str | int | None = None, **_kw) -> str:
minutes = max(1, min(60 * 24 * 30, int(minutes)))
since_ts = int(time.time()) - minutes * 60
chat_id = _resolve_chat(chat)
msgs = _get_store().since(chat_id=chat_id, since_ts=since_ts, limit=2000)
if not msgs:
return f"Keine Nachrichten in den letzten {minutes} Minuten."
header = _header(chat_id, len(msgs), label=f"letzte {minutes} Minuten")
return header + "\n" + format_messages(msgs, tz_offset_hours=TZ_OFFSET_HOURS)
def _header(chat_id: int | None, n: int, label: str) -> str:
if chat_id is None:
return f"[{n} Nachrichten, {label}, alle Chats]"
title = next(
(c.get("title") for c in _get_store().chats() if c["chat_id"] == chat_id),
None,
)
return f"[{n} Nachrichten, {label}, Chat: {title or chat_id}]"
# ---------------------------------------------------------------- LLM tool schema
TOOLS = [
{
"type": "function",
"function": {
"name": "chatlog_list_chats",
"description": (
"Liste aller mitgeloggten Telegram-Gruppen-Chats mit Titel, "
"ID, Anzahl Nachrichten und letztem Eintrag. Nutze dies, wenn "
"unklar ist, welche Gruppe gemeint ist."
),
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "chatlog_recent",
"description": (
"Letzte N Nachrichten aus mitgeloggten Telegram-Gruppen-Chats "
"(z.B. Airbnb-Concierge-Gruppe für die Wohnungen). Nutze dies "
"bei Fragen wie 'Was ist der aktuelle Stand?', 'Was wurde "
"zuletzt geschrieben?', 'Wer hat zuletzt was gesagt?', 'Muss "
"ich auf etwas reagieren?'."
),
"parameters": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Anzahl letzter Nachrichten (default 30, max 200).",
},
"chat": {
"type": "string",
"description": (
"Optional: Substring des Chat-Titels (z.B. 'Airbnb', "
"'G 210', 'D1603') oder numerische chat_id. "
"Leer = alle Chats."
),
},
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "chatlog_search",
"description": (
"Volltextsuche (FTS5) in den mitgeloggten Telegram-Chats. "
"Nutze dies für Fragen wie 'Was wurde über X gesagt?', "
"'Hat jemand X erwähnt?', 'Wer hat X zugesagt?'."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"Suchbegriff. FTS5-Syntax erlaubt: 'wort1 wort2' "
"(beide), '\"phrase\"' (genau), 'wort*' (Prefix)."
),
},
"chat": {
"type": "string",
"description": "Optional: Chat-Titel-Substring oder chat_id.",
},
"limit": {
"type": "integer",
"description": "Max Treffer (default 20).",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "chatlog_since",
"description": (
"Alle Nachrichten der letzten N Minuten. Nutze dies bei "
"Fragen wie 'Was war heute los?', 'Was war in der letzten "
"Stunde?'."
),
"parameters": {
"type": "object",
"properties": {
"minutes": {
"type": "integer",
"description": "Zeitfenster in Minuten (z.B. 60 = letzte Stunde, 1440 = letzte 24h).",
},
"chat": {
"type": "string",
"description": "Optional: Chat-Titel-Substring oder chat_id.",
},
},
"required": ["minutes"],
},
},
},
]
HANDLERS = {
"chatlog_list_chats": handle_chatlog_list_chats,
"chatlog_recent": handle_chatlog_recent,
"chatlog_search": handle_chatlog_search,
"chatlog_since": handle_chatlog_since,
}
SYSTEM_PROMPT_EXTRA = """
Telegram-Gruppen-Mitschnitt:
Es gibt einen passiven Mitleser-Bot (@arakawa_concierge_bot), der bestimmte
Telegram-Gruppen aufzeichnet z.B. die Airbnb-Concierge-Gruppe für die
ARAKAWA-Wohnungen (G2010B, D1603). Wenn der Benutzer fragt nach
"aktueller Stand", "wer hat was zugesagt", "was ist offen", "muss ich
reagieren", "letzte Nachricht im Chat", "Concierge", "Airbnb", "Listing",
"Vermietung" verwende die chatlog_*-Tools. Bei Unklarheit über die
gemeinte Gruppe zuerst chatlog_list_chats() aufrufen.
""".strip()