14 Commits

Author SHA1 Message Date
53dad27b4f fix: replace remaining OWNER_ID reference with OWNER_IDS 2025-11-02 00:43:34 +01:00
38211f8d54 fix: add logging and error handling for OWNER_ID parsing 2025-11-02 00:25:22 +01:00
a540553e44 feat: support multiple owner IDs via comma-separated OWNER_ID env var
Amp-Thread-ID: https://ampcode.com/threads/T-d9bbd9a4-ce62-4729-b3e5-e83ce72aaf7d
Co-authored-by: Amp <amp@ampcode.com>
2025-11-02 00:20:59 +01:00
0a005fc338 fix: correct Docker networking and missing quote import
Amp-Thread-ID: https://ampcode.com/threads/T-d9bbd9a4-ce62-4729-b3e5-e83ce72aaf7d
Co-authored-by: Amp <amp@ampcode.com>
2025-11-01 22:10:57 +01:00
Wiktor
b82621becb Merge pull request #4 from overspend1/codex/fix-dockerfile-and-compose-for-meme-wrangler-w3lgdz
fix: route compose db connections to postgres service
2025-11-01 22:54:12 +01:00
Wiktor
7e6d46d9c9 Merge branch 'main' into codex/fix-dockerfile-and-compose-for-meme-wrangler-w3lgdz 2025-11-01 22:54:01 +01:00
Wiktor
1cbb4513d7 Update bot.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-01 22:52:16 +01:00
Wiktor
3d81b8d6b8 Update bot.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-01 22:51:58 +01:00
Wiktor
63dc74e8a2 fix: route compose db connections to postgres service 2025-11-01 22:05:21 +01:00
Wiktor
2d6c388a3e Merge pull request #3 from overspend1/codex/fix-dockerfile-and-compose-for-meme-wrangler-dy6rbm
fix: support compose env overrides
2025-11-01 19:58:21 +01:00
Wiktor
93b6cf7a63 Merge branch 'main' into codex/fix-dockerfile-and-compose-for-meme-wrangler-dy6rbm 2025-11-01 19:58:14 +01:00
Wiktor
66c5a5a797 fix: default compose stack to .ENV 2025-11-01 19:55:27 +01:00
Wiktor
160e792e8f fix: support compose env overrides 2025-11-01 19:45:49 +01:00
Wiktor
b1cd5dfeb2 Merge pull request #2 from overspend1/codex/fix-dockerfile-and-compose-for-meme-wrangler
fix: make compose env file optional
2025-11-01 19:27:56 +01:00
10 changed files with 143 additions and 39 deletions

12
.ENV.example Normal file
View File

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

View File

@@ -5,3 +5,4 @@ __pycache__
backups
.env
.env.*
.ENV

View File

@@ -2,6 +2,7 @@
# Copy this file to .env and fill in your actual values
TELEGRAM_BOT_TOKEN=your_bot_token_here
# Comma-separated list of owner Telegram user IDs (e.g., 123456789,987654321)
OWNER_ID=your_telegram_user_id_here
CHANNEL_ID=@your_channel_or_id_here
# PostgreSQL connection string (format: postgresql://user:password@host:port/database)

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@
__pycache__/
*.pyc
memes.db
.env
.env
.ENV

View File

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

View File

@@ -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=<your_sha256_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/<stack-id>/.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

View File

@@ -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=<sha256 hash of your backup secret>

92
bot.py
View File

