3. Код
Небольшой бот для заметок: /note buy milk сохраняет заметку, /notes показывает ваши последние пять.
requirements.txt
python-telegram-bot>=22,<23
aiomysql
bot.py
import os
import aiomysql
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes
async def post_init(application: Application) -> None:
# One pool for the whole bot. The values come from the Environment pane.
pool = await aiomysql.create_pool(
host=os.environ["DB_HOST"],
port=int(os.environ["DB_PORT"]),
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
db=os.environ["DB_NAME"],
autocommit=True,
maxsize=5,
)
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"CREATE TABLE IF NOT EXISTS notes ("
"id INT AUTO_INCREMENT PRIMARY KEY, "
"user_id BIGINT NOT NULL, "
"text VARCHAR(500) NOT NULL, "
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
"KEY user_id (user_id))"
)
application.bot_data["pool"] = pool
print("Database ready")
print(f"Logged in as @{application.bot.username}")
async def post_shutdown(application: Application) -> None:
pool = application.bot_data.get("pool")
if pool is not None:
pool.close()
await pool.wait_closed()
async def note(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = " ".join(context.args).strip()
if not text:
await update.effective_message.reply_text("Write your note after the command, for example: /note buy milk")
return
if len(text) > 500:
await update.effective_message.reply_text("A note can be up to 500 characters.")
return
pool = context.bot_data["pool"]
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"INSERT INTO notes (user_id, text) VALUES (%s, %s)",
(update.effective_user.id, text),
)
await update.effective_message.reply_text("Saved. Send /notes to see your last five.")
async def notes(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
pool = context.bot_data["pool"]
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"SELECT text FROM notes WHERE user_id = %s ORDER BY id DESC LIMIT 5",
(update.effective_user.id,),
)
rows = await cur.fetchall()
if not rows:
await update.effective_message.reply_text("You have no notes yet.")
return
await update.effective_message.reply_text("\n".join(f"- {text}" for (text,) in rows))
def main() -> None:
application = (
Application.builder()
.token(os.environ["TELEGRAM_BOT_TOKEN"])
.post_init(post_init)
.post_shutdown(post_shutdown)
.build()
)
application.add_handler(CommandHandler("note", note))
application.add_handler(CommandHandler("notes", notes))
application.run_polling()
if __name__ == "__main__":
main()
Что он делает: run_polling() сначала спрашивает у Telegram, кто такой бот, затем запускает post_init и только после этого начинает получать сообщения. post_init открывает пул, создаёт таблицу notes, если её нет, кладёт пул в application.bot_data["pool"] и выводит Database ready и Logged in as @your_bot. Каждый обработчик получает тот же словарь как context.bot_data. Когда бот останавливается, run_polling() запускает post_shutdown, которая закрывает пул.
context.args содержит текст после команды, разбитый на слова, поэтому код склеивает его обратно одинарными пробелами: переводы строк и двойные пробелы внутри заметки превращаются в один пробел. Если вам нужна заметка в точности так, как её набрали, возьмите update.effective_message.text и отрежьте команду сами.
Плейсхолдеры %s подставляет драйвер, поэтому заметка вроде '); DROP TABLE notes; -- сохраняется как обычный текст. В нашем тесте она сохранилась как строка 2, а таблица осталась на месте. Никогда не собирайте SQL через f-строку.
Храните ID Telegram как BIGINT
В документации Telegram Bot API сказано, что ID пользователя или чата «может содержать более 32 значащих бит», но не больше 52, поэтому 64-битное целое надёжно. Страница об ID даёт диапазоны: ID пользователей доходят до 1.099.511.627.775, а ID групп, супергрупп и каналов отрицательные. Супергруппы и каналы лежат в диапазоне от -1.000.000.000.001 до -1.997.852.516.352 (например, -1001234567890). INT в MySQL заканчивается на 2.147.483.647. В нашем тесте, в том же строгом режиме SQL, который использует наш сервер баз данных, сохранить ID пользователя 7.000.000.001 в столбце INT не удалось. aiomysql выдал:
pymysql.err.DataError: (1264, "Out of range value for column 'user_id' at row 1")
Столбец BIGINT его сохранил, и -1001234567890 тоже. Берите обычный BIGINT, а не BIGINT UNSIGNED, чтобы поместились и ID чатов.
4. Запустите
- Загрузите
bot.py и requirements.txt в разделе Файлы.
- Нажмите Запустить и откройте Консоль. При запуске устанавливаются пакеты из
requirements.txt, затем выводятся [PlayNHost] Starting your bot, Database ready и Logged in as @your_bot. Эта последняя строка переключает статус в разделе Обзор на Онлайн в Telegram.
- В Telegram отправьте боту
/note buy milk, затем /notes. Чтобы посмотреть строки, откройте страницу базы и нажмите Открыть в phpMyAdmin.
Начиная со второго запуска консоль также показывает Warning: Table 'notes' already exists, перед которым стоит путь к cursors.py из aiomysql. Это примечание MariaDB для CREATE TABLE IF NOT EXISTS, которое драйвер передаёт дальше. Ничего не сломано.
В нашем тесте замена Telegram отправила эти команды и получила эти ответы:
| Отправлено | Ответ |
/note buy milk | Saved. Send /notes to see your last five. |
/note | Write your note after the command, for example: /note buy milk |
/note и 501 символ | A note can be up to 500 characters. |
/notes | Сохранённые заметки, сначала новые |
/notes от второго пользователя | You have no notes yet. |
Одно обновление за раз
С настройками по умолчанию python-telegram-bot обрабатывает одно обновление за раз: следующая команда ждёт, пока закончит предыдущий обработчик. В нашем тесте 4 команды, пришедшие вместе, получили ответы в порядке поступления, а после теста у бота было 1 открытое подключение. maxsize=5 всё равно важен, если вы включите concurrent_updates в Application.builder() или если задачи и обработчики обращаются к базе одновременно: пул никогда не открывает больше 5 подключений, а лишний запрос ждёт свободного.
Ловушка autocommit
aiomysql открывает подключения с выключенным autocommit. Тогда INSERT остаётся внутри открытой транзакции. Когда подключение возвращается в пул с ещё открытой транзакцией, aiomysql закрывает его, а MariaDB отбрасывает неподтверждённую строку. В нашем тесте строку, вставленную в пул без autocommit=True, увидели 0 других подключений. Либо передайте autocommit=True в create_pool, как выше, либо вызывайте await conn.commit() после записи.
Неудачный запрос не останавливает бота
В нашем тесте мы удалили таблицу, пока бот работал, и отправили /notes. python-telegram-bot вывел No error handlers are registered, logging exception. и трассировку, заканчивающуюся на pymysql.err.ProgrammingError: (1146, "Table '...notes' doesn't exist"), не отправил ответа на эту команду и продолжил работать: когда таблица вернулась, следующая /note сохранилась. Человек, отправивший команду, ничего не видит, поэтому перехватывайте ошибку в обработчике или зарегистрируйте обработчик ошибок через application.add_error_handler() и отвечайте коротким сообщением.
Тихая ночь не ломает пул
Наш сервер баз данных закрывает подключение, которое простояло без дела 8 часов. Бот, которому так долго никто не пишет, например ночью, обнаруживает, что его подключения в пуле закрыты. aiomysql проверяет это, когда обработчик просит подключение, и открывает новое вместо закрытого. Мы проверили это с лимитом простоя, сниженным до 5 секунд: после 7 тихих секунд следующая /note сохранилась без ошибки.
Лимиты, важные для вашего пула
- 20 одновременных подключений на пользователя базы. Считается каждый процесс, использующий тот же логин. Подключение 21 отклоняется с
(1226, "User '...' has exceeded the 'max_user_connections' resource (current value: 20)"). maxsize=5 держит бота намного ниже этого лимита.
- Запрос, который выполняется дольше 120 секунд, прерывается. MariaDB завершает его с ошибкой 1969,
Query execution was interrupted (max_statement_time exceeded). В нашем более раннем тесте, на учётной записи, настроенной так же, это произошло через 120,03 секунды. Запросы бота обычно занимают миллисекунды, так что это касается только запроса, пошедшего вразнос.
- 500 MB на базу данных. Страница Базы данных показывает, сколько занято. При 500 MB запись останавливается, пока вы не удалите строки.
- Резервные копии сервера не включают базу данных. Время от времени экспортируйте её из phpMyAdmin.
Ошибки и что они значат
Это ошибки, которые aiomysql выдал в нашем тесте, записанные так, как они завершают трассировку в консоли (они приходят из PyMySQL, на котором работает aiomysql):
pymysql.err.OperationalError: (1045, "Access denied for user ..."): неверный DB_USER или DB_PASSWORD. После Сбросить пароль в карточке Подключение старый пароль перестаёт работать сразу: замените DB_PASSWORD и перезапустите бота.
pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on ..."): неверный хост или порт либо подключение извне PlayNHost. База принимает подключения только с серверов PlayNHost и из phpMyAdmin, поэтому то же самое вы получите и со своего компьютера.
pymysql.err.OperationalError: (1226, ... 'max_user_connections' ...): открыто слишком много подключений, см. лимиты выше. Например, вторая копия бота или пул, открываемый на каждое сообщение.
pymysql.err.DataError: (1264, "Out of range value for column 'user_id' at row 1"): столбец с ID имеет тип INT. Измените его на BIGINT.
pymysql.err.ProgrammingError: (1146, "Table '...' doesn't exist"): таблицы нет. Код выше создаёт её при запуске; если вы создали её вручную, проверьте имя.
Если в Окружение не хватает переменной, бот останавливается до подключения с KeyError: 'DB_HOST' или с именем недостающей, потому что код читает каждое значение через os.environ[...]. Добавьте её и перезапустите бота: переменные применяются при следующем запуске.
Неверный токен останавливает бота до части с базой, потому что post_init запускается только после того, как Telegram принял токен. С нашим поддельным токеном против настоящего Telegram бот ни разу не вывел Database ready и остановился примерно через 1 секунду с telegram.error.InvalidToken: The token `...` was rejected by the server. Это сообщение содержит токен целиком, поэтому не публикуйте вывод консоли с ним нигде на виду. Скопируйте токен заново у @BotFather и вставьте его на странице Бот, панель Telegram. Другие ошибки Telegram: Как исправить ошибку 409 Conflict у Telegram-бота.
Сколько памяти он использует
В нашем тесте работающий бот использовал 48,5 MB памяти (резидентной) после ответа на 8 команд, с открытым пулом, в контейнере с лимитом 512 MB, как у нашего самого маленького тарифа для бота. В нашем более раннем тесте хостинга один python-telegram-bot занимал 44 MB (тот бот остановился на отклонённом токене, не успев обработать ни одного сообщения), так что разница, около 4,5 MB, приходится на aiomysql, пул и обработанные команды. Установка python-telegram-bot и aiomysql через pip с нуля достигла пика 73 MB вместе с файловым кэшем. Библиотеки рядом: python-telegram-bot или aiogram на PlayNHost.
То же самое с другой библиотекой
Нужен сервер для вашего бота в Telegram с включённой базой данных? Посмотрите тарифы для ботов в Telegram.