quick_bot_tutorial/README.md

541 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Туториал по tg_bot и aiogram-3
[Все проводится по гайду](https://mastergroosha.github.io/aiogram-3-guide/)
## Установка виртуального окружения venv и версионности git
инициализируем git
```
git init
```
установим виртуальную среду **venv** в папку **venv**
```sh
python -m venv venv
```
запишем в файл зависимостей первую запись для установки **aiogram**
```sh
echo "aiogram<4.0" > requirements.txt
```
добавим туда же **pydantic-settings**
```sh
echo "pydantic-settings" >> requirements.txt
```
активируем виртуальную среду
```sh
source venv/bin/activate
```
для выхода из **venv** можно использовать:
```sh
deactivate
```
Добавим файл .gitignore и файл с секретами .env (.env - укажем в .gitignore)
```sh
# Игнорирование виртуальной среды Python
venv/
.venv/
myenv/
#Игнорирование рабочих каталогов
bin/
include/
lib/
lib64/
# Игнорирование файлов с окружением
.env
.gitignore
pyvenv.cfg
# Игнорирование скомпилированных файлов Python
__pycache__/
**/__pycache__/
```
Установим наконец наши зависимости
```sh
pip install -r requirements.txt
```
## Первый бот
```py
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
```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:
```py 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``, например:
```py
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 и передайте туда нужные настройки:
```py
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
)
```
#### Экранирование ввода
Второе чуть сложнее, но более продвинутое: воспользоваться специальным инструментом,
который будет собирать отдельно текст и отдельно информацию о том, какие его куски должны быть отформатированы.
```py
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()**
Упомянутый инструмент форматирования довольно комплексный, [официальная документация](https://core.telegram.org/bots/api#formatting-options) демонстрирует удобное отображение сложных конструкций
#### Сохранение форматирования
Представим, что бот должен получить форматированный текст от пользователя и добавить туда что-то своё, например, отметку времени.
Напишем простой код:
```py
# новый импорт!
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](https://core.telegram.org/bots/api#messageentity).
Здесь кроется важный подвох. *Telegram возвращает не сами значения, а их начало в тексте и длину*.
```py
@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(), упрощающий жизнь разработчика.
Реализуем последний пример в коде:
```py
@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()** объект L**inkPreviewOptions**
```py
# Новый импорт
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()
```py
@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 есть «сборщик» альбомов,](https://docs.aiogram.dev/en/latest/utils/media_group.html)
### Сервисные (служебные) сообщения
Сообщения в Telegram делятся на текстовые, медиафайлы и служебные (они же — сервисные). Настало время поговорить о последних.
У такого служебного сообщения будет content_type равный "**new_chat_members**", но вообще это объект Message, у которого заполнено одноимённое поле.
```py
@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, клавиатура — это массив массивов кнопок, а если говорить проще, массив рядов.:
```py
@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*
```py
@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:
```py
# новый импорт!
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. В этой книге веб-аппы пока рассматриваться не будут.
две заготовки хэндлеров на приём нажатий от нижних двух кнопок:
```py
# новый импорт
from aiogram import F
@dp.message(F.user_shared)
async def on_user_shared(message: types.Message):
print(
f"Request {message.user_shared.request_id}. "
f"User ID: {message.user_shared.user_id}"
)
@dp.message(F.chat_shared)
async def on_user_shared(message: types.Message):
print(
f"Request {message.chat_shared.request_id}. "
f"User ID: {message.chat_shared.chat_id}"
)
```