This commit is contained in:
Administrator 2022-10-21 13:43:59 +03:00
commit b7ef588b34
17 changed files with 779 additions and 0 deletions

56
.deploy/deploy-dev.yaml Normal file
View File

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

51
.deploy/deploy-prod.yaml Normal file
View File

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

119
.gitignore vendored Normal file
View File

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

43
.gitlab-ci.yml Normal file
View File

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

7
Dockerfile Normal file
View File

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

0
daemons/__init__.py Normal file
View File

13
daemons/bot.py Normal file
View File

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

83
daemons/fetch.py Normal file
View File

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

38
daemons/notify.py Normal file
View File

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

19
entrypoint.py Normal file
View File

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

0
helpers/__init__.py Normal file
View File

172
helpers/answer.py Normal file
View File

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

27
helpers/models.py Normal file
View File

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

66
helpers/mongo.py Normal file
View File

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

62
helpers/ruz.py Normal file
View File

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

14
requirements.txt Normal file
View File

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

9
settings.py Normal file
View File

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