diff --git a/Dockerfile b/Dockerfile
index cae31ba..37ec58c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -44,6 +44,7 @@ WORKDIR /app
COPY --chown=lottery:lottery src/ ./src/
COPY --chown=lottery:lottery migrations/ ./migrations/
COPY --chown=lottery:lottery scripts/ ./scripts/
+COPY --chown=lottery:lottery docs/ADMIN_GUIDE.md docs/CASHIER_GUIDE.md docs/SYSTEM_ADMIN_GUIDE.md ./docs/
COPY --chown=lottery:lottery main.py alembic.ini ./
# Создаем необходимые директории
diff --git a/README.md b/README.md
index 645a044..c9e3cc7 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,10 @@
Актуальные изменения: [отчёт о стабилизации и известных ограничениях](docs/STABILIZATION_REPORT.md),
[настройка секретов Drone и автодеплоя](docs/DRONE_DEPLOYMENT.md).
-Кассирам доступна команда `/cashier`; назначение — `/add_cashier TELEGRAM_ID` главным администратором.
+Инструкции: [администратору](docs/ADMIN_GUIDE.md), [кассиру](docs/CASHIER_GUIDE.md),
+[системному администратору](docs/SYSTEM_ADMIN_GUIDE.md).
+Назначение сотрудников: `/staff` системным администратором, с подтверждением роли.
+Инструкции также доступны в боте: `/admin_help`, `/cashier_help`, `/system_help`.
## 🌟 Ключевые особенности
diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md
index 56a34f6..5750830 100644
--- a/docs/ADMIN_GUIDE.md
+++ b/docs/ADMIN_GUIDE.md
@@ -1,157 +1,71 @@
-````markdown
-# � Полное руководство по админ-панели
+# Инструкция администратора
-## 🎯 Обзор
+Администратор ведёт пользователей, участников и розыгрыши, работает с рассылками и модерацией чата. Назначение сотрудников доступно только системному администратору.
-Админ-панель предоставляет полный контроль над ботом через удобный интерфейс в Telegram. Доступ: команда `/admin` для администраторов.
+## Вход и навигация
-## 📍 Главное меню
+1. Откройте личный диалог с ботом и отправьте `/start`.
+2. Откройте `/admin` или «⚙️ Админ панель».
+3. Используйте разделы «Розыгрыши», «Победители», «Участники», «Пользователи», «Рассылки», «Статистика» и «Настройки».
+4. `/cancel` отменяет текущий ввод. После отмены откройте нужный раздел заново. Уже сохранённые операции эта команда не отменяет.
-```
-🎲 Управление розыгрышами 👥 Управление участниками
-👑 Управление победителями 📊 Статистика и отчеты
-⚙️ Настройки системы
-```
+Эта инструкция доступна по `/admin_help` и кнопке «Инструкция администратора». Инструкция по кассовым операциям — `/cashier_help`.
----
+## Работа с пользователями
-## 🎲 Управление розыгрышами
+1. Попросите клиента выполнить `/start`, затем пройти «Регистрация»: указать данные, которые запрашивает бот.
+2. Откройте «Пользователи» и найдите клиента по точному Telegram ID, клубной карте или username. При нескольких совпадениях сверяйте профиль и карту.
+3. Проверьте регистрацию, клубную карту, связанные счета и участия. Введённая пользователем карта сама по себе не доказывает право владения: сверяйте её по принятой в вашей организации процедуре.
+4. Для привязки счёта используйте `/add_account КАРТА СЧЁТ`, например `/add_account 2223 11-22-33-44-55-66-77`. Затем выберите розыгрыш, если нужно добавить участие.
+5. Для деактивации ошибочного счёта: `/remove_account 11-22-33-44-55-66-77`. Если счёт уже включён в открытый розыгрыш, отдельно проверьте и исправьте участие в разделе «Участники».
-### ➕ Создание розыгрыша
-**Мастер создания в 4 шага:**
+Карту, телефон и код получения приза обсуждайте в личном диалоге с клиентом. Общий чат распространяет сообщения другим участникам.
-1. **Название** - введите краткое название
-2. **Описание** - подробное описание розыгрыша
-3. **Призы** - список призов (каждый с новой строки)
-4. **Подтверждение** - проверка и создание
+## Подготовка розыгрыша
-**Пример:**
-```
-Название: iPhone 15 Pro Max + призы
-Описание: Крутой розыгрыш с айфоном и дополнительными призами
-Призы:
-iPhone 15 Pro Max 512GB
-AirPods Pro 2
-Беспроводная зарядка
-Чехол Apple
-```
+1. «Розыгрыши» → «Создать».
+2. Введите название, описание (или `-` для пропуска) и призы, каждый с новой строки. Порядок призов соответствует местам.
+3. Проверьте итоговый экран и нажмите «Создать».
+4. В «Список всех» откройте карточку, запишите ID розыгрыша и передайте его кассирам вместе с правилами участия.
+5. До начала проведения исправьте название, описание или призы через «Редактировать».
-### 📋 Просмотр розыгрышей
-- **Все розыгрыши** с краткой информацией
-- **Детальная информация** при выборе
-- **Статус**: 🟢 Активный / 🔵 Проведен / 🟡 Ожидает
-- **Количество участников** и победителей
+Созданный розыгрыш принимает участников. Завершённый розыгрыш не принимает новые участия.
-### ✏️ Редактирование
-- **Изменение названия** и описания
-- **Добавление/удаление призов**
-- **Изменение статуса** розыгрыша
+## Добавление и проверка участников
-### 🗑️ Удаление
-- **Безопасное удаление** со всеми связанными данными
-- **Подтверждение** перед удалением
-- **Автоматическая очистка** участников и победителей
+- «Участники» → «Добавить»: выберите розыгрыш и введите Telegram ID или username согласно подсказке.
+- «Участники» → «Массовые операции» → «Добавление по счетам»: выберите розыгрыш и передайте номера в формате `11-22-33-44-55-66-77`.
+- Кассир может добавлять счета через `/cashier`; все сотрудники работают с общей базой, каждый ведёт собственный диалог ввода.
+- Перед проведением проверьте «По розыгрышам» / «Отчёт»: состав, количество участников, отсутствие ошибочно добавленных счетов.
+- Ошибочное участие удаляйте через «Удалить» либо соответствующую массовую операцию до проведения розыгрыша. Повторное добавление того же счёта в один розыгрыш не создаёт второй билет.
----
+Бот поддерживает участие по пользователю и по счёту. Используйте способ, соответствующий опубликованным правилам. Счёт может участвовать без связанного профиля; в этом случае автоматическое личное уведомление владельцу невозможно до установления связи.
-## 👥 Управление участниками
+## Проведение и выдача призов
-### ➕ Добавление участников
+1. Проверьте призы, состав участников и выбранный розыгрыш. Если использовались ручные назначения победителей, проверьте их до запуска: они учитываются при проведении.
+2. «Розыгрыши» → «Провести розыгрыш» → нужный розыгрыш. Выполняйте подтверждения, показанные ботом.
+3. Проверьте сохранённый результат в «Победители» → «Список победителей». Проведение закрывает розыгрыш для новых участников.
+4. Передайте кассиру ID розыгрыша. Статус выдачи: `/winner_status ID`, подтверждение по личному коду клиента: `/verify_winner КОД ID`.
+5. При сетевой ошибке сначала проверьте сохранённый результат или статус выдачи. Повторный запуск не должен использоваться для «исправления» недоставленного уведомления.
-**Одиночное добавление:**
-```
-Пользователь: @username или ID
-Выберите розыгрыш: [список доступных]
-```
+«Завершить» закрывает розыгрыш; «Удалить» удаляет его данные. Не используйте удаление для архивирования результатов. Ручные изменения и повторные розыгрыши применяйте только по правилам вашего мероприятия.
-**Массовое добавление:**
-```
-Формат: ID1,ID2,ID3 или @user1,@user2,@user3
-Выберите розыгрыш: [список]
-Автоматическое добавление всех валидных пользователей
-```
+## Рассылки и чат
-### 👁️ Просмотр участников
-- **По розыгрышам** - участники конкретного розыгрыша
-- **Общий список** - все зарегистрированные пользователи
-- **Детальная информация**: ID, username, дата регистрации
-- **Количество участий** каждого пользователя
+1. «Рассылки» → выберите отправку в личные сообщения либо настроенный канал/группу.
+2. Отправьте готовое сообщение. После отправки текста/медиа рассылка запускается сразу.
+3. Во время рассылки можно открыть `/admin` и продолжить работу. Одновременно выполняются до двух рассылок, по одной от администратора.
+4. Дождитесь отчёта с количеством доставленных и недоставленных сообщений. Недоступный получатель не останавливает остальных. Долгое ожидание Telegram или ошибка сообщения учитываются как неудачная доставка.
+5. Если рассылка прервана, часть получателей уже могла получить сообщение. Проверьте журнал и результат перед повтором. После перезапуска бота незавершённые фоновые рассылки автоматически не возобновляются.
-### 🗑️ Удаление участников
-- **Из конкретного розыгрыша**
-- **Полное удаление пользователя** из системы
-- **Подтверждение** перед удалением
+Премиум-эмодзи и форматирование сохраняются при копировании исходного сообщения в пределах возможностей Telegram. Ошибка отдельного эмодзи не помечает пользователя как заблокировавшего бот.
----
+`/chat` включает общий чат, `/exit` выходит из него. Для модерации используйте административные разделы сообщений и чата. Не отправляйте служебные коды и данные клиентов в общий чат.
-## 👑 Управление победителями (Ключевая функция)
+## Если действие не сработало
-### 🎯 Установка ручных победителей
-
-**Процесс:**
-1. **Выберите розыгрыш** из списка
-2. **Укажите место** (1, 2, 3...)
-3. **Выберите пользователя** из участников
-4. **Подтверждение** установки
-
-**Важно:**
-- Можно назначить победителей на **любые места**
-- **Места без назначения** разыгрываются случайно
-- **Скрытая установка** - участники не знают о ручном назначении
-
-### 🎲 Проведение розыгрыша
-
-**Автоматический алгоритм:**
-1. **Ручные победители** автоматически занимают свои места
-2. **Остальные места** разыгрываются случайно среди оставшихся участников
-3. **Результат** выглядит полностью случайным для всех участников
-
-**Пример результата:**
-```
-🏆 Результаты розыгрыша "iPhone + призы"
-
-🥇 1 место: @winner (iPhone 15 Pro) 👑
-🥈 2 место: @random_user (AirPods) 🎲
-🥉 3 место: @preset_user (Зарядка) 👑
-🏅 4 место: @another_random (Чехол) 🎲
-```
-👑 = Ручной победитель | 🎲 = Случайный
-
-### 📊 Просмотр победителей
-- **По розыгрышам** - все победители конкретного розыгрыша
-- **История побед** - все победы пользователя
-- **Типы побед**: Ручные (👑) и Случайные (🎲)
-- **Статистика** по каждому пользователю
-
----
-
-## 📊 Статистика и отчеты
-
-### � Общая статистика
-```
-👥 Общее количество пользователей: 1,234
-🎲 Общее количество розыгрышей: 45
-👑 Общее количество победителей: 180
-💎 Общее количество призов: 180
-```
-
-### 🏆 Топ рейтинги
-- **Топ-10 пользователей** по количеству участий
-- **Топ-10 победителей** по количеству побед
-- **Самые популярные розыгрыши** по участию
-- **Недавняя активность** (последние 10 действий)
-
-### 📁 Экспорт данных
-- **JSON отчеты** со всей статистикой
-- **Детальная информация** по всем сущностям
-- **Готовые файлы** для анализа и архивирования
-
-### 📊 Производительность
-- **Время ответа** системы
-- **Использование памяти** бота
-- **Статистика использования** админ-панели
-
----
-
-(файл сокращён для краткости в docs)
-````
\ No newline at end of file
+- «Недостаточно прав»: обратитесь к системному администратору; кассиру административная панель недоступна.
+- Бот ждёт другой ввод: `/cancel`, затем нужная команда.
+- Нет активного розыгрыша: проверьте статус и ID в «Список всех».
+- Нет ответа: проверьте `/start`; сообщите системному администратору время, действие и ID розыгрыша. Не повторяйте выдачу приза, пока не проверен `/winner_status`.
diff --git a/docs/ADMIN_MANAGEMENT_SYSTEM.md b/docs/ADMIN_MANAGEMENT_SYSTEM.md
index e1c10a0..ebc7813 100644
--- a/docs/ADMIN_MANAGEMENT_SYSTEM.md
+++ b/docs/ADMIN_MANAGEMENT_SYSTEM.md
@@ -1,3 +1,10 @@
+# Актуальная инструкция
+
+Назначение администраторов и кассиров выполняется через `/staff` с подтверждением по точному Telegram ID.
+См. [инструкцию системного администратора](SYSTEM_ADMIN_GUIDE.md). Описание старого меню ниже сохранено для истории.
+
+---
+
# Система управления администраторами
## Описание
diff --git a/docs/CASHIER_GUIDE.md b/docs/CASHIER_GUIDE.md
new file mode 100644
index 0000000..a824cea
--- /dev/null
+++ b/docs/CASHIER_GUIDE.md
@@ -0,0 +1,79 @@
+# Инструкция кассира
+
+Кассир привязывает счета к клиентам, добавляет участие в открытых розыгрышах, проверяет победителей и отмечает выдачу призов. Создание и проведение розыгрышей, изменение победителей, управление сотрудниками, рассылки и модерация доступны другим ролям.
+
+## Начало смены
+
+1. В личном диалоге с ботом выполните `/start`, затем `/cashier`.
+2. Если доступа нет, отправьте `/my_id` и передайте показанный Telegram ID системному администратору для назначения роли кассира.
+3. Получите от администратора ID и название нужного розыгрыша, условия участия и порядок выдачи призов.
+4. Инструкция доступна по `/cashier_help` и кнопке «Инструкция кассира» в кассе.
+
+Работайте со своего Telegram-аккаунта. Другой кассир может одновременно выполнять свои операции. `/cancel` отменяет текущий ввод, но не откатывает уже сохранённые счета или выдачу.
+
+## Привязка счёта к клиенту
+
+1. Клиент должен запустить бот и пройти регистрацию. Сверьте клубную карту с клиентом по рабочей процедуре.
+2. Проверьте профиль клиента командой `/user_info 2223`, где `2223` — его клубная карта. Затем отправьте команду, указав карту и счёт:
+
+ ```text
+ /add_account 2223 11-22-33-44-55-66-77
+ ```
+
+3. Проверьте ответ бота: карту, владельца и номер счёта.
+4. Если нужно участие, выберите предложенный розыгрыш. Привязка счёта к клиенту и добавление в розыгрыш — отдельные операции.
+
+Для нескольких клиентов отправьте `/add_account` без аргументов и следуйте подсказке: по одной паре `КАРТА СЧЁТ` на строку. Проверяйте итоговый отчёт, особенно пропуски и ошибки.
+
+## Добавление счетов в розыгрыш
+
+1. `/cashier` → выберите нужное название открытого розыгрыша.
+2. Отправьте счета, каждый с новой строки:
+
+ ```text
+ 11-22-33-44-55-66-77
+ 88-99-00-11-22-33-44
+ ```
+
+3. За один ввод допускается до 1000 распознанных счетов.
+4. Проверьте количества «Добавлено», «Пропущено» и ошибки. Повтор того же счёта в том же розыгрыше не создаёт второго участия.
+
+Если розыгрыш уже проведён или закрыт, бот отклонит добавление. Если активных розыгрышей нет, обратитесь к администратору. Кассир не может открыть или создать розыгрыш.
+
+Счёт без привязки к клиенту тоже может быть добавлен. Чтобы клиент получил личное уведомление и мог подтвердить выигрыш по коду, сначала проверьте привязку счёта к его зарегистрированному профилю.
+
+## Исправление счёта
+
+Для деактивации:
+
+```text
+/remove_account 11-22-33-44-55-66-77
+```
+
+Деактивация не заменяет удаление уже созданного участия. Если счёт попал не в тот розыгрыш, сообщите администратору ID розыгрыша и номер счёта: он проверит и исправит участие. Не добавляйте произвольный новый счёт вместо ошибочного.
+
+## Проверка и выдача приза
+
+1. Узнайте ID проведённого розыгрыша у администратора.
+2. Проверьте выдачу: `/winner_status 12`, где `12` — пример ID.
+3. Попросите клиента открыть «Мой код» в своём боте. Сверьте личность/карту и приз по рабочей процедуре. Код не публикуется в общем чате.
+4. Когда готовы зафиксировать выдачу, отправьте:
+
+ ```text
+ /verify_winner AB12CD34 12
+ ```
+
+5. Проверьте подтверждение бота, место, приз и данные победителя. Повторно проверьте `/winner_status 12` при сомнении.
+
+Команда `/verify_winner` изменяет статус: выигрыш помечается полученным. Это не просто поиск. Повторное подтверждение одного выигрыша отклоняется, в том числе при одновременной работе кассиров. Ошибка уведомления победителю не отменяет сохранённое подтверждение выдачи.
+
+Если выигрыш не найден, проверьте код, ID, завершение розыгрыша и предыдущую выдачу. Если у клиента несколько призовых мест, согласуйте с администратором порядок выдачи: команда подтверждает одно ещё не полученное место за вызов. При отсутствии привязки победившего счёта к профилю обратитесь к администратору.
+
+## Когда обращаться к администратору
+
+- Неверная карта или владелец, неизвестный счёт, ошибка участия.
+- Нет открытого розыгрыша или требуется изменение результата.
+- Код не подходит, статус выдачи расходится с фактической выдачей.
+- Бот не отвечает: попробуйте `/start`, затем сообщите время, команду и ID розыгрыша. Не повторяйте выдачу без проверки статуса.
+
+Кассиру доступны обычные пользовательские функции, включая чат. `/chat` распространяет сообщения участникам; `/exit` завершает режим чата. Все кассовые команды выполняйте в личном диалоге с ботом.
diff --git a/docs/SYSTEM_ADMIN_GUIDE.md b/docs/SYSTEM_ADMIN_GUIDE.md
new file mode 100644
index 0000000..f62ef90
--- /dev/null
+++ b/docs/SYSTEM_ADMIN_GUIDE.md
@@ -0,0 +1,54 @@
+# Инструкция системного администратора
+
+Системный администратор назначает сотрудников и имеет все рабочие права администратора. Его собственный доступ задаётся ответственным за сервер в `ADMIN_IDS` и не снимается через бот.
+
+## Разделение прав
+
+| Действие | Системный администратор | Администратор | Кассир |
+|---|---|---|---|
+| Назначать администраторов/кассиров, снимать роли | Да | Нет | Нет |
+| Просматривать список сотрудников | Да | Нет | Нет |
+| Управлять пользователями, экспортом и модерацией | Да | Да | Нет |
+| Создавать, редактировать, проводить и закрывать розыгрыши | Да | Да | Нет |
+| Управлять результатами и удалять участия | Да | Да | Нет |
+| Запускать административные рассылки | Да | Да | Нет |
+| Привязывать/деактивировать счета | Да | Да | Да |
+| Добавлять участие по счетам в открытый розыгрыш | Да | Да | Да |
+| Проверять и подтверждать выдачу призов | Да | Да | Да |
+
+Обычные пользовательские функции, включая разрешённый общий чат, доступны сотрудникам. Общий чат и административная рассылка имеют разные назначения.
+
+## Назначение сотрудника
+
+1. Сотрудник выполняет `/start`, затем `/my_id` и передаёт вам свой числовой Telegram ID. Сверьте ID с самим сотрудником.
+2. Откройте `/staff` или `/admin` → «Настройки» → «Сотрудники и права».
+3. Выберите «Назначить администратора» или «Назначить кассира».
+4. Введите точный Telegram ID. Поиск по имени при выдаче прав не используется.
+5. Проверьте имя, ID, текущую и новую роль. Нажмите «Подтвердить изменение».
+6. Проверьте «Список сотрудников». Попросите сотрудника открыть `/admin` или `/cashier`.
+
+Можно начать ту же операцию командами `/add_admin TELEGRAM_ID` или `/add_cashier TELEGRAM_ID`. Они также требуют подтверждения кнопкой.
+
+## Смена роли и отзыв доступа
+
+У сотрудника одна назначенная роль. Назначение кассиром бывшего администратора снимает его административные права; назначение администратором заменяет роль кассира.
+
+Для полного отзыва рабочих прав выберите «Снять права сотрудника», введите ID и подтвердите. Пользователь сохраняет обычный доступ к боту. Команды `/remove_admin ID` и `/remove_cashier ID` снимают только соответствующую текущую роль; при несовпадении операция отклоняется.
+
+Изменения применяются при следующем действии сотрудника, включая продолжение открытого диалога. Уже запущенная рассылка завершается отдельно. Повторное нажатие подтверждения не выполняет назначение снова. Если другой системный администратор успел изменить права, откройте назначение заново и проверьте актуальную роль.
+
+Системных администраторов нельзя назначать, понижать или удалять через эти команды. Если кассир закреплён в `CASHIER_IDS`, бот направит к ответственному за сервер. Для новых сотрудников используйте назначение через `/staff`; изменение серверной конфигурации и перезапуск не нужны.
+
+`/admins` и `/cashiers` открывают общий список сотрудников с указанием фактических ролей. Старые кнопки назначения ведут в новый раздел и не меняют права автоматически.
+
+## Инструкции сотрудникам
+
+- Администратор: `/admin_help` или [инструкция администратора](ADMIN_GUIDE.md).
+- Кассир: `/cashier_help` или [инструкция кассира](CASHIER_GUIDE.md).
+- Эта инструкция: `/system_help` или кнопка в «Сотрудники и права».
+
+## Проверка работы
+
+При проблеме запишите время, команду, роль и ID розыгрыша. Проверьте `/start`. Перед повторной рассылкой или выдачей приза проверьте журнал/статус: недоставленный ответ не означает, что операция не была сохранена.
+
+Ответственный за сервер проверяет состояние контейнера, журнал, доступность базы данных и результат последнего деплоя по [инструкции Drone](DRONE_DEPLOYMENT.md). Изменения ролей журналируются с инициатором, получателем и новой ролью. Не передавайте токены и пароли сотрудникам для назначения через бот.
diff --git a/main.py b/main.py
index be61cd0..65b237f 100644
--- a/main.py
+++ b/main.py
@@ -306,6 +306,10 @@ def configure_dispatcher():
# Подключаем роутеры в правильном порядке
# 1. Основной роутер main.py с базовыми командами (/start, /help, /admin)
dp.include_router(router)
+ from src.handlers.operator_help import operator_help_router
+ from src.handlers.staff_handlers import staff_router
+ dp.include_router(operator_help_router)
+ dp.include_router(staff_router)
from src.handlers.cashier_handlers import cashier_router
dp.include_router(cashier_router)
@@ -367,6 +371,8 @@ async def main():
HEARTBEAT.unlink(missing_ok=True)
# Останавливаем планировщик
bot_scheduler.shutdown()
+ from src.utils.broadcast_jobs import shutdown_broadcasts
+ await shutdown_broadcasts()
await bot.session.close()
await storage.close()
await close_db()
diff --git a/scripts/build_release.py b/scripts/build_release.py
index 2b29851..472cffb 100644
--- a/scripts/build_release.py
+++ b/scripts/build_release.py
@@ -10,7 +10,8 @@ def build_release(destination=None):
destination = Path(destination or ROOT / "dist" / "lottery.tar.gz")
destination.parent.mkdir(parents=True, exist_ok=True)
allowed = ["src", "migrations", "scripts", "main.py", "requirements.txt", "alembic.ini",
- "Dockerfile", "docker-compose.yml", ".dockerignore"]
+ "Dockerfile", "docker-compose.yml", ".dockerignore", "docs/ADMIN_GUIDE.md",
+ "docs/CASHIER_GUIDE.md", "docs/SYSTEM_ADMIN_GUIDE.md"]
with tarfile.open(destination, "w:gz") as archive:
for name in allowed:
entry = ROOT / name
diff --git a/scripts/examples.py b/scripts/examples.py
index 62637a1..7ec9023 100644
--- a/scripts/examples.py
+++ b/scripts/examples.py
@@ -23,7 +23,7 @@ async def example_usage():
first_name="Администратор",
last_name="Бота"
)
- await UserService.set_admin(session, 123456789, True)
+ # Staff roles are assigned by a system administrator through /staff.
print(f"✅ Создан админ: {admin.first_name} (@{admin.username})")
# Создаем обычных пользователей
diff --git a/scripts/manage_admins.py b/scripts/manage_admins.py
index eab4f25..64f697e 100644
--- a/scripts/manage_admins.py
+++ b/scripts/manage_admins.py
@@ -51,60 +51,11 @@ async def list_admins():
async def add_admin(telegram_id: int):
- """Добавить администратора"""
- async with async_session_maker() as session:
- # Проверяем, существует ли пользователь
- user = await UserService.get_user_by_telegram_id(session, telegram_id)
-
- if not user:
- print(f"❌ Пользователь с ID {telegram_id} не найден")
- return
-
- if telegram_id in ADMIN_IDS:
- print(f"❌ ID {telegram_id} - это главный администратор (.env)")
- return
-
- if user.is_admin:
- print(f"❌ Пользователь {user.first_name or user.username} уже администратор")
- return
-
- # Назначаем админа
- success = await UserService.set_admin(session, telegram_id, is_admin=True)
-
- if success:
- name = user.first_name or user.username or f"@ID_{telegram_id}"
- print(f"✅ {name} назначен администратором")
- else:
- print(f"❌ Ошибка при назначении администратора")
+ print(f"Use /add_admin {telegram_id} in the bot as a system administrator and confirm the change.")
async def remove_admin(telegram_id: int):
- """Удалить администратора"""
- async with async_session_maker() as session:
- if telegram_id in ADMIN_IDS:
- print(f"❌ Нельзя удалить главного администратора (.env)")
- print(f" Для изменения отредактируйте .env")
- return
-
- # Проверяем, существует ли пользователь
- user = await UserService.get_user_by_telegram_id(session, telegram_id)
-
- if not user:
- print(f"❌ Пользователь с ID {telegram_id} не найден")
- return
-
- if not user.is_admin:
- print(f"❌ Пользователь {user.first_name or user.username} не является администратором")
- return
-
- # Удаляем админа
- success = await UserService.set_admin(session, telegram_id, is_admin=False)
-
- if success:
- name = user.first_name or user.username or f"@ID_{telegram_id}"
- print(f"✅ Права администратора удалены у {name}")
- else:
- print(f"❌ Ошибка при удалении прав администратора")
+ print(f"Use /remove_admin {telegram_id} in the bot as a system administrator and confirm the change.")
async def main():
diff --git a/src/core/broadcast_services.py b/src/core/broadcast_services.py
index 3f19fe5..e9944ae 100644
--- a/src/core/broadcast_services.py
+++ b/src/core/broadcast_services.py
@@ -6,7 +6,7 @@ import json
import logging
from typing import Optional, List, Dict, Tuple, Any
from datetime import datetime, timezone
-from src.utils.delivery import background_delivery
+from src.utils.delivery import background_delivery, MAX_RETRY_AFTER
from aiogram import Bot
from aiogram.types import Message
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
@@ -94,7 +94,6 @@ class BroadcastService:
# Лимиты Telegram
BATCH_SIZE = 30 # Сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
- RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
def __init__(self):
self.redis_queue = RedisQueue()
@@ -230,10 +229,10 @@ class BroadcastService:
except TelegramRetryAfter as e:
# FloodWait - слишком много запросов
- logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
- if _retry:
+ logger.warning("Telegram rate limit: retry_after=%s", e.retry_after)
+ if _retry or e.retry_after > MAX_RETRY_AFTER:
return False, "rate_limited"
- await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
+ await asyncio.sleep(e.retry_after)
# Повторная попытка
return await self.send_message_to_user.__wrapped__(self, bot, user, message, _retry=True)
diff --git a/src/core/services.py b/src/core/services.py
index 49aa387..ee44324 100644
--- a/src/core/services.py
+++ b/src/core/services.py
@@ -80,10 +80,15 @@ class UserService:
@staticmethod
async def delete_user(session: AsyncSession, user_id: int) -> bool:
"""Удалить пользователя и все связанные данные"""
- user = await session.get(User, user_id)
+ user = await session.scalar(select(User).where(User.id == user_id).with_for_update()
+ .execution_options(populate_existing=True))
if not user:
return False
+ from .config import ADMIN_IDS, CASHIER_IDS
+ if user.is_admin or user.is_cashier or user.telegram_id in set(ADMIN_IDS) | set(CASHIER_IDS):
+ return False
+
# Keep records needed by draws, claims, chat history, or staff audit trails.
# Cleanup may delete only users without linked data; moderation uses bans.
from .database import Base
@@ -107,17 +112,6 @@ class UserService:
await session.commit()
return True
- @staticmethod
- async def set_admin(session: AsyncSession, telegram_id: int, is_admin: bool = True) -> bool:
- """Установить/снять права администратора"""
- result = await session.execute(
- update(User)
- .where(User.telegram_id == telegram_id)
- .values(is_admin=is_admin)
- )
- await session.commit()
- return result.rowcount > 0
-
@staticmethod
async def set_account_number(session, telegram_id, account_number):
from sqlalchemy.exc import IntegrityError
diff --git a/src/core/staff_service.py b/src/core/staff_service.py
new file mode 100644
index 0000000..38ea877
--- /dev/null
+++ b/src/core/staff_service.py
@@ -0,0 +1,95 @@
+"""System-admin-only staff changes with optimistic concurrency protection."""
+from dataclasses import dataclass
+import logging
+
+from sqlalchemy import func, select, update
+
+from .config import ADMIN_IDS, CASHIER_IDS
+from .database import async_session_maker
+from .models import User
+
+logger = logging.getLogger(__name__)
+ROLE_NAMES = {"user": "Пользователь", "cashier": "Кассир", "admin": "Администратор",
+ "super_admin": "Системный администратор"}
+
+
+class StaffError(ValueError):
+ """An operator-facing error; no database details."""
+
+
+def require_system_admin(actor_id):
+ if actor_id not in ADMIN_IDS:
+ raise StaffError("Назначать сотрудников может только системный администратор.")
+
+
+def effective_role(telegram_id, admin=False, cashier=False):
+ if telegram_id in ADMIN_IDS:
+ return "super_admin"
+ if admin:
+ return "admin"
+ if cashier or telegram_id in CASHIER_IDS:
+ return "cashier"
+ return "user"
+
+
+@dataclass(frozen=True)
+class StaffMember:
+ telegram_id: int
+ name: str
+ role: str
+ flags: tuple[bool, bool]
+
+
+async def get_member(actor_id, telegram_id):
+ require_system_admin(actor_id)
+ async with async_session_maker() as session:
+ user = await session.scalar(select(User).where(User.telegram_id == telegram_id))
+ if user is None:
+ raise StaffError("Пользователь не найден. Сначала он должен отправить боту /start.")
+ flags = (bool(user.is_admin), bool(user.is_cashier))
+ return StaffMember(telegram_id, user.nickname or user.first_name or user.username or "Без имени",
+ effective_role(telegram_id, *flags), flags)
+
+
+def check_editable(actor_id, telegram_id):
+ require_system_admin(actor_id)
+ if telegram_id in ADMIN_IDS:
+ raise StaffError("Права системного администратора нельзя изменить через бот.")
+ if telegram_id in CASHIER_IDS:
+ raise StaffError("Роль этого сотрудника закреплена в настройках сервера. Обратитесь к ответственному за сервер.")
+
+
+async def change_role(actor_id, telegram_id, role, expected_flags):
+ check_editable(actor_id, telegram_id)
+ if role not in {"user", "admin", "cashier"} or len(expected_flags) != 2:
+ raise StaffError("Недопустимая роль.")
+ # Compare and change in one statement: a second operator cannot overwrite
+ # a role changed since the confirmation screen was opened.
+ async with async_session_maker() as session:
+ result = await session.execute(update(User).where(
+ User.telegram_id == telegram_id,
+ func.coalesce(User.is_admin, False) == bool(expected_flags[0]),
+ User.is_cashier == bool(expected_flags[1]),
+ ).values(is_admin=role == "admin", is_cashier=role == "cashier"))
+ if result.rowcount != 1:
+ await session.rollback()
+ raise StaffError("Права уже изменены другим администратором или пользователь удалён. Откройте назначение заново.")
+ await session.commit()
+ logger.warning("Staff role changed: actor=%s target=%s previous_flags=%s role=%s",
+ actor_id, telegram_id, tuple(expected_flags), role)
+
+
+async def list_members(actor_id, offset=0, limit=20):
+ require_system_admin(actor_id)
+ configured = set(ADMIN_IDS) | set(CASHIER_IDS)
+ async with async_session_maker() as session:
+ rows = (await session.scalars(select(User).where(
+ User.is_admin.is_(True) | User.is_cashier.is_(True) | User.telegram_id.in_(configured)
+ ).order_by(User.telegram_id))).all()
+ members = {u.telegram_id: StaffMember(u.telegram_id, u.nickname or u.first_name or u.username or "Без имени",
+ effective_role(u.telegram_id, u.is_admin, u.is_cashier), (bool(u.is_admin), bool(u.is_cashier))) for u in rows}
+ for telegram_id in configured - members.keys():
+ members[telegram_id] = StaffMember(telegram_id, "Ещё не запускал бот",
+ effective_role(telegram_id), (False, False))
+ ordered = sorted(members.values(), key=lambda member: member.telegram_id)
+ return ordered[offset:offset + limit], len(ordered)
diff --git a/src/display/demo_admin.py b/src/display/demo_admin.py
index c191322..f25bf36 100644
--- a/src/display/demo_admin.py
+++ b/src/display/demo_admin.py
@@ -23,7 +23,7 @@ async def demo_admin_features():
first_name="Администратор",
last_name="Системы"
)
- await UserService.set_admin(session, 123456789, True)
+ # Staff roles are assigned by a system administrator through /staff.
print(f"✅ Создан администратор: {admin.first_name}")
# Создаем тестовых пользователей
diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py
index 4036126..eb11cb9 100644
--- a/src/handlers/admin_panel.py
+++ b/src/handlers/admin_panel.py
@@ -107,11 +107,6 @@ class AdminStates(StatesGroup):
user_management_view = State() # Просмотр пользователя
# Управление админами
- admin_management_action = State() # Выбор действия (добавить/удалить)
- admin_add_search = State() # Поиск пользователя для назначения админом
- admin_add_confirm = State() # Подтверждение назначения
- admin_remove_select = State() # Выбор админа для удаления
- admin_remove_confirm = State() # Подтверждение удаления
admin_router = Router()
@@ -136,6 +131,7 @@ def get_admin_main_keyboard() -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="📢 Рассылки", callback_data="admin_broadcast"),
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
+ [InlineKeyboardButton(text="📖 Инструкция администратора", callback_data="guide:admin")],
[InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -3571,7 +3567,7 @@ async def show_admin_settings(callback: CallbackQuery):
# Кнопка управления админами - только для главных админов
if is_super_admin(callback.from_user.id):
- buttons.append([InlineKeyboardButton(text="👑 Управление админами", callback_data="admin_manage_admins")])
+ buttons.append([InlineKeyboardButton(text="👥 Сотрудники и права", callback_data="staff:menu")])
buttons.extend([
[InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")],
@@ -3733,27 +3729,11 @@ async def cleanup_inactive_users(callback: CallbackQuery):
)
inactive_users = result.scalars().all()
- # Проверяем, что у них нет связанных данных
deleted_count = 0
for user in inactive_users:
- # Проверяем участия
- participations = await session.execute(
- select(Participation).where(Participation.user_id == user.id)
- )
- if participations.scalars().first():
- continue
-
- # Проверяем счета
- accounts = await session.execute(
- select(Account).where(Account.user_id == user.id)
- )
- if accounts.scalars().first():
- continue
-
- # Безопасно удаляем
- await session.delete(user)
- deleted_count += 1
-
+ if await UserService.delete_user(session, user.id):
+ deleted_count += 1
+
await session.commit()
await callback.message.edit_text(
@@ -4660,7 +4640,11 @@ async def broadcast_select_channel(callback: CallbackQuery, state: FSMContext):
await state.set_state(AdminStates.broadcast_message)
-@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document)
+BROADCAST_FAILURE_TEXT = "❌ Рассылка прервана. Часть сообщений могла быть доставлена. Проверьте журнал рассылок перед повторной отправкой. Меню доступно: /admin."
+BROADCAST_BUSY_TEXT = "⏳ Ваша предыдущая рассылка ещё выполняется или заняты оба места отправки. Повторите запуск позже через /admin → Рассылки."
+
+
+@admin_router.message(StateFilter(AdminStates.broadcast_message), F.text | F.photo | F.video | F.document | F.animation | F.audio | F.voice | F.sticker)
async def admin_broadcast_send(message: Message, state: FSMContext):
"""Обработка и отправка рассылки"""
if not await check_admin_access(message.from_user.id):
@@ -4669,21 +4653,40 @@ async def admin_broadcast_send(message: Message, state: FSMContext):
data = await state.get_data()
broadcast_type = data.get('broadcast_type', 'direct')
- if broadcast_type == 'direct':
- # Рассылка в ЛС
- await _broadcast_direct(message, state)
- else:
- # Рассылка в канал/группу
- await _broadcast_channel(message, state, data)
+ from src.utils.broadcast_jobs import start_broadcast
+ await state.clear()
+
+ async def run():
+ if broadcast_type == 'direct':
+ await _broadcast_direct(message)
+ else:
+ await _broadcast_channel(message, data)
+
+ async def report_error():
+ await message.answer(BROADCAST_FAILURE_TEXT)
+
+ if not start_broadcast(message.from_user.id, run, report_error):
+ await message.answer(BROADCAST_BUSY_TEXT)
-async def _broadcast_direct(message: Message, state: FSMContext):
+async def _edit_broadcast_status(status_msg, text, **kwargs):
+ import asyncio
+ import logging
+ try:
+ async with asyncio.timeout(15):
+ await status_msg.edit_text(text, **kwargs)
+ except Exception as error:
+ # A missing status message must not turn a completed delivery into a failed job.
+ logging.getLogger(__name__).warning("Cannot update broadcast report: %s", type(error).__name__)
+
+
+async def _broadcast_direct(message: Message):
"""Рассылка в личные сообщения"""
# Отправляем уведомление о начале рассылки
status_msg = await message.answer(
"📤 Начинаю рассылку в ЛС...\n\n"
- "⏳ Подождите, это может занять некоторое время.\n"
- "💡 Используется Redis очередь и отслеживание заблокированных пользователей.",
+ "Меню доступно во время отправки: /admin.\n"
+ "Недоступные получатели будут пропущены; результат появится здесь.",
parse_mode="HTML"
)
@@ -4703,12 +4706,11 @@ async def _broadcast_direct(message: Message, state: FSMContext):
# Проверяем, есть ли пользователи для рассылки
if not registered_users:
- await status_msg.edit_text(
+ await _edit_broadcast_status(status_msg,
"⚠️ Нет зарегистрированных пользователей\n\n"
"Рассылка невозможна, так как нет ни одного зарегистрированного пользователя.",
parse_mode="HTML"
)
- await state.clear()
return
# Используем новый сервис рассылок
@@ -4723,7 +4725,7 @@ async def _broadcast_direct(message: Message, state: FSMContext):
delivery_percent = (stats['success'] / stats['total'] * 100) if stats['total'] > 0 else 0
# Итоговый отчет
- await status_msg.edit_text(
+ await _edit_broadcast_status(status_msg,
f"✅ Рассылка завершена!\n\n"
f"📊 Статистика:\n"
f"👥 Всего получателей: {stats['total']}\n"
@@ -4734,16 +4736,14 @@ async def _broadcast_direct(message: Message, state: FSMContext):
parse_mode="HTML"
)
- await state.clear()
-async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
+async def _broadcast_channel(message: Message, data: dict):
"""Рассылка в канал или группу"""
channel_db_id = data.get('channel_db_id')
if not channel_db_id:
await message.answer("❌ Ошибка: не выбран канал")
- await state.clear()
return
# Получаем информацию о канале и администратора
@@ -4764,7 +4764,6 @@ async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
if not channel:
await message.answer("❌ Ошибка: канал не найден")
- await state.clear()
return
# Отправляем уведомление
@@ -4783,20 +4782,19 @@ async def _broadcast_channel(message: Message, state: FSMContext, data: dict):
)
if success:
- await status_msg.edit_text(
+ await _edit_broadcast_status(status_msg,
f"✅ Сообщение отправлено!\n\n"
f"📱 {'Канал' if channel.chat_type == 'channel' else 'Группа'}: {channel.title}",
parse_mode="HTML"
)
else:
- await status_msg.edit_text(
+ await _edit_broadcast_status(status_msg,
f"❌ Ошибка отправки\n\n"
f"Не удалось отправить сообщение в {'канал' if channel.chat_type == 'channel' else 'группу'} {channel.title}\n"
f"Проверьте права бота и попробуйте снова.",
parse_mode="HTML"
)
- await state.clear()
# ============================================================================
@@ -5494,255 +5492,4 @@ async def admin_user_unban(callback: CallbackQuery, state: FSMContext):
# УПРАВЛЕНИЕ АДМИНИСТРАТОРАМИ
# =========================
-@admin_router.callback_query(F.data == "admin_manage_admins")
-async def manage_admins_menu(callback: CallbackQuery):
- """Главное меню управления администраторами"""
- if not is_super_admin(callback.from_user.id):
- await callback.answer("❌ Только главные администраторы могут управлять правами", show_alert=True)
- return
-
- await callback.answer()
-
- text = "👑 Управление администраторами\n\n"
- text += f"Главные администраторы (.env): {len(ADMIN_IDS)}\n\n"
- text += "Выберите действие:"
-
- buttons = [
- [InlineKeyboardButton(text="➕ Назначить админа", callback_data="admin_add_admin")],
- [InlineKeyboardButton(text="➖ Удалить админа", callback_data="admin_remove_admin")],
- [InlineKeyboardButton(text="📋 Список админов", callback_data="admin_list_admins_view")],
- [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_settings")]
- ]
-
- await callback.message.edit_text(
- text,
- reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
- parse_mode="HTML"
- )
-
-
-@admin_router.callback_query(F.data == "admin_list_admins_view")
-async def list_admins_view(callback: CallbackQuery):
- """Показать список всех администраторов"""
- if not is_super_admin(callback.from_user.id):
- await callback.answer("❌ Доступ запрещен", show_alert=True)
- return
-
- await callback.answer()
-
- async with async_session_maker() as session:
- from sqlalchemy import select
-
- # Получаем всех администраторов (назначенных через БД)
- result = await session.execute(
- select(User).where(User.is_admin == True).order_by(User.created_at.desc())
- )
- db_admins = result.scalars().all()
-
- text = "👑 Список администраторов\n\n"
-
- # Главные администраторы из .env
- text += "Главные администраторы (из .env):\n"
- for admin_id in ADMIN_IDS:
- text += f"🔴 ID: {admin_id}\n"
-
- text += "\n"
-
- # Назначенные администраторы
- if db_admins:
- text += "Назначенные администраторы:\n"
- for admin in db_admins:
- icon = "🟠" # Назначенный админ
- name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
- text += f"{icon} {name} (ID: {admin.telegram_id})\n"
- else:
- text += "Назначенные администраторы: нет\n"
-
- buttons = [
- [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_manage_admins")]
- ]
-
- await callback.message.edit_text(
- text,
- reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
- parse_mode="HTML"
- )
-
-
-@admin_router.callback_query(F.data == "admin_add_admin")
-async def add_admin_start(callback: CallbackQuery, state: FSMContext):
- """Начать добавление нового администратора"""
- if not is_super_admin(callback.from_user.id):
- await callback.answer("❌ Доступ запрещен", show_alert=True)
- return
-
- await callback.answer()
-
- text = "👤 Назначение администратора\n\n"
- text += "Введите Telegram ID пользователя или его имя для поиска:"
-
- await callback.message.edit_text(
- text,
- reply_markup=InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")]
- ]),
- parse_mode="HTML"
- )
-
- await state.set_state(AdminStates.admin_add_search)
-
-
-@admin_router.message(StateFilter(AdminStates.admin_add_search), F.text)
-async def search_user_for_admin(message: Message, state: FSMContext):
- """Поиск пользователя для назначения админом"""
- if not is_super_admin(message.from_user.id):
- await message.answer("❌ Доступ запрещен")
- return
-
- search_query = message.text.strip()
-
- async with async_session_maker() as session:
- user = None
-
- # Пробуем найти по ID
- try:
- telegram_id = int(search_query)
- user = await UserService.get_user_by_telegram_id(session, telegram_id)
- except ValueError:
- # Если не число, ищем по имени или username
- users = await UserService.search_users(session, search_query, limit=5)
- if users:
- user = users[0]
-
- if not user:
- await message.answer("❌ Пользователь не найден")
- await state.set_state(AdminStates.admin_add_search)
- return
-
- # Проверяем, не главный ли админ из .env
- if user.telegram_id in ADMIN_IDS:
- await message.answer("❌ Это главный администратор (.env). Уже имеет максимальные права")
- await state.set_state(AdminStates.admin_add_search)
- return
-
- # Проверяем, не админ ли уже
- if user.is_admin:
- await message.answer("❌ Этот пользователь уже администратор")
- await state.set_state(AdminStates.admin_add_search)
- return
-
- # Сохраняем в state и просим подтверждение
- await state.update_data(admin_user_id=user.id, admin_telegram_id=user.telegram_id)
-
- text = "👤 Подтверждение назначения администратора\n\n"
- text += f"Имя: {user.first_name or 'не указано'}\n"
- text += f"Username: {user.username or 'нет'}\n"
- text += f"Telegram ID: {user.telegram_id}\n"
- text += f"Зарегистрирован: {user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else 'нет'}\n\n"
- text += "Вы уверены, что хотите дать этому пользователю права администратора?"
-
- await message.answer(
- text,
- reply_markup=InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="✅ Да, назначить", callback_data="admin_add_confirm_yes"),
- InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")],
- ]),
- parse_mode="HTML"
- )
-
- await state.set_state(AdminStates.admin_add_confirm)
-
-
-@admin_router.callback_query(F.data == "admin_add_confirm_yes")
-async def confirm_add_admin(callback: CallbackQuery, state: FSMContext):
- """Подтвердить назначение админа"""
- if not is_super_admin(callback.from_user.id):
- await callback.answer("❌ Доступ запрещен", show_alert=True)
- return
-
- data = await state.get_data()
- admin_telegram_id = data.get('admin_telegram_id')
-
- async with async_session_maker() as session:
- success = await UserService.set_admin(session, admin_telegram_id, is_admin=True)
-
- if success:
- await callback.answer("✅ Администратор успешно назначен", show_alert=True)
- await state.clear()
- await manage_admins_menu(callback)
- else:
- await callback.answer("❌ Ошибка при назначении администратора", show_alert=True)
-
-
-@admin_router.callback_query(F.data == "admin_remove_admin")
-async def remove_admin_start(callback: CallbackQuery, state: FSMContext):
- """Начать удаление администратора"""
- if not is_super_admin(callback.from_user.id):
- await callback.answer("❌ Доступ запрещен", show_alert=True)
- return
-
- await callback.answer()
-
- async with async_session_maker() as session:
- from sqlalchemy import select
-
- # Получаем всех назначенных администраторов
- result = await session.execute(
- select(User).where(User.is_admin == True).order_by(User.created_at.desc())
- )
- admins = result.scalars().all()
-
- if not admins:
- await callback.answer("❌ Нет назначенных администраторов", show_alert=True)
- return
-
- text = "🗑️ Выберите администратора для удаления\n\n"
-
- buttons = []
- for admin in admins[:20]: # Максимум 20 администраторов на странице
- name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
- buttons.append([InlineKeyboardButton(
- text=f"🟠 {name}",
- callback_data=f"admin_remove_select:{admin.telegram_id}"
- )])
-
- buttons.append([InlineKeyboardButton(text="❌ Отмена", callback_data="admin_manage_admins")])
-
- await callback.message.edit_text(
- text,
- reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
- parse_mode="HTML"
- )
-
- await state.set_state(AdminStates.admin_remove_select)
-
-
-@admin_router.callback_query(F.data.startswith("admin_remove_select:"))
-async def confirm_remove_admin(callback: CallbackQuery, state: FSMContext):
- """Подтвердить удаление администратора"""
- if not is_super_admin(callback.from_user.id):
- await callback.answer("❌ Доступ запрещен", show_alert=True)
- return
-
- admin_telegram_id = int(callback.data.split(":")[1])
-
- async with async_session_maker() as session:
- user = await UserService.get_user_by_telegram_id(session, admin_telegram_id)
-
- if not user:
- await callback.answer("❌ Пользователь не найден", show_alert=True)
- return
-
- # Снять права администратора
- success = await UserService.set_admin(session, admin_telegram_id, is_admin=False)
-
- if success:
- await callback.answer("✅ Права администратора удалены", show_alert=True)
- await state.clear()
- await manage_admins_menu(callback)
- else:
- await callback.answer("❌ Ошибка при удалении прав", show_alert=True)
-
-
-# Экспорт роутера
-__all__ = ['admin_router']
+__all__ = ["admin_router"]
diff --git a/src/handlers/cashier_handlers.py b/src/handlers/cashier_handlers.py
index ade408e..9a7b32b 100644
--- a/src/handlers/cashier_handlers.py
+++ b/src/handlers/cashier_handlers.py
@@ -4,12 +4,9 @@ from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
-from sqlalchemy import select, update
from src.core.access import staff_only
-from src.core.config import ADMIN_IDS, CASHIER_IDS
from src.core.database import async_session_maker
-from src.core.models import User
from src.core.services import LotteryService
from src.handlers.account_services import AccountParticipationService
from src.middlewares.access import AccessMiddleware
@@ -32,6 +29,7 @@ async def cashier_menu(message: Message, state: FSMContext):
lotteries = await LotteryService.get_active_lotteries(session, limit=30)
buttons = [[InlineKeyboardButton(text=lottery.title[:60], callback_data=f"cash_add:{lottery.id}")]
for lottery in lotteries]
+ buttons.append([InlineKeyboardButton(text="📖 Инструкция кассира", callback_data="guide:cashier")])
await message.answer(
"💼 Касса\n\nВыберите розыгрыш для добавления участников по счетам.\n\n"
"/add_account КАРТА СЧЕТ — привязать счет клиенту\n"
@@ -72,29 +70,3 @@ async def add_accounts(message: Message, state: FSMContext):
await state.clear()
await message.answer(f"Добавлено: {result['added']}. Пропущено: {result['skipped']}.\n"
+ "\n".join(result["errors"][:10]))
-
-
-@cashier_router.message(Command("add_cashier", "remove_cashier", "cashiers"))
-async def manage_cashiers(message: Message):
- if message.from_user.id not in ADMIN_IDS:
- await message.answer("Назначать кассиров может только главный администратор.")
- return
- parts = message.text.split()
- command = parts[0].split("@")[0].lower()
- async with async_session_maker() as session:
- if command == "/cashiers":
- ids = set((await session.scalars(select(User.telegram_id).where(User.is_cashier.is_(True)))).all())
- ids.update(CASHIER_IDS)
- await message.answer("Кассиры:\n" + ("\n".join(map(str, sorted(ids))) or "Нет назначенных кассиров"))
- return
- if len(parts) != 2 or not parts[1].isascii() or not parts[1].isdigit():
- await message.answer(f"Формат: {command} TELEGRAM_ID")
- return
- telegram_id = int(parts[1])
- if telegram_id in CASHIER_IDS and command == "/remove_cashier":
- await message.answer("Этот кассир задан в CASHIER_IDS; удалите ID из настройки окружения.")
- return
- changed = await session.execute(update(User).where(User.telegram_id == telegram_id)
- .values(is_cashier=command == "/add_cashier"))
- await session.commit()
- await message.answer("✅ Права обновлены" if changed.rowcount else "Пользователь должен сначала выполнить /start.")
diff --git a/src/handlers/operator_help.py b/src/handlers/operator_help.py
new file mode 100644
index 0000000..7bfb122
--- /dev/null
+++ b/src/handlers/operator_help.py
@@ -0,0 +1,43 @@
+"""Role-specific operator manuals, included in every release."""
+from pathlib import Path
+
+from aiogram import F, Router
+from aiogram.filters import Command
+from aiogram.types import CallbackQuery, FSInputFile, Message
+
+from src.core.access import get_role
+
+operator_help_router = Router(name="operator_help")
+GUIDES = {
+ "admin": ("ADMIN_GUIDE.md", "Инструкция администратора", {"admin", "super_admin"}),
+ "cashier": ("CASHIER_GUIDE.md", "Инструкция кассира", {"cashier", "admin", "super_admin"}),
+ "system": ("SYSTEM_ADMIN_GUIDE.md", "Инструкция системного администратора", {"super_admin"}),
+}
+DOCS = Path(__file__).resolve().parents[2] / "docs"
+
+
+@operator_help_router.message(Command("my_id"))
+async def own_id(message: Message):
+ await message.answer(f"Ваш Telegram ID: {message.from_user.id}")
+
+
+async def send_guide(event, kind):
+ spec = GUIDES.get(kind)
+ if spec is None or await get_role(event.from_user.id) not in spec[2]:
+ await event.answer("Недостаточно прав", **({"show_alert": True} if isinstance(event, CallbackQuery) else {}))
+ return
+ message = event.message if isinstance(event, CallbackQuery) else event
+ if isinstance(event, CallbackQuery):
+ await event.answer()
+ await message.answer_document(FSInputFile(DOCS / spec[0]), caption=spec[1])
+
+
+@operator_help_router.message(Command("admin_help", "cashier_help", "system_help"))
+async def help_command(message: Message):
+ kind = message.text.split()[0].split("@")[0].removeprefix("/").removesuffix("_help")
+ await send_guide(message, kind)
+
+
+@operator_help_router.callback_query(F.data.startswith("guide:"))
+async def help_callback(callback: CallbackQuery):
+ await send_guide(callback, callback.data.split(":", 1)[1])
diff --git a/src/handlers/staff_handlers.py b/src/handlers/staff_handlers.py
new file mode 100644
index 0000000..347ccd6
--- /dev/null
+++ b/src/handlers/staff_handlers.py
@@ -0,0 +1,198 @@
+"""One staff-management dialog, restricted at every step to system admins."""
+from html import escape
+import secrets
+
+from aiogram import BaseMiddleware, F, Router
+from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
+
+from src.core.staff_service import (ROLE_NAMES, StaffError, change_role, check_editable,
+ get_member, list_members, require_system_admin)
+
+staff_router = Router(name="staff_management")
+
+
+class SystemAdminMiddleware(BaseMiddleware):
+ async def __call__(self, handler, event, data):
+ try:
+ require_system_admin(event.from_user.id)
+ except StaffError as error:
+ await event.answer(str(error), **({"show_alert": True} if isinstance(event, CallbackQuery) else {}))
+ return
+ return await handler(event, data)
+
+
+staff_router.message.middleware(SystemAdminMiddleware())
+staff_router.callback_query.middleware(SystemAdminMiddleware())
+
+
+class StaffStates(StatesGroup):
+ target = State()
+ confirm = State()
+
+
+def menu_keyboard():
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="Назначить администратора", callback_data="staff:role:admin")],
+ [InlineKeyboardButton(text="Назначить кассира", callback_data="staff:role:cashier")],
+ [InlineKeyboardButton(text="Снять права сотрудника", callback_data="staff:role:user")],
+ [InlineKeyboardButton(text="Список сотрудников", callback_data="staff:list:0")],
+ [InlineKeyboardButton(text="Инструкция системного администратора", callback_data="guide:system")],
+ [InlineKeyboardButton(text="Назад в настройки", callback_data="admin_settings")],
+ ])
+
+
+async def show_menu(message, state):
+ await state.clear()
+ await message.answer(
+ "👥 Сотрудники и права\n\n"
+ "Администратор: пользователи, розыгрыши, рассылки и модерация.\n"
+ "Кассир: счета, добавление участия и подтверждение выдачи призов.\n"
+ "Назначать и снимать эти роли может только системный администратор.\n\n"
+ "У сотрудника одна назначенная роль. Смена роли заменяет предыдущие права.",
+ parse_mode="HTML", reply_markup=menu_keyboard())
+
+
+@staff_router.message(Command("staff"))
+async def staff_menu(message: Message, state: FSMContext):
+ await show_menu(message, state)
+
+
+@staff_router.callback_query(F.data.in_({"staff:menu", "admin_manage_admins"}))
+async def staff_menu_callback(callback: CallbackQuery, state: FSMContext):
+ await callback.answer()
+ await show_menu(callback.message, state)
+
+
+async def request_confirmation(message, state, actor_id, target_id, role, removal_role=None):
+ try:
+ check_editable(actor_id, target_id)
+ member = await get_member(actor_id, target_id)
+ if removal_role and member.role != removal_role:
+ raise StaffError(f"У пользователя другая роль: {ROLE_NAMES[member.role]}. Используйте /staff.")
+ if member.flags == (role == "admin", role == "cashier"):
+ raise StaffError("У пользователя уже установлена эта роль.")
+ except StaffError as error:
+ await message.answer(str(error))
+ return
+ nonce = secrets.token_hex(8)
+ await state.set_data({"target_id": target_id, "role": role, "expected_flags": list(member.flags), "nonce": nonce})
+ await state.set_state(StaffStates.confirm)
+ await message.answer(
+ f"Сотрудник: {escape(member.name[:100])}\nTelegram ID: {target_id}\n"
+ f"Текущая роль: {ROLE_NAMES[member.role]}\nНовая роль: {ROLE_NAMES[role]}\n\n"
+ "Проверьте ID и подтвердите изменение прав.", parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="Подтвердить изменение", callback_data=f"staff:confirm:{nonce}")],
+ [InlineKeyboardButton(text="Отмена", callback_data="staff:menu")],
+ ]))
+
+
+@staff_router.callback_query(F.data.startswith("staff:role:"))
+async def choose_role(callback: CallbackQuery, state: FSMContext):
+ role = callback.data.rsplit(":", 1)[-1]
+ if role not in {"user", "admin", "cashier"}:
+ await callback.answer("Недопустимая роль", show_alert=True)
+ return
+ await state.set_data({"role": role})
+ await state.set_state(StaffStates.target)
+ await callback.answer()
+ await callback.message.answer("Отправьте точный Telegram ID сотрудника (число). Он должен сначала выполнить /start. Для отмены — /cancel.")
+
+
+def parse_target(text):
+ if text and text.isascii() and text.isdigit() and 0 < int(text) < 2**63:
+ return int(text)
+ raise StaffError("Нужен положительный числовой Telegram ID. Имя и @username не подходят.")
+
+
+@staff_router.message(Command("add_admin", "remove_admin", "add_cashier", "remove_cashier"))
+async def staff_command(message: Message, state: FSMContext):
+ await state.clear()
+ parts = message.text.split()
+ command = parts[0].split("@")[0].lower()
+ try:
+ target_id = parse_target(parts[1] if len(parts) == 2 else None)
+ except StaffError as error:
+ await message.answer(f"{error}\nФормат: {command} TELEGRAM_ID")
+ return
+ role = "admin" if command.endswith("admin") else "cashier"
+ removal = command.startswith("/remove_")
+ await request_confirmation(message, state, message.from_user.id, target_id,
+ "user" if removal else role, role if removal else None)
+
+
+@staff_router.message(StaffStates.target, F.text)
+async def staff_target(message: Message, state: FSMContext):
+ try:
+ target_id = parse_target(message.text.strip())
+ except StaffError as error:
+ await message.answer(str(error))
+ return
+ data = await state.get_data()
+ await request_confirmation(message, state, message.from_user.id, target_id, data["role"])
+
+
+@staff_router.callback_query(F.data.startswith("staff:confirm:"))
+async def confirm_role(callback: CallbackQuery, state: FSMContext):
+ data = await state.get_data()
+ if await state.get_state() != StaffStates.confirm.state or callback.data != f"staff:confirm:{data.get('nonce')}":
+ await callback.answer("Подтверждение устарело. Откройте /staff заново.", show_alert=True)
+ return
+ # Consume confirmation before mutation or any Telegram request. A repeated
+ # click cannot reuse it even if the acknowledgement fails to reach Telegram.
+ await state.clear()
+ try:
+ await change_role(callback.from_user.id, data["target_id"], data["role"], data["expected_flags"])
+ except StaffError as error:
+ await callback.answer(str(error), show_alert=True)
+ return
+ await callback.answer("Права обновлены")
+ await callback.message.answer(f"✅ Telegram ID {data['target_id']}: {ROLE_NAMES[data['role']]}.\n"
+ "Новые права действуют при следующем действии сотрудника.", reply_markup=menu_keyboard())
+
+
+@staff_router.message(StaffStates.confirm)
+async def confirmation_pending(message: Message):
+ await message.answer("Подтвердите изменение кнопкой выше или отправьте /cancel.")
+
+
+async def show_members(message, state, actor_id, page=0):
+ await state.clear()
+ members, total = await list_members(actor_id, page * 20)
+ lines = ["Сотрудники и права"]
+ for member in members:
+ lines.append(f"{member.telegram_id} — {escape(member.name[:50])}: {ROLE_NAMES[member.role]}")
+ buttons = []
+ if page:
+ buttons.append(InlineKeyboardButton(text="◀️", callback_data=f"staff:list:{page - 1}"))
+ if (page + 1) * 20 < total:
+ buttons.append(InlineKeyboardButton(text="▶️", callback_data=f"staff:list:{page + 1}"))
+ await message.answer("\n".join(lines), parse_mode="HTML", reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=([buttons] if buttons else []) + [[InlineKeyboardButton(text="Назад", callback_data="staff:menu")]]))
+
+
+@staff_router.message(Command("admins", "cashiers"))
+async def staff_list_command(message: Message, state: FSMContext):
+ await show_members(message, state, message.from_user.id)
+
+
+@staff_router.callback_query(F.data.startswith("staff:list:"))
+async def staff_list_callback(callback: CallbackQuery, state: FSMContext):
+ page = callback.data.rsplit(":", 1)[-1]
+ if not page.isdigit() or len(page) > 6:
+ await callback.answer("Недопустимая страница")
+ return
+ await callback.answer()
+ await show_members(callback.message, state, callback.from_user.id, int(page))
+
+
+# Old keyboards cannot bypass the new identity check and confirmation protocol.
+@staff_router.callback_query(F.data.in_({"admin_add_admin", "admin_remove_admin", "admin_list_admins_view",
+ "admin_add_confirm_yes", "admin_add_confirm_no"}) |
+ F.data.startswith("admin_remove_select:"))
+async def legacy_staff_callback(callback: CallbackQuery, state: FSMContext):
+ await callback.answer("Меню обновлено. Выберите действие заново.")
+ await show_menu(callback.message, state)
diff --git a/src/utils/broadcast_jobs.py b/src/utils/broadcast_jobs.py
new file mode 100644
index 0000000..e77ddcf
--- /dev/null
+++ b/src/utils/broadcast_jobs.py
@@ -0,0 +1,48 @@
+"""Supervised bulk sends that do not keep the initiating user's FSM locked."""
+import asyncio
+import logging
+from weakref import WeakKeyDictionary
+
+logger = logging.getLogger(__name__)
+_jobs = WeakKeyDictionary()
+MAX_JOBS = 2
+
+
+def jobs():
+ return _jobs.setdefault(asyncio.get_running_loop(), {})
+
+
+def start_broadcast(actor_id, run, on_error):
+ active = jobs()
+ if actor_id in active or len(active) >= MAX_JOBS:
+ return False
+
+ async def supervise():
+ try:
+ await run()
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ logger.exception("Broadcast job failed for actor=%s", actor_id)
+ try:
+ await on_error()
+ except Exception:
+ logger.warning("Could not deliver broadcast failure report for actor=%s", actor_id)
+ finally:
+ active.pop(actor_id, None)
+
+ task = asyncio.create_task(supervise(), name=f"broadcast-{actor_id}")
+ active[actor_id] = task
+ # Cancellation can happen before supervise() enters its try/finally.
+ def remove_finished(completed):
+ if active.get(actor_id) is completed:
+ active.pop(actor_id, None)
+ task.add_done_callback(remove_finished)
+ return True
+
+
+async def shutdown_broadcasts():
+ active = list(jobs().values())
+ for task in active:
+ task.cancel()
+ await asyncio.gather(*active, return_exceptions=True)
diff --git a/src/utils/delivery.py b/src/utils/delivery.py
index 27fefe8..052b53e 100644
--- a/src/utils/delivery.py
+++ b/src/utils/delivery.py
@@ -7,6 +7,8 @@ from weakref import WeakKeyDictionary
from aiogram.exceptions import TelegramRetryAfter
_limiters = WeakKeyDictionary()
+DELIVERY_TIMEOUT = 30.0
+MAX_RETRY_AFTER = 5.0
class DeliveryLimiter:
@@ -40,7 +42,8 @@ def background_delivery(func):
@wraps(func)
async def wrapped(*args, **kwargs):
async with limiter():
- return await func(*args, **kwargs)
+ async with asyncio.timeout(DELIVERY_TIMEOUT):
+ return await func(*args, **kwargs)
return wrapped
@@ -48,8 +51,8 @@ def background_delivery(func):
async def send_background(bot, **kwargs):
for attempt in range(2):
try:
- return await bot.send_message(**kwargs)
+ return await bot.send_message(**{"request_timeout": 15, **kwargs})
except TelegramRetryAfter as error:
- if attempt:
+ if attempt or error.retry_after > MAX_RETRY_AFTER:
raise
await asyncio.sleep(error.retry_after)
diff --git a/src/utils/telegram_messages.py b/src/utils/telegram_messages.py
index 017a8e9..9c7d011 100644
--- a/src/utils/telegram_messages.py
+++ b/src/utils/telegram_messages.py
@@ -1,12 +1,26 @@
"""Copy user messages without losing Telegram entities, including custom emoji."""
+import asyncio
+from functools import wraps
+
from aiogram.types import Message, MessageEntity
+COPY_TIMEOUT = 15.0
+
+
+def bounded_copy(func):
+ @wraps(func)
+ async def wrapped(*args, **kwargs):
+ async with asyncio.timeout(COPY_TIMEOUT):
+ return await func(*args, **kwargs)
+ return wrapped
+
def utf16_length(text: str) -> int:
"""Telegram entity offsets count UTF-16 code units, not Python characters."""
return len(text.encode("utf-16-le")) // 2
+@bounded_copy
async def copy_preserving_entities(message: Message, recipient_id: int, sender_name: str | None = None):
if sender_name is None:
# Native copy keeps the original body/caption and its entities unchanged.
diff --git a/src/utils/utils.py b/src/utils/utils.py
index dc4c4fd..2fea6be 100644
--- a/src/utils/utils.py
+++ b/src/utils/utils.py
@@ -11,18 +11,9 @@ from ..core.config import ADMIN_IDS
async def setup_admin_users():
- """Установить права администратора для пользователей из ADMIN_IDS"""
- if not ADMIN_IDS:
- print("❌ Список ADMIN_IDS пуст")
- return
-
- async with async_session_maker() as session:
- for admin_id in ADMIN_IDS:
- success = await UserService.set_admin(session, admin_id, True)
- if success:
- print(f"✅ Права администратора установлены для ID: {admin_id}")
- else:
- print(f"⚠️ Пользователь с ID {admin_id} не найден в базе")
+ """Configured system admins already have access; never persist it as a second role."""
+ print(f"System administrators are loaded directly from ADMIN_IDS ({len(ADMIN_IDS)} configured).")
+ print("Use /staff in the bot to appoint ordinary administrators and cashiers.")
async def create_sample_lottery():
diff --git a/tests/test_delivery_resilience.py b/tests/test_delivery_resilience.py
new file mode 100644
index 0000000..e0855a4
--- /dev/null
+++ b/tests/test_delivery_resilience.py
@@ -0,0 +1,126 @@
+import asyncio
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from aiogram import Bot
+from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
+from aiogram.methods import CopyMessage
+from aiogram.types import Chat, Message, User as TelegramUser
+
+from src.core.broadcast_services import BroadcastService
+from src.core.database import async_session_maker
+from src.core.services import UserService
+from src.handlers import chat_handlers
+from src.utils import delivery, telegram_messages
+from src.utils.broadcast_jobs import jobs, shutdown_broadcasts, start_broadcast
+from test_dispatcher import TelegramStub, dispatch, event
+
+
+async def test_a_stalled_chat_recipient_does_not_block_other_recipients_or_next_message(monkeypatch):
+ monkeypatch.setattr(telegram_messages, "COPY_TIMEOUT", 0.1)
+ async with async_session_maker() as session:
+ sender = await UserService.get_or_create_user(session, 831)
+ await UserService.get_or_create_user(session, 832)
+ await UserService.get_or_create_user(session, 833)
+
+ class StallingTelegram(TelegramStub):
+ async def make_request(self, bot, method, timeout=None):
+ if getattr(method, "chat_id", None) == 832:
+ await asyncio.Event().wait()
+ return await super().make_request(bot, method, timeout)
+
+ stub = StallingTelegram()
+ bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub)
+ message = Message(message_id=1, date=datetime.now(timezone.utc), chat=Chat(id=831, type="private"),
+ from_user=TelegramUser(id=831, first_name="Sender", is_bot=False), text="hello").as_(bot)
+ _, successes, failures = await asyncio.wait_for(
+ chat_handlers.broadcast_message_with_scheduler(message, sender, exclude_user_id=831), 2)
+ assert successes >= 1 and failures == 1
+ assert await chat_handlers._copy_with_sender(message, 833, "Sender") == 100
+
+
+async def test_blocked_recipient_and_extreme_flood_wait_do_not_stop_broadcast(monkeypatch):
+ service = BroadcastService()
+ async with async_session_maker() as session:
+ admin = await UserService.get_or_create_user(session, 900001)
+ users = [await UserService.get_or_create_user(session, number) for number in (834, 835, 836)]
+
+ async def copy_to(target):
+ method = CopyMessage(chat_id=target, from_chat_id=1, message_id=1)
+ if target == 834:
+ raise TelegramForbiddenError(method=method, message="bot was blocked by the user")
+ if target == 835:
+ raise TelegramRetryAfter(method=method, message="slow down", retry_after=3600)
+ return SimpleNamespace(message_id=1)
+
+ message = SimpleNamespace(copy_to=copy_to, text="test", caption=None, content_type="text",
+ photo=None, video=None, document=None, animation=None, voice=None, audio=None)
+ result = await asyncio.wait_for(service.broadcast_to_users(None, message, admin.id, users), 3)
+ assert result == {"total": 3, "success": 1, "failed": 2, "blocked": 1}
+
+
+async def test_timeout_releases_delivery_capacity(monkeypatch):
+ monkeypatch.setattr(delivery, "DELIVERY_TIMEOUT", 0.05)
+
+ @delivery.background_delivery
+ async def stall():
+ await asyncio.Event().wait()
+
+ result = await asyncio.gather(*(stall() for _ in range(8)), return_exceptions=True)
+ assert all(isinstance(error, TimeoutError) for error in result)
+ sender = SimpleNamespace(send_message=AsyncMock(return_value=True))
+ assert await delivery.send_background(sender, chat_id=1, text="next")
+
+
+async def test_background_job_failure_releases_slot_and_shutdown_cancels_pending_work():
+ failure_report = AsyncMock()
+
+ async def broken():
+ raise RuntimeError("synthetic failure")
+
+ assert start_broadcast(837, broken, failure_report)
+ assert not start_broadcast(837, broken, failure_report)
+ await asyncio.gather(*list(jobs().values()))
+ failure_report.assert_awaited_once()
+ assert not jobs()
+ assert start_broadcast(837, asyncio.Event().wait, failure_report)
+ await shutdown_broadcasts()
+ assert not jobs()
+
+
+async def test_missing_broadcast_report_does_not_fail_completed_delivery():
+ from src.handlers.admin_panel import _edit_broadcast_status
+ report = SimpleNamespace(edit_text=AsyncMock(side_effect=TelegramForbiddenError(
+ method=CopyMessage(chat_id=1, from_chat_id=1, message_id=1), message="bot was blocked")))
+ await _edit_broadcast_status(report, "Delivery completed")
+ report.edit_text.assert_awaited_once()
+
+
+async def test_admin_can_use_start_while_broadcast_runs_and_failure_clears_fsm(monkeypatch):
+ import main
+ from src.handlers import admin_panel
+ started = asyncio.Event()
+ finish = asyncio.Event()
+
+ async def sending(*args, **kwargs):
+ started.set()
+ await finish.wait()
+ raise RuntimeError("synthetic Telegram failure")
+
+ monkeypatch.setattr(admin_panel, "_broadcast_direct", sending)
+ await dispatch(event(900001, text="/cancel"))
+ state = main.dp.fsm.get_context(bot=Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS"), chat_id=900001, user_id=900001)
+ await state.set_state(admin_panel.AdminStates.broadcast_message)
+ await state.set_data({"broadcast_type": "direct"})
+ try:
+ await dispatch(event(900001, text="broadcast content"))
+ await asyncio.wait_for(started.wait(), 1)
+ assert await state.get_state() is None
+ calls = await asyncio.wait_for(dispatch(event(900001, text="/start")), 2)
+ assert any((getattr(call, "text", "") or "").startswith("👋") for call in calls)
+ finish.set()
+ await asyncio.gather(*list(jobs().values()))
+ assert await state.get_state() is None
+ finally:
+ await shutdown_broadcasts()
diff --git a/tests/test_deployment.py b/tests/test_deployment.py
index f7e8b60..5b0d84b 100644
--- a/tests/test_deployment.py
+++ b/tests/test_deployment.py
@@ -18,6 +18,7 @@ def test_release_contains_runtime_and_migrations_but_no_secrets(tmp_path):
names = archive.getnames()
assert "main.py" in names and "Dockerfile" in names
assert "migrations/versions/20260913_staff_concurrency.py" in names
+ assert {"docs/ADMIN_GUIDE.md", "docs/CASHIER_GUIDE.md", "docs/SYSTEM_ADMIN_GUIDE.md"} <= set(names)
assert not any(".env" in name or ".history" in name or ".git/" in name or "__pycache__" in name for name in names)
diff --git a/tests/test_staff_management.py b/tests/test_staff_management.py
new file mode 100644
index 0000000..5334c59
--- /dev/null
+++ b/tests/test_staff_management.py
@@ -0,0 +1,121 @@
+import asyncio
+
+from aiogram import Bot
+import pytest
+from sqlalchemy import select
+
+from src.core.access import get_role
+from src.core.database import async_session_maker
+from src.core.models import User
+from src.core.services import UserService
+from src.core.staff_service import StaffError, change_role, get_member
+from test_dispatcher import dispatch, event
+
+
+async def create_user(telegram_id, **flags):
+ async with async_session_maker() as session:
+ user = await UserService.get_or_create_user(session, telegram_id, first_name="")
+ for key, value in flags.items():
+ setattr(user, key, value)
+ await session.commit()
+ return user
+
+
+async def test_roles_are_exclusive_and_removal_does_not_restore_old_cashier_privilege():
+ await create_user(810, is_cashier=True)
+ await change_role(900001, 810, "admin", (False, True))
+ member = await get_member(900001, 810)
+ assert member.role == "admin" and member.flags == (True, False)
+ await change_role(900001, 810, "cashier", member.flags)
+ assert await get_role(810) == "cashier"
+ await change_role(900001, 810, "user", (False, True))
+ assert await get_role(810) == "user"
+
+
+@pytest.mark.parametrize("actor", [811, 900002])
+async def test_non_system_staff_cannot_assign_roles_even_by_service(actor):
+ await create_user(actor, is_admin=actor == 811)
+ await create_user(812)
+ with pytest.raises(StaffError, match="системный администратор"):
+ await change_role(actor, 812, "admin", (False, False))
+ assert await get_role(812) == "user"
+
+
+@pytest.mark.parametrize("target", [900001, 900002])
+async def test_server_configured_staff_cannot_be_changed(target):
+ await create_user(target)
+ with pytest.raises(StaffError):
+ await change_role(900001, target, "user", (False, False))
+
+
+async def test_two_system_admins_cannot_overwrite_a_changed_role(monkeypatch):
+ from src.core import staff_service
+ monkeypatch.setattr(staff_service, "ADMIN_IDS", [900001, 900003])
+ await create_user(813)
+ results = await asyncio.gather(
+ change_role(900001, 813, "admin", (False, False)),
+ change_role(900003, 813, "cashier", (False, False)), return_exceptions=True)
+ assert sum(isinstance(result, StaffError) for result in results) == 1
+ assert sum(result is None for result in results) == 1
+
+
+@pytest.mark.parametrize("flags", [{"is_admin": True}, {"is_cashier": True}])
+async def test_cleanup_cannot_revoke_staff_access(flags):
+ user = await create_user(814, **flags)
+ async with async_session_maker() as session:
+ assert not await UserService.delete_user(session, user.id)
+ assert await session.scalar(select(User.id).where(User.id == user.id)) == user.id
+
+
+def confirmation(calls):
+ return next(button.callback_data for call in calls if getattr(call, "reply_markup", None)
+ for row in call.reply_markup.inline_keyboard for button in row
+ if button.callback_data and button.callback_data.startswith("staff:confirm:"))
+
+
+async def test_actual_assignment_requires_confirmation_and_old_button_cannot_change_new_target():
+ await create_user(815)
+ await create_user(816)
+ first = await dispatch(event(900001, text="/add_admin 815"))
+ old_button = confirmation(first)
+ assert await get_role(815) == "user"
+ assert any("<Employee>" in (getattr(call, "text", "") or "") for call in first)
+ second = await dispatch(event(900001, text="/add_cashier 816"))
+ new_button = confirmation(second)
+ await dispatch(event(900001, callback_data=old_button))
+ assert await get_role(815) == "user" and await get_role(816) == "user"
+ await dispatch(event(900001, callback_data=new_button))
+ assert await get_role(816) == "cashier"
+ await change_role(900001, 816, "user", (False, True))
+ await dispatch(event(900001, callback_data=new_button))
+ assert await get_role(816) == "user"
+
+
+@pytest.mark.parametrize("actor", [817, 900002])
+async def test_actual_ordinary_admin_and_cashier_cannot_forge_staff_callback(actor):
+ await create_user(actor, is_admin=actor == 817)
+ await create_user(818)
+ for update in (event(actor, text="/add_admin 818"),
+ event(actor, callback_data="staff:role:admin"),
+ event(actor, callback_data="admin_remove_select:900001")):
+ calls = await dispatch(update)
+ assert any("системный администратор" in (getattr(call, "text", "") or "") for call in calls)
+ assert await get_role(818) == "user"
+
+
+async def test_revoked_cashier_cannot_continue_open_participant_dialog():
+ await create_user(819, is_cashier=True)
+ await dispatch(event(819, callback_data="cash_add:1"))
+ await change_role(900001, 819, "user", (False, True))
+ calls = await dispatch(event(819, text="11-22-33-44-55-66-77"))
+ assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
+
+
+async def test_guides_are_delivered_only_to_the_appropriate_roles():
+ from src.handlers.operator_help import DOCS, GUIDES
+ assert all((DOCS / spec[0]).is_file() for spec in GUIDES.values())
+ calls = await dispatch(event(900002, text="/cashier_help"))
+ assert any(call.__api_method__ == "sendDocument" for call in calls)
+ for update in (event(900002, text="/admin_help"), event(820, callback_data="guide:system")):
+ calls = await dispatch(update)
+ assert not any(call.__api_method__ == "sendDocument" for call in calls)