From 98a29f9f65d629e71d5e2dd2d9a5d822d1b548a0 Mon Sep 17 00:00:00 2001 From: ummataliyev Date: Mon, 16 Feb 2026 19:49:27 +0500 Subject: [PATCH 1/5] chore: add CI and repository hygiene --- .github/workflows/ci.yml | 61 ++++++++++++++++++++++++++++ .gitignore | 87 ++++++++++++++++++++++++++++++++++++++++ Readme.md | 67 ++++++++++++++++++++----------- makefile | 9 +++-- pyproject.toml | 6 +++ 5 files changed, 202 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 pyproject.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..27627f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + env: + DEBUG: "False" + SECRET_KEY: ci-secret-key + ALLOWED_HOSTS: localhost,127.0.0.1,testserver + CSRF_TRUSTED_ORIGINS: http://testserver + TIME_ZONE: UTC + BOT_TOKEN: 123456:TEST_TOKEN + IS_POLLING: "True" + WEBHOOK_BASE_URL: https://example.com + TELEGRAM_WEBHOOK_SECRET: ci-webhook-secret + USE_NGROK: "False" + DB_ENGINE: django.db.backends.sqlite3 + DB_NAME: /tmp/djangogram-ci.sqlite3 + DB_USER: ci + DB_PASSWORD: ci + DB_HOST: localhost + DB_PORT: "5432" + DB_URL: sqlite:////tmp/djangogram-ci.sqlite3 + REDIS_URL: redis://localhost:6379/0 + REDIS_HOST: localhost + REDIS_PORT: "6379" + REDIS_DB: "0" + CELERY_BROKER_URL: redis://localhost:6379/0 + CELERY_RESULT_BACKEND: redis://localhost:6379/0 + CELERY_BEAT_SCHEDULER: django_celery_beat.schedulers:DatabaseScheduler + CELERY_TIMEZONE: UTC + CELERY_NOTIFY_INTERVAL: "1" + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff + + - name: Lint + run: ruff check . + + - name: Validate migrations + run: python manage.py makemigrations --check --dry-run + + - name: Django system check + run: python manage.py check + + - name: Run tests + run: python manage.py test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89171e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,87 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.pyo +*.pyd +*.so + +venv/ +env/ +.venv/ +.env/ +ENV/ +*.env +*.env.* + +# Django +*.log +*.pot +*.pyc +*.sqlite3 +db.sqlite3 +media/ +staticfiles/ +static/ +node_modules/ +.mypy_cache/ +pytest_cache/ +.coverage +htmlcov/ +.DS_Store + +settings_local.py +local_settings.py + +**/migrations/__pycache__/ + +# Aiogram +*.session +*.session-journal +bot_data/ +logs/ +cache/ +tmp/ + +# Docker +docker/*.env +docker/development/*.env +docker/production/*.env +docker/volumes/ +docker-compose.override.yml +*.pid +*.sock + +# Ignore build artifacts +build/ +dist/ +*.egg-info/ +.eggs/ + +# VSCode +.vscode/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json + +# PyCharm / JetBrains +.idea/ +.idea/* +!.idea/codeStyles/ + +# Sublime +*.sublime-project +*.sublime-workspace + +# System files +.DS_Store +Thumbs.db +ehthumbs.db +Icon? +Desktop.ini + +# GIT / CI RELATED +.gitmodules +.gitconfig +.envrc +.env.bak diff --git a/Readme.md b/Readme.md index 14bb120..98c2aa7 100644 --- a/Readme.md +++ b/Readme.md @@ -1,44 +1,63 @@ -# Djangogram ⚡️ -## Production-Ready Django + Aiogram Telegram Bot +# Djangogram -A production-ready **Django + Aiogram Telegram bot** boilerplate. - ---- +Django + Aiogram Telegram bot template with Docker, Celery, and production webhook support. ## Features -- ✅ Polling mode (development) -- ✅ Webhook mode (production) -- ✅ Ngrok integration (for local webhook testing) -- ✅ Nginx reverse proxy ready -- ✅ Async PostgreSQL operations with Django ORM -- ✅ Celery for background tasks & scheduling -- ✅ Modular handler/router structure -- ✅ Structured logging -- ✅ Dockerized environment (PostgreSQL, Redis, Nginx) - ---- +- Polling mode for local development +- Webhook mode for production +- Webhook request authentication via `X-Telegram-Bot-Api-Secret-Token` +- Django + Celery + Redis + PostgreSQL +- Nginx reverse proxy for production +- CI checks (lint, migrations, Django checks, tests) -## Getting Started +## Quick Start -### 1. Clone the repository +1. Clone the project: ```bash git clone git@github.com:ummataliyev/djangogram.git cd djangogram ``` -### 2. Create .env file -Create a .env file for development: +2. Create environment files: ```bash cp docker/development/.env-example docker/development/.env +cp docker/production/.env-example docker/production/.env ``` -Create a .env file for production: +If a value in `.env` contains `$`, escape it as `$$` to avoid Docker Compose interpolation warnings. + +3. Check available commands: ```bash -cp docker/development/.env-example docker/production/.env +make help ``` -### 3. Check available commands +4. Start development stack: ```bash -make help +make dev-up +``` + +## Important Production Variables + +- `DEBUG=False` +- `ALLOWED_HOSTS=your-domain.com` +- `CSRF_TRUSTED_ORIGINS=https://your-domain.com` +- `WEBHOOK_BASE_URL=https://your-domain.com` +- `TELEGRAM_WEBHOOK_SECRET=` + +## Local Webhook Testing (optional) + +If you want webhook mode locally, set: + +- `IS_POLLING=False` +- `USE_NGROK=True` + +and provide a running ngrok endpoint in your environment. + +## Quality Checks + +```bash +python manage.py makemigrations --check --dry-run +python manage.py check +python manage.py test ``` diff --git a/makefile b/makefile index 5a5216b..6e0b391 100644 --- a/makefile +++ b/makefile @@ -1,5 +1,6 @@ DEV_COMPOSE = docker compose -f docker/development/docker-compose.yml PROD_COMPOSE = docker compose -f docker/production/docker-compose.yml +SELF_MAKEFILE = $(firstword $(MAKEFILE_LIST)) define run_compose $(1) $(2) @@ -161,14 +162,14 @@ help: @echo "Available commands:" @echo "" @echo "Development commands:" - @grep -E '^[a-zA-Z0-9_-]+:' Makefile | grep '^dev-' | while read cmd _; do \ - desc=$$(grep -A1 "$$cmd" Makefile | tail -n1 | sed 's/^[ \t]*# //'); \ + @grep -E '^[a-zA-Z0-9_-]+:' $(SELF_MAKEFILE) | grep '^dev-' | while read cmd _; do \ + desc=$$(grep -A1 "$$cmd" $(SELF_MAKEFILE) | tail -n1 | sed 's/^[ \t]*# //'); \ printf " %-25s %s\n" "$$cmd" "$$desc"; \ done @echo "" @echo "Production commands:" - @grep -E '^[a-zA-Z0-9_-]+:' Makefile | grep '^prod-' | while read cmd _; do \ - desc=$$(grep -A1 "$$cmd" Makefile | tail -n1 | sed 's/^[ \t]*# //'); \ + @grep -E '^[a-zA-Z0-9_-]+:' $(SELF_MAKEFILE) | grep '^prod-' | while read cmd _; do \ + desc=$$(grep -A1 "$$cmd" $(SELF_MAKEFILE) | tail -n1 | sed 's/^[ \t]*# //'); \ printf " %-25s %s\n" "$$cmd" "$$desc"; \ done diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..83ae96e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E9", "F63", "F7", "F82"] From c53d2e876d800e262124037f823ba7cadb7757f5 Mon Sep 17 00:00:00 2001 From: ummataliyev Date: Mon, 16 Feb 2026 19:49:40 +0500 Subject: [PATCH 2/5] refactor: harden settings and fix module path typos --- src/asgi.py | 29 +++--------- src/settings/base.py | 17 ++++++- src/settings/config/celery.py | 2 +- src/settings/config/configs.py | 47 +++++++++++++------ src/settings/db/postgres/__init__.py | 2 +- .../db/postgres/{databse.py => database.py} | 0 src/settings/external/__init__.py | 2 +- .../{backgroun_task.py => background_task.py} | 0 src/urls.py | 6 +-- 9 files changed, 60 insertions(+), 45 deletions(-) rename src/settings/db/postgres/{databse.py => database.py} (100%) rename src/settings/external/{backgroun_task.py => background_task.py} (100%) diff --git a/src/asgi.py b/src/asgi.py index 0a6898c..4439960 100644 --- a/src/asgi.py +++ b/src/asgi.py @@ -1,26 +1,11 @@ -import os -import django -from django.core.handlers.asgi import ASGIHandler - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.settings") -django.setup() - -from apps.bot.instance import on_startup # noqa -from apps.bot.utils.logging import logger # noqa -from src.settings.config.configs import config # noqa +""" +ASGI config for src project. +""" +import os -class DjangogramASGIHandler(ASGIHandler): - async def lifespan(self, scope, receive, send): - if not config.IS_POLLING: - logger.info("🌐 Starting webhook setup...") - try: - await on_startup() - logger.info("✅ Webhook setup done successfully.") - except Exception as e: - logger.error(f"❌ Failed to setup webhook: {e}") - await super().lifespan(scope, receive, send) +from django.core.asgi import get_asgi_application +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.settings") -application = DjangogramASGIHandler() -logger.info("ASGI application initialized successfully.") +application = get_asgi_application() diff --git a/src/settings/base.py b/src/settings/base.py index b276201..72c7bb4 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -94,8 +94,7 @@ # https://docs.djangoproject.com/en/5.0/topics/i18n/ LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" +TIME_ZONE = config.TIME_ZONE USE_I18N = True @@ -118,3 +117,17 @@ CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND CELERY_BEAT_SCHEDULER = config.CELERY_BEAT_SCHEDULER CELERY_TIMEZONE = config.CELERY_TIMEZONE + +if not DEBUG: + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SECURE_SSL_REDIRECT = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_CONTENT_TYPE_NOSNIFF = True + X_FRAME_OPTIONS = "DENY" + SESSION_COOKIE_SAMESITE = "Lax" + CSRF_COOKIE_SAMESITE = "Lax" + SECURE_REFERRER_POLICY = "same-origin" + SECURE_HSTS_SECONDS = 31536000 + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True diff --git a/src/settings/config/celery.py b/src/settings/config/celery.py index bf8a102..58b0a08 100644 --- a/src/settings/config/celery.py +++ b/src/settings/config/celery.py @@ -21,4 +21,4 @@ } -app.conf.timezone = "Asia/Tashkent" +app.conf.timezone = config.CELERY_TIMEZONE diff --git a/src/settings/config/configs.py b/src/settings/config/configs.py index 1586cff..fd85b16 100644 --- a/src/settings/config/configs.py +++ b/src/settings/config/configs.py @@ -8,30 +8,47 @@ class Config: def __init__(self): self.DEBUG = env.bool("DEBUG", False) self.SECRET_KEY = env.str("SECRET_KEY") - self.ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") - self.CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS") + self.ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["localhost", "127.0.0.1"]) + self.CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", []) + self.TIME_ZONE = env.str("TIME_ZONE", "UTC") self.BOT_TOKEN = env.str("BOT_TOKEN") - self.IS_POLLING = env.bool("IS_POLLING") - - self.DB_ENGINE = env.str("DB_ENGINE") - self.DB_NAME = env.str("DB_NAME") - self.DB_USER = env.str("DB_USER") - self.DB_PASSWORD = env.str("DB_PASSWORD") - self.DB_HOST = env.str("DB_HOST") - self.DB_PORT = env.int("DB_PORT") + self.IS_POLLING = env.bool("IS_POLLING", True) + self.WEBHOOK_BASE_URL = env.str("WEBHOOK_BASE_URL", "").rstrip("/") + self.TELEGRAM_WEBHOOK_SECRET = env.str("TELEGRAM_WEBHOOK_SECRET", "") + self.USE_NGROK = env.bool("USE_NGROK", False) + + self.DB_ENGINE = env.str("DB_ENGINE", "django.db.backends.postgresql") + self.DB_NAME = env.str("DB_NAME", "djangogram_db") + self.DB_USER = env.str("DB_USER", "djangogram_user") + self.DB_PASSWORD = env.str("DB_PASSWORD", "djangogram_password") + self.DB_HOST = env.str("DB_HOST", "db") + self.DB_PORT = env.int("DB_PORT", 5432) self.DB_URL = env.str("DB_URL", "") - self.CELERY_BROKER_URL = env.str("CELERY_BROKER_URL") - self.CELERY_RESULT_BACKEND = env.str("CELERY_RESULT_BACKEND") - self.CELERY_BEAT_SCHEDULER = env.str("CELERY_BEAT_SCHEDULER") - self.CELERY_TIMEZONE = env.str("CELERY_TIMEZONE") - self.CELERY_NOTIFY_INTERVAL = env.int("CELERY_NOTIFY_INTERVAL") + self.CELERY_BROKER_URL = env.str("CELERY_BROKER_URL", "redis://redis:6379/0") + self.CELERY_RESULT_BACKEND = env.str("CELERY_RESULT_BACKEND", self.CELERY_BROKER_URL) + self.CELERY_BEAT_SCHEDULER = env.str( + "CELERY_BEAT_SCHEDULER", + "django_celery_beat.schedulers:DatabaseScheduler", + ) + self.CELERY_TIMEZONE = env.str("CELERY_TIMEZONE", self.TIME_ZONE) + self.CELERY_NOTIFY_INTERVAL = env.int("CELERY_NOTIFY_INTERVAL", 1) + + self.REDIS_URL = env.str("REDIS_URL", self.CELERY_BROKER_URL) + self.REDIS_HOST = env.str("REDIS_HOST", "redis") + self.REDIS_PORT = env.int("REDIS_PORT", 6379) + self.REDIS_DB = env.int("REDIS_DB", 0) if not self.DB_URL: self.DB_URL = self.generate_db_url() + if not self.REDIS_URL: + self.REDIS_URL = f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" def generate_db_url(self): + if self.DB_ENGINE == "django.db.backends.sqlite3": + return f"sqlite:///{self.DB_NAME}" + return ( f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@" f"{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" diff --git a/src/settings/db/postgres/__init__.py b/src/settings/db/postgres/__init__.py index 93e9f5d..a0f3f71 100644 --- a/src/settings/db/postgres/__init__.py +++ b/src/settings/db/postgres/__init__.py @@ -1 +1 @@ -from .databse import * # noqa +from .database import * # noqa diff --git a/src/settings/db/postgres/databse.py b/src/settings/db/postgres/database.py similarity index 100% rename from src/settings/db/postgres/databse.py rename to src/settings/db/postgres/database.py diff --git a/src/settings/external/__init__.py b/src/settings/external/__init__.py index 7d4c68c..afec228 100644 --- a/src/settings/external/__init__.py +++ b/src/settings/external/__init__.py @@ -1 +1 @@ -from .backgroun_task import * # noqa +from .background_task import * # noqa diff --git a/src/settings/external/backgroun_task.py b/src/settings/external/background_task.py similarity index 100% rename from src/settings/external/backgroun_task.py rename to src/settings/external/background_task.py diff --git a/src/urls.py b/src/urls.py index a969eb7..d33158f 100644 --- a/src/urls.py +++ b/src/urls.py @@ -16,10 +16,9 @@ """ from django.contrib import admin +from django.conf import settings from django.urls import path, include from django.conf.urls.static import static -from src.settings import STATIC_URL -from src.settings import STATIC_ROOT urlpatterns = [ @@ -28,4 +27,5 @@ ] -urlpatterns += static(STATIC_URL, document_root=STATIC_ROOT) +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) From 018cb54a79a6f19872062c77604a4d35827866c9 Mon Sep 17 00:00:00 2001 From: ummataliyev Date: Mon, 16 Feb 2026 19:49:45 +0500 Subject: [PATCH 3/5] feat(docker): standardize compose naming and optimize nginx --- docker/development/.env-example | 19 +++- docker/development/commands/beat.sh | 2 + docker/development/docker-compose.yml | 2 +- docker/production/.env-example | 27 +++--- docker/production/commands/beat.sh | 2 + docker/production/docker-compose.yml | 35 +------ docker/production/nginx/default.conf | 28 ------ docker/production/nginx/nginx.conf | 133 ++++++++++++++++++++++++++ 8 files changed, 174 insertions(+), 74 deletions(-) delete mode 100644 docker/production/nginx/default.conf create mode 100644 docker/production/nginx/nginx.conf diff --git a/docker/development/.env-example b/docker/development/.env-example index ebe8dd9..1d6dfdb 100644 --- a/docker/development/.env-example +++ b/docker/development/.env-example @@ -1,12 +1,17 @@ # Django DEBUG=True -SECRET_KEY=your_secret_key -ALLOWED_HOSTS=* -CSRF_TRUSTED_ORIGINS=https://*.ngrok-free.app,https://*.ngrok-free.dev,https://*.ngrok.io,http://localhost,http://127.0.0.1 +# If your secret contains '$', escape it as '$$' for Docker Compose interpolation. +SECRET_KEY=replace-with-a-long-dev-secret-key +ALLOWED_HOSTS=localhost,127.0.0.1 +CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 +TIME_ZONE=UTC # Telegram Bot BOT_TOKEN=your_bot_token IS_POLLING=True +WEBHOOK_BASE_URL= +TELEGRAM_WEBHOOK_SECRET= +USE_NGROK=False # Database (PostgreSQL) DB_ENGINE=django.db.backends.postgresql @@ -16,9 +21,15 @@ DB_PASSWORD=your_db_password DB_HOST=db DB_PORT=5432 +# Redis +REDIS_URL=redis://redis:6379/0 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 + # Celery CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0 CELERY_BEAT_SCHEDULER=django_celery_beat.schedulers:DatabaseScheduler -CELERY_TIMEZONE=Asia/Tashkent +CELERY_TIMEZONE=UTC CELERY_NOTIFY_INTERVAL=1 diff --git a/docker/development/commands/beat.sh b/docker/development/commands/beat.sh index 8e97335..1ceedd7 100755 --- a/docker/development/commands/beat.sh +++ b/docker/development/commands/beat.sh @@ -1,3 +1,5 @@ #!/bin/bash +set -e +python manage.py migrate --noinput celery -A src.settings.config.celery beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler diff --git a/docker/development/docker-compose.yml b/docker/development/docker-compose.yml index 2c49a81..f82aa26 100644 --- a/docker/development/docker-compose.yml +++ b/docker/development/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.9" +name: djangogram services: db: diff --git a/docker/production/.env-example b/docker/production/.env-example index 8120eb1..077a03f 100644 --- a/docker/production/.env-example +++ b/docker/production/.env-example @@ -1,30 +1,35 @@ - # Django -DEBUG=True or False -SECRET_KEY= -ALLOWED_HOSTS=* -CSRF_TRUSTED_ORIGINS=https://*.ngrok-free.app,https://*.ngrok-free.dev,https://*.ngrok.io,http://localhost,http://127.0.0.1 +DEBUG=False +# If your secret contains '$', escape it as '$$' for Docker Compose interpolation. +SECRET_KEY=replace-with-a-long-production-secret-key +ALLOWED_HOSTS=your-domain.com +CSRF_TRUSTED_ORIGINS=https://your-domain.com +TIME_ZONE=UTC # Telegram Bot -BOT_TOKEN= -NGROK_AUTHTOKEN= +BOT_TOKEN=your_bot_token IS_POLLING=False +WEBHOOK_BASE_URL=https://your-domain.com +TELEGRAM_WEBHOOK_SECRET=replace-with-a-random-long-secret +USE_NGROK=False # Database (PostgreSQL) DB_ENGINE=django.db.backends.postgresql -DB_NAME= -DB_USER= -DB_PASSWORD= +DB_NAME=your_db_name +DB_USER=your_db_user +DB_PASSWORD=your_db_password DB_HOST=db DB_PORT=5432 # Redis +REDIS_URL=redis://redis:6379/0 REDIS_HOST=redis REDIS_PORT=6379 +REDIS_DB=0 # Celery CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0 CELERY_BEAT_SCHEDULER=django_celery_beat.schedulers:DatabaseScheduler -CELERY_TIMEZONE=Asia/Tashkent +CELERY_TIMEZONE=UTC CELERY_NOTIFY_INTERVAL=1 diff --git a/docker/production/commands/beat.sh b/docker/production/commands/beat.sh index 8e97335..1ceedd7 100755 --- a/docker/production/commands/beat.sh +++ b/docker/production/commands/beat.sh @@ -1,3 +1,5 @@ #!/bin/bash +set -e +python manage.py migrate --noinput celery -A src.settings.config.celery beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler diff --git a/docker/production/docker-compose.yml b/docker/production/docker-compose.yml index c29da75..8aaa6b8 100644 --- a/docker/production/docker-compose.yml +++ b/docker/production/docker-compose.yml @@ -1,3 +1,5 @@ +name: djangogram + services: db: image: postgres:17.5 @@ -42,7 +44,6 @@ services: container_name: djangogram-web command: docker/production/commands/web.sh volumes: - - ../../:/app - static_data:/app/staticfiles - media_data:/app/media env_file: @@ -60,44 +61,23 @@ services: image: nginx:latest container_name: djangogram-nginx volumes: - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - static_data:/static - media_data:/media depends_on: - web + ports: + - "80:80" networks: - djangogram_network restart: unless-stopped - ngrok: - image: ngrok/ngrok:latest - container_name: djangogram_ngrok - command: ["http", "djangogram-nginx:80"] - env_file: - - .env - environment: - NGROK_DEBUG: "true" - depends_on: - - nginx - networks: - - djangogram_network - ports: - - "4040:4040" - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "curl -s http://localhost:4040/api/tunnels | grep -q 'https'"] - interval: 5s - timeout: 5s - retries: 15 - celery_worker: build: context: ../../ dockerfile: docker/production/Dockerfile.bot container_name: djangogram-celery-worker command: docker/production/commands/worker.sh - volumes: - - ../../:/app env_file: - .env depends_on: @@ -115,8 +95,6 @@ services: dockerfile: docker/production/Dockerfile.bot container_name: djangogram-celery-beat command: docker/production/commands/beat.sh - volumes: - - ../../:/app env_file: - .env depends_on: @@ -136,14 +114,11 @@ services: dockerfile: docker/production/Dockerfile.bot container_name: djangogram-bot command: docker/production/commands/bot.sh - volumes: - - ../../:/app env_file: - .env depends_on: - web - nginx - - ngrok networks: - djangogram_network restart: unless-stopped diff --git a/docker/production/nginx/default.conf b/docker/production/nginx/default.conf deleted file mode 100644 index 2e952bc..0000000 --- a/docker/production/nginx/default.conf +++ /dev/null @@ -1,28 +0,0 @@ -server { - listen 80; - server_name localhost; - - location /static/ { - alias /static/; - } - - location /media/ { - alias /media/; - } - - location /bot/webhook/ { - proxy_pass http://djangogram-web:8000/bot/webhook/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location / { - proxy_pass http://djangogram-web:8000; - proxy_set_header Host localhost; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/docker/production/nginx/nginx.conf b/docker/production/nginx/nginx.conf new file mode 100644 index 0000000..f8ecc49 --- /dev/null +++ b/docker/production/nginx/nginx.conf @@ -0,0 +1,133 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format json_format escape=json '{' + '"time":"$time_iso8601",' + '"remote_addr":"$remote_addr",' + '"request":"$request",' + '"status":$status,' + '"body_bytes_sent":$body_bytes_sent,' + '"request_time":$request_time,' + '"referer":"$http_referer",' + '"user_agent":"$http_user_agent"' + '}'; + + access_log /var/log/nginx/access.log json_format; + error_log /var/log/nginx/error.log warn; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + keepalive_requests 1000; + types_hash_max_size 2048; + + server_tokens off; + + client_max_body_size 10M; + client_body_timeout 12s; + client_header_timeout 12s; + + gzip on; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_vary on; + gzip_proxied any; + gzip_types + text/plain + text/css + text/xml + application/json + application/javascript + application/xml + application/xml+rss + image/svg+xml; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream django_web { + server djangogram-web:8000; + keepalive 32; + } + + server { + listen 80; + server_name _; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "0" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + proxy_buffering on; + proxy_buffer_size 8k; + proxy_buffers 16 8k; + + proxy_next_upstream error timeout http_502 http_503 http_504; + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + location = /health { + access_log off; + add_header Content-Type text/plain; + return 200 "ok\n"; + } + + location /static/ { + alias /static/; + access_log off; + expires 1h; + } + + location /media/ { + alias /media/; + access_log off; + expires 1h; + } + + location /ws/ { + proxy_pass http://django_web; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location /bot/webhook/ { + proxy_pass http://django_web/bot/webhook/; + proxy_set_header Connection ""; + } + + location / { + proxy_pass http://django_web; + proxy_set_header Connection ""; + } + } +} From 3b478f7f792ee36890b3be28147a2158cdbe18cc Mon Sep 17 00:00:00 2001 From: ummataliyev Date: Mon, 16 Feb 2026 19:49:51 +0500 Subject: [PATCH 4/5] feat(bot): improve webhook safety and startup notifications --- apps/bot/instance.py | 230 +++++++++++++----------- apps/bot/migrations/0001_initial.py | 30 ++++ apps/bot/tasks/notify.py | 112 +++++++++--- apps/bot/tests/__init__.py | 1 + apps/bot/tests/test_instance_startup.py | 36 ++++ apps/bot/tests/test_webhook_view.py | 65 +++++++ apps/bot/utils/ngrok.py | 10 +- apps/bot/views/webhook.py | 23 ++- 8 files changed, 371 insertions(+), 136 deletions(-) create mode 100644 apps/bot/migrations/0001_initial.py create mode 100644 apps/bot/tests/__init__.py create mode 100644 apps/bot/tests/test_instance_startup.py create mode 100644 apps/bot/tests/test_webhook_view.py diff --git a/apps/bot/instance.py b/apps/bot/instance.py index ef81824..1b6db08 100644 --- a/apps/bot/instance.py +++ b/apps/bot/instance.py @@ -1,158 +1,180 @@ import asyncio +from urllib.parse import urlparse -from typing import Optional - -from aiogram import Bot -from aiogram import Router -from aiogram import Dispatcher - +from aiogram import Bot, Dispatcher +from aiogram.client.session.aiohttp import AiohttpSession from aiogram.exceptions import TelegramRetryAfter +from asgiref.sync import sync_to_async +from aiogram.fsm.storage.base import BaseStorage from aiogram.fsm.storage.memory import MemoryStorage -from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.fsm.storage.redis import RedisStorage -from apps.bot.utils.logging import logger from apps.bot.handlers import register_all +from apps.bot.models.users import Users +from apps.bot.utils.logging import logger from apps.bot.utils.ngrok import get_ngrok_url - from src.settings.config.configs import config +def build_storage() -> BaseStorage: + if not config.REDIS_URL: + logger.warning("REDIS_URL is empty. Falling back to in-memory FSM storage.") + return MemoryStorage() + + try: + return RedisStorage.from_url(config.REDIS_URL) + except Exception as exc: + logger.warning( + "Failed to initialize RedisStorage (%s). Falling back to MemoryStorage.", + exc, + ) + return MemoryStorage() + + +async def resolve_webhook_base_url() -> str: + if config.WEBHOOK_BASE_URL: + parsed = urlparse(config.WEBHOOK_BASE_URL) + if parsed.scheme != "https": + raise ValueError("WEBHOOK_BASE_URL must start with https://") + return config.WEBHOOK_BASE_URL.rstrip("/") + + if config.USE_NGROK: + logger.info("Waiting for ngrok to provide a public URL...") + return (await get_ngrok_url()).rstrip("/") + + raise RuntimeError( + "Webhook mode requires WEBHOOK_BASE_URL or USE_NGROK=True." + ) + + session = AiohttpSession() bot = Bot(config.BOT_TOKEN, session=session) -dp = Dispatcher(storage=MemoryStorage()) - -router: Optional[Router] = None +dp = Dispatcher(storage=build_storage()) register_all(dp) -logger.info("✅ All routers registered") +logger.info("All routers registered") + +@sync_to_async +def _fetch_startup_chat_ids() -> list[int]: + return list(Users.objects.exclude(chat_id__isnull=True).values_list("chat_id", flat=True)) -def init_router(actual_router: Router) -> None: - """ - Initialize and include a given router into the dispatcher. - :param actual_router: The router instance to include into the dispatcher. - :type actual_router: aiogram.Router - :return: None - :rtype: NoneType - """ - global router - if router is None: - dp.include_router(actual_router) - router = actual_router - logger.info("✅ Router successfully included") +async def notify_bot_started() -> None: + startup_text = "Hi, Bot is Running!" + try: + chat_ids = await _fetch_startup_chat_ids() + except Exception: + logger.exception("Failed to fetch startup notification recipients.") + return + + if not chat_ids: + logger.info("No Telegram users found for startup notification.") + return + + sent = 0 + failed = 0 + for chat_id in chat_ids: + try: + await bot.send_message(chat_id=chat_id, text=startup_text) + sent += 1 + except TelegramRetryAfter as exc: + wait_time = getattr(exc, "retry_after", 1) + logger.warning( + "Rate limited while sending startup notification to chat_id=%s; retrying in %ss.", + chat_id, + wait_time, + ) + await asyncio.sleep(wait_time) + try: + await bot.send_message(chat_id=chat_id, text=startup_text) + sent += 1 + except Exception: + failed += 1 + logger.exception( + "Failed to send startup notification after retry to chat_id=%s.", + chat_id, + ) + except Exception: + failed += 1 + logger.exception("Failed to send startup notification to chat_id=%s.", chat_id) + + logger.info("Startup notification completed. sent=%s failed=%s", sent, failed) async def on_startup() -> None: - """ - Perform bot startup routines. - - This function initializes the bot depending on the selected mode: - - **Polling mode:** Skips webhook setup (used for local development). - - **Webhook mode:** Waits for Ngrok to start, retrieves the public URL, - and registers it with Telegram as the bot's webhook endpoint. - - :raises Exception: If webhook setup fails after maximum retry attempts. - :return: None - :rtype: NoneType - """ if config.IS_POLLING: - logger.info("🚀 Polling mode active — skipping webhook setup") + logger.info("Polling mode active: webhook setup skipped.") + await notify_bot_started() return max_retries = 3 - retry_count = 0 - - while retry_count < max_retries: + for attempt in range(1, max_retries + 1): try: - logger.info("⏳ Waiting for ngrok to start...") - ngrok_url = await get_ngrok_url() - webhook_url = f"{ngrok_url}/bot/webhook/" - logger.info(f"🌐 Ngrok URL: {ngrok_url}") - logger.info(f"📍 Webhook URL: {webhook_url}") + webhook_base_url = await resolve_webhook_base_url() + webhook_url = f"{webhook_base_url}/bot/webhook/" + logger.info("Webhook URL resolved: %s", webhook_url) await bot.delete_webhook(drop_pending_updates=True) - logger.info("🗑️ Old webhook deleted") - webhook_set = await bot.set_webhook( - url=webhook_url, - allowed_updates=dp.resolve_used_update_types(), - drop_pending_updates=True - ) - - if webhook_set: - logger.info("✅ Webhook successfully set!") - - webhook_info = await bot.get_webhook_info() - logger.info("📊 Webhook info:") - logger.info(f" - URL: {webhook_info.url}") - logger.info(f" - Has custom certificate: {webhook_info.has_custom_certificate}") - logger.info(f" - Pending update count: {webhook_info.pending_update_count}") - if webhook_info.last_error_date: - logger.warning(f" - Last error: {webhook_info.last_error_message}") - return # Success — exit retry loop - else: - raise ValueError("set_webhook returned False") - - except TelegramRetryAfter as e: - retry_count += 1 - wait_time = getattr(e, "retry_after", 1) + set_webhook_payload = { + "url": webhook_url, + "allowed_updates": dp.resolve_used_update_types(), + "drop_pending_updates": True, + } + if config.TELEGRAM_WEBHOOK_SECRET: + set_webhook_payload["secret_token"] = config.TELEGRAM_WEBHOOK_SECRET + + webhook_set = await bot.set_webhook(**set_webhook_payload) + if not webhook_set: + raise RuntimeError("Telegram set_webhook returned False.") + + webhook_info = await bot.get_webhook_info() + logger.info("Webhook active: %s", webhook_info.url) + await notify_bot_started() + return + except TelegramRetryAfter as exc: + wait_time = getattr(exc, "retry_after", 1) logger.warning( - f"⚠️ Rate limit hit on set_webhook (retry {retry_count}/{max_retries}). " - f"Waiting {wait_time}s..." + "Telegram rate limit on set_webhook (attempt %s/%s). Waiting %ss.", + attempt, + max_retries, + wait_time, ) await asyncio.sleep(wait_time) - - except Exception as e: - logger.error(f"❌ Failed to setup webhook: {e}", exc_info=True) + except Exception: + logger.exception("Failed to configure webhook.") raise - raise Exception(f"Failed to set webhook after {max_retries} retries") + raise RuntimeError(f"Failed to configure webhook after {max_retries} retries.") async def on_shutdown() -> None: - """ - Cleanly shut down the bot and close open sessions. - - :return: None - :rtype: NoneType - """ try: if not config.IS_POLLING: await bot.delete_webhook() - logger.info("🛑 Webhook deleted") + logger.info("Webhook deleted") await bot.session.close() - logger.info("🛑 Bot session closed") - - except Exception as e: - logger.error(f"Error during shutdown: {e}") + logger.info("Bot session closed") + except Exception: + logger.exception("Error during bot shutdown.") async def start_bot() -> None: - """ - Start the bot in **polling mode**. - - :return: None - :rtype: NoneType - """ if not config.IS_POLLING: - logger.warning( - "⚠️ start_bot() called but IS_POLLING=False. " - "Use webhook mode in production." - ) + logger.warning("start_bot() called with IS_POLLING=False; skipping.") return try: await on_startup() - logger.info("🤖 Starting polling...") + logger.info("Starting polling loop...") await dp.start_polling(bot, polling_timeout=20, handle_signals=True) - except asyncio.CancelledError: - logger.info("⚠️ Polling cancelled") + logger.info("Polling cancelled") except KeyboardInterrupt: - logger.info("⚠️ Keyboard interrupt received") - except Exception as e: - logger.error(f"❌ Error in start_bot: {e}", exc_info=True) + logger.info("Keyboard interrupt received") + except Exception: + logger.exception("Error while running polling mode.") finally: await on_shutdown() diff --git a/apps/bot/migrations/0001_initial.py b/apps/bot/migrations/0001_initial.py new file mode 100644 index 0000000..d4c1538 --- /dev/null +++ b/apps/bot/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.7 on 2025-11-02 14:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Users', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('first_name', models.CharField(blank=True, default='Anonymous', max_length=255, null=True)), + ('chat_id', models.BigIntegerField(unique=True)), + ('username', models.CharField(blank=True, max_length=255, null=True)), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + 'db_table': 'users', + }, + ), + ] diff --git a/apps/bot/tasks/notify.py b/apps/bot/tasks/notify.py index 60092f8..bc454c9 100644 --- a/apps/bot/tasks/notify.py +++ b/apps/bot/tasks/notify.py @@ -1,21 +1,41 @@ -from celery import shared_task +import time +from typing import Any + import requests +from celery import shared_task +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + from apps.bot.models.users import Users +from apps.bot.utils.logging import logger from src.settings.config.configs import config -def safe_send_message(chat_id: int, text: str) -> None: - """ - Safely send a message via Telegram Bot API using synchronous requests. - This avoids any asyncio event loop issues in Celery workers. +def build_telegram_session() -> requests.Session: + retry = Retry( + total=3, + connect=3, + read=3, + status=3, + allowed_methods=frozenset(["POST"]), + status_forcelist=[429, 500, 502, 503, 504], + backoff_factor=1.0, + respect_retry_after_header=True, + ) + adapter = HTTPAdapter(max_retries=retry) + + session = requests.Session() + session.mount("https://", adapter) + return session + + +TELEGRAM_SESSION = build_telegram_session() + - :param chat_id: Telegram chat ID of the user to send the message to. - :param text: The message text to send. - :return: None - """ +def safe_send_message(chat_id: int, text: str, max_attempts: int = 3) -> bool: if not config.BOT_TOKEN: - print("BOT_TOKEN not found in environment variables") - return + logger.error("BOT_TOKEN is missing; cannot send Telegram notifications.") + return False url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage" payload = { @@ -24,22 +44,62 @@ def safe_send_message(chat_id: int, text: str) -> None: "parse_mode": "HTML", } - try: - r = requests.post(url, json=payload, timeout=10) - if r.status_code != 200: - print(f"Telegram API error: {r.status_code} - {r.text}") - except Exception as e: - print(f"Error sending message: {e}") + for attempt in range(1, max_attempts + 1): + try: + response = TELEGRAM_SESSION.post(url, json=payload, timeout=10) + if response.status_code == 200: + return True + + if response.status_code == 429: + retry_after = 1 + try: + data: dict[str, Any] = response.json() + retry_after = int(data.get("parameters", {}).get("retry_after", 1)) + except Exception: + retry_after = 1 + + logger.warning( + "Telegram rate-limited message for chat_id=%s. Attempt %s/%s. Retrying in %ss.", + chat_id, + attempt, + max_attempts, + retry_after, + ) + time.sleep(retry_after) + continue + + logger.warning( + "Telegram API error for chat_id=%s. status=%s body=%s", + chat_id, + response.status_code, + response.text, + ) + except requests.RequestException as exc: + logger.warning( + "Network error while sending Telegram message to chat_id=%s (attempt %s/%s): %s", + chat_id, + attempt, + max_attempts, + exc, + ) + + if attempt < max_attempts: + time.sleep(attempt) + + return False @shared_task def send_hi_to_all_users() -> None: - """ - Celery task that sends 'Hi' to all users in the Users table. - - :return: None - """ - users = Users.objects.all() - for user in users: - if user.chat_id: - safe_send_message(user.chat_id, "Hi 👋") + sent = 0 + failed = 0 + + chat_ids = Users.objects.exclude(chat_id__isnull=True).values_list("chat_id", flat=True) + for chat_id in chat_ids.iterator(): + if safe_send_message(chat_id, "Hi 👋"): + sent += 1 + else: + failed += 1 + time.sleep(0.05) + + logger.info("Notification task completed. sent=%s failed=%s", sent, failed) diff --git a/apps/bot/tests/__init__.py b/apps/bot/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/bot/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/bot/tests/test_instance_startup.py b/apps/bot/tests/test_instance_startup.py new file mode 100644 index 0000000..27642d2 --- /dev/null +++ b/apps/bot/tests/test_instance_startup.py @@ -0,0 +1,36 @@ +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock +from unittest.mock import patch + +from apps.bot import instance + + +class BotStartupNotificationTests(IsolatedAsyncioTestCase): + async def test_notify_bot_started_sends_expected_message_to_all_users(self) -> None: + with patch( + "apps.bot.instance._fetch_startup_chat_ids", + AsyncMock(return_value=[111, 222]), + ), patch("apps.bot.instance.bot.send_message", AsyncMock()) as send_message_mock: + await instance.notify_bot_started() + + self.assertEqual(send_message_mock.await_count, 2) + send_message_mock.assert_any_await(chat_id=111, text="Hi, Bot is Running!") + send_message_mock.assert_any_await(chat_id=222, text="Hi, Bot is Running!") + + async def test_notify_bot_started_skips_when_no_users(self) -> None: + with patch( + "apps.bot.instance._fetch_startup_chat_ids", + AsyncMock(return_value=[]), + ), patch("apps.bot.instance.bot.send_message", AsyncMock()) as send_message_mock: + await instance.notify_bot_started() + + send_message_mock.assert_not_awaited() + + async def test_on_startup_in_polling_mode_triggers_startup_notification(self) -> None: + with patch.object(instance.config, "IS_POLLING", True), patch( + "apps.bot.instance.notify_bot_started", + AsyncMock(), + ) as notify_mock: + await instance.on_startup() + + notify_mock.assert_awaited_once() diff --git a/apps/bot/tests/test_webhook_view.py b/apps/bot/tests/test_webhook_view.py new file mode 100644 index 0000000..536754e --- /dev/null +++ b/apps/bot/tests/test_webhook_view.py @@ -0,0 +1,65 @@ +import json +from unittest.mock import MagicMock, patch + +from django.test import TestCase + + +class TelegramWebhookViewTests(TestCase): + def test_rejects_request_when_secret_header_is_missing(self) -> None: + with patch( + "apps.bot.views.webhook.config.TELEGRAM_WEBHOOK_SECRET", + "expected-secret", + ): + response = self.client.post( + "/bot/webhook/", + data=json.dumps({"update_id": 1}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 403) + + def test_rejects_request_when_secret_header_is_invalid(self) -> None: + with patch( + "apps.bot.views.webhook.config.TELEGRAM_WEBHOOK_SECRET", + "expected-secret", + ): + response = self.client.post( + "/bot/webhook/", + data=json.dumps({"update_id": 1}), + content_type="application/json", + HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN="wrong-secret", + ) + + self.assertEqual(response.status_code, 403) + + def test_rejects_invalid_json_payload(self) -> None: + with patch("apps.bot.views.webhook.config.TELEGRAM_WEBHOOK_SECRET", ""): + response = self.client.post( + "/bot/webhook/", + data="{not-json", + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + + def test_accepts_valid_request(self) -> None: + with patch( + "apps.bot.views.webhook.config.TELEGRAM_WEBHOOK_SECRET", + "expected-secret", + ), patch( + "apps.bot.views.webhook.types.Update.model_validate", + return_value=object(), + ), patch("apps.bot.views.webhook.async_to_sync") as async_to_sync_mock: + handler_mock = MagicMock() + async_to_sync_mock.return_value = handler_mock + + response = self.client.post( + "/bot/webhook/", + data=json.dumps({"update_id": 1}), + content_type="application/json", + HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN="expected-secret", + ) + + self.assertEqual(response.status_code, 200) + async_to_sync_mock.assert_called_once() + handler_mock.assert_called_once() diff --git a/apps/bot/utils/ngrok.py b/apps/bot/utils/ngrok.py index 4af52b7..ba5a554 100644 --- a/apps/bot/utils/ngrok.py +++ b/apps/bot/utils/ngrok.py @@ -22,6 +22,7 @@ async def get_ngrok_url(max_retries: int = 20, delay: int = 3) -> str: try: async with aiohttp.ClientSession() as session: async with session.get("http://ngrok:4040/api/tunnels") as resp: + resp.raise_for_status() data = await resp.json() for tunnel in data.get("tunnels", []): if tunnel.get("proto") == "https": @@ -32,8 +33,13 @@ async def get_ngrok_url(max_retries: int = 20, delay: int = 3) -> str: logger.info(f"🌍 NGROK_DOMAIN set to: {domain}") return url - except Exception: - logger.warning(f"⏳ Ngrok not ready yet ({attempt + 1}/{max_retries})...") + except Exception as exc: + logger.warning( + "⏳ Ngrok not ready yet (%s/%s): %s", + attempt + 1, + max_retries, + exc, + ) await asyncio.sleep(delay) raise RuntimeError("Failed to obtain Ngrok URL — no tunnel found.") diff --git a/apps/bot/views/webhook.py b/apps/bot/views/webhook.py index 967d9bb..3f6cb5a 100644 --- a/apps/bot/views/webhook.py +++ b/apps/bot/views/webhook.py @@ -1,4 +1,5 @@ import json +from hmac import compare_digest from typing import Any @@ -12,6 +13,8 @@ from django.views.decorators.csrf import csrf_exempt from apps.bot.instance import bot, dp +from apps.bot.utils.logging import logger +from src.settings.config.configs import config @method_decorator(csrf_exempt, name="dispatch") @@ -34,15 +37,27 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: :return: HTTP 200 on success, 500 on failure. :rtype: django.http.HttpResponse """ + if config.TELEGRAM_WEBHOOK_SECRET: + received_secret = request.headers.get( + "X-Telegram-Bot-Api-Secret-Token", + "", + ) + if not compare_digest(received_secret, config.TELEGRAM_WEBHOOK_SECRET): + logger.warning("Rejected webhook request with invalid secret token.") + return HttpResponse(status=403) + try: data: dict[str, Any] = json.loads(request.body) - update: types.Update = types.Update.model_validate(data) + except json.JSONDecodeError: + logger.warning("Rejected webhook request with invalid JSON payload.") + return HttpResponse(status=400) + try: + update: types.Update = types.Update.model_validate(data) async_to_sync(dp.feed_update)(bot, update) return HttpResponse(status=200) - - except Exception as e: - print("Webhook error:", e) + except Exception: + logger.exception("Webhook processing failed.") return HttpResponse(status=500) def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: From 3ba19ca55587310176150b4c7f95b8a2bd87c1d5 Mon Sep 17 00:00:00 2001 From: ummataliyev Date: Mon, 16 Feb 2026 19:52:36 +0500 Subject: [PATCH 5/5] test: run webhook view tests over HTTPS to avoid redirect --- apps/bot/tests/test_webhook_view.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/bot/tests/test_webhook_view.py b/apps/bot/tests/test_webhook_view.py index 536754e..d82c321 100644 --- a/apps/bot/tests/test_webhook_view.py +++ b/apps/bot/tests/test_webhook_view.py @@ -14,6 +14,7 @@ def test_rejects_request_when_secret_header_is_missing(self) -> None: "/bot/webhook/", data=json.dumps({"update_id": 1}), content_type="application/json", + secure=True, ) self.assertEqual(response.status_code, 403) @@ -28,6 +29,7 @@ def test_rejects_request_when_secret_header_is_invalid(self) -> None: data=json.dumps({"update_id": 1}), content_type="application/json", HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN="wrong-secret", + secure=True, ) self.assertEqual(response.status_code, 403) @@ -38,6 +40,7 @@ def test_rejects_invalid_json_payload(self) -> None: "/bot/webhook/", data="{not-json", content_type="application/json", + secure=True, ) self.assertEqual(response.status_code, 400) @@ -58,6 +61,7 @@ def test_accepts_valid_request(self) -> None: data=json.dumps({"update_id": 1}), content_type="application/json", HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN="expected-secret", + secure=True, ) self.assertEqual(response.status_code, 200)