Compare commits

...

6 Commits

6 changed files with 197 additions and 32 deletions

View File

@@ -1,17 +1,16 @@
# Multi-stage für kleinere finale Imagegröße # Alpine-basiertes Image für kleinere Größe
FROM python:3.12-slim AS base FROM python:3.12-alpine AS base
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 PIP_NO_CACHE_DIR=1
# System deps (tzdata optional falls benötigt) # Systemabhängigkeiten installieren (su-exec für Rechtewechsel)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apk add --no-cache ca-certificates su-exec
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Non-root user # Non-root user
RUN useradd -u 10001 -m appuser RUN addgroup -S appgroup \
&& adduser -S -G appgroup -u 10001 appuser
WORKDIR /app WORKDIR /app

View File

@@ -79,6 +79,21 @@ Alle Counter werden nach jeder Änderung in `/data/counters.json` gespeichert. D
- Non-root User `appuser` - Non-root User `appuser`
- Kein Port-Expose (Long Polling) - Kein Port-Expose (Long Polling)
## Docker Image veröffentlichen
Zum Bauen und Pushen des Images mit `latest`-Tag und einem datierten Tag (z.B. `2025-09-30`) steht das Skript `publish.py` bereit. Stelle vorher sicher, dass du in der Registry `git.beging.de` angemeldet bist.
```bash
python publish.py --date 2025-09-30
```
Ohne `--date` wird automatisch das heutige UTC-Datum verwendet. Mit `--dry-run` kannst du die Docker-Kommandos nur anzeigen lassen:
```bash
python publish.py --dry-run
```
Falls du den `latest` Tag nicht veröffentlichen möchtest, verwende `--no-latest`.
## Logging ## Logging
Standard: INFO. Anpassbar über `log_level` in der Config. Standard: INFO. Anpassbar über `log_level` in der Config.

101
bot.py
View File

