commit 4280385d32eeab5a17d403655be5d435b9b89aec Author: krasi Date: Wed May 1 19:55:22 2024 +0300 Support-BOT diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..823720d --- /dev/null +++ b/.gitignore @@ -0,0 +1,132 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# 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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# .idea +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e1b601 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Dmitry Konstantinov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a71b4ea --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Бот техподдерки пользователя + +Пользователи пишут свои вопросы боту компании, бот пересылает эти сообщения в чат поддержки, сотрудники поддержки отвечают на эти сообщения через reply. Основной плюс - анонимизация сотрудников поддержки. + +Бот работает в режиме webhook, но может работать и в режиме polling + +Для обхода запрета на пересылку сообщения у пользователя, бот копирует содержимое и уже затем отправляет его в чат поддержки. + +По умолчанию бот отправляет сообщения в один чат поддержки с id, указанным в переменных окружения .env + +## Бот умеет + +- Пересылать сообщения, документы, аудио и видео от пользователя в группу к администраторам и обратно +- Выдавать информацию о пользователе из телеграма +- Выдавать месячный отчет и за указанный интервал даты по количеству обращений и общему числу сообщений и ответов +- Банить и разбанивать пользователей + +## Типы контента, которые может пересылать бот + +- Текстовые сообщения +- Фотографии +- Группы фотографий (пересылаются по одной) +- Видео +- Аудиозаписи +- Файлы + +## Разворачивание образа на личном или vps сервере + +### Настройка Nignx + +Предполагается, что есть готовый настроенный vps сервер с установленным nginx. + +1.Перейти в каталог nginx sites-available +``` +cd /etc/nginx/sites-available/ +``` +2.Создать файл с именем вашего домена +``` +nano domain.example.com +``` +3.Внутри написать +``` +server { + listen 80; + + server_name domain.example.com; + + location /telegram/ { + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:7772; + } +} +``` +server_name - ваш домен с подключенным ssl сертификатом (например, Let's Encrypt) 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..d48b7f1 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,99 @@ +import asyncio +from logging.config import fileConfig + +from dotenv import load_dotenv +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +from app.core.base import Base +from app.core.config import settings + +load_dotenv('.env') + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +config.set_main_option('sqlalchemy.url', settings.DATABASE_URL) + +# 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 = Base.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"}, + compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, + target_metadata=target_metadata, + compare_type=True) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + 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/3de147b35e89_fix_telegram_ids.py b/alembic/versions/3de147b35e89_fix_telegram_ids.py new file mode 100644 index 0000000..4cc2095 --- /dev/null +++ b/alembic/versions/3de147b35e89_fix_telegram_ids.py @@ -0,0 +1,42 @@ +"""fix telegram ids + +Revision ID: 3de147b35e89 +Revises: 559d3cc36e6e +Create Date: 2023-06-08 16:41:33.395065 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3de147b35e89' +down_revision = '559d3cc36e6e' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('message', 'telegram_user_id', + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=False) + op.alter_column('message', 'answer_to_user_id', + existing_type=sa.INTEGER(), + type_=sa.BigInteger(), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('message', 'answer_to_user_id', + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=True) + op.alter_column('message', 'telegram_user_id', + existing_type=sa.BigInteger(), + type_=sa.INTEGER(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/559d3cc36e6e_first_commit.py b/alembic/versions/559d3cc36e6e_first_commit.py new file mode 100644 index 0000000..44627ed --- /dev/null +++ b/alembic/versions/559d3cc36e6e_first_commit.py @@ -0,0 +1,53 @@ +"""first commit + +Revision ID: 559d3cc36e6e +Revises: +Create Date: 2023-04-10 13:09:57.880742 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '559d3cc36e6e' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('telegram_id', sa.BigInteger(), nullable=False), + sa.Column('telegram_username', sa.String(length=100), nullable=True), + sa.Column('is_banned', sa.Boolean(), nullable=True), + sa.Column('first_name', sa.String(length=100), nullable=True), + sa.Column('last_name', sa.String(length=100), nullable=True), + sa.Column('is_admin', sa.Boolean(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('telegram_id') + ) + op.create_table('message', + sa.Column('telegram_user_id', sa.Integer(), nullable=False), + sa.Column('answer_to_user_id', sa.Integer(), nullable=True), + sa.Column('text', sa.Text(), nullable=True), + sa.Column('attachments', sa.Boolean(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.ForeignKeyConstraint(['answer_to_user_id'], ['user.telegram_id'], ), + sa.ForeignKeyConstraint(['telegram_user_id'], ['user.telegram_id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('message') + op.drop_table('user') + # ### end Alembic commands ### diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/filter_media.py b/app/bot/filter_media.py new file mode 100644 index 0000000..d7f1d29 --- /dev/null +++ b/app/bot/filter_media.py @@ -0,0 +1,10 @@ +from aiogram.filters import BaseFilter +from aiogram.types import Message, ContentType + + +class SupportedMediaFilter(BaseFilter): + async def __call__(self, message: Message) -> bool: + return message.content_type in ( + ContentType.ANIMATION, ContentType.AUDIO, ContentType.DOCUMENT, + ContentType.PHOTO, ContentType.VIDEO, ContentType.VOICE + ) diff --git a/app/bot/get_reports.py b/app/bot/get_reports.py new file mode 100644 index 0000000..98cc71f --- /dev/null +++ b/app/bot/get_reports.py @@ -0,0 +1,48 @@ +from datetime import datetime +from dateutil.relativedelta import relativedelta +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crud.message import crud_message + + +async def get_report_from_db(session: AsyncSession, + from_date=None, + to_date=None): + """Получение отчета за интервал времени из базы данных. + :param session: Асинхронная сессия к БД + :type session: AsyncSession + :param from_date: Дата начала + :type from_date: str + :param to_date: Дата окончания + :type to_date: str + :return: Возвращает словарь с данными для отчета + :rtype: dict + """ + if not to_date: + to_date = datetime.now() + if not from_date: + from_date = to_date + relativedelta(months=-1) + messages = await crud_message.get_by_date_interval( + from_date, to_date, session + ) + users_senders = [] + answers_amount = 0 + questions_amount = 0 + + for mes in messages: + if (mes.telegram_user_id not in users_senders + and not mes.answer_to_user_id): + users_senders.append(mes.telegram_user_id) + if mes.answer_to_user_id: + answers_amount += 1 + else: + questions_amount += 1 + users_amount = len(users_senders) + report = { + 'from_date': from_date.strftime('%d.%m.%Y'), + 'to_date': to_date.strftime('%d.%m.%Y'), + 'users_amount': users_amount, + 'answers_amount': answers_amount, + 'questions_amount': questions_amount, + } + return report diff --git a/app/bot/handlers_commands.py b/app/bot/handlers_commands.py new file mode 100644 index 0000000..e05b321 --- /dev/null +++ b/app/bot/handlers_commands.py @@ -0,0 +1,195 @@ +from aiogram import Bot, F, Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import Command, CommandObject +from aiogram.types import Message + +from app.bot.get_reports import get_report_from_db +from app.bot.utils import get_user_name, check_input_date_correct, \ + stringdate_to_date, check_user_is_banned, \ + get_telegram_user_from_resend_message, parse_ban_command +from app.core.config import settings +from app.core.db import get_async_session +from app.crud.user import crud_user +from app.crud.message import crud_message + +router = Router() + + +@router.message(Command(commands=["start"])) +async def command_start(message: Message): + await message.answer(settings.START_MESSAGE) + session_generator = get_async_session() + session = await session_generator.__anext__() + db_user = await crud_user.get_or_create_user_by_tg_message(message, + session) + if check_user_is_banned(db_user): + return + + +@router.message(Command(commands="info"), + F.chat.id == int(settings.GROUP_ID), + F.reply_to_message) +async def get_user_info(message: Message, bot: Bot): + session_generator = get_async_session() + session = await session_generator.__anext__() + telegram_user = await get_telegram_user_from_resend_message(message, bot) + if not telegram_user: + return + messages_count = await crud_message.get_count_user_messages( + telegram_user.id, session + ) + ans_count = await crud_message.get_count_answers_to_user( + telegram_user.id, session + ) + username = f"@{telegram_user.username}" if telegram_user.username else "отсутствует" + await message.reply(text=f'Имя: {get_user_name(telegram_user)}\n' + f'Id: {telegram_user.id}\n' + f'username: {username}\n' + f'Сообщений от пользователя: {messages_count}\n' + f'Ответов пользователю: {ans_count}\n') + + +@router.message(Command(commands='report'), + F.chat.id == int(settings.GROUP_ID)) +async def get_report(message: Message, + bot: Bot, + command: CommandObject): + session_generator = get_async_session() + session = await session_generator.__anext__() + if command.args: + if not check_input_date_correct(command.args): + answer_text = 'Неверный формат даты' + await bot.send_message( + chat_id=int(settings.GROUP_ID), text=answer_text + ) + return + from_date, to_date = stringdate_to_date(command.args) + report = await get_report_from_db(session, from_date, to_date) + report['period'] = 'выбранный период' + else: + report = await get_report_from_db(session) + report['period'] = 'последний месяц' + + answer_text = (f"Отчет за {report['period']}, c {report['from_date']} до " + f"{report['to_date']}.\n" + f"Всего было получено {report['questions_amount']} " + f"сообщений от {report['users_amount']} клиентов.\n" + f"Количество ответов от администраторов: {report['answers_amount']}") + await bot.send_message(chat_id=int(settings.GROUP_ID), text=answer_text) + + +@router.message(Command(commands='ban'), + F.chat.id == int(settings.GROUP_ID)) +async def handler_ban_user(message: Message, + bot: Bot, + command: CommandObject): + if not command.args and not message.reply_to_message: + return await message.reply( + text='Команда некорректна. Укажите ID или ответьте на сообщение') + session_generator = get_async_session() + session = await session_generator.__anext__() + if command.args: + telegram_user_id = parse_ban_command(command) + if not telegram_user_id: + return await message.reply( + text='Невозможно извлечь id пользователя. ' + 'Нужно ввести id в формате "12345", либо ответить на ' + 'сообщение пользователя, которого хотите забанить') + try: + telegram_user = await bot.get_chat(telegram_user_id) + except TelegramBadRequest: + return await message.reply( + text='Пользователя с таким id не существует ') + + else: + telegram_user = await get_telegram_user_from_resend_message(message, bot) + if not telegram_user: + return + db_user = await crud_user.get_user_by_telegram_id(telegram_user.id, + session) + await crud_user.ban_user(db_user, session) + await message.reply(text=f'Пользователь {db_user.first_name} ' + f'{db_user.last_name} забанен.' + f'Чтобы разбанить, отправьте /unban\n' + f'Тикет: #id{db_user.telegram_id}') + + +@router.message(Command(commands='unban'), + F.chat.id == int(settings.GROUP_ID)) +async def handler_unban_user(message: Message, + bot: Bot, + command: CommandObject): + if not command.args and not message.reply_to_message: + return await message.reply( + text='Команда некорректна. Укажите ID или ответьте на сообщение') + session_generator = get_async_session() + session = await session_generator.__anext__() + if command.args: + telegram_user_id = parse_ban_command(command) + if not telegram_user_id: + return await message.reply( + text='Невозможно извлечь id пользователя. ' + 'Нужно ввести id в формате "12345", либо ответить на ' + 'сообщение пользователя, которого хотите забанить') + db_user = await crud_user.get_user_by_telegram_id(telegram_user_id, + session) + else: + telegram_user = await get_telegram_user_from_resend_message(message, bot) + if not telegram_user: + return + db_user = await crud_user.get_user_by_telegram_id(telegram_user.id, + session) + await crud_user.unban_user(db_user, session) + await message.reply(text=f'Пользователь с id ' + f'{db_user.first_name} {db_user.last_name} разбанен\n' + f'Тикет: #id{db_user.telegram_id}') + + +@router.message(Command(commands='banlist'), + F.chat.id == int(settings.GROUP_ID)) +async def handler_unban_user(message: Message, + bot: Bot): + session_generator = get_async_session() + session = await session_generator.__anext__() + banned_users = await crud_user.get_banned_users(session) + text = 'Список забанненых пользователей:\n' + for user in banned_users: + text += f'{user.telegram_id} - {user.first_name} {user.last_name}\n' + await message.reply(text=text) + + +@router.message(Command(commands='registeradmin'), + F.chat.id == int(settings.GROUP_ID)) +async def handle_register_admin(message: Message, + bot: Bot): + if not message.reply_to_message: + return message.reply(text="Введите команду как ответ на сообщение " + "пользователя") + session_generator = get_async_session() + session = await session_generator.__anext__() + db_user = await crud_user.get_or_create_user_by_tg_message( + message.reply_to_message, + session + ) + db_user = await crud_user.register_admin(db_user, session) + text = (f'Пользователь {db_user.first_name} {db_user.last_name} теперь ' + f'администратор') + await message.reply(text=text) + + +@router.message(Command(commands='deleteadmin'), + F.chat.id == int(settings.GROUP_ID)) +async def handle_remove_admin(message: Message, + bot: Bot): + if not message.reply_to_message: + return message.reply(text="Введите команду как ответ на сообщение " + "пользователя") + session_generator = get_async_session() + session = await session_generator.__anext__() + db_user = await crud_user.get_or_create_user_by_tg_message( + message.reply_to_message, + session + ) + db_user = await crud_user.remove_admin(db_user, session) + text = f'Администратор {db_user.first_name} {db_user.last_name} удален' + await message.reply(text=text) diff --git a/app/bot/handlers_messages.py b/app/bot/handlers_messages.py new file mode 100644 index 0000000..9c383ce --- /dev/null +++ b/app/bot/handlers_messages.py @@ -0,0 +1,101 @@ +from aiogram import Bot, F, Router +from aiogram.exceptions import TelegramForbiddenError +from aiogram.types import Message + +from app.bot.utils import extract_user_id, check_user_is_banned +from app.core.config import settings +from app.core.db import get_async_session +from app.crud.message import crud_message +from app.crud.user import crud_user +from filter_media import SupportedMediaFilter + +router = Router() + + +@router.message(F.chat.type == 'private', F.text) +async def send_message_to_group(message: Message, bot: Bot): + if message.text and len(message.text) > 4000: + return await message.reply(text='Пожалуйста, уменьшите размер ' + 'сообщения, чтобы оно было менее ' + '4000 символов') + await bot.send_message( + chat_id=settings.GROUP_ID, + text=( + f'{message.text}\n\n' + f'Тикет: #id{message.from_user.id}' + ), + parse_mode='HTML' + ) + session_generator = get_async_session() + session = await session_generator.__anext__() + db_user = await crud_user.get_or_create_user_by_tg_message(message, session) + if check_user_is_banned(db_user): + return + message_data = { + 'text': message.text, + 'telegram_user_id': message.from_user.id, + 'attachments': False, + } + + await crud_message.create(message_data, session) + + +@router.message(SupportedMediaFilter(), F.chat.type == 'private') +async def supported_media(message: Message): + if message.caption and len(message.caption) > 1000: + return await message.reply(text='Слишком длинное описание. Описание ' + 'не может быть больше 1000 символов') + await message.copy_to( + chat_id=settings.GROUP_ID, + caption=((message.caption or "") + + f"\n\n Тикет: #id{message.from_user.id}"), + parse_mode="HTML" + ) + session_generator = get_async_session() + session = await session_generator.__anext__() + db_user = await crud_user.get_or_create_user_by_tg_message(message, session) + if check_user_is_banned(db_user): + return + message_data = { + 'telegram_user_id': message.from_user.id, + 'attachments': True, + } + if message.caption: + message_data['text'] = message.caption + await crud_message.create(message_data, session) + + +@router.message(F.chat.id == int(settings.GROUP_ID), + F.reply_to_message) +async def send_message_answer(message: Message, + bot: Bot): + if not message.reply_to_message.from_user.is_bot: + return + try: + chat_id = extract_user_id(message.reply_to_message) + except ValueError as err: + return await message.reply(text=f'Не могу извлечь Id. Возможно он ' + f'некорректный. Текст ошибки:\n' + f'{str(err)}') + try: + await message.copy_to(chat_id) + except TelegramForbiddenError: + await message.reply(text='Сообщение не доставлено. Бот был ' + 'заблокировн пользователем, ' + 'либо пользователь удален') + session_generator = get_async_session() + session = await session_generator.__anext__() + db_user = await crud_user.get_or_create_user_by_tg_message(message, session) + await crud_user.register_admin(db_user, session) + message_data = { + 'telegram_user_id': message.from_user.id, + 'answer_to_user_id': chat_id, + } + if message.text: + message_data['text'] = message.text + else: + if message.caption: + message_data['text'] = message.caption + message_data['attachments'] = True + + await crud_message.create(message_data, session) diff --git a/app/bot/main.py b/app/bot/main.py new file mode 100644 index 0000000..b7f6cf6 --- /dev/null +++ b/app/bot/main.py @@ -0,0 +1,57 @@ +import asyncio +import logging +import sys + +from aiogram import Bot, Dispatcher +from aiogram.webhook.aiohttp_server import SimpleRequestHandler +from aiohttp import web + +from app.core.config import settings +from handlers_commands import router as commands_router +from handlers_messages import router as messages_router + + +async def main(): + logging.basicConfig(level=logging.INFO, stream=sys.stdout) + bot = Bot(token=settings.TELEGRAM_TOKEN, parse_mode="HTML") + dp = Dispatcher() + dp.include_router(commands_router) + dp.include_router(messages_router) + + try: + if not settings.WEBHOOK_DOMAIN: + await bot.delete_webhook() + await dp.start_polling( + bot, + allowed_updates=dp.resolve_used_update_types() + ) + else: + aiohttp_logger = logging.getLogger('aiohttp.access') + aiohttp_logger.setLevel(logging.DEBUG) + + await bot.set_webhook( + url=settings.WEBHOOK_DOMAIN + settings.WEBHOOK_PATH, + drop_pending_updates=True, + allowed_updates=dp.resolve_used_update_types() + ) + + app = web.Application() + SimpleRequestHandler( + dispatcher=dp, bot=bot + ).register(app, path=settings.WEBHOOK_PATH) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, + host=settings.APP_HOST, + port=settings.APP_PORT + ) + await site.start() + await asyncio.Event().wait() + except RuntimeError: + pass + finally: + await bot.session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/bot/utils.py b/app/bot/utils.py new file mode 100644 index 0000000..7274c4b --- /dev/null +++ b/app/bot/utils.py @@ -0,0 +1,88 @@ +import re +from datetime import datetime + +from aiogram import Bot +from aiogram.exceptions import TelegramAPIError +from aiogram.filters import CommandObject +from aiogram.types import Message, Chat +from dateutil.relativedelta import relativedelta +from app.schemas.user import UserFromDBScheme +from app.schemas.user import UserBaseScheme + +DATE_PATTERN = r'^(0?[1-9]|[12][0-9]|3[01]).(0?[1-9]|1[012]).((19|20)\d\d)$' + + +def extract_user_id(message: Message) -> int: + text = message.text if message.text else message.caption + if '#id' not in text: + return False + telegram_user_id = int(text.split(sep='#id')[-1]) + return telegram_user_id + + +def parse_ban_command(command: CommandObject) -> int: + telegram_user_id = command.args.strip() + try: + telegram_user_id = int(telegram_user_id) + except ValueError: + return False + return telegram_user_id + + +def get_user_name(chat: Chat): + """Получение полного имени пользователя из чата""" + if not chat.first_name: + return "" + if not chat.last_name: + return chat.first_name + return f"{chat.first_name} {chat.last_name}" + + +def check_input_date_correct(date_args): + """Проверка интервала дат на соотвествие паттерну""" + date_from, date_to = date_args.split() + pattern = re.compile(DATE_PATTERN) + if not (pattern.match(date_from) and pattern.match(date_to)): + return False + return True + + +def stringdate_to_date(date_args): + """Конвертация текстового интервала дат в формат datetime""" + from_date, to_date = date_args.split() + from_date = datetime.strptime(from_date, '%d.%m.%Y') + to_date = datetime.strptime(to_date, '%d.%m.%Y') + relativedelta(days=+1) + return from_date, to_date + + +def get_user_data(message: Message): + user_data = { + 'telegram_id': message.from_user.id, + 'telegram_username': message.from_user.username, + 'first_name': message.from_user.first_name, + 'last_name': message.from_user.last_name, + } + return user_data + + +def check_user_is_banned(user: UserBaseScheme): + return user.is_banned + + +def check_user_is_admin(user: UserFromDBScheme): + return user.is_admin + + +async def get_telegram_user_from_resend_message(message: Message, bot: Bot): + telegram_user_id = extract_user_id(message.reply_to_message) + if not telegram_user_id: + return await message.reply( + text='Невозможно найти пользователя с таким Id' + ) + try: + return await bot.get_chat(telegram_user_id) + except TelegramAPIError as err: + return await message.reply( + text=(f'Невозможно найти пользователя с таким Id. Текст ошибки:\n' + f'{err.message}') + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/base.py b/app/core/base.py new file mode 100644 index 0000000..60732eb --- /dev/null +++ b/app/core/base.py @@ -0,0 +1,3 @@ +"""Импорты класса Base и всех моделей для Alembic.""" +from app.core.db import Base # noqa +from app.models import User, Message # noqa diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..48ecceb --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,29 @@ +import os +from typing import Optional + +from pydantic import BaseSettings +from dotenv import load_dotenv + +load_dotenv() + + +class Settings(BaseSettings): + TELEGRAM_TOKEN: str + GROUP_ID: str + WEBHOOK_DOMAIN: Optional[str] + WEBHOOK_PATH: Optional[str] + APP_HOST: str + APP_PORT: int + DATABASE_URL: str + DB_HOST: str + DB_PORT: str + DB_USER: str = os.getenv('POSTGRES_USER') + DB_PASSWORD: str = os.getenv('POSTGRES_PASSWORD') + START_MESSAGE: str = os.getenv('START_MESSAGE') + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/app/core/db.py b/app/core/db.py new file mode 100644 index 0000000..4d73cc4 --- /dev/null +++ b/app/core/db.py @@ -0,0 +1,36 @@ +from sqlalchemy import Integer, TIMESTAMP, func +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import declared_attr, declarative_base, sessionmaker, \ + mapped_column + +from app.core.config import settings + + +class PreBase: + """Абстрактная модель для наследования""" + + @declared_attr + def __tablename__(cls): + return cls.__name__.lower() + + id = mapped_column(Integer, primary_key=True) + created_at = mapped_column(TIMESTAMP, + server_default=func.current_timestamp(), + nullable=False) + updated_at = mapped_column(TIMESTAMP, + server_default=func.current_timestamp(), + nullable=False, + onupdate=func.current_timestamp()) + + +Base = declarative_base(cls=PreBase) + +engine = create_async_engine(settings.DATABASE_URL) + +AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession) + + +async def get_async_session(): + """Генератор асинхронной сессии""" + async with AsyncSessionLocal() as async_session_gen: + yield async_session_gen diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/base.py b/app/crud/base.py new file mode 100644 index 0000000..4e92bcd --- /dev/null +++ b/app/crud/base.py @@ -0,0 +1,55 @@ +from fastapi.encoders import jsonable_encoder +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +class CRUDBase: + + def __init__(self, model): + self.model = model + + async def get( + self, + obj_id: int, + session: AsyncSession + ): + db_obj = await session.execute( + select(self.model).where( + self.model.db == obj_id + ) + ) + return db_obj.scalars().first() + + async def get_multi(self, session: AsyncSession): + db_objs = await session.execute(select(self.model)) + return db_objs.scalars().all() + + async def create(self, + obj_in, + session: AsyncSession): + obj_in_data = obj_in + db_obj = self.model(**obj_in_data) + session.add(db_obj) + await session.commit() + await session.refresh(db_obj) + return db_obj + + + async def update( + self, + db_obj, + obj_in, + session: AsyncSession + ): + obj_data = jsonable_encoder(db_obj) + update_data = obj_in.dict(exclude_unsets=True) + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + session.add(db_obj) + await session.commit() + await session.refresh(db_obj) + return db_obj + + + diff --git a/app/crud/message.py b/app/crud/message.py new file mode 100644 index 0000000..815ef5b --- /dev/null +++ b/app/crud/message.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crud.base import CRUDBase +from app.models import Message + + +class CRUDMessage(CRUDBase): + async def get_by_date_interval( + self, + from_date: datetime, + to_date: datetime, + session: AsyncSession + ): + query = select(Message).where(and_(Message.created_at >= from_date, + Message.created_at <= to_date)) + messages = await session.execute(query) + messages = messages.scalars().all() + return messages + + async def get_count_user_messages( + self, + telegram_id: int, + session: AsyncSession + ): + stmt = select(func.count()).select_from( + select(Message).where(Message.telegram_user_id == telegram_id) + ) + mes_count = await session.execute(stmt) + return mes_count.scalars().one() + + async def get_count_answers_to_user( + self, + telegram_id: int, + session: AsyncSession + ): + stmt = select(func.count()).select_from( + select(Message).where(Message.answer_to_user_id == telegram_id) + ) + answers_count = await session.execute(stmt) + return answers_count.scalars().one() + + +crud_message = CRUDMessage(Message) diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..72c2b02 --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,110 @@ +import asyncio +from typing import Optional + +from fastapi.encoders import jsonable_encoder +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from aiogram.types import Message as TelegramMessage + +from app.bot.utils import get_user_data +from app.crud.base import CRUDBase +from app.models import User +from app.schemas.user import UserBaseScheme + + +class CRUDUser(CRUDBase): + async def get_user_by_telegram_id( + self, + telegram_id: int, + session: AsyncSession + ) -> Optional[User]: + user = await session.execute( + select(User).where(User.telegram_id == telegram_id)) + return user.scalars().first() + + async def update( + self, + db_obj: User, + obj_in: UserBaseScheme, + session: AsyncSession + ) -> User: + obj_data = jsonable_encoder(db_obj) + update_data = obj_in.dict(exclude_unset=True) + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + session.add(db_obj) + await session.commit() + await session.refresh(db_obj) + return db_obj + + async def update_with_db_obj( + self, + updating_db_obj: User, + session: AsyncSession + ) -> User: + session.add(updating_db_obj) + await session.commit() + await session.refresh(updating_db_obj) + return updating_db_obj + + async def get_banned_users( + self, + session: AsyncSession + ): + banned_users = await session.execute( + select(User).where(User.is_banned) + ) + return banned_users.scalars().all() + + async def register_admin(self, + user: User, + session: AsyncSession) -> User: + if user.is_admin: + return user + user.is_admin = True + updated_user = await self.update_with_db_obj(user, session) + return updated_user + + async def remove_admin(self, + user: User, + session: AsyncSession) -> User: + if not user.is_admin: + return user + user.is_admin = False + updated_user = await self.update_with_db_obj(user, session) + return updated_user + + async def ban_user(self, + user: User, + session: AsyncSession) -> User: + if user.is_banned: + return user + user.is_banned = True + updated_user = await self.update_with_db_obj(user, session) + return updated_user + + async def unban_user(self, + user: User, + session: AsyncSession) -> User: + if not user.is_banned: + return user + user.is_banned = False + updated_user = await self.update_with_db_obj(user, session) + return updated_user + + async def get_or_create_user_by_tg_message( + self, + message: TelegramMessage, + session: AsyncSession + ) -> Optional[User]: + telegram_id = message.from_user.id + user = await self.get_user_by_telegram_id(telegram_id, session) + if user: + return user + user_data = get_user_data(message) + new_user = await self.create(user_data, session) + return new_user + + +crud_user = CRUDUser(User) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..a12278b --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,2 @@ +from .user import User +from .message import Message diff --git a/app/models/message.py b/app/models/message.py new file mode 100644 index 0000000..ec5488a --- /dev/null +++ b/app/models/message.py @@ -0,0 +1,19 @@ +from sqlalchemy import Boolean, ForeignKey, Text, BigInteger +from sqlalchemy.orm import mapped_column, relationship + +from app.core.db import Base + + +class Message(Base): + """Модель сообщений""" + telegram_user_id = mapped_column(BigInteger, ForeignKey( + 'user.telegram_id'), nullable=False) + answer_to_user_id = mapped_column(BigInteger, ForeignKey( + 'user.telegram_id'), nullable=True) + text = mapped_column(Text, nullable=True) + attachments = mapped_column(Boolean, default=False) + telegram_user = relationship('User', backref='messages', foreign_keys=[ + telegram_user_id], lazy='subquery') + answer_to_user = relationship('User', backref='answers', foreign_keys=[ + answer_to_user_id]) + diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..031fc4d --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,17 @@ +from sqlalchemy import BigInteger, String, Boolean +from sqlalchemy.orm import mapped_column, relationship + +from app.core.db import Base + + +class User(Base): + """Модель пользователя телеграм""" + + telegram_id = mapped_column(BigInteger, unique=True, nullable=False) + telegram_username = mapped_column(String(100), nullable=True) + is_banned = mapped_column(Boolean, default=False) + first_name = mapped_column(String(100), nullable=True) + last_name = mapped_column(String(100), nullable=True) + is_admin = mapped_column(Boolean, default=False) + # messages = relationship('Message', backref='telegram_user', + # foreign_keys=[telegram_id]) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/message.py b/app/schemas/message.py new file mode 100644 index 0000000..ca071ad --- /dev/null +++ b/app/schemas/message.py @@ -0,0 +1,24 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +class MessageBaseScheme(BaseModel): + telegram_user_id: int + text: Optional[str] + attachments: Optional[bool] + answer_to_user: Optional[int] + + +class MessageCreateScheme(MessageBaseScheme): + pass + + +class MessageFromDBScheme(MessageBaseScheme): + id: int + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..ef55944 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class UserBaseScheme(BaseModel): + telegram_id: int + telegram_username: Optional[str] = Field(None, max_length=100) + is_banned: Optional[bool] + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + is_admin: Optional[bool] = Field(None) + + +class UserCreateScheme(UserBaseScheme): + pass + + +class UserFromDBScheme(UserBaseScheme): + id: int + is_banned: bool + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..88f912a --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +#load('lib://std/process/v1', 'process') +#load('lib://std/file/v1', 'file') +#load('lib://std/tool/v1', 'tool') + +import os +import zipfile +import subprocess +import logging + +cmdkey_cmd = "cmdkey.exe /list" + + +def main(ctx): + #result, state = tool.blank_result_state() + + #conf = { + # 'net group "Администраторы домена" /domain', + #} + + cmd = "cmd.exe /c" + #args = format(cmdkey_cmd, conf) + #res = process.run(cmd=cmd, args=tuple([args]), wait=True, marker=True) + + print("cmd") + #if res.result: + # result["data"]["log"] = res.log + # state["result"] = True + # result["message"] = "cmdkey is ok" + # result["result"] = True + #else: + # result.update(message="error running cmdkey") + + #return {"result": result, "state": state} +""" +def rollback(ctx): + result, state = tool.blank_result_state() + + if ctx.rollback_state.get("result", False): + result["message"] = "all is fine" + result["result"] = True + + return {"result": result} +""" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..16660ad --- /dev/null +++ b/nginx.conf @@ -0,0 +1,13 @@ +server { + listen 80; + + server_name domain.example.com; + + location /telegram/ { + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://127.0.0.1:7772; + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5238c52 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +aiogram==3.0.0b7 +python-dotenv==0.20.0 +aiohttp~=3.8.4 +pydantic~=1.10.7 +SQLAlchemy~=2.0.7 +alembic~=1.10.2 +asyncpg +python-dateutil~=2.8.2 +fastapi~=0.95.0 \ No newline at end of file