Админ-панель и роли
Учим Цыплёнка-помощника отличать своих от чужих: храним список админов в настройках, делаем фильтр доступа по роли, закрываем опасные команды от обычных пользователей и собираем для админов отдельное меню управления.
Роль — это набор прав пользователя в боте: что ему можно, а что нельзя. У обычного пользователя одна роль («гость»), у тебя как у хозяина бота — другая («админ») с доступом к закрытым командам.
Зачем боту вообще различать людей
Представь, что ты сделал бота для своего игрового клана. У него есть полезные команды для всех: /расписание, /состав, /статистика. Но есть и опасные: /анонс рассылает сообщение всем тридцати участникам, /чистка удаляет неактивных из базы, /выключить вообще останавливает бота. Если эти команды доступны кому угодно, то любой шутник из чата нажмёт /анонс спам — и привет, вся аудитория получила мусор от твоего имени.
Так бывает не только в кланах. Бот школьного класса не должен позволять любому рассылать «уроки отменили» всем подряд. Бот музыкального паблика не должен давать случайному подписчику менять описание. Везде один принцип: есть команды для всех, а есть — только для своих. И боту нужно как-то понимать, кто перед ним.
Вот к чему мы придём в этом уроке — у Цыплёнка появится понятие админа. Обычные команды останутся открытыми, а закрытые честно ответят чужаку отказом и пустят только тебя. Плюс у админов будет своё меню управления.
Чужой: /анонс привет всем
Бот: Эта команда только для админов 🐥
Админ: /анонс привет всем
Бот: Принято, начинаю рассылку…
Админ: /админка
Бот: Панель управления Цыплёнком 🐥
[📢 Рассылка] [🧹 Чистка базы]
[📊 Статистика] [⛔ Стоп]Результат: в чате одна и та же команда у обычного пользователя получает вежливый отказ, а у админа — срабатывает. А по команде /админка бот покажет тебе кнопочное меню управления, которого обычные люди вообще не увидят.
Как это устроено: список своих и проверка на входе
Идея ролей куда проще, чем кажется. Вспомни, как работает фейс-контроль на закрытой вечеринке. У охранника на входе есть список приглашённых. Подходит человек — охранник смотрит в список: есть имя — проходи, нет — извини, сегодня только для своих. Бот делает ровно то же самое.
Список админов (кто «свой») + проверка на входе в команду (а ты вообще в списке?) = роли.
Значит, нам нужны две вещи. Первая — где-то хранить список «своих», то есть id админов. Вторая — в начале каждой закрытой команды проверять: id того, кто пишет, есть в этом списке? Если да — пускаем. Если нет — отвечаем «только для админов» и выходим.
А где взять список админов? Можно, конечно, вписать id прямо в код. Но помнишь правило про токен — секреты не держат в коде? С админами похожая история: список своих удобнее хранить в настройках, отдельно от логики. В уроке про модель пользователя и настройки мы уже привыкли держать важные значения в одном месте. Сейчас сделаем так же: положим список админов в переменную окружения и прочитаем её при старте бота.
Маленькая разминка: разобрать список id из строки
Прежде чем трогать настоящего бота, отработаем чистую механику на стандартном Python. Переменные окружения всегда приходят строками, поэтому список вроде "777123,888456,999789" нам надо разобрать в набор чисел, по которому удобно проверять «свой/чужой».
raw = "777123, 888456 ,999789"
# режем по запятой, убираем пробелы, превращаем в числа
admin_ids = {int(part.strip()) for part in raw.split(",")}
print("админы:", admin_ids)
print("888456 свой? ", 888456 in admin_ids)
print("111111 свой? ", 111111 in admin_ids)Вывод:
админы: {777123, 888456, 999789}
888456 свой? True
111111 свой? FalseЗдесь весь секрет ролей в трёх строках. Мы режем строку по запятой методом split(","), у каждого куска срезаем лишние пробелы через strip(), превращаем в число int(...) и складываем всё в множество (фигурные скобки). Множество выбрано не случайно: проверка число in множество работает мгновенно и читается по-человечески — «этот id среди своих?». Именно эту проверку мы и будем делать в каждой закрытой команде.
Шаг 1. Храним список админов в настройках
Начнём с первого пункта формулы — где живут «свои». Добавим в наш bot.py чтение списка админов из переменной окружения, рядом с тем местом, где мы уже читаем токен. Назовём переменную ADMIN_IDS.
import os
# токен мы уже читаем так же — из окружения, а не из кода
TOKEN = os.environ["BOT_TOKEN"]
# список админов: строка вида "777123,888456" -> множество чисел
_raw_admins = os.environ.get("ADMIN_IDS", "")
ADMIN_IDS = {int(x.strip()) for x in _raw_admins.split(",") if x.strip()}
def is_admin(user_id: int) -> bool:
return user_id in ADMIN_IDSРезультат: в чате это ничего не печатает — код просто при старте бота читает переменную окружения ADMIN_IDS и готовит множество id плюс удобную функцию-проверку is_admin. Если переменная не задана, множество будет пустым, и админов просто нет.
Разберём по частям. os.environ.get("ADMIN_IDS", "") берёт строку из окружения, а если её нет — отдаёт пустую строку (так бот не упадёт). Дальше уже знакомая по разминке сборка множества, только с защитой if x.strip() — она отбрасывает пустые куски, если ты, например, поставил лишнюю запятую. А is_admin — это маленькая обёртка-помощник: вместо длинного user_id in ADMIN_IDS по всему коду мы будем писать короткое и понятное is_admin(user_id). Сам список задаётся при запуске бота, например так: ADMIN_IDS=777123,888456 python bot.py. Свой tg_id, напомню, можно подсмотреть через бота @userinfobot.
Шаг 2. Закрываем команду проверкой внутри хэндлера
Теперь самый простой способ защитить команду — проверка прямо в начале хэндлера. Мы уже видели похожее в уроке про хэндлеры сообщений: хэндлер — это функция, которую aiogram зовёт в ответ на команду. Добавим в неё первую строку-«фейс-контроль».
from aiogram.filters import Command
from aiogram.types import Message
@dp.message(Command("стоп"))
async def cmd_stop(message: Message):
if not is_admin(message.from_user.id):
await message.answer("Эта команда только для админов 🐥")
return
await message.answer("Останавливаю Цыплёнка… до встречи! 🐥")
# здесь была бы реальная остановка ботаРезультат: в чате на команду /стоп обычный пользователь получит «Эта команда только для админов 🐥», а ты как админ — «Останавливаю Цыплёнка… до встречи!». То есть команда видна всем, но срабатывает только у своих.
Логика здесь — тот самый охранник на входе. Самой первой строкой мы спрашиваем: is_admin(message.from_user.id) — этот человек в списке? Если нет (not is_admin(...)), бот вежливо отказывает и тут же делает return — выходит из функции, не выполняя ничего опасного ниже. Этот return критически важен: он обрывает хэндлер прямо здесь. Если же проверка прошла — выполнение спокойно идёт дальше, к настоящему телу команды. Один и тот же приём ты можешь скопировать в начало любой закрытой команды: /анонс, /чистка, что угодно.
Шаг 3. Свой фильтр доступа, чтобы не копировать проверку
Проверка из шага 2 отлично работает, но представь, что закрытых команд у тебя десять. В каждую копировать одни и те же три строки — скучно и легко ошибиться: где-нибудь забудешь return, и дыра в защите готова. У aiogram для таких случаев есть фильтры — это условия, которые проверяются до того, как хэндлер вообще запустится. Если фильтр сказал «не подходит», aiogram даже не зайдёт в функцию. Сделаем собственный фильтр IsAdmin.
from aiogram.filters import BaseFilter, Command
from aiogram.types import Message
class IsAdmin(BaseFilter):
async def __call__(self, message: Message) -> bool:
return is_admin(message.from_user.id)
@dp.message(Command("чистка"), IsAdmin())
async def cmd_cleanup(message: Message):
# сюда мы попадём, ТОЛЬКО если IsAdmin вернул True
await message.answer("Чищу базу от неактивных… 🧹")Результат: в чате команду /чистка у обычного пользователя бот просто проигнорирует — он не зайдёт в хэндлер вообще. А у админа команда сработает и ответит «Чищу базу от неактивных…». Внутри функции уже не нужна никакая проверка — её сделал фильтр на входе.
Разберём, как это устроено. IsAdmin — это класс-фильтр, унаследованный от BaseFilter. Главное в нём — метод __call__, который aiogram сам вызовет перед хэндлером и передаст ему сообщение. Метод просто возвращает True или False по нашей знакомой проверке is_admin(...). Дальше в декораторе мы перечисляем фильтры через запятую: @dp.message(Command("чистка"), IsAdmin()) означает «сработай, только если это команда /чистка и человек — админ». Оба условия должны быть истинны.
Чувствуешь разницу с шагом 2? Раньше проверка жила внутри функции, и хэндлер всё равно запускался, просто рано выходил. Теперь проверка стоит на входе, как настоящий охранник у двери: чужак вообще не попадает внутрь. А ещё IsAdmin() можно повесить разом на много команд, и логика «кто такой админ» останется в одном-единственном месте — захочешь поменять, правишь только класс.
В чём разница: ручная проверка или фильтр?
Оба способа правильные, и вот когда какой удобнее. Ручная проверка с return хороша, когда чужаку нужно ответить («только для админов») — фильтр-то просто молча не пустит, и человек может не понять, почему команда «не работает». А фильтр IsAdmin() хорош, когда команд много и ответ чужаку не важен (например, секретные команды, о которых обычным людям знать и не нужно). На практике часто комбинируют: на видимые команды вешают ручную проверку с понятным отказом, на скрытые — фильтр.
Шаг 4. Собираем меню управления для админа
Раздавать команды по одной — нормально, но удобнее дать админу единую панель. Соберём её на inline-кнопках (помнишь, это кнопки прямо под сообщением, которые шлют боту скрытый callback, а не текст в чат). По команде /админка бот покажет меню, но только своим.
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@dp.message(Command("админка"))
async def cmd_admin_panel(message: Message):
if not is_admin(message.from_user.id):
await message.answer("Эта команда только для админов 🐥")
return
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📢 Рассылка", callback_data="admin:broadcast"),
InlineKeyboardButton(text="🧹 Чистка базы", callback_data="admin:cleanup"),
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data="admin:stats"),
InlineKeyboardButton(text="⛔ Стоп", callback_data="admin:stop"),
],
])
await message.answer("Панель управления Цыплёнком 🐥", reply_markup=keyboard)Результат: в чате по команде /админка админ увидит сообщение «Панель управления Цыплёнком 🐥» с четырьмя кнопками в два ряда. Чужой получит привычный отказ. Нажатия пока ничего не делают — их обработаем дальше.
Тут всё знакомо по урокам про кнопки: InlineKeyboardMarkup — это сама клавиатура, внутри список рядов, каждый ряд — список кнопок. У каждой кнопки есть text (что видно) и callback_data (скрытый сигнал, который придёт боту при нажатии). Мы дали всем кнопкам data с префиксом admin: — это удобно, чтобы потом ловить их одним хэндлером. И, конечно, в начале — наш фейс-контроль, ведь меню тоже только для своих.
Осталось научить бота реагировать на нажатия. Поймаем все callback, начинающиеся с admin:, и — внимание — снова проверим админа. Кнопку-то теоретически можно нажать и из пересланного сообщения, поэтому доверять одному лишь факту нажатия нельзя.
from aiogram.types import CallbackQuery
from aiogram import F
@dp.callback_query(F.data.startswith("admin:"))
async def admin_buttons(callback: CallbackQuery):
if not is_admin(callback.from_user.id):
await callback.answer("Только для админов 🐥", show_alert=True)
return
action = callback.data.split(":")[1] # "broadcast", "cleanup", ...
titles = {
"broadcast": "Запускаю рассылку…",
"cleanup": "Чищу базу…",
"stats": "Собираю статистику…",
"stop": "Останавливаю бота…",
}
await callback.message.answer(titles.get(action, "Неизвестное действие"))
await callback.answer() # убираем «часики» на кнопкеРезультат: в чате при нажатии, например, кнопки «📢 Рассылка» бот ответит «Запускаю рассылку…». Если кнопку как-то нажмёт не админ — он увидит всплывающее окошко «Только для админов». На остальные кнопки бот отвечает соответствующей подписью из словаря.
Главное здесь — F.data.startswith("admin:"): это фильтр, который ловит все callback с нашим префиксом одним хэндлером, не плодя по функции на кнопку. Внутри мы повторяем проверку админа (для callback берём callback.from_user.id) — да, ту же самую, что и в команде. Это не паранойя: кнопка живёт под сообщением и может оказаться у кого угодно, поэтому права проверяем при каждом действии, а не только при показе меню. Дальше отрезаем из callback.data само действие через split(":")[1] и по словарю выбираем, что ответить. А await callback.answer() в конце — обязательная мелочь: она убирает крутящиеся «часики» на кнопке, иначе пользователю кажется, что бот завис.
Частые ошибки и подводные камни
Вот грабли, на которые наступают почти все, кто впервые делает роли. Прочитай сейчас — сбережёшь и нервы, и безопасность бота.
- Забыть
returnпосле отказа. Самая опасная ошибка. Если написатьif not is_admin(...): await message.answer("нельзя")и не поставитьreturn, выполнение пойдёт дальше и команда сработает несмотря на отказ. Чужак получит и «нельзя», и результат опасной команды. После отказа всегдаreturn. - Проверять админа только при показе меню, но не при нажатии кнопок. Меню показали своему — но callback от кнопки может прилететь и от чужого (например, через пересланное сообщение). Проверяй права и в команде
/админка, и в обработчикеadmin:-кнопок. - Хранить список админов прямо в коде и заливать его в публичный репозиторий. Это как выложить токен на всеобщее обозрение: по чужим id злоумышленник поймёт, кого атаковать, а ты не сможешь поменять список без правки кода. Держи
ADMIN_IDSв переменной окружения, рядом с токеном. - Сравнивать id со строкой. Переменные окружения приходят строками, а
message.from_user.id— это число. Если забытьint(...)при разбореADMIN_IDS, проверка777123 in {"777123"}всегда дастFalse, и даже настоящий админ не пройдёт. Приводи id кintпри чтении настроек. - Не вызывать
callback.answer()в обработчике кнопок. Telegram ждёт от бота подтверждения нажатия. Безawait callback.answer()на кнопке висят бесконечные «часики», и кажется, что бот завис. Этот вызов нужен в конце любого callback-хэндлера.
Мини-практика: роль «модератор» и закрытая чистка
Сейчас у нас две роли: обычный пользователь и админ. Добавь третью — модератора: ему можно меньше, чем админу, но больше, чем гостю. Например, модератор может смотреть статистику, но не может останавливать бота.
- Заведи рядом с
ADMIN_IDSещё одно множествоMOD_IDS, читая его из переменной окруженияMOD_IDSточно так же, как админов. - Напиши функцию
can_see_stats(user_id), которая возвращаетTrue, если пользователь админ или модератор. Подсказка:is_admin(user_id) or user_id in MOD_IDS. - Сделай свой фильтр
CanSeeStats(BaseFilter)по образцуIsAdminи повесь его на команду/статистика. Команду/стоппри этом оставь только для админов черезIsAdmin. - Проверь с трёх аккаунтов: админ видит и статистику, и стоп; модератор — только статистику (на
/стопполучает отказ); обычный пользователь — ни то, ни другое.
Бонус для смелых: сделай так, чтобы кнопка «⛔ Стоп» вообще не показывалась модератору в меню /админка. Подсказка: собирай список рядов клавиатуры в переменную, и кнопку стопа добавляй в него только если is_admin(...). Так разные роли увидят разное меню — это уже почти как в настоящих больших ботах.
Итоги и что дальше
Сегодня Цыплёнок-помощник научился отличать своих от чужих. Мы разобрали простую формулу ролей — список админов плюс проверка на входе — и собрали её по шагам: положили ADMIN_IDS в переменную окружения и сделали помощник is_admin; закрыли команду ручной проверкой с обязательным return; завели собственный фильтр IsAdmin, чтобы не копировать проверку по всем хэндлерам; собрали для админов inline-меню /админка и аккуратно обработали нажатия его кнопок, не забыв проверить права ещё раз и снять «часики» через callback.answer(). А заодно поняли, когда удобнее ручная проверка с отказом, а когда — молчаливый фильтр.
Теперь у тебя есть фундамент любого серьёзного бота: пока у проекта одна команда — роли не нужны, но как только появляются опасные действия, без разграничения прав не обойтись. В следующих уроках раздела мы продолжим осваивать жизнь бота в группах: разберём, как он ведёт себя в общих чатах, как реагирует на новых участников и как помогает модерировать беседу, не мешая обычному общению.