Планировщик задач: напоминалки

Учим Цыплёнка делать то, чего раньше он не умел совсем: сам, без единого сообщения от пользователя, написать тебе в нужный момент — «не забудь про домашку!».
Планировщик задач — это «будильник» внутри бота: ты заранее говоришь, что и когда нужно сделать, а он сам в назначенное время вызывает твою функцию — даже если в этот момент тебе никто ничего не пишет.

Зачем боту вообще будильник

Смотри, какая штука. Всё, что мы делали с Цыплёнком до сих пор, работало по одной схеме: пользователь пишет — бот отвечает. Команда /start пришла — хэндлер сработал. Кнопку нажали — callback прилетел. Бот всё время реагирует. Он как продавец в магазине: пока к нему не подошли, он просто стоит за прилавком.

А теперь представь бот-напоминалку про домашку. Ты вечером говоришь ему: «напомни мне про реферат завтра в 18:00». И всё, закрываешь Telegram, идёшь гулять, играть, спать. Завтра в 18:00 тебе никто не пишет — но бот сам, по своей инициативе, присылает: «Эй, ты хотел сесть за реферат 🐥». Вот это «сам, без входящего сообщения» — принципиально новая способность. Реагировать на сообщения бот умел, а вот действовать по времени — ещё нет.

Вот к чему мы придём в этом уроке:

Пользователь: /напомни 10 покормить кота
Бот: Окей! Через 10 секунд напомню 🐥

(проходит 10 секунд, пользователь ничего не пишет)
Бот: ⏰ Напоминание: покормить кота

Результат: в чате пользователь один раз попросил напомнить, а спустя заданное время бот сам прислал напоминание — без всякого нового сообщения от человека. Между этими двумя строками пользователь вообще не трогал телефон.

Чтобы такое сделать, нам нужен механизм, который «держит в голове» список будущих дел и сам срабатывает в нужную секунду. Этот механизм называется планировщик задач, и для aiogram удобнее всего взять готовую библиотеку — APScheduler.

Что такое планировщик: метафора будильника

Самая близкая аналогия — будильник в телефоне. Ты не сидишь всю ночь и не смотришь на часы, чтобы проснуться в 7:00. Ты один раз ставишь будильник и спокойно засыпаешь — а телефон сам следит за временем и звенит, когда нужно. Планировщик — это ровно такой будильник, только вместо «зазвенеть» он вызывает твою функцию Python.

Поставить будильник = добавить задачу в планировщик. «Зазвенел» = планировщик сам вызвал твою функцию. Что делать при звонке — пишешь ты сам внутри этой функции.

Библиотека, которая даёт нам такой будильник, называется APScheduler (читается «эй-пи-скедьюлер», расшифровывается как Advanced Python Scheduler — «продвинутый планировщик для Python»). Она умеет три типа «звонков»:

  • date — позвонить один раз в конкретный момент. «Напомни в 18:00 сегодня» или «через 10 секунд». Сработал один раз — и забыл.
  • interval — звонить каждые столько-то времени. «Каждые 30 минут проверяй новые задания». Повторяется бесконечно, пока не выключишь.
  • cron — звонить по расписанию, как в календаре. «Каждый будний день в 8:00» или «каждый понедельник». Это как умный будильник, который знает про дни недели.

Тебе не нужно вручную следить за временем, заводить бесконечные циклы с sleep или как-то ещё извращаться. Ты просто говоришь планировщику: «вот функция, вызови её тогда-то» — а он берёт всю возню со временем на себя.

Почему не просто sleep?

У новичка возникает соблазн: «а давай я просто напишу await asyncio.sleep(600) и потом отправлю сообщение?» Для одного напоминания это даже сработает. Но представь, что напоминалкой пользуются десять друзей и у каждого по пять напоминаний. Получится куча «спящих» кусков кода, за которыми невозможно уследить: ты не можешь их посмотреть списком, не можешь отменить одно конкретное, и если бот перезапустится — все они просто исчезнут. Планировщик же держит все задачи в одном месте, как список будильников в телефоне: их видно, ими можно управлять, их можно удалять по одному.

Подключаем APScheduler к боту

Сначала ставим библиотеку. В терминале, там же, где у тебя установлен aiogram:

pip install apscheduler

Дальше работаем в нашем bot.py — том самом файле, где уже живут объекты bot и dp. Для асинхронного бота берётся специальный планировщик AsyncIOScheduler: он умеет работать в той же «асинхронной кухне», что и aiogram, и не блокирует бота, пока ждёт нужного времени.

from apscheduler.schedulers.asyncio import AsyncIOScheduler

# рядом с объявлением bot и dp
scheduler = AsyncIOScheduler(timezone="Europe/Moscow")