@@ -7,7 +7,13 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from telegram import Update, InlineQueryResultArticle, InputTextMessageContent from telegram import (
Update,
InlineQueryResultArticle,
InputTextMessageContent,
InlineKeyboardButton,
InlineKeyboardMarkup,
)
from telegram.constants import ParseMode from telegram.constants import ParseMode
from telegram.ext import ( from telegram.ext import (
ApplicationBuilder, ApplicationBuilder,
@@ -17,6 +23,7 @@ from telegram.ext import (
filters, filters,
ChatMemberHandler, ChatMemberHandler,
InlineQueryHandler, InlineQueryHandler,
CallbackQueryHandler,
) )
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
@@ -144,12 +151,34 @@ async def remove_counter(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def increment_counter(update: Update, context: ContextTypes.DEFAULT_TYPE): async def increment_counter(update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
if not context.bot_data['access'](chat_id): if not context.bot_data['access'](chat_id):
logger.info("Access denied for chat %s on /increment", chat_id)
try:
await update.message.reply_text("Zugriff verweigert: Dieser Chat ist nicht freigeschaltet.")
except Exception:
pass
return return
counters = context.bot_data['counters']
# If no argument: show inline keyboard with counters
if not context.args: if not context.args:
await update.message.reply_text("Bitte einen Counternamen angeben: /increment <name>") logger.debug("/increment without args in chat %s -> showing keyboard (%d counters)", chat_id, len(counters))
if not counters:
await update.message.reply_text("Keine Counter vorhanden. Lege einen an mit /add <name>.")
return
buttons = []
row: list = []
for idx, name in enumerate(sorted(counters.keys())):
row.append(InlineKeyboardButton(text=f"{name} ({counters[name]})", callback_data=f"inc:{name}"))
if len(row) == 3:
buttons.append(row)
row = []
if row:
buttons.append(row)
markup = InlineKeyboardMarkup(buttons)
await update.message.reply_text(
"Wähle einen Counter zum Inkrementieren:", reply_markup=markup
)
return return
key = norm_key(context.args[0]) key = norm_key(context.args[0])
counters = context.bot_data['counters']
if key not in counters: if key not in counters:
esc = html_escape(key) 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) await update.message.reply_text(f"Counter <b>{esc}</b> existiert nicht. Lege ihn mit /add {esc} an.", parse_mode=ParseMode.HTML)
@@ -159,6 +188,51 @@ async def increment_counter(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(f"Counter <b>{html_escape(key)}</b> steht jetzt auf <b>{counters[key]}</b>.", parse_mode=ParseMode.HTML) 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 handle_increment_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle callback button presses for incrementing counters."""
query = update.callback_query
if not query:
return
await query.answer()
data = query.data or ""
if not data.startswith("inc:"):
return
name = data.split(":", 1)[1]
counters = context.bot_data['counters']
chat_id = query.message.chat_id if query.message else None
if chat_id is not None and not context.bot_data['access'](chat_id):
logger.info("Access denied for chat %s on increment button %s", chat_id, name)
return
key = norm_key(name)
if key not in counters:
await query.edit_message_text(f"Counter <b>{html_escape(key)}</b> existiert nicht mehr.", parse_mode=ParseMode.HTML)
return
counters[key] += 1
save_counters(counters)
logger.debug("Increment via button: %s -> %d", key, counters[key])
# Rebuild keyboard to reflect new values
buttons = []
row: list = []
for idx, cname in enumerate(sorted(counters.keys())):
row.append(InlineKeyboardButton(text=f"{cname} ({counters[cname]})", callback_data=f"inc:{cname}"))
if len(row) == 3:
buttons.append(row)
row = []
if row:
buttons.append(row)
markup = InlineKeyboardMarkup(buttons)
try:
await query.edit_message_text(
f"Counter <b>{html_escape(key)}</b> steht jetzt auf <b>{counters[key]}</b>. Wähle weiteren Counter:",
parse_mode=ParseMode.HTML,
reply_markup=markup,
)
except Exception:
# Fallback: send separate message if edit fails
if chat_id is not None:
await context.bot.send_message(chat_id, 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): async def decrement_counter(update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
if not context.bot_data['access'](chat_id): if not context.bot_data['access'](chat_id):
@@ -331,24 +405,6 @@ def init_counters(existing: Dict[str, int], config: Dict[str, Any]) -> Dict[str,
async def on_startup(app): async def on_startup(app):
logger.info("Bot gestartet und bereit.") 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(): def main():
@@ -388,6 +444,7 @@ def main():
application.add_handler(CommandHandler("add", add_counter)) application.add_handler(CommandHandler("add", add_counter))
application.add_handler(CommandHandler("remove", remove_counter)) application.add_handler(CommandHandler("remove", remove_counter))
application.add_handler(CommandHandler("increment", increment_counter)) application.add_handler(CommandHandler("increment", increment_counter))
application.add_handler(CallbackQueryHandler(handle_increment_button, pattern=r"^inc:"))
application.add_handler(CommandHandler("decrement", decrement_counter)) application.add_handler(CommandHandler("decrement", decrement_counter))
application.add_handler(CommandHandler("list", list_counters)) application.add_handler(CommandHandler("list", list_counters))
# Group / membership events # Group / membership events
@@ -405,7 +462,7 @@ def main():
application.run_polling( application.run_polling(
stop_signals=(signal.SIGINT, signal.SIGTERM), stop_signals=(signal.SIGINT, signal.SIGTERM),
allowed_updates=["message", "chat_member", "my_chat_member", "inline_query"], allowed_updates=["message", "chat_member", "my_chat_member", "inline_query", "callback_query"],
) )

View File

@@ -4,13 +4,13 @@ set -e
# If running as root, fix ownership of /data, then drop privileges # If running as root, fix ownership of /data, then drop privileges
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
mkdir -p /data mkdir -p /data
chown -R appuser:appuser /data || echo "Warn: could not chown /data" chown -R appuser:appgroup /data || echo "Warn: could not chown /data"
# Copy example config only if missing target # Copy example config only if missing target
if [ ! -f /app/config.yaml ] && [ -f /app/config.example.yaml ]; then if [ ! -f /app/config.yaml ] && [ -f /app/config.example.yaml ]; then
cp /app/config.example.yaml /app/config.yaml cp /app/config.example.yaml /app/config.yaml
chown appuser:appuser /app/config.yaml || true chown appuser:appgroup /app/config.yaml || true
fi fi
exec su -s /bin/sh appuser -c "$*" exec su-exec appuser "$@"
else else
exec "$@" exec "$@"
fi fi

93
publish.py Normal file
View File

@@ -0,0 +1,93 @@
import argparse
import datetime as dt
import shlex
import subprocess
import sys
from pathlib import Path
REPOSITORY = "git.beging.de/troogs/gigalativbot"
DEFAULT_CONTEXT = Path(__file__).parent
def run_command(cmd: list[str], /, *, dry_run: bool) -> None:
"""Print and optionally execute a shell command."""
print("$", shlex.join(cmd))
if dry_run:
return
subprocess.run(cmd, check=True)
def build_and_push(*, include_latest: bool, date_tag: str, context: Path, dockerfile: Path | None, dry_run: bool) -> None:
tags: list[str] = []
if include_latest:
tags.append(f"{REPOSITORY}:latest")
tags.append(f"{REPOSITORY}:{date_tag}")
build_cmd = ["docker", "build"]
for tag in tags:
build_cmd.extend(["-t", tag])
if dockerfile is not None:
build_cmd.extend(["-f", str(dockerfile)])
build_cmd.append(str(context))
run_command(build_cmd, dry_run=dry_run)
for tag in tags:
run_command(["docker", "push", tag], dry_run=dry_run)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build and push docker images with latest and YYYYMMDD tags.")
parser.add_argument("--context", default=DEFAULT_CONTEXT, type=Path, help="Build context directory (default: project root)")
parser.add_argument("--dockerfile", type=Path, default=None, help="Path to Dockerfile (defaults to <context>/Dockerfile)")
parser.add_argument("--date", help="Custom date string for tag (YYYY-MM-DD). Defaults to today (UTC).")
parser.add_argument("--no-latest", action="store_true", help="Do not tag/push the 'latest' tag.")
parser.add_argument("--dry-run", action="store_true", help="Print the docker commands without executing them.")
return parser.parse_args()
def ensure_date_tag(raw: str | None) -> str:
if raw is None:
return dt.datetime.utcnow().strftime("%Y-%m-%d")
try:
parsed = dt.datetime.strptime(raw, "%Y-%m-%d")
except ValueError as exc:
raise ValueError("Date tag must be in YYYY-MM-DD format.") from exc
return parsed.strftime("%Y-%m-%d")
def main() -> int:
args = parse_args()
try:
date_tag = ensure_date_tag(args.date)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
context = args.context.resolve()
dockerfile = args.dockerfile.resolve() if args.dockerfile else None
if dockerfile and not dockerfile.exists():
print(f"Error: Dockerfile {dockerfile} does not exist.", file=sys.stderr)
return 2
if not context.exists():
print(f"Error: context directory {context} does not exist.", file=sys.stderr)
return 3
try:
build_and_push(
include_latest=not args.no_latest,
date_tag=date_tag,
context=context,
dockerfile=dockerfile,
dry_run=args.dry_run,
)
except subprocess.CalledProcessError as exc:
print(f"Command failed with exit code {exc.returncode}: {exc.cmd}", file=sys.stderr)
return exc.returncode
return 0
if __name__ == "__main__":
sys.exit(main())

1
test.txt Normal file
View File

@@ -0,0 +1 @@
.