diff --git a/KorenbZla/HikkaModules/Send.py b/KorenbZla/HikkaModules/Send.py index 794cd66..7688cf5 100644 --- a/KorenbZla/HikkaModules/Send.py +++ b/KorenbZla/HikkaModules/Send.py @@ -23,7 +23,7 @@ # meta pic: https://i.postimg.cc/Hx3Zm8rB/logo.png # meta banner: https://te.legra.ph/file/55fa6eebae860a359ac27.jpg -__version__ = (1, 3, 2) +__version__ = (1, 3, 3) from .. import loader, utils # type: ignore from telethon.tl import types # type: ignore @@ -103,70 +103,70 @@ class SendMod(loader.Module): else: await message.edit(f'{self.strings["error"]} {str(e)}') +# telegram fixed bug + # @loader.command( + # ru_doc="[text or reply(media/file/sticker) or coordinates (, )] - Написать сообщение в закрытую тему", + # uz_doc="[text or reply(media/file/sticker) or coordinates (, )] - Yopiq mavzuga xabar yozing", + # de_doc="[text or reply(media/file/sticker) or coordinates (, )] - Schreiben Sie eine Nachricht zu einem geschlossenen Thema", + # es_doc="[text or reply(media/file/sticker) or coordinates (, )] - Escribir un mensaje a un tema cerrado", + # ) + # async def sendclosedtopic(self, message: Message): + # """[text or reply(media/file/sticker) or coordinates (, )] - Write a message to a closed topic""" + # args = utils.get_args_raw(message) + # message_text = args if args else "" + # reply = await message.get_reply_message() - @loader.command( - ru_doc="[text or reply(media/file/sticker) or coordinates (, )] - Написать сообщение в закрытую тему", - uz_doc="[text or reply(media/file/sticker) or coordinates (, )] - Yopiq mavzuga xabar yozing", - de_doc="[text or reply(media/file/sticker) or coordinates (, )] - Schreiben Sie eine Nachricht zu einem geschlossenen Thema", - es_doc="[text or reply(media/file/sticker) or coordinates (, )] - Escribir un mensaje a un tema cerrado", - ) - async def sendclosedtopic(self, message: Message): - """[text or reply(media/file/sticker) or coordinates (, )] - Write a message to a closed topic""" - args = utils.get_args_raw(message) - message_text = args if args else "" - reply = await message.get_reply_message() + # media = None + # temp_file = None - media = None - temp_file = None + # if reply and reply.media: + # doc = getattr(reply.media, "document", None) - if reply and reply.media: - doc = getattr(reply.media, "document", None) + # if doc and any(a.__class__.__name__ == "DocumentAttributeSticker" for a in doc.attributes): + # media = InputDocument( + # id=doc.id, + # access_hash=doc.access_hash, + # file_reference=doc.file_reference + # ) + # message_text = "" - if doc and any(a.__class__.__name__ == "DocumentAttributeSticker" for a in doc.attributes): - media = InputDocument( - id=doc.id, - access_hash=doc.access_hash, - file_reference=doc.file_reference - ) - message_text = "" - - elif doc and doc.mime_type == "image/webp": - temp_file = await reply.download_media() - media = temp_file - else: - media = reply.media - else: - media = message.media + # elif doc and doc.mime_type == "image/webp": + # temp_file = await reply.download_media() + # media = temp_file + # else: + # media = reply.media + # else: + # media = message.media - if message_text and "," in message_text: - lat_str, long_str = message_text.split(",", 1) - try: - gps_x = float(lat_str.strip()) - gps_y = float(long_str.strip()) - if -90 <= gps_x <= 90 and -180 <= gps_y <= 180: - geo_point = types.InputGeoPoint(lat=gps_x, long=gps_y) - media = types.InputMediaGeoPoint(geo_point) - message_text = "" - except ValueError: - pass + # if message_text and "," in message_text: + # lat_str, long_str = message_text.split(",", 1) + # try: + # gps_x = float(lat_str.strip()) + # gps_y = float(long_str.strip()) + # if -90 <= gps_x <= 90 and -180 <= gps_y <= 180: + # geo_point = types.InputGeoPoint(lat=gps_x, long=gps_y) + # media = types.InputMediaGeoPoint(geo_point) + # message_text = "" + # except ValueError: + # pass - if not message_text and not media: - await utils.answer(message, self.strings["error_send_2"]) - return + # if not message_text and not media: + # await utils.answer(message, self.strings["error_send_2"]) + # return - await message.delete() - await message.reply( - message_text, - file=media if media else None, - parse_mode="html" - ) + # await message.delete() + # await message.reply( + # message_text, + # file=media if media else None, + # parse_mode="html" + # ) - if temp_file: - import os - try: - os.remove(temp_file) - except: - pass + # if temp_file: + # import os + # try: + # os.remove(temp_file) + # except: + # pass @loader.command( ru_doc="[@UserName] [text or replay] - Написать сообщение в личные сообщения", diff --git a/archquise/H.Modules/AccountData.py b/archquise/H.Modules/AccountData.py index 1aee3ca..385a977 100644 --- a/archquise/H.Modules/AccountData.py +++ b/archquise/H.Modules/AccountData.py @@ -26,8 +26,10 @@ # scope: Api AccountData 0.0.1 # --------------------------------------------------------------------------------- +import asyncio import logging from datetime import datetime +from typing import Optional import aiohttp @@ -35,6 +37,7 @@ from .. import loader, utils logger = logging.getLogger(__name__) + @loader.tds class AccountData(loader.Module): """Find out the approximate date of registration of the telegram account""" @@ -45,9 +48,21 @@ class AccountData(loader.Module): "api_token", "7518491974:1ea2284eec9dc40a9838cfbcb48a2b36", "API token for datereg.pro", - validator=loader.validators.String(), + validator=loader.validators.Hidden(), ) ) + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=15) + ) + return self._session + + async def on_unload(self): + if self._session and not self._session.closed: + await self._session.close() strings = { "name": "AccountData", @@ -64,15 +79,16 @@ class AccountData(loader.Module): "no_reply": "💬 Вы не ответили на сообщение пользователя", } - async def get_creation_date(self, user_id: int) -> str: - api_token = self.config.get("api_token", "") + async def get_creation_date(self, user_id: int) -> dict: + api_token = self.config["api_token"] if not api_token: return {"error": "API token not configured"} - + url = "https://api.datereg.pro/api/v1/users/getCreationDateFast" params = {"token": api_token, "user_id": user_id} - async with aiohttp.ClientSession() as session: + session = await self._get_session() + try: async with session.get(url, params=params) as response: if response.status == 200: json_response = await response.json() @@ -80,11 +96,15 @@ class AccountData(loader.Module): return { "creation_date": json_response["creation_date"], "accuracy_percent": json_response["accuracy_percent"], - } # type: ignore + } else: - return {"error": json_response["error"]["message"]} # type: ignore + return {"error": json_response["error"]["message"]} else: - return {"error": f"HTTP {response.status}"} # type: ignore + return {"error": f"HTTP {response.status}"} + except asyncio.TimeoutError: + return {"error": "Request timed out"} + except Exception as e: + return {"error": str(e)} @loader.command( ru_doc="Узнать примерную дату регистрации Telergam-аккаунта", @@ -93,17 +113,17 @@ class AccountData(loader.Module): async def accdata(self, message): if reply := await message.get_reply_message(): result = await self.get_creation_date(user_id=reply.sender.id) - + if "error" in result or not result.get("creation_date"): error_msg = result.get("error", "Unknown error occurred") await utils.answer(message, f"Ошибка: {error_msg}") return - + try: - month, year = map(int, result['creation_date'].split('.')) + month, year = map(int, result["creation_date"].split(".")) date_object = datetime(year, month, 1) - formatted = date_object.strftime('%B %Y') - + formatted = date_object.strftime("%B %Y") + await utils.answer( message, f"{self.strings('date_text').format(data=formatted, accuracy=result['accuracy_percent'])}\n\n{self.strings('date_text_ps')}", diff --git a/archquise/H.Modules/AniLiberty.py b/archquise/H.Modules/AniLiberty.py index 801e58f..737456d 100644 --- a/archquise/H.Modules/AniLiberty.py +++ b/archquise/H.Modules/AniLiberty.py @@ -43,26 +43,35 @@ from ..inline.types import InlineQuery logger = logging.getLogger(__name__) -BASE_API_URL = "https://aniliberty.top/api/v1" +BASE_API_URL = "https://aniliberty.top/api/v1" + # Датаклассы для парсинга и хранения json @dataclass class Genre: name: str -@dataclass + + +@dataclass class Name: main: str + + @dataclass class Type: description: str + + @dataclass class Poster: preview: str thumbnail: str + + @dataclass class ReleaseInfo: id: int - genres: Optional[list[Genre]] + genres: Optional[list[Genre]] name: Name is_ongoing: bool type: Type @@ -71,6 +80,7 @@ class ReleaseInfo: alias: str poster: Poster + @loader.tds class AniLibertyMod(loader.Module): """Ищет и возвращает случайное аниме из базы Aniliberty""" @@ -92,36 +102,53 @@ class AniLibertyMod(loader.Module): "favorite": "Избранное <3:", # < == < } + def __init__(self): + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=15) + ) + return self._session + + async def on_unload(self): + if self._session and not self._session.closed: + await self._session.close() + async def search_title(self, query): - async with aiohttp.ClientSession() as session: - async with session.get(f'{BASE_API_URL}/app/search/releases?query={query}&include=id%2Cname.main%2Cis_ongoing%2Ctype.description%2Cdescription%2Cadded_in_users_favorites%2Calias%2Cposter.preview%2Cposter.thumbnail') as resp: - json_answer = await resp.json() - results = [] - for i in json_answer: - obj = from_dict(data_class=ReleaseInfo, data=i) - results.append(obj) - return results - + session = await self._get_session() + async with session.get( + f"{BASE_API_URL}/app/search/releases?query={query}&include=id%2Cname.main%2Cis_ongoing%2Ctype.description%2Cdescription%2Cadded_in_users_favorites%2Calias%2Cposter.preview%2Cposter.thumbnail" + ) as resp: + json_answer = await resp.json() + results = [] + for i in json_answer: + obj = from_dict(data_class=ReleaseInfo, data=i) + results.append(obj) + return results + async def get_title(self, release_id): - async with aiohttp.ClientSession() as session: - async with session.get(f'{BASE_API_URL}/anime/releases/{release_id}?include=id%2Cgenres.name%2Cname.main%2Cis_ongoing%2Ctype.description%2Cdescription%2Cadded_in_users_favorites%2Calias%2Cposter.preview%2Cposter.thumbnail') as resp: - try: - json_answer = await resp.json() - data = from_dict(data_class=ReleaseInfo, data=json_answer) - return data - except JSONDecodeError: - logger.error("Ошибка парсинга JSON!") + session = await self._get_session() + async with session.get( + f"{BASE_API_URL}/anime/releases/{release_id}?include=id%2Cgenres.name%2Cname.main%2Cis_ongoing%2Ctype.description%2Cdescription%2Cadded_in_users_favorites%2Calias%2Cposter.preview%2Cposter.thumbnail" + ) as resp: + try: + json_answer = await resp.json() + data = from_dict(data_class=ReleaseInfo, data=json_answer) + return data + except JSONDecodeError: + logger.error("Ошибка парсинга JSON!") async def get_random_title(self): - async with aiohttp.ClientSession() as session: - async with session.get(f'{BASE_API_URL}/anime/releases/random?limit=1&include=id') as resp: - randid = await resp.json() - """ - Приходится запрашивать по второму кругу, т.к. API в рандомных релизах не отдает жанры, даже если попросить через include - """ - data = await self.get_title(randid[0]['id']) - return data - + session = await self._get_session() + async with session.get( + f"{BASE_API_URL}/anime/releases/random?limit=1&include=id" + ) as resp: + randid = await resp.json() + data = await self.get_title(randid[0]["id"]) + return data + @loader.command( ru_doc="Возвращает случайный релиз из базы", en_doc="Returns a random release from the database", @@ -130,17 +157,18 @@ class AniLibertyMod(loader.Module): anime_release = await self.get_random_title() genres_str = "" for genre in anime_release.genres[:-1]: - genres_str += f'{genre.name}, ' + genres_str += f"{genre.name}, " genres_str += anime_release.genres[-1].name - text = f"{anime_release.name.main} \n" text += f"{self.strings['ongoing']} {'Да' if anime_release.is_ongoing else 'Нет'}\n\n" text += f"{self.strings['type']} {anime_release.type.description}\n" text += f"{self.strings['genres']} {genres_str}\n\n" - + text += f"{anime_release.description}\n\n" - text += f"{self.strings['favorite']} {str(anime_release.added_in_users_favorites)}" + text += ( + f"{self.strings['favorite']} {str(anime_release.added_in_users_favorites)}" + ) kb = [ [ @@ -179,14 +207,14 @@ class AniLibertyMod(loader.Module): """ Приходится запрашивать по второму кругу, т.к. API в поиске не отдает жанры, даже если попросить через include """ - release_genres = await self.get_title(anime_release.id) + release_genres = await self.get_title(anime_release.id) genres_str = "" for genre in release_genres.genres[:-1]: - genres_str += f'{genre.name}, ' + genres_str += f"{genre.name}, " genres_str += release_genres.genres[-1].name release_text = ( f"{anime_release.name.main}\n" - f"{self.strings['ongoing']} {"Да" if anime_release.is_ongoing else "Нет"}\n\n" + f"{self.strings['ongoing']} {'Да' if anime_release.is_ongoing else 'Нет'}\n\n" f"{self.strings['type']} {anime_release.type.description}\n" f"{self.strings['genres']} {genres_str}\n\n" f"{anime_release.description}\n\n" @@ -214,16 +242,18 @@ class AniLibertyMod(loader.Module): anime_release = await self.get_random_title() genres_str = "" for genre in anime_release.genres[:-1]: - genres_str += f'{genre.name}, ' + genres_str += f"{genre.name}, " genres_str += anime_release.genres[-1].name text = f"{anime_release.name.main} \n" - text += f"{self.strings['ongoing']} {"Да" if anime_release.is_ongoing else "Нет"}\n\n" + text += f"{self.strings['ongoing']} {'Да' if anime_release.is_ongoing else 'Нет'}\n\n" text += f"{self.strings['type']} {anime_release.type.description}\n" text += f"{self.strings['genres']} {genres_str}\n\n" text += f"{anime_release.description}\n\n" - text += f"{self.strings['favorite']} {str(anime_release.added_in_users_favorites)}" + text += ( + f"{self.strings['favorite']} {str(anime_release.added_in_users_favorites)}" + ) kb = [ [ diff --git a/archquise/H.Modules/AnimeQuotes.py b/archquise/H.Modules/AnimeQuotes.py index c06edb0..4d98c64 100644 --- a/archquise/H.Modules/AnimeQuotes.py +++ b/archquise/H.Modules/AnimeQuotes.py @@ -28,6 +28,7 @@ # --------------------------------------------------------------------------------- import logging +from typing import Optional import aiohttp @@ -35,6 +36,7 @@ from .. import loader, utils logger = logging.getLogger(__name__) + @loader.tds class AnimeQuotesMod(loader.Module): """A module for sending random quotes from anime""" @@ -58,6 +60,20 @@ class AnimeQuotesMod(loader.Module): "error": "Не удалось получить цитату. Попробуйте позже!", } + def __init__(self): + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=15) + ) + return self._session + + async def on_unload(self): + if self._session and not self._session.closed: + await self._session.close() + @loader.command( ru_doc="Получить случайную цитату из аниме", en_doc="Get a random quote from the anime", @@ -66,19 +82,19 @@ class AnimeQuotesMod(loader.Module): url = "https://api.animechan.io/v1/quotes/random" try: - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - response.raise_for_status() - data = await response.json() + session = await self._get_session() + async with session.get(url) as response: + response.raise_for_status() + data = await response.json() - quote_content = data["data"]["content"] - character_name = data["data"]["character"]["name"] - anime_name = data["data"]["anime"]["name"] + quote_content = data["data"]["content"] + character_name = data["data"]["character"]["name"] + anime_name = data["data"]["anime"]["name"] - quote = self.strings["quote_template"].format( - quote=quote_content, character=character_name, anime=anime_name - ) - await utils.answer(message, quote) + quote = self.strings("quote_template").format( + quote=quote_content, character=character_name, anime=anime_name + ) + await utils.answer(message, quote) except aiohttp.ClientError: - await utils.answer(message, self.strings["error"]) + await utils.answer(message, self.strings("error")) diff --git a/archquise/H.Modules/CodeShare.py b/archquise/H.Modules/CodeShare.py index f3a9543..f22a523 100644 --- a/archquise/H.Modules/CodeShare.py +++ b/archquise/H.Modules/CodeShare.py @@ -29,6 +29,7 @@ import aiohttp import aiofiles import os import logging +from typing import Optional from .. import loader, utils from telethon.types import MessageMediaDocument @@ -53,19 +54,33 @@ class CodeShareMod(loader.Module): "link_ready": " Код загружен! Ссылка: {}", } - async def upload_to_kmi(self, content: str) -> str: + def __init__(self): + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=15) + ) + return self._session + + async def on_unload(self): + if self._session and not self._session.closed: + await self._session.close() + + async def upload_to_kmi(self, content: str) -> Optional[str]: url = "https://kmi.aeza.net" data = aiohttp.FormData() data.add_field("kmi", content) - async with aiohttp.ClientSession() as session: - async with session.post(url, data=data) as response: - if response.status == 200: - link = await response.text() - return link - else: - logger.error(f"Error occurred! Status code: {response.status}") - return + session = await self._get_session() + async with session.post(url, data=data) as response: + if response.status == 200: + link = await response.text() + return link + else: + logger.error(f"Error occurred! Status code: {response.status}") + return None @loader.command( ru_doc="Загрузка кода на сайт", diff --git a/archquise/H.Modules/CryptoCurrency.py b/archquise/H.Modules/CryptoCurrency.py index 488347f..bff8059 100644 --- a/archquise/H.Modules/CryptoCurrency.py +++ b/archquise/H.Modules/CryptoCurrency.py @@ -27,6 +27,7 @@ # --------------------------------------------------------------------------------- import logging +from typing import Optional import aiohttp @@ -34,6 +35,7 @@ from .. import loader, utils logger = logging.getLogger(__name__) + @loader.tds class CryptoCurrencyMod(loader.Module): """Module for displaying current cryptocurrency exchange rates.""" @@ -49,12 +51,26 @@ class CryptoCurrencyMod(loader.Module): "coin_not_found": "Криптовалюта '{query}' не найдена.", } + def __init__(self): + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=15) + ) + return self._session + + async def on_unload(self): + if self._session and not self._session.closed: + await self._session.close() + async def fetch_json(self, url): """Fetch JSON data from a given URL.""" - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - response.raise_for_status() - return await response.json() + session = await self._get_session() + async with session.get(url) as response: + response.raise_for_status() + return await response.json() async def get_exchange_rates(self): """Get exchange rates for RUB and EUR based on USD.""" diff --git a/archquise/H.Modules/FolderAutoRead.py b/archquise/H.Modules/FolderAutoRead.py index 40810ba..fd742b7 100644 --- a/archquise/H.Modules/FolderAutoRead.py +++ b/archquise/H.Modules/FolderAutoRead.py @@ -17,7 +17,7 @@ # --------------------------------------------------------------------------------- # Name: FolderAutoRead -# Description: Automatically reads chats in selected folders +# Description: Automatically reads chats in selected folders # Author: @hikka_mods # --------------------------------------------------------------------------------- # meta developer: @hikka_mods @@ -41,13 +41,13 @@ class FolderAutoReadMod(loader.Module): "name": "FolderAutoRead", "not_exists_or_already_added": "🚫 This folder does not exists or it is already added for tracking!", "_cls_doc": "Automatically reads chats in selected folders every 60 seconds!", - "_cmd_doc_addfolder": "Adds folder to the tracking list by it's name. Usage: .addfolder FolderName", + "_cmd_doc_addfolder": "Adds folder to the tracking list by it's name. Usage: .addfolder FolderName", "_cmd_doc_listfolders": "Prints list of tracked folders", "_cmd_doc_delfolder": "Deletes folder from the tracking list", "wrong_args": "🚫 Wrong arguments! Usage: .addfolder/delfolder FolderName\n\nTip: If you trying to delete the folder from the tracking list, double-check that it really still tracking using .listfolders", "listfolders": "📁 List of tracked folders:\n", "delfolder": "🗑 Folder is successfully deleted from the tracking list!", - "addfolder": "📁 Folder is successfully added to the tracking list!" + "addfolder": "📁 Folder is successfully added to the tracking list!", } strings_ru = { @@ -59,79 +59,96 @@ class FolderAutoReadMod(loader.Module): "wrong_args": "🚫 Неверные аргументы! Использование: .addfolder/delfolder НазваниеПапки\n\nСовет: Если вы пытаетесь удалить папку из списка отслеживания, проверьте, что она вообще отслеживается, используя .listfolders", "listfolders": "📁 Список отслеживаемых папок:\n", "delfolder": "🗑 Папка успешно удалена из листа отслеживания!", - "addfolder": "📁 Папка успешно добавлена в лист отслеживания!" + "addfolder": "📁 Папка успешно добавлена в лист отслеживания!", } def __init__(self): self.tracked_folders = [] async def client_ready(self, client, db): - self.tracked_folders = self.get("tracked_folders", []) + self.tracked_folders = self.pointer("tracked_folders", []) - async def on_unload(self): - self.tracked_folders = [] - self.set("tracked_folders", []) + async def _read_peers(self, peers): + for peer in peers: + try: + await self._client(functions.messages.ReadMentionsRequest(peer=peer)) + await self._client(functions.messages.ReadReactionsRequest(peer=peer)) + if isinstance(peer, InputPeerChannel): + await self._client( + functions.channels.ReadHistoryRequest(channel=peer, max_id=0) + ) + else: + await self._client( + functions.messages.ReadHistoryRequest(peer=peer, max_id=0) + ) + except Exception as e: + logger.debug(f"Failed to read peer {peer}: {e}") @loader.loop(interval=60, autostart=True) async def read_chats_in_folders(self): if self.tracked_folders: - all_folders = await self._client(functions.messages.GetDialogFiltersRequest()) - for i in range(len(self.tracked_folders)): + all_folders = await self._client( + functions.messages.GetDialogFiltersRequest() + ) + for folder_name in self.tracked_folders: match = next( - (f for f in all_folders.filters - if isinstance(f, DialogFilter) and f.title.text == self.tracked_folders[i]), - None + ( + f + for f in all_folders.filters + if isinstance(f, DialogFilter) and f.title.text == folder_name + ), + None, ) - for peer in match.pinned_peers: - await self._client(functions.messages.ReadMentionsRequest(peer=peer)) - await self._client(functions.messages.ReadReactionsRequest(peer=peer)) - if isinstance(peer, InputPeerChannel): - await self._client(functions.channels.ReadHistoryRequest(channel=peer, max_id=0)) - else: - await self._client(functions.messages.ReadHistoryRequest(peer=peer, max_id=0)) - for peer in match.include_peers: - await self._client(functions.messages.ReadMentionsRequest(peer=peer)) - await self._client(functions.messages.ReadReactionsRequest(peer=peer)) - if isinstance(peer, InputPeerChannel): - await self._client(functions.channels.ReadHistoryRequest(channel=peer, max_id=0)) - else: - await self._client(functions.messages.ReadHistoryRequest(peer=peer, max_id=0)) + if match is None: + continue + await self._read_peers(match.pinned_peers) + await self._read_peers(match.include_peers) - @loader.command() + @loader.command( + ru_doc="Добавить папку в список отслеживания", + en_doc="Add folder to the tracking list", + ) async def addfolder(self, message): arg = utils.get_args_raw(message) if arg: - all_folders = await self._client(functions.messages.GetDialogFiltersRequest()) - match = next( - (f for f in all_folders.filters - if isinstance(f, DialogFilter) and f.title.text == arg), - None + all_folders = await self._client( + functions.messages.GetDialogFiltersRequest() ) - if match and match not in self.tracked_folders: + match = next( + ( + f + for f in all_folders.filters + if isinstance(f, DialogFilter) and f.title.text == arg + ), + None, + ) + if match and arg not in self.tracked_folders: self.tracked_folders.append(arg) - self.set("tracked_folders", self.tracked_folders) - await utils.answer(message, self.strings['addfolder']) - else: - await utils.answer(message, self.strings["not_exists_or_already_added"]) - - @loader.command() + await utils.answer(message, self.strings("addfolder")) + else: + await utils.answer(message, self.strings("not_exists_or_already_added")) + else: + await utils.answer(message, self.strings("wrong_args")) + + @loader.command( + ru_doc="Удалить папку из списка отслеживания", + en_doc="Delete folder from the tracking list", + ) async def delfolder(self, message): arg = utils.get_args_raw(message) if arg and arg in self.tracked_folders: self.tracked_folders.remove(arg) - self.set("tracked_folders", self.tracked_folders) - await utils.answer(message, self.strings['delfolder']) + await utils.answer(message, self.strings("delfolder")) else: - await utils.answer(message, self.strings["wrong_args"]) - - @loader.command() + await utils.answer(message, self.strings("wrong_args")) + + @loader.command( + ru_doc="Список отслеживаемых папок", + en_doc="List tracked folders", + ) async def listfolders(self, message): - await utils.answer(message, self.strings["listfolders"] + "\n".join( - f"• {folder}" for folder in self.tracked_folders - )) - - - - - - + await utils.answer( + message, + self.strings("listfolders") + + "\n".join(f"• {folder}" for folder in self.tracked_folders), + ) diff --git a/archquise/H.Modules/MooFarmRC1.py b/archquise/H.Modules/MooFarmRC1.py index f5332f1..6536509 100644 --- a/archquise/H.Modules/MooFarmRC1.py +++ b/archquise/H.Modules/MooFarmRC1.py @@ -465,6 +465,7 @@ class AutoFarmbotMod(loader.Module): :return: """ self.client = client + self.tg_id = (await client.get_me()).id self.db = 0 self.redis = await aioredis.from_url( self.config["config_redis_cloud_link"], diff --git a/archquise/H.Modules/SMAcrhiver.py b/archquise/H.Modules/SMArchiver.py similarity index 100% rename from archquise/H.Modules/SMAcrhiver.py rename to archquise/H.Modules/SMArchiver.py diff --git a/archquise/H.Modules/face.py b/archquise/H.Modules/face.py index 99aaf23..19007b1 100644 --- a/archquise/H.Modules/face.py +++ b/archquise/H.Modules/face.py @@ -28,6 +28,7 @@ # --------------------------------------------------------------------------------- import logging +from typing import Optional import aiohttp import re @@ -37,6 +38,7 @@ from .. import loader, utils logger = logging.getLogger(__name__) + @loader.tds class face(loader.Module): """random face""" @@ -62,6 +64,20 @@ class face(loader.Module): "error": "Произошла ошибка!", } + def __init__(self): + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=15) + ) + return self._session + + async def on_unload(self): + if self._session and not self._session.closed: + await self._session.close() + @loader.command( ru_doc="Рандом kaomoji", en_doc="Random kaomoji", @@ -71,14 +87,14 @@ class face(loader.Module): url = "https://files.archquise.ru/kaomoji.txt" - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 200: - data = await response.text() - kaomoji_list = [s.strip() for s in re.split(r'[\t\r\n]+', data) if s.strip()] - kaomoji = random.choice(kaomoji_list) - await utils.answer( - message, self.strings("random_face").format(kaomoji) - ) - else: - await utils.answer(message, self.strings("error")) + session = await self._get_session() + async with session.get(url) as response: + if response.status == 200: + data = await response.text() + kaomoji_list = [ + s.strip() for s in re.split(r"[\t\r\n]+", data) if s.strip() + ] + kaomoji = random.choice(kaomoji_list) + await utils.answer(message, self.strings("random_face").format(kaomoji)) + else: + await utils.answer(message, self.strings("error")) diff --git a/archquise/H.Modules/globalrestrict.py b/archquise/H.Modules/globalrestrict.py index 9f642a1..f117092 100644 --- a/archquise/H.Modules/globalrestrict.py +++ b/archquise/H.Modules/globalrestrict.py @@ -676,7 +676,7 @@ class GlobalRestrict(loader.Module): utils.get_entity_url(user), utils.escape_html(get_full_name(user)), ( - self.strings("unmutes_in_n_chats").format(counter) + self.strings("unmute_in_n_chats").format(counter) if silent else chats ), diff --git a/archquise/H.Modules/hikkahost.py b/archquise/H.Modules/hikkahost.py index 43de8aa..2f1e681 100644 --- a/archquise/H.Modules/hikkahost.py +++ b/archquise/H.Modules/hikkahost.py @@ -36,6 +36,7 @@ from .. import loader, utils logger = logging.getLogger(__name__) + class HostApi: """ A class for interacting with a Host API. @@ -58,8 +59,11 @@ class HostApi: Returns: dict: The API response as a dictionary. """ - url = "http://api.hikka.host" + path - async with aiohttp.ClientSession(trust_env=True) as session: + url = "https://api.hikka.host" + path + async with aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=15), + ) as session: async with session.request( method, url, @@ -67,7 +71,6 @@ class HostApi: "Content-Type": "application/json", "token": self.token, }, - ssl=False, ) as response: return await response.json() @@ -290,14 +293,20 @@ class HikkahostMod(loader.Module): token = self.config["token"] user_id = token.split(":")[0] api = HostApi(token) - data = await api.logs(user_id, token) + data = await api.logs(user_id) files_log = data["logs"] - with open("log.txt", "w") as log_file: - json.dump(files_log, log_file) + import tempfile + import os - await utils.answer_file(message, "log.txt", self.strings("logs")) + fd, tmp_path = tempfile.mkstemp(suffix=".txt", prefix="hikkahost_log_") + try: + with os.fdopen(fd, "w") as log_file: + json.dump(files_log, log_file) + await utils.answer_file(message, tmp_path, self.strings("logs")) + finally: + os.unlink(tmp_path) @loader.command( ru_doc="Рестарт HikkaHost", @@ -314,4 +323,4 @@ class HikkahostMod(loader.Module): user_id = token.split(":")[0] api = HostApi(token) - await api.action(user_id, token) + await api.action(user_id) diff --git a/archquise/H.Modules/jacques.py b/archquise/H.Modules/jacques.py index 3486400..cc425fa 100644 --- a/archquise/H.Modules/jacques.py +++ b/archquise/H.Modules/jacques.py @@ -30,6 +30,7 @@ import io import logging from textwrap import wrap +from typing import Optional import aiohttp from PIL import Image, ImageDraw, ImageFont @@ -38,6 +39,7 @@ from .. import loader, utils logger = logging.getLogger(__name__) + @loader.tds class JacquesMod(loader.Module): """Жаконизатор""" @@ -50,6 +52,7 @@ class JacquesMod(loader.Module): self.name = self.strings["name"] self._me = None self._ratelimit = [] + self._session: Optional[aiohttp.ClientSession] = None self.config = loader.ModuleConfig( loader.ConfigValue( "font", @@ -64,6 +67,17 @@ class JacquesMod(loader.Module): ), ) + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=30) + ) + return self._session + + async def on_unload(self): + if self._session and not self._session.closed: + await self._session.close() + @loader.command( ru_doc="<реплай на сообщение/свой текст>", en_doc="", @@ -81,14 +95,14 @@ class JacquesMod(loader.Module): else: txt = args - async with aiohttp.ClientSession() as session: - async with session.get(self.config["font"]) as font_response: - font_data = await font_response.read() + session = await self._get_session() + async with session.get(self.config["font"]) as font_response: + font_data = await font_response.read() - async with session.get( - "https://raw.githubusercontent.com/Codwizer/ReModules/main/assets/IMG_20231128_152538.jpg" - ) as pic_response: - pic_data = await pic_response.read() + async with session.get( + "https://raw.githubusercontent.com/Codwizer/ReModules/main/assets/IMG_20231128_152538.jpg" + ) as pic_response: + pic_data = await pic_response.read() img = Image.open(io.BytesIO(pic_data)).convert("RGB") @@ -96,7 +110,8 @@ class JacquesMod(loader.Module): draw = ImageDraw.Draw(img) font = ImageFont.truetype(io.BytesIO(font_data), 32, encoding="UTF-8") - text_size = draw.multiline_textsize(wrapped_text, font=font) + text_bbox = draw.multiline_textbbox((0, 0), wrapped_text, font=font) + text_size = (text_bbox[2] - text_bbox[0], text_bbox[3] - text_bbox[1]) imtext = Image.new("RGBA", (text_size[0] + 10, text_size[1] + 10), (0, 0, 0, 0)) draw_imtext = ImageDraw.Draw(imtext) draw_imtext.multiline_text( diff --git a/archquise/H.Modules/numbersapi.py b/archquise/H.Modules/numbersapi.py index 910e9c2..01bad74 100644 --- a/archquise/H.Modules/numbersapi.py +++ b/archquise/H.Modules/numbersapi.py @@ -114,13 +114,13 @@ class NumbersAPI(loader.Module): async def _get_number_fact(self, number: int, fact_type: str) -> str: """Get fact about number""" - url = f"http://numbersapi.com/{number}/{fact_type}" + url = f"https://numbersapi.com/{number}/{fact_type}" return await self._fetch_fact(url) async def _get_date_fact(self, month: int, day: int) -> str: """Get fact about date""" date_str = f"{month:02d}/{day:02d}" - url = f"http://numbersapi.com/{date_str}/date" + url = f"https://numbersapi.com/{date_str}/date" return await self._fetch_fact(url) @loader.command( diff --git a/archquise/H.Modules/shortener.py b/archquise/H.Modules/shortener.py index 2d28ec0..e97edd5 100644 --- a/archquise/H.Modules/shortener.py +++ b/archquise/H.Modules/shortener.py @@ -28,6 +28,8 @@ import logging import re +from typing import Optional + import aiohttp from .. import loader, utils @@ -69,6 +71,18 @@ class Shortener(loader.Module): validator=loader.validators.Hidden(), ) ) + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=15) + ) + return self._session + + async def on_unload(self): + if self._session and not self._session.closed: + await self._session.close() def _validate_url(self, url: str) -> bool: """Validate URL format""" @@ -87,27 +101,32 @@ class Shortener(loader.Module): return url_pattern.match(url) is not None - async def shorten_url(self, url: str, token: str) -> str: - async with aiohttp.ClientSession() as session: - async with session.post("https://api-ssl.bitly.com/v4/shorten", json={'long_url': url}, headers={"Authorization": f"Bearer {token}"}) as resp: - if resp.status == 201: - json_response = await resp.json() - return json_response['link'] - else: - logger.error(f"Error occurred! Status code: {resp.status}") - return - - async def get_bitlink_stats(self, bitlink: str, token: str) -> str: - async with aiohttp.ClientSession() as session: - async with session.get(f"https://api-ssl.bitly.com/v4/bitlinks/{bitlink}/clicks/summary", headers={"Authorization": f"Bearer {token}"}) as resp: - if resp.status == 200: - json_response = await resp.json() - return json_response['total_clicks'] - else: - logger.error(f"Error occurred! Status code: {resp.status}") - return - + async def shorten_url(self, url: str, token: str) -> Optional[str]: + session = await self._get_session() + async with session.post( + "https://api-ssl.bitly.com/v4/shorten", + json={"long_url": url}, + headers={"Authorization": f"Bearer {token}"}, + ) as resp: + if resp.status == 201: + json_response = await resp.json() + return json_response["link"] + else: + logger.error(f"Error occurred! Status code: {resp.status}") + return None + async def get_bitlink_stats(self, bitlink: str, token: str) -> Optional[int]: + session = await self._get_session() + async with session.get( + f"https://api-ssl.bitly.com/v4/bitlinks/{bitlink}/clicks/summary", + headers={"Authorization": f"Bearer {token}"}, + ) as resp: + if resp.status == 200: + json_response = await resp.json() + return json_response["total_clicks"] + else: + logger.error(f"Error occurred! Status code: {resp.status}") + return None @loader.command( ru_doc="Сократить ссылку через bit.ly (ссылка с https://)", @@ -129,7 +148,13 @@ class Shortener(loader.Module): return try: - short_url = await self.shorten_url(url=args, token=self.config['token']) + short_url = await self.shorten_url(url=args, token=self.config["token"]) + if short_url is None: + await utils.answer( + message, + self.strings("api_error").format(error="Failed to shorten URL"), + ) + return await utils.answer(message, self.strings("shortencmd").format(c=short_url)) except Exception as e: logger.error(f"Error shortening URL: {e}") @@ -155,7 +180,17 @@ class Shortener(loader.Module): await utils.answer(message, self.strings("invalid_url")) return else: - clicks = await self.get_bitlink_stats(bitlink=args, token=self.config['token']) + clicks = await self.get_bitlink_stats( + bitlink=args, token=self.config["token"] + ) + if clicks is None: + await utils.answer( + message, + self.strings("api_error").format( + error="Failed to get statistics" + ), + ) + return await utils.answer(message, self.strings("statclcmd").format(c=clicks)) except Exception as e: logger.error(f"Error getting statistics: {e}") diff --git a/fiksofficial/python-modules/.github/FUNDING.yml b/fiksofficial/python-modules/.github/FUNDING.yml new file mode 100644 index 0000000..90bc3cf --- /dev/null +++ b/fiksofficial/python-modules/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: gh/fiksofficial +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/fiksofficial/python-modules/README.md b/fiksofficial/python-modules/README.md index 305e38a..5337cc6 100644 --- a/fiksofficial/python-modules/README.md +++ b/fiksofficial/python-modules/README.md @@ -15,5 +15,5 @@ - [x] Попасть в FHeta - [x] Попасть в Limoka - [x] Попасть в команду верефицированных разработчиков модулей Heroku -- [] Soon... +- [ ] Сойти с ума... diff --git a/fiksofficial/python-modules/full.txt b/fiksofficial/python-modules/full.txt index 6e12380..68362d7 100644 --- a/fiksofficial/python-modules/full.txt +++ b/fiksofficial/python-modules/full.txt @@ -22,4 +22,5 @@ tagall2.0 point deviceinfo mpi -aigenuser \ No newline at end of file +aigenuser +github \ No newline at end of file diff --git a/fiksofficial/python-modules/github.py b/fiksofficial/python-modules/github.py new file mode 100644 index 0000000..4e07e94 --- /dev/null +++ b/fiksofficial/python-modules/github.py @@ -0,0 +1,1034 @@ +# ______ ___ ___ _ _ +# ____ | ___ \ | \/ | | | | | +# / __ \| |_/ / _| . . | ___ __| |_ _| | ___ +# / / _` | __/ | | | |\/| |/ _ \ / _` | | | | |/ _ \ +# | | (_| | | | |_| | | | | (_) | (_| | |_| | | __/ +# \ \__,_\_| \__, \_| |_/\___/ \__,_|\__,_|_|\___| +# \____/ __/ | +# |___/ + +# На модуль распространяется лицензия "GNU General Public License v3.0" +# https://github.com/all-licenses/GNU-General-Public-License-v3.0 + +# meta developer: @pymodule +# requires: aiohttp + +import contextlib +import logging +from datetime import datetime, timezone + +import aiohttp +from herokutl.tl.functions.channels import EditAdminRequest, InviteToChannelRequest +from herokutl.tl.types import Channel, Chat, ChatAdminRights, Message + +from .. import loader, utils + +logger = logging.getLogger(__name__) + +GITHUB_API = "https://api.github.com" +HEADERS_BASE = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + +E = { + "push": "🔨", + "issue_open": "🟢", + "issue_close": "🔴", + "pr_open": "🟢", + "pr_merge": "🟣", + "pr_close": "🔴", + "release": "🚀", + "prerelease": "⚠️", +} + +EVENT_LABELS = { + "push": "🔨 Push", + "issues": "🐛 Issues", + "pull_request": "🔀 Pull Requests", + "release": "🚀 Releases", + "star": "⭐ Stars", +} + + +@loader.tds +class GitHubMod(loader.Module): + """GitHub repository monitor — commits, issues, PRs, releases and stars""" + + strings = { + "name": "GitHubMonitor", + "setup_welcome": ( + "🐙 GitHub Monitor\n\n" + "Choose a destination to configure.\n" + "Each channel/group has its own repository list and settings.\n" + "Notifications are sent on behalf of the bot." + ), + "enter_dest": ( + "{icon} {label} setup\n\n" + "Enter the @username or ID of the {label_lc}.\n" + "The bot will be added as admin automatically." + ), + "dest_not_found": ( + "❌ Chat not found.\n\n" + "Check the @username or ID and try again.\n" + "Make sure you are an admin of that chat." + ), + "dest_configured": ( + "✅ {label} configured: {title}\n\n" + "Now add the first repository to track\n" + "in owner/repo format:" + ), + "bot_invite_fail": ( + "⚠️ Could not add the bot automatically.\n" + "Please add {bot} as admin with Post Messages right manually,\n" + "then open .github again." + ), + "dest_removed": "🗑 {title} removed.", + "repo_already": "⚠️ {repo} is already tracked in {title}.", + "repo_not_tracked": "⚠️ {repo} is not tracked in {title}.", + "repo_not_found": "❌ Repository {repo} not found or inaccessible.", + "repo_added": "✅ Added {repo} to {title}.", + "repo_removed": "✅ Removed {repo} from {title}.", + "no_dests": ( + "❌ No destinations configured.\n\n" + "Run .github to set up a channel or group." + ), + "setup_canceled": "❌ Setup canceled.", + "panel_title": ( + "{icon} {title}\n\n" + "📦 Repositories: {repos}\n" + "📣 Events: {events}\n" + "⏱ Interval: {interval}s\n" + "🔑 Token: {token}" + ), + "panel_repos_empty": "none", + "interval_invalid": "❌ Enter a number between 60 and 3600.", + "rate_limit": ( + "⚠️ GitHub API rate limit.\n" + "Resets at {reset}.\n" + "Set a personal token in the destination panel." + ), + "dests_list": "📋 Configured destinations:\n\n{list}", + "notify_push_header": ( + "📏 On " + "{repo}:{branch}" + " new commits!\n" + "{count} commits pushed.\n" + "Compare changes" + ), + "notify_push_commit": ( + "\n
Commit " + "#{sha}" + " by {name} (" + "@{login}" + ")\n" + "{msg}\n\n" + "{files_section}" + "{diff_section}" + "
" + ), + "notify_push_footer": "", + "notify_push_created": "🔧 Created files:\n{files}\n\n", + "notify_push_removed": "🗑 Removed files:\n{files}\n\n", + "notify_push_modified": "🖊 Modified files:\n{files}\n\n", + "notify_push_diff": "⌨️ Diff:\n➕ {added}\n➖ {removed}\n", + "notify_push_empty": ( + "📏 On " + "{repo}:{branch}" + " new empty push" + ), + "notify_issue": ( + "{e} On {repo}" + " {action} issue!\n\n" + "{title}\n" + "#{num} by " + "@{author}" + ), + "notify_pr": ( + "{e} On {repo}" + " {action} pull request!\n\n" + "{title}\n" + "
{body}
\n\n" + "User: @{author}\n\n" + "#{num}" + ), + "notify_release": ( + "{e} On {repo}" + " {action} release!\n\n" + "🏷 {tag} · {name}\n" + "👤 @{author}\n" + "Open release" + ), + "notify_star_added": ( + "⭐️ On {repo}" + " added star!\n\n" + "Total stars: {stars}\n" + "User: @{user}" + ), + "notify_star_removed": ( + "💔 On {repo}" + " removed star!\n\n" + "Total stars: {stars}\n" + "User: @{user}" + ), + "_cfg_interval": "Default polling interval in seconds (60–3600). Overridden per destination.", + "star_label": "⭐ Stars", + "_cfg_token": ( + "Default GitHub token for destinations without a personal token.\n" + + "Without token: 60 req/h. With token: 5000 req/h.\n" + "Create at: github.com/settings/tokens" + ), + "push_label": "🔨 Push", + "issues_label": "🐛 Issues", + "pull_request_label": "🔀 Pull Requests", + "release_label": "🚀 Releases", + "token_set": "✅ set", + "token_not_set": "❌ not set", + "btn_channel": "➕ Channel", + "btn_group": "➕ Group", + "btn_close": "✖️ Close", + "btn_back": "◀️ Back", + "btn_skip": "⏩ Skip", + "btn_add_repo": "➕ Add repository", + "btn_set_interval": "⏱ Set interval", + "btn_set_token": "🔑 Set token", + "btn_clear_token": "🔑 Clear token", + "btn_remove": "🗑 Remove", + "btn_enter_dest": "✏️ Enter {label} username / ID", + "btn_add_repo_confirm": "✏️ Add repository", + "input_dest": "@username or ID of the {label}", + "input_repo": "owner/repo (e.g. torvalds/linux)", + "input_interval": "Interval in seconds (60 – 3600)", + "input_token": "GitHub Personal Access Token", + "repo_invalid_format": "❌ Invalid format. Use owner/repo.", + "checking_repo": "🔍 Checking repository...", + "issue_opened": "opened", + "issue_closed": "closed", + "pr_merged": "merged", + "pr_closed": "closed", + "pr_opened": "opened", + "release_prerelease": "pre-release", + "release_published": "published", + "dest_label_channel": "Channel", + "dest_label_group": "Group", + } + + strings_ru = { + "name": "GitHubMonitor", + "_cls_doc": "Мониторинг GitHub репозиториев — коммиты, issues, PR, релизы и звёзды", + + "setup_welcome": ( + "🐙 GitHub Monitor\n\n" + "Выберите назначение для настройки.\n" + "У каждого канала/группы свой список репозиториев и настройки.\n" + "Уведомления отправляются от имени бота." + ), + "enter_dest": ( + "{icon} Настройка {label_lc}а\n\n" + "Введите @username или ID {label_lc}а.\n" + "Бот будет добавлен администратором автоматически." + ), + "dest_not_found": ( + "❌ Чат не найден.\n\n" + "Проверьте @username или ID.\n" + "Убедитесь, что вы администратор этого чата." + ), + "dest_configured": ( + "✅ {label} настроен: {title}\n\n" + "Теперь добавьте первый репозиторий для отслеживания\n" + "в формате owner/repo:" + ), + "bot_invite_fail": ( + "⚠️ Не удалось добавить бота автоматически.\n" + "Добавьте {bot} вручную как администратора с правом Публикация сообщений,\n" + "затем откройте .github снова." + ), + "dest_removed": "🗑 {title} удалён.", + "repo_already": "⚠️ {repo} уже отслеживается в {title}.", + "repo_not_tracked": "⚠️ {repo} не отслеживается в {title}.", + "repo_not_found": "❌ Репозиторий {repo} не найден или недоступен.", + "repo_added": "✅ Репозиторий {repo} добавлен в {title}.", + "repo_removed": "✅ Репозиторий {repo} удалён из {title}.", + "no_dests": ( + "❌ Нет настроенных назначений.\n\n" + "Запустите .github чтобы добавить канал или группу." + ), + "setup_canceled": "❌ Настройка отменена.", + "panel_title": ( + "{icon} {title}\n\n" + "📦 Репозитории: {repos}\n" + "📣 События: {events}\n" + "⏱ Интервал: {interval} сек\n" + "🔑 Токен: {token}" + ), + "panel_repos_empty": "нет", + "interval_invalid": "❌ Введите число от 60 до 3600.", + "rate_limit": ( + "⚠️ GitHub API rate limit.\n" + "Сброс в {reset}.\n" + "Установите токен в панели назначения." + ), + "dests_list": "📋 Настроенные назначения:\n\n{list}", + "notify_push_header": ( + "📏 На " + "{repo}:{branch}" + " новые коммиты!\n" + "{count} коммитов отправлено.\n" + "Сравнить изменения" + ), + "notify_push_commit": ( + "\n
Коммит " + "#{sha}" + " от {name} (" + "@{login}" + ")\n" + "{msg}\n\n" + "{files_section}" + "{diff_section}" + "
" + ), + "notify_push_footer": "", + "notify_push_created": "🔧 Созданные файлы:\n{files}\n\n", + "notify_push_removed": "🗑 Удалённые файлы:\n{files}\n\n", + "notify_push_modified": "🖊 Изменённые файлы:\n{files}\n\n", + "notify_push_diff": "⌨️ Diff:\n➕ {added}\n➖ {removed}\n", + "notify_push_empty": ( + "📏 На " + "{repo}:{branch}" + " пустой push" + ), + "notify_issue": ( + "{e} На {repo}" + " {action} issue!\n\n" + "{title}\n" + "#{num} от " + "@{author}" + ), + "notify_pr": ( + "{e} На {repo}" + " {action} pull request!\n\n" + "{title}\n" + "
{body}
\n\n" + "Пользователь: @{author}\n\n" + "#{num}" + ), + "notify_release": ( + "{e} На {repo}" + " {action} релиз!\n\n" + "🏷 {tag} · {name}\n" + "👤 @{author}\n" + "Открыть релиз" + ), + "notify_star_added": ( + "⭐️ На {repo}" + " добавлена звезда!\n\n" + "Всего звёзд: {stars}\n" + "Пользователь: @{user}" + ), + "notify_star_removed": ( + "💔 На {repo}" + " убрана звезда!\n\n" + "Всего звёзд: {stars}\n" + "Пользователь: @{user}" + ), + "_cfg_interval": "Интервал опроса по умолчанию (60–3600 сек). Переопределяется в настройках назначения.", + "star_label": "⭐ Звёзды", + "_cfg_token": ( + "Глобальный GitHub-токен для назначений без персонального токена.\n" + + "Без токена: 60 запросов/час. С токеном: 5000.\n" + "Создать: github.com/settings/tokens" + ), + "push_label": "🔨 Push", + "issues_label": "🐛 Issues", + "pull_request_label": "🔀 Pull Requests", + "release_label": "🚀 Релизы", + "token_set": "✅ установлен", + "token_not_set": "❌ не установлен", + "btn_channel": "➕ Канал", + "btn_group": "➕ Группа", + "btn_close": "✖️ Закрыть", + "btn_back": "◀️ Назад", + "btn_skip": "⏩ Пропустить", + "btn_add_repo": "➕ Добавить репозиторий", + "btn_set_interval": "⏱ Установить интервал", + "btn_set_token": "🔑 Установить токен", + "btn_clear_token": "🔑 Очистить токен", + "btn_remove": "🗑 Удалить", + "btn_enter_dest": "✏️ Ввести {label} username / ID", + "btn_add_repo_confirm": "✏️ Добавить репозиторий", + "input_dest": "@username или ID {label}а", + "input_repo": "owner/repo (например: torvalds/linux)", + "input_interval": "Интервал в секундах (60 – 3600)", + "input_token": "GitHub Personal Access Token", + "repo_invalid_format": "❌ Неверный формат. Используйте owner/repo.", + "checking_repo": "🔍 Проверяю репозиторий...", + "issue_opened": "открыт", + "issue_closed": "закрыт", + "pr_merged": "смёрджен", + "pr_closed": "закрыт", + "pr_opened": "открыт", + "release_prerelease": "пре-релиз", + "release_published": "опубликован", + "dest_label_channel": "Канал", + "dest_label_group": "Группа", + } + + def __init__(self): + self.config = loader.ModuleConfig( + loader.ConfigValue( + "interval", + 300, + lambda: self.strings["_cfg_interval"], + validator=loader.validators.Integer(minimum=60, maximum=3600), + ), + loader.ConfigValue( + "github_token", + None, + lambda: self.strings["_cfg_token"], + validator=loader.validators.Hidden( + loader.validators.Union( + loader.validators.String(), + loader.validators.NoneType(), + ) + ), + ), + ) + self._sessions: dict[str, aiohttp.ClientSession] = {} + + async def client_ready(self): + raw = self.db.get("GitHubMod", "dests") + if raw is None: + self.db.set("GitHubMod", "dests", {}) + return + if not isinstance(raw, dict): + self.db.set("GitHubMod", "dests", {}) + return + migrated = {} + changed = False + for k, v in raw.items(): + if isinstance(v, dict): + migrated[k] = v + else: + changed = True + logger.info("GitHubMod: dropping malformed dest entry %s=%r", k, v) + if changed: + self.db.set("GitHubMod", "dests", migrated) + + async def on_unload(self): + self.poller.stop() + for s in self._sessions.values(): + with contextlib.suppress(Exception): + await s.close() + + + def _get_dests(self) -> dict: + return self.db.get("GitHubMod", "dests", {}) + + def _save_dests(self, dests: dict): + self.db.set("GitHubMod", "dests", dests) + + + def _get_session(self, chat_id_str: str) -> aiohttp.ClientSession: + dest = self._get_dests().get(chat_id_str, {}) + token = dest.get("token") or self.config["github_token"] + headers = dict(HEADERS_BASE) + if token: + headers["Authorization"] = f"Bearer {token}" + s = self._sessions.get(chat_id_str) + if s and not s.closed: + s.headers.update(headers) + return s + s = aiohttp.ClientSession(headers=headers, timeout=aiohttp.ClientTimeout(total=20)) + self._sessions[chat_id_str] = s + return s + + def _reset_session(self, chat_id_str: str): + s = self._sessions.pop(chat_id_str, None) + if s and not s.closed: + import asyncio + asyncio.ensure_future(s.close()) + + + @staticmethod + def _to_bot_api_id(entity) -> int: + """Convert Telethon entity ID to Bot API format (-100XXXXXXXXX for channels/supergroups).""" + eid = entity.id + if isinstance(entity, (Channel, Chat)): + return int(f"-100{eid}") + return eid + + async def _resolve_peer(self, peer_str: str): + peer_str = peer_str.strip() + try: + ident = int(peer_str) if peer_str.lstrip("-").isdigit() else peer_str + return await self._client.get_entity(ident) + except Exception: + return None + + async def _invite_bot(self, peer) -> tuple[bool, str]: + bot = self.inline.bot_username + try: + await self._client(InviteToChannelRequest(peer, [bot])) + except Exception as e: + err = str(e).lower() + if "already" in err or "participant" in err: + pass + else: + return False, str(e) + + with contextlib.suppress(Exception): + await self._client( + EditAdminRequest( + channel=peer, + user_id=bot, + admin_rights=ChatAdminRights( + post_messages=True, + edit_messages=True, + delete_messages=True, + ), + rank="GitHub", + ) + ) + return True, "" + + + async def _api_get(self, path: str, chat_id_str: str, extra_headers: dict | None = None) -> tuple[list | dict | None, bool]: + url = f"{GITHUB_API}{path}" + session = self._get_session(chat_id_str) + try: + async with session.get(url, headers=extra_headers) as resp: + if resp.status in (403, 429): + reset = int(resp.headers.get("X-RateLimit-Reset", 0)) + dt = datetime.fromtimestamp(reset).strftime("%H:%M:%S") if reset else "?" + logger.warning("GitHubMod: rate limited (%s), resets %s", chat_id_str, dt) + return None, True + if resp.status == 404: + return None, False + if resp.status != 200: + logger.warning("GitHubMod: %s → %s", path, resp.status) + return None, False + return await resp.json(), False + except Exception: + logger.exception("GitHubMod: request failed %s", path) + return None, False + + async def _check_repo(self, repo: str, chat_id_str: str) -> tuple[bool, bool]: + data, rl = await self._api_get(f"/repos/{repo}", chat_id_str) + return data is not None, rl + + async def _fetch_commits(self, repo: str, since: str, cid: str) -> list: + # List commits since last check (returns sha, html_url, commit.message, author — but NO stats/files) + data, _ = await self._api_get(f"/repos/{repo}/commits?since={since}&per_page=5", cid) + if not isinstance(data, list) or not data: + return [] + # Enrich each commit with stats+files by fetching individually + enriched = [] + for c in data: + sha = c.get("sha", "") + if not sha: + enriched.append(c) + continue + detail, _ = await self._api_get(f"/repos/{repo}/commits/{sha}", cid) + enriched.append(detail if isinstance(detail, dict) else c) + return enriched + + async def _fetch_branch_for_commit(self, repo: str, sha: str, cid: str) -> str: + """Find the branch name that contains this commit SHA.""" + data, _ = await self._api_get(f"/repos/{repo}/branches", cid) + if not isinstance(data, list): + return "main" + # Check each branch's latest commit — fast path for small repos + for b in data: + if (b.get("commit") or {}).get("sha", "") == sha: + return b.get("name", "main") + return (data[0].get("name", "main")) if data else "main" + + async def _fetch_issues(self, repo: str, since: str, cid: str) -> list: + data, _ = await self._api_get( + f"/repos/{repo}/issues?state=all&since={since}&per_page=10&sort=updated", cid + ) + return [i for i in (data or []) if isinstance(data, list) and "pull_request" not in i] + + async def _fetch_prs(self, repo: str, since: str, cid: str) -> list: + data, _ = await self._api_get( + f"/repos/{repo}/pulls?state=all&per_page=10&sort=updated&direction=desc", cid + ) + if not isinstance(data, list): + return [] + since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) + return [ + pr for pr in data + if datetime.fromisoformat( + (pr.get("updated_at") or "1970-01-01T00:00:00Z").replace("Z", "+00:00") + ) > since_dt + ] + + async def _fetch_releases(self, repo: str, since: str, cid: str) -> list: + data, _ = await self._api_get(f"/repos/{repo}/releases?per_page=5", cid) + if not isinstance(data, list): + return [] + since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) + return [ + r for r in data + if datetime.fromisoformat( + (r.get("published_at") or "1970-01-01T00:00:00Z").replace("Z", "+00:00") + ) > since_dt + ] + + async def _fetch_stargazers(self, repo: str, since: str, cid: str) -> list: + data, _ = await self._api_get( + f"/repos/{repo}/stargazers?per_page=20", cid, + extra_headers={"Accept": "application/vnd.github.star+json"}, + ) + if not isinstance(data, list): + return [] + since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) + result = [] + for item in data: + starred_at = item.get("starred_at") or "1970-01-01T00:00:00Z" + if datetime.fromisoformat(starred_at.replace("Z", "+00:00")) > since_dt: + result.append({ + "action": "created", + "sender": item.get("user", {}), + "repository": {"stargazers_count": "?"}, + }) + return result + + + def _fmt_push(self, repo: str, commits: list, branch: str = "main", compare_url: str = "") -> list[str]: + if not commits: + return [self.strings("notify_push_empty").format(repo=repo, branch=branch)] + + commit_blocks = [] + for c in commits: + commit = c.get("commit", {}) + login = (c.get("author") or {}).get("login", "") + name = commit.get("author", {}).get("name", login or "unknown") + sha = c.get("sha", "")[:7] + msg = commit.get("message", "").split("\n")[0][:120] + + files_section = "" + diff_section = "" + stats = c.get("stats", {}) + files = c.get("files", []) + if files: + created = [f["filename"] for f in files if f.get("status") == "added"] + removed_f = [f["filename"] for f in files if f.get("status") == "removed"] + modified = [f["filename"] for f in files if f.get("status") == "modified"] + if created: + files_section += self.strings("notify_push_created").format(files="\n".join(created)) + if removed_f: + files_section += self.strings("notify_push_removed").format(files="\n".join(removed_f)) + if modified: + files_section += self.strings("notify_push_modified").format(files="\n".join(modified)) + if stats.get("additions") or stats.get("deletions"): + diff_section = self.strings("notify_push_diff").format( + added=stats.get("additions", 0), + removed=stats.get("deletions", 0), + ) + commit_blocks.append(self.strings("notify_push_commit").format( + url=c.get("html_url", "#"), + sha=sha, name=name, login=login or name, + msg=msg, files_section=files_section, diff_section=diff_section, + )) + + pusher = (commits[-1].get("author") or {}).get("login", "") if commits else "" + # Build compare URL: oldest_sha...newest_sha (GitHub shows diff between them) + if not compare_url and len(commits) >= 2: + old_sha = commits[0].get("parents", [{}])[0].get("sha", commits[0].get("sha", ""))[:12] + new_sha = commits[-1].get("sha", "")[:12] + compare_url = f"https://github.com/{repo}/compare/{old_sha}...{new_sha}" + elif not compare_url: + compare_url = commits[-1].get("html_url", f"https://github.com/{repo}") + + msg = self.strings("notify_push_header").format( + repo=repo, branch=branch, + count=len(commits), compare=compare_url, + ) + msg += "".join(commit_blocks) + msg += self.strings("notify_push_footer").format(login=pusher) + return [msg] + + def _fmt_issues(self, repo: str, issues: list) -> list[str]: + return [ + self.strings("notify_issue").format( + e=E["issue_open" if i.get("state") == "open" else "issue_close"], + action=self.strings("issue_opened") if i.get("state") == "open" else self.strings("issue_closed"), + repo=repo, + url=i.get("html_url", "#"), + num=i.get("number", "?"), + title=i.get("title", "")[:100], + author=(i.get("user") or {}).get("login", "unknown"), + ) + for i in reversed(issues) + ] + + def _fmt_prs(self, repo: str, prs: list) -> list[str]: + msgs = [] + for pr in reversed(prs): + merged = pr.get("merged_at") is not None + state = pr.get("state", "open") + if merged: + e_key, action = "pr_merge", self.strings("pr_merged") + elif state == "closed": + e_key, action = "pr_close", self.strings("pr_closed") + 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 + msgs.append(self.strings("notify_pr").format( + e=E[e_key], action=action, repo=repo, + url=pr.get("html_url", "#"), + num=pr.get("number", "?"), + title=pr.get("title", "")[:100], + body=body, + author=(pr.get("user") or {}).get("login", "unknown"), + )) + return msgs + + def _fmt_releases(self, repo: str, releases: list) -> list[str]: + return [ + self.strings("notify_release").format( + e=E["prerelease" if r.get("prerelease") else "release"], + action=self.strings("release_prerelease") if r.get("prerelease") else self.strings("release_published"), + repo=repo, + tag=r.get("tag_name", ""), + name=r.get("name") or r.get("tag_name", ""), + author=(r.get("author") or {}).get("login", "unknown"), + url=r.get("html_url", "#"), + ) + for r in reversed(releases) + ] + + + def _fmt_star(self, repo: str, stars_data: list) -> list[str]: + msgs = [] + for s in stars_data: + action = s.get("action", "created") + user = (s.get("sender") or {}).get("login", "unknown") + stars = (s.get("repository") or {}).get("stargazers_count", "?") + key = "notify_star_added" if action == "created" else "notify_star_removed" + msgs.append(self.strings(key).format(repo=repo, stars=stars, user=user)) + return msgs + + @loader.loop(autostart=True, wait_before=True) + async def poller(self): + dests = self._get_dests() + self.poller.interval = self.config["interval"] + if not dests: + return + + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + for cid_str, dest in list(dests.items()): + interval = dest.get("interval", self.config["interval"]) + self.poller.interval = min(self.poller.interval, interval) + try: + await self._poll_dest(cid_str, dest, now_iso) + except Exception: + logger.exception("GitHubMod: error polling %s", cid_str) + for repo in dest.get("repos", {}): + dests[cid_str]["repos"][repo]["last_checked"] = now_iso + + self._save_dests(dests) + + async def _poll_dest(self, cid_str: str, dest: dict, now_iso: str): + events = dest.get("events", list(EVENT_LABELS.keys())) + chat_id = int(cid_str) + messages: list[str] = [] + + for repo, repo_data in dest.get("repos", {}).items(): + 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) + if "issues" in events: + i = await self._fetch_issues(repo, since, cid_str) + if i: + messages += self._fmt_issues(repo, i) + if "pull_request" in events: + p = await self._fetch_prs(repo, since, cid_str) + if p: + messages += self._fmt_prs(repo, p) + if "release" in events: + r = await self._fetch_releases(repo, since, cid_str) + if r: + messages += self._fmt_releases(repo, r) + if "star" in events: + s = await self._fetch_stargazers(repo, since, cid_str) + if s: + messages += self._fmt_star(repo, s) + + for text in messages: + try: + await self.inline.bot.send_message( + chat_id, + text, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception: + logger.exception("GitHubMod: failed to send to %s", chat_id) + + + def _panel_text(self, dest: dict) -> str: + repos = dest.get("repos", {}) + events = dest.get("events", list(EVENT_LABELS.keys())) + token = dest.get("token") + return self.strings("panel_title").format( + icon="📢" if dest.get("type") == "channel" else "👥", + title=dest.get("title", "?"), + repos=", ".join(f"{r}" for r in repos) + or self.strings("panel_repos_empty"), + events=" · ".join(self.strings(e + "_label") for e in events), + interval=dest.get("interval", self.config["interval"]), + token=self.strings("token_set") if token else self.strings("token_not_set"), + ) + + def _panel_markup(self, cid_str: str, dest: dict) -> list: + events = dest.get("events", list(EVENT_LABELS.keys())) + repos = dest.get("repos", {}) + markup = [] + + for e_key in EVENT_LABELS: + markup.append([{ + "text": ("✅ " if e_key in events else "☑️ ") + self.strings(e_key + "_label"), + "callback": self._cb_toggle_event, + "args": (cid_str, e_key), + }]) + + markup.append([{ + "text": self.strings("btn_add_repo"), + "input": self.strings("input_repo"), + "handler": self._cb_add_repo, + "kwargs": {"cid_str": cid_str}, + }]) + + for repo in repos: + markup.append([{ + "text": self.strings("btn_remove") + f" {repo}", + "callback": self._cb_remove_repo, + "args": (cid_str, repo), + }]) + + markup.append([{ + "text": self.strings("btn_set_interval"), + "input": self.strings("input_interval"), + "handler": self._cb_set_interval, + "kwargs": {"cid_str": cid_str}, + }]) + markup.append([{ + "text": self.strings("btn_set_token"), + "input": self.strings("input_token"), + "handler": self._cb_set_token, + "kwargs": {"cid_str": cid_str}, + }]) + markup.append([ + {"text": self.strings("btn_clear_token"), "callback": self._cb_clear_token, "args": (cid_str,)}, + {"text": self.strings("btn_remove"), "callback": self._cb_remove_dest, "args": (cid_str,)}, + ]) + markup.append([{"text": self.strings("btn_back"), "callback": self._cb_main_menu}]) + return markup + + async def _render_main_menu(self, call_or_msg): + dests = self._get_dests() + text = self.strings("setup_welcome") + markup = [] + for cid_str, dest in dests.items(): + if not isinstance(dest, dict): + continue + icon = "📢" if dest.get("type") == "channel" else "👥" + markup.append([{ + "text": icon + " " + dest.get("title", cid_str), + "callback": self._cb_open_panel, + "args": (cid_str,), + }]) + markup.append([ + {"text": self.strings("btn_channel"), "callback": self._cb_add_dest, "args": ("channel",)}, + {"text": self.strings("btn_group"), "callback": self._cb_add_dest, "args": ("group",)}, + ]) + if dests: + markup.append([{"text": self.strings("btn_close"), "action": "close"}]) + + if isinstance(call_or_msg, Message): + await self.inline.form(message=call_or_msg, text=text, reply_markup=markup) + else: + await call_or_msg.edit(text, reply_markup=markup) + + + async def _cb_main_menu(self, call): + await self._render_main_menu(call) + + async def _cb_open_panel(self, call, cid_str: str): + dest = self._get_dests().get(cid_str, {}) + await call.edit(self._panel_text(dest), reply_markup=self._panel_markup(cid_str, dest)) + + async def _cb_toggle_event(self, call, cid_str: str, event: str): + dests = self._get_dests() + events = dests[cid_str].get("events", list(EVENT_LABELS.keys())) + if event in events: + events.remove(event) + else: + events.append(event) + dests[cid_str]["events"] = events + self._save_dests(dests) + dest = dests[cid_str] + await call.edit(self._panel_text(dest), reply_markup=self._panel_markup(cid_str, dest)) + + async def _cb_remove_repo(self, call, cid_str: str, repo: str): + dests = self._get_dests() + dests[cid_str].get("repos", {}).pop(repo, None) + self._save_dests(dests) + dest = dests[cid_str] + await call.edit(self._panel_text(dest), reply_markup=self._panel_markup(cid_str, dest)) + + async def _cb_clear_token(self, call, cid_str: str): + dests = self._get_dests() + dests[cid_str].pop("token", None) + self._save_dests(dests) + self._reset_session(cid_str) + dest = dests[cid_str] + await call.edit(self._panel_text(dest), reply_markup=self._panel_markup(cid_str, dest)) + + async def _cb_remove_dest(self, call, cid_str: str): + dests = self._get_dests() + title = dests.get(cid_str, {}).get("title", cid_str) + dests.pop(cid_str, None) + self._save_dests(dests) + self._reset_session(cid_str) + await call.edit(self.strings("dest_removed").format(title=title)) + + async def _cb_add_dest(self, call, dest_type: str): + icon = "📢" if dest_type == "channel" else "👥" + label = self.strings("dest_label_channel") if dest_type == "channel" else self.strings("dest_label_group") + await call.edit( + self.strings("enter_dest").format(icon=icon, label=label, label_lc=label.lower()), + reply_markup=[ + [{ + "text": self.strings("btn_enter_dest").format(label=label.lower()), + "input": self.strings("input_dest").format(label=label.lower()), + "handler": self._cb_got_dest, + "kwargs": {"dest_type": dest_type}, + }], + [{"text": self.strings("btn_back"), "callback": self._cb_main_menu}], + ], + ) + + async def _cb_got_dest(self, call, peer_str: str, dest_type: str): + entity = await self._resolve_peer(peer_str) + if not entity: + await call.edit( + self.strings("dest_not_found"), + reply_markup=[[{"text": self.strings("btn_back"), "callback": self._cb_main_menu}]], + ) + return + + ok, err = await self._invite_bot(entity) + if not ok: + await call.edit( + self.strings("bot_invite_fail").format(bot=self.inline.bot_username), + reply_markup=[[{"text": self.strings("btn_back"), "callback": self._cb_main_menu}]], + ) + return + + bot_api_id = self._to_bot_api_id(entity) + cid_str = str(bot_api_id) + title = getattr(entity, "title", cid_str) + label = self.strings("dest_label_channel") if dest_type == "channel" else self.strings("dest_label_group") + + dests = self._get_dests() + if cid_str not in dests: + dests[cid_str] = { + "id": bot_api_id, + "title": title, + "type": dest_type, + "repos": {}, + "events": list(EVENT_LABELS.keys()), + } + self._save_dests(dests) + + await call.edit( + self.strings("dest_configured").format(label=label, title=title), + reply_markup=[ + [{ + "text": self.strings("btn_add_repo_confirm"), + "input": self.strings("input_repo"), + "handler": self._cb_add_repo, + "kwargs": {"cid_str": cid_str}, + }], + [{"text": self.strings("btn_skip"), "callback": self._cb_open_panel, "args": (cid_str,)}], + ], + ) + + async def _cb_add_repo(self, call, repo: str, cid_str: str): + repo = repo.strip().strip("/") + dests = self._get_dests() + dest = dests.get(cid_str, {}) + title = dest.get("title", cid_str) + + if "/" not in repo or len(repo.split("/")) != 2: + await call.edit( + self.strings("repo_invalid_format"), + reply_markup=[[{"text": self.strings("btn_back"), "callback": self._cb_open_panel, "args": (cid_str,)}]], + ) + return + if repo in dest.get("repos", {}): + await call.edit( + self.strings("repo_already").format(repo=repo, title=title), + reply_markup=[[{"text": self.strings("btn_back"), "callback": self._cb_open_panel, "args": (cid_str,)}]], + ) + return + + await call.edit(self.strings("checking_repo")) + exists, rl = await self._check_repo(repo, cid_str) + if rl: + await call.edit(self.strings("rate_limit").format(reset="—")) + return + if not exists: + await call.edit( + self.strings("repo_not_found").format(repo=repo), + reply_markup=[[{"text": self.strings("btn_back"), "callback": self._cb_open_panel, "args": (cid_str,)}]], + ) + return + + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + dests[cid_str].setdefault("repos", {})[repo] = {"last_checked": now_iso} + self._save_dests(dests) + dest = dests[cid_str] + await call.edit(self._panel_text(dest), reply_markup=self._panel_markup(cid_str, dest)) + + async def _cb_set_interval(self, call, val: str, cid_str: str): + try: + secs = int(val.strip()) + assert 60 <= secs <= 3600 + except (ValueError, AssertionError): + await call.answer(self.strings("interval_invalid"), show_alert=True) + return + dests = self._get_dests() + dests[cid_str]["interval"] = secs + self._save_dests(dests) + dest = dests[cid_str] + await call.edit(self._panel_text(dest), reply_markup=self._panel_markup(cid_str, dest)) + + async def _cb_set_token(self, call, token: str, cid_str: str): + token = token.strip() + dests = self._get_dests() + dests[cid_str]["token"] = token or None + self._save_dests(dests) + self._reset_session(cid_str) + dest = dests[cid_str] + await call.edit(self._panel_text(dest), reply_markup=self._panel_markup(cid_str, dest)) + + + @loader.command(ru_doc="- Открыть панель управления GitHub Monitor") + async def githubcmd(self, message: Message): + """- Open GitHub Monitor control panel""" + await self._render_main_menu(message) + diff --git a/mead0wsss/mead0wsMods/SenderGifts.py b/mead0wsss/mead0wsMods/SenderGifts.py index 9ea5a29..730aa5d 100644 --- a/mead0wsss/mead0wsMods/SenderGifts.py +++ b/mead0wsss/mead0wsMods/SenderGifts.py @@ -1,5 +1,5 @@ # -- version -- -__version__ = (1, 2, 2) +__version__ = (1, 2, 3) # -- version -- @@ -35,6 +35,7 @@ class SenderGifts(loader.Module): "user_not_found": " Пользователь не найден", "gift_menu": "🎁 Выберите категорию подарков.\n\n👤 Пользователь: {}\n📂 Текст: {}\n⭐️ Баланс: {} звезд", "category_menu": "🎁 Подарки за {} ⭐\n\n👤 Пользователь: {}\n📂 Текст: {}", + "unique_category_menu": "🎁 {}\n\n👤 Пользователь: {}\n📂 Текст: {}", "privacy_menu": "🎁 Выбран подарок: {}\n\nКак отправить подарок?", "sending_gift": "🛫 Отправка подарка...", "gift_sent": " Подарок успешно отправлен!", @@ -45,8 +46,8 @@ class SenderGifts(loader.Module): "btn_public": "📢 Публично", "btn_anon": "🕵️ Анонимно", } - - gift_categories = { + + regular_gifts = { 15: [ {"id": 5170145012310081615, "emoji": "❤️", "name": "Сердце"}, {"id": 5170233102089322756, "emoji": "🧸", "name": "Мишка"}, @@ -59,8 +60,6 @@ class SenderGifts(loader.Module): {"id": 5170144170496491616, "emoji": "🎂", "name": "Тортик"}, {"id": 5170314324215857265, "emoji": "💐", "name": "Цветы"}, {"id": 5170564780938756245, "emoji": "🚀", "name": "Ракета"}, - {"id": 5922558454332916696, "emoji": "🎄", "name": "Ёлка"}, - {"id": 5956217000635139069, "emoji": "🧸", "name": "Новогодний мишка"} ], 100: [ {"id": 5168043875654172773, "emoji": "🏆", "name": "Кубок"}, @@ -69,8 +68,28 @@ class SenderGifts(loader.Module): ] } - async def client_ready(self, client, db): - self.client = client + 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}, + ] + } + } async def get_star_balance(self): try: @@ -100,6 +119,10 @@ class SenderGifts(loader.Module): text = parts[1] if len(parts) > 1 else "" if username.startswith('@'): username = username[1:] + try: + username = int(username) + except ValueError: + pass msg = await utils.answer(message, self.strings["checking_user"]) try: user = await self.client.get_entity(username) @@ -116,115 +139,54 @@ class SenderGifts(loader.Module): await utils.answer(balance_msg, self.strings["balance_error"]) return - min_price = min(self.gift_categories.keys()) + min_price = min(self.regular_gifts.keys()) if balance < min_price: await utils.answer(balance_msg, self.strings["min_stars_error"]) return - available_categories = [price for price in self.gift_categories.keys() if balance >= price] - if not available_categories: - await utils.answer(balance_msg, self.strings["no_available_gifts"]) - return - buttons = [] - row = [] - for price in sorted(available_categories): - row.append({ - "text": f"{price} ⭐", - "callback": self._show_category, - "args": (user.id, price, text, balance, message.id), - }) - if len(row) == 2: - buttons.append(row) - row = [] - - if row: - buttons.append(row) - helper_msg = await self.inline.form("🪐", balance_msg) - await utils.answer( - helper_msg, - self.strings["gift_menu"].format( - f"@{user.username}" if user.username else user.first_name, - text if text else "-", - balance - ), - reply_markup=buttons - ) - async def _show_category(self, call, user_id, price, text, balance, msg_id): - gifts = self.gift_categories[price] - buttons = [] - row = [] - for gift in gifts: - row.append({ - "text": gift["emoji"], - "callback": self._select_privacy, - "args": (user_id, gift["id"], text, gift["emoji"], msg_id, balance, price), - }) - if len(row) == 3: - buttons.append(row) - row = [] - - if row: - buttons.append(row) - buttons.append([{ - "text": "⬅️ Назад", - "callback": self._back_to_categories, - "args": (user_id, text, balance, msg_id), - }]) - + 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): try: user = await self.client.get_entity(user_id) user_display = f"@{user.username}" if user.username else user.first_name except: user_display = f"ID: {user_id}" - - await call.edit( - self.strings["category_menu"].format( - price, - user_display, - text if text else "-" - ), - reply_markup=buttons - ) - async def _select_privacy(self, call, user_id, gift_id, text, gift_emoji, msg_id, balance, price): buttons = [ - [ - { - "text": self.strings["btn_public"], - "callback": self._send_gift, - "args": (user_id, gift_id, text, gift_emoji, msg_id, balance, False) # hide_name=False публично - }, - { - "text": self.strings["btn_anon"], - "callback": self._send_gift, - "args": (user_id, gift_id, text, gift_emoji, msg_id, balance, True) # hide_name=True анонимно - } - ], - [ - { - "text": "⬅️ Назад", - "callback": self._show_category, - "args": (user_id, price, text, balance, msg_id) - } - ] + [{ + "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), + }] ] - await call.edit( - self.strings["privacy_menu"].format(gift_emoji), - reply_markup=buttons - ) + text_menu = self.strings["gift_menu"].format(user_display, text if text else "-", balance) + + if answer: + await utils.answer(msg_or_call, text_menu, reply_markup=buttons) + else: + await msg_or_call.edit(text_menu, reply_markup=buttons) - async def _back_to_categories(self, call, user_id, text, balance, msg_id): + async def _show_main_menu(self, call, user_id, text, balance, msg_id): + await self._show_main_menu_logic(call, user_id, text, balance, msg_id) + + async def _show_regular_categories(self, call, user_id, text, balance, msg_id): try: user = await self.client.get_entity(user_id) + user_display = f"@{user.username}" if user.username else user.first_name except: - await call.answer("Ошибка получения пользователя", show_alert=True) - return - - available_categories = [price for price in self.gift_categories.keys() if balance >= price] + user_display = f"ID: {user_id}" + + available_categories = [price for price in self.regular_gifts.keys() if balance >= price] buttons = [] row = [] @@ -237,16 +199,151 @@ class SenderGifts(loader.Module): if len(row) == 2: buttons.append(row) row = [] + if row: + buttons.append(row) + + buttons.append([{ + "text": "⬅️ Назад", + "callback": self._show_main_menu, + "args": (user_id, text, balance, msg_id), + }]) + + await call.edit( + self.strings["gift_menu"].format(user_display, text if text else "-", balance), + reply_markup=buttons + ) + + async def _show_unique_categories(self, call, user_id, text, balance, msg_id): + try: + user = await self.client.get_entity(user_id) + user_display = f"@{user.username}" if user.username else user.first_name + except: + user_display = f"ID: {user_id}" + + buttons = [] + for cat_id, cat_data in self.unique_gifts.items(): + if any(balance >= gift["price"] for gift in cat_data["gifts"]): + buttons.append([{ + "text": cat_data["name"], + "callback": self._show_unique_category_gifts, + "args": (user_id, cat_id, text, balance, msg_id), + }]) + + if not buttons: + buttons.append([{ + "text": "❌ Нет доступных (баланс)", + "callback": self._show_main_menu, + "args": (user_id, text, balance, msg_id), + }]) + + buttons.append([{ + "text": "⬅️ Назад", + "callback": self._show_main_menu, + "args": (user_id, text, balance, msg_id), + }]) + + await call.edit( + self.strings["gift_menu"].format(user_display, text if text else "-", balance), + reply_markup=buttons + ) + + async def _show_category(self, call, user_id, price, text, balance, msg_id): + gifts = self.regular_gifts[price] + buttons = [] + row = [] + for gift in gifts: + row.append({ + "text": gift["emoji"], + "callback": self._select_privacy, + "args": (user_id, gift["id"], text, gift["emoji"], msg_id, balance, "regular", price), + }) + if len(row) == 3: + buttons.append(row) + row = [] if row: buttons.append(row) + buttons.append([{ + "text": "⬅️ Назад", + "callback": self._show_regular_categories, + "args": (user_id, text, balance, msg_id), + }]) + + try: + user = await self.client.get_entity(user_id) + user_display = f"@{user.username}" if user.username else user.first_name + except: + user_display = f"ID: {user_id}" await call.edit( - self.strings["gift_menu"].format( - f"@{user.username}" if user.username else user.first_name, - text if text else "-", - balance - ), + self.strings["category_menu"].format(price, user_display, text if text else "-"), + reply_markup=buttons + ) + + async def _show_unique_category_gifts(self, call, user_id, cat_id, text, balance, msg_id): + category = self.unique_gifts[cat_id] + buttons = [] + row = [] + for gift in category["gifts"]: + if balance >= gift["price"]: + row.append({ + "text": gift["emoji"], + "callback": self._select_privacy, + "args": (user_id, gift["id"], text, gift["emoji"], msg_id, balance, "unique", cat_id), + }) + if len(row) == 3: + buttons.append(row) + row = [] + + if row: + buttons.append(row) + buttons.append([{ + "text": "⬅️ Назад", + "callback": self._show_unique_categories, + "args": (user_id, text, balance, msg_id), + }]) + + try: + user = await self.client.get_entity(user_id) + user_display = f"@{user.username}" if user.username else user.first_name + except: + user_display = f"ID: {user_id}" + + await call.edit( + self.strings["unique_category_menu"].format(category["name"], user_display, text if text else "-"), + reply_markup=buttons + ) + + async def _select_privacy(self, call, user_id, gift_id, text, gift_emoji, msg_id, balance, gift_type, type_arg): + if gift_type == "regular": + back_callback = self._show_category + else: + back_callback = self._show_unique_category_gifts + + buttons = [ + [ + { + "text": self.strings["btn_public"], + "callback": self._send_gift, + "args": (user_id, gift_id, text, gift_emoji, msg_id, balance, False) + }, + { + "text": self.strings["btn_anon"], + "callback": self._send_gift, + "args": (user_id, gift_id, text, gift_emoji, msg_id, balance, True) + } + ], + [ + { + "text": "⬅️ Назад", + "callback": back_callback, + "args": (user_id, type_arg, text, balance, msg_id) + } + ] + ] + + await call.edit( + self.strings["privacy_menu"].format(gift_emoji), reply_markup=buttons )