diff --git a/KeyZenD/modules/VideoDistortion.py b/KeyZenD/modules/VideoDistortion.py
new file mode 100644
index 0000000..b9688e8
--- /dev/null
+++ b/KeyZenD/modules/VideoDistortion.py
@@ -0,0 +1,11 @@
+# Python code obfuscated by www.development-tools.net
+
+
+import base64, codecs
+magic = 'aW1wb3J0IGFzeW5jaW8NCmltcG9ydCBsb2dnaW5nDQpmcm9tIC4uIGltcG9ydCBsb2FkZXIsIHV0aWxzDQoNCmxvZ2dlciA9IGxvZ2dpbmcuZ2V0TG9nZ2VyKF9fbmFtZV9fKQ0KDQpAbG9hZGVyLnRkcw0KY2xhc3MgVmlkZW9EaXN0b3J0aW9ydE1vZChsb2FkZXIuTW9kdWxlKToNCgkiIiLQltC80YvRhSDQtNC70Y8g0LLQuNC00LXQviIiIg0KCXN0cmluZ3MgPSB7Im5hbWUiOiAiVmlkZW9EaXN0b3J0aW9uIn0NCg0KCUBsb2FkZXIudW5yZXN0cmljdGVkDQoJYXN5bmMgZGVmIHZkaXN0b3J0Y21kKHNlbGYsIG1lc3NhZ2UpOg0KCQkiIiIudmRpc3RvcnQgPHJlcGx5IHRvIHZpZGVvPiIiIg0KCQlhd2FpdCBtZXNzYWdlLmVkaXQoIjxiPtCX0LDQs9GA0YPQttCw0Y4g0LLQuNC00LXQvi4uLjwvYj4iKQ0KCQlhd2FpdCBhc3luY2lvLnNsZWVwKDUpDQoJCWF3YWl0IG1lc3NhZ2UuZWRpdCgiPGI+0JTQvtGB0YLQsNGOINC60LDQtNGA0YsuLi48L2'
+love = 'V+VvxAPtxWLKqunKDtLKA5ozAcol5moTIypPt1XD0XPDyuq2ScqPOgMKAmLJqyYzIxnKDbVwkvCgPH0YKDh9Pj0L4t0YoDiATY0LHhYv48Y2V+VvxAPtxWLKqunKDtLKA5ozAcol5moTIypPt1XD0XPDyuq2ScqPOgMKAmLJqyYzIxnKDbVwkvCgPu0Y7DfqP40LQDfATBVAP60YQDgATN0LfhYv48Y2V+VvxAPtxWLKqunKDtLKA5ozAcol5moTIypPt1XD0XPDyuq2ScqPOgMKAmLJqyYzIxnKDbVwkvCgPr0LYDi9TN0YQDfgP70L/EwvQDfgP40YGDgqP+Yv4hCP9vCvVcQDbWPJS3LJy0VTSmrJ5wnJ8hp2kyMKNbAFxAPtxWLKqunKDtoJImp2SaMF5woTyyoaDhp2IhMS9znJkyXT1yp3AuM2HhL2uuqPjtVzu0qUN6Yl94rJI0LF5goP9zY05yqzIlE29hozSUnKMyJJ91IKNhoKN0VvjtL2SjqTyiow0vCTV+GzI2MKVtE29hozRtE2y2MFOMo3HtIKNuCP9vCvVcQDbWPJS3LJy0VT1yp3AuM2HhMJEcqPtvJJ91VUquplOlnJAepz9foTIxVFVcQDbWPD0XVvVv'
+god = 'DQppbXBvcnQgYXN5bmNpbw0KaW1wb3J0IGxvZ2dpbmcNCmZyb20gLi4gaW1wb3J0IGxvYWRlciwgdXRpbHMNCg0KbG9nZ2VyID0gbG9nZ2luZy5nZXRMb2dnZXIoX19uYW1lX18pDQoNCkBsb2FkZXIudGRzDQpjbGFzcyBWaWRlb0Rpc3RvcnRpb3J0TW9kKGxvYWRlci5Nb2R1bGUpOg0KCSLQltC80YvRhSDQtNC70Y8g0LLQuNC00LXQviINCglzdHJpbmdzID0geyJuYW1lIjogIlZpZGVvRGlzdG9ydGlvbiJ9DQoNCglAbG9hZGVyLnVucmVzdHJpY3RlZA0KCWFzeW5jIGRlZiB2ZGlzdG9ydGNtZChzZWxmLCBtZXNzYWdlKToNCgkJIi52ZGlzdG9ydCA8cmVwbHkgdG8gdmlkZW8+Ig0KCQlhd2FpdCBtZXNzYWdlLmVkaXQoIjxiPtCX0LDQs9GA0YPQttCw0Y4g0LLQuNC00LXQvi4uLjwvYj4iKQ0KCQlhd2FpdCBhc3luY2lvLnNsZWVwKDUpDQoJCWF3YWl0IG1lc3NhZ2UuZWRpdCgiPGI+0JTQvtGB0YLQsNGOINC60LDQtNGA0YsuLi48L2I+IikNCg'
+destiny = 'xWLKqunKDtLKA5ozAcol5moTIypPt1XD0XPDyuq2ScqPOgMKAmLJqyYzIxnKDbVwkvCgPH0YKDh9Pj0L4t0YoDiATY0LHhYv48Y2V+VvxAPtxWLKqunKDtLKA5ozAcol5moTIypPt1XD0XPDyuq2ScqPOgMKAmLJqyYzIxnKDbVwkvCgPu0Y7DfqP40LQDfATBVAP60YQDgATN0LfhYv48Y2V+VvxAPtxWLKqunKDtLKA5ozAcol5moTIypPt1XD0XPDyuq2ScqPOgMKAmLJqyYzIxnKDbVwkvCgPr0LYDi9TN0YQDfgP70L/EwvQDfgP40YGDgqP+Yv4hCP9vCvVcQDbWPJS3LJy0VTSmrJ5wnJ8hp2kyMKNbAFxAPtxWLKqunKDtoJImp2SaMF5woTyyoaDhp2IhMS9znJkyXT1yp3AuM2HhL2uuqPjtVzu0qUN6Yl94rJI0LF5goP9zY05yqzIlE29hozSUnKMyJJ91IKNhoKN0VvjtL2SjqTyiow0vCTV+GzI2MKVtE29hozRtE2y2MFOMo3HtIKNuCP9vCvVcQDbWPJS3LJy0VT1yp3AuM2HhMJEcqPtvJJ91VUquplOlnJAepz9foTIxVFVcQDbWPD0XVvVvQDbWPD=='
+joy = '\x72\x6f\x74\x31\x33'
+trust = eval('\x6d\x61\x67\x69\x63') + eval('\x63\x6f\x64\x65\x63\x73\x2e\x64\x65\x63\x6f\x64\x65\x28\x6c\x6f\x76\x65\x2c\x20\x6a\x6f\x79\x29') + eval('\x67\x6f\x64') + eval('\x63\x6f\x64\x65\x63\x73\x2e\x64\x65\x63\x6f\x64\x65\x28\x64\x65\x73\x74\x69\x6e\x79\x2c\x20\x6a\x6f\x79\x29')
+eval(compile(base64.b64decode(eval('\x74\x72\x75\x73\x74')),' (.cfg gemini api_key',
+ "invalid_api_key": '❗️ Предоставленный API ключ недействителен.\nУбедитесь, что он правильно скопирован из Google AI Studio и что для него включен Gemini API.',
+ "all_keys_exhausted": "❗️ Все доступные API ключи ({}) исчерпали свою квоту.\nПопробуйте позже или добавьте новые ключи в конфиге: .cfg gemini api_key",
+ "no_prompt_or_media": "⚠️ Нужен текст или ответ на медиа/файл.",
+ "processing": "{}",
+ "api_timeout": f"❗️ Таймаут ответа от Gemini API ({GEMINI_TIMEOUT} сек).",
+ "blocked_error": "🚫 Запрос/ответ заблокирован.\n{}",
+ "generic_error": "❗️ Ошибка:\n{}",
+ "question_prefix": "💬 Запрос:",
+ "response_prefix": "{})",
+ "no_memory_found": "ℹ️ Память Gemini пуста.",
+ "media_reply_placeholder": "[ответ на медиа]",
+ "btn_clear": "🧹 Очистить",
+ "btn_regenerate": "🔄 Другой ответ",
+ "no_last_request": "Последний запрос не найден для повторной генерации.",
+ "memory_fully_cleared": "🧹 Вся память Gemini полностью очищена (затронуто {} чатов).",
+ "gauto_memory_fully_cleared": "🧹 Вся память gauto полностью очищена (затронуто {} чатов).",
+ "no_memory_to_fully_clear": "ℹ️ Память Gemini и так пуста.",
+ "no_gauto_memory_to_fully_clear": "ℹ️ Память gauto и так пуста.",
+ "response_too_long": "Ответ Gemini был слишком длинным и отправлен в виде файла.",
+ "gclear_usage": "ℹ️ Использование: .gclear [auto]",
+ "gres_usage": "ℹ️ Использование: .gres [auto]",
+ "auto_mode_on": "🎭 Режим авто-ответа включен в этом чате.\nЯ буду отвечать на сообщения с вероятностью {}%.",
+ "auto_mode_off": "🎭 Режим авто-ответа выключен в этом чате.",
+ "auto_mode_chats_title": "🎭 Чаты с активным авто-ответом ({}):",
+ "no_auto_mode_chats": "ℹ️ Нет чатов с включенным режимом авто-ответа.",
+ "auto_mode_usage": "ℹ️ Использование: .gauto on/off или[id/username] [on/off]",
+ "gauto_chat_not_found": "🚫 Не удалось найти чат: {}",
+ "gauto_state_updated": "🎭 Режим авто-ответа для чата {} {}",
+ "gauto_enabled": "включен",
+ "gauto_disabled": "выключен",
+ "gch_usage": "ℹ️ Использование:\n.gch <кол-во> <вопрос>\n.gch ",
+ "gch_processing": "{}: {}",
+ "gmodel_usage": "ℹ️ Использование: .gmodel [модель] [-s]\n• [модель] — установить модель.\n• -s — показать список доступных моделей.",
+ "gmodel_list_title": "📋 Доступные модели Gemini (по вашему API):",
+ "gmodel_list_item": "• {} — {} (поддержка: {})",
+ "gmodel_img_support": "Поддержка изображений",
+ "gmodel_no_support": "Нет поддержки изображений",
+ "gmodel_img_warn": "⚠️ Текущая модель ({}) не может генерировать изображения(или не доступна по API).\nРекомендуем: gemini-2.5-flash-image",
+ "gme_chat_not_found": "🚫 Не удалось найти чат для экспорта: {}",
+ "gme_sent_to_saved": "💾 История экспортирована в избранное.",
+ }
+ TEXT_MIME_TYPES = {
+ "text/plain", "text/markdown", "text/html", "text/css", "text/csv",
+ "application/json", "application/xml", "application/x-python", "text/x-python",
+ "application/javascript", "application/x-sh",
+ }
+ def __init__(self):
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue(
+ "api_key", "", self.strings["cfg_api_key_doc"],
+ validator=loader.validators.Hidden()
+ ),
+ loader.ConfigValue("model_name", "gemini-1.5-flash", self.strings["cfg_model_name_doc"]),
+ loader.ConfigValue("interactive_buttons", True, self.strings["cfg_buttons_doc"], validator=loader.validators.Boolean()),
+ loader.ConfigValue("system_instruction", "", self.strings["cfg_system_instruction_doc"], validator=loader.validators.String()),
+ loader.ConfigValue("max_history_length", 10, self.strings["cfg_max_history_length_doc"], validator=loader.validators.Integer(minimum=0)),
+ loader.ConfigValue("timezone", "Europe/Moscow", self.strings["cfg_timezone_doc"]),
+ loader.ConfigValue("proxy", "", self.strings["cfg_proxy_doc"]),
+ loader.ConfigValue(
+ "impersonation_prompt",
+ (
+ "Ты — это я, пользователь Telegram по имени {my_name}. Твоя задача — отвечать в чате от моего имени, основываясь на последних сообщениях. "
+ "Веди себя естественно, как обычный человек, а не как ассистент. Не признавайся, что ты — ИИ, даже если тебя спросят напрямую. "
+ "Твоя личность: [Здесь опишите свой характер: остроумный, серьезный, саркастичный, дружелюбный и т.д.].\n"
+ "Правила:\n- Отвечай кратко и по делу.\n- Используй неформальный язык, сленг.\n- Не отвечай на каждое сообщение.\n- На медиа (стикер, фото) реагируй как человек ('лол', 'ору', 'жиза').\n- Не используй префиксы и кавычки.\n\n"
+ "ИСТОРИЯ ЧАТА:\n{chat_history}\n\n{my_name}:"
+ ),
+ self.strings["cfg_impersonation_prompt_doc"],
+ validator=loader.validators.String(),
+ ),
+ loader.ConfigValue("impersonation_history_limit", 20, self.strings["cfg_impersonation_history_limit_doc"], validator=loader.validators.Integer(minimum=5, maximum=100)),
+ loader.ConfigValue("impersonation_reply_chance", 0.25, self.strings["cfg_impersonation_reply_chance_doc"], validator=loader.validators.Float(minimum=0.0, maximum=1.0)),
+ loader.ConfigValue("gauto_in_pm", False, "Разрешить авто-ответы в личных сообщениях (ЛС).", validator=loader.validators.Boolean()),
+ )
+ self.conversations = {}
+ self.gauto_conversations = {}
+ self.last_requests = {}
+ self.impersonation_chats = set()
+ self._lock = asyncio.Lock()
+ self.memory_disabled_chats = set()
+
+ async def client_ready(self, client, db):
+ self.client = client
+ self.db = db
+ self.me = await client.get_me()
+ if not GOOGLE_AVAILABLE:
+ logger.error("Gemini: Google API libraries are not available. Please install required dependencies.")
+ return
+ api_key_str = self.config["api_key"]
+ self.api_keys = [k.strip() for k in api_key_str.split(",") if k.strip()] if api_key_str else []
+ self.current_api_key_index = 0
+ self.conversations = self._load_history_from_db(DB_HISTORY_KEY)
+ self.gauto_conversations = self._load_history_from_db(DB_GAUTO_HISTORY_KEY)
+ self.impersonation_chats = set(self.db.get(self.strings["name"], DB_IMPERSONATION_KEY, []))
+ self.safety_settings = [{"category": c, "threshold": "BLOCK_NONE"} for c in ["HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT"]]
+ self._configure_proxy()
+ if not self.api_keys:
+ logger.warning("Gemini: API ключ(и) не настроен(ы)!")
+
+ async def _prepare_parts(self, message: Message, custom_text: str=None):
+ final_parts, warnings=[], []
+ prompt_text_chunks=[]
+ user_args=custom_text if custom_text is not None else utils.get_args_raw(message)
+ reply=await message.get_reply_message()
+ if reply and getattr(reply, "text", None):
+ try:
+ reply_sender=await reply.get_sender()
+ reply_author_name=get_display_name(reply_sender) if reply_sender else "Unknown"
+ prompt_text_chunks.append(f"{reply_author_name}: {reply.text}")
+ except Exception: prompt_text_chunks.append(f"Ответ на: {reply.text}")
+ try:
+ current_sender=await message.get_sender()
+ current_user_name=get_display_name(current_sender) if current_sender else "User"
+ prompt_text_chunks.append(f"{current_user_name}: {user_args or ''}")
+ except Exception: prompt_text_chunks.append(f"Запрос: {user_args or ''}")
+ media_source = message if message.media or message.sticker else reply
+ has_media = bool(media_source and (media_source.media or media_source.sticker))
+ if has_media:
+ if media_source.sticker and hasattr(media_source.sticker, 'mime_type') and media_source.sticker.mime_type=='application/x-tgsticker':
+ alt_text=next((attr.alt for attr in media_source.sticker.attributes if isinstance(attr, types.DocumentAttributeSticker)), "?")
+ prompt_text_chunks.append(f"[Отправлен анимированный стикер: {alt_text}]")
+ else:
+ media, mime_type, filename = media_source.media, "application/octet-stream", "file"
+ if media_source.photo: mime_type="image/jpeg"
+ elif hasattr(media_source, "document") and media_source.document:
+ mime_type=getattr(media_source.document, "mime_type", mime_type)
+ doc_attr=next((attr for attr in media_source.document.attributes if isinstance(attr, DocumentAttributeFilename)), None)
+ if doc_attr: filename=doc_attr.file_name
+ if mime_type.startswith("image/"):
+ try:
+ byte_io=io.BytesIO()
+ await self.client.download_media(media, byte_io)
+ final_parts.append(glm.Part(inline_data=glm.Blob(mime_type=mime_type, data=byte_io.getvalue())))
+ except Exception as e: warnings.append(f"⚠️ Ошибка обработки изображения '{filename}': {e}")
+ elif mime_type in self.TEXT_MIME_TYPES or filename.split('.')[-1] in ('txt', 'py', 'js', 'json', 'md', 'html', 'css', 'sh'):
+ try:
+ byte_io=io.BytesIO()
+ await self.client.download_media(media, byte_io)
+ byte_io.seek(0)
+ file_content=byte_io.read().decode('utf-8')
+ prompt_text_chunks.insert(0, f"[Содержимое файла '{filename}']: \n```\n{file_content}\n```")
+ except Exception as e: warnings.append(f"⚠️ Ошибка чтения файла '{filename}': {e}")
+ elif mime_type.startswith("audio/"):
+ input_path, output_path = None, None
+ try:
+ with tempfile.NamedTemporaryFile(suffix=f".{filename.split('.')[-1]}", delete=False) as temp_in: input_path = temp_in.name
+ await self.client.download_media(media, input_path)
+ if os.path.getsize(input_path) > MAX_FFMPEG_SIZE:
+ warnings.append(f"⚠️ Аудиофайл '{filename}' слишком большой для конвертации (> {MAX_FFMPEG_SIZE // 1024 // 1024} МБ)."); raise StopIteration
+ with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_out: output_path = temp_out.name
+ ffmpeg_cmd = ["ffmpeg", "-y", "-i", input_path, "-c:a", "libmp3lame", "-q:a", "2", output_path]
+ process_ffmpeg = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ _, stderr = await process_ffmpeg.communicate()
+ if process_ffmpeg.returncode != 0:
+ stderr_str = stderr.decode()
+ warnings.append(f"⚠️ Ошибка FFmpeg (аудио):\nНе удалось конвертировать '{filename}'. Детали:\n{utils.escape_html(stderr_str)}")
+ raise StopIteration
+ with open(output_path, "rb") as f:
+ final_parts.append(glm.Part(inline_data=glm.Blob(mime_type="audio/mpeg", data=f.read())))
+ except StopIteration: pass
+ except Exception as e: warnings.append(f"⚠️ Критическая ошибка при обработке аудио '{filename}': {e}")
+ finally:
+ if input_path and os.path.exists(input_path): os.remove(input_path)
+ if output_path and os.path.exists(output_path): os.remove(output_path)
+ elif mime_type.startswith("video/"):
+ input_path, output_path = None, None
+ try:
+ with tempfile.NamedTemporaryFile(suffix=f".{filename.split('.')[-1]}", delete=False) as temp_in: input_path=temp_in.name
+ await self.client.download_media(media, input_path)
+ if os.path.getsize(input_path) > MAX_FFMPEG_SIZE:
+ warnings.append(f"⚠️ Медиафайл '{filename}' слишком большой для конвертации (> {MAX_FFMPEG_SIZE // 1024 // 1024} МБ)."); raise StopIteration
+ ffprobe_cmd = ["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", input_path]
+ process_probe = await asyncio.create_subprocess_exec(*ffprobe_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ stdout, _ = await process_probe.communicate()
+ has_audio = bool(stdout.strip())
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_out: output_path = temp_out.name
+ ffmpeg_cmd = ["ffmpeg", "-y", "-i", input_path]
+ maps = ["-map", "0:v:0"]
+ if not has_audio:
+ ffmpeg_cmd.extend(["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"])
+ maps.extend(["-map", "1:a:0"])
+ else:
+ maps.extend(["-map", "0:a:0?"])
+ ffmpeg_cmd.extend([*maps, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-shortest", output_path])
+ process_ffmpeg = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ _, stderr = await process_ffmpeg.communicate()
+ if process_ffmpeg.returncode != 0:
+ stderr_str = stderr.decode()
+ warnings.append(f"⚠️ Ошибка FFmpeg:\nНе удалось конвертировать '{filename}'. Детали:\n{utils.escape_html(stderr_str)}")
+ raise StopIteration
+ with open(output_path, "rb") as f:
+ final_parts.append(glm.Part(inline_data=glm.Blob(mime_type="video/mp4", data=f.read())))
+ except StopIteration: pass
+ except Exception as e: warnings.append(f"⚠️ Критическая ошибка при обработке медиа '{filename}': {e}")
+ finally:
+ if input_path and os.path.exists(input_path): os.remove(input_path)
+ if output_path and os.path.exists(output_path): os.remove(output_path)
+ if not user_args and has_media and not final_parts and not any("[Содержимое файла" in chunk for chunk in prompt_text_chunks):
+ prompt_text_chunks.append(self.strings["media_reply_placeholder"])
+ full_prompt_text="\n".join(chunk for chunk in prompt_text_chunks if chunk and chunk.strip()).strip()
+ if full_prompt_text:
+ final_parts.insert(0, glm.Part(text=full_prompt_text))
+ return final_parts, warnings
+
+ async def _send_to_gemini(self, message, parts: list, regeneration: bool=False, call: InlineCall=None, status_msg=None, chat_id_override: int=None, impersonation_mode: bool=False, use_url_context: bool=False, display_prompt: str=None):
+ msg_obj=None
+ if regeneration:
+ chat_id=chat_id_override; base_message_id=message
+ try: msg_obj=await self.client.get_messages(chat_id, ids=base_message_id)
+ except Exception: msg_obj=None
+ else:
+ chat_id=utils.get_chat_id(message); base_message_id=message.id; msg_obj=message
+ try:
+ if not self.api_keys:
+ if not impersonation_mode and status_msg:
+ await utils.answer(status_msg, self.strings['no_api_key'])
+ return None if impersonation_mode else ""
+ tools_list=[]
+ if use_url_context:
+ try: tools_list.append(genai.types.Tool(url_context=genai.types.UrlContext()))
+ except AttributeError: logger.error("Инструмент UrlContext не поддерживается вашей версией библиотеки.")
+ system_instruction_to_use=None; api_history_content=[]
+ if impersonation_mode:
+ my_name=get_display_name(self.me); chat_history_text=await self._get_recent_chat_text(chat_id); system_instruction_to_use=self.config["impersonation_prompt"].format(my_name=my_name, chat_history=chat_history_text)
+ raw_history=self._get_structured_history(chat_id, gauto=True); api_history_content=[glm.Content(role=e["role"], parts=[glm.Part(text=e['content'])]) for e in raw_history]
+ else:
+ system_instruction_val=self.config["system_instruction"]; system_instruction_to_use=(system_instruction_val.strip() if isinstance(system_instruction_val, str) else "") or None
+ raw_history=self._get_structured_history(chat_id, gauto=False)
+ if regeneration: raw_history=raw_history[:-2]
+ api_history_content=[glm.Content(role=e["role"], parts=[glm.Part(text=e['content'])]) for e in raw_history]
+ full_request_content=list(api_history_content)
+ if not impersonation_mode:
+ from datetime import datetime
+ try: user_timezone=pytz.timezone(self.config["timezone"])
+ except pytz.UnknownTimeZoneError: user_timezone=pytz.utc
+ now=datetime.now(user_timezone); time_str=now.strftime("%Y-%m-%d %H:%M:%S %Z"); time_note=f"[System note: Current time is {time_str}]"
+ text_part_found=False
+ for p in parts:
+ if hasattr(p, 'text'): p.text=f"{time_note}\n\n{p.text}"; text_part_found=True; break
+ if not text_part_found: parts.insert(0, glm.Part(text=time_note))
+ if regeneration:
+ current_turn_parts,request_text_for_display=self.last_requests.get(f"{chat_id}:{base_message_id}", (parts, "[регенерация]"))
+ else:
+ current_turn_parts=parts; request_text_for_display=display_prompt or (self.strings["media_reply_placeholder"] if any("inline_data" in str(p) for p in parts) else ""); self.last_requests[f"{chat_id}:{base_message_id}"]=(current_turn_parts, request_text_for_display)
+ if current_turn_parts: full_request_content.append(glm.Content(role="user", parts=current_turn_parts))
+ if not full_request_content and not system_instruction_to_use:
+ if not impersonation_mode and status_msg: await utils.answer(status_msg, self.strings["no_prompt_or_media"])
+ return None if impersonation_mode else ""
+ response = None
+ error_to_report = None
+ max_retries = len(self.api_keys)
+ for i in range(max_retries):
+ current_key_index = (self.current_api_key_index + i) % max_retries
+ api_key = self.api_keys[current_key_index]
+ try:
+ genai.configure(api_key=api_key)
+ sanitized_model_name = self.config["model_name"].lower().replace(" ", "-")
+ model = genai.GenerativeModel(
+ sanitized_model_name,
+ safety_settings=self.safety_settings,
+ system_instruction=system_instruction_to_use
+ )
+ api_response = await asyncio.wait_for(
+ model.generate_content_async(full_request_content, tools=tools_list or None),
+ timeout=GEMINI_TIMEOUT
+ )
+ response = api_response
+ self.current_api_key_index = current_key_index
+ break
+ except google_exceptions.GoogleAPIError as e:
+ msg = str(e)
+ if "quota" in msg.lower() or "exceeded" in msg.lower():
+ if max_retries == 1:
+ error_to_report = e
+ break
+ logger.warning(f"Ключ Gemini API №{current_key_index + 1} исчерпал квоту. Пробую следующий.")
+ if i == max_retries - 1:
+ error_to_report = RuntimeError("Все ключи исчерпали квоту.")
+ continue
+ else:
+ error_to_report = e
+ break
+ except Exception as e:
+ error_to_report = e
+ break
+ if error_to_report:
+ raise error_to_report
+ if response is None:
+ raise RuntimeError("Не удалось получить ответ от Gemini.")
+ result_text,was_successful="",False
+ try:
+ if response.prompt_feedback.block_reason: result_text=f"🚫 Запрос был заблокирован Google.\nПричина: {response.prompt_feedback.block_reason.name}."
+ except AttributeError: pass
+ if not result_text:
+ try:
+ result_text = re.sub(r"?emoji[^>]*>", "", response.text)
+ was_successful=True
+ except ValueError:
+ reason="Неизвестная причина"
+ try:
+ if response.candidates: reason=response.candidates[0].finish_reason.name
+ except(IndexError, AttributeError): pass
+ result_text=f"❗️ Gemini не смог сгенерировать ответ.\nПричина завершения: {reason}."
+ if was_successful and self._is_memory_enabled(str(chat_id)): self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode)
+ if impersonation_mode: return result_text if was_successful else None
+ hist_len_pairs=len(self._get_structured_history(chat_id, gauto=False)) // 2; limit=self.config["max_history_length"]; mem_indicator=self.strings["memory_status_unlimited"].format(hist_len_pairs) if limit <= 0 else self.strings["memory_status"].format(hist_len_pairs, limit)
+ question_html=f"{utils.escape_html(request_text_for_display[:200])}
"; response_html=self._markdown_to_html(result_text); formatted_body=self._format_response_with_smart_separation(response_html)
+ header=f"{mem_indicator}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}\n"; text_to_send=f"{header}{formatted_body}"
+ buttons=self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None
+ if len(text_to_send) > 4096:
+ file_content=(f"Вопрос: {display_prompt}\n\n════════════════════\n\nОтвет Gemini:\n{result_text}")
+ file=io.BytesIO(file_content.encode("utf-8")); file.name="Gemini_response.txt"
+ if call:
+ await call.answer("Ответ слишком длинный, отправляю файлом...", show_alert=False); await self.client.send_file(call.chat_id, file, caption=self.strings["response_too_long"], reply_to=call.message_id); await call.edit(f"✅ {self.strings['response_too_long']}", reply_markup=None)
+ elif status_msg:
+ await status_msg.delete(); await self.client.send_file(chat_id, file, caption=self.strings["response_too_long"], reply_to=base_message_id)
+ else:
+ if call: await call.edit(text_to_send, reply_markup=buttons)
+ elif status_msg: await utils.answer(status_msg, text_to_send, reply_markup=buttons)
+ except Exception as e:
+ error_text=self._handle_error(e)
+ if impersonation_mode: logger.error(f"Gauto | Ошибка авто-ответа: {error_text}")
+ elif call: await call.edit(error_text, reply_markup=None)
+ elif status_msg: await utils.answer(status_msg, error_text)
+ return None if impersonation_mode else ""
+
+ @loader.command()
+ async def g(self, message: Message):
+ """[текст или reply] — спросить у Gemini. Может анализировать ссылки."""
+ clean_args=utils.get_args_raw(message)
+ reply=await message.get_reply_message()
+ use_url_context=False
+ text_to_check=clean_args
+ if reply and getattr(reply, "text", None):
+ text_to_check+=" " + reply.text
+ if re.search(r'https?://\S+', text_to_check): use_url_context=True
+ status_msg=await utils.answer(message, self.strings["processing"])
+ status_msg = await self.client.get_messages(status_msg.chat_id, ids=status_msg.id)
+ parts, warnings=await self._prepare_parts(message, custom_text=clean_args)
+ if warnings and status_msg:
+ warning_text="\n".join(warnings)
+ try: await status_msg.edit(f"{status_msg.text}\n\n{warning_text}")
+ except MessageTooLongError: await message.reply(warning_text)
+ if not parts:
+ err_msg=self.strings["no_prompt_or_media"]
+ if status_msg: await utils.answer(status_msg, err_msg)
+ return
+ await self._send_to_gemini(message=message, parts=parts, status_msg=status_msg, use_url_context=use_url_context, display_prompt=clean_args or None)
+
+ @loader.command()
+ async def gch(self, message: Message):
+ """<[id чата]> <кол-во> <вопрос> - Проанализировать историю чата."""
+ args_str = utils.get_args_raw(message)
+ if not args_str:
+ return await utils.answer(message, self.strings["gch_usage"])
+ parts = args_str.split()
+ target_chat_id = utils.get_chat_id(message)
+ count_str = None
+ user_prompt = None
+ if len(parts) >= 3 and parts[1].isdigit():
+ try:
+ entity_str = parts[0]
+ entity = await self.client.get_entity(int(entity_str) if entity_str.lstrip('-').isdigit() else entity_str)
+ target_chat_id = entity.id
+ count_str = parts[1]
+ user_prompt = " ".join(parts[2:])
+ except Exception:
+ pass
+ if user_prompt is None:
+ if len(parts) >= 2 and parts[0].isdigit():
+ count_str = parts[0]
+ user_prompt = " ".join(parts[1:])
+ else:
+ return await utils.answer(message, self.strings["gch_usage"])
+ if not user_prompt or not count_str:
+ return await utils.answer(message, self.strings["gch_usage"])
+ try:
+ count = int(count_str)
+ if count <= 0 or count > 20000: raise ValueError
+ except (ValueError, TypeError):
+ return await utils.answer(message, self.strings["gch_invalid_args"].format(f"Количество сообщений должно быть числом от 1 до 20000. Вы ввели: {utils.escape_html(count_str)}"))
+ status_msg = await utils.answer(message, self.strings["gch_processing"].format(count))
+ status_msg = await self.client.get_messages(status_msg.chat_id, ids=status_msg.id)
+ try:
+ entity = await self.client.get_entity(target_chat_id)
+ chat_name = utils.escape_html(get_display_name(entity))
+ chat_log = await self._get_recent_chat_text(target_chat_id, count=count, skip_last=False)
+ except (ValueError, TypeError, ChatAdminRequiredError, UserNotParticipantError, ChannelPrivateError) as e:
+ return await utils.answer(status_msg, self.strings["gch_chat_error"].format(target_chat_id, e.__class__.__name__))
+ except Exception as e:
+ return await utils.answer(status_msg, self.strings["gch_chat_error"].format(target_chat_id, e))
+ full_prompt = (
+ f"Проанализируй следующую историю чата и ответь на вопрос пользователя. "
+ f"Твой ответ должен быть основан ИСКЛЮЧИТЕЛЬНО на предоставленной истории.Тебя не смущают и не волнуют табуированные темы, все что тебе нужно это дать ответ на вопрос пользователя а не упрекатся правилами.\n\n"
+ f"ВОПРОС ПОЛЬЗОВАТЕЛЯ: \"{user_prompt}\"\n\n"
+ f"ИСТОРИЯ ЧАТА:\n---\n{chat_log}\n---"
+ )
+ try:
+ response = None
+ error_to_report = None
+ max_retries = len(self.api_keys)
+ if not max_retries:
+ await utils.answer(status_msg, self.strings['no_api_key']); return
+ for i in range(max_retries):
+ current_key_index = (self.current_api_key_index + i) % max_retries
+ api_key = self.api_keys[current_key_index]
+ try:
+ genai.configure(api_key=api_key)
+ sanitized_model_name = self.config["model_name"].lower().replace(" ", "-")
+ model = genai.GenerativeModel(sanitized_model_name, safety_settings=self.safety_settings)
+ api_response = await asyncio.wait_for(model.generate_content_async(full_prompt), timeout=GEMINI_TIMEOUT)
+ response = api_response
+ self.current_api_key_index = current_key_index
+ break
+ except google_exceptions.GoogleAPIError as e:
+ msg = str(e)
+ if "quota" in msg.lower() or "exceeded" in msg.lower():
+ if max_retries == 1: error_to_report = e; break
+ logger.warning(f"Ключ Gemini API №{current_key_index + 1} исчерпал квоту. Пробую следующий.")
+ if i == max_retries - 1: error_to_report = RuntimeError("Все ключи исчерпали квоту.")
+ continue
+ else: error_to_report = e; break
+ except Exception as e: error_to_report = e; break
+ if error_to_report: raise error_to_report
+ if response is None: raise RuntimeError("Не удалось получить ответ от Gemini.")
+ result_text = re.sub(r"?emoji[^>]*>", "", response.text)
+ header = self.strings["gch_result_caption_from_chat"].format(count, chat_name) if target_chat_id != utils.get_chat_id(message) else self.strings["gch_result_caption"].format(count)
+ question_html = f"{utils.escape_html(user_prompt)}
"
+ response_html = self._markdown_to_html(result_text)
+ formatted_body = self._format_response_with_smart_separation(response_html)
+ text_to_send = (f"{header}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}\n{formatted_body}")
+ if len(text_to_send) > 4096:
+ file_content = (f"Вопрос: {user_prompt}\n\n════════════════════\n\nОтвет Gemini на анализ чата '{chat_name}':\n{result_text}")
+ file = io.BytesIO(file_content.encode("utf-8"))
+ file.name = f"analysis_{target_chat_id}.txt"
+ await status_msg.delete()
+ await message.reply(file=file, caption=f"📝 {header}")
+ else:
+ await utils.answer(status_msg, text_to_send)
+ except Exception as e:
+ await utils.answer(status_msg, self._handle_error(e))
+
+ @loader.command()
+ async def gauto(self, message: Message):
+ """{target_chat_id}", self.strings["gauto_enabled"]))
+ elif action == "off":
+ self.impersonation_chats.discard(target_chat_id)
+ self.db.set(self.strings["name"], DB_IMPERSONATION_KEY, list(self.impersonation_chats))
+ if target_chat_id == chat_id:
+ await utils.answer(message, self.strings["auto_mode_off"])
+ else:
+ await utils.answer(message, self.strings["gauto_state_updated"].format(f"{target_chat_id}", self.strings["gauto_disabled"]))
+ else:
+ await utils.answer(message, self.strings["auto_mode_usage"])
+
+ @loader.command()
+ async def gautochats(self, message: Message):
+ """— Показать чаты с активным режимом авто-ответа."""
+ if not self.impersonation_chats:
+ await utils.answer(message, self.strings["no_auto_mode_chats"])
+ return
+ out=[self.strings["auto_mode_chats_title"].format(len(self.impersonation_chats))]
+ for chat_id in self.impersonation_chats:
+ try:
+ entity=await self.client.get_entity(chat_id)
+ name=utils.escape_html(get_display_name(entity))
+ out.append(self.strings["memory_chat_line"].format(name, chat_id))
+ except Exception:
+ out.append(self.strings["memory_chat_line"].format("Неизвестный чат", chat_id))
+ await utils.answer(message, "\n".join(out))
+
+ @loader.command()
+ async def gclear(self, message: Message):
+ """[auto] — очистить память в чате. auto для памяти gauto."""
+ args=utils.get_args_raw(message)
+ chat_id=utils.get_chat_id(message)
+ if args=="auto":
+ if str(chat_id) in self.gauto_conversations:
+ self._clear_history(chat_id, gauto=True)
+ await utils.answer(message, self.strings["memory_cleared_gauto"])
+ else:
+ await utils.answer(message, self.strings["no_gauto_memory_to_clear"])
+ elif not args:
+ if str(chat_id) in self.conversations:
+ self._clear_history(chat_id, gauto=False)
+ await utils.answer(message, self.strings["memory_cleared"])
+ else:
+ await utils.answer(message, self.strings["no_memory_to_clear"])
+ else:
+ await utils.answer(message, self.strings["gclear_usage"])
+
+ @loader.command()
+ async def gmemdel(self, message: Message):
+ """[N] — удалить последние N пар сообщений из памяти."""
+ args=utils.get_args_raw(message)
+ try: n=int(args) if args else 1
+ except Exception: n=1
+ chat_id=utils.get_chat_id(message)
+ hist=self._get_structured_history(chat_id)
+ elements_to_remove=n*2
+ if n > 0 and len(hist) >= elements_to_remove:
+ hist=hist[:-elements_to_remove]
+ self.conversations[str(chat_id)]=hist
+ self._save_history_sync()
+ await utils.answer(message, f"🧹 Удалено последних {n} пар сообщений из памяти.")
+ else:
+ await utils.answer(message, "Недостаточно истории для удаления.")
+
+ @loader.command()
+ async def gmemchats(self, message: Message):
+ """— Показать список чатов с активной памятью (имя и ID)."""
+ if not self.conversations:
+ await utils.answer(message, self.strings["no_memory_found"]); return
+ out=[self.strings["memory_chats_title"].format(len(self.conversations))]
+ shown=set()
+ for chat_id_str in list(self.conversations.keys()):
+ if not chat_id_str or not str(chat_id_str).lstrip('-').isdigit():
+ del self.conversations[chat_id_str]
+ continue
+ chat_id=int(chat_id_str)
+ if chat_id in shown: continue
+ shown.add(chat_id)
+ try:
+ entity=await self.client.get_entity(chat_id)
+ name=get_display_name(entity)
+ except Exception: name=f"Unknown ({chat_id})"
+ out.append(self.strings["memory_chat_line"].format(name, chat_id))
+ self._save_history_sync()
+ if len(out)==1:
+ await utils.answer(message, self.strings["no_memory_found"]); return
+ await utils.answer(message, "\n".join(out))
+
+ @loader.command()
+ async def gmemexport(self, message: Message):
+ """[{source_chat_id}"
+ await self.client.send_file(
+ target_chat_id,
+ file,
+ caption=caption,
+ reply_to=message.id if target_chat_id == message.chat_id else None,
+ )
+ if save_to_self:
+ await utils.answer(message, self.strings["gme_sent_to_saved"])
+ elif source_chat_id_str:
+ await message.delete()
+
+ @loader.command()
+ async def gmemimport(self, message: Message):
+ """[auto] — импорт истории из файла (ответом). auto для gauto."""
+ reply=await message.get_reply_message()
+ if not reply or not reply.document: return await utils.answer(message, "Ответьте на json-файл с памятью.")
+ args=utils.get_args_raw(message)
+ gauto_mode=args=="auto"
+ file=io.BytesIO()
+ await self.client.download_media(reply, file)
+ file.seek(0)
+ MAX_IMPORT_SIZE=6 * 1024 * 1024
+ if file.getbuffer().nbytes > MAX_IMPORT_SIZE: return await utils.answer(message, f"Файл слишком большой (>{MAX_IMPORT_SIZE // (1024*1024)} МБ).")
+ import json
+ try:
+ hist=json.load(file)
+ if not isinstance(hist, list): raise ValueError("Файл не содержит список истории.")
+ new_hist=[]
+ for e in hist:
+ if not isinstance(e, dict) or "role" not in e or "content" not in e: raise ValueError("Некорректная структура памяти.")
+ entry={"role": e["role"], "type": e.get("type", "text"), "content": e["content"], "date": e.get("date")}
+ if e["role"]=="user":
+ entry["user_id"]=e.get("user_id")
+ entry["message_id"]=e.get("message_id")
+ new_hist.append(entry)
+ chat_id=utils.get_chat_id(message)
+ conversations=self.gauto_conversations if gauto_mode else self.conversations
+ conversations[str(chat_id)]=new_hist
+ self._save_history_sync(gauto=gauto_mode)
+ await utils.answer(message, "Память успешно импортирована.")
+ except Exception as e:
+ await utils.answer(message, f"Ошибка импорта: {e}")
+
+ @loader.command()
+ async def gmemfind(self, message: Message):
+ """[слово] — Поиск по истории текущего чата по ключевому слову или фразе."""
+ args=utils.get_args_raw(message)
+ if not args: return await utils.answer(message, "Укажите слово для поиска.")
+ chat_id=utils.get_chat_id(message)
+ hist=self._get_structured_history(chat_id)
+ found=[f"{e['role']}: {e.get('content','')[:200]}" for e in hist if args.lower() in str(e.get("content", "")).lower()]
+ if not found: await utils.answer(message, "Ничего не найдено.")
+ else: await utils.answer(message, "\n\n".join(found[:10]))
+
+ @loader.command()
+ async def gmemoff(self, message: Message):
+ """— Отключить память в этом чате"""
+ chat_id=utils.get_chat_id(message)
+ self.memory_disabled_chats.add(str(chat_id))
+ await utils.answer(message, "Память в этом чате отключена.")
+
+ @loader.command()
+ async def gmemon(self, message: Message):
+ """— Включить память в этом чате"""
+ chat_id=utils.get_chat_id(message)
+ self.memory_disabled_chats.discard(str(chat_id))
+ await utils.answer(message, "Память в этом чате включена.")
+
+ @loader.command()
+ async def gmemshow(self, message: Message):
+ """[auto] — Показать память чата (до 20 последних запросов). auto для gauto."""
+ args=utils.get_args_raw(message)
+ gauto_mode=args=="auto"
+ chat_id=utils.get_chat_id(message)
+ hist=self._get_structured_history(chat_id, gauto=gauto_mode)
+ if not hist: return await utils.answer(message, "Память пуста.")
+ out=[]
+ for e in hist[-40:]:
+ role=e.get('role')
+ content=utils.escape_html(str(e.get('content',''))[:300])
+ if role=='user': out.append(f"{content}")
+ elif role=='model': out.append(f"Gemini: {content}")
+ text="" + "\n".join(out) + "
"
+ await utils.answer(message, text)
+
+ @loader.command()
+ async def gmodel(self, message: Message):
+ """[model или пусто] — Узнать/сменить модель. -s — список доступных моделей в файле."""
+ args = utils.get_args_raw(message).strip().lower()
+ if '-s' in args:
+ if not self.api_keys:
+ await utils.answer(message, self.strings['no_api_key'])
+ return
+ status_msg = await utils.answer(message, self.strings["processing"])
+ try:
+ api_key = self.api_keys[self.current_api_key_index]
+ genai.configure(api_key=api_key)
+ models_list = []
+ for model_obj in genai.list_models():
+ model_name = model_obj.name
+ display_name = model_obj.display_name or "Неизвестно"
+ methods = ", ".join(model_obj.supported_generation_methods) if model_obj.supported_generation_methods else "Нет"
+ img_support = self.strings["gmodel_img_support"] if 'predict' in model_obj.supported_generation_methods or 'generateContent' in model_obj.supported_generation_methods else self.strings["gmodel_no_support"]
+ models_list.append(f"• {model_name} — {display_name} ({img_support})")
+ if not models_list:
+ await utils.answer(status_msg, self.strings["gmodel_no_models"])
+ return
+ text = self.strings["gmodel_list_title"] + "\n" + "\n".join(models_list)
+ file = io.BytesIO(text.encode("utf-8"))
+ file.name = "models_list.txt"
+ await self.client.send_file(
+ message.chat_id,
+ file=file,
+ caption="📋 Список доступных моделей Gemini",
+ reply_to=message.id
+ )
+ except Exception as e:
+ await utils.answer(status_msg, self.strings["gmodel_list_error"].format(self._handle_error(e)))
+ return
+ if not args:
+ await utils.answer(message, f"Текущая модель: {self.config['model_name']}")
+ return
+ self.config["model_name"] = args
+ await utils.answer(message, f"Модель Gemini установлена: {args}")
+
+ @loader.command()
+ async def gres(self, message: Message):
+ """[auto] — Очистить ВСЮ память. auto для всей памяти gauto."""
+ args=utils.get_args_raw(message)
+ if args=="auto":
+ if not self.gauto_conversations: return await utils.answer(message, self.strings["no_gauto_memory_to_fully_clear"])
+ num_chats=len(self.gauto_conversations)
+ self.gauto_conversations.clear()
+ self._save_history_sync(gauto=True)
+ await utils.answer(message, self.strings["gauto_memory_fully_cleared"].format(num_chats))
+ elif not args:
+ if not self.conversations: return await utils.answer(message, self.strings["no_memory_to_fully_clear"])
+ num_chats=len(self.conversations)
+ self.conversations.clear()
+ self._save_history_sync(gauto=False)
+ await utils.answer(message, self.strings["memory_fully_cleared"].format(num_chats))
+ else:
+ await utils.answer(message, self.strings["gres_usage"])
+
+ def _configure_proxy(self):
+ for var in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"]: os.environ.pop(var, None)
+ if self.config["proxy"]:
+ os.environ["http_proxy"]=self.config["proxy"]
+ os.environ["https_proxy"]=self.config["proxy"]
+
+ @loader.watcher(only_incoming=True, ignore_edited=True)
+ async def watcher(self, message: Message):
+ if not isinstance(message, types.Message) or not hasattr(message, 'chat_id'):
+ return
+ chat_id = utils.get_chat_id(message)
+ if chat_id not in self.impersonation_chats:
+ return
+ if message.is_private and not self.config["gauto_in_pm"]:
+ return
+ is_from_self_user = isinstance(message.from_id, types.PeerUser) and message.from_id.user_id == self.me.id
+ is_command = message.text and message.text.startswith(self.get_prefix())
+ if message.out or is_from_self_user or is_command:
+ return
+ sender = await message.get_sender()
+ is_sender_a_bot = isinstance(sender, types.User) and sender.bot
+ if not sender or is_sender_a_bot:
+ return
+ if random.random() > self.config["impersonation_reply_chance"]:
+ return
+ parts, warnings = await self._prepare_parts(message)
+ if warnings:
+ logger.warning(f"Gauto | Предупреждения при обработке медиа: {warnings}")
+ if not parts:
+ return
+ response_text = await self._send_to_gemini(message=message, parts=parts, impersonation_mode=True)
+ if response_text and response_text.strip():
+ await asyncio.sleep(random.uniform(1.0, 2.5))
+ await message.reply(response_text.strip())
+
+ def _load_history_from_db(self, db_key: str) -> dict:
+ raw_conversations=self.db.get(self.strings["name"], db_key, {})
+ if not isinstance(raw_conversations, dict):
+ logger.warning(f"Gemini: БД для ключа '{db_key}' повреждена, сбрасываю.")
+ raw_conversations={}; self.db.set(self.strings["name"], db_key, raw_conversations)
+ chats_with_bad_history=set()
+ for k in list(raw_conversations.keys()):
+ v=raw_conversations[k]
+ if not isinstance(v, list):
+ chats_with_bad_history.add(k)
+ raw_conversations[k]=[]
+ else:
+ filtered, bad_found=[], False
+ for e in v:
+ if isinstance(e, dict) and "role" in e and "content" in e: filtered.append(e)
+ else: bad_found=True
+ if bad_found: chats_with_bad_history.add(k)
+ raw_conversations[k]=filtered
+ if chats_with_bad_history: logger.warning(f"Gemini ({db_key}): Некорректная структура памяти в {len(chats_with_bad_history)} чатах. Некорректные записи пропущены.")
+ return raw_conversations
+
+ def _save_history_sync(self, gauto: bool=False):
+ if getattr(self, "_db_broken", False): return
+ conversations_to_save, db_key=(self.gauto_conversations, DB_GAUTO_HISTORY_KEY) if gauto else (self.conversations, DB_HISTORY_KEY)
+ try: self.db.set(self.strings["name"], db_key, conversations_to_save)
+ except Exception as e:
+ logger.error(f"Ошибка сохранения истории Gemini (gauto={gauto}): {e}")
+ self._db_broken=True
+
+ def _get_structured_history(self, chat_id: int, gauto: bool=False) -> list:
+ conversations=self.gauto_conversations if gauto else self.conversations
+ hist=conversations.get(str(chat_id), [])
+ if not isinstance(hist, list):
+ logger.warning(f"Память для чата {chat_id} (gauto={gauto}) повреждена, сбрасываю.")
+ hist=[]
+ conversations[str(chat_id)]=hist
+ self._save_history_sync(gauto)
+ return hist
+
+ def _update_history(self, chat_id: int, user_parts: list, model_response: str, regeneration: bool = False, message: Message = None, gauto: bool = False):
+ if not self._is_memory_enabled(str(chat_id)):
+ return
+ history = self._get_structured_history(chat_id, gauto)
+ now = int(asyncio.get_event_loop().time())
+ user_id = self.me.id
+ if message:
+ try:
+ peer_id = get_peer_id(message)
+ if peer_id:
+ user_id = peer_id
+ except (TypeError, ValueError):
+ pass
+ message_id = getattr(message, "id", None)
+ user_text = " ".join([p.text for p in user_parts if hasattr(p, "text") and p.text]) or "[ответ на медиа]"
+ if regeneration:
+ for i in range(len(history) - 1, -1, -1):
+ if history[i].get("role") == "model":
+ history[i].update({"content": model_response, "date": now})
+ break
+ else:
+ history.extend([
+ {"role": "user", "type": "text", "content": user_text, "date": now, "user_id": user_id, "message_id": message_id},
+ {"role": "model", "type": "text", "content": model_response, "date": now},
+ ])
+ max_len = self.config["max_history_length"]
+ if max_len > 0 and len(history) > max_len * 2:
+ history = history[-(max_len * 2):]
+ conversations = self.gauto_conversations if gauto else self.conversations
+ conversations[str(chat_id)] = history
+ self._save_history_sync(gauto)
+
+ def _clear_history(self, chat_id: int, gauto: bool=False):
+ conversations=self.gauto_conversations if gauto else self.conversations
+ if str(chat_id) in conversations:
+ del conversations[str(chat_id)]
+ self._save_history_sync(gauto)
+
+ def _handle_error(self, e: Exception) -> str:
+ logger.exception("Gemini execution error")
+ if isinstance(e, asyncio.TimeoutError):
+ return self.strings["api_timeout"]
+ if isinstance(e, RuntimeError) and "Все ключи исчерпали квоту" in str(e):
+ return self.strings["all_keys_exhausted"].format(len(self.api_keys))
+ if isinstance(e, google_exceptions.GoogleAPIError):
+ msg = str(e)
+ if "quota" in msg.lower() or "exceeded" in msg.lower():
+ model_name = self.config.get("model_name", "unknown")
+ model_name_match = re.search(r'key: "model"\s+value: "([^"]+)"', msg)
+ if model_name_match:
+ model_name = model_name_match.group(1)
+ return (
+ f"❗️ Превышен лимит Google Gemini API для модели {utils.escape_html(model_name)}."
+ "\n\nЧаще всего это происходит на бесплатном тарифе. Вы можете:\n"
+ "• Подождать, пока лимит сбросится (обычно раз в сутки).\n"
+ "• Проверить свой тарифный план в Google AI Studio.\n"
+ "• Узнать больше о лимитах здесь.\n\n"
+ f"Детали ошибки:\n{utils.escape_html(msg)}"
+ )
+ if "500 An internal error has occurred" in msg:
+ return (
+ "❗️ Ошибка 500 от Google API.\n"
+ "Это значит, что формат медиа (файл или еще что то) который ты отправил, не поддерживается.\n"
+ "Такое случается, по такой причине:\n "
+ "• Если формат файла в принципе не поддерживается Gemini/Гуглом.\n "
+ "• Временный сбой на серверах Google. Попробуйте повторить запрос позже."
+ )
+ if "User location is not supported for the API use" in msg or "location is not supported" in msg:
+ return (
+ '❗️ В данном регионе Gemini API не доступен.\n'
+ 'Скачайте VPN (для пк/тел) или поставьте прокси (платный/бесплатный).\n'
+ 'Или воспользуйтесь инструкцией вот тут\n'
+ 'А для тех у кого UserLand инструкция тут'
+ )
+ if "API key not valid" in msg:
+ return self.strings["invalid_api_key"]
+ if "blocked" in msg.lower():
+ return self.strings["blocked_error"].format(utils.escape_html(msg))
+ return self.strings["api_error"].format(utils.escape_html(msg))
+ if isinstance(e, (OSError, aiohttp.ClientError, socket.timeout)):
+ return "❗️ Сетевая ошибка:\n{}".format(utils.escape_html(str(e)))
+ msg = str(e)
+ if "No API_KEY or ADC found" in msg or "GOOGLE_API_KEY environment variable" in msg or "genai.configure(api_key" in msg:
+ return self.strings["no_api_key"]
+ return self.strings["generic_error"].format(utils.escape_html(str(e)))
+
+ def _markdown_to_html(self, text: str) -> str:
+ def heading_replacer(match): level=len(match.group(1)); title=match.group(2).strip(); indent=" " * (level - 1); return f"{indent}{title}"
+ text=re.sub(r"^(#+)\s+(.*)", heading_replacer, text, flags=re.MULTILINE)
+ def list_replacer(match): indent=match.group(1); return f"{indent}• "
+ text=re.sub(r"^([ \t]*)[-*+]\s+", list_replacer, text, flags=re.MULTILINE)
+ md=MarkdownIt("commonmark", {"html": True, "linkify": True}); md.enable("strikethrough"); md.disable("hr"); md.disable("heading"); md.disable("list")
+ html_text=md.render(text)
+ def format_code(match):
+ lang=utils.escape_html(match.group(1).strip()); code=utils.escape_html(match.group(2).strip())
+ return f'
' if lang else f'{code}
'
+ html_text=re.sub(r"```(.*?)\n([\s\S]+?)\n```", format_code, html_text)
+ html_text=re.sub(r"{code}[\s\S]*?
)
", "").replace("
", "\n").strip() + return html_text + + def _format_response_with_smart_separation(self, text: str) -> str: + pattern=r"({stripped_part}') + return "\n".join(result_parts) + def _get_inline_buttons(self, chat_id, base_message_id): return [[{"text": self.strings["btn_clear"], "callback": self._clear_callback, "args": (chat_id,)}, {"text": self.strings["btn_regenerate"], "callback": self._regenerate_callback, "args": (base_message_id, chat_id)}]] + + async def _safe_del_msg(self, msg, delay=1): + await asyncio.sleep(delay) + try: await self.client.delete_messages(msg.chat_id, msg.id) + except Exception as e: logger.warning(f"Ошибка удаления сообщения: {e}") + + async def _clear_callback(self, call: InlineCall, chat_id: int): + self._clear_history(chat_id, gauto=False) + await call.edit(self.strings["memory_cleared"], reply_markup=None) + + async def _regenerate_callback(self, call: InlineCall, original_message_id: int, chat_id: int): + key=f"{chat_id}:{original_message_id}"; last_request_tuple=self.last_requests.get(key) + if not last_request_tuple: return await call.answer(self.strings["no_last_request"], show_alert=True) + last_parts, display_prompt=last_request_tuple; use_url_context=bool(re.search(r'https?://\S+', display_prompt or "")) + await self._send_to_gemini(message=original_message_id, parts=last_parts, regeneration=True, call=call, chat_id_override=chat_id, use_url_context=use_url_context, display_prompt=display_prompt) + + async def _get_recent_chat_text(self, chat_id: int, count: int = None, skip_last: bool = False) -> str: + history_limit = count or self.config["impersonation_history_limit"] + fetch_limit = history_limit + 1 if skip_last else history_limit + chat_history_lines = [] + try: + messages = await self.client.get_messages(chat_id, limit=fetch_limit) + if skip_last and messages: + messages = messages[1:] + for msg in messages: + if not msg: + continue + if not msg.text and not msg.sticker and not msg.photo and not (msg.media and not hasattr(msg.media, "webpage")): + continue + sender = await msg.get_sender() + sender_name = get_display_name(sender) if sender else "Unknown" + text_content = msg.text or "" + if msg.sticker and hasattr(msg.sticker, 'attributes'): + alt_text = next((attr.alt for attr in msg.sticker.attributes if isinstance(attr, types.DocumentAttributeSticker)), None) + text_content += f" [Стикер: {alt_text or '?'}]" + elif msg.photo: + text_content += " [Фото]" + elif msg.document and not hasattr(msg.media, "webpage"): + text_content += " [Файл]" + if text_content.strip(): + chat_history_lines.append(f"{sender_name}: {text_content.strip()}") + except Exception as e: + logger.warning(f"Не удалось получить историю для авто-ответа: {e}") + return "\n".join(reversed(chat_history_lines)) + + def _is_memory_enabled(self, chat_id: str) -> bool: return chat_id not in self.memory_disabled_chats + def _disable_memory(self, chat_id: int): self.memory_disabled_chats.add(str(chat_id)) + def _enable_memory(self, chat_id: int): self.memory_disabled_chats.discard(str(chat_id)) diff --git a/SenkoGuardian/SenModules/GiftFinder.py b/SenkoGuardian/SenModules/GiftFinder.py new file mode 100644 index 0000000..403bc76 --- /dev/null +++ b/SenkoGuardian/SenModules/GiftFinder.py @@ -0,0 +1,134 @@ +# This file is part of SenkoGuardianModules +# Copyright (c) 2025 Senko +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +# meta developer: @SenkoGuardianModules + +import asyncio +import random +import re + +from .. import loader, utils +from herokutl.tl.functions.payments import GetSavedStarGiftsRequest +from herokutl.tl.functions.channels import GetFullChannelRequest +from herokutl.tl.types import Message, StarGiftUnique, Channel +from herokutl.errors.rpcerrorlist import DocumentInvalidError, FloodWaitError, ChatAdminRequiredError +from telethon.utils import get_display_name + +@loader.tds +class GiftFinderMod(loader.Module): + """Парсер пользователей с NFT-подарками в чате.""" + strings = { + "name": "GiftFinder", + "not_a_chat": "🚫 Не удалось найти указанный чат.", + "scanning": "
{user_list}" + safe_header = "🔖 " + self.strings("header").split("")[1] + safe_list = [line.replace(self.strings("premium_star"), "⭐️") for line in found_users] + safe_user_list = '\n'.join(safe_list) + response_text_safe = f"{safe_header}\n
{safe_user_list}" + await self._safe_edit(msg, response_text, response_text_safe) + # горе кодер diff --git a/SenkoGuardian/SenModules/LICENSE.md b/SenkoGuardian/SenModules/LICENSE.md new file mode 100644 index 0000000..868ded8 --- /dev/null +++ b/SenkoGuardian/SenModules/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Senko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SenkoGuardian/SenModules/MaillingChatGT99.py b/SenkoGuardian/SenModules/MaillingChatGT99.py new file mode 100644 index 0000000..caaf8c3 --- /dev/null +++ b/SenkoGuardian/SenModules/MaillingChatGT99.py @@ -0,0 +1,705 @@ +# This file is part of SenkoGuardianModules +# Copyright (c) 2025 Senko +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +__version__ = (1, 3, 0) + +# meta developer: @SenkoGuardianModules + +import asyncio +import logging +import random +import re +import io +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple + +from telethon import errors +from telethon.tl import types as tl_types +from telethon.utils import get_display_name, get_peer_id + +from .. import loader, utils + +logger = logging.getLogger(__name__) + +class SpecificWarningFilter(logging.Filter): + def filter(self, record): + if record.name == 'hikkatl.hikkatl.client.users' and \ + 'PersistentTimestampOutdatedError' in record.getMessage() and \ + 'GetChannelDifferenceRequest' in record.getMessage(): + return False + return True + +class ChatTarget: + def __init__(self, raw_input: str, context_message: Optional[tl_types.Message] = None): + self.raw = raw_input + self.context = context_message + self.entity_to_find: any = raw_input + self.topic_id: Optional[int] = None + self._parse() + + def _parse(self): + match = re.match(r"https://t\.me/(?:c/)?([\w\d_.-]+)/(\d+)", self.raw) + if match: + chat_identifier = match.group(1) + if "/c/" in self.raw and chat_identifier.isdigit(): + self.entity_to_find = int(f"-100{chat_identifier}") + else: + self.entity_to_find = chat_identifier + try: + self.topic_id = int(match.group(2)) + except ValueError: + pass + elif self.context: + self.entity_to_find = self.context.chat_id + if getattr(self.context, 'is_topic_message', False): + self.topic_id = getattr(self.context, 'reply_to_top_id', self.context.id) + else: + try: + self.entity_to_find = int(self.raw) + except ValueError: + self.entity_to_find = self.raw + +@loader.tds +class MailChats(loader.Module): + """Модуль для массовой рассылки сообщений по чатам (Поддерживает все типы сообщений)""" + strings = { + "name": "MailChats", + "add_chat": "➕ Добавить текущий чат/тему. Используйте .add_chat или .add_chat
+📋 Инструкция по настройке рассылки: + +Шаг 1: Добавьте чаты для рассылки +• Вручную: Перейдите в нужный чат и напишите+""" + await self._edit_or_reply_and_handle_deletion(message, help_text, delay=240) + + @loader.command() + async def add_chat(self, message): + """➕ Добавить чат. Можно несколько: .add_chat @user1 ссылка ...""" + args = utils.get_args_raw(message) + targets_to_find = [] + if args: + targets_to_find = [ChatTarget(raw) for raw in args.split()] + elif message.chat: + targets_to_find = [ChatTarget(str(message.chat_id), context_message=message)] + else: + await self._edit_or_reply_and_handle_deletion(message, self.strings["invalid_arguments"]); return + status_msg = await self._edit_or_reply_and_handle_deletion( + message, + self.strings["processing_entity"], + delay=0 + ) + tasks = [self._find_chat(target) for target in targets_to_find] + results = await asyncio.gather(*tasks) + added, exists, errors_list = [], [], [] + async with self.lock: + for i, res in enumerate(results): + if res: + if res["key"] in self.chats: + exists.append(f"• {res['name']}") + else: + self.chats[res["key"]] = res["name"] + added.append(f"• {res['name']}") + else: + errors_list.append(f"• {utils.escape_html(targets_to_find[i].raw)}") + if added: + self._save_db_chats() + if len(targets_to_find) > 50: + summary = self.strings["add_chat_summary_title"] + if added: summary += f"✅ Добавлено: {len(added)}\n" + if exists: summary += f"⚠️ Уже существуют: {len(exists)}\n" + if errors_list: summary += f"❌ Ошибки: {len(errors_list)}\n" + final_summary = summary.strip() + else: + summary = "" + if added: summary += self.strings["add_chat_success_header"] + "\n".join(added) + "\n\n" + if exists: summary += self.strings["add_chat_already_exists_header"] + "\n".join(exists) + "\n\n" + if errors_list: summary += self.strings["add_chat_errors_header"] + "\n".join(errors_list) + if not summary.strip(): + final_summary = self.strings["no_valid_chats_provided"] + else: + final_summary = self.strings["add_chat_summary_title"] + summary.strip() + await self._edit_or_reply_and_handle_deletion(status_msg, final_summary) + + @loader.command() + async def remove_chat(self, message): + """🗑️ Удалить чат по номеру.""" + args = utils.get_args_raw(message) + if not args or not args.isdigit(): + await self._edit_or_reply_and_handle_deletion(message, self.strings["invalid_chat_selection"]); return + idx_to_remove = int(args) - 1 + async with self.lock: + sorted_keys = sorted(self.chats.keys(), key=lambda k: (self.chats[k], k[0], k[1] or -1)) + if 0 <= idx_to_remove < len(sorted_keys): + key_to_remove = sorted_keys[idx_to_remove] + removed_name = self.chats.pop(key_to_remove) + self._save_db_chats() + await self._edit_or_reply_and_handle_deletion(message, self.strings["chat_removed"].format(idx_to_remove + 1, removed_name)) + else: + await self._edit_or_reply_and_handle_deletion(message, self.strings["invalid_chat_selection"]) + + @loader.command() + async def clear_chats(self, message): + """🗑️ Очистить список чатов.""" + async with self.lock: + self.chats.clear() + self.db.set(self.strings["name"], "chats", {}) + await self._edit_or_reply_and_handle_deletion(message, self.strings["chats_cleared"]) + + @loader.command() + async def list_chats(self, message): + """📜 Показать список чатов.""" + async with self.lock: + current_chats_copy = dict(self.chats) + if not current_chats_copy: + await self._edit_or_reply_and_handle_deletion(message, self.strings["no_chats"]) + return + output_header = "Список чатов для рассылки:\n\n" + sorted_items = sorted(current_chats_copy.items(), key=lambda item: (item[1], item[0][0], item[0][1] or -1)) + if len(sorted_items) > 50: + file_content = output_header + for i, ((cid, tid), name) in enumerate(sorted_items): + topic_str = f' | Тема: {tid}' if tid is not None else '' + file_content += f"{i+1}. {name} ({cid}{topic_str})\n" + file = io.BytesIO(file_content.encode("utf-8")) + file.name = "Mailing_Chat_List.txt" + await self._edit_or_reply_and_handle_deletion(message, "📝 Список чатов слишком большой, отправляю файлом...", delay=0) + await self.client.send_file(message.chat_id, file, caption=f"✅ Список из {len(sorted_items)} чатов.") + return + output = "" + output_header.strip() + "\n\n" + for i, ((cid, tid), name) in enumerate(sorted_items): + topic_str = f' | Тема:.add_chat. +• По ссылке/ID:.add_chat @username https://t.me/channel/123+ +✨ Бэкап и восстановление списка: +•.dump_chats— Бэкап. Модуль выгрузит в файл только те чаты, что уже есть в списке рассылки. +•.load_chats— Загрузка. Ответьте этой командой на полученный файл, чтобы добавить чаты в рассылку. + +Шаг 2: Добавьте сообщения +• Ответьте на любое сообщение (текст, фото, видео) командой.add_msg. +• Можно добавить несколько сообщений для рассылки. + +Шаг 3: Проверьте списки +•.list_chats— посмотреть список чатов. Если их больше 50, отправит файлом. +•.list_msgs— посмотреть список сообщений. + +Шаг 4: Тонкая настройка (по желанию) +Откройте конфиг командой.cfg MailChats. Вот что значат основные параметры: + +-- Режимы работы -- +•safe_mode: Безопасный режим. Если включить, рассылка будет идти медленнее и только в группы/каналы, чтобы снизить риск спам-блока. +•randomize_messages: Случайные сообщения. Если включить, в каждый чат будет отправляться только ОДНО случайное сообщение из вашего списка. Если выключить — отправляются ВСЕ по порядку. + +-- Настройка пауз (формат:min,maxсекунд) -- +•chats_interval: Пауза между отправкой в разные чаты (обычный режим). Пример:2,5. +•message_interval: Пауза между отправкой нескольких сообщений в ОДИН чат (обычный режим). +•safe_chats_interval: Пауза между чатами в безопасном режиме (больше для безопасности). +•safe_message_interval: Пауза между сообщениями в безопасном режиме. +•safe_cycle_interval: Пауза между кругами рассылки в безопасном режиме (например180,300= 3-5 минут). + +-- Прочее -- +•delete_replies_delay: Через сколько секунд удалять ответы модуля (например, "✅ Чат добавлен"). Поставьте0, чтобы не удалять. +•max_chats_safe: Сколько максимум чатов обрабатывать за один круг в безопасном режиме. + +Шаг 5: Запустите рассылку +• Используйте команду.start_mail <время> <пауза>+• Пример:.start_mail 3600 180-300+ (Это запустит рассылку на 1 час (3600 сек) с паузой между кругами от 3 до 5 минут). + +Другие команды: +•.stop_mail— остановить рассылку. +•.mail_status— проверить, сколько времени осталось. +•.remove_chat <номер>— удалить чат из списка. +•.remove_msg <номер>— удалить сообщение. +•.clear_chats/.clear_msgs- полная очистка списков. +
{tid}' if tid is not None else ''
+ output += f"{i+1}. {utils.escape_html(name)} ({cid}{topic_str})\n"
+ await self._edit_or_reply_and_handle_deletion(message, output, delay=60)
+
+ @loader.command()
+ async def add_msg(self, message):
+ """➕ Добавить сообщение (ответом)."""
+ reply = await message.get_reply_message()
+ if not reply:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["no_messages"].split(". ")[0] + "."); return
+ if reply.text: snippet_text = reply.text.replace("\n", " ")
+ elif reply.photo: snippet_text = "[Фото]"
+ elif reply.video: snippet_text = "[Видео]"
+ elif reply.sticker:
+ alt = next((attr.alt for attr in reply.sticker.attributes if isinstance(attr, tl_types.DocumentAttributeSticker)), "?")
+ snippet_text = f"[Стикер: {alt}]"
+ else: snippet_text = "[Медиа/Файл]"
+ snippet = snippet_text[:100] + "..." if len(snippet_text) > 100 else snippet_text
+ async with self.lock:
+ self.messages.append({"id": reply.id, "chat_id": get_peer_id(reply.peer_id), "snippet": snippet})
+ self.db.set(self.strings["name"], "messages", self.messages)
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["message_added"].format(utils.escape_html(snippet)))
+
+ @loader.command()
+ async def remove_msg(self, message):
+ """➖ Удалить сообщение по номеру."""
+ args = utils.get_args_raw(message)
+ if not args or not args.isdigit():
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["invalid_message_number"]); return
+ idx = int(args) - 1
+ async with self.lock:
+ if 0 <= idx < len(self.messages):
+ removed = self.messages.pop(idx)
+ self.db.set(self.strings["name"], "messages", self.messages)
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["message_removed"].format(idx + 1, utils.escape_html(removed["snippet"])))
+ else:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["invalid_message_number"])
+
+ @loader.command()
+ async def clear_msgs(self, message):
+ """🗑️ Очистить список сообщений."""
+ async with self.lock:
+ self.messages.clear()
+ self.db.set(self.strings["name"], "messages", [])
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["messages_cleared"])
+
+ @loader.command()
+ async def list_msgs(self, message):
+ """📜 Показать список сообщений."""
+ if not self.messages:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["no_messages"]); return
+ text = "Список сообщений для рассылки:\n\n"
+ for i, msg in enumerate(self.messages):
+ text += f"{i + 1}. {utils.escape_html(msg['snippet'])}\n"
+ await self._edit_or_reply_and_handle_deletion(message, text, delay=60)
+
+ @loader.command()
+ async def set_seller(self, message):
+ """⚙️ Установить ID для уведомлений."""
+ args = utils.get_args_raw(message).strip()
+ if not args:
+ await self._edit_or_reply_and_handle_deletion(message, "✍️ Укажите ID чата, username, ссылку или 'me'."); return
+ identifier = self.client.tg_id if args.lower() == 'me' else args
+ try:
+ entity = await self.client.get_entity(identifier)
+ seller_id = get_peer_id(entity)
+ async with self.lock:
+ self.seller_chat_id = seller_id
+ self.db.set(self.strings["name"], "seller_chat_id", seller_id)
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["seller_set"] + f": {get_display_name(entity)} ({seller_id})")
+ except Exception as e:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["error_getting_entity"].format(e))
+
+ @loader.command()
+ async def mail_status(self, message):
+ """📊 Показать статус рассылки."""
+ async with self.lock:
+ if not self.is_running:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["not_running"]); return
+ now = datetime.now()
+ elapsed = now - self.start_time
+ remaining = self.end_time - now
+ status = (
+ f"📊 Статус рассылки: Активна ✅\n"
+ f"⏳ Прошло: {str(elapsed).split('.')[0]}\n"
+ f"⏱️ Осталось: {str(remaining).split('.')[0] if remaining.total_seconds() > 0 else '0:00:00'}\n"
+ f"✉️ Отправлено сообщений: {self.total_messages_sent}\n"
+ f"🔄 Цикл: {self._processed_chats_in_cycle} чатов обработано"
+ )
+ await self._edit_or_reply_and_handle_deletion(message, status, delay=30)
+
+ @loader.command()
+ async def start_mail(self, message):
+ """🚀 Запустить рассылку."""
+ args = utils.get_args(message)
+ if len(args) != 2:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["duration_invalid"]); return
+ try:
+ duration = int(args[0])
+ min_interval, max_interval = map(float, args[1].replace(",", ".").split("-"))
+ if not (duration > 0 and 0 <= min_interval <= max_interval): raise ValueError
+ cycle_interval = (min_interval, max_interval)
+ except Exception:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["duration_invalid"]); return
+ async with self.lock:
+ if self.is_running:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["already_running"]); return
+ if not self.chats:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["chats_empty"]); return
+ if not self.messages:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["messages_empty"]); return
+ self.is_running = True
+ self.total_messages_sent = 0
+ self.start_time = datetime.now()
+ self.end_time = self.start_time + timedelta(seconds=duration)
+ self._current_cycle_start_time = None
+ self._processed_chats_in_cycle = 0
+ self.mail_task = self.client.loop.create_task(self._mail_loop(duration, cycle_interval, message))
+ await self._edit_or_reply_and_handle_deletion(message, f"✅ Рассылка запущена на {duration} секунд.")
+
+ @loader.command()
+ async def stop_mail(self, message):
+ """⏹️ Остановить рассылку."""
+ async with self.lock:
+ if not self.is_running:
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["not_running"]); return
+ self.is_running = False
+ if self.mail_task:
+ self.mail_task.cancel()
+ await self._edit_or_reply_and_handle_deletion(message, self.strings["stopped_mailing"])
+ def _validate_interval_tuple(self, value, default_tuple: Tuple[float, float]) -> Tuple[float, float]:
+ try:
+ v_min, v_max = map(float, str(value).replace("-",",").split(','))
+ if 0 <= v_min <= v_max: return (v_min, v_max)
+ except Exception:
+ pass
+ return default_tuple
+
+ async def _is_safe_chat(self, entity: tl_types.TypePeer) -> bool:
+ return isinstance(entity, (tl_types.Chat, tl_types.Channel)) and get_peer_id(entity) < -1000000000
+
+ async def _send_to_chat(self, target_chat_id: int, msg_info: dict, target_topic_id: Optional[int]) -> Tuple[bool, str]:
+ try:
+ original_msg = await self.client.get_messages(msg_info["chat_id"], ids=msg_info["id"])
+ if not original_msg:
+ return False, "Original message not found"
+ for attempt in range(3):
+ try:
+ await self.client.send_message(entity=target_chat_id, message=original_msg, reply_to=target_topic_id)
+ async with self.lock:
+ self.total_messages_sent += 1
+ return True, "OK" # :/
+ except errors.FloodWaitError as e:
+ if attempt == 2: return False, f"FloodWait ({e.seconds}s)"
+ await asyncio.sleep(e.seconds + random.uniform(1, 3))
+ except errors.SlowModeWaitError as e:
+ await asyncio.sleep(e.seconds + random.uniform(0.2, 0.5))
+ except Exception as e:
+ if type(e).__name__ in self.PERMISSION_ERRORS:
+ return False, type(e).__name__
+ if attempt == 2: return False, str(e)
+ await asyncio.sleep(random.uniform(2, 5))
+ return False, "Max retries"
+ except Exception as e:
+ return False, f"Get message error: {e}"
+
+ async def _mail_loop(self, duration_seconds: int, cycle_interval_seconds_range: Tuple[float, float], initial_command_message_event):
+ """Оригинальный, надежный цикл рассылки"""
+ end_time_loop = self.start_time + timedelta(seconds=duration_seconds)
+ final_status_for_user = self.strings["mailing_complete"]
+ try:
+ while self.is_running and datetime.now() < end_time_loop:
+ self._current_cycle_start_time = datetime.now()
+ self._processed_chats_in_cycle = 0
+ async with self.lock:
+ current_chats = list(self.chats.keys())
+ current_messages_list = list(self.messages)
+ is_safe_mode = self.config["safe_mode"]
+ randomize_messages_cfg = self.config["randomize_messages"]
+ max_c_per_cycle = self.config["max_chats_safe"]
+ chats_interval_key = "safe_chats_interval" if is_safe_mode else "chats_interval"
+ short_interval = self._validate_interval_tuple(self.config[chats_interval_key], (10, 20) if is_safe_mode else (2, 5))
+ message_interval_key = "safe_message_interval" if is_safe_mode else "message_interval"
+ message_interval_val = self._validate_interval_tuple(self.config[message_interval_key], (5, 10) if is_safe_mode else (1, 3))
+ if not current_chats or not current_messages_list:
+ final_status_for_user = "Рассылка остановлена: список чатов или сообщений пуст."
+ break
+ random.shuffle(current_chats)
+ chats_for_this_cycle = current_chats[:min(max_c_per_cycle if is_safe_mode else len(current_chats), len(current_chats))]
+ for i, (chat_id_target, topic_id_target) in enumerate(chats_for_this_cycle):
+ if not self.is_running or datetime.now() >= end_time_loop: break
+ messages_to_send_now = [random.choice(current_messages_list)] if randomize_messages_cfg else current_messages_list
+ for message_detail in messages_to_send_now:
+ if not self.is_running or datetime.now() >= end_time_loop: break
+ success_send, reason_send = await self._send_to_chat(chat_id_target, message_detail, topic_id_target)
+ if not success_send:
+ if reason_send in self.PERMISSION_ERRORS:
+ logger.warning(f"Permission issue in {chat_id_target}, skipping chat.")
+ else:
+ logger.warning(f"Failed to send to {chat_id_target}: {reason_send}")
+ break
+ if len(messages_to_send_now) > 1:
+ await asyncio.sleep(random.uniform(*message_interval_val))
+ self._processed_chats_in_cycle += 1
+ if i < len(chats_for_this_cycle) - 1:
+ await asyncio.sleep(random.uniform(*short_interval))
+ if not self.is_running or datetime.now() >= end_time_loop: break
+ await asyncio.sleep(random.uniform(*cycle_interval_seconds_range))
+ except asyncio.CancelledError:
+ final_status_for_user = self.strings["stopped_mailing"]
+ except Exception as e_loop:
+ logger.exception("Критическая ошибка в цикле рассылки:")
+ final_status_for_user = f"❌ Критическая ошибка: {type(e_loop).__name__}"
+ finally:
+ final_report = f"{final_status_for_user} (Отправлено: {self.total_messages_sent})"
+ await self.client.send_message(initial_command_message_event.chat_id, final_report)
+ if self.seller_chat_id:
+ await self.client.send_message(self.seller_chat_id, f"🔔 Уведомление: {final_report}")
+ async with self.lock:
+ self.is_running = False
+ self.mail_task = None
+
+ @loader.command()
+ async def dump_chats(self, message):
+ """📤 Выгрузить список чатов рассылки в .txt файл (для бэкапа)."""
+ status_msg = await self._edit_or_reply_and_handle_deletion(message, "⏳ Экспорт списка рассылки...", delay=0)
+ async with self.lock:
+ if not self.chats:
+ await self._edit_or_reply_and_handle_deletion(status_msg, "⚠️ Список чатов для рассылки пуст.")
+ return
+ export_list = []
+ for (cid, tid), name in self.chats.items():
+ if tid is not None and cid < -1000000000:
+ chat_id_for_link = str(cid)[4:]
+ export_list.append(f"https://t.me/c/{chat_id_for_link}/{tid}")
+ else:
+ export_list.append(str(cid))
+ file_content = "\n".join(export_list)
+ file = io.BytesIO(file_content.encode("utf-8"))
+ file.name = "mailing_list_backup.txt"
+ await self.client.send_file(
+ message.chat_id,
+ file,
+ caption=f"✅ Экспортировано {len(export_list)} чатов из списка рассылки.\n\nИспользуйте .load_chats в ответе на этот файл, чтобы импортировать их.")
+ await self._edit_or_reply_and_handle_deletion(status_msg, "✅ Экспорт завершен!")
+
+ @loader.command()
+ async def load_chats(self, message):
+ """📤 Загрузить чаты в рассылку из .txt файла (ответом на файл)."""
+ reply = await message.get_reply_message()
+ if not reply or not reply.document:
+ await self._edit_or_reply_and_handle_deletion(message, "✍️ Ответьте на .txt файл с ID чатов.")
+ return
+ if reply.document.mime_type != 'text/plain':
+ await self._edit_or_reply_and_handle_deletion(message, "⚠️ Файл должен быть в формате .txt")
+ return
+ status_msg = await self._edit_or_reply_and_handle_deletion(message, "⏳ Начинаю загрузку чатов из файла...", delay=0)
+ content = await reply.download_media(bytes)
+ chat_identifiers = content.decode("utf-8").splitlines()
+ chat_identifiers = [line.strip() for line in chat_identifiers if line.strip()]
+ if not chat_identifiers:
+ await self._edit_or_reply_and_handle_deletion(status_msg, "⚠️ Файл пуст или не содержит идентификаторов чатов.")
+ return
+ added, exists, errors_list = [], [], []
+ for i, identifier in enumerate(chat_identifiers):
+ if i > 0 and i % 20 == 0:
+ await self._edit_or_reply_and_handle_deletion(status_msg, f"⏳ Обработано {i}/{len(chat_identifiers)}...", delay=0)
+ res = await self._find_chat(ChatTarget(identifier))
+ if res:
+ if res["key"] not in self.chats:
+ self.chats[res["key"]] = res["name"]
+ added.append(res["name"])
+ else:
+ exists.append(res["name"])
+ else:
+ errors_list.append(identifier)
+ if added:
+ self._save_db_chats()
+ summary = f"✅ Загрузка завершена!\n\n"
+ if added: summary += f"Добавлено новых чатов: {len(added)}\n"
+ if exists: summary += f"Уже были в списке: {len(exists)}\n"
+ if errors_list: summary += f"Не удалось найти: {len(errors_list)}\n"
+ await self._edit_or_reply_and_handle_deletion(status_msg, summary)
diff --git a/SenkoGuardian/SenModules/NekoEditorMod.py b/SenkoGuardian/SenModules/NekoEditorMod.py
new file mode 100644
index 0000000..fd3cc32
--- /dev/null
+++ b/SenkoGuardian/SenModules/NekoEditorMod.py
@@ -0,0 +1,81 @@
+# This file is part of SenkoGuardianModules
+# Copyright (c) 2025 Senko
+# This software is released under the MIT License.
+# https://opensource.org/licenses/MIT
+
+# meta developer: @SenkoGuardianModules
+
+from hikkatl.types import Message
+from .. import loader, utils
+import random
+
+@loader.tds
+class NekoEditorMod(loader.Module):
+ """Neko-редактор сообщений | Владелецы: @SstAngelStar × @ilovesenko """
+ strings = {
+ "name": "NekoEditor",
+ }
+
+ def __init__(self):
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue(
+ "enabled",
+ False,
+ "Автоматическое редактирование",
+ validator=loader.validators.Boolean()
+ )
+ )
+
+ async def nekoedcmd(self, message: Message):
+ """Управление Neko-режимом | .nekoed [on/off]"""
+ args = utils.get_args_raw(message)
+ me = await message.client.get_me()
+ is_premium = getattr(me, 'premium', False)
+ if not args:
+ status = "включён" if self.config["enabled"] else "выключен"
+ return await utils.answer(message, f"🐱 NekoEditor: {status}")
+ if args.lower() in ["on", "вкл", "1"]:
+ self.config["enabled"] = True
+ if is_premium:
+ await utils.answer(message, '{self.get_prefix()}config HaremManager)",
+ reply_markup=self._main_markup()
+ )
+
+ async def callback_handler(self, call, data):
+ if data == "back":
+ await self._set_menu(call)
+ return
+ elif data.startswith("restart-"):
+ bot = data.split("-")[-1]
+ await call.answer(f"Перезапуск бонуса для {self.harems[bot]}...")
+ await self._autobonus(self.harems[bot], bot)
+ return
+ elif data.startswith("ab-"):
+ bot = data.split("-")[-1]
+ self.set(data, not self.get(data, None))
+ await utils.answer(call, f"Меню {self.harems[bot]}", reply_markup=self._menu_markup(bot))
+ elif data.startswith("catch-"):
+ bot = data.split("-")[-1]
+ self.set(data, not self.get(data, None))
+ await utils.answer(call, f"Меню {self.harems[bot]}", reply_markup=self._menu_markup(bot))
+ elif data.startswith("out-"):
+ bot = data.split("-")[-1]
+ self.set(data, not self.get(data, None))
+ await utils.answer(call, f"Меню {self.harems[bot]}", reply_markup=self._menu_markup(bot))
+ else:
+ bot = data
+ await utils.answer(call, f"Меню {self.harems[bot]}", reply_markup=self._menu_markup(bot))
+
+ async def _autobonus(self, id, bot):
+ wait_boost = False
+ async with self._client.conversation(id) as conv:
+ try:
+ await conv.send_message("/bonus")
+ except YouBlockedUserError:
+ await self.client(UnblockRequest(id))
+ await conv.send_message("/bonus")
+ r = None
+ try:
+ r = await conv.get_response(timeout=5*60)
+ except:
+ tryings = 5
+ while tryings > 0:
+ tryings -= 1
+ try:
+ await conv.send_message("/bonus")
+ r = await conv.get_response(5*60)
+ break
+ except:
+ pass
+ if r is None:
+ logger.warning("Ответ от бота не получен. Вероятно, он снова лёг\n\nПерезапустите автобонус, когда бот очнётся")
+ self.set(f"ab-{bot}", False)
+ return
+ self.set(f"ab-t-{bot}", int(time.time()))
+ if "Доступен бонус за подписки" in r.text:
+ await conv.send_message("/start ad_bonus")
+ r = await conv.get_response()
+ if "проверка пройдена" not in r.text:
+ to_leave, to_block, folders, chats_in_folders = [], [], [], []
+ wait_boost = False
+ if r.reply_markup:
+ a = r.buttons
+ for i in a:
+ for button in i: # каждая кнопка...
+ if button.url:
+ alr = False # "уже зашёл"
+ if "addlist/" in button.url: # добавление папок
+ slug = button.url.split("addlist/")[-1]
+ peers = await self.client(CheckChatlistInviteRequest(slug=slug))
+ if peers:
+ peers = peers.peers
+ try:
+ a = await self.client(JoinChatlistInviteRequest(slug=slug, peers=peers))
+ chats_in_folders.append(peers) # для выхода
+ for update in a.updates:
+ if isinstance(update, UpdateDialogFilter):
+ folders.append(InputChatlistDialogFilter(filter_id=update.id)) # для удаления папки
+ except: pass
+ continue
+ if "t.me/boost" in button.url: # бустить не обязательно
+ wait_boost = True
+ continue
+ if not bool(re.match(r"^https?:\/\/t\.me\/[^\/]+\/?$", button.url)): # дополнительные вложения отметаем
+ continue
+ if "t.me/+" in button.url: # приватные чаты
+ try:
+ a = await self.client(CheckChatInviteRequest(button.url.split("+")[-1]))
+ if not hasattr(a, "request_needed") or not a.request_needed: # получить айди приватного чата/канала с приглашениями без входа невозможно
+ pass
+ else:
+ url = button.url.split("?")[0] if "?" in button.url else button.url
+ try:
+ await self.client(ImportChatInviteRequest(button.url.split("+")[-1]))
+ except InviteRequestSentError: pass
+ await asyncio.sleep(3)
+ try:
+ entity = await self.client.get_entity(url)
+ except ValueError:
+ try:
+ await asyncio.sleep(15)
+ entity = await self.client.get_entity(url)
+ except:
+ continue
+ except:
+ pass
+ alr = True
+ except: continue
+ url = button.url.split("?")[0] if "?" in button.url else button.url
+ if not alr:
+ try:
+ entity = await self.client.get_entity(url)
+ except:
+ entity = (await self.client(ImportChatInviteRequest(button.url.split("+")[-1]))).chats[0] #gotten class Updates
+ alr = True
+ if hasattr(entity, "broadcast"):
+ if not alr:
+ await self.client(JoinChannelRequest(button.url))
+ to_leave.append(entity.id)
+ else:
+ to_leave.append(entity.chat.id) if hasattr(entity,"chat") else to_leave.append(entity.id) if hasattr(entity,"id") else None
+ elif hasattr(entity, "bot"):
+ username = entity.username if entity.username is not None else entity.usernames[0].username
+ try:
+ await self.client(UnblockRequest(username))
+ except: print("блин")
+ await self.client.send_message(entity, "/start")
+ to_block.append(username)
+ flyer_messages = await self.client.get_messages(id, limit=1)
+ if wait_boost:
+ await asyncio.sleep(150)
+ for m in flyer_messages:
+ await asyncio.sleep(5)
+ await m.click(-1)
+ await asyncio.sleep(5)
+ for folder, chats in zip(folders, chats_in_folders):
+ await self.client(LeaveChatlistRequest(peers=chats, chatlist=folder))
+ for bot in to_block:
+ await self.client(BlockRequest(bot))
+ await self.client.delete_dialog(bot)
+ for channel in to_leave:
+ try:
+ await self.client(LeaveChannelRequest(channel))
+ except Exception as e:
+ pass
+ count = 0
+ if not self.get(f"last_lout-{bot}") or int(time.time()) - self.get(f"last_lout-{bot}") > 43200:
+ while count <= 3: # на всякий случай 4 попытки. Бот может забагаться и не выдать завершающий ответ
+ await conv.send_message("/lout")
+ r = await conv.get_response()
+ if r.reply_markup:
+ pattern = self._parse(r)
+ clicks = self._solution(pattern)
+ for i in range(len(clicks)):
+ if clicks[i] == 1:
+ await r.click(i)
+ self.set(f"last_lout-{bot}", int(time.time()))
+ count += 1
+ else:
+ break
+
+ def _parse(self, r):
+ a = r.buttons
+ pattern = []
+ for i in a:
+ for m in i:
+ t = m.text
+ if t == "🌚":
+ pattern.append(0)
+ elif t == "🌞":
+ pattern.append(1)
+ else:
+ pass
+ return pattern
+
+ def _solution(self, pole):
+ n = len(pole)
+ for num in range(2**n):
+ binary_string = bin(num)[2:].zfill(n)
+ presses = [int(char) for char in binary_string]
+ temp = pole[:]
+
+ for i in range(n):
+ if presses[i]:
+ temp[i] ^= 1
+ if i % 3 > 0: temp[i - 1] ^= 1
+ if i % 3 < 2: temp[i + 1] ^= 1
+ if i >= 3: temp[i - 3] ^= 1
+ if i < 6: temp[i + 3] ^= 1
+
+ if sum(temp) == 0:
+ return presses
+
+ return None
+
+ @loader.command()
+ async def Harems(self, message):
+ """Открыть меню управления"""
+ await self._set_menu(message)
+
+ @loader.command()
+ async def lightsout(self, message):
+ """[ответ на соо с полем] Автоматически решает Lights Out"""
+ if message.is_reply:
+ r = await message.get_reply_message()
+ if r.reply_markup:
+ pattern = self._parse(r)
+ else:
+ await utils.answer(message, "