@@ -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
@@ -89,9 +90,74 @@ 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://{quote(user, safe='')}:{quote(password, safe='')}"
f"@{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
if "@" in parsed.netloc:
auth_prefix, _, _ = parsed.netloc.rpartition("@")
auth_segment = f"{auth_prefix}@"
else:
auth_segment = ""
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 ""
target_host = host_override
if ":" in target_host and not target_host.startswith("["):
target_host = f"[{target_host}]"
new_netloc = f"{auth_segment}{target_host}{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"))
# Support multiple owner IDs (comma-separated)
_owner_ids_raw = os.environ.get("OWNER_ID", "0")
try:
OWNER_IDS = set(int(oid.strip()) for oid in _owner_ids_raw.split(",") if oid.strip())
logger.info(f"Configured owner IDs: {OWNER_IDS}")
except ValueError as e:
logger.error(f"Failed to parse OWNER_ID env var '{_owner_ids_raw}': {e}")
OWNER_IDS = {0}
CHANNEL_ID = os.environ.get("CHANNEL_ID") # @channelusername or -100<id>
BACKUP_DIR = Path(os.environ.get("MEMEBOT_BACKUP_DIR", "backups"))
_HARDCODED_BACKUP_PASSWORD_HASH = "16c5b5ddf1b27f16ad5f801bb83595d00e666cc53085e53a4b1e67b715016251"
@@ -254,7 +320,7 @@ async def pop_due_memes_and_post(context: ContextTypes.DEFAULT_TYPE):
posting_log.pop(0)
async def scheduled(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await update.message.reply_text("Only the owner can use this command.")
return
@@ -340,7 +406,7 @@ async def scheduled(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(caption)
async def unschedule(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await update.message.reply_text("Only the owner can use this command.")
return
if not context.args or not all(arg.isdigit() for arg in context.args):
@@ -359,7 +425,7 @@ async def unschedule(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def preview(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Preview a scheduled meme by id. Tries direct send, then downloads and reuploads as a document if needed."""
user_id = update.effective_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await update.message.reply_text("Only the owner can use this command.")
return
if not context.args or not context.args[0].isdigit():
@@ -420,7 +486,7 @@ async def preview(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def logcmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await update.message.reply_text("Only the owner can use this command.")
return
if not posting_log:
@@ -431,7 +497,7 @@ async def logcmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def backup(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await update.message.reply_text("Only the owner can use this command.")
return
if not _verify_backup_password(context.args):
@@ -500,7 +566,7 @@ async def create_backup(send_document_to: Optional[int] = None, bot: Optional[An
async def restore(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await update.message.reply_text("Only the owner can use this command.")
return
if not _verify_backup_password(context.args):
@@ -627,7 +693,7 @@ async def helpcmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
<i>Example:</i> <code>/scheduleat ids: 5-10 2025-10-19</code>
<b>Notes:</b>
• <b>Only the owner</b> (set by OWNER_ID) can use admin commands.
• <b>Only owners</b> (set by OWNER_ID) can use admin commands.
• All times are in <b>IST (Asia/Kolkata)</b>.
• Meme IDs are shown in <b>/scheduled</b> previews.
• Use <b>/preview</b> to check a meme before posting.
@@ -640,7 +706,7 @@ async def helpcmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def handle_media(update: Update, context: ContextTypes.DEFAULT_TYPE):
msg: Message = update.message
user_id = msg.from_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await msg.reply_text("Sorry, only the owner can send memes to schedule.")
return
@@ -681,7 +747,7 @@ async def handle_media(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def postnow(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await update.message.reply_text("Only the owner can use this command.")
return
@@ -725,7 +791,7 @@ import re
async def scheduleat(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
if user_id != OWNER_ID:
if user_id not in OWNER_IDS:
await update.message.reply_text("Only the owner can use this command.")
return
if not context.args or len(context.args) < 2:
@@ -793,7 +859,7 @@ async def scheduleat(update: Update, context: ContextTypes.DEFAULT_TYPE):
def main():
if not BOT_TOKEN:
raise SystemExit("Please set TELEGRAM_BOT_TOKEN environment variable")
if not OWNER_ID or OWNER_ID == 0:
if not OWNER_IDS or 0 in OWNER_IDS:
raise SystemExit("Please set OWNER_ID environment variable to your Telegram user id")
if not CHANNEL_ID:
raise SystemExit("Please set CHANNEL_ID to target channel (username or id)")

View File

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

View File

@@ -1,10 +1,12 @@
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:
- .env
- *compose_env_file
environment:
- POSTGRES_DB=${POSTGRES_DB:-meme_wrangler}
- POSTGRES_USER=${POSTGRES_USER:-meme}
@@ -26,7 +28,7 @@ services:
postgres:
condition: service_healthy
env_file:
- .env
- *compose_env_file
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- OWNER_ID=${OWNER_ID:-}
@@ -34,8 +36,10 @@ services:
- POSTGRES_DB=${POSTGRES_DB:-meme_wrangler}
- POSTGRES_USER=${POSTGRES_USER:-meme}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-meme}
- DATABASE_URL=postgresql://${POSTGRES_USER:-meme}:${POSTGRES_PASSWORD:-meme}@postgres:5432/${POSTGRES_DB:-meme_wrangler}
- MEMEBOT_BACKUP_DIR=/app/backups
- 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: