This commit is contained in:
Yuriy Yuriev
2026-07-21 00:22:37 +07:00
parent 9aa072792d
commit 19acea8481
2 changed files with 214 additions and 104 deletions
+43 -6
View File
@@ -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:
+156 -83
View File
@@ -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]:
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} (🔗{p.links_found})",
text=f"{emoji} {p.channel} ({suffix})",
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}"
))
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]:
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"
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()
label = f"{em} 📺 {p.channel} (🔗{p.links_found})"
else:
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()
label = f"{em} 📺 {p.channel} (завершена)"
else:
url_s = (p.url or "")[:35]
uid_tag = f" | uid:{p.user_id}" if p.user_id else ""
if is_active:
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 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 ""
label = f"{em} 🔗 {url_s[:28]} ({p.successful_visits}/{p.max_visits})"
else:
text += f"{em} {url_s}{uid_tag}\n"
builder.row(InlineKeyboardButton(
text=f"{em} 🔗 {url_s[:28]} (завершена)",
callback_data=f"tdetail_{tid}"
))
label = f"{em} 🔗 {url_s[:28]} (завершена)"
builder.row(InlineKeyboardButton(text=label, 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"