commit b951296d9de7eef8500d25b30f287c569ad215af Author: averageencoreenjoer <86197438+averageencoreenjoer@users.noreply.github.com> Date: Tue Sep 2 01:35:30 2025 +0300 feat: Add GitHub Actions CI and professional README diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..813fa0c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "Telegram Bot Dev Environment", + "dockerComposeFile": "../docker-compose.yml", + "service": "bot", + "workspaceFolder": "/app", + "customizations": { + "vscode": { + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python" + }, + "extensions": [ + "ms-python.python", + "ms-azuretools.vscode-docker" + ] + } + } +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8da799 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN + +POSTGRES_DB=greeting_db +POSTGRES_USER=user +POSTGRES_PASSWORD=password + +WEB_USERNAME=admin +WEB_PASSWORD=secure_password diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..2b1e740 --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +BOT_TOKEN=dummy_token_for_testing +POSTGRES_DB=greeting_db_test +POSTGRES_USER=user +POSTGRES_PASSWORD=password +WEB_USERNAME=admin +WEB_PASSWORD=supersecret diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..b9b6eda --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,52 @@ +# .github/workflows/main.yml + +name: CI Pipeline + +# --- Triggers --- +# This workflow runs on pushes to the 'main' branch and on pull requests targeting 'main'. +on: + push: + branches: + - main + pull_request: + branches: + - main + +# --- Jobs --- +# We define a single job called 'build-and-test'. +jobs: + build-and-test: + # The type of virtual machine to run the job on. 'ubuntu-latest' is a standard choice. + runs-on: ubuntu-latest + + # --- Steps --- + # A sequence of tasks that will be executed as part of the job. + steps: + # Step 1: Check out the repository code + # This action checks out your repository under $GITHUB_WORKSPACE, so your job can access it. + - name: Checkout repository + uses: actions/checkout@v4 + + # Step 2: Set up Docker Buildx + # This is a required step for building multi-platform images and using advanced Docker features. + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Step 3: Run the test suite + # This is the core of our CI. It executes the same script we used locally. + # We provide a dummy BOT_TOKEN because the tests don't need a real one. + - name: Run integration tests + run: ./run_tests.sh + env: + # We need to provide the BOT_TOKEN variable, as the script expects it, + # but it can be a dummy value since it's only used for the bot service + # which is idle during tests. + BOT_TOKEN: "dummy_token_for_ci" + + # Step 4 (Optional but recommended): Clean up Docker resources + # This ensures that any lingering containers or images are removed to free up space on the GitHub runner. + - name: Docker cleanup + if: always() # This step runs even if previous steps fail. + run: | + docker-compose down -v --remove-orphans + docker system prune -af --volumes \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e4ed41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +.vscode/ +venv/ +.DS_Store +db_data/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a4363e --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# Telegram Greeting Bot + +[![CI Pipeline](https://github.com/averageencoreenjoer/telegram_greeting_bot/actions/workflows/main.yml/badge.svg)](https://github.com/averageencoreenjoer/telegram_greeting_bot/actions/workflows/main.yml) + +A fully containerized Telegram bot project featuring a dynamic, database-driven greeting message that can be updated in real-time via a simple web admin panel. This project is built with a senior-level approach, emphasizing clean architecture, separation of concerns, containerization, and automated testing. + +--- + +## ✨ Features + +- **Dynamic Greetings**: The `/start` command greeting is fetched directly from a PostgreSQL database. +- **Real-time Updates**: No need to restart the bot. Changes made in the admin panel are reflected instantly. +- **Mini Admin Panel**: A secure, password-protected web interface (built with FastAPI) to update the greeting text. +- **Fully Containerized**: Uses Docker and Docker Compose to run the entire stack (Bot, Web, DB) with a single command. +- **Automated Integration Tests**: A robust test suite using Pytest ensures the entire system works correctly. +- **CI/CD Ready**: Includes a GitHub Actions workflow to automatically run tests on every push and pull request. + +--- + +## 🛠️ Tech Stack + +

+ Python + Telegram + FastAPI + PostgreSQL + Docker + Pytest + GitHub Actions +

+ +- **Bot Framework**: `aiogram` +- **Web Framework**: `FastAPI` +- **Database**: `PostgreSQL` with `asyncpg` driver +- **Containerization**: `Docker` & `Docker Compose` +- **Testing**: `Pytest`, `pytest-asyncio`, `httpx` +- **CI/CD**: `GitHub Actions` + +--- + +## 🚀 Getting Started + +### Prerequisites + +- [Docker](https://www.docker.com/products/docker-desktop/) installed and running. +- A Telegram Bot Token obtained from [@BotFather](https://t.me/BotFather). + +### 1. Clone the Repository + +```bash +git clone https://github.com/averageencoreenjoer/telegram_greeting_bot.git +cd telegram_greeting_bot +``` + +### 2. Configure Environment Variables + +Create a local environment file by copying the example. + +```bash +cp .env.example .env +``` + +Now, open the `.env` file and fill in your details: + +```ini +# .env + +# 1. Your unique Telegram Bot Token from @BotFather +BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u1234567 + +# 2. Credentials for the database (can be left as default for local development) +POSTGRES_DB=greeting_db +POSTGRES_USER=user +POSTGRES_PASSWORD=password + +# 3. Credentials for the web admin panel (you will use these to log in) +WEB_USERNAME=admin +WEB_PASSWORD=supersecret +``` + +### 3. Run the Application + +Launch the entire stack using Docker Compose. + +```bash +docker-compose up --build -d +``` + +The services will start in detached mode (`-d`). You can view the logs with `docker-compose logs -f`. + +### 4. Interact with the System + +- **Telegram Bot**: Find your bot on Telegram and send the `/start` command. It will reply with the default greeting. +- **Admin Panel**: Open your browser and navigate to `http://localhost:8000/docs`. + - You will be prompted for authentication. Use the `WEB_USERNAME` and `WEB_PASSWORD` from your `.env` file. + - Use the `/update_greeting` endpoint to change the greeting text. + - Go back to Telegram and send `/start` again. The bot will now use the new greeting! + +### 5. Stopping the Application + +To stop all services, run: + +```bash +docker-compose down +``` + +To stop services and remove the database volume (delete all data), run: + +```bash +docker-compose down -v +``` + +--- + +## 🧪 Running Tests + +This project comes with a fully automated integration test suite that spins up a separate, isolated environment to validate the system's behavior. + +To run the tests, execute the provided script from the project root: + +```bash +./run_tests.sh +``` + +The script will: +1. Start the services using a dedicated test database (`.env.test`). +2. Wait for the database to be fully ready. +3. Install test dependencies. +4. Run the Pytest suite, which makes real API calls. +5. Shut down and clean up all test-related containers and volumes. + +This is the same script that runs in the GitHub Actions CI pipeline. \ No newline at end of file diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..e0043f8 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.9-slim-buster + +WORKDIR /app + +# Копируем файл зависимостей из папки bot +COPY bot/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем исходный код из папки bot/src +COPY bot/src/. /app/ + +ENV PYTHONUNBUFFERED 1 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..29bd4e6 --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,3 @@ +aiogram==3.* +asyncpg==0.* +python-dotenv==1.* \ No newline at end of file diff --git a/bot/src/__init__.py b/bot/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/src/config.py b/bot/src/config.py new file mode 100644 index 0000000..235c8cd --- /dev/null +++ b/bot/src/config.py @@ -0,0 +1,17 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +class BotConfig: + BOT_TOKEN = os.getenv("BOT_TOKEN") + DB_HOST = os.getenv("DB_HOST", "localhost") + DB_NAME = os.getenv("DB_NAME", "greeting_db") + DB_USER = os.getenv("DB_USER", "user") + DB_PASSWORD = os.getenv("DB_PASSWORD", "password") + + @classmethod + def get_db_url(cls): + return f"postgresql://{cls.DB_USER}:{cls.DB_PASSWORD}@{cls.DB_HOST}/{cls.DB_NAME}" + \ No newline at end of file diff --git a/bot/src/db.py b/bot/src/db.py new file mode 100644 index 0000000..db72439 --- /dev/null +++ b/bot/src/db.py @@ -0,0 +1,28 @@ +import asyncpg +from config import BotConfig + + +async def get_db_pool(): + return await asyncpg.create_pool(BotConfig.get_db_url()) + + +async def get_greeting(pool): + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT greeting_text FROM greetings ORDER BY id LIMIT 1") + return row['greeting_text'] if row else "Привет! Добро пожаловать!" + + +async def set_greeting(pool, new_text: str): + async with pool.acquire() as conn: + await conn.execute( + "UPDATE greetings SET greeting_text = $1 WHERE id = 1", + new_text + ) + await conn.execute( + """ + INSERT INTO greetings (id, greeting_text) + VALUES (1, $1) + ON CONFLICT (id) DO NOTHING; + """, + new_text + ) diff --git a/bot/src/handlers.py b/bot/src/handlers.py new file mode 100644 index 0000000..dfb8f55 --- /dev/null +++ b/bot/src/handlers.py @@ -0,0 +1,11 @@ +from aiogram import Router, types +from aiogram.filters import CommandStart +from db import get_greeting + +router = Router() + + +@router.message(CommandStart()) +async def command_start_handler(message: types.Message, db_pool) -> None: + greeting_text = await get_greeting(db_pool) + await message.answer(greeting_text + "!!!") diff --git a/bot/src/keyboards.py b/bot/src/keyboards.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/src/main.py b/bot/src/main.py new file mode 100644 index 0000000..6327645 --- /dev/null +++ b/bot/src/main.py @@ -0,0 +1,29 @@ +import asyncio +import logging +from aiogram import Bot, Dispatcher +from config import BotConfig +from handlers import router +from db import get_db_pool + + +async def main() -> None: + logging.basicConfig(level=logging.INFO) + + bot = Bot(token=BotConfig.BOT_TOKEN) + dp = Dispatcher() + + db_pool = await get_db_pool() + dp["db_pool"] = db_pool + + dp.include_router(router) + + logging.info("Starting bot...") + await dp.start_polling(bot) + + await db_pool.close() + logging.info("Bot stopped.") + + +if __name__ == "__main__": + asyncio.run(main()) + \ No newline at end of file diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..286b67b --- /dev/null +++ b/db/init.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS greetings ( + id SERIAL PRIMARY KEY, + greeting_text TEXT NOT NULL DEFAULT 'Привет! Добро пожаловать!' +); + +INSERT INTO greetings (greeting_text) +SELECT 'Привет! Добро пожаловать!' +WHERE NOT EXISTS (SELECT 1 FROM greetings WHERE id = 1); \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..b8b7df8 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,5 @@ +version: '3.8' + +services: + bot: + command: tail -f /dev/null \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a0ec7a0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +version: '3.8' + +services: + db: + image: postgres:13 + restart: always + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - db_data:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: [ "CMD-SHELL", "pg_isready && psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -c 'SELECT 1'" ] + interval: 5s + timeout: 5s + retries: 10 + + bot: + build: + context: . + dockerfile: bot/Dockerfile + restart: always + environment: + BOT_TOKEN: ${BOT_TOKEN} + DB_HOST: db + DB_NAME: ${POSTGRES_DB} + DB_USER: ${POSTGRES_USER} + DB_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ./bot/src:/app + tty: true + depends_on: + db: + condition: service_healthy + + web: + build: + context: . + dockerfile: web/Dockerfile + restart: always + environment: + DB_HOST: db + DB_NAME: ${POSTGRES_DB} + DB_USER: ${POSTGRES_USER} + DB_PASSWORD: ${POSTGRES_PASSWORD} + WEB_USERNAME: ${WEB_USERNAME} + WEB_PASSWORD: ${WEB_PASSWORD} + volumes: + - ./web/src:/app + ports: + - "8000:8000" + tty: true + depends_on: + db: + condition: service_healthy + +volumes: + db_data: \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 0000000..c54e575 --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,4 @@ +pytest +pytest-asyncio +httpx +python-dotenv diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..27da2f8 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,33 @@ +set -e + +echo "--- 1. Запуск тестового окружения (используя .env.test) ---" +docker-compose --env-file .env.test up -d --build --force-recreate + +echo "--- 2. Ожидание готовности Базы Данных ---" + +retries=15 +while [ $retries -gt 0 ]; do + docker-compose exec -T db pg_isready -U user -d greeting_db_test && break + retries=$((retries-1)) + echo "Ожидание БД... Осталось попыток: $retries" + sleep 2 +done +if [ $retries -eq 0 ]; then + echo "!!! База Данных не запустилась вовремя. Проверьте логи: docker-compose logs db" + exit 1 +fi +echo "--- База Данных готова! ---" + +echo "--- 3. Установка Python-зависимостей для тестов ---" + +pip3 install -r requirements.test.txt + +echo "--- 4. ЗАПУСК ТЕСТОВ ---" +pytest tests/ + +TEST_EXIT_CODE=$? + +echo "--- 5. Очистка: остановка и удаление тестового окружения ---" +docker-compose --env-file .env.test down -v + +exit $TEST_EXIT_CODE diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..135a25b --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,30 @@ +import pytest +import httpx +import os +from dotenv import load_dotenv + + +load_dotenv(dotenv_path='.env.test') + +BASE_URL = "http://localhost:8000" +ADMIN_USER = os.getenv("WEB_USERNAME") +ADMIN_PASSWORD = os.getenv("WEB_PASSWORD") +AUTH = (ADMIN_USER, ADMIN_PASSWORD) + +@pytest.mark.asyncio +async def test_full_greeting_cycle(): + async with httpx.AsyncClient(timeout=10) as client: + response_before = await client.get(BASE_URL + "/", auth=AUTH) + assert response_before.status_code == 200 + assert response_before.json()["current_greeting_text"] == "Привет! Добро пожаловать!" + + new_text = "Автотест прошел успешно!" + update_payload = {"new_greeting_text": new_text} + response_update = await client.post(BASE_URL + "/update_greeting", json=update_payload, auth=AUTH) + assert response_update.status_code == 200 + assert response_update.json()["current_greeting_text"] == new_text + + response_after = await client.get(BASE_URL + "/", auth=AUTH) + assert response_after.status_code == 200 + assert response_after.json()["current_greeting_text"] == new_text + \ No newline at end of file diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..fd6e47a --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.9-slim-buster + +WORKDIR /app + +# Копируем файл зависимостей из папки web +COPY web/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем исходный код из папки web/src +COPY web/src/. /app/ + +ENV PYTHONUNBUFFERED 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..f9590f0 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.* +uvicorn==0.* +asyncpg==0.* +python-dotenv==1.* \ No newline at end of file diff --git a/web/src/__init__.py b/web/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/src/config.py b/web/src/config.py new file mode 100644 index 0000000..f438ca2 --- /dev/null +++ b/web/src/config.py @@ -0,0 +1,18 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +class WebConfig: + DB_HOST = os.getenv("DB_HOST", "localhost") + DB_NAME = os.getenv("DB_NAME", "greeting_db") + DB_USER = os.getenv("DB_USER", "user") + DB_PASSWORD = os.getenv("DB_PASSWORD", "password") + WEB_USERNAME = os.getenv("WEB_USERNAME", "admin") + WEB_PASSWORD = os.getenv("WEB_PASSWORD", "secure_password") + + @classmethod + def get_db_url(cls): + return f"postgresql://{cls.DB_USER}:{cls.DB_PASSWORD}@{cls.DB_HOST}/{cls.DB_NAME}" + \ No newline at end of file diff --git a/web/src/main.py b/web/src/main.py new file mode 100644 index 0000000..459f914 --- /dev/null +++ b/web/src/main.py @@ -0,0 +1,94 @@ +import logging +import asyncio +import asyncpg +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from config import WebConfig +from schemas import GreetingUpdate, GreetingResponse +import secrets + + +app = FastAPI(title="Greeting Bot Admin Panel") +security = HTTPBasic() + +logging.basicConfig(level=logging.INFO) + +db_pool = None + + +async def get_db_pool(): + global db_pool + if db_pool is None: + for i in range(5): + try: + logging.info(f"Attempting to connect to DB (try {i+1}/5)...") + db_pool = await asyncpg.create_pool(WebConfig.get_db_url()) + logging.info("DB pool created successfully.") + break + except (ConnectionRefusedError, asyncpg.exceptions.InvalidPasswordError) as e: + logging.warning(f"DB connection failed: {e}. Retrying in 3 seconds...") + if i == 4: + raise + await asyncio.sleep(3) + return db_pool + + +async def get_current_username(credentials: HTTPBasicCredentials = Depends(security)): + correct_username = secrets.compare_digest(credentials.username, WebConfig.WEB_USERNAME) + correct_password = secrets.compare_digest(credentials.password, WebConfig.WEB_PASSWORD) + if not (correct_username and correct_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username + + +@app.on_event("startup") +async def startup_event(): + await get_db_pool() + logging.info("Web service started and DB pool initialized.") + + +@app.on_event("shutdown") +async def shutdown_event(): + global db_pool + if db_pool: + await db_pool.close() + logging.info("DB pool closed.") + + +@app.get("/", response_model=GreetingResponse) +async def get_greeting_text(username: str = Depends(get_current_username)): + pool = await get_db_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT greeting_text FROM greetings ORDER BY id LIMIT 1") + if row: + return GreetingResponse(current_greeting_text=row['greeting_text']) + else: + return GreetingResponse(current_greeting_text="Привет! Добро пожаловать!") + + +@app.post("/update_greeting", response_model=GreetingResponse) +async def update_greeting_text( + greeting_update: GreetingUpdate, + username: str = Depends(get_current_username) +): + pool = await get_db_pool() + async with pool.acquire() as conn: + existing_greeting = await conn.fetchrow("SELECT id FROM greetings WHERE id = 1") + + if existing_greeting: + await conn.execute( + "UPDATE greetings SET greeting_text = $1 WHERE id = 1", + greeting_update.new_greeting_text + ) + else: + await conn.execute( + "INSERT INTO greetings (id, greeting_text) VALUES (1, $1)", + greeting_update.new_greeting_text + ) + logging.info(f"Greeting updated to: {greeting_update.new_greeting_text}") + return GreetingResponse(current_greeting_text=greeting_update.new_greeting_text) + \ No newline at end of file diff --git a/web/src/schemas.py b/web/src/schemas.py new file mode 100644 index 0000000..5899b2a --- /dev/null +++ b/web/src/schemas.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class GreetingUpdate(BaseModel): + new_greeting_text: str + + +class GreetingResponse(BaseModel): + current_greeting_text: str