diff --git a/fiksofficial/python-modules/full.txt b/fiksofficial/python-modules/full.txt index 68362d7..72e180b 100644 --- a/fiksofficial/python-modules/full.txt +++ b/fiksofficial/python-modules/full.txt @@ -23,4 +23,5 @@ point deviceinfo mpi aigenuser -github \ No newline at end of file +github +stream \ No newline at end of file diff --git a/fiksofficial/python-modules/libs/pyupdater.py b/fiksofficial/python-modules/libs/pyupdater.py new file mode 100644 index 0000000..df7e651 --- /dev/null +++ b/fiksofficial/python-modules/libs/pyupdater.py @@ -0,0 +1,222 @@ +# ______ ___ ___ _ _ +# ____ | ___ \ | \/ | | | | | +# / __ \| |_/ / _| . . | ___ __| |_ _| | ___ +# / / _` | __/ | | | |\/| |/ _ \ / _` | | | | |/ _ \ +# | | (_| | | | |_| | | | | (_) | (_| | |_| | | __/ +# \ \__,_\_| \__, \_| |_/\___/ \__,_|\__,_|_|\___| +# \____/ __/ | +# |___/ + +# На модуль распространяется лицензия "GNU General Public License v3.0" +# https://github.com/all-licenses/GNU-General-Public-License-v3.0 + +# meta developer: @pymodule +# requires: aiohttp + +import asyncio +import hashlib +import hmac +import logging +import re +import time + +from .. import loader + +logger = logging.getLogger(__name__) + +BLOB_RE = re.compile(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)") +RAW_RE = re.compile(r"https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)") + +API_BASE = "https://37a5bcc11453.vps.myjino.ru" +BOT_USERNAME = "pyupdater_bot" +GITHUB_TOKEN = "" + +TOKEN_DB_KEY = "user_token" +TOKEN_PREFIX = "pyut_" + + +def _parse(url: str): + for pat in (BLOB_RE, RAW_RE): + m = pat.match(url.strip()) + if m: + return m.groups() + return None + + +def _gh_headers() -> dict: + h = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + if GITHUB_TOKEN: + h["Authorization"] = f"Bearer {GITHUB_TOKEN}" + return h + + +def _sign(secret_key: str, api_key: str) -> str: + bucket = (int(time.time()) // 60) * 60 + msg = f"{api_key}:{bucket}".encode() + return hmac.new(secret_key.encode(), msg, hashlib.sha256).hexdigest() + + +class PyUpdaterLib(loader.Library): + developer = "@pyupdater" + version = (1, 0, 0) + + async def init(self): + await self._refresh_token() + + async def _refresh_token(self): + """Каждый раз при загрузке библиотеки запрашивает свежий токен у бота.""" + try: + sent = await self.client.send_message(BOT_USERNAME, "/token") + + response = None + for _ in range(20): + await asyncio.sleep(0.5) + msgs = await self.client.get_messages(BOT_USERNAME, limit=1) + if ( + msgs + and msgs[0].id != sent.id + and msgs[0].text + and msgs[0].text.startswith(TOKEN_PREFIX) + ): + response = msgs[0] + break + + if response is None: + logger.warning("PyUpdater: не получили токен от бота") + return + + self._lib_set(TOKEN_DB_KEY, response.text.strip()) + logger.info("PyUpdater: токен обновлён") + + async def _cleanup(): + await asyncio.sleep(3) + try: + await sent.delete() + await response.delete() + except Exception: + pass + + asyncio.create_task(_cleanup()) + + except Exception: + logger.exception("PyUpdater: ошибка при получении токена") + + async def check( + self, + module: loader.Module, + github_url: str, + api_key: str, + secret_key: str, + ) -> None: + parts = _parse(github_url) + if parts is None: + logger.warning("PyUpdater: cannot parse URL %r", github_url) + return + + owner, repo, branch, path = parts + module_name = module.strings["name"] + + asyncio.create_task(self._ping(api_key, secret_key, module.tg_id, module_name)) + asyncio.create_task(self._maybe_confirm_test(api_key, module.tg_id)) + + commits_url = ( + f"https://api.github.com/repos/{owner}/{repo}/commits" + f"?path={path}&sha={branch}&per_page=1" + ) + raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}" + db_key = f"pyu_sha_{owner}_{repo}_{path.replace('/', '_')}" + saved_sha: str = module._db.get(module_name, db_key, "") + + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get( + commits_url, + headers=_gh_headers(), + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status in (403, 429): + logger.warning("PyUpdater: GitHub rate limit for %s", module_name) + return + resp.raise_for_status() + commits = await resp.json() + + if not commits: + return + + latest_sha: str = commits[0]["sha"] + if latest_sha == saved_sha: + return + + async with session.get( + raw_url, + headers=_gh_headers(), + timeout=aiohttp.ClientTimeout(total=20), + ) as resp: + resp.raise_for_status() + new_code = await resp.text() + + except asyncio.TimeoutError: + logger.warning("PyUpdater: timeout for %s", module_name) + return + except Exception: + logger.exception("PyUpdater: error checking %s", module_name) + return + + module._db.set(module_name, db_key, latest_sha) + logger.info("PyUpdater: reloading %s (commit %s)", module_name, latest_sha[:7]) + + loader_mod = next( + (m for m in module.allmodules.modules if m.__class__.__name__ == "LoaderMod"), + None, + ) + if loader_mod is None: + logger.error("PyUpdater: LoaderMod not found") + return + + asyncio.create_task(loader_mod.load_module(new_code, None, save_fs=True)) + + async def _maybe_confirm_test(self, api_key: str, user_tg_id: int) -> None: + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get( + f"{API_BASE}/test/pending", + params={"api_key": api_key}, + timeout=aiohttp.ClientTimeout(total=8), + ) as resp: + if resp.status != 200: + return + data = await resp.json() + if not data.get("pending"): + return + + await session.post( + f"{API_BASE}/test/confirm", + json={"api_key": api_key, "user_tg_id": user_tg_id}, + timeout=aiohttp.ClientTimeout(total=8), + ) + except Exception: + pass + + async def _ping(self, api_key: str, secret_key: str, user_tg_id: int, module_name: str) -> None: + user_token: str = self._lib_get(TOKEN_DB_KEY, "") + payload = { + "api_key": api_key, + "signature": _sign(secret_key, api_key), + "user_tg_id": user_tg_id, + "module_name": module_name, + } + if user_token: + payload["user_token"] = user_token + + try: + import aiohttp + async with aiohttp.ClientSession() as session: + await session.post( + f"{API_BASE}/ping", + json=payload, + timeout=aiohttp.ClientTimeout(total=8), + ) + except Exception: + pass diff --git a/fiksofficial/python-modules/stream.py b/fiksofficial/python-modules/stream.py new file mode 100644 index 0000000..bd048d4 --- /dev/null +++ b/fiksofficial/python-modules/stream.py @@ -0,0 +1 @@ +# 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 diff --git a/mead0wsss/mead0wsMods/SenderGifts.py b/mead0wsss/mead0wsMods/SenderGifts.py index 730aa5d..195a232 100644 --- a/mead0wsss/mead0wsMods/SenderGifts.py +++ b/mead0wsss/mead0wsMods/SenderGifts.py @@ -88,6 +88,12 @@ class SenderGifts(loader.Module): "gifts": [ {"id": 5866352046986232958, "emoji": "🧸", "name": "8 Марта мишка", "price": 50}, ] + }, + "saint_patricks_day ": { + "name": "💰 День святого патрика", + "gifts": [ + {"id": 5893356958802511476, "emoji": "🧸", "name": "Лепрекон мишка", "price": 50}, + ] } } diff --git a/modules.json b/modules.json index 0b12db1..c2b2fb0 100644 --- a/modules.json +++ b/modules.json @@ -52266,13 +52266,7 @@ "_doc_text_ru": "Текст, который будет написан рядом с файлом", "_doc_username_ru": "Ваш username с last.fm", "nick_error_ru": " Укажите ваш никнейм с last.fm", - "uploading_ru": "🕔 Загрузка баннера...", - "name_jp": "LastFm", - "no_track_jp": " 現在再生中のトラックはありません", - "_doc_text_jp": "ファイルの横に表示されるテキスト", - "_doc_username_jp": "Last.fmのユーザー名", - "nick_error_jp": " Last.fmのニックネームを入力してください", - "uploading_jp": "🕔 バナーをアップロード中..." + "uploading_ru": "🕔 Загрузка баннера..." }, "has_on_load": false, "has_on_unload": false, @@ -62648,6 +62642,148 @@ "has_on_unload": false, "class_cmd_names": {} }, + "fiksofficial/python-modules/stream.py": { + "name": "StreamMod", + "description": "📡 RTMP media streaming", + "cls_doc": { + "ru": "📡 RTMP стриминг медиафайлов" + }, + "meta": { + "pic": null, + "banner": null, + "developer": null + }, + "commands": [ + { + "stream": "[reply to media] — start stream or add to queue | (RU) [ответ на медиа] – запустить трансляцию" + }, + { + "streamctl": "– open stream control panel | (RU) – панель управления трансляцией" + }, + { + "streamstop": "– stop stream and clear queue | (RU) – остановить трансляцию и очистить очередь" + } + ], + "new_commands": [ + { + "name": "stream", + "original_name": "stream", + "description": { + "default": "[reply to media] — start stream or add to queue", + "ru": "[ответ на медиа] – запустить трансляцию" + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, + { + "name": "streamctl", + "original_name": "streamctl", + "description": { + "default": "– open stream control panel", + "ru": "– панель управления трансляцией" + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + }, + { + "name": "streamstop", + "original_name": "streamstop", + "description": { + "default": "– stop stream and clear queue", + "ru": "– остановить трансляцию и очистить очередь" + }, + "cmd_names": {}, + "aliases": [], + "usage": null, + "inline": false, + "is_inline_handler": false, + "decorators": [] + } + ], + "inline_handlers": [], + "strings": { + "name": "Stream", + "status_active": "▶️ Stream is live\n\n{icon} {file}\n⏱ Time: {elapsed}\n🔢 PID: {pid}\n📡 {rtmp}\n🎥 {vbr} | {fps}fps | {preset}\n🔊 {abr}\n📋 Queue: {queue}", + "status_idle": "⏸ Stream is not active", + "status_queue": "\n📋 Queue: {n}", + "stopped": "⏹ Stream stopped.", + "no_rtmp": "❌ RTMP not configured!\nTap a button to set it up.", + "downloading": "⏳ Downloading…", + "dl_failed": "❌ Failed to download file.", + "queued": "📋 Added to queue ({n})\n{icon} {file}", + "not_running": "Not running", + "queue_empty": "Queue is empty", + "queue_header": "📋 Queue:\n", + "settings_title": "⚙️ Stream settings", + "btn_stop": "⏹ Stop", + "btn_queue": "📋 Queue", + "btn_refresh": "🔄 Refresh", + "btn_settings": "⚙️ Settings", + "btn_status": "📊 Status", + "btn_back": "🔙 Back", + "btn_preset": "🎞 Preset: {v}", + "btn_tune": "🎭 Tune: {v}", + "btn_vbr": "🎥 Video: {v}", + "btn_abr": "🔊 Audio: {v}", + "btn_fps": "📐 FPS: {v}", + "btn_res": "🖥 Res: {v}", + "btn_threads": "🧵 Threads: {v}", + "btn_rtmps": "📡 RTMP URL", + "btn_key": "🔑 Stream key", + "btn_set_rtmps": "📡 Set RTMP URL", + "btn_set_key": "🔑 Set stream key", + "ph_vbr": "Video bitrate, e.g. 2000k", + "ph_abr": "Audio bitrate, e.g. 128k", + "ph_threads": "Thread count (0 = auto)", + "ph_rtmps": "rtmp://a.rtmp.youtube.com/live2", + "ph_key": "Stream key...", + "status_active_ru": "▶️ Трансляция идёт\n\n{icon} {file}\n⏱ Время: {elapsed}\n🔢 PID: {pid}\n📡 {rtmp}\n🎥 {vbr} | {fps}fps | {preset}\n🔊 {abr}\n📋 В очереди: {queue}", + "status_idle_ru": "⏸ Трансляция не активна", + "status_queue_ru": "\n📋 В очереди: {n}", + "stopped_ru": "⏹ Трансляция остановлена.", + "no_rtmp_ru": "❌ RTMP не настроен!\nНажми кнопку чтобы задать прямо сейчас.", + "downloading_ru": "⏳ Скачиваю…", + "dl_failed_ru": "❌ Не удалось скачать файл.", + "queued_ru": "📋 Добавлено в очередь ({n} шт.)\n{icon} {file}", + "not_running_ru": "Не запущено", + "queue_empty_ru": "Очередь пуста", + "queue_header_ru": "📋 Очередь:\n", + "settings_title_ru": "⚙️ Настройки трансляции", + "btn_stop_ru": "⏹ Стоп", + "btn_queue_ru": "📋 Очередь", + "btn_refresh_ru": "🔄 Обновить", + "btn_settings_ru": "⚙️ Настройки", + "btn_status_ru": "📊 Статус", + "btn_back_ru": "🔙 Назад", + "btn_preset_ru": "🎞 Пресет: {v}", + "btn_tune_ru": "🎭 Tune: {v}", + "btn_vbr_ru": "🎥 Видео: {v}", + "btn_abr_ru": "🔊 Аудио: {v}", + "btn_fps_ru": "📐 FPS: {v}", + "btn_res_ru": "🖥 Разр: {v}", + "btn_threads_ru": "🧵 Треды: {v}", + "btn_rtmps_ru": "📡 RTMP URL", + "btn_key_ru": "🔑 Ключ", + "btn_set_rtmps_ru": "📡 Задать RTMP URL", + "btn_set_key_ru": "🔑 Задать ключ", + "ph_vbr_ru": "Битрейт видео, напр. 2000k", + "ph_abr_ru": "Битрейт аудио, напр. 128k", + "ph_threads_ru": "Потоков (0 = авто)", + "ph_rtmps_ru": "rtmp://a.rtmp.youtube.com/live2", + "ph_key_ru": "Ключ трансляции..." + }, + "has_on_load": false, + "has_on_unload": false, + "class_cmd_names": {} + }, "fiksofficial/python-modules/lyrics.py": { "name": "LyricsMod", "description": "Модуль для поиска текста песни через Genius API напрямую", @@ -82294,7 +82430,7 @@ } }, "meta": { - "total_modules": 1021, - "generated_at": "2026-03-11T01:22:17.528364" + "total_modules": 1022, + "generated_at": "2026-03-23T01:31:16.986395" } } \ No newline at end of file diff --git a/radiocycle/Modules/LastFm.py b/radiocycle/Modules/LastFm.py index 7069ef3..90ad508 100644 --- a/radiocycle/Modules/LastFm.py +++ b/radiocycle/Modules/LastFm.py @@ -39,11 +39,11 @@ class Banners: def _prepare_cover(self, size, radius): cover = Image.open(io.BytesIO(self.track_cover)).convert("RGBA") cover = cover.resize((size, size), Image.Resampling.LANCZOS) - + mask = Image.new("L", (size, size), 0) draw = ImageDraw.Draw(mask) draw.rounded_rectangle((0, 0, size, size), radius=radius, fill=255) - + output = Image.new("RGBA", (size, size), (0, 0, 0, 0)) output.paste(cover, (0, 0), mask=mask) return output @@ -59,15 +59,15 @@ class Banners: W, H = 1500, 600 padding = 60 cover_size = 480 - + font_bytes = requests.get(self.font_url).content title_font = self._get_font(55, font_bytes) artist_font = self._get_font(45, font_bytes) - lfm_font = self._get_font(35, font_bytes) + lfm_font = self._get_font(55, font_bytes) img = self._prepare_background(W, H) draw = ImageDraw.Draw(img) - + cover = self._prepare_cover(cover_size, 30) img.paste(cover, (padding, (H - cover_size) // 2), cover) @@ -88,8 +88,8 @@ class Banners: draw.text((text_x, text_y_start), display_title, font=title_font, fill="white") draw.text((text_x, text_y_start + 70), display_artist, font=artist_font, fill="#B3B3B3") - bar_y = 480 - draw.text((text_x, bar_y), "last.fm", font=lfm_font, fill="white") + text_y = 430 + draw.text((text_x, text_y), "last.fm", font=lfm_font, fill="white") by = io.BytesIO() img.save(by, format="PNG") @@ -98,24 +98,24 @@ class Banners: return by def vertical(self): - W, H = 1000, 1500 - padding = 80 + W, H = 1000, 1300 + padding = 60 cover_size = 800 - + font_bytes = requests.get(self.font_url).content title_font = self._get_font(60, font_bytes) artist_font = self._get_font(45, font_bytes) - lfm_font = self._get_font(35, font_bytes) + lfm_font = self._get_font(60, font_bytes) img = self._prepare_background(W, H) draw = ImageDraw.Draw(img) cover = self._prepare_cover(cover_size, 40) cover_x = (W - cover_size) // 2 - cover_y = 120 + cover_y = 100 img.paste(cover, (cover_x, cover_y), cover) - text_area_y = cover_y + cover_size + 120 + text_area_y = cover_y + cover_size + 60 text_width_limit = W - (padding * 2) display_title = self.title @@ -134,10 +134,10 @@ class Banners: artist_w = artist_font.getlength(display_artist) draw.text(((W - artist_w) / 2, text_area_y + 75), display_artist, font=artist_font, fill="#B3B3B3") - bar_y = text_area_y + 260 - + text_y = text_area_y + 180 + lfm_w = lfm_font.getlength("last.fm") - draw.text(((W - lfm_w) / 2, bar_y), "last.fm", font=lfm_font, fill="white") + draw.text(((W - lfm_w) / 2, text_y), "last.fm", font=lfm_font, fill="white") by = io.BytesIO() img.save(by, format="PNG") @@ -165,14 +165,6 @@ class lastfmmod(loader.Module): "nick_error": " Укажите ваш никнейм с last.fm", "uploading": "🕔 Загрузка баннера...", } - strings_jp = { - "name": "LastFm", - "no_track": " 現在再生中のトラックはありません", - "_doc_text": "ファイルの横に表示されるテキスト", - "_doc_username": "Last.fmのユーザー名", - "nick_error": " Last.fmのニックネームを入力してください", - "uploading": "🕔 バナーをアップロード中...", - } def __init__(self): self.config = loader.ModuleConfig( @@ -180,6 +172,7 @@ class lastfmmod(loader.Module): loader.ConfigValue("custom_text", "🤩 {song_name}{song_artist}", lambda: self.strings["_doc_text"]), loader.ConfigValue("font", "https://raw.githubusercontent.com/kamekuro/assets/master/fonts/Onest-Bold.ttf", "Custom font URL (ttf)"), loader.ConfigValue("banner_version", "horizontal", lambda: "Banner version", validator=loader.validators.Choice(["horizontal", "vertical"])), + loader.ConfigValue("fallback_cover", "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", "Fallback cover URL if track has no image"), ) @loader.command(alias="np") @@ -189,22 +182,27 @@ class lastfmmod(loader.Module): if not user: await self.invoke("config", "lastfm", message=message) return await utils.answer(message, self.strings["nick_error"]) - + try: + msg = await utils.answer(message, self.strings["uploading"]) + url = f'http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&nowplaying=true&user={user}&api_key=460cda35be2fbf4f28e8ea7a38580730&format=json' data = requests.get(url).json() track = next((t for t in data.get('recenttracks', {}).get('track', []) if t.get('@attr', {}).get('nowplaying')), None) if not track: - return await utils.answer(message, self.strings["no_track"]) + return await utils.answer(msg, self.strings["no_track"]) name = track.get('name', 'Unknown') artist = track.get('artist', {}).get('#text', 'Unknown') caption = self.config["custom_text"].format(song_artist=artist, song_name=name) imgs = track.get('image', []) cov_url = next((i['#text'] for i in imgs if i['size'] == 'extralarge'), imgs[-1]['#text'] if imgs else None) - if not cov_url: - return await utils.answer(message, caption) - msg = await utils.answer(message, self.strings["uploading"]) + if not cov_url or not str(cov_url).strip(): + cov_url = self.config["fallback_cover"] + + if not cov_url or not str(cov_url).strip(): + return await utils.answer(msg, caption) + cov_bytes = await utils.run_sync(requests.get, cov_url) banners = Banners(name, artist, cov_bytes.content, self.config["font"]) file = await utils.run_sync(getattr(banners, self.config["banner_version"]))