chore: initial commit with counter bot, docker compose enhancements, html formatting, startup + join announcements

This commit is contained in:
Andre Beging
2025-09-30 09:14:43 +02:00
parent 6b337603bf
commit 2430b15b2c
7 changed files with 568 additions and 144 deletions

188
.gitignore vendored
View File

@@ -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

33
Dockerfile Normal file
View File

@@ -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"]

110
README.md
View File

@@ -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 <name> legt Counter (0) an
- /remove <name> löscht Counter
- /increment <name> erhöht Counter um 1 (Antwort mit neuem Wert)
- /decrement <name> 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 <name> <value>
- /rename <old> <new>
- 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ß!

336
bot.py Normal file
View File

@@ -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 <name> - Legt einen neuen Counter mit Wert 0 an\n"
"/remove <name> - Löscht einen Counter\n"
"/increment <name> - Erhöht den Counter um 1 und zeigt neuen Wert\n"
"/decrement <name> - 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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
)
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 <name>")
return
key = norm_key(context.args[0])
counters = context.bot_data['counters']
if key in counters:
await update.message.reply_text(f"Counter <b>{html_escape(key)}</b> existiert bereits.", parse_mode=ParseMode.HTML)
return
counters[key] = 0
save_counters(counters)
await update.message.reply_text(f"Counter <b>{html_escape(key)}</b> 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 <name>")
return
key = norm_key(context.args[0])
counters = context.bot_data['counters']
if key not in counters:
await update.message.reply_text(f"Counter <b>{html_escape(key)}</b> existiert nicht.", parse_mode=ParseMode.HTML)
return
del counters[key]
save_counters(counters)
await update.message.reply_text(f"Counter <b>{html_escape(key)}</b> 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 <name>")
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 <b>{esc}</b> 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 <b>{html_escape(key)}</b> steht jetzt auf <b>{counters[key]}</b>.", 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 <name>")
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 <b>{esc}</b> 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 <b>{html_escape(key)}</b> steht jetzt auf <b>{counters[key]}</b>.", 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 <name>.")
return
lines = ["<b>Aktuelle Counter:</b>"]
for k in sorted(counters.keys()):
lines.append(f"- <b>{html_escape(k)}</b>: {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()

14
config.example.yaml Normal file
View File

@@ -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

29
docker-compose.yaml Normal file
View File

@@ -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:

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
python-telegram-bot>=21.1,<22.0
PyYAML>=6.0,<7.0