diff --git a/.gitignore b/.gitignore index 36b13f1..75246c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,23 @@ -# ---> Python -# Byte-compiled / optimized / DLL files +# Python (core) __pycache__/ *.py[cod] *$py.class +*.pyc +*.pyo +*.pyd -# C extensions -*.so +# Environments / virtual envs +.env +.env/ +.venv/ +venv/ +ENV/ +env/ +env.bak/ +venv.bak/ -# Distribution / packaging -.Python +# Packaging / build artifacts build/ -develop-eggs/ dist/ downloads/ eggs/ @@ -21,156 +28,51 @@ parts/ sdist/ var/ wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg *.egg MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ +# Test / coverage .tox/ .nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ +.coverage* .pytest_cache/ -cover/ +.hypothesis/ +coverage.xml +htmlcov/ +cache/ -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy +# Tool caches +.ruff_cache/ .mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker +.pytype/ .pyre/ -# pytype static type analyzer -.pytype/ +# Jupyter +.ipynb_checkpoints -# Cython debug symbols -cython_debug/ +# IDE / Editor +.vscode/ +.idea/ +*.swp -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Logs +*.log -# Ruff stuff: -.ruff_cache/ +# Local config not to commit +config.yaml -# PyPI configuration file +# Data persistence (volume) +/data/ +*/counters.json + +# System / OS +.DS_Store +Thumbs.db + +# Secrets +.env.local .pypirc +# Project extras +Dockerfile.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4d851d7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Multi-stage für kleinere finale Imagegröße +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# System deps (tzdata optional falls benötigt) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user +RUN useradd -u 10001 -m appuser + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY bot.py ./ +COPY config.example.yaml ./config.yaml + +# Datenverzeichnis +RUN mkdir -p /data && chown -R appuser:appuser /data && chown appuser:appuser /app +VOLUME ["/data"] + +USER appuser + +ENV DATA_DIR=/data \ + CONFIG_FILE=/app/config.yaml + +CMD ["python", "bot.py"] diff --git a/README.md b/README.md index b83a306..d448922 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,110 @@ -# GigalativBot +# GigalativBot (Telegram Counter Bot) +Ein schlanker Telegram-Bot zum Verwalten einfacher Integer-Counter. Läuft per Long Polling in einem Docker-Container. + +## Features +- /add – legt Counter (0) an +- /remove – löscht Counter +- /increment – erhöht Counter um 1 (Antwort mit neuem Wert) +- /decrement – verringert Counter um 1 (Antwort mit neuem Wert) +- /list – zeigt alle Counter +- /help – Übersicht +- Counter-Namen case-insensitive (intern lowercase gespeichert) +- Persistenz als JSON unter `/data/counters.json` (Docker Volume) +- Zugriffsbeschränkung via allowed_chat_ids in `config.yaml` +- Startup-Benachrichtigung an definierte Chats via ENV `STARTUP_ANNOUNCE_CHAT_IDS` +- Begrüßung neuer Mitglieder (optional) via ENV `ANNOUNCE_ALL_JOINS=true` +- Automatische Willkommens-Nachricht, wenn der Bot einer Gruppe hinzugefügt wird + +## Struktur +``` +. +├─ bot.py +├─ requirements.txt +├─ Dockerfile +├─ docker-compose.yaml +├─ config.example.yaml (umbenennen zu config.yaml und anpassen) +└─ README.md +``` + +## Konfiguration +Passe `config.example.yaml` an und benenne sie zu `config.yaml`. + +Beispiel: +```yaml +bot_token: "123456:ABCDEF..." # oder via ENV BOT_TOKEN +allowed_chat_ids: [123456789, -1001122334455] +initial_counters: + beispiel: 5 +log_level: INFO +``` +Wenn `allowed_chat_ids` leer oder fehlt, sind alle Chats erlaubt. + +### Wichtige Environment Variablen +| Variable | Beschreibung | Beispiel | +|----------|--------------|----------| +| BOT_TOKEN | Telegram Bot Token (sensitiv, nicht committen) | 123456:ABCDEF | +| STARTUP_ANNOUNCE_CHAT_IDS | Kommagetrennte Liste von Chat IDs für Start-Meldung | -4549916385,-1001122334455 | +| ANNOUNCE_ALL_JOINS | `true` um alle neuen Mitglieder zu begrüßen | true | +| CONFIG_FILE | Pfad zur Config Datei | /app/config.yaml | +| DATA_DIR | Verzeichnis für Persistenz | /data | + +## Start (Docker Compose) +Setze dein Bot Token als Environment Variable oder trage es in der config.yaml ein. + +### Variante 1: Token via .env +Datei `.env` anlegen: +``` +BOT_TOKEN=123456:ABCDEF... +``` +Dann: +``` +docker compose up -d --build +``` + +### Variante 2: Token in config.yaml +Trage `bot_token:` dort ein und entferne den `BOT_TOKEN` Eintrag aus `docker-compose.yaml` oder lass die ENV leer. + +### Startup Ankündigung aktivieren +Füge in `.env` hinzu (optional): +``` +STARTUP_ANNOUNCE_CHAT_IDS=-4549916385 +ANNOUNCE_ALL_JOINS=true +``` + +## Persistenz / Volume +Alle Counter werden nach jeder Änderung in `/data/counters.json` gespeichert. Das Volume `counterbot_data` stellt dauerhafte Speicherung sicher. + +## Sicherheit +- Non-root User `appuser` +- Kein Port-Expose (Long Polling) + +## Logging +Standard: INFO. Anpassbar über `log_level` in der Config. + +Antwort-Ausgabe nutzt jetzt HTML-Formatierung (statt MarkdownV2) um Parse-Fehler bei Sonderzeichen zu vermeiden. + +## Beispiel-Interaktion +``` +/add test +Counter test wurde angelegt (Wert 0). +/increment test +Counter test steht jetzt auf 1. +/list +Aktuelle Counter: +- test: 1 +/remove test +Counter test wurde gelöscht. +``` + +## Erweiterungsideen +- /set +- /rename +- Export als CSV/JSON über /export +- Webhook-Modus + Traefik Labels +- Rate Limiting pro User + +## Lizenz +Keine explizite Lizenz enthalten (privat). Ergänze bei Bedarf. + +Viel Spaß! diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..d9901e5 --- /dev/null +++ b/bot.py @@ -0,0 +1,336 @@ +import os +import json +import yaml +import logging +import signal +import sys +from pathlib import Path +from typing import Dict, Any, Optional + +from telegram import Update +from telegram.constants import ParseMode +from telegram.ext import ( + ApplicationBuilder, + CommandHandler, + ContextTypes, + MessageHandler, + filters, + ChatMemberHandler, +) + +DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) +COUNTERS_FILE = DATA_DIR / "counters.json" +CONFIG_FILE = Path(os.environ.get("CONFIG_FILE", "config.yaml")) + +logger = logging.getLogger("counter_bot") + +# ---------------------- Persistence Layer ---------------------- + +def load_config() -> Dict[str, Any]: + if not CONFIG_FILE.exists(): + logger.warning("Konfigurationsdatei %s nicht gefunden. Nutze Umgebungsvariablen / Defaults.", CONFIG_FILE) + return {} + with CONFIG_FILE.open("r", encoding="utf-8") as f: + try: + return yaml.safe_load(f) or {} + except yaml.YAMLError as e: + logger.error("Fehler beim Lesen der Konfigurationsdatei: %s", e) + return {} + + +def load_counters() -> Dict[str, int]: + if not COUNTERS_FILE.exists(): + return {} + try: + with COUNTERS_FILE.open("r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + # ensure int values + fixed = {} + for k, v in data.items(): + try: + fixed[k] = int(v) + except (ValueError, TypeError): + logger.warning("Ungültiger Wert für Counter %s -> %s, setze 0", k, v) + fixed[k] = 0 + return fixed + return {} + except json.JSONDecodeError: + logger.error("Konnte %s nicht lesen (JSON Fehler). Starte mit leerem Satz.", COUNTERS_FILE) + return {} + + +def atomic_write(path: Path, content: str) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as f: + f.write(content) + tmp.replace(path) + + +def save_counters(counters: Dict[str, int]) -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + atomic_write(COUNTERS_FILE, json.dumps(counters, ensure_ascii=False, sort_keys=True, indent=2)) + +# ---------------------- Access Control ---------------------- + +def is_allowed(chat_id: int, allowed_list: Optional[list]) -> bool: + if not allowed_list: # None oder leere Liste => alle erlaubt + return True + return chat_id in allowed_list + +# ---------------------- Command Handlers ---------------------- + +HELP_TEXT = ( + "Verfügbare Befehle:\n" + "/add - Legt einen neuen Counter mit Wert 0 an\n" + "/remove - Löscht einen Counter\n" + "/increment - Erhöht den Counter um 1 und zeigt neuen Wert\n" + "/decrement - Verringert den Counter um 1 und zeigt neuen Wert\n" + "/list - Listet alle Counter\n" + "/help - Zeigt diese Hilfe an" +) + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + await update.message.reply_text(HELP_TEXT) + + +def norm_key(raw: str) -> str: + return raw.strip().lower() + + +def html_escape(text: str) -> str: + return (text + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + ) + + +async def add_counter(update: Update, context: ContextTypes.DEFAULT_TYPE): + chat_id = update.effective_chat.id + if not context.bot_data['access'](chat_id): + return + if not context.args: + await update.message.reply_text("Bitte einen Counternamen angeben: /add ") + return + key = norm_key(context.args[0]) + counters = context.bot_data['counters'] + if key in counters: + await update.message.reply_text(f"Counter {html_escape(key)} existiert bereits.", parse_mode=ParseMode.HTML) + return + counters[key] = 0 + save_counters(counters) + await update.message.reply_text(f"Counter {html_escape(key)} wurde angelegt (Wert 0).", parse_mode=ParseMode.HTML) + + +async def remove_counter(update: Update, context: ContextTypes.DEFAULT_TYPE): + chat_id = update.effective_chat.id + if not context.bot_data['access'](chat_id): + return + if not context.args: + await update.message.reply_text("Bitte einen Counternamen angeben: /remove ") + return + key = norm_key(context.args[0]) + counters = context.bot_data['counters'] + if key not in counters: + await update.message.reply_text(f"Counter {html_escape(key)} existiert nicht.", parse_mode=ParseMode.HTML) + return + del counters[key] + save_counters(counters) + await update.message.reply_text(f"Counter {html_escape(key)} wurde gelöscht.", parse_mode=ParseMode.HTML) + + +async def increment_counter(update: Update, context: ContextTypes.DEFAULT_TYPE): + chat_id = update.effective_chat.id + if not context.bot_data['access'](chat_id): + return + if not context.args: + await update.message.reply_text("Bitte einen Counternamen angeben: /increment ") + return + key = norm_key(context.args[0]) + counters = context.bot_data['counters'] + if key not in counters: + esc = html_escape(key) + await update.message.reply_text(f"Counter {esc} existiert nicht. Lege ihn mit /add {esc} an.", parse_mode=ParseMode.HTML) + return + counters[key] += 1 + save_counters(counters) + await update.message.reply_text(f"Counter {html_escape(key)} steht jetzt auf {counters[key]}.", parse_mode=ParseMode.HTML) + + +async def decrement_counter(update: Update, context: ContextTypes.DEFAULT_TYPE): + chat_id = update.effective_chat.id + if not context.bot_data['access'](chat_id): + return + if not context.args: + await update.message.reply_text("Bitte einen Counternamen angeben: /decrement ") + return + key = norm_key(context.args[0]) + counters = context.bot_data['counters'] + if key not in counters: + esc = html_escape(key) + await update.message.reply_text(f"Counter {esc} existiert nicht. Lege ihn mit /add {esc} an.", parse_mode=ParseMode.HTML) + return + counters[key] -= 1 + save_counters(counters) + await update.message.reply_text(f"Counter {html_escape(key)} steht jetzt auf {counters[key]}.", parse_mode=ParseMode.HTML) + + +async def list_counters(update: Update, context: ContextTypes.DEFAULT_TYPE): + chat_id = update.effective_chat.id + if not context.bot_data['access'](chat_id): + return + counters = context.bot_data['counters'] + if not counters: + await update.message.reply_text("Keine Counter vorhanden. Lege einen an mit /add .") + return + lines = ["Aktuelle Counter:"] + for k in sorted(counters.keys()): + lines.append(f"- {html_escape(k)}: {counters[k]}") + await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) + + +async def unknown(update: Update, context: ContextTypes.DEFAULT_TYPE): + await update.message.reply_text("Unbekannter Befehl. Nutze /help für eine Übersicht.") + + +async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE): + logger.error("Fehler während Update Verarbeitung", exc_info=context.error) + # Optionale kurze Rückmeldung nur bei klassischen Nachrichten + try: + if isinstance(update, Update) and update.effective_chat: + await context.bot.send_message(update.effective_chat.id, "⚠️ Interner Fehler beim Verarbeiten der Anfrage.") + except Exception: + pass + + +# ---------------------- Group Events ---------------------- + +async def new_members(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Send a ready message when the bot itself is added to a group or any new member joins (configurable).""" + if update.message is None or not update.message.new_chat_members: + return + bot_user = (await context.bot.get_me()) + announce_all = os.environ.get("ANNOUNCE_ALL_JOINS", "false").lower() in {"1", "true", "yes"} + for member in update.message.new_chat_members: + if member.id == bot_user.id: + await update.message.reply_text("🤖 Bot ist bereit! Verwende /help für Befehle.") + elif announce_all: + await update.message.reply_text(f"Willkommen {member.full_name}! Tippe /help für Befehle.") + + +async def debug_all_messages(update: Update, context: ContextTypes.DEFAULT_TYPE): + if update.message: + logger.debug("Empfangene Nachricht chat=%s user=%s text=%r", update.effective_chat.id, update.effective_user.id if update.effective_user else None, update.message.text) + + +async def my_chat_member(update: Update, context: ContextTypes.DEFAULT_TYPE): + # Fired when bot's status in a chat changes (e.g., added, removed, promoted) + chat = update.effective_chat + diff = update.my_chat_member + if diff: + logger.info("my_chat_member update chat=%s old=%s new=%s", chat.id if chat else None, diff.old_chat_member.status, diff.new_chat_member.status) + # If bot was just added / became member + try: + if diff.new_chat_member.status in {"member", "administrator"}: + await context.bot.send_message(chat.id, "🤖 Bot ist jetzt aktiv in diesem Chat. /help für Befehle.") + except Exception as e: + logger.warning("Fehler beim Senden der Aktiv-Nachricht: %s", e) + +# ---------------------- Setup & Main ---------------------- + +def setup_logging(level: str): + numeric = getattr(logging, level.upper(), logging.INFO) + logging.basicConfig(stream=sys.stdout, level=numeric, format='%(asctime)s %(levelname)s %(name)s: %(message)s') + + +def init_counters(existing: Dict[str, int], config: Dict[str, Any]) -> Dict[str, int]: + if existing: + return existing + initial = config.get('initial_counters') or {} + normalized = {norm_key(k): int(v) for k, v in initial.items()} + if normalized: + save_counters(normalized) + return normalized + +async def on_startup(app): + logger.info("Bot gestartet und bereit.") + announce_ids = os.environ.get("STARTUP_ANNOUNCE_CHAT_IDS") + if announce_ids: + ids = [] + for raw in announce_ids.split(','): + raw = raw.strip() + if not raw: + continue + try: + ids.append(int(raw)) + except ValueError: + logger.warning("Kann Chat ID %s nicht in int umwandeln", raw) + if ids: + me = await app.bot.get_me() + for cid in ids: + try: + await app.bot.send_message(cid, f"🤖 {me.first_name} ist bereit. Nutze /help für Befehle.") + except Exception as e: + logger.warning("Konnte Startup-Nachricht an %s nicht senden: %s", cid, e) + + +def main(): + config = load_config() + + token = os.environ.get('BOT_TOKEN') or config.get('bot_token') + if not token: + print("Fehlendes Bot Token: Setze BOT_TOKEN env oder bot_token in config.yaml", file=sys.stderr) + sys.exit(1) + + setup_logging(config.get('log_level', 'INFO')) + + counters = load_counters() + counters = init_counters(counters, config) + + allowed_chat_ids = config.get('allowed_chat_ids') + if allowed_chat_ids is not None: + try: + allowed_chat_ids = [int(x) for x in allowed_chat_ids] + except Exception: + logger.error("allowed_chat_ids in config.yaml müssen Ganzzahlen sein") + allowed_chat_ids = None + + def access(chat_id: int) -> bool: + if not is_allowed(chat_id, allowed_chat_ids): + logger.info("Verweigerter Zugriff von Chat %s", chat_id) + return False + return True + + application = ApplicationBuilder().token(token).build() + + # bot_data shared state + application.bot_data['counters'] = counters + application.bot_data['access'] = access + + application.add_handler(CommandHandler("help", help_command)) + application.add_handler(CommandHandler("add", add_counter)) + application.add_handler(CommandHandler("remove", remove_counter)) + application.add_handler(CommandHandler("increment", increment_counter)) + application.add_handler(CommandHandler("decrement", decrement_counter)) + application.add_handler(CommandHandler("list", list_counters)) + # Group / membership events + application.add_handler(MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, new_members)) + application.add_handler(ChatMemberHandler(my_chat_member, ChatMemberHandler.MY_CHAT_MEMBER)) + # Debug raw messages (placed last with low priority) + application.add_handler(MessageHandler(filters.ALL, debug_all_messages), group=100) + + # Error handler + application.add_error_handler(error_handler) + + # Startup callback + application.post_init = on_startup + + application.run_polling( + stop_signals=(signal.SIGINT, signal.SIGTERM), + allowed_updates=["message", "chat_member", "my_chat_member"], + ) + + +if __name__ == '__main__': + main() diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..ef6648f --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,14 @@ +# Beispiel-Konfiguration für den Counter-Bot +# Benenne diese Datei in config.yaml um und passe Werte an. + +bot_token: "DEIN_TELEGRAM_BOT_TOKEN" +# Optional Liste erlaubter Chat IDs (Integer). Wenn leer oder nicht vorhanden: alle erlaubt +allowed_chat_ids: [] + +# Optional initiale Counter (werden nur beim ersten Start verwendet, falls Datei counters.json leer/nicht vorhanden ist) +initial_counters: + beispiel: 5 + test: 0 + +# Logging Level: DEBUG, INFO, WARNING, ERROR +log_level: INFO diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..14c7a78 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,29 @@ +version: '3.9' +services: + counterbot: + build: . + container_name: counterbot + restart: unless-stopped + # Entweder environment Schlüssel direkt setzen oder eine .env Datei mit docker compose verwenden. + environment: + # Liefert das Telegram Bot Token (nicht in Git committen). Kann auch via .env Datei bereitgestellt werden. + - BOT_TOKEN=${BOT_TOKEN} + # Optional: Startup Ankündigung an kommagetrennte Chat IDs (z.B. -4549916385) + - STARTUP_ANNOUNCE_CHAT_IDS=${STARTUP_ANNOUNCE_CHAT_IDS:-} + # Optional: Begrüßung aller neuen Mitglieder (true/false) + - ANNOUNCE_ALL_JOINS=${ANNOUNCE_ALL_JOINS:-false} + # Standard Pfade + - CONFIG_FILE=/app/config.yaml + - DATA_DIR=/data + volumes: + - counterbot_data:/data + # Optional: eigene angepasste config.yaml aus Host einbinden + # - ./config.yaml:/app/config.yaml:ro + # Keine Ports nötig bei Long Polling + # healthcheck: + # test: ["CMD", "python", "-c", "import os,sys; sys.exit(0)"] + # interval: 1m + # timeout: 5s + # retries: 3 +volumes: + counterbot_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..505b566 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-telegram-bot>=21.1,<22.0 +PyYAML>=6.0,<7.0