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.file_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = asyncio.Lock() 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: async with self._lock:
data = await asyncio.to_thread(self._load_sync) data = await asyncio.to_thread(self._load_sync)
if str(user_id) in data: key = str(user_id)
return False is_new = key not in data
data[str(user_id)] = {"registered_at": datetime.now().isoformat()} 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) await asyncio.to_thread(self._save_sync, data)
logger.info(f"User {user_id} registered") if is_new:
return True 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 def delete_user(self, user_id: int) -> bool:
async with self._lock: async with self._lock:
+171 -98
View File
@@ -97,13 +97,15 @@ class BotInterface:
latest_msg_id=message.message_id 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): if interface.auth_manager.is_authenticated(user_id) and interface.auth_manager.is_admin(user_id):
await interface._show_main_menu(message) await interface._show_main_menu(message)
return return
# Пароля для входа нет — личность подтверждает сам Telegram.
# Первый /start заодно регистрирует пользователя.
await interface.auth_storage.register(user_id)
if not interface.auth_manager.is_authenticated(user_id): if not interface.auth_manager.is_authenticated(user_id):
interface.auth_manager.login(user_id, is_admin=False) interface.auth_manager.login(user_id, is_admin=False)
await interface._show_user_menu(message) await interface._show_user_menu(message)
@@ -1140,6 +1142,37 @@ class BotInterface:
if not await require_admin(callback): if not await require_admin(callback):
return return
await interface._show_users_list(callback.message, edit=True) 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() await callback.answer()
@dp.callback_query(F.data.startswith("udetail_")) @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, self._menu_msg.pop(user_id))
await self._safe_delete(message.bot, message.chat.id, message.message_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: async def _edit_or_send(self, message: Message, text: str, markup, edit: bool, user_id: int = None) -> None:
if edit: if edit:
try: try:
@@ -1611,116 +1683,100 @@ class BotInterface:
await self._track_msg(uid, sent1.message_id) await self._track_msg(uid, sent1.message_id)
await self._track_msg(uid, sent2.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() active = await self.task_manager.get_active_tasks()
completed = await self.task_manager.get_completed_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Задач нет. Отправьте название канала для добавления." text = "📺 Стримеры\n\nЗадач нет. Отправьте название канала для добавления."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main"))
else: else:
text = "📺 Стримеры\n\n" page_items, page, total_pages = self._paginate(entries, page)
builder = InlineKeyboardBuilder() text = f"📺 Стримеры ({len(entries)})"
if total_pages > 1:
text += f" — страница {page + 1}/{total_pages}"
text += "\n\n"
if twitch_active: current_section = None
text += "🔄 Активные:\n" for section, tid, p in page_items:
for tid, p in list(twitch_active.items())[:10]: if section != current_section:
emoji = p.get_status_emoji() text += f"{section}:\n"
domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" 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 += ( self._add_nav(builder, "streamers_page_", page, total_pages)
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"))
builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main"))
await self._edit_or_send(message, text, builder.as_markup(), edit) 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() active = await self.task_manager.get_active_tasks()
completed = await self.task_manager.get_completed_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")} def _pick(src, types):
visit_done = {tid: p for tid, p in completed.items() if p.task_type in ("visit", "user_visit")} return [(tid, p) for tid, p in src.items() if p.task_type in types]
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 = [("📺 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() builder = InlineKeyboardBuilder()
if not any([visit_active, visit_done, twitch_active, twitch_done]): if not entries:
text = "📊 Задачи\n\nЗадач нет." text = "📊 Задачи\n\nЗадач нет."
else: 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: current_section = None
text += "📺 Twitch — активные:\n" for section, tid, p in page_items:
for tid, p in list(twitch_active.items())[:10]: if section != current_section:
em = p.get_status_emoji() text += f"\n{section}:\n" if current_section else f"{section}:\n"
domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все" current_section = section
text += f"{em} {p.channel}\n 🔗{p.links_found} | 📊{p.total_visits} | 🌐{domains}\n" em = p.get_status_emoji()
builder.row(InlineKeyboardButton( is_active = "активные" in section
text=f"{em} 📺 {p.channel} (🔗{p.links_found})", if section.startswith("📺"):
callback_data=f"tdetail_{tid}" 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"
if twitch_done: label = f"{em} 📺 {p.channel} (🔗{p.links_found})"
text += "\n📺 Twitch — завершённые:\n" else:
for tid, p in list(twitch_done.items())[:5]: text += f"{em} {p.channel}\n"
em = p.get_status_emoji() label = f"{em} 📺 {p.channel} (завершена)"
text += f"{em} {p.channel}\n" else:
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()
url_s = (p.url or "")[:35] url_s = (p.url or "")[:35]
uid_tag = f" | uid:{p.user_id}" if p.user_id else "" 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" if is_active:
builder.row(InlineKeyboardButton( text += f"{em} {url_s}{uid_tag}\n{p.successful_visits}/{p.max_visits} | 📊{p.total_visits}\n"
text=f"{em} 🔗 {url_s[:28]} ({p.successful_visits}/{p.max_visits})", label = f"{em} 🔗 {url_s[:28]} ({p.successful_visits}/{p.max_visits})"
callback_data=f"tdetail_{tid}" 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: self._add_nav(builder, "tasks_page_", page, total_pages)
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}"
))
builder.row(InlineKeyboardButton(text="🔄 Обновить", callback_data="menu_tasks")) builder.row(InlineKeyboardButton(text="🔄 Обновить", callback_data="menu_tasks"))
builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main")) builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main"))
@@ -3070,21 +3126,27 @@ class BotInterface:
else: else:
await self._send_temp(message, "Ошибка запуска задачи") await self._send_temp(message, "Ошибка запуска задачи")
async def _show_users_list(self, message: Message, edit: bool = False): async def _show_users_list(self, message: Message, edit: bool = False, page: int = 0):
all_user_ids, all_tasks, all_clicks, all_rub = await asyncio.gather( profiles, all_tasks, all_clicks, all_rub = await asyncio.gather(
self.auth_storage.get_all_user_ids(), self.auth_storage.get_all_profiles(),
self.task_manager.get_all_tasks(), self.task_manager.get_all_tasks(),
self.balance_storage.get_all(), self.balance_storage.get_all(),
self.rub_storage.get_all(), self.rub_storage.get_all(),
) )
all_user_ids = list(profiles.keys())
text = f"👥 Пользователи ({len(all_user_ids)})\n\n"
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if not all_user_ids: if not all_user_ids:
text += "Нет зарегистрированных пользователей." text = "👥 Пользователи (0)\n\nНет зарегистрированных пользователей."
else: 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) clicks = all_clicks.get(str(uid), 0)
rub = all_rub.get(str(uid), 0) rub = all_rub.get(str(uid), 0)
active = sum( active = sum(
@@ -3096,24 +3158,35 @@ class BotInterface:
bal_str += f" 🪙{rub}" bal_str += f" 🪙{rub}"
if active: if active:
bal_str += f" 📋{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( builder.row(InlineKeyboardButton(
text=f"👤 {uid} {bal_str}", text=f"👤 {name or uid} {bal_str}",
callback_data=f"udetail_{uid}", callback_data=f"udetail_{uid}",
)) ))
self._add_nav(builder, "users_page_", page, total_pages)
builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main")) builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main"))
await self._edit_or_send(message, text, builder.as_markup(), edit) await self._edit_or_send(message, text, builder.as_markup(), edit)
async def _show_user_detail(self, callback: CallbackQuery, target_user_id: int): async def _show_user_detail(self, callback: CallbackQuery, target_user_id: int):
clicks = await self.balance_storage.get_balance(target_user_id) clicks = await self.balance_storage.get_balance(target_user_id)
rub = await self.rub_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() 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} 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) 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 = ( text = (
f"👤 Пользователь {target_user_id}\n\n" f"{header}\n\n"
f"🖱 Переходов: {clicks}\n" f"🖱 Переходов: {clicks}\n"
f"🪙 Рублей: {rub}\n" f"🪙 Рублей: {rub}\n"
f"📋 Задач: {len(user_tasks)} (активных: {active})\n" f"📋 Задач: {len(user_tasks)} (активных: {active})\n"