Результат: в чате это ничего не показывает — мы лишь создали «будильник», но ещё не завели его. Обрати внимание на timezone: планировщик должен знать твой часовой пояс, иначе он будет считать время по Гринвичу, и «напомни в 18:00» сработает на несколько часов мимо. Указывай свой пояс явно — для Москвы это Europe/Moscow.

Создать планировщик мало — его нужно ещё и запустить, иначе он не будет следить за временем. Запускают его прямо перед стартом бота, в том же месте, где мы вызываем polling.

import asyncio

async def main():
    scheduler.start()          # заводим будильник
    await dp.start_polling(bot) # запускаем приём сообщений

if __name__ == "__main__":
    asyncio.run(main())

Результат: в чате опять ничего не видно, но теперь планировщик «проснулся» и готов принимать задачи. Важно: scheduler.start() зовём до start_polling и в той же асинхронной функции — тогда будильник и бот крутятся на одной кухне и не мешают друг другу.

Пример 1. Отложенное напоминание (тип date)

Начнём с самого понятного — напомнить один раз через сколько-то секунд. Нам понадобятся две вещи: функция, которую планировщик вызовет в нужный момент, и сам момент добавления задачи в хэндлере.

Сначала функция, которая отправляет напоминание. Заметь: она async и сама вызывает bot.send_message — ведь отправлять-то будет бот, причём в тот момент, когда пользователь молчит. Значит, нам нужно заранее знать, кому писать. Кому писать — это chat id, который мы возьмём из сообщения, когда пользователь нас попросит.

async def send_reminder(chat_id: int, text: str):
    await bot.send_message(chat_id, f"⏰ Напоминание: {text}")

Результат: сама по себе эта функция ничего не делает — это «что сказать, когда зазвонит будильник». Дальше мы попросим планировщик вызвать её в нужное время.

Теперь хэндлер команды /напомни. Формат придумаем простой: /напомни 10 покормить кота — где 10 это через сколько секунд, а дальше текст напоминания.

from datetime import datetime, timedelta
from aiogram.filters import Command
from aiogram.types import Message

@dp.message(Command("напомни"))
async def cmd_remind(message: Message):
    parts = message.text.split(maxsplit=2)
    if len(parts) < 3:
        await message.answer("Формат: /напомни 10 текст напоминания")
        return

    seconds = int(parts[1])
    text = parts[2]
    run_at = datetime.now() + timedelta(seconds=seconds)

    scheduler.add_job(
        send_reminder,
        trigger="date",
        run_date=run_at,
        args=[message.chat.id, text],
    )
    await message.answer(f"Окей! Через {seconds} секунд напомню 🐥")

Результат: в чате на сообщение /напомни 10 покормить кота бот сразу ответит «Окей! Через 10 секунд напомню 🐥», а через 10 секунд сам пришлёт «⏰ Напоминание: покормить кота». Если команду написать без текста, бот вежливо подскажет формат.

Разберём по шагам, что здесь происходит:

  • message.text.split(maxsplit=2) режет строку максимум на три куска: сама команда, число секунд и весь остальной текст одним куском. Без maxsplit=2 длинное напоминание развалилось бы по пробелам.
  • datetime.now() + timedelta(seconds=seconds) — это «сейчас плюс столько-то секунд», то есть тот самый момент, когда должен зазвонить будильник. timedelta — это «промежуток времени», его удобно прибавлять к текущему моменту.
  • scheduler.add_job(...) — главная строка. Мы говорим планировщику: «вызови функцию send_reminder, тип звонка — date (один раз), момент — run_at, а вот аргументы, которые надо передать в функцию».
  • args=[message.chat.id, text] — именно так мы заранее «кладём в конверт» того, кому и что написать. Когда через 10 секунд будильник зазвонит, он вызовет send_reminder(chat_id, text) с этими значениями.

Главное, что нужно прочувствовать: хэндлер срабатывает сразу и просто «ставит будильник», а send_reminder вызывается потом, отдельно, когда пользователь уже ничего не пишет. Это два разных момента времени.

Маленький чистый пример: считаем будущее время

Чтобы поиграть с timedelta без всякого бота, вот самодостаточный сниппет на стандартной библиотеке — его можно запустить прямо здесь.

from datetime import datetime, timedelta

now = datetime(2026, 6, 19, 17, 50, 0)  # как будто сейчас 17:50
run_at = now + timedelta(seconds=600)    # через 600 секунд = 10 минут

print("сейчас:", now.strftime("%H:%M"))
print("сработает в:", run_at.strftime("%H:%M"))

Вывод:

сейчас: 17:50
сработает в: 18:00

Видишь, как timedelta(seconds=600) сдвинул время ровно на 10 минут вперёд? Ровно это число планировщик и берёт как момент «звонка». А strftime("%H:%M") — это способ показать время по-человечески, в формате «часы:минуты».

