Перед Новым годом мы организовали тайного санту. Для упрощения процесса задумались о боте. Да, мы нашли на просторах гитхаба различные варианты, но решили не лишать себя праздничного веселья от создания бота на коленке. Меня зовут Вильданов Ринат, я python разработчик в Технократии, и я расскажу, что мы наделали. Возможно, описание нашего пути поможет и вам.
Первым делом надо определиться с тем, что должен делать бот:
Регистрировать пользователя. Делаем это при старте бота
В компании мы хорошо общаемся между собой, но какие-то факты о потенциальном “внучке” могли ускользнуть, поэтому добавляем каждому пользователю биографию — по ней будет легче выбирать подарки
Не забываем про удаленщиков. Для них при заполнении биографии добавим стейт про адрес.
С командами для обычного пользователя все. Теперь об админах.
Добавим счетчик пользователей
Праздника не будет, если пары не распределены. Делаем команду для запуска события
Разделили создание пар и рассылку внучков на две разные команды
Админ говорит. Добавили оповещалку пользователей от имени админа
Команда по очистке базы. Исключительно для тестовой части, чтобы не лезть каждый раз в БД.
Защитим права админа. Люди у нас любознательные, кто-нибудь точно захочет побыть в роли админа-Гринча без прав на это. Позаботимся об этом, а виновникам добавим углей в список подарков.
Определились с задачами, определимся со стеком. Самый простой вариант, как мне кажется,— aiogram. Прекрасная документация, много проектов, в которых можно подглядеть реализацию.
Надо где-то хранить данные. Для этого выберем postgres. Классика. Для взаимодействия с базой будем использовать sqlalchemy. Да, это мощно.
Чтобы комфортно накатывать миграции, добавим alembic. Кажется, все. Начнем разработку.
Расчехляем шаблон для бота и немного дорабатываем его под наши нужды. Имеем что то подобное:
Тут я хотел бы обратить внимание на модуль models. В нем будут лежать классы, на основе которых alembic генерирует миграции. Также в дальнейшем добавим методы класса для получения/записи данных. Определим их:
telegram_users.py
class TelegramUser(Base):
__tablename__ = "telegram_user"
id = Column(Integer, primary_key=True)
first_name = Column(String)
last_name = Column(String)
tg_id = Column(Integer, unique=True)
chat_id = Column(Integer, unique=True)
description = Column(String, nullable=True)
address = Column(String, nullable=True)
event.py
class Event(Base):
__tablename__ = "event"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("telegram_user.id"), unique=True) # Кому дарит
santa_id = Column(Integer, ForeignKey("telegram_user.id"), unique=True) # Кто дарит
Ничего специфического здесь нет: описываем модели, на основе которых будут сгенерированы миграции
Классы готовы, но сам alembic их не найдет, ему необходимо помочь. Для этого перейдем в alembic/env.py и объявим, на основе чего делать миграции.
Теперь надо зафиксировать, куда катить миграции. Для этого в def run_migrations_offline укажем DSN. DSN лежит в файле settings.py со всеми настройками.
def run_migrations_offline():
url = DSN
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
Так же поправим def run_migrations_online
def run_migrations_online()
conf = config.get_section(config.config_ini_section)
conf["sqlalchemy.url"] = SYNC_DSN
connectable = engine_from_config(
conf,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
С миграциями должно быть все хорошо. Главное при добавлении новых моделей, не забыть их описать здесь. Возможно есть более оптимальные решения, допустим, указать папку, откуда брать данные о таблицах, но я не нашел.
Генерируем миграции и накатываем:
Создание: alembic revision --autogenerate
Накатывание: alembic upgrade head
Замечательно, таблицы создались, займемся реализациями комманд. Для этого пройдем в модуль handlers.
Разбирать реализацию всех команд не вижу смысла: если понятно, как сделать хотя бы одну, с остальными проблем не возникнет.
@dp.message_handler(commands=["start"])
async def start(message: types.Message):
data = {
"chat_id": message["chat"]["id"],
"tg_id": message["from"]["id"],
"first_name": message["chat"]["first_name"],
"last_name": message["chat"]["last_name"],
}
response = await TelegramUser.add_user(data)
await TelegramUser.get_user_by_id(int(message["from"]["id"]))
await message.answer(response)
Из переменной message с соответствующим типом вытаскиваем необходимые для нас данные: id пользователя, id чата (они совпадают, но раз подаются отдельно, то и хранить мы их будем отдельно), также имя и фамилию. Передаем эти данные в метод add_user класса, на основе которого была создана таблица. Внутри данного метода будет простое добавление данных в базу.
Думаю, с пользовательским ручками разобрались. С админскими +- тоже, за одним исключением — необходимо реализовать проверку на админа. Первое, что приходит в голову, — решение в лоб: в каждом админском методе сравнивать id владельца и id пользователя, который отправил сообщение. Метод имеет право на существование, так как решает поставленную задачу, но он не идеален, так как будет много дублирующего кода.
Можно написать декоратор и обернуть все админские ручки в него. И да и нет. Декоратор мы использовать будем, но писать будем не его, а фильтр. Обращаемся к документации и видим там параграф про фильтры. Это то, что нам нужно, копипастим кусок кода, переименовываем и получаем подобное:
class AdminFilter(BoundFilter):
key = 'is_admin'
def __init__(self, is_admin):
self.is_admin = is_admin
async def check(self, message: types.Message):
if message["from"]["id"] not in OWNER_ID:
return False
return True
Как можно заметить, проверка происходит в методе check.
Теперь к расстановке пар. Тут нет единого способа решения. К примеру, вот свежая статья про это. Мы решили не идти против течения и воспользовались обычным шаффлом из модуля random.
Получаем список всех id. Смешиваем их в случайном порядке и получаем такие пары: пользователь c индексом 0 дарит подарок пользователю с индексом 1. Пользователь c индексом 1 дарит пользователю с индексом 2 … пользователь c индексом (n-2) дарит пользователю с индексом (n-1), пользователь с индексом (n-1) дарит подарок пользователь с индексом 0. Говоря про индексы, имеется в виду индекс списка, а значением является id. Вот собственно и вся магия.
Пары получены, теперь осталось рассказать сантам про их подопечных. Для этого мы запланировали команду /notify
@dp.message_handler(commands=["notify"])
async def start_event(message: types.Message):
tg_id = message["from"]["id"]
responses = await Event.notify_all(int(tg_id))
if isinstance(responses, str):
await message.answer(responses)
return
for response in responses:
await bot.send_message(response["santa_id"], format_message(response))
Тут тоже несложно. С помощью таблицы event получаем список сант, матчим список подопечных с их адресами и описанием. Потом идем в цикле и с помощью telegram id отправляем сообщения.
Выводы
Бота запустили, подопечных всем раздали, успели получить первые отзывы и вот что мы не учли:
многие нажали на /start просто из интереса, а их уже зарегистрировало - нужно сделать подтверждение участия
у части ребят нет фамилии или она скрыта, из-за этого приходилось вручную искать какой из трех Алмазов кому выпал — надо сохранять alias
хорошо бы сделать напоминание по таймеру для тех, кто не заполнил описание
поскольку все писалось на коленке, проверок маловато, хорошо бы еще понимать, кому сообщение отправилось, а кому нет
А вы играете в «Тайного Санту» на работе или с друзьями?
Также подписывайтесь на наш телеграм-канал «Голос Технократии». Каждое утро мы публикуем новостной дайджест из мира ИТ, а по вечерам делимся интересными и полезными мастридами.