Support-BOT

This commit is contained in:
2024-05-01 19:55:22 +03:00
commit 4280385d32
34 changed files with 1361 additions and 0 deletions

132
.gitignore vendored Normal file
View File

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

21
LICENSE Normal file
View File

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

57
README.md Normal file
View File

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

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

99
alembic/env.py Normal file
View File

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

24
alembic/script.py.mako Normal file
View File

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

View File

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

View File

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

0
app/__init__.py Normal file
View File

0
app/bot/__init__.py Normal file
View File

10
app/bot/filter_media.py Normal file
View File

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

48
app/bot/get_reports.py Normal file
View File

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

View File

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

View File

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

57
app/bot/main.py Normal file
View File

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

88
app/bot/utils.py Normal file
View File

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

0
app/core/__init__.py Normal file
View File

3
app/core/base.py Normal file
View File

@@ -0,0 +1,3 @@
"""Импорты класса Base и всех моделей для Alembic."""
from app.core.db import Base # noqa
from app.models import User, Message # noqa

29
app/core/config.py Normal file
View File

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

36
app/core/db.py Normal file
View File

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

0
app/crud/__init__.py Normal file
View File

55
app/crud/base.py Normal file
View File

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

46
app/crud/message.py Normal file
View File

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

110
app/crud/user.py Normal file
View File

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

2
app/models/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .user import User
from .message import Message

19
app/models/message.py Normal file
View File

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

17
app/models/user.py Normal file
View File

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

0
app/schemas/__init__.py Normal file
View File

24
app/schemas/message.py Normal file
View File

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

27
app/schemas/user.py Normal file
View File

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

0
app/services/__init__.py Normal file
View File

43
main.py Normal file
View File

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

13
nginx.conf Normal file
View File

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

9
requirements.txt Normal file
View File

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