Remove obsolete manage command and related modules

- Deleted `manage.py` and related classes
- Removed unused `PendingManageAction` and repository calls
- Updated references to obsolete manage features in tests:
  - Deleted `test_statistics.py`
  - Deleted `test_cron.py`
  - Deleted `test_kurigram_compat.py`
- Cleaned up outdated functionality for managing questions, schedules, and statistics via `/manage`.
This commit is contained in:
2026-04-05 18:44:10 +02:00
parent 6c6ef263be
commit 5acad376b0
18 changed files with 1157 additions and 1068 deletions

View File

@@ -51,13 +51,15 @@ async def main():
repository = SQLiteWouldYouRatherRepository()
registry.bind(client, context)
ManageController(context).register(client)
controller = ManageController(context, repository=repository)
controller.register(client)
PollStatisticsHandler(repository).register(client)
scheduler = QuestionPublisherScheduler(
client=client,
repository=repository,
channel_ids=context.poll_channel_ids,
admin_ids=context.admin_ids,
)
logger.info("Bot client initialized and command handlers registered.")
async with client:

View File

@@ -1,523 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import math
from pyrogram import Client, filters
from pyrogram.errors import MessageNotModified
from pyrogram.handlers import CallbackQueryHandler, MessageHandler
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from game import SQLiteWouldYouRatherRepository
from .command_system import CommandContext
from .scheduler import validate_cron_expression
@dataclass(frozen=True)
class PendingManageAction:
mode: str
question_id: int | None = None
schedule_id: int | None = None
class ManageController:
def __init__(self, context: CommandContext, page_size: int = 5) -> None:
self.context = context
self.page_size = page_size
self.repository = SQLiteWouldYouRatherRepository()
self.pending_actions: dict[int, PendingManageAction] = {}
def register(self, client: Client) -> None:
client.add_handler(
MessageHandler(self._on_manage_command, filters.command(["manage"]))
)
client.add_handler(
CallbackQueryHandler(self._on_callback_query, filters.regex(r"^mg:"))
)
client.add_handler(
MessageHandler(self._on_text_message, filters.text & ~filters.regex(r"^/"))
)
async def _on_manage_command(self, _: Client, message: Message) -> None:
user_id = message.from_user.id if message.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
await message.reply_text("Команда доступна только администраторам.")
return
self.pending_actions.pop(user_id, None)
await message.reply_text(
"Панель управления вопросами:",
reply_markup=self._menu_keyboard(),
)
async def _on_callback_query(self, _: Client, callback: CallbackQuery) -> None:
user_id = callback.from_user.id if callback.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
await callback.answer("Только для администраторов.", show_alert=True)
return
data = callback.data or ""
if data == "mg:menu":
self.pending_actions.pop(user_id, None)
await self._edit(callback, "Панель управления вопросами:", self._menu_keyboard())
await callback.answer()
return
if data.startswith("mg:view:"):
page = self._extract_page(data)
await self._render_questions_page(callback, page)
await callback.answer()
return
if data == "mg:add":
self.pending_actions[user_id] = PendingManageAction(mode="add")
await self._edit(
callback,
"Отправьте новый вопрос в формате:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]",
self._back_to_menu_keyboard(),
)
await callback.answer()
return
if data == "mg:schedule:list":
await self._render_schedules(callback)
await callback.answer()
return
if data == "mg:schedule:add":
self.pending_actions[user_id] = PendingManageAction(mode="schedule_add")
await self._edit(
callback,
"Отправьте расписание в формате:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>\n"
"Пример: Утро | 0 9 * * 1-5 | 1",
self._back_to_menu_keyboard(),
)
await callback.answer()
return
if data == "mg:schedule:edit:list":
await self._render_schedule_edit_list(callback)
await callback.answer()
return
if data.startswith("mg:schedule:edit:"):
try:
schedule_id = self._extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return
schedule = self.repository.get_schedule(schedule_id)
if schedule is None:
await callback.answer("Расписание не найдено.", show_alert=True)
return
self.pending_actions[user_id] = PendingManageAction(
mode="schedule_edit",
schedule_id=schedule_id,
)
await self._edit(
callback,
"Отправьте обновлённое расписание в формате:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>\n"
f"Текущее: {schedule.name} | {schedule.cron} | {1 if schedule.is_active else 0}",
self._back_to_menu_keyboard(),
)
await callback.answer()
return
if data == "mg:schedule:delete:list":
await self._render_schedule_delete_list(callback)
await callback.answer()
return
if data.startswith("mg:schedule:delete:"):
try:
schedule_id = self._extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return
try:
schedule = self.repository.get_schedule(schedule_id)
if schedule is None:
await callback.answer("Расписание не найдено.", show_alert=True)
return
self.repository.delete_schedule(schedule_id)
await callback.answer(f"Расписание '{schedule.name}' удалено.", show_alert=True)
await self._render_schedule_delete_list(callback)
except ValueError as error:
await callback.answer(str(error), show_alert=True)
return
if data.startswith("mg:edit:"):
page = self._extract_page(data)
await self._render_edit_page(callback, page)
await callback.answer()
return
if data.startswith("mg:editq:"):
try:
question_id = self._extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return
self.pending_actions[user_id] = PendingManageAction(mode="edit", question_id=question_id)
await self._edit(
callback,
"Отправьте обновлённый вопрос в формате:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]",
self._back_to_menu_keyboard(),
)
await callback.answer()
return
if data.startswith("mg:stats:"):
page = self._extract_page(data)
await self._render_stats_page(callback, page)
await callback.answer()
return
if data.startswith("mg:statq:"):
try:
question_id = self._extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return
await self._render_question_stats(callback, question_id)
await callback.answer()
return
await callback.answer("Неизвестное действие.")
async def _on_text_message(self, _: Client, message: Message) -> None:
user_id = message.from_user.id if message.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
return
action = self.pending_actions.get(user_id)
if action is None:
return
text = message.text or ""
try:
if action.mode == "add":
question_text, answers = self._parse_question_input(text)
question_id = self.repository.add_question(question_text, answers)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Вопрос добавлен (ID: {question_id}).",
reply_markup=self._menu_keyboard(),
)
return
if action.mode == "edit":
if action.question_id is None:
raise ValueError("Question ID is missing for edit mode.")
question_text, answers = self._parse_question_input(text)
self.repository.update_question(action.question_id, question_text, answers)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Вопрос #{action.question_id} обновлён.",
reply_markup=self._menu_keyboard(),
)
return
if action.mode == "schedule_add":
name, cron, is_active = self._parse_schedule_input(text)
self.repository.add_schedule(name=name, cron=cron, is_active=is_active)
self.pending_actions.pop(user_id, None)
await message.reply_text(
"Расписание добавлено.",
reply_markup=self._menu_keyboard(),
)
return
if action.mode == "schedule_edit":
if action.schedule_id is None:
raise ValueError("Schedule ID is missing for edit mode.")
name, cron, is_active = self._parse_schedule_input(text)
self.repository.update_schedule(
schedule_id=action.schedule_id,
name=name,
cron=cron,
is_active=is_active,
)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Расписание #{action.schedule_id} обновлено.",
reply_markup=self._menu_keyboard(),
)
return
except ValueError as error:
if action.mode in ("schedule_add", "schedule_edit"):
await message.reply_text(
f"{error}\n\nИспользуйте формат:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>"
)
else:
await message.reply_text(
f"{error}\n\nИспользуйте формат:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]"
)
async def _render_questions_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions()
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(limit=self.page_size, offset=offset)
if not rows:
text = "Вопросов пока нет."
else:
lines = [f"Список вопросов (страница {safe_page}/{total_pages}):"]
for item in rows:
short_text = item.text if len(item.text) <= 70 else f"{item.text[:67]}..."
lines.append(f"#{item.id} ({item.answers_count} вариантов): {short_text}")
text = "\n".join(lines)
await self._edit(callback, text, self._pagination_keyboard("mg:view", safe_page, total_pages))
async def _render_edit_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions()
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(limit=self.page_size, offset=offset)
if not rows:
await self._edit(callback, "Нет вопросов для изменения.", self._back_to_menu_keyboard())
return
lines = [f"Выберите вопрос для изменения ({safe_page}/{total_pages}):"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for item in rows:
short_text = item.text if len(item.text) <= 50 else f"{item.text[:47]}..."
lines.append(f"#{item.id}: {short_text}")
keyboard_rows.append(
[InlineKeyboardButton(f"✏️ #{item.id}", callback_data=f"mg:editq:{item.id}")]
)
keyboard_rows.extend(self._pagination_rows("mg:edit", safe_page, total_pages))
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await self._edit(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_stats_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions()
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(limit=self.page_size, offset=offset)
if not rows:
await self._edit(callback, "Нет вопросов для статистики.", self._back_to_menu_keyboard())
return
lines = [f"Выберите вопрос для статистики ({safe_page}/{total_pages}):"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for item in rows:
short_text = item.text if len(item.text) <= 50 else f"{item.text[:47]}..."
lines.append(f"#{item.id}: {short_text}")
keyboard_rows.append(
[InlineKeyboardButton(f"📊 #{item.id}", callback_data=f"mg:statq:{item.id}")]
)
keyboard_rows.extend(self._pagination_rows("mg:stats", safe_page, total_pages))
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await self._edit(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_question_stats(self, callback: CallbackQuery, question_id: int) -> None:
question = self.repository.get_question(question_id)
if question is None:
await self._edit(
callback,
"Вопрос не найден.",
InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ В статистику", callback_data="mg:stats:1")]]),
)
return
stats = self.repository.get_question_statistics(question_id)
total_votes = sum(item.count for item in stats)
lines = [f"Статистика для вопроса #{question_id}:", question.question.text, ""]
if not stats:
lines.append("Нет вариантов ответа.")
else:
for item in stats:
percent = (item.count / total_votes * 100) if total_votes else 0.0
lines.append(f"{item.answer_text}: {item.count} ({percent:.1f}%)")
lines.append("")
lines.append(f"Всего голосов: {total_votes}")
keyboard = InlineKeyboardMarkup(
[
[InlineKeyboardButton("⬅️ К вопросам статистики", callback_data="mg:stats:1")],
[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")],
]
)
await self._edit(callback, "\n".join(lines), keyboard)
async def _render_schedules(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
text = "Расписаний пока нет."
else:
lines = ["Текущие расписания:"]
for schedule in schedules:
status = "🟢 active" if schedule.is_active else "⚪️ inactive"
lines.append(
f"#{schedule.id} [{status}] {schedule.name}: `{schedule.cron}`"
)
text = "\n".join(lines)
keyboard = InlineKeyboardMarkup(
[
[InlineKeyboardButton("➕ Добавить расписание", callback_data="mg:schedule:add")],
[InlineKeyboardButton("✏️ Изменить расписание", callback_data="mg:schedule:edit:list")],
[InlineKeyboardButton("🗑️ Удалить расписание", callback_data="mg:schedule:delete:list")],
[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")],
]
)
await self._edit(callback, text, keyboard)
async def _render_schedule_edit_list(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
await self._edit(
callback,
"Нет расписаний для изменения.",
InlineKeyboardMarkup(
[[InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")]]
),
)
return
lines = ["Выберите расписание для изменения:"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for schedule in schedules:
status = "🟢" if schedule.is_active else "⚪️"
lines.append(f"#{schedule.id} {schedule.name}: {schedule.cron} ({status})")
keyboard_rows.append(
[
InlineKeyboardButton(
f"✏️ #{schedule.id} {schedule.name}",
callback_data=f"mg:schedule:edit:{schedule.id}",
)
]
)
keyboard_rows.append([InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")])
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await self._edit(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_schedule_delete_list(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
await self._edit(
callback,
"Нет расписаний для удаления.",
InlineKeyboardMarkup(
[[InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")]]
),
)
return
lines = ["⚠️ Выберите расписание для удаления:"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for schedule in schedules:
status = "🟢" if schedule.is_active else "⚪️"
lines.append(f"#{schedule.id} {schedule.name}: {schedule.cron} ({status})")
keyboard_rows.append(
[
InlineKeyboardButton(
f"🗑️ #{schedule.id} {schedule.name}",
callback_data=f"mg:schedule:delete:{schedule.id}",
)
]
)
keyboard_rows.append([InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")])
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await self._edit(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
def _menu_keyboard(self) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[InlineKeyboardButton("📋 Просмотреть вопросы", callback_data="mg:view:1")],
[InlineKeyboardButton("➕ Добавить вопрос", callback_data="mg:add")],
[InlineKeyboardButton("✏️ Изменить вопрос", callback_data="mg:edit:1")],
[InlineKeyboardButton("📊 Смотреть статистику", callback_data="mg:stats:1")],
[InlineKeyboardButton("⏰ Расписания", callback_data="mg:schedule:list")],
]
)
def _back_to_menu_keyboard(self) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")]])
def _pagination_keyboard(self, prefix: str, page: int, total_pages: int) -> InlineKeyboardMarkup:
rows = self._pagination_rows(prefix, page, total_pages)
rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
return InlineKeyboardMarkup(rows)
def _pagination_rows(self, prefix: str, page: int, total_pages: int) -> list[list[InlineKeyboardButton]]:
buttons: list[InlineKeyboardButton] = []
if page > 1:
buttons.append(InlineKeyboardButton("⬅️", callback_data=f"{prefix}:{page - 1}"))
if page < total_pages:
buttons.append(InlineKeyboardButton("➡️", callback_data=f"{prefix}:{page + 1}"))
if not buttons:
return []
return [buttons]
async def _edit(
self,
callback: CallbackQuery,
text: str,
reply_markup: InlineKeyboardMarkup,
) -> None:
if callback.message:
try:
await callback.message.edit_text(text=text, reply_markup=reply_markup)
except MessageNotModified:
return
def _parse_question_input(self, text: str) -> tuple[str, list[str]]:
parts = [part.strip() for part in text.split("|")]
if len(parts) < 3:
raise ValueError("Нужно указать вопрос и минимум 2 варианта ответа.")
question_text = parts[0]
answers = [part for part in parts[1:] if part]
if not question_text:
raise ValueError("Текст вопроса не может быть пустым.")
if len(answers) < 2:
raise ValueError("Нужно указать минимум 2 непустых варианта.")
return question_text, answers
def _parse_schedule_input(self, text: str) -> tuple[str, str, bool]:
parts = [part.strip() for part in text.split("|")]
if len(parts) != 3:
raise ValueError("Нужно указать имя, cron и active-флаг (1 или 0).")
name, cron, active_token = parts
if not name:
raise ValueError("Имя расписания не может быть пустым.")
validate_cron_expression(cron)
if active_token not in ("0", "1"):
raise ValueError("active-флаг должен быть 1 (активно) или 0 (выключено).")
return name, cron, active_token == "1"
def _extract_page(self, data: str) -> int:
try:
return max(1, int(data.rsplit(":", 1)[1]))
except (IndexError, ValueError):
return 1
def _extract_id(self, data: str) -> int:
try:
value = int(data.rsplit(":", 1)[1])
except (IndexError, ValueError) as error:
raise ValueError("Invalid callback payload.") from error
return value

4
bot/manage/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .controller import ManageController
from .state import PendingManageAction
__all__ = ["ManageController", "PendingManageAction"]

138
bot/manage/api.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from pyrogram import Client
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from game import SQLiteWouldYouRatherRepository
from game.api_service import QuestionApiService, parse_wyr_app_payload
from .shared import edit_message, menu_keyboard
from .state import PendingManageAction
class ApiMenu:
def __init__(
self,
repository: SQLiteWouldYouRatherRepository,
pending_actions: dict[int, PendingManageAction],
) -> None:
self.repository = repository
self.pending_actions = pending_actions
async def handle_callback(self, client: Client, callback: CallbackQuery, data: str, user_id: int) -> bool:
"""Handle API-related callbacks. Returns True if handled."""
if data == "mg:api:load":
self.pending_actions.pop(user_id, None)
await self._handle_api_load(callback, user_id)
return True
if data == "mg:api:next":
self.pending_actions.pop(user_id, None)
offset = int(self.repository.get_setting("api_offset", "0"))
self.repository.set_setting("api_offset", str(offset + 5))
await self._handle_api_load(callback, user_id)
return True
if data.startswith("mg:api:add:"):
index = int(data.split(":")[-1])
await self._handle_api_add(callback, user_id, index)
await callback.answer()
return True
if data == "mg:api:done":
offset = int(self.repository.get_setting("api_offset", "0"))
self.repository.set_setting("api_offset", str(offset + 5))
self.pending_actions.pop(user_id, None)
await edit_message(callback, "✅ Загрузка завершена.", menu_keyboard())
await callback.answer()
return True
return False
async def _handle_api_load(self, callback: CallbackQuery, user_id: int) -> None:
offset_str = self.repository.get_setting("api_offset", "0")
offset = int(offset_str)
page_size = 5
api_url = f"https://io.wyr.app/api/v1/statements/ru/daily?pageOffset={offset}&pageSize={page_size}"
service = QuestionApiService(url=api_url, handler=parse_wyr_app_payload)
try:
questions = service.fetch_questions()
if not questions:
await callback.answer("API не вернуло вопросов.", show_alert=True)
return
self.pending_actions[user_id] = PendingManageAction(
mode="api_select",
api_questions=questions,
added_indices=set()
)
await self._render_api_selection(callback, questions)
await callback.answer()
except Exception as e:
await callback.answer(f"Ошибка загрузки: {str(e)}", show_alert=True)
async def _render_api_selection(self, callback: CallbackQuery, questions: list) -> None:
user_id = callback.from_user.id if callback.from_user else None
action = self.pending_actions.get(user_id) if user_id else None
added_indices = action.added_indices if action else set()
text = "🌐 **Выберите вопросы для добавления в базу:**\n\n"
keyboard_rows = []
for i, q in enumerate(questions):
is_added = i in added_indices
status = "✅ [ДОБАВЛЕНО] " if is_added else ""
answers_text = "\n ".join([f"🔹 {a}" for a in q.answers])
text += f"{i+1}. {status}{q.text}\n {answers_text}\n\n"
if is_added:
keyboard_rows.append([InlineKeyboardButton(f"➖ Удалить #{i+1}", callback_data=f"mg:api:add:{i}")])
else:
keyboard_rows.append([InlineKeyboardButton(f"➕ Добавить #{i+1}", callback_data=f"mg:api:add:{i}")])
keyboard_rows.append([InlineKeyboardButton("➡️ Загрузить еще (пропустить эти)", callback_data="mg:api:next")])
keyboard_rows.append([InlineKeyboardButton("✅ Готово", callback_data="mg:api:done")])
await edit_message(callback, text, InlineKeyboardMarkup(keyboard_rows))
async def _handle_api_add(self, callback: CallbackQuery, user_id: int, index: int) -> None:
action = self.pending_actions.get(user_id)
if not action or action.mode != "api_select" or not action.api_questions:
await callback.answer("Сессия истекла.", show_alert=True)
return
if index < 0 or index >= len(action.api_questions):
await callback.answer("Некорректный индекс.", show_alert=True)
return
if index in action.added_indices:
question = action.api_questions[index]
try:
if self.repository.delete_question_by_text(question.text):
action.added_indices.remove(index)
await callback.answer(f"Вопрос #{index+1} удалён!")
await self._render_api_selection(callback, action.api_questions)
else:
await callback.answer("Не удалось найти вопрос для удаления.", show_alert=True)
except Exception as e:
await callback.answer(f"Ошибка удаления: {str(e)}", show_alert=True)
return
question = action.api_questions[index]
try:
self.repository.add_question(question.text, question.answers)
action.added_indices.add(index)
await callback.answer(f"Вопрос #{index+1} добавлен!")
await self._render_api_selection(callback, action.api_questions)
except Exception as e:
if "UNIQUE constraint failed" in str(e):
action.added_indices.add(index)
await callback.answer("Этот вопрос уже есть в базе.", show_alert=True)
await self._render_api_selection(callback, action.api_questions)
else:
await callback.answer(f"Ошибка: {str(e)}", show_alert=True)

99
bot/manage/controller.py Normal file
View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from pyrogram import Client, filters
from pyrogram.handlers import CallbackQueryHandler, MessageHandler
from pyrogram.types import Message
from game import SQLiteWouldYouRatherRepository
from bot.command_system import CommandContext
from .api import ApiMenu
from .questions import QuestionMenu
from .schedules import ScheduleMenu
from .shared import edit_message, menu_keyboard
from .state import PendingManageAction
from .statistics import StatisticsMenu
class ManageController:
def __init__(
self,
context: CommandContext,
page_size: int = 5,
repository: SQLiteWouldYouRatherRepository | None = None,
) -> None:
self.context = context
self.page_size = page_size
self.repository = repository or SQLiteWouldYouRatherRepository()
self.pending_actions: dict[int, PendingManageAction] = {}
# Initialize menu handlers
self.question_menu = QuestionMenu(self.repository, self.pending_actions, page_size)
self.statistics_menu = StatisticsMenu(self.repository, self.pending_actions, page_size)
self.schedule_menu = ScheduleMenu(self.repository, self.pending_actions)
self.api_menu = ApiMenu(self.repository, self.pending_actions)
def register(self, client: Client) -> None:
client.add_handler(
MessageHandler(self._on_manage_command, filters.command(["manage"]))
)
client.add_handler(
CallbackQueryHandler(self._on_callback_query, filters.regex(r"^mg:"))
)
client.add_handler(
MessageHandler(self._on_text_message, filters.text & ~filters.regex(r"^/"))
)
async def _on_manage_command(self, _: Client, message: Message) -> None:
user_id = message.from_user.id if message.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
await message.reply_text("Команда доступна только администраторам.")
return
await message.reply_text(
"Панель управления вопросами:",
reply_markup=menu_keyboard(),
)
async def _on_callback_query(self, client: Client, callback) -> None:
user_id = callback.from_user.id if callback.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
await callback.answer("Только для администраторов.", show_alert=True)
return
data = callback.data or ""
# Main menu
if data == "mg:menu":
self.pending_actions.pop(user_id, None)
await edit_message(callback, "Панель управления вопросами:", menu_keyboard())
await callback.answer()
return
# Delegate to menu handlers
if await self.question_menu.handle_callback(client, callback, data, user_id):
return
if await self.statistics_menu.handle_callback(client, callback, data, user_id):
return
if await self.schedule_menu.handle_callback(client, callback, data, user_id):
return
if await self.api_menu.handle_callback(client, callback, data, user_id):
return
await callback.answer("Неизвестное действие.")
async def _on_text_message(self, client: Client, message: Message) -> None:
user_id = message.from_user.id if message.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
return
# Delegate to menu handlers
if await self.question_menu.handle_text_message(message, user_id):
return
if await self.schedule_menu.handle_text_message(message, user_id):
return

175
bot/manage/questions.py Normal file
View File

@@ -0,0 +1,175 @@
from __future__ import annotations
import math
from pyrogram import Client
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from game import SQLiteWouldYouRatherRepository
from .shared import (
back_to_menu_keyboard,
edit_message,
extract_id,
extract_page,
menu_keyboard,
pagination_keyboard,
parse_question_input,
)
from .state import PendingManageAction
class QuestionMenu:
def __init__(
self,
repository: SQLiteWouldYouRatherRepository,
pending_actions: dict[int, PendingManageAction],
page_size: int = 5,
) -> None:
self.repository = repository
self.pending_actions = pending_actions
self.page_size = page_size
async def handle_callback(self, client: Client, callback: CallbackQuery, data: str, user_id: int) -> bool:
"""Handle question-related callbacks. Returns True if handled."""
if data.startswith("mg:view:"):
page = extract_page(data)
await self._render_questions_page(callback, page)
await callback.answer()
return True
if data == "mg:add":
self.pending_actions.pop(user_id, None)
self.pending_actions[user_id] = PendingManageAction(mode="add")
await edit_message(
callback,
"Отправьте новый вопрос в формате:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]",
back_to_menu_keyboard(),
)
await callback.answer()
return True
if data.startswith("mg:edit:"):
page = extract_page(data)
await self._render_edit_page(callback, page)
await callback.answer()
return True
if data.startswith("mg:editq:"):
try:
question_id = extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return True
self.pending_actions[user_id] = PendingManageAction(mode="edit", question_id=question_id)
await edit_message(
callback,
"Отправьте обновлённый вопрос в формате:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]",
back_to_menu_keyboard(),
)
await callback.answer()
return True
return False
async def handle_text_message(self, message: Message, user_id: int) -> bool:
"""Handle text messages for question actions. Returns True if handled."""
action = self.pending_actions.get(user_id)
if action is None:
return False
text = message.text or ""
if action.mode == "add":
try:
question_text, answers = parse_question_input(text)
question_id = self.repository.add_question(question_text, answers)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Вопрос добавлен (ID: {question_id}).",
reply_markup=menu_keyboard(),
)
except ValueError as error:
await message.reply_text(
f"{error}\n\nИспользуйте формат:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]"
)
return True
if action.mode == "edit":
if action.question_id is None:
await message.reply_text("Ошибка: ID вопроса не найден.")
return True
try:
question_text, answers = parse_question_input(text)
self.repository.update_question(action.question_id, question_text, answers)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Вопрос #{action.question_id} обновлён.",
reply_markup=menu_keyboard(),
)
except ValueError as error:
await message.reply_text(
f"{error}\n\nИспользуйте формат:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]"
)
return True
return False
async def _render_questions_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions(unused_only=True)
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(
limit=self.page_size, offset=offset, unused_only=True
)
if not rows:
text = "В очереди пока нет вопросов."
else:
lines = [
f"В очереди: {total} вопросов",
f"Список (страница {safe_page}/{total_pages}):",
"",
]
for item in rows:
short_text = (
item.text if len(item.text) <= 100 else f"{item.text[:97]}..."
)
full_question = self.repository.get_question(item.id)
answers_text = ", ".join([a.text for a in full_question.answers]) if full_question else "нет вариантов"
lines.append(f"#{item.id}: {short_text}\n 🔹 {answers_text}\n")
text = "\n".join(lines)
await edit_message(callback, text, pagination_keyboard("mg:view", safe_page, total_pages))
async def _render_edit_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions()
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(limit=self.page_size, offset=offset)
if not rows:
await edit_message(callback, "Нет вопросов для изменения.", back_to_menu_keyboard())
return
lines = [f"Выберите вопрос для изменения ({safe_page}/{total_pages}):"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for item in rows:
short_text = item.text if len(item.text) <= 50 else f"{item.text[:47]}..."
lines.append(f"#{item.id}: {short_text}")
keyboard_rows.append(
[InlineKeyboardButton(f"✏️ #{item.id}", callback_data=f"mg:editq:{item.id}")]
)
from .shared import pagination_rows
keyboard_rows.extend(pagination_rows("mg:edit", safe_page, total_pages))
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await edit_message(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))

269
bot/manage/schedules.py Normal file
View File

@@ -0,0 +1,269 @@
from __future__ import annotations
from pyrogram import Client
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from game import SQLiteWouldYouRatherRepository
from .shared import (
back_to_menu_keyboard,
edit_message,
extract_id,
menu_keyboard,
parse_schedule_input,
)
from .state import PendingManageAction
class ScheduleMenu:
def __init__(
self,
repository: SQLiteWouldYouRatherRepository,
pending_actions: dict[int, PendingManageAction],
) -> None:
self.repository = repository
self.pending_actions = pending_actions
async def handle_callback(self, client: Client, callback: CallbackQuery, data: str, user_id: int) -> bool:
"""Handle schedule-related callbacks. Returns True if handled."""
if data == "mg:schedule:help":
await self._render_help(callback)
await callback.answer()
return True
if data == "mg:schedule:list":
await self._render_schedules(callback)
await callback.answer()
return True
if data == "mg:schedule:add":
self.pending_actions.pop(user_id, None)
self.pending_actions[user_id] = PendingManageAction(mode="schedule_add")
await edit_message(
callback,
"Отправьте расписание в формате:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>\n"
"Пример: Утро | 0 9 * * 1-5 | 1",
back_to_menu_keyboard(),
)
await callback.answer()
return True
if data == "mg:schedule:edit:list":
await self._render_schedule_edit_list(callback)
await callback.answer()
return True
if data.startswith("mg:schedule:edit:"):
try:
schedule_id = extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return True
schedule = self.repository.get_schedule(schedule_id)
if schedule is None:
await callback.answer("Расписание не найдено.", show_alert=True)
return True
self.pending_actions[user_id] = PendingManageAction(
mode="schedule_edit",
schedule_id=schedule_id,
)
await edit_message(
callback,
"Отправьте обновлённое расписание в формате:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>\n"
f"Текущее: {schedule.name} | {schedule.cron} | {1 if schedule.is_active else 0}",
back_to_menu_keyboard(),
)
await callback.answer()
return True
if data == "mg:schedule:delete:list":
await self._render_schedule_delete_list(callback)
await callback.answer()
return True
if data.startswith("mg:schedule:delete:"):
try:
schedule_id = extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return True
try:
schedule = self.repository.get_schedule(schedule_id)
if schedule is None:
await callback.answer("Расписание не найдено.", show_alert=True)
return True
self.repository.delete_schedule(schedule_id)
await callback.answer(f"Расписание '{schedule.name}' удалено.", show_alert=True)
await self._render_schedule_delete_list(callback)
except ValueError as error:
await callback.answer(str(error), show_alert=True)
return True
return False
async def handle_text_message(self, message: Message, user_id: int) -> bool:
"""Handle text messages for schedule actions. Returns True if handled."""
action = self.pending_actions.get(user_id)
if action is None:
return False
text = message.text or ""
if action.mode == "schedule_add":
try:
name, cron, is_active = parse_schedule_input(text)
self.repository.add_schedule(name=name, cron=cron, is_active=is_active)
self.pending_actions.pop(user_id, None)
await message.reply_text(
"Расписание добавлено.",
reply_markup=menu_keyboard(),
)
except ValueError as error:
await message.reply_text(
f"{error}\n\nИспользуйте формат:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>"
)
return True
if action.mode == "schedule_edit":
if action.schedule_id is None:
await message.reply_text("Ошибка: ID расписания не найден.")
return True
try:
name, cron, is_active = parse_schedule_input(text)
self.repository.update_schedule(
schedule_id=action.schedule_id,
name=name,
cron=cron,
is_active=is_active,
)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Расписание #{action.schedule_id} обновлено.",
reply_markup=menu_keyboard(),
)
except ValueError as error:
await message.reply_text(
f"{error}\n\nИспользуйте формат:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>"
)
return True
return False
async def _render_schedules(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
text = "Расписаний пока нет."
else:
lines = ["Текущие расписания:"]
for schedule in schedules:
status = "🟢 active" if schedule.is_active else "⚪️ inactive"
lines.append(
f"#{schedule.id} [{status}] {schedule.name}: `{schedule.cron}`"
)
text = "\n".join(lines)
keyboard = InlineKeyboardMarkup(
[
[InlineKeyboardButton("➕ Добавить расписание", callback_data="mg:schedule:add")],
[InlineKeyboardButton("✏️ Изменить расписание", callback_data="mg:schedule:edit:list")],
[InlineKeyboardButton("🗑️ Удалить расписание", callback_data="mg:schedule:delete:list")],
[InlineKeyboardButton("❓ Помощь", callback_data="mg:schedule:help")],
[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")],
]
)
await edit_message(callback, text, keyboard)
async def _render_schedule_edit_list(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
await edit_message(
callback,
"Нет расписаний для изменения.",
InlineKeyboardMarkup(
[[InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")]]
),
)
return
lines = ["Выберите расписание для изменения:"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for schedule in schedules:
status = "🟢" if schedule.is_active else "⚪️"
lines.append(f"#{schedule.id} {schedule.name}: {schedule.cron} ({status})")
keyboard_rows.append(
[
InlineKeyboardButton(
f"✏️ #{schedule.id} {schedule.name}",
callback_data=f"mg:schedule:edit:{schedule.id}",
)
]
)
keyboard_rows.append([InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")])
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await edit_message(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_schedule_delete_list(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
await edit_message(
callback,
"Нет расписаний для удаления.",
InlineKeyboardMarkup(
[[InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")]]
),
)
return
lines = ["⚠️ Выберите расписание для удаления:"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for schedule in schedules:
status = "🟢" if schedule.is_active else "⚪️"
lines.append(f"#{schedule.id} {schedule.name}: {schedule.cron} ({status})")
keyboard_rows.append(
[
InlineKeyboardButton(
f"🗑️ #{schedule.id} {schedule.name}",
callback_data=f"mg:schedule:delete:{schedule.id}",
)
]
)
keyboard_rows.append([InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")])
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await edit_message(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_help(self, callback: CallbackQuery) -> None:
help_text = (
"📖 <b>Помощь по расписаниям</b>\n\n"
"<b>Формат ввода:</b>\n"
"название | cron | active\n\n"
"<b>Cron-формат (5 полей, UTC):</b>\n"
"минута час день месяц день_недели\n\n"
"<b>Примеры:</b>\n"
"• <code>0 9 * * 1-5</code> — каждый рабочий день в 09:00 UTC\n"
"• <code>30 12 * * *</code> — каждый день в 12:30 UTC\n"
"• <code>0 18 * * 0</code> — каждое воскресенье в 18:00 UTC\n"
"• <code>*/15 * * * *</code> — каждые 15 минут\n"
"• <code>0 9,18 * * *</code> — дважды в день: 09:00 и 18:00\n\n"
"<b>День недели:</b> 0 и 7 = воскресенье, 1 = понедельник\n\n"
"<b>Active-флаг:</b>\n"
"• 1 — расписание активно (вопросы публикуются)\n"
"• 0 — расписание выключено"
)
keyboard = InlineKeyboardMarkup(
[
[InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")],
[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")],
]
)
await edit_message(callback, help_text, keyboard)

94
bot/manage/shared.py Normal file
View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from pyrogram.errors import MessageNotModified
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from bot.scheduler import validate_cron_expression
def menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[InlineKeyboardButton("📋 Просмотреть вопросы", callback_data="mg:view:1")],
[InlineKeyboardButton("➕ Добавить вопрос", callback_data="mg:add")],
[InlineKeyboardButton("🌐 Загрузить из API", callback_data="mg:api:load")],
[InlineKeyboardButton("✏️ Изменить вопрос", callback_data="mg:edit:1")],
[InlineKeyboardButton("📊 Смотреть статистику", callback_data="mg:stats:1")],
[InlineKeyboardButton("⏰ Расписания", callback_data="mg:schedule:list")],
]
)
def back_to_menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")]])
def pagination_keyboard(prefix: str, page: int, total_pages: int) -> InlineKeyboardMarkup:
rows = pagination_rows(prefix, page, total_pages)
rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
return InlineKeyboardMarkup(rows)
def pagination_rows(prefix: str, page: int, total_pages: int) -> list[list[InlineKeyboardButton]]:
buttons: list[InlineKeyboardButton] = []
if page > 1:
buttons.append(InlineKeyboardButton("⬅️", callback_data=f"{prefix}:{page - 1}"))
if page < total_pages:
buttons.append(InlineKeyboardButton("➡️", callback_data=f"{prefix}:{page + 1}"))
if not buttons:
return []
return [buttons]
async def edit_message(
callback: CallbackQuery,
text: str,
reply_markup: InlineKeyboardMarkup,
) -> None:
if callback.message:
try:
await callback.message.edit_text(text=text, reply_markup=reply_markup)
except MessageNotModified:
return
def parse_question_input(text: str) -> tuple[str, list[str]]:
parts = [part.strip() for part in text.split("|")]
if len(parts) < 3:
raise ValueError("Нужно указать вопрос и минимум 2 варианта ответа.")
question_text = parts[0]
answers = [part for part in parts[1:] if part]
if not question_text:
raise ValueError("Текст вопроса не может быть пустым.")
if len(answers) < 2:
raise ValueError("Нужно указать минимум 2 непустых варианта.")
return question_text, answers
def parse_schedule_input(text: str) -> tuple[str, str, bool]:
parts = [part.strip() for part in text.split("|")]
if len(parts) != 3:
raise ValueError("Нужно указать имя, cron и active-флаг (1 или 0).")
name, cron, active_token = parts
if not name:
raise ValueError("Имя расписания не может быть пустым.")
validate_cron_expression(cron)
if active_token not in ("0", "1"):
raise ValueError("active-флаг должен быть 1 (активно) или 0 (выключено).")
return name, cron, active_token == "1"
def extract_page(data: str) -> int:
try:
return max(1, int(data.rsplit(":", 1)[1]))
except (IndexError, ValueError):
return 1
def extract_id(data: str) -> int:
try:
value = int(data.rsplit(":", 1)[1])
except (IndexError, ValueError) as error:
raise ValueError("Invalid callback payload.") from error
return value

14
bot/manage/state.py Normal file
View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass, field
from game.api_service import ApiQuestion
@dataclass
class PendingManageAction:
mode: str
question_id: int | None = None
schedule_id: int | None = None
api_questions: list[ApiQuestion] | None = None
added_indices: set[int] = field(default_factory=set)

104
bot/manage/statistics.py Normal file
View File

@@ -0,0 +1,104 @@
from __future__ import annotations
import math
from pyrogram import Client
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from game import SQLiteWouldYouRatherRepository
from .shared import (
back_to_menu_keyboard,
edit_message,
extract_id,
extract_page,
pagination_rows,
)
from .state import PendingManageAction
class StatisticsMenu:
def __init__(
self,
repository: SQLiteWouldYouRatherRepository,
pending_actions: dict[int, PendingManageAction],
page_size: int = 5,
) -> None:
self.repository = repository
self.pending_actions = pending_actions
self.page_size = page_size
async def handle_callback(self, client: Client, callback: CallbackQuery, data: str, user_id: int) -> bool:
"""Handle statistics-related callbacks. Returns True if handled."""
if data.startswith("mg:stats:"):
page = extract_page(data)
await self._render_stats_page(callback, page)
await callback.answer()
return True
if data.startswith("mg:statq:"):
try:
question_id = extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return True
await self._render_question_stats(callback, question_id)
await callback.answer()
return True
return False
async def _render_stats_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions()
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(limit=self.page_size, offset=offset)
if not rows:
await edit_message(callback, "Нет вопросов для статистики.", back_to_menu_keyboard())
return
lines = [f"Выберите вопрос для статистики ({safe_page}/{total_pages}):"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for item in rows:
short_text = item.text if len(item.text) <= 50 else f"{item.text[:47]}..."
lines.append(f"#{item.id}: {short_text}")
keyboard_rows.append(
[InlineKeyboardButton(f"📊 #{item.id}", callback_data=f"mg:statq:{item.id}")]
)
keyboard_rows.extend(pagination_rows("mg:stats", safe_page, total_pages))
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await edit_message(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_question_stats(self, callback: CallbackQuery, question_id: int) -> None:
question = self.repository.get_question(question_id)
if question is None:
await edit_message(
callback,
"Вопрос не найден.",
InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ В статистику", callback_data="mg:stats:1")]]),
)
return
stats = self.repository.get_question_statistics(question_id)
total_votes = sum(item.count for item in stats)
lines = [f"Статистика для вопроса #{question_id}:", question.question.text, ""]
if not stats:
lines.append("Нет вариантов ответа.")
else:
for item in stats:
percent = (item.count / total_votes * 100) if total_votes else 0.0
lines.append(f"{item.answer_text}: {item.count} ({percent:.1f}%)")
lines.append("")
lines.append(f"Всего голосов: {total_votes}")
keyboard = InlineKeyboardMarkup(
[
[InlineKeyboardButton("⬅️ К вопросам статистики", callback_data="mg:stats:1")],
[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")],
]
)
await edit_message(callback, "\n".join(lines), keyboard)

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
import logging
from pyrogram import Client, raw
from pyrogram.handlers import RawUpdateHandler
from pyrogram import Client
from pyrogram.handlers import PollHandler
from pyrogram.types import Poll
from game import SQLiteWouldYouRatherRepository
@@ -11,77 +12,82 @@ logger = logging.getLogger(__name__)
class PollStatisticsHandler:
"""Обработчик ответов на опросы для записи статистики."""
"""
Обработчик обновлений опросов для записи статистики.
Для анонимных опросов Telegram не отправляет индивидуальные голоса,
только агрегированные результаты. Мы отслеживаем изменения в количестве
голосов и записываем дельту.
"""
def __init__(self, repository: SQLiteWouldYouRatherRepository | None = None) -> None:
self.repository = repository or SQLiteWouldYouRatherRepository()
# Кэш последних известных результатов для вычисления дельты
# poll_id -> {option_index: voter_count}
self._last_results: dict[str, dict[int, int]] = {}
def register(self, client: Client) -> None:
"""Регистрирует обработчик в клиенте Pyrogram."""
client.add_handler(RawUpdateHandler(self._on_raw_update))
client.add_handler(PollHandler(self._on_poll_update))
logger.info("Poll statistics handler registered.")
async def _on_raw_update(self, client: Client, update, users, chats) -> None:
"""Обрабатывает raw update от Telegram."""
# Проверяем, является ли это обновлением голосования в опросе
if not isinstance(update, raw.types.UpdateMessagePollVote):
return
async def _on_poll_update(self, client: Client, poll: Poll) -> None:
"""
Обрабатывает обновление опроса (изменение результатов).
Telegram отправляет это событие каждый раз, когда кто-то голосует
в анонимном опросе, но содержит только агрегированные результаты.
"""
try:
await self._process_poll_vote(update)
except Exception:
logger.exception("Failed to process poll vote: %s", update)
poll_id = poll.id
if not poll_id:
return
async def _process_poll_vote(self, update: raw.types.UpdateMessagePollVote) -> None:
"""Обрабатывает голос пользователя в опросе."""
poll_id = str(update.poll_id)
# Получаем question_id по poll_id
question_id = self.repository.get_question_id_by_poll(poll_id)
if question_id is None:
logger.debug("Poll %s not found in mappings, skipping.", poll_id)
return
# Получаем question_id по poll_id
question_id = self.repository.get_question_id_by_poll(poll_id)
if question_id is None:
logger.debug("Poll %s not found in mappings, skipping.", poll_id)
return
# Обрабатываем выбранные опции (каждая опция - это bytes объект)
option_bytes_list = update.options or []
# Получаем user_id из peer
peer = update.peer
if isinstance(peer, raw.types.PeerUser):
user_id = peer.user_id
else:
user_id = "unknown"
for option_data in option_bytes_list:
# В Telegram API каждая опция - это один байт с индексом
# option_data это bytes объект, берём первый байт как индекс
try:
if isinstance(option_data, bytes):
option_index = option_data[0] if len(option_data) > 0 else 0
else:
option_index = int(option_data)
except (ValueError, TypeError, IndexError) as e:
logger.warning("Invalid option data: %s (%s)", option_data, e)
continue
# Получаем текущие результаты
current_results = {}
for idx, option in enumerate(poll.options):
current_results[idx] = option.voter_count
# Получаем предыдущие результаты или инициализируем нулями
previous_results = self._last_results.get(poll_id, {})
# Получаем answer_id по индексу опции
answer_id = self.repository.get_answer_id_by_option_index(
question_id, option_index
)
if answer_id is None:
logger.warning(
"Answer not found for question %s option %s",
question_id,
option_index,
)
continue
# Вычисляем и записываем изменения
for option_index, current_count in current_results.items():
previous_count = previous_results.get(option_index, 0)
delta = current_count - previous_count
if delta > 0:
# Получаем answer_id по индексу опции
answer_id = self.repository.get_answer_id_by_option_index(
question_id, option_index
)
if answer_id is None:
logger.warning(
"Answer not found for question %s option %s",
question_id,
option_index,
)
continue
# Записываем статистику
self.repository.record_answer(question_id, answer_id, increment=1)
logger.debug(
"Recorded answer: question=%s, answer=%s, option=%s, user=%s",
question_id,
answer_id,
option_index,
user_id,
)
# Записываем дельту в статистику
self.repository.record_answer(question_id, answer_id, increment=delta)
logger.debug(
"Recorded %d new vote(s): question=%s, answer=%s, option=%s (total: %d)",
delta,
question_id,
answer_id,
option_index,
current_count,
)
# Сохраняем текущие результаты для следующего сравнения
self._last_results[poll_id] = current_results
except Exception:
logger.exception("Failed to process poll update: %s", poll)

View File

@@ -89,10 +89,12 @@ class QuestionPublisherScheduler:
client: Client,
repository: SQLiteWouldYouRatherRepository,
channel_ids: list[int],
admin_ids: set[int] | None = None,
) -> None:
self.client = client
self.repository = repository
self.channel_ids = channel_ids
self.admin_ids = admin_ids or set()
self._task: asyncio.Task[None] | None = None
def start(self) -> None:
@@ -187,3 +189,13 @@ class QuestionPublisherScheduler:
channel_id,
schedule_name,
)
# Проверяем количество оставшихся вопросов
unused_count = self.repository.count_questions(unused_only=True)
if unused_count == 2:
admin_message = "⚠️ В базе осталось всего 2 неиспользованных вопроса. Пожалуйста, пополните базу!"
for admin_id in self.admin_ids:
try:
await self.client.send_message(admin_id, admin_message)
except Exception:
logger.exception("Failed to notify admin %s about low questions.", admin_id)