25 KiB
Туториал по tg_bot и aiogram-3
Установка виртуального окружения venv и версионности git
инициализируем git
git init
установим виртуальную среду venv в папку venv
python -m venv venv
запишем в файл зависимостей первую запись для установки aiogram
echo "aiogram<4.0" > requirements.txt
добавим туда же pydantic-settings
echo "pydantic-settings" >> requirements.txt
активируем виртуальную среду
source venv/bin/activate
для выхода из venv можно использовать:
deactivate
Добавим файл .gitignore и файл с секретами .env (.env - укажем в .gitignore)
# Игнорирование виртуальной среды Python
venv/
.venv/
myenv/
#Игнорирование рабочих каталогов
bin/
include/
lib/
lib64/
# Игнорирование файлов с окружением
.env
.gitignore
pyvenv.cfg
# Игнорирование скомпилированных файлов Python
__pycache__/
**/__pycache__/
Установим наконец наши зависимости
pip install -r requirements.txt
Первый бот
import asyncio
import logging
from aiogram import Bot, Dispatcher, types
from aiogram.filters.command import Command
# Включаем логирование, чтобы не пропустить важные сообщения
logging.basicConfig(level=logging.INFO)
# Объект бота
bot = Bot(token="12345678:AaBbCcDdEeFfGgHh")
# Диспетчер
dp = Dispatcher()
# Хэндлер на команду /start
@dp.message(Command("start"))
async def cmd_start(message: types.Message):
await message.answer("Hello!")
# Запуск процесса поллинга новых апдейтов
async def main():
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
Создадим файл конфигурации и переопределим файл секретов в него
Итак, создадим рядом с bot.py отдельный файл config_reader.py о следующим содержимым
config_reader.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr
class Settings(BaseSettings):
# Желательно вместо str использовать SecretStr
# для конфиденциальных данных, например, токена бота
bot_token: SecretStr
# Начиная со второй версии pydantic, настройки класса настроек задаются
# через model_config
# В данном случае будет использоваться файла .env, который будет прочитан
# с кодировкой UTF-8
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8')
# При импорте файла сразу создастся
# и провалидируется объект конфига,
# который можно далее импортировать из разных мест
config = Settings()
Теперь немного отредактируем наш bot.py:
# импорты
from config_reader import config
# Для записей с типом Secret* необходимо
# вызывать метод get_secret_value(),
# чтобы получить настоящее содержимое вместо '*******'
bot = Bot(token=config.bot_token.get_secret_value())
Работа с сообщениями
Разберёмся, как применять различные типы форматирования к сообщениям и работать с медиафайлами.
ТЕКСТ
В распоряжении у разработчика имеется три способа разметки текста: HTML, Markdown и MarkdownV2. Наиболее продвинутыми из них считаются HTML и MarkdownV2
Форматированный вывод
За выбор форматирования при отправке сообщений отвечает аргумент parse_mode
, например:
from aiogram import F
from aiogram.types import Message
from aiogram.filters import Command
from aiogram.enums import ParseMode
# Если не указать фильтр F.text,
# то хэндлер сработает даже на картинку с подписью /test
@dp.message(F.text, Command("test"))
async def any_message(message: Message):
await message.answer(
"Hello, <b>world</b>!",
parse_mode=ParseMode.HTML
)
await message.answer(
"Hello, *world*\!",
parse_mode=ParseMode.MARKDOWN_V2
)
В aiogram можно задать параметры бота по умолчанию. Для этого создайте объект DefaultBotProperties и передайте туда нужные настройки:
from aiogram.client.default import DefaultBotProperties
bot = Bot(
token=config.bot_token.get_secret_value(),
default=DefaultBotProperties(
parse_mode=ParseMode.HTML
# тут ещё много других интересных настроек
)
)
# где-то в функции...
await message.answer("Сообщение с <u>HTML-разметкой</u>")
# чтобы явно отключить форматирование в конкретном запросе,
# передайте parse_mode=None
await message.answer(
"Сообщение без <s>какой-либо разметки</s>",
parse_mode=None
)
Экранирование ввода
Второе чуть сложнее, но более продвинутое: воспользоваться специальным инструментом, который будет собирать отдельно текст и отдельно информацию о том, какие его куски должны быть отформатированы.
from aiogram.filters import Command
from aiogram.utils.formatting import Text, Bold
@dp.message(Command("hello"))
async def cmd_hello(message: Message):
content = Text(
"Hello, ",
Bold(message.from_user.full_name)
)
await message.answer(
**content.as_kwargs()
)
В примере выше конструкция **content.as_kwargs() вернёт аргументы text, entities, parse_mode и подставит их в вызов answer()
Упомянутый инструмент форматирования довольно комплексный, официальная документация демонстрирует удобное отображение сложных конструкций
Сохранение форматирования
Представим, что бот должен получить форматированный текст от пользователя и добавить туда что-то своё, например, отметку времени.
Напишем простой код:
# новый импорт!
from datetime import datetime
@dp.message(F.text)
async def echo_with_time(message: Message):
# Получаем текущее время в часовом поясе ПК
time_now = datetime.now().strftime('%H:%M')
# Создаём подчёркнутый текст
added_text = html.underline(f"Создано в {time_now}")
# Отправляем новое сообщение с добавленным текстом
await message.answer(f"{message.text}\n\n{added_text}", parse_mode="HTML")
НО! message.text
возвращает просто текст, без каких-либо оформлений.
Чтобы получить текст в нужном форматировании, воспользуемся альтернативными свойствами: message.html_text
или message.md_text
.
Работа с entities
Telegram сильно упрощает жизнь разработчикам, выполняя предобработку сообщений пользователей на своей стороне. Например, некоторые сущности, типа e-mail, номера телефона, юзернейма и др. можно не доставать регулярными выражениями, а извлечь напрямую из объекта Message и поля entities, содержащего массив объектов типа MessageEntity.
Здесь кроется важный подвох. Telegram возвращает не сами значения, а их начало в тексте и длину.
@dp.message(F.text)
async def extract_data(message: Message):
data = {
"url": "<N/A>",
"email": "<N/A>",
"code": "<N/A>"
}
entities = message.entities or []
for item in entities:
if item.type in data.keys():
# Неправильно
# data[item.type] = message.text[item.offset : item.offset+item.length]
# Правильно
data[item.type] = item.extract_from(message.text)
await message.reply(
"Вот что я нашёл:\n"
f"URL: {html.quote(data['url'])}\n"
f"E-mail: {html.quote(data['email'])}\n"
f"Пароль: {html.quote(data['code'])}"
)
Команды и их аргументы
В составе aiogram есть фильтр Command(), упрощающий жизнь разработчика.
Реализуем последний пример в коде:
@dp.message(Command("settimer", prefix="/!")) # добавим дополнительные префиксы для оперделения команды
async def cmd_settimer(
message: Message,
command: CommandObject
):
# Если не переданы никакие аргументы, то
# command.args будет None
if command.args is None:
await message.answer(
"Ошибка: не переданы аргументы"
)
return
# Пробуем разделить аргументы на две части по первому встречному пробелу
try:
delay_time, text_to_send = command.args.split(" ", maxsplit=1)
# Если получилось меньше двух частей, вылетит ValueError
except ValueError:
await message.answer(
"Ошибка: неправильный формат команды. Пример:\n"
"/settimer <time> <message>"
)
return
await message.answer(
"Таймер добавлен!\n"
f"Время: {delay_time}\n"
f"Текст: {text_to_send}"
)
Проблема кастомных префиксов в группах только в том, что боты не-админы со включенным Privacy Mode (по умолчанию) могут не увидеть такие команды из-за особенностей логики сервера. Самый частый use-case — боты-модераторы групп, которые уже являются администраторами.
Диплинки
Существует одна команда в Telegram, у которой есть чуть больше возможностей. Это /start. Дело в том, что можно сформировать ссылку вида t.me/bot?start=xxx и пре переходе по такой ссылке пользователю покажут кнопку «Начать», при нажатии которой бот получит сообщение /start xxx.
Учтите, что диплинки через start отправляют пользователя в личку с ботом. Чтобы выбрать группу и отправить диплинк туда, замените start на startgroup. Также у aiogram существует удобная функция для создания диплинков прямо из вашего кода.
Первый дииплинк https://t.me/your_bot?start=help выведет сообщение, соответсвующее команде /help
Второй диплиинк https://t.me/your_bot?start=book_2 выведет соответствующее сообщение, полученное из регулярного выражения
Sending book №2
Больше диплинков, но не для ботов:
В документации Telegram есть подробное описание всевозможных диплинков для клиентских приложений: https://core.telegram.org/api/links
Предпросмотр ссылок
Обычно при отправке текстового сообщения со ссылками Telegram пытается найти и показать предпросмотр первой по порядку ссылки. Это поведение можно настроить по своему желанию, передав в качестве аргумента link_preview_options метода send_message() объект LinkPreviewOptions
# Новый импорт
from aiogram.types import LinkPreviewOptions
@dp.message(Command("links"))
async def cmd_links(message: Message):
links_text = (
"https://nplus1.ru/news/2024/05/23/voyager-1-science-data"
"\n"
"https://t.me/telegram"
)
# Ссылка отключена
options_1 = LinkPreviewOptions(is_disabled=True)
await message.answer(
f"Нет превью ссылок\n{links_text}",
link_preview_options=options_1
)
# -------------------- #
# Маленькое превью
# Для использования prefer_small_media обязательно указывать ещё и url
options_2 = LinkPreviewOptions(
url="https://nplus1.ru/news/2024/05/23/voyager-1-science-data",
prefer_small_media=True
)
await message.answer(
f"Маленькое превью\n{links_text}",
link_preview_options=options_2
)
# -------------------- #
# Большое превью
# Для использования prefer_large_media обязательно указывать ещё и url
options_3 = LinkPreviewOptions(
url="https://nplus1.ru/news/2024/05/23/voyager-1-science-data",
prefer_large_media=True
)
await message.answer(
f"Большое превью\n{links_text}",
link_preview_options=options_3
)
# -------------------- #
# Можно сочетать: маленькое превью и расположение над текстом
options_4 = LinkPreviewOptions(
url="https://nplus1.ru/news/2024/05/23/voyager-1-science-data",
prefer_small_media=True,
show_above_text=True
)
await message.answer(
f"Маленькое превью над текстом\n{links_text}",
link_preview_options=options_4
)
# -------------------- #
# Можно выбрать, какая ссылка будет использоваться для предпосмотра,
options_5 = LinkPreviewOptions(
url="https://t.me/telegram"
)
await message.answer(
f"Предпросмотр не первой ссылки\n{links_text}",
link_preview_options=options_5
)
Также некоторые параметры предпросмотра можно указать по умолчанию в DefaultBotProperties
Медиафайлы
Отправка файлов
У большинства медиафайлов есть свойства file_id
и file_unique_id
. Первый можно использовать для повторной отправки одного и того же файла много раз, причём отправка будет мгновенной, т.к. сам файл уже лежит на серверах Telegram
Скачивание файлов
бот может скачать медиа к себе на компьютер/сервер. Для этого у объекта типа Bot есть метод download()
@dp.message(F.photo)
async def download_photo(message: Message, bot: Bot):
await bot.download(
message.photo[-1],
destination=f"/tmp/{message.photo[-1].file_id}.jpg"
)
@dp.message(F.sticker)
async def download_sticker(message: Message, bot: Bot):
await bot.download(
message.sticker,
# для Windows пути надо подправить
destination=f"/tmp/{message.sticker.file_id}.webp"
)
Альбомы
Начиная с версии 3.1, в aiogram есть «сборщик» альбомов,
Сервисные (служебные) сообщения
Сообщения в Telegram делятся на текстовые, медиафайлы и служебные (они же — сервисные). Настало время поговорить о последних.
У такого служебного сообщения будет content_type равный "new_chat_members", но вообще это объект Message, у которого заполнено одноимённое поле.
@dp.message(F.new_chat_members)
async def somebody_added(message: Message):
for user in message.new_chat_members:
# проперти full_name берёт сразу имя И фамилию
# (на скриншоте выше у юзеров нет фамилии)
await message.reply(f"Привет, {user.full_name}")
Кнопки
Обычные кнопки
Это то, что выводится внизу экрана
Кнопки как шаблоны
Напишем хэндлер, который будет при нажатии на команду /start отправлять сообщение с двумя кнопками в bot2.py.
С точки зрения Bot API, клавиатура — это массив массивов кнопок, а если говорить проще, массив рядов.:
@dp.message(Command("start"))
async def cmd_start(message: types.Message):
kb = [
[
types.KeyboardButton(text="С пюрешкой"),
types.KeyboardButton(text="Без пюрешки")
],
]
keyboard = types.ReplyKeyboardMarkup(
keyboard=kb,
resize_keyboard=True,
input_field_placeholder="Выберите способ подачи"
)
await message.answer("Как подавать котлеты?", reply_markup=keyboard)
Осталось научить бота реагировать на нажатие таких кнопок. Как уже было сказано выше, необходимо делать проверку на полное совпадение текста.
Сделаем это при помощи магического фильтра F
@dp.message(F.text.lower() == "с пюрешкой")
async def with_puree(message: types.Message):
await message.reply("Отличный выбор!", reply_markup=types.ReplyKeyboardRemove()) #удалим клавиатуру после ответа
Keyboard Builder
Сборщик клавиатур для генерации кнопок.
Нам пригодятся следующие методы:
add(<KeyboardButton>)
— добавляет кнопку в память сборщика;
adjust(int1, int2, int3...)
— делает строки по int1, int2, int3... кнопок;
as_markup()
— возвращает готовый объект клавиатуры;
button(<params>)
— добавляет кнопку с заданными параметрами, тип кнопки (Reply или Inline) определяется автоматически.
Создадим пронумерованную клавиатуру размером 4×4:
# новый импорт!
from aiogram.utils.keyboard import ReplyKeyboardBuilder
@dp.message(Command("reply_builder"))
async def reply_builder(message: types.Message):
builder = ReplyKeyboardBuilder()
for i in range(1, 17):
builder.add(types.KeyboardButton(text=str(i)))
builder.adjust(4)
await message.answer(
"Выберите число:",
reply_markup=builder.as_markup(resize_keyboard=True),
)
Специальные обычные кнопки¶
На момент написания этой главы в Telegram существует шесть специальных видов обычных кнопок, не являющихся обычными шаблонами сообщений. Они предназначены для:
отправки текущей геолокации; отправки своего контакта с номером телефона; создания опроса/викторины; выбора и отправки боту данных пользователя с нужными критериями; выбора и отправки боту данных (супер)группы или канала с нужными критериями; запуска веб-приложения (WebApp). Поговорим про них подробнее.
Отправка текущей геолокации. Здесь всё просто: где пользователь находится, те координаты и отправляет. Это будет статическое гео, а не Live Location, который обновляется автоматически. Разумеется, хитрые юзеры могут подменить своё местонахождение, иногда даже на уровне всей системы (Android).
Отправка своего контакта с номером телефона. При нажатии на кнопку (с предварительным подтверждением) пользователь отправляет свой контакт с номером телефона боту. Те же хитрые юзеры могут проигнорировать кнопку и отправить любой контакт, но в этом случае на них можно найти управу: достаточно проверить в хэндлере или в фильтре равенство message.contact.user_id == message.from_user.id
.
Создание опроса/викторины. По нажатию на кнопку пользователю предлагается создать опрос или викторину, которые потом отправятся в текущий чат. Необходимо передать объект KeyboardButtonPollType, необязательный аргумент type служит для уточнения типа опроса (опрос или викторина).
Выбор и отправка боту данных пользователя с нужными критериями. Показывает окно выбора пользователя из списка чатов юзера, нажавшего на кнопку. Необходимо передать объект KeyboardButtonRequestUser, в котором надо указать сгенерированный любым способом айди запроса и критерии, например, "бот", "есть подписка Telegram Premium" и т.д. После выбора юзера бот получит сервисное сообщение с типом UserShared.
Выбор и отправка боту чата с нужными критериями. Показывает окно выбора пользователя из списка чатов юзера, нажавшего на кнопку. Необходимо передать объект KeyboardButtonRequestChat, в котором надо указать сгенерированный любым способом айди запроса и критерии, например, "группа или канал", "юзер — создатель чата" и т.д. После выбора юзера бот получит сервисное сообщение с типом ChatShared.
Запуск веб-приложения (WebApp). При нажатии на кнопку открывает WebApp. Необходимо передать объект WebAppInfo. В этой книге веб-аппы пока рассматриваться не будут.