diff --git a/KorenbZla/HikkaModules/InvalidFiles.py b/KorenbZla/HikkaModules/InvalidFiles.py
new file mode 100644
index 0000000..d4c82da
--- /dev/null
+++ b/KorenbZla/HikkaModules/InvalidFiles.py
@@ -0,0 +1,326 @@
+# * _ __ __ _ _
+# * / \ _ _ _ __ ___ _ __ __ _| \/ | ___ __| |_ _| | ___ ___
+# * / _ \| | | | '__/ _ \| '__/ _` | |\/| |/ _ \ / _` | | | | |/ _ \/ __|
+# * / ___ \ |_| | | | (_) | | | (_| | | | | (_) | (_| | |_| | | __/\__ \
+# * /_/ \_\__,_|_| \___/|_| \__,_|_| |_|\___/ \__,_|\__,_|_|\___||___/
+# *
+# * © Copyright 2026
+# *
+# * https://t.me/AuroraModules
+# *
+# * 🔒 Code is licensed under GNU AGPLv3
+# * 🌐 https://www.gnu.org/licenses/agpl-3.0.html
+# * ⛔️ You CANNOT edit this file without direct permission from the author.
+# * ⛔️ You CANNOT distribute this file if you have modified it without the direct permission of the author.
+
+# Name: InvalidFiles
+# Author: Felix?
+# Commands:
+# .CreateInvalidFile (cifile) | .FormatFiles (ffiles)
+# scope: hikka_only
+# meta developer: @AuroraModules
+
+__version__ = (1, 0, 0)
+
+import os
+import re
+import time
+from .. import loader, utils # type: ignore
+from telethon.tl.types import Message # type: ignore
+from telethon.tl.functions.messages import EditMessageRequest # type: ignore
+from telethon.tl.types import InputMediaUploadedDocument, DocumentAttributeFilename # type: ignore
+
+@loader.tds
+class InvalidFilesMod(loader.Module):
+ """Module for creating corrupted (broken) files of any format."""
+
+
+ strings = {
+ "name": "InvalidFiles",
+ "invalid_format": "❌ Invalid size format.",
+ "max_size": "❌ Maximum file size is 2GB",
+ "file_created": (
+ "✅ File successfully created and sent.\n\n"
+ "
"
+ "👤 File name: {}\n"
+ "⚖️ Size: {}{}\n"
+ "⌛️ Creation: {:.2f} sec.\n"
+ "📤 Upload: {:.2f} sec."
+ "
"
+ ),
+ "invalid_args": (
+ "❌ Invalid arguments\n\n"
+ "Usage: {prefix}cifile <name> <size>\n"
+ "Example: {prefix}cifile test.txt 3.4mb\n\n"
+ "Supported: b, kb, mb, gb"
+ ),
+ "creating": "⌛️ Creating file...\n\n*Large files may take a long time to upload.",
+ "error": "⚠️ Error:\n{}",
+ "formats": (
+ "📂 Popular file extensions:\n\n"
+ "📄 Documents: .txt .docx .pdf .rtf\n"
+ "📊 Spreadsheets: .xlsx .csv\n"
+ "📈 Presentations: .pptx\n"
+ "🖼️ Images: .jpg .png .gif .bmp .webp\n"
+ "🎵 Audio: .mp3 .wav .flac\n"
+ "🎬 Video: .mp4 .mkv .avi\n"
+ "📦 Archives: .zip .rar .7z\n"
+ "💻 Code: .py .js .html .css .json"
+ ),
+ }
+
+ strings_ru = {
+ "invalid_format": "❌ Неверный формат размера.",
+ "max_size": "❌ Максимальный размер файла — 2GB",
+ "file_created": (
+ "✅ Файл успешно создан и отправлен.\n\n"
+ ""
+ "👤 Имя файла: {}\n"
+ "⚖️ Размер: {}{}\n"
+ "⌛️ Создание: {:.2f} сек.\n"
+ "📤 Отправка: {:.2f} сек."
+ "
"
+ ),
+ "invalid_args": (
+ "❌ Неверные аргументы\n\n"
+ "Использование: {prefix}cifile <имя> <размер>\n"
+ "Пример: {prefix}cifile test.txt 3.4mb\n\n"
+ "Поддерживаются: b, kb, mb, gb"
+ ),
+ "creating": "⌛️ Создаю файл...\n\n*Файлы большого размера могут долго загружаться.",
+ "error": "⚠️ Ошибка:\n{}",
+ "formats": (
+ "📂 Популярные расширения файлов:\n\n"
+ "📄 Документы: .txt .docx .pdf .rtf\n"
+ "📊 Таблицы: .xlsx .csv\n"
+ "📈 Презентации: .pptx\n"
+ "🖼️ Изображения: .jpg .png .gif .bmp .webp\n"
+ "🎵 Аудио: .mp3 .wav .flac\n"
+ "🎬 Видео: .mp4 .mkv .avi\n"
+ "📦 Архивы: .zip .rar .7z\n"
+ "💻 Код: .py .js .html .css .json"
+ ),
+ }
+
+ strings_uz = {
+ "invalid_format": "❌ Hajm formati noto‘g‘ri.",
+ "max_size": "❌ Maksimal fayl hajmi — 2GB",
+ "file_created": (
+ "✅ Fayl muvaffaqiyatli yaratildi va yuborildi.\n\n"
+ ""
+ "👤 Fayl nomi: {}\n"
+ "⚖️ Hajmi: {}{}\n"
+ "⌛️ Yaratish: {:.2f} sek.\n"
+ "📤 Yuborish: {:.2f} sek."
+ "
"
+ ),
+ "invalid_args": (
+ "❌ Noto‘g‘ri argumentlar\n\n"
+ "Foydalanish: {prefix}cifile <nom> <hajm>\n"
+ "Misol: {prefix}cifile test.txt 3.4mb\n\n"
+ "Qo‘llab-quvvatlanadi: b, kb, mb, gb"
+ ),
+ "creating": "⌛️ Fayl yaratilmoqda...\n\n*Katta fayllar uzoq yuklanishi mumkin.",
+ "error": "⚠️ Xatolik:\n{}",
+ "formats": (
+ "📂 Mashhur fayl kengaytmalari:\n\n"
+ "📄 Hujjatlar: .txt .docx .pdf .rtf\n"
+ "📊 Jadvallar: .xlsx .csv\n"
+ "📈 Taqdimotlar: .pptx\n"
+ "🖼️ Rasmlar: .jpg .png .gif .bmp .webp\n"
+ "🎵 Audio: .mp3 .wav .flac\n"
+ "🎬 Video: .mp4 .mkv .avi\n"
+ "📦 Arxivlar: .zip .rar .7z\n"
+ "💻 Kod: .py .js .html .css .json"
+ ),
+ }
+
+
+ strings_de = {
+ "invalid_format": "❌ Ungültiges Größenformat.",
+ "max_size": "❌ Maximale Dateigröße — 2GB",
+ "file_created": (
+ "✅ Datei erfolgreich erstellt und gesendet.\n\n"
+ ""
+ "👤 Dateiname: {}\n"
+ "⚖️ Größe: {}{}\n"
+ "⌛️ Erstellung: {:.2f} Sek.\n"
+ "📤 Upload: {:.2f} Sek."
+ "
"
+ ),
+ "invalid_args": (
+ "❌ Ungültige Argumente\n\n"
+ "Verwendung: {prefix}cifile <name> <größe>\n"
+ "Beispiel: {prefix}cifile test.txt 3.4mb\n\n"
+ "Unterstützt: b, kb, mb, gb"
+ ),
+ "creating": "⌛️ Datei wird erstellt...\n\n*Große Dateien können lange zum Hochladen brauchen.",
+ "error": "⚠️ Fehler:\n{}",
+ "formats": (
+ "📂 Beliebte Dateiendungen:\n\n"
+ "📄 Dokumente: .txt .docx .pdf .rtf\n"
+ "📊 Tabellen: .xlsx .csv\n"
+ "📈 Präsentationen: .pptx\n"
+ "🖼️ Bilder: .jpg .png .gif .bmp .webp\n"
+ "🎵 Audio: .mp3 .wav .flac\n"
+ "🎬 Video: .mp4 .mkv .avi\n"
+ "📦 Archive: .zip .rar .7z\n"
+ "💻 Code: .py .js .html .css .json"
+ ),
+ }
+
+
+ strings_es = {
+ "invalid_format": "❌ Formato de tamaño inválido.",
+ "max_size": "❌ El tamaño máximo del archivo es 2GB",
+ "file_created": (
+ "✅ Archivo creado y enviado correctamente.\n\n"
+ ""
+ "👤 Nombre del archivo: {}\n"
+ "⚖️ Tamaño: {}{}\n"
+ "⌛️ Creación: {:.2f} seg.\n"
+ "📤 Subida: {:.2f} seg."
+ "
"
+ ),
+ "invalid_args": (
+ "❌ Argumentos inválidos\n\n"
+ "Uso: {prefix}cifile <nombre> <tamaño>\n"
+ "Ejemplo: {prefix}cifile test.txt 3.4mb\n\n"
+ "Soportado: b, kb, mb, gb"
+ ),
+ "creating": "⌛️ Creando archivo...\n\n*Los archivos grandes pueden tardar en subirse.",
+ "error": "⚠️ Error:\n{}",
+ "formats": (
+ "📂 Extensiones de archivo populares:\n\n"
+ "📄 Documentos: .txt .docx .pdf .rtf\n"
+ "📊 Hojas de cálculo: .xlsx .csv\n"
+ "📈 Presentaciones: .pptx\n"
+ "🖼️ Imágenes: .jpg .png .gif .bmp .webp\n"
+ "🎵 Audio: .mp3 .wav .flac\n"
+ "🎬 Video: .mp4 .mkv .avi\n"
+ "📦 Archivos: .zip .rar .7z\n"
+ "💻 Código: .py .js .html .css .json"
+ ),
+ }
+
+ async def create_invalid_file(self, filename: str, size_str: str):
+ match = re.fullmatch(r"(\d+(?:\.\d+)?)(b|kb|mb|gb)", size_str.lower())
+
+ if not match:
+ return False, self.strings["invalid_format"]
+
+ multiplier = {
+ "b": 1,
+ "kb": 1024,
+ "mb": 1024 ** 2,
+ "gb": 1024 ** 3,
+ }
+
+ size_value = float(match.group(1))
+ unit = match.group(2)
+ total_bytes = int(size_value * multiplier[unit])
+
+ if total_bytes > 2 * 1024 ** 3:
+ return False, self.strings["max_size"]
+
+ start_time = time.time()
+
+ try:
+ with open(filename, "wb") as f:
+ remaining = total_bytes
+ chunk = 5 * 1024 * 1024
+
+ while remaining > 0:
+ write_size = min(chunk, remaining)
+ f.write(os.urandom(write_size))
+ remaining -= write_size
+
+ except Exception as e:
+ return False, self.strings["error"].format(e)
+
+ elapsed = time.time() - start_time
+
+ return True, (filename, size_value, unit, elapsed)
+
+ @loader.command(
+ ru_doc="<имя>.<формат> <размер> — создать битый файл",
+ uz_doc=". — buzilgan fayl yaratish",
+ de_doc=". — beschädigte Datei erstellen",
+ es_doc=". — crear archivo corrupto",
+ alias="cifile"
+ )
+ async def CreateInvalidFile(self, message: Message):
+ """. - create corrupted file"""
+
+ args = utils.get_args_raw(message).split()
+
+ if len(args) != 2:
+ await utils.answer(
+ message,
+ self.strings("invalid_args").format(prefix=self.get_prefix())
+ )
+ return
+
+ filename, size_str = args
+
+ status = await utils.answer(message, self.strings("creating"))
+
+ success, data = await self.create_invalid_file(filename, size_str)
+
+ if not success:
+ await utils.answer(status, data)
+ return
+
+ filename, size_value, unit, create_time = data
+
+ try:
+ start_upload = time.time()
+
+ uploaded = await self.client.upload_file(filename)
+
+ upload_time = time.time() - start_upload
+
+ media = InputMediaUploadedDocument(
+ file=uploaded,
+ mime_type="application/octet-stream",
+ attributes=[DocumentAttributeFilename(file_name=filename)]
+ )
+
+ await self.client(EditMessageRequest(
+ peer=message.chat_id,
+ id=status.id,
+ message="",
+ media=media
+ ))
+
+ await utils.answer(
+ status,
+ self.strings["file_created"].format(
+ filename,
+ size_value,
+ unit,
+ create_time,
+ upload_time
+ )
+ )
+
+ except Exception as e:
+ await utils.answer(status, self.strings["error"].format(e))
+
+ finally:
+ if os.path.exists(filename):
+ try:
+ os.remove(filename)
+ except Exception:
+ pass
+
+ @loader.command(
+ ru_doc="— показать список популярных форматов(расширейний) файлов",
+ uz_doc="— mashhur fayl formatlari (kengaytmalari) ro'yxatini ko'rsatish",
+ de_doc="— eine Liste gängiger Dateiformate (Erweiterungen) anzeigen",
+ es_doc="— mostrar una lista de formatos de archivo (extensiones) populares",
+ alias="ffiles"
+ )
+ async def FormatFiles(self, message: Message):
+ """— show a list of popular file formats (extensions)"""
+ await utils.answer(message, self.strings('formats'))
diff --git a/SenkoGuardian/SenModules/ChatCopy.py b/SenkoGuardian/SenModules/ChatCopy.py
new file mode 100644
index 0000000..9cf0713
--- /dev/null
+++ b/SenkoGuardian/SenModules/ChatCopy.py
@@ -0,0 +1,1952 @@
+# This file is part of SenkoGuardianModules
+# Copyright (c) 2025-2026 Senko
+# This software is released under the MIT License.
+# https://opensource.org/licenses/MIT
+
+# scope heroku_min: 2.0.0
+# meta banner: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png
+# meta pic: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png
+
+__version__ = ("1", "3", "0") # в этот раз комменты свои добавил что бы было понятно кратко, что да как и где что работает.
+
+""" ̄へ ̄"""
+
+# meta developer: @SenkoGuardianModules (from VIP section)
+
+# .------. .------. .------. .------. .------. .------.
+# |S.--. | |E.--. | |N.--. | |M.--. | |O.--. | |D.--. |
+# | :/\: | | :/\: | | :(): | | :/\: | | :/\: | | :/\: |
+# | :\/: | | :\/: | | ()() | | :\/: | | :\/: | | :\/: |
+# | '--'S| | '--'E| | '--'N| | '--'M| | '--'O| | '--'D|
+# `------' `------' `------' `------' `------' `------'
+
+
+import asyncio
+import logging
+import re
+import traceback
+import random
+import time
+from datetime import datetime, timedelta, timezone
+MSK = timezone(timedelta(hours=3), name="MSK")
+from telethon import functions, errors, types
+from telethon.tl.types import Message, Channel
+from .. import loader, utils
+
+logger = logging.getLogger(__name__)
+
+_cc_client = None
+_cc_log_channel = None
+_cc_log_topic_id = None
+
+class _CCTopicHandler(logging.Handler):
+
+ def emit(self, record):
+ if _cc_client is None or _cc_log_channel is None or _cc_log_topic_id is None:
+ return
+ try:
+ text = f"[{record.levelname}] {self.format(record)}"
+ asyncio.ensure_future(
+ _cc_client.send_message(
+ int(f"-100{_cc_log_channel}"),
+ text,
+ parse_mode="html",
+ reply_to=_cc_log_topic_id,
+ )
+ )
+ except Exception:
+ pass
+
+
+_cc_topic_handler = _CCTopicHandler()
+_cc_topic_handler.setLevel(logging.INFO) # INFO чтобы видеть прогресс пересылки
+logger.addHandler(_cc_topic_handler)
+
+FILTER_ALL = "all"
+FILTER_MEDIA = "media"
+FILTER_PHOTO_VIDEO = "photo_video"
+FILTER_DOCS = "docs"
+FILTER_TEXT = "text"
+FILTER_NO_AD = "no_ad"
+
+@loader.tds
+class ChatCopy(loader.Module):
+ """Модуль для копирования чатов с поддержкой топиков (форумов), фото, видео, файлов (документов)."""
+ strings = {
+ "name": "ChatCopy",
+ "cfg_batch": "Размер пачки сообщений (1-100)",
+ "cfg_delay": "Задержка ОТПРАВКИ между пачками (сек)",
+ "cfg_flood_buffer": "Дополнительное время к FloodWait (сек)",
+ "copy_start_prem": (
+ '🚀 ChatCopy: Запуск копирования\n\n'
+ "Источник: {src}\n"
+ '⏬⏬⏬⏬\n'
+ "Цель: {dest}\n\n"
+ '⚙️ Режим: {mode}\n'
+ '🔢 Старт с ID: {start_id}\n'
+ '👤 Без автора: {no_auth}\n'
+ '💬 Без подписей: {no_capt}\n'
+ '📎 Фильтр: {filter_type}\n'
+ '📦 Всего сообщений: {total_msgs}\n'
+ '⏱ Оценка времени: {estimated_time}\n\n'
+ "Задача добавлена в очередь. Позиция: {position}"
+ ),
+ "copy_start_no_prem": (
+ "🚀 ChatCopy: Запуск копирования\n\n"
+ "Источник: {src}\n"
+ "⏬⏬⏬⏬\n"
+ "Цель: {dest}\n\n"
+ "⚙️ Режим: {mode}\n"
+ "🔢 Старт с ID: {start_id}\n"
+ "👤 Без автора: {no_auth}\n"
+ "💬 Без подписей: {no_capt}\n"
+ "📎 Фильтр: {filter_type}\n"
+ "📦 Всего сообщений: {total_msgs}\n"
+ "⏱ Оценка времени: {estimated_time}\n\n"
+ "Задача добавлена в очередь. Позиция: {position}"
+ ),
+ "copy_done_detailed_prem": (
+ '✅ Задача выполнена\n'
+ "{src} → {dest}\n"
+ "Без автора: {no_auth}\n"
+ "Без подписей: {no_capt}\n"
+ "Старт с ID: {start_id}\n"
+ "Режим: {mode}\n"
+ "Фильтр: {filter_type}
\n"
+ '✅ Перенесено сообщений: {count} ✅\n'
+ '⏱ Длительность: {duration}\n'
+ '⚡ Средняя скорость: {avg_speed} сообщений/мин'
+ "{flood_info}"
+ ),
+ "copy_done_detailed_no_prem": (
+ "Задача выполнена\n"
+ "{src} → {dest}\n"
+ "Без автора: {no_auth}\n"
+ "Без подписей: {no_capt}\n"
+ "Старт с ID: {start_id}\n"
+ "Режим: {mode}\n"
+ "Фильтр: {filter_type}
\n"
+ "✔️ Перенесено сообщений: {count} ✔️\n"
+ "⏱ Длительность: {duration}\n"
+ "⚡ Средняя скорость: {avg_speed} сообщений/мин"
+ "{flood_info}"
+ ),
+ "flood_wait_notice": (
+ "⏸ FloodWait\n"
+ "📊 Задержка: {minutes}m {seconds}s\n"
+ "🕐 Возобновление: {resume_time}\n"
+ "📨 Переслано: {count} сообщений\n"
+ "⏳ Осталось: {remaining} сообщений\n"
+ "⚡ Скорость: {speed} сообщений/мин"
+ ),
+ "panel_summary": "📊 ChatCopy Status\n\n🔄 Активная: {active}\n⏳ В очереди: {queue_len}\n👀 Слежка: {watching_count}\n⏱ Последний FW: {last_flood}",
+ "panel_task_running": "{name}\n├ 📦 {count}/{total} сообщений\n├ ⚡ {speed}/мин | 📊 {progress}%\n├ ⏱ Прошло: {elapsed} | Осталось: {eta}\n└ 🕐 Начало: {start_time} | Окончание: {end_time}",
+ "panel_task_paused": "{name}\n├ ⏸ На паузе (FW: {flood_time})\n├ 📦 {count}/{total} сообщений\n├ ⚡ {speed}/мин\n└ 🕐 Продолжение: {resume_time}",
+ "btn_stop": "🛑 Стоп",
+ "btn_pause": "⏸ Пауза",
+ "btn_resume": "▶️ Продолжить",
+ "btn_back": "🔙 Назад",
+ "btn_tasks": "📋 Очередь задач",
+ "btn_watch": "👀 Слежка",
+ "btn_settings": "⚙️ Настройки",
+ "btn_stats": "📊 Статистика",
+ "forum_enabled": "✅ Топики включены в {chat}",
+ "forum_enable_failed": "❌ Не удалось включить топики в {chat}. Нужны права администратора.",
+ "forum_not_channel": "❌ {chat} не является каналом/группой",
+ "err_ent": "❌ Ошибка: Чат не найден или нет доступа.",
+ "args_err": "❌ Синтаксис: .chatcopy [start_id:final_id] [-n] [-dmc] [--now] [--media|--photo_video|--docs|--text]",
+ "watch_added": "👀 Наблюдение активировано\nID: {src_id}\n{src} -> {dest}\nРежим топиков: {topics}\nБез подписей: {no_capt}\nФильтр: {filter_type}",
+ "queue_wait": "⏳ Задача в очереди... ({pos})",
+ "topic_created": "📂 Создан топик: {title}",
+ "topic_error": "❌ Ошибка создания топика: {error}",
+ "task_stopped": "🛑 Задача остановлена\nПереслано: {count} сообщений{flood_info}",
+ "stats_title": "📊 Статистика ChatCopy\n\n",
+ "stats_total": "Всего задач: {total}\nЗавершено: {completed}\nОстановлено: {stopped}\nFloodWait'ов: {floods}",
+ "task_list_header": "📋 Очередь задач ({total})\n\nНажми на номер для подробностей\n\n",
+ "task_item_compact_running": "▶️{num}. {src} → {dest} ({progress}%)",
+ "task_item_compact_queued": "⏳{num}. {src} → {dest} (через {wait})",
+ "task_item_compact_paused": "⚠️{num}. {src} → {dest} (FW)",
+ "task_item_compact_completed": "✅{num}. {src} → {dest}",
+ "task_item_compact_error": "❌{num}. {src} → {dest}",
+ "task_detail_running": "▶️ Задача #{num}\n\n{src} → {dest}\n├ Статус: Выполняется\n├ Прогресс: {current}/{total} ({progress}%)\n├ Скорость: {speed}/мин\n├ Прошло: {elapsed}\n├ Осталось: {eta_left}\n├ Начато: {start_time}\n├ Окончание: {end_time}\n└ Позиция: {position}",
+ "task_detail_queued": "⏳ Задача #{num}\n\n{src} → {dest}\n├ Статус: В очереди\n├ Позиция: {position}\n├ Сообщений: ~{total}\n├ Ожидание старта: {eta_start}\n└ Примерное время работы: {estimated_duration}",
+ "task_detail_paused": "⚠️ Задача #{num}\n\n{src} → {dest}\n├ Статус: Пауза (FloodWait)\n├ Прогресс: {current}/{total} ({progress}%)\n├ FloodWait'ов: {flood_count}\n├ Время ожидания: {flood_time}\n├ Продолжение: {resume_time}\n├ Скорость до паузы: {speed}/мин\n└ Осталось сообщений: {remaining}",
+ "task_detail_completed": "✅ Задача #{num}\n\n{src} → {dest}\n├ Статус: Завершена\n├ Переслано: {count} сообщений\n├ Длительность: {duration}\n├ Средняя скорость: {avg_speed}/мин\n├ Завершено: {end_time}\n└ FloodWait'ов: {floods}",
+ "task_detail_error": "❌ Задача #{num}\n\n{src} → {dest}\n├ Статус: Ошибка\n└ Попробуйте перезапустить",
+ "no_tasks": "Нет активных задач",
+ "preparing_prem": "💫 Подготовка к копированию. Подсчитываем (да, вручную!) кол-во медиа, это может занять время...",
+ "preparing_no_prem": "⌛️ Подготовка к копированию. Подсчитываем кол-во медиа, это может занять время...",
+ }
+
+ def __init__(self):
+ self._tasks = []
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue("batch_size", 100, lambda: self.strings["cfg_batch"], validator=loader.validators.Integer(minimum=1, maximum=100)),
+ loader.ConfigValue("delay", 10, lambda: self.strings["cfg_delay"], validator=loader.validators.Integer(minimum=1)),
+ loader.ConfigValue("flood_buffer", 5, lambda: self.strings["cfg_flood_buffer"], validator=loader.validators.Integer(minimum=0, maximum=60)),
+ )
+ self.queue = asyncio.Queue()
+ self.dump_queue = asyncio.Queue()
+ self.watcher_buffer = {}
+ self.watcher_flush_tasks = {}
+ self.watchlist = {}
+ self.active_dumps = {}
+ self.last_watched = {}
+ self.last_processed_ids = {}
+ self.current_dump_task = None
+ self.is_premium = False
+ self.topic_mapping = {}
+ self.topic_info_cache = {}
+ self.task_stats = {}
+ self.last_flood_info = {"time": None, "duration": 0, "task": None, "resume_at": None}
+ self.task_queue = []
+ self.task_history = []
+ self.current_task_index = 0
+ self.is_processing_queue = False
+ self.task_progress_cache = {}
+ self.global_speed_history = []
+ self.avg_speed_history = []
+ self._queue_lock = asyncio.Lock()
+ self._task_counter = 0
+
+ async def client_ready(self, client, db):
+ global _cc_client, _cc_log_channel, _cc_log_topic_id
+ self.client = client
+ self.db = db
+ self.watchlist = self.db.get("ChatCopy", "watchlist", {})
+ self.last_processed_ids = self.db.get("ChatCopy", "last_processed_ids", {})
+ self.topic_mapping = self.db.get("ChatCopy", "topic_mapping", {})
+ self.task_stats = self.db.get("ChatCopy", "task_stats", {})
+ self.task_queue = self.db.get("ChatCopy", "persistent_queue", [])
+ for task in self.task_queue:
+ task['status'] = 'queued'
+ me = await client.get_me()
+ self.is_premium = getattr(me, 'premium', False)
+ try:
+ asset_channel = self._db.get("heroku.forums", "channel_id", 0)
+ if asset_channel:
+ notif_topic = await utils.asset_forum_topic(
+ self._client,
+ self._db,
+ asset_channel,
+ "ChatCopy Logs",
+ description="ChatCopy module activity logs (warnings & errors).",
+ icon_emoji_id=5372917041193828849,
+ )
+ _cc_client = self._client
+ _cc_log_channel = asset_channel
+ _cc_log_topic_id = notif_topic.id
+ logger.info("ChatCopy log topic ready (id=%s)", _cc_log_topic_id)
+ except Exception as _e:
+ logger.debug("ChatCopy log topic setup skipped: %s", _e)
+ self._tasks.extend([
+ asyncio.create_task(self.worker()),
+ asyncio.create_task(self.dump_worker()),
+ asyncio.create_task(self._catch_up_on_restart())
+ ])
+ if not self.task_queue:
+ return
+ logger.info(f"Возобновление {len(self.task_queue)} задач из очереди после перезапуска.")
+ for task in self.task_queue:
+ try:
+ src = await self.client.get_entity(task['src_id'])
+ dest = await self.client.get_entity(task['dest_id'])
+ class FakeMsg:
+ id = None
+ chat_id = task.get('status_chat_id')
+ async def edit(self, *args, **kwargs): pass
+ await self.dump_queue.put({
+ "status_msg": FakeMsg(),
+ "src": src, "dest": dest,
+ "no_auth": task['no_author'], "no_captions": task['no_captions'],
+ "map_t": task.get('map_t', False), "f_src_t": task.get('f_src_t'),
+ "f_dest_t": task.get('f_dest_t'), "tid": task['tid'],
+ "min_id": task.get('last_processed_id', task.get('start_id', 0)),
+ "max_id": task.get('final_id', 0),
+ "filter_type": task['filter_type'], "src_name": task['src'],
+ "total_msgs": task['total_msgs'],
+ "restored_count": task.get('current', 0),
+ })
+ except Exception as e:
+ logger.error(f"Не удалось возобновить задачу {task.get('tid')}: {e}")
+
+ async def _resolve_arg(self, arg): # все виды (ну почти) ссылок как дадут id и прочее,
+ # работает если копировать сообщение в топике и в аргумент типа куда отправлять вставить.
+ extra = {}
+ entity = None
+ arg = str(arg).strip()
+ regex = r"(?:t\.me/|tg://resolve\?domain=|tg://openmessage\?user_id=)(?:c/)?([\w\d_]+)(?:/(\d+))?(?:/(\d+))?"
+ match = re.search(regex, arg)
+ if match:
+ identifier = match.group(1)
+ if match.group(2): extra['topic'] = int(match.group(2))
+ if match.group(3): extra['msg'] = int(match.group(3))
+ if identifier.isdigit():
+ for potential_id in [int(identifier), int(f"-100{identifier}")]:
+ try:
+ entity = await self.client.get_entity(potential_id)
+ if entity: break
+ except: continue
+ else:
+ try: entity = await self.client.get_entity(identifier)
+ except: pass
+ else:
+ try:
+ if arg.lstrip("-").isdigit(): entity = await self.client.get_entity(int(arg))
+ else: entity = await self.client.get_entity(arg)
+ except: pass
+ return entity, extra
+
+ def _get_normalized_id(self, entity): # что бы получать норм айди а не нечто, что бы копировка шла хорошо.
+ if not entity:
+ return "0"
+ try:
+ return str(utils.get_chat_id(entity))
+ except Exception:
+ if hasattr(entity, 'id') and entity.id:
+ eid = str(entity.id)
+ if not eid.startswith("-100") and len(eid) > 9:
+ return f"-100{eid}"
+ if not eid.startswith("-"):
+ return f"-100{eid}"
+ return eid
+ return "0"
+
+ def _is_forum(self, entity): # да, не спрашивайте.
+ if not isinstance(entity, Channel):
+ return False
+ if hasattr(entity, 'forum') and entity.forum:
+ return True
+ if hasattr(entity, 'flags') and entity.flags is not None:
+ return bool(entity.flags & (1 << 30))
+ return False
+
+ async def _ensure_forum_enabled(self, entity): # проверяет режим топиков у чата и пытается включить его, если он отключен (требуются права админа).
+ if not isinstance(entity, Channel):
+ return False
+ if self._is_forum(entity):
+ return True
+ try:
+ result = await self.client(functions.channels.ToggleForumRequest(channel=entity, enabled=True))
+ if result:
+ updated_entity = await self.client.get_entity(entity.id)
+ return self._is_forum(updated_entity)
+ return False
+ except errors.FloodWaitError as e:
+ await asyncio.sleep(e.seconds + self.config["flood_buffer"])
+ return await self._ensure_forum_enabled(entity)
+ except errors.ChatAdminRequiredError:
+ return False
+ except Exception:
+ return False
+
+ async def _get_topic_info(self, chat_entity, topic_id): #получаем инфо о топике для копирования
+ if not topic_id:
+ return None, None, None
+ cache_key = f"{chat_entity.id}_{topic_id}"
+ if cache_key in self.topic_info_cache:
+ return self.topic_info_cache[cache_key]
+ title, icon_emoji_id, icon_color = None, None, None
+ for attempt in range(3):
+ try:
+ result = await self.client(functions.messages.GetForumTopicsByIDRequest(peer=chat_entity, topics=[topic_id]))
+ if result and hasattr(result, 'topics') and result.topics:
+ for topic in result.topics:
+ if hasattr(topic, 'id') and topic.id == topic_id:
+ title = getattr(topic, 'title', None)
+ icon_emoji_id = getattr(topic, 'icon_emoji_id', None)
+ icon_color = getattr(topic, 'icon_color', None)
+ break
+ if title:
+ break
+ except errors.FloodWaitError as e:
+ await asyncio.sleep(e.seconds + self.config["flood_buffer"])
+ except Exception:
+ pass
+ if not title:
+ try:
+ result = await self.client(functions.messages.GetForumTopicsRequest(peer=chat_entity, offset_date=0, offset_id=0, offset_topic=0, limit=100))
+ if result and hasattr(result, 'topics'):
+ for topic in result.topics:
+ if hasattr(topic, 'id') and topic.id == topic_id:
+ title = getattr(topic, 'title', None)
+ icon_emoji_id = getattr(topic, 'icon_emoji_id', None)
+ icon_color = getattr(topic, 'icon_color', None)
+ break
+ except Exception:
+ pass
+ if not title:
+ try:
+ async for msg in self.client.iter_messages(chat_entity, limit=1, reply_to=topic_id):
+ if msg and hasattr(msg, 'reply_to') and msg.reply_to:
+ title = getattr(msg.reply_to, 'forum_topic_title', None)
+ if not title and msg:
+ title = msg.text[:50] if msg.text else f"Topic {topic_id}"
+ break
+ except Exception:
+ pass
+ if not title:
+ title = f"Topic {topic_id}"
+ info = (title, icon_emoji_id, icon_color)
+ self.topic_info_cache[cache_key] = info
+ return info
+
+ async def _create_topic(self, dest_entity, title, src_topic_id=None, icon_emoji_id=None, icon_color=None): # создает топик
+ if not isinstance(dest_entity, Channel) or not self._is_forum(dest_entity):
+ return None
+ try:
+ random_id = random.randint(1, 2**63 - 1)
+ kwargs = {
+ "peer": dest_entity,
+ "title": title[:128] if len(title) > 128 else title,
+ "random_id": random_id
+ }
+ if icon_emoji_id:
+ kwargs["icon_emoji_id"] = icon_emoji_id
+ elif icon_color:
+ kwargs["icon_color"] = icon_color
+ else:
+ kwargs["icon_color"] = 0x6FB9F0
+ result = await self.client(functions.messages.CreateForumTopicRequest(**kwargs))
+ new_topic_id = None
+ if result:
+ if hasattr(result, 'updates'):
+ for update in result.updates:
+ if hasattr(update, 'message'):
+ msg = update.message
+ if hasattr(msg, 'action') and hasattr(msg.action, 'topic_id'):
+ new_topic_id = msg.action.topic_id
+ if hasattr(msg, 'reply_to') and msg.reply_to:
+ new_topic_id = getattr(msg.reply_to, 'reply_to_top_id', None) or getattr(msg.reply_to, 'reply_to_msg_id', None)
+ if new_topic_id:
+ break
+ if not new_topic_id and hasattr(result, 'messages') and result.messages:
+ for msg in result.messages:
+ if hasattr(msg, 'reply_to') and msg.reply_to:
+ new_topic_id = getattr(msg.reply_to, 'reply_to_top_id', None)
+ if new_topic_id:
+ break
+ if not new_topic_id:
+ await asyncio.sleep(1)
+ topics_result = await self.client(functions.messages.GetForumTopicsRequest(peer=dest_entity, offset_date=0, offset_id=0, offset_topic=0, limit=20))
+ if topics_result and hasattr(topics_result, 'topics'):
+ for topic in topics_result.topics:
+ if getattr(topic, 'title', '') == title:
+ new_topic_id = topic.id
+ break
+ return new_topic_id
+ except errors.FloodWaitError as e:
+ await asyncio.sleep(e.seconds + self.config["flood_buffer"])
+ return await self._create_topic(dest_entity, title, src_topic_id, icon_emoji_id, icon_color)
+ except errors.TopicDeletedError:
+ return None
+ except Exception:
+ return None
+
+ async def _ensure_topic_mapping(self, src_entity, dest_entity, src_topic_id): # копирует точ в точ топик.
+ if not src_topic_id:
+ return None
+ mapping_key = f"{src_entity.id}_{dest_entity.id}_{src_topic_id}"
+ if mapping_key in self.topic_mapping:
+ cached_id = self.topic_mapping[mapping_key]
+ try:
+ await self.client(functions.messages.GetForumTopicsByIDRequest(peer=dest_entity, topics=[cached_id]))
+ return cached_id
+ except Exception:
+ pass
+ title, icon_emoji_id, icon_color = await self._get_topic_info(src_entity, src_topic_id)
+ if not title:
+ title = f"Topic {src_topic_id}"
+ try:
+ offset_date = 0
+ offset_id = 0
+ offset_topic = 0
+ found_topic_id = None
+ for _ in range(5):
+ topics_result = await self.client(functions.messages.GetForumTopicsRequest(
+ peer=dest_entity, offset_date=offset_date, offset_id=offset_id, offset_topic=offset_topic, limit=100
+ ))
+ if not topics_result or not hasattr(topics_result, 'topics') or not topics_result.topics:
+ break
+ for topic in topics_result.topics:
+ if getattr(topic, 'title', '') == title:
+ if icon_emoji_id:
+ if getattr(topic, 'icon_emoji_id', None) == icon_emoji_id:
+ found_topic_id = topic.id
+ break
+ else:
+ found_topic_id = topic.id
+ break
+ if found_topic_id:
+ break
+ offset_topic = topics_result.topics[-1].id
+ if found_topic_id:
+ self.topic_mapping[mapping_key] = found_topic_id
+ self.db.set("ChatCopy", "topic_mapping", self.topic_mapping)
+ return found_topic_id
+ except Exception as e:
+ logger.error(f"Error checking existing topics: {e}")
+ for attempt in range(3):
+ new_topic_id = await self._create_topic(dest_entity, title, src_topic_id, icon_emoji_id, icon_color)
+ if new_topic_id:
+ self.topic_mapping[mapping_key] = new_topic_id
+ self.db.set("ChatCopy", "topic_mapping", self.topic_mapping)
+ return new_topic_id
+ await asyncio.sleep(5)
+ return None
+
+ async def on_unload(self):
+ """Остановка всех задач при выгрузке модуля"""
+ for task in self._tasks:
+ if not task.done(): task.cancel()
+ for tid in list(self.active_dumps.keys()):
+ self.active_dumps[tid]["status"] = "stopped"
+ if "cancel" in self.active_dumps[tid]: self.active_dumps[tid]["cancel"].set()
+ for t in self.watcher_flush_tasks.values(): t.cancel()
+
+ def _should_include_message(self, msg, filter_type): # handler типов сообщений. медиа, документ и прочее.
+ if filter_type == FILTER_ALL:
+ return True
+ has_photo = bool(msg.photo)
+ has_video = bool(msg.video)
+ has_video_note = bool(msg.video_note)
+ has_document = bool(msg.document)
+ has_voice = bool(msg.voice)
+ has_audio = bool(msg.audio)
+ has_sticker = bool(msg.sticker)
+ has_text = bool(msg.text and not msg.media)
+ is_gif = False
+ if has_document and not has_sticker and hasattr(msg.document, 'attributes'):
+ for attr in msg.document.attributes:
+ if isinstance(attr, types.DocumentAttributeAnimated):
+ is_gif = True
+ break
+ is_file_document = has_document and not (has_video or has_video_note or has_audio or has_voice or has_sticker or is_gif or has_photo)
+ if has_video and has_sticker:
+ has_video = False
+ if has_document and not has_photo and not has_sticker:
+ doc = msg.document
+ if hasattr(doc, 'mime_type'):
+ mime = doc.mime_type or ''
+ if mime.startswith('image/'):
+ has_photo = True
+ is_file_document = False
+ elif mime.startswith('video/') and not is_gif:
+ has_video = True
+ is_file_document = False
+ if filter_type == FILTER_MEDIA:
+ return has_photo or has_video or is_file_document
+ elif filter_type == FILTER_PHOTO_VIDEO:
+ return (has_photo or has_video) and not (has_sticker or is_gif)
+ elif filter_type == FILTER_DOCS:
+ return is_file_document
+ elif filter_type == FILTER_TEXT:
+ return has_text and not (has_photo or has_video or has_video_note or has_document or has_sticker or has_voice or has_audio or is_gif)
+ return True
+
+ async def _send_flood_notice(self, chat_id, seconds, count,
+ task_id, total_msgs=0, speed=0): # ниже этой функции, функция обработки флудвейта, он просто отправляет примерное время когда продолжит работать.
+ minutes = seconds // 60
+ secs = seconds % 60
+ resume_time = (datetime.now(MSK) + timedelta(seconds=seconds + self.config["flood_buffer"])).strftime("%H:%M:%S")
+ remaining = max(0, total_msgs - count)
+ self.last_flood_info = {
+ "time": datetime.now(MSK).strftime("%H:%M:%S"),
+ "duration": seconds,
+ "task": task_id,
+ "resume_at": resume_time
+ }
+ try:
+ await self.client.send_message(
+ chat_id,
+ self.strings["flood_wait_notice"].format(
+ minutes=minutes,
+ seconds=secs,
+ resume_time=resume_time,
+ count=count,
+ remaining=remaining,
+ speed=round(speed, 1)
+ )
+ )
+ except Exception:
+ pass
+
+ def _format_flood_stats(self, task_data): # формирует красивую строку со статистикой FloodWait для вывода в итоговом сообщении.
+ floods = task_data.get('flood_count', 0)
+ total_seconds = task_data.get('flood_total_seconds', 0)
+ if floods == 0:
+ return ""
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ if hours > 0:
+ time_str = f"{hours}h {minutes}m"
+ else:
+ time_str = f"{minutes}m"
+ return f"\n⏱ {floods} FloodWait (~{time_str})"
+
+ def _format_duration(self, seconds): # описание ниже
+ """Форматирует длительность в читаемый вид"""
+ if seconds < 60:
+ return f"{int(seconds)}с"
+ elif seconds < 3600:
+ return f"{int(seconds // 60)}м {int(seconds % 60)}с"
+ else:
+ hours = int(seconds // 3600)
+ mins = int((seconds % 3600) // 60)
+ return f"{hours}ч {mins}м"
+
+ async def _process_batch(self, messages, dest_id, no_author,
+ no_captions=False, fixed_dest_topic=None, map_topics=False, dest_entity=None,
+ src_entity=None, filter_type=FILTER_ALL, status_msg=None, tid=None):
+ if not messages:
+ return 0
+ if tid and tid in self.active_dumps:
+ await self.active_dumps[tid].get("cancel", asyncio.Event()).wait()
+ if self.active_dumps[tid].get("status") == "stopped":
+ return 0
+ filtered_messages = [msg for msg in messages if self._should_include_message(msg, filter_type)]
+ if not filtered_messages:
+ return 0
+ if map_topics and (not dest_entity or isinstance(dest_entity, (int, str))):
+ try:
+ dest_entity = await self.client.get_entity(dest_id)
+ except Exception:
+ map_topics = False
+ if map_topics and not src_entity:
+ try:
+ src_entity = await self.client.get_entity(filtered_messages[0].chat_id)
+ except Exception:
+ pass
+ msg_groups = {}
+ for msg in filtered_messages:
+ src_topic_id = None
+ if map_topics and src_entity and dest_entity:
+ if hasattr(msg, 'reply_to') and msg.reply_to:
+ src_topic_id = getattr(msg.reply_to, 'reply_to_top_id', None) or getattr(msg.reply_to, 'reply_to_msg_id', None)
+ if not src_topic_id and hasattr(msg, 'topic_id') and msg.topic_id:
+ src_topic_id = msg.topic_id
+ key = src_topic_id if src_topic_id else "no_topic"
+ msg_groups.setdefault(key, []).append(msg)
+ total_sent = 0
+ delay = self.config["delay"]
+ if not isinstance(delay, int):
+ delay = 10
+ for src_topic_id, msgs in msg_groups.items():
+ if tid and tid in self.active_dumps:
+ await self.active_dumps[tid].get("cancel", asyncio.Event()).wait()
+ if self.active_dumps[tid].get("status") == "stopped":
+ break
+ target_topic = fixed_dest_topic
+ if map_topics and src_topic_id != "no_topic":
+ target_topic = await self._ensure_topic_mapping(src_entity, dest_entity, src_topic_id)
+ if not target_topic:
+ continue
+ if tid and tid in self.active_dumps:
+ last_send = self.active_dumps[tid].get("last_successful_send", 0)
+ time_since_last = time.time() - last_send
+ min_interval = 3
+ if time_since_last < min_interval:
+ extra_wait = min_interval - time_since_last
+ logger.debug(f"[{tid}] Дополнительная задержка для соблюдения интервала: {extra_wait:.1f}с")
+ await asyncio.sleep(extra_wait)
+ success = await self._raw_sender(msgs, dest_id, no_author, no_captions, target_topic, status_msg, tid)
+ if success:
+ total_sent += len(msgs)
+ if tid and tid in self.active_dumps:
+ self.active_dumps[tid]["last_successful_send"] = time.time()
+ await asyncio.sleep(delay)
+ return total_sent
+
+ async def worker(self): # воркер для Watcher'а
+ while True:
+ item = await self.queue.get()
+ try:
+ watch_cid = item.get("watch_cid")
+ if watch_cid and watch_cid not in self.watchlist:
+ logger.debug(f"Игнорируем сообщение для {watch_cid}, слежка была остановлена")
+ continue
+ result = await self._process_batch(**item)
+ if watch_cid and item.get("messages"):
+ last_msg = item["messages"][-1]
+ self.last_processed_ids[watch_cid] = last_msg.id
+ self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids)
+ except Exception as e:
+ logger.error(f"Worker error: {e}")
+ finally:
+ self.queue.task_done()
+
+ async def dump_worker(self):
+ """worker очереди, с последовательным выполнением задач""" # он типа очень умни и если добавить последовательно несколько чатов,
+ # то он не переключится а просто в очередь добавит
+ while True:
+ task_data = await self.dump_queue.get()
+ tid = task_data.get('tid')
+ async with self._queue_lock:
+ self.is_processing_queue = True
+ self.current_dump_task = tid
+ self._update_queue_positions()
+ if tid in self.task_queue:
+ idx = next((i for i, t in enumerate(self.task_queue) if t['tid'] == tid), None)
+ if idx is not None:
+ self.task_queue[idx]['status'] = 'running'
+ self.task_queue[idx]['start_time'] = datetime.now(MSK)
+ self.current_task_index = idx
+ if tid:
+ self.active_dumps[tid] = {
+ "current": 0,
+ "cancel": asyncio.Event(),
+ "name": task_data.get('src_name', 'Unknown'),
+ "status": "running",
+ "start_time": time.time(),
+ "flood_count": 0,
+ "flood_total_seconds": 0,
+ "status_msg_id": task_data.get('status_msg').id if task_data.get('status_msg') else None,
+ "status_chat_id": task_data.get('status_msg').chat_id if task_data.get('status_msg') else None,
+ "total_estimated": task_data.get('total_msgs', 0),
+ "last_update_time": time.time(),
+ "last_update_count": 0,
+ "last_successful_send": time.time(),
+ "consecutive_floods": 0,
+ "speed_samples": [],
+ "current_speed": 0,
+ }
+ self.active_dumps[tid]["cancel"].set()
+ update_task = asyncio.create_task(self._auto_update_status(tid, task_data.get('status_msg')))
+ try:
+ logger.info("[%s] Задача запущена: %s → %s | Всего: %d сообщений",
+ tid, task_data.get('src_name', '?'),
+ getattr(task_data.get('dest'), 'title', '?'),
+ task_data.get('total_msgs', 0))
+ await self._history_dumper(**task_data)
+ except Exception as e:
+ logger.error(f"Dump Worker Error: {e}")
+ if tid and tid in self.active_dumps:
+ self.active_dumps[tid]["status"] = "error"
+ finally:
+ update_task.cancel()
+ if tid in self.active_dumps:
+ completed_task = self.active_dumps[tid].copy()
+ completed_task['tid'] = tid
+ completed_task['end_time'] = datetime.now(MSK)
+ self.task_history.append(completed_task)
+ self.task_queue = [t for t in self.task_queue if t['tid'] != tid]
+ duration = time.time() - completed_task.get('start_time', time.time())
+ active_duration = duration - completed_task.get('flood_total_seconds', 0)
+ if active_duration <= 0: active_duration = 1
+ avg_spd = (completed_task.get('current', 0) / active_duration) * 60
+ self.task_stats[tid] = {
+ 'completed_at': time.time() if completed_task.get('status') == 'completed' else None,
+ 'flood_count': completed_task.get('flood_count', 0),
+ 'flood_time': completed_task.get('flood_total_seconds', 0),
+ 'avg_speed': avg_spd
+ }
+ self.db.set("ChatCopy", "task_stats", self.task_stats)
+ logger.info("[%s] Задача завершена. Переслано: %d",
+ tid, self.active_dumps.get(tid, {}).get('current', 0))
+ self.current_dump_task = None
+ self.is_processing_queue = False
+ self.dump_queue.task_done()
+ if tid and tid in self.task_history:
+ last_task = next((t for t in reversed(self.task_history) if t.get('tid') == tid), None)
+ if last_task and last_task.get('flood_count', 0) > 0:
+ final_wait = min(60 * last_task['flood_count'], 300)
+ logger.info(f"Финальная задержка после задачи с FloodWait'ами: {final_wait}с")
+ await asyncio.sleep(final_wait)
+ self._save_tasks()
+
+ def _update_queue_positions(self): # описание ниже
+ """Обновляет позиции задач в очереди"""
+ queued_tasks = [t for t in self.task_queue if t['status'] == 'queued']
+ for i, task in enumerate(queued_tasks, 1):
+ task['position'] = i
+
+ async def _auto_update_status(self, tid, status_msg): # описание ниже
+ """Обновляет только внутренний кэш скорости без редактирования сообщения"""
+ while True:
+ try:
+ await asyncio.sleep(5)
+ if tid not in self.active_dumps:
+ break
+ task = self.active_dumps[tid]
+ status = task.get('status', 'unknown')
+ if status not in ['running', 'paused']:
+ continue
+ current = task.get('current', 0)
+ total = task.get('total_estimated', 0)
+ start_time = task.get('start_time', time.time())
+ elapsed = time.time() - start_time
+ now = time.time()
+ last_calc_time = task.get('_last_calc_time', now - 5)
+ last_calc_count = task.get('_last_calc_count', current)
+ delta_t = now - last_calc_time
+ delta_c = current - last_calc_count
+ if status == 'running':
+ if delta_t > 0:
+ inst_speed = (delta_c / delta_t) * 60
+ task['speed_samples'].append(inst_speed)
+ if len(task['speed_samples']) > 12:
+ task['speed_samples'].pop(0)
+ task['_last_calc_time'] = now
+ task['_last_calc_count'] = current
+ avg_speed = sum(task['speed_samples']) / len(task['speed_samples']) if task['speed_samples'] else 0
+ task['current_speed'] = avg_speed
+ if avg_speed > 0:
+ self.global_speed_history.append(avg_speed)
+ if len(self.global_speed_history) > 50:
+ self.global_speed_history.pop(0)
+ self.task_progress_cache[tid] = {
+ 'current': current,
+ 'speed': round(avg_speed, 1),
+ 'eta': self._calculate_eta(current, total, avg_speed),
+ 'progress': round((current / total * 100), 1) if total > 0 else 0,
+ 'elapsed': elapsed,
+ 'status': status
+ }
+ # прогресс идёт в логи через logger.info
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"Auto update error: {e}")
+
+ def _get_avg_speed(self): # описание ниже
+ """Получает среднюю скорость из глобальной истории"""
+ if not self.global_speed_history:
+ return 100
+ return sum(self.global_speed_history) / len(self.global_speed_history)
+
+ def _calculate_eta(self, current, total, speed_per_min): # описание ниже
+ """Расчёт оставшегося времени"""
+ if speed_per_min <= 0 or total <= 0:
+ return "∞"
+ remaining = total - current
+ minutes = remaining / speed_per_min
+ return self._format_duration(minutes * 60)
+
+ def _calculate_task_wait_time(self, target_position): # описание ниже
+ """Расчёт времени ожидания для задачи в очереди"""
+ avg_speed = self._get_avg_speed()
+ total_seconds = 0
+ for task in self.task_queue:
+ if task['position'] < target_position and task['status'] not in ['completed', 'stopped', 'error']:
+ remaining = task.get('total_msgs', 0) - task.get('current', 0)
+ if remaining > 0:
+ task_seconds = (remaining / avg_speed) * 60 if avg_speed > 0 else 3600
+ total_seconds += task_seconds
+ return self._format_duration(total_seconds)
+
+ def _estimate_duration(self, total_msgs): # описание ниже
+ """Оценка длительности задачи"""
+ avg_speed = self._get_avg_speed()
+ if avg_speed <= 0 or total_msgs <= 0:
+ return "∞"
+ minutes = total_msgs / avg_speed
+ return self._format_duration(minutes * 60)
+
+ def _calculate_end_time(self, start_time, total_msgs, speed_per_min=None): # описание ниже
+ """Расчёт времени окончания задачи"""
+ if speed_per_min is None:
+ speed_per_min = self._get_avg_speed()
+ if speed_per_min <= 0 or total_msgs <= 0:
+ return "∞"
+ minutes = total_msgs / speed_per_min
+ end_time = start_time + timedelta(minutes=minutes)
+ return end_time.strftime("%H:%M:%S")
+
+ async def _raw_sender(self, messages, dest_id, no_author, no_captions, topic_id, status_msg=None, tid=None): # описание ниже
+ """Улучшенный sender с умной обработкой FloodWait"""
+ try:
+ dest_peer = await self.client.get_input_entity(dest_id)
+ src_peer = await self.client.get_input_entity(messages[0].chat_id)
+ await self.client(functions.messages.ForwardMessagesRequest(
+ from_peer=src_peer, id=[m.id for m in messages],
+ to_peer=dest_peer, drop_author=no_author, top_msg_id=topic_id,
+ with_my_score=False, drop_media_captions=no_captions
+ ))
+ if tid and tid in self.active_dumps:
+ self.active_dumps[tid]["last_successful_send"] = time.time()
+ self.active_dumps[tid]["consecutive_floods"] = 0
+ return True
+ except errors.FloodWaitError as e:
+ wait_time = e.seconds if e.seconds is not None else 60
+ if tid and tid in self.active_dumps:
+ task = self.active_dumps[tid]
+ task["consecutive_floods"] = task.get("consecutive_floods", 0) + 1
+ task["flood_count"] = task.get("flood_count", 0) + 1
+ task["flood_total_seconds"] = task.get("flood_total_seconds", 0) + wait_time
+ task["current_flood_wait"] = wait_time
+ task["status"] = "paused"
+ task["flood_wait_until"] = time.time() + wait_time + self.config["flood_buffer"]
+ current_speed = task.get('current_speed', 0)
+ total_msgs = task.get('total_estimated', 0)
+ current_count = task.get('current', 0)
+ status_chat = task.get("status_chat_id")
+ if status_chat:
+ await self._send_flood_notice(status_chat, wait_time, current_count, tid, total_msgs, current_speed)
+ logger.warning(f"[{tid}] FloodWait: ждём {wait_time}с (запрошено Telegram) + {self.config['flood_buffer']}с буфер")
+ total_wait = wait_time + self.config["flood_buffer"]
+ waited = 0
+ check_interval = 5
+ while waited < total_wait:
+ if tid in self.active_dumps:
+ if self.active_dumps[tid].get("status") == "stopped":
+ logger.info(f"[{tid}] Задача остановлена во время ожидания FloodWait")
+ return False
+ await asyncio.sleep(min(check_interval, total_wait - waited))
+ waited += check_interval
+ if tid in self.active_dumps:
+ self.active_dumps[tid]["status"] = "running"
+ self.active_dumps[tid]["last_successful_send"] = time.time()
+ try:
+ await self.client(functions.messages.ForwardMessagesRequest(
+ from_peer=src_peer, id=[m.id for m in messages],
+ to_peer=dest_peer, drop_author=no_author, top_msg_id=topic_id,
+ with_my_score=False, drop_media_captions=no_captions
+ ))
+ if tid and tid in self.active_dumps:
+ self.active_dumps[tid]["last_successful_send"] = time.time()
+ self.active_dumps[tid]["consecutive_floods"] = 0
+ return True
+ except errors.FloodWaitError as e2:
+ logger.warning(f"[{tid}] Повторный FloodWait: ждём ещё {e2.seconds}с")
+ await asyncio.sleep(e2.seconds + self.config["flood_buffer"])
+ return False
+ return False
+ except Exception as e:
+ logger.error(f"[{tid}] Send Error: {e}")
+ return False
+
+ def _parse_filter(self, args): # все аргументы нужные цепляет
+ filter_type = FILTER_ALL
+ args_list = list(args)
+ for arg in args_list:
+ if arg == "--media":
+ filter_type = FILTER_MEDIA
+ if arg in args: args.remove(arg)
+ elif arg == "--photo_video":
+ filter_type = FILTER_PHOTO_VIDEO
+ if arg in args: args.remove(arg)
+ elif arg == "--docs":
+ filter_type = FILTER_DOCS
+ if arg in args: args.remove(arg)
+ elif arg == "--text":
+ filter_type = FILTER_TEXT
+ if arg in args: args.remove(arg)
+ return filter_type, args
+
+ def _get_filter_name(self, filter_type):
+ names = {
+ FILTER_ALL: "Все сообщения",
+ FILTER_MEDIA: "Только медиа",
+ FILTER_PHOTO_VIDEO: "Фото и видео",
+ FILTER_DOCS: "Документы",
+ FILTER_TEXT: "Текст",
+ }
+ return names.get(filter_type, "Неизвестно")
+
+ def _get_effective_batch_size(self) -> int:
+ """Returns the current batch_size from config, always fresh."""
+ val = self.config.get("batch_size", 100)
+ if isinstance(val, int) and 1 <= val <= 100:
+ return val
+ return 100
+
+ @loader.command()
+ async def chatcopy(self, message: Message):
+ """ [start_id:final_id] [-n] [-dmc] [--now] [--media|--photo_video|--docs|--text] — Добавить задачу в очередь. --now: начать сразу, без полного подсчёта."""
+ args_raw = utils.get_args_raw(message).split()
+ no_author = "-n" in args_raw
+ no_captions = "-dmc" in args_raw
+ start_now = "--now" in args_raw
+ if start_now:
+ args_raw.remove("--now")
+ filter_type, args_raw = self._parse_filter(args_raw)
+ clean_args = [x for x in args_raw if x not in ["-n", "-dmc"]]
+ if len(clean_args) < 2:
+ return await utils.answer(message, self.strings["args_err"])
+ start_id = 0
+ final_id = 0
+ if len(clean_args) >= 3:
+ id_arg = clean_args[2]
+ if ":" in id_arg:
+ parts = id_arg.split(":")
+ if parts[0].isdigit():
+ start_id = int(parts[0])
+ if len(parts) > 1 and parts[1].isdigit():
+ final_id = int(parts[1])
+ elif id_arg.isdigit():
+ start_id = int(id_arg)
+ src, src_map = await self._resolve_arg(clean_args[0])
+ dest, dest_map = await self._resolve_arg(clean_args[1])
+ if not src or not dest:
+ return await utils.answer(message, self.strings["err_ent"])
+ self._task_counter += 1
+ tid = f"{src.id}_{dest.id}_{self._task_counter}_{int(time.time())}"
+ src_is_forum = self._is_forum(src)
+ dest_is_forum = self._is_forum(dest)
+ if src_is_forum and not dest_is_forum:
+ forum_result = await self._ensure_forum_enabled(dest)
+ if forum_result:
+ dest = await self.client.get_entity(dest.id)
+ dest_is_forum = self._is_forum(dest)
+ if not dest_is_forum:
+ await asyncio.sleep(2)
+ dest = await self.client.get_entity(dest.id)
+ dest_is_forum = self._is_forum(dest)
+ if dest_is_forum:
+ logger.info("[%s] Режим топиков включён на dest %s", tid, getattr(dest, 'title', dest.id))
+ else:
+ logger.warning("[%s] _ensure_forum_enabled вернул True, но _is_forum всё ещё False для dest %s", tid, getattr(dest, 'title', dest.id))
+ else:
+ logger.warning("[%s] Не удалось включить топики на dest %s — копирование пойдёт без маппинга топиков", tid, getattr(dest, 'title', dest.id))
+ elif src_is_forum and dest_is_forum:
+ try:
+ dest = await self.client.get_entity(dest.id)
+ dest_is_forum = self._is_forum(dest)
+ except Exception:
+ pass
+ if src_is_forum and not dest_is_forum:
+ logger.warning("[%s] src — форум, dest — НЕ форум. Все сообщения пойдут в General!", tid)
+ prep_key = "preparing_prem" if self.is_premium else "preparing_no_prem"
+ status_msg = await utils.answer(message, self.strings[prep_key])
+ total_msgs = 0
+ f_src_t_for_count = src_map.get('topic')
+ if start_now:
+ try:
+ if f_src_t_for_count:
+ async for _ in self.client.iter_messages(
+ src,
+ reply_to=f_src_t_for_count,
+ min_id=start_id - 1 if start_id else 0,
+ max_id=final_id + 1 if final_id else 0,
+ ):
+ total_msgs += 1
+ if total_msgs > 150000: break
+ else:
+ result = await self.client(functions.messages.GetHistoryRequest(
+ peer=src,
+ offset_id=0,
+ offset_date=None,
+ add_offset=0,
+ limit=1,
+ max_id=final_id + 1 if final_id else 0,
+ min_id=start_id - 1 if start_id else 0,
+ hash=0,
+ ))
+ total_msgs = getattr(result, 'count', 0) or 0
+ except Exception as e:
+ logger.warning(f"Count failed for --now: {e}")
+ total_msgs = 0
+ else:
+ try:
+ iter_kwargs = {
+ "min_id": start_id - 1 if start_id else 0,
+ "max_id": final_id + 1 if final_id else 0,
+ }
+ if f_src_t_for_count:
+ iter_kwargs["reply_to"] = f_src_t_for_count
+ async for _ in self.client.iter_messages(src, **iter_kwargs):
+ total_msgs += 1
+ if total_msgs > 150000: break
+ except Exception as e:
+ logger.error(f"Ошибка при подсчете сообщений: {e}")
+ total_msgs = -1
+ src_name = getattr(src, 'title', src.id)
+ dest_name = getattr(dest, 'title', dest.id)
+ async with self._queue_lock:
+ queue_position = len([t for t in self.task_queue if t['status'] == 'queued']) + 1
+ estimated_duration = self._estimate_duration(total_msgs)
+ mode_str = "🗂️ Топики (Auto)" if src_is_forum else "Обычный"
+ no_auth_str = "Да" if no_author else "Нет"
+ no_capt_str = "Да" if no_captions else "Нет"
+ start_id_str = f"с {start_id}" if start_id > 0 else "С начала"
+ if final_id > 0: start_id_str += f" до {final_id}"
+ task_info = {
+ 'tid': tid, 'src': src_name, 'dest': dest_name, 'src_id': src.id, 'dest_id': dest.id,
+ 'status': 'queued', 'position': queue_position, 'added_time': datetime.now(MSK).isoformat(),
+ 'no_author': no_author, 'no_captions': no_captions, 'filter_type': filter_type,
+ 'start_id': start_id, 'final_id': final_id, 'total_msgs': total_msgs if total_msgs > -1 else 0,
+ 'current': 0, 'last_processed_id': start_id,
+ 'status_msg_id': status_msg.id, 'status_chat_id': status_msg.chat_id,
+ 'map_t': src_is_forum, 'f_src_t': src_map.get('topic'), 'f_dest_t': dest_map.get('topic'),
+ 'start_now': start_now,
+ }
+ self.task_queue.append(task_info)
+ self._save_tasks()
+ filter_name = self._get_filter_name(filter_type)
+ start_string_key = "copy_start_prem" if self.is_premium else "copy_start_no_prem"
+ await status_msg.edit(self.strings[start_string_key].format(
+ src=utils.escape_html(src_name), dest=utils.escape_html(dest_name),
+ mode=mode_str, start_id=start_id_str, no_auth=no_auth_str,
+ no_capt=no_capt_str, filter_type=filter_name,
+ total_msgs=total_msgs if total_msgs > -1 else "∞ (ошибка подсчета)",
+ estimated_time=estimated_duration, position=queue_position
+ ))
+ await self.dump_queue.put({
+ "status_msg": status_msg, "src": src, "dest": dest,
+ "no_auth": no_author, "no_captions": no_captions,
+ "map_t": src_is_forum, "f_src_t": src_map.get('topic'), "f_dest_t": dest_map.get('topic'),
+ "tid": tid, "min_id": start_id, "max_id": final_id,
+ "mode_str": mode_str, "no_auth_str": no_auth_str, "no_capt_str": no_capt_str,
+ "start_id_str": start_id_str, "filter_type": filter_name, "filter_name": filter_name,
+ "src_name": src_name, "queue_position": queue_position, "total_msgs": total_msgs if total_msgs > -1 else 0,
+ "restored_count": 0,
+ })
+
+ def _parse_duration(self, duration_str): # описание ниже
+ """Парсит строку длительности в секунды"""
+ if duration_str == "∞":
+ return 3600
+ total = 0
+ parts = duration_str.split()
+ for part in parts:
+ if 'ч' in part:
+ total += int(part.replace('ч', '')) * 3600
+ elif 'ч' in part and 'м' in part:
+ pass
+ elif 'м' in part and 'с' not in part:
+ total += int(part.replace('м', '')) * 60
+ elif 'м' in part and 'с' in part:
+ mins_secs = part.replace('м', '').replace('с', '').split()
+ if len(mins_secs) >= 1:
+ total += int(mins_secs[0]) * 60
+ if len(mins_secs) >= 2:
+ total += int(mins_secs[1])
+ elif 'с' in part:
+ total += int(part.replace('с', ''))
+ elif part.isdigit():
+ total += int(part)
+ return total if total > 0 else 0
+
+ @loader.command() # стартует слежку за чатом что бы пи... кхм кхм, благополучно заимствовать сей прекрасный или не очень контент
+ async def ccwatch(self, message: Message):
+ """ [start_id:final_id] [-n] [-dmc][--media|--photo_video|--docs|--text] — Наблюдение за чатом"""
+ args = utils.get_args_raw(message).split()
+ no_author = "-n" in args
+ no_captions = "-dmc" in args
+ filter_type, args = self._parse_filter(args)
+ clean_args = [x for x in args if x not in ["-n", "-t", "-dmc"]]
+ if len(clean_args) < 2:
+ return await utils.answer(message, self.strings["args_err"])
+ start_id = 0
+ final_id = 0
+ if len(clean_args) >= 3:
+ id_arg = clean_args[2]
+ if ":" in id_arg:
+ parts = id_arg.split(":")
+ if parts[0].isdigit(): start_id = int(parts[0])
+ if len(parts) > 1 and parts[1].isdigit(): final_id = int(parts[1])
+ elif id_arg.isdigit():
+ start_id = int(id_arg)
+ src, src_map = await self._resolve_arg(clean_args[0])
+ dest, dest_map = await self._resolve_arg(clean_args[1])
+ if not src or not dest:
+ return await utils.answer(message, self.strings["err_ent"])
+ src_is_forum = self._is_forum(src)
+ dest_is_forum = self._is_forum(dest)
+ if src_is_forum and not dest_is_forum:
+ forum_result = await self._ensure_forum_enabled(dest)
+ if forum_result:
+ await utils.answer(message, self.strings["forum_enabled"].format(chat=utils.escape_html(getattr(dest, 'title', dest.id))))
+ dest = await self.client.get_entity(dest.id)
+ else:
+ return await utils.answer(message, self.strings["forum_enable_failed"].format(chat=utils.escape_html(getattr(dest, 'title', dest.id))))
+ is_restricted = False
+ try:
+ async for test_m in self.client.iter_messages(src, limit=1):
+ if test_m.noforwards:
+ is_restricted = True
+ break
+ except Exception:
+ pass
+ if is_restricted:
+ return await utils.answer(message, "❌ Ошибка: канал в режиме запрета копирования") # ну как бы, учитываем да
+ src_t = src_map.get('topic')
+ dest_t = dest_map.get('topic')
+ map_topics = src_is_forum
+ cid = self._get_normalized_id(src)
+ try:
+ dest_id = utils.get_chat_id(dest)
+ except:
+ dest_id = dest.id
+ if start_id > 0:
+ self.last_processed_ids[cid] = start_id - 1
+ elif cid not in self.last_processed_ids:
+ self.last_processed_ids[cid] = 0
+ self.watchlist[cid] = {
+ "dest": dest_id, "no_author": no_author, "no_captions": no_captions, "map_topics": map_topics,
+ "fixed_src_topic": src_t, "fixed_dest_topic": dest_t, "src_entity_id": src.id, "dest_entity_id": dest.id,
+ "filter_type": filter_type, "final_id": final_id
+ }
+ self.db.set("ChatCopy", "watchlist", self.watchlist)
+ self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids)
+ filter_name = self._get_filter_name(filter_type)
+ msg_text = self.strings["watch_added"].format(
+ src=getattr(src, 'title', src.id), src_id=cid, dest=getattr(dest, 'title', dest.id),
+ topics="🗂️ ВКЛ (Auto-mapping)" if map_topics else "ВЫКЛ", no_capt="Да" if no_captions else "Нет", filter_type=filter_name
+ )
+ if start_id > 0 or final_id > 0:
+ range_str = "Все новые"
+ if start_id > 0 and final_id > 0: range_str = f"с {start_id} по {final_id}"
+ elif start_id > 0: range_str = f"с {start_id}"
+ elif final_id > 0: range_str = f"до {final_id}"
+ msg_text += f"\nДиапазон ID: {range_str}"
+ await utils.answer(message, msg_text)
+
+ async def _history_dumper(self, status_msg, src, dest, no_auth, no_captions,
+ map_t, f_src_t, f_dest_t, tid, min_id=0, max_id=0,
+ filter_type=FILTER_ALL, filter_name="", restored_count=0, **kwargs):
+ if tid in self.active_dumps:
+ self.active_dumps[tid]["status"] = "running"
+ task = next((t for t in self.task_queue if t['tid'] == tid), None)
+ if not task:
+ logger.error(f"Задача {tid} не найдена в очереди для дампа.")
+ return
+ count = task.get('current', 0) or restored_count
+ if tid in self.active_dumps and count > 0:
+ self.active_dumps[tid]["current"] = count
+ start_from_id = task.get('last_processed_id', min_id)
+ if map_t:
+ try:
+ dest = await self.client.get_entity(dest.id)
+ if not self._is_forum(dest):
+ logger.info("[%s] dest не форум, пытаемся включить топики...", tid)
+ ok = await self._ensure_forum_enabled(dest)
+ if ok:
+ await asyncio.sleep(2)
+ dest = await self.client.get_entity(dest.id)
+ if self._is_forum(dest):
+ logger.info("[%s] Режим топиков включён на dest в dumper", tid)
+ else:
+ logger.warning("[%s] _ensure_forum_enabled OK, но _is_forum False. Пробуем ещё раз...", tid)
+ await asyncio.sleep(3)
+ dest = await self.client.get_entity(dest.id)
+ if not self._is_forum(dest):
+ logger.warning("[%s] dest не является форумом после повторной проверки, пересылка без топиков", tid)
+ map_t = False
+ else:
+ logger.warning("[%s] dest не является форумом, пересылка без топиков", tid)
+ map_t = False
+ except Exception as e:
+ logger.warning("[%s] Ошибка обновления dest entity: %s", tid, e)
+ if map_t:
+ try:
+ src = await self.client.get_entity(src.id)
+ if not self._is_forum(src):
+ logger.warning("[%s] src не является форумом (хотя map_t=True), отключаем маппинг", tid)
+ map_t = False
+ except Exception as e:
+ logger.warning("[%s] Ошибка обновления src entity: %s", tid, e)
+ batch = []
+ dumper_kwargs = {"reverse": True}
+ if f_src_t: dumper_kwargs["reply_to"] = f_src_t
+ if start_from_id > 0: dumper_kwargs["min_id"] = start_from_id - 1
+ if max_id > 0: dumper_kwargs["max_id"] = max_id + 1
+ delay = self.config["delay"]
+ try:
+ async for msg in self.client.iter_messages(src, **dumper_kwargs):
+ if tid not in self.active_dumps or self.active_dumps[tid].get("status") == "stopped": break
+ await self.active_dumps[tid].get("cancel", asyncio.Event()).wait()
+ if tid not in self.active_dumps or self.active_dumps[tid].get("status") == "stopped": break
+ if isinstance(msg, types.MessageService) or not self._should_include_message(msg, filter_type): continue
+ batch.append(msg)
+ if len(batch) >= self._get_effective_batch_size():
+ processed = await self._process_batch(
+ messages=list(batch), dest_id=dest.id, no_author=no_auth, no_captions=no_captions,
+ fixed_dest_topic=f_dest_t, map_topics=map_t, dest_entity=dest, src_entity=src,
+ filter_type=filter_type, status_msg=status_msg, tid=tid
+ )
+ if tid not in self.active_dumps or self.active_dumps[tid].get("status") == "stopped": break
+ if tid in self.active_dumps:
+ self.active_dumps[tid]["current"] += processed
+ count = self.active_dumps[tid]["current"]
+ task['current'] = count
+ task['last_processed_id'] = batch[-1].id
+ self._save_tasks()
+ total = task.get('total_msgs', 0)
+ pct = round(count / total * 100, 1) if total else 0
+ spd = round(self.active_dumps[tid].get('current_speed', 0), 1)
+ logger.info("[%s] Прогресс: %d/%d (%.1f%%) | %.1f сооб/мин",
+ tid, count, total, pct, spd)
+ batch = []
+ if batch and self.active_dumps.get(tid, {}).get("status") != "stopped":
+ processed = await self._process_batch(
+ messages=list(batch), dest_id=dest.id, no_author=no_auth, no_captions=no_captions,
+ fixed_dest_topic=f_dest_t, map_topics=map_t, dest_entity=dest, src_entity=src,
+ filter_type=filter_type, status_msg=status_msg, tid=tid
+ )
+ if tid in self.active_dumps:
+ self.active_dumps[tid]["current"] += processed
+ count = self.active_dumps[tid]["current"]
+ task['current'] = count
+ task['last_processed_id'] = batch[-1].id
+ if self.active_dumps.get(tid, {}).get("status") != "stopped":
+ task['status'] = 'completed'
+ self.task_queue = [t for t in self.task_queue if t['tid'] != tid]
+ self._save_tasks()
+ task_data = self.active_dumps[tid]
+ duration_seconds = time.time() - task_data.get('start_time', time.time())
+ duration_str = self._format_duration(duration_seconds)
+ active_seconds = duration_seconds - task_data.get('flood_total_seconds', 0)
+ if active_seconds <= 0: active_seconds = 1
+ avg_speed = round((count / active_seconds) * 60, 1)
+ chat_id_to_report = status_msg.chat_id if status_msg and status_msg.chat_id else task.get('status_chat_id')
+ done_string_key = "copy_done_detailed_prem" if self.is_premium else "copy_done_detailed_no_prem"
+ done_full = self.strings[done_string_key].format(
+ src=utils.escape_html(getattr(src, 'title', src.id)), dest=utils.escape_html(getattr(dest, 'title', dest.id)),
+ no_auth=kwargs.get("no_auth_str", "N/A"), no_capt=kwargs.get("no_capt_str", "N/A"),
+ start_id=kwargs.get("start_id_str", "N/A"), mode=kwargs.get("mode_str", "N/A"),
+ filter_type=filter_name, count=count, duration=duration_str,
+ avg_speed=avg_speed, flood_info=self._format_flood_stats(task_data)
+ )
+ # краткий итог в логи
+ logger.info(
+ "[✅ %s] Завершено: %d сообщений за %s | %.1f сооб/мин",
+ task_data.get('name', '?'), count, duration_str, avg_speed
+ )
+ # полный итог в чат где было запущено
+ if chat_id_to_report:
+ await self.client.send_message(chat_id_to_report, done_full)
+ except Exception as e:
+ logger.error(f"Dumper Error: {e}", exc_info=True)
+ chat_id_to_report = status_msg.chat_id if status_msg and status_msg.chat_id else task.get('status_chat_id')
+ if chat_id_to_report: await self.client.send_message(chat_id_to_report, f"❌ Ошибка в задаче:\n{e}")
+ task['status'] = 'error'
+ self._save_tasks()
+ except Exception as e:
+ logger.error(f"Dumper Error: {e}")
+ await self.client.send_message(status_msg.chat_id, f"❌ Ошибка в задаче:\n{e}")
+
+ @loader.watcher() # сам ватчер, который следит за чатами
+ async def watcher(self, message: Message):
+ if isinstance(message, types.MessageService):
+ return
+ if not getattr(message, 'chat_id', None):
+ return
+ raw_chat_id = str(message.chat_id)
+ normalized_id = self._get_normalized_id(getattr(message, 'chat', None))
+ chat_id_from_utils = "0"
+ if getattr(message, 'chat', None) and hasattr(utils, 'get_chat_id'):
+ try:
+ chat_id_from_utils = str(utils.get_chat_id(message.chat))
+ except Exception:
+ pass
+ possible_ids = [
+ normalized_id,
+ raw_chat_id,
+ raw_chat_id.replace("-100", ""),
+ f"-100{raw_chat_id.replace('-100', '').replace('-', '')}",
+ chat_id_from_utils
+ ]
+ cid = None
+ for test_id in possible_ids:
+ if test_id in self.watchlist:
+ cid = test_id
+ break
+ if not cid:
+ return
+ cfg = self.watchlist[cid]
+ filter_type = cfg.get("filter_type", FILTER_ALL)
+ last_id = self.last_processed_ids.get(cid, 0)
+ final_id = cfg.get("final_id", 0)
+ if message.id <= last_id:
+ return
+ if final_id > 0 and message.id > final_id:
+ return
+ if not self._should_include_message(message, filter_type):
+ self.last_processed_ids[cid] = message.id
+ self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids)
+ return
+ if cfg.get("fixed_src_topic"):
+ cur_t = getattr(message, 'topic_id', None) or (message.reply_to.reply_to_top_id if message.reply_to else None)
+ if cur_t != cfg["fixed_src_topic"]:
+ self.last_processed_ids[cid] = message.id
+ self.db.set("ChatCopy", "last_processed_ids", self.last_processed_ids)
+ return
+ if cid not in self.watcher_buffer:
+ self.watcher_buffer[cid] = []
+ self.watcher_buffer[cid].append(message)
+ self.last_watched[cid] = {
+ "name": getattr(getattr(message, 'chat', None), "title", cid) if getattr(message, 'chat', None) else cid,
+ "time": datetime.now(MSK).strftime("%H:%M:%S")
+ }
+ if cid in self.watcher_flush_tasks:
+ self.watcher_flush_tasks[cid].cancel()
+ batch_size = self.config["batch_size"]
+ if not isinstance(batch_size, int):
+ batch_size = 100
+ if len(self.watcher_buffer[cid]) >= batch_size:
+ await self._flush_watcher_buffer(cid, cfg)
+ else:
+ self.watcher_flush_tasks[cid] = asyncio.get_event_loop().call_later(
+ 3.0,
+ lambda: asyncio.create_task(self._flush_watcher_buffer(cid, cfg))
+ )
+
+ async def _flush_watcher_buffer(self, cid, cfg): # опустошает буфер watcher'а: группирует альбомы и отправляет пачку в очередь на пересылку.
+ if cid not in self.watcher_buffer or not self.watcher_buffer[cid]:
+ return
+ msgs = self.watcher_buffer[cid].copy()
+ self.watcher_buffer[cid] = []
+ if cid in self.watcher_flush_tasks:
+ del self.watcher_flush_tasks[cid]
+ try:
+ cid_int = int(cid)
+ except (ValueError, TypeError):
+ logger.error(f"Watcher flush: неверный cid={cid}")
+ return
+ albums = {}
+ single_msgs = []
+ for msg in msgs:
+ if msg.grouped_id:
+ if msg.grouped_id not in albums:
+ albums[msg.grouped_id] = []
+ albums[msg.grouped_id].append(msg)
+ else:
+ single_msgs.append(msg)
+ for gid, album_msgs in albums.items():
+ sorted_album = sorted(album_msgs, key=lambda x: x.id)
+ try:
+ dest_entity = await self.client.get_entity(cfg["dest"])
+ src_entity = await self.client.get_entity(cid_int)
+ await self.queue.put({
+ "messages": sorted_album,
+ "dest_id": cfg["dest"],
+ "no_author": cfg["no_author"],
+ "no_captions": cfg.get("no_captions", False),
+ "fixed_dest_topic": cfg.get("fixed_dest_topic"),
+ "map_topics": cfg.get("map_topics"),
+ "dest_entity": dest_entity,
+ "src_entity": src_entity,
+ "filter_type": cfg.get("filter_type", FILTER_ALL),
+ "watch_cid": cid
+ })
+ except Exception as e:
+ logger.error(f"Watcher album flush error (cid={cid}): {e}")
+ batch_size = self.config["batch_size"]
+ if not isinstance(batch_size, int):
+ batch_size = 100
+ for i in range(0, len(single_msgs), batch_size):
+ batch = single_msgs[i:i + batch_size]
+ try:
+ dest_entity = await self.client.get_entity(cfg["dest"])
+ src_entity = await self.client.get_entity(cid_int)
+ await self.queue.put({
+ "messages": batch,
+ "dest_id": cfg["dest"],
+ "no_author": cfg["no_author"],
+ "no_captions": cfg.get("no_captions", False),
+ "fixed_dest_topic": cfg.get("fixed_dest_topic"),
+ "map_topics": cfg.get("map_topics"),
+ "dest_entity": dest_entity,
+ "src_entity": src_entity,
+ "filter_type": cfg.get("filter_type", FILTER_ALL),
+ "watch_cid": cid
+ })
+ except Exception as e:
+ logger.error(f"Watcher batch flush error (cid={cid}): {e}")
+
+ async def _catch_up_on_restart(self): # ватчер восстанавливает после перезагрузки
+ await asyncio.sleep(15)
+ for cid_str, cfg in self.watchlist.items():
+ try:
+ last_id = self.last_processed_ids.get(cid_str, 0)
+ if not isinstance(last_id, int):
+ last_id = 0
+ missed = []
+ batch_size = self.config["batch_size"]
+ if not isinstance(batch_size, int):
+ batch_size = 100
+ filter_type = cfg.get("filter_type", FILTER_ALL)
+ cid_int = int(cid_str)
+ async for msg in self.client.iter_messages(cid_int, min_id=last_id):
+ if cfg.get("final_id", 0) > 0 and msg.id > cfg.get("final_id", 0):
+ continue
+ if not isinstance(msg, types.MessageService) and self._should_include_message(msg, filter_type):
+ missed.append(msg)
+ if missed:
+ missed.sort(key=lambda x: x.id)
+ for i in range(0, len(missed), batch_size):
+ batch = missed[i:i + batch_size]
+ dest_ent = await self.client.get_entity(cfg["dest"])
+ src_ent = await self.client.get_entity(cid_int)
+ await self.queue.put({
+ "messages": batch, "dest_id": cfg["dest"], "no_author": cfg["no_author"],
+ "no_captions": cfg.get("no_captions", False), "fixed_dest_topic": cfg.get("fixed_dest_topic"),
+ "map_topics": cfg.get("map_topics"), "dest_entity": dest_ent, "src_entity": src_ent,
+ "filter_type": filter_type, "watch_cid": cid_str
+ })
+ await asyncio.sleep(self.config["delay"])
+ except Exception as e:
+ logger.debug(f"Catch-up error for {cid_str}: {e}")
+
+ @loader.command()
+ async def cchelp(self, message: Message):
+ """— Подробная документация по модулю ChatCopy"""
+ help_text_prem = (
+ '🛡 Подробная документация по модулю ChatCopy!\n\n'
+ '1️⃣ Основные команды \n'
+ '🛫 .chatcopy <откуда> <куда>[диапазон (от:до)] [флаги (можно несколько)]\n'
+ 'Копирует старую историю чата (делает дамп). Ставит задачу в очередь в случае если другая была запущена.\n'
+ '⚙️ --now — Начать немедленно, без полного подсчёта (примерное кол-во сообщений запрашивается у Telegram мгновенно). Идеально для 110k+ медиа.\n\n'
+ '👀 .ccwatch <откуда> <куда> [диапазон (от:до)] [флаги (можно несколько)]\n'
+ 'Режим слежки. Модуль будет висеть в фоне и моментально пересылать все новые сообщения. Функции [от:до] аналогичны .chatcopy\n\n'
+ '📺 .ccpanel\n'
+ 'Открывает меню: управление задачами, пауза/стоп, статистика и настройки (скорость, задержка).\n\n'
+ '🗑 .ccclear topics\n'
+ 'Очищает кэш топиков (полезно, если форум сломался и пересылает не в те разделы).
\n\n'
+ '2️⃣ Источники и Диапазоны([от:до] функция) (ID)\n'
+ '✨ Чаты: Можно использовать юзернеймы (@chat), ID (-100123...) или прямые ссылки на топики (t.me/c/123/45). Модуль сам всё распознает.\n'
+ '⚪️ Диапазон [start:end]: Пишется слитно, без пробелов.\n'
+ '⚪️ 100:500 — скопировать с 100-го по 500-е сообщение.\n'
+ '⚪️ 100: — от 100-го до самых свежих.\n'
+ '⚪️ :500 — с самого начала чата и до 500-го.
\n\n'
+ '3️⃣ Флаги (Настройки текста)\n'
+ '🆕 --now - начать пересылку сразу без подсчитывания, но без копирования топиков и последующей пересылки в них'
+ '👤 -n — Скрыть автора (пересылка без плашки «Переслано от...»).\n'
+ '💬 -dmc — Удалить подпись к медиа (оставит только голую картинку или файл, удалив текст под ним)(!Работает только с[-n] флагом!).
\n\n'
+ '4️⃣ Фильтры контента\n'
+ '(Указывайте только один! Если не указать ничего — скопируется всё подряд)\n'
+ '📌 --media — Любые медиа (фото, видео) и документы.\n'
+ '📷 --photo_video — Строго только фото и видео (без гифок/стикеров).\n'
+ '💼 --docs — Строго только документы (файлы, архивы, apk).\n'
+ '💬 --text — Только чисто текстовые сообщения.
\n\n'
+ '💡 Полные примеры использования\n'
+ '1. Полная копия канала со скрытием автора:\n'
+ '➡️ .chatcopy @donor_channel @my_channel -n\n\n'
+ '2. Слежка за конкретным топиком (воруем только фото/видео без подписей):\n'
+ '➡️ .ccwatch t.me/c/123/4 t.me/c/321/5 -dmc --photo_video\n\n'
+ '3. Скопировать историю с 5000 по 6000 сообщение, только текст:\n'
+ '➡️ .chatcopy -100111 -100222 5000:6000 --text
\n\n'
+ '💎 Приятного пользования!\n'
+ '❕ Единственный минус, не копирует с чатов с запрещенным копированием.'
+ )
+
+ help_text_no_prem = (
+ '🛡 Подробная документация по модулю ChatCopy!\n\n'
+ '1️⃣ Основные команды \n'
+ '🛫 .chatcopy <откуда> <куда>[диапазон (от:до)] [флаги (можно несколько)]\n'
+ 'Копирует старую историю чата (делает дамп). Ставит задачу в очередь в случае если другая была запущена.\n'
+ '⚙️ --now — Начать немедленно, без полного подсчёта (примерное кол-во запрашивается у Telegram мгновенно). Идеально для 110k+ медиа.\n\n'
+ '👀 .ccwatch <откуда> <куда> [диапазон (от:до)] [флаги (можно несколько)]\n'
+ 'Режим слежки. Модуль будет висеть в фоне и моментально пересылать все новые сообщения. Функции [от:до] аналогичны .chatcopy\n\n'
+ '📺 .ccpanel\n'
+ 'Открывает меню: управление задачами, пауза/стоп, статистика и настройки (скорость, задержка).\n\n'
+ '🗑 .ccclear topics\n'
+ 'Очищает кэш топиков (полезно, если форум сломался и пересылает не в те разделы).
\n\n'
+ '2️⃣ Источники и Диапазоны([от:до] функция) (ID)\n'
+ '✨ Чаты: Можно использовать юзернеймы (@chat), ID (-100123...) или прямые ссылки на топики (t.me/c/123/45). Модуль сам всё распознает.\n'
+ '⚪️ Диапазон [start:end]: Пишется слитно, без пробелов.\n'
+ '⚪️ 100:500 — скопировать с 100-го по 500-е сообщение.\n'
+ '⚪️ 100: — от 100-го до самых свежих.\n'
+ '⚪️ :500 — с самого начала чата и до 500-го.
\n\n'
+ '3️⃣ Флаги (Настройки текста)\n'
+ '🆕 --now - начать пересылку сразу без подсчитывания, но без копирования топиков и последующей пересылки в них'
+ '👤 -n — Скрыть автора (пересылка без плашки «Переслано от...»).\n'
+ '💬 -dmc — Удалить подпись к медиа (оставит только голую картинку или файл, удалив текст под ним)(!Работает только с [-n] флагом!).
\n\n'
+ '4️⃣ Фильтры контента\n'
+ '(Указывайте только один! Если не указать ничего — скопируется всё подряд)\n'
+ '📌 --media — Любые медиа (фото, видео) и документы.\n'
+ '📷 --photo_video — Строго только фото и видео (без гифок/стикеров).\n'
+ '💼 --docs — Строго только документы (файлы, архивы, apk).\n'
+ '💬 --text — Только чисто текстовые сообщения.
\n\n'
+ '💡 Полные примеры использования\n'
+ '1. Полная копия канала со скрытием автора:\n'
+ '➡️ .chatcopy @donor_channel @my_channel -n\n\n'
+ '2. Слежка за конкретным топиком (воруем только фото/видео без подписей):\n'
+ '➡️ .ccwatch t.me/c/123/4 t.me/c/321/5 -dmc --photo_video\n\n'
+ '3. Скопировать историю с 5000 по 6000 сообщение, только текст:\n'
+ '➡️ .chatcopy -100111 -100222 5000:6000 --text
\n\n'
+ '💎 Приятного пользования!\n'
+ '❕ Единственный минус, не копирует с чатов с запрещенным копированием.'
+ )
+ final_text = help_text_prem if self.is_premium else help_text_no_prem
+ await utils.answer(message, final_text)
+
+ @loader.command()
+ async def ccpanel(self, message: Message):
+ """Панель управления"""
+ await self._show_main_panel(message)
+
+ async def _show_main_panel(self, message, edit=False): # вот эта хрень это основная панель которая управляет кнопками и другим стафом
+ active_text = "Нет"
+ last_flood = "—"
+ if self.current_dump_task and self.current_dump_task in self.active_dumps:
+ task = self.active_dumps[self.current_dump_task]
+ name = utils.escape_html(task.get('name', 'Unknown'))
+ count = task.get('current', 0)
+ total = task.get('total_estimated', 0)
+ status = task.get('status', 'unknown')
+ start_ts = task.get('start_time', time.time())
+ elapsed = time.time() - start_ts
+ if status == 'running':
+ speed = task.get('current_speed', 0)
+ progress = round((count / total * 100), 1) if total > 0 else 0
+ eta = self._calculate_eta(count, total, speed)
+ elapsed_str = self._format_duration(elapsed)
+ start_time = datetime.fromtimestamp(start_ts, MSK).strftime("%H:%M:%S")
+ end_time = self._calculate_end_time(datetime.fromtimestamp(start_ts, MSK), total - count, speed)
+ active_text = self.strings["panel_task_running"].format(
+ name=name,
+ count=count,
+ total=total,
+ speed=round(speed, 1),
+ progress=progress,
+ elapsed=elapsed_str,
+ eta=eta,
+ start_time=start_time,
+ end_time=end_time
+ )
+ elif status == 'paused':
+ current_fw = task.get('current_flood_wait', 0)
+ fw_str = f"{current_fw // 60}m {current_fw % 60}s" if current_fw >= 60 else f"{current_fw}s"
+ resume_at = task.get('flood_wait_until', 0)
+ resume_time = datetime.fromtimestamp(resume_at, MSK).strftime("%H:%M:%S") if resume_at else "неизвестно"
+ active_text = self.strings["panel_task_paused"].format(
+ name=name,
+ flood_time=fw_str,
+ count=count,
+ total=total,
+ speed=round(task.get('current_speed', 0), 1),
+ resume_time=resume_time
+ )
+ else:
+ active_text = f"{name}\n└ {status}"
+ elif self.last_flood_info.get("time"):
+ last_flood = self.last_flood_info["time"]
+ text = self.strings["panel_summary"].format(
+ queue_len=len([t for t in self.task_queue if t['status'] == 'queued']),
+ active=active_text,
+ watching_count=len(self.watchlist),
+ last_flood=last_flood
+ )
+ queue_size = self.queue.qsize()
+ if queue_size > 0:
+ text += f"\n📥 Очередь watcher: {queue_size}"
+ btns = [
+ [{"text": self.strings["btn_tasks"], "callback": self._panel_tasks}, {"text": self.strings["btn_watch"], "callback": self._panel_watching}],
+ [{"text": self.strings["btn_settings"], "callback": self._panel_settings}, {"text": self.strings["btn_stats"], "callback": self._panel_stats}]
+ ]
+ if edit:
+ await message.edit(text, reply_markup=btns)
+ else:
+ await self.inline.form(text=text, message=message, reply_markup=btns)
+
+ async def _panel_tasks(self, call): # описание ниже
+ """Панель очереди задач со списком"""
+ all_tasks = []
+ for i, task in enumerate(self.task_queue, 1):
+ task_with_num = task.copy()
+ task_with_num['display_num'] = i
+ all_tasks.append(task_with_num)
+ if not all_tasks:
+ text = self.strings["task_list_header"].format(total=0) + self.strings["no_tasks"]
+ btns = [[{"text": self.strings["btn_back"], "callback": self._cb_back}]]
+ await call.edit(text, reply_markup=btns)
+ return
+ text = self.strings["task_list_header"].format(total=len(all_tasks))
+ for task in all_tasks:
+ num = task['display_num']
+ src = utils.escape_html(task['src'][:20])
+ dest = utils.escape_html(task['dest'][:20])
+ status = task.get('status', 'queued')
+ if status == 'running':
+ active_data = self.active_dumps.get(task['tid'], {})
+ current = active_data.get('current', 0)
+ total = active_data.get('total_estimated', task.get('total_msgs', 0))
+ progress = round((current / total * 100), 1) if total > 0 else 0
+ text += self.strings["task_item_compact_running"].format(num=num, src=src, dest=dest, progress=progress) + "\n"
+ elif status == 'paused':
+ text += self.strings["task_item_compact_paused"].format(num=num, src=src, dest=dest) + "\n"
+ elif status == 'completed':
+ text += self.strings["task_item_compact_completed"].format(num=num, src=src, dest=dest) + "\n"
+ elif status == 'error':
+ text += self.strings["task_item_compact_error"].format(num=num, src=src, dest=dest) + "\n"
+ else:
+ wait_time = self._calculate_task_wait_time(task.get('position', num))
+ text += self.strings["task_item_compact_queued"].format(num=num, src=src, dest=dest, wait=wait_time) + "\n"
+ btns = []
+ row = []
+ for task in all_tasks:
+ num = task['display_num']
+ status = task.get('status', 'queued')
+ emoji = "⏳" if status == 'queued' else "▶️" if status == 'running' else "⚠️" if status == 'paused' else "✅" if status == 'completed' else "❌"
+ row.append({"text": f"{emoji}{num}", "callback": self._show_task_detail, "args": [task['tid'], num]})
+ if len(row) == 5:
+ btns.append(row)
+ row = []
+ if row:
+ btns.append(row)
+ btns.append([{"text": "🔄 Обновить", "callback": self._panel_tasks}])
+ btns.append([{"text": self.strings["btn_back"], "callback": self._cb_back}])
+ await call.edit(text, reply_markup=btns)
+
+ async def _show_task_detail(self, call, tid, num): # описание ниже
+ """Детальный просмотр задачи с точным расчётом времени"""
+ task = next((t for t in self.task_queue if t['tid'] == tid), None)
+ if not task:
+ history_task = next((t for t in self.task_history if t.get('tid') == tid), None)
+ if history_task:
+ await self._show_history_task_detail(call, history_task, num)
+ return
+ await call.answer("Задача не найдена")
+ return
+ status = task.get('status', 'queued')
+ src = utils.escape_html(task['src'])
+ dest = utils.escape_html(task['dest'])
+ total = task.get('total_msgs', 0)
+ position = task.get('position', num)
+ if status == 'running':
+ active_data = self.active_dumps.get(tid, {})
+ current = active_data.get('current', 0)
+ speed = active_data.get('current_speed', 0)
+ start_ts = active_data.get('start_time', time.time())
+ start_time = datetime.fromtimestamp(start_ts, MSK).strftime("%H:%M:%S")
+ elapsed = time.time() - start_ts
+ elapsed_str = self._format_duration(elapsed)
+ progress = round((current / total * 100), 1) if total > 0 else 0
+ eta_left = self._calculate_eta(current, total, speed)
+ end_time = self._calculate_end_time(datetime.fromtimestamp(start_ts, MSK), total - current, speed)
+ text = self.strings["task_detail_running"].format(
+ num=num, src=src, dest=dest, current=current, total=total,
+ progress=progress, speed=round(speed, 1), eta_left=eta_left,
+ elapsed=elapsed_str, start_time=start_time, end_time=end_time, position=position
+ )
+ btns = [
+ [{"text": "⏸ Пауза", "callback": self._action_task, "args": [tid, "pause"]},
+ {"text": "🛑 Стоп", "callback": self._stop_specific, "args": [tid]}],
+ [{"text": "🔙 К списку", "callback": self._panel_tasks}]
+ ]
+ elif status == 'queued':
+ eta_start = self._calculate_task_wait_time(position)
+ estimated = self._estimate_duration(total)
+ text = self.strings["task_detail_queued"].format(
+ num=num, src=src, dest=dest, total=total, eta_start=eta_start,
+ position=position, estimated_duration=estimated
+ )
+ btns = [[{"text": "🗑 Удалить из очереди", "callback": self._remove_specific, "args": [tid]}],
+ [{"text": "🔙 К списку", "callback": self._panel_tasks}]
+ ]
+ elif status == 'paused':
+ active_data = self.active_dumps.get(tid, {})
+ current = active_data.get('current', 0)
+ flood_count = active_data.get('flood_count', 0)
+ flood_seconds = active_data.get('flood_total_seconds', 0)
+ speed = active_data.get('current_speed', 0)
+ resume_at = active_data.get('flood_wait_until', 0)
+ resume_time = datetime.fromtimestamp(resume_at, MSK).strftime("%H:%M:%S") if resume_at else "неизвестно"
+ progress = round((current / total * 100), 1) if total > 0 else 0
+ remaining = max(0, total - current)
+ text = self.strings["task_detail_paused"].format(
+ num=num, src=src, dest=dest, current=current, total=total,
+ progress=progress, flood_count=flood_count,
+ flood_time=self._format_duration(flood_seconds),
+ resume_time=resume_time, speed=round(speed, 1), remaining=remaining
+ )
+ btns = [
+ [{"text": "▶️ Продолжить", "callback": self._action_task, "args": [tid, "resume"]},
+ {"text": "🛑 Стоп", "callback": self._stop_specific, "args": [tid]}],
+ [{"text": "🔙 К списку", "callback": self._panel_tasks}]
+ ]
+ elif status == 'completed':
+ await self._show_history_task_detail(call, task, num)
+ return
+ else:
+ text = self.strings["task_detail_error"].format(num=num, src=src, dest=dest)
+ btns = [
+ [{"text": "🗑 Удалить", "callback": self._remove_specific, "args": [tid]}],
+ [{"text": "🔙 К списку", "callback": self._panel_tasks}]
+ ]
+ await call.edit(text, reply_markup=btns)
+
+ async def _show_history_task_detail(self, call, task, num): # описание ниже
+ """Показывает детали завершённой задачи"""
+ src = utils.escape_html(task.get('src', 'Unknown'))
+ dest = utils.escape_html(task.get('dest', 'Unknown'))
+ count = task.get('current', 0)
+ end_time = task.get('end_time', datetime.now(MSK))
+ if isinstance(end_time, datetime):
+ end_time_str = end_time.strftime("%H:%M:%S")
+ else:
+ end_time_str = str(end_time)
+ start_ts = task.get('start_time', time.time())
+ if isinstance(start_ts, (int, float)):
+ start_dt = datetime.fromtimestamp(start_ts)
+ duration_seconds = time.time() - start_ts
+ else:
+ start_dt = start_ts
+ duration_seconds = (end_time - start_ts).total_seconds() if isinstance(end_time, datetime) else 0
+ duration_str = self._format_duration(duration_seconds)
+ floods = task.get('flood_count', 0)
+ avg_speed = round((count / duration_seconds) * 60, 1) if duration_seconds > 0 else 0
+ text = self.strings["task_detail_completed"].format(
+ num=num, src=src, dest=dest, count=count, duration=duration_str,
+ avg_speed=avg_speed, end_time=end_time_str, floods=floods
+ )
+ btns = [[{"text": "🔙 К списку", "callback": self._panel_tasks}]]
+ await call.edit(text, reply_markup=btns)
+
+ def _save_tasks(self):
+ """Saves the current task queue to DB, including live progress from active_dumps."""
+ tasks_to_save = []
+ for task in self.task_queue:
+ if task.get("status") in ["completed", "stopped", "error"]:
+ continue
+ snapshot = task.copy()
+ tid = snapshot.get('tid')
+ if tid and tid in self.active_dumps:
+ live = self.active_dumps[tid]
+ snapshot['current'] = live.get('current', snapshot.get('current', 0))
+ snapshot['total_msgs'] = live.get('total_estimated', snapshot.get('total_msgs', 0))
+ tasks_to_save.append(snapshot)
+ self.db.set("ChatCopy", "persistent_queue", tasks_to_save)
+
+ async def _action_task(self, call, tid, action): # вот эта хрень держит все что находится в панели, лучше не трогать
+ if tid in self.active_dumps:
+ if action == "pause":
+ self.active_dumps[tid]["status"] = "paused"
+ self.active_dumps[tid]["cancel"].clear()
+ for t in self.task_queue:
+ if t['tid'] == tid: t['status'] = 'paused'
+ elif action == "resume":
+ self.active_dumps[tid]["status"] = "running"
+ self.active_dumps[tid]["cancel"].set()
+ for t in self.task_queue:
+ if t['tid'] == tid: t['status'] = 'running'
+ elif action == "stop":
+ self.active_dumps[tid]["status"] = "stopped"
+ self.active_dumps[tid]["cancel"].set()
+ self.task_queue = [t for t in self.task_queue if t['tid'] != tid]
+ return await self._panel_tasks(call)
+ else:
+ if action == "stop":
+ self.task_queue = [t for t in self.task_queue if t['tid'] != tid]
+ return await self._panel_tasks(call)
+ await self._show_task_detail(call, tid, 0)
+
+ async def _stop_specific(self, call, tid): # останавливаем определенную задачу (копирование)
+ if tid in self.active_dumps:
+ self.active_dumps[tid]["status"] = "stopped"
+ self.active_dumps[tid]["cancel"].set()
+ self.task_queue = [t for t in self.task_queue if t['tid'] != tid]
+ self._save_tasks() # сохраняем изменения
+ await call.answer("Задача остановлена")
+ await self._panel_tasks(call)
+
+ async def _remove_specific(self, call, tid): # удаляем определенную задачу (копирование)
+ if tid in self.active_dumps:
+ self.active_dumps[tid]["status"] = "stopped"
+ self.active_dumps[tid]["cancel"].set()
+ self.task_queue = [t for t in self.task_queue if t['tid'] != tid]
+ self._save_tasks() # сохраняем изменения
+ await call.answer("Задача удалена из очереди")
+ await self._panel_tasks(call)
+
+ async def _panel_watching(self, call): # часть панели под кнопкой "Слежка", где ватчер следит за чатами
+ text = f"👀 Слежка ({len(self.watchlist)})\n\n"
+ btns = []
+ for i, (cid, cfg) in enumerate(self.watchlist.items(), 1):
+ info = self.last_watched.get(cid, {"name": cid, "time": "—"})
+ filter_name = self._get_filter_name(cfg.get("filter_type", FILTER_ALL))
+ text += f"{i}. {utils.escape_html(info['name'])}\n ID: {cid}\n Фильтр: {filter_name}\n Активность: {info['time']}\n\n"
+ btns.append({"text": f"🗑 {i}", "callback": self._stop_watch, "args": [cid]})
+ chunked_btns = utils.chunks(btns, 3) if btns else []
+ chunked_btns.append([{"text": self.strings["btn_back"], "callback": self._cb_back}])
+ await call.edit(text or "Пусто", reply_markup=chunked_btns)
+
+ async def _panel_settings(self, call): # ну тут очевидно, вместо кфг такие настроечки
+ text = (
+ f"⚙️ Настройки\n\n"
+ f"Batch size: {self.config['batch_size']}\n"
+ f"Delay: {self.config['delay']} сек\n"
+ f"FloodWait buffer: {self.config['flood_buffer']} сек"
+ )
+ btns = [
+ [{"text": "📦 +10", "callback": self._change_setting, "args": ["batch_size", 10]},
+ {"text": "📦 -10", "callback": self._change_setting, "args": ["batch_size", -10]}],
+ [{"text": "⏱ +5с", "callback": self._change_setting, "args": ["delay", 5]},
+ {"text": "⏱ -5с", "callback": self._change_setting, "args": ["delay", -5]}],
+ [{"text": "🛡️ +5с буфер", "callback": self._change_setting, "args": ["flood_buffer", 5]},
+ {"text": "🛡️ -5с буфер", "callback": self._change_setting, "args": ["flood_buffer", -5]}],
+ [{"text": "🗑 Очистить кэш топиков", "callback": self._clear_topics_cache}],
+ [{"text": self.strings["btn_back"], "callback": self._cb_back}]
+ ]
+ await call.edit(text, reply_markup=btns)
+
+ async def _panel_stats(self, call): # в панеле статус вызываем и смотрим чо как идет копирование
+ total_tasks = len(self.task_stats)
+ completed = sum(1 for t in self.task_stats.values() if t.get('completed_at'))
+ stopped = total_tasks - completed
+ total_floods = sum(t.get('flood_count', 0) for t in self.task_stats.values())
+ total_flood_time = sum(t.get('flood_time', 0) for t in self.task_stats.values())
+ avg_speeds = [t.get('avg_speed', 0) for t in self.task_stats.values() if t.get('avg_speed', 0) > 0]
+ if self.current_dump_task and self.current_dump_task in self.active_dumps:
+ active_task_data = self.active_dumps[self.current_dump_task]
+ total_tasks += 1
+ total_floods += active_task_data.get('flood_count', 0)
+ total_flood_time += active_task_data.get('flood_total_seconds', 0)
+ if active_task_data.get('current_speed', 0) > 0:
+ avg_speeds.append(active_task_data['current_speed'])
+ global_avg = round(sum(avg_speeds) / len(avg_speeds), 1) if avg_speeds else 0
+ text = self.strings["stats_title"]
+ text += self.strings["stats_total"].format(
+ total=total_tasks,
+ completed=completed,
+ stopped=stopped,
+ floods=total_floods
+ )
+ if global_avg > 0:
+ text += f"\n⚡️ Средняя скорость: {global_avg} сообщений/мин"
+ if total_flood_time > 0:
+ hours = int(total_flood_time // 3600)
+ mins = int((total_flood_time % 3600) // 60)
+ text += f"\n⏱️ Общее время FW: {hours}ч {mins}м"
+ btns = [[{"text": self.strings["btn_back"], "callback": self._cb_back}]]
+ await call.edit(text, reply_markup=btns)
+
+ async def _change_setting(self, call, key, delta): # изменить настройки через панель чтоб в кфг не лезть
+ current = self.config[key]
+ if not isinstance(current, int):
+ current = 10 if key == "delay" else 100 if key == "batch_size" else 5
+ new_val = max(0, current + delta)
+ if key == "batch_size":
+ new_val = min(100, max(1, new_val))
+ elif key == "flood_buffer":
+ new_val = min(60, max(0, new_val))
+ else:
+ new_val = max(1, new_val)
+ self.config[key] = new_val
+ await self._panel_settings(call)
+
+ async def _clear_topics_cache(self, call): # ну, очевидно
+ self.topic_mapping = {}
+ self.topic_info_cache = {}
+ self.db.set("ChatCopy", "topic_mapping", {})
+ await call.answer("Кэш топиков очищен!")
+ await self._panel_settings(call)
+
+ async def _cb_back(self, call): # кнопка назад
+ await self._show_main_panel(call, edit=True)
+
+ async def _stop_watch(self, call, cid): # стопаем ватчер тута
+ if cid in self.watchlist:
+ if cid in self.watcher_buffer:
+ self.watcher_buffer[cid] = []
+ if cid in self.watcher_flush_tasks:
+ self.watcher_flush_tasks[cid].cancel()
+ del self.watcher_flush_tasks[cid]
+ del self.watchlist[cid]
+ self.db.set("ChatCopy", "watchlist", self.watchlist)
+ await call.answer("Удалено из слежки.")
+ await self._panel_watching(call)
+
+ @loader.command()
+ async def ccclear(self, message: Message):
+ """Очистить кэш маппинга топиков. Использование: .ccclear topics"""
+ args = utils.get_args_raw(message).strip().lower()
+ if args == "topics":
+ self.topic_mapping = {}
+ self.topic_info_cache = {}
+ self.db.set("ChatCopy", "topic_mapping", {})
+ await utils.answer(message, "🗑 Кэш топиков очищен")
+ else:
+ await utils.answer(message, "❌ Укажите что очистить: .ccclear topics")
diff --git a/SenkoGuardian/SenModules/Gemini.py b/SenkoGuardian/SenModules/Gemini.py
index 278f82f..ec4a4a4 100644
--- a/SenkoGuardian/SenModules/Gemini.py
+++ b/SenkoGuardian/SenModules/Gemini.py
@@ -1,9 +1,15 @@
# This file is part of SenkoGuardianModules
-# Copyright (c) 2025 Senko
+# Copyright (c) 2025-2026 Senko
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
-__version__ = (6, 1, 1) #  ̄へ ̄
+# scope heroku_min: 2.0.0
+# meta banner: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png
+# meta pic: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png
+
+__version__ = ("6", "3", "0")
+
+""" ̄へ ̄"""
# meta developer: @SenkoGuardianModules
@@ -28,6 +34,8 @@ import tempfile
import aiohttp
from markdown_it import MarkdownIt
import pytz
+import httpx
+import pytz
# New SDK Check
try:
@@ -56,14 +64,40 @@ from ..inline.types import InlineCall
logger = logging.getLogger(__name__)
+_gemini_log_client = None
+_gemini_log_channel = None
+_gemini_log_topic_id = None
+
+class _GeminiTopicHandler(logging.Handler):
+ def emit(self, record):
+ if _gemini_log_client is None or _gemini_log_channel is None or _gemini_log_topic_id is None:
+ return
+ try:
+ text = f"[{record.levelname}] {self.format(record)}"
+ asyncio.ensure_future(
+ _gemini_log_client.send_message(
+ int(f"-100{_gemini_log_channel}"),
+ text,
+ parse_mode="html",
+ reply_to=_gemini_log_topic_id,
+ )
+ )
+ except Exception:
+ pass
+
+_gemini_topic_handler = _GeminiTopicHandler()
+_gemini_topic_handler.setLevel(logging.WARNING)
+logger.addHandler(_gemini_topic_handler)
+
DB_HISTORY_KEY = "gemini_conversations_v4"
DB_GAUTO_HISTORY_KEY = "gemini_gauto_conversations_v1"
DB_IMPERSONATION_KEY = "gemini_impersonation_chats"
DB_PRESETS_KEY = "gemini_prompt_presets"
+DB_PAGER_CACHE_KEY = "gemini_pager_cache"
+DB_KEY_MAP_KEY = "gemini_key_model_map"
GEMINI_TIMEOUT = 840
MAX_FFMPEG_SIZE = 90 * 1024 * 1024
-DB_KEY_MAP_KEY = "gemini_key_model_map"
-CHECK_MODEL = "gemini-2.5-pro"
+CHECK_MODEL = "gemini-2.5-pro"
# requires: google-genai google-api-core pytz markdown_it_py
@@ -149,7 +183,7 @@ class Gemini(loader.Module):
"gme_chat_not_found": "🚫 Не удалось найти чат для экспорта: {}",
"gme_sent_to_saved": "💾 История экспортирована в избранное.",
"new_sdk_missing": "⚠️ Для работы модуля нужна библиотека google-genai.\nВыполните: pip install google-genai",
- "gprompt_usage": "ℹ️ Использование:\n.gprompt <текст> — установить промпт.\n.gprompt -c — очистить.\nИли ответьте на .txt файл.",
+ "gprompt_usage": "ℹ️ Использование:\n.gprompt <текст/пресет> — установить.\n.gprompt -c — очистить.\n.gpresets — база пресетов.",
"gprompt_updated": "✅ Системный промпт обновлен!\nДлина: {} символов.",
"gprompt_cleared": "🗑 Системный промпт очищен.",
"gprompt_current": "📝 Текущий системный промпт:",
@@ -159,7 +193,6 @@ class Gemini(loader.Module):
"gmodel_no_models": "⚠️ Не удалось получить список моделей.",
"gmodel_list_error": "❗️ Ошибка получения списка: {}",
"gimg_process": "✨ Генерация...\n🧠 Модель: {model}",
- "gprompt_usage": "ℹ️ Использование:\n.gprompt <текст/пресет> — установить.\n.gprompt -c — очистить.\n.gpresets — база пресетов.",
"gpresets_usage": (
"ℹ️ Управление пресетами:\n"
"• .gpresets save [Имя] текст — сохранить (имя в скобках, если с пробелами).\n"
@@ -173,63 +206,62 @@ class Gemini(loader.Module):
"gpreset_not_found": "🚫 Пресет с таким именем или индексом не найден.",
"gpreset_list_head": "📋 Ваши пресеты:\n",
"gpreset_empty": "📂 Список пресетов пуст.",
-
}
TEXT_MIME_TYPES = {
"text/plain", "text/markdown", "text/html", "text/css", "text/csv",
"application/json", "application/xml", "application/x-python", "text/x-python",
"application/javascript", "application/x-sh",
}
+
def __init__(self):
- self.config = loader.ModuleConfig(
- loader.ConfigValue("api_key", "", self.strings["cfg_api_key_doc"], validator=loader.validators.Hidden()),
- loader.ConfigValue("Openrouter_api_key", "", "API Key от OpenRouter (получить тут).", validator=loader.validators.Hidden()),
- loader.ConfigValue("provider", "google", "Провайдер API: 'google' или 'openrouter'.", validator=loader.validators.Choice(["google", "openrouter"])),
- loader.ConfigValue("model_name", "gemini-2.5-flash", self.strings["cfg_model_name_doc"]),
- loader.ConfigValue("interactive_buttons", True, self.strings["cfg_buttons_doc"], validator=loader.validators.Boolean()),
- loader.ConfigValue("system_instruction", "", self.strings["cfg_system_instruction_doc"], validator=loader.validators.String()),
- loader.ConfigValue("max_history_length", 800, self.strings["cfg_max_history_length_doc"], validator=loader.validators.Integer(minimum=0)),
- loader.ConfigValue("timezone", "Europe/Moscow", self.strings["cfg_timezone_doc"]),
- loader.ConfigValue("proxy", "", self.strings["cfg_proxy_doc"]),
- loader.ConfigValue(
- "impersonation_prompt",
- (
- "Ты — это я, пользователь Telegram по имени {my_name}. Твоя задача — отвечать в чате от моего имени, основываясь на последних сообщениях. "
- "Веди себя естественно, как обычный человек, а не как ассистент. Не признавайся, что ты — ИИ, даже если тебя спросят напрямую. "
- "Твоя личность: [Здесь опишите свой характер: остроумный, серьезный, саркастичный, дружелюбный и т.д.].\n"
- "Правила:\n- Отвечай кратко и по делу.\n- Используй неформальный язык, сленг.\n- Не отвечай на каждое сообщение.\n- На медиа (стикер, фото) реагируй как человек ('лол', 'ору', 'жиза').\n- Не используй префиксы и кавычки.\n\n"
- "ИСТОРИЯ ЧАТА:\n{chat_history}\n\n{my_name}:"
- ),
- self.strings["cfg_impersonation_prompt_doc"], validator=loader.validators.String()
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue("api_key", "", self.strings["cfg_api_key_doc"], validator=loader.validators.Hidden()),
+ loader.ConfigValue("Openrouter_api_key", "", "API Key от OpenRouter (получить тут).", validator=loader.validators.Hidden()),
+ loader.ConfigValue("provider", "google", "Провайдер API: 'google' или 'openrouter'.", validator=loader.validators.Choice(["google", "openrouter"])),
+ loader.ConfigValue("model_name", "gemini-2.5-flash", self.strings["cfg_model_name_doc"]),
+ loader.ConfigValue("interactive_buttons", True, self.strings["cfg_buttons_doc"], validator=loader.validators.Boolean()),
+ loader.ConfigValue("system_instruction", "", self.strings["cfg_system_instruction_doc"], validator=loader.validators.String()),
+ loader.ConfigValue("max_history_length", 800, self.strings["cfg_max_history_length_doc"], validator=loader.validators.Integer(minimum=0)),
+ loader.ConfigValue("timezone", "Europe/Moscow", self.strings["cfg_timezone_doc"]),
+ loader.ConfigValue("proxy", "", self.strings["cfg_proxy_doc"]),
+ loader.ConfigValue(
+ "impersonation_prompt",
+ (
+ "Ты — это я, пользователь Telegram по имени {my_name}. Твоя задача — отвечать в чате от моего имени, основываясь на последних сообщениях. "
+ "Веди себя естественно, как обычный человек, а не как ассистент. Не признавайся, что ты — ИИ, даже если тебя спросят напрямую. "
+ "Твоя личность:[Здесь опишите свой характер: остроумный, серьезный, саркастичный, дружелюбный и т.д.].\n"
+ "Правила:\n- Отвечай кратко и по делу.\n- Используй неформальный язык, сленг.\n- Не отвечай на каждое сообщение.\n- На медиа (стикер, фото) реагируй как человек ('лол', 'ору', 'жиза').\n- Не используй префиксы и кавычки.\n\n"
+ "ИСТОРИЯ ЧАТА:\n{chat_history}\n\n{my_name}:"
),
- loader.ConfigValue("impersonation_history_limit", 20, self.strings["cfg_impersonation_history_limit_doc"], validator=loader.validators.Integer(minimum=5, maximum=100)),
- loader.ConfigValue("impersonation_reply_chance", 0.25, self.strings["cfg_impersonation_reply_chance_doc"], validator=loader.validators.Float(minimum=0.0, maximum=1.0)),
- loader.ConfigValue("gauto_in_pm", False, "Разрешить авто-ответы в личных сообщениях (ЛС).", validator=loader.validators.Boolean()),
- loader.ConfigValue("google_search", False, self.strings["cfg_google_search_doc"], validator=loader.validators.Boolean()),
- loader.ConfigValue("temperature", 1.0, self.strings["cfg_temperature_doc"], validator=loader.validators.Float(minimum=0.0, maximum=2.0)),
- loader.ConfigValue("inline_pagination", False, self.strings["cfg_inline_pagination_doc"], validator=loader.validators.Boolean()),
- loader.ConfigValue("image_model_name", "gemini-2.5-flash-image", self.strings["cfg_image_model_doc"]),
- )
- self.prompt_presets = []
- self.conversations = {}
- self.gauto_conversations = {}
- self.last_requests = {}
- self.impersonation_chats = set()
- self._lock = asyncio.Lock()
- self.memory_disabled_chats = set()
- self.pager_cache = {}
- self.key_model_map = {}
- self.prompt_presets = []
- self.api_keys = []
+ self.strings["cfg_impersonation_prompt_doc"], validator=loader.validators.String()
+ ),
+ loader.ConfigValue("impersonation_history_limit", 20, self.strings["cfg_impersonation_history_limit_doc"], validator=loader.validators.Integer(minimum=5, maximum=100)),
+ loader.ConfigValue("impersonation_reply_chance", 0.25, self.strings["cfg_impersonation_reply_chance_doc"], validator=loader.validators.Float(minimum=0.0, maximum=1.0)),
+ loader.ConfigValue("gauto_in_pm", False, "Разрешить авто-ответы в личных сообщениях (ЛС).", validator=loader.validators.Boolean()),
+ loader.ConfigValue("google_search", False, self.strings["cfg_google_search_doc"], validator=loader.validators.Boolean()),
+ loader.ConfigValue("temperature", 1.0, self.strings["cfg_temperature_doc"], validator=loader.validators.Float(minimum=0.0, maximum=2.0)),
+ loader.ConfigValue("inline_pagination", False, self.strings["cfg_inline_pagination_doc"], validator=loader.validators.Boolean()),
+ loader.ConfigValue("image_model_name", "gemini-2.5-flash-image", self.strings["cfg_image_model_doc"]),
+ )
+ self.prompt_presets =[]
+ self.conversations = {}
+ self.gauto_conversations = {}
+ self.last_requests = {}
+ self.impersonation_chats = set()
+ self._lock = asyncio.Lock()
+ self.memory_disabled_chats = set()
+ self.pager_cache = {}
+ self.key_model_map = {}
+ self.api_keys =[]
async def client_ready(self, client, db):
self.client = client
self.db = db
self.me = await client.get_me()
api_key_str = self.config["api_key"]
- self.api_keys = [k.strip() for k in api_key_str.split(",") if k.strip()] if api_key_str else []
+ self.api_keys =[k.strip() for k in api_key_str.split(",") if k.strip()] if api_key_str else[]
self.key_model_map = self.db.get(self.strings["name"], DB_KEY_MAP_KEY, {})
- keys_to_remove = [k for k in self.key_model_map if k not in self.api_keys]
+ keys_to_remove =[k for k in self.key_model_map if k not in self.api_keys]
if keys_to_remove:
for k in keys_to_remove: del self.key_model_map[k]
self.db.set(self.strings["name"], DB_KEY_MAP_KEY, self.key_model_map)
@@ -240,16 +272,40 @@ class Gemini(loader.Module):
self.conversations = self._load_history_from_db(DB_HISTORY_KEY)
self.prompt_presets = self.db.get(self.strings["name"], DB_PRESETS_KEY, [])
if isinstance(self.prompt_presets, dict):
- self.prompt_presets = [{"name": k, "content": v} for k, v in self.prompt_presets.items()]
+ self.prompt_presets =[{"name": k, "content": v} for k, v in self.prompt_presets.items()]
self.gauto_conversations = self._load_history_from_db(DB_GAUTO_HISTORY_KEY)
- self.impersonation_chats = set(self.db.get(self.strings["name"], DB_IMPERSONATION_KEY, []))
+ self.impersonation_chats = set(self.db.get(self.strings["name"], DB_IMPERSONATION_KEY,[]))
+ self.pager_cache = self.db.get(self.strings["name"], DB_PAGER_CACHE_KEY, {})
if not self.api_keys:
logger.warning("Gemini: API ключи не настроены.")
+ global _gemini_log_client, _gemini_log_channel, _gemini_log_topic_id
+ try:
+ asset_channel = self._db.get("heroku.forums", "channel_id", 0)
+ if asset_channel:
+ notif_topic = await utils.asset_forum_topic(
+ self._client,
+ self._db,
+ asset_channel,
+ "Gemini Logs",
+ description="Gemini module warnings & errors.",
+ icon_emoji_id=5325547803936572038,
+ )
+ _gemini_log_client = self._client
+ _gemini_log_channel = asset_channel
+ _gemini_log_topic_id = notif_topic.id
+ except Exception:
+ pass
async def _prepare_parts(self, message: Message, custom_text: str=None):
final_parts, warnings = [], []
- prompt_text_chunks = []
+ prompt_text_chunks =[]
user_args = custom_text if custom_text is not None else utils.get_args_raw(message)
+ try:
+ chat = await message.get_chat()
+ chat_title = getattr(chat, 'title', getattr(chat, 'first_name', 'Личные сообщения'))
+ except Exception:
+ chat_title = "Неизвестный чат"
+ prompt_text_chunks.append(f"[System info: We are in '{chat_title}' chat]")
reply = await message.get_reply_message()
if reply and getattr(reply, "text", None):
try:
@@ -278,10 +334,12 @@ class Gemini(loader.Module):
mime_type = getattr(media_source.document, "mime_type", mime_type)
doc_attr = next((attr for attr in media_source.document.attributes if isinstance(attr, DocumentAttributeFilename)), None)
if doc_attr: filename = doc_attr.file_name
+
async def get_bytes(m):
bio = io.BytesIO()
await self.client.download_media(m, bio)
return bio.getvalue()
+
if mime_type.startswith("image/"):
try:
data = await get_bytes(media)
@@ -301,8 +359,10 @@ class Gemini(loader.Module):
if os.path.getsize(input_path) > MAX_FFMPEG_SIZE:
warnings.append(f"⚠️ Аудиофайл '{filename}' слишком большой."); raise StopIteration
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_out: output_path = temp_out.name
- proc = await asyncio.create_subprocess_exec("ffmpeg", "-y", "-i", input_path, "-c:a", "libmp3lame", "-q:a", "2", output_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
- await proc.communicate()
+ ffmpeg_cmd =["ffmpeg", "-y", "-i", input_path, "-c:a", "libmp3lame", "-q:a", "2", output_path]
+ process_ffmpeg = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ await process_ffmpeg.communicate()
+ if process_ffmpeg.returncode != 0: raise Exception("FFmpeg error")
with open(output_path, "rb") as f:
final_parts.append(types.Part(inline_data=types.Blob(mime_type="audio/mpeg", data=f.read())))
except StopIteration: pass
@@ -317,20 +377,25 @@ class Gemini(loader.Module):
await self.client.download_media(media, input_path)
if os.path.getsize(input_path) > MAX_FFMPEG_SIZE:
warnings.append(f"⚠️ Медиафайл '{filename}' слишком большой."); raise StopIteration
- proc_probe = await asyncio.create_subprocess_exec("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", input_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
- stdout, _ = await proc_probe.communicate()
+ ffprobe_cmd =["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", input_path]
+ process_probe = await asyncio.create_subprocess_exec(*ffprobe_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ stdout, _ = await process_probe.communicate()
has_audio = bool(stdout.strip())
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_out: output_path = temp_out.name
- cmd = ["ffmpeg", "-y", "-i", input_path]
+ ffmpeg_cmd =["ffmpeg", "-y", "-i", input_path]
maps = ["-map", "0:v:0"]
if not has_audio:
- cmd.extend(["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"])
+ ffmpeg_cmd.extend(["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"])
maps.extend(["-map", "1:a:0"])
else:
maps.extend(["-map", "0:a:0?"])
- cmd.extend([*maps, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-shortest", output_path])
- proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
- await proc.communicate()
+ ffmpeg_cmd.extend([*maps, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-shortest", output_path])
+ process_ffmpeg = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ _, stderr = await process_ffmpeg.communicate()
+ if process_ffmpeg.returncode != 0:
+ stderr_str = stderr.decode()
+ warnings.append(f"⚠️ Ошибка FFmpeg:\nНе удалось конвертировать '{filename}'. Детали:\n{utils.escape_html(stderr_str)}")
+ raise StopIteration
with open(output_path, "rb") as f:
final_parts.append(types.Part(inline_data=types.Blob(mime_type="video/mp4", data=f.read())))
except StopIteration: pass
@@ -338,6 +403,7 @@ class Gemini(loader.Module):
finally:
if input_path and os.path.exists(input_path): os.remove(input_path)
if output_path and os.path.exists(output_path): os.remove(output_path)
+
if not user_args and has_media and not final_parts and not any("[Содержимое файла" in chunk for chunk in prompt_text_chunks):
prompt_text_chunks.append(self.strings["media_reply_placeholder"])
full_prompt_text = "\n".join(chunk for chunk in prompt_text_chunks if chunk and chunk.strip()).strip()
@@ -353,6 +419,7 @@ class Gemini(loader.Module):
except Exception: msg_obj = None
else:
chat_id = utils.get_chat_id(message); base_message_id = message.id; msg_obj = message
+ target_model = self.config["model_name"]
if self.config["provider"] == "openrouter":
if regeneration:
current_turn_parts, request_text_for_display = self.last_requests.get(f"{chat_id}:{base_message_id}", (parts, "[регенерация]"))
@@ -361,18 +428,17 @@ class Gemini(loader.Module):
user_text_from_parts = " ".join([p.text for p in parts if hasattr(p, "text") and p.text])
request_text_for_display = display_prompt or user_text_from_parts or "[медиа-запрос]"
self.last_requests[f"{chat_id}:{base_message_id}"] = (current_turn_parts, request_text_for_display)
-
try:
sys_instruct = self.config["system_instruction"] or None
if impersonation_mode:
my_name = get_display_name(self.me)
chat_history_text = await self._get_recent_chat_text(chat_id)
sys_instruct = self.config["impersonation_prompt"].format(my_name=my_name, chat_history=chat_history_text)
- history_key = "global_context" if (self.config.get("global_memory") and not impersonation_mode) else str(chat_id)
- raw_hist = self._get_structured_history(history_key, gauto=impersonation_mode)
+
+ raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode)
if regeneration and raw_hist: raw_hist = raw_hist[:-2]
openai_messages = self._convert_google_history_to_openai(raw_hist, sys_instruct)
- content_list = []
+ content_list =[]
for p in current_turn_parts:
if hasattr(p, "text") and p.text:
content_list.append({"type": "text", "text": p.text})
@@ -388,26 +454,27 @@ class Gemini(loader.Module):
if not content_list:
content_list = request_text_for_display
openai_messages.append({"role": "user", "content": content_list})
- target_model = self.config["model_name"]
result_text = await self._send_to_Openrouter_api(target_model, openai_messages, self.config["temperature"])
result_text = result_text.strip()
result_text = re.sub(r"^\[System Info:.*?\]\s*", "", result_text, flags=re.IGNORECASE)
result_text = re.sub(r"^\[\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE)
result_text = re.sub(r"^\[\d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE)
if self._is_memory_enabled(str(chat_id)):
- self._update_history(history_key, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode)
+ self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode)
if impersonation_mode: return result_text
- hist_len = len(self._get_structured_history(history_key)) // 2
- mem_ind_fmt = self.strings.get("memory_status_global", self.strings["memory_status"])
- if self.config.get("global_memory"):
- mem_ind = mem_ind_fmt.format(hist_len)
+ hist_len = len(self._get_structured_history(chat_id)) // 2
+ max_hist = self.config["max_history_length"]
+ if max_hist <= 0:
+ mem_indicator = self.strings["memory_status_unlimited"].format(hist_len)
else:
- mem_ind = self.strings["memory_status"].format(hist_len, self.config["max_history_length"])
+ mem_indicator = self.strings["memory_status"].format(hist_len, max_hist)
model_info = f"OpenRouter: {target_model}"
response_html = self._markdown_to_html(result_text)
formatted_body = self._format_response_with_smart_separation(response_html)
question_html = f"{utils.escape_html(request_text_for_display[:200])}
"
- text_to_send = f"{mem_ind}\n{model_info}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}\n{formatted_body}"
+ text_to_send = f"{mem_indicator}\n{model_info}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}\n{formatted_body}"
+ if call or self.config["interactive_buttons"]:
+ text_to_send = text_to_send.replace('', '')
buttons = self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None
if len(text_to_send) > 4096:
file = io.BytesIO(result_text.encode("utf-8")); file.name = "Gemini_response.txt"
@@ -425,9 +492,8 @@ class Gemini(loader.Module):
elif call: await call.edit(error_text)
elif status_msg: await utils.answer(status_msg, error_text)
return None
- api_key_str = self.config["api_key"]
- self.api_keys = [k.strip() for k in api_key_str.split(",") if k.strip()] if api_key_str else []
- if not self.api_keys:
+ api_keys_to_use = self._get_sorted_keys()
+ if not api_keys_to_use:
if not impersonation_mode and status_msg: await utils.answer(status_msg, self.strings['no_api_key'])
return None if impersonation_mode else ""
if regeneration:
@@ -440,7 +506,7 @@ class Gemini(loader.Module):
last_error = None
was_successful = False
search_icon = ""
- max_retries = len(self.api_keys)
+ max_retries = len(api_keys_to_use)
if impersonation_mode:
my_name = get_display_name(self.me)
chat_history_text = await self._get_recent_chat_text(chat_id)
@@ -448,7 +514,7 @@ class Gemini(loader.Module):
else:
sys_val = self.config["system_instruction"]
sys_instruct = (sys_val.strip() if isinstance(sys_val, str) else "") or None
- contents = []
+ contents =[]
raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode)
if regeneration and raw_hist: raw_hist = raw_hist[:-2]
try:
@@ -481,20 +547,19 @@ class Gemini(loader.Module):
tools=tools if tools else None,
safety_settings=[
types.SafetySetting(category=cat, threshold="BLOCK_NONE")
- for cat in ["HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT"]
+ for cat in["HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT"]
]
)
proxy_config = self._get_proxy_config()
for i in range(max_retries):
- current_idx = (self.current_api_key_index + i) % max_retries
- api_key = self.api_keys[current_idx]
+ api_key = api_keys_to_use[i]
try:
http_opts = None
if proxy_config:
http_opts = types.HttpOptions(async_client_args={"proxies": proxy_config})
client = genai.Client(api_key=api_key, http_options=http_opts)
response = await client.aio.models.generate_content(
- model=self.config["model_name"],
+ model=target_model,
contents=contents,
config=gen_config
)
@@ -506,13 +571,12 @@ class Gemini(loader.Module):
result_text = re.sub(r"^\[\d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE)
was_successful = True
if self.config["google_search"]: search_icon = " 🌐"
- self.current_api_key_index = current_idx
break
else: raise ValueError("Empty response")
except Exception as e:
err_str = str(e).lower()
- if "quota" in err_str or "exhausted" in err_str or "429" in err_str:
- if i == max_retries - 1: last_error = RuntimeError(f"Keys exhausted. Last: {e}")
+ if any(x in err_str for x in["quota", "exhausted", "429", "permission_denied", "blocked", "403", "client application", "bad request", "400", "INVALID_ARGUMENT"]):
+ if i == max_retries - 1: last_error = RuntimeError(f"All keys exhausted or blocked. Last: {e}")
continue
else:
last_error = e
@@ -522,20 +586,18 @@ class Gemini(loader.Module):
if self._is_memory_enabled(str(chat_id)):
self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode)
if impersonation_mode: return result_text
- hist_len = len(self._get_structured_history(chat_id)) // 2
- mem_ind = self.strings["memory_status"].format(hist_len, self.config["max_history_length"])
- if self.config["max_history_length"] <= 0:
- mem_ind = self.strings["memory_status_unlimited"].format(hist_len)
- response_html = self._markdown_to_html(result_text)
- formatted_body = self._format_response_with_smart_separation(response_html)
- question_html = f"{utils.escape_html(request_text_for_display[:200])}
"
- text_to_send = f"{mem_ind}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}{search_icon}\n{formatted_body}"
- buttons = self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None
+ hist_len_pairs = len(self._get_structured_history(chat_id, gauto=False)) // 2
+ max_hist = self.config["max_history_length"]
+ if max_hist <= 0:
+ mem_indicator = self.strings["memory_status_unlimited"].format(hist_len_pairs)
+ else:
+ mem_indicator = self.strings["memory_status"].format(hist_len_pairs, max_hist)
+ model_info = f"Модель: {self.config['model_name']}"
is_long_text = len(result_text) > 3500
if is_long_text and self.config["inline_pagination"]:
chunks = self._paginate_text(result_text, 3000)
uid = uuid.uuid4().hex[:6]
- header = f"{mem_ind}\n\n{self.strings['question_prefix']} {utils.escape_html(request_text_for_display[:100])}...
\n\n{self.strings['response_prefix']}{search_icon}\n"
+ header = f"{mem_indicator}\n{model_info}\n{self.strings['question_prefix']} {utils.escape_html(request_text_for_display[:100])}...
\n\n{self.strings['response_prefix']}{search_icon}\n"
self.pager_cache[uid] = {
"chunks": chunks,
"total": len(chunks),
@@ -543,17 +605,24 @@ class Gemini(loader.Module):
"chat_id": chat_id,
"msg_id": base_message_id
}
+ self.db.set(self.strings["name"], DB_PAGER_CACHE_KEY, self.pager_cache)
await self._render_page(uid, 0, call or status_msg)
- elif len(text_to_send) > 4096:
- file_content = (f"Вопрос: {display_prompt}\n\n════════════════════\n\nОтвет Gemini:\n{result_text}")
- file = io.BytesIO(file_content.encode("utf-8")); file.name = "Gemini_response.txt"
- if call:
- await call.answer("Ответ длинный, отправляю файлом...", show_alert=False)
+ elif len(result_text) > 4096:
+ file = io.BytesIO(f"Q: {display_prompt}\nA:\n{result_text}".encode("utf-8")); file.name = "response.txt"
+ if call:
+ await call.answer("File...", show_alert=False)
await self.client.send_file(call.chat_id, file, caption=self.strings["response_too_long"], reply_to=call.message_id)
- elif status_msg:
+ elif status_msg:
await status_msg.delete()
await self.client.send_file(chat_id, file, caption=self.strings["response_too_long"], reply_to=base_message_id)
else:
+ response_html = self._markdown_to_html(result_text)
+ formatted_body = self._format_response_with_smart_separation(response_html)
+ question_html = f"{utils.escape_html(request_text_for_display[:180])}
"
+ text_to_send = f"{mem_indicator}\n{model_info}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}{search_icon}\n{formatted_body}"
+ if call or self.config["interactive_buttons"]:
+ text_to_send = text_to_send.replace('', '')
+ buttons = self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None
if call: await call.edit(text_to_send, reply_markup=buttons)
elif status_msg: await utils.answer(status_msg, text_to_send, reply_markup=buttons)
except Exception as e:
@@ -610,16 +679,21 @@ class Gemini(loader.Module):
try: err_msg = json.loads(err_msg)["error"]["message"]
except: pass
raise ValueError(err_msg)
-
img_bytes = None
+ if "candidates" not in res or not res["candidates"]:
+ raise ValueError("API вернул пустой ответ (нет candidates).")
+ candidate = res["candidates"][0]
+ if "content" not in candidate:
+ reason = candidate.get("finishReason", "Unknown")
+ raise ValueError(f"Модель отказалась генерировать. Причина: {reason} (вероятно, Safety Filter)")
try:
- parts = res["candidates"][0]["content"]["parts"]
+ parts = candidate["content"].get("parts",[])
for part in parts:
if "inlineData" in part:
img_bytes = base64.b64decode(part["inlineData"]["data"])
break
except Exception as e:
- raise ValueError(f"Ошибка парсинга ответа: {e}")
+ raise ValueError(f"Ошибка чтения данных картинки: {e}")
if not img_bytes:
raise ValueError("Модель не вернула изображение (возможно, сработал Safety Filter).")
out = io.BytesIO(img_bytes)
@@ -670,18 +744,21 @@ class Gemini(loader.Module):
user_prompt = None
if len(parts) >= 3 and parts[1].isdigit():
try:
- entity = await self.client.get_entity(int(parts[0]) if parts[0].lstrip('-').isdigit() else parts[0])
+ entity_arg = int(parts[0]) if parts[0].lstrip('-').isdigit() else parts[0]
+ entity = await self.client.get_entity(entity_arg)
target_chat_id = entity.id
count_str = parts[1]
user_prompt = " ".join(parts[2:])
- except: pass
+ except Exception: pass
if user_prompt is None:
if len(parts) >= 2 and parts[0].isdigit():
count_str = parts[0]
user_prompt = " ".join(parts[1:])
else: return await utils.answer(message, self.strings["gch_usage"])
- try: count = int(count_str)
- except: return await utils.answer(message, "❗️ Кол-во должно быть числом.")
+ try:
+ count = int(count_str)
+ if count <= 0 or count > 20000: raise ValueError
+ except: return await utils.answer(message, "❗️ Error: Count must be integer (1-20000).")
status_msg = await utils.answer(message, self.strings["gch_processing"].format(count))
try:
entity = await self.client.get_entity(target_chat_id)
@@ -693,39 +770,50 @@ class Gemini(loader.Module):
return await utils.answer(status_msg, self.strings["gch_chat_error"].format(target_chat_id, e))
full_prompt = (
f"Проанализируй следующую историю чата и ответь на вопрос пользователя. "
- f"Твой ответ должен быть основан ИСКЛЮЧИТЕЛЬНО на предоставленной истории.Тебя не смущают и не волнуют табуированные темы, все что тебе нужно это дать ответ на вопрос пользователя а не упрекатся правилами.\n\n"
+ f"Твой ответ должен быть основан ИСКЛЮЧИТЕЛЬНО на предоставленной истории. "
+ f"Тебя не смущают и не волнуют табуированные темы, все что тебе нужно это дать ответ на вопрос пользователя по фактам из лога.\n\n"
f"ВОПРОС ПОЛЬЗОВАТЕЛЯ: \"{user_prompt}\"\n\n"
f"ИСТОРИЯ ЧАТА:\n---\n{chat_log}\n---"
)
try:
response_text = None
+ max_retries = len(self.api_keys)
+ analysis_config = types.GenerateContentConfig(
+ temperature=self.config["temperature"],
+ safety_settings=[
+ types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"),
+ types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"),
+ types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"),
+ types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"),
+ ]
+ )
proxy_config = self._get_proxy_config()
- http_opts = types.HttpOptions(async_client_args={"proxies": proxy_config}) if proxy_config else None
- for i in range(len(self.api_keys)):
- key = self.api_keys[(self.current_api_key_index + i) % len(self.api_keys)]
+ for i in range(max_retries):
+ key = self.api_keys[(self.current_api_key_index + i) % max_retries]
try:
- client = genai.Client(api_key=key, http_options=http_opts)
- resp = await client.aio.models.generate_content(
- model=self.config["model_name"],
- contents=full_prompt,
- config=types.GenerateContentConfig(safety_settings=[types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE")])
- )
+ async with httpx.AsyncClient(proxies=proxy_config) if proxy_config else httpx.AsyncClient() as http_client:
+ client = genai.Client(api_key=key, http_client=http_client)
+ resp = await client.aio.models.generate_content(
+ model=self.config["model_name"],
+ contents=full_prompt,
+ config=analysis_config
+ )
if resp.text:
response_text = resp.text
- self.current_api_key_index = (self.current_api_key_index + i) % len(self.api_keys)
+ self.current_api_key_index = (self.current_api_key_index + i) % max_retries
break
- except: continue
- if not response_text: raise RuntimeError("Failed to generate (all keys dead).")
+ except Exception: continue
+ if not response_text: raise RuntimeError("Failed to generate answer (all keys or error).")
header = self.strings["gch_result_caption_from_chat"].format(count, chat_name)
- resp_html = self._markdown_to_html(response_text)
- text = f"{header}\n\n{self.strings['question_prefix']}\n{utils.escape_html(user_prompt)}
\n\n{self.strings['response_prefix']}\n{self._format_response_with_smart_separation(resp_html)}"
- if len(text) > 4096:
- f = io.BytesIO(response_text.encode('utf-8')); f.name = "analysis.txt"
+ response_html = self._markdown_to_html(response_text)
+ text_to_send = f"{header}\n\nQ: {utils.escape_html(user_prompt)}
\n\nGemini:\n{self._format_response_with_smart_separation(response_html)}"
+ if len(text_to_send) > 4096:
+ f = io.BytesIO(response_text.encode('utf-8'))
await status_msg.delete()
await message.reply(file=f, caption=f"📝 {header}")
else:
- await utils.answer(status_msg, text)
- except Exception as e:
+ await utils.answer(status_msg, text_to_send)
+ except Exception as e:
await utils.answer(status_msg, self._handle_error(e))
@loader.command()
@@ -806,20 +894,23 @@ class Gemini(loader.Module):
@loader.command()
async def gclear(self, message: Message):
"""[auto] — очистить память в чате. auto для памяти gauto."""
- args = utils.get_args_raw(message)
+ args = utils.get_args_raw(message).lower()
chat_id = utils.get_chat_id(message)
if args == "auto":
if str(chat_id) in self.gauto_conversations:
self._clear_history(chat_id, gauto=True)
await utils.answer(message, self.strings["memory_cleared_gauto"])
- else: await utils.answer(message, self.strings["no_gauto_memory_to_clear"])
- elif not args:
- if str(chat_id) in self.conversations:
- self._clear_history(chat_id)
- await utils.answer(message, self.strings["memory_cleared"])
- else: await utils.answer(message, self.strings["no_memory_to_clear"])
+ else:
+ await utils.answer(message, self.strings["no_gauto_memory_to_clear"])
+ return
+ if str(chat_id) in self.conversations:
+ self._clear_history(chat_id)
+ keys_to_del =[k for k, v in self.pager_cache.items() if v.get("chat_id") == chat_id]
+ for k in keys_to_del: del self.pager_cache[k]
+ if keys_to_del: self.db.set(self.strings["name"], DB_PAGER_CACHE_KEY, self.pager_cache)
+ await utils.answer(message, self.strings["memory_cleared"])
else:
- await utils.answer(message, self.strings["gclear_usage"])
+ await utils.answer(message, self.strings["no_memory_to_clear"])
@loader.command()
async def gpresets(self, message: Message):
@@ -917,41 +1008,115 @@ class Gemini(loader.Module):
"""[] [auto] [-s] — \n[из id/@юза чата] экспорт. -s в избранное."""
args = utils.get_args_raw(message).split()
save_to_self = "-s" in args
- if save_to_self: args.remove("-s")
- gauto = "auto" in args
- if gauto: args.remove("auto")
- src_id = int(args[0]) if args and args[0].lstrip('-').isdigit() else utils.get_chat_id(message)
- hist = self._get_structured_history(src_id, gauto=gauto)
- if not hist: return await utils.answer(message, "История для экспорта пуста.")
+ if save_to_self:
+ args.remove("-s")
+ gauto_mode = "auto" in args
+ if gauto_mode:
+ args.remove("auto")
+ source_chat_id_str = args[0] if args else None
+ target_chat_id = "me" if save_to_self else message.chat_id
+ if source_chat_id_str:
+ try:
+ entity = await self.client.get_entity(
+ int(source_chat_id_str)
+ if source_chat_id_str.lstrip("-").isdigit()
+ else source_chat_id_str
+ )
+ source_chat_id = entity.id
+ hist = self._get_structured_history(source_chat_id, gauto=gauto_mode)
+ except Exception:
+ await utils.answer(message, self.strings["gme_chat_not_found"].format(utils.escape_html(source_chat_id_str)))
+ return
+ else:
+ source_chat_id = utils.get_chat_id(message)
+ hist = self._get_structured_history(source_chat_id, gauto=gauto_mode)
+ if not hist:
+ await utils.answer(message, "История для экспорта пуста.")
+ return
+ user_ids = {e.get("user_id") for e in hist if e.get("role") == "user" and e.get("user_id")}
+ user_names = {None: None}
+ for uid in user_ids:
+ if not uid: continue
+ try:
+ entity = await self.client.get_entity(uid)
+ user_names[uid] = get_display_name(entity)
+ except Exception: user_names[uid] = f"Deleted Account ({uid})"
import json
- data = json.dumps(hist, ensure_ascii=False, indent=2)
- f = io.BytesIO(data.encode('utf-8'))
- f.name = f"gemini_{'gauto_' if gauto else ''}{src_id}.json"
- dest = "me" if save_to_self else message.chat_id
- cap = "Экспорт истории gauto Gemini" if gauto else "Экспорт памяти Gemini"
- if src_id != utils.get_chat_id(message): cap += f" из чата {src_id}"
- await self.client.send_file(dest, f, caption=cap)
- if save_to_self: await utils.answer(message, self.strings["gme_sent_to_saved"])
- elif args: await message.delete()
+ def make_serializable(entry):
+ entry = dict(entry)
+ user_id = entry.get("user_id")
+ if user_id: entry["user_name"] = user_names.get(user_id)
+ if hasattr(user_id, "user_id"): entry["user_id"] = user_id.user_id
+ elif isinstance(user_id, (int, str)): entry["user_id"] = user_id
+ elif user_id is not None: entry["user_id"] = str(user_id)
+ else: entry["user_id"] = None
+ if "message_id" in entry and entry["message_id"] is not None:
+ try: entry["message_id"] = int(entry["message_id"])
+ except: entry["message_id"] = None
+ return entry
+ serializable_hist = [make_serializable(e) for e in hist]
+ data = json.dumps(serializable_hist, ensure_ascii=False, indent=2)
+ file_suffix = "gauto_history" if gauto_mode else "history"
+ file = io.BytesIO(data.encode("utf-8"))
+ file.name = f"gemini_{file_suffix}_{source_chat_id}.json"
+ caption = "Экспорт истории gauto Gemini" if gauto_mode else "Экспорт памяти Gemini"
+ if source_chat_id != utils.get_chat_id(message):
+ caption += f" из чата {source_chat_id}"
+ await self.client.send_file(
+ target_chat_id,
+ file,
+ caption=caption,
+ reply_to=message.id if target_chat_id == message.chat_id else None,
+ )
+ if save_to_self:
+ if target_chat_id == "me" and message.chat_id != self.me.id:
+ await utils.answer(message, self.strings["gme_sent_to_saved"])
+ else:
+ await message.delete()
@loader.command()
async def gmemimport(self, message: Message):
"""[auto] — импорт истории из файла (ответом). auto для gauto."""
reply = await message.get_reply_message()
- if not reply or not reply.document: return await utils.answer(message, "Ответьте на json-файл с памятью.")
- gauto = "auto" in utils.get_args_raw(message)
-
+ if not reply or not reply.document:
+ return await utils.answer(message, "Ответьте на json-файл с памятью.")
+ args = utils.get_args_raw(message).lower()
+ gauto_mode = args == "auto"
+ file = io.BytesIO()
+ await self.client.download_media(reply, file)
+ file.seek(0)
+ MAX_IMPORT_SIZE = 15 * 1024 * 1024
+ if file.getbuffer().nbytes > MAX_IMPORT_SIZE:
+ return await utils.answer(message, f"Файл слишком большой (>{MAX_IMPORT_SIZE // (1024*1024)} МБ).")
+ import json
try:
- f = await self.client.download_media(reply, bytes)
- import json
- hist = json.loads(f)
- if not isinstance(hist, list): raise ValueError
- cid = utils.get_chat_id(message)
- target = self.gauto_conversations if gauto else self.conversations
- target[str(cid)] = hist
- self._save_history_sync(gauto)
- await utils.answer(message, "Память успешно импортирована.")
- except Exception as e: await utils.answer(message, f"Ошибка импорта: {e}")
+ hist = json.load(file)
+ if not isinstance(hist, list): raise ValueError("Файл не содержит список истории.")
+ new_hist =[]
+ for e in hist:
+ if not isinstance(e, dict) or "role" not in e or "content" not in e:
+ raise ValueError("Некорректная структура памяти.")
+ entry = {
+ "role": e["role"],
+ "type": e.get("type", "text"),
+ "content": e["content"],
+ "date": e.get("date")
+ }
+ if e["role"] == "user":
+ entry["user_id"] = e.get("user_id")
+ entry["message_id"] = e.get("message_id")
+ new_hist.append(entry)
+ chat_id = str(utils.get_chat_id(message))
+ if gauto_mode:
+ self.gauto_conversations[chat_id] = new_hist
+ self._save_history_sync(gauto=True)
+ else:
+ self.conversations[chat_id] = new_hist
+ self._save_history_sync(gauto=False)
+ mem_type = "Gauto память" if gauto_mode else "Память"
+ await utils.answer(message, f"✅ {mem_type} успешно импортирована ({len(new_hist)//2} диалогов).")
+ except Exception as e:
+ await utils.answer(message, f"❌ Ошибка импорта: {e}")
@loader.command()
async def gmemfind(self, message: Message):
@@ -993,10 +1158,10 @@ class Gemini(loader.Module):
@loader.command()
async def gmodel(self, message: Message):
- """[model] [-s] — Узнать/сменить модель. -s — список."""
- args_raw = utils.get_args_raw(message).strip()
+ """[model] [-s] — Узнать/сменить модель. -s — список. Авто-проверка совместимости."""
+ args_raw = utils.get_args_raw(message).strip().lower()
args_list = args_raw.split()
- is_list_request = "-s" in [arg.lower() for arg in args_list]
+ is_list_request = "-s" in args_list
provider = self.config["provider"]
if is_list_request:
status_msg = await utils.answer(message, self.strings["processing"])
@@ -1011,25 +1176,27 @@ class Gemini(loader.Module):
) as resp:
if resp.status != 200: raise ValueError(f"HTTP {resp.status}")
data = await resp.json()
- models_data = data.get("data", [])
+ models_data = data.get("data",[])
models_data.sort(key=lambda x: x["id"])
top_list = []
other_list = []
- favs = ["google/gemini-2.0-flash-001", "openai/gpt-4o", "anthropic/claude-3.5-sonnet", "deepseek/deepseek-r1"]
+ favs =["google/gemini-2.0-flash-001", "openai/gpt-4o", "anthropic/claude-3.5-sonnet", "deepseek/deepseek-r1"]
for m in models_data:
mid = m["id"]
line = f"• {mid}"
if mid in favs: top_list.append(line)
- elif any(x in mid for x in ["gemini", "gpt", "claude", "deepseek"]): other_list.append(line)
- text = self.strings.get("gmodel_list_title_Openrouter", "📋 Models:") + "\n" + "\n".join(top_list) + "\n\n" + "\n".join(other_list[:50])
- file = io.BytesIO(text.encode("utf-8")); file.name = "openrouter_models.txt"
+ elif any(x in mid for x in ["gemini", "gpt", "claude", "deepseek", "llama"]): other_list.append(line)
+ text = self.strings.get("gmodel_list_title_Openrouter", "📋 Models:") + "\n" + "\n".join(top_list) + "\n\n"
+ text += "\n".join(other_list[:50])
+ if len(other_list) > 50: text += f"\n\n...и еще {len(other_list)-50} моделей."
+ file = io.BytesIO(text.encode("utf-8"))
await self.client.send_file(message.chat_id, file=file, caption="📋 OpenRouter Models", reply_to=message.id)
await status_msg.delete()
else:
if not self.api_keys: return await utils.answer(status_msg, self.strings['no_api_key'])
- client = genai.Client(api_key=self.api_keys[0])
+ client = genai.Client(api_key=self.api_keys[self.current_api_key_index])
models = await asyncio.to_thread(client.models.list)
- txt = "\n".join([f"• {m.name.split('/')[-1]} ({m.display_name})" for m in models])
+ txt = "\n".join([f"• {m.name.split('/')[-1]}" for m in models])
f = io.BytesIO((self.strings["gmodel_list_title"] + "\n" + txt).encode('utf-8'))
f.name = "models_list.txt"
await self.client.send_file(message.chat_id, file=f, caption="📋 Список доступных моделей", reply_to=message.id)
@@ -1038,16 +1205,16 @@ class Gemini(loader.Module):
await utils.answer(status_msg, self.strings["gmodel_list_error"].format(self._handle_error(e)))
return
if not args_raw:
- return await utils.answer(message, f"🔮 Провайдер: {provider}\n🧠 Модель: {self.config['model_name']}")
+ return await utils.answer(message, f"🔮 Провайдер: {provider}\n🧠 Модель: {self.config['model_name']}")
self.config["model_name"] = args_raw
warning = ""
- if provider == "google" and ("/" in args_raw or any(x in args_raw.lower() for x in ["gpt", "claude", "deepseek", "llama"])):
+ if provider == "google" and ("/" in args_raw or any(x in args_raw for x in["gpt", "claude", "deepseek", "llama"])):
warning = (
"\n\n⚠️ Конфликт настроек!\n"
f"Вы установили модель {args_raw}, но провайдер остался Google.\n"
"Смените провайдера командой:\n.cfg gemini provider openrouter"
)
- elif provider == "openrouter" and "/" not in args_raw and "gemini" in args_raw.lower():
+ elif provider == "openrouter" and "/" not in args_raw and "gemini" in args_raw:
warning = (
"\n\n⚠️ Совет: Для OpenRouter лучше использовать полные ID.\n"
f"Например: google/{args_raw}"
@@ -1070,19 +1237,60 @@ class Gemini(loader.Module):
self._save_history_sync(False)
await utils.answer(message, self.strings["memory_fully_cleared"].format(n))
+
@loader.callback_handler()
async def gemini_callback_handler(self, call: InlineCall):
if not call.data.startswith("gemini:"): return
parts = call.data.split(":")
action = parts[1]
+
if action == "noop":
await call.answer()
return
+ if action == "close":
+ uid = parts[2]
+ if uid in self.pager_cache:
+ del self.pager_cache[uid]
+ self.db.set(self.strings["name"], DB_PAGER_CACHE_KEY, self.pager_cache)
+ try: await call.answer()
+ except: pass
+ try:
+ chat = call.chat_id
+ msg_id = call.message_id
+ if chat and msg_id:
+ await self.client.delete_messages(chat, msg_id)
+ else:
+ await call.delete()
+ except Exception:
+ try: await call.edit("🗑 Сессия закрыта.", reply_markup=None)
+ except: pass
+ return
if action == "pg":
uid = parts[2]
page = int(parts[3])
await self._render_page(uid, page, call)
return
+ if action == "regen":
+ chat_id = int(parts[2])
+ msg_id = int(parts[3])
+ key = f"{chat_id}:{msg_id}"
+ last_request_tuple = self.last_requests.get(key)
+ if not last_request_tuple:
+ await call.answer(self.strings["no_last_request"], show_alert=True)
+ return
+ last_parts, display_prompt = last_request_tuple
+ use_url_context = bool(re.search(r'https?://\S+', display_prompt or ""))
+ await call.edit(f"⌛️ Регенерация...", reply_markup=None)
+ await self._send_to_gemini(
+ message=msg_id,
+ parts=last_parts,
+ regeneration=True,
+ call=call,
+ chat_id_override=chat_id,
+ use_url_context=use_url_context,
+ display_prompt=display_prompt
+ )
+ return
async def _clear_callback(self, call: InlineCall, cid):
self._clear_history(cid, gauto=False)
@@ -1112,24 +1320,36 @@ class Gemini(loader.Module):
data = self.pager_cache.get(uid)
if not data:
if isinstance(entity, InlineCall):
- await entity.edit("⚠️ Сессия истекла (RAM cleared).", reply_markup=None)
+ await entity.edit(
+ "⚠️ Сессия истекла или бот был перезагружен с потерей данных.",
+ reply_markup=[[{"text": "🗑 Удалить", "data": f"gemini:close:{uid}"}]]
+ )
return
chunks = data["chunks"]
total = data["total"]
header = data.get("header", "")
+ chat_id = data.get("chat_id")
+ base_msg_id = data.get("msg_id")
raw_text_chunk = chunks[page_num]
safe_text = self._markdown_to_html(raw_text_chunk)
formatted_body = self._format_response_with_smart_separation(safe_text)
text_to_show = f"{header}\n{formatted_body}"
- nav_row = []
+ text_to_show = text_to_show.replace('', '')
+ nav_row =[]
if page_num > 0:
- nav_row.append({"text": "◀️", "data": f"gemini:pg:{uid}:{page_num - 1}"})
+ nav_row.append({
+ "text": "◀️",
+ "data": f"gemini:pg:{uid}:{page_num - 1}"})
nav_row.append({"text": f"{page_num + 1}/{total}", "data": "gemini:noop"})
if page_num < total - 1:
- nav_row.append({"text": "▶️", "data": f"gemini:pg:{uid}:{page_num + 1}"})
- extra_row = [{"text": "❌ Закрыть", "callback": self._close_callback, "args": (uid,)}]
- if data.get("chat_id") and data.get("msg_id"):
- extra_row.append({"text": "🔄", "callback": self._regenerate_callback, "args": (data['msg_id'], data['chat_id'])})
+ nav_row.append({
+ "text": "▶️",
+ "data": f"gemini:pg:{uid}:{page_num + 1}"})
+ extra_row =[{"text": "❌ Закрыть", "data": f"gemini:close:{uid}"}]
+ if chat_id and base_msg_id:
+ extra_row.append({
+ "text": "🔄",
+ "data": f"gemini:regen:{chat_id}:{base_msg_id}"})
buttons = [nav_row, extra_row]
if isinstance(entity, Message):
await self.inline.form(text=text_to_show, message=entity, reply_markup=buttons)
@@ -1354,54 +1574,68 @@ class Gemini(loader.Module):
logger.exception("Gemini execution error")
if isinstance(e, asyncio.TimeoutError):
return self.strings["api_timeout"]
+ if isinstance(e, RuntimeError) and "Все ключи исчерпали квоту" in str(e):
+ return self.strings["all_keys_exhausted"].format(len(self.api_keys))
if google_exceptions and isinstance(e, google_exceptions.GoogleAPIError):
msg = str(e)
if "quota" in msg.lower() or "exceeded" in msg.lower():
- model = self.config.get("model_name", "unknown")
+ model_name = self.config.get("model_name", "unknown")
+ model_name_match = re.search(r'key: "model"\s+value: "([^"]+)"', msg)
+ if model_name_match:
+ model_name = model_name_match.group(1)
return (
- f"❗️ Превышен лимит Google Gemini API для модели {utils.escape_html(model)}.\n"
+ f"❗️ Превышен лимит Google Gemini API для модели {utils.escape_html(model_name)}."
+ "\n\nЧаще всего это происходит на бесплатном тарифе. Вы можете:\n"
+ "• Подождать, пока лимит сбросится (обычно раз в сутки).\n"
+ "• Проверить свой тарифный план в Google AI Studio.\n"
+ "• Узнать больше о лимитах здесь.\n\n"
f"Детали ошибки:\n{utils.escape_html(msg)}"
)
+ if "500 An internal error has occurred" in msg:
+ return (
+ "❗️ Ошибка 500 от Google API.\n"
+ "Это значит, что формат медиа (файл или еще что то) который ты отправил, не поддерживается.\n"
+ "Такое случается, по такой причине:\n "
+ "• Если формат файла в принципе не поддерживается Gemini/Гуглом.\n "
+ "• Временный сбой на серверах Google. Попробуйте повторить запрос позже."
+ )
if "User location is not supported" in msg or "location is not supported" in msg:
- return (
+ return (
'❗️ В данном регионе Gemini API не доступен.\n'
- 'Используйте VPN или прокси.'
+ 'Скачайте VPN (для пк/тел) или поставьте прокси (платный/бесплатный).\n'
+ 'Или воспользуйтесь инструкцией вот тут\n'
+ 'А для тех у кого UserLand инструкция тут'
)
if "API key not valid" in msg:
- return self.strings["invalid_api_key"]
+ return self.strings["invalid_api_key"]
if "blocked" in msg.lower():
- return self.strings["blocked_error"].format(utils.escape_html(msg))
+ return self.strings["blocked_error"].format(utils.escape_html(msg))
return self.strings["api_error"].format(utils.escape_html(msg))
- if isinstance(e, (OSError, socket.timeout)):
+ if isinstance(e, (OSError, aiohttp.ClientError, socket.timeout)):
return "❗️ Сетевая ошибка:\n{}".format(utils.escape_html(str(e)))
msg = str(e)
+ if "No API_KEY or ADC found" in msg or "GOOGLE_API_KEY environment variable" in msg or "genai.configure(api_key" in msg:
+ return self.strings["no_api_key"]
if "quota" in msg.lower() or "429" in msg: return self.strings["all_keys_exhausted"].format(len(self.api_keys))
return self.strings["generic_error"].format(utils.escape_html(msg))
def _markdown_to_html(self, text: str) -> str:
- def heading_replacer(match):
- level = len(match.group(1))
- title = match.group(2).strip()
- indent = " " * (level - 1)
- return f"{indent}{title}"
- text = re.sub(r"^(#+)\s+(.*)", heading_replacer, text, flags=re.MULTILINE)
- def list_replacer(match):
- indent = match.group(1)
- return f"{indent}• "
- text = re.sub(r"^([ \t]*)[-*+]\s+", list_replacer, text, flags=re.MULTILINE)
- md = MarkdownIt("commonmark", {"html": True, "linkify": True})
- md.enable("strikethrough")
- md.disable("hr")
- md.disable("heading")
- md.disable("list")
- html_text = md.render(text)
+ text = re.sub(r".*?", "", text, flags=re.DOTALL)
+ text = re.sub(r".*?", "", text, flags=re.DOTALL)
+ text = re.sub(r"(?i)
", "\n", text)
+ def heading_replacer(match): level=len(match.group(1)); title=match.group(2).strip(); indent=" " * (level - 1); return f"{indent}{title}"
+ text=re.sub(r"^(#+)\s+(.*)", heading_replacer, text, flags=re.MULTILINE)
+ def list_replacer(match): indent=match.group(1); return f"{indent}• "
+ text=re.sub(r"^([ \t]*)[-*+]\s+", list_replacer, text, flags=re.MULTILINE)
+ md=MarkdownIt("commonmark", {"html": True, "linkify": True}); md.enable("strikethrough"); md.disable("hr"); md.disable("heading"); md.disable("list")
+ html_text=md.render(text)
def format_code(match):
- lang = utils.escape_html(match.group(1).strip())
- code = utils.escape_html(match.group(2).strip())
+ lang=utils.escape_html(match.group(1).strip()); code=utils.escape_html(match.group(2).strip())
return f'{code}
' if lang else f'{code}
'
- html_text = re.sub(r"```(.*?)\n([\s\S]+?)\n```", format_code, html_text)
- html_text = re.sub(r"(
[\s\S]*?
)", r"\1", html_text, flags=re.DOTALL)
- html_text = html_text.replace("", "").replace("
", "\n").strip()
+ html_text=re.sub(r"```(.*?)\n([\s\S]+?)\n```", format_code, html_text)
+ html_text=re.sub(r"(
[\s\S]*?
)", r"\1", html_text, flags=re.DOTALL)
+ html_text=html_text.replace("", "").replace("
", "\n")
+ html_text=re.sub(r"(?i)
", "\n", html_text).strip()
return html_text
def _format_response_with_smart_separation(self, text: str) -> str:
@@ -1409,8 +1643,9 @@ class Gemini(loader.Module):
parts = re.split(pattern, text, flags=re.DOTALL)
result_parts = []
for i, part in enumerate(parts):
- if not part or part.isspace(): continue
- if i % 2 == 1:
+ if not part or part.isspace():
+ continue
+ if i % 2 == 1:
result_parts.append(part.strip())
else:
stripped_part = part.strip()
@@ -1418,11 +1653,10 @@ class Gemini(loader.Module):
result_parts.append(f'{stripped_part}
')
return "\n".join(result_parts)
- def _get_inline_buttons(self, chat_id, base_message_id):
- return [[
- {"text": self.strings["btn_clear"], "callback": self._clear_callback, "args": (chat_id,)},
- {"text": self.strings["btn_regenerate"], "callback": self._regenerate_callback, "args": (base_message_id, chat_id)}
- ]]
+ def _get_inline_buttons(self, chat_id, base_message_id):
+ return [[{"text": self.strings["btn_clear"], "callback": self._clear_callback, "args": (chat_id,)},
+ {"text": self.strings["btn_regenerate"], "data": f"gemini:regen:{chat_id}:{base_message_id}"}]
+ ]
async def _safe_del_msg(self, msg, delay=1):
await asyncio.sleep(delay)
diff --git a/archquise/H.Modules/soundcloud.py b/archquise/H.Modules/soundcloud.py
new file mode 100644
index 0000000..d4d9390
--- /dev/null
+++ b/archquise/H.Modules/soundcloud.py
@@ -0,0 +1,875 @@
+# Proprietary License Agreement
+
+# Copyright (c) 2024-29 CodWiz
+
+# Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use the Software for personal and non-commercial purposes, subject to the following conditions:
+
+# 1. The Software may not be modified, altered, or otherwise changed in any way without the explicit written permission of the author.
+
+# 2. Redistribution of the Software, in original or modified form, is strictly prohibited without the explicit written permission of the author.
+
+# 3. The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the author or copyright holder be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.
+
+# 4. Any use of the Software must include the above copyright notice and this permission notice in all copies or substantial portions of the Software.
+
+# 5. By using the Software, you agree to be bound by the terms and conditions of this license.
+
+# For any inquiries or requests for permissions, please contact codwiz@yandex.ru.
+
+# ---------------------------------------------------------------------------------
+# Name: SoundCloud
+# Description: Card with the currently playing track on SoundCloud
+# Author: @hikka_mods
+# ---------------------------------------------------------------------------------
+# meta developer: @hikka_mods
+# scope: SoundCloud
+# scope: SoundCloud 0.0.2
+# requires: requests pillow yt-dlp
+# ---------------------------------------------------------------------------------
+
+import contextlib
+import dataclasses
+import functools
+import hashlib
+import io
+import logging
+from typing import Dict, List, Optional
+
+import requests
+from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
+from telethon.tl.types import Message
+from yt_dlp import YoutubeDL
+
+from .. import loader, utils
+
+logger = logging.getLogger(__name__)
+
+_API = "https://api-v2.soundcloud.com"
+_COVER_HQ = "-t500x500"
+
+_ORANGE = (255, 85, 0)
+_DIM = (155, 155, 170)
+_FADED = (100, 100, 115)
+_CARD_BG = (255, 255, 255, 14)
+_CARD_ACTIVE = (255, 255, 255, 26)
+_BAR_MUTED = (255, 255, 255, 16)
+
+
+@dataclasses.dataclass(frozen=True)
+class TrackInfo:
+ """Parsed SoundCloud track metadata."""
+
+ track_id: int
+ title: str
+ artist: str
+ duration_ms: int
+ permalink: str
+ cover_url: str
+ genre: str
+ plays: int
+ likes: int
+ reposts: int
+ comments: int
+
+ @classmethod
+ def parse(cls, raw: dict) -> "TrackInfo":
+ u = raw.get("user") or {}
+ return cls(
+ track_id=raw.get("id", 0),
+ title=raw.get("title") or "Unknown",
+ artist=u.get("username") or "Unknown",
+ duration_ms=raw.get("duration") or raw.get("full_duration") or 0,
+ permalink=raw.get("permalink_url") or "",
+ cover_url=raw.get("artwork_url") or u.get("avatar_url") or "",
+ genre=raw.get("genre") or "",
+ plays=raw.get("playback_count") or 0,
+ likes=raw.get("likes_count") or raw.get("favoritings_count") or 0,
+ reposts=raw.get("reposts_count") or 0,
+ comments=raw.get("comment_count") or 0,
+ )
+
+ @property
+ def duration_fmt(self) -> str:
+ s = self.duration_ms // 1000
+ return f"{s // 60}:{s % 60:02d}"
+
+ @property
+ def hq_cover(self) -> str:
+ return self.cover_url.replace("-large", _COVER_HQ)
+
+
+def _compact(n: int) -> str:
+ """Format large numbers: 12500 → 12.5K."""
+ if n >= 1_000_000:
+ return f"{n / 1_000_000:.1f}M"
+ if n >= 1_000:
+ return f"{n / 1_000:.1f}K"
+ return str(n)
+
+
+class _Fonts:
+ """Cached font loader from raw bytes."""
+
+ __slots__ = ("_raw", "_loaded")
+
+ def __init__(self, data: bytes):
+ self._raw = data
+ self._loaded: Dict[int, ImageFont.FreeTypeFont] = {}
+
+ def __call__(self, size: int) -> ImageFont.FreeTypeFont:
+ if size not in self._loaded:
+ self._loaded[size] = ImageFont.truetype(io.BytesIO(self._raw), size)
+ return self._loaded[size]
+
+ def fit(self, text: str, max_w: int, hi: int, lo: int) -> ImageFont.FreeTypeFont:
+ for s in range(hi, lo - 1, -2):
+ f = self(s)
+ if f.getlength(text) <= max_w:
+ return f
+ return self(lo)
+
+
+def _ellipsis(text: str, font: ImageFont.FreeTypeFont, max_w: int) -> str:
+ """Truncate text with '…' using binary search."""
+ if font.getlength(text) <= max_w:
+ return text
+ lo, hi = 0, len(text)
+ while lo < hi:
+ mid = (lo + hi + 1) // 2
+ if font.getlength(text[:mid] + "…") <= max_w:
+ lo = mid
+ else:
+ hi = mid - 1
+ return text[:lo] + "…"
+
+
+def _center_text(draw, text, font, y, canvas_w, fill="white"):
+ bb = draw.textbbox((0, 0), text, font=font)
+ draw.text(((canvas_w - bb[2] + bb[0]) // 2, y), text, font=font, fill=fill)
+
+
+def _frosted_bg(src: bytes, w: int, h: int, dim: float = 0.25) -> Image.Image:
+ """Blurred & dimmed background from cover art."""
+ img = Image.open(io.BytesIO(src)).convert("RGBA")
+ small = img.resize((max(w // 5, 1), max(h // 5, 1)), Image.Resampling.BILINEAR)
+ small = small.filter(ImageFilter.GaussianBlur(12))
+ result = small.resize((w, h), Image.Resampling.BILINEAR)
+ return ImageEnhance.Brightness(result).enhance(dim)
+
+
+def _gradient(
+ w: int, h: int, vertical: bool = True, c_from=(0, 0, 0, 160), c_to=(0, 0, 0, 40)
+) -> Image.Image:
+ """Fast linear gradient via 1px strip resize."""
+ length = h if vertical else w
+ strip = Image.new("RGBA", (1, length) if vertical else (length, 1))
+ px = strip.load()
+ for i in range(length):
+ t = i / max(length - 1, 1)
+ rgba = tuple(int(c_from[c] + (c_to[c] - c_from[c]) * t) for c in range(4))
+ if vertical:
+ px[0, i] = rgba
+ else:
+ px[i, 0] = rgba
+ return strip.resize((w, h), Image.Resampling.BILINEAR)
+
+
+def _round_corners(img: Image.Image, r: int) -> Image.Image:
+ mask = Image.new("L", img.size, 0)
+ ImageDraw.Draw(mask).rounded_rectangle((0, 0, *img.size), r, fill=255)
+ out = Image.new("RGBA", img.size, (0, 0, 0, 0))
+ out.paste(img, mask=mask)
+ return out
+
+
+def _rounded_cover(data: bytes, size: int, r: int) -> Image.Image:
+ img = Image.open(io.BytesIO(data)).convert("RGBA")
+ img = img.resize((size, size), Image.Resampling.LANCZOS)
+ return _round_corners(img, r)
+
+
+def _place_cover(
+ base: Image.Image,
+ cover_data: bytes,
+ size: int,
+ radius: int,
+ pos: tuple,
+ shadow_blur: int = 20,
+ shadow_alpha: int = 50,
+):
+ """Place cover with colored drop shadow (offset downward)."""
+ cover = _rounded_cover(cover_data, size, radius)
+ avg = cover.resize((1, 1), Image.Resampling.BILINEAR).getpixel((0, 0))
+
+ pad = shadow_blur * 2
+ offset_y = 8
+ canvas = Image.new(
+ "RGBA", (size + pad * 2, size + pad * 2 + offset_y), (0, 0, 0, 0)
+ )
+ shadow_shape = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ ImageDraw.Draw(shadow_shape).rounded_rectangle(
+ (0, 0, size, size), radius, fill=(*avg[:3], shadow_alpha)
+ )
+ canvas.paste(shadow_shape, (pad, pad + offset_y), shadow_shape)
+ canvas = canvas.filter(ImageFilter.GaussianBlur(shadow_blur))
+ canvas.paste(cover, (pad, pad), cover)
+
+ base.paste(canvas, (pos[0] - pad, pos[1] - pad), canvas)
+
+
+def _waveform(draw, x, y, w, h, bars=45, color=_ORANGE, muted=_BAR_MUTED, prog=0.0):
+ """Waveform visualization bars with sha256-seeded heights."""
+ bw = max(w // (bars * 2), 2)
+ gap = (w - bw * bars) // max(bars - 1, 1)
+ seed = hashlib.sha256(f"sc{bars}".encode()).digest()
+ for i in range(bars):
+ bx = x + i * (bw + gap)
+ amp = seed[i % len(seed)] / 255
+ bh = int(h * (0.25 + amp * 0.75))
+ by = y + (h - bh) // 2
+ c = color if i / bars <= prog else muted
+ draw.rounded_rectangle((bx, by, bx + bw, by + bh), bw // 2, fill=c)
+
+
+def _badge(
+ draw, text, font, x, y, fg="white", bg=(255, 255, 255, 18), px=12, py=5
+) -> int:
+ """Rounded pill badge. Returns width."""
+ bb = font.getbbox(text)
+ tw, th = bb[2] - bb[0], bb[3] - bb[1]
+ pw, ph = tw + px * 2, th + py * 2
+ draw.rounded_rectangle((x, y, x + pw, y + ph), ph // 2, fill=bg)
+ draw.text((x + px, y + py), text, font=font, fill=fg)
+ return pw
+
+
+def _export(img: Image.Image, name: str = "soundcloud.png") -> io.BytesIO:
+ buf = io.BytesIO()
+ img.save(buf, "PNG", optimize=True)
+ buf.seek(0)
+ buf.name = name
+ return buf
+
+
+class CardFactory:
+ """Generates visual cards for SoundCloud tracks."""
+
+ def __init__(self, fonts: _Fonts):
+ self._f = fonts
+
+ def square(self, track: TrackInfo, cover: bytes) -> io.BytesIO:
+ """Square now-playing card (800×800)."""
+ S = 800
+ p = 45
+
+ bg = _frosted_bg(cover, S, S, 0.22)
+ bg = Image.alpha_composite(
+ bg, _gradient(S, S, True, (0, 0, 0, 50), (0, 0, 0, 190))
+ )
+ draw = ImageDraw.Draw(bg)
+
+ bf = self._f(12)
+ draw.text((p, p), "SOUNDCLOUD", font=bf, fill=_ORANGE)
+ lw = bf.getlength("SOUNDCLOUD")
+ draw.line([(p, p + 17), (p + lw, p + 17)], fill=(*_ORANGE, 100), width=2)
+
+ cs = 310
+ cx, cy = (S - cs) // 2, p + 32
+ _place_cover(bg, cover, cs, 14, (cx, cy), shadow_blur=25, shadow_alpha=50)
+ draw = ImageDraw.Draw(bg)
+
+ wy = cy + cs + 30
+ _waveform(draw, p + 35, wy, S - p * 2 - 70, 26, bars=50)
+
+ tf = self._f(13)
+ draw.text((p + 35, wy + 30), "0:00", font=tf, fill=_FADED)
+ ds = track.duration_fmt
+ draw.text((S - p - 35 - tf.getlength(ds), wy + 30), ds, font=tf, fill=_FADED)
+
+ tw = S - p * 2
+ ty = wy + 56
+ title_f = self._f.fit(track.title, tw, 36, 20)
+ _center_text(draw, _ellipsis(track.title, title_f, tw), title_f, ty, S)
+
+ af = self._f.fit(track.artist, tw, 24, 16)
+ _center_text(draw, _ellipsis(track.artist, af, tw), af, ty + 44, S, _DIM)
+
+ sy = ty + 92
+ sf = self._f(14)
+ parts = []
+ if track.genre:
+ parts.append(track.genre)
+ if track.plays:
+ parts.append(f"▶ {_compact(track.plays)}")
+ if track.likes:
+ parts.append(f"♥ {_compact(track.likes)}")
+ if not parts:
+ parts.append(track.duration_fmt)
+ _center_text(draw, " · ".join(parts), sf, sy, S, _FADED)
+
+ return _export(_round_corners(bg, 22))
+
+ def horizontal(self, track: TrackInfo, cover: bytes) -> io.BytesIO:
+ """Wide now-playing card (1200×400)."""
+ W, H = 1200, 400
+ p = 40
+ cs = 280
+
+ bg = _frosted_bg(cover, W, H, 0.22)
+ bg = Image.alpha_composite(
+ bg, _gradient(W, H, False, (0, 0, 0, 180), (0, 0, 0, 60))
+ )
+
+ cvy = (H - cs) // 2
+ _place_cover(bg, cover, cs, 14, (p, cvy), shadow_blur=20, shadow_alpha=40)
+ draw = ImageDraw.Draw(bg)
+
+ bf = self._f(11)
+ draw.text((p, p - 6), "SOUNDCLOUD", font=bf, fill=_ORANGE)
+
+ if track.genre:
+ gf = self._f(12)
+ gt = track.genre.upper()
+ draw.text((W - p - gf.getlength(gt), p - 6), gt, font=gf, fill=_FADED)
+
+ tx = p + cs + 50
+ tw = W - tx - p
+
+ tty = cvy + 10
+ title_f = self._f.fit(track.title, tw, 36, 22)
+ draw.text(
+ (tx, tty),
+ _ellipsis(track.title, title_f, tw),
+ font=title_f,
+ fill="white",
+ )
+
+ af = self._f(22)
+ draw.text(
+ (tx, tty + 50),
+ _ellipsis(track.artist, af, tw),
+ font=af,
+ fill=_DIM,
+ )
+
+ by = tty + 98
+ bx = tx
+ pill_f = self._f(14)
+ bw = _badge(
+ draw,
+ track.duration_fmt,
+ pill_f,
+ bx,
+ by,
+ fg=_ORANGE,
+ bg=(*_ORANGE, 35),
+ )
+ bx += bw + 8
+ if track.plays:
+ bw = _badge(draw, f"▶ {_compact(track.plays)}", pill_f, bx, by, fg=_DIM)
+ bx += bw + 8
+ if track.likes:
+ _badge(draw, f"♥ {_compact(track.likes)}", pill_f, bx, by, fg=_DIM)
+
+ wy = cvy + cs - 50
+ _waveform(draw, tx, wy, tw, 22, bars=55)
+
+ wf = self._f(12)
+ draw.text((tx, wy + 26), "0:00", font=wf, fill=_FADED)
+ ds = track.duration_fmt
+ draw.text((tx + tw - wf.getlength(ds), wy + 26), ds, font=wf, fill=_FADED)
+
+ return _export(_round_corners(bg, 20))
+
+ def history(self, tracks: List[TrackInfo], fetch_cover) -> io.BytesIO:
+ """History card with dynamic height based on track count."""
+ W = 1200
+ p = 36
+ row_h = 120
+ gap = 8
+ hdr = 55
+ n = len(tracks)
+ H = p * 2 + hdr + n * row_h + (n - 1) * gap
+
+ bg_data = fetch_cover(tracks[0].hq_cover)
+ bg = _frosted_bg(bg_data, W, H, 0.18)
+ bg = Image.alpha_composite(bg, Image.new("RGBA", (W, H), (0, 0, 0, 150)))
+ draw = ImageDraw.Draw(bg)
+
+ hf = self._f(14)
+ draw.text((p, p), "SOUNDCLOUD", font=hf, fill=_ORANGE)
+ thf = self._f(22)
+ draw.text((p, p + 20), "Listening History", font=thf, fill="white")
+
+ lw = hf.getlength("SOUNDCLOUD")
+ draw.rounded_rectangle((p, p + 48, p + lw, p + 50), 1, fill=_ORANGE)
+
+ ct = f"{n} tracks"
+ draw.text((W - p - hf.getlength(ct), p + 22), ct, font=hf, fill=_FADED)
+
+ title_f = self._f(22)
+ artist_f = self._f(16)
+ time_f = self._f(14)
+ num_f = self._f(12)
+ cp = 12
+ cvsz = row_h - cp * 2
+ card_w = W - p * 2
+
+ yo = p + hdr + 8
+ for idx, trk in enumerate(tracks):
+ ry = int(yo)
+
+ card = Image.new("RGBA", (card_w, row_h), (0, 0, 0, 0))
+ cd = ImageDraw.Draw(card)
+ cd.rounded_rectangle(
+ (0, 0, card_w, row_h),
+ 12,
+ fill=_CARD_ACTIVE if idx == 0 else _CARD_BG,
+ )
+ if idx == 0:
+ cd.rounded_rectangle((0, 0, 4, row_h), 2, fill=_ORANGE)
+ region = bg.crop((p, ry, p + card_w, ry + row_h))
+ bg.paste(Image.alpha_composite(region, card), (p, ry))
+
+ try:
+ cv_data = fetch_cover(trk.hq_cover)
+ cv = _rounded_cover(cv_data, cvsz, 8)
+ bg.paste(cv, (p + cp + 6, ry + cp), cv)
+ except Exception:
+ pass
+
+ draw = ImageDraw.Draw(bg)
+
+ nt = f"{idx + 1:02d}"
+ nw = num_f.getlength(nt)
+ nx = p + cp + 6 + (cvsz - nw) // 2
+ ny = ry + cp + cvsz - 18
+ draw.rounded_rectangle(
+ (nx - 3, ny - 1, nx + nw + 3, ny + 14), 3, fill=(0, 0, 0, 170)
+ )
+ draw.text((nx, ny - 1), nt, font=num_f, fill=_ORANGE)
+
+ txt_x = p + cp + cvsz + 24
+ txt_w = card_w - cvsz - cp * 3 - 24 - 70
+ ty_center = ry + (row_h - 58) // 2
+
+ draw.text(
+ (txt_x, ty_center),
+ _ellipsis(trk.title, title_f, txt_w),
+ font=title_f,
+ fill="white",
+ )
+ draw.text(
+ (txt_x, ty_center + 30),
+ _ellipsis(trk.artist, artist_f, txt_w),
+ font=artist_f,
+ fill=_DIM,
+ )
+
+ dt = trk.duration_fmt
+ dw = time_f.getlength(dt)
+ draw.text(
+ (p + card_w - cp - dw - 8, ty_center + 4),
+ dt,
+ font=time_f,
+ fill=_FADED,
+ )
+
+ if trk.plays:
+ pt = f"▶ {_compact(trk.plays)}"
+ pw = time_f.getlength(pt)
+ draw.text(
+ (p + card_w - cp - pw - 8, ty_center + 24),
+ pt,
+ font=time_f,
+ fill=_FADED,
+ )
+
+ yo += row_h + gap
+
+ return _export(_round_corners(bg, 20), "soundcloud_history.png")
+
+
+def _require_token(func):
+ """Decorator: ensure oauth_token is configured."""
+
+ @functools.wraps(func)
+ async def wrapper(self, message, *a, **kw):
+ if not self.config["oauth_token"]:
+ return await utils.answer(message, self.strings("no_token"))
+ return await func(self, message, *a, **kw)
+
+ return wrapper
+
+
+def _catch_errors(func):
+ """Decorator: log & report exceptions to user."""
+
+ @functools.wraps(func)
+ async def wrapper(self, message, *a, **kw):
+ try:
+ return await func(self, message, *a, **kw)
+ except Exception:
+ logger.exception("SoundCloud: %s failed", func.__name__)
+ with contextlib.suppress(Exception):
+ import traceback
+
+ await utils.answer(
+ message, self.strings("error").format(traceback.format_exc())
+ )
+
+ return wrapper
+
+
+@loader.tds
+class SoundCloudMod(loader.Module):
+ """Display the currently playing SoundCloud track as a stylized card."""
+
+ strings = {
+ "name": "SoundCloud",
+ "no_token": (
+ "\u274c"
+ " Set oauth_token in module config\n\n"
+ "\U0001f511 Get it via extension:\n"
+ "\u2022 Chromium\n"
+ "\u2022 Firefox\n"
+ "\u2022 Or via DevTools: Application \u2192 Cookies \u2192 "
+ "oauth_token"
+ ),
+ "nothing": (
+ "❌"
+ " Nothing is playing right now"
+ ),
+ "error": (
+ "❌"
+ " Error\n{}"
+ ),
+ "wait_card": (
+ "\n\n🕔"
+ " Generating card…"
+ ),
+ "wait_dl": (
+ "\n\n🕔 Downloading…"
+ ),
+ "dl_fail": (
+ "\n\n❌"
+ " Download failed"
+ ),
+ }
+
+ strings_ru = {
+ "no_token": (
+ "❌"
+ " Установи oauth_token"
+ " в конфиге модуля\n\n"
+ "🔑 Получить токен:\n"
+ "• Chromium\n"
+ "• Firefox\n"
+ "• Или через DevTools: Application → Cookies → "
+ "oauth_token"
+ ),
+ "nothing": (
+ "❌"
+ " Сейчас ничего не играет"
+ ),
+ "error": (
+ "❌"
+ " Ошибка\n{}"
+ ),
+ "wait_card": (
+ "\n\n🕔"
+ " Генерация карточки…"
+ ),
+ "wait_dl": (
+ "\n\n🕔 Скачивание…"
+ ),
+ "dl_fail": (
+ "\n\n❌"
+ " Ошибка скачивания"
+ ),
+ }
+
+ def __init__(self):
+ self._font_data: Optional[bytes] = None
+ self._font_src: Optional[str] = None
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue(
+ "show_banner",
+ True,
+ "Generate image card",
+ validator=loader.validators.Boolean(),
+ ),
+ loader.ConfigValue(
+ "banner_type",
+ "square",
+ "Card layout",
+ validator=loader.validators.Choice(["square", "horizontal"]),
+ ),
+ loader.ConfigValue(
+ "template",
+ (
+ "🎧"
+ " Now playing: {artist} — {track}\n"
+ "🕓"
+ " {duration}{genre}\n"
+ "🔗"
+ " SoundCloud"
+ ),
+ "Message template. Placeholders: {track}, {artist},"
+ " {url}, {duration}, {genre}, {stats}",
+ validator=loader.validators.String(),
+ ),
+ loader.ConfigValue(
+ "font",
+ "https://github.com/web-fonts/ttf/raw/refs/heads/master/alk-sanet-webfont.ttf",
+ "URL to .ttf font file",
+ validator=loader.validators.String(),
+ ),
+ loader.ConfigValue(
+ "oauth_token",
+ "",
+ "SoundCloud OAuth token",
+ validator=loader.validators.String(),
+ ),
+ loader.ConfigValue(
+ "history_count",
+ 5,
+ "Tracks in history (3–5)",
+ validator=loader.validators.Integer(minimum=3, maximum=5),
+ ),
+ )
+
+ def _headers(self) -> dict:
+ return {
+ "Authorization": f"OAuth {self.config['oauth_token']}",
+ "Accept": "application/json",
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ ),
+ }
+
+ async def _get(self, path: str, **params) -> Optional[dict]:
+ try:
+ r = await utils.run_sync(
+ requests.get,
+ f"{_API}{path}",
+ headers=self._headers(),
+ params=params,
+ timeout=5,
+ )
+ if r.status_code == 200:
+ return r.json()
+ except Exception:
+ logger.debug("SC API %s failed", path)
+ return None
+
+ async def _load_font(self) -> bytes:
+ url = self.config["font"]
+ if self._font_data and self._font_src == url:
+ return self._font_data
+ data = await utils.run_sync(lambda: requests.get(url, timeout=10).content)
+ self._font_data = data
+ self._font_src = url
+ return data
+
+ async def _load_cover(self, url: str) -> Optional[bytes]:
+ try:
+ hq = url.replace("-large", _COVER_HQ)
+ r = await utils.run_sync(requests.get, hq, timeout=10)
+ if r.status_code == 200:
+ return r.content
+ except Exception:
+ pass
+ return None
+
+ async def _current(self) -> Optional[TrackInfo]:
+ for ep in ("/me/play-history/tracks", "/me/activities", "/stream"):
+ data = await self._get(ep, limit=3)
+ if not data:
+ continue
+ for item in data.get("collection", []):
+ raw = item.get("track") or item
+ if raw and "title" in raw and (raw.get("duration") or 0) > 0:
+ return TrackInfo.parse(raw)
+ return None
+
+ async def _recent(self, count: int) -> List[TrackInfo]:
+ data = await self._get("/me/play-history/tracks", limit=count)
+ if not data:
+ return []
+ return [
+ TrackInfo.parse(it["track"])
+ for it in data.get("collection", [])
+ if it.get("track") and "title" in it["track"]
+ ]
+
+ async def _download(self, url: str) -> Optional[bytes]:
+ try:
+ token = self.config["oauth_token"]
+ opts = {
+ "format": "best[ext=mp3]/best",
+ "quiet": True,
+ "no_warnings": True,
+ "http_headers": {
+ "Authorization": f"OAuth {token}",
+ "User-Agent": (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ ),
+ },
+ }
+
+ def _run():
+ with YoutubeDL(opts) as ydl:
+ info = ydl.extract_info(url, download=False)
+ audio = info.get("url")
+ if audio:
+ r = requests.get(audio, timeout=60)
+ if r.status_code == 200:
+ return r.content
+ return None
+
+ return await utils.run_sync(_run)
+ except Exception as e:
+ logger.error("Download failed: %s", e)
+ return None
+
+ def _format_message(self, t: TrackInfo) -> str:
+ genre_part = f" | {utils.escape_html(t.genre)}" if t.genre else ""
+ stats = []
+ if t.plays:
+ stats.append(f"▶ {_compact(t.plays)}")
+ if t.likes:
+ stats.append(f"♥ {_compact(t.likes)}")
+ return self.config["template"].format(
+ track=utils.escape_html(t.title),
+ artist=utils.escape_html(t.artist),
+ duration=t.duration_fmt,
+ url=t.permalink,
+ genre=genre_part,
+ stats=" · ".join(stats),
+ )
+
+ def _format_detail(self, t: TrackInfo) -> str:
+ parts = [t.duration_fmt]
+ if t.genre:
+ parts.append(utils.escape_html(t.genre))
+ if t.plays:
+ parts.append(f"▶ {_compact(t.plays)}")
+ if t.likes:
+ parts.append(f"♥ {_compact(t.likes)}")
+ info = " | ".join(parts)
+ return (
+ f"🎧"
+ f" {utils.escape_html(t.artist)} — {utils.escape_html(t.title)}\n"
+ f"🕓 {info}\n"
+ f"🔗"
+ f" SoundCloud"
+ )
+
+ @_catch_errors
+ @_require_token
+ @loader.command(
+ ru_doc="— Показать карточку текущего трека",
+ en_doc="— Show current track card",
+ )
+ async def scnow(self, message: Message):
+ track = await self._current()
+ if not track:
+ return await utils.answer(message, self.strings("nothing"))
+
+ text = self._format_message(track)
+
+ if not (self.config["show_banner"] and track.cover_url):
+ return await utils.answer(message, text)
+
+ msg = await utils.answer(message, text + self.strings("wait_card"))
+
+ cover = await self._load_cover(track.cover_url)
+ if not cover:
+ return await utils.answer(msg, text)
+
+ font_data = await self._load_font()
+ factory = CardFactory(_Fonts(font_data))
+
+ render = (
+ factory.square
+ if self.config["banner_type"] == "square"
+ else factory.horizontal
+ )
+ card = await utils.run_sync(render, track, cover)
+ await utils.answer(msg, text, file=card)
+
+ @_catch_errors
+ @_require_token
+ @loader.command(
+ ru_doc="— Скачать текущий трек",
+ en_doc="— Download current track",
+ )
+ async def scnowt(self, message: Message):
+ track = await self._current()
+ if not track:
+ return await utils.answer(message, self.strings("nothing"))
+
+ text = self._format_detail(track)
+ msg = await utils.answer(message, text + self.strings("wait_dl"))
+
+ audio = await self._download(track.permalink)
+ if not audio:
+ return await utils.answer(msg, text + self.strings("dl_fail"))
+
+ buf = io.BytesIO(audio)
+ buf.name = f"{track.artist} - {track.title}.mp3"
+ await utils.answer(msg, text, file=buf)
+
+ @_catch_errors
+ @_require_token
+ @loader.command(
+ ru_doc="— История прослушивания",
+ en_doc="— Listening history",
+ )
+ async def schistory(self, message: Message):
+ tracks = await self._recent(self.config["history_count"])
+ if not tracks:
+ return await utils.answer(message, self.strings("nothing"))
+
+ text = (
+ "📜"
+ " История прослушивания:\n\n"
+ )
+ for i, t in enumerate(tracks, 1):
+ parts = [t.duration_fmt]
+ if t.genre:
+ parts.append(utils.escape_html(t.genre))
+ if t.plays:
+ parts.append(f"▶ {_compact(t.plays)}")
+ meta = " | ".join(parts)
+ text += (
+ f"{i}. {utils.escape_html(t.artist)} —"
+ f" {utils.escape_html(t.title)}\n"
+ f" 🕓"
+ f" {meta} | Link\n\n"
+ )
+
+ if not self.config["show_banner"]:
+ return await utils.answer(message, text)
+
+ msg = await utils.answer(message, text + self.strings("wait_card"))
+ try:
+ font_data = await self._load_font()
+
+ def _render():
+ factory = CardFactory(_Fonts(font_data))
+
+ def fetcher(u):
+ return requests.get(u, timeout=10).content
+
+ return factory.history(tracks, fetcher)
+
+ card = await utils.run_sync(_render)
+ await utils.answer(msg, text, file=card)
+ except Exception:
+ await utils.answer(msg, text)
diff --git a/coddrago/modules/YaMusic.py b/coddrago/modules/YaMusic.py
index 2279cfe..036e671 100644
--- a/coddrago/modules/YaMusic.py
+++ b/coddrago/modules/YaMusic.py
@@ -1,11 +1,9 @@
-__version__ = (3, 1, 1)
+__version__ = (3, 2, 0)
# meta banner: https://raw.githubusercontent.com/kamekuro/hikka-mods/main/banners/yamusic.png
-# packurl: https://raw.githubusercontent.com/coddrago/assets/refs/heads/main/modules/yamusic.yml
-# meta banner: https://raw.githubusercontent.com/coddrago/modules/refs/heads/main/banner.png
+# packurl: https://raw.githubusercontent.com/coddrago/modules/refs/heads/dev/translations/yamusic.yml
# meta developer: @codrago_m
-# old meta dev: @kamekuro xuesos
# scope: heroku_only
-# scope: heroku_min 1.7.2
+# scope: heroku_min 2.0.0
# requires: aiohttp asyncio pillow>=10.0.0 git+https://github.com/MarshalX/yandex-music-api
import aiohttp
@@ -17,6 +15,7 @@ import random
import string
import typing
import time
+import uuid
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
import telethon
@@ -171,7 +170,6 @@ class Banners:
current_y += 80
bar_width = 800
- bar_height = 6
font_time = get_font(40)
bar_start_x = center_x - (bar_width // 2)
@@ -180,11 +178,12 @@ class Banners:
total_mins = self.duration // 1000 // 60
total_secs = (self.duration // 1000) % 60
- total_time_str = f"{total_mins}:{total_secs:02d}"
+
+ total_time_str = f"{total_mins:02d}:{total_secs:02d}"
cur_mins = self.progress // 1000 // 60
cur_secs = (self.progress // 1000) % 60
- cur_time_str = f"{cur_mins}:{cur_secs:02d}"
+ cur_time_str = f"{cur_mins:02d}:{cur_secs:02d}"
draw_text_shadow(
cur_time_str, (bar_start_x - 30, bar_y), font_time, anchor="rm"
@@ -193,34 +192,44 @@ class Banners:
total_time_str, (bar_end_x + 30, bar_y), font_time, anchor="lm"
)
- draw.line(
- [(bar_start_x, bar_y), (bar_end_x, bar_y)],
- fill=(255, 255, 255, 80),
- width=bar_height,
- )
-
+ old_state = random.getstate()
+
+ random.seed(self.title + str(self.duration))
+
+ num_bars = 65
+ bar_spacing = bar_width / num_bars
+ bar_w = max(4, int(bar_spacing * 0.5))
+ max_h = 50
+ min_h = 6
+
if self.duration > 0:
progress_ratio = self.progress / self.duration
else:
progress_ratio = 0
- progress_px = int(bar_width * progress_ratio)
- if progress_px > bar_width:
- progress_px = bar_width
+
+ active_bars = int(num_bars * progress_ratio)
- draw.line(
- [(bar_start_x, bar_y), (bar_start_x + progress_px, bar_y)],
- fill="white",
- width=bar_height + 5,
- )
- draw.ellipse(
- (
- bar_start_x + progress_px - 10,
- bar_y - 10,
- bar_start_x + progress_px + 10,
- bar_y + 10,
- ),
- fill="white",
- )
+ for i in range(num_bars):
+ base_h = random.randint(min_h, max_h)
+ edge_factor = 1.0 - abs((i - num_bars / 2) / (num_bars / 2))
+ h = int(base_h * 0.4 + max_h * edge_factor * 0.6)
+ h = max(min_h, h)
+
+ x_center = bar_start_x + i * bar_spacing
+ left = x_center - (bar_w / 2)
+ right = x_center + (bar_w / 2)
+ top = bar_y - (h / 2)
+ bottom = bar_y + (h / 2)
+
+ color = (255, 255, 255, 255) if i < active_bars else (80, 80, 80, 100)
+
+ draw.rounded_rectangle(
+ (left, top, right, bottom),
+ radius=int(bar_w / 2),
+ fill=color
+ )
+
+ random.setstate(old_state)
current_y += 80
@@ -312,13 +321,7 @@ class YaMusicMod(loader.Module):
"""The module for Yandex.Music streaming service"""
strings = {
- "name": "YaMusic",
- "iguide": '📜 Guide for obtaining access token for Yandex.Music',
- }
-
- strings_ru = {
- "_cls_doc": "Модуль для стримингового сервиса Яндекс.Музыка",
- "iguide": '📜 Гайд по получению токена Яндекс.Музыки',
+ "name": "YaMusic"
}
def __init__(self):
@@ -373,11 +376,10 @@ class YaMusicMod(loader.Module):
self._client: telethon.TelegramClient = client
self._db = db
- #utils.register_placeholder(
- #"now_play", self._now_play_placeholder, "placeholder for nowplay music"
- # Heroku 2.0.0 feature
- #)
- #utils.register_placeholder("duration", self._duration_placeholder, "progress bar")
+ utils.register_placeholder(
+ "now_play", self._now_play_placeholder, "placeholder for nowplay music"
+ )
+ utils.register_placeholder("duration", self._duration_placeholder, "progress bar")
if not self.get("guide_sent", False):
await self.inline.bot.send_message(self._tg_id, self.strings("iguide"))
@@ -423,7 +425,7 @@ class YaMusicMod(loader.Module):
me = await self._client.get_me()
self._premium = me.premium if hasattr(me, "premium") else False
- @loader.loop(15)
+ @loader.loop(30)
async def autobio(self):
if not self.config["token"]:
self.autobio.stop()
@@ -543,7 +545,7 @@ class YaMusicMod(loader.Module):
now = await self.__get_now_playing()
if not now or now.get("paused"):
return "Not Playing"
-
+
duration = now.get("duration_ms", 0)
progress = now.get("progress_ms", 0)
@@ -632,13 +634,14 @@ class YaMusicMod(loader.Module):
)
async def ynowcmd(self, message: telethon.types.Message):
"""👉 Get the banner of the track playing right now"""
+
+ await utils.answer(message, self.strings("uploading_banner"))
ym_client = await self._get_ym_client()
if not ym_client:
return await utils.answer(
message, self.strings("errors")["no_token_or_invalid"]
)
- await utils.answer(message, self.strings("uploading_banner"))
now = await self.__get_now_playing()
if not now or now.get("paused"):
@@ -694,10 +697,6 @@ class YaMusicMod(loader.Module):
.format(playlist_name),
link=f"Яндекс.Музыка",
)
- try:
- await utils.answer(message, out + self.strings("uploading_banner"))
- except Exception:
- pass
album_obj = track_object.albums[0] if track_object.albums else None
@@ -823,10 +822,6 @@ class YaMusicMod(loader.Module):
.format(playlist_name),
link=f"Яндекс.Музыка",
)
- try:
- await utils.answer(message, out + self.strings("downloading_track"))
- except Exception:
- pass
await utils.answer(
message=message,
@@ -954,6 +949,7 @@ class YaMusicMod(loader.Module):
),
)
+
async def __download_track(
self,
client: yandex_music.ClientAsync,
@@ -977,7 +973,7 @@ class YaMusicMod(loader.Module):
await asyncio.sleep(1)
continue
raise e
-
+
async def __get_ynison(self):
async def create_ws(token, ws_proto):
async with aiohttp.ClientSession() as session:
diff --git a/coddrago/modules/translations/yamusic.yml b/coddrago/modules/translations/yamusic.yml
new file mode 100644
index 0000000..12f694f
--- /dev/null
+++ b/coddrago/modules/translations/yamusic.yml
@@ -0,0 +1,116 @@
+en:
+ iguide: "📜 Guide for obtaining access token for Yandex.Music"
+ search: "🎧 {performer} — {title}\n🎵 Yandex.Music | song.link"
+ downloading_track: "\n\n🕔 Downloading audio…"
+ uploading_banner: "\n\n🕔 Uploading banner…"
+ lyrics: "📜 Lyrics of the {track} track:\n{text}
\n\n©️ Writers: {writers}"
+ no_lyrics: "🚫 Track {track} has no lyrics!"
+ errors:
+ no_query: "🚫 Specify the search query first!"
+ no_token_or_invalid: "🚫 You specified an invalid access token or didn't specified it at all!"
+ not_found: "🚫 No results found."
+ no_playing: "🚫 You don't listening to anything right now."
+ autobio:
+ enabled: "🎧 Autobio was enabled."
+ disabled: "🎧 Autobio was disabled."
+ likes:
+ liked: "❤️ Track {track} was liked."
+ unliked: "🖤 Track {track} was unliked."
+ disliked: "💔 Track {track} was disliked."
+ _entity_types:
+ VARIOUS: "Your queue"
+ RADIO: "«My Vibe»"
+ PLAYLIST: "Playlist «{}»"
+ ALBUM: "«{}»"
+ ARTIST: "Popular tracks by {}"
+ _cfg:
+ token: "The access token for Yandex.Music."
+ now_playing_text: "The caption for .ynow and .ynowt commands. May contain {performer}, {title}, {device}, {volume}, {playing_from}, {link}, {track_id}, {album_id} keywords."
+ autobio_text: "The text for automatically changing «Bio». May contains {performer} and {title}."
+ no_playing_bio_text: "The text for changing «Bio» when there is no playing tracks."
+ banner_version: "Banner version"
+ repeat_on: "🔁 Repeat enabled"
+ repeat_off: "➡️ Repeat disabled"
+ next_track: "⏭ Next track"
+ prev_track: "⏭ Previous track"
+ volume_set: "➡️ Volume set to {vol}%"
+ volume_invalid: "❌ Volume must be between 0 and 100"
+ ynison_error: "❌ Failed to send command to Yandex.Music (Ynison)"
+
+ru:
+ iguide: "📜 Гайд по получению токена Яндекс.Музыки"
+ search: "🎧 {performer} — {title}\n🎵 Яндекс.Музыка | song.link"
+ downloading_track: "\n\n🕔 Загрузка трека…"
+ uploading_banner: "\n\n🕔 Загрузка баннера…"
+ lyrics: "📜 Текст трека {track}:\n{text}
\n\n©️ Авторы: {writers}"
+ no_lyrics: "🚫 У трека {track} нет текста!"
+ errors:
+ no_query: "🚫 Укажите поисковый запрос!"
+ no_token_or_invalid: "🚫 Вы указали невалидный токен или не указали его вообще!"
+ not_found: "🚫 Результаты не найдены."
+ no_playing: "🚫 Вы ничего не слушаете сейчас."
+ autobio:
+ enabled: "🎧 Автобио теперь включено."
+ disabled: "🎧 Автобио теперь выключено."
+ likes:
+ liked: "❤️ Трек {track} был лайкнут."
+ unliked: "🖤 С трека {track} был снят лайк."
+ disliked: "💔 Трек {track} был дизлайкнут."
+ _entity_types:
+ VARIOUS: "Ваша очередь"
+ RADIO: "«Моя волна»"
+ PLAYLIST: "Плейлист «{}»"
+ ALBUM: "«{}»"
+ ARTIST: "Популярные треки {}"
+ _cfg:
+ token: "Токен для Яндекс.Музыки."
+ now_playing_text: "Текст, использующийся в подписи к файлу в командах .ynow и .ynowt. Может содержать {performer}, {title}, {device}, {volume}, {playing_from}, {link}, {track_id} и {album_id}"
+ autobio_text: "Текст, использующийся при автоматическом изменении «О себе». Может содержать {performer} и {title}."
+ no_playing_bio_text: "Текст, использующийся при изменении «О себе», когда ничего не играет."
+ banner_version: "Версия баннера"
+ repeat_on: "🔁 Повтор включен (Один трек)"
+ repeat_off: "➡️ Повтор выключен"
+ next_track: "⏭ Следующий трек"
+ prev_track: "⏭ Предыдущий трек"
+ volume_set: "➡️ Громкость установлена на {vol}%"
+ volume_invalid: "❌ Громкость должна быть от 0 до 100"
+ ynison_error: "❌ Не удалось отправить команду в Яндекс.Музыку (Ynison)"
+
+jp:
+ iguide: "📜 Yandex.Music アクセストークン取得ガイド"
+ search: "🎧 {performer} — {title}\n🎵 Yandex.Music | song.link"
+ downloading_track: "\n\n🕔 オーディオをダウンロード中…"
+ uploading_banner: "\n\n🕔 バナーをアップロード中…"
+ lyrics: "📜 トラック {track} の歌詞:\n{text}
\n\n©️ 作詞・作曲: {writers}"
+ no_lyrics: "🚫 トラック {track} には歌詞がありません!"
+ errors:
+ no_query: "🚫 最初に検索クエリを指定してください!"
+ no_token_or_invalid: "🚫 無効なアクセストークンを指定したか、指定されていません!"
+ not_found: "🚫 結果が見つかりません。"
+ no_playing: "🚫 現在何も再生していません。"
+ autobio:
+ enabled: "🎧 Autobio(自動プロフィール)が有効になりました。"
+ disabled: "🎧 Autobioが無効になりました。"
+ likes:
+ liked: "❤️ トラック {track} に「いいね」しました。"
+ unliked: "🖤 トラック {track} の「いいね」を取り消しました。"
+ disliked: "💔 トラック {track} に「低評価」しました。"
+ _entity_types:
+ VARIOUS: "あなたのキュー"
+ RADIO: "«My Vibe»"
+ PLAYLIST: "プレイリスト «{}»"
+ ALBUM: "«{}»"
+ ARTIST: "{} の人気トラック"
+ _cfg:
+ token: "Yandex.Musicのアクセストークン。"
+ now_playing_text: ".ynow および .ynowt コマンド用のキャプション。{performer}, {title}, {device}, {volume}, {playing_from}, {link}, {track_id}, {album_id} のキーワードを含めることができます。"
+ autobio_text: "«Bio»(自己紹介)を自動変更するためのテキスト。{performer} と {title} を含めることができます。"
+ no_playing_bio_text: "何も再生されていない時に «Bio» を変更するためのテキスト。"
+ banner_version: "バナーのバージョン"
+ repeat_on: "🔁 リピート有効"
+ repeat_off: "➡️ リピート無効"
+ next_track: "⏭ 次のトラック"
+ prev_track: "⏭ 前のトラック"
+ volume_set: "➡️ 音量を {vol}% に設定しました"
+ volume_invalid: "❌ 音量は0から100の間で指定してください"
+ ynison_error: "❌ Yandex.Music (Ynison) へのコマンド送信に失敗しました"
diff --git a/fiksofficial/python-modules/PyInstall.py b/fiksofficial/python-modules/PyInstall.py
new file mode 100644
index 0000000..5849a17
--- /dev/null
+++ b/fiksofficial/python-modules/PyInstall.py
@@ -0,0 +1,109 @@
+# meta developer: @pymodule
+# requires: cryptography
+
+__version__ = (1, 0, 1)
+
+import base64
+import logging
+from hashlib import sha256
+
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import padding
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
+from cryptography.exceptions import InvalidSignature
+
+from telethon.tl.types import Message
+from telethon import functions, types
+from typing import Optional
+
+from .. import loader, utils
+
+logger = logging.getLogger(__name__)
+
+pubkey_data = """
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0S50qdajfeRmKqS+sBsn
+VYYJL8loDMkfMf55flSPkhwwAwKbHk9i+VxRxHs32/J/LHxPR0ix3W6bgzf8m1/A
+79uu2WkMrkfcIrAaOoz07EqHdyyD7MEZuHIAm977uQfdYgseOMa2uclYgNppJf35
+8oqGP7+0+ks5IxzNLn8/7zeo6DrlyOVJ2lgv860NXPQ+WqTttMovkjDTTwBthE8i
+WMg02r6fo+GFafeyaTRHusPAGqg2oZ3VFIxcsJFVqgxmGJkbQVGgSuPwHWM5yPGi
+gx0uB71i6y4NXk/PpoYdQMDanOFJvYe7JBpiktcqk8LB/PqPEm4ctsdGFiu9PR6K
+wrzo0fK9zbpbPyiAHaCC/0/LkfWT7Cdc9bECDPaSGgJJde9wUpDoz+coAc5BfeW5
+6xu9J5fzkiw+zBQNlpkrtjG7JvqAYzul2GB+kDfCdVgkcQEPwBCTn6xGZvtWgE5b
+yzQXaDkaTvbTUkUA41Ab6xsKSmU43otwV+9Rrzxovd+Nk7u9qwj5Ghambt37YNf3
+vUJ9XQFr8uy2nKaPHzGoLgNCBReUyua6aYqMtqCkU1id+dI4HqgDMPlDDGxGV6mK
+Gamdu+eIJHl9chHrlTOxEDetLxZLuAdnoDRzHJyTce6NCsyz8tvwWnKv+8l3R+Bu
+B9EM+BFIFwCXKt85P/eabMcCAwEAAQ==
+-----END PUBLIC KEY-----
+"""
+
+pubkey = serialization.load_pem_public_key(pubkey_data.strip().encode())
+
+@loader.tds
+class PyInstallMod(loader.Module):
+ """Provides PyModule modules installation trough buttons"""
+
+ strings = {
+ "name": "PyInstall",
+ "_cls_doc": "Provides PyModule modules installation trough buttons",
+ "module_downloaded": "Module downloaded!"
+ }
+
+ strings_ru = {
+ "_cls_doc": "Позволяет устанавливать модули от PyModule через кнопки",
+ "module_downloaded": "Модуль загружен!"
+ }
+
+ async def on_dlmod(self, client, db):
+ ent = await self.client(functions.users.GetFullUserRequest('@pymodule_bot'))
+ if ent.full_user.blocked:
+ await self.client(functions.contacts.UnblockRequest('@pymodule_bot'))
+ await self.client.send_message('@pymodule_bot', '/start')
+ await self.client.delete_dialog('@pymodule_bot')
+
+ async def _load_module(self, url: str, message: Optional[Message] = None):
+ loader_m = self.lookup("loader")
+ await loader_m.download_and_install(url, None)
+
+ if getattr(loader_m, "_fully_loaded", getattr(loader_m, "fully_loaded", False)):
+ getattr(
+ loader_m,
+ "_update_modules_in_db",
+ getattr(loader_m, "update_modules_in_db", lambda: None),
+ )()
+
+ async def watcher(self, message: Message):
+ if not isinstance(message, Message):
+ return
+
+ if message.sender_id == 7575984561 and message.raw_text.startswith("#install"):
+ await message.delete()
+
+ try:
+ fileref = message.raw_text.split("#install:")[1].strip().splitlines()[0].strip()
+ sig_b64 = message.raw_text.splitlines()[1].strip()
+ sig = base64.b64decode(sig_b64)
+ except (IndexError, ValueError):
+ logger.error("Invalid #install message format")
+ return
+
+ try:
+ pubkey.verify(
+ signature=sig,
+ data=fileref.encode("utf-8"),
+ padding=padding.PKCS1v15(),
+ algorithm=hashes.SHA256()
+ )
+ logger.info(f"Signature verified successfully for {fileref}")
+ except InvalidSignature:
+ logger.error(f"Got message with non-verified signature ({fileref=})")
+ return
+ except Exception as e:
+ logger.error(f"Signature verification error: {e}")
+ return
+
+ await self._load_module(
+ f"https://raw.githubusercontent.com/fiksofficial/python-modules/refs/heads/main/{fileref}",
+ message
+ )
+ await self.client.send_message('@pymodule_bot', self.strings['module_downloaded'])
diff --git a/fiksofficial/python-modules/full.txt b/fiksofficial/python-modules/full.txt
index 72e180b..0d35b2a 100644
--- a/fiksofficial/python-modules/full.txt
+++ b/fiksofficial/python-modules/full.txt
@@ -24,4 +24,6 @@ deviceinfo
mpi
aigenuser
github
-stream
\ No newline at end of file
+stream
+placeholders+
+PyInstall
\ No newline at end of file
diff --git a/fiksofficial/python-modules/github.py b/fiksofficial/python-modules/github.py
index 4e07e94..d3c8fae 100644
--- a/fiksofficial/python-modules/github.py
+++ b/fiksofficial/python-modules/github.py
@@ -15,6 +15,7 @@
import contextlib
import logging
+import re
from datetime import datetime, timezone
import aiohttp
@@ -51,6 +52,25 @@ EVENT_LABELS = {
}
+def _sanitize_body(text: str, max_len: int = 300) -> str:
+ if not text:
+ return ""
+ text = re.sub(r"", "", text, flags=re.DOTALL)
+ text = re.sub(r"]*>.*? ", "", text, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(r"
]*>", "", text, flags=re.IGNORECASE)
+ ALLOWED = {"b", "i", "u", "s", "code", "pre", "a", "blockquote", "tg-spoiler"}
+ text = re.sub(
+ r"<(/?)([a-zA-Z][a-zA-Z0-9]*)[^>]*>",
+ lambda m: m.group(0) if m.group(2).lower() in ALLOWED else "",
+ text,
+ )
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
+ if len(text) > max_len:
+ text = text[:max_len].rstrip() + "…"
+ return text
+
+
@loader.tds
class GitHubMod(loader.Module):
"""GitHub repository monitor — commits, issues, PRs, releases and stars"""
@@ -396,6 +416,7 @@ class GitHubMod(loader.Module):
),
)
self._sessions: dict[str, aiohttp.ClientSession] = {}
+ self._seen: set[str] = set() # дедупликация событий: "repo:type:id"
async def client_ready(self):
raw = self.db.get("GitHubMod", "dests")
@@ -677,7 +698,7 @@ class GitHubMod(loader.Module):
else:
e_key, action = "pr_open", self.strings("pr_opened")
raw_body = pr.get("body") or ""
- body = (raw_body[:200] + "...") if len(raw_body) > 200 else raw_body
+ body = _sanitize_body(raw_body, max_len=300)
msgs.append(self.strings("notify_pr").format(
e=E[e_key], action=action, repo=repo,
url=pr.get("html_url", "#"),
@@ -743,28 +764,79 @@ class GitHubMod(loader.Module):
since = repo_data.get("last_checked")
if not since:
continue
+
if "push" in events:
c = await self._fetch_commits(repo, since, cid_str)
if c:
- newest_sha = c[-1].get("sha", "")
- branch = await self._fetch_branch_for_commit(repo, newest_sha, cid_str)
- messages += self._fmt_push(repo, c, branch=branch)
+ # дедуп по SHA
+ new_commits = []
+ for commit in c:
+ key = f"{repo}:push:{commit.get('sha', '')}"
+ if key not in self._seen:
+ self._seen.add(key)
+ new_commits.append(commit)
+ if new_commits:
+ newest_sha = new_commits[-1].get("sha", "")
+ branch = await self._fetch_branch_for_commit(repo, newest_sha, cid_str)
+ messages += self._fmt_push(repo, new_commits, branch=branch)
+
if "issues" in events:
i = await self._fetch_issues(repo, since, cid_str)
if i:
- messages += self._fmt_issues(repo, i)
+ new_issues = []
+ for issue in i:
+ # ключ: repo:issue:number:state (state меняется — open/closed)
+ key = f"{repo}:issue:{issue.get('number')}:{issue.get('state')}"
+ if key not in self._seen:
+ self._seen.add(key)
+ new_issues.append(issue)
+ if new_issues:
+ messages += self._fmt_issues(repo, new_issues)
+
if "pull_request" in events:
p = await self._fetch_prs(repo, since, cid_str)
if p:
- messages += self._fmt_prs(repo, p)
+ new_prs = []
+ for pr in p:
+ merged = pr.get("merged_at") is not None
+ state = pr.get("state", "open")
+ # ключ включает финальное состояние PR
+ phase = "merged" if merged else state
+ key = f"{repo}:pr:{pr.get('number')}:{phase}"
+ if key not in self._seen:
+ self._seen.add(key)
+ new_prs.append(pr)
+ if new_prs:
+ messages += self._fmt_prs(repo, new_prs)
+
if "release" in events:
r = await self._fetch_releases(repo, since, cid_str)
if r:
- messages += self._fmt_releases(repo, r)
+ new_releases = []
+ for rel in r:
+ key = f"{repo}:release:{rel.get('id', rel.get('tag_name'))}"
+ if key not in self._seen:
+ self._seen.add(key)
+ new_releases.append(rel)
+ if new_releases:
+ messages += self._fmt_releases(repo, new_releases)
+
if "star" in events:
s = await self._fetch_stargazers(repo, since, cid_str)
if s:
- messages += self._fmt_star(repo, s)
+ new_stars = []
+ for star in s:
+ user = (star.get("sender") or {}).get("login", "")
+ key = f"{repo}:star:{user}"
+ if key not in self._seen:
+ self._seen.add(key)
+ new_stars.append(star)
+ if new_stars:
+ messages += self._fmt_star(repo, new_stars)
+
+ # Ограничиваем размер _seen чтобы не распухал в памяти
+ if len(self._seen) > 2000:
+ self._seen = set(list(self._seen)[-1000:])
for text in messages:
try:
@@ -1032,3 +1104,4 @@ class GitHubMod(loader.Module):
"""- Open GitHub Monitor control panel"""
await self._render_main_menu(message)
+
\ No newline at end of file
diff --git a/fiksofficial/python-modules/placeholders+.py b/fiksofficial/python-modules/placeholders+.py
new file mode 100644
index 0000000..a58f96b
--- /dev/null
+++ b/fiksofficial/python-modules/placeholders+.py
@@ -0,0 +1,650 @@
+# ______ ___ ___ _ _
+# ____ | ___ \ | \/ | | | | |
+# / __ \| |_/ / _| . . | ___ __| |_ _| | ___
+# / / _` | __/ | | | |\/| |/ _ \ / _` | | | | |/ _ \
+# | | (_| | | | |_| | | | | (_) | (_| | |_| | | __/
+# \ \__,_\_| \__, \_| |_/\___/ \__,_|\__,_|_|\___|
+# \____/ __/ |
+# |___/
+
+# На модуль распространяется лицензия "GNU General Public License v3.0"
+# https://github.com/all-licenses/GNU-General-Public-License-v3.0
+
+# meta developer: @pymodule
+
+import logging
+import platform
+import socket
+import os
+import time
+import aiohttp
+import psutil
+import json
+import random
+from datetime import datetime, timezone, timedelta
+from typing import Optional, Dict, Any
+from collections import OrderedDict
+
+from .. import loader, utils, validators
+from herokutl.tl.functions.users import GetFullUserRequest
+from herokutl.tl.functions.payments import GetStarsStatusRequest
+
+logger = logging.getLogger(__name__)
+
+class LRUCache:
+ """LRU-кэш с TTL"""
+ def __init__(self, max_size: int = 100, ttl: int = 300):
+ self.cache = OrderedDict()
+ self.max_size = max_size
+ self.ttl = ttl
+ self.timestamps = {}
+
+ def get(self, key: str) -> Optional[Any]:
+ if key not in self.cache:
+ return None
+
+ if time.time() - self.timestamps[key] > self.ttl:
+ del self.cache[key]
+ del self.timestamps[key]
+ return None
+
+ self.cache.move_to_end(key)
+ return self.cache[key]
+
+ def set(self, key: str, value: Any):
+ if len(self.cache) >= self.max_size:
+ oldest = next(iter(self.cache))
+ del self.cache[oldest]
+ del self.timestamps[oldest]
+
+ self.cache[key] = value
+ self.timestamps[key] = time.time()
+
+@loader.tds
+class PlaceholdersMod(loader.Module):
+ """Плейсхолдеры"""
+ strings = {"name": "Placeholders+"}
+
+ def __init__(self):
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue(
+ "timezone",
+ 5,
+ "Часовой пояс (offset от UTC)",
+ validator=validators.Integer(),
+ ),
+ loader.ConfigValue(
+ "weather_city",
+ "Oral",
+ "Город для погоды",
+ validator=validators.String(),
+ ),
+ loader.ConfigValue(
+ "lastfm_user",
+ "",
+ "Last.FM username",
+ validator=validators.String(),
+ ),
+ loader.ConfigValue(
+ "crypto_address",
+ "YOUR_WALLET_ADDRESS",
+ "Крипто-кошелёк",
+ validator=validators.String(),
+ ),
+ loader.ConfigValue(
+ "card_number",
+ "**** **** **** ****",
+ "Номер карты",
+ validator=validators.String(),
+ ),
+ loader.ConfigValue(
+ "donate_site",
+ "Boosty:https://boosty.to/yourname",
+ "Донат: имя:ссылка",
+ validator=validators.String(),
+ ),
+ loader.ConfigValue(
+ "channel",
+ "@yourchannel",
+ "Канал",
+ validator=validators.String(),
+ ),
+ loader.ConfigValue(
+ "social_network",
+ "https://vk.com/your",
+ "Соцсеть",
+ validator=validators.String(),
+ ),
+ )
+ self.cache = LRUCache(max_size=100, ttl=300)
+
+ async def client_ready(self):
+ self.session = aiohttp.ClientSession()
+
+ self.me = await self._client.get_me()
+ self.full_me = await self._client(GetFullUserRequest(self.me))
+
+ try:
+ stars_status = await self._client(GetStarsStatusRequest(entity="me"))
+ self.stars_balance = stars_status.balance
+ except Exception:
+ self.stars_balance = 0
+
+ self.tz = timezone(timedelta(hours=self.config["timezone"]))
+ self.weekdays_ru = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"]
+
+ self._register_placeholders()
+
+ def _register_placeholders(self):
+ placeholders = [
+ ("username", self.get_username, "Username"),
+ ("name", self.get_name, "Имя"),
+ ("surname", self.get_surname, "Фамилия"),
+ ("bio_description", self.get_bio, "Описание"),
+ ("user_id", self.get_user_id, "ID"),
+ ("phone_number", self.get_phone, "Телефон"),
+ ("dc_id", self.get_dc_id, "DC ID"),
+ ("amount_stars", self.get_stars, "Stars"),
+ ("premium_check", self.get_premium_check, "Дата окончания Premium"),
+
+ ("dollars_in_rub", self.get_usd_to_rub, "USD → RUB"),
+ ("rub_in_dollars", self.get_rub_to_usd, "RUB → USD"),
+ ("usdt_in_rub", self.get_usdt_to_rub, "USDT → RUB"),
+ ("rub_in_usdt", self.get_rub_to_usdt, "RUB → USDT"),
+ ("ton_in_rub", self.get_ton_to_rub, "TON → RUB"),
+ ("rub_in_ton", self.get_rub_to_ton, "RUB → TON"),
+ ("btc_in_rub", self.get_btc_to_rub, "BTC → RUB"),
+ ("eth_in_rub", self.get_eth_to_rub, "ETH → RUB"),
+ ("stars_in_rub", self.get_stars_to_rub, "Stars → RUB"),
+ ("stars_in_ton", self.get_stars_to_ton, "Stars → TON"),
+ ("stars_in_usdt", self.get_stars_to_usdt, "Stars → USDT"),
+
+ ("os_uptime", self.get_os_uptime, "Аптайм системы"),
+ ("internet_usage", self.get_internet_usage, "Статистика трафика"),
+ ("speedtest", self.get_speedtest, "Скорость интернета"),
+ ("host", self.get_host, "Hostname ОС"),
+ ("shell", self.get_shell, "Оболочка"),
+ ("gpu", self.get_gpu, "GPU"),
+ ("disk", self.get_disk, "Использование диска"),
+ ("local_ip", self.get_local_ip, "Локальный IP"),
+ ("user_and_hostname", self.get_user_hostname, "user@hostname"),
+
+ ("time", self.get_time, "Время"),
+ ("date", self.get_date, "Дата"),
+ ("day_of_the_week", self.get_weekday, "День недели"),
+ ("data_and_time", self.get_date_time, "Дата и время"),
+ ("data_and_time_and_day_of_the_week", self.get_full_date_time_weekday, "Дата, время, день недели"),
+ ("weather", self.get_weather_condition, "Погода"),
+ ("outdoor_temperature", self.get_temperature, "Температура"),
+ ("weather_and_temperature", self.get_weather_temp, "Погода и температура"),
+ ("humidity", self.get_humidity, "Влажность"),
+ ("pressure", self.get_pressure, "Давление"),
+ ("wind_speed", self.get_wind_speed, "Скорость ветра"),
+
+ ("my_crypto_address", self.get_crypto_address, "Крипто-адрес"),
+ ("my_card_number", self.get_card_number, "Номер карты"),
+ ("my_donate_site", self.get_donate_site, "Донат"),
+ ("my_channel", self.get_channel, "Канал"),
+ ("my_social_network", self.get_social, "Соцсеть"),
+
+ ("now_playing", self.get_now_playing, "Сейчас играет"),
+ ("last_fm_user_and_now_playing", self.get_user_and_playing, "Last.FM + трек"),
+ ("song_name", self.get_song_name, "Название трека"),
+ ("song_artist", self.get_song_artist, "Артист"),
+ ("last_fm_user", self.get_lastfm_user, "Last.FM username"),
+ ("lastfm_stats", self.get_lastfm_stats, "Last.FM статистика"),
+ ]
+
+ for name, func, desc in placeholders:
+ utils.register_placeholder(name, func, desc)
+
+ async def get_premium_check(self):
+ if not getattr(self.me, "premium", False):
+ return "Нет Premium"
+
+ # premium_until отсутствует в публичном MTProto API herokutl/Telethon —
+ # пробуем достать его, но не падаем если поля нет
+ until = None
+ try:
+ until = getattr(self.full_me.full_user, "premium_until", None)
+ # Иногда это datetime, иногда unix timestamp (int)
+ if isinstance(until, datetime):
+ until = until.timestamp()
+ except Exception:
+ until = None
+
+ if not until:
+ return "✅ Premium активен"
+
+ if until < time.time():
+ return "⚠️ Премиум истёк"
+
+ end_date = datetime.fromtimestamp(until, tz=self.tz)
+ days_left = (end_date.date() - datetime.now(self.tz).date()).days
+ formatted = end_date.strftime("%d.%m.%Y")
+ return f"✅ до {formatted} (ещё {days_left} дн.)"
+
+ async def get_username(self):
+ return f"@{self.me.username}" if self.me.username else "Нет"
+
+ async def get_name(self):
+ return self.me.first_name or "Нет"
+
+ async def get_surname(self):
+ return self.me.last_name or "Нет"
+
+ async def get_bio(self):
+ return self.full_me.full_user.about or "Нет описания"
+
+ async def get_user_id(self):
+ return str(self.me.id)
+
+ async def get_phone(self):
+ return self.me.phone or "Скрыт"
+
+ async def get_dc_id(self):
+ return str(self.me.dc_id if hasattr(self.me, "dc_id") else "Неизвестно")
+
+ async def get_stars(self):
+ return f"{self.stars_balance:,}".replace(",", " ") if self.stars_balance else "0"
+
+ async def get_usd_to_rub(self):
+ cache_key = "usd_rub"
+ cached = self.cache.get(cache_key)
+ if cached:
+ return cached
+
+ try:
+ async with self.session.get("https://www.cbr-xml-daily.ru/daily_json.js") as resp:
+ data = await resp.json()
+ rate = data["Valute"]["USD"]["Value"]
+ result = f"1 USD ≈ {rate:.2f} RUB"
+ self.cache.set(cache_key, result)
+ return result
+ except Exception:
+ try:
+ async with self.session.get("https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json") as resp:
+ data = await resp.json()
+ rate = data["usd"]["rub"]
+ result = f"1 USD ≈ {rate:.2f} RUB"
+ self.cache.set(cache_key, result)
+ return result
+ except Exception:
+ return "Курс USD недоступен"
+
+ async def get_rub_to_usd(self):
+ usd_rub = await self.get_usd_to_rub()
+ if "≈" in usd_rub:
+ try:
+ rate = float(usd_rub.split("≈")[1].strip().split()[0])
+ return f"1 RUB ≈ {1/rate:.4f} USD"
+ except Exception:
+ pass
+ return "Курс RUB недоступен"
+
+ async def get_usdt_to_rub(self):
+ return await self.get_usd_to_rub() # USDT ≈ USD
+
+ async def get_rub_to_usdt(self):
+ return await self.get_rub_to_usd()
+
+ async def get_ton_to_rub(self):
+ cache_key = "ton_rub"
+ cached = self.cache.get(cache_key)
+ if cached:
+ return cached
+
+ try:
+ async with self.session.get("https://api.coingecko.com/api/v3/simple/price?ids=toncoin&vs_currencies=rub") as resp:
+ data = await resp.json()
+ rate = data["toncoin"]["rub"]
+ result = f"1 TON ≈ {rate:.2f} RUB"
+ self.cache.set(cache_key, result)
+ return result
+ except Exception:
+ return "Курс TON недоступен"
+
+ async def get_rub_to_ton(self):
+ ton_rub = await self.get_ton_to_rub()
+ if "≈" in ton_rub:
+ try:
+ rate = float(ton_rub.split("≈")[1].strip().split()[0])
+ return f"1 RUB ≈ {1/rate:.6f} TON"
+ except Exception:
+ pass
+ return "Курс недоступен"
+
+ async def get_btc_to_rub(self):
+ cache_key = "btc_rub"
+ cached = self.cache.get(cache_key)
+ if cached:
+ return cached
+
+ try:
+ async with self.session.get("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=rub") as resp:
+ data = await resp.json()
+ rate = data["bitcoin"]["rub"]
+ result = f"1 BTC ≈ {rate:,.0f} RUB"
+ self.cache.set(cache_key, result)
+ return result
+ except Exception:
+ return "Курс BTC недоступен"
+
+ async def get_eth_to_rub(self):
+ cache_key = "eth_rub"
+ cached = self.cache.get(cache_key)
+ if cached:
+ return cached
+
+ try:
+ async with self.session.get("https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=rub") as resp:
+ data = await resp.json()
+ rate = data["ethereum"]["rub"]
+ result = f"1 ETH ≈ {rate:,.0f} RUB"
+ self.cache.set(cache_key, result)
+ return result
+ except Exception:
+ return "Курс ETH недоступен"
+
+ async def get_stars_to_rub(self):
+ return "1 Star ≈ 85 RUB"
+
+ async def get_stars_to_ton(self):
+ return "1 Star ≈ 0.012 TON"
+
+ async def get_stars_to_usdt(self):
+ return "1 Star ≈ 0.92 USDT"
+
+ async def get_os_uptime(self):
+ boot = datetime.fromtimestamp(psutil.boot_time())
+ delta = datetime.now() - boot
+ days = delta.days
+ hours, remainder = divmod(delta.seconds, 3600)
+ minutes, _ = divmod(remainder, 60)
+
+ if days > 0:
+ return f"{days}d {hours}h {minutes}m"
+ else:
+ return f"{hours}h {minutes}m"
+
+ async def get_internet_usage(self):
+ try:
+ net = psutil.net_io_counters()
+ sent_gb = net.bytes_sent // (1024**3)
+ recv_gb = net.bytes_recv // (1024**3)
+ return f"↑ {sent_gb} GB │ ↓ {recv_gb} GB"
+ except Exception:
+ return "↑ 0 GB │ ↓ 0 GB"
+
+ async def get_speedtest(self):
+ cache_key = "speedtest"
+ cached = self.cache.get(cache_key)
+ if cached:
+ return cached
+
+ test_urls = [
+ "https://proof.ovh.net/files/10Mb.dat",
+ "http://ipv4.download.thinkbroadband.com/10MB.zip",
+ "https://speedtest.ftp.otenet.gr/files/test10Mb.db"
+ ]
+
+ for url in test_urls:
+ try:
+ start = time.time()
+ async with self.session.get(url, timeout=10) as resp:
+ chunk_size = 1024 * 1024
+ total = 0
+ async for chunk in resp.content.iter_chunked(chunk_size):
+ total += len(chunk)
+ if total >= chunk_size:
+ break
+
+ duration = time.time() - start
+ if duration > 0:
+ speed_mbps = (total * 8) / (duration * 1024 * 1024)
+ result = f"≈ {speed_mbps:.1f} Mbps"
+ self.cache.set(cache_key, result)
+ return result
+ except Exception:
+ continue
+
+ return "Тест скорости недоступен"
+
+ async def get_host(self):
+ return platform.node() or "Неизвестно"
+
+ async def get_shell(self):
+ return os.environ.get("SHELL", "Неизвестно").split("/")[-1]
+
+ async def get_gpu(self):
+ return "N/A (Cloud)"
+
+ async def get_disk(self):
+ try:
+ usage = psutil.disk_usage("/")
+ percent = (usage.used / usage.total) * 100
+ used_gb = usage.used // (1024**3)
+ total_gb = usage.total // (1024**3)
+ return f"{used_gb} GB / {total_gb} GB ({percent:.1f}%)"
+ except Exception:
+ return "Диск недоступен"
+
+ async def get_local_ip(self):
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ ip = s.getsockname()[0]
+ s.close()
+ return ip
+ except Exception:
+ return "Неизвестно"
+
+ async def get_user_hostname(self):
+ user = os.getlogin() if hasattr(os, 'getlogin') else os.environ.get("USER", "user")
+ host = await self.get_host()
+ return f"{user}@{host}"
+
+ async def get_time(self):
+ return datetime.now(self.tz).strftime("%H:%M:%S")
+
+ async def get_date(self):
+ return datetime.now(self.tz).strftime("%d.%m.%Y")
+
+ async def get_weekday(self):
+ return self.weekdays_ru[datetime.now(self.tz).weekday()]
+
+ async def get_date_time(self):
+ return datetime.now(self.tz).strftime("%d.%m.%Y %H:%M")
+
+ async def get_full_date_time_weekday(self):
+ now = datetime.now(self.tz)
+ return f"{now.strftime('%d.%m.%Y %H:%M')} ({self.weekdays_ru[now.weekday()]})"
+
+ async def get_weather_condition(self):
+ data = await self._get_weather_data()
+ return data.get("condition", "Неизвестно")
+
+ async def get_temperature(self):
+ data = await self._get_weather_data()
+ return data.get("temp", "??°C")
+
+ async def get_weather_temp(self):
+ data = await self._get_weather_data()
+ return data.get("weather_temp", "??")
+
+ async def get_humidity(self):
+ data = await self._get_weather_data()
+ return data.get("humidity", "??%")
+
+ async def get_pressure(self):
+ data = await self._get_weather_data()
+ return data.get("pressure", "?? гПа")
+
+ async def get_wind_speed(self):
+ data = await self._get_weather_data()
+ return data.get("wind", "?? м/с")
+
+ async def _get_weather_data(self):
+ city = self.config["weather_city"]
+
+ cache_key = f"weather_{city}"
+ cached = self.cache.get(cache_key)
+ if cached:
+ return cached
+
+ try:
+ async with self.session.get(f"http://wttr.in/{city}?format=j1&lang=ru") as resp:
+ if resp.status == 200:
+ data = await resp.json()
+ c = data["current_condition"][0]
+ weather_data = {
+ "condition": c["lang_ru"][0]["value"],
+ "temp": f"{c['temp_C']}°C",
+ "weather_temp": f"{c['lang_ru'][0]['value']} {c['temp_C']}°C",
+ "humidity": f"{c['humidity']}%",
+ "pressure": f"{c['pressure']} мм",
+ "wind": f"{c['windspeedKmph']} км/ч",
+ }
+ self.cache.set(cache_key, weather_data)
+ return weather_data
+ except Exception:
+ pass
+
+ default = {
+ "condition": "Неизвестно",
+ "temp": "??°C",
+ "weather_temp": "??",
+ "humidity": "??%",
+ "pressure": "?? мм",
+ "wind": "?? км/ч",
+ }
+ self.cache.set(cache_key, default)
+ return default
+
+ async def get_crypto_address(self):
+ return self.config["crypto_address"]
+
+ async def get_card_number(self):
+ return self.config["card_number"]
+
+ async def get_donate_site(self):
+ val = self.config["donate_site"]
+ if ":" in val:
+ name, link = val.split(":", 1)
+ return f'{name.strip()}'
+ return val
+
+ async def get_channel(self):
+ ch = self.config["channel"]
+ if ch.startswith("@"):
+ return f'{ch}'
+ return ch
+
+ async def get_social(self):
+ return self.config["social_network"]
+
+ async def get_lastfm_user(self):
+ return self.config["lastfm_user"] or "Не указан"
+
+ async def get_now_playing(self):
+ track = await self._get_current_track()
+ if not track:
+ return "🎵 Ничего не играет"
+ return f"🎵 {track['name']} — {track['artist']}"
+
+ async def get_user_and_playing(self):
+ user = await self.get_lastfm_user()
+ track = await self._get_current_track()
+ if not track:
+ return f"{user}: ничего не играет"
+ return f"{user}: {track['name']} — {track['artist']}"
+
+ async def get_song_name(self):
+ track = await self._get_current_track()
+ return track["name"] if track else "—"
+
+ async def get_song_artist(self):
+ track = await self._get_current_track()
+ return track["artist"] if track else "—"
+
+ async def get_lastfm_stats(self):
+ user = self.config["lastfm_user"]
+ if not user:
+ return "Укажите Last.FM username"
+
+ cache_key = f"lastfm_stats_{user}"
+ cached = self.cache.get(cache_key)
+ if cached:
+ return cached
+
+ api_key = "460cda35be2fbf4f28e8ea7a38580730"
+
+ try:
+ async with self.session.get(
+ "http://ws.audioscrobbler.com/2.0/",
+ params={
+ "method": "user.getinfo",
+ "user": user,
+ "api_key": api_key,
+ "format": "json"
+ }
+ ) as resp:
+ data = await resp.json()
+ if "user" in data:
+ stats = data["user"]
+ result = f"🎵 {stats['playcount']} скробблов"
+ self.cache.set(cache_key, result)
+ return result
+ except Exception:
+ pass
+
+ return "Статистика недоступна"
+
+ async def _get_current_track(self):
+ user = self.config["lastfm_user"]
+ if not user:
+ return None
+
+ cache_key = f"lastfm_track_{user}"
+ cached = self.cache.get(cache_key)
+ if cached:
+ return cached
+
+ api_key = "460cda35be2fbf4f28e8ea7a38580730"
+
+ try:
+ async with self.session.get(
+ "http://ws.audioscrobbler.com/2.0/",
+ params={
+ "method": "user.getrecenttracks",
+ "user": user,
+ "api_key": api_key,
+ "format": "json",
+ "limit": 1
+ }
+ ) as resp:
+ data = await resp.json()
+ tracks = data.get("recenttracks", {}).get("track", [])
+
+ if tracks:
+ track = tracks[0]
+ now_playing = "@attr" in track and "nowplaying" in track["@attr"]
+
+ result = {
+ "name": track["name"],
+ "artist": track["artist"]["#text"],
+ "now_playing": now_playing
+ }
+ self.cache.set(cache_key, result)
+ return result
+ except Exception:
+ pass
+
+ return None
+
+ async def on_unload(self):
+ utils.unregister_placeholders(self.__class__.__name__)
+ try:
+ await self.session.close()
+ except Exception:
+ pass
\ No newline at end of file
diff --git a/fiksofficial/python-modules/stream.py b/fiksofficial/python-modules/stream.py
index bd048d4..771b5f2 100644
--- a/fiksofficial/python-modules/stream.py
+++ b/fiksofficial/python-modules/stream.py
@@ -1 +1,435 @@
-# Security issue in this module. RTMP Key doesn't hide in config with vaildator Hidden, because of that, we will wait for update from developer to fix it
+
+import asyncio
+import mimetypes
+import os
+import subprocess
+import time
+
+from .. import loader, utils
+from ..inline.types import InlineCall
+
+def detect_type(path: str) -> str:
+ mime, _ = mimetypes.guess_type(path)
+ if not mime:
+ return "video"
+ if mime.startswith("video"):
+ return "video"
+ if mime.startswith("audio"):
+ return "audio"
+ if mime.startswith("image"):
+ return "image"
+ return "video"
+
+TYPE_ICON = {"video": "🎬", "audio": "🎵", "image": "🖼️"}
+PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"]
+TUNES = ["zerolatency", "film", "animation", "grain", "stillimage", "fastdecode"]
+SCALES = ["off", "426x240", "640x360", "854x480", "1280x720", "1920x1080", "2560x1440"]
+FPS_OPT = [24, 25, 30, 48, 60]
+
+def build_cmd(file_path: str, rtmp_url: str, cfg: dict) -> list:
+ preset = cfg.get("preset", "veryfast")
+ tune = cfg.get("tune", "zerolatency")
+ vbr = cfg.get("vbitrate", "2000k")
+ abr = cfg.get("abitrate", "128k")
+ fps = str(cfg.get("fps", 30))
+ res = cfg.get("resolution", None)
+ threads = str(cfg.get("threads", 0))
+ gop = str(int(fps) * 2)
+ bufsize = str(int(vbr.replace("k", "")) * 2) + "k"
+ ftype = detect_type(file_path)
+
+ base = ["ffmpeg", "-re", "-stream_loop", "-1", "-threads", threads]
+ vf_scale = f",scale={res}" if res else ""
+ common_v = [
+ "-c:v", "libx264", "-preset", preset, "-tune", tune,
+ "-pix_fmt", "yuv420p", "-profile:v", "baseline",
+ "-r", fps, "-g", gop, "-keyint_min", gop, "-sc_threshold", "0",
+ "-b:v", vbr, "-maxrate", vbr, "-bufsize", bufsize,
+ ]
+ common_a = ["-c:a", "aac", "-b:a", abr, "-ar", "44100"]
+ out = ["-f", "flv", rtmp_url]
+
+ if ftype == "video":
+ vf = ["-vf", f"scale=trunc(iw/2)*2:trunc(ih/2)*2{vf_scale}"] if res else []
+ return base + ["-i", file_path] + common_v + vf + common_a + out
+ if ftype == "audio":
+ size = res or "1280x720"
+ return (
+ base
+ + ["-i", file_path, "-f", "lavfi", "-i", f"color=c=black:s={size}:r={fps}"]
+ + ["-shortest"] + common_v + common_a
+ + ["-map", "1:v:0", "-map", "0:a:0"] + out
+ )
+ if ftype == "image":
+ scale_vf = f"scale=trunc(iw/2)*2:trunc(ih/2)*2{vf_scale}"
+ return (
+ base
+ + ["-loop", "1", "-i", file_path, "-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo"]
+ + ["-vf", scale_vf] + common_v
+ + ["-shortest"] + common_a
+ + ["-map", "0:v:0", "-map", "1:a:0"] + out
+ )
+ raise ValueError(f"Unsupported: {ftype}")
+
+@loader.tds
+class StreamMod(loader.Module):
+ """📡 RTMP media streaming"""
+ strings = {
+ "name": "Stream",
+ "status_active": "▶️ Stream is live\n\n{icon} {file}\n⏱ Time: {elapsed}\n🔢 PID: {pid}\n📡 {rtmp}\n🎥 {vbr} | {fps}fps | {preset}\n🔊 {abr}\n📋 Queue: {queue}",
+ "status_idle": "⏸ Stream is not active",
+ "status_queue": "\n📋 Queue: {n}",
+ "stopped": "⏹ Stream stopped.",
+ "no_rtmp": "❌ RTMP not configured!\nTap a button to set it up.",
+ "downloading": "⏳ Downloading…",
+ "dl_failed": "❌ Failed to download file.",
+ "queued": "📋 Added to queue ({n})\n{icon} {file}",
+ "not_running": "Not running",
+ "queue_empty": "Queue is empty",
+ "queue_header": "📋 Queue:\n",
+ "settings_title": "⚙️ Stream settings",
+ "btn_stop": "⏹ Stop",
+ "btn_queue": "📋 Queue",
+ "btn_refresh": "🔄 Refresh",
+ "btn_settings": "⚙️ Settings",
+ "btn_status": "📊 Status",
+ "btn_back": "🔙 Back",
+ "btn_preset": "🎞 Preset: {v}",
+ "btn_tune": "🎭 Tune: {v}",
+ "btn_vbr": "🎥 Video: {v}",
+ "btn_abr": "🔊 Audio: {v}",
+ "btn_fps": "📐 FPS: {v}",
+ "btn_res": "🖥 Res: {v}",
+ "btn_threads": "🧵 Threads: {v}",
+ "btn_rtmps": "📡 RTMP URL",
+ "btn_key": "🔑 Stream key",
+ "btn_set_rtmps": "📡 Set RTMP URL",
+ "btn_set_key": "🔑 Set stream key",
+ "ph_vbr": "Video bitrate, e.g. 2000k",
+ "ph_abr": "Audio bitrate, e.g. 128k",
+ "ph_threads": "Thread count (0 = auto)",
+ "ph_rtmps": "rtmp://a.rtmp.youtube.com/live2",
+ "ph_key": "Stream key...",
+ }
+
+ strings_ru = {
+ "_cls_doc": "📡 RTMP стриминг медиафайлов",
+ "status_active": "▶️ Трансляция идёт\n\n{icon} {file}\n⏱ Время: {elapsed}\n🔢 PID: {pid}\n📡 {rtmp}\n🎥 {vbr} | {fps}fps | {preset}\n🔊 {abr}\n📋 В очереди: {queue}",
+ "status_idle": "⏸ Трансляция не активна",
+ "status_queue": "\n📋 В очереди: {n}",
+ "stopped": "⏹ Трансляция остановлена.",
+ "no_rtmp": "❌ RTMP не настроен!\nНажми кнопку чтобы задать прямо сейчас.",
+ "downloading": "⏳ Скачиваю…",
+ "dl_failed": "❌ Не удалось скачать файл.",
+ "queued": "📋 Добавлено в очередь ({n} шт.)\n{icon} {file}",
+ "not_running": "Не запущено",
+ "queue_empty": "Очередь пуста",
+ "queue_header": "📋 Очередь:\n",
+ "settings_title": "⚙️ Настройки трансляции",
+ "btn_stop": "⏹ Стоп",
+ "btn_queue": "📋 Очередь",
+ "btn_refresh": "🔄 Обновить",
+ "btn_settings": "⚙️ Настройки",
+ "btn_status": "📊 Статус",
+ "btn_back": "🔙 Назад",
+ "btn_preset": "🎞 Пресет: {v}",
+ "btn_tune": "🎭 Tune: {v}",
+ "btn_vbr": "🎥 Видео: {v}",
+ "btn_abr": "🔊 Аудио: {v}",
+ "btn_fps": "📐 FPS: {v}",
+ "btn_res": "🖥 Разр: {v}",
+ "btn_threads": "🧵 Треды: {v}",
+ "btn_rtmps": "📡 RTMP URL",
+ "btn_key": "🔑 Ключ",
+ "btn_set_rtmps": "📡 Задать RTMP URL",
+ "btn_set_key": "🔑 Задать ключ",
+ "ph_vbr": "Битрейт видео, напр. 2000k",
+ "ph_abr": "Битрейт аудио, напр. 128k",
+ "ph_threads": "Потоков (0 = авто)",
+ "ph_rtmps": "rtmp://a.rtmp.youtube.com/live2",
+ "ph_key": "Ключ трансляции...",
+ }
+
+ def __init__(self):
+ self._proc: subprocess.Popen | None = None
+ self._file: str | None = None
+ self._started: float | None = None
+ self._queue: list[str] = []
+ self._qtask: asyncio.Task | None = None
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue("rtmps", "", "Base RTMP URL (rtmp://...)"),
+ loader.ConfigValue("key", "", "Stream key"),
+ loader.ConfigValue("preset", "veryfast", "x264 preset",
+ validator=loader.validators.Choice(PRESETS)),
+ loader.ConfigValue("tune", "zerolatency","x264 tune",
+ validator=loader.validators.Choice(TUNES)),
+ loader.ConfigValue("vbitrate", "2000k", "Video bitrate (e.g. 1500k, 3000k)"),
+ loader.ConfigValue("abitrate", "128k", "Audio bitrate (e.g. 64k, 192k)"),
+ loader.ConfigValue("fps", 30, "Frames per second",
+ validator=loader.validators.Integer(minimum=1, maximum=120)),
+ loader.ConfigValue("resolution", "", "Output resolution (e.g. 1280x720, empty = no scaling)"),
+ loader.ConfigValue("threads", 0, "FFmpeg thread count (0 = auto)",
+ validator=loader.validators.Integer(minimum=0, maximum=64)),
+ loader.ConfigValue("loop", True, "Loop the file indefinitely",
+ validator=loader.validators.Boolean()),
+ loader.ConfigValue("reconnect", True, "Auto-restart on stream disconnect",
+ validator=loader.validators.Boolean()),
+ )
+
+ def _s(self, key: str, **kw) -> str:
+ return self.strings[key].format(**kw) if kw else self.strings[key]
+
+ def _running(self) -> bool:
+ return self._proc is not None and self._proc.poll() is None
+
+ def _stop(self):
+ if self._proc:
+ try:
+ self._proc.terminate()
+ self._proc.wait(timeout=5)
+ except Exception:
+ try:
+ self._proc.kill()
+ except Exception:
+ pass
+ self._proc = None
+ if self._file and os.path.exists(self._file):
+ try:
+ os.remove(self._file)
+ except Exception:
+ pass
+ self._file = None
+ self._started = None
+
+ def _launch(self, path: str):
+ cfg = {k: self.config[k] for k in ("preset", "tune", "vbitrate", "abitrate", "fps", "threads")}
+ cfg["resolution"] = self.config["resolution"] or None
+ rtmp = f"{self.config['rtmps'].rstrip('/')}/{self.config['key']}"
+ self._proc = subprocess.Popen(build_cmd(path, rtmp, cfg), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ self._file = path
+ self._started = time.time()
+
+ def _elapsed(self) -> str:
+ if not self._started:
+ return "00:00:00"
+ e = int(time.time() - self._started)
+ return f"{e//3600:02d}:{(e%3600)//60:02d}:{e%60:02d}"
+
+ def _status_text(self) -> str:
+ if not self._running():
+ txt = self._s("status_idle")
+ if self._queue:
+ txt += self._s("status_queue", n=len(self._queue))
+ return txt
+ ftype = detect_type(self._file or "")
+ rtmp = f"{self.config['rtmps'].rstrip('/')}/{self.config['key'][:4]}***"
+ return self._s(
+ "status_active",
+ icon=TYPE_ICON.get(ftype, "📄"),
+ file=os.path.basename(self._file or "?"),
+ elapsed=self._elapsed(),
+ pid=self._proc.pid if self._proc else "—",
+ rtmp=rtmp,
+ vbr=self.config["vbitrate"],
+ fps=self.config["fps"],
+ preset=self.config["preset"],
+ abr=self.config["abitrate"],
+ queue=len(self._queue),
+ )
+
+ def _res_label(self) -> str:
+ r = self.config["resolution"]
+ return r if r else "auto"
+
+ def _thr_label(self) -> str:
+ t = self.config["threads"]
+ return str(t) if t else "auto"
+
+ def _main_markup(self) -> list:
+ running = self._running()
+ return [
+ [
+ {"text": self._s("btn_stop"), "callback": self._cb_stop} if running
+ else {"text": self._s("btn_queue"), "callback": self._cb_queue},
+ {"text": self._s("btn_refresh"), "callback": self._cb_refresh},
+ ],
+ [
+ {"text": self._s("btn_settings"), "callback": self._cb_settings},
+ {"text": self._s("btn_status"), "callback": self._cb_status},
+ ],
+ ]
+
+ def _settings_markup(self) -> list:
+ return [
+ [
+ {"text": self._s("btn_preset", v=self.config["preset"]), "callback": self._cb_set_preset},
+ {"text": self._s("btn_tune", v=self.config["tune"]), "callback": self._cb_set_tune},
+ ],
+ [
+ {"text": self._s("btn_vbr", v=self.config["vbitrate"]),
+ "input": self._s("ph_vbr"), "handler": self._ih_vbr},
+ {"text": self._s("btn_abr", v=self.config["abitrate"]),
+ "input": self._s("ph_abr"), "handler": self._ih_abr},
+ ],
+ [
+ {"text": self._s("btn_fps", v=self.config["fps"]), "callback": self._cb_set_fps},
+ {"text": self._s("btn_res", v=self._res_label()), "callback": self._cb_set_res},
+ ],
+ [
+ {"text": self._s("btn_threads", v=self._thr_label()),
+ "input": self._s("ph_threads"), "handler": self._ih_threads},
+ ],
+ [
+ {"text": self._s("btn_rtmps"),
+ "input": self._s("ph_rtmps"), "handler": self._ih_rtmps},
+ {"text": self._s("btn_key"),
+ "input": self._s("ph_key"), "handler": self._ih_key},
+ ],
+ [{"text": self._s("btn_back"), "callback": self._cb_back}],
+ ]
+
+ async def _ih_vbr(self, call: InlineCall, query: str):
+ q = query.strip()
+ if q.endswith("k") and q[:-1].isdigit():
+ self.config["vbitrate"] = q
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _ih_abr(self, call: InlineCall, query: str):
+ q = query.strip()
+ if q.endswith("k") and q[:-1].isdigit():
+ self.config["abitrate"] = q
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _ih_threads(self, call: InlineCall, query: str):
+ q = query.strip()
+ if q.isdigit():
+ self.config["threads"] = int(q)
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _ih_rtmps(self, call: InlineCall, query: str):
+ q = query.strip()
+ if q.startswith("rtmp"):
+ self.config["rtmps"] = q.rstrip("/")
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _ih_key(self, call: InlineCall, query: str):
+ q = query.strip()
+ if q:
+ self.config["key"] = q
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _cb_refresh(self, call: InlineCall):
+ await call.edit(self._status_text(), reply_markup=self._main_markup())
+
+ async def _cb_status(self, call: InlineCall):
+ await call.answer(self._elapsed() if self._running() else self._s("not_running"))
+
+ async def _cb_stop(self, call: InlineCall):
+ self._queue.clear()
+ if self._qtask:
+ self._qtask.cancel()
+ self._qtask = None
+ self._stop()
+ await call.edit(self._s("stopped"), reply_markup=self._main_markup())
+
+ async def _cb_queue(self, call: InlineCall):
+ if not self._queue:
+ await call.answer(self._s("queue_empty"), show_alert=True)
+ return
+ lines = [f"{i}. {TYPE_ICON.get(detect_type(f), '📄')} {os.path.basename(f)}"
+ for i, f in enumerate(self._queue, 1)]
+ await call.answer(self._s("queue_header") + "\n".join(lines), show_alert=True)
+
+ async def _cb_back(self, call: InlineCall):
+ await call.edit(self._status_text(), reply_markup=self._main_markup())
+
+ async def _cb_settings(self, call: InlineCall):
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _cb_set_preset(self, call: InlineCall):
+ cur = self.config["preset"]
+ self.config["preset"] = PRESETS[(PRESETS.index(cur) + 1) % len(PRESETS)]
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _cb_set_tune(self, call: InlineCall):
+ cur = self.config["tune"]
+ self.config["tune"] = TUNES[(TUNES.index(cur) + 1) % len(TUNES)]
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _cb_set_fps(self, call: InlineCall):
+ cur = self.config["fps"]
+ self.config["fps"] = FPS_OPT[(FPS_OPT.index(cur) + 1) % len(FPS_OPT)] if cur in FPS_OPT else 30
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ async def _cb_set_res(self, call: InlineCall):
+ cur = self.config["resolution"] or "off"
+ idx = SCALES.index(cur) if cur in SCALES else 0
+ nxt = SCALES[(idx + 1) % len(SCALES)]
+ self.config["resolution"] = "" if nxt == "off" else nxt
+ await call.edit(self._s("settings_title"), reply_markup=self._settings_markup())
+
+ @loader.command(ru_doc="[ответ на медиа] – запустить трансляцию")
+ async def stream(self, message):
+ """[reply to media] — start stream or add to queue"""
+ if not self.config["rtmps"] or not self.config["key"]:
+ await self.inline.form(
+ self._s("no_rtmp"),
+ message=message,
+ reply_markup=[
+ [{"text": self._s("btn_set_rtmps"), "input": self._s("ph_rtmps"), "handler": self._ih_rtmps}],
+ [{"text": self._s("btn_set_key"), "input": self._s("ph_key"), "handler": self._ih_key}],
+ ],
+ )
+ return
+
+ reply = await message.get_reply_message()
+ if not reply or not reply.media:
+ await self.inline.form(
+ self._status_text(),
+ message=message,
+ reply_markup=self._main_markup(),
+ )
+ return
+
+ status = await utils.answer(message, self._s("downloading"))
+ path = await reply.download_media(file=f"/tmp/stream_{int(time.time())}")
+ if not path:
+ await status.edit(self._s("dl_failed"))
+ return
+ await status.delete()
+
+ if self._running():
+ self._queue.append(path)
+ await self.inline.form(
+ self._s("queued", n=len(self._queue), icon=TYPE_ICON.get(detect_type(path), "📄"), file=os.path.basename(path)),
+ message=message,
+ reply_markup=self._main_markup(),
+ )
+ return
+
+ self._stop()
+ self._launch(path)
+ await self.inline.form(
+ self._status_text(),
+ message=message,
+ reply_markup=self._main_markup(),
+ )
+
+ @loader.command(ru_doc="– панель управления трансляцией")
+ async def streamctl(self, message):
+ """– open stream control panel"""
+ await self.inline.form(
+ self._status_text(),
+ message=message,
+ reply_markup=self._main_markup(),
+ )
+
+ @loader.command(ru_doc="– остановить трансляцию и очистить очередь")
+ async def streamstop(self, message):
+ """– stop stream and clear queue"""
+ self._queue.clear()
+ if self._qtask:
+ self._qtask.cancel()
+ self._qtask = None
+ self._stop()
+ await utils.answer(message, self._s("stopped"))
\ No newline at end of file
diff --git a/mead0wsss/mead0wsMods/SenderGifts.py b/mead0wsss/mead0wsMods/SenderGifts.py
index 195a232..b925606 100644
--- a/mead0wsss/mead0wsMods/SenderGifts.py
+++ b/mead0wsss/mead0wsMods/SenderGifts.py
@@ -1,5 +1,5 @@
# -- version --
-__version__ = (1, 2, 3)
+__version__ = (1, 2, 4)
# -- version --
@@ -14,6 +14,8 @@ __version__ = (1, 2, 3)
# meta developer: @mead0wssMods x @nullmod
+# meta banner: https://files.catbox.moe/nie3ef.jpg
+# banner by: @SunnexGB
# scope: heroku_only
from .. import loader, utils
@@ -22,10 +24,12 @@ from herokutl.tl.types import InputInvoiceStarGift, TextWithEntities
from herokutl.errors.rpcerrorlist import BadRequestError
import logging
import herokutl
+import aiohttp
+import json
@loader.tds
class SenderGifts(loader.Module):
- """Модуль для отправки подарков Telegram прямиком в чате"""
+ """Модуль для отправки обычных и удаленных подарков Telegram прямиком в чате"""
strings = {
"name": "SenderGifts",
@@ -43,25 +47,27 @@ class SenderGifts(loader.Module):
"min_stars_error": "❌ Недостаточно звезд для отправки минимального подарка!",
"no_available_gifts": "❌ Нет доступных подарков для вашего баланса",
"balance_error": "❌ Ошибка при проверке баланса",
+ "user_disallowed_gifts": "❌ Данный пользователь не принимает подарки!",
"btn_public": "📢 Публично",
"btn_anon": "🕵️ Анонимно",
}
+ # резерв
regular_gifts = {
- 15: [
+ 15:[
{"id": 5170145012310081615, "emoji": "❤️", "name": "Сердце"},
{"id": 5170233102089322756, "emoji": "🧸", "name": "Мишка"},
],
- 25: [
+ 25:[
{"id": 5170250947678437525, "emoji": "🎁", "name": "Подарок"},
{"id": 5168103777563050263, "emoji": "🌹", "name": "Роза"},
],
- 50: [
+ 50:[
{"id": 5170144170496491616, "emoji": "🎂", "name": "Тортик"},
{"id": 5170314324215857265, "emoji": "💐", "name": "Цветы"},
{"id": 5170564780938756245, "emoji": "🚀", "name": "Ракета"},
],
- 100: [
+ 100:[
{"id": 5168043875654172773, "emoji": "🏆", "name": "Кубок"},
{"id": 5170690322832818290, "emoji": "💍", "name": "Кольцо"},
{"id": 5170521118301225164, "emoji": "💎", "name": "Алмаз"},
@@ -71,32 +77,52 @@ class SenderGifts(loader.Module):
unique_gifts = {
"new_year": {
"name": "🎄 Новогодние подарки",
- "gifts": [
+ "gifts":[
{"id": 5922558454332916696, "emoji": "🎄", "name": "Ёлка", "price": 50},
{"id": 5956217000635139069, "emoji": "🧸", "name": "Новогодний мишка", "price": 50},
]
},
"valentines": {
"name": "💘 День святого валентина",
- "gifts": [
+ "gifts":[
{"id": 5800655655995968830, "emoji": "🧸", "name": "14 Февраля мишка", "price": 50},
{"id": 5801108895304779062, "emoji": "💘", "name": "14 Февраля сердце", "price": 50},
]
},
"march_8th": {
"name": "🌷 8 Марта",
- "gifts": [
+ "gifts":[
{"id": 5866352046986232958, "emoji": "🧸", "name": "8 Марта мишка", "price": 50},
]
},
- "saint_patricks_day ": {
+ "saint_patricks_day": {
"name": "💰 День святого патрика",
- "gifts": [
+ "gifts":[
{"id": 5893356958802511476, "emoji": "🧸", "name": "Лепрекон мишка", "price": 50},
]
+ },
+ "april_1th": {
+ "name": "🤪 1 Апреля",
+ "gifts":[
+ {"id": 5935895822435615975, "emoji": "🧸", "name": "1 Апреля мишка", "price": 50}
+ ]
}
}
+ async def fetch_gifts_from_github(self):
+ url = "https://raw.githubusercontent.com/mead0wsss/mead0wsMods/main/gifts.json"
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, timeout=5) as response:
+ if response.status == 200:
+ data = await response.json(content_type=None)
+ if "regular_gifts" in data:
+ self.regular_gifts = {int(k): v for k, v in data["regular_gifts"].items()}
+ if "unique_gifts" in data:
+ self.unique_gifts = data["unique_gifts"]
+ except Exception as e:
+ logging.error(f"Не удалось загрузить подарки с GitHub: {e}")
+
async def get_star_balance(self):
try:
balance_info = (await self.client(GetStarsStatusRequest("me")))
@@ -108,6 +134,8 @@ class SenderGifts(loader.Module):
@loader.command()
async def sendgift(self, message):
"""- - отправить подарок пользователю (* - необязательный параметр.) Поддерживается реплай режим."""
+
+ await self.fetch_gifts_from_github()
args = utils.get_args_html(message)
reply = await message.get_reply_message()
if reply:
@@ -152,7 +180,6 @@ class SenderGifts(loader.Module):
helper_msg = await self.inline.form("🪐", balance_msg)
-
await self._show_main_menu_logic(helper_msg, user.id, text, balance, message.id, answer=True)
async def _show_main_menu_logic(self, msg_or_call, user_id, text, balance, msg_id, answer=False):
@@ -162,13 +189,11 @@ class SenderGifts(loader.Module):
except:
user_display = f"ID: {user_id}"
- buttons = [
- [{
+ buttons = [[{
"text": "🎁 Обычные подарки",
"callback": self._show_regular_categories,
"args": (user_id, text, balance, msg_id),
- }],
- [{
+ }],[{
"text": "✨ Уникальные подарки",
"callback": self._show_unique_categories,
"args": (user_id, text, balance, msg_id),
@@ -192,10 +217,10 @@ class SenderGifts(loader.Module):
except:
user_display = f"ID: {user_id}"
- available_categories = [price for price in self.regular_gifts.keys() if balance >= price]
+ available_categories =[price for price in self.regular_gifts.keys() if balance >= price]
buttons = []
- row = []
+ row =[]
for price in sorted(available_categories):
row.append({
"text": f"{price} ⭐",
@@ -226,7 +251,7 @@ class SenderGifts(loader.Module):
except:
user_display = f"ID: {user_id}"
- buttons = []
+ buttons =[]
for cat_id, cat_data in self.unique_gifts.items():
if any(balance >= gift["price"] for gift in cat_data["gifts"]):
buttons.append([{
@@ -256,7 +281,7 @@ class SenderGifts(loader.Module):
async def _show_category(self, call, user_id, price, text, balance, msg_id):
gifts = self.regular_gifts[price]
buttons = []
- row = []
+ row =[]
for gift in gifts:
row.append({
"text": gift["emoji"],
@@ -265,7 +290,7 @@ class SenderGifts(loader.Module):
})
if len(row) == 3:
buttons.append(row)
- row = []
+ row =[]
if row:
buttons.append(row)
@@ -299,7 +324,7 @@ class SenderGifts(loader.Module):
})
if len(row) == 3:
buttons.append(row)
- row = []
+ row =[]
if row:
buttons.append(row)
@@ -326,8 +351,7 @@ class SenderGifts(loader.Module):
else:
back_callback = self._show_unique_category_gifts
- buttons = [
- [
+ buttons = [[
{
"text": self.strings["btn_public"],
"callback": self._send_gift,
@@ -338,8 +362,7 @@ class SenderGifts(loader.Module):
"callback": self._send_gift,
"args": (user_id, gift_id, text, gift_emoji, msg_id, balance, True)
}
- ],
- [
+ ],[
{
"text": "⬅️ Назад",
"callback": back_callback,
@@ -369,7 +392,7 @@ class SenderGifts(loader.Module):
user,
gift_id,
hide_name=hide_name,
- message=TextWithEntities(text, entities) if text else TextWithEntities("", [])
+ message=TextWithEntities(text, entities) if text else TextWithEntities("",[])
)
form = await self.client(GetPaymentFormRequest(inv))
result = await self.client(SendStarsFormRequest(form.form_id, inv))
@@ -382,6 +405,11 @@ class SenderGifts(loader.Module):
self.strings["not_enough_stars"].format(gift_emoji),
reply_markup=None
)
+ elif "USER_DISALLOWED_STARGIFTS" in str(e):
+ await call.edit(
+ self.strings["user_disallowed_gifts"].format(gift_emoji),
+ reply_markup=None
+ )
else:
logging.error(f"Error sending gift: {e}")
await call.edit(
diff --git a/mead0wsss/mead0wsMods/gifts.json b/mead0wsss/mead0wsMods/gifts.json
new file mode 100644
index 0000000..a7c4456
--- /dev/null
+++ b/mead0wsss/mead0wsMods/gifts.json
@@ -0,0 +1,62 @@
+{
+ "regular_gifts": {
+ "15":[
+ {"id": 5170145012310081615, "emoji": "❤️", "name": "Сердце"},
+ {"id": 5170233102089322756, "emoji": "🧸", "name": "Мишка"}
+ ],
+ "25":[
+ {"id": 5170250947678437525, "emoji": "🎁", "name": "Подарок"},
+ {"id": 5168103777563050263, "emoji": "🌹", "name": "Роза"}
+ ],
+ "50":[
+ {"id": 5170144170496491616, "emoji": "🎂", "name": "Тортик"},
+ {"id": 5170314324215857265, "emoji": "💐", "name": "Цветы"},
+ {"id": 5170564780938756245, "emoji": "🚀", "name": "Ракета"}
+ ],
+ "100":[
+ {"id": 5168043875654172773, "emoji": "🏆", "name": "Кубок"},
+ {"id": 5170690322832818290, "emoji": "💍", "name": "Кольцо"},
+ {"id": 5170521118301225164, "emoji": "💎", "name": "Алмаз"}
+ ]
+ },
+ "unique_gifts": {
+ "new_year": {
+ "name": "🎄 Новогодние подарки",
+ "gifts":[
+ {"id": 5922558454332916696, "emoji": "🎄", "name": "Ёлка", "price": 50},
+ {"id": 5956217000635139069, "emoji": "🧸", "name": "Новогодний мишка", "price": 50}
+ ]
+ },
+ "valentines": {
+ "name": "💘 День святого валентина",
+ "gifts":[
+ {"id": 5800655655995968830, "emoji": "🧸", "name": "14 Февраля мишка", "price": 50},
+ {"id": 5801108895304779062, "emoji": "💘", "name": "14 Февраля сердце", "price": 50}
+ ]
+ },
+ "march_8th": {
+ "name": "🌷 8 Марта",
+ "gifts":[
+ {"id": 5866352046986232958, "emoji": "🧸", "name": "8 Марта мишка", "price": 50}
+ ]
+ },
+ "saint_patricks_day": {
+ "name": "💰 День святого патрика",
+ "gifts":[
+ {"id": 5893356958802511476, "emoji": "🧸", "name": "Лепрекон мишка", "price": 50}
+ ]
+ },
+ "april_1th": {
+ "name": "🤪 1 Апреля",
+ "gifts":[
+ {"id": 5935895822435615975, "emoji": "🧸", "name": "1 Апреля мишка", "price": 50}
+ ]
+ },
+ "easter_day": {
+ "name": "🥚 Пасха",
+ "gifts":[
+ {"id": 5969796561943660080, "emoji": "🧸", "name": "Пасхальный мишка", "price": 50}
+ ]
+ }
+ }
+}
diff --git a/radiocycle/Modules/PicToStories.py b/radiocycle/Modules/PicToStories.py
index 84bbd9a..b244a2c 100644
--- a/radiocycle/Modules/PicToStories.py
+++ b/radiocycle/Modules/PicToStories.py
@@ -179,7 +179,7 @@ class PicToStoriesMod(loader.Module):
all_albums = await self.client(
functions.stories.GetAlbumsRequest(peer=types.InputPeerSelf(), hash=0)
)
-
+
target = next(
(a for a in all_albums.albums if getattr(a, 'title', '') == args),
None
@@ -201,11 +201,11 @@ class PicToStoriesMod(loader.Module):
title=args,
)
)
- else:
- await self.client(
- functions.stories.TogglePinnedRequest(
- peer=types.InputPeerSelf(), id=story_ids, pinned=True
- )
+
+ await self.client(
+ functions.stories.TogglePinnedRequest(
+ peer=types.InputPeerSelf(), id=story_ids, pinned=True
)
+ )
await utils.answer(message, self.strings("done"))
\ No newline at end of file
diff --git a/radiocycle/Modules/RandomAnimePic.py b/radiocycle/Modules/RandomAnimePic.py
new file mode 100644
index 0000000..f609d7e
--- /dev/null
+++ b/radiocycle/Modules/RandomAnimePic.py
@@ -0,0 +1,181 @@
+# =======================================
+# _ __ __ __ _
+# | |/ /___ | \/ | ___ __| |___
+# | ' // _ \ | |\/| |/ _ \ / _` / __|
+# | . \ __/ | | | | (_) | (_| \__ \
+# |_|\_\___| |_| |_|\___/ \__,_|___/
+# @ke_mods
+# =======================================
+#
+# LICENSE: CC BY-ND 4.0 (Attribution-NoDerivatives 4.0 International)
+# --------------------------------------
+# https://creativecommons.org/licenses/by-nd/4.0/legalcode
+# =======================================
+
+# meta developer: @ke_mods
+# requires: pillow
+
+import asyncio
+import logging
+import traceback
+from logging import basicConfig
+from io import BytesIO
+
+import requests
+from PIL import Image
+
+from .. import loader, utils
+
+basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+@loader.tds
+class RandomAnimePicMod(loader.Module):
+ strings = {
+ "name": "RandomAnimePic",
+ "img": "✅ Your anime pic\n🔗 URL: {}",
+ "loading": "✨ Loading image...",
+ "categories_loading": "✨ Loading categories...",
+ "categories": "✅ Available categories\n{}
",
+ "no_categories": "🚫 Categories not found",
+ "error": "🚫 An unexpected error occurred...",
+ }
+
+ strings_ru = {
+ "img": "✅ Ваша аниме-картинка\n🔗 Ссылка: {}",
+ "loading": "✨ Загрузка изображения...",
+ "categories_loading": "✨ Загрузка категорий...",
+ "categories": "✅ Доступные категории\n{}
",
+ "no_categories": "🚫 Категории не найдены",
+ "error": "🚫 Произошла непредвиденная ошибка...",
+ }
+
+ RANDOM_API_URL = "https://api.nekosapi.com/v4/images/random"
+ IMAGES_API_URL = "https://api.nekosapi.com/v4/images"
+ CATEGORIES_SCAN_LIMIT = 500
+
+ def __init__(self):
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue(
+ "category",
+ "",
+ "Category",
+ validator=loader.validators.String(),
+ ),
+ )
+
+ @loader.command(ru_doc="- получить рандомную аниме-картинку 👀")
+ async def rapiccmd(self, message):
+ """- fetch random anime-pic 👀"""
+ await utils.answer(message, self.strings("loading"))
+
+ try:
+ category = self.config["category"].strip()
+
+ def fetch_image():
+ params = {"limit": 1, "rating": ["safe"]}
+
+ if category:
+ params["tags"] = [category]
+
+ response = requests.get(self.RANDOM_API_URL, params=params, timeout=15)
+ response.raise_for_status()
+
+ data = response.json()
+ if not isinstance(data, list) or not data:
+ raise ValueError("API returned empty response")
+
+ url = data[0].get("url")
+ if not url:
+ raise ValueError("API response does not contain image url")
+
+ image_response = requests.get(url, timeout=20)
+ image_response.raise_for_status()
+
+ image_stream = BytesIO(image_response.content)
+ image = Image.open(image_stream)
+ image.load()
+
+ output = BytesIO()
+ if "A" in image.getbands() or image.mode == "P":
+ image.convert("RGBA").save(output, format="PNG")
+ output.name = "anime.png"
+ else:
+ image.convert("RGB").save(output, format="JPEG", quality=95)
+ output.name = "anime.jpg"
+
+ output.seek(0)
+ return url, output
+
+ url, file = await asyncio.to_thread(fetch_image)
+ await utils.answer(
+ message,
+ self.strings("img").format(url),
+ file=file
+ )
+
+ except Exception:
+ logger.error(
+ "Error fetching random anime pic: %s",
+ traceback.format_exc(),
+ )
+ await utils.answer(message, self.strings("error"))
+
+ @loader.command(ru_doc="- получить список категорий из API 👀")
+ async def racategoriescmd(self, message):
+ """- fetch categories from api 👀"""
+ await utils.answer(message, self.strings("categories_loading"))
+
+ try:
+ def fetch_categories() -> list[str]:
+ tags = set()
+ offset = 0
+
+ while offset < self.CATEGORIES_SCAN_LIMIT:
+ response = requests.get(
+ self.IMAGES_API_URL,
+ params={
+ "limit": 100,
+ "offset": offset,
+ "rating": ["safe"],
+ },
+ timeout=20,
+ )
+ response.raise_for_status()
+
+ data = response.json()
+ items = data.get("items") or data.get("results") or []
+ if not items:
+ break
+
+ for item in items:
+ tags.update(item.get("tags", []))
+
+ if len(items) < 100:
+ break
+
+ offset += 100
+
+ return sorted(tags)
+
+ categories = await asyncio.to_thread(fetch_categories)
+
+ if not categories:
+ await utils.answer(message, self.strings("no_categories"))
+ return
+
+ formatted_categories = "\n".join(
+ f"{category}" for category in categories
+ )
+ await utils.answer(
+ message,
+ self.strings("categories").format(formatted_categories),
+ )
+
+ except Exception:
+ logger.error(
+ "Error fetching categories: %s",
+ traceback.format_exc(),
+ )
+ await utils.answer(message, self.strings("error"))
\ No newline at end of file
diff --git a/radiocycle/Modules/SpotifyMod.py b/radiocycle/Modules/SpotifyMod.py
index 218588b..d1ea339 100644
--- a/radiocycle/Modules/SpotifyMod.py
+++ b/radiocycle/Modules/SpotifyMod.py
@@ -1,16 +1,12 @@
# █ █ ▀ █▄▀ ▄▀█ █▀█ ▀
# █▀█ █ █ █ █▀█ █▀▄ █
# © Copyright 2022
+# https://t.me/hikariatama
#
-# https://t.me/hikariatama
-#
-# 🔒 Licensed under the GNU AGPLv3
+# 🔒 Licensed under the GNU AGPLv3
# 🌐 https://www.gnu.org/licenses/agpl-3.0.html
-#
-# You CANNOT edit, distribute or redistribute this file without direct permission from the author.
-#
# ORIGINAL MODULE: https://raw.githubusercontent.com/hikariatama/ftg/master/spotify.py
-
+#
# =======================================
# _ __ __ __ _
# | |/ /___ | \/ | ___ __| |___
@@ -24,9 +20,12 @@
# --------------------------------------
# https://creativecommons.org/licenses/by-nd/4.0/legalcode
# =======================================
-
+#
# meta developer: @ke_mods
-# requires: telethon spotipy pillow requests yt-dlp
+# requires: telethon spotipy pillow requests yt-dlp curl_cffi
+# scope: ffmpeg
+
+__version__ = (1, 0)
import asyncio
import contextlib
@@ -45,6 +44,7 @@ import spotipy
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps
from telethon.errors import FloodWaitError
from telethon.tl.functions.account import UpdateProfileRequest
+from telethon.tl.functions.users import GetFullUserRequest
from telethon.tl.types import Message
from .. import loader, utils
@@ -60,7 +60,8 @@ class Banners:
duration: int,
progress: int,
track_cover: bytes,
- font
+ font,
+ blur
):
self.title = title
self.artists = ", ".join(artists) if isinstance(artists, list) else artists
@@ -68,6 +69,7 @@ class Banners:
self.progress = progress
self.track_cover = track_cover
self.font_url = font
+ self.blur_intensity = blur
def _get_font(self, size, font_bytes):
return ImageFont.truetype(io.BytesIO(font_bytes), size)
@@ -86,27 +88,18 @@ class Banners:
def _prepare_background(self, w, h):
bg = Image.open(io.BytesIO(self.track_cover)).convert("RGBA")
- bg = bg.resize((w, h), Image.Resampling.BICUBIC)
- bg = bg.filter(ImageFilter.GaussianBlur(radius=40))
- bg = ImageEnhance.Brightness(bg).enhance(0.4)
+ bg = bg.resize((w, h), Image.Resampling.LANCZOS)
+ bg = bg.filter(ImageFilter.GaussianBlur(radius=self.blur_intensity))
+ bg = ImageEnhance.Brightness(bg).enhance(0.35)
return bg
- def _draw_progress_bar(self, draw, x, y, w, h, progress_pct, color="white", bg_color="#5e5e5e"):
+ def _draw_progress_bar(self, draw, x, y, w, h, progress_pct, color="white", bg_color="#6b6b6b"):
draw.rounded_rectangle((x, y, x + w, y + h), radius=h/2, fill=bg_color)
fill_w = int(w * progress_pct)
if fill_w > 0:
draw.rounded_rectangle((x, y, x + fill_w, y + h), radius=h/2, fill=color)
- dot_radius = h * 1.2
- dot_x = x + fill_w
- dot_y = y + (h / 2)
-
- draw.ellipse(
- (dot_x - dot_radius, dot_y - dot_radius, dot_x + dot_radius, dot_y + dot_radius),
- fill=color
- )
-
def horizontal(self):
W, H = 1500, 600
padding = 60
@@ -127,18 +120,27 @@ class Banners:
text_y_start = 100
text_width_limit = W - text_x - padding
- display_title = self.title
- while title_font.getlength(display_title) > text_width_limit and len(display_title) > 0:
- display_title = display_title[:-1]
- if len(display_title) < len(self.title): display_title += "…"
+ wrapper = textwrap.TextWrapper(width=23)
+ title_lines = wrapper.wrap(self.title)
+
+ if len(title_lines) > 2:
+ title_lines = title_lines[:2]
+ title_lines[-1] += "..."
+ current_y = text_y_start
+ title_height = title_font.getbbox("Ah")[3] + 15
+
+ for line in title_lines:
+ draw.text((text_x, current_y), line, font=title_font, fill="white")
+ current_y += title_height
+
display_artist = self.artists
while artist_font.getlength(display_artist) > text_width_limit and len(display_artist) > 0:
display_artist = display_artist[:-1]
if len(display_artist) < len(self.artists): display_artist += "…"
- draw.text((text_x, text_y_start), display_title, font=title_font, fill="white")
- draw.text((text_x, text_y_start + 70), display_artist, font=artist_font, fill="#B3B3B3")
+ artist_y = current_y + 10
+ draw.text((text_x, artist_y), display_artist, font=artist_font, fill="#b3b3b3")
cur_time = f"{(self.progress//1000//60):02}:{(self.progress//1000%60):02}"
dur_time = f"{(self.duration//1000//60):02}:{(self.duration//1000%60):02}"
@@ -188,36 +190,46 @@ class Banners:
text_area_y = cover_y + cover_size + 120
text_width_limit = W - (padding * 2)
- display_title = self.title
- while title_font.getlength(display_title) > text_width_limit and len(display_title) > 0:
- display_title = display_title[:-1]
- if len(display_title) < len(self.title): display_title += "…"
+ wrapper = textwrap.TextWrapper(width=23)
+ title_lines = wrapper.wrap(self.title)
+
+ if len(title_lines) > 2:
+ title_lines = title_lines[:2]
+ title_lines[-1] += "..."
+
+ current_y = text_area_y
+ title_height = title_font.getbbox("Ah")[3] + 15
+
+ for line in title_lines:
+ w = title_font.getlength(line)
+ draw.text(((W - w) / 2, current_y), line, font=title_font, fill="white")
+ current_y += title_height
display_artist = self.artists
while artist_font.getlength(display_artist) > text_width_limit and len(display_artist) > 0:
display_artist = display_artist[:-1]
if len(display_artist) < len(self.artists): display_artist += "…"
- title_w = title_font.getlength(display_title)
- draw.text(((W - title_w) / 2, text_area_y), display_title, font=title_font, fill="white")
-
artist_w = artist_font.getlength(display_artist)
- draw.text(((W - artist_w) / 2, text_area_y + 75), display_artist, font=artist_font, fill="#B3B3B3")
+ draw.text(((W - artist_w) / 2, current_y + 15), display_artist, font=artist_font, fill="#b3b3b3")
bar_y = text_area_y + 260
+ if len(title_lines) > 1:
+ bar_y += 60
+
bar_h = 8
bar_w = W - (padding * 2)
prog_pct = self.progress / self.duration if self.duration > 0 else 0
- self._draw_progress_bar(draw, padding, bar_y, bar_w, bar_h, prog_pct, color="white", bg_color="#5e5e5e")
+ self._draw_progress_bar(draw, padding, bar_y, bar_w, bar_h, prog_pct, color="white", bg_color="#6b6b6b")
cur_time = f"{(self.progress//1000//60):02}:{(self.progress//1000%60):02}"
dur_time = f"{(self.duration//1000//60):02}:{(self.duration//1000%60):02}"
- draw.text((padding, bar_y + 40), cur_time, font=time_font, fill="#B3B3B3")
+ draw.text((padding, bar_y + 40), cur_time, font=time_font, fill="white")
dur_w = time_font.getlength(dur_time)
- draw.text((W - padding - dur_w, bar_y + 40), dur_time, font=time_font, fill="#B3B3B3")
+ draw.text((W - padding - dur_w, bar_y + 40), dur_time, font=time_font, fill="white")
by = io.BytesIO()
img.save(by, format="PNG")
@@ -232,389 +244,277 @@ class SpotifyMod(loader.Module):
strings = {
"name": "SpotifyMod",
"need_auth": (
- "❌ Please execute"
+ "❌ Please execute"
" .sauth before performing this action."
),
"on-repeat": (
- "🔄 Set on-repeat."
+ "🔄 Set on-repeat."
),
"off-repeat": (
- "🔄 Stopped track"
+ "🔄 Stopped track"
" repeat."
),
"skipped": (
- "➡️ Skipped track."
+ "➡️ Skipped track."
),
- "playing": "▶️ Playing...",
+ "playing": "▶️ Playing...",
"back": (
- "⬅️ Switched to previous"
+ "⬅️ Switched to previous"
" track"
),
- "paused": "❌ Pause",
+ "paused": "❌ Pause",
"restarted": (
- "✅️ Playing track"
+ "✅️ Playing track"
" from the"
" beginning"
),
"liked": (
- "❤️ Liked current"
+ "❤️ Liked current"
" playback"
),
"unlike": (
- "❌"
+ "❌"
" Unliked current playback"
),
"err": (
- "❌ An error occurred."
+ "❌ An error occurred."
"\n{}"
),
"already_authed": (
- "❌ Already authorized"
+ "❌ Already authorized"
),
"authed": (
- "✅ Authentication"
+ "✅ Authentication"
" successful"
),
"deauth": (
- "🚪 Successfully logged out"
+ "🚪 Successfully logged out"
" of account"
),
"auth": (
- '🔗 Follow this'
+ '🔗 Follow this'
" link, allow access, then enter .scode https://... with"
" the link you received."
),
"no_music": (
- "❌ No music is playing!"
+ "❌ No music is playing!"
),
"dl_err": (
- "❌ Failed to download"
+ "❌ Failed to download"
" track."
),
"volume_changed": (
- "🔊"
+ "🔊"
" Volume changed to {}%."
),
"volume_invalid": (
- "❌ Volume level must be"
+ "❌ Volume level must be"
" a number between 0 and 100."
),
"volume_err": (
- "❌ An error occurred while"
+ "❌ An error occurred while"
" changing volume."
),
"no_volume_arg": (
- "❌ Please specify a"
+ "❌ Please specify a"
" volume level between 0 and 100."
),
"searching_tracks": (
- "🕔 Searching for tracks"
+ "🕔 Searching for tracks"
" matching {}..."
),
"no_search_query": (
- "❌ Please specify a"
+ "❌ Please specify a"
" search query."
),
"no_tracks_found": (
- "❌ No tracks found for"
+ "❌ No tracks found for"
" {}."
),
"search_results": (
- "✅ Search results for"
+ "✅ Search results for"
" {}:\n\n{}"
),
+ "search_results_inline": (
+ "✅ Found {count} results"
+ " for {query}.\nSelect a track:"
+ ),
"downloading_search_track": (
- "🕔 Downloading {}..."
+ "🕔 Downloading {}..."
),
"download_success": (
- "✅ Successfully downloaded {} - {}"
+ "✅ Successfully downloaded {} - {}"
),
"invalid_track_number": (
- "❌ Invalid track number."
+ "❌ Invalid track number."
" Please search first or provide a valid number from the list."
),
"device_list": (
- "📄 Available devices:\n{}"
+ "📄 Available devices:\n{}"
),
"no_devices_found": (
- "❌ No devices found."
+ "❌ No devices found."
),
"device_changed": (
- "✅ Playback transferred to"
+ "✅ Playback transferred to"
" {}."
),
"invalid_device_id": (
- "❌ Invalid device ID."
+ "❌ Invalid device ID."
" Use .sdevice to see available devices."
),
- "search_results_cleared": "✅ Search results cleared",
"autobio": (
- "🎧 Spotify autobio {}"
+ "🎧 Spotify autobio {}"
),
- "no_ytdlp": "❌ yt-dlp not found... Check config or install yt-dlp ({}terminal pip install yt-dlp)",
- "snowt_failed": "\n\n❌ Download failed",
- "uploading_banner": "\n\n🕔 Uploading banner...",
- "downloading_track": "\n\n🕔 Downloading track...",
- "no_playlists": "❌ No playlists found.",
- "playlists_list": "📄 Your playlists:\n\n{}",
- "added_to_playlist": "✅ Added {} to {}",
- "removed_from_playlist": "✅ Removed {} from {}",
- "invalid_playlist_index": "❌ Invalid playlist number.",
- "no_cached_playlists": "❌ Use .splaylists first.",
- "playlist_created": "✅ Playlist {} created.",
- "playlist_deleted": "✅ Playlist {} deleted.",
- "no_playlist_name": "❌ Please specify a playlist name.",
+ "no_ytdlp": "❌ yt-dlp not found... Check config or install yt-dlp ({}terminal pip install yt-dlp)",
+ "snowt_failed": "\n\n❌ Download failed",
+ "uploading_banner": "\n\n🕔 Uploading banner...",
+ "downloading_track": "\n\n🕔 Downloading track...",
+ "no_playlists": "❌ No playlists found.",
+ "playlists_list": "📄 Your playlists:\n\n{}",
+ "added_to_playlist": "✅ Added {} to {}",
+ "removed_from_playlist": "✅ Removed {} from {}",
+ "invalid_playlist_index": "❌ Invalid playlist number.",
+ "no_cached_playlists": "❌ Use .splaylists first.",
+ "playlist_created": "✅ Playlist {} created.",
+ "playlist_deleted": "✅ Playlist {} deleted.",
+ "no_playlist_name": "❌ Please specify a playlist name.",
}
strings_ru = {
"_cls_doc": "Карточка с играющим треком в Spotify.",
"need_auth": (
- "❌ Выполни"
+ "❌ Выполни"
" .sauth перед выполнением этого действия."
),
"err": (
- "❌ Произошла ошибка."
+ "❌ Произошла ошибка."
"\n{}"
),
"on-repeat": (
- "🔄 Включен повтор трека."
+ "🔄 Включен повтор трека."
),
"off-repeat": (
- "🔄 Повтор трека отключён."
+ "🔄 Повтор трека отключён."
),
"skipped": (
- "➡️ Трек пропущен."
+ "➡️ Трек пропущен."
),
- "playing": "▶️ Играет...",
+ "playing": "▶️ Играет...",
"back": (
- "⬅️ Переключено на предыдущий трек"
+ "⬅️ Переключено на предыдущий трек"
),
- "paused": "❌ Пауза",
+ "paused": "❌ Пауза",
"restarted": (
- "✅️ Воспроизведение трека с начала..."
+ "✅️ Воспроизведение трека с начала..."
),
"liked": (
- "❤️ Текущий трек добавлен в избранное"
+ "❤️ Текущий трек добавлен в избранное"
),
"unlike": (
- "❌ Убрал лайк с текущего трека"
+ "❌ Убрал лайк с текущего трека"
),
"already_authed": (
- "❌ Уже авторизован"
+ "❌ Уже авторизован"
),
"authed": (
- "✅ Успешная аутентификация"
+ "✅ Успешная аутентификация"
),
"deauth": (
- "🚪 Успешный выход из аккаунта"
+ "🚪 Успешный выход из аккаунта"
),
"auth": (
- '🔗 Пройдите по этой ссылке, разрешите вход, затем введите .scode https://... с ссылкой которую вы получили.'
+ '🔗 Пройдите по этой ссылке, разрешите вход, затем введите .scode https://... с ссылкой которую вы получили.'
),
"no_music": (
- "❌ Музыка не играет!"
+ "❌ Музыка не играет!"
),
"dl_err": (
- "❌ Не удалось скачать трек."
+ "❌ Не удалось скачать трек."
),
"volume_changed": (
- "🔊"
+ "🔊"
" Громкость изменена на {}%."
),
"volume_invalid": (
- "❌ Уровень громкости должен"
+ "❌ Уровень громкости должен"
" быть числом от 0 до 100."
),
"volume_err": (
- "❌ Произошла ошибка при"
+ "❌ Произошла ошибка при"
" изменении громкости."
),
"no_volume_arg": (
- "❌ Пожалуйста, укажите"
+ "❌ Пожалуйста, укажите"
" уровень громкости от 0 до 100."
),
"searching_tracks": (
- "🕔 Идет поиск треков"
+ "🕔 Идет поиск треков"
" по запросу {}..."
),
"no_search_query": (
- "❌ Пожалуйста, укажите"
+ "❌ Пожалуйста, укажите"
" поисковый запрос."
),
"no_tracks_found": (
- "❌ По запросу '{}'"
+ "❌ По запросу '{}'"
" ничего не найдено."
),
"search_results": (
- "✅ Результаты поиска"
+ "✅ Результаты поиска"
" по запросу {}:\n\n{}"
),
+ "search_results_inline": (
+ "✅ Найдено {count} результатов"
+ " по запросу {query}.\nВыберите трек:"
+ ),
"downloading_search_track": (
- "🕔 Скачиваю {}..."
+ "🕔 Скачиваю {}..."
),
"download_success": (
- "✅ Трек {} - {} успешно скачан."
+ "✅ Трек {} - {} успешно скачан."
),
"invalid_track_number": (
- "❌ Некорректный номер трека."
+ "❌ Некорректный номер трека."
" Сначала выполните поиск или укажите правильный номер из списка."
),
"device_list": (
- "📄 Доступные устройства:\n{}"
+ "📄 Доступные устройства:\n{}"
),
"no_devices_found": (
- "❌ Устройства не найдены."
+ "❌ Устройства не найдены."
),
"device_changed": (
- "✅ Воспроизведение переключено на"
+ "✅ Воспроизведение переключено на"
" {}."
),
"invalid_device_id": (
- "❌ Некорректный ID устройства."
+ "❌ Некорректный ID устройства."
" Используйте .sdevice , чтобы увидеть доступные устройства."
),
- "search_results_cleared": "✅ Результаты поиска очищены",
"autobio": (
- "🎧 Обновление био"
+ "🎧 Обновление био"
" включено {}"
),
- "no_ytdlp": "❌ yt-dlp не найден... Проверьте конфиг или установите yt-dlp ({}terminal pip install yt-dlp)",
- "snowt_failed": "\n\n❌ Ошибка скачивания.",
- "uploading_banner": "\n\n🕔 Загрузка баннера...",
- "downloading_track": "\n\n🕔 Скачивание трека...",
- "no_playlists": "❌ Плейлисты не найдены.",
- "playlists_list": "📄 Ваши плейлисты:\n\n{}",
- "added_to_playlist": "✅ Трек {} добавлен в {}",
- "removed_from_playlist": "✅ Трек {} удален из {}",
- "invalid_playlist_index": "❌ Неверный номер плейлиста.",
- "no_cached_playlists": "❌ Сначала используйте .splaylists.",
- "playlist_created": "✅ Плейлист {} создан.",
- "playlist_deleted": "✅ Плейлист {} удален.",
- "no_playlist_name": "❌ Пожалуйста, укажите название плейлиста.",
- }
- strings_jp = {
- "_cls_doc": "Spotify からのメッセージ",
- "need_auth": (
- "❌ この操作を行う前に "
- ".sauth を実行してください。"
- ),
- "on-repeat": (
- "🔄 リピート再生を設定しました。"
- ),
- "off-repeat": (
- "🔄 リピート再生を解除しました。"
- ),
- "skipped": (
- "➡️ スキップしました。"
- ),
- "playing": "▶️ 再生中...",
- "back": (
- "⬅️ 前のトラックに戻りました。"
- ),
- "paused": "❌ 一時停止",
- "restarted": (
- "✅️ 最初から再生します。"
- ),
- "liked": (
- "❤️ お気に入りに追加しました。"
- ),
- "unlike": (
- "❌"
- " お気に入りから削除しました。"
- ),
- "err": (
- "❌ エラーが発生しました。"
- "\n{}"
- ),
- "already_authed": (
- "❌ 既に認証されています。"
- ),
- "authed": (
- "✅ 認証に成功しました。"
- ),
- "deauth": (
- "🚪 ログアウトしました。"
- ),
- "auth": (
- '🔗 リンクをクリックしてアクセスを許可し、取得したURLを使って .scode https://... を入力してください。'
- ),
- "no_music": (
- "❌ 音楽は再生されていません!"
- ),
- "dl_err": (
- "❌ トラックのダウンロードに失敗しました。"
- ),
- "volume_changed": (
- "🔊"
- " 音量を {}% に変更しました。"
- ),
- "volume_invalid": (
- "❌ 音量は0から100の数字で指定してください。"
- ),
- "volume_err": (
- "❌ 音量の変更中にエラーが発生しました。"
- ),
- "no_volume_arg": (
- "❌ 0から100の間で音量を指定してください。"
- ),
- "searching_tracks": (
- "🕔 {} を検索中..."
- ),
- "no_search_query": (
- "❌ 検索キーワードを指定してください。"
- ),
- "no_tracks_found": (
- "❌ {} は見つかりませんでした。"
- ),
- "search_results": (
- "✅ {} の検索結果:\n\n{}"
- ),
- "downloading_search_track": (
- "🕔 {} をダウンロード中..."
- ),
- "download_success": (
- "✅ {} - {} のダウンロードに成功しました。"
- ),
- "invalid_track_number": (
- "❌ トラック番号が無効です。"
- " 先に検索するか、リストから有効な番号を指定してください。"
- ),
- "device_list": (
- "📄 利用可能なデバイス:\n{}"
- ),
- "no_devices_found": (
- "❌ デバイスが見つかりません。"
- ),
- "device_changed": (
- "✅ 再生デバイスを"
- " {} に切り替えました。"
- ),
- "invalid_device_id": (
- "❌ デバイスIDが無効です。"
- " .sdevice で利用可能なデバイスを確認してください。"
- ),
- "search_results_cleared": "✅ 検索結果をクリアしました。",
- "autobio": (
- "🎧 Spotify AutoBio: {}"
- ),
- "no_ytdlp": "❌ yt-dlpが見つかりません... 設定を確認するか、インストールしてください ({}terminal pip install yt-dlp)",
- "snowt_failed": "\n\n❌ ダウンロードに失敗しました。",
- "uploading_banner": "\n\n🕔 バナーをアップロード中...",
- "downloading_track": "\n\n🕔 トラックをダウンロード中...",
- "no_playlists": "❌ プレイリストが見つかりません。",
- "playlists_list": "📄 あなたのプレイリスト:\n\n{}",
- "added_to_playlist": "✅ {} を {} に追加しました。",
- "removed_from_playlist": "✅ {} を {} から削除しました。",
- "invalid_playlist_index": "❌ プレイリスト番号が無効です。",
- "no_cached_playlists": "❌ 先に .splaylists を使用してください。",
- "playlist_created": "✅ プレイリスト {} を作成しました。",
- "playlist_deleted": "✅ プレイリスト {} を削除しました。",
- "no_playlist_name": "❌ プレイリスト名を指定してください。",
+ "no_ytdlp": "❌ yt-dlp не найден... Проверьте конфиг или установите yt-dlp ({}terminal pip install yt-dlp)",
+ "snowt_failed": "\n\n❌ Ошибка скачивания.",
+ "uploading_banner": "\n\n🕔 Загрузка баннера...",
+ "downloading_track": "\n\n🕔 Скачивание трека...",
+ "no_playlists": "❌ Плейлисты не найдены.",
+ "playlists_list": "📄 Ваши плейлисты:\n\n{}",
+ "added_to_playlist": "✅ Трек {} добавлен в {}",
+ "removed_from_playlist": "✅ Трек {} удален из {}",
+ "invalid_playlist_index": "❌ Неверный номер плейлиста.",
+ "no_cached_playlists": "❌ Сначала используйте .splaylists.",
+ "playlist_created": "✅ Плейлист {} создан.",
+ "playlist_deleted": "✅ Плейлист {} удален.",
+ "no_playlist_name": "❌ Пожалуйста, укажите название плейлиста.",
}
def __init__(self):
self._client_id = "e0708753ab60499c89ce263de9b4f57a"
self._client_secret = "80c927166c664ee98a43a2c0e2981b4a"
+ self.sp = None
self.scope = (
"user-read-playback-state playlist-read-private playlist-read-collaborative"
" user-modify-playback-state user-library-modify"
@@ -636,10 +536,10 @@ class SpotifyMod(loader.Module):
loader.ConfigValue(
"custom_text",
(
- "🎧 Now playing: {track} — {artists}\n"
- "🔗 song.link"
+ "🎧 Now playing: {track} — {artists}\n"
+ "🔗 song.link"
),
- """Custom text, supports {track}, {artists}, {album}, {playlist}, {playlist_owner}, {spotify_url}, {songlink}, {progress}, {duration}, {device} placeholders""",
+ "Custom text, supports {track}, {artists}, {album}, {playlist}, {playlist_owner}, {spotify_url}, {songlink}, {progress}, {duration}, {device} placeholders." + "\n\n" + "ℹ️ Custom placeholders: {}".format(utils.config_placeholders()),
validator=loader.validators.String(),
),
loader.ConfigValue(
@@ -650,8 +550,8 @@ class SpotifyMod(loader.Module):
),
loader.ConfigValue(
"auto_bio_template",
- "🎧 {}",
- lambda: "Template for Spotify AutoBio",
+ "🎧 {title} - {artist}",
+ lambda: "Template for Spotify AutoBio, supports {artist}, {title}",
),
loader.ConfigValue(
"ytdlp_path",
@@ -659,26 +559,53 @@ class SpotifyMod(loader.Module):
"Path to ytdlp binary",
validator=loader.validators.String(),
),
+ loader.ConfigValue(
+ "cookies_path",
+ "",
+ "Path to your cookies for yt-dlp",
+ validator=loader.validators.String(),
+ ),
loader.ConfigValue(
"banner_version",
"horizontal",
lambda: "Banner version",
validator=loader.validators.Choice(["horizontal", "vertical"]),
),
+ loader.ConfigValue(
+ "blur_intensity",
+ 40,
+ lambda: "Blur intensity",
+ validator=loader.validators.Integer(minimum=0),
+ ),
)
+ self._sp_store = {}
+
+ def _init_spotify_client(self) -> bool:
+ token = self.get("acs_tkn") or {}
+ access_token = token.get("access_token")
+ if not access_token:
+ self.sp = None
+ return False
+
+ try:
+ self.sp = spotipy.Spotify(auth=access_token)
+ except Exception:
+ self.sp = None
+ return False
+
+ return True
async def client_ready(self, client, db):
self.font_ready = asyncio.Event()
self._premium = getattr(await client.get_me(), "premium", False)
- try:
- self.sp = spotipy.Spotify(auth=self.get("acs_tkn")["access_token"])
- except Exception:
+ if not self._init_spotify_client():
self.set("acs_tkn", None)
- self.sp = None
- if self.get("autobio", False):
- self.autobio.start()
+ self.bio_task = None
+
+ if self.get("autobio", False) and self.sp:
+ await self.autobio()
def tokenized(func) -> FunctionType:
@functools.wraps(func)
@@ -699,12 +626,23 @@ class SpotifyMod(loader.Module):
async def wrapped(*args, **kwargs):
try:
return await func(*args, **kwargs)
- except Exception:
- logger.exception(traceback.format_exc())
+ except Exception as e:
+ error_msg = str(e)
+ logger.error(f"Error in {func.__name__}: {error_msg}")
+
+ if "NO_ACTIVE_DEVICE" in error_msg:
+ user_error = "No active device"
+ elif "PREMIUM_REQUIRED" in error_msg:
+ user_error = "Spotify Premium is required for this feature"
+ elif "Insufficient client scope" in error_msg:
+ user_error = "Insufficient permissions. Please re-authenticate."
+ else:
+ user_error = f"{type(e).__name__}: {error_msg[:50]}"
+
with contextlib.suppress(Exception):
await utils.answer(
args[1],
- args[0].strings("err").format(traceback.format_exc()),
+ args[0].strings("err").format(user_error),
)
wrapped.__doc__ = func.__doc__
@@ -713,81 +651,409 @@ class SpotifyMod(loader.Module):
return wrapped
- @loader.loop(interval=90)
async def autobio(self):
- try:
- current_playback = self.sp.current_playback()
- track = current_playback["item"]["name"]
- track = re.sub(r"([(].*?[)])", "", track).strip()
- except Exception:
- return
-
- bio = self.config["auto_bio_template"].format(f"{track}")
-
- try:
- await self._client(
- UpdateProfileRequest(about=bio[: 140 if self._premium else 70])
- )
- except FloodWaitError as e:
- logger.info(f"Sleeping {max(e.seconds, 60)} bc of floodwait")
- await asyncio.sleep(max(e.seconds, 60))
- return
+ if getattr(self, "bio_task", None) and not self.bio_task.done():
+ self.bio_task.cancel()
- async def _download_track(self, message, query: str, caption: str = ""):
+ async def _loop():
+ while self.get("autobio", False):
+ try:
+ if not self.sp and not self._init_spotify_client():
+ self.set("autobio", False)
+ await self._restore_original_bio()
+ break
+
+ current_playback = await utils.run_sync(self.sp.current_playback)
+
+ if not current_playback or not current_playback.get("is_playing"):
+ if self.get("last_bio", ""):
+ await self._restore_original_bio(clear_original=False)
+ await asyncio.sleep(10)
+ continue
+
+ item = current_playback.get("item") or {}
+ title = item.get("name") or ""
+ artists = ", ".join(
+ [a.get("name", "") for a in item.get("artists", []) if a.get("name")]
+ )
+
+ if not title:
+ await asyncio.sleep(10)
+ continue
+
+ bio = self.config["auto_bio_template"].format(
+ title=title,
+ artist=artists or "Unknown Artist",
+ ).strip()
+
+ if len(bio) > 70:
+ bio = bio[:69] + "…"
+
+ if bio != self.get("last_bio", ""):
+ await self._client(UpdateProfileRequest(about=bio))
+ self.set("last_bio", bio)
+
+ except FloodWaitError as e:
+ await asyncio.sleep(getattr(e, "seconds", 30) + 1)
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.exception("autobio error: %s", e)
+
+ await asyncio.sleep(self.config.get("BIO_UPDATE_DELAY", 30))
+
+ self.bio_task = asyncio.create_task(_loop())
+
+ async def _get_current_about(self) -> str:
+ full_user = await self._client(GetFullUserRequest("me"))
+ return getattr(full_user.full_user, "about", "") or ""
+
+ async def _restore_original_bio(
+ self,
+ *,
+ clear_original: bool = True,
+ clear_last: bool = True,
+ ):
+ original_bio = self.get("original_bio", None)
+ if original_bio is None:
+ return
+
+ await self._client(UpdateProfileRequest(about=original_bio))
+ if clear_original:
+ self.set("original_bio", None)
+ if clear_last:
+ self.set("last_bio", "")
+
+ def _get_chat_id(self, target):
+ if isinstance(target, int):
+ return target
+ if not target:
+ return None
+ chat_id = getattr(target, "chat_id", None)
+ if chat_id:
+ return chat_id
+ with contextlib.suppress(Exception):
+ return utils.get_chat_id(target)
+ return None
+
+ def _reply_id(self, message):
+ reply_to_id = getattr(message, "reply_to_msg_id", None)
+ if reply_to_id:
+ return reply_to_id
+ reply_to = getattr(message, "reply_to", None)
+ return getattr(reply_to, "reply_to_msg_id", None) if reply_to else None
+
+ async def _download_track(
+ self,
+ target,
+ query,
+ caption=None,
+ track_name=None,
+ artists=None,
+ log_context=None,
+ reply_to_id=None,
+ ) -> bool:
dl_dir = os.path.join(os.getcwd(), "spotifymod")
if not os.path.exists(dl_dir):
os.makedirs(dl_dir, exist_ok=True)
-
+
for f in os.listdir(dl_dir):
try:
os.remove(os.path.join(dl_dir, f))
- except:
+ except Exception:
pass
+ success = False
+ if caption is None:
+ safe_track = utils.escape_html(track_name or "Unknown")
+ safe_artists = utils.escape_html(artists or "Unknown Artist")
+ caption = self.strings("download_success").format(safe_track, safe_artists)
+
+ async def send_text(text: str) -> bool:
+ if target is None:
+ return False
+ if isinstance(target, int):
+ await self._client.send_message(target, text, reply_to=reply_to_id)
+ return True
+ try:
+ await utils.answer(target, text)
+ return True
+ except Exception:
+ chat_id = self._get_chat_id(target)
+ if chat_id is None:
+ return False
+ await self._client.send_message(chat_id, text, reply_to=reply_to_id)
+ return True
+
+ async def send_file(file_path: str) -> bool:
+ if target is None:
+ return False
+ if isinstance(target, int):
+ await self._client.send_file(
+ target,
+ file_path,
+ caption=caption,
+ reply_to=reply_to_id,
+ )
+ return True
+ try:
+ await utils.answer(target, caption, file=file_path)
+ return True
+ except Exception:
+ chat_id = self._get_chat_id(target)
+ if chat_id is None:
+ return False
+ await self._client.send_file(
+ chat_id,
+ file_path,
+ caption=caption,
+ reply_to=reply_to_id,
+ )
+ return True
+
try:
squery = query.replace('"', '').replace("'", "")
- cmd = (
- f'{self.config["ytdlp_path"]} -x --audio-format mp3 --add-metadata '
- f'-o "{dl_dir}/%(title)s [%(id)s].%(ext)s" '
- f'"ytsearch1:{squery}"'
- )
+ cookies = self.config["cookies_path"]
+
+ if cookies:
+ cmd = (
+ f'{self.config["ytdlp_path"]} -x --impersonate="" --cookies {cookies} --audio-format mp3 --add-metadata '
+ f'--audio-quality 0 -o "{dl_dir}/%(title)s [%(id)s].%(ext)s" '
+ f'"ytsearch1:{squery}"'
+ )
+ else:
+ cmd = (
+ f'{self.config["ytdlp_path"]} -x --impersonate="" --audio-format mp3 --add-metadata '
+ f'--audio-quality 0 -o "{dl_dir}/%(title)s [%(id)s].%(ext)s" '
+ f'"ytsearch1:{squery}"'
+ )
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
- await proc.communicate()
+ _, stderr = await proc.communicate()
+ if proc.returncode and log_context:
+ err_text = stderr.decode(errors="ignore").strip() if stderr else ""
+ err_text = err_text[-400:] if err_text else "yt-dlp failed"
+ logger.error("Search download failed (%s): %s", log_context, err_text)
files = [f for f in os.listdir(dl_dir) if f.endswith(".mp3")]
-
+
if files:
- target_file = os.path.join(dl_dir, files[0])
- await utils.answer(message, caption, file=target_file)
+ first = files[0]
+ target_file = os.path.join(dl_dir, first)
+ success = await send_file(target_file)
+ if not success:
+ if log_context:
+ logger.error(
+ "Search download send failed (%s). target=%s chat_id=%s",
+ log_context,
+ type(target).__name__,
+ self._get_chat_id(target),
+ )
+ await send_text(self.strings("dl_err"))
else:
- await utils.answer(message, self.strings("snowt_failed"))
+ if log_context:
+ logger.error("Search download produced no files (%s)", log_context)
+ await send_text(self.strings("snowt_failed"))
except Exception as e:
- logger.error(e)
- await utils.answer(message, self.strings("dl_err"))
-
+ if log_context:
+ logger.exception("Search download error (%s)", log_context)
+ else:
+ logger.error(e)
+ await send_text(self.strings("dl_err"))
+
finally:
if os.path.exists(dl_dir):
for f in os.listdir(dl_dir):
try:
os.remove(os.path.join(dl_dir, f))
- except:
+ except Exception:
pass
+ return success
+ def _short_text(self, text: str, limit: int = 60) -> str:
+ text = " ".join(text.split())
+ if len(text) <= limit:
+ return text
+ if limit <= 3:
+ return text[:limit]
+ return text[: limit - 3] + "..."
+
+
+
+ def _track_info(self, track_info) -> tuple:
+ if isinstance(track_info, dict):
+ track_name = track_info.get("name", "Unknown")
+ artists_list = [
+ a.get("name") for a in track_info.get("artists", []) if a.get("name")
+ ]
+ artists = ", ".join(artists_list) if artists_list else "Unknown Artist"
+ return track_name, artists
+
+ if isinstance(track_info, (list, tuple)):
+ track_name = track_info[0] if len(track_info) > 0 else "Unknown"
+ artists = track_info[1] if len(track_info) > 1 else "Unknown Artist"
+ if not artists:
+ artists = "Unknown Artist"
+ return track_name or "Unknown", artists
+
+ return "Unknown", "Unknown Artist"
+
+ def _search_keyboard(self, tracks: list, chat_id=None, reply_to_id=None) -> list:
+ keyboard = []
+ for track in tracks:
+ track_name, artists = self._track_info(track)
+ label = f"{track_name} — {artists}" if artists else track_name
+ keyboard.append(
+ [
+ {
+ "text": self._short_text(label),
+ "callback": self._inline_download_track,
+ "args": (track_name, artists, reply_to_id, chat_id),
+ }
+ ]
+ )
+
+ return keyboard
+
+ async def _inline_download_track(
+ self,
+ call,
+ track_name: str,
+ artists: str,
+ reply_to_id=None,
+ chat_id=None,
+ ):
+ track_name = track_name or "Unknown"
+ artists = artists or "Unknown Artist"
+
+ with contextlib.suppress(Exception):
+ await call.answer()
+
+ with contextlib.suppress(Exception):
+ await call.edit(self.strings("downloading_track").lstrip(), reply_markup=None)
+
+ target_message = getattr(call, "message", None)
+ if reply_to_id is None:
+ reply_to_id = self._reply_id(target_message)
+
+ if chat_id is None:
+ chat_id = self._get_chat_id(target_message)
+ if chat_id is None:
+ chat_id = getattr(call, "chat_id", None)
+ if chat_id is None:
+ chat_id = self._get_chat_id(call)
+
+ if chat_id is None and target_message is None:
+ logger.error("Inline download missing chat_id (%s - %s)", track_name, artists)
+ with contextlib.suppress(Exception):
+ await call.edit(self.strings("dl_err"), reply_markup=None)
+ return
+
+ target = chat_id if chat_id is not None else target_message
+
+ success = await self._download_track(
+ target,
+ f"{artists} {track_name}",
+ track_name=track_name,
+ artists=artists,
+ log_context=f"{track_name} - {artists}",
+ reply_to_id=reply_to_id,
+ )
+
+ if success:
+ with contextlib.suppress(Exception):
+ await call.delete()
+ else:
+ with contextlib.suppress(Exception):
+ await call.edit(self.strings("dl_err"), reply_markup=None)
+
+ async def _inline_search_tracks(self, query):
+ if not self.get("acs_tkn", False) or not self.sp:
+ return {
+ "title": "Auth required",
+ "description": "Run .sauth",
+ "message": self.strings("need_auth"),
+ }
+
+ query_text = (query.args or "").strip()
+ if not query_text:
+ return {
+ "title": "No query",
+ "description": "Provide search query",
+ "message": self.strings("no_search_query"),
+ }
+
+ try:
+ results = await asyncio.to_thread(
+ self.sp.search,
+ q=query_text,
+ limit=5,
+ type="track",
+ )
+ except Exception as e:
+ return {
+ "title": "Search error",
+ "description": "Try again",
+ "message": self.strings("err").format(
+ utils.escape_html(str(e)[:50])
+ ),
+ }
+
+ if not results or not results["tracks"]["items"]:
+ return {
+ "title": "No results",
+ "description": self._short_text(query_text, limit=60),
+ "message": self.strings("no_tracks_found").format(
+ utils.escape_html(query_text)
+ ),
+ }
+
+ tracks = results["tracks"]["items"]
+ store_id = id(tracks)
+ self._sp_store[store_id] = [(t.get("name", "Unknown"), ", ".join(a.get("name", "") for a in t.get("artists", []) if a.get("name")) or "Unknown Artist") for t in tracks]
+
+ entries = []
+ for i, track in enumerate(tracks):
+ track_name, artists = self._track_info(track)
+ cover_list = track.get("album", {}).get("images", [])
+ thumb = cover_list[0]["url"] if cover_list else None
+
+ entries.append(
+ {
+ "title": self._short_text(track_name, limit=60),
+ "description": self._short_text(artists, limit=60) if artists else "",
+ "message": f"{self.strings('downloading_track').lstrip()}\nspdl_{store_id}_{i}",
+ "thumb": thumb,
+ }
+ )
+
+ return entries
+
+ @loader.inline_handler(ru_doc="<запрос> - поиск треков Spotify.")
+ async def sq(self, query):
+ """ - search Spotify track"""
+ return await self._inline_search_tracks(query)
+
+ @loader.inline_handler(ru_doc="<запрос> - поиск треков Spotify.")
+ async def ssearch(self, query):
+ """ - search Spotify track"""
+ return await self._inline_search_tracks(query)
+
@error_handler
@tokenized
@loader.command(
- ru_doc="- ➕ Добавить текущий трек в плейлист (используйте номер из .splaylists)"
+ ru_doc="| .spla - ➕ Добавить текущий трек в плейлист (используйте номер из .splaylists | .spls)",
+ alias="spla"
)
async def splaylistadd(self, message: Message):
- """- ➕ Add current track to playlist (use number from .splaylists)"""
+ """| .spla - ➕ Add current track to playlist (use number from .splaylists | .spls)"""
args = utils.get_args_raw(message)
if not args or not args.isdigit():
await utils.answer(message, self.strings("invalid_playlist_index"))
@@ -799,7 +1065,6 @@ class SpotifyMod(loader.Module):
if not playlists:
await utils.answer(message, self.strings("no_cached_playlists"))
return
-
if index < 0 or index >= len(playlists):
await utils.answer(message, self.strings("invalid_playlist_index"))
return
@@ -817,23 +1082,17 @@ class SpotifyMod(loader.Module):
playlist_id = playlists[index]["id"]
playlist_name = playlists[index]["name"]
- try:
- self.sp.playlist_add_items(playlist_id, [track_uri])
- except spotipy.exceptions.SpotifyException as e:
- if e.http_status == 403 and "Insufficient client scope" in str(e):
- await utils.answer(message, self.strings("need_auth"))
- return
- raise e
-
+ self.sp.playlist_add_items(playlist_id, [track_uri])
await utils.answer(message, self.strings("added_to_playlist").format(utils.escape_html(full_track_name), utils.escape_html(playlist_name)))
@error_handler
@tokenized
@loader.command(
- ru_doc="- ➖ Удалить текущий трек из плейлиста (используйте номер из .splaylists)"
+ ru_doc="| .splr - ➖ Удалить текущий трек из плейлиста (используйте номер из .splaylists | .spls)",
+ alias="splr"
)
async def splaylistrem(self, message: Message):
- """- ➖ Remove current track from playlist (use number from .splaylists)"""
+ """| .splr - ➖ Remove current track from playlist (use number from .splaylists | .spls)"""
args = utils.get_args_raw(message)
if not args or not args.isdigit():
await utils.answer(message, self.strings("invalid_playlist_index"))
@@ -845,7 +1104,6 @@ class SpotifyMod(loader.Module):
if not playlists:
await utils.answer(message, self.strings("no_cached_playlists"))
return
-
if index < 0 or index >= len(playlists):
await utils.answer(message, self.strings("invalid_playlist_index"))
return
@@ -863,23 +1121,17 @@ class SpotifyMod(loader.Module):
playlist_id = playlists[index]["id"]
playlist_name = playlists[index]["name"]
- try:
- self.sp.playlist_remove_all_occurrences_of_items(playlist_id, [track_uri])
- except spotipy.exceptions.SpotifyException as e:
- if e.http_status == 403 and "Insufficient client scope" in str(e):
- await utils.answer(message, self.strings("need_auth"))
- return
- raise e
-
+ self.sp.playlist_remove_all_occurrences_of_items(playlist_id, [track_uri])
await utils.answer(message, self.strings("removed_from_playlist").format(utils.escape_html(full_track_name), utils.escape_html(playlist_name)))
@error_handler
@tokenized
@loader.command(
- ru_doc="- 🆕 Создать новый плейлист"
+ ru_doc="| .splc - 🆕 Создать новый плейлист",
+ alias="splc"
)
async def splaylistcreate(self, message: Message):
- """- 🆕 Create a new playlist"""
+ """| .splc - 🆕 Create a new playlist"""
name = utils.get_args_raw(message)
if not name:
await utils.answer(message, self.strings("no_playlist_name"))
@@ -892,10 +1144,11 @@ class SpotifyMod(loader.Module):
@error_handler
@tokenized
@loader.command(
- ru_doc="- 🗑 Удалить плейлист (используйте номер из .splaylists)"
+ ru_doc="| .spld - 🗑 Удалить плейлист (используйте номер из .splaylists | .spls)",
+ alias="spld"
)
async def splaylistdelete(self, message: Message):
- """- 🗑 Delete playlist (use number from .splaylists)"""
+ """| .spld - 🗑 Delete playlist (use number from .splaylists | .spls)"""
args = utils.get_args_raw(message)
if not args or not args.isdigit():
await utils.answer(message, self.strings("invalid_playlist_index"))
@@ -907,7 +1160,6 @@ class SpotifyMod(loader.Module):
if not playlists:
await utils.answer(message, self.strings("no_cached_playlists"))
return
-
if index < 0 or index >= len(playlists):
await utils.answer(message, self.strings("invalid_playlist_index"))
return
@@ -921,17 +1173,18 @@ class SpotifyMod(loader.Module):
@error_handler
@tokenized
@loader.command(
- ru_doc="- 📃 Получить все плейлисты"
+ ru_doc="| .spls - 📃 Получить все плейлисты",
+ alias="spls"
)
async def splaylists(self, message: Message):
- """- 📃 Get all playlists"""
+ """| .spls - 📃 Get all playlists"""
user_id = self.sp.me()["id"]
playlists = self.sp.current_user_playlists()
- editable_playlists = []
- for playlist in playlists["items"]:
- if playlist["owner"]["id"] == user_id or playlist["collaborative"]:
- editable_playlists.append(playlist)
+ editable_playlists = [
+ p for p in playlists["items"]
+ if p["owner"]["id"] == user_id or p["collaborative"]
+ ]
self.set("last_playlists", editable_playlists)
@@ -942,7 +1195,7 @@ class SpotifyMod(loader.Module):
count = playlist["tracks"]["total"]
playlist_list_text += f"{i + 1}. {name} ({count} tracks)\n"
- if not playlist_list_text:
+ if playlist_list_text == "":
await utils.answer(message, self.strings("no_playlists"))
else:
await utils.answer(message, self.strings("playlists_list").format(playlist_list_text))
@@ -952,94 +1205,99 @@ class SpotifyMod(loader.Module):
@loader.command(
ru_doc="- ℹ️ Переключить стриминг воспроизведения в био"
)
- async def sbiocmd(self, message: Message):
- """- ℹ️ Toggle bio playback streaming"""
- current = self.get("autobio", False)
- new = not current
- self.set("autobio", new)
+ async def sbiocmd(self, message):
+ """- ℹ️ Toggle streaming playback in bio"""
+ if not getattr(self, "sp", None):
+ await utils.answer(message, self.strings("need_auth"))
+ return
+
+ state = not self.get("autobio", False)
+ self.set("autobio", state)
+
+ if state:
+ self.set("original_bio", await self._get_current_about())
+ self.set("last_bio", "")
+ await self.autobio()
+ else:
+ task = getattr(self, "bio_task", None)
+ if task and not task.done():
+ task.cancel()
+ self.bio_task = None
+ await self._restore_original_bio()
+
await utils.answer(
message,
- self.strings("autobio").format("enabled" if new else "disabled"),
+ self.strings("autobio").format("on" if state else "off"),
)
- if new:
- self.autobio.start()
- else:
- self.autobio.stop()
-
@error_handler
@tokenized
@loader.command(
- ru_doc="- 🔊 Изменить громкость. .svolume <0-100>"
+ ru_doc="| .sv - 🔊 Изменить громкость. .svolume | .sv <0-100>",
+ alias="sv"
)
async def svolume(self, message: Message):
- """- 🔊 Change playback volume. .svolume <0-100>"""
- try:
- args = utils.get_args_raw(message)
- if not args:
- await utils.answer(message, self.strings("no_volume_arg"))
- return
-
- volume_percent = int(args)
- if 0 <= volume_percent <= 100:
- self.sp.volume(volume_percent)
- await utils.answer(message, self.strings("volume_changed").format(volume_percent))
- else:
+ """| .sv - 🔊 Change playback volume. .svolume | .sv <0-100>"""
+ args = utils.get_args_raw(message)
+ if args == "":
+ await utils.answer(message, self.strings("no_volume_arg"))
+ else:
+ try:
+ volume_percent = int(args)
+ if 0 <= volume_percent <= 100:
+ self.sp.volume(volume_percent)
+ await utils.answer(message, self.strings("volume_changed").format(volume_percent))
+ else:
+ await utils.answer(message, self.strings("volume_invalid"))
+ except ValueError:
await utils.answer(message, self.strings("volume_invalid"))
- except ValueError:
- await utils.answer(message, self.strings("volume_invalid"))
- except Exception:
- await utils.answer(message, self.strings("volume_err"))
@error_handler
@tokenized
@loader.command(
ru_doc=(
- "- 🎵 Выбрать устройство для воспроизведения. Например: .sdevice \n"
- "- 📝 Показать список устройств: .sdevice"
- )
+ "| .sd - 🎵 Выбрать устройство для воспроизведения. Например: .sdevice или .sdevice | .sd для вывода списка устройств"
+ ),
+ alias="sd"
)
async def sdevicecmd(self, message: Message):
- """- 🎵 Set preferred playback device. Usage: .sdevice or .sdevice to list devices"""
+ """| .sd - 🎵 Set preferred playback device. Usage: .sdevice or .sdevice | .sd to list devices"""
args = utils.get_args_raw(message)
devices = self.sp.devices()["devices"]
- if not args:
+ if args == "":
if not devices:
await utils.answer(message, self.strings("no_devices_found"))
- return
-
- device_list_text = ""
- for i, device in enumerate(devices):
- is_active = "(active)" if device["is_active"] else ""
- device_list_text += (
- f"{i+1}. {device['name']}"
- f" ({device['type']}) {is_active}\n"
- )
-
- await utils.answer(message, self.strings("device_list").format(device_list_text.strip()))
- return
-
- device_id = None
- try:
- device_number = int(args)
- if 0 < device_number <= len(devices):
- device_id = devices[device_number - 1]["id"]
- device_name = devices[device_number - 1]["name"]
else:
- await utils.answer(message, self.strings("invalid_device_id"))
- return
- except ValueError:
- found_device = next((d for d in devices if d["id"] == args.strip()), None)
- if found_device:
- device_id = found_device["id"]
- device_name = found_device["name"]
- else:
- await utils.answer(message, self.strings("invalid_device_id"))
- return
+ device_list_text = ""
+ for i, device in enumerate(devices):
+ is_active = "(active)" if device["is_active"] else ""
+ device_list_text += (
+ f"{i+1}. {device['name']}"
+ f" ({device['type']}) {is_active}\n"
+ )
+ await utils.answer(message, self.strings("device_list").format(device_list_text.strip()))
+ else:
+ device_id = None
+ try:
+ device_number = int(args)
+ if 0 < device_number <= len(devices):
+ device_id = devices[device_number - 1]["id"]
+ device_name = devices[device_number - 1]["name"]
+ else:
+ await utils.answer(message, self.strings("invalid_device_id"))
+ return
+ except ValueError:
+ found_device = next((d for d in devices if d["id"] == args.strip()), None)
+ if found_device:
+ device_id = found_device["id"]
+ device_name = found_device["name"]
+ else:
+ await utils.answer(message, self.strings("invalid_device_id"))
+ return
- self.sp.transfer_playback(device_id=device_id)
- await utils.answer(message, self.strings("device_changed").format(device_name))
+ self.sp.transfer_playback(device_id=device_id)
+ await utils.answer(message, self.strings("device_changed").format(device_name))
@error_handler
@tokenized
@@ -1157,7 +1415,7 @@ class SpotifyMod(loader.Module):
url = message.message.split(" ")[1]
code = self.sp_auth.parse_auth_response_url(url)
self.set("acs_tkn", self.sp_auth.get_access_token(code, True, False))
- self.sp = spotipy.Spotify(auth=self.get("acs_tkn")["access_token"])
+ self._init_spotify_client()
await utils.answer(message, self.strings("authed"))
@error_handler
@@ -1167,31 +1425,33 @@ class SpotifyMod(loader.Module):
async def unauthcmd(self, message: Message):
"""- Log out of account"""
self.set("acs_tkn", None)
- del self.sp
+ self.sp = None
await utils.answer(message, self.strings("deauth"))
@error_handler
@tokenized
@loader.command(
- ru_doc="- Обновить токен авторизации"
+ ru_doc="| .stokr - Обновить токен авторизации",
+ alias="stokr"
)
async def stokrefreshcmd(self, message: Message):
- """- Refresh authorization token"""
+ """| .stokr - Refresh authorization token"""
self.set(
"acs_tkn",
self.sp_auth.refresh_access_token(self.get("acs_tkn")["refresh_token"]),
)
self.set("NextRefresh", time.time() + 45 * 60)
- self.sp = spotipy.Spotify(auth=self.get("acs_tkn")["access_token"])
+ self._init_spotify_client()
await utils.answer(message, self.strings("authed"))
@error_handler
@tokenized
@loader.command(
- ru_doc="- 🎧 Показать карточку играющего трека"
+ ru_doc="| .sn - 🎧 Показать карточку играющего трека",
+ alias="sn"
)
async def snowcmd(self, message: Message):
- """- 🎧 View current track card."""
+ """| .sn - 🎧 View current track card."""
current_playback = self.sp.current_playback()
if not current_playback or not current_playback.get("is_playing", False):
await utils.answer(message, self.strings("no_music"))
@@ -1235,18 +1495,22 @@ class SpotifyMod(loader.Module):
playlist_name = ""
playlist_owner = ""
- text = self.config["custom_text"].format(
- track=utils.escape_html(track),
- artists=utils.escape_html(artists),
- album=utils.escape_html(album_name),
- duration=duration,
- progress=progress,
- device=device,
- spotify_url=spotify_url,
- songlink=songlink,
- playlist=utils.escape_html(playlist_name) if playlist_name else "",
- playlist_owner=playlist_owner or "",
- )
+ sdata = {
+ "track": utils.escape_html(track),
+ "artists": utils.escape_html(artists),
+ "album": utils.escape_html(album_name),
+ "duration": duration,
+ "progress": progress,
+ "device": device,
+ "spotify_url": spotify_url,
+ "songlink": songlink,
+ "playlist": utils.escape_html(playlist_name) if playlist_name else "",
+ "playlist_owner": playlist_owner or "",
+ }
+
+ data = await utils.get_placeholders(sdata, self.config["custom_text"])
+
+ text = self.config["custom_text"].format(**data)
if self.config["show_banner"]:
cover_url = current_playback["item"]["album"]["images"][0]["url"]
@@ -1260,8 +1524,13 @@ class SpotifyMod(loader.Module):
progress=progress_ms,
track_cover=requests.get(cover_url).content,
font=self.config["font"],
+ blur=self.config["blur_intensity"],
)
- file = getattr(banners, self.config["banner_version"], banners.horizontal)()
+
+ if self.config["banner_version"] == "vertical":
+ file = banners.vertical()
+ else:
+ file = banners.horizontal()
await utils.answer(tmp_msg, text, file=file)
else:
@@ -1270,10 +1539,11 @@ class SpotifyMod(loader.Module):
@error_handler
@tokenized
@loader.command(
- ru_doc="- 🎧 Скачать играющий трек"
+ ru_doc="| .snt - 🎧 Скачать играющий трек",
+ alias="snt"
)
async def snowtcmd(self, message: Message):
- """- 🎧 Download current track."""
+ """| .snt - 🎧 Download current track."""
current_playback = self.sp.current_playback()
if not current_playback or not current_playback.get("is_playing", False):
await utils.answer(message, self.strings("no_music"))
@@ -1316,18 +1586,22 @@ class SpotifyMod(loader.Module):
playlist_name = ""
playlist_owner = ""
- text = self.config["custom_text"].format(
- track=utils.escape_html(track),
- artists=utils.escape_html(artists),
- album=utils.escape_html(album_name),
- duration=duration,
- progress=progress,
- device=device,
- spotify_url=spotify_url,
- songlink=songlink,
- playlist=utils.escape_html(playlist_name) if playlist_name else "",
- playlist_owner=playlist_owner or "",
- )
+ sdata = {
+ "track": utils.escape_html(track),
+ "artists": utils.escape_html(artists),
+ "album": utils.escape_html(album_name),
+ "duration": duration,
+ "progress": progress,
+ "device": device,
+ "spotify_url": spotify_url,
+ "songlink": songlink,
+ "playlist": utils.escape_html(playlist_name) if playlist_name else "",
+ "playlist_owner": playlist_owner or "",
+ }
+
+ data = await utils.get_placeholders(sdata, self.config["custom_text"])
+
+ text = self.config["custom_text"].format(**data)
msg = await utils.answer(message, text + self.strings("downloading_track"))
@@ -1336,101 +1610,124 @@ class SpotifyMod(loader.Module):
@error_handler
@tokenized
@loader.command(
- ru_doc=(
- "- 🔍 Поиск треков. Например: .ssearch Imagine Dragons Believer\n"
- "- 🎧 Скачать трек: .ssearch 1 (где 1 — номер трека из списка)"
- )
+ ru_doc="| .sq - 🔍 Поиск треков.",
+ alias="sq"
)
async def ssearchcmd(self, message: Message):
- """🔍 Search for tracks. Usage: .ssearch or .ssearch to download"""
+ """| .sq - 🔍 Search for tracks."""
args = utils.get_args_raw(message)
if not args:
await utils.answer(message, self.strings("no_search_query"))
return
- try:
+ search_results = self.get("last_search_results", [])
+
+ is_selection = False
+ if args.isdigit():
+ track_number = int(args)
+ if search_results and 0 < track_number <= len(search_results):
+ is_selection = True
+
+ if is_selection:
track_number = int(args)
- search_results = self.get("last_search_results", [])
-
- if not search_results:
- await utils.answer(message, self.strings("no_tracks_found"))
- return
-
- if track_number <= 0 or track_number > len(search_results):
- raise ValueError
-
msg = await utils.answer(message, self.strings("downloading_track"))
-
track_info = search_results[track_number - 1]
- track_name = track_info["name"]
- artists = ", ".join([a["name"] for a in track_info["artists"]])
-
- caption_text = self.strings("download_success").format(
- utils.escape_html(track_name),
- utils.escape_html(artists)
+ track_name, artists = self._track_info(track_info)
+ reply_to_id = self._reply_id(message)
+
+ chat_id = self._get_chat_id(message)
+ target = chat_id if chat_id is not None else msg
+ success = await self._download_track(
+ target,
+ f"{artists} {track_name}",
+ track_name=track_name,
+ artists=artists,
+ log_context=f"{track_name} - {artists}",
+ reply_to_id=reply_to_id,
+ )
+ if success:
+ with contextlib.suppress(Exception):
+ await msg.delete()
+ self.set("last_search_results", [])
+
+ else:
+ results = await asyncio.to_thread(
+ self.sp.search,
+ q=args,
+ limit=5,
+ type="track",
)
-
- await self._download_track(msg, f"{artists} {track_name}", caption=caption_text)
- return
-
- except ValueError:
- await utils.answer(message, self.strings("searching_tracks").format(args))
-
- results = self.sp.search(q=args, limit=5, type="track")
if not results or not results["tracks"]["items"]:
await utils.answer(message, self.strings("no_tracks_found").format(args))
return
- self.set("last_search_results", results["tracks"]["items"])
-
- tracks_list = []
- for i, track in enumerate(results["tracks"]["items"]):
- track_name = track["name"]
- artists = ", ".join([artist["name"] for artist in track["artists"]])
- track_url = track["external_urls"]["spotify"]
- tracks_list.append(
- "{number}. {track_name} — {artists}\n🔗 Spotify".format(
- number=i + 1,
- track_name=utils.escape_html(track_name),
- artists=utils.escape_html(artists),
- track_url=track_url,
- )
- )
+ tracks = results["tracks"]["items"]
+ self.set("last_search_results", tracks)
- text = "\n".join(tracks_list)
- await utils.answer(message, self.strings("search_results").format(args, text))
+ reply_to_id = self._reply_id(message)
-
- @loader.command(
- ru_doc="- 🔄 Сброс результатов поиска по трекам"
- )
- async def ssearchresetcmd(self, message: Message):
- """- 🔄 Reset track search results"""
- self.set("last_search_results", [])
- await utils.answer(message, self.strings["search_results_cleared"])
+ await self.inline.form(
+ self.strings("search_results_inline").format(
+ count=len(tracks),
+ query=utils.escape_html(args),
+ ),
+ message=message,
+ reply_markup=self._search_keyboard(
+ tracks,
+ self._get_chat_id(message),
+ reply_to_id,
+ ),
+ )
async def watcher(self, message: Message):
"""Watcher is used to update token"""
if not self.sp:
return
- if self.get("NextRefresh", False):
- ttc = self.get("NextRefresh", 0)
- crnt = time.time()
- if ttc < crnt:
+ raw = getattr(message, "raw_text", "") or ""
+ if "spdl_" in raw:
+ try:
+ tag = raw.split("spdl_")[1].split("")[0]
+ sid, idx = tag.split("_")
+ store_id, index = int(sid), int(idx)
+ except:
+ return
+
+ data = self._sp_store.pop(store_id, [])
+ if not data or index >= len(data):
+ return
+
+ track_name, artists = data[index]
+ chat_id = self._get_chat_id(message)
+ if not chat_id:
+ return
+
+ reply_to_id = self._reply_id(message)
+ success = await self._download_track(
+ chat_id, f"{artists} {track_name}",
+ track_name=track_name, artists=artists,
+ log_context=f"{track_name} - {artists}",
+ reply_to_id=reply_to_id,
+ )
+ if success:
+ with contextlib.suppress(Exception):
+ await message.delete()
+ return
+
+ next_refresh = self.get("NextRefresh")
+ if not next_refresh or next_refresh < time.time():
+ try:
self.set(
"acs_tkn",
- self.sp_auth.refresh_access_token(
- self.get("acs_tkn")["refresh_token"]
- ),
+ self.sp_auth.refresh_access_token(self.get("acs_tkn")["refresh_token"]),
)
self.set("NextRefresh", time.time() + 45 * 60)
self.sp = spotipy.Spotify(auth=self.get("acs_tkn")["access_token"])
- else:
- self.set(
- "acs_tkn",
- self.sp_auth.refresh_access_token(self.get("acs_tkn")["refresh_token"]),
- )
- self.set("NextRefresh", time.time() + 45 * 60)
- self.sp = spotipy.Spotify(auth=self.get("acs_tkn")["access_token"])
+ except Exception as e:
+ logger.error(f"Spotify watcher error: {e}")
+ if "Refresh token revoked" in str(e):
+ refresh_token = await self.invoke("stokrefresh", "", self.inline.bot.id)
+ await refresh_token.delete()
+ else:
+ self.set("NextRefresh", time.time() + 300)
diff --git a/radiocycle/Modules/full.txt b/radiocycle/Modules/full.txt
index 699e87a..a2d7110 100644
--- a/radiocycle/Modules/full.txt
+++ b/radiocycle/Modules/full.txt
@@ -1,5 +1,5 @@
Neofetch
-randomanimepic
+RandomAnimePic
SpotifyMod
UnbanAll
voicetotext
diff --git a/radiocycle/Modules/randomanimepic.py b/radiocycle/Modules/randomanimepic.py
deleted file mode 100644
index aa7766b..0000000
--- a/radiocycle/Modules/randomanimepic.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# =======================================
-# _ __ __ __ _
-# | |/ /___ | \/ | ___ __| |___
-# | ' // _ \ | |\/| |/ _ \ / _` / __|
-# | . \ __/ | | | | (_) | (_| \__ \
-# |_|\_\___| |_| |_|\___/ \__,_|___/
-# @ke_mods
-# =======================================
-#
-# LICENSE: CC BY-ND 4.0 (Attribution-NoDerivatives 4.0 International)
-# --------------------------------------
-# https://creativecommons.org/licenses/by-nd/4.0/legalcode
-# =======================================
-
-# meta developer: @ke_mods
-
-import requests
-import asyncio
-import logging
-import traceback
-from logging import basicConfig
-from .. import loader, utils
-
-basicConfig(level=logging.INFO)
-logger = logging.getLogger(__name__)
-
-@loader.tds
-class RandomAnimePicMod(loader.Module):
- strings = {
- "name": "RandomAnimePic",
- "img": "✅ Your anime pic\n🔗 URL: {}",
- "loading": "✨ Loading image...",
- "error": "🚫 An unexpected error occurred...",
- }
-
- strings_ru = {
- "img": "✅ Ваша аниме-картинка\n🔗 Ссылка: {}",
- "loading": "✨ Загрузка изображения...",
- "error": "🚫 Произошла непредвиденная ошибка...",
- }
-
- @loader.command(
- ru_doc="- получить рандомную аниме-картинку 👀"
- )
- async def rapiccmd(self, message):
- """- fetch random anime-pic 👀"""
-
- await utils.answer(message, self.strings("loading"))
-
- try:
- res = requests.get("https://api.nekosia.cat/api/v1/images/cute?count=1")
- res.raise_for_status()
- data = res.json()
- image_url = data['image']['original']['url']
-
- await asyncio.sleep(2)
-
- await utils.answer(message, self.strings("img").format(image_url), file=image_url, reply_to=message.reply_to_msg_id)
-
- except Exception:
- logger.error("Error fetching random anime pic: %s", traceback.format_exc())
-
- await utils.answer(message, self.strings("error"))
-
- await asyncio.sleep(5)
diff --git a/trololo65/Modules/TTsaveMod.py b/trololo65/Modules/TTsaveMod.py
index e90cfc1..9e53ee9 100644
--- a/trololo65/Modules/TTsaveMod.py
+++ b/trololo65/Modules/TTsaveMod.py
@@ -1,83 +1,169 @@
# meta developer: @trololo_1
-from telethon import events
from .. import utils, loader
import re, asyncio, os
+from datetime import datetime
-chat = "@TTFullBot"
+default_chat = "@SaveAsBot"
+MODE_FORWARD = "forward"
+MODE_DOWNLOAD = "download"
class TTsaveMod(loader.Module):
- """Save tiktok video"""
- strings = {'name': 'TTsaveMod'}
- async def client_ready(self, client, db):
- self.db = db
-
- async def ttsavecmd(self, message):
- """.ttsave {link}"""
+ """Save tiktok video"""
+ strings = {'name': 'TTsaveMod'}
+ async def client_ready(self, client, db):
+ self.db = db
+ self.default_chat = default_chat
+ if not self.db.get('TTsaveMod', 'chat', False):
+ self.db.set('TTsaveMod', 'chat', self.default_chat)
- args = utils.get_args_raw(message)
- async with message.client.conversation(chat) as conv:
- await utils.answer(message, 'Скачиваю...')
- response1, response2, response3 = [conv.wait_event(events.NewMessage(incoming=True, from_users=chat, chats=chat)) for i in range(3)]
- bot_send_link = await message.client.send_message(chat, args)
- response1 = await response1
- response2 = await response2
- response3 = await response3
- await response2.download_media("hui.mp4")
- await message.client.send_file(message.to_id, "hui.mp4")
- await response1.delete()
- await response2.delete()
- await response3.delete()
- await bot_send_link.delete()
- await message.delete()
- os.remove("hui.mp4")
+ def _send_mode(self):
+ m = self.db.get('TTsaveMod', 'send_mode', MODE_FORWARD)
+ return m if m in (MODE_FORWARD, MODE_DOWNLOAD) else MODE_FORWARD
- async def ttacceptcmd(self, message):
- """ .ttaccept {reply/id} для открытия в чате автоматического скачивания ссылок. без аргументов тоже работает.\n.ttaccept -l для показа открытых чатов """
+ async def save_video(self, message, url=None):
+ """save video from tiktok. url: ссылка; для .ttsave можно не передавать (берётся из аргументов команды)."""
+ if url is not None:
+ args = str(url).strip()
+ else:
+ args = utils.get_args_raw(message).strip()
+ if not args:
+ await utils.answer(message, "Нет ссылки.")
+ return False
+ dest = message.peer_id
+ chat = self.db.get('TTsaveMod', 'chat')
+ mode = self._send_mode()
+ status_msg = await message.respond('Скачиваю...')
- args = utils.get_args_raw(message)
- reply = await message.get_reply_message()
- users_list = self.db.get('TTsaveMod', 'users', [])
+ async def erase_status():
+ try:
+ await status_msg.delete()
+ except Exception:
+ pass
- if args == '-l':
- if len(users_list) == 0: return await utils.answer(message, 'Список пуст.')
- return await utils.answer(message, '• '+'\n• '.join([''+str(i)+'' for i in users_list]))
+ try:
+ async with message.client.conversation(chat) as conv:
+ bot_send_link = await conv.send_message(args)
+ response1 = await conv.get_response()
+ response2 = await conv.get_response()
- try:
- if not args and not reply:
- user = message.chat_id
- else:
- user = reply.sender_id if not args else int(args)
- except:
- return await utils.answer(message, 'Неверно введён ид.')
- if user in users_list:
- users_list.remove(user)
- await utils.answer(message, f'Ид {str(user)} исключен.')
- else:
- users_list.append(user)
- await utils.answer(message, f'Ид {str(user)} добавлен.')
- self.db.set('TTsaveMod', 'users', users_list)
+ # Определяем, в каком из response пришло видео
+ video_response, other_response = None, None
+ if hasattr(response1, "media") and response1.media is not None:
+ if getattr(response1.media, "document", None) or getattr(response1.media, "video", None):
+ video_response = response1
+ other_response = response2
+ if video_response is None and hasattr(response2, "media") and response2.media is not None:
+ if getattr(response2.media, "document", None) or getattr(response2.media, "video", None):
+ video_response = response2
+ other_response = response1
+ if video_response is None:
+ await erase_status()
+ await message.respond("Не удалось получить видео.")
+ await response1.delete()
+ await response2.delete()
+ await bot_send_link.delete()
+ return False
- async def watcher(self, message):
- try:
- users = self.db.get('TTsaveMod', 'users', [])
- if message.chat_id not in users: return
- links = re.findall(r'((?:https?://)?v[mt]\.tiktok\.com/[A-Za-z0-9_]+/?)', message.raw_text)
- if len(links) == 0: return
+ if mode == MODE_FORWARD:
+ await video_response.forward_to(dest)
+ await response1.delete()
+ await response2.delete()
+ await bot_send_link.delete()
+ await erase_status()
+ return True
- async with message.client.conversation(chat) as conv:
- for link in links:
- response1, response2, response3 = [conv.wait_event(events.NewMessage(incoming=True, from_users=chat, chats=chat)) for i in range(3)]
- bot_send_link = await message.client.send_message(chat, link)
- response1 = await response1
- response2 = await response2
- response3 = await response3
- await response2.download_media("hui.mp4")
- await message.client.send_file(message.chat_id, "hui.mp4")
- await response1.delete()
- await response2.delete()
- await response3.delete()
- await bot_send_link.delete()
- os.remove("hui.mp4")
- await asyncio.sleep(5)
- except: pass
\ No newline at end of file
+ now_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ filename = f"{now_time}.mp4"
+ await video_response.download_media(filename)
+ await response1.delete()
+ await response2.delete()
+ await bot_send_link.delete()
+ await erase_status()
+ await message.client.send_file(dest, filename)
+ os.remove(filename)
+ return True
+ except Exception:
+ await erase_status()
+ raise
+
+ async def setbotcmd(self, message):
+ """use: .setbot чтобы установить бота для скачивания."""
+ args = utils.get_args_raw(message)
+
+ try:
+ bot = await message.client.get_entity(args)
+ except:
+ return await utils.answer(message, f"бот не найден.")
+ self.db.set('TTsaveMod', 'bot', str(bot.id))
+ await utils.answer(message, f"бот {bot.username} установлен.")
+
+ async def ttsendmodecmd(self, message):
+ """.ttsendmode forward|download — пересылка с бота (по умолчанию) или скачивание и отправка. Без аргументов — текущий режим."""
+ raw = (utils.get_args_raw(message) or "").strip().lower()
+ if not raw:
+ cur = self._send_mode()
+ tip = "пересылка с бота" if cur == MODE_FORWARD else "скачивание и отправка"
+ return await utils.answer(
+ message,
+ f"Сейчас: {tip}\n.ttsendmode forward|download",
+ )
+ if raw in ("forward", "пересылка", "fwd", "f"):
+ self.db.set("TTsaveMod", "send_mode", MODE_FORWARD)
+ return await utils.answer(message, "Режим: пересылка с бота.")
+ if raw in ("download", "скачивание", "скачать", "dl", "d"):
+ self.db.set("TTsaveMod", "send_mode", MODE_DOWNLOAD)
+ return await utils.answer(message, "Режим: скачивание и отправка.")
+ return await utils.answer(message, ".ttsendmode forward|download")
+
+ async def ttsavecmd(self, message):
+ """.ttsave {link}"""
+
+ args = utils.get_args_raw(message)
+ save_video = await self.save_video(message)
+ if save_video:
+ if self._send_mode() == MODE_FORWARD:
+ await utils.answer(message, "видео переслано.")
+ else:
+ await utils.answer(message, "видео успешно отправлено.")
+ else:
+ await utils.answer(message, "не удалось скачать видео.")
+
+ async def ttacceptcmd(self, message):
+ """ .ttaccept {reply/id} для открытия в чате автоматического скачивания ссылок. без аргументов тоже работает.\n.ttaccept -l для показа открытых чатов """
+
+ args = utils.get_args_raw(message)
+ reply = await message.get_reply_message()
+ users_list = self.db.get('TTsaveMod', 'users', [])
+
+ if args == '-l':
+ if len(users_list) == 0: return await utils.answer(message, 'Список пуст.')
+ return await utils.answer(message, '• '+'\n• '.join([''+str(i)+'' for i in users_list]))
+
+ try:
+ if not args and not reply:
+ user = message.chat_id
+ else:
+ user = reply.sender_id if not args else int(args)
+ except:
+ return await utils.answer(message, 'Неверно введён ид.')
+ if user in users_list:
+ users_list.remove(user)
+ await utils.answer(message, f'Ид {str(user)} исключен.')
+ else:
+ users_list.append(user)
+ await utils.answer(message, f'Ид {str(user)} добавлен.')
+ self.db.set('TTsaveMod', 'users', users_list)
+
+ async def watcher(self, message):
+ try:
+ users = self.db.get('TTsaveMod', 'users', [])
+ if message.chat_id not in users: return
+ links = re.findall(r'((?:https?://)?v[mt]\.tiktok\.com/[A-Za-z0-9_]+/?)', message.raw_text)
+ if len(links) == 0: return
+
+ for link in links:
+ await self.save_video(message, url=link)
+ await asyncio.sleep(5)
+ except Exception:
+ pass