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
+
+[](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
+
+
+
+
+
+
+
+
+
+
+
+- **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