commit 63c6a83e8840fbe8166d1f7ac26db20ab25cde3a Author: GitLab CI/CD Date: Sun Sep 13 10:32:45 2026 +0000 Showcase update - f20be312 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3c0e083 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.github/ +.git/ +.gitignore + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aed9626 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ + +TOKEN=your_token_here + +ADMIN_IDS=123456789,987654321 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a9a9a1e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,96 @@ +name: Quote Bot CI/CD Pipeline + +on: + push: + branches: + - main + +jobs: + check-syntax: + name: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Syntax Check + run: | + pip install pyflakes + pyflakes src/ + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run OWASP Noir + id: noir + uses: owasp-noir/noir@v1.1.0 + with: + base_path: 'src' + - name: Display results + run: echo '${{ steps.noir.outputs.endpoints }}' | jq . + - name: Run Bandit (Python SAST) + run: | + pip install bandit + bandit -r src/ + + build-and-push: + name: Build and push to Docker Hub + needs: [security-scan, check-syntax] + runs-on: ubuntu-latest + steps: + - name: Скачиваем код бота + uses: actions/checkout@v4 + + - name: Логинимся в Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_LOGIN }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Сборка и пуш Docker образа + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: leor156/quote-bot:latest + + #terraform infrastructure-check and infrastructure-deployment: + #name: check infrastructure + #needs: [build-and-push] + #runs-on: ubuntu-latest + #steps: + #- uses: actions/checkout@v4 + #- name: Terraform init + #run: terraform init + #- name: Terraform plan + #run: terraform plan + #- name: Terraform apply + #run: terraform apply -auto-approve + + deploy: + name: Deploy to VPS via Ansible + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Скачиваем код репозитория (чтобы получить файл deploy.yml) + uses: actions/checkout@v4 + + - name: Запускаем Ansible плейбук + uses: dawidd6/action-ansible-playbook@v2 + with: + playbook: auto-deployment/A_deploy.yml + key: ${{ secrets.SERVER_SSH_KEY }} + options: | + -i ${{ secrets.SERVER_HOST }}, + -u ${{ secrets.SERVER_USER }} + --extra-vars "TOKEN=${{ secrets.TG_TOKEN_QUOTE }} ADMIN_IDS=${{ secrets.ADMIN_ID_TG }}" + + - name: Notifier + uses: ethyaan/tgate-action@v1.0.0 + if: always() + with: + token: ${{ secrets.TG_TOKEN_ALERT }} + to: ${{ secrets.ADMIN_ID_TG }} + thread_id: ${{ secrets.threadid }} + disable_web_page_preview: false + disable_notification: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..670d5d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.env +__pycache__/ +*.pyc +*.pyo + +# Terraform +authorized_key.json +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.backup +*.tfvars +*.tfvars.json + +# IDE +.idea/ +.vscode/ +*.swp + +# Python venv +venv/ +.venv/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..cc9e307 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,125 @@ +default: + tags: + - universal + +stages: + - tests + - build-and-push + - deploy + +tests_job1: + stage: tests + image: python:3.11-slim + variables: + PYTHONPATH: "." + before_script: + - pip install --no-cache-dir -r requirements.txt + - pip install pytest + script: + - pytest tests/ + +push_job: + resource_group: production + stage: build-and-push + image: + name: gcr.io/kaniko-project/executor:v1.23.0-debug + entrypoint: [""] + before_script: + - mkdir -p /kaniko/.docker + - cp "$DOCKER_AUTH_CONFIG" /kaniko/.docker/config.json + script: + - > + /kaniko/executor + --context "${CI_PROJECT_DIR}" + --dockerfile "${CI_PROJECT_DIR}/Dockerfile" + --destination "${DOCKERHUB_USER}/quote-bot:${CI_COMMIT_SHORT_SHA}" + --destination "${DOCKERHUB_USER}/quote-bot:latest" + +deploy_to_server: + resource_group: production + stage: deploy + image: ghcr.io/ansible/creator-ee:v0.22.0 + variables: + ANSIBLE_HOST_KEY_CHECKING: "False" + before_script: + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - echo "$ANSIBLE_PRIVATE_KEY" > ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - ansible-galaxy collection install community.docker + script: + - cd auto-deployment/ + - ansible-playbook -i inventory.ini A_deploy.yml + --extra-vars "image_tag=$CI_COMMIT_SHORT_SHA" + + environment: + name: production + url: http://$BOT_SERVER_IP + on_stop: stop_production + +stop_production: + stage: deploy + image: ghcr.io/ansible/creator-ee:v0.22.0 + variables: + ANSIBLE_HOST_KEY_CHECKING: "False" + before_script: + - ansible-galaxy collection install community.docker + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - echo "$ANSIBLE_PRIVATE_KEY" > ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + script: + - cd auto-deployment/ + - ansible-playbook -i inventory.ini A_deploy.yml + --extra-vars "container_state=absent" + when: manual + environment: + name: production + action: stop + + +variables: + MSG_SUCCESS: "✅ Успех! Пайплайн проекта $CI_PROJECT_NAME завершен.\nВетка: $CI_COMMIT_REF_NAME\nКоммит: $CI_COMMIT_MESSAGE" + MSG_FAILURE: "❌ Ошибка! Пайплайн проекта $CI_PROJECT_NAME упал.\nВетка: $CI_COMMIT_REF_NAME\nСмотреть логи пайплайна" + +.notify_telegram: + stage: .post + tags: + - tg + image: curlimages/curl:latest + script: + - | + curl -s -X POST "https://api.telegram.org/bot$ALERT_TG_BOT/sendMessage" \ + -d chat_id="$ADMIN_ID_TG" \ + -d text="$MESSAGE" \ + -d parse_mode="HTML" \ + -d disable_web_page_preview="true" + +notify_success: + extends: .notify_telegram + variables: + MESSAGE: $MSG_SUCCESS + when: on_success + +notify_failure: + extends: .notify_telegram + variables: + MESSAGE: $MSG_FAILURE + when: on_failure + +mirror_to_gitea: + image: + name: alpine/git:latest + entrypoint: [""] + stage: .post + variables: + GIT_DEPTH: 1 + script: + - git config user.name "GitLab CI/CD" + - git config user.email "ci@leor156.online" + - git checkout --orphan showcase + - git add -A + - git commit -m "Showcase update - ${CI_COMMIT_SHORT_SHA}" + - git remote add gitea https://leo:$GITEA_TOKEN@gitea.leor156.online/leo/quote_bot_auto-deployment.git + - git push gitea showcase:main --force + when: on_success diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0bea3ab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +WORKDIR /app + + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + + +COPY src/ ./src/ + + +COPY data/ ./data/ + +CMD ["python", "-m", "src.bot"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7e4bb8 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +### Цитатный бот diff --git a/auto-deployment/A_deploy.yml b/auto-deployment/A_deploy.yml new file mode 100644 index 0000000..2ce867f --- /dev/null +++ b/auto-deployment/A_deploy.yml @@ -0,0 +1,39 @@ +# deploy.yml +- name: Автоматический деплой Telegram-бота + hosts: all + + vars: + image_name: "leor156/quote-bot:{{ image_tag | default('latest') }}" + container_name: "quote-bot" + container_state: "started" + + tasks: + - name: Убеждаемся, что установлена библиотека Docker для Python + apt: + name: python3-docker + state: present + update_cache: yes + + - name: Скачиваем образ бота с Docker Hub + community.docker.docker_image: + name: "{{ image_name }}" + source: pull + + - name: Запускаем контейнер + community.docker.docker_container: + name: "{{ container_name }}" + image: "{{ image_name }}" + state: "{{ container_state }}" + restart_policy: unless-stopped + pull: always + env: + TOKEN: "{{ lookup('env', 'BOT_TOKEN') }}" + ADMIN_IDS: "{{ lookup('env', 'ADMIN_ID_TG') }}" + + - name: Чистка + community.docker.docker_prune: + images: yes + images_filters: + dangling: true + + diff --git a/auto-deployment/inventory.ini b/auto-deployment/inventory.ini new file mode 100644 index 0000000..d4400c6 --- /dev/null +++ b/auto-deployment/inventory.ini @@ -0,0 +1,2 @@ +[server-bot] +quote-bot-prod ansible_host="{{ lookup('env', 'BOT_SERVER_IP') }}" ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519 \ No newline at end of file diff --git a/auto-deployment/main.tf b/auto-deployment/main.tf new file mode 100644 index 0000000..0f4e6bf --- /dev/null +++ b/auto-deployment/main.tf @@ -0,0 +1,44 @@ +resource "yandex_compute_filesystem" "shared_disk" { + name = "my-shared-30gb-disk" + type = "network-hdd" + size = 30 + zone = "ru-central1-d" +} + +data "yandex_compute_image" "ubuntu" { + family = "ubuntu-2404-lts" +} + +resource "yandex_compute_instance" "servers" { + count = 2 + name = "server-${count.index + 1}" + platform_id = "standard-v3" + zone = "ru-central1-d" + + resources { + cores = 2 + memory = 2 + } + + + + # Указываем загрузочный диск с Ubuntu + boot_disk { + initialize_params { + image_id = data.yandex_compute_image.ubuntu.id + size = 10 # Размер загрузочного диска + } + } + # Подключаем наш общий диск на 30 ГБ + filesystem { + filesystem_id = yandex_compute_filesystem.shared_disk.id + device_name = "shared-disk" + } + # Подключаем сервер к подсети (которая лежит в файле network.tf) + network_interface { + subnet_id = yandex_vpc_subnet.subnet.id + + # Даем белый IP только первому серверу + nat = count.index == 0 ? true : false + } +} \ No newline at end of file diff --git a/auto-deployment/network.tf b/auto-deployment/network.tf new file mode 100644 index 0000000..7e997d9 --- /dev/null +++ b/auto-deployment/network.tf @@ -0,0 +1,10 @@ +resource "yandex_vpc_network" "network" { + name = "my-network" +} + +resource "yandex_vpc_subnet" "subnet" { + name = "my-internal-subnet" + v4_cidr_blocks = ["10.0.0.0/24"] + zone = "ru-central1-d" + network_id = yandex_vpc_network.network.id +} diff --git a/auto-deployment/outputs.tf b/auto-deployment/outputs.tf new file mode 100644 index 0000000..310860c --- /dev/null +++ b/auto-deployment/outputs.tf @@ -0,0 +1,8 @@ +output "public_ip_server_1" { + value = yandex_compute_instance.servers[0].network_interface.0.nat_ip_address + description = "Белый IP первого сервера" +} +output "internal_ips" { + value = yandex_compute_instance.servers[*].network_interface.0.ip_address + description = "Внутренние IP адреса обоих серверов" +} diff --git a/auto-deployment/providers.tf b/auto-deployment/providers.tf new file mode 100644 index 0000000..5603bc6 --- /dev/null +++ b/auto-deployment/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + } + } + required_version = ">= 0.13" +} +provider "yandex" { + service_account_key_file = "authorized_key.json" + cloud_id = "b1gl9hdpdr35i83uh5t5" + folder_id = "b1gbb5kej5tlskp8i5oq" + zone = "ru-central1-a" +} diff --git a/auto-deployment/variables.tf b/auto-deployment/variables.tf new file mode 100644 index 0000000..7d63bff --- /dev/null +++ b/auto-deployment/variables.tf @@ -0,0 +1,76 @@ +variable "zone" { + type = string + default = "ru-central1-d" +} + +variable "network" { + type = string + default = "ya-network" +} + +variable "subnet" { + type = string + default = "ya-network" +} + +variable "subnet_v4_cidr_blocks" { + type = list(string) + default = ["192.168.10.0/24"] +} + +variable "nat" { + type = bool + default = true +} + +variable "image_family" { + type = string + default = "ubuntu-2404-lts" +} + +variable "name" { + type = string +} + +variable "cores" { + type = number + default = 2 +} + +variable "memory" { + type = number + default = 4 +} + +variable "disk_size" { + type = number + default = 50 +} + +variable "disk_type" { + type = string + default = "network-nvme" +} + +variable "user_name" { + default = "" + type = string +} + +variable "user_pass" { + default = "" + type = string +} + +variable "admin_pass" { + default = "" + type = string +} + +variable "timeout_create" { + default = "10m" +} + +variable "timeout_delete" { + default = "10m" +} \ No newline at end of file diff --git a/data/quotes.txt b/data/quotes.txt new file mode 100644 index 0000000..fa26518 --- /dev/null +++ b/data/quotes.txt @@ -0,0 +1,5 @@ +Учиться — значит открывать то, что ты уже знал. Делать — значит демонстрировать, что ты это знаешь. Обучать — значит напоминать другим, что они знают это так же хорошо, как и ты. +Жизнь — это то, что случается, пока ты строишь другие планы. +Единственный способ делать великую работу — любить то, что делаешь. +Код, написанный наспех, работает ровно столько, сколько тебе нужно для демонстрации, — и ни секунды больше. +Лучший способ предсказать будущее — изобрести его. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..805454a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyTelegramBotAPI==4.23.0 diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..5af0ce8 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,91 @@ +import logging + +from telebot import TeleBot +from telebot.types import Message + +from .config import TOKEN, ADMIN_IDS +from .quotes import get_random_quote, add_quote, count_quotes + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) + +bot = TeleBot(TOKEN) + +# ─── Хэндлеры ──────────────────────────────────────────────────────────────── + + +@bot.message_handler(commands=["start"]) +def cmd_start(message: Message): + name = message.from_user.first_name + bot.reply_to( + message, + f"Привет, {name}! 👋\n\n" + "Я бот с цитатами. Вот что я умею:\n" + "/quote — случайная цитата\n" + "/stats — сколько цитат в базе\n" + "/add <текст> — добавить цитату (только для администратора)", + ) + logging.info( + f"[/start] user_id={message.from_user.id} ({message.from_user.first_name})" + ) + + +@bot.message_handler(commands=["quote"]) +def cmd_quote(message: Message): + quote = get_random_quote() + if quote is None: + bot.reply_to( + message, + "😔 Цитат пока нет. Попросите администратора добавить хотя бы одну!", + ) + else: + bot.reply_to(message, f"💬 {quote}") + logging.info(f"[/quote] user_id={message.from_user.id}") + + +@bot.message_handler(commands=["stats"]) +def cmd_stats(message: Message): + total = count_quotes() + bot.reply_to(message, f"📚 В базе {total} цитат.") + logging.info(f"[/stats] user_id={message.from_user.id}, total={total}") + + +@bot.message_handler(commands=["add"]) +def cmd_add(message: Message): + # Проверяем права + if message.from_user.id not in ADMIN_IDS: + bot.reply_to(message, "🚫 У вас нет прав для добавления цитат.") + logging.warning( + f"[/add] Unauthorized attempt by user_id={message.from_user.id}") + return + + # Извлекаем текст после команды /add + parts = message.text.split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + bot.reply_to( + message, "✏️ Укажите текст цитаты. Пример:\n/add Жизнь прекрасна!") + return + + quote_text = parts[1].strip() + added = add_quote(quote_text) + + if added: + bot.reply_to(message, f"✅ Цитата добавлена:\n\n💬 {quote_text}") + logging.info( + f"[/add] Added new quote by user_id={message.from_user.id}") + else: + bot.reply_to(message, "⚠️ Такая цитата уже есть в базе.") + + +# ─── Точка входа ───────────────────────────────────────────────────────────── + + +def main(): + logging.info("Бот запущен в режиме polling...") + bot.infinity_polling() + + +if __name__ == "__main__": + main() diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..cb1fe02 --- /dev/null +++ b/src/config.py @@ -0,0 +1,8 @@ +import os + +TOKEN = os.environ.get("TOKEN") +if not TOKEN: + raise ValueError("Переменная окружения TOKEN не задана!") + +admin_ids_raw = os.environ.get("ADMIN_IDS", "") +ADMIN_IDS = {int(i) for i in admin_ids_raw.split(",") if i.strip()} diff --git a/src/quotes.py b/src/quotes.py new file mode 100644 index 0000000..a0e2449 --- /dev/null +++ b/src/quotes.py @@ -0,0 +1,41 @@ +import random +from pathlib import Path + +DATA_FILE = Path(__file__).parent.parent / "data" / "quotes.txt" + + +def _read_all() -> list[str]: + """Читает все цитаты из файла. Возвращает пустой список, если файл пуст или не существует.""" + if not DATA_FILE.exists(): + return [] + lines = DATA_FILE.read_text(encoding="utf-8").splitlines() + return [line for line in lines if line.strip()] + + +def get_random_quote() -> str | None: + """Возвращает случайную цитату или None, если их нет.""" + quotes = _read_all() + if not quotes: + return None + return random.choice(quotes) # nosec + + +def add_quote(text: str) -> bool: + """ + Добавляет новую цитату в файл. + Возвращает False, если такая цитата уже существует, иначе True. + """ + quotes = _read_all() + if text in quotes: + return False + DATA_FILE.parent.mkdir(parents=True, exist_ok=True) + with DATA_FILE.open("a", encoding="utf-8") as f: + f.write(text + "\n") + return True + + +def count_quotes() -> int: + """Возвращает количество цитат в базе.""" + return len(_read_all()) + +# Секретная строка. \ No newline at end of file diff --git a/tests/test_quotes.py b/tests/test_quotes.py new file mode 100644 index 0000000..a276464 --- /dev/null +++ b/tests/test_quotes.py @@ -0,0 +1,67 @@ +import unittest +from pathlib import Path +from unittest.mock import patch +import tempfile +import os + + +# Подменяем DATA_FILE на временный файл перед импортом модуля +class TestQuotes(unittest.TestCase): + + def setUp(self): + """Создаем временный файл для каждого теста.""" + self.tmp = tempfile.NamedTemporaryFile(mode="w", + suffix=".txt", + delete=False, + encoding="utf-8") + self.tmp.close() + # Патчим путь к файлу в модуле quotes + self.patcher = patch("src.quotes.DATA_FILE", Path(self.tmp.name)) + self.patcher.start() + + def tearDown(self): + """Удаляем временный файл после каждого теста.""" + self.patcher.stop() + os.unlink(self.tmp.name) + + def test_empty_file_returns_none(self): + from src.quotes import get_random_quote + + self.assertIsNone(get_random_quote()) + + def test_empty_file_count_is_zero(self): + from src.quotes import count_quotes + + self.assertEqual(count_quotes(), 0) + + def test_add_and_count(self): + from src.quotes import add_quote, count_quotes + + add_quote("Первая цитата") + add_quote("Вторая цитата") + self.assertEqual(count_quotes(), 2) + + def test_add_returns_true_for_new_quote(self): + from src.quotes import add_quote + + result = add_quote("Новая цитата") + self.assertTrue(result) + + def test_add_returns_false_for_duplicate(self): + from src.quotes import add_quote + + add_quote("Дубликат") + result = add_quote("Дубликат") + self.assertFalse(result) + + def test_get_random_returns_string(self): + from src.quotes import add_quote, get_random_quote + + add_quote("Тестовая цитата") + quote = get_random_quote() + self.assertIsInstance(quote, str) + self.assertEqual(quote, "Тестовая цитата") + + +if __name__ == "__main__": + unittest.main()