feat(chatlogger): Arakawa Concierge — passive Telegram group chat logger
Standalone bot @arakawa_concierge_bot that silently records configured group chats into SQLite (FTS5). Phase 1 — capture only, no LLM, no auto-replies. Reads token + chat allowlist from /opt/chatlogger/env (NOT in git). Runs as systemd unit chatlogger-bot.service on CT 116 sharing /opt/bot-venv with the hausmeister-bot. - chatlogger_bot.py: telegram polling, message/edit handlers, admin cmds (/whichchat, /log_status, /recent, /search) — silent in groups - store.py: SQLite schema + FTS5 search + edit audit - query.py: CLI (recent, search, stats, chats, since, export JSONL) - chatlogger-bot.service: systemd unit, hardened (ProtectSystem=strict) - env.example, README.md Tested live: message 'hallo' from -1003924901022 stored, retrieved via CLI within seconds. Group privacy must be off in BotFather.
This commit is contained in:
parent
74c2dbfa88
commit
e1be45399e
7 changed files with 972 additions and 0 deletions
109
chatlogger/README.md
Normal file
109
chatlogger/README.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Arakawa Concierge — Group Chat Logger
|
||||
|
||||
Stand-alone Telegram bot (`@arakawa_concierge_bot`) that silently records
|
||||
configured group chats into a SQLite database. Intended as the read-only
|
||||
data source for the Hausmeister bot (Jarvis) so it can answer questions
|
||||
about what was said, agreed, or left open in a managed chat.
|
||||
|
||||
**Phase 1 (this code):** capture only — no auto-replies, no LLM, no
|
||||
classification. Only the admin can use admin commands; in groups the bot
|
||||
is mute except when the admin sends `/whichchat`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `chatlogger_bot.py` | Main bot (long-poll, message handlers, admin commands) |
|
||||
| `store.py` | SQLite schema + CRUD + FTS5 search |
|
||||
| `query.py` | CLI: `recent`, `search`, `stats`, `chats`, `since`, `export` |
|
||||
| `chatlogger-bot.service` | systemd unit (installed on CT 116) |
|
||||
| `env.example` | template for `/opt/chatlogger/env` (NOT committed) |
|
||||
|
||||
## Runtime layout on CT 116
|
||||
|
||||
```
|
||||
/opt/homelab-brain/chatlogger/ # code (read-only via bind-mount mp1)
|
||||
/opt/chatlogger/
|
||||
├── env # token + chat allowlist (chmod 600, root)
|
||||
└── chats.db # SQLite, WAL mode
|
||||
/etc/systemd/system/chatlogger-bot.service
|
||||
```
|
||||
|
||||
`/opt/bot-venv` is shared with the hausmeister-bot (python-telegram-bot 22.6).
|
||||
|
||||
## First-time install (on CT 116)
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/chatlogger
|
||||
cp /opt/homelab-brain/chatlogger/env.example /opt/chatlogger/env
|
||||
$EDITOR /opt/chatlogger/env # paste token, set chat ids
|
||||
chmod 600 /opt/chatlogger/env
|
||||
|
||||
cp /opt/homelab-brain/chatlogger/chatlogger-bot.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now chatlogger-bot
|
||||
journalctl -u chatlogger-bot -f
|
||||
```
|
||||
|
||||
## Adding a new group
|
||||
|
||||
1. Add `@arakawa_concierge_bot` to the group as a regular member (no admin needed).
|
||||
2. As admin, send `/whichchat` in the group → bot replies with the `chat_id`.
|
||||
3. Append the id to `ARAKAWA_BOT_ALLOWED_CHATS` in `/opt/chatlogger/env` (comma separated).
|
||||
4. `systemctl restart chatlogger-bot`.
|
||||
|
||||
**BotFather group privacy MUST be off** otherwise the bot only sees `@mentions`:
|
||||
`/mybots` → `@arakawa_concierge_bot` → `Bot Settings` → `Group Privacy` → `Turn off`,
|
||||
then remove and re-add the bot to the group (Telegram caches the privacy flag per chat).
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# Most recent 50 messages, all chats
|
||||
/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/query.py recent -n 50
|
||||
|
||||
# Search
|
||||
/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/query.py search "kaution"
|
||||
|
||||
# Last 24h of one chat
|
||||
/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/query.py since 1440 --chat -1003924901022
|
||||
|
||||
# Per-chat stats
|
||||
/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/query.py stats
|
||||
|
||||
# Export as JSONL (e.g. for handing to the Hausmeister-Bot)
|
||||
/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/query.py export --chat -1003924901022 > /tmp/log.jsonl
|
||||
```
|
||||
|
||||
A wrapper `/usr/local/bin/chatlogger` may be installed for convenience:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
exec /opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/query.py "$@"
|
||||
```
|
||||
|
||||
## Telegram commands (admin only)
|
||||
|
||||
| Command | Where | Effect |
|
||||
|---------|-------|--------|
|
||||
| `/whichchat` | any chat (admin) | Bot replies with `chat_id`, type, title, allowlist status |
|
||||
| `/log_status` | any chat (admin) | DB size, message counts per chat |
|
||||
| `/recent [N]` | any chat (admin) | Last N messages of the current chat (or all in PM) |
|
||||
| `/search <text>` | any chat (admin) | FTS5 search in the current chat (or all in PM) |
|
||||
|
||||
These are the *only* messages the bot ever sends. Group members other than
|
||||
the admin get no reply at all.
|
||||
|
||||
## Schema (SQLite)
|
||||
|
||||
`messages` (one row per Telegram message), `messages_fts` (FTS5 mirror),
|
||||
`message_edits` (audit log of edits). Unique key `(chat_id, message_id)`
|
||||
makes ingestion idempotent — a Telegram retry never duplicates rows.
|
||||
|
||||
## Phase 2 (later) — Jarvis access
|
||||
|
||||
Add `homelab-ai-bot/tools/chatlog.py` with three tool functions
|
||||
(`chatlog_recent`, `chatlog_search`, `chatlog_summary`) that import
|
||||
`chatlogger.store` and read the same SQLite file directly (CT 116 sees it
|
||||
locally, no API needed). Wire them into `llm.py` routing on keywords
|
||||
like „Gruppe", „Airbnb", chat title, etc.
|
||||
0
chatlogger/__init__.py
Normal file
0
chatlogger/__init__.py
Normal file
25
chatlogger/chatlogger-bot.service
Normal file
25
chatlogger/chatlogger-bot.service
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[Unit]
|
||||
Description=Arakawa Concierge — 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
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/opt/bot-venv/bin/python /opt/homelab-brain/chatlogger/chatlogger_bot.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=10
|
||||
|
||||
# Hardening — bot only needs to read repo + write its own data dir.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/chatlogger
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
385
chatlogger/chatlogger_bot.py
Normal file
385
chatlogger/chatlogger_bot.py
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
"""Arakawa Concierge — passive Telegram group chat logger.
|
||||
|
||||
Reads messages from configured groups, stores them in SQLite,
|
||||
never replies to group messages. Provides /whichchat and /log_status
|
||||
commands for the admin (private chat or in any chat).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from telegram import Update
|
||||
from telegram.constants import ChatType
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
ContextTypes,
|
||||
MessageHandler,
|
||||
filters,
|
||||
)
|
||||
|
||||
from store import Store, format_messages
|
||||
|
||||
LOG = logging.getLogger("chatlogger")
|
||||
|
||||
|
||||
def _env(name: str, default: str | None = None) -> str | None:
|
||||
val = os.environ.get(name, default)
|
||||
return val.strip() if isinstance(val, str) else val
|
||||
|
||||
|
||||
def _env_int(name: str, default: int = 0) -> int:
|
||||
val = _env(name)
|
||||
try:
|
||||
return int(val) if val else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _allowed_chats() -> set[int]:
|
||||
raw = _env("ARAKAWA_BOT_ALLOWED_CHATS", "") or ""
|
||||
out: set[int] = set()
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
out.add(int(part))
|
||||
except ValueError:
|
||||
LOG.warning("Ignoring non-integer chat id in ALLOWED_CHATS: %r", part)
|
||||
return out
|
||||
|
||||
|
||||
CONFIG = {
|
||||
"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),
|
||||
"tz_offset_hours": _env_int("ARAKAWA_BOT_TZ_OFFSET_HOURS", 7),
|
||||
}
|
||||
|
||||
|
||||
def _extract_forward_origin(message) -> str | None:
|
||||
"""Return a short string describing where a forwarded message came from.
|
||||
|
||||
python-telegram-bot 22.x uses MessageOrigin* classes via message.forward_origin.
|
||||
Keep this defensive — older fields may also exist depending on PTB version.
|
||||
"""
|
||||
origin = getattr(message, "forward_origin", None)
|
||||
if origin is not None:
|
||||
sender_user = getattr(origin, "sender_user", None)
|
||||
if sender_user is not None:
|
||||
return getattr(sender_user, "full_name", None) or getattr(sender_user, "username", None)
|
||||
sender_chat = getattr(origin, "sender_chat", None) or getattr(origin, "chat", None)
|
||||
if sender_chat is not None:
|
||||
return getattr(sender_chat, "title", None) or getattr(sender_chat, "username", None)
|
||||
sender_user_name = getattr(origin, "sender_user_name", None)
|
||||
if sender_user_name:
|
||||
return sender_user_name
|
||||
return type(origin).__name__
|
||||
legacy_user = getattr(message, "forward_from", None)
|
||||
if legacy_user is not None:
|
||||
return getattr(legacy_user, "full_name", None)
|
||||
legacy_chat = getattr(message, "forward_from_chat", None)
|
||||
if legacy_chat is not None:
|
||||
return getattr(legacy_chat, "title", None) or getattr(legacy_chat, "username", None)
|
||||
return None
|
||||
|
||||
|
||||
def detect_media_type(message) -> tuple[str | None, str | None]:
|
||||
"""Return (media_type, file_id_or_summary) for the message, if any."""
|
||||
if message.photo:
|
||||
return "photo", message.photo[-1].file_id
|
||||
if message.document:
|
||||
return "document", message.document.file_id
|
||||
if message.voice:
|
||||
return "voice", message.voice.file_id
|
||||
if message.video:
|
||||
return "video", message.video.file_id
|
||||
if message.video_note:
|
||||
return "video_note", message.video_note.file_id
|
||||
if message.audio:
|
||||
return "audio", message.audio.file_id
|
||||
if message.sticker:
|
||||
return "sticker", message.sticker.file_id
|
||||
if message.animation:
|
||||
return "animation", message.animation.file_id
|
||||
if message.location:
|
||||
loc = message.location
|
||||
return "location", f"{loc.latitude},{loc.longitude}"
|
||||
if message.venue:
|
||||
v = message.venue
|
||||
return "venue", f"{v.title} | {v.address}"
|
||||
if message.contact:
|
||||
return "contact", message.contact.phone_number or ""
|
||||
if message.poll:
|
||||
return "poll", message.poll.question
|
||||
if message.dice:
|
||||
return "dice", str(message.dice.value)
|
||||
return None, None
|
||||
|
||||
|
||||
async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
msg = update.effective_message
|
||||
chat = update.effective_chat
|
||||
if msg is None or chat is None:
|
||||
return
|
||||
|
||||
allowed = context.bot_data["allowed_chats"]
|
||||
if chat.id not in allowed:
|
||||
return
|
||||
|
||||
user = msg.from_user
|
||||
media_type, media_payload = detect_media_type(msg)
|
||||
|
||||
forward_from = _extract_forward_origin(msg)
|
||||
|
||||
is_edit = bool(update.edited_message)
|
||||
store: Store = context.bot_data["store"]
|
||||
|
||||
try:
|
||||
raw_json = msg.to_json()
|
||||
except Exception:
|
||||
raw_json = "{}"
|
||||
|
||||
if is_edit:
|
||||
edit_ts = int(msg.edit_date.timestamp()) if msg.edit_date else int(time.time())
|
||||
ok = store.update_message(
|
||||
chat_id=chat.id,
|
||||
message_id=msg.message_id,
|
||||
new_text=msg.text,
|
||||
new_caption=msg.caption,
|
||||
edit_timestamp_utc=edit_ts,
|
||||
)
|
||||
if not ok:
|
||||
store.add_message(
|
||||
chat_id=chat.id,
|
||||
chat_title=chat.title,
|
||||
chat_type=str(chat.type),
|
||||
message_id=msg.message_id,
|
||||
user_id=user.id if user else None,
|
||||
user_first_name=user.first_name if user else None,
|
||||
user_last_name=user.last_name if user else None,
|
||||
user_username=user.username if user else None,
|
||||
text=msg.text,
|
||||
media_type=media_type,
|
||||
media_caption=msg.caption,
|
||||
media_file_id=media_payload,
|
||||
reply_to_message_id=msg.reply_to_message.message_id if msg.reply_to_message else None,
|
||||
forward_from=forward_from,
|
||||
timestamp_utc=edit_ts,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
LOG.info("EDIT chat=%s msg=%s user=%s", chat.id, msg.message_id, user.id if user else None)
|
||||
return
|
||||
|
||||
ts = int(msg.date.timestamp()) if msg.date else int(time.time())
|
||||
inserted = store.add_message(
|
||||
chat_id=chat.id,
|
||||
chat_title=chat.title,
|
||||
chat_type=str(chat.type),
|
||||
message_id=msg.message_id,
|
||||
user_id=user.id if user else None,
|
||||
user_first_name=user.first_name if user else None,
|
||||
user_last_name=user.last_name if user else None,
|
||||
user_username=user.username if user else None,
|
||||
text=msg.text,
|
||||
media_type=media_type,
|
||||
media_caption=msg.caption,
|
||||
media_file_id=media_payload,
|
||||
reply_to_message_id=msg.reply_to_message.message_id if msg.reply_to_message else None,
|
||||
forward_from=forward_from,
|
||||
timestamp_utc=ts,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
LOG.info(
|
||||
"%s chat=%s msg=%s user=%s media=%s text=%r",
|
||||
"STORE" if inserted else "DUP ",
|
||||
chat.id,
|
||||
msg.message_id,
|
||||
user.id if user else None,
|
||||
media_type,
|
||||
(msg.text or msg.caption or "")[:80],
|
||||
)
|
||||
|
||||
|
||||
async def cmd_whichchat(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Reply with current chat id + title. Open to anyone — useful for setup.
|
||||
|
||||
The reply itself is the only message this bot ever sends in a group chat.
|
||||
Restricted to admin user to avoid noise from random people typing /whichchat.
|
||||
"""
|
||||
chat = update.effective_chat
|
||||
user = update.effective_user
|
||||
if chat is None or user is None:
|
||||
return
|
||||
admin = context.bot_data["admin_user_id"]
|
||||
if admin and user.id != admin:
|
||||
return
|
||||
|
||||
allowed = context.bot_data["allowed_chats"]
|
||||
status = "AKTIV (wird geloggt)" if chat.id in allowed else "NICHT konfiguriert"
|
||||
text = (
|
||||
f"chat_id: <code>{chat.id}</code>\n"
|
||||
f"type: {chat.type}\n"
|
||||
f"title: {chat.title or '(privat)'}\n"
|
||||
f"status: {status}"
|
||||
)
|
||||
await context.bot.send_message(chat_id=chat.id, text=text, parse_mode="HTML")
|
||||
|
||||
|
||||
async def cmd_log_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
user = update.effective_user
|
||||
chat = update.effective_chat
|
||||
if chat is None or user is None:
|
||||
return
|
||||
admin = context.bot_data["admin_user_id"]
|
||||
if admin and user.id != admin:
|
||||
return
|
||||
|
||||
store: Store = context.bot_data["store"]
|
||||
s = store.stats()
|
||||
lines = [
|
||||
f"📒 <b>Arakawa Concierge — Status</b>",
|
||||
f"DB: <code>{store.db_path}</code> ({s['db_size_bytes'] / 1024:.1f} KB)",
|
||||
f"Nachrichten gesamt: <b>{s['total_messages']}</b>",
|
||||
f"Edits: {s['total_edits']}",
|
||||
"",
|
||||
"<b>Pro Chat:</b>",
|
||||
]
|
||||
for c in s["chats"]:
|
||||
last = time.strftime("%Y-%m-%d %H:%M", time.gmtime(c["last_ts"] + CONFIG["tz_offset_hours"] * 3600))
|
||||
lines.append(f" • <code>{c['chat_id']}</code> {c['title'] or '?'}: {c['n']} (letzter {last})")
|
||||
if not s["chats"]:
|
||||
lines.append(" (noch keine Nachrichten)")
|
||||
await context.bot.send_message(chat_id=chat.id, text="\n".join(lines), parse_mode="HTML")
|
||||
|
||||
|
||||
async def cmd_recent(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
user = update.effective_user
|
||||
chat = update.effective_chat
|
||||
if chat is None or user is None:
|
||||
return
|
||||
admin = context.bot_data["admin_user_id"]
|
||||
if admin and user.id != admin:
|
||||
return
|
||||
|
||||
args = context.args or []
|
||||
n = 20
|
||||
if args:
|
||||
try:
|
||||
n = max(1, min(200, int(args[0])))
|
||||
except ValueError:
|
||||
pass
|
||||
store: Store = context.bot_data["store"]
|
||||
target_chat = chat.id if chat.type != ChatType.PRIVATE else None
|
||||
msgs = store.recent(chat_id=target_chat, limit=n)
|
||||
if not msgs:
|
||||
await context.bot.send_message(chat_id=chat.id, text="(keine Nachrichten gespeichert)")
|
||||
return
|
||||
text = format_messages(msgs, tz_offset_hours=CONFIG["tz_offset_hours"])
|
||||
for chunk in _chunks(text, 3500):
|
||||
await context.bot.send_message(chat_id=chat.id, text=f"<pre>{_html_escape(chunk)}</pre>", parse_mode="HTML")
|
||||
|
||||
|
||||
async def cmd_search(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
user = update.effective_user
|
||||
chat = update.effective_chat
|
||||
if chat is None or user is None:
|
||||
return
|
||||
admin = context.bot_data["admin_user_id"]
|
||||
if admin and user.id != admin:
|
||||
return
|
||||
|
||||
args = context.args or []
|
||||
if not args:
|
||||
await context.bot.send_message(chat_id=chat.id, text="Nutzung: /search <begriff>", parse_mode="HTML")
|
||||
return
|
||||
query = " ".join(args)
|
||||
store: Store = context.bot_data["store"]
|
||||
target_chat = chat.id if chat.type != ChatType.PRIVATE else None
|
||||
msgs = store.search(query=query, chat_id=target_chat, limit=30)
|
||||
if not msgs:
|
||||
await context.bot.send_message(chat_id=chat.id, text=f"(keine Treffer für „{query}“)")
|
||||
return
|
||||
text = format_messages(msgs, tz_offset_hours=CONFIG["tz_offset_hours"])
|
||||
for chunk in _chunks(text, 3500):
|
||||
await context.bot.send_message(chat_id=chat.id, text=f"<pre>{_html_escape(chunk)}</pre>", parse_mode="HTML")
|
||||
|
||||
|
||||
def _chunks(text: str, max_len: int):
|
||||
if len(text) <= max_len:
|
||||
yield text
|
||||
return
|
||||
cur = []
|
||||
cur_len = 0
|
||||
for line in text.split("\n"):
|
||||
if cur_len + len(line) + 1 > max_len and cur:
|
||||
yield "\n".join(cur)
|
||||
cur = [line]
|
||||
cur_len = len(line) + 1
|
||||
else:
|
||||
cur.append(line)
|
||||
cur_len += len(line) + 1
|
||||
if cur:
|
||||
yield "\n".join(cur)
|
||||
|
||||
|
||||
def _html_escape(s: str) -> str:
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def build_app() -> Application:
|
||||
if not CONFIG["token"]:
|
||||
raise SystemExit("ARAKAWA_BOT_TOKEN missing in environment")
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
app = Application.builder().token(CONFIG["token"]).build()
|
||||
app.bot_data["store"] = store
|
||||
app.bot_data["allowed_chats"] = allowed
|
||||
app.bot_data["admin_user_id"] = CONFIG["admin_user_id"]
|
||||
|
||||
app.add_handler(CommandHandler("whichchat", cmd_whichchat))
|
||||
app.add_handler(CommandHandler("log_status", cmd_log_status))
|
||||
app.add_handler(CommandHandler("recent", cmd_recent))
|
||||
app.add_handler(CommandHandler("search", cmd_search))
|
||||
|
||||
msg_filter = (
|
||||
filters.ChatType.GROUPS | filters.ChatType.SUPERGROUP | filters.ChatType.PRIVATE
|
||||
) & ~filters.COMMAND
|
||||
app.add_handler(MessageHandler(msg_filter, on_message))
|
||||
app.add_handler(MessageHandler(filters.UpdateType.EDITED_MESSAGE & ~filters.COMMAND, on_message))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("telegram").setLevel(logging.WARNING)
|
||||
|
||||
app = build_app()
|
||||
app.run_polling(
|
||||
allowed_updates=["message", "edited_message", "channel_post", "my_chat_member"],
|
||||
drop_pending_updates=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
chatlogger/env.example
Normal file
20
chatlogger/env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# /opt/chatlogger/env — install on CT 116 with chmod 600
|
||||
# NOT committed to git. Loaded via systemd EnvironmentFile.
|
||||
|
||||
# Telegram bot token from BotFather (@arakawa_concierge_bot)
|
||||
ARAKAWA_BOT_TOKEN=PASTE_TOKEN_HERE
|
||||
|
||||
# Comma-separated list of chat ids the bot is allowed to log.
|
||||
# Anything else is silently ignored. Get the id by adding the bot to a chat
|
||||
# and (as admin) sending /whichchat.
|
||||
ARAKAWA_BOT_ALLOWED_CHATS=-1003924901022
|
||||
|
||||
# Telegram user id allowed to use admin commands (/log_status, /recent, /search).
|
||||
# Same id used for the Hausmeister bot in homelab.conf as TG_CHAT_ID.
|
||||
ARAKAWA_BOT_ADMIN_USER_ID=674951792
|
||||
|
||||
# Time zone offset in hours (Phnom Penh = +7). Used for human-readable timestamps.
|
||||
ARAKAWA_BOT_TZ_OFFSET_HOURS=7
|
||||
|
||||
# SQLite path — only this directory is writable for the systemd unit.
|
||||
ARAKAWA_BOT_DB=/opt/chatlogger/chats.db
|
||||
143
chatlogger/query.py
Normal file
143
chatlogger/query.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""CLI access to the chatlogger SQLite database.
|
||||
|
||||
Usage:
|
||||
chatlogger recent [--chat <id>] [-n 50]
|
||||
chatlogger search "query string" [--chat <id>] [-n 30]
|
||||
chatlogger stats
|
||||
chatlogger chats
|
||||
chatlogger since <minutes> [--chat <id>]
|
||||
chatlogger export [--chat <id>] [--since-ts N] # JSONL to stdout
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from store import Store, format_messages
|
||||
|
||||
DEFAULT_DB = os.environ.get("ARAKAWA_BOT_DB", "/opt/chatlogger/chats.db")
|
||||
TZ_OFFSET_HOURS = int(os.environ.get("ARAKAWA_BOT_TZ_OFFSET_HOURS", "7"))
|
||||
|
||||
|
||||
def cmd_recent(args, store: Store) -> int:
|
||||
msgs = store.recent(chat_id=args.chat, limit=args.n)
|
||||
if not msgs:
|
||||
print("(no messages)")
|
||||
return 0
|
||||
print(format_messages(msgs, tz_offset_hours=TZ_OFFSET_HOURS))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_search(args, store: Store) -> int:
|
||||
msgs = store.search(query=args.query, chat_id=args.chat, limit=args.n)
|
||||
if not msgs:
|
||||
print(f"(no matches for {args.query!r})")
|
||||
return 0
|
||||
print(format_messages(msgs, tz_offset_hours=TZ_OFFSET_HOURS))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_stats(args, store: Store) -> int:
|
||||
s = store.stats()
|
||||
print(f"Total messages: {s['total_messages']}")
|
||||
print(f"Total edits: {s['total_edits']}")
|
||||
print(f"DB size: {s['db_size_bytes'] / 1024:.1f} KB")
|
||||
print()
|
||||
print("Per chat:")
|
||||
for c in s["chats"]:
|
||||
last = time.strftime("%Y-%m-%d %H:%M", time.gmtime(c["last_ts"] + TZ_OFFSET_HOURS * 3600))
|
||||
print(f" {c['chat_id']:>20} {c['n']:>6} last={last} title={c['title'] or '?'}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_chats(args, store: Store) -> int:
|
||||
rows = store.chats()
|
||||
if not rows:
|
||||
print("(no chats)")
|
||||
return 0
|
||||
for c in rows:
|
||||
last = time.strftime("%Y-%m-%d %H:%M", time.gmtime(c["last_ts"] + TZ_OFFSET_HOURS * 3600))
|
||||
print(f"{c['chat_id']:>20} {c['type']:<12} count={c['n']:<5} last={last} {c['title'] or ''}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_since(args, store: Store) -> int:
|
||||
since_ts = int(time.time()) - args.minutes * 60
|
||||
msgs = store.since(chat_id=args.chat, since_ts=since_ts, limit=10_000)
|
||||
if not msgs:
|
||||
print("(no messages in window)")
|
||||
return 0
|
||||
print(format_messages(msgs, tz_offset_hours=TZ_OFFSET_HOURS))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_export(args, store: Store) -> int:
|
||||
if args.since_ts:
|
||||
msgs = store.since(chat_id=args.chat, since_ts=args.since_ts, limit=1_000_000)
|
||||
else:
|
||||
msgs = store.recent(chat_id=args.chat, limit=1_000_000)
|
||||
for m in msgs:
|
||||
print(json.dumps({
|
||||
"id": m.id,
|
||||
"chat_id": m.chat_id,
|
||||
"chat_title": m.chat_title,
|
||||
"message_id": m.message_id,
|
||||
"user_id": m.user_id,
|
||||
"user_display": m.user_display,
|
||||
"text": m.text,
|
||||
"media_type": m.media_type,
|
||||
"media_caption": m.media_caption,
|
||||
"reply_to_message_id": m.reply_to_message_id,
|
||||
"timestamp_utc": m.timestamp_utc,
|
||||
"edit_timestamp_utc": m.edit_timestamp_utc,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="chatlogger", description="Arakawa Concierge chat log query tool")
|
||||
p.add_argument("--db", default=DEFAULT_DB, help=f"SQLite path (default: {DEFAULT_DB})")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
r = sub.add_parser("recent", help="Last N messages (chronological)")
|
||||
r.add_argument("--chat", type=int, default=None)
|
||||
r.add_argument("-n", type=int, default=50)
|
||||
r.set_defaults(func=cmd_recent)
|
||||
|
||||
s = sub.add_parser("search", help="FTS5 search")
|
||||
s.add_argument("query")
|
||||
s.add_argument("--chat", type=int, default=None)
|
||||
s.add_argument("-n", type=int, default=30)
|
||||
s.set_defaults(func=cmd_search)
|
||||
|
||||
st = sub.add_parser("stats", help="DB statistics")
|
||||
st.set_defaults(func=cmd_stats)
|
||||
|
||||
ch = sub.add_parser("chats", help="List known chats")
|
||||
ch.set_defaults(func=cmd_chats)
|
||||
|
||||
si = sub.add_parser("since", help="Messages from the last N minutes")
|
||||
si.add_argument("minutes", type=int)
|
||||
si.add_argument("--chat", type=int, default=None)
|
||||
si.set_defaults(func=cmd_since)
|
||||
|
||||
ex = sub.add_parser("export", help="Export as JSONL")
|
||||
ex.add_argument("--chat", type=int, default=None)
|
||||
ex.add_argument("--since-ts", type=int, default=None)
|
||||
ex.set_defaults(func=cmd_export)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
store = Store(args.db)
|
||||
return args.func(args, store)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
290
chatlogger/store.py
Normal file
290
chatlogger/store.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"""SQLite store for the Arakawa Concierge chat logger.
|
||||
|
||||
One DB file holds all watched groups. Schema is created on first open.
|
||||
Full-text search via FTS5 across message text, captions, sender names.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
chat_title TEXT,
|
||||
chat_type TEXT,
|
||||
message_id INTEGER NOT NULL,
|
||||
user_id INTEGER,
|
||||
user_first_name TEXT,
|
||||
user_last_name TEXT,
|
||||
user_username TEXT,
|
||||
text TEXT,
|
||||
media_type TEXT,
|
||||
media_caption TEXT,
|
||||
media_file_id TEXT,
|
||||
reply_to_message_id INTEGER,
|
||||
forward_from TEXT,
|
||||
timestamp_utc INTEGER NOT NULL,
|
||||
edit_timestamp_utc INTEGER,
|
||||
raw_json TEXT,
|
||||
UNIQUE(chat_id, message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_ts ON messages(chat_id, timestamp_utc);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_ts ON messages(user_id, timestamp_utc);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
text, media_caption, user_first_name, user_username,
|
||||
content='messages', content_rowid='id',
|
||||
tokenize='unicode61 remove_diacritics 1'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts(rowid, text, media_caption, user_first_name, user_username)
|
||||
VALUES (new.id, new.text, new.media_caption, new.user_first_name, new.user_username);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, text, media_caption, user_first_name, user_username)
|
||||
VALUES ('delete', old.id, old.text, old.media_caption, old.user_first_name, old.user_username);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
||||
INSERT INTO messages_fts(messages_fts, rowid, text, media_caption, user_first_name, user_username)
|
||||
VALUES ('delete', old.id, old.text, old.media_caption, old.user_first_name, old.user_username);
|
||||
INSERT INTO messages_fts(rowid, text, media_caption, user_first_name, user_username)
|
||||
VALUES (new.id, new.text, new.media_caption, new.user_first_name, new.user_username);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_edits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
text_before TEXT,
|
||||
text_after TEXT,
|
||||
edit_timestamp_utc INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_edits_msg ON message_edits(chat_id, message_id);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredMessage:
|
||||
id: int
|
||||
chat_id: int
|
||||
chat_title: str | None
|
||||
message_id: int
|
||||
user_id: int | None
|
||||
user_display: str
|
||||
text: str | None
|
||||
media_type: str | None
|
||||
media_caption: str | None
|
||||
reply_to_message_id: int | None
|
||||
timestamp_utc: int
|
||||
edit_timestamp_utc: int | None
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
||||
with self._conn() as c:
|
||||
c.executescript(SCHEMA)
|
||||
c.execute("PRAGMA journal_mode=WAL")
|
||||
c.execute("PRAGMA synchronous=NORMAL")
|
||||
|
||||
@contextmanager
|
||||
def _conn(self):
|
||||
conn = sqlite3.connect(self.db_path, timeout=10, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _user_display(row: sqlite3.Row | dict[str, Any]) -> str:
|
||||
first = (row["user_first_name"] or "").strip()
|
||||
last = (row["user_last_name"] or "").strip()
|
||||
uname = (row["user_username"] or "").strip()
|
||||
full = " ".join(p for p in (first, last) if p)
|
||||
if full and uname:
|
||||
return f"{full} (@{uname})"
|
||||
return full or (f"@{uname}" if uname else f"user:{row['user_id']}")
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
chat_title: str | None,
|
||||
chat_type: str | None,
|
||||
message_id: int,
|
||||
user_id: int | None,
|
||||
user_first_name: str | None,
|
||||
user_last_name: str | None,
|
||||
user_username: str | None,
|
||||
text: str | None,
|
||||
media_type: str | None,
|
||||
media_caption: str | None,
|
||||
media_file_id: str | None,
|
||||
reply_to_message_id: int | None,
|
||||
forward_from: str | None,
|
||||
timestamp_utc: int,
|
||||
raw_json: str,
|
||||
) -> bool:
|
||||
with self._conn() as c:
|
||||
try:
|
||||
c.execute(
|
||||
"""INSERT INTO messages (
|
||||
chat_id, chat_title, chat_type, message_id,
|
||||
user_id, user_first_name, user_last_name, user_username,
|
||||
text, media_type, media_caption, media_file_id,
|
||||
reply_to_message_id, forward_from, timestamp_utc, raw_json
|
||||
) VALUES (?,?,?,?, ?,?,?,?, ?,?,?,?, ?,?,?,?)""",
|
||||
(
|
||||
chat_id, chat_title, chat_type, message_id,
|
||||
user_id, user_first_name, user_last_name, user_username,
|
||||
text, media_type, media_caption, media_file_id,
|
||||
reply_to_message_id, forward_from, timestamp_utc, raw_json,
|
||||
),
|
||||
)
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
return False
|
||||
|
||||
def update_message(
|
||||
self,
|
||||
*,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
new_text: str | None,
|
||||
new_caption: str | None,
|
||||
edit_timestamp_utc: int,
|
||||
) -> bool:
|
||||
with self._conn() as c:
|
||||
row = c.execute(
|
||||
"SELECT id, text, media_caption FROM messages WHERE chat_id=? AND message_id=?",
|
||||
(chat_id, message_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
text_before = row["text"] or row["media_caption"]
|
||||
text_after = new_text if new_text is not None else new_caption
|
||||
c.execute(
|
||||
"""UPDATE messages SET text=?, media_caption=?, edit_timestamp_utc=?
|
||||
WHERE chat_id=? AND message_id=?""",
|
||||
(new_text, new_caption, edit_timestamp_utc, chat_id, message_id),
|
||||
)
|
||||
c.execute(
|
||||
"""INSERT INTO message_edits (chat_id, message_id, text_before, text_after, edit_timestamp_utc)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(chat_id, message_id, text_before, text_after, edit_timestamp_utc),
|
||||
)
|
||||
return True
|
||||
|
||||
def recent(self, chat_id: int | None = None, limit: int = 50) -> list[StoredMessage]:
|
||||
with self._conn() as c:
|
||||
if chat_id is None:
|
||||
rows = c.execute(
|
||||
"SELECT * FROM messages ORDER BY timestamp_utc DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = c.execute(
|
||||
"SELECT * FROM messages WHERE chat_id=? ORDER BY timestamp_utc DESC LIMIT ?",
|
||||
(chat_id, limit),
|
||||
).fetchall()
|
||||
return [self._row_to_msg(r) for r in reversed(rows)]
|
||||
|
||||
def since(self, chat_id: int | None, since_ts: int, limit: int = 1000) -> list[StoredMessage]:
|
||||
with self._conn() as c:
|
||||
if chat_id is None:
|
||||
rows = c.execute(
|
||||
"SELECT * FROM messages WHERE timestamp_utc >= ? ORDER BY timestamp_utc ASC LIMIT ?",
|
||||
(since_ts, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = c.execute(
|
||||
"SELECT * FROM messages WHERE chat_id=? AND timestamp_utc >= ? ORDER BY timestamp_utc ASC LIMIT ?",
|
||||
(chat_id, since_ts, limit),
|
||||
).fetchall()
|
||||
return [self._row_to_msg(r) for r in rows]
|
||||
|
||||
def search(self, query: str, chat_id: int | None = None, limit: int = 30) -> list[StoredMessage]:
|
||||
with self._conn() as c:
|
||||
sql = (
|
||||
"SELECT m.* FROM messages m "
|
||||
"JOIN messages_fts f ON f.rowid = m.id "
|
||||
"WHERE messages_fts MATCH ?"
|
||||
)
|
||||
params: list[Any] = [query]
|
||||
if chat_id is not None:
|
||||
sql += " AND m.chat_id = ?"
|
||||
params.append(chat_id)
|
||||
sql += " ORDER BY m.timestamp_utc DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
rows = c.execute(sql, params).fetchall()
|
||||
return [self._row_to_msg(r) for r in rows]
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
with self._conn() as c:
|
||||
total = c.execute("SELECT COUNT(*) AS n FROM messages").fetchone()["n"]
|
||||
per_chat = c.execute(
|
||||
"""SELECT chat_id, MAX(chat_title) AS title, COUNT(*) AS n,
|
||||
MIN(timestamp_utc) AS first_ts, MAX(timestamp_utc) AS last_ts
|
||||
FROM messages GROUP BY chat_id ORDER BY n DESC"""
|
||||
).fetchall()
|
||||
edits = c.execute("SELECT COUNT(*) AS n FROM message_edits").fetchone()["n"]
|
||||
return {
|
||||
"total_messages": total,
|
||||
"total_edits": edits,
|
||||
"chats": [dict(r) for r in per_chat],
|
||||
"db_size_bytes": os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0,
|
||||
}
|
||||
|
||||
def chats(self) -> list[dict[str, Any]]:
|
||||
with self._conn() as c:
|
||||
rows = c.execute(
|
||||
"""SELECT chat_id, MAX(chat_title) AS title, MAX(chat_type) AS type,
|
||||
COUNT(*) AS n, MAX(timestamp_utc) AS last_ts
|
||||
FROM messages GROUP BY chat_id ORDER BY last_ts DESC"""
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def _row_to_msg(self, row: sqlite3.Row) -> StoredMessage:
|
||||
return StoredMessage(
|
||||
id=row["id"],
|
||||
chat_id=row["chat_id"],
|
||||
chat_title=row["chat_title"],
|
||||
message_id=row["message_id"],
|
||||
user_id=row["user_id"],
|
||||
user_display=self._user_display(row),
|
||||
text=row["text"],
|
||||
media_type=row["media_type"],
|
||||
media_caption=row["media_caption"],
|
||||
reply_to_message_id=row["reply_to_message_id"],
|
||||
timestamp_utc=row["timestamp_utc"],
|
||||
edit_timestamp_utc=row["edit_timestamp_utc"],
|
||||
)
|
||||
|
||||
|
||||
def format_messages(msgs: Iterable[StoredMessage], tz_offset_hours: int = 0) -> str:
|
||||
"""Render messages as a chat-style transcript (chronological)."""
|
||||
out: list[str] = []
|
||||
for m in msgs:
|
||||
ts = time.gmtime(m.timestamp_utc + tz_offset_hours * 3600)
|
||||
stamp = time.strftime("%Y-%m-%d %H:%M", ts)
|
||||
body = m.text or m.media_caption or (f"[{m.media_type}]" if m.media_type else "")
|
||||
if m.media_type and (m.text or m.media_caption):
|
||||
body = f"[{m.media_type}] {body}"
|
||||
reply = f" ↩#{m.reply_to_message_id}" if m.reply_to_message_id else ""
|
||||
edit = " (bearbeitet)" if m.edit_timestamp_utc else ""
|
||||
out.append(f"[{stamp}] {m.user_display}{reply}{edit}: {body}")
|
||||
return "\n".join(out)
|
||||
Loading…
Add table
Reference in a new issue