"""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 = {
"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),
"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: {chat.id}\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"π Arakawa Concierge β Status",
f"DB: {store.db_path} ({s['db_size_bytes'] / 1024:.1f} KB)",
f"Nachrichten gesamt: {s['total_messages']}",
f"Edits: {s['total_edits']}",
"",
"Pro Chat:",
]
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" β’ {c['chat_id']} {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"
{_html_escape(chunk)}", 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"{_html_escape(chunk)}", 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[%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()
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()