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:
parent
e1be45399e
commit
d82b6f97ef
3 changed files with 255 additions and 4 deletions
|
|
@ -1,13 +1,14 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Arakawa Concierge — passive Telegram group chat logger
|
Description=Chatlogger bot instance %i (passive Telegram group chat logger)
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/opt/homelab-brain/chatlogger
|
WorkingDirectory=/opt/homelab-brain/chatlogger
|
||||||
EnvironmentFile=/opt/chatlogger/env
|
EnvironmentFile=/opt/chatlogger/%i.env
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=CHATLOGGER_INSTANCE=%i
|
||||||
ExecStart=/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/chatlogger_bot.py
|
ExecStart=/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/chatlogger_bot.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|
@ -58,6 +58,7 @@ def _allowed_chats() -> set[int]:
|
||||||
|
|
||||||
|
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
|
"instance": _env("CHATLOGGER_INSTANCE", "default"),
|
||||||
"token": _env("ARAKAWA_BOT_TOKEN"),
|
"token": _env("ARAKAWA_BOT_TOKEN"),
|
||||||
"db_path": _env("ARAKAWA_BOT_DB", "/opt/chatlogger/chats.db"),
|
"db_path": _env("ARAKAWA_BOT_DB", "/opt/chatlogger/chats.db"),
|
||||||
"admin_user_id": _env_int("ARAKAWA_BOT_ADMIN_USER_ID", 0),
|
"admin_user_id": _env_int("ARAKAWA_BOT_ADMIN_USER_ID", 0),
|
||||||
|
|
@ -342,8 +343,8 @@ def build_app() -> Application:
|
||||||
store = Store(CONFIG["db_path"])
|
store = Store(CONFIG["db_path"])
|
||||||
allowed = _allowed_chats()
|
allowed = _allowed_chats()
|
||||||
LOG.info(
|
LOG.info(
|
||||||
"Starting chatlogger: db=%s allowed_chats=%s admin=%s",
|
"Starting chatlogger[%s]: db=%s allowed_chats=%s admin=%s",
|
||||||
CONFIG["db_path"], sorted(allowed), CONFIG["admin_user_id"],
|
CONFIG["instance"], CONFIG["db_path"], sorted(allowed), CONFIG["admin_user_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app = Application.builder().token(CONFIG["token"]).build()
|
app = Application.builder().token(CONFIG["token"]).build()
|
||||||
|
|
|
||||||
249
homelab-ai-bot/tools/chatlog.py
Normal file
249
homelab-ai-bot/tools/chatlog.py
Normal 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()
|
||||||
Loading…
Add table
Reference in a new issue