import aiohttp import asyncio import base64 import hashlib import html import io import json import re import time from .. import loader, utils @loader.tds class AIMod(loader.Module): """AI-собеседник + анализ чата. Создатель: @fiftarir""" strings = {"name": "AI"} CREATOR_USERNAME = "@fiftarir" CREATOR_ID = 1314782374 TREE_EMOJI = "🌳" TREE_RESPONSE = "ох.. Создатель?" MAX_MEDIA_SIZE = 20 * 1024 * 1024 PRESETS = { "cook": "Ты — опытный повар. Отвечай рецептами, советами по продуктам и техникам готовки. Кратко. Отвечай на русском.", "coder": "Ты — senior-разработчик. Отвечай кодом, примерами и технически точно. Отвечай на русском.", "psychologist": "Ты — поддерживающий психолог. Слушай, задавай вопросы, помогай разобраться. Без диагнозов. Отвечай на русском.", "sarcastic": "Ты — саркастичный собеседник. Отвечай с иронией, но по сути. Не груби. Отвечай на русском.", "teacher": "Ты — терпеливый учитель. Объясняй просто, с примерами, по шагам. Отвечай на русском.", "philosopher": "Ты — философ. Отвечай размышлениями и вопросами, ссылайся на идеи. Отвечай на русском.", "pirate": "Ты — пират. Отвечай в морском стиле, с 'арр' и 'йо-хо-хо'. Отвечай на русском.", "anime": "Ты — персонаж аниме. Отвечай эмоционально, с 'ня' и восклицаниями. Отвечай на русском.", } def __init__(self): self.config = loader.ModuleConfig( loader.ConfigValue( "provider", "groq", "Провайдер: groq, gemini, openrouter, mistral.", validator=loader.validators.Choice(["groq", "gemini", "openrouter", "mistral"]), ), loader.ConfigValue("api_key", "", "API-ключ.", validator=loader.validators.Hidden()), loader.ConfigValue("model", "", "Модель для текста.", validator=loader.validators.String()), loader.ConfigValue( "vision_model", "", "Модель для картинок. Пусто — авто для провайдера.", validator=loader.validators.String(), ), loader.ConfigValue( "vision_enabled", True, "Разрешить AI смотреть картинки, стикеры, гифки.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "system_prompt", "Ты — дружелюбный и остроумный помощник в Telegram-чате. Отвечай кратко, по делу, с легким юмором. Отвечай всегда на русском языке.", "Характер бота по умолчанию.", validator=loader.validators.String(), ), loader.ConfigValue( "max_tokens", 500, "Максимум токенов.", validator=loader.validators.Integer(minimum=50, maximum=4000), ), loader.ConfigValue( "temperature", 0.8, "Креативность.", validator=loader.validators.Float(minimum=0.0, maximum=2.0), ), loader.ConfigValue( "history_size", 20, "Сообщений в истории диалога.", validator=loader.validators.Integer(minimum=2, maximum=100), ), loader.ConfigValue( "summary_size", 100, "Сообщений для .aisummary.", validator=loader.validators.Integer(minimum=20, maximum=1000), ), loader.ConfigValue( "summary_prompt", "Ты — аналитик чата. Сделай краткую сводку обсуждения: " "о чём говорили, главные темы, ключевые выводы. " "Пиши по-русски, структурировано, не больше 10 строк.", "Инструкция для саммари.", validator=loader.validators.String(), ), loader.ConfigValue( "context_size", 100, "Сообщений для .aicontext (по умолчанию).", validator=loader.validators.Integer(minimum=20, maximum=1000), ), loader.ConfigValue( "context_prompt", "Ты — аналитик Telegram-чата. Тебе дают выдержку из сообщений и вопрос. " "Ответь на вопрос ОПИРАЯСЬ ТОЛЬКО НА ЭТИ СООБЩЕНИЯ. " "Если в сообщениях нет ответа — честно скажи об этом. " "Не выдумывай факты. Пиши по-русски, кратко и по делу.", "Инструкция для .aicontext.", validator=loader.validators.String(), ), loader.ConfigValue("ignore_bots", True, "Игнорировать ботов.", validator=loader.validators.Boolean()), loader.ConfigValue("ignore_commands", True, "Игнорировать команды.", validator=loader.validators.Boolean()), loader.ConfigValue("reply_to_trigger", "", "Слово-триггер.", validator=loader.validators.String()), loader.ConfigValue( "max_history_per_chat", 50, "Максимум чатов в истории.", validator=loader.validators.Integer(minimum=1, maximum=1000), ), loader.ConfigValue( "reply_only_when_mentioned", False, "Отвечать только при упоминании.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "save_history_to_db", False, "Сохранять историю в БД.", validator=loader.validators.Boolean(), ), loader.ConfigValue("auto_reactions", False, "Авто-реакции.", validator=loader.validators.Boolean()), loader.ConfigValue("diary_enabled", False, "Дневник в Saved Messages.", validator=loader.validators.Boolean()), loader.ConfigValue( "diary_hour", 22, "Час дневника (0-23).", validator=loader.validators.Integer(minimum=0, maximum=23), ), loader.ConfigValue( "reply_context_enabled", True, "Учитывать сообщение, на которое отвечает пользователь.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "reply_context_max_chars", 500, "Максимум символов из сообщения-контекста.", validator=loader.validators.Integer(minimum=50, maximum=2000), ), loader.ConfigValue( "use_notes", True, "Использовать память о пользователях в ответах AI.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "cache_enabled", True, "Кэшировать ответы AI. Повторные вопросы не тратят лимиты.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "cache_ttl_hours", 24, "Сколько часов хранить ответы в кэше.", validator=loader.validators.Integer(minimum=1, maximum=720), ), loader.ConfigValue( "cache_max_size", 500, "Максимум записей в кэше.", validator=loader.validators.Integer(minimum=10, maximum=10000), ), loader.ConfigValue( "whitelist_enabled", False, "Ограничить AI только белым списком чатов.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "sanitize_output", True, "Убирать 'thinking', 'analysis' и служебные префиксы из ответов AI.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "auto_memory", True, "AI сам запоминает факты о пользователях из их сообщений.", validator=loader.validators.Boolean(), ), loader.ConfigValue( "auto_memory_prompt", "Ты — экстрактор фактов. Прочитай сообщение пользователя и выпиши " "ЛЮБЫЕ факты о нём (имя, работа, увлечения, город, питомцы, предпочтения). " "Формат строго:\n" "- <факт 1>\n" "- <факт 2>\n\n" "Если фактов нет — ответь ровно: НЕТ\n" "Не пиши ничего кроме списка фактов или слова НЕТ.", "Инструкция для извлечения фактов.", validator=loader.validators.String(), ), ) self._history = {} self._tree_replied = set() self._chat_order = [] self._stats = { "requests": 0, "errors": 0, "summaries": 0, "vision": 0, "ocr": 0, "cache_hits": 0, "contexts": 0, "tokens_in": 0, "tokens_out": 0, "tokens_total": 0, "errors_401": 0, "errors_404": 0, "errors_429": 0, "errors_500": 0, "errors_other": 0, } self._diary_task = None self._last_diary_date = None self._banned = set() self._silent_banned = set() self._gagged_chats = set() self._gagged_users = {} self._maintenance = False self._tree_enabled = True self._whitelist_chats = set() self._user_msg_count = {} self._cache = {} _PROVIDERS = { "groq": { "url": "https://api.groq.com/openai/v1/chat/completions", "default_model": "llama-3.3-70b-versatile", "vision_default": "meta-llama/llama-4-scout-17b-16e-instruct", }, "gemini": { "url": "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}", "default_model": "gemini-2.5-flash", "vision_default": "gemini-2.5-flash", "gemini": True, }, "openrouter": { "url": "https://openrouter.ai/api/v1/chat/completions", "default_model": "openrouter/free", "vision_default": "openrouter/free", }, "mistral": { "url": "https://api.mistral.ai/v1/chat/completions", "default_model": "mistral-small-latest", "vision_default": "pixtral-12b-2409", }, } @staticmethod def _compact_num(value) -> str: try: n = int(value or 0) except Exception: n = 0 if n >= 1_000_000: return f"{n / 1_000_000:.2f}".rstrip("0").rstrip(".") + "M" if n >= 1_000: return f"{n / 1_000:.2f}".rstrip("0").rstrip(".") + "K" return str(n) def _track_usage(self, data): try: usage = data.get("usage") or {} except Exception: return try: self._stats["tokens_in"] += int(usage.get("prompt_tokens") or 0) self._stats["tokens_out"] += int(usage.get("completion_tokens") or 0) self._stats["tokens_total"] += int(usage.get("total_tokens") or 0) except Exception: pass def _friendly_api_error(self, status, body_text): code = int(status or 0) if code in (401, 403): self._stats["errors_401"] += 1 return "❌ Ключ отклонён\nПроверь .cfg AI → api_key." if code == 404: self._stats["errors_404"] += 1 return "❌ Модель не найдена\nПроверь model в .cfg AI." if code == 429: self._stats["errors_429"] += 1 return "⏳ Лимит исчерпан\nПодожди минуту или смени провайдера." if code == 413: self._stats["errors_other"] += 1 return "📦 Слишком большой запрос\nУменьши history_size или context_size." if code >= 500: self._stats["errors_500"] += 1 return f"🔥 Сервер лежит (HTTP {code})\nПопробуй через минуту." self._stats["errors_other"] += 1 return f"⚠️ Ошибка API {code}\n{html.escape((body_text or '')[:200])}" def _sanitize_ai_output(self, text: str) -> str: if not text or not self.config["sanitize_output"]: return text cleaned = str(text) cleaned = re.sub(r"<\s*(?:think|thinking|analysis|reasoning)[^>]*>[\s\S]*?<\s*/\s*(?:think|thinking|analysis|reasoning)\s*>", "", cleaned, flags=re.IGNORECASE) cleaned = re.sub(r"```(?:thinking|analysis|reasoning|scratchpad)[\s\S]*?```", "", cleaned, flags=re.IGNORECASE) cleaned = re.sub(r"(?im)^\s*(?:thinking|analysis|reasoning|scratchpad|final answer|answer|reply|assistant)\s*:\s*", "", cleaned) cleaned = re.sub(r"\n{4,}", "\n\n\n", cleaned) return cleaned.strip() async def client_ready(self, client, db): self._client = client self.db = db self._me = await client.get_me() if not self.CREATOR_ID: self.CREATOR_ID = self._me.id self._load_from_db() # при перезагрузке всегда восстанавливаем список включённых режимов try: chat_ids = self.db.get("AI", "chat_ids", []) or [] self._chat_order = [] self._history = {} for cid in chat_ids: cid_int = int(cid) hist = self.db.get("AI", f"history_{cid}", []) or [] self._history[cid_int] = hist self._chat_order.append(cid_int) except Exception: pass if self.config["diary_enabled"] and self._diary_task is None: self._diary_task = asyncio.create_task(self._diary_loop()) def _load_from_db(self): try: self._banned = set(self.db.get("AI", "banned", []) or []) self._silent_banned = set(self.db.get("AI", "silent_banned", []) or []) self._gagged_chats = set(self.db.get("AI", "gagged_chats", []) or []) self._gagged_users = self.db.get("AI", "gagged_users", {}) or {} self._maintenance = self.db.get("AI", "maintenance", False) self._tree_enabled = self.db.get("AI", "tree_enabled", True) self._user_msg_count = self.db.get("AI", "user_msg_count", {}) or {} self._whitelist_chats = set(self.db.get("AI", "whitelist_chats", []) or []) self._cache = self.db.get("AI", "cache", {}) or {} if self.config["save_history_to_db"]: chat_ids = self.db.get("AI", "chat_ids", []) or [] for cid in chat_ids: hist = self.db.get("AI", f"history_{cid}", []) if hist: self._history[int(cid)] = hist self._chat_order.append(int(cid)) except Exception: pass async def on_unload(self): if self._diary_task: self._diary_task.cancel() self._diary_task = None self._save_cache() def _save_cache(self): try: self.db.set("AI", "cache", self._cache) except Exception: pass def _save_user_count(self): try: self.db.set("AI", "user_msg_count", self._user_msg_count) except Exception: pass def _save_whitelist(self): try: self.db.set("AI", "whitelist_chats", list(self._whitelist_chats)) except Exception: pass def _cache_key(self, model, messages): try: payload = {"m": model, "msg": messages} blob = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") return hashlib.sha256(blob).hexdigest() except Exception: return None def _cache_get(self, key): if not self.config["cache_enabled"] or not key: return None entry = self._cache.get(key) if not entry: return None ttl = int(self.config["cache_ttl_hours"]) * 3600 if time.time() - entry.get("ts", 0) > ttl: self._cache.pop(key, None) return None return entry.get("reply") def _cache_set(self, key, reply): if not self.config["cache_enabled"] or not key: return self._cache[key] = {"reply": reply, "ts": time.time()} max_size = int(self.config["cache_max_size"]) if len(self._cache) > max_size: items = sorted(self._cache.items(), key=lambda x: x[1].get("ts", 0)) for k, _ in items[: len(items) - max_size]: self._cache.pop(k, None) if len(self._cache) % 50 == 0: self._save_cache() def _is_user_gagged(self, user_id, chat_id): if user_id not in self._gagged_users: return False chats = self._gagged_users[user_id] if not chats: return True return chat_id in chats def _whitelist_ok(self, message): # Создатель всегда в обход всех проверок if message.sender_id == self.CREATOR_ID: return True # Режим диалога в чате должен быть включён (кем угодно, обычно тобой) if message.chat_id not in self._history: return False # Если включён ещё и whitelist — проверяем его if self.config["whitelist_enabled"]: return message.chat_id in self._whitelist_chats return True def _get_history(self, chat_id): return self._history.get(chat_id, []) def _add_to_history(self, chat_id, role, content): if chat_id not in self._history: self._history[chat_id] = [] self._chat_order.append(chat_id) max_chats = int(self.config["max_history_per_chat"]) while len(self._chat_order) > max_chats: old = self._chat_order.pop(0) self._history.pop(old, None) if self.config["save_history_to_db"]: try: self.db.set("AI", f"history_{old}", []) except Exception: pass self._history[chat_id].append({"role": role, "content": content}) limit = int(self.config["history_size"]) if len(self._history[chat_id]) > limit: self._history[chat_id] = self._history[chat_id][-limit:] if self.config["save_history_to_db"]: try: self.db.set("AI", f"history_{chat_id}", self._history[chat_id]) self.db.set("AI", "chat_ids", self._chat_order) except Exception: pass def _get_user_prompt(self, user_id): try: gp = self.db.get("AI", "global_prompt", None) if gp: return gp except Exception: pass try: custom = self.db.get("AI", f"user_prompt_{user_id}", None) except Exception: custom = None return custom or self.config["system_prompt"] def _get_user_notes(self, uid): try: return self.db.get("AI", f"user_notes_{uid}", []) or [] except Exception: return [] def _save_user_notes(self, uid, notes): try: self.db.set("AI", f"user_notes_{uid}", notes) except Exception: pass def _notes_block(self, uid): if not self.config["use_notes"]: return "" notes = self._get_user_notes(uid) if not notes: return "" lines = "\n".join(f" - {n}" for n in notes) return f"\n\nЧто известно об этом пользователе:\n{lines}\nУчитывай это, но не перечисляй в ответе явно." async def _extract_and_save_facts(self, user_id: int, text: str): """Фоново извлекает факты из сообщения и сохраняет в память.""" if not self.config["auto_memory"]: return if not text or len(text.strip()) < 10: return try: messages = [ {"role": "system", "content": self.config["auto_memory_prompt"]}, {"role": "user", "content": text[:1500]}, ] reply = await self._ask_ai(messages) if not reply: return cleaned = re.sub(r"<[^>]+>", "", reply).strip() if not cleaned or cleaned.upper().startswith("НЕТ"): return new_facts = [] for line in cleaned.splitlines(): line = line.strip() if line.startswith(("-", "•", "*")): fact = line.lstrip("-•* ").strip() if 3 < len(fact) < 200: new_facts.append(fact) if not new_facts: return existing = self._get_user_notes(user_id) existing_lower = {e.lower().strip() for e in existing} added = 0 for fact in new_facts[:5]: if fact.lower().strip() not in existing_lower: existing.append(fact) existing_lower.add(fact.lower().strip()) added += 1 if added > 0: self._save_user_notes(user_id, existing[-50:]) except Exception: pass def _get_user_prompt_with_notes(self, uid): return self._get_user_prompt(uid) + self._notes_block(uid) async def _get_reply_context(self, message, skip_bot_id=None): if not self.config["reply_context_enabled"]: return "" if not message.is_reply: return "" try: reply = await message.get_reply_message() if not reply: return "" if skip_bot_id is not None and reply.sender_id == skip_bot_id: return "" text = reply.raw_text or "(без текста)" try: sender = await reply.get_sender() name = getattr(sender, "first_name", None) or getattr(sender, "title", None) or "Кто-то" except Exception: name = "Кто-то" limit = int(self.config["reply_context_max_chars"]) if len(text) > limit: text = text[:limit] + "…" return f'[Ответ на сообщение от {name}: "{text}"]' except Exception: return "" async def _build_user_message(self, message, user_text, skip_bot_id=None): context = await self._get_reply_context(message, skip_bot_id=skip_bot_id) if context: return f"{context}\n\n{user_text}" return user_text async def _extract_media(self, msg): if not msg: return None try: mime = None if msg.photo: mime = "image/jpeg" elif msg.sticker: st_mime = getattr(msg.sticker, "mime_type", "") or "" if "tgsticker" in st_mime or "tgs" in st_mime: return ("__unsupported__", "Анимированный стикер (.tgs) не поддерживается") mime = "video/webm" if "webm" in st_mime else "image/webp" elif msg.gif: mime = "image/gif" elif msg.video: mime = "video/mp4" else: return None file_size = 0 if msg.file and getattr(msg.file, "size", None): file_size = msg.file.size if file_size and file_size > self.MAX_MEDIA_SIZE: return ("__too_big__", "Файл больше 20 МБ") data = await msg.download_media(bytes) if not data: return None return (data, mime) except Exception as e: return ("__error__", str(e)) def _get_vision_model(self): if self.config["vision_model"]: return self.config["vision_model"] cfg = self._PROVIDERS.get(self.config["provider"], {}) return cfg.get("vision_default", cfg.get("default_model", "")) async def _ask_ai_vision(self, message, user_text, media_msg): if not self.config["api_key"]: return "❌ API-ключ не настроен." extracted = await self._extract_media(media_msg) if not extracted: return "❌ Не удалось найти медиа." if extracted[0] == "__unsupported__": return f"⚠️ {extracted[1]}" if extracted[0] == "__too_big__": return f"⚠️ {extracted[1]}" if extracted[0] == "__error__": return f"⚠️ Ошибка: {html.escape(extracted[1])}" media_bytes, mime = extracted b64 = base64.b64encode(media_bytes).decode("ascii") provider = self.config["provider"] cfg = self._PROVIDERS.get(provider) if not cfg: return f"❌ Неизвестный провайдер: {provider}" model = self._get_vision_model() key = self.config["api_key"] self._stats["requests"] += 1 self._stats["vision"] += 1 user_prompt = self._get_user_prompt_with_notes(message.sender_id) if "русск" not in user_prompt.lower(): user_prompt += "\n\nВАЖНО: отвечай ТОЛЬКО на русском языке." text_part = user_text.strip() if user_text and user_text.strip() else ( "Опиши подробно, что на этой картинке. Если есть текст — переведи по-русски." ) cache_key = None if self.config["cache_enabled"]: media_hash = hashlib.sha256(media_bytes).hexdigest()[:16] cache_key = self._cache_key(model, [ {"role": "system", "content": user_prompt}, {"role": "user", "content": f"[IMG:{media_hash}] {text_part}"}, ]) cached = self._cache_get(cache_key) if cached: self._stats["cache_hits"] += 1 self._add_to_history(message.chat_id, "user", f"[картинка] {user_text or ''}") self._add_to_history(message.chat_id, "assistant", cached) return cached try: async with aiohttp.ClientSession() as session: if cfg.get("gemini"): url = cfg["url"].format(model=model, key=key) payload = { "contents": [{"role": "user", "parts": [ {"text": text_part}, {"inline_data": {"mime_type": mime, "data": b64}}, ]}], "systemInstruction": {"parts": [{"text": user_prompt}]}, "generationConfig": { "temperature": float(self.config["temperature"]), "maxOutputTokens": int(self.config["max_tokens"]), }, } async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=90)) as r: data = await r.json() if r.status != 200: self._stats["errors"] += 1 return self._friendly_api_error(r.status, str(data)) try: usage = data.get("usageMetadata") or {} self._stats["tokens_in"] += int(usage.get("promptTokenCount") or 0) self._stats["tokens_out"] += int(usage.get("candidatesTokenCount") or 0) self._stats["tokens_total"] += int(usage.get("totalTokenCount") or 0) except Exception: pass reply = data["candidates"][0]["content"]["parts"][0]["text"].strip() else: data_url = f"data:{mime};base64,{b64}" messages = [ {"role": "system", "content": user_prompt}, {"role": "user", "content": [ {"type": "text", "text": text_part}, {"type": "image_url", "image_url": {"url": data_url}}, ]}, ] payload = { "model": model, "messages": messages, "max_tokens": int(self.config["max_tokens"]), "temperature": float(self.config["temperature"]), } headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} async with session.post(cfg["url"], json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=90)) as r: if r.status != 200: self._stats["errors"] += 1 err = await r.text() return self._friendly_api_error(r.status, err) data = await r.json() self._track_usage(data) reply = data["choices"][0]["message"]["content"].strip() self._add_to_history(message.chat_id, "user", f"[картинка] {user_text or ''}") self._add_to_history(message.chat_id, "assistant", reply) result = html.escape(reply) if cache_key: self._cache_set(cache_key, result) return result except aiohttp.ClientError as e: self._stats["errors"] += 1 return f"⚠️ Ошибка сети: {html.escape(str(e))}" except Exception as e: self._stats["errors"] += 1 return f"⚠️ Ошибка: {html.escape(str(e))}" async def _ask_ai(self, messages, use_history_chat_id=None): if not self.config["api_key"]: return "❌ API-ключ не настроен." provider = self.config["provider"] cfg = self._PROVIDERS.get(provider) if not cfg: return f"❌ Неизвестный провайдер: {provider}" model = self.config["model"] or cfg["default_model"] key = self.config["api_key"] cache_key = None if self.config["cache_enabled"]: cache_key = self._cache_key(model, messages) cached = self._cache_get(cache_key) if cached: self._stats["cache_hits"] += 1 if use_history_chat_id is not None: self._add_to_history(use_history_chat_id, "assistant", cached) return cached self._stats["requests"] += 1 try: async with aiohttp.ClientSession() as session: if cfg.get("gemini"): url = cfg["url"].format(model=model, key=key) contents = [] for m in messages: if m["role"] == "system": continue role = "user" if m["role"] == "user" else "model" contents.append({"role": role, "parts": [{"text": m["content"]}]}) system = next((m["content"] for m in messages if m["role"] == "system"), "") payload = { "contents": contents, "systemInstruction": {"parts": [{"text": system}]}, "generationConfig": { "temperature": float(self.config["temperature"]), "maxOutputTokens": int(self.config["max_tokens"]), }, } async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=60)) as r: data = await r.json() if r.status != 200: self._stats["errors"] += 1 return self._friendly_api_error(r.status, str(data)) try: usage = data.get("usageMetadata") or {} self._stats["tokens_in"] += int(usage.get("promptTokenCount") or 0) self._stats["tokens_out"] += int(usage.get("candidatesTokenCount") or 0) self._stats["tokens_total"] += int(usage.get("totalTokenCount") or 0) except Exception: pass reply = data["candidates"][0]["content"]["parts"][0]["text"].strip() else: payload = { "model": model, "messages": messages, "max_tokens": int(self.config["max_tokens"]), "temperature": float(self.config["temperature"]), } headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} async with session.post(cfg["url"], json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as r: if r.status != 200: self._stats["errors"] += 1 err = await r.text() return self._friendly_api_error(r.status, err) data = await r.json() self._track_usage(data) reply = data["choices"][0]["message"]["content"].strip() result = html.escape(reply) if use_history_chat_id is not None: self._add_to_history(use_history_chat_id, "assistant", result) if cache_key: self._cache_set(cache_key, result) return result except aiohttp.ClientError as e: self._stats["errors"] += 1 return f"⚠️ Ошибка сети: {html.escape(str(e))}" except Exception as e: self._stats["errors"] += 1 return f"⚠️ Ошибка: {html.escape(str(e))}" async def _collect_messages(self, message, limit, from_user=None): collected = [] async for msg in message.client.iter_messages(message.chat_id, limit=limit * 3, from_user=from_user): if len(collected) >= limit: break if not msg.raw_text: continue if self.config["ignore_commands"] and msg.raw_text.startswith((".", "/", "!")): continue if self.config["ignore_bots"]: sender = await msg.get_sender() if sender and getattr(sender, "bot", False): continue name = "Кто-то" try: sender = await msg.get_sender() name = getattr(sender, "first_name", None) or getattr(sender, "title", None) or "Кто-то" except Exception: pass collected.append(f"{name}: {msg.raw_text}") collected.reverse() return collected def _pick_reaction(self, text): t = (text or "").lower() if any(w in t for w in ["ахах", "ржу", "😂", "🤣", "лол", "ору"]): return "😂" if any(w in t for w in ["спасибо", "круто", "супер", "отлично", "топ"]): return "🔥" if any(w in t for w in ["люблю", "❤", "😍", "обожаю"]): return "❤" if any(w in t for w in ["грустно", "😢", "😭", "печаль"]): return "😢" if any(w in t for w in ["думаю", "интересно", "хм"]): return "🤔" if any(w in t for w in ["ура", "победа", "получилось"]): return "🎉" return None async def _diary_loop(self): while True: try: now = time.localtime() date = time.strftime("%Y-%m-%d", now) if now.tm_hour == int(self.config["diary_hour"]) and self._last_diary_date != date: self._last_diary_date = date await self._send_diary() except Exception: pass await asyncio.sleep(300) async def _send_diary(self): if not self._chat_order: return parts = [] for cid in self._chat_order[-5:]: try: msgs = [] async for m in self._client.iter_messages(cid, limit=50): if not m.raw_text: continue if self.config["ignore_commands"] and m.raw_text.startswith((".", "/", "!")): continue msgs.append(m.raw_text) if not msgs: continue try: chat = await self._client.get_entity(cid) title = getattr(chat, "title", None) or "Личный чат" except Exception: title = "Чат" parts.append(f"--- {title} ---\n" + "\n".join(msgs[-30:])) except Exception: continue if not parts: return text = "\n\n".join(parts)[-10000:] messages = [ {"role": "system", "content": "Ты — составитель дневника."}, {"role": "user", "content": "Сделай краткий дневник за сутки по этим чатам. 5-10 строк, по-русски.\n\n" + text}, ] summary = await self._ask_ai(messages) summary = self._sanitize_ai_output(summary) try: await self._client.send_message("me", f"📔 Дневник за {time.strftime('%d.%m.%Y')}\n\n{summary}") except Exception: pass # ─── Обработчики ─── async def _do_ask(self, message, text): if not self._whitelist_ok(message): await utils.answer(message, "🔒 AI в этом чате недоступен.") return reply = await message.get_reply_message() if reply and self.config["vision_enabled"] and (reply.photo or reply.sticker or reply.gif or reply.video): await utils.answer(message, "👀 Смотрю картинку…") answer = await self._ask_ai_vision(message, text, reply) await utils.answer(message, self._sanitize_ai_output(answer)) return user_prompt = self._get_user_prompt_with_notes(message.sender_id) final_text = await self._build_user_message(message, text) self._add_to_history(message.chat_id, "user", final_text) messages = [{"role": "system", "content": user_prompt}] + self._get_history(message.chat_id) await utils.answer(message, "🤔 Думаю…") answer = await self._ask_ai(messages, use_history_chat_id=message.chat_id) await utils.answer(message, self._sanitize_ai_output(answer)) asyncio.create_task(self._extract_and_save_facts(message.sender_id, text)) async def _do_ocr(self, message, args): reply = await message.get_reply_message() if not reply: await utils.answer(message, "❌ Ответь на фото.") return base_prompt = ( "Распознай ВЕСЬ текст на этой картинке. Сохрани структуру. " "Если текст на другом языке — сначала оригинал, потом перевод на русский. " "Только распознанный текст, без описаний." ) prompt = f"{args}\n\n{base_prompt}" if args else base_prompt await utils.answer(message, "📝 Распознаю текст…") answer = await self._ask_ai_vision(message, prompt, reply) self._stats["ocr"] += 1 await utils.answer(message, f"📄 OCR\n\n{self._sanitize_ai_output(answer)}") async def _do_remember(self, message, args): uid = message.sender_id text = args.strip() target_uid = uid if text: parts = text.split(maxsplit=1) if parts[0].startswith("@"): try: entity = await message.client.get_entity(parts[0]) target_uid = entity.id text = parts[1] if len(parts) > 1 else "" except Exception: await utils.answer(message, f"❌ Не нашёл {html.escape(parts[0])}") return if not text and message.is_reply: reply = await message.get_reply_message() if reply: target_uid = reply.sender_id text = args.strip() if not text: await utils.answer(message, "❌ Укажи факт.") return notes = self._get_user_notes(target_uid) notes.append(text[:300]) self._save_user_notes(target_uid, notes) await utils.answer(message, f"✅ Запомнил.\n{html.escape(text)}\nВсего: {len(notes)}") async def _do_forget(self, message, args): args = args.strip() target_uid = message.sender_id idx = None if args: parts = args.split() if parts[0].startswith("@"): try: entity = await message.client.get_entity(parts[0]) target_uid = entity.id except Exception: await utils.answer(message, "❌ Не нашёл.") return if len(parts) > 1 and parts[1].isdigit(): idx = int(parts[1]) elif parts[0].isdigit(): idx = int(parts[0]) notes = self._get_user_notes(target_uid) if not notes: await utils.answer(message, "📭 Нет фактов.") return if idx is None: self._save_user_notes(target_uid, []) await utils.answer(message, f"🗑 Удалено: {len(notes)}.") return if idx < 1 or idx > len(notes): await utils.answer(message, f"❌ Номер 1..{len(notes)}.") return removed = notes.pop(idx - 1) self._save_user_notes(target_uid, notes) await utils.answer(message, f"🗑 Удалён #{idx}: {html.escape(removed)}") async def _do_notes(self, message, args): args = args.strip() target_uid = message.sender_id display = "себе" if args: try: entity = await message.client.get_entity( int(args) if args.lstrip("-").isdigit() else args ) target_uid = entity.id display = html.escape(getattr(entity, "first_name", None) or str(target_uid)) except Exception: await utils.answer(message, "❌ Не нашёл.") return notes = self._get_user_notes(target_uid) if not notes: await utils.answer(message, f"📭 Нет фактов о {display}.") return lines = [f"📝 Факты о {display}:\n"] for i, n in enumerate(notes, 1): lines.append(f" {i}. {html.escape(n)}") await utils.answer(message, "\n".join(lines)) async def _do_summary(self, message, args): if not self._whitelist_ok(message): await utils.answer(message, "🔒 AI недоступен.") return limit = int(self.config["summary_size"]) from_user = None if args: for p in args.split(): if p.startswith("@"): try: entity = await message.client.get_entity(p) from_user = entity.id except Exception: await utils.answer(message, f"❌ Не нашёл {html.escape(p)}") return elif p.isdigit(): limit = max(20, min(int(p), 1000)) await utils.answer(message, f"📊 Читаю {limit} сообщений…") collected = await self._collect_messages(message, limit, from_user=from_user) if len(collected) < 5: await utils.answer(message, "❌ Мало сообщений.") return text = "\n".join(collected)[-12000:] messages = [ {"role": "system", "content": "Ты — аналитик чатов."}, {"role": "user", "content": f"{self.config['summary_prompt']}\n\nСообщения:\n\n{text}"}, ] summary = await self._ask_ai(messages) self._stats["summaries"] += 1 await utils.answer(message, f"📊 Сводка ({len(collected)} сообщ.)\n\n{self._sanitize_ai_output(summary)}") async def _do_context(self, message, args): if not self._whitelist_ok(message): await utils.answer(message, "🔒 AI недоступен.") return if not args or not args.strip(): await utils.answer( message, "📖 Контекст чата\n\n" "Беру последние N сообщений и отвечаю на вопрос по ним.\n\n" " • .aicontext о чём говорили?\n" " • .aicontext 300 кто спорил?", ) return parts = args.split(maxsplit=1) limit = int(self.config["context_size"]) question = args if parts[0].isdigit(): limit = max(20, min(int(parts[0]), 1000)) question = parts[1].strip() if len(parts) > 1 else "" if not question: await utils.answer(message, "❌ Укажи вопрос после числа.") return await utils.answer(message, f"🔎 Читаю {limit} сообщений…") collected = await self._collect_messages(message, limit) if len(collected) < 5: await utils.answer(message, "❌ Мало сообщений.") return context_text = "\n".join(collected)[-14000:] messages = [ {"role": "system", "content": self.config["context_prompt"]}, {"role": "user", "content": f"Вопрос: {question}\n\n=== ВЫДЕРЖКА ===\n{context_text}\n=== КОНЕЦ ==="}, ] answer = await self._ask_ai(messages) self._stats["contexts"] += 1 await utils.answer( message, f"📖 Контекст ({len(collected)} сообщ.)\n\n" f"Вопрос: {html.escape(question)}\n\n" f"{self._sanitize_ai_output(answer)}", ) async def _do_timeline(self, message, args): if not self._whitelist_ok(message): await utils.answer(message, "🔒 AI недоступен.") return limit = 30 if args and args.isdigit(): limit = max(5, min(int(args), 100)) lines = [] async for msg in message.client.iter_messages(message.chat_id, limit=limit * 3): if not msg.raw_text: continue if self.config["ignore_commands"] and msg.raw_text.startswith((".", "/", "!")): continue try: sender = await msg.get_sender() name = getattr(sender, "first_name", None) or getattr(sender, "title", None) or "?" except Exception: name = "?" when = msg.date.strftime("%H:%M") if msg.date else "??:??" lines.append(f"{when} {html.escape(name)}: {html.escape(msg.raw_text[:100])}") if len(lines) >= limit: break if not lines: await utils.answer(message, "❌ Нет сообщений.") return lines.reverse() await utils.answer(message, "🕐 Хронология\n\n" + "\n".join(lines)) async def _do_toggle(self, message): if message.sender_id != self.CREATOR_ID: await utils.answer(message, "🔒 Только создатель может включать режим диалога.") return chat_id = message.chat_id if chat_id in self._history: del self._history[chat_id] if chat_id in self._chat_order: self._chat_order.remove(chat_id) if self.config["save_history_to_db"]: try: self.db.set("AI", f"history_{chat_id}", []) except Exception: pass await utils.answer(message, "🔇 Режим диалога выключен. Бот молчит в этом чате.") else: self._history[chat_id] = [] if chat_id not in self._chat_order: self._chat_order.append(chat_id) # сохраняем пустой список — так при перезагрузке режим восстановится try: self.db.set("AI", f"history_{chat_id}", []) self.db.set("AI", "chat_ids", self._chat_order) except Exception: pass await utils.answer(message, "🔊 Режим диалога включён. Бот отвечает в этом чате.") async def _do_reset(self, message): self._history[message.chat_id] = [] await utils.answer(message, "🧹 История очищена.") async def _do_clear(self, message): self._history.clear() self._chat_order.clear() await utils.answer(message, "🧹 Вся история очищена.") async def _do_prompt(self, message, args): user_id = message.sender_id if not args: await utils.answer(message, f"🎭 {html.escape(self._get_user_prompt(user_id))}\n\nСменить: .aiprompt текст") return try: self.db.set("AI", f"user_prompt_{user_id}", args) await utils.answer(message, f"✅ Характер обновлён:\n{html.escape(args)}") except Exception as e: await utils.answer(message, f"⚠️ {html.escape(str(e))}") async def _do_preset(self, message, args): user_id = message.sender_id if not args: presets = "\n".join(f" • {k}" for k in self.PRESETS) await utils.answer(message, f"🎭 Пресеты:\n{presets}") return if args not in self.PRESETS: await utils.answer(message, f"❌ Доступно: {', '.join(self.PRESETS.keys())}") return try: self.db.set("AI", f"user_prompt_{user_id}", self.PRESETS[args]) await utils.answer(message, f"✅ Пресет {args} установлен.") except Exception as e: await utils.answer(message, f"⚠️ {html.escape(str(e))}") async def _do_char(self, message, args): user_id = message.sender_id if args.strip().lower() == "reset": try: self.db.set("AI", f"user_prompt_{user_id}", None) except Exception: pass await utils.answer(message, "✅ Сброшено.") return current = self._get_user_prompt(user_id) is_custom = False try: is_custom = self.db.get("AI", f"user_prompt_{user_id}", None) is not None except Exception: pass await utils.answer( message, f"🎭 Твой характер ({'личный' if is_custom else 'по умолчанию'}):\n{html.escape(current)}\n\n" "Сброс: .aichar reset", ) async def _do_stats(self, message): total_req = self._stats["requests"] + self._stats.get("cache_hits", 0) hit_rate = (self._stats.get("cache_hits", 0) / total_req * 100) if total_req else 0 c = self._compact_num await utils.answer( message, "📊 Статистика AI\n\n" f" • Запросов: {c(self._stats['requests'])}\n" f" • Cache hits: {c(self._stats.get('cache_hits', 0))} ({hit_rate:.1f}%)\n" f" • Vision: {c(self._stats.get('vision', 0))}\n" f" • OCR: {c(self._stats.get('ocr', 0))}\n" f" • Context: {c(self._stats.get('contexts', 0))}\n" f" • Сводок: {c(self._stats.get('summaries', 0))}\n" f" • Ошибок: {c(self._stats['errors'])}\n\n" "💳 Токены\n" f" • In: {c(self._stats.get('tokens_in', 0))}\n" f" • Out: {c(self._stats.get('tokens_out', 0))}\n" f" • Total: {c(self._stats.get('tokens_total', 0))}\n\n" "🚨 Ошибки\n" f" • 401/403: {c(self._stats.get('errors_401', 0))}\n" f" • 404: {c(self._stats.get('errors_404', 0))}\n" f" • 429: {c(self._stats.get('errors_429', 0))}\n" f" • 500+: {c(self._stats.get('errors_500', 0))}\n" f" • Прочие: {c(self._stats.get('errors_other', 0))}", ) async def _do_export(self, message): data = {"history": {str(k): v for k, v in self._history.items()}, "exported_at": time.time()} content = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") file = io.BytesIO(content) file.name = f"ai_history_{int(time.time())}.json" await message.client.send_file(message.chat_id, file, caption="📦 Экспорт истории") async def _do_import(self, message): reply = await message.get_reply_message() if not reply or not reply.document: await utils.answer(message, "❌ Ответь на JSON-файл.") return try: data = await reply.download_media(bytes) parsed = json.loads(data.decode("utf-8")) hist = parsed.get("history", {}) count = 0 for k, v in hist.items(): try: cid = int(k) except ValueError: continue self._history[cid] = v if cid not in self._chat_order: self._chat_order.append(cid) count += 1 await utils.answer(message, f"✅ Импортировано {count} чатов.") except Exception as e: await utils.answer(message, f"⚠️ {html.escape(str(e))}") async def _do_info(self, message): provider = self.config["provider"] cfg = self._PROVIDERS.get(provider, {}) model = self.config["model"] or cfg.get("default_model", "—") vision_model = self._get_vision_model() or "—" history_size = sum(len(h) for h in self._history.values()) await utils.answer( message, "🤖 AI-модуль — информация\n\n" f" • Создатель: {self.CREATOR_USERNAME}\n" f" • Провайдер: {provider}\n" f" • Модель: {model}\n" f" • Vision: {vision_model}\n" f" • Temperature: {self.config['temperature']}\n" f" • Max tokens: {self.config['max_tokens']}\n" f" • История: {history_size} сообщ. в {len(self._history)} чатах\n" f" • Vision: {'вкл' if self.config['vision_enabled'] else 'выкл'}\n" f" • Кэш: {'вкл' if self.config['cache_enabled'] else 'выкл'} ({len(self._cache)})\n" f" • Санитайзер: {'вкл' if self.config['sanitize_output'] else 'выкл'}\n" f" • Белый список: {'вкл 🔒' if self.config['whitelist_enabled'] else 'выкл 🔓'} ({len(self._whitelist_chats)})\n" f" • Контекст: {self.config['context_size']} сообщ.", ) # ─── Whitelist ─── async def _do_allow(self, message): if message.sender_id != self.CREATOR_ID: await utils.answer(message, "🔒 Только для создателя.") return self._whitelist_chats.add(message.chat_id) self._save_whitelist() await utils.answer( message, f"✅ Чат добавлен в белый список.\n" f"ID: {message.chat_id}\n" f"Всего: {len(self._whitelist_chats)}", ) async def _do_disallow(self, message): if message.sender_id != self.CREATOR_ID: await utils.answer(message, "🔒 Только для создателя.") return self._whitelist_chats.discard(message.chat_id) self._save_whitelist() await utils.answer( message, f"🚫 Чат удалён из белого списка.\nID: {message.chat_id}", ) async def _do_allowlist(self, message): if message.sender_id != self.CREATOR_ID: await utils.answer(message, "🔒 Только для создателя.") return if not self._whitelist_chats: await utils.answer(message, "📋 Белый список пуст.") return lines = [f"📋 Белый список ({len(self._whitelist_chats)})\n"] for cid in self._whitelist_chats: try: chat = await self._client.get_entity(cid) title = getattr(chat, "title", None) or "Личный чат" except Exception: title = "?" lines.append(f" • {cid} — {html.escape(title)}") await utils.answer(message, "\n".join(lines)) async def _do_allow_all(self, message): if message.sender_id != self.CREATOR_ID: await utils.answer(message, "🔒 Только для создателя.") return count = len(self._whitelist_chats) self._whitelist_chats.clear() self._save_whitelist() await utils.answer(message, f"✅ Белый список очищен ({count}).") async def _do_whitelist_toggle(self, message): if message.sender_id != self.CREATOR_ID: await utils.answer(message, "🔒 Только для создателя.") return self.config["whitelist_enabled"] = not self.config["whitelist_enabled"] status = "вкл 🔒" if self.config["whitelist_enabled"] else "выкл 🔓" await utils.answer( message, f"Белый список: {status}\nВ списке: {len(self._whitelist_chats)}", ) def _help_public(self): return ( "🤖 AI-модуль\n" f"Создатель: {self.CREATOR_USERNAME}\n\n" "💬 Диалог\n" " • .ai вопрос — спросить AI (ответом на сообщение тоже работает)\n" " • .aitoggle — вкл/выкл режим живого диалога\n" " • .aireset — сбросить историю диалога в этом чате\n" " • .aiclear — сбросить историю во ВСЕХ чатах\n\n" "🖼 Картинки\n" " • ответом на фото + .ai вопрос — AI посмотрит и опишет\n" " • .aiocr — распознать текст с фото/скриншота\n\n" "📊 Анализ\n" " • .aisummary [N] [@user] — краткая сводка последних N сообщений\n" " • .aicontext [N] вопрос — ответить на вопрос по истории чата\n" " • .aitimeline [N] — хронология сообщений\n\n" "🧠 Память о людях\n" " • .airemember текст — запомнить факт о себе\n" " • .airemember @user текст — запомнить факт о другом\n" " • .aiforget [@user] [N] — удалить факт (или все)\n" " • .ainotes [@user] — посмотреть все факты\n\n" "🎭 Характер\n" " • .aiprompt текст — задать свой личный характер\n" " • .aipreset coder — готовый пресет\n" " • .aichar — посмотреть текущий / reset\n\n" "⚙️ Прочее\n" " • .aistats — статистика: запросы, токены, ошибки, кэш\n" " • .aiinfo — настройки модуля\n" " • .aiexport / .aiimport — история\n\n" "🔒 Белый список)\n" " • .aiwhitelist — вкл/выкл режим\n" " • .aiallow — добавить текущий чат\n" " • .aidisallow — убрать текущий чат\n" " • .aiallowlist — показать список\n" " • .aiallowall — очистить список" ) # ─── Основные команды ─── @loader.command() async def ai(self, message): """AI: спросить, сводка, контекст, характер, настройки.""" args = (utils.get_args_raw(message) or "").strip() if not self._whitelist_ok(message): await utils.answer(message, "🔒 AI в этом чате недоступен.") return if not args: reply = await message.get_reply_message() if reply and self.config["vision_enabled"]: if reply.photo or reply.sticker or reply.gif or reply.video: await self._do_ask(message, "") return await utils.answer(message, self._help_public()) return parts = args.split(maxsplit=1) sub = parts[0].lower() rest = parts[1] if len(parts) > 1 else "" if sub == "help": await utils.answer(message, self._help_public()); return if sub == "info": await self._do_info(message); return if sub == "stats": await self._do_stats(message); return if sub in ("summary", "sum"): await self._do_summary(message, rest); return if sub in ("context", "ctx", "ask"): await self._do_context(message, rest); return if sub in ("timeline", "tl"): await self._do_timeline(message, rest); return if sub in ("toggle", "on", "off"): await self._do_toggle(message); return if sub == "reset": await self._do_reset(message); return if sub == "clear": await self._do_clear(message); return if sub in ("prompt", "p"): await self._do_prompt(message, rest); return if sub in ("preset", "pr"): await self._do_preset(message, rest); return if sub in ("char", "c"): await self._do_char(message, rest); return if sub == "export": await self._do_export(message); return if sub == "import": await self._do_import(message); return if sub == "ocr": await self._do_ocr(message, rest); return if sub in ("remember", "rem"): await self._do_remember(message, rest); return if sub in ("forget", "fgt"): await self._do_forget(message, rest); return if sub in ("notes", "note", "n"): await self._do_notes(message, rest); return # Whitelist через .ai if sub == "whitelist": await self._do_whitelist_toggle(message); return if sub == "allow": await self._do_allow(message); return if sub == "disallow": await self._do_disallow(message); return if sub == "allowlist": await self._do_allowlist(message); return if sub == "allow_all": await self._do_allow_all(message); return await self._do_ask(message, args) # ─── Alias-команды ─── @loader.command() async def aistats(self, message): """Статистика AI: запросы, токены, ошибки""" await self._do_stats(message) @loader.command() async def aiinfo(self, message): """Информация о настройках AI-модуля""" await self._do_info(message) @loader.command() async def aisummary(self, message): """Сводка последних N сообщений. .aisummary [N] [@user]""" await self._do_summary(message, utils.get_args_raw(message) or "") @loader.command() async def aicontext(self, message): """Ответить на вопрос по истории чата. .aicontext [N] вопрос""" await self._do_context(message, utils.get_args_raw(message) or "") @loader.command() async def aitimeline(self, message): """Хронология сообщений. .aitimeline [N]""" await self._do_timeline(message, utils.get_args_raw(message) or "") @loader.command() async def aitoggle(self, message): """Включить/выключить режим диалога в этом чате""" await self._do_toggle(message) @loader.command() async def aireset(self, message): """Сбросить историю диалога в этом чате""" await self._do_reset(message) @loader.command() async def aiclear(self, message): """Сбросить историю диалога во ВСЕХ чатах""" await self._do_clear(message) @loader.command() async def aiprompt(self, message): """Задать личный характер AI. .aiprompt текст""" await self._do_prompt(message, utils.get_args_raw(message) or "") @loader.command() async def aipreset(self, message): """Установить пресет характера. .aipreset coder""" await self._do_preset(message, utils.get_args_raw(message) or "") @loader.command() async def aichar(self, message): """Показать твой текущий характер. .aichar reset — сбросить""" await self._do_char(message, utils.get_args_raw(message) or "") @loader.command() async def aiexport(self, message): """Экспорт истории диалогов в JSON-файл""" await self._do_export(message) @loader.command() async def aiimport(self, message): """Импорт истории из JSON (ответом на файл)""" await self._do_import(message) @loader.command() async def aiocr(self, message): """Распознать текст с фото. Ответом на картинку""" await self._do_ocr(message, utils.get_args_raw(message) or "") @loader.command() async def airemember(self, message): """Запомнить факт о пользователе. .airemember [@user] текст""" await self._do_remember(message, utils.get_args_raw(message) or "") @loader.command() async def aiforget(self, message): """Удалить факт из памяти. .aiforget [@user] [N]""" await self._do_forget(message, utils.get_args_raw(message) or "") @loader.command() async def aimemory(self, message): """Показать, что AI запомнил о тебе (или о другом). .aimemory [@user]""" args = (utils.get_args_raw(message) or "").strip() target_uid = message.sender_id display = "тебе" if args: try: entity = await message.client.get_entity( int(args) if args.lstrip("-").isdigit() else args ) target_uid = entity.id display = html.escape(getattr(entity, "first_name", None) or str(target_uid)) except Exception: await utils.answer(message, "❌ Не нашёл.") return notes = self._get_user_notes(target_uid) if not notes: await utils.answer(message, f"📭 AI пока ничего не запомнил о {display}.") return lines = [f"🧠 AI помнит о {display} ({len(notes)}):\n"] for i, n in enumerate(notes, 1): lines.append(f" {i}. {html.escape(n)}") lines.append("\nУдалить: .aiforget [@user] N") await utils.answer(message, "\n".join(lines)) @loader.command() async def aimemory_off(self, message): """[Создатель] Выключить авто-память""" if message.sender_id != self.CREATOR_ID: await utils.answer(message, "🔒 Только для создателя.") return self.config["auto_memory"] = False await utils.answer(message, "🧠 Авто-память выключена.") @loader.command() async def aimemory_on(self, message): """[Создатель] Включить авто-память""" if message.sender_id != self.CREATOR_ID: await utils.answer(message, "🔒 Только для создателя.") return self.config["auto_memory"] = True await utils.answer(message, "🧠 Авто-память включена.") @loader.command() async def aiwhitelist(self, message): """ Вкл/выкл белый список чатов""" await self._do_whitelist_toggle(message) @loader.command() async def aiallow(self, message): """ Добавить текущий чат в белый список""" await self._do_allow(message) @loader.command() async def aidisallow(self, message): """ Убрать текущий чат из белого списка""" await self._do_disallow(message) @loader.command() async def aiallowlist(self, message): """ Показать белый список чатов""" await self._do_allowlist(message) @loader.command() async def aiallowall(self, message): """ Очистить белый список""" await self._do_allow_all(message) @loader.watcher(only_messages=True) async def watcher(self, message): if not message.raw_text and not ( message.photo or message.sticker or message.gif or message.video ): return if message.sender_id == self._me.id: return if self._maintenance and message.sender_id != self.CREATOR_ID: return if message.sender_id in self._banned: return if message.sender_id in self._silent_banned: return if message.chat_id in self._gagged_chats: return if self._is_user_gagged(message.sender_id, message.chat_id): return if not self._whitelist_ok(message): return uid_str = str(message.sender_id) self._user_msg_count[uid_str] = self._user_msg_count.get(uid_str, 0) + 1 raw_text = message.raw_text or "" if self._tree_enabled and self.TREE_EMOJI in raw_text: if message.sender_id == self.CREATOR_ID: if message.id in self._tree_replied: return self._tree_replied.add(message.id) await utils.answer(message, self.TREE_RESPONSE) return if self.config["auto_reactions"] and raw_text: reaction = self._pick_reaction(raw_text) if reaction: try: await message.react(reaction) except Exception: pass if raw_text.startswith((".", "/", "!")): return text_lower = raw_text.lower() my_username = (self._me.username or "").lower() trigger = (self.config["reply_to_trigger"] or "").lower().strip() mentioned = bool(my_username) and f"@{my_username}" in text_lower triggered = False if trigger: words = re.findall(r"\w+", text_lower) if trigger in words: triggered = True if mentioned or triggered: if self.config["vision_enabled"] and ( message.photo or message.sticker or message.gif or message.video ): answer = await self._ask_ai_vision(message, raw_text, message) else: user_prompt = self._get_user_prompt_with_notes(message.sender_id) final_text = await self._build_user_message(message, raw_text) self._add_to_history(message.chat_id, "user", final_text) messages = [{"role": "system", "content": user_prompt}] + self._get_history(message.chat_id) answer = await self._ask_ai(messages, use_history_chat_id=message.chat_id) await utils.answer(message, self._sanitize_ai_output(answer)) self._save_user_count() return if message.chat_id not in self._history: return if self.config["reply_only_when_mentioned"] and not (mentioned or triggered): return if not message.is_reply: return reply = await message.get_reply_message() if not reply or reply.sender_id != self._me.id: return if self.config["vision_enabled"] and ( message.photo or message.sticker or message.gif or message.video ): answer = await self._ask_ai_vision(message, raw_text, message) else: user_prompt = self._get_user_prompt_with_notes(message.sender_id) self._add_to_history(message.chat_id, "user", raw_text) messages = [{"role": "system", "content": user_prompt}] + self._get_history(message.chat_id) answer = await self._ask_ai(messages, use_history_chat_id=message.chat_id) await utils.answer(message, self._sanitize_ai_output(answer))