Пример 2. Повторяющееся напоминание (тип interval)

Теперь сделаем то, чего одним sleep уже не сделаешь красиво: напоминание, которое повторяется. Например, бот для тренировок, который каждые 30 минут пишет «встань, разомнись, попей воды». Для повторений берём тип interval.

@dp.message(Command("разминка"))
async def cmd_workout(message: Message):
    scheduler.add_job(
        send_reminder,
        trigger="interval",
        minutes=30,
        args=[message.chat.id, "встань и разомнись 🤸"],
        id=f"workout_{message.chat.id}",
        replace_existing=True,
    )
    await message.answer("Буду напоминать про разминку каждые 30 минут 🐥")

Результат: в чате на /разминка бот ответит, что будет напоминать, и дальше каждые 30 минут сам присылает «⏰ Напоминание: встань и разомнись 🤸» — снова и снова, пока задачу не отменишь.

Здесь два новых важных параметра, на которые стоит обратить внимание:

  • id=f"workout_{message.chat.id}" — мы даём задаче имя. Это как подписать будильник, чтобы потом найти именно его среди других. Имя делаем уникальным для каждого пользователя, подмешивая его chat id.
  • replace_existing=True — «если такая задача с таким именем уже есть, замени её, а не создавай вторую». Без этого, если человек дважды напишет /разминка, у него заведётся два одинаковых будильника, и напоминания будут приходить по два раза. С этим флагом второй вызов просто перезапишет первый.

А раз у задачи есть имя, её можно и отменить. Сделаем команду «хватит»:

@dp.message(Command("хватит"))
async def cmd_stop(message: Message):
    job_id = f"workout_{message.chat.id}"
    job = scheduler.get_job(job_id)
    if job is None:
        await message.answer("А я и так ничего не напоминаю 🙂")
        return
    scheduler.remove_job(job_id)
    await message.answer("Окей, больше не буду напоминать про разминку 🐥")

Результат: в чате на /хватит бот находит будильник по его имени и удаляет его; если такого будильника не было — мягко сообщает, что напоминать и нечего. Проверка job is None здесь обязательна: если попытаться удалить несуществующую задачу напрямую, APScheduler поднимет ошибку.

Пример 3. Расписание по часам (тип cron)

Последний тип — cron, расписание как в календаре. Допустим, мы хотим, чтобы Цыплёнок каждый будний день в 8 утра писал тебе «не забудь дневник и физру 🐥». Тут не подходит ни «один раз», ни «каждые N минут» — нужно именно «по будням в 8:00».

scheduler.add_job(
    send_reminder,
    trigger="cron",
    day_of_week="mon-fri",
    hour=8,
    minute=0,
    args=[my_chat_id, "собери портфель: дневник и физра 🐥"],
    id="school_morning",
    replace_existing=True,
)

Результат: в чате каждый будний день ровно в 8:00 бот сам пришлёт «⏰ Напоминание: собери портфель: дневник и физра 🐥», а в выходные будет молчать. Здесь day_of_week="mon-fri" значит «с понедельника по пятницу», а hour=8, minute=0 — «в 8:00». Именно ради такого расписания и нужен часовой пояс, который мы задали при создании планировщика: без него «8 утра» оказалось бы вовсе не твоим утром.

Заметь, что здесь стоит my_chat_id — какое-то конкретное число. На практике этот id ты берёшь из базы (помнишь урок про SQLite для бота?), куда сохранил пользователей при первом /start. Расписание «по будням в 8:00» обычно ставят не в ответ на команду, а один раз при запуске бота — пройдясь по всем сохранённым пользователям.

Частые ошибки и подводные камни

Вот грабли, на которые наступает почти каждый, кто впервые прикручивает планировщик. Прочитай заранее — сэкономишь себе вечер.

  • Забыть вызвать scheduler.start(). Самая частая боль: код вроде написан, задачи добавляются, а напоминания не приходят. Причина — будильник создали, но не завели. Без scheduler.start() в main() планировщик просто спит и за временем не следит.
  • Передать в add_job вызов функции, а не саму функцию. Нужно писать add_job(send_reminder, ...) — без скобок. Если написать add_job(send_reminder(...)) со скобками, Python вызовет функцию прямо сейчас, а в планировщик попадёт её результат (скорее всего None). Передавай имя функции, а данные для неё клади отдельно в args.
  • Не указать timezone. Если не задать часовой пояс, планировщик считает время по UTC (по Гринвичу), и расписание cron сработает со сдвигом на несколько часов. Для отложенных date-задач это особенно коварно: всё «почти работает», просто не в то время. Всегда указывай timezone при создании планировщика.
  • Создавать дубли задач без id и replace_existing. Если пользователь дважды нажмёт «начать напоминания», а ты не дал задаче имя и не поставил replace_existing=True, заведётся два одинаковых будильника — и человек начнёт получать по два сообщения. Давай повторяющимся задачам уникальный id.
  • Думать, что задачи переживут перезапуск бота. По умолчанию APScheduler держит задачи в оперативной памяти. Перезапустил бота — все запланированные напоминания исчезли, как будильники, сброшенные при выключении телефона. Если напоминания должны жить и после перезапуска, их нужно сохранять в базу (например, в ту же SQLite) и заново ставить при старте бота.

