feat: Add GitHub Actions CI and professional README

This commit is contained in:
averageencoreenjoer
2025-09-02 01:35:30 +03:00
commit b951296d9d
26 changed files with 604 additions and 0 deletions

View File

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

8
.env.example Normal file
View File

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

6
.env.test Normal file
View File

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

52
.github/workflows/main.yml vendored Normal file
View File

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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.env
__pycache__/
*.pyc
.pytest_cache/
.vscode/
venv/
.DS_Store
db_data/

132
README.md Normal file
View File

@@ -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
<p align="center">
<img src="https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python"/>
<img src="https://img.shields.io/badge/Telegram-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram"/>
<img src="https://img.shields.io/badge/FastAPI-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="FastAPI"/>
<img src="https://img.shields.io/badge/PostgreSQL-4169E1?style=for-the-badge&logo=postgresql&logoColor=white" alt="PostgreSQL"/>
<img src="https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white" alt="Docker"/>
<img src="https://img.shields.io/badge/Pytest-0A9B71?style=for-the-badge&logo=pytest&logoColor=white" alt="Pytest"/>
<img src="https://img.shields.io/badge/GitHub_Actions-2088FF?style=for-the-badge&logo=github-actions&logoColor=white" alt="GitHub Actions"/>
</p>
- **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.

14
bot/Dockerfile Normal file
View File

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

3
bot/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
aiogram==3.*
asyncpg==0.*
python-dotenv==1.*

0
bot/src/__init__.py Normal file
View File

17
bot/src/config.py Normal file
View File

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

28
bot/src/db.py Normal file
View File

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

11
bot/src/handlers.py Normal file
View File

@@ -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 + "!!!")

0
bot/src/keyboards.py Normal file
View File

29
bot/src/main.py Normal file
View File

@@ -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())

8
db/init.sql Normal file
View File

@@ -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);

View File

@@ -0,0 +1,5 @@
version: '3.8'
services:
bot:
command: tail -f /dev/null

60
docker-compose.yml Normal file
View File

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

4
requirements.test.txt Normal file
View File

@@ -0,0 +1,4 @@
pytest
pytest-asyncio
httpx
python-dotenv

33
run_tests.sh Executable file
View File

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

30
tests/test_integration.py Normal file
View File

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

14
web/Dockerfile Normal file
View File

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

4
web/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.*
uvicorn==0.*
asyncpg==0.*
python-dotenv==1.*

0
web/src/__init__.py Normal file
View File

18
web/src/config.py Normal file
View File

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

94
web/src/main.py Normal file
View File

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

9
web/src/schemas.py Normal file
View File

@@ -0,0 +1,9 @@
from pydantic import BaseModel
class GreetingUpdate(BaseModel):
new_greeting_text: str
class GreetingResponse(BaseModel):
current_greeting_text: str