diff --git a/cli_launcher.py b/cli_launcher.py index 1decdea5..54eea89f 100755 --- a/cli_launcher.py +++ b/cli_launcher.py @@ -28,7 +28,7 @@ console = Console() BACK_DIR = os.path.expanduser("~/.solobot_backup") TEMP_DIR = os.path.expanduser("~/.solobot_tmp") PROJECT_DIR = os.path.abspath(os.path.dirname(__file__)) -IS_ROOT_DIR = PROJECT_DIR == '/root' +IS_ROOT_DIR = PROJECT_DIR == "/root" if IS_ROOT_DIR: console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]") @@ -73,12 +73,12 @@ def fix_permissions(): """Устанавливает корректные права на файлы проекта""" console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]") try: - user = os.getenv('SUDO_USER') or os.getenv('USER') + user = os.getenv("SUDO_USER") or os.getenv("USER") if user: subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True) - + subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True) - + console.print("[green]✅ Права успешно установлены[/green]") except subprocess.CalledProcessError as e: console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]") @@ -195,7 +195,7 @@ def update_from_beta(): if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"): return - + console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]") if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"): return @@ -215,11 +215,11 @@ def update_from_beta(): subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")]) clean_project_dir_safe(update_buttons=update_buttons) - + exclude_options = "--exclude=img" if not update_buttons: exclude_options += " --exclude=handlers/buttons.py" - + subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True) subprocess.run(["rm", "-rf", TEMP_DIR]) @@ -258,8 +258,7 @@ def update_from_release(): console.print(f"[cyan]{idx}.[/cyan] {tag}") selected = Prompt.ask( - "[bold blue]Выберите номер релиза[/bold blue]", - choices=[str(i) for i in range(1, len(tag_choices) + 1)] + "[bold blue]Выберите номер релиза[/bold blue]", choices=[str(i) for i in range(1, len(tag_choices) + 1)] ) tag_name = tag_choices[int(selected) - 1] @@ -277,7 +276,7 @@ def update_from_release(): exclude_options = "--exclude=img" if not update_buttons: exclude_options += " --exclude=handlers/buttons.py" - + subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True) subprocess.run(["rm", "-rf", TEMP_DIR]) @@ -291,7 +290,6 @@ def update_from_release(): def show_update_menu(): - if IS_ROOT_DIR: console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]") console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]") @@ -335,9 +333,9 @@ def update_cli_launcher(): try: url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py" response = requests.get(url, timeout=10) - + if response.status_code == 200: - with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), 'w', encoding='utf-8') as f: + with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8") as f: f.write(response.text) console.print("[green]✅ CLI лаунчер успешно обновлён[/green]") os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755) diff --git a/handlers/admin/management/management_handler.py b/handlers/admin/management/management_handler.py index 176d3bfc..4372956a 100644 --- a/handlers/admin/management/management_handler.py +++ b/handlers/admin/management/management_handler.py @@ -6,10 +6,10 @@ from asyncpg import Connection from filters.admin import IsAdminFilter from logger import logger +from middlewares import maintenance from ..panel.keyboard import build_admin_back_kb from .keyboard import AdminPanelCallback, build_management_kb -from middlewares import maintenance router = Router() @@ -78,10 +78,9 @@ async def process_new_domain(message: Message, state: FSMContext, session: Conne @router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance")) async def toggle_maintenance_mode(callback: CallbackQuery): - maintenance.maintenance_mode = not maintenance.maintenance_mode new_status = "включён" if maintenance.maintenance_mode else "выключен" await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True) - - await callback.message.edit_reply_markup(reply_markup=build_management_kb()) \ No newline at end of file + + await callback.message.edit_reply_markup(reply_markup=build_management_kb()) diff --git a/handlers/captcha.py b/handlers/captcha.py index 7a2da14a..880bc76b 100644 --- a/handlers/captcha.py +++ b/handlers/captcha.py @@ -24,22 +24,36 @@ async def generate_captcha(message: Message, state: FSMContext): all_emojis = [correct_emoji] + wrong_emojis random.shuffle(all_emojis) - from_user = message.from_user or message.chat + state_data = await state.get_data() - await state.update_data( - correct_emoji=correct_emoji, - message_id=message.message_id, - chat_id=message.chat.id, - original_text=message.text, - user_data={ - "tg_id": from_user.id, - "username": getattr(from_user, "username", None), - "first_name": getattr(from_user, "first_name", None), - "last_name": getattr(from_user, "last_name", None), - "language_code": getattr(from_user, "language_code", None), - "is_bot": getattr(from_user, "is_bot", False), - } - ) + if "user_data" not in state_data: + from_user = message.from_user + if not from_user: + logger.warning("[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data") + return None + + await state.update_data( + user_data={ + "tg_id": from_user.id, + "username": getattr(from_user, "username", None), + "first_name": getattr(from_user, "first_name", None), + "last_name": getattr(from_user, "last_name", None), + "language_code": getattr(from_user, "language_code", None), + "is_bot": getattr(from_user, "is_bot", False), + } + ) + + update_data = { + "correct_emoji": correct_emoji, + "message_id": message.message_id, + "chat_id": message.chat.id, + } + + state_data = await state.get_data() + if "original_text" not in state_data: + update_data["original_text"] = message.text + + await state.update_data(**update_data) builder = InlineKeyboardBuilder() for emoji in all_emojis: @@ -59,38 +73,28 @@ async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any selected_emoji = callback.data.split("captcha_")[1] state_data = await state.get_data() correct_emoji = state_data.get("correct_emoji") - message_id = state_data.get("message_id") - chat_id = state_data.get("chat_id") original_text = state_data.get("original_text") user_data = state_data.get("user_data") - if not message_id or not chat_id: - target_message = callback.message - else: - try: - target_message = await callback.bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=callback.message.text, - ) - except Exception: - target_message = callback.message + target_message = callback.message if selected_emoji == correct_emoji: logger.info(f"Пользователь {callback.from_user.id} успешно прошел капчу") + logger.debug(f"[CAPTCHA] user_data передано в process_start_logic: {user_data}") await process_start_logic( message=target_message, state=state, session=session, admin=admin, text_to_process=original_text, - user_data=user_data + user_data=user_data, ) else: logger.warning(f"Пользователь {callback.from_user.id} неверно ответил на капчу") captcha = await generate_captcha(target_message, state) - await edit_or_send_message( - target_message=target_message, - text=captcha["text"], - reply_markup=captcha["markup"], - ) + if captcha: + await edit_or_send_message( + target_message=target_message, + text=captcha["text"], + reply_markup=captcha["markup"], + ) diff --git a/handlers/keys/key_mode/key_country_mode.py b/handlers/keys/key_mode/key_country_mode.py index 532e5098..1d414a8f 100644 --- a/handlers/keys/key_mode/key_country_mode.py +++ b/handlers/keys/key_mode/key_country_mode.py @@ -3,8 +3,8 @@ import uuid from datetime import datetime from typing import Any -import asyncpg +import asyncpg import pytz from aiogram import F, Router @@ -14,10 +14,10 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from py3xui import AsyncApi from config import ( - DATABASE_URL, ADMIN_PASSWORD, ADMIN_USERNAME, CONNECT_PHONE_BUTTON, + DATABASE_URL, PUBLIC_LINK, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD, @@ -39,7 +39,12 @@ from handlers.texts import ( SELECT_COUNTRY_MSG, key_message_success, ) -from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster, is_full_remnawave_cluster +from handlers.utils import ( + edit_or_send_message, + generate_random_email, + get_least_loaded_cluster, + is_full_remnawave_cluster, +) from logger import logger from panels.remnawave import RemnawaveAPI from panels.three_xui import delete_client, get_xui_instance diff --git a/handlers/keys/key_view.py b/handlers/keys/key_view.py index 8b3fe11b..896a8561 100644 --- a/handlers/keys/key_view.py +++ b/handlers/keys/key_view.py @@ -232,7 +232,9 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any) f"Осталось: {format_days(days)}, {format_hours(hours)}, {format_minutes(minutes)}" ) - formatted_expiry_date = f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года" + formatted_expiry_date = ( + f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года" + ) response_message = key_message( final_link, formatted_expiry_date, diff --git a/handlers/keys/subscriptions.py b/handlers/keys/subscriptions.py index c6ed0f83..f571ae57 100644 --- a/handlers/keys/subscriptions.py +++ b/handlers/keys/subscriptions.py @@ -12,8 +12,8 @@ from aiohttp import web from config import ( DATABASE_URL, - RANDOM_SUBSCRIPTIONS, PROJECT_NAME, + RANDOM_SUBSCRIPTIONS, SUPERNODE, SUPPORT_CHAT_URL, USERNAME_BOT, @@ -41,7 +41,9 @@ async def fetch_url_content(url: str, identifier: str) -> tuple[list[str], dict[ return [], {} -async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> tuple[list[str], list[dict[str, str]]]: +async def combine_unique_lines( + urls: list[str], identifier: str, query_string: str +) -> tuple[list[str], list[dict[str, str]]]: if SUPERNODE: logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}") if not urls: @@ -82,7 +84,9 @@ async def get_subscription_urls(server_id: str, email: str, conn, include_remnaw return urls -def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | None, headers_list: list[dict[str, str]]) -> str: +def calculate_traffic( + cleaned_subscriptions: list[str], expiry_time_ms: int | None, headers_list: list[dict[str, str]] +) -> str: logger.debug(f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}") expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0 diff --git a/handlers/notifications/general_notifications.py b/handlers/notifications/general_notifications.py index d6951065..d19adce9 100644 --- a/handlers/notifications/general_notifications.py +++ b/handlers/notifications/general_notifications.py @@ -1,9 +1,12 @@ import asyncio + from datetime import datetime, timedelta + import asyncpg -from aiogram import Bot, Router import pytz +from aiogram import Bot, Router + from config import ( DATABASE_URL, NOTIFICATION_TIME, @@ -176,7 +179,9 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, if NOTIFY_RENEW: try: - await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text) + await process_auto_renew_or_notify( + bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text + ) except Exception as e: logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}") continue @@ -193,14 +198,16 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, if messages: results = await send_messages_with_limit(bot, messages, conn=conn) sent_count = 0 - for msg, result in zip(messages, results): + for msg, result in zip(messages, results, strict=False): tg_id = msg["tg_id"] if result: await add_notification(tg_id, msg["notification_id"], session=conn) sent_count += 1 logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.") else: - logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.") + logger.warning( + f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}." + ) logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.") logger.info("Обработка всех уведомлений за 24 часа завершена.") @@ -273,14 +280,16 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, if messages: results = await send_messages_with_limit(bot, messages, conn=conn) sent_count = 0 - for msg, result in zip(messages, results): + for msg, result in zip(messages, results, strict=False): tg_id = msg["tg_id"] if result: await add_notification(tg_id, msg["notification_id"], session=conn) sent_count += 1 logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.") else: - logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.") + logger.warning( + f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}." + ) logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.") logger.info("Обработка всех уведомлений за 10 часов завершена.") @@ -395,7 +404,7 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: if messages: results = await send_messages_with_limit(bot, messages, conn=conn) sent_count = 0 - for msg, result in zip(messages, results): + for msg, result in zip(messages, results, strict=False): tg_id = msg["tg_id"] email = msg["email"] if result: @@ -403,7 +412,9 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: sent_count += 1 logger.info(f"📢 Отправлено уведомление об истекшем ключе для подписки {email} пользователю {tg_id}.") else: - logger.warning(f"📢 Не удалось отправить уведомление об истекшем ключе для подписки {email} пользователю {tg_id}.") + logger.warning( + f"📢 Не удалось отправить уведомление об истекшем ключе для подписки {email} пользователю {tg_id}." + ) logger.info(f"Отправлено {sent_count} уведомлений об истекших ключах.") logger.info("Обработка истекших ключей завершена.") diff --git a/handlers/notifications/notify_utils.py b/handlers/notifications/notify_utils.py index 7092c91b..1d70682b 100644 --- a/handlers/notifications/notify_utils.py +++ b/handlers/notifications/notify_utils.py @@ -3,6 +3,7 @@ import os import aiofiles import asyncpg + from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from aiogram.types import BufferedInputFile, InlineKeyboardMarkup @@ -16,7 +17,7 @@ async def send_messages_with_limit( messages: list[dict], conn: asyncpg.Connection = None, source_file: str = None, - messages_per_second: int = 25 + messages_per_second: int = 25, ): """ Отправляет сообщения с ограничением по количеству сообщений в секунду. @@ -28,16 +29,10 @@ async def send_messages_with_limit( batch = messages[i : i + batch_size] tasks = [] for msg in batch: - tasks.append(send_notification( - bot, - msg["tg_id"], - msg.get("photo"), - msg["text"], - msg.get("keyboard") - )) + tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard"))) batch_results = await asyncio.gather(*tasks, return_exceptions=True) processed_results = [] - for msg, result in zip(batch, batch_results): + for msg, result in zip(batch, batch_results, strict=False): tg_id = msg["tg_id"] if isinstance(result, bool) and result: processed_results.append(True) @@ -94,6 +89,7 @@ def rate_limited_send(func): tg_id = kwargs.get("tg_id") or args[1] logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}") return False + return wrapper diff --git a/handlers/notifications/special_notifications.py b/handlers/notifications/special_notifications.py index 9f806027..39466173 100644 --- a/handlers/notifications/special_notifications.py +++ b/handlers/notifications/special_notifications.py @@ -1,8 +1,10 @@ import asyncio + from datetime import datetime, timedelta import asyncpg import pytz + from aiogram import Bot, Router, types from aiogram.utils.keyboard import InlineKeyboardBuilder @@ -23,6 +25,7 @@ from handlers.texts import ( ) from handlers.utils import format_days from logger import logger + from .notify_utils import send_messages_with_limit, send_notification @@ -76,14 +79,10 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection): }) if messages: results = await send_messages_with_limit( - bot, - messages, - conn=conn, - source_file="special_notifications", - messages_per_second=25 + bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25 ) sent_count = 0 - for msg, result in zip(messages, results): + for msg, result in zip(messages, results, strict=False): tg_id = msg["tg_id"] if result: await add_notification(tg_id, msg["notification_id"], session=conn) @@ -148,9 +147,7 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti ) try: - await conn.execute( - "UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id - ) + await conn.execute("UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id) except Exception as e: logger.error(f"Ошибка обновления notified для пользователя {tg_id} (client_id: {client_id}): {e}") continue @@ -171,14 +168,10 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti if messages: results = await send_messages_with_limit( - bot, - messages, - conn=conn, - source_file="special_notifications", - messages_per_second=25 + bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25 ) sent_count = 0 - for msg, result in zip(messages, results): + for msg, result in zip(messages, results, strict=False): tg_id = msg["tg_id"] if result: sent_count += 1 diff --git a/handlers/start.py b/handlers/start.py index 9ea7a3ce..586ceacd 100644 --- a/handlers/start.py +++ b/handlers/start.py @@ -142,7 +142,7 @@ async def process_start_logic( session: Any, admin: bool, text_to_process: str = None, - user_data: dict | None = None + user_data: dict | None = None, ): text = text_to_process or message.text or message.caption user_data = user_data or { diff --git a/handlers/utils.py b/handlers/utils.py index 9bc59208..7d737b02 100644 --- a/handlers/utils.py +++ b/handlers/utils.py @@ -4,13 +4,14 @@ import re import secrets import string +from datetime import datetime + import aiofiles import aiohttp import asyncpg from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message -from datetime import datetime from bot import bot from config import ADMIN_ID, DATABASE_URL from database import get_all_keys, get_servers @@ -146,12 +147,14 @@ def format_days(days: int) -> str: return "0 дней" return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}" + def format_hours(hours: int) -> str: """Форматирует количество часов с правильным склонением""" if hours <= 0: return "0 часов" return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}" + def format_minutes(minutes: int) -> str: """Форматирует количество минут с правильным склонением""" if minutes <= 0: @@ -272,28 +275,28 @@ def sanitize_key_name(key_name: str) -> str: RUSSIAN_MONTHS = { - 'January': 'Января', - 'February': 'Февраля', - 'March': 'Марта', - 'April': 'Апреля', - 'May': 'Мая', - 'June': 'Июня', - 'July': 'Июля', - 'August': 'Августа', - 'September': 'Сентября', - 'October': 'Октября', - 'November': 'Ноября', - 'December': 'Декабря' + "January": "Января", + "February": "Февраля", + "March": "Марта", + "April": "Апреля", + "May": "Мая", + "June": "Июня", + "July": "Июля", + "August": "Августа", + "September": "Сентября", + "October": "Октября", + "November": "Ноября", + "December": "Декабря", } def get_russian_month(date: datetime) -> str: """ Преобразует английское название месяца в русское. - + Args: date: Объект datetime, из которого извлекается месяц. - + Returns: Название месяца на русском языке. """ diff --git a/middlewares/__init__.py b/middlewares/__init__.py index 51e4b236..05cfe773 100644 --- a/middlewares/__init__.py +++ b/middlewares/__init__.py @@ -5,11 +5,10 @@ from aiogram.dispatcher.middlewares.base import BaseMiddleware from .admin import AdminMiddleware from .loggings import LoggingMiddleware +from .maintenance import MaintenanceModeMiddleware from .session import SessionMiddleware from .throttling import ThrottlingMiddleware from .user import UserMiddleware -from .maintenance import MaintenanceModeMiddleware - def register_middleware( @@ -17,13 +16,12 @@ def register_middleware( middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None, exclude: Iterable[str] | None = None, ) -> None: - """Регистрирует middleware в диспетчере. - """ + """Регистрирует middleware в диспетчере.""" if middlewares is None: available_middlewares = { "admin": AdminMiddleware(), "session": SessionMiddleware(), - "maintenance": MaintenanceModeMiddleware(), + "maintenance": MaintenanceModeMiddleware(), "logging": LoggingMiddleware(), "throttling": ThrottlingMiddleware(), "user": UserMiddleware(), diff --git a/middlewares/maintenance.py b/middlewares/maintenance.py index bbcef0a6..09a41ae3 100644 --- a/middlewares/maintenance.py +++ b/middlewares/maintenance.py @@ -1,9 +1,12 @@ from aiogram import BaseMiddleware -from aiogram.types import Message, CallbackQuery +from aiogram.types import CallbackQuery, Message + from config import ADMIN_ID + maintenance_mode = False + class MaintenanceModeMiddleware(BaseMiddleware): async def __call__(self, handler, event, data): if maintenance_mode: @@ -16,5 +19,5 @@ class MaintenanceModeMiddleware(BaseMiddleware): if user_id and user_id not in ADMIN_ID: await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.") return - + return await handler(event, data) diff --git a/utils/csv_export.py b/utils/csv_export.py index 40d99dca..565909fc 100644 --- a/utils/csv_export.py +++ b/utils/csv_export.py @@ -1,6 +1,6 @@ import csv -from datetime import datetime +from datetime import datetime from io import StringIO from typing import Any @@ -185,9 +185,9 @@ async def export_keys_csv(session) -> BufferedInputFile: buffer.write("tg_id,client_id,email,created_at,expiry_time,key,server_id,is_frozen,alias\n") for row in keys: - created_at = datetime.utcfromtimestamp(row['created_at'] / 1000).strftime("%Y-%m-%d %H:%M:%S") - expiry_time = datetime.utcfromtimestamp(row['expiry_time'] / 1000).strftime("%Y-%m-%d %H:%M:%S") - + created_at = datetime.utcfromtimestamp(row["created_at"] / 1000).strftime("%Y-%m-%d %H:%M:%S") + expiry_time = datetime.utcfromtimestamp(row["expiry_time"] / 1000).strftime("%Y-%m-%d %H:%M:%S") + buffer.write( f"{row['tg_id']},{row['client_id']},{row['email']}," f"{created_at},{expiry_time},{row['key']},"