From 911c1f3400a1f18f59d38327dadd4d47e8476a30 Mon Sep 17 00:00:00 2001 From: Andrey Gumirov Date: Fri, 29 Apr 2022 20:11:54 +0700 Subject: [PATCH] Initial --- .env | 3 + .gitignore | 1 + blueprints/__init__.py | 2 + blueprints/menu.py | 17 ++++++ blueprints/test.py | 87 +++++++++++++++++++++++++++++ config.py | 11 ++++ db/__init__.py | 1 + db/db.py | 74 ++++++++++++++++++++++++ docker-compose.yml | 14 +++++ locales.py | 63 +++++++++++++++++++++ main.py | 26 +++++++++ middlewares/__init__.py | 1 + middlewares/user_data_middleware.py | 24 ++++++++ util/__init__.py | 1 + util/singleton.py | 26 +++++++++ 15 files changed, 351 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 blueprints/__init__.py create mode 100644 blueprints/menu.py create mode 100644 blueprints/test.py create mode 100644 config.py create mode 100644 db/__init__.py create mode 100644 db/db.py create mode 100644 docker-compose.yml create mode 100644 locales.py create mode 100644 main.py create mode 100644 middlewares/__init__.py create mode 100644 middlewares/user_data_middleware.py create mode 100644 util/__init__.py create mode 100644 util/singleton.py diff --git a/.env b/.env new file mode 100644 index 0000000..e3670dc --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +PG_USER=pg +PG_PASS=pg +PG_OUTBOUND_PORT=5432 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd4c22c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*__pycache__* diff --git a/blueprints/__init__.py b/blueprints/__init__.py new file mode 100644 index 0000000..87d883f --- /dev/null +++ b/blueprints/__init__.py @@ -0,0 +1,2 @@ +from .menu import menu_router +from .test import test_router \ No newline at end of file diff --git a/blueprints/menu.py b/blueprints/menu.py new file mode 100644 index 0000000..d82ff10 --- /dev/null +++ b/blueprints/menu.py @@ -0,0 +1,17 @@ +from vkwave.bots import DefaultRouter, SimpleBotEvent, simple_bot_message_handler +import locales + +# MENU_KB.add_row() +# MENU_KB.add_text_button(text="Профиль", payload={"command": "profile"}, color=ButtonColor.SECONDARY) +# MENU_KB.add_row() +# MENU_KB.add_text_button(text="Бонус", payload={"command": "bonus"}, color=ButtonColor.POSITIVE) + +menu_router = DefaultRouter() + + +@simple_bot_message_handler(menu_router,) +async def menu(event: SimpleBotEvent): + return await event.answer( + message=f"Привет!", + keyboard=locales.MENU_KB.get_keyboard(), + ) \ No newline at end of file diff --git a/blueprints/test.py b/blueprints/test.py new file mode 100644 index 0000000..3d3cf58 --- /dev/null +++ b/blueprints/test.py @@ -0,0 +1,87 @@ +import json +import logging +import random + +from vkwave.bots import DefaultRouter, SimpleBotEvent, simple_bot_message_handler, PayloadFilter, PayloadContainsFilter +from vkwave.bots import Keyboard, ButtonColor +from vkwave.bots import EventTypeFilter, BotEvent +from vkwave.types.bot_events import BotEventType +from vkwave.bots.fsm import FiniteStateMachine, StateFilter, ForWhat, State, ANY_STATE + +import locales +from db import DB +from db.db import TestResult +from locales import INPUT_NAME_TEXT + + +# MENU_KB.add_row() +# MENU_KB.add_text_button(text="Профиль", payload={"command": "profile"}, color=ButtonColor.SECONDARY) +# MENU_KB.add_row() +# MENU_KB.add_text_button(text="Бонус", payload={"command": "bonus"}, color=ButtonColor.POSITIVE) + +test_router = DefaultRouter() + +test_router.registrar.add_default_filter( + EventTypeFilter(BotEventType.MESSAGE_NEW.value)) # we don't want to write it in all handlers. + + +# # exiting from poll (works on any state) +# @test_router.registrar.with_decorator( +# lambda event: event.object.object.message.text == "exit", +# StateFilter(fsm=fsm, state=ANY_STATE, for_what=ForWhat.FOR_USER) +# ) +# async def simple_handler(event: BotEvent): +# # Check if we have the user in database +# if await fsm.get_data(event, for_what=ForWhat.FOR_USER) is not None: +# await fsm.finish(event=event, for_what=ForWhat.FOR_USER) +# return "You are quited!" + +@test_router.registrar.with_decorator( + PayloadContainsFilter("test"),# for state in States.questions[:-1]] +) +async def main_part_handle(event: BotEvent): + user_id = event.object.object.message.from_id + payload = json.loads(event.object.object.message.payload) + + botevent = SimpleBotEvent(event) + state_idx = int(payload["test"]) + logging.debug(f"State index: {state_idx}") + + + q_res = payload['q'] if 'q' in payload else None + logging.debug(f"Qres: {q_res}") + + # extra_state_data works as fsm.add_data(..., state_data={"name": event.object.object.message.text}) + logging.debug(f"Got text: {event.object.object.message.text}") + + if q_res: + DB().update_test_result(user_id, question=state_idx, answer=q_res) + + if state_idx + 1 < len(locales.questions): + return await botevent.answer( + message=locales.questions[state_idx + 1][0], + keyboard=locales.questions[state_idx + 1][1].get_keyboard(), + ) + else: + return await botevent.answer( + message=locales.LAST_MESSAGE, + keyboard=locales.LAST_MESSAGE_KB.get_keyboard(), + ) + +# @test_router.registrar.with_decorator( +# StateFilter(fsm=fsm, state=MyState.age, for_what=ForWhat.FOR_USER), +# ) +# async def simple_handler(event: BotEvent): +# if not event.object.object.message.text.isdigit(): +# return f"Please, send only positive numbers!" +# await fsm.add_data( +# event=event, +# for_what=ForWhat.FOR_USER, +# state_data={"age": event.object.object.message.text}, +# ) +# user_data = await fsm.get_data(event=event, for_what=ForWhat.FOR_USER) +# +# # finish poll and delete the user +# # `fsm.finish` will do it +# await fsm.finish(event=event, for_what=ForWhat.FOR_USER) +# return f"Your data - {user_data}" diff --git a/config.py b/config.py new file mode 100644 index 0000000..e9f51d3 --- /dev/null +++ b/config.py @@ -0,0 +1,11 @@ +import os +from util import Singleton + + +class Config(metaclass=Singleton): + TOKEN = os.environ["TOKEN"] + GROUP_ID = int(os.environ["GROUP_ID"]) + + PG_USER = os.environ["USER"] + PG_PASS = os.environ["PASS"] + PG_ADDR = os.environ["DB_ADDR"] diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..dc04851 --- /dev/null +++ b/db/__init__.py @@ -0,0 +1 @@ +from .db import DB, Candidate, TestResult \ No newline at end of file diff --git a/db/db.py b/db/db.py new file mode 100644 index 0000000..f9bb992 --- /dev/null +++ b/db/db.py @@ -0,0 +1,74 @@ +from sqlalchemy import Column, DateTime, String, Integer, func, ForeignKey +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship +from sqlalchemy import create_engine +import logging + +import locales +from config import Config +from util import Singleton + +Base = declarative_base() + + +class Candidate(Base): + __tablename__ = 'candidates' + id = Column(Integer, primary_key=True) + first_interaction = Column(DateTime, default=func.now()) + name = Column(String) + last_name = Column(String) + + sex = Column(String) + + test_result = relationship("TestResult", back_populates="candidate") + + def __str__(self): + return f"Candidate(id={self.id} first_interaction={self.first_interaction} name={self.name})" + + +class TestResult(Base): + __tablename__ = "test_results" + id = Column(Integer, primary_key=True) + candidate_id = Column(Integer, ForeignKey('candidates.id')) + candidate = relationship("Candidate", back_populates="test_result") + + answers = relationship("QuestionAnswer", back_populates="user", cascade="all, delete-orphan") + + +class QuestionAnswer(Base): + __tablename__ = "test_answers" + id = Column(Integer, primary_key=True) + + test_result_id = Column(Integer, ForeignKey("test_results.id"), nullable=False) + user = relationship("TestResult", back_populates="answers") + + question = Column(Integer) + answer = Column(String) + + +class DB(metaclass=Singleton): + def __init__(self): + self._engine = create_engine(f'postgresql+psycopg2://{Config.PG_USER}:{Config.PG_PASS}@{Config.PG_ADDR}/db') + self._Session = sessionmaker() + self._Session.configure(bind=self._engine) + self._session = self._Session() + Base.metadata.create_all(self._engine) + logging.debug("Db connection succeeded!") + + def get_user(self, user_id: int) -> Candidate: + return self._session.query(Candidate).filter(Candidate.id == user_id).first() + + def set_name(self, user_id: int, name: str): + self._session.query(Candidate).filter(Candidate.id == user_id).update({Candidate.name: name}) + self._session.commit() + + def update_test_result(self, user_id: int, question: int, answer: str): + user = self._session.query(Candidate).filter(Candidate.id == user_id).first() + user.test_result[0].answers.append(QuestionAnswer(question=question, answer=answer)) + self._session.commit() + + def add_candidate(self, candidate: Candidate): + tres = TestResult(answers=[]) + candidate.test_result = [tres] + self._session.add(candidate) + self._session.commit() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66d3874 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: "2" +services: + pgdb: + image: 'postgres:12' + restart: always + environment: + - POSTGRES_USER=${PG_USER} + - POSTGRES_PASSWORD=${PG_PASS} + - POSTGRES_DB=db + - PGDATA=/var/lib/postgresql/data/pgdata +# volumes: +# - ${PG_MNT}:/var/lib/postgresql/data + ports: + - ${PG_OUTBOUND_PORT}:5432 diff --git a/locales.py b/locales.py new file mode 100644 index 0000000..b41cedc --- /dev/null +++ b/locales.py @@ -0,0 +1,63 @@ +from vkwave.bots import Keyboard, ButtonColor + +# menu +MENU_KB = Keyboard() +MENU_KB.add_text_button(text="Пройти тест!", payload={"test": "-1"}, color=ButtonColor.POSITIVE) + +# TEST Questions +INPUT_NAME_TEXT = "Пожалуйста, введите имя:" + +# 1 +WHAT_ENGINEER_ARE_YOU = "Кто ты из инженеров?" +WHAT_ENGINEER_ARE_YOU_KB = Keyboard() +WHAT_ENGINEER_ARE_YOU_KB.add_text_button(text="Маск", payload={"q": "Маск", "test": "0"}, color=ButtonColor.PRIMARY) +WHAT_ENGINEER_ARE_YOU_KB.add_text_button(text="Рогозин", payload={"q": "Рогозин", "test": "0"}, color=ButtonColor.PRIMARY) +WHAT_ENGINEER_ARE_YOU_KB.add_text_button(text="Тесла", payload={"q": "Тесла", "test": "0"}, color=ButtonColor.PRIMARY) +WHAT_ENGINEER_ARE_YOU_KB.add_row() +WHAT_ENGINEER_ARE_YOU_KB.add_text_button(text="Кулибин", payload={"q": "Кулибин", "test": "0"}, color=ButtonColor.PRIMARY) +WHAT_ENGINEER_ARE_YOU_KB.add_text_button(text="Калашников", payload={"q": "Калашников", "test": "0"}, color=ButtonColor.PRIMARY) +WHAT_ENGINEER_ARE_YOU_KB.add_text_button(text="Кондратюк", payload={"q": "Кондратюк", "test": "0"}, color=ButtonColor.PRIMARY) + +# 2 +PROG_LANG = "Какой ЯП нравится?" +PROG_LANG_KB = Keyboard() +PROG_LANG_KB.add_text_button(text="Python", payload={"q": "Python", "test": "1"}, color=ButtonColor.PRIMARY) +PROG_LANG_KB.add_text_button(text="Pascal", payload={"q": "Pascal", "test": "1"}, color=ButtonColor.PRIMARY) +PROG_LANG_KB.add_text_button(text="C/C++", payload={"q": "ccpp", "test": "1"}, color=ButtonColor.PRIMARY) +PROG_LANG_KB.add_row() +PROG_LANG_KB.add_text_button(text="JS", payload={"q": "JS", "test": "1"}, color=ButtonColor.PRIMARY) +PROG_LANG_KB.add_text_button(text="HTML+CSS", payload={"q": "HTMLCSS", "test": "1"}, color=ButtonColor.PRIMARY) +PROG_LANG_KB.add_text_button(text="Haskel", payload={"q": "Haskel", "test": "1"}, color=ButtonColor.PRIMARY) + +# 3 +FAV_THEME = "Какой предмет нравится?" +FAV_THEME_KB = Keyboard() +FAV_THEME_KB.add_text_button(text="Матеша", payload={"q": "Матеша", "test": "2"}, color=ButtonColor.PRIMARY) +FAV_THEME_KB.add_text_button(text="Русский/Литра", payload={"q": "русскийлитра", "test": "2"}, color=ButtonColor.PRIMARY) +FAV_THEME_KB.add_text_button(text="Инфа", payload={"q": "Инфа", "test": "0"}, color=ButtonColor.PRIMARY) +FAV_THEME_KB.add_row() +FAV_THEME_KB.add_text_button(text="Физика", payload={"q": "Физика", "test": "2"}, color=ButtonColor.PRIMARY) +FAV_THEME_KB.add_text_button(text="другое", payload={"q": "other", "test": "2"}, color=ButtonColor.PRIMARY) + +# 4 +EGE = "Как готовился к ЕГЭ?" +EGE_KB = Keyboard() +EGE_KB.add_text_button(text="В школе", payload={"q": "школа", "test": "3"}, color=ButtonColor.PRIMARY) +EGE_KB.add_text_button(text="online", payload={"q": "online", "test": "3"}, color=ButtonColor.PRIMARY) +EGE_KB.add_text_button(text="репетитор", payload={"q": "репетитор", "test": "3"}, color=ButtonColor.PRIMARY) +EGE_KB.add_row() +EGE_KB.add_text_button(text="Сам", payload={"q": "Сам", "test": "3"}, color=ButtonColor.PRIMARY) +EGE_KB.add_text_button(text="wtf?", payload={"q": "wtf", "test": "3"}, color=ButtonColor.PRIMARY) + +# last +LAST_MESSAGE = "Спасибо, что прошел тест!" +LAST_MESSAGE_KB = Keyboard() +LAST_MESSAGE_KB.add_text_button(text="Вернуться на главную", payload={}, color=ButtonColor.POSITIVE) + + +questions = [ + (WHAT_ENGINEER_ARE_YOU, WHAT_ENGINEER_ARE_YOU_KB), + (PROG_LANG, PROG_LANG_KB), + (FAV_THEME, FAV_THEME_KB), + (EGE, EGE_KB), +] diff --git a/main.py b/main.py new file mode 100644 index 0000000..2dff82c --- /dev/null +++ b/main.py @@ -0,0 +1,26 @@ +import logging + +from vkwave.bots import SimpleLongPollBot + +from blueprints import ( + menu_router, test_router, +) +from config import Config +from middlewares import UserMiddleware + +logging.basicConfig(level="DEBUG") + +bot = SimpleLongPollBot(Config.TOKEN, group_id=Config.GROUP_ID) + +bot.middleware_manager.add_middleware(UserMiddleware()) + + +bot.dispatcher.add_router(test_router) +# bot.dispatcher.add_router(games_router) +# bot.dispatcher.add_router(coin_flip_router) +# bot.dispatcher.add_router(bonus_router) + +# регаем последним чтобы сначала проверялись все остальные команды +bot.dispatcher.add_router(menu_router) + +bot.run_forever() \ No newline at end of file diff --git a/middlewares/__init__.py b/middlewares/__init__.py new file mode 100644 index 0000000..95cf97d --- /dev/null +++ b/middlewares/__init__.py @@ -0,0 +1 @@ +from .user_data_middleware import UserMiddleware \ No newline at end of file diff --git a/middlewares/user_data_middleware.py b/middlewares/user_data_middleware.py new file mode 100644 index 0000000..759b182 --- /dev/null +++ b/middlewares/user_data_middleware.py @@ -0,0 +1,24 @@ +import logging + +from vkwave.bots import BaseMiddleware, BotEvent, MiddlewareResult, SimpleBotEvent + +from db import DB, Candidate + + +class UserMiddleware(BaseMiddleware): + async def pre_process_event(self, event: BotEvent) -> MiddlewareResult: + db = DB() + botevent = SimpleBotEvent(event) + user_id = event.object.object.message.from_id + + user = db.get_user(user_id) + if not user: + user_info = await botevent.get_user() + logging.debug(f"Got user info: {user_info}") + user = Candidate(id=user_id, sex=user_info.sex, name=user_info.first_name, last_name=user_info.last_name) + db.add_candidate(user) + logging.debug(f"Created new user: {user}") + print(f"Found user: {user}") + + event["current_user"] = user + return MiddlewareResult(True) diff --git a/util/__init__.py b/util/__init__.py new file mode 100644 index 0000000..9079c19 --- /dev/null +++ b/util/__init__.py @@ -0,0 +1 @@ +from .singleton import Singleton \ No newline at end of file diff --git a/util/singleton.py b/util/singleton.py new file mode 100644 index 0000000..dda539a --- /dev/null +++ b/util/singleton.py @@ -0,0 +1,26 @@ +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + """ + Test: + >>> flag = False + >>> class A(metaclass=Singleton): + ... def __init__(self): + ... global flag + ... assert not flag + ... flag = True + ... + >>> class B(metaclass=Singleton): pass + ... + >>> a = A();b = B();a1 = A();b1 = B() + >>> id(a) == id(a1) and id(b) == id(b1) and id(a) != id(b) + True + + :param args: + :param kwargs: + :return: + """ + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] \ No newline at end of file