Мини-практика: напоминалка про домашку

Собери из кусочков урока свою команду /домашка, которая раз в день вечером спрашивает, сделал ли ты уроки. Это уже почти настоящий полезный бот.

  1. Сделай команду /домашка ЧЧ:ММ (например /домашка 19:30), которая ставит cron-задачу на это время каждый день. Разбери введённое время через parts[1].split(":") и преврати в числа hour и minute.
  2. В функции напоминания пусть бот пишет что-то вроде «Ты сделал домашку на завтра? 📚 Если да — красавчик, если нет — самое время 🐥».
  3. Дай задаче уникальный id вида f"homework_{chat_id}" и поставь replace_existing=True, чтобы повторный вызов команды переносил время, а не плодил дубли.
  4. Добавь команду /домашка_стоп, которая по тому же id удаляет задачу — не забудь проверить, что задача вообще существует, через get_job.
  5. Проверь: поставь напоминание на минуту вперёд от текущего времени и убедись, что бот сам написал тебе в назначенную минуту, пока ты ничего ему не отправлял.

Подсказка: cron-задача «каждый день в ЧЧ:ММ» — это trigger="cron" с параметрами hour и minute, без day_of_week (тогда сработает во все дни недели).

Итоги и что дальше

Сегодня Цыплёнок-помощник научился действовать сам, по времени, а не только отвечать на сообщения. Мы подключили APScheduler — «будильник» внутри бота, создали AsyncIOScheduler с правильным часовым поясом и не забыли завести его через scheduler.start(). Разобрали три типа задач: date (один раз в нужный момент), interval (каждые N минут) и cron (по расписанию, как в календаре). Научились заранее «класть в конверт» chat id и текст через args, давать задачам уникальный id, заменять и отменять их, а ещё прошли по самым липким граблям — от забытого start() до неуказанного часового пояса.

Важно помнить вот что: чтобы рассылать напоминания, бот должен знать chat id своих пользователей — а это значит, что без сохранения людей в базу далеко не уедешь. Хорошо, что в диалогах вроде «во сколько напомнить?» ты уже умеешь вести пользователя по шагам — мы разбирали это в уроке про FSM и машину состояний. В следующем уроке мы соберём эти кусочки вместе и научим Цыплёнка делать массовую рассылку — отправлять сообщение сразу всем пользователям, аккуратно, не упираясь в лимиты Telegram.

Проверьте себя
1. Зачем боту планировщик задач (APScheduler), если бот и так умеет отвечать на сообщения?
AЧтобы бот отвечал на сообщения быстрее
BЧтобы бот мог сам, по времени, выполнить действие без входящего сообщения от пользователя
CЧтобы хранить настройки пользователей в базе
DЧтобы бот мог показывать inline-кнопки
2. Какой тип задачи (trigger) подойдёт, чтобы напомнить пользователю ровно один раз через 10 минут?
Ainterval
Bcron
Cdate
Dloop
3. Почему в scheduler.add_job передают send_reminder без скобок, а не send_reminder(...)?
AСкобки замедляют выполнение бота
BСо скобками функция вызовется прямо сейчас, и в планировщик попадёт её результат вместо самой функции
CAPScheduler не умеет работать с async-функциями
DБез скобок функция вообще не сработает в назначенное время
4. Что произойдёт, если создать планировщик, добавить в него задачи, но забыть вызвать scheduler.start()?
AБот выдаст ошибку при запуске
BЗадачи сработают, но с задержкой
CПланировщик не будет следить за временем, и напоминания не придут
DВсе задачи выполнятся сразу при добавлении
5. Зачем повторяющейся задаче давать уникальный id и ставить replace_existing=True?
AЧтобы задача выполнялась быстрее
BЧтобы при повторном вызове команды не плодились дубли и пользователь не получал по два сообщения
CЭто обязательные параметры для любого типа задач
DЧтобы задача сохранилась после перезапуска бота
6. Что случится с запланированными напоминаниями, если бот перезапустить, а задачи хранились в памяти (по умолчанию)?
AОни сохранятся и сработают как ни в чём не бывало
BОни исчезнут, потому что по умолчанию APScheduler держит задачи в оперативной памяти
CОни автоматически перенесутся в базу данных
DОни сработают все сразу при следующем запуске