From 63dc74e8a2718ea406970b0c9abf9c44ceca9b6b Mon Sep 17 00:00:00 2001 From: Wiktor <42137698+overspend1@users.noreply.github.com> Date: Sat, 1 Nov 2025 22:05:21 +0100 Subject: [PATCH] fix: route compose db connections to postgres service --- .ENV.example | 12 ++++++ .dockerignore | 38 +++-------------- .gitignore | 3 +- AGENTS.md | 2 +- DOCKER_DEPLOY.md | 26 ++++++++---- Dockerfile | 32 ++++++++------ README.md | 11 +++-- bot.py | 104 ++++++++++++++++++++++++++++++++++++--------- deploy.sh | 22 ++++++---- docker-compose.yml | 36 ++++++++++++---- 10 files changed, 191 insertions(+), 95 deletions(-) create mode 100644 .ENV.example diff --git a/.ENV.example b/.ENV.example new file mode 100644 index 0000000..d66caf6 --- /dev/null +++ b/.ENV.example @@ -0,0 +1,12 @@ +# Sample environment for Meme Wrangler Docker stack. +# Copy to `.ENV` and adjust secrets before deploying. +TELEGRAM_BOT_TOKEN= +OWNER_ID=0 +CHANNEL_ID= +POSTGRES_DB=meme_wrangler +POSTGRES_USER=meme +POSTGRES_PASSWORD=meme +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +MEMEBOT_BACKUP_DIR=/app/backups +# MEMEBOT_BACKUP_PASSWORD_HASH= diff --git a/.dockerignore b/.dockerignore index 6bc9356..3e545aa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,32 +1,8 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -.venv/ -venv/ -ENV/ - -# Database -*.db -*.db-journal - -# Environment variables +.git +.gitignore +__pycache__ +*.pyc +backups .env - -# IDEs -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log - -# Docker -data/ +.env.* +.ENV diff --git a/.gitignore b/.gitignore index e184a5a..d98b4f6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__/ *.pyc memes.db -.env \ No newline at end of file +.env +.ENV diff --git a/AGENTS.md b/AGENTS.md index 972f959..41232a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,6 @@ - Link issues or TODOs, flag breaking changes in bold, and include screenshots only when UI-facing Telegram copy changes. ## Deployment & Secret Tips -- Keep `.env` out of version control; copy from `.env.example` and set `TELEGRAM_BOT_TOKEN`, `OWNER_ID`, `CHANNEL_ID`, `POSTGRES_*` credentials, and `DATABASE_URL` for local runs. +- Keep `.ENV` (or `.env`) out of version control; copy from `.ENV.example` and set `TELEGRAM_BOT_TOKEN`, `OWNER_ID`, `CHANNEL_ID`, `POSTGRES_*` credentials, and `DATABASE_URL` for local runs. - Only override `MEMEBOT_BACKUP_PASSWORD_HASH` if you plan to replace the baked-in SHA-256 hash for backup commands. - Docker workflows mount backups in `./backups/` and keep the PostgreSQL cluster in the `pgdata` volume; prune carefully when resetting schedules. New memes automatically generate a fresh backup file in that directory. diff --git a/DOCKER_DEPLOY.md b/DOCKER_DEPLOY.md index 9c14b30..ddfd23c 100644 --- a/DOCKER_DEPLOY.md +++ b/DOCKER_DEPLOY.md @@ -37,8 +37,8 @@ sudo usermod -aG docker $USER Copy the example file and edit it: ```bash -cp .env.example .env -nano .env # or use any text editor +cp .ENV.example .ENV +nano .ENV # or use any text editor ``` Fill in your actual values: @@ -49,16 +49,22 @@ CHANNEL_ID=@yourchannel POSTGRES_DB=meme_wrangler POSTGRES_USER=meme POSTGRES_PASSWORD=meme +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +# Optional: adjust which env file Compose should read (defaults to .ENV) +# COMPOSE_ENV_FILE=staging.env # Optional: hash (SHA-256) for replacing the baked-in backup secret # MEMEBOT_BACKUP_PASSWORD_HASH= # Optional: adjust DATABASE_URL for non-compose workflows # DATABASE_URL=postgresql://meme:meme@postgres:5432/meme_wrangler ``` +Leave `POSTGRES_HOST=postgres` when running through Docker Compose or Portainer; it's the internal service name that the bot rewrites into any localhost-style URLs so the container connects to the bundled PostgreSQL instance. Override it only if your database lives elsewhere. + ### 2. Build and Run with Docker Compose (Easiest) ```bash -# Build and start in background +# Build and start in background (stack reads .ENV automatically) docker-compose up -d # View logs @@ -73,6 +79,10 @@ docker-compose restart That's it! Your bot is now running in Docker! 🎉 +#### Deploying with Portainer Stacks + +When launching the stack from Portainer, upload your `.ENV` file through the **Environment variables** tab—Portainer saves it beside the stack as `/data/compose//.ENV`, which matches the default expected by `docker-compose.yml`. Only set `COMPOSE_ENV_FILE` if you deliberately use a different name. + ## Alternative: Using Docker Commands Directly ### Build the Image @@ -152,9 +162,9 @@ ssh -i /path/to/ssh_key username@server_ip # Navigate to bot directory cd ~/meme-wrangler -# Create .env file -cp .env.example .env -nano .env # Fill in your credentials +# Create .ENV file +cp .ENV.example .ENV +nano .ENV # Fill in your credentials # Build and run docker-compose up -d @@ -308,8 +318,8 @@ scp -i /path/to/key -r \ ssh -i /path/to/key username@server_ip cd ~/memebot -# Create .env file -nano .env # Add your credentials +# Create .ENV file +nano .ENV # Add your credentials # Run with Docker Compose docker-compose up -d diff --git a/Dockerfile b/Dockerfile index 6ddea87..0c59710 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,38 @@ # Use Python 3.12 slim image FROM python:3.12-slim +# Configure Python environment +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + # Set working directory WORKDIR /app -# Install system dependencies (if needed) -RUN apt-get update && apt-get install -y \ +# Install system dependencies required for asyncpg build +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ && rm -rf /var/lib/apt/lists/* # Copy requirements first (for better caching) -COPY requirements.txt . +COPY requirements.txt ./ # Install Python dependencies -RUN pip install --no-cache-dir -r requirements.txt -RUN pip install --no-cache-dir python-telegram-bot==20.7 +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt -# Copy the bot code -COPY bot.py . +# Copy application source +COPY . . -# Create directory for backups +# Ensure backups directory exists inside the container RUN mkdir -p /app/backups # Set environment variables (will be overridden by docker-compose or run command) -ENV TELEGRAM_BOT_TOKEN="" -ENV OWNER_ID="" -ENV CHANNEL_ID="" -ENV DATABASE_URL="" -ENV MEMEBOT_BACKUP_DIR="/app/backups" +ENV TELEGRAM_BOT_TOKEN="" \ + OWNER_ID="" \ + CHANNEL_ID="" \ + DATABASE_URL="" \ + MEMEBOT_BACKUP_DIR="/app/backups" # Run the bot CMD ["python", "bot.py"] diff --git a/README.md b/README.md index 84492e7..17bc598 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ The easiest way to run the bot is using Docker: 1. **Set up environment variables:** ```bash - cp .env.example .env - nano .env # Edit with your bot credentials + cp .ENV.example .ENV + nano .ENV # Edit with your bot credentials ``` - The compose stack expects PostgreSQL credentials, so populate `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` (defaults provided). Backups are protected by a built-in SHA-256 hash; optionally define `MEMEBOT_BACKUP_PASSWORD_HASH` to replace it. + The compose file now looks for `.ENV` by default so Portainer and other orchestrators can supply secrets without additional flags. Copy `.ENV.example` to `.ENV`, then populate `TELEGRAM_BOT_TOKEN`, `OWNER_ID`, `CHANNEL_ID`, and the Postgres settings. Leave `POSTGRES_HOST=postgres` (and `POSTGRES_PORT=5432` unless your database listens elsewhere) when you run inside Docker. The bot rewrites any localhost URLs with that host so the container connects to the bundled PostgreSQL service instead of looping back on itself. You can still point the stack at a different file by exporting `COMPOSE_ENV_FILE` (e.g. `COMPOSE_ENV_FILE=staging.env docker compose up -d`). Backups are protected by a built-in SHA-256 hash; optionally define `MEMEBOT_BACKUP_PASSWORD_HASH` to replace it. 2. **Run with Docker Compose:** ```bash @@ -25,6 +25,8 @@ The easiest way to run the bot is using Docker: docker-compose logs -f ``` + Deploying via Portainer? Upload your `.ENV` file under **Environment variables**—Portainer stores it beside the stack so the compose file picks it up automatically. Override `COMPOSE_ENV_FILE` only when you use a differently named file. + 4. **Stop the bot:** ```bash docker-compose down @@ -51,6 +53,9 @@ export TELEGRAM_BOT_TOKEN=123:ABC export OWNER_ID=123456789 export CHANNEL_ID=@yourchannel # or -1001234567890 export DATABASE_URL=postgresql://meme:meme@localhost:5432/meme_wrangler +# Optional pieces that mirror the Docker variables +# export POSTGRES_HOST=localhost +# export POSTGRES_PORT=5432 # Optional: where JSON backups are written # export MEMEBOT_BACKUP_DIR=/path/to/backups # export MEMEBOT_BACKUP_PASSWORD_HASH= diff --git a/bot.py b/bot.py index 487a5cc..4337e12 100644 --- a/bot.py +++ b/bot.py @@ -10,6 +10,7 @@ import io import json import logging import os +from urllib.parse import urlparse, urlunparse from datetime import datetime, time, timedelta from pathlib import Path from types import SimpleNamespace @@ -30,31 +31,41 @@ if TYPE_CHECKING: from telegram import Update, Message, InputFile from telegram.ext import ApplicationBuilder, ContextTypes, CommandHandler, MessageHandler, filters else: - Update = Message = InputFile = Any # type: ignore - ContextTypes = SimpleNamespace(DEFAULT_TYPE=Any) # type: ignore + try: + from telegram import Update, Message, InputFile # type: ignore + from telegram.ext import ( # type: ignore + ApplicationBuilder, + ContextTypes, + CommandHandler, + MessageHandler, + filters, + ) + except ModuleNotFoundError: + Update = Message = InputFile = Any # type: ignore + ContextTypes = SimpleNamespace(DEFAULT_TYPE=Any) # type: ignore - class _MissingTelegramModule: - """Lazily raise when Telegram features are used without dependency.""" + class _MissingTelegramModule: + """Lazily raise when Telegram features are used without dependency.""" - def __getattr__(self, item): - raise RuntimeError( - "python-telegram-bot must be installed to use the Meme Wrangler bot (missing telegram module)." - ) + def __getattr__(self, item): + raise RuntimeError( + "python-telegram-bot must be installed to use the Meme Wrangler bot (missing telegram module)." + ) - def __call__(self, *args, **kwargs): - raise RuntimeError( - "python-telegram-bot must be installed to use the Meme Wrangler bot (missing telegram module)." - ) + def __call__(self, *args, **kwargs): + raise RuntimeError( + "python-telegram-bot must be installed to use the Meme Wrangler bot (missing telegram module)." + ) - ApplicationBuilder = CommandHandler = MessageHandler = _MissingTelegramModule() # type: ignore + ApplicationBuilder = CommandHandler = MessageHandler = _MissingTelegramModule() # type: ignore - class _MissingFilters(SimpleNamespace): - def __getattr__(self, item): - raise RuntimeError( - "python-telegram-bot must be installed to use the Meme Wrangler bot (missing telegram filters)." - ) + class _MissingFilters(SimpleNamespace): + def __getattr__(self, item): + raise RuntimeError( + "python-telegram-bot must be installed to use the Meme Wrangler bot (missing telegram filters)." + ) - filters = _MissingFilters() # type: ignore + filters = _MissingFilters() # type: ignore logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -79,7 +90,60 @@ def _ensure_ist(dt: datetime) -> datetime: return _ist_localize(dt) return dt.astimezone(IST) -DATABASE_URL = os.environ.get("DATABASE_URL") or os.environ.get("MEMEBOT_DB") +def _build_database_url() -> Optional[str]: + """Derive the database URL from explicit env vars or component pieces.""" + + raw_url = os.environ.get("DATABASE_URL") or os.environ.get("MEMEBOT_DB") + if not raw_url: + user = os.environ.get("POSTGRES_USER") + password = os.environ.get("POSTGRES_PASSWORD") + db_name = os.environ.get("POSTGRES_DB") + host = os.environ.get("POSTGRES_HOST", "localhost") + port = os.environ.get("POSTGRES_PORT", "5432") + if user and password and db_name: + raw_url = f"postgresql://{user}:{password}@{host}:{port}/{db_name}" + + if raw_url: + return _normalize_database_url(raw_url) + return None + + +def _normalize_database_url(url: str) -> str: + """Replace localhost hosts with the configured Postgres host for container runs.""" + + host_override = os.environ.get("POSTGRES_HOST") + if not host_override: + return url + + host_override = host_override.strip() + if not host_override or host_override in {"localhost", "127.0.0.1", "::1"}: + return url + + parsed = urlparse(url) + if parsed.hostname not in {"localhost", "127.0.0.1", "::1"}: + return url + + username = parsed.username or "" + password = parsed.password + auth = "" + if username: + auth = username + if password is not None: + auth += f":{password}" + auth += "@" + + if parsed.port is not None: + port_fragment = f":{parsed.port}" + else: + port_env = os.environ.get("POSTGRES_PORT") + port_fragment = f":{port_env}" if port_env else "" + + new_netloc = f"{auth}{host_override}{port_fragment}" + rebuilt = parsed._replace(netloc=new_netloc) + return urlunparse(rebuilt) + + +DATABASE_URL = _build_database_url() BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") OWNER_ID = int(os.environ.get("OWNER_ID", "0")) CHANNEL_ID = os.environ.get("CHANNEL_ID") # @channelusername or -100 diff --git a/deploy.sh b/deploy.sh index 7dea455..02436c8 100755 --- a/deploy.sh +++ b/deploy.sh @@ -26,16 +26,20 @@ if [ -z "$SERVER" ]; then exit 1 fi -echo "Step 1: Checking if .env file exists..." -if [ ! -f ".env" ]; then - echo "Error: .env file not found!" - echo "Please create .env file with your credentials:" - echo " cp .env.example .env" - echo " nano .env" +ENV_FILE=".ENV" +echo "Step 1: Checking for environment file (.ENV or .env)..." +if [ -f "$ENV_FILE" ]; then + echo "✓ .ENV file found" +elif [ -f ".env" ]; then + ENV_FILE=".env" + echo "✓ .env file found" +else + echo "Error: No environment file found!" + echo "Please create one with your credentials:" + echo " cp .ENV.example .ENV" + echo " nano .ENV" exit 1 fi - -echo "✓ .env file found" echo "" echo "Step 2: Uploading files to server..." @@ -45,7 +49,7 @@ scp -i "$SSH_KEY" \ docker-compose.yml \ bot.py \ requirements.txt \ - .env \ + "$ENV_FILE" \ "$SERVER":~/meme-wrangler/ echo "✓ Files uploaded" diff --git a/docker-compose.yml b/docker-compose.yml index 6c7c5df..fd1a33b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,45 @@ +x-env-file: &compose_env_file ${COMPOSE_ENV_FILE:-.ENV} + services: postgres: image: postgres:15 container_name: meme-wrangler-db restart: unless-stopped + env_file: + - *compose_env_file environment: - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB:-meme_wrangler} + - POSTGRES_USER=${POSTGRES_USER:-meme} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-meme} volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-meme} -d ${POSTGRES_DB:-meme_wrangler}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s meme-wrangler: build: . container_name: meme-wrangler restart: unless-stopped depends_on: - - postgres + postgres: + condition: service_healthy + env_file: + - *compose_env_file environment: - - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - - OWNER_ID=${OWNER_ID} - - CHANNEL_ID=${CHANNEL_ID} - - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - - MEMEBOT_BACKUP_DIR=/app/backups + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - OWNER_ID=${OWNER_ID:-} + - CHANNEL_ID=${CHANNEL_ID:-} + - POSTGRES_DB=${POSTGRES_DB:-meme_wrangler} + - POSTGRES_USER=${POSTGRES_USER:-meme} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-meme} + - POSTGRES_HOST=${POSTGRES_HOST:-postgres} + - POSTGRES_PORT=${POSTGRES_PORT:-5432} + - DATABASE_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-meme}:${POSTGRES_PASSWORD:-meme}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-meme_wrangler}} + - MEMEBOT_BACKUP_DIR=${MEMEBOT_BACKUP_DIR:-/app/backups} volumes: - ./backups:/app/backups logging: