Compare commits
6 Commits
68a75b8171
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a626923a | ||
|
|
a283673bcf | ||
|
|
5c22415ab4 | ||
|
|
a29ce99420 | ||
|
|
fe3d852434 | ||
|
|
fba72a679d |
13
Dockerfile
13
Dockerfile
@@ -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
|
||||||
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -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
101
bot.py
@@ -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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
93
publish.py
Normal 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())
|
||||||
Reference in New Issue
Block a user