diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a48bc9 --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# 🤖 Telegram-бот для проекта [`lab_grader_web`](https://github.com/markpolyak/lab_grader_web) + +Этот проект представляет собой Telegram-бота, взаимодействующего с FastAPI-бэкендом. Он автоматизирует процесс регистрации студентов и проверки лабораторных работ через GitHub Classroom и Google Таблицы. + +## 📁 Структура проекта + +> **Важно:** все файлы из папки `telegram_bot/` должны быть перемещены в **корневую директорию проекта**, а не оставаться внутри вложенной папки. + +Проект включает в себя следующие компоненты: + +| Файл/директива | Назначение | +|----------------------|-------------------------------------------------------------| +| `main.py` | FastAPI-бэкенд с API для работы с курсами и Google Таблицей | +| `bot2.py` | Telegram-бот на `python-telegram-bot`, взаимодействующий с API | +| `backend.Dockerfile` | Docker-образ для запуска `main.py` | +| `bot.Dockerfile` | Docker-образ для запуска `bot2.py` | +| `docker-compose.yml` | Конфигурация запуска обоих сервисов в Docker | +| `config.py` | Конфигурация токена Telegram-бота (`TOKEN = "..."`) | +| `.env` | Секреты для доступа к GitHub и авторизации | +| `credentials.json` | Доступ к Google Sheets API | +| `users.db` | SQLite-база данных для хранения состояния пользователей | +| `courses/` | Каталог с YAML-файлами курсов | + +## 🚀 Быстрый запуск + +> Перед запуском убедитесь, что у вас установлен **Docker** и **docker-compose**. + +### 1. Клонируйте репозиторий + +```bash +git clone ... +cd <путь_к_проекту> +``` + +### 2. Подготовьте необходимые файлы + +- `credentials.json` — JSON-файл с ключом для доступа к Google Sheets API. +- `.env` — переменные окружения (создайте вручную): + +```env +GITHUB_TOKEN=ghp_... +ADMIN_LOGIN=... +ADMIN_PASSWORD=... +SECRET_KEY=any-secret-key +``` + +- `config.py` — должен содержать Telegram-токен: + +```python +TOKEN = "ваш_токен_бота" +``` + +### 3. Переместите файлы из `telegram_bot/` в корень + +### 4. Запустите в Docker из корневой папки проекта + +```bash +cd <путь_к_проекту> +docker compose up --build +``` + +--- + +## 🧠 Архитектура + +Проект состоит из двух основных компонентов: + +### 🖥️ Бэкенд (`main.py`) + +Бэкенд реализован на FastAPI и предоставляет REST API для: + +- получения списка курсов и групп; +- регистрации студента по ФИО; +- записи GitHub-ника студента в Google Таблицу; +- получения GitHub-ника из таблицы; +- запуска проверки лабораторной на GitHub через CI; +- обновления таблицы результатами проверки. + +#### Используемые технологии: +- **FastAPI** — HTTP-сервер и маршрутизация; +- **gspread** + **Google API** — работа с Google Таблицами; +- **httpx** — асинхронные HTTP-запросы к GitHub API; +- **YAML** — описание курсов в директории `courses/`. + +#### Особенности: +- Все проверки CI сравниваются с версией первого коммита (`test_main.py`, `tests/`, `.github/workflows`); +- При успешной проверке в таблице проставляется символ `✓` в нужную ячейку; + +### 🤖 Telegram-бот (`bot2.py`) + +Бот предоставляет двуязычный интерфейс (русский и английский) с возможностью: + +- выбора языка; +- авторизации по номеру группы и ФИО; +- регистрации GitHub-ника; +- синхронизации GitHub-ника из таблицы; +- запуска проверки прохождения тестов с выбором курса и лабораторной работы. + +#### Используемые технологии: +- **python-telegram-bot** — реализация логики взаимодействия с пользователем; +- **SQLite (`users.db`)** — хранение авторизационных данных, последнего сообщения и состояния меню; +- **httpx** — вызов API-бэкенда для регистрации, получения списка курсов и проверки лабораторных; +- **ConversationHandler** — пошаговая авторизация, регистрация и взаимодействие через inline-кнопки. + +#### Поведение: +- Бот сохраняет последнее состояние (меню и сообщение) и восстанавливает его при следующем запуске; +- Все действия сопровождаются подробными текстовыми подсказками и возможностью отмены; + +--- + +## 👤 Пользовательское руководство + +После запуска бота пользователь взаимодействует с ним через команды и inline-кнопки. Всё поведение построено на диалогах и зависит от действий пользователя. + +### 🔑 Авторизация + +Перед использованием бот запрашивает номер группы и ФИО. Варианты развития: + +- ✅ **Группа и ФИО найдены в таблице** + → бот показывает данные и предлагает подтвердить. + → после подтверждения авторизация завершается, и пользователь переходит в главное меню. + +- ❌ **Группа не найдена среди курсов** + → бот сообщает об ошибке и предлагает ввести данные заново. + +- ❌ **ФИО не найдено в группе** + → бот сообщает, что студент не найден, и предлагает повторить ввод. + +### 📝 Регистрация GitHub-ника + +Пункт доступен только после авторизации. Пользователь вводит GitHub-ник, который далее проверяется через GitHub API: + +- ✅ **GitHub-пользователь существует** и в таблице есть пустая ячейка + → бот записывает ник в таблицу и сообщает об успешной регистрации. + +- ℹ️ **Такой GitHub-ник уже был указан ранее этим студентом** + → бот сообщает, что ник уже зарегистрирован, но подтверждение повторно не требуется. + +- 🚫 **В таблице уже указан другой GitHub-ник** + → бот не перезаписывает данные и просит обратиться к преподавателю. + +- ❌ **Пользователь не существует на GitHub** + → бот сообщает об ошибке, регистрация невозможна. + +### 🔄 Синхронизация GitHub-ника + +Бот получает GitHub-ник из таблицы и сохраняет его в свою локальную базу: + +- ✅ **Если GitHub-ник найден**, он сохраняется, и становится доступен для последующих проверок. +- ❌ **Если ник не найден в таблице** или возникла ошибка (например, отсутствует столбец), бот уведомляет об этом. + +### 🧪 Проверка выполнения лабораторной + +Процесс проверки происходит пошагово: + +1. **Определение курса**: + - Если для группы найден один курс → он выбирается автоматически. + - Если найдено несколько курсов → бот предлагает выбрать курс вручную. + +2. **Выбор лабораторной**: + - Список формируется на основе таблицы и YAML-описания курса. + - Если список пуст → бот уведомляет об этом. + +3. **Запуск проверки**: + - Бот отправляет запрос в API с текущим GitHub-ником. + - Бэкенд анализирует: + - наличие коммитов; + - соответствие `test_main.py`, `tests/`, `.github/workflows/` первому коммиту; + - результат CI на последнем коммите. + - В ответ бот выводит подробный результат, включая статус каждой проверки. + +Возможные исходы: + +- ✅ **Все проверки CI пройдены** — бот выводит успех и сообщает, что результат записан в таблицу. +- ❌ **Обнаружены изменения в файлах** — бот указывает, что тесты не могут быть засчитаны. +- ❌ **Проблемы с GitHub API или CI** — бот сообщает об ошибке (например, отсутствие проверок или коммитов). + +### 🔁 Повторные обращения + +- При повторном запуске бота: + - если пользователь уже авторизован, бот восстанавливает последнее состояние меню и сообщения; + - если нет — вновь запрашивает данные. + +- Все действия можно выполнять многократно (регистрация, синхронизация, проверка), при этом предыдущие данные сохраняются. + +Бот обрабатывает ошибки API, отсутствие данных, проблемы с сетью и корректно завершает диалог, возвращая пользователя к безопасной точке. \ No newline at end of file diff --git a/telegram_bot/backend.Dockerfile b/telegram_bot/backend.Dockerfile new file mode 100644 index 0000000..4870dde --- /dev/null +++ b/telegram_bot/backend.Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/telegram_bot/bot.Dockerfile b/telegram_bot/bot.Dockerfile new file mode 100644 index 0000000..5d58038 --- /dev/null +++ b/telegram_bot/bot.Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . . + +RUN pip install --no-cache-dir -r requirements.txt + +CMD ["python", "bot2.py"] diff --git a/telegram_bot/bot2.py b/telegram_bot/bot2.py new file mode 100644 index 0000000..a4779c7 --- /dev/null +++ b/telegram_bot/bot2.py @@ -0,0 +1,1028 @@ +import sqlite3 +import httpx +from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton +from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes +from telegram.ext import MessageHandler, filters, ConversationHandler + +from config import TOKEN + +DB_PATH = "users.db" + +WAITING_FOR_FULL_NAME = 1 +WAITING_FOR_CONFIRMATION = 2 +WAITING_FOR_GITHUB = 3 + +CHOOSING_COURSE = 10 +CHOOSING_LAB = 11 + +# Инициализация таблицы пользователей +def init_db(): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + language TEXT DEFAULT 'ru', + group_id TEXT, + surname TEXT, + name TEXT, + patronymic TEXT, + github TEXT, + authorized INTEGER DEFAULT 0, + last_message TEXT, + last_buttons TEXT + ) + """) + conn.commit() + conn.close() + + + +# Добавление пользователя или обновление записи +def upsert_user(user_id: int, language: str = "ru"): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("INSERT OR IGNORE INTO users (user_id, language) VALUES (?, ?)", (user_id, language)) + conn.commit() + conn.close() + + + +# Обновление языка пользователя +def set_language(user_id: int, language: str): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("UPDATE users SET language = ? WHERE user_id = ?", (language, user_id)) + conn.commit() + conn.close() + + + +# Получение языка пользователя +def get_language(user_id: int) -> str: + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("SELECT language FROM users WHERE user_id = ?", (user_id,)) + result = cursor.fetchone() + conn.close() + return result[0] if result else "ru" + + + +# Текст на русском +def get_welcome_text_ru() -> str: + return ( + "👋 Добро пожаловать!\n\n" + "Этот бот создан для обучающихся в университете ГУАП, которые проходят курсы Поляка М. Д.\n\n" + "Что можно сделать с его помощью?\n" + "✅ Записать свой GitHub-ник в Google-таблицу курса\n" + "✅ Запустить проверку выполнения тестов в репозиториях\n\n" + "📌 Далее потребуется указать свои данные для доступа к функционалу бота.\n\n" + "Готовы начать? Выбирайте нужную команду! 🚀" + ) + + + +# Текст на английском +def get_welcome_text_en() -> str: + return ( + "👋 Welcome!\n\n" + "This bot is designed for SUAI University students attending M. D. Polyak's courses.\n\n" + "What can it do?\n" + "✅ Add your GitHub username to the course's Google Sheet\n" + "✅ Check test completion in repositories\n\n" + "📌 You'll need to provide some information to access the bot's features.\n\n" + "Ready to begin? Select an action! 🚀" + ) + + + +# Формирование клавиатуры +def get_keyboard(language: str) -> InlineKeyboardMarkup: + if language == "ru": + return InlineKeyboardMarkup([ + [InlineKeyboardButton("Switch to English", callback_data="lang_en")], + [InlineKeyboardButton("Продолжить", callback_data="continue")] + ]) + else: + return InlineKeyboardMarkup([ + [InlineKeyboardButton("Переключиться на русский", callback_data="lang_ru")], + [InlineKeyboardButton("Continue", callback_data="continue")] + ]) + + + +def get_main_keyboard(lang: str) -> InlineKeyboardMarkup: + if lang == "ru": + return InlineKeyboardMarkup([ + [InlineKeyboardButton("📝 Записать GitHub-ник в таблицу", callback_data="register_github")], + [InlineKeyboardButton("🔄 Синхронизировать GitHub-ник", callback_data="sync_github")], + [InlineKeyboardButton("🧪 Проверить выполнение тестов", callback_data="check_tests")], + [InlineKeyboardButton("Switch to English", callback_data="lang_en2")] + ]) + else: + return InlineKeyboardMarkup([ + [InlineKeyboardButton("📝 Submit GitHub username", callback_data="register_github")], + [InlineKeyboardButton("🔄 Sync GitHub username", callback_data="sync_github")], + [InlineKeyboardButton("🧪 Check test results", callback_data="check_tests")], + [InlineKeyboardButton("Переключиться на русский", callback_data="lang_ru2")] + ]) + + +def update_last_menu(user_id, msg, keyboard): + import json + buttons_data = [[{"text": b.text, "callback_data": b.callback_data} for b in row] for row in keyboard.inline_keyboard] + conn = sqlite3.connect(DB_PATH) + conn.execute( + "UPDATE users SET last_message = ?, last_buttons = ? WHERE user_id = ?", + (msg, json.dumps(buttons_data), user_id) + ) + conn.commit() + conn.close() + + + +# Обработчик команды /start +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + context.user_data.pop("awaiting_full_name", None) + context.user_data.pop("awaiting_github", None) + context.user_data.pop("auth_data", None) + + user_id = update.effective_user.id + upsert_user(user_id) + + # Проверяем авторизацию и читаем last_message + last_buttons + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "SELECT authorized, last_message, last_buttons FROM users WHERE user_id = ?", + (user_id,) + ) + row = cursor.fetchone() + conn.close() + + if row and row[0] == 1: + lang = get_language(user_id) + # Сообщение о повторной авторизации + msg = "🔐 Вы уже авторизовались." if lang == "ru" else "🔐 You're already signed in." + await update.message.reply_text(msg) + + # Если есть сохранённое последнее сообщение + last_msg, last_buttons_json = row[1], row[2] + if last_msg and last_buttons_json: + import json + buttons_data = json.loads(last_buttons_json) + # Восстанавливаем клавиатуру + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton(item["text"], callback_data=item["callback_data"]) + for item in row_data + ] + for row_data in buttons_data + ]) + await update.message.reply_text(last_msg, reply_markup=keyboard) + return + + # Если не авторизован — как было + language = get_language(user_id) + text = get_welcome_text_ru() if language == "ru" else get_welcome_text_en() + keyboard = get_keyboard(language) + await update.message.reply_text(text, reply_markup=keyboard) + + + +# Обработчик всех кнопок +async def handle_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + user_id = query.from_user.id + data = query.data + + if data == "lang_en": + set_language(user_id, "en") + await query.edit_message_text(get_welcome_text_en(), reply_markup=get_keyboard("en")) + + elif data == "lang_ru": + set_language(user_id, "ru") + await query.edit_message_text(get_welcome_text_ru(), reply_markup=get_keyboard("ru")) + + elif data == "continue": + lang = get_language(user_id) + if lang == "ru": + prompt = "Введите номер группы и ФИО в формате:\n`1234 Фамилия Имя Отчество`" + else: + prompt = "Enter your group number and full name in this format:\n`1234 Lastname Firstname Patronymic`" + await query.message.edit_text(prompt, parse_mode="Markdown") + context.user_data["awaiting_full_name"] = True + return WAITING_FOR_FULL_NAME + + elif data == "confirm_auth": + context.user_data.pop("awaiting_full_name", None) + lang = get_language(user_id) + + auth_data = context.user_data.get("auth_data") + if not auth_data: + await query.message.edit_text( + "⚠️ Внутренняя ошибка. Повторите попытку." + if get_language(user_id) == "ru" + else "⚠️ Internal error. Please try again." + ) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + cursor = sqlite3.connect(DB_PATH).cursor() + cursor.execute(""" + UPDATE users SET group_id = ?, surname = ?, name = ?, patronymic = ?, authorized = 1 + WHERE user_id = ? + """, ( + auth_data["group_id"], + auth_data["surname"], + auth_data["name"], + auth_data["patronymic"], + user_id + )) + cursor.connection.commit() + cursor.connection.close() + + msg = "✅ Успешная авторизация!" if lang == "ru" else "✅ Authorization successful!" + await query.message.edit_text(msg) + + # Отображение следующего сообщения с кнопками + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + + keyboard = get_main_keyboard(lang) + + await query.message.reply_text(next_msg, reply_markup=keyboard) + + import json + + buttons_data = [ + [ + {"text": btn.text, "callback_data": btn.callback_data} + for btn in row_buttons + ] + for row_buttons in keyboard.inline_keyboard + ] + + conn = sqlite3.connect(DB_PATH) + conn.execute( + "UPDATE users SET last_message = ?, last_buttons = ? WHERE user_id = ?", + (next_msg, json.dumps(buttons_data), user_id) + ) + conn.commit() + conn.close() + + context.user_data.pop("auth_data", None) + + return ConversationHandler.END + + elif data == "cancel_auth": + context.user_data.pop("awaiting_full_name", None) + context.user_data.pop("auth_data", None) + + lang = get_language(user_id) + text = get_welcome_text_ru() if lang == "ru" else get_welcome_text_en() + keyboard = get_keyboard(lang) + await query.message.edit_text(text, reply_markup=keyboard) + return ConversationHandler.END + + elif data == "register_github": + lang = get_language(user_id) + prompt = ( + "Введите свой GitHub-ник:" + if lang == "ru" + else "Enter your GitHub username:" + ) + await query.message.edit_text(prompt) + context.user_data["awaiting_github"] = True + return WAITING_FOR_GITHUB + + elif data == "confirm_github": + github = context.user_data.pop("pending_github", None) + if not github: + await query.message.edit_text("⚠️ GitHub-ник не найден в памяти.") + return ConversationHandler.END + + # вызов handle_github_submission как подфункции + return await handle_github_submission(query, context, github) + + elif data == "cancel_github": + context.user_data.pop("pending_github", None) + + lang = get_language(user_id) + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + + await query.message.edit_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + elif data == "sync_github": + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("SELECT group_id, surname, name, patronymic FROM users WHERE user_id = ?", (user_id,)) + row = cursor.fetchone() + conn.close() + + lang = get_language(user_id) + + if not row or not all(row): + msg = "⚠️ Сначала укажите ФИО и группу." if lang == "ru" \ + else "⚠️ Please provide your full name and group first." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + group_id_db, surname, name, patronymic = row + + async with httpx.AsyncClient(base_url="http://backend:8000") as client: + try: + r = await client.get("/courses") + r.raise_for_status() + courses = r.json() + except Exception: + msg = "⚠️ Не удалось получить список курсов." if lang == "ru" \ + else "⚠️ Failed to retrieve course list." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + course_id = None + for i, _ in enumerate(courses, start=1): + r = await client.get(f"/courses/{i}/groups") + if r.status_code == 200 and group_id_db in r.json(): + course_id = i + break + + if not course_id: + msg = "⚠️ Курс не найден." if lang == "ru" else "⚠️ Course not found." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + payload = { + "surname": surname, + "name": name, + "patronymic": patronymic + } + + try: + r = await client.post(f"/courses/{course_id}/groups/{group_id_db}/github", json=payload, timeout=httpx.Timeout(15.0)) + r.raise_for_status() + github = r.json().get("github") + except httpx.HTTPStatusError as e: + detail = e.response.json().get("detail", "") + msg = f"⚠️ {detail}" if lang == "ru" else f"⚠️ {detail}" + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + # Сохраняем GitHub в БД + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE users SET github = ? WHERE user_id = ?", (github, user_id)) + conn.commit() + conn.close() + + msg = "✅ GitHub-ник синхронизирован из таблицы." if lang == "ru" \ + else "✅ GitHub username synchronized from the table." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + elif data == "check_tests": + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("SELECT group_id, github FROM users WHERE user_id = ?", (user_id,)) + row = cursor.fetchone() + conn.close() + + lang = get_language(user_id) + + if not row or not row[0] or not row[1]: + await query.message.edit_text("⚠️ Сначала укажите ФИО, группу и GitHub-ник." if lang == "ru" else "⚠️ Please first provide your group, name and GitHub username.") + + lang = get_language(user_id) + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + group_id_db, github = row + context.user_data["test_github"] = github + context.user_data["test_group"] = group_id_db + + async with httpx.AsyncClient(base_url="http://backend:8000") as client: + try: + resp = await client.get("/courses") + resp.raise_for_status() + courses = resp.json() + except Exception: + msg = "⚠️ Не удалось получить список курсов." if lang == "ru" else "⚠️ Failed to fetch course list." + await query.message.edit_text(msg) + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + matching_courses = [] + for i, course in enumerate(courses, start=1): + r = await client.get(f"/courses/{i}/groups") + if r.status_code == 200 and group_id_db in r.json(): + matching_courses.append((i, course["name"])) + + if not matching_courses: + msg = "❌ Не найдено подходящих курсов." if lang == "ru" else "❌ No matching courses found." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + if len(matching_courses) == 1: + context.user_data["test_course"] = matching_courses[0][0] + return await show_lab_buttons(query, context) + + # Выбор курса вручную + keyboard = [[InlineKeyboardButton(name, callback_data=f"select_course_{cid}")] for cid, name in matching_courses] + + keyboard.append([InlineKeyboardButton("↩️ Отмена" if lang == "ru" else "↩️ Cancel", callback_data="cancel_test")]) + + await query.message.edit_text("📘 Выберите курс:" if lang == "ru" else "📘 Select a course:", reply_markup=InlineKeyboardMarkup(keyboard)) + + return CHOOSING_COURSE + + elif data.startswith("check_lab_"): + lab_id = data.removeprefix("check_lab_") + course_id = context.user_data.get("test_course") + group_id = context.user_data.get("test_group") + github = context.user_data.get("test_github") + lang = get_language(user_id) + + if not all([course_id, group_id, github]): + msg = "⚠️ Внутренняя ошибка. Не хватает данных." if lang == "ru" \ + else "⚠️ Internal error: missing data." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + await query.message.edit_text("⏳ Проверка выполняется..." if lang == "ru" else "⏳ Running tests...") + + async with httpx.AsyncClient(base_url="http://backend:8000") as client: + try: + r = await client.post( + f"/courses/{course_id}/groups/{group_id}/labs/{lab_id}/grade", + json={"github": github}, + timeout=httpx.Timeout(60.0) + ) + + if r.status_code == 200: + data = r.json() + passed = data.get("passed", "") + result = data.get("result", "") + message = data.get("message", "") + checks = data.get("checks", []) + + status_line = ( + "✅ Все тесты пройдены успешно." if result == "✓" + else "❌ Обнаружены ошибки в тестах." + ) if lang == "ru" else ( + "✅ All tests passed successfully." if result == "✓" + else "❌ Errors detected in tests." + ) + + full_msg = ( + f"{status_line}\n" + f"{message}\n" + f"{passed}\n\n" + + "\n".join(checks) + ) + await query.message.edit_text(full_msg, disable_web_page_preview=True) + + else: + try: + detail = r.json().get("detail", "") + except Exception: + detail = "" + + if r.status_code == 400: + detail_map = { + "Missing course configuration": ( + "❌ Отсутствует конфигурация курса.", + "❌ Course configuration is missing." + ), + "Столбец 'GitHub' не найден": ( + "❌ Столбец 'GitHub' не найден в таблице.", + "❌ Column 'GitHub' not found in spreadsheet." + ) + } + elif r.status_code == 403: + detail_map = { + "🚨 test_main.py был изменён или удалён": ( + "❌ test_main.py был изменён или удалён.", + "❌ test_main.py was modified or deleted." + ), + "🚨 Изменён файл в папке tests/": ( + "❌ В папке tests/ был изменён или удалён файл.", + "❌ A file in the `tests/` folder was modified or deleted." + ), + "🚨 Изменён файл в папке .github/workflows/": ( + "❌ В папке .github/workflows/ был изменён или удалён файл.", + "❌ A file in the .github/workflows/ folder was modified or deleted." + ) + } + elif r.status_code == 404: + detail_map = { + "Не удалось получить список коммитов": ( + "❌ Не удалось получить список коммитов.", + "❌ Failed to fetch commit list." + ), + "Нет коммитов в репозитории": ( + "❌ В репозитории отсутствуют коммиты.", + "❌ No commits found in repository." + ), + "Проверки CI не найдены": ( + "❌ CI-проверки не найдены.", + "❌ CI checks not found." + ), + "Группа не найдена в Google Таблице": ( + "❌ Группа не найдена в Google Таблице.", + "❌ Group not found in spreadsheet." + ), + "GitHub логин не найден в таблице": ( + "❌ GitHub логин не найден в таблице.", + "❌ GitHub login not found in spreadsheet." + ) + } + else: + detail_map = {} + + if detail in detail_map: + msg = detail_map[detail][0] if lang == "ru" else detail_map[detail][1] + else: + msg = ( + f"❌ Неизвестная ошибка {r.status_code}: {detail}" + if lang == "ru" + else f"❌ Unknown error {r.status_code}: {detail}" + ) + + await query.message.edit_text(msg) + + except Exception: + msg = "⚠️ Ошибка при выполнении запроса." if lang == "ru" \ + else "⚠️ Failed to perform the request." + await query.message.edit_text(msg) + + lang = get_language(user_id) + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + elif data.startswith("select_course_"): + course_id = int(data.removeprefix("select_course_")) + context.user_data["test_course"] = course_id + return await show_lab_buttons(query, context) + + elif data == "cancel_test": + lang = get_language(user_id) + text = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + + await query.message.edit_text(text, reply_markup=keyboard) + + update_last_menu(user_id, text, keyboard) + + # Чистим context.user_data от временных значений + context.user_data.pop("test_github", None) + context.user_data.pop("test_group", None) + context.user_data.pop("test_course", None) + + return ConversationHandler.END + + elif data == "lang_en2": + set_language(user_id, "en") + text = "📌 Select an action:" + keyboard = get_main_keyboard("en") + await query.edit_message_text(text, reply_markup=keyboard) + + update_last_menu(user_id, text, keyboard) + + elif data == "lang_ru2": + set_language(user_id, "ru") + text = "📌 Выберите действие:" + keyboard = get_main_keyboard("ru") + await query.edit_message_text(text, reply_markup=keyboard) + + update_last_menu(user_id, text, keyboard) + + + +async def handle_github_submission(query, context, github: str) -> int: + user_id = query.from_user.id + lang = get_language(user_id) + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "SELECT group_id, surname, name, patronymic FROM users WHERE user_id = ?", + (user_id,) + ) + row = cursor.fetchone() + conn.close() + + if not row: + await query.message.edit_text( + "⚠️ Внутренняя ошибка. Повторите попытку." if lang == "ru" + else "⚠️ Internal error. Please try again." + ) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + group_id_db, surname, name, patronymic = row + + async with httpx.AsyncClient(base_url="http://backend:8000") as client: + resp = await client.get("/courses") + resp.raise_for_status() + courses = resp.json() + + course_id = None + for i, _ in enumerate(courses, start=1): + r = await client.get(f"/courses/{i}/groups") + if r.status_code == 200 and group_id_db in r.json(): + course_id = i + break + + payload = { + "surname": surname, + "name": name, + "patronymic": patronymic, + "github": github + } + + reg = await client.post( + f"/courses/{course_id}/groups/{group_id_db}/register", + json=payload, + timeout=httpx.Timeout(15.0) + ) + + save_github = False + + if reg.status_code == 200: + resp_json = reg.json() + status = resp_json.get("status") + if status == "already_registered": + msg = "ℹ️ Этот GitHub-ник уже был записан ранее." if lang == "ru" \ + else "ℹ️ This GitHub username was already submitted earlier." + else: + msg = "✅ Ваш GitHub-ник успешно записан в таблицу." if lang == "ru" \ + else "✅ Your GitHub username has been successfully saved to the table." + save_github = True + elif reg.status_code == 400: + msg = "⚠️ Ошибка в структуре таблицы курса. Обратитесь к преподавателю." if lang == "ru" \ + else "⚠️ Course table is misconfigured. Please contact your instructor." + elif reg.status_code == 404: + msg = "❌ Студент или GitHub-ник не найден. Убедитесь, что вы ввели всё правильно." if lang == "ru" \ + else "❌ Student or GitHub user not found. Please check your input." + elif reg.status_code == 409: + msg = "🚫 GitHub-ник уже был указан ранее. Для его изменения обратитесь к преподавателю." if lang == "ru" \ + else "🚫 Your GitHub username was already submitted earlier. Contact your instructor to change it." + elif reg.status_code == 500: + msg = "⚠️ Внутренняя ошибка сервера. Повторите попытку позже." if lang == "ru" \ + else "⚠️ Internal server error. Please try again later." + else: + msg = f"❓ Неизвестная ошибка (код {reg.status_code})" if lang == "ru" \ + else f"❓ Unknown error (code {reg.status_code})" + + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + import json + buttons_data = [[{"text": b.text, "callback_data": b.callback_data} for b in row] for row in keyboard.inline_keyboard] + + if save_github: + conn = sqlite3.connect(DB_PATH) + conn.execute( + "UPDATE users SET github = ?, last_message = ?, last_buttons = ? WHERE user_id = ?", + (github, next_msg, json.dumps(buttons_data), user_id) + ) + conn.commit() + conn.close() + else: + conn = sqlite3.connect(DB_PATH) + conn.execute( + "UPDATE users SET last_message = ?, last_buttons = ? WHERE user_id = ?", + (next_msg, json.dumps(buttons_data), user_id) + ) + conn.commit() + conn.close() + + return ConversationHandler.END + + + +async def handle_full_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not context.user_data.get("awaiting_full_name", False): + return ConversationHandler.END + + user_id = update.effective_user.id + text = update.message.text.strip() + lang = get_language(user_id) + + parts = text.split(maxsplit=4) + if len(parts) < 3: + msg = "❌ Неверный формат. Попробуйте снова: `1234 Фамилия Имя Отчество`" if lang == "ru" \ + else "❌ Invalid format. Please try again: `1234 Lastname Firstname Patronymic`" + await update.message.reply_text(msg, parse_mode="Markdown") + return WAITING_FOR_FULL_NAME + + group = parts[0] + surname = parts[1] + name = parts[2] + patronymic = parts[3] if len(parts) > 3 else "" + + async with httpx.AsyncClient(base_url="http://backend:8000") as client: + try: + resp = await client.get("/courses") + resp.raise_for_status() + courses = resp.json() + except Exception: + msg = "⚠️ Не удалось получить список курсов." if lang == "ru" else "⚠️ Couldn't retrieve the course list." + await update.message.reply_text(msg) + return ConversationHandler.END + + course_id = None + for i, course in enumerate(courses, start=1): + try: + r = await client.get(f"/courses/{i}/groups") + if r.status_code == 200 and group in r.json(): + course_id = i + break + except Exception: + continue + + if course_id is None: + msg = "❌ Не удалось найти введённый номер группы. Попробуйте снова." if lang == "ru" \ + else "❌ The group you entered wasn't found. Please try again." + await update.message.reply_text(msg) + return WAITING_FOR_FULL_NAME + + payload = { + "surname": surname, + "name": name, + "patronymic": patronymic + } + + try: + check = await client.post(f"/courses/{course_id}/groups/{group}/check-student", json=payload) + if check.status_code != 200: + msg = "❌ Не удалось найти введённые ФИО. Попробуйте снова." if lang == "ru" \ + else "❌ We couldn't find that name. Please try again." + await update.message.reply_text(msg) + return WAITING_FOR_FULL_NAME + except Exception: + msg = "⚠️ Ошибка при подключении к серверу." if lang == "ru" else "⚠️ Server connection error." + await update.message.reply_text(msg) + return WAITING_FOR_FULL_NAME + + # Сохраняем данные во временный контекст + context.user_data["auth_data"] = { + "group_id": group, + "surname": surname, + "name": name, + "patronymic": patronymic + } + + if lang == "ru": + confirm_text = ( + f"🔎 Найдены следующие данные:\n" + f"Группа: {group}\n" + f"ФИО: {surname} {name} {patronymic}\n\n" + f"Подтвердить?" + ) + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✅ Да", callback_data="confirm_auth"), + InlineKeyboardButton("❌ Нет", callback_data="cancel_auth") + ] + ]) + else: + confirm_text = ( + f"🔎 The following data was found in the table:\n" + f"Group: {group}\n" + f"Full name: {surname} {name} {patronymic}\n\n" + f"Confirm data?" + ) + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✅ Yes", callback_data="confirm_auth"), + InlineKeyboardButton("❌ No", callback_data="cancel_auth") + ] + ]) + + await update.message.reply_text(confirm_text, reply_markup=keyboard) + return WAITING_FOR_CONFIRMATION + + + +async def handle_github(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not context.user_data.pop("awaiting_github", False): + return ConversationHandler.END + + github = update.message.text.strip() + context.user_data["pending_github"] = github + + lang = get_language(update.effective_user.id) + + text = ( + f"🔎 Вы ввели GitHub-ник: `{github}`\n\nПодтвердить?" + if lang == "ru" + else f"🔎 You entered GitHub username: `{github}`\n\nConfirm?" + ) + + if lang == "ru": + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✅ Да", callback_data="confirm_github"), + InlineKeyboardButton("❌ Нет", callback_data="cancel_github") + ] + ]) + else: + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✅ Yes", callback_data="confirm_github"), + InlineKeyboardButton("❌ No", callback_data="cancel_github") + ] + ]) + + await update.message.reply_text(text, reply_markup=keyboard, parse_mode="Markdown") + return WAITING_FOR_GITHUB + + + +async def show_lab_buttons(query, context): + course_id = context.user_data.get("test_course") + group_id = context.user_data.get("test_group") + lang = get_language(query.from_user.id) + user_id = query.from_user.id + + if not course_id or not group_id: + msg = "⚠️ Внутренняя ошибка. Не указан курс или группа." if lang == "ru" \ + else "⚠️ Internal error: course or group missing." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + async with httpx.AsyncClient(base_url="http://backend:8000") as client: + try: + r = await client.get(f"/courses/{course_id}/groups/{group_id}/labs") + r.raise_for_status() + labs = r.json() + except Exception: + msg = "❌ Не удалось получить список лабораторных работ." if lang == "ru" \ + else "❌ Failed to retrieve lab list." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + if not labs: + msg = "❌ Нет доступных лабораторных работ." if lang == "ru" \ + else "❌ No available labs found." + await query.message.edit_text(msg) + + next_msg = "📌 Выберите действие:" if lang == "ru" else "📌 Select an action:" + keyboard = get_main_keyboard(lang) + await query.message.reply_text(next_msg, reply_markup=keyboard) + + update_last_menu(user_id, next_msg, keyboard) + + return ConversationHandler.END + + keyboard = [[InlineKeyboardButton(lab, callback_data=f"check_lab_{lab}")] for lab in labs] + keyboard.append([InlineKeyboardButton("↩️ Отмена" if lang == "ru" else "↩️ Cancel", callback_data="cancel_test")]) + + await query.message.edit_text( + "🧪 Выберите лабораторную работу:" if lang == "ru" else "🧪 Select a lab to check:", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + return CHOOSING_LAB + + + +# Точка входа +def main(): + init_db() + application = Application.builder().token(TOKEN).build() + + conv_handler = ConversationHandler( + entry_points=[ + CallbackQueryHandler(handle_button, pattern="^continue$"), + CallbackQueryHandler(handle_button, pattern="^register_github$") + ], + states={ + WAITING_FOR_FULL_NAME: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_full_name) + ], + WAITING_FOR_CONFIRMATION: [ + CallbackQueryHandler(handle_button, pattern="^(confirm_auth|cancel_auth)$") + ], + WAITING_FOR_GITHUB: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_github) + ], + CHOOSING_COURSE: [ + CallbackQueryHandler(handle_button, pattern="^select_course_\\d+$") + ], + CHOOSING_LAB: [ + CallbackQueryHandler(handle_button, pattern="^check_lab_") + ], + }, + fallbacks=[] + ) + + application.add_handler(CommandHandler("start", start)) + application.add_handler(conv_handler) # ⬅️ сначала conv_handler + application.add_handler(CallbackQueryHandler(handle_button)) + + application.run_polling() + + + +if __name__ == "__main__": + main() diff --git a/telegram_bot/docker-compose.yml b/telegram_bot/docker-compose.yml new file mode 100644 index 0000000..2a0faf7 --- /dev/null +++ b/telegram_bot/docker-compose.yml @@ -0,0 +1,22 @@ +services: + backend: + build: + context: . + dockerfile: backend.Dockerfile + ports: + - "8000:8000" + volumes: + - .:/app + - ./courses:/app/courses + env_file: + - .env + bot: + build: + context: . + dockerfile: bot.Dockerfile + container_name: telegram_bot + volumes: + - .:/app + depends_on: + - backend + restart: unless-stopped \ No newline at end of file diff --git a/telegram_bot/main.py b/telegram_bot/main.py new file mode 100644 index 0000000..3c23c2c --- /dev/null +++ b/telegram_bot/main.py @@ -0,0 +1,481 @@ +from fastapi import FastAPI, Request, Response, status, HTTPException +import os +import yaml +import gspread +import requests +import hashlib +import base64 +import httpx +import asyncio +from oauth2client.service_account import ServiceAccountCredentials +from pydantic import BaseModel, Field +from fastapi.responses import FileResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi import UploadFile, File +from dotenv import load_dotenv +from itsdangerous import TimestampSigner, BadSignature +import re + +load_dotenv() +app = FastAPI() +COURSES_DIR = "courses" +CREDENTIALS_FILE = "credentials.json" # Файл с учетными данными Google API +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +ADMIN_LOGIN = os.getenv("ADMIN_LOGIN") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") +SECRET_KEY = os.getenv("SECRET_KEY", "super-secret-key") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Разрешить запросы с любых источников + allow_credentials=True, + allow_methods=["*"], # Разрешить все HTTP-методы + allow_headers=["*"], # Разрешить все заголовки +) +signer = TimestampSigner(SECRET_KEY) + +class StudentRegistration(BaseModel): + name: str = Field(..., min_length=1) + surname: str = Field(..., min_length=1) + patronymic: str = "" + github: str = Field(..., min_length=1) + +class StudentIdentity(BaseModel): + name: str = Field(..., min_length=1) + surname: str = Field(..., min_length=1) + patronymic: str = "" + + + +async def fetch_file_content(client, repo, path, ref, headers): + url = f"https://api.github.com/repos/{repo}/contents/{path}?ref={ref}" + r = await client.get(url, headers=headers) + if r.status_code == 200: + content = r.json().get("content", "") + return base64.b64decode(content) + return None + +def get_sha256(content): + return hashlib.sha256(content).hexdigest() + +async def list_all_files(client, repo, path, ref, headers): + files = [] + url = f"https://api.github.com/repos/{repo}/contents/{path}?ref={ref}" + r = await client.get(url, headers=headers) + if r.status_code != 200: + return files + for item in r.json(): + if item["type"] == "file": + files.append(item["path"]) + elif item["type"] == "dir": + nested = await list_all_files(client, repo, item["path"], ref, headers) + files.extend(nested) + return files + + + +@app.get("/courses") +def get_courses(): + courses = [] + for index, filename in enumerate(sorted(os.listdir(COURSES_DIR)), start=1): + file_path = os.path.join(COURSES_DIR, filename) + if filename.endswith(".yaml") and os.path.isfile(file_path): + with open(file_path, "r", encoding="utf-8") as file: + try: + data = yaml.safe_load(file) + except yaml.YAMLError as e: + print(f"Ошибка при разборе YAML в {filename}: {e}") + continue + + if not isinstance(data, dict) or "course" not in data: + print(f"Пропускаем файл {filename}: неверная структура") + continue + + course_info = data["course"] + courses.append({ + "id": str(index), + "name": course_info.get("name", "Unknown"), + "semester": course_info.get("semester", "Unknown"), + "logo": course_info.get("logo", "/assets/default.png"), + "email": course_info.get("email", ""), + }) + return courses + + + +def parse_lab_id(lab_id: str) -> int: + match = re.search(r"\d+", lab_id) + if not match: + raise HTTPException(status_code=400, detail="Некорректный lab_id") + return int(match.group(0)) + + + +@app.get("/courses/{course_id}/groups") +def get_course_groups(course_id: str): + files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) + try: + filename = files[int(course_id) - 1] + except (IndexError, ValueError): + raise HTTPException(status_code=404, detail="Course not found") + + file_path = os.path.join(COURSES_DIR, filename) + with open(file_path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) + course_info = data.get("course", {}) + spreadsheet_id = course_info.get("google", {}).get("spreadsheet") + info_sheet = course_info.get("google", {}).get("info-sheet") + + if not spreadsheet_id: + raise HTTPException(status_code=400, detail="Spreadsheet ID not found in course config") + + + scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + client = gspread.authorize(creds) + + try: + spreadsheet = client.open_by_key(spreadsheet_id) + sheet_names = [sheet.title for sheet in spreadsheet.worksheets() if sheet.title != info_sheet] + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch sheets: {str(e)}") + + return sheet_names + + + +@app.get("/courses/{course_id}/groups/{group_id}/labs") +def get_course_labs(course_id: str, group_id: str): + files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) + try: + filename = files[int(course_id) - 1] + except (IndexError, ValueError): + raise HTTPException(status_code=404, detail="Course not found") + + file_path = os.path.join(COURSES_DIR, filename) + with open(file_path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) + course_info = data.get("course", {}) + spreadsheet_id = course_info.get("google", {}).get("spreadsheet") + labs = [lab["short-name"] for lab in course_info.get("labs", {}).values() if "short-name" in lab] + + if not spreadsheet_id or not labs: + raise HTTPException(status_code=400, detail="Missing spreadsheet ID or labs in config") + + + scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + client = gspread.authorize(creds) + + try: + spreadsheet = client.open_by_key(spreadsheet_id) + sheet = spreadsheet.worksheet(group_id) + + + headers = sheet.row_values(2)[2:] + except Exception as e: + raise HTTPException(status_code=404, detail=f"Group not found in spreadsheet: {str(e)}") + + available_labs = [lab for lab in labs if lab in headers] + return available_labs + + + +@app.post("/courses/{course_id}/groups/{group_id}/check-student", status_code=200) +def check_student_exists(course_id: str, group_id: str, student: StudentIdentity): + files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) + try: + filename = files[int(course_id) - 1] + except (IndexError, ValueError): + raise HTTPException(status_code=404, detail="Курс не найден") + + file_path = os.path.join(COURSES_DIR, filename) + with open(file_path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) + course_info = data.get("course", {}) + spreadsheet_id = course_info.get("google", {}).get("spreadsheet") + student_col = course_info.get("google", {}).get("student-name-column", 2) + + if not spreadsheet_id: + raise HTTPException(status_code=400, detail="Spreadsheet ID not found in course config") + + scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + client = gspread.authorize(creds) + + try: + spreadsheet = client.open_by_key(spreadsheet_id) + sheet = spreadsheet.worksheet(group_id) + except Exception: + raise HTTPException(status_code=404, detail="Group not found in spreadsheet") + + full_name = f"{student.surname} {student.name} {student.patronymic}".strip() + student_list = sheet.col_values(student_col)[2:] + + if full_name in student_list: + return {"exists": True} + else: + raise HTTPException(status_code=404, detail="Студент не найден") + + + +@app.post("/courses/{course_id}/groups/{group_id}/register") +def register_student(course_id: str, group_id: str, student: StudentRegistration): + files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) + try: + filename = files[int(course_id) - 1] + except (IndexError, ValueError): + raise HTTPException(status_code=404, detail="Course not found") + + file_path = os.path.join(COURSES_DIR, filename) + with open(file_path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) + course_info = data.get("course", {}) + spreadsheet_id = course_info.get("google", {}).get("spreadsheet") + student_col = course_info.get("google", {}).get("student-name-column", 2) + + if not spreadsheet_id: + raise HTTPException(status_code=400, detail="Spreadsheet ID not found in course config") + + + scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + client = gspread.authorize(creds) + + try: + spreadsheet = client.open_by_key(spreadsheet_id) + sheet = spreadsheet.worksheet(group_id) + except Exception: + raise HTTPException(status_code=404, detail="Group not found in spreadsheet") + + full_name = f"{student.surname} {student.name} {student.patronymic}".strip() + + + student_list = sheet.col_values(student_col)[2:] + + if full_name not in student_list: + raise HTTPException(status_code=404, detail={"message": "Студент не найден"}) + + row_idx = student_list.index(full_name) + 3 + + + header_row = sheet.row_values(1) + try: + github_col_idx = header_row.index("GitHub") + 1 + except ValueError: + raise HTTPException(status_code=400, detail="Столбец 'GitHub' не найден в таблице") + + + try: + github_response = requests.get(f"https://api.github.com/users/{student.github}") + if github_response.status_code != 200: + raise HTTPException(status_code=404, detail={"message": "Пользователь GitHub не найден"}) + except Exception: + raise HTTPException(status_code=500, detail="Ошибка проверки GitHub пользователя") + + existing_github = sheet.cell(row_idx, github_col_idx).value + + if not existing_github: + sheet.update_cell(row_idx, github_col_idx, student.github) + return {"status": "registered", "message": "Аккаунт GitHub успешно задан"} + + if existing_github == student.github: + return { + "status": "already_registered", + "message": "Этот аккаунт GitHub уже был указан ранее для этого же студента" + } + + raise HTTPException(status_code=409, detail={ + "status": "conflict", + "message": "Аккаунт GitHub уже был указан ранее. Для изменения аккаунта обратитесь к преподавателю" + }) + + + +class GradeRequest(BaseModel): + github: str = Field(..., min_length=1) + + + +@app.post("/courses/{course_id}/groups/{group_id}/labs/{lab_id}/grade") +async def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest): + files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) + try: + filename = files[int(course_id) - 1] + except (IndexError, ValueError): + raise HTTPException(status_code=404, detail="Course not found") + + file_path = os.path.join(COURSES_DIR, filename) + with open(file_path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) + course_info = data.get("course", {}) + org = course_info.get("github", {}).get("organization") + spreadsheet_id = course_info.get("google", {}).get("spreadsheet") + student_col = course_info.get("google", {}).get("student-name-column", 2) + lab_offset = course_info.get("google", {}).get("lab-column-offset", 1) + + lab_config = course_info.get("labs", {}).get(lab_id, {}) + repo_prefix = lab_config.get("github-prefix") + + if not all([org, spreadsheet_id, repo_prefix]): + raise HTTPException(status_code=400, detail="Missing course configuration") + + username = request.github + repo_name = f"{repo_prefix}-{username}" + repo = f"{org}/{repo_name}" + headers = { + "Authorization": f"Bearer {GITHUB_TOKEN}", + "Accept": "application/vnd.github+json" + } + + async with httpx.AsyncClient(timeout=20.0) as client: + commits_url = f"https://api.github.com/repos/{repo}/commits?per_page=100" + commits_resp = await client.get(commits_url, headers=headers) + if commits_resp.status_code != 200: + raise HTTPException(404, "Не удалось получить список коммитов") + commits = commits_resp.json() + if not commits: + raise HTTPException(404, "Нет коммитов в репозитории") + first_commit_sha = commits[-1]["sha"] + + orig_test = await fetch_file_content(client, repo, "test_main.py", first_commit_sha, headers) + curr_test = await fetch_file_content(client, repo, "test_main.py", "HEAD", headers) + if orig_test: + if not curr_test or get_sha256(curr_test) != get_sha256(orig_test): + raise HTTPException(403, "🚨 test_main.py был изменён или удалён") + + test_files = await list_all_files(client, repo, "tests", first_commit_sha, headers) + tasks = [] + for path in test_files: + tasks.append(fetch_file_content(client, repo, path, first_commit_sha, headers)) + tasks.append(fetch_file_content(client, repo, path, "HEAD", headers)) + file_contents = await asyncio.gather(*tasks) + for i in range(0, len(file_contents), 2): + orig, curr = file_contents[i], file_contents[i + 1] + if not curr or get_sha256(curr) != get_sha256(orig): + raise HTTPException(403, f"🚨 Изменён файл в папке tests/") + + wf_files = await list_all_files(client, repo, ".github/workflows", first_commit_sha, headers) + tasks = [] + for path in wf_files: + tasks.append(fetch_file_content(client, repo, path, first_commit_sha, headers)) + tasks.append(fetch_file_content(client, repo, path, "HEAD", headers)) + file_contents = await asyncio.gather(*tasks) + for i in range(0, len(file_contents), 2): + orig, curr = file_contents[i], file_contents[i + 1] + if not curr or get_sha256(curr) != get_sha256(orig): + raise HTTPException(403, f"🚨 Изменён файл в папке .github/workflows/") + + latest_sha = commits[0]["sha"] + + # Получение check-runs + check_url = f"https://api.github.com/repos/{org}/{repo_name}/commits/{latest_sha}/check-runs" + check_resp = requests.get(check_url, headers=headers) + if check_resp.status_code != 200: + raise HTTPException(status_code=404, detail="Проверки CI не найдены") + + check_runs = check_resp.json().get("check_runs", []) + if not check_runs: + raise HTTPException(status_code=404, detail="Нет коммитов в репозитории") + + # Подсчёт успешных проверок + summary = [] + passed_count = 0 + for check in check_runs: + name = check.get("name", "Unnamed check") + conclusion = check.get("conclusion") + html_url = check.get("html_url") + if conclusion == "success": + emoji = "✅" + passed_count += 1 + elif conclusion == "failure": + emoji = "❌" + else: + emoji = "⏳" + summary.append(f"{emoji} {name} — {html_url}") + + total_checks = len(check_runs) + result_string = f"{passed_count}/{total_checks} тестов пройдено" + final_result = "✓" if passed_count == total_checks else "✗" + + # Обновление таблицы + scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + client = gspread.authorize(creds) + + try: + sheet = client.open_by_key(spreadsheet_id).worksheet(group_id) + except Exception: + raise HTTPException(status_code=404, detail="Группа не найдена в Google Таблице") + + header_row = sheet.row_values(1) + try: + github_col_idx = header_row.index("GitHub") + 1 + except ValueError: + raise HTTPException(status_code=400, detail="Столбец 'GitHub' не найден") + + github_values = sheet.col_values(github_col_idx)[2:] + try: + row_idx = github_values.index(username) + 3 + except ValueError: + raise HTTPException(status_code=404, detail="GitHub логин не найден в таблице") + + lab_number = int("".join(filter(str.isdigit, lab_id))) + lab_col = student_col + lab_number + lab_offset + + if final_result == "✓": + sheet.update_cell(row_idx, lab_col, "✓") + + return { + "status": "updated", + "result": final_result, + "message": f"Результат CI: {'✅ Все проверки пройдены' if final_result == '✓' else '❌ Обнаружены ошибки'}", + "passed": result_string, + "checks": summary + } + +@app.post("/courses/{course_id}/groups/{group_id}/github") +def get_github_from_sheet(course_id: str, group_id: str, student: StudentIdentity): + files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) + try: + filename = files[int(course_id) - 1] + except (IndexError, ValueError): + raise HTTPException(status_code=404, detail="Курс не найден") + + file_path = os.path.join(COURSES_DIR, filename) + with open(file_path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) + course_info = data.get("course", {}) + spreadsheet_id = course_info.get("google", {}).get("spreadsheet") + student_col = course_info.get("google", {}).get("student-name-column", 2) + + if not spreadsheet_id: + raise HTTPException(status_code=400, detail="Spreadsheet ID not found in course config") + + scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] + creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) + client = gspread.authorize(creds) + + try: + spreadsheet = client.open_by_key(spreadsheet_id) + sheet = spreadsheet.worksheet(group_id) + except Exception: + raise HTTPException(status_code=404, detail="Group not found in spreadsheet") + + full_name = f"{student.surname} {student.name} {student.patronymic}".strip() + student_list = sheet.col_values(student_col)[2:] + if full_name not in student_list: + raise HTTPException(status_code=404, detail="Студент не найден в таблице") + + row_idx = student_list.index(full_name) + 3 + + header_row = sheet.row_values(1) + try: + github_col_idx = header_row.index("GitHub") + 1 + except ValueError: + raise HTTPException(status_code=400, detail="Столбец 'GitHub' не найден") + + github_value = sheet.cell(row_idx, github_col_idx).value + if not github_value: + raise HTTPException(status_code=404, detail="GitHub-ник не найден в таблице") + + return {"github": github_value} \ No newline at end of file diff --git a/telegram_bot/requirements.txt b/telegram_bot/requirements.txt new file mode 100644 index 0000000..90d54f7 --- /dev/null +++ b/telegram_bot/requirements.txt @@ -0,0 +1,11 @@ +fastapi +uvicorn +gspread +oauth2client +pyyaml +requests +python-multipart +python-dotenv +itsdangerous +httpx +python-telegram-bot>=20.0 \ No newline at end of file diff --git a/telegram_bot/users.db b/telegram_bot/users.db new file mode 100644 index 0000000..e69de29