From cea0d40a588b8cae57afd5b3461e5e9fa3f335a2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" (Это может занять до 5 минут, в зависимости от длины и качества видео.",
"default_error": "❌ Ошибка!\n\n{}",
"default_response": "🎥 Вот [ваше видео]({link})!\n\n{title}",
- }
+ "default_channel": "📺 Канал: {channel}",
+ "cookies_error": "🍪 YouTube требует аутентификацию!\n\n❌ Ошибка: Sign in to confirm you're not a bot\n\nВозможные причины:\n▫️ YouTube детектит запросы без аутентификации\n▫️ IP сервера может быть заблокирован YouTube\n▫️ Видео требует подтверждения возраста/входа\n\nРешения (попробуй по порядку):\n\n1️⃣ Смени YouTube клиент:\n• Открой .cfg YouTube-DLD\n• Попробуй разные значения youtube_client:\n - mweb (мобильная веб-версия, часто работает)\n - android (Android приложение, сработало на сервере во Франции)\n - ios (iOS приложение)\n - tv_embedded (встроенный ТВ плеер)\n\n2️⃣ Добавь куки (для пользователей из проблемных регионов):\n• Открой НОВОЕ приватное окно (в браузере ctrl+shift+N) и залогинься на YouTube\n• Перейди на https://www.youtube.com/robots.txt в ТОЙ же вкладке\n• Cookie-Editor (расширение) → Export → Netscape format\n• СРАЗУ закрой приватное окно\n• Вставь куки в youtube_cookies (БЕЗ кавычек)",
+ "supported_sites": """🎥 Поддерживаемые сайты:
+🔴 YouTube — youtube.com, youtu.be
+🎵 TikTok — tiktok.com, vt.tiktok.com, vm.tiktok.com
+📸 Instagram — instagram.com (посты, reels, IGTV)
+🐦 X (Twitter) — x.com, twitter.com
+👥 Facebook — facebook.com (видео)
+🎬 Vimeo — vimeo.com
+📺 Twitch — twitch.tv (стримы, клипы, VOD)
+🤖 Reddit — reddit.com (видео из постов)
+⚡ Dailymotion — dailymotion.com
+
+🇷🇺 Российские:
+▫️ RuTube — rutube.ru
+▫️ ВКонтакте — vk.com (видео)
+▫️ Одноклассники — ok.ru
+
+📚 Образовательные:
+▫️ Coursera — coursera.org
+▫️ Udemy — udemy.com
+▫️ Khan Academy — khanacademy.org
+
+🌍 Международные:
+▫️ Bilibili — bilibili.com
+▫️ NicoNico — niconico.jp
+▫️ BBC iPlayer — bbc.co.uk/iplayer
+
+🎵 Аудио:
+▫️ SoundCloud — soundcloud.com
+▫️ Bandcamp — bandcamp.com
+▫️ Mixcloud — mixcloud.com
+
+И многие другие платформы..."""
+ }
def __init__(self):
self.config = loader.ModuleConfig(
loader.ConfigValue(
@@ -75,32 +214,84 @@ class YouTube_DLDMod(loader.Module):
self.strings["default_response"],
"Ответ после загрузки. (используй {link} для ссылки и {title} для названия видео)"
),
+ loader.ConfigValue(
+ "show_channel",
+ True,
+ "Показывать название канала?",
+ validator=loader.validators.Boolean(),
+ ),
+ loader.ConfigValue(
+ "channel_text",
+ self.strings["default_channel"],
+ "Текст для отображения канала. (используй {channel} для имени канала)"
+ ),
+ loader.ConfigValue(
+ "youtube_cookies",
+ "",
+ "🍪 Куки YouTube в формате Netscape (опционально)\n\n"
+ "⚠️ ВНИМАНИЕ: Риск бана аккаунта! Используй тестовый аккаунт.\n\n"
+ "Как получить:\n"
+ "1. НОВОЕ приватное окно (Ctrl+Shift+N) → залогинься на YouTube\n"
+ "2. Перейди на https://www.youtube.com/robots.txt в той же вкладке\n"
+ "3. Cookie-Editor (расширение) → Export → Netscape format\n"
+ "4. СРАЗУ закрой приватное окно\n"
+ "5. Вставь текст сюда (БЕЗ кавычек)",
+ validator=loader.validators.String(),
+ ),
+ loader.ConfigValue(
+ "youtube_client",
+ "mweb",
+ "📱 YouTube клиент для обхода блокировок\n\n"
+ "Доступные варианты:\n"
+ "• default - стандартный (может не работать)\n"
+ "• mweb - мобильная веб-версия\n"
+ "• android - Android приложение (рекомендуется)\n"
+ "• ios - iOS приложение\n"
+ "• tv_embedded - встроенный ТВ плеер\n\n"
+ "Если видео не скачивается, попробуй другой клиент!",
+ validator=loader.validators.Choice(["default", "mweb", "android", "ios", "tv_embedded"]),
+ ),
+ loader.ConfigValue(
+ "custom_user_agent",
+ "",
+ "🌐 Кастомный User-Agent (опционально)\n\n"
+ "Можно указать User-Agent браузера для обхода некоторых блокировок.\n"
+ "Например:\n"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\n\n"
+ "Оставь пустым для использования стандартного.",
+ validator=loader.validators.String(),
+ ),
)
@loader.command()
- async def dlvideo(self, message):
- """<ссылка> или ответ на сообщение со ссылкой — скачивает видео с YouTube"""
+ async def dvlist(self, message):
+ """Показать список всех поддерживаемых сайтов"""
+ await utils.answer(message, self.strings["supported_sites"])
+ @loader.command()
+ async def dlvideo(self, message):
+ """<ссылка> или ответ на сообщение со ссылкой — скачивает видео с поддерживаемых платформ"""
args = utils.get_args_raw(message)
reply = await message.get_reply_message()
-
- link = extract_youtube_link(args) if args else None
+ link = extract_video_link(args) if args else None
if not link and reply:
- link = extract_youtube_link(reply.raw_text)
-
+ link = extract_video_link(reply.raw_text)
if not link:
await utils.answer(message, self.strings["no_link"])
return
-
await utils.answer(message, self.config["downloading_text"])
-
try:
- video, title = await download_video(link)
-
+ cookies_text = self.config["youtube_cookies"].strip() if self.config["youtube_cookies"] else None
+ youtube_client = self.config["youtube_client"]
+ user_agent = self.config["custom_user_agent"].strip() if self.config["custom_user_agent"] else None
+ video, title, channel = await download_video(link, cookies_text, youtube_client, user_agent)
if self.config["show_link"]:
caption_template = self.config["response_text"]
caption = convert_markdown_to_html(caption_template, link)
caption = caption.replace("{title}", title or "")
+ if self.config["show_channel"] and channel:
+ channel_text = self.config["channel_text"].replace("{channel}", channel)
+ caption += f"\n\n{channel_text}"
else:
caption = title or "Готово!"
@@ -108,9 +299,10 @@ class YouTube_DLDMod(loader.Module):
message,
video,
caption=caption,
- parse_mode="HTML"
+ parse_mode="HTML",
+ reply_to=reply or message,
+ silent=True
)
-
try:
await message.delete()
except:
@@ -120,9 +312,14 @@ class YouTube_DLDMod(loader.Module):
except:
pass
except Exception as e:
- error_msg = self.config["error_text"].format(e)
- await utils.answer(message, error_msg)
+ error_str = str(e)
+ if "Sign in to confirm you're not a bot" in error_str or "Use --cookies" in error_str:
+ await utils.answer(message, self.strings["cookies_error"])
+ else:
+ error_msg = self.config["error_text"].format(e)
+ await utils.answer(message, error_msg)
try:
- os.remove(video)
+ if 'video' in locals():
+ os.remove(video)
except:
pass
diff --git a/SenkoGuardian/SenModules/Gemini.py b/SenkoGuardian/SenModules/Gemini.py
index 6901a26..4fa283a 100644
--- a/SenkoGuardian/SenModules/Gemini.py
+++ b/SenkoGuardian/SenModules/Gemini.py
@@ -3,9 +3,9 @@
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT
-__version__ = (5, 6, 0) # Емае, ну это уже слишком
+__version__ = (5, 7, 0) #перепешите на меня квартиру пж
-#фырфырфырфырфырфыр
+#ладно
# meta developer: @SenkoGuardianModules
@@ -23,23 +23,24 @@ import random
import socket
import asyncio
import logging
-import aiohttp
import tempfile
+import httpx
+from datetime import datetime
from markdown_it import MarkdownIt
import pytz
-# New SDK
-from google import genai as google_genai_sdk
+
+# New SDK Check
try:
- from google.genai import types as new_types
- NEW_SDK_AVAILABLE = True
+ from google import genai
+ from google.genai import types
+ import google.api_core.exceptions as google_exceptions
+ GOOGLE_AVAILABLE = True
except ImportError:
- NEW_SDK_AVAILABLE = False
-# Old SDK
-import google.ai.generativelanguage as glm
-import google.generativeai as old_genai
-from google.generativeai import types as old_types
-from telethon import types
-from telethon.tl.types import Message, DocumentAttributeFilename
+ GOOGLE_AVAILABLE = False
+ google_exceptions = None
+
+from telethon import types as tg_types
+from telethon.tl.types import Message, DocumentAttributeFilename, DocumentAttributeSticker
from telethon.utils import get_display_name, get_peer_id
from telethon.errors.rpcerrorlist import (
MessageTooLongError,
@@ -48,12 +49,6 @@ from telethon.errors.rpcerrorlist import (
ChannelPrivateError
)
-try:
- import google.api_core.exceptions as google_exceptions
- GOOGLE_AVAILABLE = True
-except ImportError:
- GOOGLE_AVAILABLE = False
-
from .. import loader, utils
from ..inline.types import InlineCall
@@ -65,19 +60,10 @@ DB_IMPERSONATION_KEY = "gemini_impersonation_chats"
GEMINI_TIMEOUT = 840
MAX_FFMPEG_SIZE = 90 * 1024 * 1024
-# requires: google-generativeai google-genai google-api-core pytz markdown_it_py
-# рекомендованные версии: google-generativeai==0.8.5 google-genai==1.52.0 google-api-core==2.28.1
-
-logger = logging.getLogger(__name__)
-
-DB_HISTORY_KEY = "gemini_conversations_v4"
-DB_GAUTO_HISTORY_KEY = "gemini_gauto_conversations_v1"
-DB_IMPERSONATION_KEY = "gemini_impersonation_chats"
-GEMINI_TIMEOUT = 840
-MAX_FFMPEG_SIZE = 90 * 1024 * 1024
+# requires: google-genai google-api-core pytz markdown_it_py
class Gemini(loader.Module):
- """Модуль для работы с Google Gemini AI.(стабильная память и поддержка video/image/audio)"""
+ """Модуль для работы с Google Gemini AI (New SDK). Поддержка видео/фото/аудио и контекста пользователей."""
strings = {
"name": "Gemini",
"cfg_api_key_doc": "API ключи Google Gemini, разделенные запятой. Будут скрыты.",
@@ -147,7 +133,7 @@ class Gemini(loader.Module):
"gmodel_img_warn": "⚠️ Текущая модель ({}) не может генерировать изображения(или не доступна по API).\nРекомендуем: gemini-2.5-flash-image",
"gme_chat_not_found": "🚫 Не удалось найти чат для экспорта: {}",
"gme_sent_to_saved": "💾 История экспортирована в избранное.",
- "new_sdk_missing": "⚠️ Для работы поиска (Grounding) нужна библиотека google-genai.\nВыполните: pip install google-genai",
+ "new_sdk_missing": "⚠️ Для работы модуля нужна библиотека google-genai.\nВыполните: pip install google-genai",
"gprompt_usage": "ℹ️ Использование:\n.gprompt <текст> — установить промпт.\n.gprompt -c — очистить.\nИли ответьте на .txt файл.",
"gprompt_updated": "✅ Системный промпт обновлен!\nДлина: {} симв.",
"gprompt_cleared": "🗑 Системный промпт очищен.",
@@ -155,6 +141,8 @@ class Gemini(loader.Module):
"gprompt_file_error": "❗️ Ошибка чтения файла: {}",
"gprompt_file_too_big": "❗️ Файл слишком большой (лимит 1 МБ).",
"gprompt_not_text": "❗️ Это не похоже на текстовый файл.(txt)",
+ "gmodel_no_models": "⚠️ Не удалось получить список моделей.",
+ "gmodel_list_error": "❗️ Ошибка получения списка: {}",
}
TEXT_MIME_TYPES = {
"text/plain", "text/markdown", "text/html", "text/css", "text/csv",
@@ -163,10 +151,7 @@ class Gemini(loader.Module):
}
def __init__(self):
self.config = loader.ModuleConfig(
- loader.ConfigValue(
- "api_key", "", self.strings["cfg_api_key_doc"],
- validator=loader.validators.Hidden()
- ),
+ loader.ConfigValue("api_key", "", self.strings["cfg_api_key_doc"], validator=loader.validators.Hidden()),
loader.ConfigValue("model_name", "gemini-2.5-flash", self.strings["cfg_model_name_doc"]),
loader.ConfigValue("interactive_buttons", True, self.strings["cfg_buttons_doc"], validator=loader.validators.Boolean()),
loader.ConfigValue("system_instruction", "", self.strings["cfg_system_instruction_doc"], validator=loader.validators.String()),
@@ -182,8 +167,7 @@ class Gemini(loader.Module):
"Правила:\n- Отвечай кратко и по делу.\n- Используй неформальный язык, сленг.\n- Не отвечай на каждое сообщение.\n- На медиа (стикер, фото) реагируй как человек ('лол', 'ору', 'жиза').\n- Не используй префиксы и кавычки.\n\n"
"ИСТОРИЯ ЧАТА:\n{chat_history}\n\n{my_name}:"
),
- self.strings["cfg_impersonation_prompt_doc"],
- validator=loader.validators.String(),
+ self.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)),
@@ -203,7 +187,7 @@ class Gemini(loader.Module):
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.")
+ logger.error("Gemini: 'google-genai' library missing! pip install google-genai")
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 []
@@ -211,52 +195,54 @@ class Gemini(loader.Module):
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 ключ(и) не настроен(ы)!")
+ 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()
+ 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"
+ 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}")
+ 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"
+ 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 ''}")
+ 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}]")
+ alt_text = next((attr.alt for attr in media_source.sticker.attributes if isinstance(attr, 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"
+ 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
+ mime_type = getattr(media_source.document, "mime_type", mime_type)
+ doc_attr = next((attr for attr in media_source.document.attributes if isinstance(attr, DocumentAttributeFilename)), None)
+ if doc_attr: filename = doc_attr.file_name
+ async def get_bytes(m):
+ bio = io.BytesIO()
+ await self.client.download_media(m, bio)
+ return bio.getvalue()
if mime_type.startswith("image/"):
try:
- 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())))
+ data = await get_bytes(media)
+ final_parts.append(types.Part(inline_data=types.Blob(mime_type=mime_type, data=data)))
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')
+ data = await get_bytes(media)
+ file_content = data.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/"):
@@ -265,207 +251,178 @@ class Gemini(loader.Module):
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
+ warnings.append(f"⚠️ Аудиофайл '{filename}' слишком большой."); 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
+ proc = await asyncio.create_subprocess_exec("ffmpeg", "-y", "-i", input_path, "-c:a", "libmp3lame", "-q:a", "2", output_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ await proc.communicate()
with open(output_path, "rb") as f:
- final_parts.append(glm.Part(inline_data=glm.Blob(mime_type="audio/mpeg", data=f.read())))
+ final_parts.append(types.Part(inline_data=types.Blob(mime_type="audio/mpeg", data=f.read())))
except StopIteration: pass
- except Exception as e: warnings.append(f"⚠️ Критическая ошибка при обработке аудио '{filename}': {e}")
+ except Exception as e: warnings.append(f"⚠️ Ошибка обработки аудио: {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
+ 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()
+ warnings.append(f"⚠️ Медиафайл '{filename}' слишком большой."); raise StopIteration
+ proc_probe = await asyncio.create_subprocess_exec("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", input_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ stdout, _ = await proc_probe.communicate()
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]
+ 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"])
+ 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
+ cmd.extend([*maps, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-shortest", output_path])
+ proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
+ await proc.communicate()
with open(output_path, "rb") as f:
- final_parts.append(glm.Part(inline_data=glm.Blob(mime_type="video/mp4", data=f.read())))
+ final_parts.append(types.Part(inline_data=types.Blob(mime_type="video/mp4", data=f.read())))
except StopIteration: pass
- except Exception as e: warnings.append(f"⚠️ Критическая ошибка при обработке медиа '{filename}': {e}")
+ except Exception as e: warnings.append(f"⚠️ Ошибка обработки видео: {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()
+ 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))
+ final_parts.insert(0, types.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
+ 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
+ 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
+ chat_id = utils.get_chat_id(message); base_message_id = message.id; msg_obj = message
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 []
- 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 ""
- api_key = self.api_keys[self.current_api_key_index]
- if regeneration:
- current_turn_parts, request_text_for_display = self.last_requests.get(f"{chat_id}:{base_message_id}", (parts, "[регенерация]"))
+ 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 ""
+ 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(getattr(p, 'inline_data', None) for p in parts) else "")
+ self.last_requests[f"{chat_id}:{base_message_id}"] = (current_turn_parts, request_text_for_display)
+ result_text = ""
+ last_error = None
+ was_successful = False
+ search_icon = ""
+ max_retries = len(self.api_keys)
+ if impersonation_mode:
+ my_name = get_display_name(self.me)
+ chat_history_text = await self._get_recent_chat_text(chat_id)
+ sys_instruct = self.config["impersonation_prompt"].format(my_name=my_name, chat_history=chat_history_text)
+ else:
+ sys_val = self.config["system_instruction"]
+ sys_instruct = (sys_val.strip() if isinstance(sys_val, str) else "") or None
+ contents = []
+ raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode)
+ if regeneration and raw_hist: raw_hist = raw_hist[:-2]
+ for item in raw_hist:
+ contents.append(types.Content(
+ role=item['role'],
+ parts=[types.Part(text=item['content'])]
+ ))
+ request_parts = list(current_turn_parts)
+ if not impersonation_mode:
+ try: user_timezone = pytz.timezone(self.config["timezone"])
+ except pytz.UnknownTimeZoneError: user_timezone = pytz.utc
+ now = datetime.now(user_timezone)
+ time_note = f"[System note: Current time is {now.strftime('%Y-%m-%d %H:%M:%S %Z')}]"
+ if request_parts and getattr(request_parts[0], 'text', None):
+ request_parts[0] = types.Part(text=f"{time_note}\n\n{request_parts[0].text}")
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)
- has_media = False
- for p in current_turn_parts:
- if hasattr(p, 'inline_data') and p.inline_data:
- has_media = True; break
- should_use_new_sdk = (self.config["google_search"] or use_url_context) and NEW_SDK_AVAILABLE and not has_media
- result_text = ""; was_successful = False; search_icon = ""
- if should_use_new_sdk:
- try:
- client = google_genai_sdk.Client(api_key=api_key); sys_instruct = None
- if impersonation_mode:
- my_name = get_display_name(self.me); chat_history_text = await self._get_recent_chat_text(chat_id)
- sys_instruct = self.config["impersonation_prompt"].format(my_name=my_name, chat_history=chat_history_text)
- else:
- sys_val = self.config["system_instruction"]; sys_instruct = (sys_val.strip() if isinstance(sys_val, str) else "") or None
- new_contents = []; raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode)
- if regeneration and raw_hist: raw_hist = raw_hist[:-2]
- for item in raw_hist:
- new_contents.append(new_types.Content(role=item['role'], parts=[new_types.Part(text=item['content'])]))
- new_parts = []
- for p in current_turn_parts:
- if hasattr(p, 'text'): new_parts.append(new_types.Part(text=p.text))
- new_contents.append(new_types.Content(role="user", parts=new_parts))
- tools = []
- if self.config["google_search"] or use_url_context:
- tools.append(new_types.Tool(google_search=new_types.GoogleSearch()))
- config_new = new_types.GenerateContentConfig(tools=tools if tools else None, temperature=self.config["temperature"], system_instruction=sys_instruct)
- response = await asyncio.to_thread(client.models.generate_content, model=self.config["model_name"], contents=new_contents, config=config_new)
- if response.text:
- result_text = response.text; was_successful = True
- if self.config["google_search"]: search_icon = " 🌐"
- else: result_text = "⚠️ Пустой ответ от модели (возможно, блокировка безопасности)."
- except Exception as e:
- logger.error(f"New SDK Error: {e}")
- if "quota" in str(e).lower(): raise e
- was_successful = False
- if not was_successful:
- if (self.config["google_search"] or use_url_context) and not NEW_SDK_AVAILABLE and not has_media:
- if status_msg: await utils.answer(status_msg, self.strings["new_sdk_missing"])
- return None
- tools_list = []
- if use_url_context and not has_media:
- try: tools_list.append(old_types.Tool(url_context=old_types.UrlContext()))
- except AttributeError: pass
- 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]
+ request_parts.insert(0, types.Part(text=time_note))
+ contents.append(types.Content(role="user", parts=request_parts))
+ tools = []
+ if self.config["google_search"] or use_url_context:
+ tools.append(types.Tool(google_search=types.GoogleSearch()))
+ gen_config = types.GenerateContentConfig(
+ temperature=self.config["temperature"],
+ system_instruction=sys_instruct,
+ tools=tools if tools else None,
+ safety_settings=[
+ types.SafetySetting(category=cat, threshold="BLOCK_NONE")
+ for cat in ["HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT"]
+ ]
+ )
+ proxy_config = self._get_proxy_config()
+ for i in range(max_retries):
+ current_idx = (self.current_api_key_index + i) % max_retries
+ api_key = self.api_keys[current_idx]
+ try:
+ http_opts = None
+ if proxy_config:
+ http_opts = types.HttpOptions(async_client_args={"proxies": proxy_config})
+
+ client = genai.Client(api_key=api_key, http_options=http_opts)
+ response = await client.aio.models.generate_content(
+ model=self.config["model_name"],
+ contents=contents,
+ config=gen_config
+ )
+
+ if response.text:
+ result_text = response.text
+ was_successful = True
+ if self.config["google_search"]: search_icon = " 🌐"
+ self.current_api_key_index = current_idx
+ break
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}]"
- request_content_parts = list(current_turn_parts)
- if request_content_parts and hasattr(request_content_parts[0], 'text'):
- original_text = request_content_parts[0].text
- request_content_parts[0] = glm.Part(text=f"{time_note}\n\n{original_text}")
- else: request_content_parts.insert(0, glm.Part(text=time_note))
- else: request_content_parts = current_turn_parts
- if request_content_parts: full_request_content.append(glm.Content(role="user", parts=request_content_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 ""
- error_to_report = None; max_retries = len(self.api_keys)
- generation_cfg = old_types.GenerationConfig(temperature=self.config["temperature"])
- 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:
- old_genai.configure(api_key=api_key)
- sanitized_model_name = self.config["model_name"].lower().replace(" ", "-")
- model = old_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, generation_config=generation_cfg), 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"Ключ №{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("Не удалось получить ответ.")
- try:
- if response.prompt_feedback.block_reason: result_text = f"🚫 Запрос заблокирован.\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: result_text = "❗️ Gemini не смог сгенерировать ответ (возможно, только safety ratings)."
- if was_successful and self._is_memory_enabled(str(chat_id)):
+ raise ValueError("Empty response (Safety?)")
+ except Exception as e:
+ err_str = str(e).lower()
+ if "quota" in err_str or "exhausted" in err_str or "429" in err_str:
+ if i == max_retries - 1: last_error = RuntimeError(f"Keys exhausted. Last: {e}")
+ continue
+ else:
+ last_error = e
+ break
+ try:
+ if not was_successful:
+ raise last_error or RuntimeError("Unknown generation error")
+ if self._is_memory_enabled(str(chat_id)):
self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode)
- if impersonation_mode: return result_text 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])}
"
+ if impersonation_mode: return result_text
+ hist_len = len(self._get_structured_history(chat_id)) // 2
+ mem_ind = self.strings["memory_status"].format(hist_len, self.config["max_history_length"])
+ if self.config["max_history_length"] <= 0:
+ mem_ind = self.strings["memory_status_unlimited"].format(hist_len)
response_html = self._markdown_to_html(result_text)
formatted_body = self._format_response_with_smart_separation(response_html)
- header = f"{mem_indicator}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}{search_icon}\n"
- text_to_send = f"{header}{formatted_body}"
+ question_html = f"{utils.escape_html(request_text_for_display[:200])}
"
+ text_to_send = f"{mem_ind}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}{search_icon}\n{formatted_body}"
buttons = self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None
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"
+ 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)
+ 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)
+ 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}")
+ if impersonation_mode: logger.error(f"Gauto error: {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 ""
@@ -473,66 +430,55 @@ class Gemini(loader.Module):
@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
+ 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"])
+ 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)
+ 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)
+ try: await status_msg.edit(f"{status_msg.text}\n\n" + "\n".join(warnings))
+ except: pass
if not parts:
- err_msg=self.strings["no_prompt_or_media"]
- if status_msg: await utils.answer(status_msg, err_msg)
+ if status_msg: await utils.answer(status_msg, self.strings["no_prompt_or_media"])
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)
+ 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"])
+ 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)
+ entity = await self.client.get_entity(int(parts[0]) if parts[0].lstrip('-').isdigit() else parts[0])
target_chat_id = entity.id
count_str = parts[1]
user_prompt = " ".join(parts[2:])
- except Exception:
- pass
+ except: 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)}"))
+ else: return await utils.answer(message, self.strings["gch_usage"])
+ try: count = int(count_str)
+ except: return await utils.answer(message, "❗️ Кол-во должно быть числом.")
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 = (
@@ -542,50 +488,33 @@ class Gemini(loader.Module):
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]
+ response_text = None
+ proxy_config = self._get_proxy_config()
+ http_opts = types.HttpOptions(async_client_args={"proxies": proxy_config}) if proxy_config else None
+ for i in range(len(self.api_keys)):
+ key = self.api_keys[(self.current_api_key_index + i) % len(self.api_keys)]
try:
- old_genai.configure(api_key=api_key)
- sanitized_model_name = self.config["model_name"].lower().replace(" ", "-")
- generation_cfg = old_types.GenerationConfig(
- temperature=self.config["temperature"]
+ client = genai.Client(api_key=key, http_options=http_opts)
+ resp = await client.aio.models.generate_content(
+ model=self.config["model_name"],
+ contents=full_prompt,
+ config=types.GenerateContentConfig(safety_settings=[types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE")])
)
- model = old_genai.GenerativeModel(sanitized_model_name, safety_settings=self.safety_settings)
- api_response = await asyncio.wait_for(model.generate_content_async(full_prompt, generation_config=generation_cfg), 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"
+ if resp.text:
+ response_text = resp.text
+ self.current_api_key_index = (self.current_api_key_index + i) % len(self.api_keys)
+ break
+ except: continue
+ if not response_text: raise RuntimeError("Failed to generate (all keys dead).")
+ header = self.strings["gch_result_caption_from_chat"].format(count, chat_name)
+ resp_html = self._markdown_to_html(response_text)
+ text = f"{header}\n\n{self.strings['question_prefix']}\n{utils.escape_html(user_prompt)}
\n\n{self.strings['response_prefix']}\n{self._format_response_with_smart_separation(resp_html)}"
+ if len(text) > 4096:
+ f = io.BytesIO(response_text.encode('utf-8')); f.name = "analysis.txt"
await status_msg.delete()
- await message.reply(file=file, caption=f"📝 {header}")
+ await message.reply(file=f, caption=f"📝 {header}")
else:
- await utils.answer(status_msg, text_to_send)
+ await utils.answer(status_msg, text)
except Exception as e:
await utils.answer(status_msg, self._handle_error(e))
@@ -597,148 +526,115 @@ class Gemini(loader.Module):
if args == "-c":
self.config["system_instruction"] = ""
return await utils.answer(message, self.strings["gprompt_cleared"])
- new_prompt = None
+ new_p = None
if reply and reply.file:
if reply.file.size > 1024 * 1024:
return await utils.answer(message, self.strings["gprompt_file_too_big"])
try:
- file_data = await self.client.download_file(reply.media, bytes)
- try:
- new_prompt = file_data.decode("utf-8")
- except UnicodeDecodeError:
- return await utils.answer(message, self.strings["gprompt_not_text"])
- except Exception as e:
- return await utils.answer(message, self.strings["gprompt_file_error"].format(e))
- elif args:
- new_prompt = args
- if new_prompt is not None:
- self.config["system_instruction"] = new_prompt
- return await utils.answer(message, self.strings["gprompt_updated"].format(len(new_prompt)))
- current_prompt = self.config["system_instruction"]
- if not current_prompt:
- return await utils.answer(message, self.strings["gprompt_usage"])
- if len(current_prompt) > 4000:
- file = io.BytesIO(current_prompt.encode("utf-8"))
- file.name = "system_instruction.txt"
+ data = await self.client.download_file(reply.media, bytes)
+ try: new_p = data.decode("utf-8")
+ except UnicodeDecodeError: return await utils.answer(message, self.strings["gprompt_not_text"])
+ except Exception as e: return await utils.answer(message, self.strings["gprompt_file_error"].format(e))
+ elif args: new_p = args
+ if new_p:
+ self.config["system_instruction"] = new_p
+ return await utils.answer(message, self.strings["gprompt_updated"].format(len(new_p)))
+ cur = self.config["system_instruction"]
+ if not cur: return await utils.answer(message, self.strings["gprompt_usage"])
+ if len(cur) > 4000:
+ file = io.BytesIO(cur.encode("utf-8")); file.name = "system_instruction.txt"
await utils.answer(message, self.strings["gprompt_current"], file=file)
else:
- await utils.answer(message, f"{self.strings['gprompt_current']}\n{utils.escape_html(current_prompt)}")
+ await utils.answer(message, f"{self.strings['gprompt_current']}\n{utils.escape_html(cur)}")
@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)
+ txt = self.strings["auto_mode_on"].format(int(self.config["impersonation_reply_chance"]*100)) if target==chat_id else self.strings["gauto_state_updated"].format(f"{target}", self.strings["gauto_enabled"])
+ await utils.answer(message, txt)
+ elif state == "off":
+ self.impersonation_chats.discard(target)
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"])
+ txt = self.strings["auto_mode_off"] if target==chat_id else self.strings["gauto_state_updated"].format(f"{target}", self.strings["gauto_disabled"])
+ await utils.answer(message, txt)
+ 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:
+ if not self.impersonation_chats: return await utils.answer(message, self.strings["no_auto_mode_chats"])
+ out = [self.strings["auto_mode_chats_title"].format(len(self.impersonation_chats))]
+ for cid 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))
+ e = await self.client.get_entity(cid)
+ name = utils.escape_html(get_display_name(e))
+ out.append(self.strings["memory_chat_line"].format(name, cid))
+ except: out.append(self.strings["memory_chat_line"].format("Неизвестный чат", cid))
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":
+ 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"])
+ 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)
+ self._clear_history(chat_id)
await utils.answer(message, self.strings["memory_cleared"])
- else:
- await utils.answer(message, self.strings["no_memory_to_clear"])
+ else: await utils.answer(message, self.strings["no_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
+ try: n = int(utils.get_args_raw(message) or 1)
+ except: n = 1
+ cid = utils.get_chat_id(message)
+ hist = self._get_structured_history(cid)
+ if n > 0 and len(hist) >= n*2:
+ self.conversations[str(cid)] = hist[:-n*2]
self._save_history_sync()
await utils.answer(message, f"🧹 Удалено последних {n} пар сообщений из памяти.")
- else:
- await utils.answer(message, "Недостаточно истории для удаления.")
+ 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 not self.conversations: return await utils.answer(message, self.strings["no_memory_found"])
+ out = [self.strings["memory_chats_title"].format(len(self.conversations))]
+ shown = set()
+ for cid in list(self.conversations.keys()):
+ if not str(cid).lstrip('-').isdigit(): continue
+ chat_id = int(cid)
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})"
+ e = await self.client.get_entity(chat_id)
+ name = get_display_name(e)
+ except: 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
+ if len(out) == 1: return await utils.answer(message, self.strings["no_memory_found"])
await utils.answer(message, "\n".join(out))
@loader.command()
@@ -746,415 +642,357 @@ class Gemini(loader.Module):
"""[{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()
+ data = json.dumps(hist, ensure_ascii=False, indent=2)
+ f = io.BytesIO(data.encode('utf-8'))
+ f.name = f"gemini_{'gauto_' if gauto else ''}{src_id}.json"
+ dest = "me" if save_to_self else message.chat_id
+ cap = "Экспорт истории gauto Gemini" if gauto else "Экспорт памяти Gemini"
+ if src_id != utils.get_chat_id(message): cap += f" из чата {src_id}"
+ await self.client.send_file(dest, f, caption=cap)
+ if save_to_self: await utils.answer(message, self.strings["gme_sent_to_saved"])
+ elif args: await message.delete()
@loader.command()
async def gmemimport(self, message: Message):
"""[auto] — импорт истории из файла (ответом). auto для gauto."""
- reply=await message.get_reply_message()
+ 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
+ gauto = "auto" in utils.get_args_raw(message)
+
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)
+ f = await self.client.download_media(reply, bytes)
+ import json
+ hist = json.loads(f)
+ if not isinstance(hist, list): raise ValueError
+
+ cid = utils.get_chat_id(message)
+ target = self.gauto_conversations if gauto else self.conversations
+ target[str(cid)] = hist
+ self._save_history_sync(gauto)
await utils.answer(message, "Память успешно импортирована.")
- except Exception as e:
- await utils.answer(message, f"Ошибка импорта: {e}")
+ 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()]
+ q = utils.get_args_raw(message).lower()
+ if not q: return await utils.answer(message, "Укажите слово для поиска.")
+ cid = utils.get_chat_id(message)
+ hist = self._get_structured_history(cid)
+ found = [f"{e['role']}: {e.get('content','')[:200]}" for e in hist if q 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))
+ self.memory_disabled_chats.add(str(utils.get_chat_id(message)))
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))
+ self.memory_disabled_chats.discard(str(utils.get_chat_id(message)))
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)
+ gauto = "auto" in utils.get_args_raw(message)
+ cid = utils.get_chat_id(message)
+ hist = self._get_structured_history(cid, gauto=gauto)
if not hist: return await utils.answer(message, "Память пуста.")
- out=[]
+ 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)
+ 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}")
+ await utils.answer(message, "" + "\n".join(out) + "
")
@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"])
+ if not self.api_keys: return await utils.answer(message, self.strings['no_api_key'])
+ sts = await utils.answer(message, self.strings["processing"])
try:
- api_key = self.api_keys[self.current_api_key_index]
- old_genai.configure(api_key=api_key)
- models_list = []
- for model_obj in old_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']}")
+ client = genai.Client(api_key=self.api_keys[0])
+ models = await asyncio.to_thread(client.models.list)
+ txt = "\n".join([f"• {m.name.split('/')[-1]} ({m.display_name})" for m in models])
+ f = io.BytesIO((self.strings["gmodel_list_title"] + "\n" + txt).encode('utf-8'))
+ f.name = "models_list.txt"
+ await self.client.send_file(message.chat_id, file=f, caption="📋 Список доступных моделей", reply_to=message.id)
+ await sts.delete()
+ except Exception as e: await utils.answer(sts, self.strings["gmodel_list_error"].format(self._handle_error(e)))
return
+
+ if not args: return await utils.answer(message, f"Текущая модель: {self.config['model_name']}")
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 utils.get_args_raw(message) == "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)
+ n = 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))
+ self._save_history_sync(True)
+ await utils.answer(message, self.strings["gauto_memory_fully_cleared"].format(n))
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"]
+ if not self.conversations: return await utils.answer(message, self.strings["no_memory_to_fully_clear"])
+ n = len(self.conversations)
+ self.conversations.clear()
+ self._save_history_sync(False)
+ await utils.answer(message, self.strings["memory_fully_cleared"].format(n))
@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
- if message.out or is_from_self_user:
- return
- if message.text and message.text.startswith(self.get_prefix()):
- return
+ if not hasattr(message, 'chat_id'): return
+ cid = utils.get_chat_id(message)
+ if cid not in self.impersonation_chats: return
+ if message.is_private and not self.config["gauto_in_pm"]: return
+ if message.out or (isinstance(message.from_id, tg_types.PeerUser) and message.from_id.user_id == self.me.id): 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
+ if isinstance(sender, tg_types.User) and sender.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():
- clean_text = response_text.strip()
- reaction_delay = random.uniform(2.0, 10.0)
- await asyncio.sleep(reaction_delay)
- try:
- await self.client.send_read_acknowledge(message.chat_id, message=message)
- except Exception:
- pass
- char_count = len(clean_text)
- typing_speed = random.uniform(0.1, 0.25)
- typing_duration = char_count * typing_speed
- typing_duration = max(1.5, min(typing_duration, 25.0))
- async with message.client.action(message.chat_id, "typing"):
- await asyncio.sleep(typing_duration)
- await message.reply(clean_text)
+ if warnings: logger.warning(f"Gauto warn: {warnings}")
+ if not parts: return
+ resp = await self._send_to_gemini(message=message, parts=parts, impersonation_mode=True)
+ if resp and resp.strip():
+ cln = resp.strip()
+ await asyncio.sleep(random.uniform(2, 8))
+ try: await self.client.send_read_acknowledge(cid, message=message)
+ except: pass
+ async with message.client.action(cid, "typing"):
+ await asyncio.sleep(min(25.0, max(1.5, len(cln) * random.uniform(0.1, 0.25))))
+ await message.reply(cln)
- 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 _get_proxy_config(self):
+ p = self.config["proxy"]
+ return {"http://": p, "https://": p} if p else None
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
+ data, key = (self.gauto_conversations, DB_GAUTO_HISTORY_KEY) if gauto else (self.conversations, DB_HISTORY_KEY)
+ try: self.db.set(self.strings["name"], key, data)
+ except: 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 _load_history_from_db(self, key):
+ d = self.db.get(self.strings["name"], key, {})
+ return d if isinstance(d, dict) else {}
+
+ def _get_structured_history(self, cid, gauto=False):
+ d = self.gauto_conversations if gauto else self.conversations
+ if str(cid) not in d: d[str(cid)] = []
+ return d[str(cid)]
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())
+ import time
+ now = int(time.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
+ user_name = get_display_name(self.me)
message_id = getattr(message, "id", None)
+
+ if message:
+ if message.sender_id:
+ user_id = message.sender_id
+ if message.sender:
+ user_name = get_display_name(message.sender)
user_text = " ".join([p.text for p in user_parts if hasattr(p, "text") and p.text]) or "[ответ на медиа]"
- if regeneration:
+ if regeneration and history:
for i in range(len(history) - 1, -1, -1):
if history[i].get("role") == "model":
- history[i].update({"content": model_response, "date": now})
+ 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
+ user_entry = {
+ "role": "user",
+ "type": "text",
+ "content": user_text,
+ "date": now,
+ "user_id": user_id,
+ "message_id": message_id,
+ "user_name": user_name
+ }
+ model_entry = {
+ "role": "model",
+ "type": "text",
+ "content": model_response,
+ "date": now,
+ "user_id": None
+ }
+
+ history.extend([user_entry, model_entry])
+ limit = self.config["max_history_length"]
+ if limit > 0 and len(history) > limit * 2:
+ history = history[-(limit * 2):]
+ target = self.gauto_conversations if gauto else self.conversations
+ target[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)]
+ def _clear_history(self, cid, gauto=False):
+ d = self.gauto_conversations if gauto else self.conversations
+ if str(cid) in d:
+ del d[str(cid)]
self._save_history_sync(gauto)
+ def _is_memory_enabled(self, cid): return cid not in self.memory_disabled_chats
+
+ def _markdown_to_html(self, text):
+ text = re.sub(r"^(#+)\s+(.*)", lambda m: f"{m.group(2)}", text, flags=re.M)
+ text = re.sub(r"^([ \t]*)[-*+]\s+", r"\1• ", text, flags=re.M)
+ md = MarkdownIt("commonmark", {"html": True, "linkify": True}).enable("strikethrough")
+ html = md.render(text)
+ def fmt_code(m):
+ lang = utils.escape_html(m.group(1).strip()) if m.group(1) else ""
+ return f'
' if lang else f'{utils.escape_html(m.group(2).strip())}
'
+ html = re.sub(r"```(\w+)?\n([\s\S]+?)\n```", fmt_code, html)
+ html = re.sub(r"{utils.escape_html(m.group(2).strip())}[\s\S]*?
)
", "").replace("
", "\n").strip() + + def _format_response_with_smart_separation(self, text): + parts = re.split(r"({p.strip()}") + return "\n".join(out) + + def _get_inline_buttons(self, cid, mid): + return [[ + {"text": self.strings["btn_clear"], "callback": self._clear_callback, "args": (cid,)}, + {"text": self.strings["btn_regenerate"], "callback": self._regenerate_callback, "args": (mid, cid)} + ]] + + async def _clear_callback(self, call: InlineCall, cid): + self._clear_history(cid, gauto=False) + await call.edit(self.strings["memory_cleared"], reply_markup=None) + + async def _regenerate_callback(self, call: InlineCall, mid, cid): + key = f"{cid}:{mid}" + if key not in self.last_requests: return await call.answer(self.strings["no_last_request"], show_alert=True) + parts, disp = self.last_requests[key] + use_url_context = bool(re.search(r'https?://\S+', disp or "")) + await self._send_to_gemini(mid, parts, regeneration=True, call=call, chat_id_override=cid, display_prompt=disp, use_url_context=use_url_context) + + async def _get_recent_chat_text(self, cid, count=None, skip_last=False): + lim = (count or self.config["impersonation_history_limit"]) + (1 if skip_last else 0) + lines = [] + try: + msgs = await self.client.get_messages(cid, limit=lim) + if skip_last and msgs: msgs = msgs[1:] + for m in msgs: + if not m or (not m.text and not m.media): continue + name = get_display_name(await m.get_sender()) or "Unknown" + txt = m.text or ("[Media]" if m.media else "") + if m.sticker: + alt = next((a.alt for a in m.sticker.attributes if isinstance(a, DocumentAttributeSticker)), "?") + txt += f" [Стикер: {alt}]" + elif m.photo: txt += " [Фото]" + elif m.document and not hasattr(m.media, "webpage"): txt += " [Файл]" + if txt.strip(): lines.append(f"{name}: {txt.strip()}") + except: pass + return "\n".join(reversed(lines)) + 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)))
+ if "quota" in msg.lower() or "exhausted" in msg.lower() or "429" in msg:
+ model = self.config.get("model_name", "unknown")
+ return (
+ f"❗️ Превышен лимит Google Gemini API для модели {utils.escape_html(model)}."
+ "\n\nЧаще всего это происходит на бесплатном тарифе. Вы можете:\n"
+ "• Подождать, пока лимит сбросится (обычно раз в сутки).\n"
+ "• Проверить свой тарифный план в Google AI Studio.\n"
+ "• Узнать больше о лимитах здесь.\n\n"
+ f"Детали ошибки:\n{utils.escape_html(msg)}"
+ )
+ if "location" in msg.lower() or "not supported" in msg.lower():
+ return (
+ '❗️ В данном регионе Gemini API не доступен.\n'
+ 'Скачайте VPN (для пк/тел) или поставьте прокси (платный/бесплатный).\n'
+ 'Или воспользуйтесь инструкцией вот тут\n'
+ 'А для тех у кого UserLand инструкция тут'
+ )
+ if "key" in msg.lower() and "valid" in msg.lower():
+ return self.strings["invalid_api_key"]
+ if "blocked" in msg.lower():
+ return self.strings["blocked_error"].format(utils.escape_html(msg))
+ if "500" in msg:
+ return (
+ "❗️ Ошибка 500 от Google API.\n"
+ "Это значит, что формат медиа (файл или еще что то) который ты отправил, не поддерживается.\n"
+ "Такое случается, по такой причине:\n "
+ "• Если формат файла в принципе не поддерживается Gemini/Гуглом.\n "
+ "• Временный сбой на серверах Google. Попробуйте повторить запрос позже."
+ )
+ return self.strings["api_error"].format(utils.escape_html(msg))
def _markdown_to_html(self, text: str) -> str:
- def heading_replacer(match): level=len(match.group(1)); title=match.group(2).strip(); indent=" " * (level - 1); return f"{indent}{title}"
- text=re.sub(r"^(#+)\s+(.*)", heading_replacer, text, flags=re.MULTILINE)
- def list_replacer(match): indent=match.group(1); return f"{indent}• "
- text=re.sub(r"^([ \t]*)[-*+]\s+", list_replacer, text, flags=re.MULTILINE)
- md=MarkdownIt("commonmark", {"html": True, "linkify": True}); md.enable("strikethrough"); md.disable("hr"); md.disable("heading"); md.disable("list")
- html_text=md.render(text)
+ def heading_replacer(match):
+ level = len(match.group(1))
+ title = match.group(2).strip()
+ indent = " " * (level - 1)
+ return f"{indent}{title}"
+ text = re.sub(r"^(#+)\s+(.*)", heading_replacer, text, flags=re.MULTILINE)
+ def list_replacer(match):
+ indent = match.group(1)
+ return f"{indent}• "
+ text = re.sub(r"^([ \t]*)[-*+]\s+", list_replacer, text, flags=re.MULTILINE)
+ md = MarkdownIt("commonmark", {"html": True, "linkify": True})
+ md.enable("strikethrough")
+ md.disable("hr")
+ md.disable("heading")
+ md.disable("list")
+ html_text = md.render(text)
def format_code(match):
- lang=utils.escape_html(match.group(1).strip()); code=utils.escape_html(match.group(2).strip())
+ lang = utils.escape_html(match.group(1).strip())
+ code = utils.escape_html(match.group(2).strip())
return f'{code}' if lang else f'{code}'
- html_text=re.sub(r"```(.*?)\n([\s\S]+?)\n```", format_code, html_text)
- html_text=re.sub(r"(
[\s\S]*?)", r"\1", html_text, flags=re.DOTALL) - html_text=html_text.replace("
", "").replace("
", "\n").strip() + html_text = re.sub(r"```(.*?)\n([\s\S]+?)\n```", format_code, html_text) + html_text = re.sub(r"(
[\s\S]*?)", r"\1", html_text, flags=re.DOTALL) + html_text = html_text.replace("
", "").replace("
", "\n").strip() return html_text def _format_response_with_smart_separation(self, text: str) -> str: - pattern=r"({stripped_part}') + stripped_part = part.strip() + if stripped_part: + result_parts.append(f'
{stripped_part}') return "\n".join(result_parts) - def _get_inline_buttons(self, chat_id, base_message_id): return [[{"text": self.strings["btn_clear"], "callback": self._clear_callback, "args": (chat_id,)}, {"text": self.strings["btn_regenerate"], "callback": self._regenerate_callback, "args": (base_message_id, chat_id)}]] + + def _get_inline_buttons(self, chat_id, base_message_id): + return [[ + {"text": self.strings["btn_clear"], "callback": self._clear_callback, "args": (chat_id,)}, + {"text": self.strings["btn_regenerate"], "callback": self._regenerate_callback, "args": (base_message_id, chat_id)} + ]] async def _safe_del_msg(self, msg, delay=1): await asyncio.sleep(delay) @@ -1163,13 +1001,24 @@ class Gemini(loader.Module): 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) + 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) + 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"] @@ -1180,20 +1029,20 @@ class Gemini(loader.Module): if skip_last and messages: messages = messages[1:] for msg in messages: - if not msg: - continue + 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) + alt_text = next((attr.alt for attr in msg.sticker.attributes if isinstance(attr, 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: diff --git a/coddrago/modules/chatmodule.py b/coddrago/modules/chatmodule.py new file mode 100644 index 0000000..21b2109 --- /dev/null +++ b/coddrago/modules/chatmodule.py @@ -0,0 +1,999 @@ +# meta developer: @codrago_m +# scope: disable_onload_docs +# packurl: https://raw.githubusercontent.com/coddrago/modules/refs/heads/main/translations/chatmodule.yml + +import logging +import typing +from datetime import datetime, timedelta, timezone + +from telethon.tl import types +from telethon.tl.functions import channels, messages + +from .. import loader, utils + +logger = logging.getLogger("ChatModule") + + +@loader.tds +class ChatModuleMod(loader.Module): + strings = { + "name": "ChatModule", + } + + async def client_ready(self, client, db): + self._client = client + self._db = db + self.xdlib = await self.import_lib( + "https://raw.githubusercontent.com/coddrago/modules/refs/heads/main/libs/xdlib.py", + suspend_on_error=True, + ) + + @loader.command(ru_doc="[reply] - Узнать ID") + async def id(self, message): + """[reply] - Get the ID""" + ids = [self.strings["my_id"].format(id=self.tg_id)] + if message.is_private: + ids.append(self.strings["user_id"].format(id=message.to_id.user_id)) + return await utils.answer(message, "\n".join(ids)) + ids.append(self.strings["chat_id"].format(id=message.chat_id)) + reply = await message.get_reply_message() + if ( + reply + and not getattr(reply, "is_private") + and not getattr(reply, "sender_id") == self.tg_id + ): + user_id = (await reply.get_sender()).id + ids.append(self.strings["user_id"].format(id=user_id)) + return await utils.answer(message, "\n".join(ids)) + + @loader.command( + ru_doc="[reply/-u username/id] - Посмотреть права администратора пользователя", + ) + @loader.tag("no_pm") + async def rights(self, message): + """[reply/-u username/id] - Check user's admin rights""" + opts = self.xdlib.parse.opts(utils.get_args(message)) + reply = await message.get_reply_message() + user = opts.get("u") or opts.get("user") or (reply.sender_id if reply else None) + if not user: + return await utils.answer(message, self.strings["no_user"]) + rights = await self.xdlib.chat.get_rights(message.chat, user) + + participant = rights.participant + user = await self._client.get_entity(user) + if not hasattr(participant, "admin_rights"): + return await utils.answer( + message, self.strings["not_an_admin"].format(user=user.first_name) + ) + if participant.admin_rights: + can_do = [] + rights = participant.to_dict().get("admin_rights") + for right, is_permitted in rights.items(): + if right == "_": + continue + if is_permitted: + can_do.append(right) + promoter = ( + await self._client.get_entity(participant.promoted_by) + if hasattr(participant, "promoted_by") + else None + ) + return await utils.answer( + message, + self.strings["admin_rights"].format( + rights="\n".join( + [ + f"
{admin.id}] / {admin.participant.rank}"
+ for admin in admins
+ )
+ if admins
+ else f"\n{self.strings['no_admins_in_chat']}"
+ ),
+ ),
+ )
+
+ @loader.command(ru_doc="Показывает ботов в группе/канале")
+ @loader.tag("no_pm")
+ async def bots(self, message):
+ """Shows the bots in the chat/channel"""
+ bots = await self.xdlib.chat.get_bots(message.chat)
+ if not bots:
+ return await utils.answer(message, self.strings["no_bots_in_chat"])
+ await utils.answer(
+ message,
+ self.strings["bot_list"].format(
+ count=len(bots),
+ bots="\n".join(
+ [
+ f"{bot.id}]"
+ for bot in bots
+ ]
+ ),
+ ),
+ )
+
+ @loader.command(ru_doc="Показывает простых участников чата/канала")
+ @loader.tag("no_pm")
+ async def users(self, message):
+ """Shows the users in the chat/channel"""
+ users = await self.xdlib.chat.get_members(message.chat)
+ if not users:
+ return await utils.answer(message, self.strings["no_user_in_chat"])
+ await utils.answer(
+ message,
+ self.strings["user_list"].format(
+ count=len(users),
+ users="\n".join(
+ [
+ f"{user.id}]"
+ for user in users
+ ]
+ ),
+ ),
+ )
+
+ @loader.command(ru_doc="[-u] [-t] [-r] Забанить участника")
+ @loader.tag("no_pm")
+ async def ban(self, message):
+ """[-u] [-t] [-r] Ban a participant temporarily or permanently"""
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+ reason = opts.get("r")
+ reply = await message.get_reply_message()
+ user = opts.get("u") or (reply.sender_id if reply else None)
+ user = await self._client.get_entity(user) if user else None
+ strings = []
+ if not user:
+ return await utils.answer(message, self.strings["no_user"])
+
+ seconds = self.xdlib.parse.time(opts.get("t")) if opts.get("t") else None
+ until_date = (
+ (datetime.now(timezone.utc) + timedelta(seconds=seconds))
+ if seconds
+ else None
+ )
+ time_info = f" {self.xdlib.format.time(seconds)}" if seconds else None
+ try:
+ await self._client.edit_permissions(
+ message.chat,
+ user,
+ until_date=until_date,
+ view_messages=False,
+ )
+ except Exception as e:
+ logger.error(str(e))
+ return await utils.answer(message, self.strings["error"])
+ strings.append(
+ self.strings["user_is_banned"].format(
+ id=user.id,
+ name=(
+ getattr(user, "first_name")
+ if hasattr(user, "first_name")
+ else getattr(user, "title")
+ ),
+ time_info=time_info or self.strings["forever"],
+ )
+ )
+
+ if reason:
+ strings.append(self.strings["reason"].format(reason=reason))
+ return await utils.answer(message, "\n".join(strings))
+
+ @loader.command(ru_doc="Разбанить пользователя")
+ @loader.tag("no_pm")
+ async def unban(self, message):
+ """[-u] Unban a user"""
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+ reply = await message.get_reply_message()
+ user = opts.get("u") or (reply.sender_id if reply else None)
+ user = await self._client.get_entity(user) if user else None
+ if not user:
+ return await utils.answer(message, self.strings["no_user"])
+ try:
+ await self._client.edit_permissions(message.chat, user, view_messages=True)
+ except Exception as e:
+ logger.error(str(e))
+ return await utils.answer(message, self.strings["error"])
+ return await utils.answer(
+ message,
+ self.strings["user_is_unbanned"].format(
+ id=user.id,
+ name=(
+ getattr(user, "first_name")
+ if hasattr(user, "first_name")
+ else getattr(user, "title")
+ ),
+ ),
+ )
+
+ @loader.command(ru_doc="[-u] [-r] Кикнуть участника")
+ @loader.tag("no_pm")
+ async def kick(self, message):
+ """[-u] [-r] Kick a participant"""
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+ reason = opts.get("r")
+ reply = await message.get_reply_message()
+ user = opts.get("u") or (reply.sender_id if reply else None)
+ user = await self._client.get_entity(user) if user else None
+ strings = []
+ if not user:
+ return await utils.answer(message, self.strings["no_user"])
+ try:
+ await self._client.kick_participant(message.chat, user)
+ except Exception as e:
+ logging.error(str(e))
+ return await utils.answer(message, self.strings["error"])
+ strings.append(
+ self.strings["user_is_kicked"].format(
+ id=user.id,
+ name=(
+ getattr(user, "first_name")
+ if hasattr(user, "first_name")
+ else getattr(user, "title")
+ ),
+ )
+ )
+ if reason:
+ strings.append(self.strings["reason"].format(reason=reason))
+ return await utils.answer(message, "\n".join(strings))
+
+ @loader.command(ru_doc="[-u] [-t] [-r] Замутить участника")
+ @loader.tag("no_pm")
+ async def mute(self, message):
+ """[-u] [-t] [-r] Mute a participant temporarily or permanently"""
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+ reason = opts.get("r")
+ reply = await message.get_reply_message()
+ user = opts.get("u") or (reply.sender_id if reply else None)
+ user = await self._client.get_entity(user) if user else None
+ strings = []
+ if not user:
+ return await utils.answer(message, self.strings["no_user"])
+
+ seconds = self.xdlib.parse.time(opts.get("t")) if opts.get("t") else None
+ until_date = (
+ (datetime.now(timezone.utc) + timedelta(seconds=seconds))
+ if seconds
+ else None
+ )
+ time_info = f" {self.xdlib.format.time(seconds)}" if seconds else None
+ try:
+ await self._client.edit_permissions(
+ message.chat,
+ user,
+ until_date=until_date,
+ send_messages=False,
+ )
+ except Exception as e:
+ logger.error(str(e))
+ return await utils.answer(message, self.strings["error"])
+ strings.append(
+ self.strings["user_is_muted"].format(
+ id=user.id,
+ name=(
+ getattr(user, "first_name")
+ if hasattr(user, "first_name")
+ else getattr(user, "title")
+ ),
+ time_info=time_info or self.strings["forever"],
+ )
+ )
+
+ if reason:
+ strings.append(self.strings["reason"].format(reason=reason))
+ return await utils.answer(message, "\n".join(strings))
+
+ @loader.command(ru_doc="Размутить участника")
+ @loader.tag("no_pm")
+ async def unmute(self, message):
+ """Unmute a participant"""
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+ reply = await message.get_reply_message()
+ user = opts.get("u") or (reply.sender_id if reply else None)
+ user = await self._client.get_entity(user) if user else None
+ if not user:
+ return await utils.answer(message, self.strings["no_user"])
+ try:
+ await self._client.edit_permissions(message.chat, user, send_messages=True)
+ except Exception as e:
+ logger.error(str(e))
+ return await utils.answer(message, self.strings["error"])
+ return await utils.answer(
+ message,
+ self.strings["user_is_unmuted"].format(
+ id=user.id,
+ name=(
+ getattr(user, "first_name")
+ if hasattr(user, "first_name")
+ else getattr(user, "title")
+ ),
+ ),
+ )
+
+ @loader.command(
+ ru_doc="[-g|--group name] [-c|--channel name] - Создать группу/канал"
+ )
+ async def create(self, message):
+ """[-g|--group name] [-c|--channel name] - Create group/channel"""
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+ group_name = opts.get("g") or opts.get("group")
+ channel_name = opts.get("c") or opts.get("channel")
+ if channel_name:
+ result = await self._client(
+ channels.CreateChannelRequest(
+ title=channel_name, broadcast=True, about=""
+ )
+ )
+ chat = await self.xdlib.chat.get_info(result.chats[0])
+ return await utils.answer(
+ message,
+ self.strings["channel_created"].format(
+ link=chat.get("link"), title=channel_name
+ ),
+ )
+ if group_name:
+ result = await self._client(
+ channels.CreateChannelRequest(
+ title=group_name, megagroup=True, about=""
+ )
+ )
+ chat = await self.xdlib.chat.get_info(result.chats[0])
+ return await utils.answer(
+ message,
+ self.strings["group_created"].format(
+ link=chat.get("link"), title=group_name
+ ),
+ )
+ return await utils.answer(message, self.strings["invalid_args"])
+
+ @loader.command(
+ ru_doc="Отключает звук и архивирует чат",
+ )
+ async def dnd(self, message):
+ """Mutes and archives the current chat"""
+ dnd = await utils.dnd(self._client, await message.get_chat())
+ if dnd:
+ return await utils.answer(message, self.strings["dnd"])
+ else:
+ return await utils.answer(message, self.strings["dnd_failed"])
+
+ @loader.command(
+ ru_doc="-u username/id - Пригласить пользователя в чат (-b пригласить инлайн бота)"
+ )
+ async def invite(self, message):
+ """-u username/id - Invite a user to the chat (use -b to invite the inline bot)"""
+ args = utils.get_args(message)
+ opts = self.xdlib.parse.opts(args)
+ if opts.get("b") or opts.get("bot"):
+ invited = await self.xdlib.chat.invite_bot(self._client, message.chat)
+ entity = await self._client.get_entity(self.inline.bot_id)
+ if invited:
+ return await utils.answer(
+ message,
+ self.strings["user_invited"].format(
+ user=entity.first_name, id=entity.id
+ ),
+ )
+ return await utils.answer(message, self.strings["user_not_invited"])
+ reply = await message.get_reply_message()
+ user = opts.get("u") or opts.get("user") or (reply.sender_id if reply else None)
+ if not user:
+ return await utils.answer(message, self.strings["no_user"])
+ entity = await self._client.get_entity(user)
+ invited = await self.xdlib.chat.invite_user(message.chat, user)
+ if invited:
+ return await utils.answer(
+ message,
+ self.strings["user_invited"].format(
+ user=entity.first_name, id=entity.id
+ ),
+ )
+ return await utils.answer(message, self.strings["user_not_invited"])
+
+ @loader.command(ru_doc="[-i] Получить информацию о сущности")
+ async def inspect(self, message):
+ """[-i] Get the info about the entity"""
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+ reply = await message.get_reply_message()
+ target = (
+ opts.get("i")
+ or (reply.sender if reply else await message.get_chat())
+ or None
+ )
+ if not target:
+ return await utils.answer(message, self.strings["no_user"])
+ ent = await self._client.get_entity(target)
+ if isinstance(ent, types.Channel):
+ try:
+ chatinfo = await self.xdlib.chat.get_info(ent)
+ photo = chatinfo.get("chat_photo")
+ photo = photo if not isinstance(photo, types.PhotoEmpty) else None
+ return await utils.answer(
+ message,
+ self.strings["chatinfo"].format(
+ id=chatinfo.get("id"),
+ title=chatinfo.get("title"),
+ about=chatinfo.get("about") or self.strings["no"],
+ admins_count=chatinfo.get("admins_count"),
+ online_count=chatinfo.get("online_count"),
+ participants_count=chatinfo.get("participants_count"),
+ kicked_count=chatinfo.get("kicked_count"),
+ slowmode_seconds=(
+ self.xdlib.format.time(chatinfo.get("slowmode_seconds"))
+ if chatinfo.get("slowmode_seconds")
+ else self.strings["no"]
+ ),
+ call=(
+ self.strings["yes"]
+ if chatinfo.get("call")
+ else self.strings["no"]
+ ),
+ ttl_period=(
+ self.xdlib.format.time(chatinfo.get("ttl_period"))
+ if chatinfo.get("ttl_period")
+ else self.strings["no"]
+ ),
+ requests_pending=chatinfo.get("requests_pending"),
+ recent_requesters=", ".join(
+ [
+ f"{user}"
+ for user in chatinfo.get("recent_requesters")
+ ]
+ )
+ or self.strings["no"],
+ linked_chat_id=chatinfo.get("linked_chat_id")
+ or self.strings["no"],
+ antispam=(
+ self.strings["yes"]
+ if chatinfo.get("antispam")
+ else self.strings["no"]
+ ),
+ participants_hidden=(
+ self.strings["yes"]
+ if chatinfo.get("participants_hidden")
+ else self.strings["no"]
+ ),
+ link=chatinfo.get("link") or self.strings["no"],
+ is_forum=(
+ self.strings["yes"]
+ if chatinfo.get("is_forum")
+ else self.strings["no"]
+ ),
+ type_of=(
+ self.strings["type_group"]
+ if chatinfo.get("is_group")
+ else (
+ self.strings["type_channel"]
+ if chatinfo.get("is_channel")
+ else self.strings["type_unknown"]
+ )
+ ),
+ ),
+ file=(
+ types.InputMediaPhoto(
+ types.InputPhoto(
+ photo.id, photo.access_hash, photo.file_reference
+ )
+ )
+ if photo
+ else None
+ ),
+ )
+ except Exception as e:
+ logger.error(str(e))
+ return await utils.answer(message, self.strings["error"])
+ if isinstance(ent, types.User):
+ try:
+ userinfo = await self.xdlib.user.get_info(ent)
+ photo = userinfo.get("profile_photo")
+ working_hours = (
+ userinfo.get("business_work_hours").weekly_open
+ if userinfo.get("business_work_hours")
+ else 0
+ )
+ weekdays = [
+ self.strings["monday"],
+ self.strings["tuesday"],
+ self.strings["wednesday"],
+ self.strings["thursday"],
+ self.strings["friday"],
+ self.strings["saturday"],
+ self.strings["sunday"],
+ ]
+ personal_channel = userinfo.get("personal_channel")
+ working_hours_output = []
+ if working_hours:
+ for item in working_hours:
+ day_index = item.start_minute // (24 * 60)
+ day = weekdays[day_index]
+
+ start = self.xdlib.parse.minutes_to_hhmm(item.start_minute)
+ end = self.xdlib.parse.minutes_to_hhmm(item.end_minute)
+ working_hours_output.append(f"{day}: {start} - {end}")
+ return await utils.answer(
+ message,
+ self.strings["userinfo"].format(
+ common_chats_count=userinfo.get("common_chats_count") or 0,
+ phone=userinfo.get("phone") or self.strings["no"],
+ common_chats=(
+ ", ".join(
+ [
+ f"{channel.title}"
+ for channel in userinfo.get("common_chats")
+ ]
+ )
+ if userinfo.get("common_chats")
+ else self.strings["no"]
+ ),
+ user_id=userinfo.get("id", 0),
+ first_name=userinfo.get("first_name") or self.strings["no"],
+ last_name=userinfo.get("last_name") or self.strings["no"],
+ about=userinfo.get("about") or self.strings["no"],
+ emoji_status=(
+ f"{str(own.id).replace('-100', '')}]"
+ for own in owns
+ ]
+ ),
+ ),
+ )
+
+ @loader.command(ru_doc="[-r] [-u] [-f] - Выдать админку участнику")
+ @loader.tag("no_pm")
+ async def promote(self, message):
+ """[-r] [-u] [-f] - Promote a participant"""
+ reply = await message.get_reply_message()
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+ user = opts.get("u") or getattr(reply, "sender_id") or None
+ if not user:
+ return await utils.answer(message, self.strings["no_user"])
+ user = await self._client.get_entity(user)
+ rank = (opts.get("r")) or "XD Admin"
+ chat = await message.get_chat()
+ rights = await self.xdlib.chat.get_rights(message.chat, user)
+ if (
+ not chat.admin_rights
+ or not getattr(chat.admin_rights, "add_admins")
+ or (
+ getattr(rights.participant, "promoted_by", self.tg_id) != self.tg_id
+ and not getattr(chat, "creator", False)
+ )
+ ):
+ return await utils.answer(message, self.strings["no_rights"])
+ full = opts.get("f")
+ if full:
+ my_rights = [
+ r for r, y in chat.admin_rights.to_dict().items() if y and r != "_"
+ ]
+ perms = self.xdlib.admin_rights(0)
+ perms = perms.add(*my_rights)
+ await self.xdlib.admin.set_rights(chat, user, perms.to_int(), rank)
+ return await utils.answer(
+ message,
+ self.strings["promoted"].format(
+ id=user.id,
+ name=user.first_name
+ if hasattr(user, "first_name")
+ else user.title
+ if hasattr(user, "title")
+ else "None",
+ rights=self.strings["full_rights"],
+ ),
+ )
+ mask = (
+ self.xdlib.admin_rights.to_mask(rights.participant.admin_rights)
+ if hasattr(rights.participant, "admin_rights")
+ else 0
+ )
+
+ await utils.answer(
+ message,
+ self.strings["promote"].format(
+ id=user.id,
+ name=user.first_name
+ if hasattr(user, "first_name")
+ else user.title
+ if hasattr(user, "title")
+ else "None",
+ rank=rank,
+ ),
+ reply_markup=await self.build_markup(user.id, chat.id, mask, rank),
+ )
+
+ @loader.command(ru_doc="[-t] [-u] - Ограничить участника")
+ @loader.tag("no_pm")
+ async def restrict(self, message):
+ """[-t] [-u] - Restrict a participant"""
+ reply = await message.get_reply_message()
+ opts = self.xdlib.parse.opts(utils.get_args(message))
+
+ user = opts.get("u") or getattr(reply, "sender_id") or None
+ if not user:
+ return await utils.answer(message, self.strings["no_user"])
+
+ user = await self._client.get_entity(user)
+ chat = await message.get_chat()
+
+ if not chat.admin_rights or not getattr(chat.admin_rights, "ban_users"):
+ return await utils.answer(message, self.strings["no_rights"])
+ duration = opts.get("t", None)
+ if duration:
+ duration = self.xdlib.format.time(self.xdlib.parse.time(duration))
+
+ rights = await self.xdlib.chat.get_rights(chat, user)
+ mask = (
+ self.xdlib.banned_rights.MAX_MASK
+ - self.xdlib.banned_rights.to_mask(rights.participant.banned_rights)
+ if hasattr(rights.participant, "banned_rights")
+ else 0
+ )
+ rank = "-"
+
+ await utils.answer(
+ message,
+ self.strings["restrict"].format(
+ id=user.id,
+ name=user.first_name
+ if hasattr(user, "first_name")
+ else user.title
+ if hasattr(user, "title")
+ else "None",
+ time=f" {duration}" if duration else self.strings["forever"],
+ ),
+ reply_markup=await self.build_markup(
+ user.id,
+ chat.id,
+ mask,
+ rank,
+ mode="restrict",
+ duration=f" {duration}" if duration else None,
+ ),
+ )
+
+ async def build_markup(
+ self,
+ user_id: int,
+ chat_id: int,
+ mask: int,
+ rank: str,
+ duration: typing.Optional[int] = None,
+ mode="admin",
+ ):
+ rights_cls = (
+ self.xdlib.admin_rights if mode == "admin" else self.xdlib.banned_rights
+ )
+ rights_names = rights_cls.RIGHTS_LIST
+ rights = rights_cls(mask)
+ chat = await self._client.get_entity(chat_id)
+
+ markup = utils.chunks(
+ [
+ {
+ "text": f"{'🟢' if rights.has_index(idx) else '🔴'} {self.strings[name]}",
+ "callback": self._toggle_right,
+ "args": (user_id, chat_id, mask, idx, rank, mode, duration),
+ }
+ for idx, name in enumerate(rights_names)
+ if (
+ name != "until_date"
+ and not (
+ getattr(chat.default_banned_rights, name, True)
+ if mode != "admin"
+ else False
+ )
+ )
+ ],
+ 2,
+ )
+
+ markup.append(
+ [
+ {
+ "text": self.strings["apply"],
+ "callback": self._apply_rights,
+ "args": (user_id, chat_id, mask, rank, mode, duration),
+ }
+ ]
+ )
+
+ markup.append([{"text": self.strings["close"], "action": "close"}])
+ return markup
+
+ async def _toggle_right(
+ self,
+ call,
+ user_id: int,
+ chat_id: int,
+ mask: int,
+ idx: int,
+ rank: str,
+ mode: str,
+ duration: str,
+ ):
+ new_mask = mask ^ (1 << idx)
+
+ new_markup = await self.build_markup(
+ user_id, chat_id, new_mask, rank, mode=mode, duration=duration
+ )
+
+ user = await self._client.get_entity(user_id)
+
+ title = self.strings["promote"] if mode == "admin" else self.strings["restrict"]
+
+ await utils.answer(
+ call,
+ title.format(
+ id=user_id,
+ name=user.first_name
+ if hasattr(user, "first_name")
+ else user.title
+ if hasattr(user, "title")
+ else "None",
+ rank=rank,
+ time=f" {duration}" if duration else self.strings["forever"],
+ ),
+ reply_markup=new_markup,
+ )
+
+ async def _apply_rights(
+ self,
+ call,
+ user_id: int,
+ chat_id: int,
+ mask: int,
+ rank: str,
+ mode: str,
+ duration: typing.Optional[str] = None,
+ ):
+ user = await self._client.get_entity(user_id)
+ chat = await self._client.get_entity(chat_id)
+
+ if mode == "admin":
+ ok = await self.xdlib.admin.set_rights(chat, user, mask, rank)
+ rights_items = self.xdlib.admin_rights(mask).to_dict()
+ else:
+ ok = await self.xdlib.chat.set_restrictions(
+ chat, user, mask, duration=duration
+ )
+ rights_items = self.xdlib.banned_rights(mask).to_dict()
+
+ rights_list = [r for r, v in rights_items.items() if v]
+
+ if ok:
+ text = (
+ self.strings["promoted"]
+ if mode == "admin" and mask
+ else self.strings["demoted"]
+ if mode == "admin" and not mask
+ else self.strings["restricted"]
+ )
+
+ await utils.answer(
+ call,
+ text.format(
+ id=user_id,
+ name=user.first_name
+ if hasattr(user, "first_name")
+ else user.title
+ if hasattr(user, "title")
+ else "None",
+ rights=", ".join([self.strings[r] for r in rights_list])
+ if rights_list
+ else self.strings["no"],
+ duration=f" {duration}" if duration else self.strings["forever"],
+ time=f" {duration}" if duration else self.strings["forever"],
+ ),
+ reply_markup=[[{"text": self.strings["close"], "action": "close"}]],
+ )
+ else:
+ await utils.answer(
+ call,
+ self.strings["error"],
+ reply_markup=[[{"text": self.strings["close"], "action": "close"}]],
+ )
\ No newline at end of file
diff --git a/coddrago/modules/dbmod.py b/coddrago/modules/dbmod.py
new file mode 100644
index 0000000..a8f69c9
--- /dev/null
+++ b/coddrago/modules/dbmod.py
@@ -0,0 +1,515 @@
+# meta developer: @codrago_m
+
+import html
+from .. import loader, utils
+
+
+class DBMod(loader.Module):
+ strings = {
+ "name": "DBMod",
+ "del_text": "Database\n\nSelect a key to view",
+ "deleted": "🗑 Key {key} deleted",
+ "deleted_all": "🗑 Deleted {count} keys",
+ "close_btn": "❌ Close",
+ "back_btn": "⬅ Back",
+ "del_btn": "🗑 Delete",
+ "del_all_btn": "💣 Delete all",
+ "not_found": "🔍 Key {key} not found",
+ "invalid_key": "⚠ Invalid key",
+ "page": "📄 Page {current}/{total}",
+ "module_not_found": "🔍 Module '{module}' not found in database",
+ "confirm_delete": "⚠ Are you sure you want to delete this?",
+ "view_path": "Path: {path}",
+ "root_path": "Root",
+ "value_display": "Value: {value}",
+ "yes_btn": "✅ Yes",
+ "no_btn": "❌ No",
+ "list_item_display": "List item [{index}]",
+ }
+
+ strings_ru = {
+ "del_text": "База данных\n\nВыберите ключ для просмотра",
+ "deleted": "🗑 Ключ {key} удален",
+ "deleted_all": "🗑 Удалено {count} ключей",
+ "close_btn": "❌ Закрыть",
+ "back_btn": "⬅ Назад",
+ "del_btn": "🗑 Удалить",
+ "del_all_btn": "💣 Удалить все",
+ "not_found": "🔍 Ключ {key} не найден",
+ "invalid_key": "⚠ Некорректный ключ",
+ "page": "📄 Страница {current}/{total}",
+ "module_not_found": "🔍 Модуль '{module}' не найден в базе данных",
+ "confirm_delete": "⚠ Вы уверены, что хотите удалить это?",
+ "view_path": "Путь: {path}",
+ "root_path": "Корень",
+ "value_display": "Значение: {value}",
+ "yes_btn": "✅ Да",
+ "no_btn": "❌ Нет",
+ "list_item_display": "Элемент списка [{index}]",
+ }
+
+ async def client_ready(self):
+ self.page_state = {}
+
+ def _make_path_text(self, key_path):
+ path = "/".join(map(str, key_path)) if key_path else self.strings["root_path"]
+ return self.strings["view_path"].format(path=path)
+
+ def _make_list_item_path_text(self, key_path, index):
+ """Создает заголовок для элемента списка"""
+ if key_path:
+ path = "/".join(map(str, key_path)) + f"[{index}]"
+ else:
+ path = f"[{index}]"
+ return self.strings["list_item_display"].format(index=index)
+
+ async def show_menu(self, message, key_path=None, page=0):
+ if key_path is None:
+ key_path = []
+ self.page_state[tuple(key_path)] = page
+
+ current_data = self._db
+ for key in key_path:
+ if isinstance(current_data, (dict, list)):
+ if isinstance(current_data, dict) and key in current_data:
+ current_data = current_data[key]
+ elif (
+ isinstance(current_data, list)
+ and isinstance(key, int)
+ and 0 <= key < len(current_data)
+ ):
+ current_data = current_data[key]
+ else:
+ await utils.answer(message, self.strings["invalid_key"])
+ return
+ else:
+ await utils.answer(message, self.strings["invalid_key"])
+ return
+
+ header = self._make_path_text(key_path)
+
+ if isinstance(current_data, (dict, list)) and current_data:
+ markup = self.generate_nested_markup(current_data, key_path, page)
+ await utils.answer(message, header, reply_markup=markup)
+ else:
+ text = f"{header}\n\n" + self.strings["value_display"].format(
+ value=html.escape(str(current_data))
+ )
+ markup = self.generate_value_markup(key_path, page)
+ await utils.answer(message, text, reply_markup=markup)
+
+ async def navigate_db(self, call, key_path=None, page=0):
+ if key_path is None:
+ key_path = []
+ self.page_state[tuple(key_path)] = page
+
+ current_data = self._db
+ for key in key_path:
+ if isinstance(current_data, (dict, list)):
+ if isinstance(current_data, dict) and key in current_data:
+ current_data = current_data[key]
+ elif (
+ isinstance(current_data, list)
+ and isinstance(key, int)
+ and 0 <= key < len(current_data)
+ ):
+ current_data = current_data[key]
+ else:
+ await call.answer(self.strings["invalid_key"])
+ return
+ else:
+ await call.answer(self.strings["invalid_key"])
+ return
+
+ is_list_item = False
+ if key_path:
+ parent_data = self._db
+ for key in key_path[:-1]:
+ if isinstance(parent_data, (dict, list)):
+ if isinstance(parent_data, dict) and key in parent_data:
+ parent_data = parent_data[key]
+ elif (
+ isinstance(parent_data, list)
+ and isinstance(key, int)
+ and 0 <= key < len(parent_data)
+ ):
+ parent_data = parent_data[key]
+ else:
+ break
+
+ if (
+ isinstance(parent_data, list)
+ and isinstance(key_path[-1], int)
+ and 0 <= key_path[-1] < len(parent_data)
+ ):
+ is_list_item = True
+
+ if is_list_item:
+ header = self._make_list_item_path_text(key_path[:-1], key_path[-1])
+ text = f"{header}\n\n" + self.strings["value_display"].format(
+ value=html.escape(str(current_data))
+ )
+ await call.edit(
+ text, reply_markup=self.generate_list_item_markup(key_path, page)
+ )
+ elif isinstance(current_data, (dict, list)) and current_data:
+ header = self._make_path_text(key_path)
+ await call.edit(
+ header,
+ reply_markup=self.generate_nested_markup(current_data, key_path, page),
+ )
+ else:
+ header = self._make_path_text(key_path)
+ text = f"{header}\n\n" + self.strings["value_display"].format(
+ value=html.escape(str(current_data))
+ )
+ await call.edit(
+ text, reply_markup=self.generate_value_markup(key_path, page)
+ )
+
+ def generate_nested_markup(self, data, key_path, page=0):
+ if isinstance(data, list) and data:
+ return self.generate_list_markup(data, key_path, page)
+
+ items = list(data.items()) if isinstance(data, dict) else []
+ items_per_page = 9
+ total_pages = (len(items) + items_per_page - 1) // items_per_page
+ start_idx = page * items_per_page
+ end_idx = min(start_idx + items_per_page, len(items))
+ page_items = items[start_idx:end_idx]
+
+ markup = []
+ row = []
+ for i, (key, value) in enumerate(page_items):
+ if i % 3 == 0 and row:
+ markup.append(row)
+ row = []
+ row.append(
+ {
+ "text": f"{key}",
+ "callback": self.navigate_db,
+ "args": [key_path + [key], 0],
+ }
+ )
+ if row:
+ markup.append(row)
+
+ nav_buttons = []
+ if key_path:
+ parent_page = self.page_state.get(tuple(key_path[:-1]), 0)
+ nav_buttons.append(
+ {
+ "text": self.strings["back_btn"],
+ "callback": self.navigate_db,
+ "args": [key_path[:-1], parent_page],
+ }
+ )
+
+ if total_pages > 1:
+ if page > 0:
+ nav_buttons.append(
+ {
+ "text": "◀️",
+ "callback": self.navigate_db,
+ "args": [key_path, page - 1],
+ }
+ )
+ nav_buttons.append(
+ {
+ "text": self.strings["page"].format(
+ current=page + 1, total=total_pages
+ ),
+ "callback": self.navigate_db,
+ "args": [key_path, page],
+ }
+ )
+ if page < total_pages - 1:
+ nav_buttons.append(
+ {
+ "text": "▶️",
+ "callback": self.navigate_db,
+ "args": [key_path, page + 1],
+ }
+ )
+ if nav_buttons:
+ markup.append(nav_buttons)
+
+ if key_path:
+ markup.append(
+ [
+ {
+ "text": self.strings["del_all_btn"],
+ "callback": self.confirm_delete_all,
+ "args": [key_path],
+ }
+ ]
+ )
+
+ if not key_path:
+ markup.append([{"text": self.strings["close_btn"], "action": "close"}])
+ return markup
+
+ def generate_list_markup(self, data, key_path, page=0):
+ """Генерирует разметку для списка, показывая элементы напрямую"""
+ items_per_page = 9
+ total_pages = (len(data) + items_per_page - 1) // items_per_page
+ start_idx = page * items_per_page
+ end_idx = min(start_idx + items_per_page, len(data))
+ page_items = list(enumerate(data[start_idx:end_idx], start_idx))
+
+ markup = []
+ row = []
+ for i, (index, value) in enumerate(page_items):
+ if i % 3 == 0 and row:
+ markup.append(row)
+ row = []
+
+ if isinstance(value, (dict, list)):
+ btn_text = f"[{index}]"
+ else:
+ value_str = str(value)
+ if len(value_str) > 10:
+ btn_text = f"{value_str[:10]}..."
+ else:
+ btn_text = value_str
+
+ row.append(
+ {
+ "text": btn_text,
+ "callback": self.navigate_db,
+ "args": [key_path + [index], 0],
+ }
+ )
+ if row:
+ markup.append(row)
+
+ nav_buttons = []
+ if key_path:
+ parent_page = self.page_state.get(tuple(key_path[:-1]), 0)
+ nav_buttons.append(
+ {
+ "text": self.strings["back_btn"],
+ "callback": self.navigate_db,
+ "args": [key_path[:-1], parent_page],
+ }
+ )
+
+ if total_pages > 1:
+ if page > 0:
+ nav_buttons.append(
+ {
+ "text": "◀️",
+ "callback": self.navigate_db,
+ "args": [key_path, page - 1],
+ }
+ )
+ nav_buttons.append(
+ {
+ "text": self.strings["page"].format(
+ current=page + 1, total=total_pages
+ ),
+ "callback": self.navigate_db,
+ "args": [key_path, page],
+ }
+ )
+ if page < total_pages - 1:
+ nav_buttons.append(
+ {
+ "text": "▶️",
+ "callback": self.navigate_db,
+ "args": [key_path, page + 1],
+ }
+ )
+ if nav_buttons:
+ markup.append(nav_buttons)
+
+ if key_path:
+ markup.append(
+ [
+ {
+ "text": self.strings["del_all_btn"],
+ "callback": self.confirm_delete_all,
+ "args": [key_path],
+ }
+ ]
+ )
+
+ return markup
+
+ def generate_list_item_markup(self, key_path, page=0):
+ """Генерирует разметку для отдельного элемента списка"""
+ parent_page = self.page_state.get(tuple(key_path[:-1]), 0)
+ return [
+ [
+ {
+ "text": self.strings["del_btn"],
+ "callback": self.delete_key,
+ "args": [key_path],
+ }
+ ],
+ [
+ {
+ "text": self.strings["back_btn"],
+ "callback": self.navigate_db,
+ "args": [key_path[:-1], parent_page],
+ }
+ ],
+ ]
+
+ def generate_value_markup(self, key_path, page=0):
+ parent_page = self.page_state.get(tuple(key_path[:-1]), 0)
+ return [
+ [
+ {
+ "text": self.strings["del_btn"],
+ "callback": self.delete_key,
+ "args": [key_path],
+ }
+ ],
+ [
+ {
+ "text": self.strings["back_btn"],
+ "callback": self.navigate_db,
+ "args": [key_path[:-1], parent_page],
+ }
+ ],
+ ]
+
+ async def confirm_delete_all(self, call, key_path):
+ await call.edit(
+ self.strings["confirm_delete"],
+ reply_markup=[
+ [
+ {
+ "text": self.strings["yes_btn"],
+ "callback": self.delete_all_keys,
+ "args": [key_path],
+ }
+ ],
+ [
+ {
+ "text": self.strings["no_btn"],
+ "callback": self.navigate_db,
+ "args": [
+ key_path,
+ self.page_state.get(tuple(key_path), 0),
+ ],
+ }
+ ],
+ ],
+ )
+
+ async def delete_all_keys(self, call, key_path):
+ if not key_path:
+ count = len(self._db)
+ self._db.clear()
+ self._db.save()
+ await call.answer(self.strings["deleted_all"].format(count=count))
+ await self.navigate_db(call, [], self.page_state.get((), 0))
+ else:
+ current = self._db
+ for key in key_path[:-1]:
+ if isinstance(current, (dict, list)):
+ if isinstance(current, dict) and key in current:
+ current = current[key]
+ elif (
+ isinstance(current, list)
+ and isinstance(key, int)
+ and 0 <= key < len(current)
+ ):
+ current = current[key]
+ else:
+ await call.answer(
+ self.strings["not_found"].format(key=key_path[-1])
+ )
+ return
+
+ if isinstance(current, (dict, list)) and key_path[-1] in current:
+ if isinstance(current[key_path[-1]], (dict, list)):
+ count = len(current[key_path[-1]])
+ else:
+ count = 1
+ del current[key_path[-1]]
+ self._db.save()
+ await call.answer(self.strings["deleted_all"].format(count=count))
+ await self.navigate_db(
+ call,
+ key_path[:-1],
+ self.page_state.get(tuple(key_path[:-1]), 0),
+ )
+ else:
+ await call.answer(self.strings["not_found"].format(key=key_path[-1]))
+
+ async def delete_key(self, call, key_path):
+ parent_page = self.page_state.get(tuple(key_path[:-1]), 0)
+
+ if len(key_path) == 1:
+ if key_path[0] in self._db:
+ del self._db[key_path[0]]
+ self._db.save()
+ await call.answer(self.strings["deleted"].format(key=key_path[0]))
+ await self.navigate_db(call, [], parent_page)
+ else:
+ await call.answer(self.strings["not_found"].format(key=key_path[0]))
+ else:
+ current = self._db
+ for key in key_path[:-1]:
+ if isinstance(current, (dict, list)):
+ if isinstance(current, dict) and key in current:
+ current = current[key]
+ elif (
+ isinstance(current, list)
+ and isinstance(key, int)
+ and 0 <= key < len(current)
+ ):
+ current = current[key]
+ else:
+ await call.answer(
+ self.strings["not_found"].format(key=key_path[-1])
+ )
+ return
+
+ if isinstance(current, dict) and key_path[-1] in current:
+ deleted_value = current[key_path[-1]]
+ del current[key_path[-1]]
+ key_display = key_path[-1]
+ self._db.save()
+ await call.answer(self.strings["deleted"].format(key=key_display))
+ await self.navigate_db(call, key_path[:-1], parent_page)
+ elif (
+ isinstance(current, list)
+ and isinstance(key_path[-1], int)
+ and 0 <= key_path[-1] < len(current)
+ ):
+ deleted_value = current.pop(key_path[-1])
+ key_display = f"[{key_path[-1]}] = {deleted_value}"
+ self._db.save()
+ await call.answer(self.strings["deleted"].format(key=key_display))
+ await self.navigate_db(call, key_path[:-1], parent_page)
+ else:
+ await call.answer(self.strings["not_found"].format(key=key_path[-1]))
+
+ def find_module_key(self, module_name):
+ module_name_lower = module_name.lower()
+ for key in self._db.keys():
+ if key.lower() == module_name_lower:
+ return key
+ return None
+
+ @loader.command(ru_doc="Просмотр базы данных")
+ async def mydb(self, message):
+ """Viewing the database"""
+ args = utils.get_args_raw(message)
+ if args:
+ module_key = self.find_module_key(args)
+ if module_key:
+ await self.show_menu(
+ message, [module_key], self.page_state.get((module_key,), 0)
+ )
+ return
+ else:
+ await utils.answer(
+ message, self.strings["module_not_found"].format(module=args)
+ )
+ return
+ await self.show_menu(message, [], self.page_state.get((), 0))
\ No newline at end of file
diff --git a/coddrago/modules/full.txt b/coddrago/modules/full.txt
index 9be591f..b970f4c 100644
--- a/coddrago/modules/full.txt
+++ b/coddrago/modules/full.txt
@@ -18,3 +18,8 @@ promoclaimer
passwordgen
send
lastfm
+dbmod
+chatmodule
+stats
+tagwatcher
+hardspam
\ No newline at end of file
diff --git a/coddrago/modules/hardspam.py b/coddrago/modules/hardspam.py
new file mode 100644
index 0000000..8387ad0
--- /dev/null
+++ b/coddrago/modules/hardspam.py
@@ -0,0 +1,62 @@
+# meta developer: @codrago_m
+
+import asyncio
+from .. import loader, utils
+from herokutl.tl.types import InputDocument
+from herokutl.errors.rpcerrorlist import MediaEmptyError
+
+
+@loader.tds
+class HardSpam(loader.Module):
+ strings = {
+ "name": "HardSpam",
+ "spam_help": "Usage sample: {}hspam [-c|--clean] 23 text",
+ }
+ strings_ru = {
+ "spam_help": "Пример использования: {}hspam [-c|--clean] 23 text",
+ }
+
+ async def send_msgs(self, c, chat_id, text):
+ msg = await c.send_message(chat_id, text)
+ return msg.id
+
+ async def send_medias(self, c, chat_id, document, text):
+ msg = await c.send_file(chat_id, document, caption=None if text is None else text)
+ return msg.id
+
+ @loader.command(
+ ru_doc="[-c|--clean] n text - Отправить n кол-во сообщений одновременно"
+ )
+ async def hspamcmd(self, message):
+ """[-c|--clean] n text - Send n number of messages at the same time"""
+ args = utils.get_args(message)
+ r = await message.get_reply_message()
+ delete_all = False
+
+ if "--clean" in args:
+ delete_all = True
+ args.remove("--clean")
+ elif "-c" in args:
+ delete_all = True
+ args.remove("-c")
+ if not args[0].isdigit() or len(args) < 1:
+ return await utils.answer(
+ message,
+ self.strings["spam_help"].format(self.get_prefix()),
+ )
+
+ number = int(args[0])
+ text = " ".join(args[1:])
+ if r and r.media:
+ document = InputDocument(id=r.media.document.id, access_hash=r.media.document.access_hash, file_reference=r.media.document.file_reference)
+ tasks = [
+ self.send_medias(self._client, message.chat_id, document, None if text is None else text) for i in range(number)
+ ]
+ else:
+ tasks = [
+ self.send_msgs(self._client, message.chat_id, text) for _ in range(number)
+ ]
+ message_ids = await asyncio.gather(*tasks)
+ if delete_all:
+ await self._client.delete_messages(message.chat_id, message_ids)
+ return await message.delete()
\ No newline at end of file
diff --git a/coddrago/modules/libs/xdlib.py b/coddrago/modules/libs/xdlib.py
new file mode 100644
index 0000000..6ede7ca
--- /dev/null
+++ b/coddrago/modules/libs/xdlib.py
@@ -0,0 +1,764 @@
+# This file is part of XDesai Mods.
+# I made this library to share various utility functions across my modules.
+# You can use this library in your own modules as well.
+
+# P.S this library is still under development and may receive updates in the future.
+
+# meta developer: @codrago_m
+
+import logging
+import re
+import typing
+
+from telethon.errors.rpcerrorlist import (
+ UserNotParticipantError,
+ HideRequesterMissingError,
+)
+from telethon.functions import messages, channels
+from telethon import types
+
+from .. import loader, utils
+from ..types import SelfUnload
+
+logger = logging.getLogger("XDLib")
+
+
+class XDLib(loader.Library):
+ """A library with various utility functions for codrago modules."""
+
+ developer = "@codrago_m"
+
+ strings = {
+ "name": "XDLib",
+ "desc": "A library with various utility functions for codrago modules.",
+ "request_join_reason": "Stay tuned for updates.",
+ }
+
+ async def init(self):
+ self.format = FormatUtils()
+ self.parse = ParseUtils()
+ self.messages = MessageUtils(self._client)
+ self.admin = AdminUtils(self._client, self)
+ self.chat = ChatUtils(self._client, self._db)
+ self.dialog = DialogUtils(self._client)
+ self.user = UserUtils(self._client, self._db)
+ self.admin_rights = AdminRights
+ self.banned_rights = BannedRights
+
+ def unload_lib(self, name: str):
+ instance = self.lookup(name)
+ if isinstance(instance, loader.Library):
+ self.allmodules.libraries.remove(instance)
+ logger.info(f"Unloaded library: {name}")
+ return True
+ return False
+
+
+class UserUtils:
+ def __init__(self, client, db):
+ self._client = client
+ self._db = db
+
+ async def get_info(
+ self, user_id: typing.Union[str, int, types.PeerUser, types.User]
+ ):
+ userfull = await self._client.get_fulluser(user_id)
+ full_user = userfull.full_user
+ user = userfull.users[0]
+ usernames = user.usernames or [user] or None
+ unames = []
+ if usernames:
+ for username in usernames:
+ unames.append(username.username)
+ personal_channel = (
+ await self._client.get_entity(full_user.personal_channel_id)
+ if full_user.personal_channel_id
+ else None
+ )
+ common = await self._client(
+ messages.GetCommonChatsRequest(user_id=user_id, max_id=0, limit=100)
+ )
+
+ return {
+ "common_chats_count": full_user.common_chats_count,
+ "common_chats": common.chats,
+ "id": user.id,
+ "personal_photo": full_user.personal_photo,
+ "business_work_hours": full_user.business_work_hours,
+ "business_intro": full_user.business_intro,
+ "birthday": full_user.birthday,
+ "personal_channel": personal_channel or None,
+ "stargifts_count": full_user.stargifts_count,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "usernames": unames,
+ "emoji_status": getattr(user.emoji_status, "document_id", None),
+ "color": user.color,
+ "blocked": full_user.blocked,
+ "about": full_user.about,
+ "profile_photo": full_user.profile_photo,
+ "phone": user.phone,
+ }
+
+
+class ParseUtils:
+ def minutes_to_hhmm(self, m):
+ h = (m // 60) % 24
+ mm = m % 60
+ return f"{h:02d}:{mm:02d}"
+
+ def opts(self, args: list) -> typing.Dict[str, typing.Any]:
+ """
+ Parses command-line style options from a list of arguments.
+ Supports sequential operations (+, -, *, /) for numeric values.
+ """
+ options = {}
+ i = 0
+
+ def auto_cast(value: str):
+ if not value:
+ return True
+ low = value.lower()
+ if low in {"true", "yes", "on"}:
+ return True
+ if low in {"false", "no", "off"}:
+ return False
+ if re.fullmatch(r"-?\d+", value):
+ return int(value)
+ if re.fullmatch(r"-?\d+\.\d+", value):
+ return float(value)
+ return value
+
+ def apply_operations(base, ops: list[str]):
+ val = base
+ for op_str in ops:
+ m = re.fullmatch(r"([+*/])(\d+(\.\d+)?)", op_str)
+ if not m:
+ val = auto_cast(op_str)
+ continue
+ op, number, _ = m.groups()
+ number = float(number) if "." in number else int(number)
+ if op == "+":
+ val += number
+ elif op == "*":
+ val *= number
+ elif op == "/":
+ val /= number
+ return val
+
+ while i < len(args):
+ arg = args[i]
+
+ if "=" in arg:
+ key, value = arg.split("=", 1)
+ key = key.lstrip("-")
+ options[key] = auto_cast(value.strip("\"'"))
+
+ elif arg.startswith("-"):
+ key = arg.lstrip("-")
+ values = []
+ i += 1
+ while i < len(args) and not args[i].startswith("-"):
+ values.append(args[i].strip("\"'"))
+ i += 1
+ i -= 1
+
+ if key in options and isinstance(options[key], (int, float)):
+ options[key] = apply_operations(options[key], values)
+ else:
+ if values:
+ base = auto_cast(values[0])
+ options[key] = apply_operations(base, values[1:])
+ else:
+ options[key] = True
+
+ i += 1
+
+ return options
+
+ def bool(self, value: str) -> bool:
+ """Parses a string into a boolean value."""
+ true_values = {"true", "yes", "1", "on"}
+ false_values = {"false", "no", "0", "off"}
+ low_value = value.lower()
+ if low_value in true_values:
+ return True
+ elif low_value in false_values:
+ return False
+ else:
+ raise ValueError(f"Cannot parse boolean from '{value}'")
+
+ def time(self, time_str: str) -> int:
+ """Parses a time duration string into seconds."""
+ time_units = {
+ "s": 1,
+ "m": 60,
+ "h": 3600,
+ "d": 86400,
+ "w": 604800,
+ "y": 31536000,
+ }
+ total_seconds = 0
+ pattern = r"(\d+)([smhdwy])"
+ matches = re.findall(pattern, time_str)
+ for value, unit in matches:
+ total_seconds += int(value) * time_units[unit]
+ return total_seconds
+
+ def size(self, size_str: str) -> int:
+ """Parses a size string into bytes."""
+ size_units = {
+ "b": 1,
+ "kb": 1024,
+ "mb": 1024**2,
+ "gb": 1024**3,
+ "tb": 1024**4,
+ }
+ pattern = r"(\d+)([bkmgt]b?)"
+ match = re.match(pattern, size_str.lower())
+ if match:
+ value, unit = match.groups()
+ return int(value) * size_units[unit]
+ return 0
+
+ def mentions(self, msg) -> typing.List[str]:
+ """Extracts mentions from a given message."""
+ if msg.entities:
+ mentions = []
+ for entity in msg.entities:
+ if isinstance(entity, types.MessageEntityMention):
+ offset = entity.offset
+ length = entity.length
+ mentions.append(msg.message[offset : offset + length])
+ elif isinstance(entity, types.MessageEntityMentionName):
+ mentions.append(entity.user_id)
+ return mentions
+ return []
+
+ def urls(self, msg) -> typing.List[str]:
+ """Extracts URLs from a given message."""
+ if msg.entities or msg.media:
+ urls = []
+ for entity in msg.entities:
+ if isinstance(entity, types.MessageEntityTextUrl):
+ urls.append(entity.url)
+ elif isinstance(entity, types.MessageEntityUrl):
+ offset = entity.offset
+ length = entity.length
+ urls.append(msg.message[offset : offset + length])
+ elif msg.media and hasattr(msg.media, "webpage"):
+ if msg.media.webpage.url:
+ urls.append(msg.media.webpage.url)
+ return urls
+ return []
+
+
+class DialogUtils:
+ def __init__(self, client) -> None:
+ self._client = client
+
+ async def get_all(self, client):
+ dialogs = []
+ async for dialog in client.iter_dialogs():
+ dialogs.append(dialog)
+ return dialogs
+
+ async def get_chats(self, client):
+ return [
+ chat
+ for chat in await self.get_all(client)
+ if chat.is_group and chat.is_channel
+ ]
+
+ async def get_pms(self, client):
+ return [pm for pm in await self.get_all(client) if pm.is_private]
+
+ async def get_channels(self, client):
+ return [
+ channel
+ for channel in await self.get_all(client)
+ if channel.is_channel and not channel.is_group
+ ]
+
+ async def get_owns(self, client):
+ return [
+ ent
+ for ent in await self.get_all(client)
+ if hasattr(ent.entity, "creator") and ent.entity.creator
+ ]
+
+
+class MessageUtils:
+ def __init__(self, client):
+ self._client = client
+
+ async def delete_messages(self, msg):
+ """Deletes multiple messages based on a specific pattern."""
+ reply = await msg.get_reply_message()
+ pattern = r"([ab])(\d+)"
+ matches = re.findall(pattern, utils.get_args_raw(msg))
+
+ ids_to_delete = [msg.id]
+ if reply:
+ ids_to_delete.append(reply.id)
+
+ for direction, count_str in matches:
+ count = int(count_str)
+ if direction == "a": # after
+ if reply:
+ async for m in self._client.iter_messages(
+ msg.chat_id, min_id=reply.id, limit=count, reverse=True
+ ):
+ ids_to_delete.append(m.id)
+ elif direction == "b": # before
+ async for m in self._client.iter_messages(
+ msg.chat_id, max_id=(reply if reply else msg).id, limit=count
+ ):
+ ids_to_delete.append(m.id)
+
+ await self._client.delete_messages(msg.chat_id, message_ids=ids_to_delete)
+
+ async def get_sender(self, message):
+ if message.out:
+ return await self._client.get_me()
+ if message.is_private:
+ return message.peer_id
+ if message.is_group and message.is_channel:
+ return message.sender or message.chat
+
+
+class ChatUtils:
+ def __init__(self, client, db) -> None:
+ self._client = client
+ self._db = db
+
+ async def set_restrictions(
+ self, chat, user, mask: int, duration: int = None
+ ) -> bool:
+ """
+ Sets chat restrictions (mute/ban permissions) for a user based on a mask.
+
+ :param chat: Chat entity
+ :param user: User entity
+ :param mask: Bitmask of BannedRights
+ :param duration: Ban duration in seconds (None = forever)
+ """
+
+ try:
+ rights = BannedRights(mask)
+
+ rights_dict = rights.to_dict()
+
+ rights_dict["until_date"] = (
+ None if duration is None else utils.timestamp() + duration
+ )
+
+ new_banned_rights = types.ChatBannedRights(**rights_dict)
+
+ await self._client(
+ channels.EditBannedRequest(
+ channel=chat,
+ participant=user,
+ banned_rights=new_banned_rights,
+ )
+ )
+
+ return True
+
+ except Exception:
+ logger.error(
+ f"Failed to set restrictions with mask {mask} for user {user.id} in chat {chat}",
+ exc_info=True,
+ )
+ return False
+
+ async def get_admin_logs(self, chat, limit: int = 5, **kwargs):
+ logs = []
+ for log_event in await self._client.get_admin_log(chat, limit=limit, **kwargs):
+ logs.append(log_event)
+ return logs
+
+ async def get_user_messages(self, chat, user_id):
+ msgs = []
+ async for msg in self._client.iter_messages(chat, from_user=user_id):
+ msgs.append(msg)
+ return msgs
+
+ async def join_request(self, chat, user_id, approved):
+ try:
+ await self._client(
+ messages.HideChatJoinRequestRequest(
+ peer=chat, user_id=user_id, approved=approved
+ )
+ )
+ except HideRequesterMissingError:
+ logger.error("Request not found")
+
+ async def join_requests(self, chat, approved):
+ try:
+ await self._client(
+ messages.HideAllChatJoinRequestsRequest(
+ peer=chat,
+ approved=approved,
+ )
+ )
+ except HideRequesterMissingError:
+ logger.error("Request not found")
+
+ async def get_members(self, chat):
+ try:
+ members = await self._client.get_participants(chat)
+ if members:
+ return members
+ return None
+ except Exception:
+ logger.error(f"Couldn't get members of the chat {chat}")
+ return None
+
+ async def get_deleted(self, chat):
+ try:
+ members = await self._client.get_participants(chat)
+ deleted = [member for member in members if getattr(member, "deleted")]
+ if deleted:
+ return deleted
+ return None
+ except Exception:
+ logger.error(f"Couldn't get members of the chat {chat}")
+ return None
+
+ async def get_bots(self, chat):
+ try:
+ bots = await self._client.get_participants(
+ chat, filter=types.ChannelParticipantsBots()
+ )
+ if bots:
+ return bots
+ return None
+ except Exception:
+ logger.error(f"Couldn't get bots from the chat {chat}")
+ return None
+
+ async def get_admins(self, chat, only_users: bool = False):
+ try:
+ admins = await self._client.get_participants(
+ chat, filter=types.ChannelParticipantsAdmins()
+ )
+ users = [
+ user
+ for user in admins
+ if user
+ and not getattr(user, "bot")
+ and not isinstance(
+ getattr(user, "participant"), types.ChannelParticipantCreator
+ )
+ ]
+ if only_users:
+ return users
+ return admins
+ except Exception:
+ logger.error(f"Couldn't get admins from the chat {chat}")
+ return None
+
+ async def get_creator(self, chat):
+ try:
+ admins = await self._client.get_participants(
+ chat, filter=types.ChannelParticipantsAdmins()
+ )
+ if not admins:
+ return None
+ for admin in admins:
+ if hasattr(admin, "participant") and isinstance(
+ getattr(admin, "participant"), types.ChannelParticipantCreator
+ ):
+ return admin
+ return None
+ except Exception:
+ logger.error(f"Couldn't get the creator from the chat {chat}")
+ return None
+
+ async def is_member(self, chat, user) -> bool:
+ """Checks if a user is a member of a chat."""
+ try:
+ perms = await self._client.get_perms_cached(chat, user)
+ return True if perms else False
+ except UserNotParticipantError:
+ return False
+ except Exception:
+ logger.error(
+ f"Failed to check membership for user {user} in chat {chat.title}",
+ exc_info=True,
+ )
+ return False
+
+ async def get_rights(self, chat, user):
+ """Checks if a user is a member of a chat."""
+ try:
+ perms = await self._client.get_perms_cached(chat, user)
+ return perms
+ except UserNotParticipantError:
+ return None
+ except Exception:
+ logger.error(
+ f"Failed to check membership for user {user} in chat {chat.title}",
+ exc_info=True,
+ )
+ return None
+
+ async def invite_user(self, chat, user):
+ """Invites a user to a chat."""
+ try:
+ await self._client(
+ channels.InviteToChannelRequest(channel=chat, users=[user])
+ )
+ return True
+ except Exception:
+ logger.error(
+ f"Failed to invite user {user} to chat {chat.title}", exc_info=True
+ )
+ return False
+
+ async def get_info(self, chat) -> dict:
+ try:
+ chat_full = await self._client.get_fullchannel(chat)
+ full_chat = chat_full.full_chat
+ chat = chat_full.chats[0]
+ return {
+ "id": full_chat.id or 0,
+ "about": full_chat.about or "",
+ "chat_photo": full_chat.chat_photo,
+ "admins_count": full_chat.admins_count or 0,
+ "online_count": full_chat.online_count or 0,
+ "participants_count": full_chat.participants_count or 0,
+ "kicked_count": full_chat.kicked_count,
+ "slowmode_seconds": full_chat.slowmode_seconds or 0,
+ "call": full_chat.call or None,
+ "title": chat.title or "",
+ "ttl_period": full_chat.ttl_period or 0,
+ "available_reactions": full_chat.available_reactions or None,
+ "requests_pending": full_chat.requests_pending or 0,
+ "recent_requesters": full_chat.recent_requesters or [],
+ "is_forum": getattr(chat, "forum"),
+ "linked_chat_id": full_chat.linked_chat_id or 0,
+ "antispam": full_chat.antispam or False,
+ "participants_hidden": full_chat.participants_hidden or False,
+ "link": (
+ f"https://t.me/{chat.username}"
+ if chat.username
+ else (
+ full_chat.exported_invite.link
+ if full_chat.exported_invite
+ else ""
+ )
+ ),
+ "is_channel": chat.broadcast or False,
+ "is_group": chat.megagroup or False,
+ }
+ except Exception:
+ logger.error("Failed to get the chat info")
+ return {}
+
+ async def invite_bot(self, client, chat) -> bool:
+ """Invites an inline bot to a chat."""
+ try:
+ await self._client(
+ channels.InviteToChannelRequest(
+ chat,
+ [client.loader.inline.bot_username or client.loader.inline.bot_id],
+ )
+ )
+ except Exception:
+ logger.error("Failed to invite inline bot to chat", exc_info=True)
+ return False
+
+ rights = AdminRights.all()
+ rights.remove("anonymous")
+ admin = AdminUtils(self._client, self._db)
+ await admin.set_rights(
+ chat,
+ client.loader.inline.bot_username or client.loader.inline.bot_id,
+ rights.to_int(),
+ rank="XD Bot",
+ )
+ return True
+
+
+class AdminUtils:
+ def __init__(self, client, lib) -> None:
+ self._client = client
+ self._lib = lib
+
+ async def set_role(self, chat, user, role_name, rank="XD Admin") -> bool:
+ rights_obj = self._lib.roles.get_role_perms(role_name)
+ if rights_obj is None:
+ return False
+
+ return await self.set_rights(chat, user, rights_obj.to_int(), rank)
+
+ async def set_rights(self, chat, user, mask: int, rank: str = "XD Admin") -> bool:
+ """Sets admin rights for a user in a chat based on a mask."""
+ try:
+ rights = AdminRights(mask)
+
+ new_admin_rights = rights.to_chat_rights()
+
+ await self._client(
+ channels.EditAdminRequest(
+ chat,
+ user,
+ new_admin_rights,
+ rank=rank,
+ )
+ )
+ return True
+ except Exception:
+ logger.error(
+ f"Failed to set rights with mask {mask} for user {user.id} in chat {chat.title}",
+ exc_info=True,
+ )
+ return False
+
+
+class FormatUtils:
+ def bytes(self, size: int) -> str:
+ """Formats a size in bytes into a human-readable string."""
+ if size < 1024:
+ if size == 1:
+ return f"{size} byte"
+ return f"{size} bytes"
+ elif size < 1024**2:
+ return f"{size / 1024:.2f} KB"
+ elif size < 1024**3:
+ return f"{size / 1024**2:.2f} MB"
+ elif size < 1024**4:
+ return f"{size / 1024**3:.2f} GB"
+ else:
+ return f"{size / 1024**4:.2f} TB"
+
+ def time(self, seconds: int) -> str:
+ """Formats a time duration in seconds into a human-readable string."""
+ intervals = (
+ ("years", 31536000),
+ ("months", 2592000),
+ ("weeks", 604800),
+ ("days", 86400),
+ ("hours", 3600),
+ ("minutes", 60),
+ ("seconds", 1),
+ )
+ result = []
+ for name, count in intervals:
+ value = seconds // count
+ if value:
+ seconds -= value * count
+ if value == 1:
+ name = name.rstrip("s")
+ result.append(f"{value} {name}")
+ return ", ".join(result) if result else "0 seconds"
+
+
+class Rights:
+ RIGHTS_LIST: typing.List = []
+
+ def __init__(self, mask: int = 0):
+ self.mask = mask & self.MAX_MASK
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+ cls.RIGHTS = {name: 1 << i for i, name in enumerate(cls.RIGHTS_LIST)}
+ cls.MAX_MASK = (1 << len(cls.RIGHTS_LIST)) - 1
+
+ def add(self, *right_names: str) -> None:
+ for name in right_names:
+ if name in self.RIGHTS:
+ self.mask |= self.RIGHTS[name]
+ else:
+ return None
+ return self
+
+ def remove(self, *right_names: str) -> None:
+ for name in right_names:
+ if name in self.RIGHTS:
+ self.mask &= ~self.RIGHTS[name]
+ else:
+ return None
+ return self
+
+ def has(self, right_name: str) -> bool:
+ return bool(self.mask & self.RIGHTS.get(right_name, 0))
+
+ def add_index(self, idx: int) -> None:
+ if 0 <= idx < len(self.RIGHTS_LIST):
+ self.mask |= 1 << idx
+ return self
+
+ @classmethod
+ def to_mask(self, chat_rights):
+ mask = 0
+ for right, rmask in self.RIGHTS.items():
+ if (
+ getattr(chat_rights, right)
+ and isinstance(chat_rights, types.ChatAdminRights)
+ ) or (
+ not getattr(chat_rights, right)
+ and isinstance(chat_rights, types.ChatBannedRights)
+ ):
+ mask |= rmask
+ return mask
+
+ def remove_index(self, idx: int) -> None:
+ if 0 <= idx < len(self.RIGHTS_LIST):
+ self.mask &= ~(1 << idx)
+ return self
+
+ def has_index(self, idx: int) -> bool:
+ if 0 <= idx < len(self.RIGHTS_LIST):
+ return bool(self.mask & (1 << idx))
+ return False
+
+ def to_dict(self) -> dict[str, bool]:
+ return {name: self.has(name) for name in self.RIGHTS_LIST}
+
+ def to_int(self) -> int:
+ return self.mask
+
+ def to_chat_rights(self):
+ return (
+ types.ChatBannedRights(**self.to_dict())
+ if self.__class__.__name__ == "BannedRights"
+ else types.ChatAdminRights(**self.to_dict())
+ )
+
+ @classmethod
+ def stringify(cls) -> str:
+ max_len = max(len(name) for name in cls.RIGHTS_LIST)
+ lines = []
+ for name in cls.RIGHTS_LIST:
+ mask = cls.RIGHTS[name]
+ lines.append(f"{name.ljust(max_len)} — {mask}")
+ return "\n".join(lines)
+
+ @classmethod
+ def list_rights(cls) -> list[tuple[int, str]]:
+ return [(i, name) for i, name in enumerate(cls.RIGHTS_LIST)]
+
+ @classmethod
+ def from_int(cls, mask: int):
+ return cls(mask)
+
+ @classmethod
+ def all(cls):
+ return cls(cls.MAX_MASK)
+
+ @classmethod
+ def none(cls):
+ return cls(0)
+
+
+class BannedRights(Rights):
+ RIGHTS_LIST = [
+ x for x in types.ChatBannedRights(until_date=None).to_dict().keys() if x != "_"
+ ]
+
+
+class AdminRights(Rights):
+ RIGHTS_LIST = [x for x in types.ChatAdminRights().to_dict().keys() if x != "_"]
\ No newline at end of file
diff --git a/coddrago/modules/stats.py b/coddrago/modules/stats.py
new file mode 100644
index 0000000..0b8ae13
--- /dev/null
+++ b/coddrago/modules/stats.py
@@ -0,0 +1,120 @@
+# meta developer: @codrago_m
+
+from .. import loader, utils
+from telethon.tl.functions.contacts import GetBlockedRequest
+
+
+@loader.tds
+class Stats(loader.Module):
+ """Показывает статистику твоего аккаунта"""
+
+ strings = {
+ "name": "Stats",
+ "stats": """
+{all_chats}
+
+{users}
+{bots}
+{groups}
+{channels}
+{archived}
+{blocked}
+ Ͱ{blocked_users}
+ Ͱ{blocked_bots}""",
+ "loading_stats": "{all_chats}
+
+{users}
+{bots}
+{groups}
+{channels}
+{archived}
+{blocked}
+ Ͱ{blocked_users}
+ Ͱ{blocked_bots}""",
+ "loading_stats": "{title} [ {chat_id} ] by {name} [ {user_id} ]:\nReplying to message:\n{reply_content}\nMessage content: {msg_content}\n\nGo to message",
+ "reply_content": "Reply content: {reply_content}",
+ "no_message_content": "❓ Empty message text",
+ "msg_link_btn": "Go to message",
+ "first_msg": "This is the channel where you will receive notifications when someone mentions you in chats.\n\nYou can disable notifications using the {prefix}tagwatcher ({prefix}tw) command.",
+ "request_join_reason": "Stay tuned for updates.",
+ }
+ strings_ru = {
+ "_cls_doc": "Сообщает когда вас отмечают в чатах.",
+ "_cfg_doc_blacklist_chats": "Список ID чатов, от которых уведомления не будут приходить.",
+ "_cfg_doc_blacklist_users": "Список ID пользователей, от которых уведомления не будут приходить.",
+ "_cfg_doc_enabled": "Включить/Выключить модуль.",
+ "_cfg_doc_ignore_bots": "Игнорировать сообщения от ботов.",
+ "_cfg_doc_pm_autoread": "Автоматически отмечать личные сообщения как прочтённые при получении сообщения.",
+ "_cfg_doc_ignore_chats": "Список ID чатов, от которых не будут срабатывать упоминания.",
+ "_cfg_doc_ignore_users": "Список ID пользователей, от которых не будут срабатывать упоминания.",
+ "_cfg_doc_pm_mark_unread": "Помечать ЛС как непрочитанные после автоматического прочтения.",
+ "_cfg_doc_custom_notif_text": "Пользовательский текст уведомления. Доступные переменные: {title}, {chat_id}, {name}, {user_id}, {msg_content}, {reply_content}, {link}.",
+ "enabled": "{user_id} ] в {title} [ {chat_id} ]:\nВ ответ на сообщение:\n{reply_content}\nТекст сообщения: {msg_content}\n\nПерейти к сообщению",
+ "reply_content": "Ответ на сообщение: {reply_content}",
+ "no_message_content": "❓ Пустой текст сообщения",
+ "msg_link_btn": "Перейти к сообщению",
+ "first_msg": "Это канал, в который вы будете получать уведомления, когда кто-то упомянет вас в чатах.\n\nВы можете отключить уведомления с помощью команды {prefix}tagwatcher ({prefix}tw).",
+ "request_join_reason": "Следите за обновлениями модулей.",
+ }
+
+ def __init__(self) -> None:
+ self.config = loader.ModuleConfig(
+ loader.ConfigValue(
+ "custom_notif_text",
+ None,
+ doc=lambda: self.strings["_cfg_doc_custom_notif_text"],
+ validator=loader.validators.Union(
+ loader.validators.String(), loader.validators.NoneType()
+ ),
+ ),
+ loader.ConfigValue(
+ "ignore_bots",
+ True,
+ doc=lambda: self.strings["_cfg_doc_ignore_bots"],
+ validator=loader.validators.Boolean(),
+ ),
+ loader.ConfigValue(
+ "ignore_chats",
+ [],
+ doc=lambda: self.strings["_cfg_doc_ignore_chats"],
+ validator=loader.validators.Series(
+ validator=loader.validators.TelegramID()
+ ),
+ ),
+ loader.ConfigValue(
+ "blacklist_chats",
+ [],
+ doc=lambda: self.strings["_cfg_doc_blacklist_chats"],
+ validator=loader.validators.Series(
+ validator=loader.validators.TelegramID()
+ ),
+ ),
+ loader.ConfigValue(
+ "ignore_users",
+ [],
+ doc=lambda: self.strings["_cfg_doc_ignore_users"],
+ validator=loader.validators.Series(
+ validator=loader.validators.TelegramID()
+ ),
+ ),
+ loader.ConfigValue(
+ "blacklist_users",
+ [],
+ doc=lambda: self.strings["_cfg_doc_blacklist_users"],
+ validator=loader.validators.Series(
+ validator=loader.validators.TelegramID()
+ ),
+ ),
+ loader.ConfigValue(
+ "pm_autoread",
+ False,
+ doc=lambda: self.strings["_cfg_doc_pm_autoread"],
+ validator=loader.validators.Boolean(),
+ ),
+ loader.ConfigValue(
+ "pm_mark_unread",
+ False,
+ doc=lambda: self.strings["_cfg_doc_pm_mark_unread"],
+ validator=loader.validators.Boolean(),
+ ),
+ )
+
+ async def client_ready(self):
+ await self.request_join("@xdesai_modules", self.strings["request_join_reason"])
+ self.xdlib = await self.import_lib(
+ "https://raw.githubusercontent.com/xdesai96/modules/refs/heads/main/libs/xdlib.py",
+ suspend_on_error=True,
+ )
+
+ self.asset_channel = self._db.get("legacy.forums", "channel_id", 0)
+ self._notif_topic = await utils.asset_forum_topic(
+ self._client,
+ self._db,
+ self.asset_channel,
+ "TagWatcher",
+ description="Here will be notifications about mentions in chats.",
+ icon_emoji_id=5409025823388741707,
+ )
+
+ async def render_text(self, m):
+ if self.config["custom_notif_text"]:
+ text = self.config["custom_notif_text"]
+ else:
+ text = self.strings["mentioned"]
+ chat = await m.get_chat()
+ sender = await self.xdlib.messages.get_sender(m)
+ title = (
+ utils.escape_html(chat.title)
+ if hasattr(chat, "title")
+ else utils.escape_html(
+ sender.first_name if hasattr(sender, "first_name") else sender.title
+ )
+ )
+ name = (
+ utils.escape_html(
+ sender.first_name if hasattr(sender, "first_name") else sender.title
+ )
+ if sender
+ else "Unknown"
+ )
+ msg_content = (
+ utils.escape_html(m.message)
+ if m.message
+ else self.strings["no_message_content"]
+ )
+ id = sender.id if sender else 0
+ reply_content = ""
+ if m.is_reply:
+ reply = await m.get_reply_message()
+ if reply:
+ reply_content = (
+ utils.escape_html(reply.message)
+ if reply.message
+ else self.strings["no_message_content"]
+ )
+ return text.format(
+ title=title,
+ name=name,
+ chat_id=chat.id,
+ user_id=id,
+ msg_content=msg_content,
+ reply_content=reply_content,
+ link=await m.link,
+ )
+
+ @loader.command(
+ ru_doc="Вкл/выкл TagWatcher.",
+ alias="tw",
+ )
+ async def tagwatcher(self, m):
+ """Enable/Disable TagWatcher."""
+ try:
+ disabled = self._db.pointer(main.__name__, "disabled_watchers", {})
+ if self.strings["name"] in list(disabled.keys()):
+ del disabled[self.strings["name"]]
+ await utils.answer(m, self.strings["enabled"])
+ else:
+ disabled[self.strings["name"]] = ["*"]
+ await utils.answer(m, self.strings["disabled"])
+ except Exception as e:
+ logger.error(e)
+
+ @loader.watcher("only_pm")
+ async def pm_reader(self, m):
+ """To automatically mark private messages as read."""
+ if self.config["pm_autoread"]:
+ chat = await m.get_chat()
+ if chat.id in self.config["ignore_users"] or chat.bot:
+ return
+ try:
+ await self._client.send_read_acknowledge(
+ chat.id, m, clear_mentions=True
+ )
+ if self.config["pm_mark_unread"]:
+ peer = await self._client.get_input_entity(chat.id)
+ await self._client(
+ MarkDialogUnreadRequest(peer, True if not m.out else False)
+ )
+ except Exception as e:
+ logger.error(e)
+
+ @loader.watcher("mention", "no_pm")
+ async def inform(self, m):
+ """To inform when you are mentioned in chats."""
+ try:
+ sender = await utils.get_user(m)
+ if (
+ utils.get_chat_id(m) in self.config["ignore_chats"]
+ or sender.id in self.config["ignore_users"]
+ ):
+ return
+ await self._client.send_read_acknowledge(m.chat_id, m, clear_mentions=True)
+ if (
+ not sender
+ or utils.get_chat_id(m) in self.config["blacklist_chats"]
+ or utils.get_chat_id(m) == self._notif_topic.id
+ or sender.id in self.config["blacklist_users"]
+ ):
+ return
+ if self.config["ignore_bots"]:
+ if hasattr(sender, "bot"):
+ if sender.bot:
+ return
+ await self.inline.bot.send_message(
+ int(f"-100{self.asset_channel}"),
+ await self.render_text(m),
+ disable_web_page_preview=True,
+ message_thread_id=self._notif_topic.id,
+ )
+ except Exception as e:
+ logger.error(e)
\ No newline at end of file
diff --git a/coddrago/modules/translations/chatmodule.yml b/coddrago/modules/translations/chatmodule.yml
new file mode 100644
index 0000000..0c2808f
--- /dev/null
+++ b/coddrago/modules/translations/chatmodule.yml
@@ -0,0 +1,197 @@
+en:
+ my_id: "{id}"
+ chat_id: "{id}"
+ user_id: "{id}"
+ user_not_participant: "" + not_an_admin: "📜 {name} Rights in this chat:\n\n{rights}\n\n🔼 Promoted by: {promoter_name} [{promoter_id}]
{title} ({count}):\n"
+ no_admins_in_chat: "" + no_bots_in_chat: "🤖 Bots ({count}):\n{bots}
" + no_user_in_chat: "👤 Users ({count}):\n{users}
{id}] has been banned for{time_info}."
+ user_is_unbanned: "{id}] has been unbanned."
+ user_is_kicked: "{name} [{id}] has been kicked."
+ user_is_muted: "{id}] has been muted for{time_info}."
+ reason: "Reason: {reason}"
+ forever: "ever"
+ user_is_unmuted: "{id}] has been unmuted."
+ title_changed: "The {type_of} title was successfully changed from {old_title} to {new_title}."
+ channel_created: "{title} is created.\n{title} is created.\n" + dnd: "👑 The creator is {name}\n\nAdmins ({admins_count}):\n{admins}
\n🔒 Type: {type_of}\n#️⃣ Chat ID:{id}\n🔥 Title: {title}\n📖 Forum: {is_forum}
\n🖋 About: {about}
\n👑 Admin count: {admins_count}\n✅ Online count: {online_count}\n👤 Participants count: {participants_count}\n🚫 Kicked сount: {kicked_count}\n🔀 Requests pending: {requests_pending}
\n🕐 Slowmode period: {slowmode_seconds}\n📞 Call: {call}\n🗑 TTL period: {ttl_period}\n👤 Recent requesters: {recent_requesters}
\n👥 Linked Chat ID: {linked_chat_id}\n🛡 Antispam: {antispam}\n👁 Participants hidden: {participants_hidden}
\n#️⃣ ID: {user_id}\n🔥 First name: {first_name}\n🔥 Last name: {last_name}\n📞 Phone number: {phone}\n🐶 Usernames: {usernames}
\nℹ️ About: {about}
\n🕐 Work hours: {business_work_hours}\n❤️ Emoji status: {emoji_status}
\n🔉 Personal channel: {personal_channel}\n🎂 Birthday: {birthday}\n🎁 Gifts count: {stargifts_count}
" + monday: "Monday" + tuesday: "Tuesday" + wednesday: "Wednesday" + thursday: "Thursday" + friday: "Friday" + saturday: "Saturday" + sunday: "Sunday" + owns: "🚨 Common chats count: {common_chats_count}\n👥 Common chats: {common_chats}
{owns}" + close: "❌ Close" + apply: "✅ Apply" +ru: + my_id: "
{id}"
+ chat_id: "{id}"
+ user_id: "{id}"
+ user_not_participant: "" + not_an_admin: "📜 {name} Права в этом чате:\n\n{rights}\n\n🔼 Повысил: {promoter_name} [{promoter_id}]
{title} ({count}):\n"
+ no_admins_in_chat: "" + no_bots_in_chat: "🤖 Боты ({count}):\n{bots}
" + no_user_in_chat: "👤 Пользователи ({count}):\n{users}
{id}] забанен на{time_info}."
+ user_is_unbanned: "{id}] разбанен."
+ user_is_kicked: "{name} [{id}] кикнут."
+ user_is_muted: "{id}] замьючен на{time_info}."
+ reason: "Причина: {reason}"
+ forever: "всегда"
+ user_is_unmuted: "{id}] размьючен."
+ title_changed: "{type_of} название успешно изменено с {old_title} на {new_title}."
+ channel_created: "{title} создан.\n{title} создана.\n" + dnd: "👑 Создатель: {name}\n\nАдмины ({admins_count}):\n{admins}
\n🔒 Тип: {type_of}\n#️⃣ ID чата:{id}\n🔥 Название: {title}\n📖 Форум: {is_forum}
\n🖋 Описание: {about}
\n👑 Кол-во админов: {admins_count}\n✅ Онлайн: {online_count}\n👤 Кол-во участников: {participants_count}\n🚫 Кикнутых: {kicked_count}\n🔀 Запросов в ожидании: {requests_pending}
\n🕐 Слоумод: {slowmode_seconds}\n📞 Звонок: {call}\n🗑 Период TTL: {ttl_period}\n👤 Недавние запросы: {recent_requesters}
\n👥 Связанный чат: {linked_chat_id}\n🛡 Антиспам: {antispam}\n👁 Скрытые участники: {participants_hidden}
\n#️⃣ ID: {user_id}\n🔥 Имя: {first_name}\n🔥 Фамилия: {last_name}\n📞 Номер телефона: {phone}\n🐶 Имена пользователей: {usernames}
\nℹ️ О себе: {about}
\n🕐 Рабочие часы: {business_work_hours}\n❤️ Статус с эмодзи: {emoji_status}
\n🔉 Личный канал: {personal_channel}\n🎂 День рождения: {birthday}\n🎁 Количество подарков: {stargifts_count}
" + monday: "Понедельник" + tuesday: "Вторник" + wednesday: "Среда" + thursday: "Четверг" + friday: "Пятница" + saturday: "Суббота" + sunday: "Воскресенье" + owns: "🚨 Количество общих чатов: {common_chats_count}\n👥 Общие чаты: {common_chats}
{owns}" + close: "❌ Закрыть" + apply: "✅ Применить" \ No newline at end of file diff --git a/fajox1/famods/.DS_Store b/fajox1/famods/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..00ec97e2bf3b088c378d52da3e48bc56c58a8e7a GIT binary patch literal 10244 zcmeHM&2QX96o0b`t#>yc+cqhs0%_G;kXnV%gbIY9>82HguU6fvr1T?rcb$zJ$FsGa zq@gOx86*xIIdJ62fm4rMIKq(w7x)7Z;=~nx@7ddqH?u|R0fDfi@tYmLpWmC`y!Y&J zh)6ZIIxiB{iO4}?SsTO2a`1I-`;JmJ@-n0Xo|XgMZUwr9-?e?(#X!M8!9c-4!9c-4 z!N9+P0X(z$aH^>+&IJPn0|f&Q8Q}U*M`O8|$-Puc>A*?00Fa{?mIHlx^@ptU0OVpO z_fm-w94duErcjk_F;up5P$?YO7c>36RHkrJVj1_zEURpXqOy&Ls*5_Q#Z(sOf`Nj8 z{S5HleUxUYOFkt7`}YQ}&HgEe04wBDNE$7sO|)6+(e41|n*q!LU}F4vlu(@4RVN;& z+uDhH*|&rTtzO{A$=uwJsx&hC)PaM}L8t26Y_98_X5uDWal?(S^RFvf2d#eJZC&-f zoz>dZk`5Es54}hLe#e8#jraYqqjws5E9}IAZKfBTvQu8I9lLjL{-ya-_4&Ee_fOUD zy>fc)RDEIo%>DaiXL{y^S1+$_1e>A$L|7q}l_VNZe?L5bie7I1etSEclls0tr=M%J z{;bNC>e%?iWbM%5BZsCAO;1lBefrol$B#d|T07Em+Z)}8y
?vK2)&p#kgS?IR&+FgE(7hu+NRjHB{&y_FO8xwCONZ@^a# zdx^~wabYlVw*%fzvN z5jf_^vHB-$nTQGy{3752$92szCt=LQOhB&%D+v%9ImmS>98$|T;!JD>SYCb3DIa&S z*b@#-jFVwvtgA(z*(=>L*em1CAy!pG_N)lxY7TLb-l1h$q6_pMo i{#E~3YxFRcAw zuvUS7BG%RsKi24B4@bT>FlD+7&d$+!dKYpQGwbBJ;|ML8R;uvHgPulR^|1b3n{OPY zIn<<}?qL1>4DW$`yu4b`%)4oG9C|%i 5K=T*@B+Qcu0YH#57*);6IMKZ>duYv#Ru^WK}8Z~SIG z&qTyZo%$?MhKLMQK~u-j3@M^t)Pa%+C23Fwdm@WG@|PT5t2zVPTA&fo2xtT}0vZ90 z!2baOyt73y#ue4AMnEH=5!gpSoDUgPL5m^nDJqW+H1Y}nIs&mQ;BgO-HkptXL)ueR zn!=~LJ+RP}g;xxw%+a3_>Y&At_7qj-#FRO)aAX$VP?#JYb{VNotf;7NH3AxeK?KCz zeUz$Xl8aC5{?4oD5y4bKo%vlBa0A-auHUXYQu|J|?bv>h%iU#(BO^zj8Z}0ZapP8b zlXuI38Fc)T>D>@dYn(gPh&QX(ZL3>JO goiH?i>OpyTR! zzx{M@Rd#JVTowB%TGhQ&Dq2@^Z2Z{qr%$9NPM(^WoJglnKXc~Uv**rDRZ^#_W^JqK ziTPgR4);n<$6B{*+%4N5TI+%JK_Hk)>SUkZs#SmYK(w~ghR)=HK>B*svn=;U)3fW# zjfQUp>oI<#k~-nnJnlFv+_wXpyBjxsK)n=LttGd?G2%90UT%vn|GMS*paVAR8>Z)( zZqq95+;YsS 5R zE6E#^ktn1r`H7xXpVq3`Ku`jsBA5q6qg zWS7_+dzoEfudz4TDyuPvz0W-6D-Vfi*Dr(~L&81+)A!BaE_<%S-6nd3)ltNSjf_@3 z%4tP0!9GXtm$8_LX+G|Fn9I*01{N;=&3K5dC>9GpK0++0U^shT$HF18@b87d5_<Lf4JJOQU42# z_U2(D^abAq1Yd`0v_(EbpHo23BNRDg4qj6MJ{BbXIy2Nn$Tn08Ito|%7Bh{%!p;xa z4f9>c2&Np(qoID;#Xi63NANq`lQ>WgpW)!zBV3xm#2r4vg!q*l_d7uitf>VYai~!R z^F4U+&rlnFnh#@_@cKNA0pAuhP3%p8QN>R5;VnmvACF_O=wLh>HgzB>M%sCJ9QiXN zZZicPweKAB%u^0^9y^|+0@^$+Ksrm8_a*a?%+PIE^{qhee%x~sGYK$%PtJdch1}P; z1t8ExBcKt`2xtT}0#6D8N7O DLjr zhz#m`sG 5 literal 0 HcmV?d00001 diff --git a/fajox1/famods/assets/banners/.DS_Store b/fajox1/famods/assets/banners/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..745e63200cd3293b43e0b79829683ad88c1b28e8 GIT binary patch literal 10244 zcmeI1O>WyT5QT?MfJT<%_y|T<-85){pv%HY(?!?a7xj 0)K-7cxUrCNJ|^fQGuvHRN$8a%nw}@+MczW z(rW6!NtXby`?#zJK4TrAOxN0;wVTo^if5Lq2V+rVml&qRalgyzusv%xr7dwWB`(H# zHg<(#)H}GYRu|JNZ9GQ>q5_W<;MzTvKGy36io1Rvf0!1-!L%5l1}D`i%JG99_$1N< zwU!mQv0T_Sf1SCjF>Nr{C_@nRIhIToe%3s%u^kyGa}JLSd|Ju`yA6em0{pic-vpn- z ~}}mw!G7RWw^tB zX7WZ({C >- 7hcjiH_{(L&uhYUkQJMvs?MO KUkYXuMRpua0s}ou!x1ZHD6Y zpt`wC3k>JlcBThf#yM_W?ohdF^rtUhP;P^2Q#+SrD;J1(j%}cos-DZFU^u(_AeoW8 zePaYxJRPCt0G?K03d|VhlUXPWshanWnkncEEaN}NbG$xPPnSw8wZyo2302Z1nt1vQ z<6OYb0}M5EnM2fMmSdc6X|Fd`(j^m+IrL!u@Do(gB|0ED7l$CWF+5T|y*zbnd~$T8 zM~?RmV#H6})rb_tN L7p ?J;gu~uM;}jMCw|`?jS8E9WrPL2F{UfT5$a}$Nahi~TNwwzyrpCD!_g?w zR&wkEnU3+?aET@e#@PrZkoi?nmq{(NM9gr;c}JZ&wc;t!cg;N9HdW8{)6&etJVNbv zMs&qfidbYs^0xwKxVL@Ot`=XnsQcS2L 0#Sjez>^f%E2%E`|KErI z|9{f6BNP>g3j7-inEkVlXZT^N8~wf}V}YUD@t^HY?!j+RV0AklCsyGX-Hyi>ZpXi{ x*Iv=`!2O=Jo6;(d>x#pcyZL|o&wvMd$G&G=|KB_7xVZiot~x90tp9fX{}W>zZG->- literal 0 HcmV?d00001 diff --git a/fajox1/famods/assets/banners/ptichki.png b/fajox1/famods/assets/banners/ptichki.png new file mode 100644 index 0000000000000000000000000000000000000000..cea6ed1081a16c11df42a59b68af2c7ef75e75bc GIT binary patch literal 1224161 zcmV(|K+(U6P) mbvg7UMQX8M)Jsq5U&mjke$-l;7A0ObsmnXmJYY^_W|$(} zZJTZLh?78-T5kv-zjIE6hr78A+sw^9JnZGm7Y;XIJN~(Y$MI{N9*4)7 ;3#^2v?_$WW?G>f{ B?rmTWOc(l%;M{&?esp9OczK8;U)XZdI&Njmzs*;aioDP$SLHm?M~$QROK z(%>EIPo8ZSa 6j!R{o8_OpM18pgXX0;d5O9sZ8;gbl$C>WC?1&H=D~KG z`lI)S>{{P~2C#g}>>Y>DG}YQhyVd5TZKIA*$D$m|q 9}oxJt1(Ba}<*Ntfh zjbmkl+47-|Q912re&2$bVmqLIpT{QcMH%*ebdl|O(pcJH2^&cB_UsR!daf)dp0Law zl+1HQTffep?$Q(L#l( h#nXkCx8gZ<9;3>tS!_J>%>XW(`PsQq{?R zl%1xWoOI6GpH2)5eYC^=-AcYwJ-k#`bVg;B{GsWonpAC#--UNT*C~~MUETEl8(a0p zBL{JGV%Gd`3;gmZL7KsSownt1k2mARMen6OPvkD$(ECmFR_%=CJ$=NaZ0N*P@YYew zUa^9(^N`8pkqJKIM#I&A9Od^teIC>=WxHKA77FGf{p>TYSkB+ucKXUmtI;=MVxYMJ z!o{u|()-O3=GJKv%~HSpu;221$Yr0hFSHcfChjNB6@Rb24NUXET2i 2)lWP*e@}zI=q0+Iyn6K& zzB~T( @2DxyBOjMMIL;nHJ?c>%>@plScv)P6 =_}gIc1H{56-Nbr zG>&MTvB)a1Pw3Qgg_nM%B=li;*NB1Wa|YI(x``<~_OE>VF35h>7hDAc#voU@nfwCK zbZMnkEA{pPK&X;2XYgu`*;3xT?o)o}xZ$l!{5A{JxCWl_k%>xIv@_NH L&4) zTFGg@^IYgo_{FIc8_L|H_LJtl+Z@{i6oLAgi|(}Rk92(LJMPC0VFIC#WltFsCzh#E zSV}8p1(B^!9Rs?~Znnfg%72L?C*~^Nkmr{Dx%Ai2&R4s^Z{LK*z;*U{D$}TAmoaG7 zxnRHwLwAS7LzTNJ3MS1D#@RBi86*yNdvktbMK?5s9BsLDHy}=#r!(yu6*^n7XSehI z4PD2=N$>6cmE8GFvEt0xq@5b|YZrM5`BBTvWo wD*+N@Jz_02(ifDk`BvU}dpo z3mrXZd-?uLzZ3v2uHu$3TfVCkqF!48t%BdTvCHgI_R&kRLg8+TV>PPFwbsL>3Qbf9 z9z3LPZ3npRTmyi2_FkEYMy}r&=c8Ny)Iu#mkVUJ19>2}(TQ#&gwQobx1^6BTnE=CG zWg2)u?sVyAI(EMwU=&bXI#$MeV*@Ri4`*zVNc04|d)}$uA;4pyo;}R7t7hq7L(5!y z$v#${S<{WTQYWp6t~szO2d#Ol8Q5+$u$SIc;&AfvbP7)+`GB- r? zIHkGxn#kBix~qh*vbh_CW^jt-_9mkp75w2+XF4Egu&oXYi~imxQhMt;IAPtiB>>-K zhtd|nfOTv1krx}*$r@Ct)#*X~YX{T}n~4EIr^~$?nA}@zAR)$tLdO+D*$=&Tm3>dG z$2pgTpq)9uIZB>Re-m28Hwhno0f`J$2xVj9>j|EExnUJl)??BRd+;u~Dn03td8(5B z)aUGHwo9r@E{$!H#9J>35xBD{4UWpfY)RxqpPN&<(spS*u~@7;&lMX5faZxE7Yt82 zL^ 13q0bB|*b{k~$2wRo{Ey1xsLAcZO|;!tkUt_ @msC9#ws<=S3)N?FE>4 zTm}R2xe0W6>o0!{J+hgg+0sW$K5Bld>7=uwRk`Exk^om)2WI-}z4=*j*f+&bQfYQ~ z)s1`X$Au>EAbjmFH1d9=FT8#BLH(pOhCbfN)4(^{l$?#p=OJIKx^8XAP>UaM<*O!p zGgb~j*{l*kI$5yB9`vPWGJF&Lt#KD+J~>m9?k4@di# 8`*1rg@ezhAtG*cuvVGEV&+bn?ysM(D6LS+xKcH zb+Rgz)4PuFREg O9r0=jxQ1h7VRd;U-Ion}D?=1ByOEy-!*6XtZsH@tPmg+UTD*^#>(fX37 zYeA{NQ-5j@+Jk|#8_`D(M}xT6zJYV?@Nc-T^;oHz ( -EIWXkq>E1(&Hy|$ZzfrjZj@1Elj>mD z9%U Y93C?T*q{%6GZfijqFC@bHy9`$$J^!4e;w zzm9AD!pXrqo0vkLdFX6S2(T=gzI4lqE4JX43{W3XuEhc9idS1~Z>2qWYI88kNrJ|! zrTyghQf+hNa^0X>Q<7fpUOuL6=F=Y2xlEZzvZU*Q-Pe}^mn}MHGH?nxWdQEtv!)*C zink?2lm88=D@bQs#R*?% _H)JivW2+c%GaF7hKfU3 zPm3R1c+lDk0?p8$#F#!nCngosw7$E;lX78O7Y#1qIms2;ENCm*SuiFbf-ZZP0htLs zEB)`dEpJ~}nR(Y0sn?raRi1T+2Q@}KU5M9pxLJ# rZ+i+9~inIyDi+~c+Ca-G0!-M|ua<(PO6ySd7*`7h@c(c)#| z6yEKqIIq5L`XdkUSv{m*_~k+QCVjW!jul^2Hl(%70ad4=8QPXMZK2b1MVxgv)q2(F z9GwvzvfiutwaZ#_Tq`y!Ryy=k+v)YOzXiF4$j)0~qW%w(&5dOyb2Z(9=Wm@*585vV zabuU?I2P@?)o|16`Z}4d_gllAUg!O|-0F9JU4glOZ5Fp+kU?4LXhIdrYC>J^^lKgM z!~^!-+5H{+0mvePG70L4nH3qP{JU@Xwk;PK)EBwD;?AR5yKVHJ)`c&;d8}dH%4cM; zc0$L>4=BqTz1P1`e7PxA#r@tq>?-@Y*w*m$?Af#bxc2U`UX*g%^b$#;c?}XriFA17 zkT12lt~ivCJ(b&2aVpUhrQ1v?oq^Q=J2L+X+!EZ0HKTJ Yat2nefzF?5?T^tSkkbyky;qJ&L`_ z|0RfQZI+K1k1}gnYs10|4%Bd&v+=~Ll@m_FOGlvrUe%Mosh+u7U{6|Aze`@xgrRI= zTw2<2d9)lJD;z8y_wwW`XGJwI?zpVnvy@Ag4*GAmTWI{b{%w;4AzRwe_1@^S-(Ty; zD&P5$TMn3(bX+OC(w_F{kJ@B~o+ebujrn^^|BIw?_z^WX+p> vI1 z!E#dMf;)HZ^FO;@tLxDLiD&5pGwyN2o%orw$}y1GerlPqWoq0eyj-JO%WHk2l`%#2 zLjCtV-P<+ol7&|mz#44_y(R$16@s&cRUUn?JVmi(G?EzE1ZfvBizd*n*3XnDu)q zz9}7vzgwqG 3vdiJt zeB0!FuSa>_A;S0tH4?f~V2#p}ar)h?BYs7fb}T*+(q{X8J$TJ;DD$?$&IMn#i+-C* zX^bbRhud9)52U8@8G*X!f75(50i^Cd=p$fD3&GY@39EDM)0!_y`5G=hDp%sC3J4xW z0QHEvYT+u!*>_geZT8)D0pFrn6&_9ZG`}rHCNU`gy<9IWm<7w^mnWO@H*Q<=3*l ) zXit>4oKg`f_0^@uKIq!dZ=)=lh!S7|I;bK#HqYM;K6sYZ8SY`(8YtBqj2=R`T~%j* zWNgXcx`N^c<9l!X#?WjnCf_5GB8GH(t1q|vav>CAI^=zstzS9Ey6quZ+J14~-`)Mk zwdDD}uS3ubv=x>`b;)ozt6VPepmr4!XEqG?6I}$`{uQkn;Ie{=hEzfRjy5xndn0;C ztISv`Sh9^wPp&g$T8|45t?lGF&hoGOYIS?60HP9;cB%c5(JPJ7x}p#)9q&wP)M`%J ztkysKKk41=?JVmI+9& 8*!4=o-ZseTkd<)6(157@`rr*N`2HPt7|rq6WP7VUR8PbW#XXpu7djc z&u$0RWtVsInEQbh=gL2IDY|va3WKY)&i$tx4Caa!?!OxLRhWPlh-p9TQ*~~o1-Dw> zuXkRi?N7qypjjsl47ndRgR5y{SIcj_HtiMrKRTUy3K>Xj==$61i#jFArM~lG&+YJ= zGk&Som*Q8iT#1N_OBAIysl*d6^zV1apL0x|%Wn;Htd@Mv>xx03&hvWShV))pFHnm& zx(%+>tcNL90!@qOg|4Xv9f0uhQH5SzFhG6ZrN+R!TU)V)XD Bv23}Iu)}lD#3xu=IhQO$NkEXh1%XAU=W0R6x+2P= z!qw~PF$R7d%t}H)QL&ArY+VU2zRn6IHW%G6^(A(trhVyZ*2Y+JUp6Qow|d^p#hX2K z3|@I7y6@VaJ!pdaoy 7WA3`l>1yK>RHpe8dTeL z^;g%vLGC2pOndH$xYWa_f-&)|c}*hs_(uH^IZPhy`iOWWmABm1M<$`M>R8?TG;A#m zB5G!7*0m3QGJYv)e6hWEVQR16gq~5;Vh4R4j@XGUi%)EgqUJTDZu)8y(Vo*jt;)7i z!dI;ItUc1-hK_>ip$L2JrnaE7)#&httw{MaoNlB3=(ccz`)V6(Z^4$@&g{1JZ#-{z z&7)A}jz)iQv(5|lo?7nNNzASS?2z44Y<_!ggrdQ#tmjhn>L(Q|3$Lu`_wK=lp7R0B zMdIC#dRwJi(Qb>^#X?-^;MuP1dM2^PtDSZ$^JO
CSQAFT0!EA zUo*Kum_D%gh`8IfdPR>fCBgQ%_=&6MswYk*j52G%P5~c%x;8EMy@Ry>Z4NI1gM(Ie z9xGT?w*S65mqaDnZ1t_I-_ $ZFiTX&pHF0VF^~= z&OL%^Pph&2R=$u5xzfWgngijr-|*ymO!e~HSuJ1zZ`{kgYP$9lH$8qtRObn9>fcgP zxJpNh&7d!I)~zL|uln2R)v8p7o9W9JTY} V*!EBo4Ht!{i-t5)eN(f4}-}570mBc=6&z>Dl0Kt;p9H s;=t5Pgm6wHfoUK0Op5@vfX?KnH`rS)ME;c+9!}GlW z^HjZXWWS+PJ&RhO-LzJ5@)f%l{0*V#i}~_G0$VT<>eo6B-5*8O1qUv2?j;I0+iM5& z+PEX1mhgua=SyO_(#TiG;w_=mA4j;u!dbF<)&1}EB{k5$#5rd5ltf~2)wP;XX=(kg z7!o%E4(UdWk}rJng-;IMsxJOKioNFb+xRQ-_BttCMamC1-3uGLyRXNIV!<~t<-3kG zhEjaE#G*F$HlMKo!Y-`sc3E`OmpX3y8UCn%4`EWJyZo)=vl<(8TT@!Eu}w#}JH1L( z5&gQ0saN_^X513MaIwXr9$3d^E8pfdzDVvypD-~<$7-rKRtgs#wlN8}-_&!#oJ#@E zepTF5*u3t$S^UMq`MA2qAQ=z2B-rllNb90sS+B2sO040p=3{P-1=1F%SzOdR>hhZy zzJ29Fn<#UM5B~pfw7%V7cVO}2f^m7lF?Ei)vhziP+dJCQ0o_A(J;tnW)X{g8bjbr; z=({=nxGhd;HgM>5U1~D9s*Yzx2r*!^My_>sV2RI>Epxn6dF&nMw9+-Bw)#36I;|Uh zSmKXvepmbdn;ZsRv9eOLD>7M7-BvBhmjKP17Vcr`Q9njq%k_8++Dy4t#dpycQ-MD) zjtQ2sii|RHtz02lf0ZNB8b!=`f0y#R1-F*5a26L0@vik)SM-$;-7x;%f-@0sb-%Y7 zd-*-9kZX2JLAIJSKSU+AySMog`2&EfN^H3=K=}Oc43=$!FFlB5Xfc~Afo@bW)z_rH zUEsThCgK*6JsZ*PmX9Z$Z_LzV1k8SiR7LxR8;{}aAuikp0FK(%^HNivQNOuUxu}(R zlWLcE>CE2hqkVp}TG~w?JFj$WsAdzs*mKzWSgYvhb>N+VFrrG~4`Yt6T_q2VhMKXNi=Yh+GbTP+}O zZ`A<{cF0A$^wV0uW>8lA(nn}g6ib7SF6osJts>RU*JLo3f)Q)n%IfY{BNmr_x%DRU z$3tKRJsxYb8`e!zDV*%roJJNj#3jRb$N!H<>i TyK<1bu9X}Kvq{~ zxob`4H2W?Ky+fF)IjqQUFrt+TT_&j-vZ(RP*R;(9axeT9rg&lzGLh%n$i?>TYO}(L zl_QU%sE&s@nX6A?TtF_`Yb)n*Q}cRO{ctP1d|9bq&wFcU^S+1tO~vukmsYqqg1@GY z>619+AMVjl>zX=R^m^}h(wnC_(dvlj65?Ks1-yKN3pXw+e{P|4@u4ni82{EaFSEY= zMt<6-Rnz*o;o@85*d-Zf&4A92{r0{!?p;Xw)!Lc9rMdD%}|qGcmU1mQK`pesgr4 z_Uy}SrB)~BDP+vTmEgAWJ7NJ}-;bbP_eMq+kw*gYj#(n6H7im7y8UNehD#K#?YQ(E zT$Q>|58>kdakDVStl69|L4`NE7306*(22aYO)tGy>UrkMT{*LWK)6cyJCwIe)Wp)d zjwYrv1`_O 4MHp;2eH=t{rVbj0vbCRx`z`u>fFeZz0RId_qsCW;p>+&NCx zcUgso`>wpBdzl3GJDkI}WQ_(H;j_qBzwxd1xQ4stwul7Uy$j6T9OM?t@FXerI5eqN z`mg?Jy@mVMb%_IR3ST;5m*3vVT%DBl8B%`xCZXMRc|HHGL)zryHEC9kOQNR1p7|1h z+>>`!v29v6lHrpz*M45}(ZZGUYvyn`ur7XDoHr>yXQh)Or~_!}42v_ZEEde|Mqj>w z>7{yfdZpgw5%G2{ pP?eb{2f^C}=uyR(EE0(tRpcZmY``sT2R Yc{JoMsK|Op7fYclfc0Mg+@AtPm_4Y|FPPB+BSU{2#;`oL zf-KPYQgnITMIUop`C3}=PJb)2#-!12arOdIcqdTUgHzu1!EX6iM@#WF*?JE2oLJ-d z#`^G}75>!(O|SvC10B2V9db*MDDBjxMOTtK4UU|5X>g^t&Lkk6^?W342IO0AGtv9r z@Iq|)PC#kCNr5N5z^Vg^gW9f1KRYe@*w7Y_zAOIZbj0fZeBoJMn?~aojiNn??lj(8 zpqK7#wCc2VdsQA-^|fHrgkM%QENmWkS;qwit6OxQI#@C&epcJSYS{UhyqC^ywl(?t z9T@n;>#;QkZnoy3dfwr&SMMje!n1vCtL*BWx!0NgnJ~Knjqi?cb#Nk<>N0q{3BR$} z{Jz+ zTb9fPUOG|IKt{3A#6H*LM%j1$et;Pk@sZP-T-xFj0rauz1L?~ZchIh>oLJ?kbxhV4 zJ@aw$C*1kNYjk I*O|6XZFW+ zQG)QExJdcO>dTd;tjVr@*TkkpEa(e&zwNR5)mlj7nba#S72SEz#wEUo-|Nh`q^{~h z%1_RuFB(#n{b1L 8#iTys8SV>*rEW{@qsa3I$85btgIc^s13c%+C@ajI zj{*iQJT2HGxK?7hu{FnKdxwraD!*Qj>bd6^uQP5!w&SXU-g=DeTIt#Kj=jZeicnnm z(O`7Iv~0~}K$~ IvbfcUKHk?-`(${OxrGq*S;H+VqAVj?eW)3Q7$w5_rm+;Ha*4%G9$oG7P_t4 zOUbX-5PJcs;>*Rg#=?H%=&E$j6=-mSN;ouXrmuMFb!4b&H%dtfLJ+r86(Z@7m+ z?08Hl+Pv6peDMpC1I$)t663;m2Vb<*>J%SkOIytW+EqzCgR4@m%kpPi+RM_G0 k<}*hdC<_UJNtqN%Fc@Gd~Cav4}m$jDU-t)IcuHn{LB z9VXvh>P{N9n~M(cP(7?**p(UxQ6)*W@0;3lP-Z|2{|x!qF)r>gaMJGr{pe}jH#Fa_ z>jWPC?t|!F`{3E2Q(p4)pvrZi@6EtN0=H`5(qjFtKvTIS{PLBK!k-@AcKfQj(dgBZ zgO*6Jwuy}RU4unx)WRF|RTR0>W%V_-+KW)W@an30OE3;}NNkVZ^W6D2wwvg1eW_hf zUf^Tz_K>Zx%apxz)9!2k?)Vmm2}alre=o>?{@le*Y;n=OG_ITb1_;YbHX07ArI^;` zxbxrMJ0tjO50J?o>Sfv+R^KPK$lub=_++)V#&(MxVMT*}ZsLQ|;Jv8w5fF~3&CY+e zw9szH6irRKg@4>T#kVmZ@?!9YFXw5Bll`(wJ@=B)d-5SmzsiW8>;BTsw>i-Fo^+Ao zyV@6C;L5-8%^%!q>#9{9G@FEMo^&`?Z-j+kkNl}Gx-hTp@$}NX?8*O!-Hqe)!rRS& z^TI2;-ghZrYhjm#V%NrXq4p!zvYvMxuI(*)*uW~D4DlV2jJ6`gAGNvYZ^N0@j>{tZ zomRO$_187%gy-5}NgtY0@NRi19h}bM;Tp=^1@VSoYx#FMa94ZG8*wxnJ6UbmVw8 zZM?2@YQz^Ua!|kI 5@-BQ@?Hr|_w^D Em^oHKa#mh;-Y# zMu^=g%pLjGe$`peHudVaII^!)4@+WZca(ND&}iA@k`SfAq2G7^Pd?fn3YDR){$FqC zTubt62CzGR6Z%=Uckdx>=mTDWdrAcly^ ZkuDzo-wkDrT58rJECZ&sq2wj+Oxt@L1{q*YX*Gx+7 zH?_V?)?pvs^^SIv;ABc|lrmg`hP*f1?O{>&P4wP7HKOAj48$rv=juT2O=bCr9cfYi zs~u$DL -h7S`*BIjhZxSFJbV`q;t=*x$@F)s4cqmEsAQP)Q1hVdNcKuI Ml^87s({_(X>5P1v)1SLhcdAc!I0Y}A-`7>(kf7fLeVQwdmv!WbAUN)m-^3o$ zx$@QV$ZL1~ExT8J4LkB$J>Bo8jQH5!mg;V??^o)y@)9PEEAmO(F1EyLp5aJR(ESyq zey887jF$GY)G~mFOZ-r`JlY3o=~lQQ;*%DV`wfK$n`K|CPeQ-V8_orbU%vVZ-yMJY z@&1dKV=F8`^ +> H5cvC+C0XQAmH zN?P!(dTEz?ZjakbC9fU>R^fJ=@=LMeVY8FM4U0dH@+SI4Jej<;jFGFHi@dkkrnB^l z6YY2Lg9X90_@sy^G`-iEN^p`EKt`CN`{+7Je%|Pbu%PIJ?k8D28mZP8J0<2}+L{NB z>sh6`>l1GIqT{Gp>poY3E@Rc%Cr9neiH?QB%}&kLpj+QH;R)3%@f#Y8;2txvv{F>l za18FH8`aLeuAlg@d&ieai`Fw+;qd4_j?=zex#3H>>NOMXqMKAX_tfcY|0O3Qe*aPY z_pOd(=9GmAAUyJOlwrosSy9{< s`4Z*Mx=%aT%mrX1%jD{e=GhX2o!<36MJ#{u31}cr@*k z;3CS7UV8-$?c|?-_=E55Tn*Sv`8(+Mivh=luK13t7iID-GU~bszb`7g5|vIe|9jBA zTp@ftzno~TsmhqC`}IaccR2Shlrl@_Y{AjX;K46+^;S{>Xc!?1k^A1<^k4Tcy5~!} zi`JLqa|Dd_7VEkz+;Ox+Vxj?It%e&TNn+N5BL)B2wwk94jSHV*@7I;KKCez^HrutE z@)w9O^6)Ms(!*3bVb^}Em$@3k(e(G$jKy)3bNv%AW~o?%Gv$YtXhuuj*{?ij^_`j? ze9lKFr3a~p*7}n6JTrq(@S}wM0YF`OB08sz3b?l~U(S!lGyo}`U3oQq&%8z|vp-Tw zX<4PW&iZCOt9^PBH|udt7nEJE?yP8BkK%0W-NH%+89h89-#SgpNAwT4*o nbY^WM`)Y+}yx+a+Sl{NOU-t_=<;YiDwcAym!56=?!S(QV_j xR<#Hn|C#x)3hr0F`y^BW&ApZsaS787f06lmF6`VU_*sr*4D03e}> zNi4O1uov%l@~5#~>H{8fME!%WZM0;w+~qf@*1qh5KLr4M{MuT1gh?y!x=-jt*Y+K9 zJJ!{m6N|gd*Y2&pEOFz(fUj!Xr5ayowxV(Er20toafjBo`heEXGDhe;)^@IaXcE>h z)HT?`!_ax}`mx3;tA5^ldF?};sXMOrYp7(2cP2e< %NKB=ZLU-*_={UdB!@1r0$R{Lvk;43&F65*DU_`p@y6=(ZcBJL5D&+(S{ z4_^&(QMLV&Be>dMU8?aPV?n=Q1DX~sQBqu_{!_4~osin7BWtZdb4FH@PYUjA)gOGX z2LI$kZR%2W>hXME0aSOsWm~1|i?0FDGcoJmM@=lsdvjdmuaj+7R_J%DL{~Smw*K };e%2|D%;vyLWiJcx(m;9!z4O2NZ{-}wGxQmYmDW4jZ~Mb0 z)3 kN)|jr+uGOfaGYGym%O zTH)yni8_ |d~YU|eWd(jvxyVTFY#~t}*HGRx#&TNKW`JKF$qfDR`bio0&lFM5~ z?|6{M7PG35$Di@-AceY@eYfV+J3jT>E@!`;(SEyvgLXOs2GR`VT)v9|wOPELY8zF$ z48ptSAdRLuj9J#Mr-U!EMFRSY;){K$QQn-)cJ3Nn4%S~haI_T#xbrI3x;pxHnRyqk zsuTED%5 8?%@?>v&NrK7CI z)|DSZU2mf*;~<7X{daGH|MQOXE%23qI?p_^X~7SQju0zaX{z%XSm9z 5|?$Var(kUe#XeEmO^KT~qa#iJhbVO`B>*J1j>%d-lSxGm% zdtb^%2bM9e)k-r5{K4J*N;wUs*+iB(bTWL4@q+hiQ|8x+NH@i6G+eqMay+H aZ0D!$%ehx7$tqp+D<-oKz(7o5AJhW$@S3S?vBTW)mP`a z_;b|>mC0&l-zcpsIZ;K9f1J!@3y)TrvCS=c*`uJFLsl@}Ud}I8E=`QBUS10D;h?** zhpGke?lGrc=rJ8|zvo=n&g*k@Ys#sTYQA~oR!lVPLPp)+RIy~< 6m4w6nC%H z#w$SjD%# o%sJ&0urgYr3v?qRUJ+Esy10>L>XyCGse&c}U=u zV0>fjJe9b7Ks*I%Nmb|izz{jv_DMJWvb3uzai=Kbimd_9Y|Xw!UoUmRvOYb$l~~XH zX@{YdZ33Ug>Nu;19lQh1!}_Tfg eEw(Uk;Co>#)I&(2T2SqH~7 z!m2Q5HibL=vFD}EAo?t`n>@PaCR9zDl$VcfK<*Nrdpw#u^?&lgG7#+YaIEn_ai2`L z*yGNa^t963oZ%IFLbf@7ZA-Iu!=j>BC0|#wW>#wTo5`6ebD0xW@LXEDJr=swJ$762 z+xl&5@PeCJ*w%a+f(x&!{GzgXgAU-fZjrGn1?e}C1H*b*4%^}j6dLWiQIiI#OHUMA zvd#<|M1S(0>R;+jHl;YzWs_}W%kLh<(MI#-K>1g9g;GV-R%>8p)+m^d_UXV+YPzX4 zkFj^CcipRi#H5~(Eze&b1B?JU^d;Z^sw@4i^LCv~NaDrs+Cl|sn7U$FgW|0zMamLI zbuT+n4K>-Tb+ySmjO*4%TbD{jUCbFMcf<6%N zecGZfCeL!EiWpuvZ@o2b{8sC}JHE}4k6ADFtZGbImRtlJM0QnY52R8HF8YC+fr%=! zluMtqYVwW;p~mgGVXvCWlz%4ltTv~rI9WyAkBfmj8e>|!gHA#{4a#4m2B`V;3bDsb zX!9)Fx?r79lTtG_ss2;sD)WACN6UuEohxX_Y1ZnSyQTXz0S!nyayI2S_oHmy#j+>; zKI=6XE^qA|Ux;Lv+r(#g#)Md{)sJQ`-pH;Pbg_Yj681#f5LNH=4bmbGk)G40Mo(={ zcK30@mbL1l*?Qt={v8p5i@m9$H(Gjsl>SiS tW3 z#?3g`Y66pEZ*AXNTO|=%Y`5!$2CprT&VSo#puEdtjL>x13WIXN#ug_;zx=AT3l)IW z)p(+Nx0~8I>F&MqRu0fHHu-vfpZmi5c$Ww9#IV*yT`9~Y|8oJ!O{P38)30@gGD{5C zv8VXo$E8^P2<3&2saU8m6#tWcEIPB2sDl*c<@(t$WU0nWm%DY?r7OVIXI*GSdZO#i zOSzS2ww}K!>$i;f=HrgW{^kCltuvohi(*z!dDq&z+vvD%xAa-&KE9!io6APMXPq3s z+{Q#@U-AF7$bw`1+!SA )=?&(EmNGdlcU@xzP4sYDQi4zU#PC z&bW1cW}O|>0jmL=+N;!NH`vqK=~(h^B3+i<%gw%gPE=T(?%mOu(}uYo(9y6}R}10s z;nyG2m(Rg`e`E&eZ#K6T%P&c0ziG--$@5p+I?fqrc)Il2eX{DClusHZ@6Du?Wf`wl(a=ioEB?E!kCN9LVdqE4(Bv^a zueMsStWAe0?u%Y&7(xu~bW=7Dz{QcXsiQs Fj|jMPlFd7%B|qei=e3p5!h zm1HX`UQypJL1d$^(!TtSr5)Z`lbzn~&y~tiIz53*W)PE-7iS;$J?z38`)kXSKFIOL zJ~}O1O){@e9otjK5}$>avq@`aSFr(yI d)-zc4P3}&aD`WHYtVDHQdynJ@LYD(%;k|XS6+SuSfNjI3->-#^8aieRq6| z!~1trA4cqyqE2-QZIua;Y2zv6HpyYPNfP=nTjcGyILoYRsU&=-0k;dVX4QL}kj qZ#`joMEzWbboNhZqEUK6W$gNJ?1jDLAU$Auizq8KpZ@#C$13Dkm9)LFA-muO} zPHl29evh_x$2#e&>?~XtgbGmeMTZ!VDvl{PU1;=n#-x9SO1Kxjy@OtQLmud!kktTm z%bbdz1FfwmuOQU)#8;$@GWfOPFUwwZ>h4g#W=k98n@Gy8eOc~J3N=ZTl|}zDaYNmV ziO}#lQ^(3@uC#V)iW4X%m1^9TX}!v7h2E>3uW?X{My_X7>qH}$9xGFuT=+!C-ePEo z5BAhI+Cn^m=HlZ+KV9g8*0;(7$|^&9uYhZGW>pD&p;_;@v7gAr4{AOZnpKInt?_cw zGOw?8s lO2U@_ zQQ?r+b^RxG=~hU3Ue~?M-*Uns@~!I~Iro`c(DSN7s{!e+&^L{*sb@^NSCt*)e5tGM z)wVBZ+F@(m_d?7|*`|*7Vb`h+=qMsTvb~O>*?+szxzf?U++25U2LeKt{#fN@?`TW^ zB}O!r3qNufP@CP#DXPjk7PX)DM+`|}TRG@QhpP7qQmkuNotOp6Fq$kEWsSPpz*2N; z1VFwQ6?~))eW15k&rMrlunDF$(4RB2%gnCJT?7S-`vvH!vgx80z_ OZdS8C+ZJtG IASl(NbFIVol^fy7L(5E&N zM=Jgv2@ms{p*`Dpd;FKQj=>3e=Rt2VaYs4$Ie^(?6`WN&@!}!kbnelUMpbegXSULy zec!=^v29`)E`dpoL_F)CiIF|z=P8)kk3D&m8*ewvjNowJqpocX9%kQ`Rb)ej-p)Fu zj-s=^+=bW^tB8XpCCfJS(BOYy)}W^b;p62vhx}ab7&H#rf%y`xsmDW~Y_70=vZp-f zSkcwn=Oo*5cTcC`)(v3bd 9=X&Pe59}(rD MBf(M%nk}K0P(B*hobj%lIJAcu1SYWx%lbILm(#(z5Hn3 z^UJebV+fm|j}Kc#`jNuL?~XtHu=LNtOSkTGw6OS0^~3X$51F#q7$myJm J3(?j5 `hVvy^Ee@sNY9r31TU9*mmCzEj0D>ku(u^O>z)~7jf+FfBykKfL-vw!qk zmWyP3FZu4mpgnmT+%{A~CB_=L<<5}x)Q8>nY9~l?iJ#`bh$(_^R$rQ}mka=gWsHLm z?ZRao1{tFjcc5FHq>WnnmNsnV z#3+cP_a{}v33=9R!#J+sLZ84B=2t4jbqSH?{reKLTX8XYH*gb{arnlVUUd@FWm@Y- z^i%A5H;&yUNXlgTlm}o~pdjqGBIWg1eRH`xIssH0LgDo9kc&MB sPpYe zSQp+VM2tN^)95TGh%R-K2zV_wx4Y(ix!{iqzT@yhc1RTjDgZj*1 I-D1%x~JaWw30# z-B5wvY(J9_+*}o@WnIrWOGg`&cC-HEXY`pJ#@r|L0vuuunMlM_T&jR@+I{ck{3d 4QP~22dgVh*scjDlCP 9au6N7j zl+og zJK1>pfd>l(NiKg~#_-Uw!u$_&?`(@$zNzlY2ON0wG3% zpjKFghNQsgGpK5d?q!jf*g)6&v@MEOFD}Z| ?BbZoJ9nC%ie1%m_2-5%5B`=lb^o! zFB^#~n@J2;0_@n!6|>xUi<8U~`8j}8MX(i-JmhRwq`|w;^MM@UMXA1>y%$^junV4A z(Z$=&E zS$f&*HEmAlE$)b6t@bAJ(FDaE<+Rt) zce{z<;LwFN-Pc&>RipDNq>4RmuDRTM$KZwLAUJCECi_a%B6W3EyT$MQQrp6j*1w9~ zQXnm!t2_IIB@?h}t7G!mVxl(!oE~>|s(g_;8qcGBXZ$o51Z*-%D~y?wjwTC;qRlmy z+6}dcBZ}3&s{mxoM?a7<2p <-78)V0;I4}3$Y$%ymlS>Eep_xuw|Rb12yXrf z_j!n=$s3ct^0N`Kw06&ZYkKC|=rT6F9*%YOd-H=PePdnH{IuI$Pu$C6ZPizQcHuys z9!+0y^rHWiQ_#7QS)(hoyX8|gH9^&GX~0FGQt;+B;96|_n6s8q5u3ExzV2&hGF8CU zUR2mxWwtaB*PI#tY}T9dS`(nR`)|Lq6}!1WAw_@sxoBmpz$j;F^3#(E*7WtNNxACn zytG&UZKVT_dMs(ndn_7uS1_EjJ2{1g2oP6L$iUyEXVE@aaxO^1U%G-yA&qh1?7OZS zDo2rFMNEsDv4m%D=NTppcNJFNJ4)_L`#_UPl9LF#cl${DsU%b-g9Pbx^c_hGsU;GO zC11|!QI6;5ZjS3f+%qXyK~V8w{4;O7C$FXqEoS*V`%)#UZ*vC9h2o&kf^P4CuYu;8 z81L7SkIe(794lUh6l%qdUHYH&pyWcGcU-Zk9(%&%uUV|N*-$!9O!2O_)k)!l 1z#GM|w4Rcs5YY5)j~gzUQ2T+p=+Y={ zR*%g!onyjZjmm`{(#Yvw6QU&vBD2vo&NHCR?B8KibT9(j)CQA5W3Kd!b6gQLm-!-B z9Yx=q!Ewk{SDtz3ohM&+Flk3}sgt=H+$LphHIs{lMu6Y#UHyrom9XALHfF+?q_p2e znr~#!dx#mjlHiJ#WdozXo9LI-6AX#+>CAx|cw`S}i|l8JYul`@=IF{p4<=3<-)Inw zXWzNl|GVSQJ9GiF3x5aaH)}FT9Nx+XPTA$+K9OzuP*A3p?Ld5!Sv^@YE*WY?ZwR<- zKdH`d`s7Zoz@YyM?DN1>dte7;N7n7j*RoZo`VmudiJcbhIug@e{{>bMKg_gehI;Is z{1S}VYVt2*LE-sq)Kf0G1pq d8BK z!oj<*OFiK=C{Kb{5{L8^#^>K{k#GR5 5c(Ie`#QS7e$* zWoT>t^fPLM$gO*fb|;g1v+OP_x3@;$h{?Q<`IMK@1)6NqpUDkU?)0-&y)Fh>#|mdm zH-{W~28?6#obe9&o_ch;F(&)gRHPLcy0%&IgyRA0T9ABp@nwsS)+A5ZFbpl`K ^*TM$=8Um`_l6Ei^Fo&okXJXcRbC))n!}=qO78BdEG@#N{t?>bYsn-2r2uUaoFyC zEC#e<@c>3Zxxe%jvcKbNzJ(=j%)xE+-d(n3Fu`rX=y8iDzAQChjl0=y%D;h9*bBPd z0iKIv3P*X}vSN+DE%a4H-s-*RZBs@1m$c++EqzWOPv$sNIAESgQF^`J2SXbVrf#c` zf#gompClC8vUUD3U-C}K_`pzO$s3cs+PSoo@UWb|`>q^Z^I6@#EXMl2v}rQtbclJT zd{x%x!RIrq!PsnV_r2Ho;7~ozwq`X_Vj GQK}Od-m) CIY`Z!`Yf+Ju-@-ctY+S_bQ(F^vSu_g zb`z&wgY=Y@_dreq;06|spuFVt_^2Rwl{I^qpzq=+Zfb#)SL@KcN7G0vpuO;OMLPD& zq>dP9J$fXEE22XxSr6-= dR@TIJEB*KDGg(bBLZ`y8s+BeX{x@~2GGDb2g znJl3&%@!V7mZO+Bi)^iRM;~R*@A9gEDN|qDo<%p(U>cBAP;s$2Lp`=v%cEn^X;2F) zoFVNxAP|f}vEq)u#l9)yE$ymb&mg16}GMkO9!J8^vv@i*#?gt^A@#Gahz1(>qo8 zu#jVlIaQVfILm~B@x>68aI+?>`r5*&te^GecgTRkJBwCFNeO$?5w APaP;uw>qTRLCQ7ErFy=6#7#z)gGoFd3XtXl1p?b9_MshwUb~Uopi!?;a6i8iP9? z;>`%#V&cXgR@~cYrD7~L!#}>F8_+R8#+)`2zLm@+)_XjLs*)2MV#DyTC99Z2IzoIT z8T#@TT>N9Lpv=D!L#4Rg#a!lSV@ohb-dj%D?p#yyBz3k(DsqH0v+|4XSAAGtd{ZlA zi6x6wFSb_SzMUR#S!b29`;G?PT}d{H%hz$!T1ECXI+O`lvDK8du-mZUlGxTbBiNA* zTj|;6ecPV&*D>j9Q3?ByF}?Fep&dm_B^cv+jpM>EYp=8SA@gxgCO>s-7bXWvL2Gz) z%oTXChg#-7wuJ_^_wL%d-o_M3@RvH?{RCpmk6E62dj94r?Xl8UbvWCyWR;vvEE#Nq zg-!Ih@mALb1OldVQ47Z+S!7eaUuozZ7{6R~zicpLZ-i` 4g 5{Fw4!IL{y*$Q2dQiSg-`XAnE0&3g=>{2ipi&5 zX~%Qz00oyX2eblI2j20lN)9Lq5Qy?AFQ#5yI-tGuwSy%fz8ShHhM@*+Bx$7y)8J?F zp8;vj3Ma@}(dpnUacSC|<=UI!X`^k%RlnF(U+*1{_v@)l-jv$mmBE%5GgzYuNGGrC zu_B0kpghky_na|t+@YzhK%I_fFC$2Q!&x{w5vhy8gaUl@t40q1S0c^os!Tp!W9sT= zh15abUur6W1P8IJKCx6LbLXBroOxsKnGAds Dp)ouKjnMrleYoh2$dG4p{-^8CbHAnf4gdOo9eZE7%%75sjArv zZAa+fYJ>D!?w7XAa6 6Qc9F zH0><43!LnGV*JXVs?C-LN=}p`GN^4Nv{=EH-(;G5bAaIBICUW&wQ(+48^?}r6_EHK z_D3?i=+oew8+2t2b&s|L4tTxnu_d=%lG(%y%|d^7{CNkB!z)fPt9UH2-^-=KC9x9s zQ oShn37rd{+i=RT%g`LV*_HBxB9Nb z8@0jk1-tC>xI-Js)oL0iM=ezy#7hdM8OYn}m`R5^%2y0HWi}IUHP)r0GE|bq6H_N< z+!&SLoeG)A6E!YglQ^oF;H~HRFudUT(q5^8E)4Cy%Zi>s!@f|ddPxjX{JaPChRsbc z5jc*L{dw$-rod5os=%6%gJ+D_QlFEv%|Eu7#My4ADLHGNiIV0YZSK2Cy=Lrm#!{}l zCoxHCF bVV?-S>J2YVs?4m5}_1823jUM5CKNpSK5o$Ik-m!J_ ~?hxC=a4?~kq$a-mU(NIg$qB9j$XD+LH#F1VLhq})3A&&O#-eSL4FF6W}2 zc95ZtID5D+bD7=lP3)!Ft;n8zQ$iA>q(1ePh3}~TsXbE0`y3GO0e}o(936L9v+mop zErYjl-&x=A?Q;ObGUcOVkmN3OioBHpH;h>abURi|kkw=+`sG}tM1Ie;2RkYW#w}BZ z)<{ANt&f)a0js^NDju5UUVZfn-yMJY@#5tRF!4>EZsDJiI JwV(*=(iCk*UGAptRj_uxin3qu5Bq4uP)3nbp@edqBm+cWxPg#J5n->tbN zz=}~=d^SH!^u7ER>t AYwn*BX$6id6SelRe$E&W22)3>5VM(rdmo2+6xGQ!rM58a>YhX|u zG}GS_%cTufGR&odv|X6U7!reaTQ6T*Un|x^>c2&vVIj8JTiI#xCGEa4tevNcx?zj1 zcy%%MY^%2SK#eV ooC;pTv9+9@99ZhHe43x6j_wbCNUs#Tt*J+ z wMieyJ`9IR>R6u@+;)SAD~@W z^L@B$M@1m4@mzK+Ijx2zMSL6YTT$8U-Oy=aP9$5XI#!+6U%OyX`Unf({4|^7c)a<_ zR=>6TZ4>)c2F0mu9S@-~v2~ZdrYtxCw(m1n7t9Q@JKj}Ci*E8~<|$?YlUS ceU0=$hIykQ>9EpQ1H@@c6;k&6+}o0u6m@$;TAOhT s_d#c=fd<>FH che1^)E`|L^429o)< z%>ehE`y$3Zh@r1=FPV|=53_Y)FR9DXnCdaa33xbdaNr#!e70f63JYi{2kH{_GT(k~ z!gFx-Th-~}Jg2Ua#Bhif4TK&dYW=lUZ(D2gFR83EY=bK9VG=lWm3tRK32BXLXkqup~YEnZn1F2&=05hSx zR*1~{=Tm_do|k{SVkTu;GAvpYpHjY7!KRwaYBD*H6@8V9XpKby#Y#TTR0$GE-UfE; z`ZiWCJ=Qn6r`geG1ox@RJzj7XCL79CNyIU(T*~`_8pv4iS@A~ofqF=Jiizs#=iyy; zPx~Br7kW5MihE9`kP*6RNypn@5Cc?uhoAWF_;$xs-SS&+8ANk0uf39=Q@l1;Gj3&k z`(96boHBbGF$aBC?!3FB^aLi*TL#C~Nh|`PO;=(uuF-)oAj-p^D1e}?SIkxVPhPN3 z+~+afb@sJ%lzN!997!`K`faJRI&2;1UFh99)}|TCzwlt`3rs;<`k74$CJGg;FYv Z`N^<72Ww_Hu@$>%BHUmJu5#seZBAOo3;KjUh@W`GkG*tIKs-Vu(YL2PJ0Tx zhq$Qp=fdI^hsm3&om{JXa$IXwW=w7|jxeOp-;MO6c$K <)bkj vBR#{bJEkt)^O7R<^qMGRVJ+Y(ha9Pl;<4k(y!n|HBGSdOWEuQpxym}H|& zr6$37F(#p(2-J*+(#Xm@cby=b{m>QLsf)@9cWFH64p6D>wxpPQ^ATysJV$%XeS3xH z>k)dq=U7+(0F`r@G7;S&y*6J~yptWYMHFUGW>!rWpS_coPk6}(@28yE|0FHq?sI`4 z^A58ej>~p2u1SB!$mp>xn qI7- z3jn=l6xBK2ny1T^1dpJbvP`2O2zok>qW6x6CR)oZy_b_C;%Ma%LvCIL@e&C%xDnPB zPvu(mP5><0$R^dnUTPt0?9xsd83_bb!1jPfb;wNalx}p&uPURph290iwSiF6op L_ zFC`j}=-{jzVGDK^aOEIC^`aI9F8aR8!-SCKk$@u1gyv#MVT(2S<^*Gua}llyK8)5< zR4(_RuNFaJ->{=iS>V;4Vg{<-iA!7QdgyK?7`u`w4=2uTb&n)()Y ^bXBF z0q)h;U*o&uPd{F~_Z|iaioJOLnFq0mc6v?DSlWVzK3gdBJni$btiYp+jiMUyABw$L z)UWAf`%Yp+@9i2lm*&s1{LOV|4I9PQIu?2-O(3);TFa(u@Y#9Vec(LN{h40!oaisS zz=AV+lVXvJU6KaB*zJ&GRG6!H6t`U$69DR?FIa9sjd{?000du0pB-!5Uu0S1bJn-l z{n>#T)C*?$oUmy(7jLeER8JYKzP6XCJ{K%=Lek|)qj>sh_*2v%9Kjn4zM}4-Y #S7l} z<0S}aDqMA$y2Qzd;SaaIC;>uo9%Y8omnv2IX=B^1iq*xwA+k-o>A0iUhblvSpW2Yt zr5%eg=#4n8ecDR)Ew1#=%id3u#ie4WP#kmgktmiPie0fyEcoPQgSDt; qMOEr;CSVmgdwtIh}@NpGo_~`X$Gg82`v #+d8^eay@?mk5!W4Nsf zd2QBiHP8<#^26oJG*D|o%bCBtr&dit>+_zN%m6UzGG6D%Y)x~IW`ZLm%u9jVfIcqj z>LpdUxuNz1?>uX^m^fg|M%@Ia^Vnmmrw_9IBG);CM=;@vt}O;Mi8gc7U{qaH0=`++ z$(m52J3zB!lqCSAK@feNro_5Xo*;P!=;}Jt22w0>Em_1senY r~(j-OnZB*{|yB`F+ft1`y2|z+DWR^v{7b>ttWKSg>d=xolg3xuyLESWASM ztTY#p+>v}2TwuUquJDn|q$6nsp}+V?WuD^lnS_cYQSM(Pz{r_TI!6!tn7X w$1SICxg-&MV#GrVJE9sg0r8)>SU(s~P~SXZ>YsRO3?a~j{PUC~=#=t>`f z!dFjFM8nF4dKo#1LRu16&f4mcR;0)#*SNn@_Bn+TzF`x8mw`Fkt|dvd6$m><@)>hB z8PY!KtCe6@0j;BGFfk+1@w`s|6ZaPumjB6d8as|v`H ^h_G3Uttg7LPb{G6* z(rkR+tR*FeOoqx#p5?@arKWs${22#h(IE%E48u3^wbb{*XMM?_CTB)GXLI!yBu30Q zGu{H8dg4hz)7^0_lkl@))j8q?%BK0`0eakB_DfY3Y=GE>wxjirYKR{lW5}0{k4=vv zTh_xtEp+>nX4}=LTv-?VRz8tG{RK5ByY}jBwj+Jc6@qrPOXA)h`+IM?$>Suf%8v@` zNtnXuN$V#2U1rp6B8>>TGUkppxV45goD)1%pA==(chv+FP;orHns+`0K=OWJWPJ6a zRhXJM6!mbGZ$5g@ b<~Ro5vwIjW*H+b z{yXtFb=z_r)82DF+;AWJc&@}ZRL;S&yT$GmPokdaIPrw#bgYv)l`N$kE6Pm2y;q%s zvm8?vhuVE5Fhj*iVzx=K=zHkS{2g+d`{-m{^)g16dbGCs+vMkpGKJY~tnURLEwPqF z%XIZrX;fcDE;%L%O3WKAB?y%I3UH-N4J`*V_3e6a)nnFWg@1!*%Kt%kd+aa9KU>gT z+cQB)hUjeC=uq5-gG%4@5t~#ZvAS3u`z#S^jZv)YqXF5U<|E=uy=-&gcgQ;9)-;>S z6Yv)n%uB0K9IRWC7MB X-JXlD?gJkh2Q3b<(TTq-$SSAhG%0e({DD z$Qy)>h8z#miS-3%%Su=IaIJS0oXW+7U#;=ki0NJ!mm)H^m8|fwugqSX87_UECo?Fx zli%eiqv_`2UFBNwWLgxpMtfblYLV7wmvbU>PGiSR6Zru#w#oVc9C^$nLBdKrZ8a84 zCeL-Y5#L}#5M2nkB|UBGTHEjf4fI(@ZDaB-G>o*}6-`iKE5&E!Wz7Nwk0e+ufw|Zu z&{kAH7@Eus{r3u3=lxS®wLrmOg7&9w@~r3p!t2M~L%vY $5k<{HL{j94Jp4T?taUyNeFI^Dl+|H|}+;u!? z(e_O5EK+oEkbon#R#IGD0_|bNL`mR!=mLPdy|tSEp&V$_fXHW-ITH>01?A(aw{5rK zH)ZO#$dBf^a`c|D-Os<{C^boi0~0*OwbiJc1`6$ZvBr`&Kw&5K&1oRH5(2%hywUOX zcYA>TS;u>^2Plt=K7=2gdpK{232NIGH!@J?Z*vzs4_&yyjnpB ^bbDq7pj4eB2+QJ7PWvo8=Oki9LQD{F})_y19~0 zLbdWkNpR?s%EQs(@fovQl}|YclSEfh;<&2zVy$HET#P|lOt1u(gZPlNHJpu>Vs#rF z%M&!6Fmc&{R*|?@-L6g*wzvM#CWXD`Vt-_nF%AeWW*=y@oqJavUeD!N*U<6@S!x2D z-mvf#X^H*YD#dHeSV5Ry@+(DKo!Yk8{!Fk)+q&?ar0a_JwU9P>iMLJaTKr$CMA=f3 zm3)l$OO&lYZOrY>Xgkk@X>1S8Cn&EAk9rP6^DB_Ly?3)fxOg@S>}ZPHxFp_msQ99B z`@+|z=u2g-U$~(KxG0iiB}l|}&sV}%s`vT(<;xddvj84IzphcX8%YL$0`OHG-AZT+ z!3r$)WX)-e)63?jsqBu%5Oq-V#^R7S+K}L%f#|+8%67!TLAY8#rHbc`>y;E)Q)!FQ zH79shzGYH@LaRQX=Qb=olfkYY_lgQ3dpeGp4d-kq`*o+6tIn;-CGFm-*_zl~b$PAa zfaF?-oN`R}^mK4Yu2Xl!hE@5gOO_odn$! )x-Q z`n=&r*1WMdq&CwS-hkIsyIK&J-&Mu6sm_$vAJrkCc=ISx%jNX#q6>Y4jj>`+q-5}g zP0_dsut(c;P+RI?+!4uqDnbR#$e`b(FLU+o##+G@9v=VYBUyv&Eg8rcgO!$fo}Z2? z!uIf%x*j2+j)UX~R4J1Q4c?%I33o_q>p@y_FI yMtLHuoAQMK zcJ&jAOH?BX#5vJeFa26y0(I?$nf8`GVXymxM* C(l}dJe=0Uu@t~rw>Qf#A5b*$9?~abqofYfw zasJH}H!d_AF3t75@Rv3v@uG_ru6gf>Ng!o)ETi;Gm&+#$7J;lO3_DNe=+3I7C0;sO ztT;%otwXTTf#rKmvIaIm2H;*kKF2p#1q@!#GP!?WW)`Bog65HY&q?1*bV+^Ee)ibC zCa2S{)=K5|D7*9IlS|ii 4Wq~bhAu;IWK+Y-=uM=aw*zy zu^f`LpGApdCS|q168l0X=gO^w%#e1*3n>Fz3!?W&&Wsj m0w(O&2 z`#o_z&ykOmS0(9Y=j3VEkiDZG6A?~@uI~D*JxZRv9|m -vLCz7nU1#Z4OPQCVeb#640T(u-`mwLHC@M6CjFgbK-}UTq Y$8t)7ZwHk!Y~Mpy|wL4{^=HVHh&erwI gNX^Sp4RQ@2YMZ#hCy~%xVI0^|IJbVxR z0?;+KbXzeFd5;&?IB~--xSc<&t`qlnX;bx=x9#5Xa@u0I`|QJ-s35KEb5 a zC4x@~8$s|^OS||~2>q8}#f!hat&(B;4XwkHZfUrG4~7 }J^Umtvf*p3Sw*Zw)0 zi7b7icv{aq5jC&rYE_RitsCfesF<&|M3Iy`@V 1q*Qeh(Wc~In zzB~T( FMM!f8H5kseS~)eTKlH>F6KQnDpwZ9=Pq@oFKKv!e1a$aiV5$90lWG{ zvKS&~_wsjnKdTd2IGSIa-4OhVFT3c9`h%&i(pQ*(VPM%__wh09Cq4$dB+Ll;GX|6^ zne?=LB)?j$Uo*~d!CuRFQqZ;-PAPbiKGn7KV%pph@7Rn5Rc8D>y}qF~d)dKAwYJKN zKH2 n zXJso|E^MaA$#I6fTPAG}LiaA1XR)8^I))lH#Fs#6Bcf~hlposxH319$)MQr0oP}-r z@Mhk(#Be&UX09MM3`zS;TqEMT&>4Iy*zVsIXLuuX4Hc`_#FP4JL_C$U4}z`Ti}6C+ zOfEK-Di)%}vB@`B;CNu)so(L|&GKP20n%_>n*y+2XnX*c924rwY`o;L-Y+(ozsr=; zM)Mwt0UBMDjaME>buD9e)V9g$^NAC1rtsOMGvg&@b z*;((=i|5au*VL2gUDpbd@d ^yYlVP0I7LOU|sj>^PnK`_*wn>2?a zjTaNpT-A;)G14|pN4N@p_BoRQF51KUwxAV#^{k=cV&0Z7Nz5#)bb~A8M!`8_SHWA* za=TAiwJEqG7dxMmQtw43i+kvhMi^yk;Hf2A6CrPiRT?ybDw4VIyvs`!tMj$Xy=tk1 zZfIU0h^f9bxfDH~pla8?FTb mCTWMP_`rT>qt&o7+)xRKRx;`3L12U^* zKY&ZpGy1{_nw9p_|FYNgNiq3E2G>J8D$Duh&70Tw?)cM>XV0EDUpnO-lH~v)6|73u zaNim>@W}k1=rtj7+OgW^Xe =7IG!yV-t3jzp#oiem=+*Q;Yzk0& z=scY5^!r;pSI%t2U1)KPXr$>o00J2|)xUYZe3#H;mhY<|efxe+5<674Xr~kqlklW| z-}$?eaDGmNM@%`NwyDWg^6n}4538qJn*E(}*(_g3YoSxz`+rXQ#w0a4$s}&JS9_xx z)Z> Zm^g8Dv sB}q)nPCFF~RrJf!=+|{}c~oBHUV!lvB*BXelhe zDaVd?2?@g2sTO?74QNjlE^Y9J#@-1=SytO@{1^U~ZgzS>TT>9WJMD0+F9XvoPI9d= zJz6)b-LOo*WTDdS-el8-eB?gpo2eVeSia4qo%I|_B}wI-t3t #c>+OndNm5R<% )IEV!@z^EWDIVVXpi~2lQfz8c}EtR8bwiwsC z+`nREYbVdyo(y`&W6{yZsFt%2hL%7|;1IsER`(pBX@9%o6b;P;vI#L7eOxfwhP4Ju zdGj9C)^HSL+f7iq!uoYq443vQ@feWG I*hc_ag02yfuME9c)_wPqTM zh(QZIw(x1zZ3#*Ydg!RhXC~J6fOg9|32cZ*koOFpoH`lJ?FpD7H<6P9U?Hl?rX~c^ zR+WBeU{=Xw7ZfsiV^AhSa)gB<_WYE+q!P>MbBGUg9H{$}a^FUo@d!{>VxfBneNO BhqmaqXWK(Z-tNO`fmeO N0 O~;m9e>&} z9~Grx6;CN&ApB|@;RBo)qI%F*p9kfht3gqNpz(|P4Xf=^yxY@eWp8T7D?m8v5%k19 z@I`W-E2Jh(v0{yOJc#iTD{p+OYz}U`v`_5N)Q@>#R}Ro6-lz#LV%>~p*6l^AK6wK$ zDiNERgTOb)>GRy4c+ah*1n)Gy$4scX_kK)S8?0d!9zj6SZI-fB+)9j3BGK_Y`sewy z$d+_T(-ZT8znU;*O!5@sBK@^Zz^o<5Q-S#$rfwDdB|lC6s@xo<4|D8;&VT7z#{MY# zSd< WMZ^~;fv?H zcX!Yg%?Y?!8~JF_*Cmh=)Q*>bTF{j?I(@V~ZE2rVjYaxnQhk&7-eYoxHsmE=M?X$j zdkc9R^6bRv>jtlf9_1^6gjdoB>RNO!Dbu~%d4r=pN }i|1jnASJH6Gs@M*`hZ zxE!=ZUk4wy8e5US(J}BTys~QnD6z-vxS_o?OyjqP*LryAtj6zXk|^mgcFP@&n!VE2 zvf19A@U5>v^%9IE#mctjp0MdQfka%kjtzyys4 1z*cp3OOxxl0>35VLo?TJ) zoA>J}(#WZi7QW&A^xd1z`iG7c^Q^`9r8mO6O=5$6=4zX tn;F)eJq48ice?Uz#+Pz`w&?OJNsp-GX!62fCK5P z(g34TSI0BxN$j=Dy-6R~-=eh{CpqIK6I*8ys?s_xnqfCGvy@fCW3P`)a(MxLY8UEW zsH!Y MDt XXY~4L7VLBD!3vsDq$OSLl>tQ0T42 zD8ff}!d8e05%Z;8jh|)}h=HnLWe4rL30OwZ6VBcvx-2~oXX4iSvfF+E+s!I)$p9UY zWSmK0ex7@RiAE9{eeAQir+(XlZAn+uGXrl= wmX9z>mqOZiKe@W%`#Xiew0@9i39l>G^#y!`>sCqgMqecU0Gb$M9*32 zZkM#3xhjRb##X@4+vlLi7xR-T)rQZFM-t?gjnO9SF9e%vARnqhc}1Je2Rdr-n~I1= z=r!dVI@kDDK+yV_#aNHrYv?$>ZvCk(ci|R~(wfE5r!JJPlWlVVXK-DuymalZo$E1W zH@m{%`}(4yMr#Q0{MdyzbxSpFi!LDaut*k#G0SdzmjM0qjxFBasuMSfN3L2`N0$*b z4%azUpR(Wmkz(*BN%)M9Z>fxT#i$q5|0rXC*hF7VG{4n;gm>*Erl2MdqEdNJhZKx{ zl b?hhpin-*yxewf3eL`q)$n#7yPr>kx?eu9#q;f}# zuN&ROE}>beps#J%FVyn9U+7$Qx7r69^331q6Bc=SwU<6xu)hizZgw=0FQ%?5 GxS&tDi5B0GQ+C_VnKk?Dxu4MKb+1Q9 z(oMnEzP_bby8RdX-!LcxRphG5B3yWb%EdHI5PnwLVUaakTLbS4dkWuDMVX`FCDFCk ze=qFLcKr^Iy>7QP*Q8^Vm0at6v$Bvj;dfr!3l=Buwunc)VO!SbX#522T6oJvPcC_z z;)^vN%K8*`6gr`+{vTF&qGOh>?n)=)W8$zCvyS_owF*RFP3g#B`3qElon@^`)^$M` z5yFZI^R2J@QlGS^v<0VhpA~h=CQfdmw>G!U7fcApECr7;7J2DH(UYgj9_;S%g+lFe z+#P0A32MOBuSs)Rs)6G&I1~c%YY&VHH1%MzX5};1KE F=$|(^ik^*v3izsH^39B9kry~iQ*y2PIjFG&GDKo5;+zC6kzFPL zP)thWVOKos8~w38Y0F!{xQEotdr11|ZR{O|Cy#_7GH~N$M)BW97;#=a2e^CmjZB{b z;hv6wv#5<%F~!XlUgl3IuP|E~D`&_ IP;&HTtvxd`DMZKENUp@gy^yObf2=;0bbU4!;|EN*zB~Q;)c>)lTs>lyOnXn zc39g|V7l$Pt2U&cbSJVg0Ec+TMhk1;ijnBRj_!9EUzjTN%|U-GL6if{zZK&eipmoz zvzM(}lvKT86pDWm`0<|ZOzcu`3r^N+-yMI}vBpibNr8N*t&}sL L09zeG z4M=viO}o%ET6Ls7%&H6vVO1}iR@{&n!`pRj(` Yj{mOD!*-E$9rLs3C{V7kJTPy zn|!Kc9GuBiyqpcrSa9W?ruup}YytlypEBdB8hKL?lS7dBMjhKgtm%w(zf7$4!s77)3t@v?5i;br<7O-nLX641jz;n~Sztvo+Y5YDm2UnV>p90A zMtL %fJ x$k?V z_(l@)j1Zsl<*0A>iqR(4D{NaPP0mwqWAD3+MLeE7VlaZO e((%_w9{j)yIAYtmsKK9 mimcfMKRBkEwj+t7 zkmCqn6kmlJQl$J#oDMm-oJ$^~bkf$9eL-NlF`sgC$!r&&qRvfXt-%O=a;K8;Qeg-o zsR*8R+s(EV6u?_6+H%DqxzR*w?LI3^?^ mO=xtj1hE|=tAU_6b zjU0$#uc1eo(u4_>rW#TnK{X{FkP^D~tcRLHpCu~UbW>$zAxr8-`jNCfzYtt}E8DgU z&!ckF1xOH%x_lQqo3}_io^*|GPuu2?faw0Hp`M%q>sE%`eOBkDfq35k&22YE)2Mcx z-J;?~C%t}=e^`P9o^P`PgEq9)I}BCygbHcO&s*72Wf}OT0LZfvy3S7LibCa?7eecc zb<&6v)UQgGbYvm{`F2^F8VyGD1d<(S=xx&P%j}rmGRT{9-sG`|SxGla?aTNa`yW-Z zkCNofCxaT^L(R-`vcJ&~sTaulLMxok70KIq+R(dJj=kh;NuBYJ+jYj=%1A5SvFg3I zI(KbF_0);A#X~?~+aa3o;Nuk;Tsid;VE}PeY*@*=tVCd+Q|C$sd+Me4$pP{u C6g`4O>2h-LzTOxW?oL;-fxbB}Uc1cclrX+BP{51&0BdzD~>`>y21D^23b zy9_$c;PI>@_G@ynv2w-pn?7PZZ6xsE=;#?-Pb0ANb#+r?Mn*I0J-)x$UmwZL*Wex! z&f106$@hJd#}yc%r_}M%JuFfvY~Z;Xz gk~%z zIqLA(e=+Q&l9 pmNA$`%?lS45=Tc{gd9=48b{tPeid3f!x~d7u`ieE#cgVNY0*;9SH>Y}T z5(l6C<@QGG+j0Hbb3A)`9@xFji45kl_dST;M*`G*PO9^PQtH**%M)M%w9=oR19u;f z_HQwnM*Cq*TZxkF=U6y_Dcz9i-22@kk+S8>M}`FVy|(-cu93Erh;c1eI<$nc3p HBr5_6dQGsS>Rz9$q<%3W R%Iz$i4lFZ2lQjgDZ5-Jv7jdX3Adl}JV<4M4%=UMgaQQ=O90$DX~RW7b)a?Oh| z?8N*mVRYqQtx{o$9an*)>R0@Xb^X-I7GQ>U7){M9B+7)2mGZkX?uN`8SxrvGNweb9 z985d-a;I5MH#-Z0U4bGUWmnt;uH O3N+cB-M5&P{AfayMAfpnSNJ^`tC?{P=1SaZw0E`Su6dcb0 zV|~N6x9gScqoFr&UwD}b+vHW6LLP =rk;aRu^T=c&`)w+{87$j*tG@%H6u?+j?|ZS`!Bi4+TF5HY`xy{OL3^p600kJ #1X6%?7))ZBjR)su~mvZH_kfXZt#kr(TW$dic5w=uwkqEviV7_C93Lys{qD z<)WEX+6tOfQ}HQU(Pi?m^)F5sa*)hN7j-hPn)AsG>P0u0?~Xt7$ngqh5?k>`EaGCb zZF!nOCJNSt0n!$%$6kx>t&a!l*VUp^z1?#lh8z2@bW%L*o(V|+>O-Z4^{J;Qnx%-d zJMpw7EL?1{Y#o}wlWz1_N-hWnmnr*0ei^Q1`lJ$LKEYAfzJ 5nx_nBLQasW zaO=kQ=Mx^q!KB(M@z^m6&?mDhvS;$U_ Bw= z5{nmQ9lFE{En1%h^{VV#O&KpOwC `H#_AOOHJlNm_1*Zlx#WM*{21LJKydzVY|P{mu+SY;FFGw{cHKllK#5wZ| zKCXvDVy04@UdXi1%h)!?FB=MT%96`2?wWV0BQYi~*v$<-lE+LGb8M>BD<2dwE$+Xo zc2GLv6{#1Bc{-6r|1h80l4j;twbceo)@?O~L~><9h=0!e-P%*RiO!0tPHN+R@f~g9 z(xopJ?@O8YD<4Tii_seIG`TFXrC>;3L0c8unn4a8x%9DEV2fV#adPqsGNy0-gi!0( zE})|6%mq0z$zMr8V0q(UUNiK52%%jUne$P)+ilf8mi8w`$zmFbwC6SYNk{Wd(Qfkt zQAYjw>`%3wxWn=b&uRf=M+53v$ +trs9K>XE(5ub$nGDsg?xI?fgGV6x0%8GNOM!l7FMrZv!j<98EyRFO-$ z;R)aw&^c!(3@!O(1s=`Vi%lXpZ-vCCl%aRcZ+w%v#sUN^^#izFd9rsJn>wW`n5cy( zEqr4=m35ba+T@)YN^sexS&f->tkzx4mgvZ5Gz!_KcU6Im#AjP05wN9xtHJ9mglc8- zT|ffqWMb%<$=0~$^5$@GMXE>lOdwEXML}cQ$R_)54LaMtjlmg1`|e9HUbcX?hYagw zQX(Cbus>IJxC@tEKF|N6pF#Ir8NhyJ&aUW8{h?KRwo7u?guw(`^jXdbrbme#4g4sq z3@FE{m+g#J$ISiH@%`!95m=td!vTM9bL#7Cb&K}Tz;|aNWjh~vieS)7|Hh{HW5woE zY@&UH4M+0x&DXE-_Iv~Lz`7&2JpS36z2RuQWg;ElSV3_7e(~P-@bu!nAu+B*dj9-r zKA!cqHqU=^{C@rQ*LeNq=P=t-GW(l1!H+XodOP(|@M*H#8_ZyK1Eo!3OPLg8`Vff~ zY9pDLLt!PTV}cnJYU!LO{K6|KnD#F9)MU)ORyy77sx`!ZhySU4kVqRkz&8L1I~;oa z7LbHm;Z!fLM =Sr^YlNswl z#+Dz;7Xx7}U)UyRvKr%rmhTOhO#YHjxhgjuUzFO!JtY6S)YHe< E|=KjNO zH!++&lTni7i2AGz>b;a+; C)I=vxu8U9=Imp7d@5ihlg&ytK8bETO{_MEpO}u}7#!CVB4eUCqO>F6d-n1J zy!YY-o>}>z?dgmG&v@+Zpnt5gJmQ77ufN8tSD#I+-^;(14~ntKsaxly{Ih3Ad(Wob z9-q(Ndp?t(LnnPEf#;)2h}&joV!Sn8x{3L;df9{M&zMJlig@Ae5i1@v9`}qViH^8+ z`lN_c`DSC+kqlzO+6x e;OK_tPwpS;C z9INF-JBTDbSCJ$_;xg)r*x;^s#?G=ofM8h}ns7CJ9mfmFCOv5XA;%3?u`d*F%Kr@C zmhtJtl=(CZ^} _AZB~PQ8agx-HAR~Vj5-DfroW4zVy^%7mRK7MW5E;d82WR zpG0?v&4SB0Rt&?R<2ip2b4b!TWI#LB=NXBU1Diaw*Qi&;uXQiY%DbWQkz*>;&SjrP ziO#y7ELAJ*nGJR(!m?(sJ@eCk)bEfek+6x*#gnAZ=!DWPbRZhF>q-vXcBt+(9mHLV zPowfx7z=Yul{*!X#$PqIgr q3CsW9(x7 z0fb^;5FT5dO_?f?#P_afSjKrBJhD=*o`cEbjmWpIvnS*`G KeXc$C! zGzF57r3pxRJ=P^XkJUQ`P&6s(PmLA@=9U>@GVWa)+9Njm9gn7XH+Hgc-*19$f|K>x zP{=u82^h$$ytXt11ykfoz_v;xgjYd$>AXoDtUnVwE)=!>3X#qOUUv;lFgi59*kBY# zXhyA$h-;~5uv{%sciE-qS~G(O^Pb3oI9Vn%SAKZo^IVPiH1?03VD#23l9b24=P!}*W*#zGnsn+e*5YbzWDv`@apr=jy`>f z{q-AIa(bWby`71X%|zy#yqz=NlD>sxK>BEOF3vL|Y|6ca>*=@@MFUDwi>5~oZ3ua8 zIcB#^G^qZ0McL?at^|>4TSYcj8#H3T0s+-tU=RIDx}Awov_}(t@BZ5^R ^8Wmd zhNIdy9cb1dvZ46A%8`*5Ua2ju*iddr$~Au={$YH`9#3)3 ruQkySBy2Nl#Nq|Iw&(9k`cn>Z-$xJ&ic53j?a3e1o!tNutW0EA(oh z=ZP7~H|rB%-Wa2n%-E)yaCrA^GH6X+`i?8NIe|@nvx?$#;#uId6=3C)DnRL0yVnYn zY7) zYZ9T#|zj?FY?R6iBER?effn7TvSWwn!*sj*NcRP(pu zd?dEh>_`j$q!agCyT+J?-lqK3^j~LkalCOIPkG;V#D&ja9`W1 kZ0<@bMp?|uLKc=7UmJb!N{U%x(LxUY{%^{>ACYEEkHb8>sG+J2k+ zX;T*w6P0g|1m~MGDSEW|{7BlKpCeIv-23K;72Vz(h36AQXL5C}7C#cO=Y;xrVk!1h zu}x$qhC-}kMj}pAkc v+tQyZW*_asvSWy7DTyY+bY zysrkviciE&;!_(}4t11;DwJdPReI=6Vf&a+eMXpn+hWCPBGfx-mE?mxNyn1()Bac> z`a`_n NUAcs*gnKF& zu<#L9;1%_msepQd#H6AUS{bla_vSrBzyKmqG1Q}>><8Z*=na^=r$?jDr9O(kbkrIR z9zj8#*NCyLie*CtBh *46CT_7JRAHw(U6hgOV40i6d^fZ5*D<_|(Mw~Q0z4vU)n4eU> zJy$@U3C|;tJ {BS?OFaRkOM-#dyv!-qCkM1S$+=lJULzc`Yh zpW?GW{P74nzmDYMhL<1z5Fh{erz6n)>a)-B$*+Go9%nx_;PoCD`oV|z(ck_X{N$&9 zfe(&g_)LKAM my)3g7MG3 z#BYD|TkJ>wHq6zbZ(qMUg7eqoarz@do_hZL`QYoYsjt5t+X2i?O?U9EjdMkhI2E74 ztcC5DBQ5>o%AR#lnfMuH%{#u+Zfcg*yIXMNfX%J2Q2k4|54GxcIQJZCwon0Nu1X4` zMji1YJtv`7y3VaSNS`JMNn5CO1dS+D7ypQ*FDB)ZH;a8wy1P#~e{psCSWQ68A$`e5 z;kRNEuXA-&2Ky+2F?}}z_hw>3d=x^y38?i!(5`+? QHQfXh+uk_O+KO~d!0NAA3z14Tew>>%zS=vfzL&zXkMdTT9A~gLR0}~U2!`A&?l-b1I$CELz zj0@U;b&21mpRPR|hhIeDV6ONuE1G~x${_~X`7m_o^H>p9z46dz;$N9wr52{2sHek- z-6po)<7s4H;Fef7&hd#FRP4<-gmy6fPHy113J*F+%6>Xyk+@DQwc03~d`Fh8Ri8I4sx`uzY)VXstkw>y_*u#15z2{S= zryjq4J;yIclJVJ@ggmZ29dXl1)qV_zpB=H*508ZB2OqwFBtJjE4}S6!{N!)`1|NO& zF`ggyoXN-6NBniV|EGhf*GDY)>WBsH#d}Ar_#Qs{>NS4%+u!1s|MkD%?UDRESBt)Q z|9gj=KOB$DfArJ8!pjdoz^hkZ;rB G@oddQQHd&hLzWUww5XN{<-t =zIbJAvuB3aje|= {=zGU;X|EIA67UPMgx3AwO zCXA<%Vqf}^FpY`)!nn{6joVzUcj{NFo%h64>H)U{@VVFdd|Aspm$8Pl$kpUnVw}Wf z!ANTG#5Elo2CkyUXwIiNi8EsiwkWw3@w6GL_ywQfnOL2AE-|v#1ILQvuq8eyB*@&@ z+=hWy4W<|%6Gnw^ny4x~ZmUd}e8Y%of={{kx)oj`Jc&7R&tvTHlsu*!IA$gd7V(#( z;|%jGiXT=TO+M>5fHB&ZPkr$|O&wZ^P-k2~%;~IQuBmb}rjD@FfFh5vd&b}BZFTUU zH%#7bU{0%Jt&5!KvuvTAJ|nJ-ry&!f$8SzvUJ{$qk0?<5hU+>cRBW)R 4v9h^ja(;VO14YrGZUf0k4RrG52WgC+Whi`h zsN$_c31w;;AyCOsZ96`5D+F^1Ql_xdiFz@V*ECr+VuE-cH{T2_Nkdbx DuyfsdRw8QdWtJY4?`xdN{UmY*4?Hj0tk cO)fW9ZB2Izj}qQkD%`L5vYBAJ}UlZ zZccyBe7`vY%5#sVuaAK444hvaiO%ya*e`Ti6B@?ZTN z?|=CH5hVWZSHHn;esu(}M{@J+5zM|l^yt+Q9DjcF z{`NN`Q9FFjLG!S(bCtjdgx@BX&6Ni}S58I{GM}KC=fX>;y=-lO16l+4Ce1xxQ(uEi z7r#>a$yF)p $@w(pJt#zi4ZNRHK#9k)yCQ>sUdZpVK;YZP0M|#EX?) z_QUs F!TLQ K?F3os4YM;m@xDNtziD@0t!)PrEXQO+7Wz& zVs`;Vh3$n~TVh$z)g~s w&Kawu1z!ZB0%r1PceyY!MwCYeeReBNHAstg`fnLdD zAGY*K$^7JH?W0J!>CBVRlgR`x4HewI=tDY+yQ|+Ff9la B8w zew=6KoJyt;2WD f8cKFm QvE$dr`A@%mh5yI@?LXn4|Lfo5(<5ek@&3p7 z-jDwVKmMz~!oU1C{}O-mmwz!Pv48i+FYv$opZ^zp@x_s#JmR-A+4{X7{tzGi_?Qeo z===ENkB|8DHU4 zFaG|&AIZ;G<7uXo{xhaM6Qt*){Hr4dJ}3Ck*l|04KR?=j|3@#5d!LOyK7Y22*!lUv zv-ghrK74t`noncG|98Ln4Sx5#UyeBUOoYBZW9TD(d^@&wI=6WG8aX(|o{5NjiO%p@ zQyVt14Qh!+A(~uj0l{j5V(O&H;*y-e@UW-F=+(Yhe!D3*Z9yg$>8wSE3!gm8tBnD* zpYDd`u E)xRl-UE;Nh6_sO^7b#wrZ>bn8ahg~N zsTwuzWxJZVWR7Cx@u<-N&8;P+DV9a=00|!~b&I8J(M6awo>Hw`{Go}=)B79gr2bkm zV!jNj{Oh!ju-!#|)JEo>xUJPB@jCHNd)pUVg7A#})c%^tjj1Ba7}UAW*yv-`84W^% z0@nGgGUeU|K$R>h0agWGN`#udj66~iEQ;S&u>dkFVkwMV<$%^$2+nJR_Q|!nA2RbR z*Z4PA DWcDV>TCS5 v*n}(RP_lv8aFXnzO9a( Xzxed``0|rK9*M#uL3*_K z;(Nzz|Fa+A*$=*kAG7pd{O(uy@(6C<+H?HX-@e4({L6oVkN@Il_~Gw=k3atIx8vXI zBcb@!5m=u4e!lwR_2Ep8fc5oj{NRT_8twh?NXq{FZ~yf{>-+fZ^H=!iU;G09@b~|K z-~QsC&ZU^xkAVGqKllOu_9*k^k+A&LC!gbYpL~Mf|Ms`|$AA3ANZ9@0CqKf=qrV>> z|IPsQG>R7o{+y4#Kb A6DS-0S50{buz0*S|UF{ramx z- cI1{a^mC_=o@ecliB3|I?8~{Swa){=W5RBj7$|G#)EH z_c=Z8KUY*RSd3i S#a8jK5kTXdwNYB$YwIsi;f9j zg&lVZj`h%>w 1OooBk61_ z9e78n4LC{afx{K&MC-x-ig6Z=O56oZN(>)JpV(wxF;(&?^kqz1aX&Q5HR@M=x*2|b zcl@cxP^o(IsK-FaICj%LpY(P|_;PEy4ezmL!yx%+IvM(=s|(czsGD^Y4W(*oNFecK zUxhj2n5;B9p959VUyXT9;+v!yMMGPavoa}ryo?h@H>|};#`-EWt1tX5#^cM95FK#o zszpU=a*E%0(JmOA `?y$8%qa>%#~pU!OgPpDQ>cAz&QX zZrfP!c|MZ ;pgXd59)gO*Y=+9q|Cyibn@#)Y1?Y|rG b0jO@;FI6|4qqH`;8>`!J;&3L(0uX1_m0Hnhxqs>Kfw= 1SCb5sW^s~?L<_O+i9`Wt_AO8>^ zeE+!b#e4YPN8iKyNB>_PG@Xy`zjwsi9~{Zw56`EIj(G9wBdPlN!K?GhqUSG;*z<^C z&!p!WOMZFOX-C}rcIf2XfA*ZU8Doy|$l-j;h7nUTmQd|o6eW&1e$WZT!mm}&bS&HK z%tPmLWhb8!wOTDrj1(PaQiqFl&hf|G@4kEYe8I1w#gaO~lki~`2d3Qwp0~1rAvMnC zn9?&DoMBk{qXo~rd=A-=<9cjj_k!at_$?D^X}{+gK0L!>dC_HMyF-_SWz1^gN7Vn2 zZjm9eB0Y$x%gh&ezBsjw@qt=NnhpZxGX&?@W++KAs$8hGdg&)?B`#9GIu@57({gT# zd)?PSbVspX<2lNJ=)Jeq{Z>sY6{h?Vx~Q)rYnSaq>V?zDcC}{8o_I}7Fmn}a=L+Q( zdO{pf`DVNTp#`GXU==#%@?unzU7aw=#W2@7S4nT1x5q1DB~B6xTm}bJ;>;|Qp-zyS zoY&xIcD6WE!4iaARRm-?NZX!$ng(Q&&?`|)w#~PtRRVFH8OorlIt4uhM`UHHK{NE^ zPl8vJg&K;qww`Z+CkLJxxPkT?^%lsTC-ZT atK``O>-98Jwt3@d291m~Hk zWO8tCs-lBp7lqD8tXgHBa?k+}X+)V$-4VA~$b*j&O#HV3Uo)9iRTP|k8?$|E*(_B) z?Zbsv%|;-$ug2)&Km=T*Og-8n0lt@vvfrdd>KikIL&u+u$Io{wd2HvZ&m*XNegtjr zeegbB9<%W8Jv(ONZ9e|~?8UQVR{dkV{Qk#d*8lBb)`qW-O3onee8lK{T*>#RV}|^_ z5!inA$*1`BFMokge*f#EU$2i@@Rvt0` zJGWGt!J=p z&)yr4xc}4N|9|j@fBL5*;NI{z|K{J|Km3P(KlYz`bKH0C{d8jSZ$AA3pM3G<5m)Z` z`1>CmG(R6KIv-Oy!@99=)6>%!C!C4Q_s(Rn#~bC3&vUP;vmd7b&KEJgIv=5r{ixpV zbK8k$$ISop6VH!8`COrTK0g18|N6h;pZ?o_#c%)dpYfX`sQ%;+pX1rf=Of{2<8iXD zj&2^o{F~5_V-T=eyiN2i!=L? =D~yv+>tke{FOBZiZwPPZTkXBDRj!@w?E;qU6ZM1>MHx-dFVi1d ztzJOeGUn?4)j!gYSUi$5{6#)W