Админ-панель и роли

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

Зачем боту вообще различать людей

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

Так бывает не только в кланах. Бот школьного класса не должен позволять любому рассылать «уроки отменили» всем подряд. Бот музыкального паблика не должен давать случайному подписчику менять описание. Везде один принцип: есть команды для всех, а есть — только для своих. И боту нужно как-то понимать, кто перед ним.

Вот к чему мы придём в этом уроке — у Цыплёнка появится понятие админа. Обычные команды останутся открытыми, а закрытые честно ответят чужаку отказом и пустят только тебя. Плюс у админов будет своё меню управления.

Чужой:  /анонс привет всем
Бот:    Эта команда только для админов 🐥

Админ:  /анонс привет всем
Бот:    Принято, начинаю рассылку…

Админ:  /админка
Бот:    Панель управления Цыплёнком 🐥
        [📢 Рассылка] [🧹 Чистка базы]
        [📊 Статистика] [⛔ Стоп]

Результат: в чате одна и та же команда у обычного пользователя получает вежливый отказ, а у админа — срабатывает. А по команде /админка бот покажет тебе кнопочное меню управления, которого обычные люди вообще не увидят.

Как это устроено: список своих и проверка на входе

Идея ролей куда проще, чем кажется. Вспомни, как работает фейс-контроль на закрытой вечеринке. У охранника на входе есть список приглашённых. Подходит человек — охранник смотрит в список: есть имя — проходи, нет — извини, сегодня только для своих. Бот делает ровно то же самое.

Список админов (кто «свой») + проверка на входе в команду (а ты вообще в списке?) = роли.

Значит, нам нужны две вещи. Первая — где-то хранить список «своих», то есть 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-хэндлера.

Мини-практика: роль «модератор» и закрытая чистка

Сейчас у нас две роли: обычный пользователь и админ. Добавь третью — модератора: ему можно меньше, чем админу, но больше, чем гостю. Например, модератор может смотреть статистику, но не может останавливать бота.

  1. Заведи рядом с ADMIN_IDS ещё одно множество MOD_IDS, читая его из переменной окружения MOD_IDS точно так же, как админов.
  2. Напиши функцию can_see_stats(user_id), которая возвращает True, если пользователь админ или модератор. Подсказка: is_admin(user_id) or user_id in MOD_IDS.
  3. Сделай свой фильтр CanSeeStats(BaseFilter) по образцу IsAdmin и повесь его на команду /статистика. Команду /стоп при этом оставь только для админов через IsAdmin.
  4. Проверь с трёх аккаунтов: админ видит и статистику, и стоп; модератор — только статистику (на /стоп получает отказ); обычный пользователь — ни то, ни другое.

Бонус для смелых: сделай так, чтобы кнопка «⛔ Стоп» вообще не показывалась модератору в меню /админка. Подсказка: собирай список рядов клавиатуры в переменную, и кнопку стопа добавляй в него только если is_admin(...). Так разные роли увидят разное меню — это уже почти как в настоящих больших ботах.

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

Сегодня Цыплёнок-помощник научился отличать своих от чужих. Мы разобрали простую формулу ролей — список админов плюс проверка на входе — и собрали её по шагам: положили ADMIN_IDS в переменную окружения и сделали помощник is_admin; закрыли команду ручной проверкой с обязательным return; завели собственный фильтр IsAdmin, чтобы не копировать проверку по всем хэндлерам; собрали для админов inline-меню /админка и аккуратно обработали нажатия его кнопок, не забыв проверить права ещё раз и снять «часики» через callback.answer(). А заодно поняли, когда удобнее ручная проверка с отказом, а когда — молчаливый фильтр.

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

Проверьте себя
1. Где правильнее всего хранить список id админов бота?
AПрямо в коде bot.py и заливать в публичный репозиторий
BВ переменной окружения (например ADMIN_IDS), рядом с токеном, отдельно от кода
CВ сообщении, которое бот сам себе отправляет при старте
DСписок админов вообще не нужно нигде хранить — Telegram знает его сам
2. Что произойдёт, если в хэндлере написать отказ чужаку, но забыть поставить return после него?
AНичего, return ставить необязательно
BБот выдаст ошибку и упадёт
CВыполнение пойдёт дальше, и опасная команда сработает несмотря на отказ
DTelegram сам прервёт выполнение функции
3. В чём главное отличие собственного фильтра IsAdmin от ручной проверки внутри хэндлера?
AФильтр работает быстрее, потому что написан на C
BФильтр проверяется ДО запуска хэндлера: при отказе aiogram вообще не заходит в функцию
CРучная проверка не умеет читать message.from_user.id
DНикакой разницы нет, это два названия одного и того же
4. Почему в обработчике нажатий admin:-кнопок нужно ещё раз проверять, что пользователь админ, хотя меню показывали только админу?
AЭто требование aiogram для всех callback-хэндлеров
BЧтобы бот работал быстрее
CКнопка живёт под сообщением и callback может прийти от чужого (например, через пересланное сообщение), поэтому права проверяют при каждом действии
DПроверка нужна, только если кнопок больше четырёх
5. Почему список админов из переменной окружения нужно приводить к int через int(...)?
AЧтобы id занимали меньше места в памяти
BПеременные окружения приходят строками, а message.from_user.id — число; без int проверка id in множество всегда даст False
Cint обязателен для любой работы со множествами
DЭто нужно только для красоты вывода
6. Зачем в конце callback-хэндлера вызывают await callback.answer()?
AЧтобы отправить пользователю ещё одно сообщение
BЧтобы убрать крутящиеся «часики» на кнопке — иначе кажется, что бот завис
CЧтобы сохранить нажатие в базу данных
DЭто удаляет кнопку из сообщения