diff --git a/.gitignore b/.gitignore index 4f68177..7351200 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,113 @@ +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -8,7 +118,7 @@ __pycache__/ # Distribution / packaging .Python -# build/ +build/ develop-eggs/ dist/ downloads/ @@ -46,8 +156,10 @@ htmlcov/ nosetests.xml coverage.xml *.cover +*.py,cover .hypothesis/ .pytest_cache/ +cover/ # Translations *.mo @@ -57,6 +169,7 @@ coverage.xml *.log local_settings.py db.sqlite3 +db.sqlite3-journal # Flask stuff: instance/ @@ -69,6 +182,7 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook @@ -79,10 +193,38 @@ profile_default/ ipython_config.py # pyenv -.python-version +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version -# celery beat schedule file +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid # SageMath parsed files *.sage.py @@ -114,11 +256,34 @@ dmypy.json # Pyre type checker .pyre/ -# IDE -.idea -.vscode +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ -# Custom -rpmtools -local -.DS_Store +### venv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..d9a0ab1 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/postgres + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..2db4903 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,46 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/kts_project_template.iml b/.idea/kts_project_template.iml new file mode 100644 index 0000000..5195124 --- /dev/null +++ b/.idea/kts_project_template.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..f0e9982 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..24c3a8c --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/READ_INSTRUCTION.txt b/READ_INSTRUCTION.txt new file mode 100644 index 0000000..8fafab8 --- /dev/null +++ b/READ_INSTRUCTION.txt @@ -0,0 +1,13 @@ +Текущие функции такие: +1) Регистрация! - регистрирует всех пользователей в чате +2) Загрузить фотографии! - человек пишет данную фун-ию и затем присылает фотографии +3) Начать игру! - начинается игра +3) Остановить игру! - игра останавливается, выводятся оставшиеся пользователи +4) Последняя игра! - выводится победитель из последней игры, если он есть + +Механизм игры такой: +РЕЖИМ: ВСЕ ПРОТИВ ВСЕХ! - все сражаются против друг друга, пока не останется 1 или 0 пользователей. В случае если остался 1 - выводится победитель, +в случае, если никто не остался - выводится соответствующее сообщение + + + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..46e74ba --- /dev/null +++ b/alembic.ini @@ -0,0 +1,103 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql+asyncpg://kts_user:kts_pass@localhost:5432/kts + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..3def7dd --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,91 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import AsyncEngine +from app.store.models.model import ParticipantsModel + +from alembic import context + +from app.store.database.sqlalchemy_base import db + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = AsyncEngine( + engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ) + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/1d6d7e9045d3_participants.py b/alembic/versions/1d6d7e9045d3_participants.py new file mode 100644 index 0000000..c61490e --- /dev/null +++ b/alembic/versions/1d6d7e9045d3_participants.py @@ -0,0 +1,37 @@ +"""Participants + +Revision ID: 1d6d7e9045d3 +Revises: +Create Date: 2023-02-27 20:20:14.025321 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1d6d7e9045d3' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('participants', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('wins', sa.BigInteger(), nullable=True), + sa.Column('chat_id', sa.BigInteger(), nullable=False), + sa.Column('owner_id', sa.BigInteger(), nullable=True), + sa.Column('photo_id', sa.BigInteger(), nullable=True), + sa.Column('access_key', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('participants') + # ### end Alembic commands ### diff --git a/kts_backend/users/__init__.py b/app/__init__.py similarity index 100% rename from kts_backend/users/__init__.py rename to app/__init__.py diff --git a/kts_backend/web/__init__.py b/app/base/__init__.py similarity index 100% rename from kts_backend/web/__init__.py rename to app/base/__init__.py diff --git a/app/base/base_accessor.py b/app/base/base_accessor.py new file mode 100644 index 0000000..aa64bb5 --- /dev/null +++ b/app/base/base_accessor.py @@ -0,0 +1,19 @@ +import typing +from logging import getLogger + +if typing.TYPE_CHECKING: + from app.web.app import Application + + +class BaseAccessor: + def __init__(self, app: "Application", *args, **kwargs): + self.app = app + self.logger = getLogger("accessor") + app.on_startup.append(self.connect) + app.on_cleanup.append(self.disconnect) + + async def connect(self, app: "Application"): + return + + async def disconnect(self, app: "Application"): + return diff --git a/app/store/__init__.py b/app/store/__init__.py new file mode 100644 index 0000000..97b1fb3 --- /dev/null +++ b/app/store/__init__.py @@ -0,0 +1,22 @@ +import typing + +from app.store.database.database import Database + +if typing.TYPE_CHECKING: + from app.web.app import Application + + +class Store: + def __init__(self, app: "Application"): + from app.store.bot.manager import BotManager + from app.store.vk_api.accessor import VkApiAccessor + + self.vk_api = VkApiAccessor(app) + self.bots_manager = BotManager(app) + + +def setup_store(app: "Application"): + app.database = Database(app) + app.on_startup.append(app.database.connect) + app.on_cleanup.append(app.database.disconnect) + app.store = Store(app) diff --git a/app/store/bot/__init__.py b/app/store/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/store/bot/dataclassess.py b/app/store/bot/dataclassess.py new file mode 100644 index 0000000..61f6abd --- /dev/null +++ b/app/store/bot/dataclassess.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass +class UpdateObject: + id: int + user_id: int + body: str + + +@dataclass +class Update: + type: str + object: UpdateObject + + +@dataclass +class Message: + user_id: int + text: str diff --git a/app/store/bot/manager.py b/app/store/bot/manager.py new file mode 100644 index 0000000..c85b2b2 --- /dev/null +++ b/app/store/bot/manager.py @@ -0,0 +1,332 @@ +import typing +from logging import getLogger +from time import sleep, time +from random import choice +from sqlalchemy.sql import select, update as refresh + +from app.store.bot.services import make_grid, check_winner +from app.store.vk_api.dataclasses import Message, Update, Attachment, UpdateObject +from app.web.app import app +from app.store.models.model import ParticipantsModel + +if typing.TYPE_CHECKING: + from app.web.app import Application + + +class SM: + def __init__(self): + self.state_photo = False + self.state_in_game = False + self.state_wait_votes = False + self.users = None + self.new_pair = None + self.voters_dict = {} + self.voters = [] + self.state_send_photo = False + self.amount_users = None + self.last_winner = None + + def reset_values(self): + self.state_photo = False + self.state_in_game = False + self.state_wait_votes = False + self.users = None + self.new_pair = None + self.voters_dict = {} + self.voters = [] + self.state_send_photo = False + self.amount_users = None + + +class BotManager: + def __init__(self, app: "Application"): + self.app = app + self.bot = None + self.logger = getLogger("handler") + self.active_chats = {} + self.time_end = {} + self.storage = {} + + async def handle_updates(self, updates: list[Update]): + for i in self.storage.keys(): + if time() - self.storage[i][0] > 30 and self.storage[i][1]: + updates.append( + Update( + type="time_out", + object=UpdateObject( + chat_id=i, + id=-1, + body="time_out", + ), + ) + ) + for update in updates: + if update.object.chat_id not in self.active_chats.keys(): + temp = SM() + self.active_chats[update.object.chat_id] = temp + this_chat = self.active_chats[update.object.chat_id] + else: + this_chat = self.active_chats[update.object.chat_id] + if update.object.body == "Регистрация!": + await self.command_registery(update) + if update.object.body == "Загрузить фотографии!" or this_chat.state_photo: + if not this_chat.state_in_game: + this_chat.state_photo = True + await self.command_download_photo(update, this_chat) + else: + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"Нельзя загружать фотографии во время игры!", + ) + ) + if update.object.body == "Начать игру!": + if not this_chat.state_in_game: + this_chat.reset_values() + await self.command_start_game(update, this_chat) + else: + await self.app.store.vk_api.send_message( + Message(chat_id=update.object.chat_id, text=f"Игра уже идет!") + ) + if update.object.body == "Остановить игру!": + if this_chat.state_in_game: + await self.command_stop_game(this_chat, update) + else: + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"Игровая сессия не запущена!", + ) + ) + if update.object.body == "Последняя игра!": + if not this_chat.state_in_game: + if this_chat.last_winner is not None: + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"Последний победитель: {this_chat.last_winner}", + ) + ) + else: + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, text="Игр еще не было!" + ) + ) + else: + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text="Данная команда недоступна во время игры!", + ) + ) + if this_chat.state_send_photo: + await self.command_send_photo(update, this_chat) + if this_chat.state_wait_votes: + await self.command_write_answers(update, this_chat) + if this_chat.state_in_game and ( + (not this_chat.state_wait_votes) + or time() - self.storage[update.object.chat_id][0] > 30 + ): + await self.command_send_preresult(update, this_chat) + if self.check_users(this_chat): + if len(this_chat.users) == 1: + this_chat.last_winner = this_chat.users[-1][0] + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"Победил {this_chat.users[0][0]}!", + ) + ) + else: + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, text=f"Никто не победил!" + ) + ) + elif len(this_chat.users) > 1: + await self.command_send_photo(update, this_chat) + this_chat.state_send_photo = False + + async def command_registery(self, update): + await self.app.database.connect() + async with self.app.database.session.begin() as session: + result = await app.store.vk_api.make_userlist(update.object.chat_id) + for k, v in result: + users_exists_select = select( + ParticipantsModel.__table__.c.chat_id, + ParticipantsModel.__table__.c.name, + ).where( + ParticipantsModel.__table__.columns.chat_id + == update.object.chat_id, + ParticipantsModel.__table__.c.name == k, + ) + result = await session.execute(users_exists_select) + if not ((update.object.chat_id, k) in result.fetchall()): + new_user = ParticipantsModel( + name=k, + wins=0, + chat_id=update.object.chat_id, + owner_id=v, + photo_id=None, + access_key=None, + ) + session.add(new_user) + await session.commit() + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, text="Регистрация прошла успешно!" + ) + ) + + def check_users(self, this_chat): + if len(this_chat.users) <= 1: + this_chat.state_send_photo = False + this_chat.state_wait_votes = False + this_chat.state_in_game = False + return 1 + return 0 + + async def command_download_photo(self, update, this_chat): + if hasattr(update.object, "type") and update.object.type == "photo": + await self.app.database.connect() + async with self.app.database.session.begin() as session: + users_add_photos = ( + refresh(ParticipantsModel.__table__) + .where( + ParticipantsModel.__table__.c.owner_id + == update.object.owner_id, + ParticipantsModel.__table__.c.chat_id == update.object.chat_id, + ) + .values( + photo_id=update.object.photo_id, + access_key=update.object.access_key, + ) + ) + await session.execute(users_add_photos) + await session.commit() + this_chat.state_photo = False + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text="Фотографии успешно загружены!", + ) + ) + + async def command_start_game(self, update, this_chat): + this_chat.users = await app.store.vk_api.proccess_start_game( + update.object.chat_id + ) + this_chat.amount_users = len(this_chat.users) + if len(this_chat.users) == 0: + await self.app.store.vk_api.send_message( + Message(chat_id=update.object.chat_id, text="Вы не прошли регистрацию!") + ) + else: + for i in range(3, 0, -1): + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"Игра начинается через {i}.", + ) + ) + sleep(1) + await self.app.store.vk_api.send_message( + Message(chat_id=update.object.chat_id, text=f"Поехали!") + ) + this_chat.state_send_photo = True + + async def command_send_photo(self, update, this_chat): + this_chat.new_pair = make_grid(this_chat.users) + attach_pair = [i[1:] for i in this_chat.new_pair] + await self.app.store.vk_api.send_photo( + Attachment( + chat_id=update.object.chat_id, attachment=attach_pair, text="Выбирай!" + ) + ) + this_chat.state_in_game = True + ( + this_chat.voters_dict[this_chat.new_pair[0]], + this_chat.voters_dict[this_chat.new_pair[1]], + ) = (0, 0) + this_chat.state_wait_votes = True + self.storage[update.object.chat_id] = [time(), this_chat.state_wait_votes] + + async def command_write_answers(self, update, this_chat): + if update.object.id not in this_chat.voters: + this_chat.state_send_photo = False + if update.object.body == "1": + this_chat.voters_dict[this_chat.new_pair[0]] += 1 + this_chat.voters.append(update.object.id) + if len(this_chat.voters) == this_chat.amount_users: + this_chat.state_wait_votes = False + self.storage[update.object.chat_id][1] = this_chat.state_wait_votes + this_chat.voters = [] + elif update.object.body == "2": + this_chat.voters_dict[this_chat.new_pair[1]] += 1 + this_chat.voters.append(update.object.id) + if len(this_chat.voters) == this_chat.amount_users: + this_chat.state_wait_votes = False + self.storage[update.object.chat_id][1] = this_chat.state_wait_votes + this_chat.voters = [] + elif update.object.id == -1: + this_chat.state_wait_votes = False + self.storage[update.object.chat_id][1] = this_chat.state_wait_votes + this_chat.voters = [] + elif update.object.id in this_chat.voters and update.object.body in ("1", "2"): + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, text=f"Вы уже отдали свой голос!" + ) + ) + + async def command_send_preresult(self, update, this_chat): + check = check_winner(this_chat.voters_dict, this_chat.new_pair) + if check == 1: + this_chat.users.remove(this_chat.new_pair[1]) + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"И в текущем сражении победителем стал обладатель первой картинки", + ) + ) + elif check == 2: + this_chat.users.remove(this_chat.new_pair[0]) + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"И в текущем сражении победителем стал обладатель второй картинки", + ) + ) + elif not check: + this_chat.users.remove(this_chat.new_pair[0]) + this_chat.users.remove(this_chat.new_pair[1]) + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"Никто не победил - следовательно оба вылетают.", + ) + ) + elif update.object.id == -1: + this_chat.users.remove(choice(this_chat.new_pair)) + await self.app.store.vk_api.send_message( + Message( + chat_id=update.object.chat_id, + text=f"Никто не проголосовал, поэтому победитель определяется случайным образом.", + ) + ) + this_chat.state_send_photo = True + + async def command_stop_game(self, this_chat, update): + await self.app.store.vk_api.send_message( + Message(chat_id=update.object.chat_id, text="Оставшиеся пользователи:") + ) + for i in this_chat.users: + await self.app.store.vk_api.send_message( + Message(chat_id=update.object.chat_id, text=f"{i[0]}") + ) + this_chat.reset_values() + self.storage[update.object.chat_id][1] = False + await self.app.store.vk_api.send_message( + Message(chat_id=update.object.chat_id, text=f"Игра остановлена!") + ) diff --git a/app/store/bot/poller.py b/app/store/bot/poller.py new file mode 100644 index 0000000..94ba589 --- /dev/null +++ b/app/store/bot/poller.py @@ -0,0 +1,25 @@ +import asyncio +from asyncio import Task +from typing import Optional + +from app.store import Store + + +class Poller: + def __init__(self, store: Store): + self.store = store + self.is_running = False + self.poll_task: Optional[Task] = None + + async def start(self): + self.is_running = True + self.poll_task = asyncio.create_task(self.poll()) + + async def stop(self): + self.is_running = False + await self.poll_task + + async def poll(self): + while self.is_running: + updates = await self.store.vk_api.poll() + await self.store.bots_manager.handle_updates(updates) diff --git a/app/store/bot/services.py b/app/store/bot/services.py new file mode 100644 index 0000000..88399da --- /dev/null +++ b/app/store/bot/services.py @@ -0,0 +1,18 @@ +from random import choice + + +def make_grid(data_users: list): + participant_1 = None + participant_2 = None + while participant_1 == participant_2: + participant_1 = choice(data_users) + participant_2 = choice(data_users) + return [participant_1, participant_2] + + +def check_winner(game: dict, pair: list): + if game[pair[0]] > game[pair[1]]: + return 1 + if game[pair[0]] < game[pair[1]]: + return 2 + return 0 diff --git a/app/store/database/__init__.py b/app/store/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/store/database/database.py b/app/store/database/database.py new file mode 100644 index 0000000..8161d11 --- /dev/null +++ b/app/store/database/database.py @@ -0,0 +1,27 @@ +from typing import Optional, TYPE_CHECKING, Any +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from app.store.database.sqlalchemy_base import db + +if TYPE_CHECKING: + from app.web.app import Application + + +class Database: + def __init__(self, app: "Application"): + self.app = app + self._engine: Optional[AsyncEngine] = None + self._db: Optional[declarative_base] = None + self.session: Optional[AsyncSession] = None + + async def connect(self, *_: list, **__: dict) -> None: + self._db = db + self._engine = create_async_engine("postgresql+asyncpg://kts_user:kts_pass@localhost:5432/kts", echo=True) + self.session = sessionmaker(self._engine, expire_on_commit=False, future=True, class_=AsyncSession) + + + + async def disconnect(self, *_: list, **__: dict) -> None: + if self._engine: + await self._engine.dispose() \ No newline at end of file diff --git a/app/store/database/sqlalchemy_base.py b/app/store/database/sqlalchemy_base.py new file mode 100644 index 0000000..7e8b0a8 --- /dev/null +++ b/app/store/database/sqlalchemy_base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +db = declarative_base() diff --git a/app/store/models/__init__.py b/app/store/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/store/models/model.py b/app/store/models/model.py new file mode 100644 index 0000000..1996d61 --- /dev/null +++ b/app/store/models/model.py @@ -0,0 +1,18 @@ +from app.store.database.sqlalchemy_base import db + +from sqlalchemy import ( + Column, + Text, + BigInteger, +) + + +class ParticipantsModel(db): + __tablename__ = "participants" + id = Column(BigInteger, primary_key=True) + name = Column(Text, nullable=False) + wins = Column(BigInteger) + chat_id = Column(BigInteger, nullable=False) + owner_id = Column(BigInteger) + photo_id = Column(BigInteger) + access_key = Column(Text) diff --git a/app/store/vk_api/__init__.py b/app/store/vk_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/store/vk_api/accessor.py b/app/store/vk_api/accessor.py new file mode 100644 index 0000000..7b840cd --- /dev/null +++ b/app/store/vk_api/accessor.py @@ -0,0 +1,200 @@ +import random +import typing +from typing import Optional +from aiohttp import TCPConnector +from sqlalchemy import select +from aiohttp.client import ClientSession +from app.store.models.model import ParticipantsModel +from app.base.base_accessor import BaseAccessor +from app.store.vk_api.dataclasses import ( + Message, + Update, + UpdateObject, + UpdatePhoto, + Attachment, +) +from app.store.vk_api.poller import Poller + +if typing.TYPE_CHECKING: + from app.web.app import Application + +API_PATH = "https://api.vk.com/method/" + + +class VkApiAccessor(BaseAccessor): + def __init__(self, app: "Application", *args, **kwargs): + super().__init__(app, *args, **kwargs) + self.session: Optional[ClientSession] = None + self.key: Optional[str] = None + self.server: Optional[str] = None + self.poller: Optional[Poller] = None + self.ts: Optional[int] = None + + async def connect(self, app: "Application"): + self.session = ClientSession(connector=TCPConnector(verify_ssl=False)) + try: + await self._get_long_poll_service() + except Exception as e: + self.logger.error("Exception", exc_info=e) + self.poller = Poller(app.store) + self.logger.info("start polling") + await self.poller.start() + + async def disconnect(self, app: "Application"): + if self.session: + await self.session.close() + if self.poller: + await self.poller.stop() + + @staticmethod + def _build_query(host: str, method: str, params: dict) -> str: + url = host + method + "?" + if "v" not in params: + params["v"] = "5.132" + url += "&".join([f"{k}={v}" for k, v in params.items()]) + print(url) + return url + + async def _get_long_poll_service(self): + async with self.session.get( + self._build_query( + host=API_PATH, + method="groups.getLongPollServer", + params={ + "group_id": self.app.config.bot.group_id, + "access_token": self.app.config.bot.token, + }, + ) + ) as resp: + data = (await resp.json())["response"] + + self.logger.info(data) + self.key = data["key"] + self.server = data["server"] + self.ts = data["ts"] + self.logger.info(self.server) + + async def poll(self): + async with self.session.get( + self._build_query( + host=self.server, + method="", + params={ + "act": "a_check", + "key": self.key, + "ts": self.ts, + "wait": 1, + }, + ) + ) as resp: + data = await resp.json() + self.logger.info(data) + self.ts = data["ts"] + raw_updates = data.get("updates", []) + updates = [] + for update in raw_updates: + updt = update["object"]["message"]["attachments"] + if len(updt) != 0: + for i in updt: + if i["type"] == "photo": + updates.append( + Update( + type=update["type"], + object=UpdatePhoto( + chat_id=update["object"]["message"]["peer_id"], + id=update["object"]["message"]["id"], + body=update["object"]["message"]["text"], + type=i["type"], + owner_id=i["photo"]["owner_id"], + photo_id=i["photo"]["id"], + access_key=i["photo"]["access_key"], + ), + ) + ) + else: + updates.append( + Update( + type=update["type"], + object=UpdateObject( + chat_id=update["object"]["message"]["peer_id"], + id=update["object"]["message"]["from_id"], + body=update["object"]["message"]["text"], + ), + ) + ) + + await self.app.store.bots_manager.handle_updates(updates) + + async def send_message(self, message: Message) -> None: + async with self.session.get( + self._build_query( + API_PATH, + "messages.send", + params={ + "random_id": random.randint(1, 2**32), + "peer_id": message.chat_id, + "message": message.text, + "access_token": self.app.config.bot.token, + }, + ) + ) as resp: + data = await resp.json() + self.logger.info(data) + + @staticmethod + def _build_attachment(attach_mass: list[str]): + spisok = [] + for i in attach_mass: + stroka = f"photo{i[0]}_{i[1]}_{i[2]}" + spisok.append(stroka) + return ",".join(spisok) + + async def send_photo(self, attachment: Attachment) -> None: + attachments = self._build_attachment(attachment.attachment) + print(attachments) + async with self.session.get( + self._build_query( + API_PATH, + "messages.send", + params={ + "random_id": random.randint(1, 2**32), + "peer_id": attachment.chat_id, + "attachment": attachments, + "message": attachment.text, + "access_token": self.app.config.bot.token, + }, + ) + ) as resp: + data = await resp.json() + self.logger.info(data) + + async def make_userlist(self, chat_id): + async with self.session.get( + self._build_query( + API_PATH, + "messages.getConversationMembers", + params={ + "peer_id": chat_id, + "access_token": self.app.config.bot.token, + }, + ) + ) as resp: + data = await resp.json() + participants = [] + for i in range(data["response"]["count"] - 1): + full_name = f'@{data["response"]["profiles"][i]["screen_name"]}' + id_profile = data["response"]["profiles"][i]["id"] + participants.append((full_name, id_profile)) + return participants + + async def proccess_start_game(self, chat_id): + await self.app.database.connect() + async with self.app.database.session.begin() as session: + users_exists_select = select( + ParticipantsModel.__table__.c.name, + ParticipantsModel.__table__.c.owner_id, + ParticipantsModel.__table__.c.photo_id, + ParticipantsModel.__table__.c.access_key, + ).where(ParticipantsModel.__table__.c.chat_id == chat_id) + result = await session.execute(users_exists_select) + return result.fetchall() diff --git a/app/store/vk_api/dataclasses.py b/app/store/vk_api/dataclasses.py new file mode 100644 index 0000000..a6f544f --- /dev/null +++ b/app/store/vk_api/dataclasses.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass + + +@dataclass +class UpdateObject: + chat_id: int + id: int + body: str + + +@dataclass +class UpdatePhoto(UpdateObject): + type: str + owner_id: int + photo_id: int + access_key: str + + +@dataclass +class Update: + type: str + object: UpdateObject | UpdatePhoto + + +@dataclass +class Message: + chat_id: int + text: str + + +@dataclass +class Attachment(Message): + attachment: list[str] + + +@dataclass +class MessageAttachment: + pass diff --git a/app/store/vk_api/poller.py b/app/store/vk_api/poller.py new file mode 100644 index 0000000..c97f3c1 --- /dev/null +++ b/app/store/vk_api/poller.py @@ -0,0 +1,26 @@ +import asyncio +from asyncio import Task +from typing import Optional +from app.store import Store + + +class Poller: + def __init__(self, store: Store): + self.store = store + self.is_running = False + self.poll_task: Optional[Task] = None + + async def start(self): + self.is_running = True + self.poll_task = asyncio.create_task(self.poll()) + await asyncio.gather(self.poll_task) + + async def stop(self): + self.is_running = False + await self.poll_task + + async def poll(self): + while self.is_running: + updates = await self.store.vk_api.poll() + if not (updates is None): + await self.store.bots_manager.handle_updates(updates) diff --git a/app/web/__init__.py b/app/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/web/app.py b/app/web/app.py new file mode 100644 index 0000000..e6f5fb0 --- /dev/null +++ b/app/web/app.py @@ -0,0 +1,28 @@ +from typing import Optional + +from aiohttp.web import ( + Application as AiohttpApplication, + Request as AiohttpRequest, + View as AiohttpView, +) + +from app.store import Store, setup_store +from app.store.database.database import Database +from app.web.config import Config, setup_config +from app.web.logger import setup_logging + + +class Application(AiohttpApplication): + config: Optional[Config] = None + store: Optional[Store] = None + database: Optional[Database] = None + + +app = Application() + + +def setup_app(config_path: str) -> Application: + setup_logging(app) + setup_config(app, config_path) + setup_store(app) + return app diff --git a/app/web/config.py b/app/web/config.py new file mode 100644 index 0000000..a885702 --- /dev/null +++ b/app/web/config.py @@ -0,0 +1,30 @@ +import typing +from dataclasses import dataclass + +import yaml + +if typing.TYPE_CHECKING: + from app.web.app import Application + + +@dataclass +class BotConfig: + token: str + group_id: int + + +@dataclass +class Config: + bot: BotConfig = None + + +def setup_config(app: "Application", config_path: str): + with open(config_path, "r") as f: + raw_config = yaml.safe_load(f) + + app.config = Config( + bot=BotConfig( + token=raw_config["bot"]["token"], + group_id=raw_config["bot"]["group_id"], + ), + ) diff --git a/app/web/logger.py b/app/web/logger.py new file mode 100644 index 0000000..95e6bd8 --- /dev/null +++ b/app/web/logger.py @@ -0,0 +1,9 @@ +import logging +import typing + +if typing.TYPE_CHECKING: + from app.web.app import Application + + +def setup_logging(_: "Application") -> None: + logging.basicConfig(level=logging.INFO) diff --git a/kts_backend/web/mw.py b/app/web/mw.py similarity index 100% rename from kts_backend/web/mw.py rename to app/web/mw.py diff --git a/kts_backend/users/urls.py b/app/web/urls.py similarity index 68% rename from kts_backend/users/urls.py rename to app/web/urls.py index ba6dd07..b21877e 100644 --- a/kts_backend/users/urls.py +++ b/app/web/urls.py @@ -1,9 +1,10 @@ from aiohttp.web_app import Application from aiohttp_cors import CorsConfig - __all__ = ("register_urls",) def register_urls(application: Application, cors: CorsConfig): - pass + import app.users.urls + + app.users.urls.register_urls(application, cors) diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..2c86ac7 --- /dev/null +++ b/config.yml @@ -0,0 +1,6 @@ + +bot: + token: vk1.a.2QX30vzXv86Yge4oS5MeibmPBlT0gSFRkZ2_AUIIOTUjDxbJys6JL9b_1zuX-1QOs6njHGYz-j9UXXf4rxFkQFqOl5jRuNl5hAx2CAMS4Fcbep0eIU1gnfOot91wYbUkc1wiw6vl90T71gx0NzUVqRS3UEPTy_pQgqSfxcFXsOtykgeYDYJTTC03zzKmYcrr_qcAz_YauFYw0jWVS30FZA + group_id: 207946988 + + diff --git a/kts_backend/__init__.py b/kts_backend/__init__.py deleted file mode 100644 index 9f2c7c5..0000000 --- a/kts_backend/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - - -def read_version(): - current_dir = os.path.dirname(os.path.realpath(__file__)) - with open(os.path.join(current_dir, "..", "VERSION")) as f: - return f.read().strip() - - -__appname__ = "kts_backend" -__version__ = read_version() diff --git a/kts_backend/store/__init__.py b/kts_backend/store/__init__.py deleted file mode 100644 index b1d7c6a..0000000 --- a/kts_backend/store/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -class Store: - def __init__(self, *args, **kwargs): - from kts_backend.users.accessor import UserAccessor - - self.user = UserAccessor(self) diff --git a/kts_backend/users/accessor.py b/kts_backend/users/accessor.py deleted file mode 100644 index b65cb4d..0000000 --- a/kts_backend/users/accessor.py +++ /dev/null @@ -1,2 +0,0 @@ -class UserAccessor: - pass diff --git a/kts_backend/users/schema.py b/kts_backend/users/schema.py deleted file mode 100644 index 42163d1..0000000 --- a/kts_backend/users/schema.py +++ /dev/null @@ -1,5 +0,0 @@ -from marshmallow import Schema - - -class UserSchema(Schema): - pass diff --git a/kts_backend/users/views/__init__.py b/kts_backend/users/views/__init__.py deleted file mode 100644 index 0899133..0000000 --- a/kts_backend/users/views/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .get import * diff --git a/kts_backend/web/app.py b/kts_backend/web/app.py deleted file mode 100644 index 8cbab44..0000000 --- a/kts_backend/web/app.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Sequence, Callable - -from aiohttp.web import ( - Application as AiohttpApplication, - View as AiohttpView, - Request as AiohttpRequest, -) -from pyparsing import Optional - - -from kts_backend import __appname__, __version__ -from .urls import register_urls - - -__all__ = ("ApiApplication",) - - -class Application(AiohttpApplication): - config = None - store = None - database = None diff --git a/kts_backend/web/urls.py b/kts_backend/web/urls.py deleted file mode 100644 index 7f84462..0000000 --- a/kts_backend/web/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from aiohttp.web_app import Application -from aiohttp_cors import CorsConfig - -__all__ = ("register_urls",) - - -def register_urls(application: Application, cors: CorsConfig): - import kts_backend.users.urls - - kts_backend.users.urls.register_urls(application, cors) diff --git a/main.py b/main.py new file mode 100644 index 0000000..8f1f171 --- /dev/null +++ b/main.py @@ -0,0 +1,13 @@ +import os + +from app.web.app import setup_app +from aiohttp.web import run_app + +if __name__ == "__main__": + run_app( + setup_app( + config_path=os.path.join( + os.path.dirname(os.path.realpath(__file__)), "config.yml" + ) + ) + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 00c3664..c3ef3b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,39 @@ -aiohttp==3.8.1 -black==22.6.0 -pytest==7.1.2 +aiohttp==3.8.3 +aiohttp-apispec==2.2.3 +aiohttp-session==2.12.0 +aiosignal==1.3.1 +alembic==1.9.3 +apispec==3.3.2 +async-timeout==4.0.2 +asyncpg==0.27.0 +attrs==22.2.0 +cffi==1.15.1 +charset-normalizer==2.1.1 +coverage==7.1.0 +cryptography==39.0.1 +frozenlist==1.3.3 +greenlet==2.0.2 +idna==3.4 +iniconfig==2.0.0 +Jinja2==3.1.2 +Mako==1.2.4 +MarkupSafe==2.1.2 +multidict==6.0.4 +packaging==23.0 +pluggy==1.0.0 +pycparser==2.21 +pyparsing==3.0.9 +pytest==7.2.1 pytest-aiohttp==1.0.4 -pytest-asyncio==0.19.0 -pytest-env==0.6.2 +pytest-asyncio==0.20.3 +pytest-cov==4.0.0 +pytest-mock==3.10.0 +python-dateutil==2.8.2 +python-dotenv==0.21.1 +PyYAML==6.0 +simplejson==3.18.3 +six==1.16.0 +SQLAlchemy==2.0.3 +typing_extensions==4.4.0 +webargs==5.5.3 +yarl==1.8.2