commit b7ef588b347958215aee45cc6d4cdf9de242414e Author: Administrator Date: Fri Oct 21 13:43:59 2022 +0300 initial diff --git a/.deploy/deploy-dev.yaml b/.deploy/deploy-dev.yaml new file mode 100644 index 0000000..352b891 --- /dev/null +++ b/.deploy/deploy-dev.yaml @@ -0,0 +1,56 @@ +version: "3.4" + + +services: + + bot: + image: mathwave/sprint-repo:ruz-bot + networks: + - b-jokes-net + environment: + MONGO_HOST: "mongo.develop.sprinthub.ru" + MONGO_PASSWORD: $MONGO_PASSWORD_DEV + TELEGRAM_TOKEN: $TELEGRAM_TOKEN_DEV + command: bot + deploy: + mode: replicated + restart_policy: + condition: any + update_config: + parallelism: 1 + order: start-first + + fetch: + image: mathwave/sprint-repo:ruz-bot + environment: + MONGO_HOST: "mongo.develop.sprinthub.ru" + MONGO_PASSWORD: $MONGO_PASSWORD_DEV + command: fetch + deploy: + mode: replicated + restart_policy: + condition: any + update_config: + parallelism: 1 + order: start-first + + notify: + image: mathwave/sprint-repo:ruz-bot + environment: + MONGO_HOST: "mongo.develop.sprinthub.ru" + MONGO_PASSWORD: $MONGO_PASSWORD_DEV + TELEGRAM_TOKEN: $TELEGRAM_TOKEN_DEV + command: notify + deploy: + mode: replicated + restart_policy: + condition: any + update_config: + parallelism: 1 + order: start-first + +networks: + b-jokes-net: + driver: overlay + common-infra-nginx: + external: true \ No newline at end of file diff --git a/.deploy/deploy-prod.yaml b/.deploy/deploy-prod.yaml new file mode 100644 index 0000000..34f8b68 --- /dev/null +++ b/.deploy/deploy-prod.yaml @@ -0,0 +1,51 @@ +version: "3.4" + + +services: + + bot: + image: mathwave/sprint-repo:ruz-bot + environment: + MONGO_HOST: "mongo.sprinthub.ru" + MONGO_PASSWORD: $MONGO_PASSWORD_PROD + TELEGRAM_TOKEN: $TELEGRAM_TOKEN_PROD + DEBUG: false + command: bot + deploy: + mode: replicated + restart_policy: + condition: any + update_config: + parallelism: 1 + order: start-first + + fetch: + image: mathwave/sprint-repo:ruz-bot + environment: + MONGO_HOST: "mongo.sprinthub.ru" + MONGO_PASSWORD: $MONGO_PASSWORD_PROD + DEBUG: false + command: fetch + deploy: + mode: replicated + restart_policy: + condition: any + update_config: + parallelism: 1 + order: start-first + + notify: + image: mathwave/sprint-repo:ruz-bot + environment: + MONGO_HOST: "mongo.sprinthub.ru" + MONGO_PASSWORD: $MONGO_PASSWORD_PROD + TELEGRAM_TOKEN: $TELEGRAM_TOKEN_PROD + DEBUG: false + command: notify + deploy: + mode: replicated + restart_policy: + condition: any + update_config: + parallelism: 1 + order: start-first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92785dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,119 @@ +# Django # +*.log +*.pot +*.pyc +__pycache__ +db.sqlite3 +media +data +*/__pycache__ + +# Backup files # +*.bak + +# If you are using PyCharm # +.idea +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/gradle.xml +.idea/**/libraries +*.iws /out/ + +# Python # +*.py[cod] +*$py.class + +# Distribution / packaging +.Python build/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +postgres-data + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +ENV/ +venv/ +env.bak/ +venv.bak/ + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# Sublime Text # +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project + +# sftp configuration file +sftp-config.json + +# Package control specific files Package +Control.last-run +Control.ca-list +Control.ca-bundle +Control.system-ca-bundle +GitHub.sublime-settings + +# Visual Studio Code # +.vscode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..57ec52b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,43 @@ +stages: + - build + - deploy-dev + - deploy-prod + +build: + stage: build + tags: + - dev + before_script: + - docker login -u mathwave -p $DOCKERHUB_PASSWORD + script: + - docker build -t mathwave/sprint-repo:ruz-bot . + - docker push mathwave/sprint-repo:ruz-bot + +.deploy: + before_script: + - docker login -u mathwave -p $DOCKERHUB_PASSWORD + +deploy-dev: + extends: + - .deploy + stage: deploy-dev + tags: + - dev + rules: + - if: '$CI_COMMIT_BRANCH == "master"' + when: on_success + - when: manual + script: + - docker stack deploy --with-registry-auth -c ./.deploy/deploy-dev.yaml ruz-bot + +deploy-prod: + extends: + - .deploy + stage: deploy-prod + tags: + - prod + only: + - master + when: manual + script: + - docker stack deploy --with-registry-auth -c ./.deploy/deploy-prod.yaml ruz-bot diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f1cffe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.10 +RUN mkdir /usr/src/app +WORKDIR /usr/src/app +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt +COPY . . +ENTRYPOINT ["python", "entrypoint.py"] \ No newline at end of file diff --git a/daemons/__init__.py b/daemons/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daemons/bot.py b/daemons/bot.py new file mode 100644 index 0000000..417c253 --- /dev/null +++ b/daemons/bot.py @@ -0,0 +1,13 @@ +import os + +import telebot +from telebot.types import Message + + +bot = telebot.TeleBot(os.getenv("TELEGRAM_TOKEN")) + + +@bot.message_handler() +def do_action(message: Message): + from helpers.answer import answer + answer.process(message) diff --git a/daemons/fetch.py b/daemons/fetch.py new file mode 100644 index 0000000..846b6c3 --- /dev/null +++ b/daemons/fetch.py @@ -0,0 +1,83 @@ +import datetime +import zoneinfo +from time import sleep + +from helpers.mongo import mongo +from helpers.ruz import ruz + + +def fetch_schedule_for_user(user_hse_id: int): + zone = zoneinfo.ZoneInfo("Europe/Moscow") + today = datetime.datetime.now(zone) + next_day = today + datetime.timedelta(days=7) + schedule = ruz.get_schedule(user_hse_id, today, next_day) + if schedule is None: + return False + saved_ids = [] + for element in schedule: + year, month, day = element['date'].split('.') + begin_hour, begin_minute = element['beginLesson'].split(':') + end_hour, end_minute = element['endLesson'].split(':') + lesson = mongo.lessons_collection.find_one({ + "discipline": element['discipline'], + "auditorium": element['auditorium'], + "hse_user_id": user_hse_id, + "begin": datetime.datetime( + year=int(year), + month=int(month), + day=int(day), + hour=int(begin_hour), + minute=int(begin_minute) + ) + }) + if lesson is None: + result = mongo.lessons_collection.insert_one({ + "discipline": element['discipline'], + "auditorium": element['auditorium'], + "hse_user_id": user_hse_id, + "begin": datetime.datetime( + year=int(year), + month=int(month), + day=int(day), + hour=int(begin_hour), + minute=int(begin_minute) + ), + "end": datetime.datetime( + year=int(year), + month=int(month), + day=int(day), + hour=int(end_hour), + minute=int(end_minute) + ), + "building": element['building'], + "lecturer": element['lecturer'], + "notified": False + }) + saved_ids.append(result.inserted_id) + else: + saved_ids.append(lesson['_id']) + mongo.lessons_collection.delete_many({"hse_user_id": user_hse_id, "_id": {"$nin": saved_ids}}) + return True + + +def process(): + for user in mongo.users_collection.find({}): + fetch_schedule_for_user(user['hse_id']) + + +def delete_old(): + zone = zoneinfo.ZoneInfo("Europe/Moscow") + today = datetime.datetime.now(zone) + mongo.lessons_collection.delete_many({"end": {"$lte": today - datetime.timedelta(days=1)}}) + + +def fetch(): + while True: + print("fetch start") + begin = datetime.datetime.now() + process() + end = datetime.datetime.now() + print('fetch finished') + print("time elapsed", (end - begin).total_seconds()) + delete_old() + sleep(60 * 60) diff --git a/daemons/notify.py b/daemons/notify.py new file mode 100644 index 0000000..c2f0196 --- /dev/null +++ b/daemons/notify.py @@ -0,0 +1,38 @@ +import datetime +import zoneinfo +from time import sleep + +from daemons.bot import bot +from helpers.mongo import mongo + + +def process(): + for user in mongo.users_collection.find({"notify_minutes": {"$ne": None}}): + zone = zoneinfo.ZoneInfo("Europe/Moscow") + now = datetime.datetime.now(zone) + for lesson in mongo.lessons_collection.find({ + "hse_user_id": user["hse_id"], + "begin": {"$lte": now + datetime.timedelta(minutes=5)}, + "notified": False + }): + ans = "" + ans += "Аудитория: " + lesson["building"] + ", " + lesson["auditorium"] + "\n" + ans += "Начало: " + lesson["begin"].strftime("%H:%M") + "\n" + ans += "Конец: " + lesson["end"].strftime("%H:%M") + "\n" + ans += "Преподаватель: " + lesson["lecturer"] + "\n" + bot.send_message( + user["chat_id"], + "Уведомляю о занятиях!\n" + ans + ) + mongo.lessons_collection.update_one({"_id": lesson['_id']}, {"$set": {"notified": True}}) + + +def notify(): + while True: + print("notify start") + begin = datetime.datetime.now() + process() + end = datetime.datetime.now() + print('notify finished') + print("time elapsed", (end - begin).total_seconds()) + sleep(60 * 2) diff --git a/entrypoint.py b/entrypoint.py new file mode 100644 index 0000000..c87ae76 --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,19 @@ +import sys + +from daemons.bot import bot +from daemons.fetch import fetch +from daemons.notify import notify + +arg = sys.argv[-1] + +if arg == "bot": + print("bot is starting") + bot.polling() +elif arg == "fetch": + print("fetch is starting") + fetch() +elif arg == "notify": + print("notify is starting") + notify() +else: + raise ValueError(f"Unknown param {arg}") diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/helpers/answer.py b/helpers/answer.py new file mode 100644 index 0000000..3db24eb --- /dev/null +++ b/helpers/answer.py @@ -0,0 +1,172 @@ +import telebot +from telebot.types import Message + +from daemons.bot import bot +from daemons.fetch import fetch_schedule_for_user +from helpers.models import UserSchema, User +from helpers.mongo import mongo +from helpers.ruz import ruz + + +class BaseAnswer: + def process(self, message: Message): + user = mongo.users_collection.find_one({"chat_id": message.chat.id}) + if user is None: + user = User(chat_id=message.chat.id) + mongo.users_collection.insert_one(UserSchema().dump(user)) + else: + user = UserSchema().load(user) + attr = getattr(self, "handle_state_" + user.state, None) + if attr is None: + raise NotImplementedError(f"handled state {user.state} but not implemented!") + attr(message, user) + + def set_state(self, user: User, state: str): + user.state = state + mongo.users_collection.update_one({"chat_id": user.chat_id}, {"$set": {"state": state}}) + + +class Answer(BaseAnswer): + + def handle_state_new(self, message: Message, user: User): + bot.send_message( + message.chat.id, + "Привет! Я буду помогать тебе выживать в вышке!\nДля начала пришли мне свое ФИО.", + ) + self.set_state(user, "wait_for_name") + + def handle_state_wait_for_name(self, message: Message, user: User): + kb = telebot.types.ReplyKeyboardMarkup(True, True) + data = ruz.find_person(message.text) + if data is None: + bot.send_message( + user.chat_id, + "В РУЗе какая-то поломка, попробуй еще раз позже." + ) + return + if len(data) == 0: + bot.send_message(user.chat_id, "К сожалению, в РУЗе не нашлось такого студента, попробуй еще раз.") + return + for entity in data: + kb.row(entity['description']) + user.name = message.text + mongo.users_collection.update_one( + {"chat_id": user.chat_id}, + {"$set": {"name": user.name}}) + bot.send_message( + user.chat_id, + "Отлично! Теперь выбери из списка свою группу.", + reply_markup=kb + ) + self.set_state(user, "wait_for_group") + + def handle_state_wait_for_group(self, message: Message, user: User): + group = message.text + data = ruz.find_person(user.name) + if data is None: + bot.send_message( + user.chat_id, + "В РУЗе какая-то поломка, попробуй еще раз позже." + ) + return + for element in data: + if element['description'] == group: + user.hse_id = int(element['id']) + user.group = group + user.name = element['label'] + break + mongo.users_collection.update_one({"chat_id": user.chat_id}, {"$set": { + "hse_id": user.hse_id, + "group": group, + "name": user.name + }}) + bot.send_message( + user.chat_id, + "Я нашел тебя в базе РУЗ. Я буду подсказывать тебе расписание, а также уведомлять о предстоящих парах.", + ) + success = fetch_schedule_for_user(user.hse_id) + if success: + kb = telebot.types.ReplyKeyboardMarkup(True, True) + kb.row("Пары сегодня") + kb.row("Уведомления") + lessons = mongo.get_today_lessons(user) + if len(lessons) == 0: + bot.send_message( + user.chat_id, + "Сегодня у тебя нет пар.", + reply_markup=kb + ) + else: + bot.send_message( + user.chat_id, + ruz.schedule_builder(lessons), + reply_markup=kb + ) + self.set_state(user, "ready") + + def handle_state_ready(self, message: Message, user: User): + kb = telebot.types.ReplyKeyboardMarkup(True, True) + kb.row("Пары сегодня") + kb.row("Уведомления") + if message.text == "Пары сегодня": + lessons = mongo.get_today_lessons(user) + if len(lessons) == 0: + text = "Сегодня у тебя нет пар." + else: + text = ruz.schedule_builder(lessons) + elif message.text == "Уведомления": + kb = telebot.types.ReplyKeyboardMarkup(True, True) + kb.row("Не уведомлять") + kb.row("5 минут") + kb.row("10 минут") + kb.row("15 минут") + kb.row("20 минут") + bot.send_message( + user.chat_id, + "Выбери когда мне нужно тебе написать о предстоящей паре", + reply_markup=kb + ) + self.set_state(user, "wait_for_notify") + return + else: + text = "Я не понимаю такой команды, используй кнопки." + bot.send_message( + user.chat_id, + text, + reply_markup=kb + ) + + def handle_state_wait_for_notify(self, message: Message, user: User): + text = message.text + if text == "Не уведомлять": + user.notify_minutes = None + elif text == "5 минут": + user.notify_minutes = 5 + elif text == "10 минут": + user.notify_minutes = 10 + elif text == "15 минут": + user.notify_minutes = 15 + elif text == "20 минут": + user.notify_minutes = 20 + else: + kb = telebot.types.ReplyKeyboardMarkup(True, True) + kb.row("Не уведомлять") + kb.row("5 минут") + kb.row("10 минут") + kb.row("15 минут") + kb.row("20 минут") + bot.send_message(user.chat_id, "Я не понимаю такой команды, используй кнопки.", reply_markup=kb) + return + mongo.users_collection.update_one({"chat_id": user.chat_id}, {"$set": {"notify_minutes": user.notify_minutes}}) + if user.notify_minutes is not None: + text = f"Принято! Буду уведомлять тебя за {text}." + else: + text = f"Принято! Я не уведомлять тебя." + kb = telebot.types.ReplyKeyboardMarkup(True, True) + kb.row("Пары сегодня") + kb.row("Уведомления") + bot.send_message(user.chat_id, text, reply_markup=kb) + self.set_state(user, "ready") + + +answer = Answer() diff --git a/helpers/models.py b/helpers/models.py new file mode 100644 index 0000000..0419ea0 --- /dev/null +++ b/helpers/models.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Optional + +import marshmallow_dataclass +from marshmallow import EXCLUDE + + +@dataclass +class User: + chat_id: int + name: Optional[str] = None + group: Optional[str] = None + hse_id: Optional[int] = None + state: str = "new" + notify_minutes: Optional[int] = 10 + + class Meta: + unknown = EXCLUDE + + +@dataclass +class Lesson: + hse_id: int + + + +UserSchema = marshmallow_dataclass.class_schema(User) diff --git a/helpers/mongo.py b/helpers/mongo.py new file mode 100644 index 0000000..2aec3cb --- /dev/null +++ b/helpers/mongo.py @@ -0,0 +1,66 @@ +import datetime +import zoneinfo +from functools import cached_property + +import pymongo + +import settings +from helpers.models import UserSchema, User + + +class Mongo: + def __init__(self): + url = f"mongodb://{settings.MONGO_USER}:{settings.MONGO_PASSWORD}@{settings.MONGO_HOST}:27017/" + self.client = pymongo.MongoClient(url) + self.database = self.client.get_database("ruz-bot") + self.users_collection.create_index([ + ("chat_id", 1) + ]) + self.users_collection.create_index([ + ("notify_minutes", 1) + ]) + self.lessons_collection.create_index([ + ("discipline", 1), + ("auditorium", 1), + ("begin", 1), + ("hse_user_id", 1) + ]) + self.lessons_collection.create_index([ + ("hse_user_id", 1), + ("begin", 1) + ]) + self.lessons_collection.create_index([ + ("hse_user_id", 1), + ("begin", 1), + ("notified", 1) + ]) + + def get_user(self, user_id: int) -> User: + return UserSchema().loads(self.users_collection.find_one({"id": user_id})) + + def __getitem__(self, item): + return self.database.get_collection(item) + + @cached_property + def users_collection(self): + return self["users"] + + @cached_property + def lessons_collection(self): + return self["lessons"] + + def get_today_lessons(self, user: User): + zone = zoneinfo.ZoneInfo("Europe/Moscow") + today = datetime.datetime.now(zone) + tomorrow = today + datetime.timedelta(days=1) + tomorrow = datetime.datetime(year=tomorrow.year, month=tomorrow.month, day=tomorrow.day) + lessons = [] + for lesson in self.lessons_collection.find({ + "hse_user_id": user.hse_id, + "begin": {"$gte": today, "$lte": tomorrow}} + ): + lessons.append(lesson) + return lessons + + +mongo = Mongo() diff --git a/helpers/ruz.py b/helpers/ruz.py new file mode 100644 index 0000000..63b4cce --- /dev/null +++ b/helpers/ruz.py @@ -0,0 +1,62 @@ +import datetime + +from requests import get + +import settings + + +fields = [ + 'discipline', + 'building', + 'auditorium', + 'date', + 'beginLesson', + 'endLesson', + 'lecturer' +] + + +class RUZ: + + def find_person(self, name: str) -> dict | None: + search_str = settings.RUZ_API + f"search?term={name}&type=student" + try: + data = get(search_str) + except: + return None + if data.status_code == 200: + return data.json() + return None + + def get_schedule(self, hse_id: int, begin_date: datetime.datetime, end_date: datetime.datetime): + start_date_str = begin_date.strftime("%Y.%m.%d") + end_date_str = end_date.strftime("%Y.%m.%d") + search_str = settings.RUZ_API + f"schedule/student/{hse_id}?start={start_date_str}&finish={end_date_str}&lng=1" + try: + data = get(search_str) + except: + return None + if data.status_code != 200: + return None + data = data.json() + formatted_data = [ + { + field: element[field] + for field in fields + } + for element in data + ] + return formatted_data + + def schedule_builder(self, lessons: list[dict]) -> str: + ans = "" + for lesson in lessons: + ans += "Аудитория: " + lesson["building"] + ", " + lesson["auditorium"] + "\n" + ans += "Начало: " + lesson["begin"].strftime("%H:%M") + "\n" + ans += "Конец: " + lesson["end"].strftime("%H:%M") + "\n" + ans += "Преподаватель: " + lesson["lecturer"] + "\n" + ans += "_______________\n" + return ans + + +ruz = RUZ() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..899e783 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +certifi==2022.9.24 +charset-normalizer==2.1.1 +idna==3.4 +marshmallow==3.18.0 +marshmallow-dataclass==8.5.9 +mypy-extensions==0.4.3 +packaging==21.3 +pymongo==4.2.0 +pyparsing==3.0.9 +pyTelegramBotAPI==4.1.1 +requests==2.28.1 +typing-inspect==0.8.0 +typing_extensions==4.4.0 +urllib3==1.26.12 diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..ac6f28f --- /dev/null +++ b/settings.py @@ -0,0 +1,9 @@ +import os + + +MONGO_USER = os.getenv("MONGO_USER", "mongo") +MONGO_PASSWORD = os.getenv("MONGO_PASSWORD", "password") +MONGO_HOST = os.getenv("MONGO_HOST", "localhost") +DEBUG = os.getenv("DEBUG", "true") == "true" + +RUZ_API = "https://ruz.hse.ru/api/"