Added and updated repositories 2026-04-12 13:56:57

This commit is contained in:
github-actions[bot]
2026-04-12 13:56:57 +00:00
parent 7555ea280e
commit 17ae450f8f
19 changed files with 6309 additions and 953 deletions

View File

@@ -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": "<emoji document_id=5456307331644037599>❌</emoji> <b>Invalid size format.</b>",
"max_size": "<emoji document_id=5456307331644037599>❌</emoji> <b>Maximum file size is 2GB</b>",
"file_created": (
"<emoji document_id=5458805056990119991>✅</emoji><b> File successfully created and sent.</b>\n\n"
"<blockquote>"
"<emoji document_id=5456625794879099391>👤</emoji> <b>File name:</b> <code>{}</code>\n"
"<emoji document_id=5456569114195692172>⚖️</emoji> <b>Size:</b> <code>{}{}</code>\n"
"<emoji document_id=5456591761558245861>⌛️</emoji> <b>Creation:</b> <code>{:.2f} sec.</code>\n"
"<tg-emoji emoji-id=5456350521835163323>📤</tg-emoji> <b>Upload:</b> <code>{:.2f} sec.</code>"
"</blockquote>"
),
"invalid_args": (
"<emoji document_id=5456307331644037599>❌</emoji><b> Invalid arguments</b>\n\n"
"<b>Usage:</b> <code>{prefix}cifile &lt;name&gt; &lt;size&gt;</code>\n"
"<b>Example:</b> <code>{prefix}cifile test.txt 3.4mb</code>\n\n"
"<i>Supported: b, kb, mb, gb</i>"
),
"creating": "<emoji document_id=5456591761558245861>⌛️</emoji> <b>Creating file...\n\n<i>*Large files may take a long time to upload.</i></b>",
"error": "<emoji document_id=5456537889783452967>⚠️</emoji> <b>Error:</b>\n<i>{}</i>",
"formats": (
"<emoji document_id=5456367813373498016>📂</emoji> <b>Popular file extensions:</b>\n\n"
"<b>📄 Documents:</b> <code>.txt .docx .pdf .rtf</code>\n"
"<b>📊 Spreadsheets:</b> <code>.xlsx .csv</code>\n"
"<b>📈 Presentations:</b> <code>.pptx</code>\n"
"<b>🖼️ Images:</b> <code>.jpg .png .gif .bmp .webp</code>\n"
"<b>🎵 Audio:</b> <code>.mp3 .wav .flac</code>\n"
"<b>🎬 Video:</b> <code>.mp4 .mkv .avi</code>\n"
"<b>📦 Archives:</b> <code>.zip .rar .7z</code>\n"
"<b>💻 Code:</b> <code>.py .js .html .css .json</code>"
),
}
strings_ru = {
"invalid_format": "<emoji document_id=5456307331644037599>❌</emoji> <b>Неверный формат размера.</b>",
"max_size": "<emoji document_id=5456307331644037599>❌</emoji> <b>Максимальный размер файла — 2GB</b>",
"file_created": (
"<emoji document_id=5458805056990119991>✅</emoji><b> Файл успешно создан и отправлен.</b>\n\n"
"<blockquote>"
"<emoji document_id=5456625794879099391>👤</emoji> <b>Имя файла:</b> <code>{}</code>\n"
"<emoji document_id=5456569114195692172>⚖️</emoji> <b>Размер:</b> <code>{}{}</code>\n"
"<emoji document_id=5456591761558245861>⌛️</emoji> <b>Создание:</b> <code>{:.2f} сек.</code>\n"
"<tg-emoji emoji-id=5456350521835163323>📤</tg-emoji> <b>Отправка:</b> <code>{:.2f} сек.</code>"
"</blockquote>"
),
"invalid_args": (
"<emoji document_id=5456307331644037599>❌</emoji><b> Неверные аргументы</b>\n\n"
"<b>Использование:</b> <code>{prefix}cifile &lt;имя&gt; &lt;размер&gt;</code>\n"
"<b>Пример:</b> <code>{prefix}cifile test.txt 3.4mb</code>\n\n"
"<i>Поддерживаются: b, kb, mb, gb</i>"
),
"creating": "<emoji document_id=5456591761558245861>⌛️</emoji> <b>Создаю файл...\n\n<i>*Файлы большого размера могут долго загружаться.</i></b>",
"error": "<emoji document_id=5456537889783452967>⚠️</emoji> <b>Ошибка:</b>\n<i>{}</i>",
"formats": (
"<emoji document_id=5456367813373498016>📂</emoji> <b>Популярные расширения файлов:</b>\n\n"
"<b>📄 Документы:</b> <code>.txt .docx .pdf .rtf</code>\n"
"<b>📊 Таблицы:</b> <code>.xlsx .csv</code>\n"
"<b>📈 Презентации:</b> <code>.pptx</code>\n"
"<b>🖼️ Изображения:</b> <code>.jpg .png .gif .bmp .webp</code>\n"
"<b>🎵 Аудио:</b> <code>.mp3 .wav .flac</code>\n"
"<b>🎬 Видео:</b> <code>.mp4 .mkv .avi</code>\n"
"<b>📦 Архивы:</b> <code>.zip .rar .7z</code>\n"
"<b>💻 Код:</b> <code>.py .js .html .css .json</code>"
),
}
strings_uz = {
"invalid_format": "<emoji document_id=5456307331644037599>❌</emoji> <b>Hajm formati notogri.</b>",
"max_size": "<emoji document_id=5456307331644037599>❌</emoji> <b>Maksimal fayl hajmi — 2GB</b>",
"file_created": (
"<emoji document_id=5458805056990119991>✅</emoji><b> Fayl muvaffaqiyatli yaratildi va yuborildi.</b>\n\n"
"<blockquote>"
"<emoji document_id=5456625794879099391>👤</emoji> <b>Fayl nomi:</b> <code>{}</code>\n"
"<emoji document_id=5456569114195692172>⚖️</emoji> <b>Hajmi:</b> <code>{}{}</code>\n"
"<emoji document_id=5456591761558245861>⌛️</emoji> <b>Yaratish:</b> <code>{:.2f} sek.</code>\n"
"<tg-emoji emoji-id=5456350521835163323>📤</tg-emoji> <b>Yuborish:</b> <code>{:.2f} sek.</code>"
"</blockquote>"
),
"invalid_args": (
"<emoji document_id=5456307331644037599>❌</emoji><b> Notogri argumentlar</b>\n\n"
"<b>Foydalanish:</b> <code>{prefix}cifile &lt;nom&gt; &lt;hajm&gt;</code>\n"
"<b>Misol:</b> <code>{prefix}cifile test.txt 3.4mb</code>\n\n"
"<i>Qollab-quvvatlanadi: b, kb, mb, gb</i>"
),
"creating": "<emoji document_id=5456591761558245861>⌛️</emoji> <b>Fayl yaratilmoqda...\n\n<i>*Katta fayllar uzoq yuklanishi mumkin.</i></b>",
"error": "<emoji document_id=5456537889783452967>⚠️</emoji> <b>Xatolik:</b>\n<i>{}</i>",
"formats": (
"<emoji document_id=5456367813373498016>📂</emoji> <b>Mashhur fayl kengaytmalari:</b>\n\n"
"<b>📄 Hujjatlar:</b> <code>.txt .docx .pdf .rtf</code>\n"
"<b>📊 Jadvallar:</b> <code>.xlsx .csv</code>\n"
"<b>📈 Taqdimotlar:</b> <code>.pptx</code>\n"
"<b>🖼️ Rasmlar:</b> <code>.jpg .png .gif .bmp .webp</code>\n"
"<b>🎵 Audio:</b> <code>.mp3 .wav .flac</code>\n"
"<b>🎬 Video:</b> <code>.mp4 .mkv .avi</code>\n"
"<b>📦 Arxivlar:</b> <code>.zip .rar .7z</code>\n"
"<b>💻 Kod:</b> <code>.py .js .html .css .json</code>"
),
}
strings_de = {
"invalid_format": "<emoji document_id=5456307331644037599>❌</emoji> <b>Ungültiges Größenformat.</b>",
"max_size": "<emoji document_id=5456307331644037599>❌</emoji> <b>Maximale Dateigröße — 2GB</b>",
"file_created": (
"<emoji document_id=5458805056990119991>✅</emoji><b> Datei erfolgreich erstellt und gesendet.</b>\n\n"
"<blockquote>"
"<emoji document_id=5456625794879099391>👤</emoji> <b>Dateiname:</b> <code>{}</code>\n"
"<emoji document_id=5456569114195692172>⚖️</emoji> <b>Größe:</b> <code>{}{}</code>\n"
"<emoji document_id=5456591761558245861>⌛️</emoji> <b>Erstellung:</b> <code>{:.2f} Sek.</code>\n"
"<tg-emoji emoji-id=5456350521835163323>📤</tg-emoji> <b>Upload:</b> <code>{:.2f} Sek.</code>"
"</blockquote>"
),
"invalid_args": (
"<emoji document_id=5456307331644037599>❌</emoji><b> Ungültige Argumente</b>\n\n"
"<b>Verwendung:</b> <code>{prefix}cifile &lt;name&gt; &lt;größe&gt;</code>\n"
"<b>Beispiel:</b> <code>{prefix}cifile test.txt 3.4mb</code>\n\n"
"<i>Unterstützt: b, kb, mb, gb</i>"
),
"creating": "<emoji document_id=5456591761558245861>⌛️</emoji> <b>Datei wird erstellt...\n\n<i>*Große Dateien können lange zum Hochladen brauchen.</i></b>",
"error": "<emoji document_id=5456537889783452967>⚠️</emoji> <b>Fehler:</b>\n<i>{}</i>",
"formats": (
"<emoji document_id=5456367813373498016>📂</emoji> <b>Beliebte Dateiendungen:</b>\n\n"
"<b>📄 Dokumente:</b> <code>.txt .docx .pdf .rtf</code>\n"
"<b>📊 Tabellen:</b> <code>.xlsx .csv</code>\n"
"<b>📈 Präsentationen:</b> <code>.pptx</code>\n"
"<b>🖼️ Bilder:</b> <code>.jpg .png .gif .bmp .webp</code>\n"
"<b>🎵 Audio:</b> <code>.mp3 .wav .flac</code>\n"
"<b>🎬 Video:</b> <code>.mp4 .mkv .avi</code>\n"
"<b>📦 Archive:</b> <code>.zip .rar .7z</code>\n"
"<b>💻 Code:</b> <code>.py .js .html .css .json</code>"
),
}
strings_es = {
"invalid_format": "<emoji document_id=5456307331644037599>❌</emoji> <b>Formato de tamaño inválido.</b>",
"max_size": "<emoji document_id=5456307331644037599>❌</emoji> <b>El tamaño máximo del archivo es 2GB</b>",
"file_created": (
"<emoji document_id=5458805056990119991>✅</emoji><b> Archivo creado y enviado correctamente.</b>\n\n"
"<blockquote>"
"<emoji document_id=5456625794879099391>👤</emoji> <b>Nombre del archivo:</b> <code>{}</code>\n"
"<emoji document_id=5456569114195692172>⚖️</emoji> <b>Tamaño:</b> <code>{}{}</code>\n"
"<emoji document_id=5456591761558245861>⌛️</emoji> <b>Creación:</b> <code>{:.2f} seg.</code>\n"
"<tg-emoji emoji-id=5456350521835163323>📤</tg-emoji> <b>Subida:</b> <code>{:.2f} seg.</code>"
"</blockquote>"
),
"invalid_args": (
"<emoji document_id=5456307331644037599>❌</emoji><b> Argumentos inválidos</b>\n\n"
"<b>Uso:</b> <code>{prefix}cifile &lt;nombre&gt; &lt;tamaño&gt;</code>\n"
"<b>Ejemplo:</b> <code>{prefix}cifile test.txt 3.4mb</code>\n\n"
"<i>Soportado: b, kb, mb, gb</i>"
),
"creating": "<emoji document_id=5456591761558245861>⌛️</emoji> <b>Creando archivo...\n\n<i>*Los archivos grandes pueden tardar en subirse.</i></b>",
"error": "<emoji document_id=5456537889783452967>⚠️</emoji> <b>Error:</b>\n<i>{}</i>",
"formats": (
"<emoji document_id=5456367813373498016>📂</emoji> <b>Extensiones de archivo populares:</b>\n\n"
"<b>📄 Documentos:</b> <code>.txt .docx .pdf .rtf</code>\n"
"<b>📊 Hojas de cálculo:</b> <code>.xlsx .csv</code>\n"
"<b>📈 Presentaciones:</b> <code>.pptx</code>\n"
"<b>🖼️ Imágenes:</b> <code>.jpg .png .gif .bmp .webp</code>\n"
"<b>🎵 Audio:</b> <code>.mp3 .wav .flac</code>\n"
"<b>🎬 Video:</b> <code>.mp4 .mkv .avi</code>\n"
"<b>📦 Archivos:</b> <code>.zip .rar .7z</code>\n"
"<b>💻 Código:</b> <code>.py .js .html .css .json</code>"
),
}
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="<fayl>.<format> <hajm> — buzilgan fayl yaratish",
de_doc="<datei>.<format> <größe> — beschädigte Datei erstellen",
es_doc="<archivo>.<formato> <tamaño> — crear archivo corrupto",
alias="cifile"
)
async def CreateInvalidFile(self, message: Message):
"""<file>.<format> <size> - 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'))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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": (
"<emoji document_id=5778527486270770928>\u274c</emoji>"
" <b>Set </b><code>oauth_token</code><b> in module config</b>\n\n"
"\U0001f511 Get it via extension:\n"
"\u2022 <a href='https://chromewebstore.google.com/detail/"
"jgocamehhjhbhomfnhknmiljlhjbaldg'>Chromium</a>\n"
"\u2022 <a href='https://addons.mozilla.org/en-US/firefox/addon/"
"playinnowbot/'>Firefox</a>\n"
"\u2022 Or via DevTools: Application \u2192 Cookies \u2192 "
"<code>oauth_token</code>"
),
"nothing": (
"<emoji document_id=5778527486270770928>❌</emoji>"
" <b>Nothing is playing right now</b>"
),
"error": (
"<emoji document_id=5778527486270770928>❌</emoji>"
" <b>Error</b>\n<code>{}</code>"
),
"wait_card": (
"\n\n<emoji document_id=5841359499146825803>🕔</emoji>"
" <i>Generating card…</i>"
),
"wait_dl": (
"\n\n<emoji document_id=5841359499146825803>🕔</emoji> <i>Downloading…</i>"
),
"dl_fail": (
"\n\n<emoji document_id=5778527486270770928>❌</emoji>"
" <i>Download failed</i>"
),
}
strings_ru = {
"no_token": (
"<emoji document_id=5778527486270770928>❌</emoji>"
" <b>Установи </b><code>oauth_token</code>"
"<b> в конфиге модуля</b>\n\n"
"🔑 Получить токен:\n"
"• <a href='https://chromewebstore.google.com/detail/"
"jgocamehhjhbhomfnhknmiljlhjbaldg'>Chromium</a>\n"
"• <a href='https://addons.mozilla.org/en-US/firefox/addon/"
"playinnowbot/'>Firefox</a>\n"
"• Или через DevTools: Application → Cookies → "
"<code>oauth_token</code>"
),
"nothing": (
"<emoji document_id=5778527486270770928>❌</emoji>"
" <b>Сейчас ничего не играет</b>"
),
"error": (
"<emoji document_id=5778527486270770928>❌</emoji>"
" <b>Ошибка</b>\n<code>{}</code>"
),
"wait_card": (
"\n\n<emoji document_id=5841359499146825803>🕔</emoji>"
" <i>Генерация карточки…</i>"
),
"wait_dl": (
"\n\n<emoji document_id=5841359499146825803>🕔</emoji> <i>Скачивание…</i>"
),
"dl_fail": (
"\n\n<emoji document_id=5778527486270770928>❌</emoji>"
" <i>Ошибка скачивания</i>"
),
}
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",
(
"<emoji document_id=6007938409857815902>🎧</emoji>"
" <b>Now playing:</b> {artist}{track}\n"
"<emoji document_id=5776213190387961618>🕓</emoji>"
" {duration}{genre}\n"
"<emoji document_id=5877465816030515018>🔗</emoji>"
" <b><a href='{url}'>SoundCloud</a></b>"
),
"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 (35)",
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"<emoji document_id=6007938409857815902>🎧</emoji>"
f" <b>{utils.escape_html(t.artist)}{utils.escape_html(t.title)}</b>\n"
f"<emoji document_id=5776213190387961618>🕓</emoji> {info}\n"
f"<emoji document_id=5877465816030515018>🔗</emoji>"
f" <b><a href='{t.permalink}'>SoundCloud</a></b>"
)
@_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 = (
"<emoji document_id=5776213190387961618>📜</emoji>"
" <b>История прослушивания:</b>\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}. <b>{utils.escape_html(t.artist)}"
f" {utils.escape_html(t.title)}</b>\n"
f" <emoji document_id=5776213190387961618>🕓</emoji>"
f" {meta} | <a href='{t.permalink}'>Link</a>\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)

View File

@@ -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": '📜 <b><a href="https://yandex-music.rtfd.io/en/main/token.html">Guide for obtaining access token for Yandex.Music</a></b>',
}
strings_ru = {
"_cls_doc": "Модуль для стримингового сервиса Яндекс.Музыка",
"iguide": '📜 <b><a href="https://yandex-music.rtfd.io/en/main/token.html">Гайд по получению токена Яндекс.Музыки</a></b>',
"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 "<code>Not Playing</code>"
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"<a href=\"https://music.yandex.ru/track/{now['playable_id']}\">Яндекс.Музыка</a>",
)
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"<a href=\"https://music.yandex.ru/track/{now['playable_id']}\">Яндекс.Музыка</a>",
)
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:

View File

@@ -0,0 +1,116 @@
en:
iguide: "<emoji document_id=5956561916573782596>📜</emoji> <b><a href=\"https://yandex-music.rtfd.io/en/main/token.html\">Guide for obtaining access token for Yandex.Music</a></b>"
search: "<emoji document_id=5474304919651491706>🎧</emoji> <b>{performer} — {title}</b>\n<emoji document_id=5242574232688298747>🎵</emoji> <b><a href=\"https://music.yandex.ru/track/{track_id}\">Yandex.Music</a> | <a href=\"https://song.link/ya/{track_id}\">song.link</a></b>"
downloading_track: "\n\n<emoji document_id=5841359499146825803>🕔</emoji> <i>Downloading audio…</i>"
uploading_banner: "\n\n<emoji document_id=5841359499146825803>🕔</emoji> <i>Uploading banner…</i>"
lyrics: "<emoji document_id=5956561916573782596>📜</emoji> <b>Lyrics of the <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> track:</b>\n<blockquote expandable>{text}</blockquote>\n\n<emoji document_id=5776287149724798198>©️</emoji> <b>Writers:</b> {writers}"
no_lyrics: "<emoji document_id=5872829476143894491>🚫</emoji> <b>Track <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> has no lyrics!</b>"
errors:
no_query: "<emoji document_id=5872829476143894491>🚫</emoji> <b>Specify the search query first!</b>"
no_token_or_invalid: "<emoji document_id=5872829476143894491>🚫</emoji> <b>You specified an invalid access token or didn't specified it at all!</b>"
not_found: "<emoji document_id=5872829476143894491>🚫</emoji> <b>No results found.</b>"
no_playing: "<emoji document_id=5872829476143894491>🚫</emoji> <b>You don't listening to anything right now.</b>"
autobio:
enabled: "<emoji document_id=5242574232688298747>🎧</emoji> <b>Autobio was enabled.</b>"
disabled: "<emoji document_id=5242574232688298747>🎧</emoji> <b>Autobio was disabled.</b>"
likes:
liked: "<emoji document_id=5899833370052923106>❤️</emoji> <b>Track <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> was liked.</b>"
unliked: "<emoji document_id=5992453811510186287>🖤</emoji> <b>Track <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> was unliked.</b>"
disliked: "<emoji document_id=5952055319059239589>💔</emoji> <b>Track <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> was disliked.</b>"
_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: "🔁 <b>Repeat enabled</b>"
repeat_off: "<tg-emoji emoji-id=5873146865637133757>➡️</tg-emoji> <b>Repeat disabled</b>"
next_track: "<tg-emoji emoji-id=5873204392429096339>⏭</tg-emoji> <b>Next track</b>"
prev_track: "<tg-emoji emoji-id=5873204392429096339>⏭</tg-emoji> <b>Previous track</b>"
volume_set: "<tg-emoji emoji-id=5873146865637133757>➡️</tg-emoji> <b>Volume set to {vol}%</b>"
volume_invalid: "<tg-emoji emoji-id=5465665476971471368>❌</tg-emoji> <b>Volume must be between 0 and 100</b>"
ynison_error: "<tg-emoji emoji-id=5465665476971471368>❌</tg-emoji> <b>Failed to send command to Yandex.Music (Ynison)</b>"
ru:
iguide: "<emoji document_id=5956561916573782596>📜</emoji> <b><a href=\"https://yandex-music.rtfd.io/en/main/token.html\">Гайд по получению токена Яндекс.Музыки</a></b>"
search: "<emoji document_id=5474304919651491706>🎧</emoji> <b>{performer} — {title}</b>\n<emoji document_id=5242574232688298747>🎵</emoji> <b><a href=\"https://music.yandex.ru/track/{track_id}\">Яндекс.Музыка</a> | <a href=\"https://song.link/ya/{track_id}\">song.link</a></b>"
downloading_track: "\n\n<emoji document_id=5841359499146825803>🕔</emoji> <i>Загрузка трека…</i>"
uploading_banner: "\n\n<emoji document_id=5841359499146825803>🕔</emoji> <i>Загрузка баннера…</i>"
lyrics: "<emoji document_id=5956561916573782596>📜</emoji> <b>Текст трека <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a>:</b>\n<blockquote expandable>{text}</blockquote>\n\n<emoji document_id=5776287149724798198>©️</emoji> <b>Авторы:</b> {writers}"
no_lyrics: "<emoji document_id=5872829476143894491>🚫</emoji> <b>У трека <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> нет текста!</b>"
errors:
no_query: "<emoji document_id=5872829476143894491>🚫</emoji> <b>Укажите поисковый запрос!</b>"
no_token_or_invalid: "<emoji document_id=5872829476143894491>🚫</emoji> <b>Вы указали невалидный токен или не указали его вообще!</b>"
not_found: "<emoji document_id=5872829476143894491>🚫</emoji> <b>Результаты не найдены.</b>"
no_playing: "<emoji document_id=5872829476143894491>🚫</emoji> <b>Вы ничего не слушаете сейчас.</b>"
autobio:
enabled: "<emoji document_id=5242574232688298747>🎧</emoji> <b>Автобио теперь включено.</b>"
disabled: "<emoji document_id=5242574232688298747>🎧</emoji> <b>Автобио теперь выключено.</b>"
likes:
liked: "<emoji document_id=5899833370052923106>❤️</emoji> <b>Трек <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> был лайкнут.</b>"
unliked: "<emoji document_id=5992453811510186287>🖤</emoji> <b>С трека <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> был снят лайк.</b>"
disliked: "<emoji document_id=5952055319059239589>💔</emoji> <b>Трек <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> был дизлайкнут.</b>"
_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: "🔁 <b>Повтор включен (Один трек)</b>"
repeat_off: "<tg-emoji emoji-id=5873146865637133757>➡️</tg-emoji> <b>Повтор выключен</b>"
next_track: "<tg-emoji emoji-id=5873204392429096339>⏭</tg-emoji> <b>Следующий трек</b>"
prev_track: "<tg-emoji emoji-id=5873204392429096339>⏭</tg-emoji> <b>Предыдущий трек</b>"
volume_set: "<tg-emoji emoji-id=5873146865637133757>➡️</tg-emoji> <b>Громкость установлена на {vol}%</b>"
volume_invalid: "<tg-emoji emoji-id=5465665476971471368>❌</tg-emoji> <b>Громкость должна быть от 0 до 100</b>"
ynison_error: "<tg-emoji emoji-id=5465665476971471368>❌</tg-emoji> <b>Не удалось отправить команду в Яндекс.Музыку (Ynison)</b>"
jp:
iguide: "<emoji document_id=5956561916573782596>📜</emoji> <b><a href=\"https://yandex-music.rtfd.io/en/main/token.html\">Yandex.Music アクセストークン取得ガイド</a></b>"
search: "<emoji document_id=5474304919651491706>🎧</emoji> <b>{performer} — {title}</b>\n<emoji document_id=5242574232688298747>🎵</emoji> <b><a href=\"https://music.yandex.ru/track/{track_id}\">Yandex.Music</a> | <a href=\"https://song.link/ya/{track_id}\">song.link</a></b>"
downloading_track: "\n\n<emoji document_id=5841359499146825803>🕔</emoji> <i>オーディオをダウンロード中…</i>"
uploading_banner: "\n\n<emoji document_id=5841359499146825803>🕔</emoji> <i>バナーをアップロード中…</i>"
lyrics: "<emoji document_id=5956561916573782596>📜</emoji> <b>トラック <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> の歌詞:</b>\n<blockquote expandable>{text}</blockquote>\n\n<emoji document_id=5776287149724798198>©️</emoji> <b>作詞・作曲:</b> {writers}"
no_lyrics: "<emoji document_id=5872829476143894491>🚫</emoji> <b>トラック <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> には歌詞がありません!</b>"
errors:
no_query: "<emoji document_id=5872829476143894491>🚫</emoji> <b>最初に検索クエリを指定してください!</b>"
no_token_or_invalid: "<emoji document_id=5872829476143894491>🚫</emoji> <b>無効なアクセストークンを指定したか、指定されていません!</b>"
not_found: "<emoji document_id=5872829476143894491>🚫</emoji> <b>結果が見つかりません。</b>"
no_playing: "<emoji document_id=5872829476143894491>🚫</emoji> <b>現在何も再生していません。</b>"
autobio:
enabled: "<emoji document_id=5242574232688298747>🎧</emoji> <b>Autobio自動プロフィールが有効になりました。</b>"
disabled: "<emoji document_id=5242574232688298747>🎧</emoji> <b>Autobioが無効になりました。</b>"
likes:
liked: "<emoji document_id=5899833370052923106>❤️</emoji> <b>トラック <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> に「いいね」しました。</b>"
unliked: "<emoji document_id=5992453811510186287>🖤</emoji> <b>トラック <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> の「いいね」を取り消しました。</b>"
disliked: "<emoji document_id=5952055319059239589>💔</emoji> <b>トラック <a href=\"https://music.yandex.ru/track/{track_id}\">{track}</a> に「低評価」しました。</b>"
_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: "🔁 <b>リピート有効</b>"
repeat_off: "<tg-emoji emoji-id=5873146865637133757>➡️</tg-emoji> <b>リピート無効</b>"
next_track: "<tg-emoji emoji-id=5873204392429096339>⏭</tg-emoji> <b>次のトラック</b>"
prev_track: "<tg-emoji emoji-id=5873204392429096339>⏭</tg-emoji> <b>前のトラック</b>"
volume_set: "<tg-emoji emoji-id=5873146865637133757>➡️</tg-emoji> <b>音量を {vol}% に設定しました</b>"
volume_invalid: "<tg-emoji emoji-id=5465665476971471368>❌</tg-emoji> <b>音量は0から100の間で指定してください</b>"
ynison_error: "<tg-emoji emoji-id=5465665476971471368>❌</tg-emoji> <b>Yandex.Music (Ynison) へのコマンド送信に失敗しました</b>"

View File

@@ -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'])

View File

@@ -24,4 +24,6 @@ deviceinfo
mpi
aigenuser
github
stream
stream
placeholders+
PyInstall

View File

@@ -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"<details[^>]*>.*?</details>", "", text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<summary[^>]*>.*?</summary>", "", text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<img[^>]*>", "", 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)

View File

@@ -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'<a href="{link.strip()}">{name.strip()}</a>'
return val
async def get_channel(self):
ch = self.config["channel"]
if ch.startswith("@"):
return f'<a href="https://t.me/{ch[1:]}">{ch}</a>'
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"🎵 <b>{track['name']}</b> — {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

View File

@@ -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": "▶️ <b>Stream is live</b>\n\n{icon} <code>{file}</code>\n⏱ Time: <b>{elapsed}</b>\n🔢 PID: <code>{pid}</code>\n📡 <code>{rtmp}</code>\n🎥 <b>{vbr}</b> | <b>{fps}fps</b> | <b>{preset}</b>\n🔊 <b>{abr}</b>\n📋 Queue: <b>{queue}</b>",
"status_idle": "⏸ <b>Stream is not active</b>",
"status_queue": "\n📋 Queue: <b>{n}</b>",
"stopped": "⏹ <b>Stream stopped.</b>",
"no_rtmp": "❌ <b>RTMP not configured!</b>\nTap a button to set it up.",
"downloading": "⏳ Downloading…",
"dl_failed": "❌ Failed to download file.",
"queued": "📋 Added to queue ({n})\n{icon} <code>{file}</code>",
"not_running": "Not running",
"queue_empty": "Queue is empty",
"queue_header": "📋 Queue:\n",
"settings_title": "⚙️ <b>Stream settings</b>",
"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": "▶️ <b>Трансляция идёт</b>\n\n{icon} <code>{file}</code>\n⏱ Время: <b>{elapsed}</b>\n🔢 PID: <code>{pid}</code>\n📡 <code>{rtmp}</code>\n🎥 <b>{vbr}</b> | <b>{fps}fps</b> | <b>{preset}</b>\n🔊 <b>{abr}</b>\n📋 В очереди: <b>{queue}</b>",
"status_idle": "⏸ <b>Трансляция не активна</b>",
"status_queue": "\n📋 В очереди: <b>{n}</b>",
"stopped": "⏹ <b>Трансляция остановлена.</b>",
"no_rtmp": "❌ <b>RTMP не настроен!</b>\nНажми кнопку чтобы задать прямо сейчас.",
"downloading": "⏳ Скачиваю…",
"dl_failed": "Не удалось скачать файл.",
"queued": "📋 Добавлено в очередь ({n} шт.)\n{icon} <code>{file}</code>",
"not_running": "Не запущено",
"queue_empty": "Очередь пуста",
"queue_header": "📋 Очередь:\n",
"settings_title": "⚙️ <b>Настройки трансляции</b>",
"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"))

View File

@@ -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": "<emoji document_id=4958526153955476488>❌</emoji> Недостаточно звезд для отправки минимального подарка!",
"no_available_gifts": "<emoji document_id=4958526153955476488>❌</emoji> Нет доступных подарков для вашего баланса",
"balance_error": "<emoji document_id=4958526153955476488>❌</emoji> Ошибка при проверке баланса",
"user_disallowed_gifts": "<emoji document_id=4958526153955476488>❌</emoji> Данный пользователь не принимает подарки!",
"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):
"""- <username> <text*> - отправить подарок пользователю (* - необязательный параметр.) Поддерживается реплай режим."""
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(

View File

@@ -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}
]
}
}
}

View File

@@ -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"))

View File

@@ -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": "<tg-emoji emoji-id=4916036072560919511>✅</tg-emoji> <b>Your anime pic</b>\n<tg-emoji emoji-id=5877465816030515018>🔗</tg-emoji> <b>URL:</b> {}",
"loading": "<tg-emoji emoji-id=4911241630633165627>✨</tg-emoji> <b>Loading image...</b>",
"categories_loading": "<tg-emoji emoji-id=4911241630633165627>✨</tg-emoji> <b>Loading categories...</b>",
"categories": "<tg-emoji emoji-id=4916036072560919511>✅</tg-emoji> <b>Available categories</b>\n<blockquote expandable>{}</blockquote>",
"no_categories": "<tg-emoji emoji-id=5116151848855667552>🚫</tg-emoji> <b>Categories not found</b>",
"error": "<tg-emoji emoji-id=5116151848855667552>🚫</tg-emoji> <b>An unexpected error occurred...</b>",
}
strings_ru = {
"img": "<tg-emoji emoji-id=4916036072560919511>✅</tg-emoji> <b>Ваша аниме-картинка</b>\n<tg-emoji emoji-id=5877465816030515018>🔗</tg-emoji> <b>Ссылка:</b> {}",
"loading": "<tg-emoji emoji-id=4911241630633165627>✨</tg-emoji> <b>Загрузка изображения...</b>",
"categories_loading": "<tg-emoji emoji-id=4911241630633165627>✨</tg-emoji> <b>Загрузка категорий...</b>",
"categories": "<tg-emoji emoji-id=4916036072560919511>✅</tg-emoji> <b>Доступные категории</b>\n<blockquote expandable>{}</blockquote>",
"no_categories": "<tg-emoji emoji-id=5116151848855667552>🚫</tg-emoji> <b>Категории не найдены</b>",
"error": "<tg-emoji emoji-id=5116151848855667552>🚫</tg-emoji> <b>Произошла непредвиденная ошибка...</b>",
}
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"<code>{category}</code>" 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"))

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
Neofetch
randomanimepic
RandomAnimePic
SpotifyMod
UnbanAll
voicetotext

View File

@@ -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": "<emoji document_id=4916036072560919511>✅</emoji> <b>Your anime pic</b>\n<emoji document_id=5877465816030515018>🔗</emoji> <b>URL:</b> {}",
"loading": "<emoji document_id=4911241630633165627>✨</emoji> <b>Loading image...</b>",
"error": "<emoji document_id=5116151848855667552>🚫</emoji> <b>An unexpected error occurred...</b>",
}
strings_ru = {
"img": "<emoji document_id=4916036072560919511>✅</emoji> <b>Ваша аниме-картинка</b>\n<emoji document_id=5877465816030515018>🔗</emoji> <b>Ссылка:</b> {}",
"loading": "<emoji document_id=4911241630633165627>✨</emoji> <b>Загрузка изображения...</b>",
"error": "<emoji document_id=5116151848855667552>🚫</emoji> <b>Произошла непредвиденная ошибка...</b>",
}
@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)

View File

@@ -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(['<code>'+str(i)+'</code>' 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'Ид <code>{str(user)}</code> исключен.')
else:
users_list.append(user)
await utils.answer(message, f'Ид <code>{str(user)}</code> добавлен.')
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
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"<b>бот не найден.</b>")
self.db.set('TTsaveMod', 'bot', str(bot.id))
await utils.answer(message, f"<b>бот <code>{bot.username}</code> установлен.</b>")
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"<b>Сейчас:</b> {tip}\n<code>.ttsendmode forward|download</code>",
)
if raw in ("forward", "пересылка", "fwd", "f"):
self.db.set("TTsaveMod", "send_mode", MODE_FORWARD)
return await utils.answer(message, "<b>Режим:</b> пересылка с бота.")
if raw in ("download", "скачивание", "скачать", "dl", "d"):
self.db.set("TTsaveMod", "send_mode", MODE_DOWNLOAD)
return await utils.answer(message, "<b>Режим:</b> скачивание и отправка.")
return await utils.answer(message, "<code>.ttsendmode forward|download</code>")
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, "<b>видео переслано.</b>")
else:
await utils.answer(message, "<b>видео успешно отправлено.</b>")
else:
await utils.answer(message, "<b>не удалось скачать видео.</b>")
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(['<code>'+str(i)+'</code>' 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'Ид <code>{str(user)}</code> исключен.')
else:
users_list.append(user)
await utils.answer(message, f'Ид <code>{str(user)}</code> добавлен.')
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