From 19acea8481d6872548f222e5975c7ca026bcdfc5 Mon Sep 17 00:00:00 2001 From: Yuriy Yuriev Date: Tue, 21 Jul 2026 00:22:37 +0700 Subject: [PATCH] fix --- auth/storage.py | 49 +++++++- handlers/commands.py | 269 +++++++++++++++++++++++++++---------------- 2 files changed, 214 insertions(+), 104 deletions(-) diff --git a/auth/storage.py b/auth/storage.py index c135e5c..2569e64 100644 --- a/auth/storage.py +++ b/auth/storage.py @@ -23,15 +23,52 @@ class AuthStorage: self.file_path.parent.mkdir(parents=True, exist_ok=True) self._lock = asyncio.Lock() - async def register(self, user_id: int) -> bool: + async def register( + self, user_id: int, username: str = None, full_name: str = None + ) -> bool: + """Регистрирует пользователя и освежает его профиль. + + Профиль перезаписывается и для уже известных пользователей — ник в + Telegram может смениться, а взять его неоткуда, кроме входящего + апдейта. + """ async with self._lock: data = await asyncio.to_thread(self._load_sync) - if str(user_id) in data: - return False - data[str(user_id)] = {"registered_at": datetime.now().isoformat()} + key = str(user_id) + is_new = key not in data + record = data.get(key, {}) + if is_new: + record["registered_at"] = datetime.now().isoformat() + if username is not None: + record["username"] = username + if full_name is not None: + record["full_name"] = full_name + data[key] = record await asyncio.to_thread(self._save_sync, data) - logger.info(f"User {user_id} registered") - return True + if is_new: + logger.info(f"User {user_id} registered ({username or full_name or 'no name'})") + return is_new + + async def get_profile(self, user_id: int) -> dict: + async with self._lock: + data = await asyncio.to_thread(self._load_sync) + return data.get(str(user_id), {}) + + async def get_all_profiles(self) -> dict: + """Возвращает {user_id: профиль} для всех пользователей.""" + async with self._lock: + data = await asyncio.to_thread(self._load_sync) + return {int(k): v for k, v in data.items()} + + @staticmethod + def display_name(profile: dict) -> str: + """Человекочитаемое имя: @ник, иначе имя, иначе пусто.""" + if not profile: + return "" + username = profile.get("username") + if username: + return f"@{username}" + return profile.get("full_name") or "" async def delete_user(self, user_id: int) -> bool: async with self._lock: diff --git a/handlers/commands.py b/handlers/commands.py index 8ceea3a..5cdc786 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -97,13 +97,15 @@ class BotInterface: latest_msg_id=message.message_id ) + # Пароля для входа нет — личность подтверждает сам Telegram. + # Первый /start заодно регистрирует пользователя; для остальных + # обновляем ник, он мог смениться. + await interface._remember_profile(message.from_user) + if interface.auth_manager.is_authenticated(user_id) and interface.auth_manager.is_admin(user_id): await interface._show_main_menu(message) return - # Пароля для входа нет — личность подтверждает сам Telegram. - # Первый /start заодно регистрирует пользователя. - await interface.auth_storage.register(user_id) if not interface.auth_manager.is_authenticated(user_id): interface.auth_manager.login(user_id, is_admin=False) await interface._show_user_menu(message) @@ -1140,6 +1142,37 @@ class BotInterface: if not await require_admin(callback): return await interface._show_users_list(callback.message, edit=True) + + # === Листание админских списков === + def _page_arg(data: str, prefix: str) -> int: + try: + return int(data.replace(prefix, "", 1)) + except ValueError: + return 0 + + @dp.callback_query(F.data.startswith("users_page_")) + async def cb_users_page(callback: CallbackQuery): + if not await require_admin(callback): + return + page = _page_arg(callback.data, "users_page_") + await interface._show_users_list(callback.message, edit=True, page=page) + await callback.answer() + + @dp.callback_query(F.data.startswith("streamers_page_")) + async def cb_streamers_page(callback: CallbackQuery): + if not await require_admin(callback): + return + page = _page_arg(callback.data, "streamers_page_") + await interface._show_streamers_list(callback.message, edit=True, page=page) + await callback.answer() + + @dp.callback_query(F.data.startswith("tasks_page_")) + async def cb_tasks_page(callback: CallbackQuery): + if not await require_admin(callback): + return + page = _page_arg(callback.data, "tasks_page_") + await interface._show_tasks_list(callback.message, edit=True, page=page) + await callback.answer() await callback.answer() @dp.callback_query(F.data.startswith("udetail_")) @@ -1562,6 +1595,45 @@ class BotInterface: await self._safe_delete(message.bot, message.chat.id, self._menu_msg.pop(user_id)) await self._safe_delete(message.bot, message.chat.id, message.message_id) + # Записей на страницу. Telegram режет сообщение на 4096 символах и + # 100 кнопках — поэтому списки листаем, а не показываем целиком. + _PAGE_SIZE = 8 + + @staticmethod + def _paginate(items: list, page: int, page_size: int = None) -> tuple: + """Возвращает (срез страницы, нормализованный номер, всего страниц).""" + page_size = page_size or BotInterface._PAGE_SIZE + total_pages = max(1, (len(items) + page_size - 1) // page_size) + page = max(0, min(page, total_pages - 1)) + start = page * page_size + return items[start:start + page_size], page, total_pages + + @staticmethod + def _add_nav(builder, prefix: str, page: int, total_pages: int) -> None: + """Добавляет строку навигации «◀ 2/5 ▶», если страниц больше одной.""" + if total_pages <= 1: + return + row = [] + if page > 0: + row.append(InlineKeyboardButton(text="◀️", callback_data=f"{prefix}{page - 1}")) + row.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop")) + if page < total_pages - 1: + row.append(InlineKeyboardButton(text="▶️", callback_data=f"{prefix}{page + 1}")) + builder.row(*row) + + async def _remember_profile(self, from_user) -> None: + """Сохраняет/освежает ник пользователя из входящего апдейта.""" + if not from_user: + return + try: + await self.auth_storage.register( + from_user.id, + username=from_user.username or "", + full_name=(from_user.full_name or "").strip(), + ) + except Exception as e: + logger.warning(f"Failed to remember profile for {from_user.id}: {e}") + async def _edit_or_send(self, message: Message, text: str, markup, edit: bool, user_id: int = None) -> None: if edit: try: @@ -1611,116 +1683,100 @@ class BotInterface: await self._track_msg(uid, sent1.message_id) await self._track_msg(uid, sent2.message_id) - async def _show_streamers_list(self, message: Message, edit: bool = False): + async def _show_streamers_list(self, message: Message, edit: bool = False, page: int = 0): active = await self.task_manager.get_active_tasks() completed = await self.task_manager.get_completed_tasks() - twitch_active = {tid: p for tid, p in active.items() if p.task_type == "twitch_irc"} - twitch_done = {tid: p for tid, p in completed.items() if p.task_type == "twitch_irc"} + # Плоский список секция→задача: листаем сквозь обе секции, + # заголовок печатаем при смене секции внутри страницы + entries = [("🔄 Активные", tid, p) for tid, p in active.items() if p.task_type == "twitch_irc"] + entries += [("📁 Завершённые", tid, p) for tid, p in completed.items() if p.task_type == "twitch_irc"] - if not twitch_active and not twitch_done: + builder = InlineKeyboardBuilder() + + if not entries: text = "📺 Стримеры\n\nЗадач нет. Отправьте название канала для добавления." - builder = InlineKeyboardBuilder() - builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main")) else: - text = "📺 Стримеры\n\n" - builder = InlineKeyboardBuilder() + page_items, page, total_pages = self._paginate(entries, page) + text = f"📺 Стримеры ({len(entries)})" + if total_pages > 1: + text += f" — страница {page + 1}/{total_pages}" + text += "\n\n" - if twitch_active: - text += "🔄 Активные:\n" - for tid, p in list(twitch_active.items())[:10]: - emoji = p.get_status_emoji() - domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" + current_section = None + for section, tid, p in page_items: + if section != current_section: + text += f"{section}:\n" + current_section = section + emoji = p.get_status_emoji() + domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" + text += ( + f"{emoji} {p.channel}\n" + f" 🔗{p.links_found} | 📊{p.total_visits} | 🌐{domains}\n" + ) + suffix = f"🔗{p.links_found}" if section.startswith("🔄") else "завершена" + builder.row(InlineKeyboardButton( + text=f"{emoji} {p.channel} ({suffix})", + callback_data=f"tdetail_{tid}" + )) - text += ( - f"{emoji} {p.channel}\n" - f" 🔗{p.links_found} | 📊{p.total_visits} | 🌐{domains}\n" - ) - builder.row(InlineKeyboardButton( - text=f"{emoji} {p.channel} (🔗{p.links_found})", - callback_data=f"tdetail_{tid}" - )) - - if twitch_done: - text += "\n📁 Завершенные:\n" - for tid, p in list(twitch_done.items())[:5]: - emoji = p.get_status_emoji() - domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" - - text += ( - f"{emoji} {p.channel}\n" - f" 🔗{p.links_found} | 📊{p.total_visits} | 🌐{domains}\n" - ) - builder.row(InlineKeyboardButton( - text=f"{emoji} {p.channel} (завершена)", - callback_data=f"tdetail_{tid}" - )) - - builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main")) + self._add_nav(builder, "streamers_page_", page, total_pages) + builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main")) await self._edit_or_send(message, text, builder.as_markup(), edit) - async def _show_tasks_list(self, message: Message, edit: bool = False): + async def _show_tasks_list(self, message: Message, edit: bool = False, page: int = 0): """Все задачи — посещения и мониторинг.""" active = await self.task_manager.get_active_tasks() completed = await self.task_manager.get_completed_tasks() - visit_active = {tid: p for tid, p in active.items() if p.task_type in ("visit", "user_visit")} - visit_done = {tid: p for tid, p in completed.items() if p.task_type in ("visit", "user_visit")} - twitch_active = {tid: p for tid, p in active.items() if p.task_type == "twitch_irc"} - twitch_done = {tid: p for tid, p in completed.items() if p.task_type == "twitch_irc"} + def _pick(src, types): + return [(tid, p) for tid, p in src.items() if p.task_type in types] + + # Плоский список во всех четырёх секциях — листаем сквозной пагинацией + entries = [("📺 Twitch — активные", t, p) for t, p in _pick(active, ("twitch_irc",))] + entries += [("📺 Twitch — завершённые", t, p) for t, p in _pick(completed, ("twitch_irc",))] + entries += [("🔗 Посещения — активные", t, p) for t, p in _pick(active, ("visit", "user_visit"))] + entries += [("🔗 Посещения — завершённые", t, p) for t, p in _pick(completed, ("visit", "user_visit"))] builder = InlineKeyboardBuilder() - if not any([visit_active, visit_done, twitch_active, twitch_done]): + if not entries: text = "📊 Задачи\n\nЗадач нет." else: - text = "📊 Задачи\n\n" + page_items, page, total_pages = self._paginate(entries, page) + text = f"📊 Задачи ({len(entries)})" + if total_pages > 1: + text += f" — страница {page + 1}/{total_pages}" + text += "\n\n" - if twitch_active: - text += "📺 Twitch — активные:\n" - for tid, p in list(twitch_active.items())[:10]: - em = p.get_status_emoji() - domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" - text += f"{em} {p.channel}\n 🔗{p.links_found} | 📊{p.total_visits} | 🌐{domains}\n" - builder.row(InlineKeyboardButton( - text=f"{em} 📺 {p.channel} (🔗{p.links_found})", - callback_data=f"tdetail_{tid}" - )) - - if twitch_done: - text += "\n📺 Twitch — завершённые:\n" - for tid, p in list(twitch_done.items())[:5]: - em = p.get_status_emoji() - text += f"{em} {p.channel}\n" - builder.row(InlineKeyboardButton( - text=f"{em} 📺 {p.channel} (завершена)", - callback_data=f"tdetail_{tid}" - )) - - if visit_active: - text += "\n🔗 Посещения — активные:\n" - for tid, p in list(visit_active.items())[:10]: - em = p.get_status_emoji() + current_section = None + for section, tid, p in page_items: + if section != current_section: + text += f"\n{section}:\n" if current_section else f"{section}:\n" + current_section = section + em = p.get_status_emoji() + is_active = "активные" in section + if section.startswith("📺"): + if is_active: + domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" + text += f"{em} {p.channel}\n 🔗{p.links_found} | 📊{p.total_visits} | 🌐{domains}\n" + label = f"{em} 📺 {p.channel} (🔗{p.links_found})" + else: + text += f"{em} {p.channel}\n" + label = f"{em} 📺 {p.channel} (завершена)" + else: url_s = (p.url or "")[:35] uid_tag = f" | uid:{p.user_id}" if p.user_id else "" - text += f"{em} {url_s}{uid_tag}\n ✅{p.successful_visits}/{p.max_visits} | 📊{p.total_visits}\n" - builder.row(InlineKeyboardButton( - text=f"{em} 🔗 {url_s[:28]} ({p.successful_visits}/{p.max_visits})", - callback_data=f"tdetail_{tid}" - )) + if is_active: + text += f"{em} {url_s}{uid_tag}\n ✅{p.successful_visits}/{p.max_visits} | 📊{p.total_visits}\n" + label = f"{em} 🔗 {url_s[:28]} ({p.successful_visits}/{p.max_visits})" + else: + text += f"{em} {url_s}{uid_tag}\n" + label = f"{em} 🔗 {url_s[:28]} (завершена)" + builder.row(InlineKeyboardButton(text=label, callback_data=f"tdetail_{tid}")) - if visit_done: - text += "\n🔗 Посещения — завершённые:\n" - for tid, p in list(visit_done.items())[:5]: - em = p.get_status_emoji() - url_s = (p.url or "")[:35] - uid_tag = f" | uid:{p.user_id}" if p.user_id else "" - text += f"{em} {url_s}{uid_tag}\n" - builder.row(InlineKeyboardButton( - text=f"{em} 🔗 {url_s[:28]} (завершена)", - callback_data=f"tdetail_{tid}" - )) + self._add_nav(builder, "tasks_page_", page, total_pages) builder.row(InlineKeyboardButton(text="🔄 Обновить", callback_data="menu_tasks")) builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main")) @@ -3070,21 +3126,27 @@ class BotInterface: else: await self._send_temp(message, "Ошибка запуска задачи") - async def _show_users_list(self, message: Message, edit: bool = False): - all_user_ids, all_tasks, all_clicks, all_rub = await asyncio.gather( - self.auth_storage.get_all_user_ids(), + async def _show_users_list(self, message: Message, edit: bool = False, page: int = 0): + profiles, all_tasks, all_clicks, all_rub = await asyncio.gather( + self.auth_storage.get_all_profiles(), self.task_manager.get_all_tasks(), self.balance_storage.get_all(), self.rub_storage.get_all(), ) + all_user_ids = list(profiles.keys()) - text = f"👥 Пользователи ({len(all_user_ids)})\n\n" builder = InlineKeyboardBuilder() if not all_user_ids: - text += "Нет зарегистрированных пользователей." + text = "👥 Пользователи (0)\n\nНет зарегистрированных пользователей." else: - for uid in all_user_ids[:20]: + page_ids, page, total_pages = self._paginate(all_user_ids, page) + text = f"👥 Пользователи ({len(all_user_ids)})" + if total_pages > 1: + text += f" — страница {page + 1}/{total_pages}" + text += "\n\n" + + for uid in page_ids: clicks = all_clicks.get(str(uid), 0) rub = all_rub.get(str(uid), 0) active = sum( @@ -3096,24 +3158,35 @@ class BotInterface: bal_str += f" 🪙{rub}₽" if active: bal_str += f" 📋{active}" - text += f"👤 `{uid}` — {bal_str}\n" + # Ник известен только после того, как пользователь напишет боту + name = AuthStorage.display_name(profiles.get(uid)) + text += f"👤 {name + ' ' if name else ''}`{uid}` — {bal_str}\n" builder.row(InlineKeyboardButton( - text=f"👤 {uid} {bal_str}", + text=f"👤 {name or uid} {bal_str}", callback_data=f"udetail_{uid}", )) + self._add_nav(builder, "users_page_", page, total_pages) + builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main")) await self._edit_or_send(message, text, builder.as_markup(), edit) async def _show_user_detail(self, callback: CallbackQuery, target_user_id: int): clicks = await self.balance_storage.get_balance(target_user_id) rub = await self.rub_storage.get_balance(target_user_id) + profile = await self.auth_storage.get_profile(target_user_id) all_tasks = await self.task_manager.get_all_tasks() user_tasks = {tid: p for tid, p in all_tasks.items() if p.user_id == target_user_id} active = sum(1 for p in user_tasks.values() if not p.completed and not p.stopped) + name = AuthStorage.display_name(profile) + header = f"👤 {name} ({target_user_id})" if name else f"👤 Пользователь {target_user_id}" + full_name = profile.get("full_name") + if full_name and name != full_name: + header += f"\n📝 {full_name}" + text = ( - f"👤 Пользователь {target_user_id}\n\n" + f"{header}\n\n" f"🖱 Переходов: {clicks}\n" f"🪙 Рублей: {rub} ₽\n" f"📋 Задач: {len(user_tasks)} (активных: {active})\n"