Created
May 27, 2026 17:46
-
-
Save sepiol026-wq/91a2c0b489fcb5b06c257a8b50262fa4 to your computer and use it in GitHub Desktop.
AetherAI: zero-setup web search (6 engines auto-fallback), cache, memory, git, shell, code, scheduler, sub-agents, 582 TL methods
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # This file is part of SenkoGuardianModules | |
| # Copyright (c) 2025-2026 Senko | |
| # This software is released under the MIT License. | |
| # https://opensource.org/licenses/MIT | |
| # scope heroku_min: 2.0.0 | |
| # meta banner: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png | |
| # meta pic: https://raw.githubusercontent.com/SenkoGuardian/SenkoGuardian.github.io/main/OfficialSenkoGuardianBanner.png | |
| __version__ = ("6", "8", "0") | |
| """ ̄へ ̄""" | |
| # meta developer: @SenkoGuardianModules | |
| # .------. .------. .------. .------. .------. .------. | |
| # |S.--. | |E.--. | |N.--. | |M.--. | |O.--. | |D.--. | | |
| # | :/\: | | :/\: | | :(): | | :/\: | | :/\: | | :/\: | | |
| # | :\/: | | :\/: | | ()() | | :\/: | | :\/: | | :\/: | | |
| # | '--'S| | '--'E| | '--'N| | '--'M| | '--'O| | '--'D| | |
| # `------' `------' `------' `------' `------' `------' | |
| import re | |
| import os | |
| import io | |
| import inspect | |
| import random | |
| import socket | |
| import base64 | |
| import uuid | |
| import json | |
| import asyncio | |
| import logging | |
| import tempfile | |
| import time | |
| import contextlib | |
| import shutil | |
| import mimetypes | |
| import hashlib | |
| import zipfile | |
| import aiohttp | |
| from urllib.parse import urlparse, unquote | |
| from markdown_it import MarkdownIt | |
| import pytz | |
| import httpx | |
| # New SDK Check | |
| try: | |
| from google import genai | |
| from google.genai import types | |
| import google.api_core.exceptions as google_exceptions | |
| GOOGLE_AVAILABLE = True | |
| except ImportError: | |
| GOOGLE_AVAILABLE = False | |
| google_exceptions = None | |
| from PIL import Image, ImageOps | |
| from datetime import datetime, timedelta | |
| from telethon import Button, types as tg_types | |
| from telethon.errors import FloodWaitError | |
| from telethon.tl.functions.channels import GetFullChannelRequest, InviteToChannelRequest, JoinChannelRequest, LeaveChannelRequest | |
| from telethon.tl.functions.contacts import BlockRequest, UnblockRequest | |
| from telethon.tl.functions.messages import GetFullChatRequest, GetStickerSetRequest, SaveDraftRequest, SendReactionRequest | |
| from telethon.tl.functions.users import GetFullUserRequest | |
| from telethon.tl.types import ( | |
| Channel, | |
| Chat, | |
| DocumentAttributeFilename, | |
| DocumentAttributeSticker, | |
| InputMessagesFilterDocument, | |
| InputMessagesFilterGif, | |
| InputMessagesFilterMusic, | |
| InputMessagesFilterPhotos, | |
| InputMessagesFilterRoundVoice, | |
| InputMessagesFilterUrl, | |
| InputMessagesFilterVideo, | |
| InputStickerSetShortName, | |
| Message, | |
| ReactionEmoji, | |
| User, | |
| ) | |
| from telethon.utils import get_display_name, get_peer_id | |
| from telethon.errors.rpcerrorlist import ( | |
| MessageTooLongError, | |
| ChatAdminRequiredError, | |
| UserNotParticipantError, | |
| ChannelPrivateError | |
| ) | |
| from .. import loader, utils | |
| from ..inline.types import InlineCall | |
| logger = logging.getLogger(__name__) | |
| _gemini_log_client = None | |
| _gemini_log_channel = None | |
| _gemini_log_topic_id = None | |
| class _GeminiTopicHandler(logging.Handler): | |
| _api_key_pattern = re.compile(r'(AIza[0-9A-Za-z\-_]{35}|sk-[A-Za-z0-9]{32,}|xai-[A-Za-z0-9]{32,}|gsk_[A-Za-z0-9]{32,})') | |
| def emit(self, record): | |
| if _gemini_log_client is None or _gemini_log_channel is None or _gemini_log_topic_id is None: | |
| return | |
| try: | |
| sanitized = self._api_key_pattern.sub("<API_KEY_REDACTED>", self.format(record)) | |
| text = f"<code>[{record.levelname}]</code> {sanitized}" | |
| asyncio.ensure_future( | |
| _gemini_log_client.send_message( | |
| int(f"-100{_gemini_log_channel}"), | |
| text, | |
| parse_mode="html", | |
| reply_to=_gemini_log_topic_id, | |
| ) | |
| ) | |
| except Exception: | |
| pass | |
| _gemini_topic_handler = _GeminiTopicHandler() | |
| _gemini_topic_handler.setLevel(logging.WARNING) | |
| logger.addHandler(_gemini_topic_handler) | |
| DB_HISTORY_KEY = "gemini_conversations_v4" | |
| DB_GAUTO_HISTORY_KEY = "gemini_gauto_conversations_v1" | |
| DB_IMPERSONATION_KEY = "gemini_impersonation_chats" | |
| DB_PRESETS_KEY = "gemini_prompt_presets" | |
| DB_PAGER_CACHE_KEY = "gemini_pager_cache" | |
| DB_KEY_MAP_KEY = "gemini_key_model_map" | |
| DB_PROVIDER_TOKEN_MAP_KEY = "aether_provider_tokens_v1" | |
| DB_PROVIDER_MODEL_MAP_KEY = "aether_provider_models_v1" | |
| GEMINI_TIMEOUT = 840 | |
| MAX_FFMPEG_SIZE = 90 * 1024 * 1024 | |
| MAX_REMOTE_FETCH_SIZE = 256 * 1024 * 1024 | |
| CHECK_MODEL = "gemini-2.5-pro" | |
| VEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" | |
| VEO_MODEL_CACHE_TTL = 600 | |
| VEO_POLL_INTERVAL = 10 | |
| # requires: google-genai google-api-core pytz markdown_it_py | |
| class Gemini(loader.Module): | |
| """Универсальный AI-модуль с несколькими провайдерами и динамическими моделями.""" | |
| strings = { | |
| "name": "AetherAI", | |
| "cfg_api_key_doc": "API ключи Google Gemini, разделенные запятой. Будут скрыты.", | |
| "cfg_model_name_doc": "Модель текущего AI-провайдера.", | |
| "cfg_buttons_doc": "Включить интерактивные кнопки.", | |
| "cfg_system_instruction_doc": "Системная инструкция (промпт) для Gemini.", | |
| "cfg_max_history_length_doc": "Макс. кол-во пар 'вопрос-ответ' в памяти (0 - без лимита).", | |
| "cfg_timezone_doc": "Ваш часовой пояс. Список: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", | |
| "cfg_proxy_doc": "Прокси для обхода региональных блокировок. Формат: http://user:pass@host:port", | |
| "cfg_impersonation_prompt_doc": "Промпт для режима авто-ответа. {my_name} и {chat_history} будут заменены.", | |
| "cfg_impersonation_history_limit_doc": "Сколько последних сообщений из чата отправлять в качестве контекста для авто-ответа.", | |
| "cfg_impersonation_reply_chance_doc": "Вероятность ответа в режиме gauto (от 0.0 до 1.0). 0.2 = 20% шанс.", | |
| "cfg_temperature_doc": "Температура генерации (креативность). От 0.0 до 2.0. По умолчанию 1.0.", | |
| "cfg_google_search_doc": "Включить поиск Google (Grounding) для актуальной информации.", | |
| "cfg_image_model_doc": "Модель Gemini для генерации изображений (например: gemini-2.5-flash-image).", | |
| "cfg_inline_pagination_doc": "Использовать инлайн-кнопки для длинных ответов.", | |
| "no_api_key": ( | |
| '❗️ <b>Api ключ(и) не настроен(ы).</b>\nПолучить Api ключ можно <a href="https://aistudio.google.com/app/apikey">здесь</a>.\n' | |
| '<b>Добавьте ключ(и) в конфиге модуля:</b> <code>.cfg AetherAI api_key</code>\n' | |
| 'Так же можно использовать провайдера Openrouter <code>.cfg AetherAI provider</code>\n' | |
| 'ℹ️ Получить Openrouter ключ можно <a href="https://openrouter.ai/settings/keys">здесь</a>' | |
| ), | |
| "no_api_key_Openrouter": '❗️ <b>API ключ для OpenRouter не настроен.</b>\nПолучить ключ можно <a href="https://openrouter.ai/settings/keys">здесь</a>.\n<b>Добавьте ключ в конфиге модуля:</b> <code>.cfg AetherAI Openrouter_api_key</code>', | |
| "invalid_api_key_Openrouter": '❗️ <b>Предоставленный API ключ OpenRouter недействителен.</b>\nУбедитесь, что он правильно скопирован из <a href="https://openrouter.ai/settings/keys">OpenRouter</a>.', | |
| "gmodel_list_title_Openrouter": "📋 <b>Доступные модели OpenRouter:</b>", | |
| "invalid_api_key": '❗️ <b>Предоставленный API ключ недействителен.</b>\nУбедитесь, что он правильно скопирован из <a href="https://aistudio.google.com/app/apikey">Google AI Studio</a> и что для него включен Gemini API.', | |
| "all_keys_exhausted": "❗️ <b>Все доступные API ключи ({}) исчерпали свою квоту.</b>\nПопробуйте позже или добавьте новые ключи в конфиге: <code>.cfg AetherAI api_key</code>", | |
| "no_prompt_or_media": "⚠️ <i>Нужен текст или ответ на медиа/файл.</i>", | |
| "processing": "<emoji document_id=5386367538735104399>⌛️</emoji> <b>Обработка...</b>", | |
| "api_error": "❗️ <b>Ошибка API Google Gemini:</b>\n<code>{}</code>", | |
| "api_timeout": f"❗️ <b>Таймаут ответа от Gemini API ({GEMINI_TIMEOUT} сек).</b>", | |
| "blocked_error": "🚫 <b>Запрос/ответ заблокирован.</b>\n<code>{}</code>", | |
| "generic_error": "❗️ <b>Ошибка:</b>\n<code>{}</code>", | |
| "question_prefix": "💬 <b>Запрос:</b>", | |
| "response_prefix": "<emoji document_id=5325547803936572038>✨</emoji> <b>AetherAI:</b>", | |
| "unsupported_media_type": "⚠️ <b>Формат медиа ({}) не поддерживается.</b>", | |
| "memory_status": "🧠 [{}/{}]", | |
| "memory_status_unlimited": "🧠 [{}/∞]", | |
| "memory_cleared": "🧹 <b>Память диалога очищена.</b>", | |
| "memory_cleared_gauto": "🧹 <b>Память gauto в этом чате очищена.</b>", | |
| "no_memory_to_clear": "ℹ️ <b>В этом чате нет истории.</b>", | |
| "no_gauto_memory_to_clear": "ℹ️ <b>В этом чате нет истории gauto.</b>", | |
| "memory_chats_title": "🧠 <b>Чаты с историей ({}):</b>", | |
| "memory_chat_line": " • {} (<code>{}</code>)", | |
| "no_memory_found": "ℹ️ Память Gemini пуста.", | |
| "media_reply_placeholder": "[ответ на медиа]", | |
| "btn_clear": "🧹 Очистить", | |
| "btn_regenerate": "🔄 Другой ответ", | |
| "no_last_request": "Последний запрос не найден для повторной генерации.", | |
| "memory_fully_cleared": "🧹 <b>Вся память Gemini полностью очищена (затронуто {} чатов).</b>", | |
| "gauto_memory_fully_cleared": "🧹 <b>Вся память gauto полностью очищена (затронуто {} чатов).</b>", | |
| "no_memory_to_fully_clear": "ℹ️ <b>Память Gemini и так пуста.</b>", | |
| "no_gauto_memory_to_fully_clear": "ℹ️ <b>Память gauto и так пуста.</b>", | |
| "response_too_long": "Ответ Gemini был слишком длинным и отправлен в виде файла.", | |
| "gclear_usage": "ℹ️ <b>Использование:</b> <code>.aclear [auto]</code>", | |
| "gres_usage": "ℹ️ <b>Использование:</b> <code>.ares [auto]</code>", | |
| "auto_mode_on": "🎭 <b>Режим авто-ответа включен в этом чате.</b>\nЯ буду отвечать на сообщения с вероятностью {}%.", | |
| "auto_mode_off": "🎭 <b>Режим авто-ответа выключен в этом чате.</b>", | |
| "auto_mode_chats_title": "🎭 <b>Чаты с активным авто-ответом ({}):</b>", | |
| "no_auto_mode_chats": "ℹ️ Нет чатов с включенным режимом авто-ответа.", | |
| "auto_mode_usage": "ℹ️ <b>Использование:</b> <code>.aauto on/off или[id/username] [on/off]</code>", | |
| "gauto_chat_not_found": "🚫 <b>Не удалось найти чат:</b> <code>{}</code>", | |
| "gauto_state_updated": "🎭 <b>Режим авто-ответа для чата {} {}</b>", | |
| "gauto_enabled": "включен", | |
| "gauto_disabled": "выключен", | |
| "gch_usage": "ℹ️ <b>Использование:</b>\n<code>.ach <кол-во> <вопрос></code>\n<code>.ach <id чата> <кол-во> <вопрос></code>", | |
| "gch_processing": "<emoji document_id=5386367538735104399>⌛️</emoji> <b>Анализирую {} сообщений...</b>", | |
| "gch_result_caption": "Анализ последних {} сообщений", | |
| "gch_result_caption_from_chat": "Анализ последних {} сообщений из чата <b>{}</b>", | |
| "gch_invalid_args": "❗️ <b>Неверные аргументы.</b>\n{}", | |
| "gch_chat_error": "❗️ <b>Ошибка доступа к чату</b> <code>{}</code>: <i>{}</i>", | |
| "gmodel_usage": "ℹ️ <b>Использование:</b> <code>.amodels</code>\n• открыть инлайн-меню провайдеров.\n• выбрать провайдера, токен и модель динамически.", | |
| "gmodel_list_title": "📋 <b>Доступные модели Gemini (по вашему API):</b>", | |
| "gmodel_list_item": "• <code>{}</code> — {} (поддержка: {})", | |
| "gmodel_img_support": "Поддержка изображений", | |
| "gmodel_no_support": "Нет поддержки изображений", | |
| "gmodel_img_warn": "⚠️ <b>Текущая модель ({}) не может генерировать изображения(или не доступна по API).</b>\nРекомендуем: <code>gemini-2.5-flash-image</code>", | |
| "gme_chat_not_found": "🚫 <b>Не удалось найти чат для экспорта:</b> <code>{}</code>", | |
| "gme_sent_to_saved": "💾 История экспортирована в избранное.", | |
| "new_sdk_missing": "⚠️ <b>Для работы модуля нужна библиотека google-genai.</b>\nВыполните: <code>pip install google-genai</code>", | |
| "gprompt_usage": "ℹ️ <b>Использование:</b>\n<code>.aprompt <текст/пресет></code> — установить.\n<code>.aprompt -c</code> — очистить.\n<code>.apresets</code> — база пресетов.", | |
| "gprompt_updated": "✅ <b>Системный промпт обновлен!</b>\nДлина: {} символов.", | |
| "gprompt_cleared": "🗑 <b>Системный промпт очищен.</b>", | |
| "gprompt_current": "📝 <b>Текущий системный промпт:</b>", | |
| "gprompt_file_error": "❗️ <b>Ошибка чтения файла:</b> {}", | |
| "gprompt_file_too_big": "❗️ <b>Файл слишком большой</b> (лимит 1 МБ).", | |
| "gprompt_not_text": "❗️ Это не похоже на текстовый файл.(txt)", | |
| "gmodel_no_models": "⚠️ Не удалось получить список моделей.", | |
| "gmodel_list_error": "❗️ Ошибка получения списка: {}", | |
| "gimg_process": "<emoji document_id=5325547803936572038>✨</emoji> <b>Генерация...</b>\n🧠 <i>Модель: {model}</i>", | |
| "gpresets_usage": ( | |
| "ℹ️ <b>Управление пресетами:</b>\n" | |
| "• <code>.apresets save [Имя] текст</code> — сохранить (имя в скобках, если с пробелами).\n" | |
| "• <code>.apresets load 1</code> или <code>имя</code> — загрузить по номеру/имени.\n" | |
| "• <code>.apresets del 1</code> или <code>имя</code> — удалить.\n" | |
| "• <code>.apresets list</code> — список." | |
| ), | |
| "gpreset_loaded": "✅ <b>Установлен пресет:</b> [<code>{}</code>]\nДлина: {} симв.", | |
| "gpreset_saved": "💾 <b>Пресет сохранен!</b>\n🏷 <b>Имя:</b> {}\n№ <b>Индекс:</b> {}", | |
| "gpreset_deleted": "🗑 <b>Пресет удален:</b> {}", | |
| "gpreset_not_found": "🚫 Пресет с таким именем или индексом не найден.", | |
| "gpreset_list_head": "📋 <b>Ваши пресеты:</b>\n", | |
| "gpreset_empty": "📂 Список пресетов пуст.", | |
| # --- Veo strings --- | |
| "gveo_no_api_key": "⚠️ <b>Настрой API ключ в <code>.cfg AetherAI api_key</code> или через <code>.amodels</code>.</b>", | |
| "gveo_no_prompt": "❌ <b>Укажи описание видео.</b>", | |
| "gveo_menu_title": "🎬 <b>Генерация видео (Veo)</b>", | |
| "gveo_menu_mode": "• <b>Режим:</b> {}", | |
| "gveo_menu_mode_t2v": "text-to-video", | |
| "gveo_menu_mode_i2v": "image-to-video", | |
| "gveo_menu_prompt": "• <b>Промпт:</b> <code>{}</code>", | |
| "gveo_menu_model": "• <b>Модель:</b> <code>{}</code>", | |
| "gveo_menu_seconds": "• <b>Длительность:</b> <code>{}с</code>", | |
| "gveo_menu_aspect": "• <b>Формат:</b> <code>{}</code>", | |
| "gveo_menu_resolution": "• <b>Разрешение:</b> <code>{}</code>", | |
| "gveo_menu_hint": "Нажми кнопки ниже, затем запусти генерацию.", | |
| "gveo_menu_warning": "⚠️ <b>{}</b>", | |
| "gveo_generating": ( | |
| "🎬 <b>Генерирую видео...</b>\n\n" | |
| "• <b>Режим:</b> {}\n" | |
| "• <b>Модель:</b> <code>{}</code>\n" | |
| "• <b>Длительность:</b> <code>{}с</code>\n" | |
| "• <b>Формат:</b> <code>{}</code>\n" | |
| "• <b>Разрешение:</b> <code>{}</code>\n" | |
| "• <b>Промпт:</b> <code>{}</code>\n\n" | |
| "⏳ Обычно это занимает 1-3 минуты..." | |
| ), | |
| "gveo_progress": ( | |
| "🎬 <b>Генерация...</b> {}%\n\n" | |
| "• <b>Режим:</b> {}\n" | |
| "• <b>Модель:</b> <code>{}</code>\n" | |
| "• <b>Длительность:</b> <code>{}с</code>\n" | |
| "• <b>Формат:</b> <code>{}</code>\n" | |
| "• <b>Разрешение:</b> <code>{}</code>\n" | |
| "• <b>Промпт:</b> <code>{}</code>" | |
| ), | |
| "gveo_success": ( | |
| "✅ <b>Видео готово!</b>\n\n" | |
| "• <b>Режим:</b> {}\n" | |
| "• <b>Модель:</b> <code>{}</code>\n" | |
| "• <b>Длительность:</b> <code>{}с</code>\n" | |
| "• <b>Формат:</b> <code>{}</code>\n" | |
| "• <b>Разрешение:</b> <code>{}</code>\n" | |
| "• <b>Время:</b> <code>{:.1f}с</code>\n\n" | |
| "<blockquote expandable>📝 {}</blockquote>" | |
| ), | |
| "gveo_error": "❌ <b>Ошибка:</b> {}", | |
| "gveo_timeout": "⏰ <b>Таймаут генерации.</b>", | |
| "gveo_quota": "💳 <b>Квота исчерпана.</b>", | |
| "gveo_closed": "🗑 <b>Меню закрыто.</b>", | |
| "gveo_expired": "⚠️ <b>Сессия устарела. Запусти <code>.aveo</code> заново.</b>", | |
| "gveo_models_error": "⚠️ <b>Не удалось получить список моделей Veo, использую локальный список.</b>", | |
| "gveo_resolution_adjusted_veo2": "Veo 2 поддерживает только 720p.", | |
| "gveo_resolution_adjusted_31": "Для Veo 3.1 1080p доступно только при 8 секундах.", | |
| "gveo_resolution_adjusted_30": "Для Veo 3.0 1080p доступно только при формате 16:9.", | |
| # --- gkeys (import keys) strings --- | |
| "gkeys_usage": ( | |
| "ℹ️ <b>Использование:</b>\n" | |
| "• Ответьте на сообщение/файл с ключами: <code>.akeys</code>\n" | |
| "• Укажите raw URL: <code>.akeys https://raw.../keys.txt</code>\n" | |
| "Ключи разделяются запятой, пробелами, новой строкой или точкой с запятой." | |
| ), | |
| "gkeys_processing": "<emoji document_id=5386367538735104399>⌛️</emoji> <b>Загружаю и проверяю ключи...</b>", | |
| "gkeys_done": ( | |
| "✅ <b>Импорт завершён.</b>\n" | |
| "➕ <b>Добавлено:</b> {added}\n" | |
| "🔁 <b>Уже были:</b> {dupes}\n" | |
| "🔑 <b>Всего ключей:</b> {total}" | |
| ), | |
| "gkeys_no_keys": "❌ <b>Ключи не найдены в указанном источнике.</b>", | |
| "gkeys_fetch_error": "❌ <b>Ошибка загрузки:</b> <code>{}</code>", | |
| "autogen_tool_invalid": "⚠️ <b>Неверный XML-блок генерации.</b>", | |
| "autogen_tool_failed": "❌ <b>Автогенерация не удалась:</b> <code>{}</code>", | |
| "autogen_image_generating": "🖼 <b>Генерирую изображение...</b>", | |
| "autogen_video_generating": "🎬 <b>Генерирую видео...</b>", | |
| "autogen_image_caption": "🤖 <b>Авто-изображение</b>\n🧠 <code>{}</code>", | |
| "autogen_video_caption": "🤖 <b>Авто-видео</b>\n🧠 <code>{}</code>", | |
| "tg_tool_exec": "🔧 <b>Выполняю инструмент:</b> <code>{}</code> <i>(шаг {}/{})</i>", | |
| "tg_tools_disabled": "⚠️ <b>Telegram tools отключены в конфиге.</b>", | |
| "provider_token_missing": "⚠️ <b>Для провайдера {}</b> токен не задан.", | |
| "amodels_title": "🧠 <b>Провайдеры и модели</b>", | |
| "amodels_hint": "", | |
| "amodels_current": "• <b>{}</b> | токен: {} | {} моделей\n• Chat: <code>{}</code>\n• Image: <code>{}</code>\n• Video: <code>{}</code>", | |
| "amodels_runtime_yes": "подключён", | |
| "amodels_runtime_no": "каталог", | |
| "amodels_token_from_cfg": "из конфига", | |
| "amodels_token_custom": "кастомный", | |
| "amodels_token_missing": "нет", | |
| "amodels_models_caption": "📚 <b>{}</b> • {} • {}/{}", | |
| "amodels_apply_ok": "✅ {} • {} → <code>{}</code>", | |
| "amodels_media_limited": "⚠️ Только текст. Для медиа — Google или OpenRouter.", | |
| "amodels_tab_chat": "💬 Чат", | |
| "amodels_tab_image": "🖼 Картинки", | |
| "amodels_tab_video": "🎬 Видео", | |
| "amodels_no_models_for_tab": "⚠️ Нет моделей для {}", | |
| "amodels_section_main": "⚙️ Провайдер", | |
| "amodels_section_models": "📚 Модели", | |
| "amodels_main_caption": "", | |
| "amodels_models_section_caption": "", | |
| "amodels_provider_page": "{}/{}", | |
| "amodels_token_input": "Введи API-ключ", | |
| "amodels_fetch_ok": "Список загружен", | |
| "amodels_provider_kind_unsupported": "{} не поддерживает {}", | |
| "amodels_perplexity_fixed": "", | |
| "amodels_validation_ok": "✅ Ключ рабочий", | |
| "amodels_provider_applied": "✅ {} — модель: <code>{}</code>", | |
| } | |
| TEXT_MIME_TYPES = { | |
| "text/plain", "text/markdown", "text/html", "text/css", "text/csv", | |
| "application/json", "application/xml", "application/x-python", "text/x-python", | |
| "application/javascript", "application/x-sh", | |
| } | |
| def __init__(self): | |
| self.config = loader.ModuleConfig( | |
| loader.ConfigValue("api_key", "", self.strings["cfg_api_key_doc"], validator=loader.validators.Hidden()), | |
| loader.ConfigValue("Openrouter_api_key", "", "API Key от OpenRouter (получить <a href='https://openrouter.ai/settings/keys'>тут</a>).", validator=loader.validators.Hidden()), | |
| loader.ConfigValue("provider", "google", "Текущий AI-провайдер.", validator=loader.validators.String()), | |
| 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()), | |
| loader.ConfigValue("max_history_length", 800, self.strings["cfg_max_history_length_doc"], validator=loader.validators.Integer(minimum=0)), | |
| loader.ConfigValue("timezone", "Europe/Moscow", self.strings["cfg_timezone_doc"]), | |
| loader.ConfigValue("proxy", "", self.strings["cfg_proxy_doc"]), | |
| loader.ConfigValue( | |
| "impersonation_prompt", | |
| ( | |
| "Ты — это я, пользователь Telegram по имени {my_name}. Твоя задача — отвечать в чате от моего имени, основываясь на последних сообщениях. " | |
| "Веди себя естественно, как обычный человек, а не как ассистент. Не признавайся, что ты — ИИ, даже если тебя спросят напрямую. " | |
| "Твоя личность:[Здесь опишите свой характер: остроумный, серьезный, саркастичный, дружелюбный и т.д.].\n" | |
| "Правила:\n- Отвечай кратко и по делу.\n- Используй неформальный язык, сленг.\n- Не отвечай на каждое сообщение.\n- На медиа (стикер, фото) реагируй как человек ('лол', 'ору', 'жиза').\n- Если уместно, можешь добавить служебные подсказки [REACTION:🔥] или [STICKER:😂], но только когда это реально подходит по тону.\n- Не используй префиксы и кавычки.\n\n" | |
| "ИСТОРИЯ ЧАТА:\n{chat_history}\n\n{my_name}:" | |
| ), | |
| self.strings["cfg_impersonation_prompt_doc"], validator=loader.validators.String() | |
| ), | |
| loader.ConfigValue("impersonation_history_limit", 20, self.strings["cfg_impersonation_history_limit_doc"], validator=loader.validators.Integer(minimum=5, maximum=100)), | |
| loader.ConfigValue("impersonation_reply_chance", 0.25, self.strings["cfg_impersonation_reply_chance_doc"], validator=loader.validators.Float(minimum=0.0, maximum=1.0)), | |
| loader.ConfigValue("gauto_in_pm", False, "Разрешить авто-ответы в личных сообщениях (ЛС).", validator=loader.validators.Boolean()), | |
| loader.ConfigValue("google_search", True, self.strings["cfg_google_search_doc"], validator=loader.validators.Boolean()), | |
| loader.ConfigValue("temperature", 1.0, self.strings["cfg_temperature_doc"], validator=loader.validators.Float(minimum=0.0, maximum=2.0)), | |
| loader.ConfigValue("inline_pagination", False, self.strings["cfg_inline_pagination_doc"], validator=loader.validators.Boolean()), | |
| loader.ConfigValue("auto_media_tools", True, "Автогенерация изображений и видео по XML-блокам в обычном диалоге.", validator=loader.validators.Boolean()), | |
| loader.ConfigValue("allow_tg_tools", True, "Разрешить execute_telegram_action с реальным выполнением Telegram-действий.", validator=loader.validators.Boolean()), | |
| loader.ConfigValue("allow_os_tools", True, "Разрешить execute_os_action с реальным выполнением shell/FS действий.", validator=loader.validators.Boolean()), | |
| loader.ConfigValue("tool_action_budget", 24, "Максимум Telegram tool-действий за один запрос Gemini.", validator=loader.validators.Integer(minimum=1, maximum=120)), | |
| loader.ConfigValue("os_tool_action_budget", 16, "Максимум OS tool-действий за один запрос Gemini.", validator=loader.validators.Integer(minimum=1, maximum=80)), | |
| loader.ConfigValue("tool_destructive_guard", True, "Требовать confirm=true для опасных действий Telegram tools.", validator=loader.validators.Boolean()), | |
| loader.ConfigValue("os_tools_root", os.getcwd(), "Корневая директория для OS tools. Пусто = cwd модуля.", validator=loader.validators.String()), | |
| loader.ConfigValue("auto_return_artifacts", True, "Автоматически возвращать в чат изменённые/выбранные артефакты после агентной работы.", validator=loader.validators.Boolean()), | |
| loader.ConfigValue("max_return_artifacts", 4, "Максимум артефактов, которые модуль автоматически вернёт в чат за один запрос.", validator=loader.validators.Integer(minimum=1, maximum=12)), | |
| loader.ConfigValue("gauto_use_stickers", True, "Разрешить gauto отправлять стикеры по эвристикам/подсказкам модели.", validator=loader.validators.Boolean()), | |
| loader.ConfigValue("gauto_use_reactions", True, "Разрешить gauto ставить реакции на входящие сообщения.", validator=loader.validators.Boolean()), | |
| loader.ConfigValue("gauto_sticker_pack", "", "Стикерпак для gauto (short name или ссылка t.me/addstickers/...).", validator=loader.validators.String()), | |
| loader.ConfigValue("gauto_reaction_chance", 0.45, "Шанс, что gauto поставит реакцию при подходящем ответе.", validator=loader.validators.Float(minimum=0.0, maximum=1.0)), | |
| loader.ConfigValue("image_model_name", "gemini-2.5-flash-image", self.strings["cfg_image_model_doc"]), | |
| loader.ConfigValue("veo_model", "veo-3.1-fast-generate-preview", "Модель Veo по умолчанию", | |
| validator=loader.validators.Choice([ | |
| "veo-2.0-generate-001", | |
| "veo-3.0-generate-001", | |
| "veo-3.0-fast-generate-001", | |
| "veo-3.0-generate-preview", | |
| "veo-3.0-fast-generate-preview", | |
| "veo-3.1-generate-preview", | |
| "veo-3.1-fast-generate-preview", | |
| "veo-3.1-lite-generate-preview", | |
| ])), | |
| loader.ConfigValue("veo_seconds", 8, "Длительность Veo видео", | |
| validator=loader.validators.Choice([4, 6, 8])), | |
| loader.ConfigValue("veo_aspect_ratio", "16:9", "Соотношение сторон Veo", | |
| validator=loader.validators.Choice(["16:9", "9:16"])), | |
| loader.ConfigValue("veo_resolution", "720p", "Разрешение Veo", | |
| validator=loader.validators.Choice(["720p", "1080p"])), | |
| loader.ConfigValue("veo_timeout", 300, "Таймаут Veo генерации (сек)", | |
| validator=loader.validators.Integer(minimum=60, maximum=900)), | |
| ) | |
| self.prompt_presets =[] | |
| self.conversations = {} | |
| self.gauto_conversations = {} | |
| self.last_requests = {} | |
| self.impersonation_chats = set() | |
| self._lock = asyncio.Lock() | |
| self.memory_disabled_chats = set() | |
| self.pager_cache = {} | |
| self.key_model_map = {} | |
| self.api_keys =[] | |
| self._veo_sessions = {} | |
| self._veo_model_cache = {"expires": 0.0, "models": []} | |
| self._request_sessions = {} | |
| self._gauto_sticker_mapping = {} | |
| self._gauto_sticker_pack = "" | |
| self.provider_tokens = {} | |
| self.provider_model_map = {} | |
| self._amodel_sessions = {} | |
| self._perplexity_model_cache = {"expires": 0.0, "models": []} | |
| self._cli_shell_jobs = {} | |
| self._request_sessions_cleanup_ts = 0.0 | |
| self._response_cache = {} | |
| self._cached_sysprompt = None | |
| self._cached_sysprompt_hash = None | |
| self._scheduled_tasks = {} | |
| self._web_cache = {} | |
| self._tool_result_cache = {} | |
| @staticmethod | |
| def _session_is_expired(session, max_age=600): | |
| if not isinstance(session, dict): | |
| return True | |
| created = session.get("_created") | |
| if created is None: | |
| session["_created"] = time.time() | |
| return False | |
| return (time.time() - created) > max_age | |
| def _cleanup_request_sessions(self, max_age=600): | |
| now = time.time() | |
| if now - self._request_sessions_cleanup_ts < 120: | |
| return | |
| self._request_sessions_cleanup_ts = now | |
| expired = [cid for cid, s in list(self._request_sessions.items()) if self._session_is_expired(s, max_age)] | |
| for cid in expired: | |
| del self._request_sessions[cid] | |
| async def client_ready(self, client, db): | |
| self.client = client | |
| self.db = db | |
| self.me = await client.get_me() | |
| self.key_model_map = self.db.get(self.strings["name"], DB_KEY_MAP_KEY, {}) | |
| self._sync_api_keys_from_config(reset_rotation=True) | |
| if not GOOGLE_AVAILABLE: | |
| logger.error("Gemini: 'google-genai' library missing! pip install google-genai") | |
| return | |
| self.current_api_key_index = 0 | |
| self.conversations = self._load_history_from_db(DB_HISTORY_KEY) | |
| self.prompt_presets = self.db.get(self.strings["name"], DB_PRESETS_KEY, []) | |
| if isinstance(self.prompt_presets, dict): | |
| self.prompt_presets =[{"name": k, "content": v} for k, v in self.prompt_presets.items()] | |
| 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.pager_cache = self.db.get(self.strings["name"], DB_PAGER_CACHE_KEY, {}) | |
| self.provider_tokens = self.db.get(self.strings["name"], DB_PROVIDER_TOKEN_MAP_KEY, {}) or {} | |
| self.provider_model_map = self.db.get(self.strings["name"], DB_PROVIDER_MODEL_MAP_KEY, {}) or {} | |
| self._hydrate_provider_model_map_from_config() | |
| if not self.api_keys: | |
| logger.warning("Gemini: API ключи не настроены.") | |
| global _gemini_log_client, _gemini_log_channel, _gemini_log_topic_id | |
| try: | |
| asset_channel = self._db.get("heroku.forums", "channel_id", 0) | |
| if asset_channel: | |
| notif_topic = await utils.asset_forum_topic( | |
| self._client, | |
| self._db, | |
| asset_channel, | |
| "Gemini Logs", | |
| description="Gemini module warnings & errors.", | |
| icon_emoji_id=5325547803936572038, | |
| ) | |
| _gemini_log_client = self._client | |
| _gemini_log_channel = asset_channel | |
| _gemini_log_topic_id = notif_topic.id | |
| except Exception: | |
| pass | |
| def _provider_specs(self): | |
| return { | |
| "google": {"label": "Google", "type": "google", "default_model": "gemini-2.5-flash", "default_image_model": "gemini-2.5-flash-image", "default_video_model": "veo-3.1-fast-generate-preview", "runtime": True, "supports": {"chat", "image", "video"}}, | |
| "openrouter": {"label": "OpenRouter", "type": "openai", "models_url": "https://openrouter.ai/api/v1/models", "chat_url": "https://openrouter.ai/api/v1/chat/completions", "default_model": "google/gemini-2.5-flash", "default_image_model": "google/gemini-2.5-flash-image", "runtime": True, "supports": {"chat", "image"}}, | |
| "openai": {"label": "OpenAI", "type": "openai", "models_url": "https://api.openai.com/v1/models", "chat_url": "https://api.openai.com/v1/chat/completions", "default_model": "gpt-5-mini", "default_image_model": "gpt-image-1", "runtime": True, "supports": {"chat", "image"}}, | |
| "anthropic": {"label": "Anthropic", "type": "anthropic", "models_url": "https://api.anthropic.com/v1/models", "chat_url": "https://api.anthropic.com/v1/messages", "default_model": "claude-sonnet-4-0", "runtime": True, "supports": {"chat"}}, | |
| "perplexity": {"label": "Perplexity", "type": "openai", "models_url": "https://api.perplexity.ai/v1/models", "chat_url": "https://api.perplexity.ai/chat/completions", "agent_url": "https://api.perplexity.ai/v1/agent", "default_model": "sonar-pro", "runtime": True, "supports": {"chat"}}, | |
| "groq": {"label": "Groq", "type": "openai", "models_url": "https://api.groq.com/openai/v1/models", "chat_url": "https://api.groq.com/openai/v1/chat/completions", "default_model": "openai/gpt-oss-20b", "runtime": True, "supports": {"chat"}}, | |
| "together": {"label": "Together", "type": "openai", "models_url": "https://api.together.xyz/v1/models", "chat_url": "https://api.together.xyz/v1/chat/completions", "default_model": "deepseek-ai/DeepSeek-V3", "runtime": True, "supports": {"chat", "image"}}, | |
| "deepseek": {"label": "DeepSeek", "type": "openai", "models_url": "https://api.deepseek.com/models", "chat_url": "https://api.deepseek.com/chat/completions", "default_model": "deepseek-chat", "runtime": True, "supports": {"chat"}}, | |
| "xai": {"label": "xAI", "type": "openai", "models_url": "https://api.x.ai/v1/models", "chat_url": "https://api.x.ai/v1/chat/completions", "default_model": "grok-4-fast", "runtime": True, "supports": {"chat", "image"}}, | |
| "mistral": {"label": "Mistral", "type": "openai", "models_url": "https://api.mistral.ai/v1/models", "chat_url": "https://api.mistral.ai/v1/chat/completions", "default_model": "mistral-medium-latest", "runtime": True, "supports": {"chat"}}, | |
| "sambanova": {"label": "SambaNova", "type": "openai", "models_url": "https://api.sambanova.ai/v1/models", "chat_url": "https://api.sambanova.ai/v1/chat/completions", "default_model": "Meta-Llama-3.3-70B-Instruct", "runtime": True, "supports": {"chat"}}, | |
| "cerebras": {"label": "Cerebras", "type": "openai", "models_url": "https://api.cerebras.ai/v1/models", "chat_url": "https://api.cerebras.ai/v1/chat/completions", "default_model": "gpt-oss-120b", "runtime": True, "supports": {"chat"}}, | |
| "fireworks": {"label": "Fireworks", "type": "openai", "models_url": "https://api.fireworks.ai/inference/v1/models", "chat_url": "https://api.fireworks.ai/inference/v1/chat/completions", "default_model": "accounts/fireworks/models/deepseek-v3p1", "runtime": True, "supports": {"chat", "image"}}, | |
| "deepinfra": {"label": "DeepInfra", "type": "openai", "models_url": "https://api.deepinfra.com/v1/openai/models", "chat_url": "https://api.deepinfra.com/v1/openai/chat/completions", "default_model": "deepseek-ai/DeepSeek-V3", "runtime": True, "supports": {"chat", "image"}}, | |
| "hyperbolic": {"label": "Hyperbolic", "type": "openai", "models_url": "https://api.hyperbolic.xyz/v1/models", "chat_url": "https://api.hyperbolic.xyz/v1/chat/completions", "default_model": "meta-llama/Llama-3.3-70B-Instruct", "runtime": True, "supports": {"chat", "image"}}, | |
| "nvidia": {"label": "NVIDIA NIM", "type": "openai", "models_url": "https://integrate.api.nvidia.com/v1/models", "chat_url": "https://integrate.api.nvidia.com/v1/chat/completions", "default_model": "meta/llama-3.3-70b-instruct", "runtime": True, "supports": {"chat", "image"}}, | |
| "nebius": {"label": "Nebius", "type": "openai", "models_url": "https://api.studio.nebius.ai/v1/models", "chat_url": "https://api.studio.nebius.ai/v1/chat/completions", "default_model": "meta-llama/Meta-Llama-3.1-70B-Instruct", "runtime": True, "supports": {"chat", "image"}}, | |
| "novita": {"label": "Novita", "type": "openai", "models_url": "https://api.novita.ai/v3/openai/models", "chat_url": "https://api.novita.ai/v3/openai/chat/completions", "default_model": "deepseek/deepseek-v3", "runtime": True, "supports": {"chat", "image"}}, | |
| "kluster": {"label": "Kluster", "type": "openai", "models_url": "https://api.kluster.ai/v1/models", "chat_url": "https://api.kluster.ai/v1/chat/completions", "default_model": "klusterai/Meta-Llama-3.1-8B-Instruct-Turbo", "runtime": True, "supports": {"chat", "image"}}, | |
| "parasail": {"label": "Parasail", "type": "openai", "models_url": "https://api.parasail.io/v1/models", "chat_url": "https://api.parasail.io/v1/chat/completions", "default_model": "meta-llama/Llama-3.3-70B-Instruct", "runtime": True, "supports": {"chat", "image"}}, | |
| "chutes": {"label": "Chutes", "type": "openai", "models_url": "https://llm.chutes.ai/v1/models", "chat_url": "https://llm.chutes.ai/v1/chat/completions", "default_model": "deepseek-ai/DeepSeek-V3-0324", "runtime": True, "supports": {"chat", "image"}}, | |
| "friendli": {"label": "Friendli", "type": "openai", "models_url": "https://api.friendli.ai/serverless/v1/models", "chat_url": "https://api.friendli.ai/serverless/v1/chat/completions", "default_model": "meta-llama-3.1-70b-instruct", "runtime": True, "supports": {"chat"}}, | |
| "avian": {"label": "Avian", "type": "openai", "models_url": "https://api.avian.io/v1/models", "chat_url": "https://api.avian.io/v1/chat/completions", "default_model": "meta-llama/Meta-Llama-3.1-70B-Instruct", "runtime": True, "supports": {"chat"}}, | |
| "aimlapi": {"label": "AIMLAPI", "type": "openai", "models_url": "https://api.aimlapi.com/v1/models", "chat_url": "https://api.aimlapi.com/v1/chat/completions", "default_model": "gpt-4.1-mini", "runtime": True, "supports": {"chat", "image"}}, | |
| "nineteen": {"label": "Nineteen", "type": "openai", "models_url": "https://api.nineteen.ai/v1/models", "chat_url": "https://api.nineteen.ai/v1/chat/completions", "default_model": "deepseek-chat", "runtime": True, "supports": {"chat"}}, | |
| "moonshot": {"label": "Moonshot", "type": "openai", "models_url": "https://api.moonshot.ai/v1/models", "chat_url": "https://api.moonshot.ai/v1/chat/completions", "default_model": "kimi-k2-0711-preview", "runtime": True, "supports": {"chat"}}, | |
| "inference": {"label": "Inference.net", "type": "openai", "models_url": "https://api.inference.net/v1/models", "chat_url": "https://api.inference.net/v1/chat/completions", "default_model": "meta-llama/Meta-Llama-3.1-70B-Instruct", "runtime": True, "supports": {"chat"}}, | |
| "scaleway": {"label": "Scaleway", "type": "openai", "models_url": "https://api.scaleway.ai/v1/models", "chat_url": "https://api.scaleway.ai/v1/chat/completions", "default_model": "deepseek-r1-distill-llama-70b", "runtime": True, "supports": {"chat"}}, | |
| "siliconflow": {"label": "SiliconFlow", "type": "openai", "models_url": "https://api.siliconflow.cn/v1/models", "chat_url": "https://api.siliconflow.cn/v1/chat/completions", "default_model": "Qwen/Qwen3-32B", "runtime": True, "supports": {"chat", "image"}}, | |
| "openpipe": {"label": "OpenPipe", "type": "openai", "models_url": "https://api.openpipe.ai/api/v1/models", "chat_url": "https://api.openpipe.ai/api/v1/chat/completions", "default_model": "openai/gpt-4.1-mini", "runtime": True, "supports": {"chat"}}, | |
| "cohere": {"label": "Cohere Compat", "type": "openai", "models_url": "https://api.cohere.com/compatibility/v1/models", "chat_url": "https://api.cohere.com/compatibility/v1/chat/completions", "default_model": "command-a-03-2025", "runtime": True, "supports": {"chat"}}, | |
| "upstage": {"label": "Upstage", "type": "openai", "models_url": "https://api.upstage.ai/v1/models", "chat_url": "https://api.upstage.ai/v1/chat/completions", "default_model": "solar-pro2", "runtime": True, "supports": {"chat"}}, | |
| "dashscope": {"label": "DashScope", "type": "openai", "models_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "chat_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", "default_model": "qwen-plus", "runtime": True, "supports": {"chat", "image"}}, | |
| "qwen": {"label": "Qwen API", "type": "openai", "models_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/models", "chat_url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", "default_model": "qwen-plus", "runtime": True, "supports": {"chat", "image"}}, | |
| "volcengine": {"label": "Volcengine Ark", "type": "openai", "models_url": "https://ark.cn-beijing.volces.com/api/v3/models", "chat_url": "https://ark.cn-beijing.volces.com/api/v3/chat/completions", "default_model": "doubao-seed-1-6-flash-250715", "runtime": True, "supports": {"chat", "image"}}, | |
| "byteplus": {"label": "BytePlus Ark", "type": "openai", "models_url": "https://ark.ap-southeast.bytepluses.com/api/v3/models", "chat_url": "https://ark.ap-southeast.bytepluses.com/api/v3/chat/completions", "default_model": "doubao-seed-1-6-flash-250715", "runtime": True, "supports": {"chat", "image"}}, | |
| "ppio": {"label": "PPIO", "type": "openai", "models_url": "https://api.ppinfra.com/v3/openai/models", "chat_url": "https://api.ppinfra.com/v3/openai/chat/completions", "default_model": "deepseek/deepseek-v3", "runtime": True, "supports": {"chat", "image"}}, | |
| "modelslab": {"label": "ModelsLab", "type": "openai", "models_url": "https://modelslab.com/api/v6/openai/v1/models", "chat_url": "https://modelslab.com/api/v6/openai/v1/chat/completions", "default_model": "gpt-4o-mini", "runtime": True, "supports": {"chat", "image"}}, | |
| "infini": {"label": "Infini AI", "type": "openai", "models_url": "https://cloud.infini-ai.com/maas/v1/models", "chat_url": "https://cloud.infini-ai.com/maas/v1/chat/completions", "default_model": "qwen3-32b", "runtime": True, "supports": {"chat"}}, | |
| "baichuan": {"label": "Baichuan", "type": "openai", "models_url": "https://api.baichuan-ai.com/v1/models", "chat_url": "https://api.baichuan-ai.com/v1/chat/completions", "default_model": "Baichuan4-Turbo", "runtime": True, "supports": {"chat"}}, | |
| "phala": {"label": "Phala", "type": "openai", "models_url": "https://api.phala.network/v1/models", "chat_url": "https://api.phala.network/v1/chat/completions", "default_model": "deepseek-ai/DeepSeek-V3", "runtime": True, "supports": {"chat"}}, | |
| "portkey": {"label": "Portkey Gateway", "type": "openai", "models_url": "https://api.portkey.ai/v1/models", "chat_url": "https://api.portkey.ai/v1/chat/completions", "default_model": "gpt-4.1-mini", "runtime": True, "supports": {"chat"}}, | |
| "targon": {"label": "Targon", "type": "openai", "models_url": "https://api.targon.com/v1/models", "chat_url": "https://api.targon.com/v1/chat/completions", "default_model": "deepseek-chat", "runtime": True, "supports": {"chat"}}, | |
| } | |
| def _provider_label(self, provider: str) -> str: | |
| return self._provider_specs().get(provider, {}).get("label", provider) | |
| def _provider_runtime_supported(self, provider: str) -> bool: | |
| return bool(self._provider_specs().get(provider, {}).get("runtime")) | |
| def _provider_supports_kind(self, provider: str, kind: str) -> bool: | |
| supports = set(self._provider_specs().get(provider, {}).get("supports") or {"chat"}) | |
| return kind in supports | |
| def _save_provider_models(self): | |
| self.db.set(self.strings["name"], DB_PROVIDER_MODEL_MAP_KEY, self.provider_model_map) | |
| def _provider_default_model(self, provider: str, kind: str = "chat") -> str: | |
| spec = self._provider_specs().get(provider, {}) | |
| if not self._provider_supports_kind(provider, kind): | |
| return "" | |
| if kind == "image": | |
| return str(spec.get("default_image_model") or self.config.get("image_model_name") or spec.get("default_model") or "") | |
| if kind == "video": | |
| return str(spec.get("default_video_model") or self.config.get("veo_model") or spec.get("default_model") or "") | |
| return str(spec.get("default_model") or self.config.get("model_name") or "") | |
| def _normalize_provider_model(self, provider: str, kind: str, model_name: str) -> str: | |
| provider = str(provider or "").strip().lower() | |
| kind = str(kind or "chat").strip().lower() | |
| model_name = str(model_name or "").strip() | |
| if not self._provider_supports_kind(provider, kind): | |
| return "" | |
| return model_name or self._provider_default_model(provider, kind) | |
| def _hydrate_provider_model_map_from_config(self): | |
| if not isinstance(self.provider_model_map, dict): | |
| self.provider_model_map = {} | |
| provider = str(self.config.get("provider") or "google").strip().lower() | |
| if provider not in self._provider_specs(): | |
| provider = "google" | |
| if provider not in self.provider_model_map or not isinstance(self.provider_model_map.get(provider), dict): | |
| self.provider_model_map[provider] = {} | |
| current_chat_model = self._normalize_provider_model(provider, "chat", self.config.get("model_name")) | |
| if current_chat_model: | |
| self.provider_model_map[provider]["chat"] = current_chat_model | |
| self.config["model_name"] = current_chat_model | |
| google_image = self._normalize_provider_model("google", "image", self.config.get("image_model_name")) | |
| if google_image: | |
| self.provider_model_map.setdefault("google", {})["image"] = google_image | |
| self.config["image_model_name"] = google_image | |
| google_video = self._normalize_provider_model("google", "video", self.config.get("veo_model")) | |
| if google_video: | |
| self.provider_model_map.setdefault("google", {})["video"] = google_video | |
| self.config["veo_model"] = google_video | |
| if hasattr(self, "db"): | |
| self._save_provider_models() | |
| def _get_provider_model(self, provider: str, kind: str = "chat") -> str: | |
| provider = str(provider or "").strip().lower() | |
| kind = str(kind or "chat").strip().lower() | |
| if not self._provider_supports_kind(provider, kind): | |
| return "" | |
| stored = self.provider_model_map.get(provider, {}) if isinstance(self.provider_model_map, dict) else {} | |
| if isinstance(stored, dict): | |
| value = str(stored.get(kind) or "").strip() | |
| if value: | |
| return value | |
| if kind == "chat" and provider == str(self.config.get("provider") or "").strip().lower(): | |
| value = str(self.config.get("model_name") or "").strip() | |
| if value: | |
| return value | |
| if kind == "image" and provider == "google": | |
| value = str(self.config.get("image_model_name") or "").strip() | |
| if value: | |
| return value | |
| if kind == "video" and provider == "google": | |
| value = str(self.config.get("veo_model") or "").strip() | |
| if value: | |
| return self._normalize_provider_model(provider, kind, value) | |
| return self._normalize_provider_model(provider, kind, self._provider_default_model(provider, kind)) | |
| def _set_provider_model(self, provider: str, kind: str, model_name: str): | |
| provider = str(provider or "").strip().lower() | |
| kind = str(kind or "chat").strip().lower() | |
| model_name = self._normalize_provider_model(provider, kind, model_name) | |
| if not self._provider_supports_kind(provider, kind): | |
| return | |
| if provider not in self.provider_model_map or not isinstance(self.provider_model_map.get(provider), dict): | |
| self.provider_model_map[provider] = {} | |
| self.provider_model_map[provider][kind] = model_name | |
| self._save_provider_models() | |
| if kind == "chat": | |
| self.config["provider"] = provider | |
| self.config["model_name"] = model_name | |
| elif provider == "google" and kind == "image": | |
| self.config["image_model_name"] = model_name | |
| elif provider == "google" and kind == "video": | |
| self.config["veo_model"] = model_name | |
| def _activate_provider(self, provider: str) -> str: | |
| provider = str(provider or "").strip().lower() | |
| if provider not in self._provider_specs(): | |
| provider = "google" | |
| model_name = self._normalize_provider_model(provider, "chat", self._get_provider_model(provider, "chat")) | |
| self.config["provider"] = provider | |
| self.config["model_name"] = model_name | |
| if provider == "google": | |
| self.config["image_model_name"] = self._normalize_provider_model("google", "image", self._get_provider_model("google", "image")) | |
| self.config["veo_model"] = self._normalize_provider_model("google", "video", self._get_provider_model("google", "video")) | |
| self._set_provider_model(provider, "chat", model_name) | |
| return model_name | |
| def _compatible_provider_names(self): | |
| return { | |
| name | |
| for name, spec in self._provider_specs().items() | |
| if name not in {"google", "openrouter"} and spec.get("runtime") | |
| } | |
| def _mask_secret(self, value: str) -> str: | |
| value = str(value or "").strip() | |
| if not value: | |
| return self.strings["amodels_token_missing"] | |
| if len(value) <= 10: | |
| return "•" * len(value) | |
| return "{}••••{}".format(value[:4], value[-4:]) | |
| def _save_provider_tokens(self): | |
| self.db.set(self.strings["name"], DB_PROVIDER_TOKEN_MAP_KEY, self.provider_tokens) | |
| def _resolve_provider_token(self, provider: str) -> str: | |
| provider = str(provider or "").strip().lower() | |
| if provider == "google": | |
| custom = str(self.provider_tokens.get("google") or "").strip() | |
| if custom: | |
| return custom | |
| self._sync_api_keys_from_config() | |
| return self.api_keys[0] if self.api_keys else "" | |
| if provider == "openrouter": | |
| custom = str(self.provider_tokens.get("openrouter") or "").strip() | |
| return custom or str(self.config.get("Openrouter_api_key", "") or "").strip() | |
| return str(self.provider_tokens.get(provider) or "").strip() | |
| def _provider_token_source_label(self, provider: str) -> str: | |
| provider = str(provider or "").strip().lower() | |
| if provider == "google": | |
| if str(self.provider_tokens.get("google") or "").strip(): | |
| return self.strings["amodels_token_custom"] | |
| return self.strings["amodels_token_from_cfg"] if self.api_keys else self.strings["amodels_token_missing"] | |
| if provider == "openrouter": | |
| if str(self.provider_tokens.get("openrouter") or "").strip(): | |
| return self.strings["amodels_token_custom"] | |
| return self.strings["amodels_token_from_cfg"] if str(self.config.get("Openrouter_api_key", "") or "").strip() else self.strings["amodels_token_missing"] | |
| return self.strings["amodels_token_custom"] if self._resolve_provider_token(provider) else self.strings["amodels_token_missing"] | |
| def _store_provider_token(self, provider: str, token: str): | |
| provider = str(provider or "").strip().lower() | |
| token = str(token or "").strip() | |
| if provider == "google": | |
| if token: | |
| self.provider_tokens["google"] = token | |
| current = [k.strip() for k in str(self.config.get("api_key", "") or "").split(",") if k.strip()] | |
| if token not in current: | |
| current.insert(0, token) | |
| self.config["api_key"] = ",".join(current) | |
| self._sync_api_keys_from_config(reset_rotation=True) | |
| else: | |
| self.provider_tokens.pop("google", None) | |
| elif provider == "openrouter": | |
| if token: | |
| self.provider_tokens["openrouter"] = token | |
| self.config["Openrouter_api_key"] = token | |
| else: | |
| self.provider_tokens.pop("openrouter", None) | |
| elif token: | |
| self.provider_tokens[provider] = token | |
| else: | |
| self.provider_tokens.pop(provider, None) | |
| self._save_provider_tokens() | |
| def _normalize_provider_message_content(self, content): | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| chunks = [] | |
| for item in content: | |
| if isinstance(item, dict): | |
| if item.get("type") == "text": | |
| chunks.append(str(item.get("text") or "")) | |
| elif item.get("type") == "image_url": | |
| chunks.append("[image omitted]") | |
| else: | |
| chunks.append(str(item)) | |
| return "\n".join(filter(None, chunks)).strip() | |
| return str(content or "") | |
| def _perplexity_default_models(self): | |
| # Sonar API models: native → /chat/completions | |
| # Agent API models: 3rd-party with provider/ prefix → /v1/agent | |
| return [ | |
| "sonar", | |
| "sonar-pro", | |
| "sonar-reasoning-pro", | |
| "sonar-deep-research", | |
| "perplexity/sonar", | |
| "anthropic/claude-sonnet-4-6", | |
| "anthropic/claude-opus-4-5", | |
| "openai/gpt-5", | |
| "openai/gpt-5.2", | |
| "google/gemini-3-flash-preview", | |
| "google/gemini-2.5-pro", | |
| ] | |
| def _is_perplexity_sonar_model(self, model: str) -> bool: | |
| """True = native Sonar → /chat/completions, False = 3rd-party → /v1/agent""" | |
| m = str(model or "").strip().lower() | |
| return "/" not in m and (m.startswith("sonar") or m == "r1-1776") | |
| def _perplexity_supported_models(self): | |
| cache = getattr(self, "_perplexity_model_cache", {}) or {} | |
| cached = [ | |
| str(item or "").strip() | |
| for item in (cache.get("models") or []) | |
| if str(item or "").strip() | |
| ] | |
| return cached or self._perplexity_default_models() | |
| def _normalize_perplexity_model_name(self, model_name: str) -> str: | |
| model_name = str(model_name or "").strip() | |
| if not model_name: | |
| return "" | |
| return model_name | |
| def _is_perplexity_chat_model(self, model_name: str) -> bool: | |
| return bool(str(model_name or "").strip()) | |
| def _sort_perplexity_models(self, models): | |
| preferred = {name.lower(): idx for idx, name in enumerate(self._perplexity_default_models())} | |
| return sorted( | |
| set(models), | |
| key=lambda item: (preferred.get(str(item or "").strip().lower(), 999), str(item or "").strip().lower()), | |
| ) | |
| async def _perplexity_fetch_models(self, token: str = None, force: bool = False): | |
| cache = getattr(self, "_perplexity_model_cache", None) | |
| now = time.time() | |
| if not isinstance(cache, dict): | |
| self._perplexity_model_cache = {"expires": 0.0, "models": []} | |
| cache = self._perplexity_model_cache | |
| if not force and cache.get("models") and float(cache.get("expires") or 0.0) > now: | |
| return list(cache["models"]) | |
| token = str(token or self._resolve_provider_token("perplexity") or "").strip() | |
| fallback = self._sort_perplexity_models(self._perplexity_default_models()) | |
| if not token: | |
| self._perplexity_model_cache = {"expires": now + 300, "models": list(fallback)} | |
| return list(fallback) | |
| headers = { | |
| "Authorization": f"Bearer {token}", | |
| "Accept": "application/json", | |
| } | |
| # Perplexity has two model lists: Sonar API and Agent API | |
| sonar_models = [] | |
| agent_models = [] | |
| try: | |
| async with aiohttp.ClientSession() as session: | |
| # Sonar models | |
| async with session.get( | |
| "https://api.perplexity.ai/models", | |
| headers=headers, | |
| timeout=30, | |
| ) as resp: | |
| raw = await resp.text() | |
| if resp.status == 200: | |
| data = json.loads(raw) | |
| sonar_models = self._extract_model_ids_from_payload("perplexity", data) | |
| # Agent/3rd-party models | |
| async with session.get( | |
| "https://api.perplexity.ai/v1/models", | |
| headers=headers, | |
| timeout=30, | |
| ) as resp: | |
| raw = await resp.text() | |
| if resp.status == 200: | |
| data = json.loads(raw) | |
| agent_models = self._extract_model_ids_from_payload("perplexity", data) | |
| except Exception: | |
| pass | |
| combined = list(dict.fromkeys(sonar_models + agent_models)) | |
| filtered = self._sort_perplexity_models(combined) | |
| if not filtered: | |
| filtered = list(fallback) | |
| self._perplexity_model_cache = {"expires": now + 600, "models": list(filtered)} | |
| return list(filtered) | |
| def _extract_model_ids_from_payload(self, provider: str, payload): | |
| if isinstance(payload, list): | |
| rows = payload | |
| elif isinstance(payload, dict): | |
| rows = payload.get("data") or payload.get("models") or payload.get("result") or payload.get("items") or [] | |
| else: | |
| rows = [] | |
| models = [] | |
| for item in rows: | |
| if isinstance(item, str): | |
| mid = item | |
| elif isinstance(item, dict): | |
| mid = item.get("id") or item.get("name") or item.get("model") or item.get("display_name") or item.get("displayName") | |
| else: | |
| mid = None | |
| mid = str(mid or "").strip() | |
| if not mid: | |
| continue | |
| if provider == "google" and "/" in mid: | |
| mid = mid.split("/")[-1].strip() | |
| models.append(mid) | |
| seen = set() | |
| uniq = [] | |
| for item in sorted(models, key=lambda x: x.lower()): | |
| low = item.lower() | |
| if low in seen: | |
| continue | |
| seen.add(low) | |
| uniq.append(item) | |
| return uniq | |
| def _filter_models_for_kind(self, provider: str, models, kind: str): | |
| kind = str(kind or "chat").strip().lower() | |
| items = [str(item or "").strip() for item in (models or []) if str(item or "").strip()] | |
| if not items: | |
| return [] | |
| if provider == "google" and kind == "video": | |
| return self._veo_sort_models(items) | |
| if provider == "perplexity": | |
| if kind != "chat": | |
| return [] | |
| ordered = self._sort_perplexity_models(items) | |
| return ordered or self._sort_perplexity_models(self._perplexity_supported_models()) | |
| image_terms = ("image", "imagen", "dall", "flux", "sdxl", "stable-diffusion", "recraft", "seedream", "janus", "kolors", "playground", "gpt-image") | |
| video_terms = ("video", "veo", "kling", "minimax", "ltx") | |
| if kind == "image": | |
| preferred = [item for item in items if any(term in item.lower() for term in image_terms)] | |
| return preferred or items | |
| if kind == "video": | |
| preferred = [item for item in items if any(term in item.lower() for term in video_terms)] | |
| return preferred or [] | |
| filtered = [item for item in items if not any(term in item.lower() for term in (*image_terms, *video_terms))] | |
| return filtered or items | |
| async def _fetch_provider_models(self, provider: str, token: str = None, kind: str = "chat"): | |
| provider = str(provider or "").strip().lower() | |
| kind = str(kind or "chat").strip().lower() | |
| if provider == "perplexity": | |
| return await self._perplexity_fetch_models(token=token, force=True) if kind == "chat" else [] | |
| token = str(token or self._resolve_provider_token(provider) or "").strip() | |
| if not token: | |
| raise ValueError(self.strings["provider_token_missing"].format(self._provider_label(provider))) | |
| if provider == "google": | |
| if kind == "video": | |
| return await self._veo_fetch_models() | |
| client = genai.Client(api_key=token) | |
| raw_models = await asyncio.to_thread(lambda: list(client.models.list())) | |
| models = self._extract_model_ids_from_payload(provider, [{"name": getattr(m, "name", "")} for m in raw_models]) | |
| return self._filter_models_for_kind(provider, models, kind) | |
| spec = self._provider_specs().get(provider) | |
| if not spec or "models_url" not in spec: | |
| raise ValueError("unsupported provider") | |
| headers = {"Authorization": f"Bearer {token}"} | |
| params = {} | |
| if provider == "anthropic": | |
| headers = {"x-api-key": token, "anthropic-version": "2023-06-01"} | |
| elif provider == "openrouter": | |
| headers["HTTP-Referer"] = "https://github.com/SenkoGuardian" | |
| headers["X-Title"] = "AetherAI for Heroku" | |
| elif provider == "perplexity": | |
| headers["Accept"] = "application/json" | |
| elif provider == "together": | |
| params["info"] = "true" | |
| async with aiohttp.ClientSession() as session: | |
| async with session.get(spec["models_url"], headers=headers, params=params, timeout=GEMINI_TIMEOUT) as resp: | |
| raw = await resp.text() | |
| if resp.status != 200: | |
| raise ValueError("HTTP {}: {}".format(resp.status, raw[:400])) | |
| data = json.loads(raw) | |
| return self._filter_models_for_kind(provider, self._extract_model_ids_from_payload(provider, data), kind) | |
| async def _send_to_provider_api(self, provider: str, model: str, messages: list, temperature: float): | |
| provider = str(provider or "").strip().lower() | |
| token = self._resolve_provider_token(provider) | |
| if not token: | |
| raise ValueError(self.strings["provider_token_missing"].format(self._provider_label(provider))) | |
| spec = self._provider_specs()[provider] | |
| normalized = [{"role": item.get("role"), "content": self._normalize_provider_message_content(item.get("content"))} for item in messages] | |
| if spec["type"] == "anthropic": | |
| system_prompt = "" | |
| anthro_messages = [] | |
| for item in normalized: | |
| if item["role"] == "system": | |
| system_prompt = item["content"] | |
| continue | |
| anthro_messages.append({"role": "assistant" if item["role"] == "assistant" else "user", "content": item["content"]}) | |
| headers = {"x-api-key": token, "anthropic-version": "2023-06-01", "Content-Type": "application/json"} | |
| payload = {"model": model, "messages": anthro_messages, "max_tokens": 4096, "temperature": min(float(temperature or 1.0), 1.0)} | |
| if system_prompt: | |
| payload["system"] = system_prompt | |
| async with aiohttp.ClientSession() as session: | |
| async with session.post(spec["chat_url"], headers=headers, json=payload, timeout=GEMINI_TIMEOUT) as resp: | |
| raw = await resp.text() | |
| if resp.status != 200: | |
| raise ValueError("HTTP {}: {}".format(resp.status, raw[:400])) | |
| data = json.loads(raw) | |
| parts = data.get("content") or [] | |
| return "\n".join(str(part.get("text") or "") for part in parts if isinstance(part, dict)).strip() | |
| headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} | |
| if provider == "openrouter": | |
| headers["HTTP-Referer"] = "https://github.com/SenkoGuardian" | |
| headers["X-Title"] = "AetherAI for Heroku" | |
| # Perplexity: sonar → /chat/completions, 3rd-party → /v1/responses (Responses API) | |
| if provider == "perplexity" and not self._is_perplexity_sonar_model(model): | |
| # Agent/3rd-party models use OpenAI Responses API format: | |
| # POST /v1/responses { model, input: str | list, instructions } | |
| responses_url = "https://api.perplexity.ai/v1/responses" | |
| sys_msgs = [m for m in normalized if m.get("role") == "system"] | |
| other_msgs = [m for m in normalized if m.get("role") != "system"] | |
| # Single user turn -> plain string; multi-turn -> list of message dicts | |
| if len(other_msgs) == 1 and other_msgs[0].get("role") == "user": | |
| input_val = str(other_msgs[0].get("content") or "") | |
| else: | |
| input_val = other_msgs | |
| resp_payload = {"model": model, "input": input_val} | |
| if sys_msgs: | |
| resp_payload["instructions"] = str(sys_msgs[0].get("content") or "") | |
| async with aiohttp.ClientSession() as session: | |
| async with session.post(responses_url, headers=headers, json=resp_payload, timeout=GEMINI_TIMEOUT) as resp: | |
| raw_r = await resp.text() | |
| if resp.status != 200: | |
| raise ValueError("HTTP {}: {}".format(resp.status, raw_r[:400])) | |
| data = json.loads(raw_r) | |
| # Responses API: output[].content[].text where type == output_text | |
| text_parts = [] | |
| for out_item in (data.get("output") or []): | |
| for c in (out_item.get("content") or []): | |
| if isinstance(c, dict) and c.get("type") == "output_text": | |
| text_parts.append(str(c.get("text") or "")) | |
| if not text_parts and data.get("output_text"): | |
| text_parts = [str(data["output_text"])] | |
| if not text_parts: | |
| raise ValueError("empty output from Perplexity Responses API: " + str(data)[:300]) | |
| return self._normalize_provider_message_content("".join(text_parts)).strip() | |
| payload = {"model": model, "messages": normalized, "temperature": min(float(temperature or 1.0), 1.0)} | |
| async with aiohttp.ClientSession() as session: | |
| async with session.post(spec["chat_url"], headers=headers, json=payload, timeout=GEMINI_TIMEOUT) as resp: | |
| raw = await resp.text() | |
| if resp.status != 200: | |
| raise ValueError("HTTP {}: {}".format(resp.status, raw[:400])) | |
| data = json.loads(raw) | |
| choices = data.get("choices") or [] | |
| if not choices: | |
| raise ValueError("empty choices") | |
| content = choices[0].get("message", {}).get("content") | |
| return self._normalize_provider_message_content(content).strip() | |
| def _sync_api_keys_from_config(self, *, reset_rotation=False): | |
| api_key_str = str(self.config.get("api_key", "") or "") | |
| fresh_keys = [k.strip() for k in api_key_str.split(",") if k.strip()] | |
| prev_keys = list(getattr(self, "api_keys", []) or []) | |
| changed = fresh_keys != prev_keys | |
| self.api_keys = fresh_keys | |
| if not isinstance(getattr(self, "key_model_map", None), dict): | |
| self.key_model_map = {} | |
| stale_keys = [k for k in list(self.key_model_map) if k not in self.api_keys] | |
| if stale_keys: | |
| for key in stale_keys: | |
| self.key_model_map.pop(key, None) | |
| if hasattr(self, "db"): | |
| self.db.set(self.strings["name"], DB_KEY_MAP_KEY, self.key_model_map) | |
| current_index = int(getattr(self, "current_api_key_index", 0) or 0) | |
| if not self.api_keys: | |
| self.current_api_key_index = 0 | |
| elif reset_rotation or current_index >= len(self.api_keys): | |
| self.current_api_key_index = current_index % len(self.api_keys) | |
| return changed | |
| 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) | |
| try: | |
| chat = await message.get_chat() | |
| chat_title = getattr(chat, 'title', getattr(chat, 'first_name', 'Личные сообщения')) | |
| except Exception: | |
| chat_title = "Неизвестный чат" | |
| prompt_text_chunks.append(f"[System info: We are in '{chat_title}' chat]") | |
| prompt_text_chunks.append("[Telegram context: current_chat_id={}, current_message_id={}]".format( | |
| getattr(message, "chat_id", None), getattr(message, "id", None) | |
| )) | |
| reply = await message.get_reply_message() | |
| if reply and getattr(reply, "text", None): | |
| try: | |
| reply_sender = await reply.get_sender() | |
| reply_author_name = get_display_name(reply_sender) if reply_sender else "Unknown" | |
| prompt_text_chunks.append(f"{reply_author_name}: {reply.text}") | |
| except Exception: | |
| prompt_text_chunks.append(f"Ответ на: {reply.text}") | |
| if reply: | |
| prompt_text_chunks.append("[Reply context: reply_message_id={}, reply_sender_id={}]".format( | |
| getattr(reply, "id", None), getattr(reply, "sender_id", None) | |
| )) | |
| try: | |
| current_sender = await message.get_sender() | |
| current_user_name = get_display_name(current_sender) if current_sender else "User" | |
| prompt_text_chunks.append(f"{current_user_name}: {user_args or ''}") | |
| except Exception: | |
| prompt_text_chunks.append(f"Запрос: {user_args or ''}") | |
| media_source = message if message.media or message.sticker else reply | |
| has_media = bool(media_source and (media_source.media or media_source.sticker)) | |
| if has_media: | |
| if media_source.sticker and hasattr(media_source.sticker, 'mime_type') and media_source.sticker.mime_type=='application/x-tgsticker': | |
| alt_text = next((attr.alt for attr in media_source.sticker.attributes if isinstance(attr, DocumentAttributeSticker)), "?") | |
| prompt_text_chunks.append(f"[Анимированный стикер: {alt_text}]") | |
| else: | |
| media, mime_type, filename = media_source.media, "application/octet-stream", "file" | |
| if media_source.photo: | |
| mime_type = "image/jpeg" | |
| elif hasattr(media_source, "document") and media_source.document: | |
| mime_type = getattr(media_source.document, "mime_type", mime_type) | |
| doc_attr = next((attr for attr in media_source.document.attributes if isinstance(attr, DocumentAttributeFilename)), None) | |
| if doc_attr: filename = doc_attr.file_name | |
| async def get_bytes(m): | |
| bio = io.BytesIO() | |
| await self.client.download_media(m, bio) | |
| return bio.getvalue() | |
| if mime_type.startswith("image/"): | |
| try: | |
| 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: | |
| 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/"): | |
| input_path, output_path = None, None | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=f".{filename.split('.')[-1]}", delete=False) as temp_in: input_path = temp_in.name | |
| await self.client.download_media(media, input_path) | |
| if os.path.getsize(input_path) > MAX_FFMPEG_SIZE: | |
| warnings.append(f"⚠️ Аудиофайл '{filename}' слишком большой."); 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) | |
| await process_ffmpeg.communicate() | |
| if process_ffmpeg.returncode != 0: raise Exception("FFmpeg error") | |
| with open(output_path, "rb") as f: | |
| 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"⚠️ Ошибка обработки аудио: {e}") | |
| finally: | |
| if input_path and os.path.exists(input_path): os.remove(input_path) | |
| if output_path and os.path.exists(output_path): os.remove(output_path) | |
| elif mime_type.startswith("video/"): | |
| input_path, output_path = None, None | |
| try: | |
| with tempfile.NamedTemporaryFile(suffix=f".{filename.split('.')[-1]}", delete=False) as temp_in: input_path = temp_in.name | |
| await self.client.download_media(media, input_path) | |
| if os.path.getsize(input_path) > MAX_FFMPEG_SIZE: | |
| warnings.append(f"⚠️ Медиафайл '{filename}' слишком большой."); raise StopIteration | |
| ffprobe_cmd =["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", input_path] | |
| process_probe = await asyncio.create_subprocess_exec(*ffprobe_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) | |
| stdout, _ = await process_probe.communicate() | |
| has_audio = bool(stdout.strip()) | |
| with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_out: output_path = temp_out.name | |
| ffmpeg_cmd =["ffmpeg", "-y", "-i", input_path] | |
| maps = ["-map", "0:v:0"] | |
| if not has_audio: | |
| ffmpeg_cmd.extend(["-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100"]) | |
| maps.extend(["-map", "1:a:0"]) | |
| else: | |
| maps.extend(["-map", "0:a:0?"]) | |
| ffmpeg_cmd.extend([*maps, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-shortest", output_path]) | |
| process_ffmpeg = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) | |
| _, stderr = await process_ffmpeg.communicate() | |
| if process_ffmpeg.returncode != 0: | |
| stderr_str = stderr.decode() | |
| warnings.append(f"⚠️ <b>Ошибка FFmpeg:</b>\nНе удалось конвертировать '{filename}'. Детали:\n<code>{utils.escape_html(stderr_str)}</code>") | |
| raise StopIteration | |
| with open(output_path, "rb") as f: | |
| 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"⚠️ Ошибка обработки видео: {e}") | |
| finally: | |
| if input_path and os.path.exists(input_path): os.remove(input_path) | |
| if output_path and os.path.exists(output_path): os.remove(output_path) | |
| if not user_args and has_media and not final_parts and not any("[Содержимое файла" in chunk for chunk in prompt_text_chunks): | |
| prompt_text_chunks.append(self.strings["media_reply_placeholder"]) | |
| full_prompt_text = "\n".join(chunk for chunk in prompt_text_chunks if chunk and chunk.strip()).strip() | |
| if full_prompt_text: | |
| final_parts.insert(0, types.Part(text=full_prompt_text)) | |
| return final_parts, warnings | |
| def _response_cache_key(self, provider, model, sys_instruct, user_text): | |
| return "{}:{}:{}".format(provider or "?", model or "?", hashlib.sha256( | |
| (str(sys_instruct or "") + str(user_text or "")).encode()).hexdigest()[:24]) | |
| def _check_response_cache(self, provider, model, sys_instruct, user_text): | |
| key = self._response_cache_key(provider, model, sys_instruct, user_text) | |
| entry = self._response_cache.get(key) | |
| if entry and (time.time() - entry["ts"]) < 300: | |
| return entry["text"] | |
| return None | |
| def _store_response_cache(self, provider, model, sys_instruct, user_text, text): | |
| if tool := self._extract_function_tool_call_info(text): | |
| return | |
| if "<tool" in str(text or ""): | |
| return | |
| key = self._response_cache_key(provider, model, sys_instruct, user_text) | |
| self._response_cache[key] = {"ts": time.time(), "text": text} | |
| while len(self._response_cache) > 200: | |
| self._response_cache.pop(next(iter(self._response_cache))) | |
| 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): | |
| self._cleanup_request_sessions(max_age=600) | |
| msg_obj = None | |
| if regeneration: | |
| chat_id = chat_id_override; base_message_id = message | |
| try: msg_obj = await self.client.get_messages(chat_id, ids=base_message_id) | |
| except Exception: msg_obj = None | |
| else: | |
| chat_id = utils.get_chat_id(message); base_message_id = message.id; msg_obj = message | |
| reply_obj = None | |
| with contextlib.suppress(Exception): | |
| if msg_obj: | |
| reply_obj = await msg_obj.get_reply_message() | |
| self._request_sessions[chat_id] = { | |
| "chat_id": chat_id, | |
| "message_id": base_message_id, | |
| "message_obj": msg_obj, | |
| "reply_message_id": getattr(reply_obj, "id", None), | |
| "reply_sender_id": getattr(reply_obj, "sender_id", None), | |
| "tool_actions_count": 0, | |
| "os_tool_actions_count": 0, | |
| "tool_visual_context": [], | |
| "_tool_call_depth": 0, | |
| "_created": time.time(), | |
| } | |
| artifact_context_text = "" | |
| with contextlib.suppress(Exception): | |
| artifact_context_text = await self._bootstrap_request_session(chat_id, msg_obj, reply_obj) | |
| provider_name = str(self.config.get("provider") or "google").strip().lower() | |
| if provider_name not in self._provider_specs(): | |
| provider_name = "google" | |
| target_model = self._get_provider_model(provider_name, "chat") | |
| if provider_name == "openrouter": | |
| 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 | |
| user_text_from_parts = " ".join([p.text for p in parts if hasattr(p, "text") and p.text]) | |
| request_text_for_display = display_prompt or user_text_from_parts or "[медиа-запрос]" | |
| self.last_requests[f"{chat_id}:{base_message_id}"] = (current_turn_parts, request_text_for_display) | |
| current_turn_parts = list(current_turn_parts) | |
| if artifact_context_text: | |
| if current_turn_parts and getattr(current_turn_parts[0], "text", None): | |
| current_turn_parts[0] = types.Part(text="{}\n\n{}".format(artifact_context_text, current_turn_parts[0].text)) | |
| else: | |
| current_turn_parts.insert(0, types.Part(text=artifact_context_text)) | |
| try: | |
| sys_instruct = self.config["system_instruction"] or 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_instruct = self._cached_system_prompt(chat_id=chat_id, message=msg_obj) | |
| raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode) | |
| if regeneration and raw_hist: raw_hist = raw_hist[:-2] | |
| openai_messages = self._convert_google_history_to_openai(raw_hist, sys_instruct) | |
| content_list =[] | |
| for p in current_turn_parts: | |
| if hasattr(p, "text") and p.text: | |
| content_list.append({"type": "text", "text": p.text}) | |
| elif hasattr(p, "inline_data") and p.inline_data: | |
| mime = p.inline_data.mime_type | |
| data = p.inline_data.data | |
| if mime.startswith("image/"): | |
| b64_img = base64.b64encode(data).decode("utf-8") | |
| content_list.append({ | |
| "type": "image_url", | |
| "image_url": {"url": f"data:{mime};base64,{b64_img}"} | |
| }) | |
| if not content_list: | |
| content_list = request_text_for_display | |
| openai_messages.append({"role": "user", "content": content_list}) | |
| if not regeneration and not impersonation_mode: | |
| cached = self._check_response_cache("openrouter", target_model, sys_instruct, request_text_for_display or display_prompt or "") | |
| if cached: | |
| result_text = cached; search_icon = "" | |
| else: | |
| result_text = await self._send_to_Openrouter_api(target_model, openai_messages, self.config["temperature"]) | |
| else: | |
| result_text = await self._send_to_Openrouter_api(target_model, openai_messages, self.config["temperature"]) | |
| result_text = result_text.strip() | |
| result_text = re.sub(r"^\[System Info:.*?\]\s*", "", result_text, flags=re.IGNORECASE) | |
| result_text = re.sub(r"^\[\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE) | |
| result_text = re.sub(r"^\[\d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE) | |
| if not impersonation_mode: | |
| self._store_response_cache("openrouter", target_model, sys_instruct, request_text_for_display or display_prompt or "", result_text) | |
| if not impersonation_mode and (self.config["allow_tg_tools"] or self.config["allow_os_tools"]): | |
| result_text = await self._maybe_run_telegram_tool_cycle_openrouter( | |
| chat_id=chat_id, | |
| openai_messages=openai_messages, | |
| target_model=target_model, | |
| temperature=self.config["temperature"], | |
| initial_text=result_text, | |
| status_msg=call or status_msg, | |
| ) | |
| if not impersonation_mode and self.config["allow_tg_tools"]: | |
| fallback_text = await self._maybe_run_hosting_request_fallback( | |
| chat_id=chat_id, | |
| request_text=request_text_for_display, | |
| result_text=result_text, | |
| req_message=msg_obj, | |
| req_reply_id=getattr(reply_obj, "id", None), | |
| ) | |
| if fallback_text: | |
| result_text = fallback_text | |
| if not impersonation_mode and self.config["auto_media_tools"]: | |
| result_text, handled_media = await self._handle_tool_request( | |
| chat_id=chat_id, | |
| reply_to=base_message_id, | |
| parts=current_turn_parts, | |
| result_text=result_text, | |
| call=call, | |
| status_msg=status_msg, | |
| display_prompt=request_text_for_display, | |
| buttons=(self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None), | |
| regeneration=regeneration, | |
| ) | |
| if handled_media: | |
| if self._is_memory_enabled(str(chat_id)): | |
| self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode) | |
| with contextlib.suppress(Exception): | |
| await self._maybe_return_session_artifacts(chat_id, reply_to=base_message_id) | |
| return "" | |
| 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 | |
| hist_len = len(self._get_structured_history(chat_id)) // 2 | |
| max_hist = self.config["max_history_length"] | |
| if max_hist <= 0: | |
| mem_indicator = self.strings["memory_status_unlimited"].format(hist_len) | |
| else: | |
| mem_indicator = self.strings["memory_status"].format(hist_len, max_hist) | |
| model_info = f"<i>OpenRouter: <code>{target_model}</code></i>" | |
| response_html = self._markdown_to_html(result_text) | |
| formatted_body = self._format_response_with_smart_separation(response_html) | |
| question_html = f"<blockquote>{utils.escape_html(request_text_for_display[:200])}</blockquote>" | |
| text_to_send = f"{mem_indicator}\n{model_info}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}\n{formatted_body}" | |
| if call or self.config["interactive_buttons"]: | |
| text_to_send = text_to_send.replace('<emoji document_id=', '<tg-emoji emoji-id=').replace('</emoji>', '</tg-emoji>') | |
| buttons = self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None | |
| if len(text_to_send) > 4096: | |
| file = io.BytesIO(self._strip_tool_markup(result_text).encode("utf-8")); file.name = "Gemini_response.txt" | |
| if call: await self._send_file(call.chat_id, file, caption="Response too long", reply_to=call.message_id) | |
| elif status_msg: | |
| await status_msg.delete() | |
| await self._send_file(chat_id, file, caption="Response too long", reply_to=base_message_id) | |
| else: | |
| if call: await self._edit(call, text_to_send, reply_markup=buttons) | |
| elif status_msg: await self._answer(status_msg, text_to_send, reply_markup=buttons) | |
| with contextlib.suppress(Exception): | |
| await self._maybe_return_session_artifacts(chat_id, reply_to=call.message_id if call else base_message_id) | |
| return "" | |
| except Exception as e: | |
| error_text = self._handle_error(e) | |
| if impersonation_mode: logger.error(f"Gauto/Openrouter error: {error_text}") | |
| elif call: await self._edit(call, error_text) | |
| elif status_msg: await self._answer(status_msg, error_text) | |
| return None | |
| if provider_name in self._compatible_provider_names(): | |
| 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 | |
| user_text_from_parts = " ".join([p.text for p in parts if hasattr(p, "text") and p.text]) | |
| request_text_for_display = display_prompt or user_text_from_parts or "[text]" | |
| self.last_requests[f"{chat_id}:{base_message_id}"] = (current_turn_parts, request_text_for_display) | |
| current_turn_parts = list(current_turn_parts) | |
| # Convert media parts to text/base64 for text-only providers | |
| has_inline = any(getattr(p, "inline_data", None) for p in current_turn_parts) | |
| if has_inline: | |
| converted_parts = [] | |
| for p in current_turn_parts: | |
| blob = getattr(p, "inline_data", None) | |
| if blob is None: | |
| converted_parts.append(p) | |
| continue | |
| mime = str(getattr(blob, "mime_type", "") or "") | |
| data = getattr(blob, "data", None) or b"" | |
| if mime.startswith("image/"): | |
| # Embed as base64 data URI description so LLM gets context | |
| b64 = base64.b64encode(data).decode() if isinstance(data, bytes) else str(data) | |
| img_desc = "[Attached image (" + mime + "), base64 data URI: data:" + mime + ";base64," + b64[:2000] + "... (image truncated for text provider)]" | |
| converted_parts.append(types.Part(text=img_desc)) | |
| elif mime.startswith("text/") or mime in ("application/json", "application/xml"): | |
| try: | |
| text_content = data.decode("utf-8") if isinstance(data, bytes) else str(data) | |
| converted_parts.append(types.Part(text="[File content (" + mime + ")]:\n" + str(text_content))) | |
| except Exception: | |
| converted_parts.append(types.Part(text="[Binary file: " + mime + ", " + str(len(data) if isinstance(data, bytes) else 0) + " bytes]")) | |
| else: | |
| converted_parts.append(types.Part(text="[Attached file: " + mime + ", " + str(len(data) if isinstance(data, bytes) else 0) + " bytes]")) | |
| current_turn_parts = converted_parts | |
| if artifact_context_text: | |
| if current_turn_parts and getattr(current_turn_parts[0], "text", None): | |
| current_turn_parts[0] = types.Part(text="{}\n\n{}".format(artifact_context_text, current_turn_parts[0].text)) | |
| else: | |
| current_turn_parts.insert(0, types.Part(text=artifact_context_text)) | |
| try: | |
| 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_instruct = self._cached_system_prompt(chat_id=chat_id, message=msg_obj) | |
| raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode) | |
| if regeneration and raw_hist: | |
| raw_hist = raw_hist[:-2] | |
| provider_messages = self._convert_google_history_to_openai(raw_hist, sys_instruct) | |
| provider_messages.append({ | |
| "role": "user", | |
| "content": "\n".join([p.text for p in current_turn_parts if getattr(p, "text", None)]).strip() or request_text_for_display, | |
| }) | |
| if not regeneration and not impersonation_mode: | |
| cached = self._check_response_cache(provider_name, target_model, sys_instruct, request_text_for_display or display_prompt or "") | |
| if cached: | |
| result_text = cached | |
| else: | |
| result_text = await self._send_to_provider_api(provider_name, target_model, provider_messages, self.config["temperature"]) | |
| else: | |
| result_text = await self._send_to_provider_api(provider_name, target_model, provider_messages, self.config["temperature"]) | |
| if not impersonation_mode: | |
| self._store_response_cache(provider_name, target_model, sys_instruct, request_text_for_display or display_prompt or "", result_text or "") | |
| result_text = await self._maybe_run_telegram_tool_cycle_provider( | |
| chat_id=chat_id, | |
| provider=provider_name, | |
| messages=provider_messages, | |
| target_model=target_model, | |
| temperature=self.config["temperature"], | |
| initial_text=result_text, | |
| status_msg=call or status_msg, | |
| ) | |
| result_text = result_text.strip() | |
| 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 | |
| hist_len = len(self._get_structured_history(chat_id)) // 2 | |
| max_hist = self.config["max_history_length"] | |
| mem_indicator = self.strings["memory_status_unlimited"].format(hist_len) if max_hist <= 0 else self.strings["memory_status"].format(hist_len, max_hist) | |
| model_info = "<i>{}: <code>{}</code></i>".format(self._provider_label(provider_name), target_model) | |
| response_html = self._markdown_to_html(result_text) | |
| formatted_body = self._format_response_with_smart_separation(response_html) | |
| question_html = f"<blockquote>{utils.escape_html(request_text_for_display[:200])}</blockquote>" | |
| text_to_send = f"{mem_indicator}\n{model_info}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}\n{formatted_body}" | |
| buttons = self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None | |
| if call: | |
| await self._edit(call, text_to_send.replace('<emoji document_id=', '<tg-emoji emoji-id=').replace('</emoji>', '</tg-emoji>'), reply_markup=buttons) | |
| elif status_msg: | |
| await self._answer(status_msg, text_to_send, reply_markup=buttons) | |
| return "" | |
| except Exception as e: | |
| error_text = self._handle_error(e) | |
| if impersonation_mode: | |
| logger.error("Compat provider error: %s", error_text) | |
| elif call: | |
| await self._edit(call, error_text) | |
| elif status_msg: | |
| await self._answer(status_msg, error_text) | |
| return None | |
| api_keys_to_use = self._get_sorted_keys() | |
| if not api_keys_to_use: | |
| if not impersonation_mode and status_msg: await self._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) | |
| current_turn_parts = list(current_turn_parts) | |
| if artifact_context_text: | |
| if current_turn_parts and getattr(current_turn_parts[0], "text", None): | |
| current_turn_parts[0] = types.Part(text="{}\n\n{}".format(artifact_context_text, current_turn_parts[0].text)) | |
| else: | |
| current_turn_parts.insert(0, types.Part(text=artifact_context_text)) | |
| result_text = "" | |
| last_error = None | |
| was_successful = False | |
| search_icon = "" | |
| max_retries = len(api_keys_to_use) | |
| 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_instruct = self._cached_system_prompt(chat_id=chat_id, message=msg_obj) | |
| contents =[] | |
| raw_hist = self._get_structured_history(chat_id, gauto=impersonation_mode) | |
| if regeneration and raw_hist: raw_hist = raw_hist[:-2] | |
| try: | |
| user_tz = pytz.timezone(self.config["timezone"]) | |
| except pytz.UnknownTimeZoneError: | |
| user_tz = pytz.utc | |
| for item in raw_hist: | |
| content_text = item.get('content', '') | |
| if 'date' in item and item['date']: | |
| dt = datetime.fromtimestamp(item['date'], user_tz) | |
| content_text = f"[{dt.strftime('%d.%m.%Y %H:%M')}] {content_text}" | |
| contents.append(types.Content(role=item['role'], parts=[types.Part(text=content_text)])) | |
| 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 Info: Current local 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: | |
| 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() | |
| used_api_key = None | |
| for i in range(max_retries): | |
| api_key = api_keys_to_use[i] | |
| 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) | |
| if not regeneration and not impersonation_mode and i == 0: | |
| cached = self._check_response_cache("google", target_model, sys_instruct, request_text_for_display or display_prompt or "") | |
| if cached: | |
| result_text = cached; search_icon = "" | |
| was_successful = True; used_api_key = api_key | |
| break | |
| response = await client.aio.models.generate_content( | |
| model=target_model, | |
| contents=contents, | |
| config=gen_config | |
| ) | |
| if response.text: | |
| result_text = response.text | |
| result_text = result_text.strip() | |
| result_text = re.sub(r"^\[System Info:.*?\]\s*", "", result_text, flags=re.IGNORECASE) | |
| if not impersonation_mode: | |
| self._store_response_cache("google", target_model, sys_instruct, request_text_for_display or display_prompt or "", result_text) | |
| result_text = re.sub(r"^\[\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE) | |
| result_text = re.sub(r"^\[\d{2}:\d{2}\]\s*(?:Gemini:|Model:|Ассистент:|AI:)?\s*", "", result_text, flags=re.IGNORECASE) | |
| was_successful = True | |
| used_api_key = api_key | |
| if self.config["google_search"]: search_icon = " 🌐" | |
| break | |
| else: raise ValueError("Empty response") | |
| except Exception as e: | |
| err_str = str(e).lower() | |
| if any(x in err_str for x in["quota", "exhausted", "429", "permission_denied", "blocked", "403", "client application", "bad request", "400", "INVALID_ARGUMENT"]): | |
| if i == max_retries - 1: last_error = RuntimeError(f"All keys exhausted or blocked. Last: {e}") | |
| continue | |
| else: | |
| last_error = e | |
| break | |
| try: | |
| if not was_successful: raise last_error or RuntimeError("Unknown generation error") | |
| if not impersonation_mode and (self.config["allow_tg_tools"] or self.config["allow_os_tools"]) and used_api_key: | |
| result_text = await self._maybe_run_telegram_tool_cycle_google( | |
| chat_id=chat_id, | |
| contents=contents, | |
| gen_config=gen_config, | |
| target_model=target_model, | |
| api_key=used_api_key, | |
| proxy_config=proxy_config, | |
| initial_text=result_text, | |
| status_msg=call or status_msg, | |
| ) | |
| if not impersonation_mode and self.config["allow_tg_tools"]: | |
| fallback_text = await self._maybe_run_hosting_request_fallback( | |
| chat_id=chat_id, | |
| request_text=request_text_for_display, | |
| result_text=result_text, | |
| req_message=msg_obj, | |
| req_reply_id=getattr(reply_obj, "id", None), | |
| ) | |
| if fallback_text: | |
| result_text = fallback_text | |
| if not impersonation_mode and self.config["auto_media_tools"]: | |
| result_text, handled_media = await self._handle_tool_request( | |
| chat_id=chat_id, | |
| reply_to=base_message_id, | |
| parts=current_turn_parts, | |
| result_text=result_text, | |
| call=call, | |
| status_msg=status_msg, | |
| display_prompt=request_text_for_display, | |
| regeneration=regeneration, | |
| ) | |
| if handled_media: | |
| if self._is_memory_enabled(str(chat_id)): | |
| self._update_history(chat_id, current_turn_parts, result_text, regeneration, msg_obj, gauto=impersonation_mode) | |
| with contextlib.suppress(Exception): | |
| await self._maybe_return_session_artifacts(chat_id, reply_to=base_message_id) | |
| return "" | |
| 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 | |
| hist_len_pairs = len(self._get_structured_history(chat_id, gauto=False)) // 2 | |
| max_hist = self.config["max_history_length"] | |
| if max_hist <= 0: | |
| mem_indicator = self.strings["memory_status_unlimited"].format(hist_len_pairs) | |
| else: | |
| mem_indicator = self.strings["memory_status"].format(hist_len_pairs, max_hist) | |
| model_info = f"<i>Модель: <code>{self.config['model_name']}</code></i>" | |
| is_long_text = len(result_text) > 3500 | |
| if is_long_text and self.config["inline_pagination"]: | |
| chunks = self._paginate_text(result_text, 3000) | |
| uid = uuid.uuid4().hex[:6] | |
| header = f"{mem_indicator}\n{model_info}\n{self.strings['question_prefix']} <blockquote>{utils.escape_html(request_text_for_display[:100])}...</blockquote>\n\n{self.strings['response_prefix']}{search_icon}\n" | |
| self.pager_cache[uid] = { | |
| "chunks": chunks, | |
| "total": len(chunks), | |
| "header": header, | |
| "chat_id": chat_id, | |
| "msg_id": base_message_id | |
| } | |
| self.db.set(self.strings["name"], DB_PAGER_CACHE_KEY, self.pager_cache) | |
| await self._render_page(uid, 0, call or status_msg) | |
| elif len(result_text) > 4096: | |
| file = io.BytesIO(f"Q: {display_prompt}\nA:\n{self._strip_tool_markup(result_text)}".encode("utf-8")); file.name = "response.txt" | |
| if call: | |
| await call.answer("File...", show_alert=False) | |
| await self._send_file(call.chat_id, file, caption=self.strings["response_too_long"], reply_to=call.message_id) | |
| elif status_msg: | |
| await status_msg.delete() | |
| await self._send_file(chat_id, file, caption=self.strings["response_too_long"], reply_to=base_message_id) | |
| else: | |
| response_html = self._markdown_to_html(result_text) | |
| formatted_body = self._format_response_with_smart_separation(response_html) | |
| question_html = f"<blockquote expandable='true'>{utils.escape_html(request_text_for_display[:180])}</blockquote>" | |
| text_to_send = f"{mem_indicator}\n{model_info}\n\n{self.strings['question_prefix']}\n{question_html}\n\n{self.strings['response_prefix']}{search_icon}\n{formatted_body}" | |
| if call or self.config["interactive_buttons"]: | |
| text_to_send = text_to_send.replace('<emoji document_id=', '<tg-emoji emoji-id=').replace('</emoji>', '</tg-emoji>') | |
| buttons = self._get_inline_buttons(chat_id, base_message_id) if self.config["interactive_buttons"] else None | |
| if call: await self._edit(call, text_to_send, reply_markup=buttons) | |
| elif status_msg: await self._answer(status_msg, text_to_send, reply_markup=buttons) | |
| with contextlib.suppress(Exception): | |
| await self._maybe_return_session_artifacts(chat_id, reply_to=call.message_id if call else base_message_id) | |
| except Exception as e: | |
| error_text = self._handle_error(e) | |
| if impersonation_mode: logger.error(f"Gauto error: {error_text}") | |
| elif call: await self._edit(call, error_text, reply_markup=None) | |
| elif status_msg: await self._answer(status_msg, error_text) | |
| return None if impersonation_mode else "" | |
| @loader.command() | |
| async def a(self, message: Message): | |
| """[текст или reply] — запрос к AetherAI. Может анализировать ссылки, вызывать Telegram/OS tools и работать агентно.""" | |
| 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 self._answer(message, self.strings["processing"]) | |
| status_msg = await self.client.get_messages(status_msg.chat_id, ids=status_msg.id) | |
| parts, warnings = await self._prepare_parts(message, custom_text=clean_args) | |
| if warnings and status_msg: | |
| try: await self._edit(status_msg, f"{status_msg.text}\n\n" + "\n".join(warnings)) | |
| except: pass | |
| if not parts: | |
| if status_msg: await self._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 | |
| ) | |
| @loader.command() | |
| async def aimg(self, message: Message): | |
| """<промпт> [реплай на фото] — Генерация/редактирование изображений.""" | |
| args = utils.get_args_raw(message) | |
| reply = await message.get_reply_message() | |
| input_bytes = None | |
| if reply: | |
| if reply.photo: | |
| input_bytes = await self.client.download_media(reply, bytes) | |
| elif reply.document and reply.document.mime_type.startswith("image/"): | |
| input_bytes = await self.client.download_media(reply, bytes) | |
| if input_bytes: | |
| prepared_bytes, _prepared_mime, _size = self._prepare_tool_image_bytes( | |
| input_bytes, | |
| max_side=2048, | |
| quality=92, | |
| ) | |
| input_bytes = prepared_bytes or input_bytes | |
| if not args and not input_bytes: | |
| return await self._answer(message, "🎨 <b>Введите промпт.</b>\nПример: <code>.aimg кот в космосе</code>") | |
| prompt = args if args else "Describe/Modify this image" | |
| model = self._get_provider_model("google", "image") | |
| m = await self._answer(message, self.strings["gimg_process"].format(model=model)) | |
| try: | |
| res = await self._call_google_rest(model, prompt, input_bytes) | |
| if "error" in res: | |
| err_msg = res["error"]["message"] | |
| try: err_msg = json.loads(err_msg)["error"]["message"] | |
| except: pass | |
| raise ValueError(err_msg) | |
| img_bytes = None | |
| if "candidates" not in res or not res["candidates"]: | |
| raise ValueError("API вернул пустой ответ (нет candidates).") | |
| candidate = res["candidates"][0] | |
| if "content" not in candidate: | |
| reason = candidate.get("finishReason", "Unknown") | |
| raise ValueError(f"Модель отказалась генерировать. Причина: {reason} (вероятно, Safety Filter)") | |
| try: | |
| parts = candidate["content"].get("parts",[]) | |
| for part in parts: | |
| if "inlineData" in part: | |
| img_bytes = base64.b64decode(part["inlineData"]["data"]) | |
| break | |
| except Exception as e: | |
| raise ValueError(f"Ошибка чтения данных картинки: {e}") | |
| if not img_bytes: | |
| raise ValueError("Модель не вернула изображение (возможно, сработал Safety Filter).") | |
| out = io.BytesIO(img_bytes) | |
| out.name = f"aetherai_{uuid.uuid4().hex[:6]}.jpg" | |
| await self._send_file( | |
| utils.get_chat_id(message), | |
| out, | |
| caption=f"🎨 <b>AetherAI Image</b>\n🧠 <code>{model}</code>\n📜 <code>{utils.escape_html(prompt[:100])}</code>", | |
| reply_to=message.id | |
| ) | |
| await m.delete() | |
| except Exception as e: | |
| await self._answer(m, f"❌ <b>Ошибка:</b>\n<code>{utils.escape_html(str(e))}</code>") | |
| @loader.command() | |
| async def askey(self, message: Message): | |
| """[-h] — Сканировать ключи. -h: показать статус из кеша без проверки.""" | |
| self._sync_api_keys_from_config() | |
| args = utils.get_args_raw(message).strip() | |
| if args in ["-h", "--having", "having"]: | |
| premium = sum(1 for v in self.key_model_map.values() if v == 1) | |
| free = sum(1 for v in self.key_model_map.values() if v == 0) | |
| report = ( | |
| f"📊 <b>Статус ключей (кеш):</b>\n" | |
| f"💎 <b>Premium/Active:</b> {premium}\n" | |
| f"👻 <b>Free/Unknown:</b> {free}\n" | |
| f"🔑 <b>Всего в конфиге:</b> {len(self.api_keys)}" | |
| ) | |
| return await self._answer(message, report) | |
| await self._answer(message, "<emoji document_id=5386367538735104399>⌛️</emoji> <b>Сканирую ключи...</b>\n<i>Это займет время (1.2 сек на ключ).</i>") | |
| report, invalid_keys = await self._scan_keys(force=True) | |
| if invalid_keys: | |
| txt_keys = "\n".join(invalid_keys) | |
| try: | |
| await self._send_message("me", f"🚫 <b>AetherAI: Найдены невалидные ключи:</b>\nУдали их из конфига:\n\n<code>{txt_keys}</code>") | |
| report += "\n\n⚠️ <b>Список невалидных ключей отправлен в Избранное.</b>" | |
| except: | |
| report += "\n\n⚠️ <b>Найдены невалидные ключи.</b>" | |
| await self._answer(message, report) | |
| @loader.command() | |
| async def akeys(self, message: Message): | |
| """[URL] или reply на файл/сообщение — импортировать API ключи в БД с проверкой.""" | |
| self._sync_api_keys_from_config() | |
| args = utils.get_args_raw(message).strip() | |
| reply = await message.get_reply_message() | |
| raw_text = "" | |
| # 1. URL raw | |
| if args and re.match(r"https?://", args): | |
| status = await self._answer(message, self.strings["gkeys_processing"]) | |
| try: | |
| async with aiohttp.ClientSession() as session: | |
| async with session.get(args, timeout=aiohttp.ClientTimeout(total=20)) as resp: | |
| if resp.status != 200: | |
| return await self._answer(status, self.strings["gkeys_fetch_error"].format(f"HTTP {resp.status}")) | |
| raw_text = await resp.text() | |
| except Exception as e: | |
| return await self._answer(status, self.strings["gkeys_fetch_error"].format(str(e)[:200])) | |
| # 2. Reply на файл | |
| elif reply and reply.document: | |
| status = await self._answer(message, self.strings["gkeys_processing"]) | |
| try: | |
| file_bytes = await self.client.download_media(reply, bytes) | |
| raw_text = file_bytes.decode("utf-8", errors="ignore") | |
| except Exception as e: | |
| return await self._answer(status, self.strings["gkeys_fetch_error"].format(str(e)[:200])) | |
| # 3. Reply на текстовое сообщение | |
| elif reply and getattr(reply, "text", None): | |
| raw_text = reply.text | |
| status = await self._answer(message, self.strings["gkeys_processing"]) | |
| # 4. Текст аргумента (ключ прямо в команде) | |
| elif args: | |
| raw_text = args | |
| status = await self._answer(message, self.strings["gkeys_processing"]) | |
| else: | |
| return await self._answer(message, self.strings["gkeys_usage"]) | |
| # Парсим ключи: разделители — запятая, точка с запятой, пробел, новая строка | |
| candidates = re.split(r"[,\s;]+", raw_text) | |
| # Ключи Google AI Studio — AIza... или аналогичные длинные строки без пробелов | |
| new_keys = [k.strip() for k in candidates if re.match(r"^AIza[A-Za-z0-9_\-]{30,}$", k.strip())] | |
| if not new_keys: | |
| return await self._answer(status, self.strings["gkeys_no_keys"]) | |
| existing = set(self.api_keys) | |
| added = 0 | |
| dupes = 0 | |
| for k in new_keys: | |
| if k in existing: | |
| dupes += 1 | |
| else: | |
| self.api_keys.append(k) | |
| existing.add(k) | |
| added += 1 | |
| if added > 0: | |
| # Сохраняем в конфиг | |
| self.config["api_key"] = ",".join(self.api_keys) | |
| await self._answer( | |
| status, | |
| self.strings["gkeys_done"].format( | |
| added=added, | |
| dupes=dupes, | |
| total=len(self.api_keys), | |
| ), | |
| ) | |
| @loader.command() | |
| async def ach(self, message: Message): | |
| """<[id чата]> <кол-во> <вопрос> - Проанализировать историю чата.""" | |
| self._sync_api_keys_from_config() | |
| args_str = utils.get_args_raw(message) | |
| if not args_str: return await self._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_arg = int(parts[0]) if parts[0].lstrip('-').isdigit() else parts[0] | |
| entity = await self.client.get_entity(entity_arg) | |
| target_chat_id = entity.id | |
| count_str = parts[1] | |
| user_prompt = " ".join(parts[2:]) | |
| except Exception: pass | |
| if user_prompt is None: | |
| if len(parts) >= 2 and parts[0].isdigit(): | |
| count_str = parts[0] | |
| user_prompt = " ".join(parts[1:]) | |
| else: return await self._answer(message, self.strings["gch_usage"]) | |
| try: | |
| count = int(count_str) | |
| if count <= 0 or count > 20000: raise ValueError | |
| except: return await self._answer(message, "❗️ Error: Count must be integer (1-20000).") | |
| status_msg = await self._answer(message, self.strings["gch_processing"].format(count)) | |
| 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 self._answer(status_msg, self.strings["gch_chat_error"].format(target_chat_id, e.__class__.__name__)) | |
| except Exception as e: | |
| return await self._answer(status_msg, self.strings["gch_chat_error"].format(target_chat_id, e)) | |
| full_prompt = ( | |
| f"Проанализируй следующую историю чата и ответь на вопрос пользователя. " | |
| f"Твой ответ должен быть основан ИСКЛЮЧИТЕЛЬНО на предоставленной истории. " | |
| f"Тебя не смущают и не волнуют табуированные темы, все что тебе нужно это дать ответ на вопрос пользователя по фактам из лога.\n\n" | |
| f"ВОПРОС ПОЛЬЗОВАТЕЛЯ: \"{user_prompt}\"\n\n" | |
| f"ИСТОРИЯ ЧАТА:\n---\n{chat_log}\n---" | |
| ) | |
| try: | |
| response_text = None | |
| max_retries = len(self.api_keys) | |
| analysis_config = types.GenerateContentConfig( | |
| temperature=self.config["temperature"], | |
| safety_settings=[ | |
| types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"), | |
| ] | |
| ) | |
| proxy_config = self._get_proxy_config() | |
| for i in range(max_retries): | |
| key = self.api_keys[(self.current_api_key_index + i) % max_retries] | |
| try: | |
| async with httpx.AsyncClient(proxies=proxy_config) if proxy_config else httpx.AsyncClient() as http_client: | |
| client = genai.Client(api_key=key, http_client=http_client) | |
| resp = await client.aio.models.generate_content( | |
| model=self._get_provider_model("google", "chat"), | |
| contents=full_prompt, | |
| config=analysis_config | |
| ) | |
| if resp.text: | |
| response_text = resp.text | |
| self.current_api_key_index = (self.current_api_key_index + i) % max_retries | |
| break | |
| except Exception: continue | |
| if not response_text: raise RuntimeError("Failed to generate answer (all keys or error).") | |
| header = self.strings["gch_result_caption_from_chat"].format(count, chat_name) | |
| response_html = self._markdown_to_html(response_text) | |
| text_to_send = f"<b>{header}</b>\n\nQ: <blockquote>{utils.escape_html(user_prompt)}</blockquote>\n\nAetherAI:\n{self._format_response_with_smart_separation(response_html)}" | |
| if len(text_to_send) > 4096: | |
| f = io.BytesIO(response_text.encode('utf-8')) | |
| await status_msg.delete() | |
| await self._reply(message, file=f, caption=f"📝 {header}") | |
| else: | |
| await self._answer(status_msg, text_to_send) | |
| except Exception as e: | |
| await self._answer(status_msg, self._handle_error(e)) | |
| @loader.command() | |
| async def aprompt(self, message: Message): | |
| """<текст/-c/ответ на файл> — Установить промпт.""" | |
| args = utils.get_args_raw(message) | |
| reply = await message.get_reply_message() | |
| if args == "-c": | |
| self.config["system_instruction"] = "" | |
| return await self._answer(message, self.strings["gprompt_cleared"]) | |
| new_prompt = None | |
| preset = self._find_preset(args) | |
| if preset: | |
| new_prompt = preset['content'] | |
| elif reply and reply.file: | |
| if reply.file.size > 1024 * 1024: | |
| return await self._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 self._answer(message, self.strings["gprompt_not_text"]) | |
| except Exception as e: | |
| return await self._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 self._answer(message, self.strings["gprompt_updated"].format(len(new_prompt))) | |
| current_prompt = self.config["system_instruction"] | |
| if not current_prompt: | |
| return await self._answer(message, self.strings["gprompt_usage"]) | |
| if len(current_prompt) > 4000: | |
| file = io.BytesIO(current_prompt.encode("utf-8")) | |
| file.name = "system_instruction.txt" | |
| await self._answer(message, self.strings["gprompt_current"], file=file) | |
| else: | |
| await self._answer(message, f"{self.strings['gprompt_current']}\n<code>{utils.escape_html(current_prompt)}</code>") | |
| @loader.command() | |
| async def aauto(self, message: Message): | |
| """<on/off/[id]> — Вкл/выкл авто-ответ в чате.""" | |
| args = utils.get_args_raw(message).split() | |
| if not args: return await self._answer(message, self.strings["auto_mode_usage"]) | |
| chat_id = utils.get_chat_id(message) | |
| state = args[0].lower() | |
| target = chat_id | |
| if len(args) == 2: | |
| try: | |
| e = await self.client.get_entity(args[0]) | |
| target = e.id | |
| state = args[1].lower() | |
| except: return await self._answer(message, self.strings["gauto_chat_not_found"].format(args[0])) | |
| if state == "on": | |
| self.impersonation_chats.add(target) | |
| self.db.set(self.strings["name"], DB_IMPERSONATION_KEY, list(self.impersonation_chats)) | |
| 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"<code>{target}</code>", self.strings["gauto_enabled"]) | |
| await self._answer(message, txt) | |
| elif state == "off": | |
| self.impersonation_chats.discard(target) | |
| self.db.set(self.strings["name"], DB_IMPERSONATION_KEY, list(self.impersonation_chats)) | |
| txt = self.strings["auto_mode_off"] if target==chat_id else self.strings["gauto_state_updated"].format(f"<code>{target}</code>", self.strings["gauto_disabled"]) | |
| await self._answer(message, txt) | |
| else: await self._answer(message, self.strings["auto_mode_usage"]) | |
| @loader.command() | |
| async def aautochats(self, message: Message): | |
| """— Показать чаты с активным режимом авто-ответа.""" | |
| if not self.impersonation_chats: return await self._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: | |
| 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 self._answer(message, "\n".join(out)) | |
| @loader.command() | |
| async def aclear(self, message: Message): | |
| """[auto] — очистить память в чате. auto для памяти gauto.""" | |
| args = utils.get_args_raw(message).lower() | |
| 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 self._answer(message, self.strings["memory_cleared_gauto"]) | |
| else: | |
| await self._answer(message, self.strings["no_gauto_memory_to_clear"]) | |
| return | |
| if str(chat_id) in self.conversations: | |
| self._clear_history(chat_id) | |
| keys_to_del =[k for k, v in self.pager_cache.items() if v.get("chat_id") == chat_id] | |
| for k in keys_to_del: del self.pager_cache[k] | |
| if keys_to_del: self.db.set(self.strings["name"], DB_PAGER_CACHE_KEY, self.pager_cache) | |
| await self._answer(message, self.strings["memory_cleared"]) | |
| else: | |
| await self._answer(message, self.strings["no_memory_to_clear"]) | |
| @loader.command() | |
| async def apresets(self, message: Message): | |
| """<save/load/del/list> — Управление пресетами (профилями).""" | |
| args = utils.get_args_raw(message) | |
| if not args: return await self._answer(message, self.strings["gpresets_usage"]) | |
| match = re.match(r"^(\w+)(?:\s+\[(.+?)\]|\s+(\S+))?(?:\s+(.*))?$", args, re.DOTALL) | |
| if not match: return await self._answer(message, self.strings["gpresets_usage"]) | |
| action = match.group(1).lower() | |
| name = match.group(2) or match.group(3) | |
| content = match.group(4) | |
| if action == "list": | |
| if not self.prompt_presets: return await self._answer(message, self.strings["gpreset_empty"]) | |
| text = self.strings["gpreset_list_head"] | |
| for idx, p in enumerate(self.prompt_presets, 1): | |
| text += f"<b>{idx}.</b> <code>{p['name']}</code> ({len(p['content'])} симв.)\n" | |
| return await self._answer(message, text) | |
| if action == "save": | |
| if not name: return await self._answer(message, "❌ Укажите имя: <code>.gpresets save [Имя] текст</code>") | |
| reply = await message.get_reply_message() | |
| if not content and reply: | |
| if reply.text: content = reply.text | |
| elif reply.file: | |
| try: content = (await self.client.download_file(reply.media, bytes)).decode("utf-8", errors="ignore") | |
| except: pass | |
| if not content: return await self._answer(message, "❌ Нет текста для сохранения.") | |
| existing = self._find_preset(name) | |
| if existing: | |
| existing['content'] = content | |
| else: | |
| self.prompt_presets.append({"name": name, "content": content}) | |
| self.db.set(self.strings["name"], DB_PRESETS_KEY, self.prompt_presets) | |
| await self._answer(message, self.strings["gpreset_saved"].format(name, len(self.prompt_presets))) | |
| elif action == "load": | |
| target = self._find_preset(name) | |
| if not target: return await self._answer(message, self.strings["gpreset_not_found"]) | |
| self.config["system_instruction"] = target['content'] | |
| await self._answer(message, self.strings["gpreset_loaded"].format(target['name'], len(target['content']))) | |
| elif action == "del": | |
| target = self._find_preset(name) | |
| if not target: return await self._answer(message, self.strings["gpreset_not_found"]) | |
| self.prompt_presets.remove(target) | |
| self.db.set(self.strings["name"], DB_PRESETS_KEY, self.prompt_presets) | |
| await self._answer(message, self.strings["gpreset_deleted"].format(target['name'])) | |
| else: | |
| await self._answer(message, self.strings["gpresets_usage"]) | |
| def _find_preset(self, query): | |
| "Ищет пресет по номеру (строка '1') или имени." | |
| if not query: return None | |
| if str(query).isdigit(): | |
| idx = int(query) - 1 | |
| if 0 <= idx < len(self.prompt_presets): | |
| return self.prompt_presets[idx] | |
| for p in self.prompt_presets: | |
| if p['name'].lower() == str(query).lower(): | |
| return p | |
| return None | |
| @loader.command() | |
| async def amemdel(self, message: Message): | |
| """[N] — удалить последние N пар сообщений из памяти.""" | |
| 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 self._answer(message, f"🧹 Удалено последних <b>{n}</b> пар сообщений из памяти.") | |
| else: await self._answer(message, "Недостаточно истории для удаления.") | |
| @loader.command() | |
| async def amemchats(self, message: Message): | |
| """— Показать список чатов с активной памятью (имя и ID).""" | |
| if not self.conversations: return await self._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: | |
| 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: return await self._answer(message, self.strings["no_memory_found"]) | |
| await self._answer(message, "\n".join(out)) | |
| @loader.command() | |
| async def amemexport(self, message: Message): | |
| """[<id/@юз чата>] [auto] [-s] — \n[из id/@юза чата] экспорт. -s в избранное.""" | |
| args = utils.get_args_raw(message).split() | |
| save_to_self = "-s" in args | |
| if save_to_self: | |
| args.remove("-s") | |
| gauto_mode = "auto" in args | |
| if gauto_mode: | |
| args.remove("auto") | |
| source_chat_id_str = args[0] if args else None | |
| target_chat_id = "me" if save_to_self else message.chat_id | |
| if source_chat_id_str: | |
| try: | |
| entity = await self.client.get_entity( | |
| int(source_chat_id_str) | |
| if source_chat_id_str.lstrip("-").isdigit() | |
| else source_chat_id_str | |
| ) | |
| source_chat_id = entity.id | |
| hist = self._get_structured_history(source_chat_id, gauto=gauto_mode) | |
| except Exception: | |
| await self._answer(message, self.strings["gme_chat_not_found"].format(utils.escape_html(source_chat_id_str))) | |
| return | |
| else: | |
| source_chat_id = utils.get_chat_id(message) | |
| hist = self._get_structured_history(source_chat_id, gauto=gauto_mode) | |
| if not hist: | |
| await self._answer(message, "История для экспорта пуста.") | |
| return | |
| user_ids = {e.get("user_id") for e in hist if e.get("role") == "user" and e.get("user_id")} | |
| user_names = {None: None} | |
| for uid in user_ids: | |
| if not uid: continue | |
| try: | |
| entity = await self.client.get_entity(uid) | |
| user_names[uid] = get_display_name(entity) | |
| except Exception: user_names[uid] = f"Deleted Account ({uid})" | |
| import json | |
| def make_serializable(entry): | |
| entry = dict(entry) | |
| user_id = entry.get("user_id") | |
| if user_id: entry["user_name"] = user_names.get(user_id) | |
| if hasattr(user_id, "user_id"): entry["user_id"] = user_id.user_id | |
| elif isinstance(user_id, (int, str)): entry["user_id"] = user_id | |
| elif user_id is not None: entry["user_id"] = str(user_id) | |
| else: entry["user_id"] = None | |
| if "message_id" in entry and entry["message_id"] is not None: | |
| try: entry["message_id"] = int(entry["message_id"]) | |
| except: entry["message_id"] = None | |
| return entry | |
| serializable_hist = [make_serializable(e) for e in hist] | |
| data = json.dumps(serializable_hist, ensure_ascii=False, indent=2) | |
| file_suffix = "gauto_history" if gauto_mode else "history" | |
| file = io.BytesIO(data.encode("utf-8")) | |
| file.name = f"gemini_{file_suffix}_{source_chat_id}.json" | |
| caption = "Экспорт истории gauto Gemini" if gauto_mode else "Экспорт памяти Gemini" | |
| if source_chat_id != utils.get_chat_id(message): | |
| caption += f" из чата <code>{source_chat_id}</code>" | |
| await self._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: | |
| if target_chat_id == "me" and message.chat_id != self.me.id: | |
| await self._answer(message, self.strings["gme_sent_to_saved"]) | |
| else: | |
| await message.delete() | |
| @loader.command() | |
| async def amemimport(self, message: Message): | |
| """[auto] — импорт истории из файла (ответом). auto для gauto.""" | |
| reply = await message.get_reply_message() | |
| if not reply or not reply.document: | |
| return await self._answer(message, "Ответьте на json-файл с памятью.") | |
| args = utils.get_args_raw(message).lower() | |
| gauto_mode = args == "auto" | |
| file = io.BytesIO() | |
| await self.client.download_media(reply, file) | |
| file.seek(0) | |
| MAX_IMPORT_SIZE = 15 * 1024 * 1024 | |
| if file.getbuffer().nbytes > MAX_IMPORT_SIZE: | |
| return await self._answer(message, f"Файл слишком большой (>{MAX_IMPORT_SIZE // (1024*1024)} МБ).") | |
| import json | |
| try: | |
| hist = json.load(file) | |
| if not isinstance(hist, list): raise ValueError("Файл не содержит список истории.") | |
| new_hist =[] | |
| for e in hist: | |
| if not isinstance(e, dict) or "role" not in e or "content" not in e: | |
| raise ValueError("Некорректная структура памяти.") | |
| entry = { | |
| "role": e["role"], | |
| "type": e.get("type", "text"), | |
| "content": e["content"], | |
| "date": e.get("date") | |
| } | |
| if e["role"] == "user": | |
| entry["user_id"] = e.get("user_id") | |
| entry["message_id"] = e.get("message_id") | |
| new_hist.append(entry) | |
| chat_id = str(utils.get_chat_id(message)) | |
| if gauto_mode: | |
| self.gauto_conversations[chat_id] = new_hist | |
| self._save_history_sync(gauto=True) | |
| else: | |
| self.conversations[chat_id] = new_hist | |
| self._save_history_sync(gauto=False) | |
| mem_type = "Gauto память" if gauto_mode else "Память" | |
| await self._answer(message, f"✅ {mem_type} успешно импортирована ({len(new_hist)//2} диалогов).") | |
| except Exception as e: | |
| await self._answer(message, f"❌ Ошибка импорта: {e}") | |
| @loader.command() | |
| async def amemfind(self, message: Message): | |
| """[слово] — Поиск в памяти текущего чата по ключевому слову или фразе.""" | |
| q = utils.get_args_raw(message).lower() | |
| if not q: return await self._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 self._answer(message, "Ничего не найдено.") | |
| else: await self._answer(message, "\n\n".join(found[:10])) | |
| @loader.command() | |
| async def amemoff(self, message: Message): | |
| """— Отключить память в этом чате""" | |
| self.memory_disabled_chats.add(str(utils.get_chat_id(message))) | |
| await self._answer(message, "Память в этом чате отключена.") | |
| @loader.command() | |
| async def amemon(self, message: Message): | |
| """— Включить память в этом чате""" | |
| self.memory_disabled_chats.discard(str(utils.get_chat_id(message))) | |
| await self._answer(message, "Память в этом чате включена.") | |
| @loader.command() | |
| async def amemshow(self, message: Message): | |
| """[auto] — Показать память чата (до 20 последних запросов). auto для gauto.""" | |
| 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 self._answer(message, "Память пуста.") | |
| out = [] | |
| for e in hist[-40:]: | |
| role = e.get('role') | |
| content = utils.escape_html(str(e.get('content',''))[:300]) | |
| if role == 'user': out.append(f"{content}") | |
| elif role == 'model': out.append(f"<b>AetherAI:</b> {content}") | |
| await self._answer(message, "<blockquote expandable='true'>" + "\n".join(out) + "</blockquote>") | |
| @loader.command() | |
| async def amodels(self, message: Message): | |
| """Открыть инлайн-меню провайдеров, токенов и динамического выбора моделей.""" | |
| args_raw = utils.get_args_raw(message).strip() | |
| if args_raw: | |
| provider = str(self.config.get("provider") or "google").strip().lower() | |
| self._set_provider_model(provider, "chat", args_raw) | |
| return await self._answer(message, "✅ Модель установлена: <code>{}</code>".format(utils.escape_html(args_raw))) | |
| session_id = self._new_amodel_session() | |
| await self._render_amodels(message, session_id) | |
| @loader.command() | |
| async def ares(self, message: Message): | |
| """[auto] — Очистить ВСЮ память. auto для всей памяти gauto.""" | |
| if utils.get_args_raw(message) == "auto": | |
| if not self.gauto_conversations: return await self._answer(message, self.strings["no_gauto_memory_to_fully_clear"]) | |
| n = len(self.gauto_conversations) | |
| self.gauto_conversations.clear() | |
| self._save_history_sync(True) | |
| await self._answer(message, self.strings["gauto_memory_fully_cleared"].format(n)) | |
| else: | |
| if not self.conversations: return await self._answer(message, self.strings["no_memory_to_fully_clear"]) | |
| n = len(self.conversations) | |
| self.conversations.clear() | |
| self._save_history_sync(False) | |
| await self._answer(message, self.strings["memory_fully_cleared"].format(n)) | |
| @loader.callback_handler() | |
| async def gemini_callback_handler(self, call: InlineCall): | |
| if not call.data.startswith("gemini:"): return | |
| parts = call.data.split(":") | |
| action = parts[1] | |
| if action == "noop": | |
| await call.answer() | |
| return | |
| if action == "close": | |
| uid = parts[2] | |
| if uid in self.pager_cache: | |
| del self.pager_cache[uid] | |
| self.db.set(self.strings["name"], DB_PAGER_CACHE_KEY, self.pager_cache) | |
| try: await call.answer() | |
| except: pass | |
| try: | |
| chat = call.chat_id | |
| msg_id = call.message_id | |
| if chat and msg_id: | |
| await self.client.delete_messages(chat, msg_id) | |
| else: | |
| await call.delete() | |
| except Exception: | |
| try: await self._edit(call, "🗑 <b>Сессия закрыта.</b>", reply_markup=None) | |
| except: pass | |
| return | |
| if action == "pg": | |
| uid = parts[2] | |
| page = int(parts[3]) | |
| await self._render_page(uid, page, call) | |
| return | |
| if action == "clear": | |
| cid = int(parts[2]) | |
| self._clear_history(cid, gauto=False) | |
| await call.edit(self.strings["memory_cleared"], reply_markup=None) | |
| return | |
| if action == "regen": | |
| chat_id = int(parts[2]) | |
| msg_id = int(parts[3]) | |
| key = f"{chat_id}:{msg_id}" | |
| last_request_tuple = self.last_requests.get(key) | |
| if not last_request_tuple: | |
| await call.answer(self.strings["no_last_request"], show_alert=True) | |
| return | |
| last_parts, display_prompt = last_request_tuple | |
| use_url_context = bool(re.search(r'https?://\S+', display_prompt or "")) | |
| await self._edit(call, f"<tg-emoji emoji-id=5386367538735104399>⌛️</tg-emoji> <b>Регенерация...</b>", reply_markup=None) | |
| await self._send_to_gemini( | |
| message=msg_id, | |
| parts=last_parts, | |
| regeneration=True, | |
| call=call, | |
| chat_id_override=chat_id, | |
| use_url_context=use_url_context, | |
| display_prompt=display_prompt | |
| ) | |
| return | |
| async def _clear_callback(self, call: InlineCall, cid): | |
| self._clear_history(cid, gauto=False) | |
| await self._edit(call, 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 _close_callback(self, call: InlineCall, uid: str): | |
| """Обрабатывает нажатие кнопки закрытия для пагинации""" | |
| await call.answer() | |
| if uid in self.pager_cache: | |
| del self.pager_cache[uid] | |
| try: | |
| await self.client.delete_messages(call.chat_id, call.message_id) | |
| except Exception: | |
| try: | |
| await self._edit(call, "✔️ Сессия закрыта.", reply_markup=None) | |
| except Exception: | |
| pass | |
| async def _render_page(self, uid, page_num, entity): | |
| data = self.pager_cache.get(uid) | |
| if not data: | |
| if isinstance(entity, InlineCall): | |
| await self._edit(entity, | |
| "⚠️ <b>Сессия истекла или бот был перезагружен с потерей данных.</b>", | |
| reply_markup=[[self._mk_inline_button("🗑 Удалить", data=f"gemini:close:{uid}", style="danger")]] | |
| ) | |
| return | |
| chunks = data["chunks"] | |
| total = data["total"] | |
| header = data.get("header", "") | |
| chat_id = data.get("chat_id") | |
| base_msg_id = data.get("msg_id") | |
| raw_text_chunk = chunks[page_num] | |
| safe_text = self._markdown_to_html(raw_text_chunk) | |
| formatted_body = self._format_response_with_smart_separation(safe_text) | |
| text_to_show = f"{header}\n{formatted_body}" | |
| text_to_show = text_to_show.replace('<emoji document_id=', '<tg-emoji emoji-id=').replace('</emoji>', '</tg-emoji>') | |
| nav_row =[] | |
| if page_num > 0: | |
| nav_row.append(self._mk_inline_button("◀️", data=f"gemini:pg:{uid}:{page_num - 1}", style="primary")) | |
| nav_row.append(self._mk_inline_button(f"{page_num + 1}/{total}", data="gemini:noop", style="primary")) | |
| if page_num < total - 1: | |
| nav_row.append(self._mk_inline_button("▶️", data=f"gemini:pg:{uid}:{page_num + 1}", style="primary")) | |
| extra_row =[self._mk_inline_button("❌ Закрыть", data=f"gemini:close:{uid}", style="danger")] | |
| if chat_id and base_msg_id: | |
| extra_row.append(self._mk_inline_button("🔄", data=f"gemini:regen:{chat_id}:{base_msg_id}", style="primary")) | |
| buttons = [nav_row, extra_row] | |
| if isinstance(entity, Message): | |
| await self.inline.form(text=text_to_show, message=entity, reply_markup=buttons) | |
| elif isinstance(entity, InlineCall): | |
| await self._edit(entity, text=text_to_show, reply_markup=buttons) | |
| elif hasattr(entity, "edit"): | |
| try: await self._edit(entity, text=text_to_show, reply_markup=buttons) | |
| except: pass | |
| def _paginate_text(self, text: str, limit: int) -> list: | |
| pages = [] | |
| current_page_lines = [] | |
| current_len = 0 | |
| in_code_block = False | |
| current_code_lang = "" | |
| lines = text.split('\n') | |
| for line in lines: | |
| line_len = len(line) + 1 | |
| stripped = line.strip() | |
| if stripped.startswith("```"): | |
| if in_code_block: | |
| in_code_block = False | |
| current_code_lang = "" | |
| else: | |
| in_code_block = True | |
| current_code_lang = stripped.replace("```", "").strip() | |
| if current_len + line_len > limit: | |
| if current_page_lines: | |
| if in_code_block: current_page_lines.append("```") | |
| pages.append("\n".join(current_page_lines)) | |
| current_page_lines = [] | |
| current_len = 0 | |
| if in_code_block: | |
| header = f"```{current_code_lang}" | |
| current_page_lines.append(header) | |
| current_len += len(header) + 1 | |
| if line_len > limit: | |
| chunks = [line[i:i+limit] for i in range(0, len(line), limit)] | |
| for chunk in chunks: | |
| if current_len + len(chunk) > limit: | |
| pages.append("\n".join(current_page_lines)) | |
| current_page_lines = [chunk] | |
| current_len = len(chunk) | |
| else: | |
| current_page_lines.append(chunk) | |
| current_len += len(chunk) | |
| continue | |
| current_page_lines.append(line) | |
| current_len += line_len | |
| if current_page_lines: | |
| pages.append("\n".join(current_page_lines)) | |
| return pages | |
| # ─────────────────────────── Veo helpers ──────────────────────────── | |
| def _veo_default_models(self): | |
| return [ | |
| "veo-2.0-generate-001", | |
| "veo-3.0-generate-001", | |
| "veo-3.0-fast-generate-001", | |
| "veo-3.1-generate-preview", | |
| "veo-3.1-fast-generate-preview", | |
| "veo-3.1-lite-generate-preview", | |
| ] | |
| async def _veo_safe_json(self, response): | |
| try: | |
| return await response.json(content_type=None) | |
| except Exception: | |
| raw = await response.text() | |
| return {"raw_text": raw} | |
| def _veo_sort_models(self, models): | |
| order = {name: idx for idx, name in enumerate(self._veo_default_models())} | |
| return sorted(set(models), key=lambda m: (order.get(m, 999), m)) | |
| async def _veo_fetch_models(self, force=False): | |
| now = time.time() | |
| cache = self._veo_model_cache | |
| if not force and cache["models"] and cache["expires"] > now: | |
| return list(cache["models"]) | |
| api_key = str(self.config["api_key"] or "").split(",")[0].strip() | |
| if not api_key: | |
| return self._veo_default_models() | |
| url = f"{VEO_BASE_URL}/models?key={api_key}" | |
| timeout = aiohttp.ClientTimeout(total=45) | |
| try: | |
| async with aiohttp.ClientSession(timeout=timeout) as session: | |
| async with session.get(url) as resp: | |
| data = await self._veo_safe_json(resp) | |
| if resp.status != 200: | |
| raise RuntimeError(str(data)) | |
| models = [] | |
| for item in data.get("models", []) or []: | |
| name = str(item.get("name") or "").split("/")[-1].strip() | |
| methods = item.get("supportedGenerationMethods") or [] | |
| if not name or "veo" not in name.lower(): | |
| continue | |
| if "predictLongRunning" not in methods: | |
| continue | |
| models.append(name) | |
| if not models: | |
| models = self._veo_default_models() | |
| models = self._veo_sort_models(models) | |
| self._veo_model_cache = {"expires": now + VEO_MODEL_CACHE_TTL, "models": list(models)} | |
| return list(models) | |
| except Exception: | |
| return self._veo_default_models() | |
| async def _veo_extract_image(self, message: Message): | |
| reply = await message.get_reply_message() | |
| if not reply: | |
| return None, None | |
| if reply.photo: | |
| image_bytes = await reply.download_media(bytes) | |
| prepared_bytes, prepared_mime, _size = self._prepare_tool_image_bytes( | |
| image_bytes, | |
| max_side=2048, | |
| quality=92, | |
| ) | |
| return prepared_bytes or image_bytes, prepared_mime or "image/jpeg" | |
| doc = getattr(reply, "document", None) | |
| mime = getattr(doc, "mime_type", "") if doc else "" | |
| if doc and mime.startswith("image/"): | |
| image_bytes = await reply.download_media(bytes) | |
| prepared_bytes, prepared_mime, _size = self._prepare_tool_image_bytes( | |
| image_bytes, | |
| max_side=2048, | |
| quality=92, | |
| ) | |
| return prepared_bytes or image_bytes, prepared_mime or "image/jpeg" | |
| return None, None | |
| def _veo_mode_label(self, image_bytes): | |
| return self.strings["gveo_menu_mode_i2v"] if image_bytes else self.strings["gveo_menu_mode_t2v"] | |
| def _veo_short_label(self, model_name): | |
| label = str(model_name or "").strip() | |
| if label.startswith("veo-"): | |
| label = label[4:] | |
| return label if len(label) <= 24 else label[:21] + "..." | |
| def _normalize_veo_seconds_value(self, value, default=8): | |
| allowed = (4, 6, 8) | |
| try: | |
| seconds = int(float(value)) | |
| except (TypeError, ValueError): | |
| seconds = int(default) | |
| if seconds in allowed: | |
| return seconds | |
| return min(allowed, key=lambda item: (abs(item - seconds), item)) | |
| def _veo_normalize(self, session): | |
| model = str(session.get("model") or "").strip() | |
| seconds = self._normalize_veo_seconds_value(session.get("seconds"), default=self.config["veo_seconds"]) | |
| aspect_ratio = self._normalize_aspect_ratio_value(session.get("aspect_ratio"), allow_square=False, default="16:9") | |
| resolution = str(session.get("resolution") or "720p").strip() | |
| warning = "" | |
| if resolution == "1080p": | |
| if model.startswith("veo-2.0"): | |
| resolution = "720p" | |
| warning = self.strings["gveo_resolution_adjusted_veo2"] | |
| elif model.startswith("veo-3.1") and seconds != 8: | |
| resolution = "720p" | |
| warning = self.strings["gveo_resolution_adjusted_31"] | |
| elif model.startswith("veo-3.0") and aspect_ratio != "16:9": | |
| resolution = "720p" | |
| warning = self.strings["gveo_resolution_adjusted_30"] | |
| session["seconds"] = seconds | |
| session["aspect_ratio"] = aspect_ratio | |
| session["resolution"] = resolution | |
| session["warning"] = warning | |
| return session | |
| def _veo_render_menu_text(self, session): | |
| self._veo_normalize(session) | |
| mode_label = self._veo_mode_label(session.get("image_bytes")) | |
| prompt = utils.escape_html(str(session.get("prompt") or "")[:180]) | |
| lines = [ | |
| self.strings["gveo_menu_title"], | |
| self.strings["gveo_menu_mode"].format(mode_label), | |
| self.strings["gveo_menu_prompt"].format(prompt), | |
| self.strings["gveo_menu_model"].format(utils.escape_html(session["model"])), | |
| self.strings["gveo_menu_seconds"].format(session["seconds"]), | |
| self.strings["gveo_menu_aspect"].format(utils.escape_html(session["aspect_ratio"])), | |
| self.strings["gveo_menu_resolution"].format(utils.escape_html(session["resolution"])), | |
| ] | |
| warning = str(session.get("warning") or "").strip() | |
| if warning: | |
| lines.append(self.strings["gveo_menu_warning"].format(utils.escape_html(warning))) | |
| lines.extend(["", self.strings["gveo_menu_hint"]]) | |
| return "\n".join(lines) | |
| def _veo_build_markup(self, session_id): | |
| session = self._veo_sessions[session_id] | |
| markup = [] | |
| models = list(session["available_models"]) | |
| for i in range(0, len(models), 2): | |
| row = [] | |
| for m in models[i: i + 2]: | |
| prefix = "✅ " if m == session["model"] else "" | |
| row.append( | |
| self._mk_inline_button( | |
| prefix + self._veo_short_label(m), | |
| callback=self._veo_cb_model, | |
| args=(session_id, m), | |
| style="success" if m == session["model"] else "primary", | |
| ) | |
| ) | |
| markup.append(row) | |
| dur_row = [] | |
| for v in (4, 6, 8): | |
| prefix = "✅ " if v == session["seconds"] else "" | |
| dur_row.append( | |
| self._mk_inline_button( | |
| f"{prefix}{v}s", | |
| callback=self._veo_cb_seconds, | |
| args=(session_id, v), | |
| style="success" if v == session["seconds"] else "primary", | |
| ) | |
| ) | |
| markup.append(dur_row) | |
| asp_row = [] | |
| for v in ("16:9", "9:16"): | |
| prefix = "✅ " if v == session["aspect_ratio"] else "" | |
| asp_row.append( | |
| self._mk_inline_button( | |
| prefix + v, | |
| callback=self._veo_cb_aspect, | |
| args=(session_id, v), | |
| style="success" if v == session["aspect_ratio"] else "primary", | |
| ) | |
| ) | |
| markup.append(asp_row) | |
| res_row = [] | |
| for v in ("720p", "1080p"): | |
| prefix = "✅ " if v == session["resolution"] else "" | |
| res_row.append( | |
| self._mk_inline_button( | |
| prefix + v, | |
| callback=self._veo_cb_resolution, | |
| args=(session_id, v), | |
| style="success" if v == session["resolution"] else "primary", | |
| ) | |
| ) | |
| markup.append(res_row) | |
| markup.append([ | |
| self._mk_inline_button("Старт", callback=self._veo_cb_start, args=(session_id,), style="success"), | |
| self._mk_inline_button("Закрыть", callback=self._veo_cb_close, args=(session_id,), style="danger"), | |
| ]) | |
| return markup | |
| async def _veo_show_menu(self, entity, session_id): | |
| session = self._veo_sessions.get(session_id) | |
| if not session: | |
| if isinstance(entity, InlineCall): | |
| await entity.answer(self.strings["gveo_expired"], show_alert=True) | |
| return | |
| text = self._veo_render_menu_text(session) | |
| markup = self._veo_build_markup(session_id) | |
| if isinstance(entity, Message): | |
| await self.inline.form(message=entity, text=text, reply_markup=markup, silent=True) | |
| return | |
| await self._edit(entity, text, reply_markup=markup) | |
| async def _veo_open_menu(self, message: Message, forced_seconds=None): | |
| api_keys = [k.strip() for k in str(self.config["api_key"] or "").split(",") if k.strip()] | |
| if not api_keys: | |
| await self._answer(message, self.strings["gveo_no_api_key"]) | |
| return | |
| prompt = utils.get_args_raw(message).strip() | |
| image_bytes, image_mime = await self._veo_extract_image(message) | |
| if not prompt: | |
| reply = await message.get_reply_message() | |
| if reply and getattr(reply, "text", None) and not image_bytes: | |
| prompt = str(reply.text).strip() | |
| if image_bytes and not prompt: | |
| prompt = "Animate this image smoothly" | |
| if not prompt: | |
| await self._answer(message, self.strings["gveo_no_prompt"]) | |
| return | |
| models = [] | |
| try: | |
| models = await self._veo_fetch_models() | |
| except Exception as e: | |
| logger.warning("Veo model fetch error: %s", e) | |
| models = self._veo_default_models() | |
| current_model = self._get_provider_model("google", "video") | |
| if current_model not in models: | |
| current_model = models[0] | |
| session_id = f"gveo_{int(time.time())}_{message.id}" | |
| self._veo_sessions[session_id] = { | |
| "chat_id": utils.get_chat_id(message), | |
| "reply_to": message.id, | |
| "prompt": prompt, | |
| "image_bytes": image_bytes, | |
| "image_mime": image_mime or "image/jpeg", | |
| "model": current_model, | |
| "seconds": self._normalize_veo_seconds_value( | |
| forced_seconds or self.config["veo_seconds"], | |
| default=self.config["veo_seconds"], | |
| ), | |
| "aspect_ratio": str(self.config["veo_aspect_ratio"]), | |
| "resolution": str(self.config["veo_resolution"]), | |
| "warning": "", | |
| "available_models": list(models), | |
| } | |
| self._veo_normalize(self._veo_sessions[session_id]) | |
| await self._veo_show_menu(message, session_id) | |
| def _veo_render_gen_text(self, session, progress=None): | |
| self._veo_normalize(session) | |
| mode_label = self._veo_mode_label(session.get("image_bytes")) | |
| prompt = utils.escape_html(str(session.get("prompt") or "")[:150]) | |
| if progress is None: | |
| return self.strings["gveo_generating"].format( | |
| mode_label, utils.escape_html(session["model"]), | |
| session["seconds"], utils.escape_html(session["aspect_ratio"]), | |
| utils.escape_html(session["resolution"]), prompt, | |
| ) | |
| return self.strings["gveo_progress"].format( | |
| progress, mode_label, utils.escape_html(session["model"]), | |
| session["seconds"], utils.escape_html(session["aspect_ratio"]), | |
| utils.escape_html(session["resolution"]), prompt, | |
| ) | |
| def _veo_map_error(self, data, status_code=None): | |
| if isinstance(data, dict): | |
| err = data.get("error") or {} | |
| message = str(err.get("message") or data.get("message") or data.get("raw_text") or "").strip() | |
| code = err.get("code", status_code) | |
| else: | |
| message = str(data or "").strip() | |
| code = status_code | |
| lower = message.lower() | |
| if code == 429 or "quota" in lower or "resource exhausted" in lower: | |
| return "QUOTA_EXCEEDED" | |
| if "timed out" in lower or "timeout" in lower: | |
| return "TIMEOUT" | |
| return message or f"HTTP {status_code or 'unknown'}" | |
| def _veo_extract_uri(self, payload): | |
| response = (payload or {}).get("response") or {} | |
| gen_resp = response.get("generateVideoResponse") or {} | |
| samples = gen_resp.get("generatedSamples") or [] | |
| if samples: | |
| video = samples[0].get("video") or {} | |
| uri = video.get("uri") or video.get("url") | |
| if uri: | |
| return uri | |
| for key in ("videos", "generatedSamples", "predictions"): | |
| items = response.get(key) or [] | |
| if not items: | |
| continue | |
| first = items[0] or {} | |
| video = first.get("video") or {} | |
| uri = video.get("uri") or video.get("url") or first.get("uri") or first.get("url") | |
| if uri: | |
| return uri | |
| return None | |
| async def _veo_generate(self, prompt, model, seconds, aspect_ratio, resolution, | |
| image_bytes=None, image_mime="image/jpeg", progress_callback=None): | |
| api_key = [k.strip() for k in str(self.config["api_key"] or "").split(",") if k.strip()][0] | |
| timeout_seconds = int(self.config["veo_timeout"]) | |
| started = time.monotonic() | |
| seconds = self._normalize_veo_seconds_value(seconds, default=self.config["veo_seconds"]) | |
| instance = {"prompt": prompt} | |
| if image_bytes: | |
| instance["image"] = { | |
| "bytesBase64Encoded": base64.b64encode(image_bytes).decode("utf-8"), | |
| "mimeType": image_mime or "image/jpeg", | |
| } | |
| payload = { | |
| "instances": [instance], | |
| "parameters": { | |
| "aspectRatio": aspect_ratio, | |
| "resolution": resolution, | |
| "durationSeconds": int(seconds), | |
| }, | |
| } | |
| headers = {"x-goog-api-key": api_key, "Content-Type": "application/json"} | |
| session_timeout = aiohttp.ClientTimeout(total=60) | |
| async with aiohttp.ClientSession(timeout=session_timeout) as session: | |
| async with session.post( | |
| f"{VEO_BASE_URL}/models/{model}:predictLongRunning", | |
| headers=headers, | |
| json=payload, | |
| ) as resp: | |
| data = await self._veo_safe_json(resp) | |
| if resp.status != 200: | |
| raise RuntimeError(self._veo_map_error(data, resp.status)) | |
| operation_name = str(data.get("name") or "").strip() | |
| if not operation_name: | |
| raise RuntimeError("No operation name in API response") | |
| while True: | |
| elapsed = int(time.monotonic() - started) | |
| if elapsed >= timeout_seconds: | |
| raise RuntimeError("TIMEOUT") | |
| async with session.get( | |
| f"{VEO_BASE_URL}/{operation_name}", | |
| headers={"x-goog-api-key": api_key}, | |
| ) as resp: | |
| status = await self._veo_safe_json(resp) | |
| if resp.status != 200: | |
| raise RuntimeError(self._veo_map_error(status, resp.status)) | |
| if status.get("done"): | |
| if status.get("error"): | |
| raise RuntimeError(self._veo_map_error(status)) | |
| video_uri = self._veo_extract_uri(status) | |
| if not video_uri: | |
| raise RuntimeError("No video URI found in API response") | |
| async with session.get( | |
| video_uri, | |
| headers={"x-goog-api-key": api_key}, | |
| allow_redirects=True, | |
| ) as resp: | |
| if resp.status != 200: | |
| data = await self._veo_safe_json(resp) | |
| raise RuntimeError(self._veo_map_error(data, resp.status)) | |
| return await resp.read(), time.monotonic() - started | |
| if progress_callback: | |
| await progress_callback(elapsed, timeout_seconds) | |
| await asyncio.sleep(VEO_POLL_INTERVAL) | |
| # Veo inline callbacks | |
| async def _veo_cb_model(self, call: InlineCall, session_id: str, model_name: str): | |
| session = self._veo_sessions.get(session_id) | |
| if not session: | |
| await call.answer(self.strings["gveo_expired"], show_alert=True) | |
| return | |
| session["model"] = model_name | |
| self._veo_normalize(session) | |
| await self._veo_show_menu(call, session_id) | |
| async def _veo_cb_seconds(self, call: InlineCall, session_id: str, seconds: int): | |
| session = self._veo_sessions.get(session_id) | |
| if not session: | |
| await call.answer(self.strings["gveo_expired"], show_alert=True) | |
| return | |
| session["seconds"] = int(seconds) | |
| self._veo_normalize(session) | |
| await self._veo_show_menu(call, session_id) | |
| async def _veo_cb_aspect(self, call: InlineCall, session_id: str, aspect_ratio: str): | |
| session = self._veo_sessions.get(session_id) | |
| if not session: | |
| await call.answer(self.strings["gveo_expired"], show_alert=True) | |
| return | |
| session["aspect_ratio"] = str(aspect_ratio) | |
| self._veo_normalize(session) | |
| await self._veo_show_menu(call, session_id) | |
| async def _veo_cb_resolution(self, call: InlineCall, session_id: str, resolution: str): | |
| session = self._veo_sessions.get(session_id) | |
| if not session: | |
| await call.answer(self.strings["gveo_expired"], show_alert=True) | |
| return | |
| session["resolution"] = str(resolution) | |
| self._veo_normalize(session) | |
| await self._veo_show_menu(call, session_id) | |
| async def _veo_cb_close(self, call: InlineCall, session_id: str): | |
| self._veo_sessions.pop(session_id, None) | |
| await call.answer() | |
| with contextlib.suppress(Exception): | |
| await call.delete() | |
| return | |
| with contextlib.suppress(Exception): | |
| await self._edit(call, self.strings["gveo_closed"], reply_markup=None) | |
| async def _veo_cb_start(self, call: InlineCall, session_id: str): | |
| session = self._veo_sessions.pop(session_id, None) | |
| if not session: | |
| await call.answer(self.strings["gveo_expired"], show_alert=True) | |
| return | |
| self._veo_normalize(session) | |
| self.config["veo_model"] = session["model"] | |
| self.config["veo_seconds"] = int(session["seconds"]) | |
| self.config["veo_aspect_ratio"] = str(session["aspect_ratio"]) | |
| self.config["veo_resolution"] = str(session["resolution"]) | |
| await self._edit(call, self._veo_render_gen_text(session), reply_markup=None) | |
| async def _progress(elapsed, timeout_seconds): | |
| progress = max(1, min(99, int((elapsed / timeout_seconds) * 100))) | |
| with contextlib.suppress(Exception): | |
| await self._edit(call, self._veo_render_gen_text(session, progress=progress), reply_markup=None) | |
| try: | |
| video_bytes, elapsed = await self._veo_generate( | |
| prompt=session["prompt"], | |
| model=session["model"], | |
| seconds=session["seconds"], | |
| aspect_ratio=session["aspect_ratio"], | |
| resolution=session["resolution"], | |
| image_bytes=session.get("image_bytes"), | |
| image_mime=session.get("image_mime"), | |
| progress_callback=_progress, | |
| ) | |
| except Exception as e: | |
| err = str(e) | |
| logger.exception("Veo generation failed") | |
| if "QUOTA_EXCEEDED" in err: | |
| await self._edit(call, self.strings["gveo_quota"], reply_markup=None) | |
| elif "TIMEOUT" in err: | |
| await self._edit(call, self.strings["gveo_timeout"], reply_markup=None) | |
| else: | |
| await self._edit(call, self.strings["gveo_error"].format(utils.escape_html(err[:300])), reply_markup=None) | |
| return | |
| video_file = io.BytesIO(video_bytes) | |
| video_file.name = "veogen.mp4" | |
| caption = self.strings["gveo_success"].format( | |
| self._veo_mode_label(session.get("image_bytes")), | |
| utils.escape_html(session["model"]), | |
| session["seconds"], | |
| utils.escape_html(session["aspect_ratio"]), | |
| utils.escape_html(session["resolution"]), | |
| elapsed, | |
| utils.escape_html(session["prompt"][:300]), | |
| ) | |
| await self._send_file( | |
| session["chat_id"], | |
| video_file, | |
| caption=caption, | |
| reply_to=session["reply_to"], | |
| supports_streaming=True, | |
| ) | |
| await self._edit(call, | |
| self.strings["gveo_success"].format( | |
| self._veo_mode_label(session.get("image_bytes")), | |
| utils.escape_html(session["model"]), | |
| session["seconds"], | |
| utils.escape_html(session["aspect_ratio"]), | |
| utils.escape_html(session["resolution"]), | |
| elapsed, | |
| utils.escape_html(session["prompt"][:120]), | |
| ), | |
| reply_markup=None, | |
| ) | |
| # ─────────────────────────── Veo commands ─────────────────────────── | |
| @loader.command(ru_doc="<описание> — сгенерировать видео через Veo; reply на фото = image-to-video") | |
| async def aveo(self, message: Message): | |
| """<prompt> — generate video via Google Veo; reply to image for i2v mode""" | |
| await self._veo_open_menu(message) | |
| @loader.watcher(only_incoming=True, ignore_edited=True) | |
| async def watcher(self, message: Message): | |
| 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() | |
| 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 warn: {warnings}") | |
| if not parts: return | |
| resp = await self._send_to_gemini(message=message, parts=parts, impersonation_mode=True) | |
| if resp and resp.strip(): | |
| source_text = str(getattr(message, "raw_text", "") or "").strip() | |
| if message.sticker and not source_text: | |
| alt = next((a.alt for a in getattr(message.sticker, "attributes", []) if isinstance(a, DocumentAttributeSticker)), "?") | |
| source_text = f"[Стикер: {alt}]" | |
| elif message.photo and not source_text: | |
| source_text = "[Фото]" | |
| elif message.file and not source_text: | |
| source_text = "[Файл]" | |
| cln, reaction_emoji, sticker_doc = await self._dispatch_gauto_side_effects(resp.strip(), source_text) | |
| 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)))) | |
| if cln: | |
| await self._reply(message, cln) | |
| if reaction_emoji and random.random() <= float(self.config["gauto_reaction_chance"] or 0): | |
| with contextlib.suppress(Exception): | |
| peer = await self.client.get_input_entity(cid) | |
| await self.client(SendReactionRequest(peer=peer, msg_id=message.id, reaction=[ReactionEmoji(emoticon=reaction_emoji)], add_to_recent=True)) | |
| if sticker_doc: | |
| with contextlib.suppress(Exception): | |
| await self._send_file(cid, sticker_doc, reply_to=message.id) | |
| _premium_emoji_map = { | |
| "⏰": "<tg-emoji emoji-id=5985616167740379273>⏰</tg-emoji>", | |
| "💬": "<tg-emoji emoji-id=5886666250158870040>💬</tg-emoji>", | |
| "✨": "<tg-emoji emoji-id=5348343042212381365>✨</tg-emoji>", | |
| "🎨": "<tg-emoji emoji-id=5814690801665446789>🎨</tg-emoji>", | |
| "🎬": "<tg-emoji emoji-id=5285466619374482920>🎬</tg-emoji>", | |
| "🧠": "<tg-emoji emoji-id=5350445475948414299>🧠</tg-emoji>", | |
| "🗂": "<tg-emoji emoji-id=5875462364110787088>🗂</tg-emoji>", | |
| "🧹": "<tg-emoji emoji-id=5845947563601041174>🧹</tg-emoji>", | |
| "🔄": "<tg-emoji emoji-id=5839200986022812209>🔄</tg-emoji>", | |
| "⚠️": "<tg-emoji emoji-id=5881702736843511327>⚠️</tg-emoji>", | |
| "❗️": "<tg-emoji emoji-id=5879813604068298387>❗️</tg-emoji>", | |
| "✅": "<tg-emoji emoji-id=5776375003280838798>✅</tg-emoji>", | |
| "❌": "<tg-emoji emoji-id=5778527486270770928>❌</tg-emoji>", | |
| "📷": "<tg-emoji emoji-id=5846024087033353251>📷</tg-emoji>", | |
| "🎥": "<tg-emoji emoji-id=5882002216323125435>🎥</tg-emoji>", | |
| "💾": "<tg-emoji emoji-id=5884448719889240368>💾</tg-emoji>", | |
| "🔖": "<tg-emoji emoji-id=5843862283964390528>🔖</tg-emoji>", | |
| "📌": "<tg-emoji emoji-id=5908961403917570106>📌</tg-emoji>", | |
| "📄": "<tg-emoji emoji-id=5839323457015256759>📄</tg-emoji>", | |
| "🗃": "<tg-emoji emoji-id=5877316724830768997>🗃</tg-emoji>", | |
| "🖼": "<tg-emoji emoji-id=5888799736508454231>🖼</tg-emoji>", | |
| "🛜": "<tg-emoji emoji-id=5967432491684860012>🛜</tg-emoji>", | |
| "ℹ️": "<tg-emoji emoji-id=5879785854284599288>ℹ️</tg-emoji>", | |
| "🗑": "<tg-emoji emoji-id=5879896690210639947>🗑</tg-emoji>", | |
| "🚫": "<tg-emoji emoji-id=5872829476143894491>🚫</tg-emoji>", | |
| "🔑": "<tg-emoji emoji-id=6005570495603282482>🔑</tg-emoji>", | |
| "📊": "<tg-emoji emoji-id=5877485980901971030>📊</tg-emoji>", | |
| "📁": "<tg-emoji emoji-id=5875206779196935950>📁</tg-emoji>", | |
| "📂": "<tg-emoji emoji-id=6017174676898321263>📂</tg-emoji>", | |
| "✏️": "<tg-emoji emoji-id=5879841310902324730>✏️</tg-emoji>", | |
| "🔍": "<tg-emoji emoji-id=5276395476646653290>🔍</tg-emoji>", | |
| "🔒": "<tg-emoji emoji-id=5832546462478635761>🔒</tg-emoji>", | |
| "🔓": "<tg-emoji emoji-id=6034962180875490251>🔓</tg-emoji>", | |
| "🔁": "<tg-emoji emoji-id=6005843436479975944>🔁</tg-emoji>", | |
| "➕": "<tg-emoji emoji-id=5877219383691972108>➕</tg-emoji>", | |
| "💻": "<tg-emoji emoji-id=5877318502947229960>💻</tg-emoji>", | |
| "🎛": "<tg-emoji emoji-id=5875431869842985304>🎛</tg-emoji>", | |
| "📎": "<tg-emoji emoji-id=5877495434124988415>📎</tg-emoji>", | |
| "📞": "<tg-emoji emoji-id=5897567714674741148>📞</tg-emoji>", | |
| "🎙": "<tg-emoji emoji-id=5897853622057700958>🎙</tg-emoji>", | |
| "🎶": "<tg-emoji emoji-id=5915480455603295660>🎶</tg-emoji>", | |
| "🎵": "<tg-emoji emoji-id=5891249688933305846>🎵</tg-emoji>", | |
| "🛡": "<tg-emoji emoji-id=5926783847453692661>🛡</tg-emoji>", | |
| "🎭": "<tg-emoji emoji-id=5891093751555694829>🎭</tg-emoji>", | |
| "🙂": "<tg-emoji emoji-id=5942913498349571809>🙂</tg-emoji>", | |
| "🤖": "<tg-emoji emoji-id=5931415565955503486>🤖</tg-emoji>", | |
| "🔨": "<tg-emoji emoji-id=5875450995332353523>🔨</tg-emoji>", | |
| "📝": "<tg-emoji emoji-id=5886330010054168711>📝</tg-emoji>", | |
| "📈": "<tg-emoji emoji-id=5776219138917668486>📈</tg-emoji>", | |
| "🌐": "<tg-emoji emoji-id=5879585266426973039>🌐</tg-emoji>", | |
| "🏷": "<tg-emoji emoji-id=5854776233950188167>🏷</tg-emoji>", | |
| } | |
| def _strip_tool_markup(self, text): | |
| if not isinstance(text, str) or not text: | |
| return text | |
| text = re.sub(r"<tool\b[^>]*>.*?</tool>", "", text, flags=re.DOTALL | re.IGNORECASE) | |
| text = re.sub(r"</?tool\b[^>]*>", "", text, flags=re.DOTALL | re.IGNORECASE) | |
| return text | |
| def _premiumize_text(self, text): | |
| if not isinstance(text, str) or not text: | |
| return text | |
| text = self._strip_tool_markup(text) | |
| text = text.replace("<emoji document_id=", "<tg-emoji emoji-id=").replace("</emoji>", "</tg-emoji>") | |
| re_emoji = getattr(self, "__premium_emoji_re", None) | |
| if re_emoji is None: | |
| escaped = {k: v for k, v in self._premium_emoji_map.items()} | |
| pattern = "|".join(re.escape(k) for k in sorted(escaped, key=len, reverse=True)) | |
| re_emoji = re.compile(pattern) | |
| self.__premium_emoji_re = (re_emoji, escaped) | |
| else: | |
| re_emoji, escaped = re_emoji | |
| return re_emoji.sub(lambda m: escaped[m.group(0)], text) | |
| async def _answer(self, message, text, *args, **kwargs): | |
| return await utils.answer(message, self._premiumize_text(text), *args, **kwargs) | |
| async def _edit(self, entity, text, *args, **kwargs): | |
| return await entity.edit(self._premiumize_text(text), *args, **kwargs) | |
| async def _send_file(self, *args, **kwargs): | |
| caption = kwargs.get("caption") | |
| if isinstance(caption, str): | |
| kwargs["caption"] = self._premiumize_text(caption) | |
| # For BytesIO/large files: ensure the stream is seeked to start | |
| if len(args) >= 2: | |
| file_arg = args[1] | |
| if hasattr(file_arg, "seek") and hasattr(file_arg, "read"): | |
| try: | |
| file_arg.seek(0) | |
| except Exception: | |
| pass | |
| # For file paths: pass force_document=True for large files > 10MB to avoid Telegram limits | |
| if len(args) >= 2 and isinstance(args[1], str) and not kwargs.get("force_document"): | |
| try: | |
| file_size = os.path.getsize(args[1]) | |
| if file_size > 10 * 1024 * 1024: # 10 MB | |
| kwargs.setdefault("force_document", False) # keep as media if possible | |
| except Exception: | |
| pass | |
| return await self.client.send_file(*args, **kwargs) | |
| async def _reply(self, message, text, *args, **kwargs): | |
| return await message.reply(self._premiumize_text(text), *args, **kwargs) | |
| async def _send_message(self, *args, **kwargs): | |
| if len(args) >= 2 and isinstance(args[1], str): | |
| args = list(args) | |
| args[1] = self._premiumize_text(args[1]) | |
| args = tuple(args) | |
| elif isinstance(kwargs.get("message"), str): | |
| kwargs["message"] = self._premiumize_text(kwargs["message"]) | |
| elif isinstance(kwargs.get("text"), str): | |
| kwargs["text"] = self._premiumize_text(kwargs["text"]) | |
| return await self.client.send_message(*args, **kwargs) | |
| def _tool_action_names(self): | |
| return [ | |
| "add_contact", "archive_dialog", "ban_user", "batch_actions", "block_user", | |
| "clear_dialog", "clear_draft", "clone_profile", "clone_profile_text", "copy_message_to_chat", "delete_contact", | |
| "copy_text_to_chat", "count_items", "cycle_profile_photos", | |
| "delete_chat_photo", "delete_dialog", "delete_last_message", "delete_messages", | |
| "delete_profile_photos", "delete_user_messages", "dedupe_items", "demote_user", | |
| "edit_message", "export_chat_invite", "extract_field", "find_and_send_message", "forward_last_messages", | |
| "forward_message", "get_blocked_users", "get_chat_active_users", | |
| "get_archived_dialogs", "get_chat_admins", "get_chat_history", "get_chat_info", | |
| "get_chat_membership", "get_chat_participants", "get_chat_stats", | |
| "get_common_chats_with_user", "get_contacts", "get_contacts_count", "get_current_chat_context", "get_dialog_by_name", "get_dialog_folders", | |
| "get_dialog_messages_count", "get_dialogs", "get_dialogs_count", "get_drafts", "get_full_chat", "get_full_user", | |
| "get_last_incoming_message", "get_last_message", "get_last_outgoing_message", | |
| "get_message_by_id", "get_message_context", "get_message_file_info", | |
| "get_message_link", "get_message_replies", "get_message_sender", | |
| "get_message_stats", "get_message_thread", "get_messages_by_ids", "get_messages_range", "get_moderation_capabilities", | |
| "get_participants", "get_peer_stories", "get_permissions", | |
| "get_pinned_dialogs", "get_pinned_messages", "get_profile_photos", "get_recent_audio", | |
| "get_recent_documents", "get_recent_gifs", "get_recent_links", "get_recent_media", | |
| "get_recent_photos", "get_recent_videos", "get_recent_voice", "get_reply_info", | |
| "generate_image", "generate_video", "get_saved_messages", "get_saved_messages_count", "get_self_profile", "get_self_profile_full", | |
| "get_unread_overview", "get_user_info", "get_user_last_messages", "get_user_media", | |
| "get_user_profile_photos", "get_users_chats", "invite_user_to_chat", | |
| "join_chat", "kick_user", "leave_chat", "mark_chat_read", "mention_user", | |
| "mark_dialog_unread", "merge_objects", "mute_dialog", "mute_user", "pin_dialog", | |
| "mirror_to_hosting", | |
| "pick_random_item", "pin_message", "promote_user", "purge_chat_messages", | |
| "quote_message", | |
| "react_messages", "read_history", "read_peer_stories", "reply_messages", | |
| "reply_to_message", "reply_with_sticker", "report_spam_user", "resolve_target", | |
| "resend_message", "restrict_user_media", "save_message_media", "save_profile_bundle", "save_profile_photo", "save_profile_text", "save_recent_media", | |
| "save_url_file", | |
| "schedule_message", "search_audio", "search_contacts", "search_documents", "search_gifs", | |
| "search_links", "search_messages", "search_messages_from_user", "search_participants", "search_photos", | |
| "search_recent_messages", "search_videos", "search_voice", "send_animation", "send_audio", | |
| "send_bulk_messages", "send_document", "send_file", "send_message", "send_message_last", | |
| "send_photo", "send_profile_photo", "send_reaction_last", "send_sticker", "send_typing", | |
| "send_upload_audio", "send_upload_document", "send_upload_photo", "send_upload_video", | |
| "send_upload_voice", "send_url_file", "send_video", "send_voice_note", "set_chat_about", "set_chat_photo", | |
| "set_chat_title", "set_context", "set_draft", "set_profile_about", "set_profile_bio", | |
| "set_profile_first_name", "set_profile_last_name", "set_profile_name", "set_profile_photo", | |
| "set_profile_username", "set_random_profile_photo", "slice_items", "sleep", "smart_flow", | |
| "sort_items", "build_text", "coalesce_values", "clear_profile_bio", | |
| "unarchive_dialog", "unban_user", "unblock_user", "unmute_dialog", "unmute_user", "unpin_dialog", | |
| "unpin_message", "unrestrict_user_media", | |
| "upload_to_hosting", | |
| "warn_user", | |
| # Extended actions | |
| "create_poll", "create_invite_link", "revoke_invite_link", "get_invite_links", | |
| "pin_chat_message", "unpin_chat_message", "unpin_all_messages", | |
| "set_slow_mode", "toggle_chat_history", "get_bot_commands", "set_bot_commands", | |
| "get_online_count", "get_recent_stickers", "get_sticker_set", "send_poll", | |
| "send_contact", "send_location", "send_dice", | |
| "get_chat_member", "get_nearby_chats", "get_top_peers", | |
| ] | |
| def _os_action_names(self): | |
| return [ | |
| "append_file", "copy_path", "create_directory", "create_zip", "delete_path", | |
| "find_files", "get_input_artifacts", "get_path_tree", "get_session_workspace", | |
| "insert_in_file", "list_directory", "move_path", "read_file", "read_file_lines", | |
| "read_json", "read_multiple_files", "replace_in_file", "replace_many_in_file", | |
| "run_shell", "set_result_file", "stat_path", "touch_file", "write_file", | |
| "write_json", | |
| ] | |
| def _tool_action_chunks(self, per_chunk=12): | |
| names = self._tool_action_names() | |
| return [", ".join(names[i:i + per_chunk]) for i in range(0, len(names), per_chunk)] | |
| def _tool_action_aliases(self): | |
| cached = getattr(self, "__tool_action_aliases", None) | |
| if cached is not None: | |
| return cached | |
| aliases = { | |
| "telegram_tool": "telegram_tool", | |
| "trlegram_tool": "telegram_tool", | |
| "telegarm_tool": "telegram_tool", | |
| "telegramtool": "telegram_tool", | |
| "sendmessage": "send_message", | |
| "sendmessages": "send_bulk_messages", | |
| "editmessage": "edit_message", | |
| "deletemessages": "delete_messages", | |
| "reactmessages": "react_messages", | |
| "readhistory": "read_history", | |
| "replywithsticker": "reply_with_sticker", | |
| "replymessages": "reply_messages", | |
| "replymessage": "reply_messages", | |
| "getdialogs": "get_dialogs", | |
| "getdialogscount": "get_dialogs_count", | |
| "getunreadoverview": "get_unread_overview", | |
| "getparticipants": "get_participants", | |
| "findandsendmessage": "find_and_send_message", | |
| "forwardmessage": "forward_message", | |
| "pinmessage": "pin_message", | |
| "unpinmessage": "unpin_message", | |
| "batch": "batch_actions", | |
| "searchmessages": "search_messages", | |
| "searchparticipants": "search_participants", | |
| "getmessagebyid": "get_message_by_id", | |
| "getmessagesbyids": "get_messages_by_ids", | |
| "getrecentmedia": "get_recent_media", | |
| "getchatadmins": "get_chat_admins", | |
| "getcontacts": "get_contacts", | |
| "getcontactscount": "get_contacts_count", | |
| "replytomessage": "reply_to_message", | |
| "copymessage": "copy_message_to_chat", | |
| "searchlinks": "search_links", | |
| "getchatstats": "get_chat_stats", | |
| "resolvetarget": "resolve_target", | |
| "currentchatcontext": "get_current_chat_context", | |
| "getreplyinfo": "get_reply_info", | |
| "getmessagecontext": "get_message_context", | |
| "getmessagelink": "get_message_link", | |
| "searchaudio": "search_audio", | |
| "forwardlastmessages": "forward_last_messages", | |
| "sendreactionlast": "send_reaction_last", | |
| "sendmessagelast": "send_message_last", | |
| "getuserschats": "get_users_chats", | |
| "getcommonchats": "get_common_chats_with_user", | |
| "getuserinfo": "get_user_info", | |
| "getchatparticipants": "get_chat_participants", | |
| "smartflow": "smart_flow", | |
| "setcontext": "set_context", | |
| "buildtext": "build_text", | |
| "coalesce": "coalesce_values", | |
| "mergeobjects": "merge_objects", | |
| "pickrandomitem": "pick_random_item", | |
| "sliceitems": "slice_items", | |
| "sortitems": "sort_items", | |
| "dedupeitems": "dedupe_items", | |
| "countitems": "count_items", | |
| "extractfield": "extract_field", | |
| "sleepfor": "sleep", | |
| "ban": "ban_user", | |
| "kick": "kick_user", | |
| "unban": "unban_user", | |
| "mute": "mute_user", | |
| "mutedialog": "mute_dialog", | |
| "unmutedialog": "unmute_dialog", | |
| "unmute": "unmute_user", | |
| "promote": "promote_user", | |
| "demote": "demote_user", | |
| "warn": "warn_user", | |
| "block": "block_user", | |
| "unblock": "unblock_user", | |
| "markread": "mark_chat_read", | |
| "join": "join_chat", | |
| "leave": "leave_chat", | |
| "inviteuser": "invite_user_to_chat", | |
| "settitle": "set_chat_title", | |
| "setchatphoto": "set_chat_photo", | |
| "deletechatphoto": "delete_chat_photo", | |
| "setabout": "set_chat_about", | |
| "purgechat": "purge_chat_messages", | |
| "cleardialog": "clear_dialog", | |
| "deletedialog": "delete_dialog", | |
| "archivedialog": "archive_dialog", | |
| "unarchivedialog": "unarchive_dialog", | |
| "pindialog": "pin_dialog", | |
| "unpindialog": "unpin_dialog", | |
| "markdialogunread": "mark_dialog_unread", | |
| "addcontact": "add_contact", | |
| "deletecontact": "delete_contact", | |
| "getselfprofile": "get_self_profile", | |
| "getselfprofilefull": "get_self_profile_full", | |
| "getprofilephotos": "get_profile_photos", | |
| "getuserprofilephotos": "get_user_profile_photos", | |
| "deleteprofilephotos": "delete_profile_photos", | |
| "setprofilename": "set_profile_name", | |
| "setprofilefirstname": "set_profile_first_name", | |
| "setprofilelastname": "set_profile_last_name", | |
| "setprofilebio": "set_profile_bio", | |
| "setprofileabout": "set_profile_about", | |
| "clearprofilebio": "clear_profile_bio", | |
| "setprofilephoto": "set_profile_photo", | |
| "copyprofilephoto": "set_profile_photo", | |
| "setrandomprofilephoto": "set_random_profile_photo", | |
| "cycleprofilephotos": "cycle_profile_photos", | |
| "setprofileusername": "set_profile_username", | |
| "getdrafts": "get_drafts", | |
| "setdraft": "set_draft", | |
| "cleardraft": "clear_draft", | |
| "reportspam": "report_spam_user", | |
| "searchphotos": "search_photos", | |
| "searchvideos": "search_videos", | |
| "searchdocuments": "search_documents", | |
| "searchvoice": "search_voice", | |
| "searchgifs": "search_gifs", | |
| "searchcontacts": "search_contacts", | |
| "searchmessagesfromuser": "search_messages_from_user", | |
| "searchrecentmessages": "search_recent_messages", | |
| "getstories": "get_peer_stories", | |
| "readstories": "read_peer_stories", | |
| "getchathistory": "get_chat_history", | |
| "getlastmessage": "get_last_message", | |
| "getlastoutgoingmessage": "get_last_outgoing_message", | |
| "getlastincomingmessage": "get_last_incoming_message", | |
| "getmessagethread": "get_message_thread", | |
| "getmessagereplies": "get_message_replies", | |
| "getmessagesrange": "get_messages_range", | |
| "getmessagefileinfo": "get_message_file_info", | |
| "getmessagesender": "get_message_sender", | |
| "getmessagestats": "get_message_stats", | |
| "getpinnedmessages": "get_pinned_messages", | |
| "getpinneddialogs": "get_pinned_dialogs", | |
| "getarchiveddialogs": "get_archived_dialogs", | |
| "getdialogbyname": "get_dialog_by_name", | |
| "getdialogfolders": "get_dialog_folders", | |
| "getdialogmessagescount": "get_dialog_messages_count", | |
| "getfullchat": "get_full_chat", | |
| "getfulluser": "get_full_user", | |
| "getchatmembership": "get_chat_membership", | |
| "getmemberrole": "get_member_role", | |
| "getusermedia": "get_user_media", | |
| "getrecentphotos": "get_recent_photos", | |
| "getrecentvideos": "get_recent_videos", | |
| "getrecentdocuments": "get_recent_documents", | |
| "getrecentaudio": "get_recent_audio", | |
| "getrecentvoice": "get_recent_voice", | |
| "getrecentlinks": "get_recent_links", | |
| "getrecentgifs": "get_recent_gifs", | |
| "getsavedmessages": "get_saved_messages", | |
| "getsavedmessagescount": "get_saved_messages_count", | |
| "savemessagemedia": "save_message_media", | |
| "saverecentmedia": "save_recent_media", | |
| "saveprofilephoto": "save_profile_photo", | |
| "sendprofilephoto": "send_profile_photo", | |
| "saveprofiletext": "save_profile_text", | |
| "saveprofilebundle": "save_profile_bundle", | |
| "cloneprofiletext": "clone_profile_text", | |
| "cloneprofile": "clone_profile", | |
| "copyprofile": "clone_profile", | |
| "copyprofiletext": "clone_profile_text", | |
| "uploadtohosting": "upload_to_hosting", | |
| "uploadhosting": "upload_to_hosting", | |
| "rehost": "upload_to_hosting", | |
| "mirrortohosting": "mirror_to_hosting", | |
| "mirrorurl": "mirror_to_hosting", | |
| "mirrormedia": "mirror_to_hosting", | |
| "saveurlfile": "save_url_file", | |
| "downloadurlfile": "save_url_file", | |
| "fetchurlfile": "save_url_file", | |
| "sendurlfile": "send_url_file", | |
| "schedulemessage": "schedule_message", | |
| "sendfile": "send_file", | |
| "sendphoto": "send_photo", | |
| "sendvideo": "send_video", | |
| "sendaudio": "send_audio", | |
| "sendvoicenote": "send_voice_note", | |
| "senddocument": "send_document", | |
| "sendanimation": "send_animation", | |
| "sendsticker": "send_sticker", | |
| "sendtyping": "send_typing", | |
| "senduploadphoto": "send_upload_photo", | |
| "senduploadvideo": "send_upload_video", | |
| "senduploaddocument": "send_upload_document", | |
| "senduploadaudio": "send_upload_audio", | |
| "senduploadvoice": "send_upload_voice", | |
| "copytexttochat": "copy_text_to_chat", | |
| "resendmessage": "resend_message", | |
| "quotemessage": "quote_message", | |
| "exportchatinvite": "export_chat_invite", | |
| "generateimage": "generate_image", | |
| "generatevideo": "generate_video", | |
| "genimage": "generate_image", | |
| "genvideo": "generate_video", | |
| "mediagenerate": "generate_image", | |
| "i2v": "generate_video", | |
| "i2i": "generate_image", | |
| "executeos": "run_shell", | |
| "shell": "run_shell", | |
| "command": "run_shell", | |
| "bash": "run_shell", | |
| "terminal": "run_shell", | |
| "readfile": "read_file", | |
| "readlines": "read_file_lines", | |
| "readmanyfiles": "read_multiple_files", | |
| "writefile": "write_file", | |
| "appendfile": "append_file", | |
| "replaceinfile": "replace_in_file", | |
| "replacemanyinfile": "replace_many_in_file", | |
| "insertinfile": "insert_in_file", | |
| "mkdir": "create_directory", | |
| "makedir": "create_directory", | |
| "makedirectory": "create_directory", | |
| "sessionworkspace": "get_session_workspace", | |
| "getsessionworkspace": "get_session_workspace", | |
| "inputartifacts": "get_input_artifacts", | |
| "getinputartifacts": "get_input_artifacts", | |
| "setresultfile": "set_result_file", | |
| "rmdir": "delete_path", | |
| "deletepath": "delete_path", | |
| "removepath": "delete_path", | |
| "renamepath": "move_path", | |
| "movepath": "move_path", | |
| "copypath": "copy_path", | |
| "zip": "create_zip", | |
| "createzip": "create_zip", | |
| "listdir": "list_directory", | |
| "tree": "get_path_tree", | |
| "stat": "stat_path", | |
| "findfiles": "find_files", | |
| "readjson": "read_json", | |
| "writejson": "write_json", | |
| "touch": "touch_file", | |
| } | |
| self.__tool_action_aliases = aliases | |
| return aliases | |
| def _extract_json_object(self, text): | |
| if not isinstance(text, str): | |
| return None | |
| text = text.strip() | |
| if not text: | |
| return None | |
| if text.startswith("```"): | |
| lines = text.splitlines() | |
| if len(lines) >= 3: | |
| text = "\n".join(lines[1:-1]).strip() | |
| with contextlib.suppress(Exception): | |
| parsed = json.loads(text) | |
| if isinstance(parsed, dict): | |
| return parsed | |
| start = text.find("{") | |
| end = text.rfind("}") | |
| if start == -1 or end == -1 or end <= start: | |
| return None | |
| with contextlib.suppress(Exception): | |
| parsed = json.loads(text[start:end + 1]) | |
| if isinstance(parsed, dict): | |
| return parsed | |
| return None | |
| def _extract_function_tool_call_info(self, raw_text): | |
| payload = self._extract_json_object(raw_text) | |
| if not isinstance(payload, dict): | |
| return None | |
| tool_name = str(payload.get("tool_call") or "").strip().lower() | |
| if tool_name in {"execute_telegram_action", "execute_os_action"} and isinstance(payload.get("arguments"), dict): | |
| return {"tool_call": tool_name, "arguments": payload["arguments"]} | |
| return None | |
| def _extract_function_tool_call(self, raw_text): | |
| info = self._extract_function_tool_call_info(raw_text) | |
| if info: | |
| return info.get("arguments") | |
| return None | |
| def _looks_like_hosting_request(self, text): | |
| if not isinstance(text, str): | |
| return False | |
| lower = text.lower() | |
| upload_terms = ( | |
| "залей", "залить", "загрузи", "загружи", "перелей", "перелить", | |
| "аплоад", "аплоуд", "upload", "rehost", "mirror", "re-upload", | |
| ) | |
| hosting_terms = ( | |
| "хостинг", "файлообмен", "обменник", "hosting", "file host", | |
| "0x0", "0х0", "tmpfiles", "temp.sh", "tempsh", "uguu", "bashupload", | |
| ) | |
| source_terms = ( | |
| "аватар", "аву", "avatar", "pfp", "photo", "фото", "gif", "гиф", | |
| "video", "видео", "file", "файл", "media", "медиа", "reply", "реплай", | |
| ) | |
| return ( | |
| any(term in lower for term in upload_terms) | |
| and any(term in lower for term in (*hosting_terms, *source_terms)) | |
| ) | |
| def _text_mentions_avatar_source(self, text): | |
| lower = str(text or "").lower() | |
| return any( | |
| term in lower | |
| for term in ( | |
| "аватар", "аву", "аватарк", "avatar", "profile photo", | |
| "pfp", "profilepic", "propic", | |
| ) | |
| ) | |
| def _extract_requested_hostings(self, text): | |
| lower = str(text or "").lower() | |
| if any(term in lower for term in ("на все хостинги", "на все хосты", "all hosts", "all hostings", "all hosting")): | |
| return ["all"] | |
| mapping = ( | |
| ("0x0", "0x0"), | |
| ("0х0", "0x0"), | |
| ("tmpfiles", "tmpfiles"), | |
| ("temp.sh", "temp_sh"), | |
| ("tempsh", "temp_sh"), | |
| ("uguu", "uguu"), | |
| ("bashupload", "bashupload"), | |
| ) | |
| hosts = [] | |
| for needle, value in mapping: | |
| if needle in lower and value not in hosts: | |
| hosts.append(value) | |
| return hosts or ["0x0"] | |
| def _build_hosting_tool_summary(self, tool_result_raw): | |
| payload = self._extract_json_object(tool_result_raw) | |
| if not isinstance(payload, dict): | |
| return None | |
| if payload.get("status") != "success": | |
| error = str(payload.get("error") or "unknown error").strip() | |
| return "Не удалось залить на хостинг: {}".format(error) if error else None | |
| details = payload.get("details") or {} | |
| links = details.get("hosts") or [] | |
| lines = [] | |
| if links: | |
| lines.append("Залил на хостинги:") | |
| for item in links: | |
| if not isinstance(item, dict): | |
| continue | |
| url = str(item.get("url") or "").strip() | |
| if not url: | |
| continue | |
| lines.append("• {}: {}".format(item.get("host") or "host", url)) | |
| failures = details.get("failures") or [] | |
| if failures: | |
| if lines: | |
| lines.append("") | |
| lines.append("Не удалось залить на:") | |
| for item in failures: | |
| if not isinstance(item, dict): | |
| continue | |
| host = str(item.get("host") or "host").strip() | |
| error = str(item.get("error") or "unknown error").strip() | |
| lines.append("• {}: {}".format(host, error)) | |
| if not lines: | |
| return None | |
| source_name = str(details.get("source_name") or "").strip() | |
| if source_name: | |
| lines.append("") | |
| lines.append("Источник: {}".format(source_name)) | |
| return "\n".join(lines) | |
| async def _maybe_run_hosting_request_fallback(self, *, chat_id, request_text, result_text, req_message=None, req_reply_id=None): | |
| if not self.config["allow_tg_tools"]: | |
| return None | |
| if self._extract_function_tool_call_info(result_text): | |
| return None | |
| if not self._looks_like_hosting_request(request_text): | |
| return None | |
| has_current_media = bool( | |
| req_message and ( | |
| getattr(req_message, "media", None) | |
| or getattr(req_message, "photo", None) | |
| or getattr(req_message, "document", None) | |
| or getattr(req_message, "sticker", None) | |
| ) | |
| ) | |
| if req_reply_id in (None, "") and not has_current_media and not self._text_mentions_avatar_source(request_text): | |
| return None | |
| tool_args = { | |
| "action": "upload_to_hosting", | |
| "use_reply": True, | |
| "hosts": self._extract_requested_hostings(request_text), | |
| } | |
| if self._text_mentions_avatar_source(request_text): | |
| tool_args["source"] = "profile_photo" | |
| if req_reply_id in (None, ""): | |
| lower = str(request_text or "").lower() | |
| if any(term in lower for term in ("мой аватар", "мою аватар", "my avatar", "my pfp")): | |
| tool_args["target"] = "me" | |
| else: | |
| return None | |
| tool_result = await self._execute_telegram_tool(chat_id, tool_args) | |
| return self._build_hosting_tool_summary(tool_result) | |
| def _compose_regular_system_prompt(self, chat_id=None, message=None): | |
| parts = [] | |
| custom_prompt = (self.config["system_instruction"] or "").strip() | |
| if custom_prompt: | |
| parts.append(custom_prompt) | |
| parts.append( | |
| "Ты работаешь внутри Telegram-модуля для Heroku userbot.\n" | |
| "Самое важное: никогда не путай Heroku и Hikka.\n" | |
| "Heroku это Heroku.\n" | |
| "Hikka это Hikka.\n" | |
| "Если речь о userbot, в первую очередь ориентируйся на Heroku.\n" | |
| "В обычной команде .g ты можешь работать как агентная система: если для решения задачи нужны Telegram-действия, медиа-генерация, shell-команды или операции с файлами, выполняй их пошагово через tool-calls, а не описывай абстрактно." | |
| ) | |
| if self.config["allow_tg_tools"]: | |
| ctx_lines = [ | |
| "СИСТЕМНЫЕ ПРАВИЛА TELEGRAM TOOL:", | |
| 'Для действий в Telegram верни СТРОГО JSON-объект function-calling формата {"tool_call":"execute_telegram_action","arguments":{...}} без дополнительного текста.', | |
| "Используй execute_telegram_action только если пользователь просит СДЕЛАТЬ действие в Telegram.", | |
| "Если пользователь просит обычный ответ, анализ, объяснение или ревью кода, отвечай обычным текстом и не вызывай tool JSON.", | |
| "Если пользователь пишет 'здесь', 'в чате', 'в этой группе' и не дал target_chat, используй текущий chat_id.", | |
| "Если запрос связан с reply на конкретное сообщение, используй message_id replied-сообщения.", | |
| "Для многошаговых сценариев можно использовать batch_actions или smart_flow.", | |
| "batch_actions подходит для нескольких записывающих действий за один вызов.", | |
| "smart_flow может принимать steps: [{action, if, foreach, do, save_as}] и шаблоны {{results.some_step.details.chat_id}}.", | |
| "Для сложной бизнес-логики доступны utility actions: set_context, sleep, merge_objects, extract_field, pick_random_item, slice_items, sort_items, dedupe_items, count_items, build_text, coalesce_values.", | |
| "Для генерации медиа внутри smart_flow и batch_actions используй generate_image и generate_video.", | |
| "generate_video умеет делать и text-to-video, и image-to-video: source_message_id/from_chat, reply, source path или profile_photo target_user.", | |
| "Если нужен аватар владельца текущего чата или канала, generate_video/generate_image поддерживает source=owner_avatar или use_owner_avatar=true.", | |
| "Если generate_image/generate_video вызван на reply и указан source=profile_photo или use_avatar=true, можно брать аватар автора reply автоматически.", | |
| "Если пользователь просит сделать видео или картинку по аватарке человека из reply, сначала можно взять reply sender, а затем вызвать generate_video или generate_image с target_user/reply.", | |
| "Для аватарок используй get_profile_photos: этот action может вернуть сами изображения в контекст, чтобы ты мог их визуально сравнить.", | |
| "Чтобы поставить себе аватарку, используй set_profile_photo. Он умеет брать фото из reply, из message_id, по локальному path или из avatar target_user/target + photo_index/photo_id.", | |
| "Для чужих профилей доступны конкретные actions: send_profile_photo, save_profile_photo, save_profile_text, save_profile_bundle, clone_profile_text и clone_profile.", | |
| "clone_profile копирует чужую аватарку себе и может перенести first_name/last_name/about; clone_profile_text меняет только текстовые части профиля.", | |
| "save_profile_bundle сохраняет профиль целиком: JSON с данными + несколько аватарок, при желании сразу в zip.", | |
| "Для переливов файлов и аватарок на бесплатные хостинги используй upload_to_hosting или mirror_to_hosting.", | |
| "upload_to_hosting умеет брать source из reply/message/path/url и из avatar через source=profile_photo + target_user/photo_index, после чего заливать на 0x0, tmpfiles, temp.sh, uguu или bashupload.", | |
| "Если пользователь просит залить reply-медиа или аватарку на хостинг, не выдумывай ограничения файловой системы и не отправляй ручные инструкции: сразу вызывай upload_to_hosting.", | |
| "Если нужно скачать файл по ссылке в рабочую директорию, используй save_url_file; если нужно скачать ссылку и сразу отправить в чат, используй send_url_file.", | |
| "Для сценариев 'скачай с одного хостинга и залей на другой' используй mirror_to_hosting или upload_to_hosting c url/source_url.", | |
| "Для изменения своего профиля используй set_profile_name, set_profile_bio, set_profile_username, set_profile_photo, clone_profile_text, clone_profile и delete_profile_photos.", | |
| "Получил данные через инструмент -> проанализируй их -> дай конкретный ответ пользователю.", | |
| "Никогда не отвечай, что Telegram-инструмент недоступен.", | |
| "Если действие опасное (ban/delete/purge/block и т.п.), добавляй confirm=true.", | |
| "Доступные actions:", | |
| *self._tool_action_chunks(18), | |
| ] | |
| if chat_id is not None: | |
| ctx_lines.append("Текущий chat_id: {}".format(chat_id)) | |
| if message is not None: | |
| ctx_lines.append("Текущее message_id: {}".format(getattr(message, "id", None))) | |
| reply_id = getattr(getattr(message, "reply_to", None), "reply_to_msg_id", None) | |
| if reply_id: | |
| ctx_lines.append("reply_message_id: {}".format(reply_id)) | |
| parts.append("\n".join(ctx_lines)) | |
| else: | |
| parts.append("Telegram tools выключены настройкой allow_tg_tools=False.") | |
| if self.config["allow_os_tools"]: | |
| parts.append( | |
| "\n".join([ | |
| "СИСТЕМНЫЕ ПРАВИЛА OS TOOL:", | |
| 'Если для ответа нужны действия в ОС или рабочей директории, верни СТРОГО JSON-объект {"tool_call":"execute_os_action","arguments":{...}} без дополнительного текста.', | |
| "execute_os_action используй для shell-команд, чтения и редактирования файлов, работы с директориями и путями.", | |
| "Доступные OS actions: {}.".format(", ".join(self._os_action_names())), | |
| "Для shell-команд используй action=run_shell с полем command.", | |
| "Для чтения конкретных строк используй read_file_lines с path/start/end.", | |
| "Для точечных правок кода используй replace_in_file, replace_many_in_file, insert_in_file или read_file_lines + write_file/append_file.", | |
| "Для кодер-режима сначала полезно вызвать get_session_workspace и get_input_artifacts: в request workspace уже могут лежать присланные пользователем артефакты.", | |
| "Если пользователь прислал файл и просит отредактировать его, работай по локальному пути из input artifacts, сохраняй результат в workspace_path и при необходимости явно отмечай итог через set_result_file.", | |
| "После агентной правки модуль может автоматически отправить обратно изменённые/зарегистрированные артефакты в чат.", | |
| "generate_image/generate_video, save_message_media, save_profile_photo и другие file-producing actions могут работать с тем же workspace, так что Telegram/media/OS инструменты можно связывать в один сценарий.", | |
| "Для удаления или перемещения путей используй delete_path и move_path; для опасных операций добавляй confirm=true.", | |
| "Все OS actions выполняются только внутри рабочей директории/разрешённого корня; относительные пути в рамках запроса резолвятся от session workspace.", | |
| "Если нужен следующий шаг, верни новый execute_os_action JSON.", | |
| ]) | |
| ) | |
| else: | |
| parts.append("OS tools выключены настройкой allow_os_tools=False.") | |
| return self._build_tool_instruction("\n\n".join([p for p in parts if p])) | |
| def _build_tool_instruction(self, sys_instruct): | |
| tool_instruction = ( | |
| "\n\n[Media tools]\n" | |
| "If the user explicitly asks for an image or a video, or if the answer is clearer with generated media, include exactly one XML block.\n" | |
| "Use this format:\n" | |
| "<tool kind=\"image|video\" prompt=\"...\" mode=\"i2i|i2v|t2i|t2v\" seconds=\"8\" aspect_ratio=\"16:9\" resolution=\"720p\">short comment</tool>\n" | |
| "Rules: keep XML valid, use double quotes, keep attributes on one line, and put the normal conversational answer outside the block.\n" | |
| "The module uses the models selected in .amodels: chat/image/video are stored separately per provider; Google image/video generation reads the Google tabs.\n" | |
| "If the current user message includes or replies to an image, mode i2i or i2v may use that image automatically.\n" | |
| "If media should be built from a Telegram avatar, profile photo, or a non-image reply author, prefer execute_telegram_action with generate_image/generate_video plus get_profile_photos or target_user.\n" | |
| "For video use only supported aspect_ratio values: 16:9 or 9:16. Never use 1:1 for video.\n" | |
| "For video use only supported seconds values: 4, 6, or 8. Never use other durations.\n" | |
| "Only request media when it genuinely helps the reply." | |
| ) | |
| base = (sys_instruct or "").strip() | |
| return f"{base}{tool_instruction}" if base else tool_instruction.strip() | |
| def _cached_system_prompt(self, chat_id=None, message=None): | |
| """Cache the system prompt — only rebuild when config changes.""" | |
| cfg_hash = hashlib.sha256(json.dumps({ | |
| "si": self.config.get("system_instruction") or "", | |
| "tg": self.config.get("allow_tg_tools"), | |
| "os": self.config.get("allow_os_tools"), | |
| "media": self.config.get("auto_media_tools"), | |
| "search": self.config.get("google_search"), | |
| }, sort_keys=True).encode()).hexdigest()[:16] | |
| if self._cached_sysprompt is not None and cfg_hash == self._cached_sysprompt_hash: | |
| return self._cached_sysprompt | |
| prompt = self._cached_system_prompt(chat_id=chat_id, message=message) | |
| self._cached_sysprompt = prompt | |
| self._cached_sysprompt_hash = cfg_hash | |
| return prompt | |
| def _invalidate_sysprompt_cache(self): | |
| self._cached_sysprompt = None | |
| self._cached_sysprompt_hash = None | |
| def _extract_inline_image_bytes(self, parts): | |
| for part in parts or []: | |
| blob = getattr(part, "inline_data", None) | |
| if blob and getattr(blob, "mime_type", "").startswith("image/"): | |
| return blob.data, blob.mime_type | |
| return None, None | |
| def _extract_tool_request(self, text): | |
| if not isinstance(text, str) or "<tool" not in text: | |
| return text, None | |
| match = re.search(r"<tool\b([^>]*)>(.*?)</tool>", text, flags=re.DOTALL | re.IGNORECASE) | |
| if not match: | |
| return text, None | |
| attrs_raw = match.group(1) or "" | |
| attrs = {} | |
| for key, val in re.findall(r'([A-Za-z_][\w-]*)\s*=\s*"([^"]*)"', attrs_raw, flags=re.DOTALL): | |
| attrs[key.lower()] = val.strip() | |
| kind = attrs.get("kind", "").lower().strip() | |
| if kind not in {"image", "video"}: | |
| return text, None | |
| comment = re.sub(r"\s+", " ", (match.group(2) or "").strip()) | |
| cleaned = re.sub(r"<tool\b[^>]*>.*?</tool>", "", text, count=1, flags=re.DOTALL | re.IGNORECASE).strip() | |
| return cleaned, {"kind": kind, "attrs": attrs, "comment": comment} | |
| def _build_autogen_media_caption(self, *, chat_id, request_text, body_text, model, kind, pending_turn=False, regeneration=False): | |
| request_text = (request_text or "").strip() | |
| body_text = (body_text or "").strip() | |
| try: | |
| hist_len = len(self._get_structured_history(chat_id)) // 2 | |
| except Exception: | |
| hist_len = 0 | |
| if pending_turn and not regeneration: | |
| hist_len += 1 | |
| max_hist = self.config["max_history_length"] | |
| if hist_len <= 0: | |
| mem_indicator = "" | |
| elif max_hist <= 0: | |
| mem_indicator = self.strings["memory_status_unlimited"].format(hist_len) | |
| else: | |
| mem_indicator = self.strings["memory_status"].format(hist_len, max_hist) | |
| model_info = f"<i>Модель: <code>{utils.escape_html(str(model))}</code></i>" | |
| question_html = f"<blockquote expandable='true'>{utils.escape_html(request_text[:180])}</blockquote>" if request_text else "" | |
| body_html = self._format_response_with_smart_separation(self._markdown_to_html(body_text)) if body_text else "" | |
| caption_parts = [] | |
| if mem_indicator: | |
| caption_parts.append(mem_indicator) | |
| caption_parts.append(model_info) | |
| if question_html: | |
| caption_parts.extend(["", self.strings['question_prefix'], question_html]) | |
| if body_html: | |
| caption_parts.extend(["", self.strings['response_prefix'], body_html]) | |
| caption = "\n".join(caption_parts).strip() | |
| if len(caption) > 980: | |
| short = utils.escape_html((body_text or request_text or 'generated media')[:500]) | |
| caption_parts = [] | |
| if mem_indicator: | |
| caption_parts.append(mem_indicator) | |
| caption_parts.append(model_info) | |
| caption_parts.extend(["", self.strings['response_prefix'], short]) | |
| caption = "\n".join(caption_parts).strip() | |
| return caption | |
| async def _handle_tool_request(self, *, chat_id, reply_to, parts, result_text, call=None, status_msg=None, display_prompt=None, buttons=None, regeneration=False): | |
| cleaned_text, tool = self._extract_tool_request(result_text) | |
| if not tool or not self.config["auto_media_tools"]: | |
| return result_text, False | |
| kind = tool["kind"] | |
| attrs = tool["attrs"] | |
| comment = tool["comment"] | |
| image_bytes, image_mime = self._extract_inline_image_bytes(parts) | |
| if not image_bytes: | |
| image_bytes, image_mime = self._get_recent_tool_image_bytes(chat_id) | |
| requested_mode = str(attrs.get("mode") or "").strip().lower() | |
| target_text = cleaned_text or comment or display_prompt or "" | |
| native_buttons = self._get_native_inline_buttons(chat_id, reply_to) if self.config["interactive_buttons"] else None | |
| async def _show_generation_status(status_text): | |
| if call: | |
| with contextlib.suppress(Exception): | |
| await self._edit(call, status_text, reply_markup=None) | |
| return | |
| if status_msg: | |
| with contextlib.suppress(Exception): | |
| await self._edit(status_msg, status_text) | |
| async def _cleanup_after_media_sent(): | |
| if call and getattr(call, "chat_id", None) and getattr(call, "message_id", None): | |
| with contextlib.suppress(Exception): | |
| await self.client.delete_messages(call.chat_id, call.message_id) | |
| elif status_msg: | |
| with contextlib.suppress(Exception): | |
| await self.client.delete_messages(chat_id, status_msg.id) | |
| try: | |
| if kind == "image": | |
| await _show_generation_status(self.strings["autogen_image_generating"]) | |
| if requested_mode == "i2i" and not image_bytes: | |
| raise ValueError("i2i mode requires a source image in the current message or reply") | |
| prompt = attrs.get("prompt") or comment or display_prompt or target_text or "Describe/Modify this image" | |
| img_bytes, model = await self._generate_image_asset(prompt, input_image_bytes=image_bytes) | |
| out = io.BytesIO(img_bytes) | |
| out.name = f"gemini_{uuid.uuid4().hex[:6]}.jpg" | |
| caption = self._build_autogen_media_caption( | |
| chat_id=chat_id, | |
| request_text=display_prompt or comment or cleaned_text, | |
| body_text=comment or cleaned_text or self.strings["autogen_image_caption"].format(utils.escape_html(model)), | |
| model=model, | |
| kind="image", | |
| pending_turn=True, | |
| regeneration=regeneration, | |
| ) | |
| await self._send_file( | |
| chat_id, | |
| out, | |
| caption=caption, | |
| reply_to=reply_to, | |
| buttons=native_buttons, | |
| ) | |
| await _cleanup_after_media_sent() | |
| return cleaned_text or comment or "[generated image]", True | |
| if kind == "video": | |
| await _show_generation_status(self.strings["autogen_video_generating"]) | |
| if requested_mode == "i2v" and not image_bytes: | |
| raise ValueError("i2v mode requires a source image in the current message or reply") | |
| prompt = attrs.get("prompt") or comment or display_prompt or target_text or "" | |
| if not prompt: | |
| prompt = "Generate a cinematic video" | |
| video_bytes, elapsed, session = await self._generate_video_asset( | |
| prompt=prompt, | |
| model=attrs.get("model"), | |
| seconds=attrs.get("seconds"), | |
| aspect_ratio=attrs.get("aspect_ratio"), | |
| resolution=attrs.get("resolution"), | |
| image_bytes=image_bytes, | |
| image_mime=image_mime, | |
| progress_callback=None, | |
| ) | |
| video_file = io.BytesIO(video_bytes) | |
| video_file.name = "veogen.mp4" | |
| caption = self._build_autogen_media_caption( | |
| chat_id=chat_id, | |
| request_text=display_prompt or comment or cleaned_text, | |
| body_text=comment or cleaned_text or self.strings["autogen_video_caption"].format(utils.escape_html(session["model"])), | |
| model=session["model"], | |
| kind="video", | |
| pending_turn=True, | |
| regeneration=regeneration, | |
| ) | |
| await self._send_file( | |
| chat_id, | |
| video_file, | |
| caption=caption, | |
| reply_to=reply_to, | |
| supports_streaming=True, | |
| buttons=native_buttons, | |
| ) | |
| await _cleanup_after_media_sent() | |
| return cleaned_text or comment or "[generated video]", True | |
| except Exception as e: | |
| logger.exception("Autogenerated media failed") | |
| with contextlib.suppress(Exception): | |
| err = self.strings["autogen_tool_failed"].format(utils.escape_html(str(e)[:300])) | |
| if call: | |
| await self._edit(call, err) | |
| elif status_msg: | |
| await self._answer(status_msg, err) | |
| return result_text, False | |
| return result_text, False | |
| 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 | |
| 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 _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) | |
| import time | |
| now = int(time.time()) | |
| user_id = self.me.id | |
| user_name = get_display_name(self.me) | |
| message_id = getattr(message, "id", None) | |
| if message: | |
| try: | |
| peer_id = get_peer_id(message) | |
| if peer_id: | |
| user_id = peer_id | |
| except (TypeError, ValueError): | |
| 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 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 | |
| }) | |
| break | |
| else: | |
| 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, 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 _markdown_to_html(self, text): | |
| text = self._strip_tool_markup(text) | |
| text = re.sub(r"^(#+)\s+(.*)", lambda m: f"<b>{m.group(2)}</b>", 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'<pre><code class="language-{lang}">{utils.escape_html(m.group(2).strip())}</code></pre>' if lang else f'<pre><code>{utils.escape_html(m.group(2).strip())}</code></pre>' | |
| html = re.sub(r"```(\w+)?\n([\s\S]+?)\n```", fmt_code, html) | |
| html = re.sub(r"<p>(<pre>[\s\S]*?</pre>)</p>", r"\1", html, flags=re.DOTALL) | |
| return html.replace("<p>", "").replace("</p>", "\n").strip() | |
| def _format_response_with_smart_separation(self, text): | |
| parts = re.split(r"(<pre.*?>[\s\S]*?</pre>)", text, flags=re.DOTALL) | |
| out = [] | |
| for i, p in enumerate(parts): | |
| if not p or p.isspace(): continue | |
| if i % 2 == 1: out.append(p.strip()) | |
| else: out.append(f"<blockquote expandable>{p.strip()}</blockquote>") | |
| return "\n".join(out) | |
| def _get_inline_buttons(self, cid, mid): | |
| return [[ | |
| self._mk_inline_button(self.strings["btn_clear"], callback=self._clear_callback, args=(cid,), style="danger"), | |
| self._mk_inline_button(self.strings["btn_regenerate"], callback=self._regenerate_callback, args=(mid, cid), style="primary"), | |
| ]] | |
| def _get_native_inline_buttons(self, cid, mid): | |
| clear_data = f"gemini:clear:{cid}".encode("utf-8") | |
| regen_data = f"gemini:regen:{cid}:{mid}".encode("utf-8") | |
| return [[ | |
| self._mk_native_inline_button(self.strings["btn_clear"], clear_data, style="danger"), | |
| self._mk_native_inline_button(self.strings["btn_regenerate"], regen_data, style="primary"), | |
| ]] | |
| async def _clear_callback(self, call: InlineCall, cid): | |
| self._clear_history(cid, gauto=False) | |
| await self._edit(call, 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: continue | |
| if not (m.text or m.sticker or m.photo or m.file or m.media): | |
| continue | |
| name = get_display_name(await m.get_sender()) or "Unknown" | |
| txt = m.text or "" | |
| if m.sticker: | |
| alt = "?" | |
| if hasattr(m.sticker, 'attributes'): | |
| alt = next((a.alt for a in m.sticker.attributes if isinstance(a, DocumentAttributeSticker)), "?") | |
| txt += f" [Стикер: {alt}]" | |
| elif m.photo: | |
| txt += " [Фото]" | |
| elif m.file: | |
| txt += " [Файл]" | |
| elif m.media and not txt: | |
| txt += " [Медиа]" | |
| if txt.strip(): | |
| lines.append(f"{name}: {txt.strip()}") | |
| except Exception as e: | |
| pass | |
| return "\n".join(reversed(lines)) | |
| def _normalize_emoji_token(self, value: str) -> str: | |
| return str(value or "").replace("\ufe0f", "").strip() | |
| def _extract_gauto_response_directives(self, text: str): | |
| clean = str(text or "") | |
| reaction = None | |
| sticker = None | |
| patterns = [ | |
| (r"\[(?:REACTION|REACT):(.+?)\]", "reaction"), | |
| (r"\[(?:STICKER|STICK):(.+?)\]", "sticker"), | |
| ] | |
| for pattern, kind in patterns: | |
| match = re.search(pattern, clean, flags=re.IGNORECASE) | |
| if not match: | |
| continue | |
| token = self._normalize_emoji_token(match.group(1)) | |
| clean = re.sub(pattern, "", clean, count=1, flags=re.IGNORECASE).strip() | |
| if kind == "reaction": | |
| reaction = token | |
| else: | |
| sticker = token | |
| return clean.strip(), reaction, sticker | |
| async def _get_gauto_sticker_mapping(self): | |
| pack_name = str(self.config["gauto_sticker_pack"] or "").strip() | |
| pack_name = pack_name.split("/")[-1].replace("?","").strip() | |
| if not pack_name: | |
| return {} | |
| if self._gauto_sticker_mapping and self._gauto_sticker_pack == pack_name: | |
| return self._gauto_sticker_mapping | |
| mapping = {} | |
| try: | |
| pack = await self.client( | |
| GetStickerSetRequest( | |
| InputStickerSetShortName(short_name=pack_name), | |
| 0, | |
| ) | |
| ) | |
| doc_map = {doc.id: doc for doc in getattr(pack, "documents", []) or []} | |
| for pack_item in getattr(pack, "packs", []) or []: | |
| for doc_id in getattr(pack_item, "documents", []) or []: | |
| doc = doc_map.get(doc_id) | |
| if doc: | |
| mapping[getattr(pack_item, "emoticon", "")] = doc | |
| except Exception as e: | |
| logger.warning("Gauto sticker pack load failed: %s", e) | |
| self._gauto_sticker_mapping = mapping | |
| self._gauto_sticker_pack = pack_name | |
| return mapping | |
| def _resolve_gauto_sticker_doc(self, mapping: dict, emojis): | |
| if not mapping: | |
| return None | |
| normalized = { | |
| self._normalize_emoji_token(emoji): doc | |
| for emoji, doc in mapping.items() | |
| if self._normalize_emoji_token(emoji) | |
| } | |
| for emoji in emojis or []: | |
| doc = normalized.get(self._normalize_emoji_token(emoji)) | |
| if doc: | |
| return doc | |
| return None | |
| def _pick_gauto_reaction(self, reply_text: str, source_text: str): | |
| clean_reply = str(reply_text or "").lower() | |
| clean_src = str(source_text or "").lower() | |
| combined = f"{clean_reply}\n{clean_src}" | |
| rules = [ | |
| (("аха", "хаха", "лол", "ору", "угар", "смешно", "ржу"), "😂"), | |
| (("спасибо", "супер", "круто", "кайф", "люблю", "мило", "ура"), "🔥"), | |
| (("жаль", "печаль", "груст", "сочув", "извини"), "💔"), | |
| (("ок", "окей", "ага", "пон", "принял", "сделано"), "👌"), | |
| (("ого", "вау", "ничего себе", "жесть", "шок"), "🤯"), | |
| (("не знаю", "хм", "думаю", "возможно", "кажется"), "🤔"), | |
| ] | |
| for keywords, emoji in rules: | |
| if any(keyword in combined for keyword in keywords): | |
| return emoji | |
| if "?" in clean_src: | |
| return "🤔" | |
| return None | |
| def _pick_gauto_sticker_doc(self, reply_text: str, source_text: str, mapping: dict): | |
| if not mapping: | |
| return None | |
| reply_clean = str(reply_text or "").strip() | |
| source_clean = str(source_text or "").strip() | |
| if not reply_clean and not source_clean: | |
| return None | |
| normalized_reply = self._normalize_emoji_token(reply_clean) | |
| if normalized_reply: | |
| for emoji, doc in mapping.items(): | |
| normalized_emoji = self._normalize_emoji_token(emoji) | |
| if normalized_emoji and normalized_emoji in normalized_reply: | |
| return doc | |
| combined = f"{reply_clean}\n{source_clean}".lower() | |
| reply_lower = reply_clean.lower() | |
| rules = [ | |
| (("аха", "хаха", "лол", "кек", "ору", "орнул", "смешно", "ржу", "угар"), ["😂", "🤣", "😁", "😹"]), | |
| (("спасибо", "пасиб", "люблю", "обожаю", "мило", "няш", "класс", "супер", "круто", "ура", "молодец"), ["❤️", "🥰", "😍", "😘", "👍", "🔥"]), | |
| (("жаль", "груст", "печаль", "сочув", "соболез", "прости", "извини", "плак"), ["😢", "😭", "💔", "🙏"]), | |
| (("бесит", "ужас", "кошмар", "фигня", "ненавиж", "злит", "трэш"), ["😡", "🤬", "👎"]), | |
| (("вау", "ого", "офиг", "ничего себе", "жесть", "шок"), ["😮", "😳", "🤯", "🔥"]), | |
| ] | |
| for keywords, emojis in rules: | |
| if any(keyword in combined for keyword in keywords): | |
| doc = self._resolve_gauto_sticker_doc(mapping, emojis) | |
| if doc: | |
| return doc | |
| if "?" in source_clean or any(keyword in reply_lower for keyword in ("хм", "мм", "думаю", "не знаю", "возможно", "кажется", "наверно")): | |
| doc = self._resolve_gauto_sticker_doc(mapping, ["🤔", "🧐", "😐"]) | |
| if doc: | |
| return doc | |
| if len(reply_clean) <= 24 and any(keyword in reply_lower for keyword in ("ок", "окей", "ага", "да", "готово", "сделано", "хорошо", "пон")): | |
| doc = self._resolve_gauto_sticker_doc(mapping, ["👍", "👌", "😎"]) | |
| if doc: | |
| return doc | |
| return None | |
| async def _dispatch_gauto_side_effects(self, reply_text: str, source_text: str): | |
| sticker_doc = None | |
| reaction_emoji = None | |
| reply_text, explicit_reaction, explicit_sticker = self._extract_gauto_response_directives(reply_text) | |
| if self.config["gauto_use_reactions"]: | |
| reaction_emoji = explicit_reaction or self._pick_gauto_reaction(reply_text, source_text) | |
| if self.config["gauto_use_stickers"] and self.config["gauto_sticker_pack"]: | |
| mapping = await self._get_gauto_sticker_mapping() | |
| if explicit_sticker: | |
| sticker_doc = self._resolve_gauto_sticker_doc(mapping, [explicit_sticker]) | |
| if not sticker_doc: | |
| sticker_doc = self._pick_gauto_sticker_doc(reply_text, source_text, mapping) | |
| return reply_text, reaction_emoji, sticker_doc | |
| def _handle_error(self, e: Exception) -> str: | |
| self._sync_api_keys_from_config() | |
| 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 google_exceptions and 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"❗️ <b>Превышен лимит Google Gemini API для модели <code>{utils.escape_html(model_name)}</code>.</b>" | |
| "\n\nЧаще всего это происходит на бесплатном тарифе. Вы можете:\n" | |
| "• Подождать, пока лимит сбросится (обычно раз в сутки).\n" | |
| "• Проверить свой тарифный план в <a href='https://aistudio.google.com/app/billing'>Google AI Studio</a>.\n" | |
| "• Узнать больше о лимитах <a href='https://ai.google.dev/gemini-api/docs/rate-limits'>здесь</a>.\n\n" | |
| f"<b>Детали ошибки:</b>\n<code>{utils.escape_html(msg)}</code>" | |
| ) | |
| if "500 An internal error has occurred" in msg: | |
| return ( | |
| "❗️ <b>Ошибка 500 от Google API.</b>\n" | |
| "Это значит, что формат медиа (файл или еще что то) который ты отправил, не поддерживается.\n" | |
| "Такое случается, по такой причине:\n " | |
| "• Если формат файла в принципе не поддерживается Gemini/Гуглом.\n " | |
| "• Временный сбой на серверах Google. Попробуйте повторить запрос позже." | |
| ) | |
| if "User location is not supported" in msg or "location is not supported" in msg: | |
| return ( | |
| '❗️ <b>В данном регионе Gemini API не доступен.</b>\n' | |
| 'Скачайте VPN (для пк/тел) или поставьте прокси (платный/бесплатный).\n' | |
| 'Или воспользуйтесь инструкцией <a href="https://t.me/SenkoGuardianModules/23">вот тут</a>\n' | |
| 'А для тех у кого UserLand инструкция <a href="https://t.me/SenkoGuardianModules/35">тут</a>' | |
| ) | |
| 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 "❗️ <b>Сетевая ошибка:</b>\n<code>{}</code>".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"] | |
| if "quota" in msg.lower() or "429" in msg: return self.strings["all_keys_exhausted"].format(len(self.api_keys)) | |
| return self.strings["generic_error"].format(utils.escape_html(msg)) | |
| def _markdown_to_html(self, text: str) -> str: | |
| text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL) | |
| text = re.sub(r"<thought>.*?</thought>", "", text, flags=re.DOTALL) | |
| text = re.sub(r"(?i)<br\s*/?>", "\n", text) | |
| def heading_replacer(match): level=len(match.group(1)); title=match.group(2).strip(); indent=" " * (level - 1); return f"{indent}<b>{title}</b>" | |
| text=re.sub(r"^(#+)\s+(.*)", heading_replacer, text, flags=re.MULTILINE) | |
| def list_replacer(match): indent=match.group(1); return f"{indent}• " | |
| text=re.sub(r"^([ \t]*)[-*+]\s+", list_replacer, text, flags=re.MULTILINE) | |
| md=MarkdownIt("commonmark", {"html": True, "linkify": True}); md.enable("strikethrough"); md.disable("hr"); md.disable("heading"); md.disable("list") | |
| html_text=md.render(text) | |
| def format_code(match): | |
| lang=utils.escape_html(match.group(1).strip()); code=utils.escape_html(match.group(2).strip()) | |
| return f'<pre><code class="language-{lang}">{code}</code></pre>' if lang else f'<pre><code>{code}</code></pre>' | |
| html_text=re.sub(r"```(.*?)\n([\s\S]+?)\n```", format_code, html_text) | |
| html_text=re.sub(r"<p>(<pre>[\s\S]*?</pre>)</p>", r"\1", html_text, flags=re.DOTALL) | |
| html_text=html_text.replace("<p>", "").replace("</p>", "\n") | |
| html_text=re.sub(r"(?i)<br\s*/?>", "\n", html_text).strip() | |
| return html_text | |
| def _format_response_with_smart_separation(self, text: str) -> str: | |
| pattern = r"(<pre.*?>[\s\S]*?</pre>)" | |
| parts = re.split(pattern, text, flags=re.DOTALL) | |
| result_parts = [] | |
| for i, part in enumerate(parts): | |
| if not part or part.isspace(): | |
| continue | |
| if i % 2 == 1: | |
| result_parts.append(part.strip()) | |
| else: | |
| stripped_part = part.strip() | |
| if stripped_part: | |
| result_parts.append(f'<blockquote expandable="true">{stripped_part}</blockquote>') | |
| return "\n".join(result_parts) | |
| def _normalize_inline_button_style(self, style): | |
| style = str(style or "").strip().lower() | |
| return style if style in {"primary", "success", "danger"} else None | |
| def _apply_inline_button_style_fallback(self, text, style): | |
| style = self._normalize_inline_button_style(style) | |
| if not style: | |
| return str(text or "") | |
| prefixes = { | |
| "primary": "🟦", | |
| "success": "🟩", | |
| "danger": "🟥", | |
| } | |
| clean = str(text or "") | |
| stripped = clean.lstrip() | |
| if any(stripped.startswith(prefix) for prefix in prefixes.values()): | |
| return clean | |
| return f"{prefixes[style]} {clean}" | |
| def _mk_inline_button(self, text, *, callback=None, args=None, data=None, style=None): | |
| button = {"text": str(text or "")} | |
| if callback is not None: | |
| button["callback"] = callback | |
| if args: | |
| button["args"] = args | |
| elif data is not None: | |
| button["data"] = data | |
| style = self._normalize_inline_button_style(style) | |
| if style: | |
| button["style"] = style | |
| return button | |
| def _telethon_inline_style_supported(self): | |
| cached = getattr(self, "_telethon_inline_style_supported_cache", None) | |
| if cached is not None: | |
| return cached | |
| supported = False | |
| try: | |
| supported = "style" in inspect.signature(Button.inline).parameters | |
| except Exception: | |
| supported = False | |
| self._telethon_inline_style_supported_cache = supported | |
| return supported | |
| def _mk_native_inline_button(self, text, data, *, style=None): | |
| payload = data if isinstance(data, (bytes, bytearray)) else str(data).encode("utf-8") | |
| style = self._normalize_inline_button_style(style) | |
| kwargs = {"data": payload} | |
| if style and self._telethon_inline_style_supported(): | |
| kwargs["style"] = style | |
| try: | |
| return Button.inline(str(text or ""), **kwargs) | |
| except TypeError: | |
| self._telethon_inline_style_supported_cache = False | |
| return Button.inline(self._apply_inline_button_style_fallback(text, style), data=payload) | |
| def _new_amodel_session(self): | |
| provider = str(self.config.get("provider") or "google").strip().lower() | |
| if provider not in self._provider_specs(): | |
| provider = "google" | |
| session_id = uuid.uuid4().hex | |
| self._amodel_sessions[session_id] = { | |
| "provider": provider, | |
| "section": "main", | |
| "provider_page": 0, | |
| "kind": "chat", | |
| "page": 0, | |
| "models": [], | |
| "selected_models": { | |
| "chat": self._get_provider_model(provider, "chat"), | |
| "image": self._get_provider_model(provider, "image"), | |
| "video": self._get_provider_model(provider, "video"), | |
| }, | |
| "show_models": False, | |
| "last_error": "", | |
| "validated": False, | |
| } | |
| return session_id | |
| def _amodel_provider_rows(self, session_id: str): | |
| state = self._amodel_sessions[session_id] | |
| provider = state["provider"] | |
| providers = sorted(self._provider_specs().keys(), key=lambda item: self._provider_label(item).lower()) | |
| per_page = 8 | |
| total = max(1, (len(providers) + per_page - 1) // per_page) | |
| page = max(0, min(int(state.get("provider_page") or 0), total - 1)) | |
| state["provider_page"] = page | |
| chunk = providers[page * per_page:(page + 1) * per_page] | |
| rows = [] | |
| for i in range(0, len(chunk), 2): | |
| line = [] | |
| for item in chunk[i:i + 2]: | |
| prefix = "✅ " if item == provider else "" | |
| line.append(self._mk_inline_button(prefix + self._provider_label(item), callback=self._amodels_cb_provider, args=(session_id, item), style="primary")) | |
| rows.append(line) | |
| rows.append([ | |
| self._mk_inline_button("⏪", callback=self._amodels_cb_provider_page, args=(session_id, 0), style="primary"), | |
| self._mk_inline_button("◀️", callback=self._amodels_cb_provider_page, args=(session_id, page - 1), style="primary"), | |
| self._mk_inline_button(self.strings["amodels_provider_page"].format(page + 1, total), callback=self._amodels_cb_refresh, args=(session_id,), style="primary"), | |
| self._mk_inline_button("▶️", callback=self._amodels_cb_provider_page, args=(session_id, page + 1), style="primary"), | |
| self._mk_inline_button("⏩", callback=self._amodels_cb_provider_page, args=(session_id, total - 1), style="primary"), | |
| ]) | |
| return rows | |
| def _amodel_buttons(self, session_id: str): | |
| state = self._amodel_sessions[session_id] | |
| provider = state["provider"] | |
| section = state.get("section") or "main" | |
| kind = state.get("kind") or "chat" | |
| rows = [[ | |
| self._mk_inline_button(("✅ " if section == "main" else "") + self.strings["amodels_section_main"], callback=self._amodels_cb_section, args=(session_id, "main"), style="primary"), | |
| self._mk_inline_button(("✅ " if section == "models" else "") + self.strings["amodels_section_models"], callback=self._amodels_cb_section, args=(session_id, "models"), style="primary"), | |
| ]] | |
| if section == "main": | |
| rows.extend(self._amodel_provider_rows(session_id)) | |
| rows.append([ | |
| { | |
| "text": "🔑 API-ключ", | |
| "input": self.strings["amodels_token_input"], | |
| "handler": self._amodels_input_token, | |
| "args": (session_id,), | |
| }, | |
| self._mk_inline_button("🗑 Сбросить", callback=self._amodels_cb_clear_token, args=(session_id,), style="primary"), | |
| ]) | |
| rows.append([self._mk_inline_button("✅ Проверить ключ", callback=self._amodels_cb_validate, args=(session_id,), style="primary")]) | |
| else: | |
| rows.append([ | |
| self._mk_inline_button(("✅ " if kind == "chat" else "") + self.strings["amodels_tab_chat"], callback=self._amodels_cb_kind, args=(session_id, "chat"), style="primary"), | |
| self._mk_inline_button(("✅ " if kind == "image" else "") + self.strings["amodels_tab_image"], callback=self._amodels_cb_kind, args=(session_id, "image"), style="primary"), | |
| self._mk_inline_button(("✅ " if kind == "video" else "") + self.strings["amodels_tab_video"], callback=self._amodels_cb_kind, args=(session_id, "video"), style="primary"), | |
| ]) | |
| rows.append([self._mk_inline_button("🔄 Загрузить", callback=self._amodels_cb_models, args=(session_id, True), style="primary")]) | |
| if section == "models" and state["show_models"] and state["models"]: | |
| per_page = 10 | |
| total = max(1, (len(state["models"]) + per_page - 1) // per_page) | |
| page = max(0, min(int(state["page"] or 0), total - 1)) | |
| state["page"] = page | |
| chunk = state["models"][page * per_page:(page + 1) * per_page] | |
| for model in chunk: | |
| text = model[:48] + "…" if len(model) > 48 else model | |
| selected_model = state["selected_models"].get(kind, "") | |
| prefix = "☑️ " if model == selected_model else "" | |
| rows.append([self._mk_inline_button(prefix + text, callback=self._amodels_cb_set_model, args=(session_id, kind, model), style="primary")]) | |
| nav = [] | |
| nav.append(self._mk_inline_button("⏪", callback=self._amodels_cb_page, args=(session_id, 0), style="primary")) | |
| nav.append(self._mk_inline_button("◀️", callback=self._amodels_cb_page, args=(session_id, page - 1), style="primary")) | |
| nav.append(self._mk_inline_button("{}/{}".format(page + 1, total), callback=self._amodels_cb_refresh, args=(session_id,), style="primary")) | |
| nav.append(self._mk_inline_button("▶️", callback=self._amodels_cb_page, args=(session_id, page + 1), style="primary")) | |
| nav.append(self._mk_inline_button("⏩", callback=self._amodels_cb_page, args=(session_id, total - 1), style="primary")) | |
| rows.append(nav) | |
| elif section == "models" and state["show_models"]: | |
| rows.append([self._mk_inline_button(self.strings["amodels_no_models_for_tab"].format(kind), callback=self._amodels_cb_refresh, args=(session_id,), style="primary")]) | |
| rows.append([self._mk_inline_button("❌ Закрыть", callback=self._amodels_cb_close, args=(session_id,), style="primary")]) | |
| return rows | |
| def _amodel_text(self, session_id: str): | |
| state = self._amodel_sessions[session_id] | |
| provider = state["provider"] | |
| section = state.get("section") or "main" | |
| kind = state.get("kind") or "chat" | |
| token = self._resolve_provider_token(provider) | |
| token_text = "{} ({})".format(self._mask_secret(token), self._provider_token_source_label(provider)) | |
| runtime_label = self.strings["amodels_runtime_yes"] if self._provider_runtime_supported(provider) else self.strings["amodels_runtime_no"] | |
| lines = [ | |
| self.strings["amodels_title"], | |
| "", | |
| self.strings["amodels_current"].format( | |
| self._provider_label(provider), | |
| token_text, | |
| runtime_label, | |
| utils.escape_html(str(state["selected_models"].get("chat") or self._provider_default_model(provider, "chat") or "—")), | |
| utils.escape_html(str(state["selected_models"].get("image") or self._provider_default_model(provider, "image") or "—")), | |
| utils.escape_html(str(state["selected_models"].get("video") or self._provider_default_model(provider, "video") or "—")), | |
| ), | |
| ] | |
| if section == "models" and state.get("models"): | |
| per_page = 10 | |
| total = max(1, (len(state["models"]) + per_page - 1) // per_page) | |
| page = int(state.get("page") or 0) | |
| lines += ["", self.strings["amodels_models_caption"].format(self._provider_label(provider), kind, page + 1, total), "Всего: <b>{}</b>".format(len(state["models"]))] | |
| if state.get("last_error"): | |
| lines += ["", "⚠️ <code>{}</code>".format(utils.escape_html(state["last_error"][:500]))] | |
| return "\n".join(lines) | |
| async def _render_amodels(self, entity, session_id: str): | |
| if session_id not in self._amodel_sessions: | |
| return | |
| text = self._amodel_text(session_id) | |
| markup = self._amodel_buttons(session_id) | |
| if isinstance(entity, Message): | |
| await self.inline.form(message=entity, text=text, reply_markup=markup, silent=True) | |
| else: | |
| await self._edit(entity, text, reply_markup=markup) | |
| async def _amodels_cb_provider(self, call: InlineCall, session_id: str, provider: str): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| provider = str(provider or "").strip().lower() | |
| if provider not in self._provider_specs(): | |
| provider = "google" | |
| active_model = self._activate_provider(provider) | |
| state.update({ | |
| "provider": provider, | |
| "page": 0, | |
| "models": [], | |
| "show_models": False, | |
| "validated": False, | |
| "last_error": self.strings["amodels_provider_applied"].format( | |
| utils.escape_html(self._provider_label(provider)), | |
| utils.escape_html(active_model or "—"), | |
| ), | |
| "selected_models": { | |
| "chat": self._get_provider_model(provider, "chat"), | |
| "image": self._get_provider_model(provider, "image"), | |
| "video": self._get_provider_model(provider, "video"), | |
| }, | |
| }) | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_section(self, call: InlineCall, session_id: str, section: str): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| section = str(section or "main").strip().lower() | |
| if section not in {"main", "models"}: | |
| section = "main" | |
| state["section"] = section | |
| state["last_error"] = "" | |
| if section == "models": | |
| kind = state.get("kind") or "chat" | |
| if not self._provider_supports_kind(state["provider"], kind): | |
| state["kind"] = "chat" | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_provider_page(self, call: InlineCall, session_id: str, page: int): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| providers = list(self._provider_specs().keys()) | |
| total = max(1, (len(providers) + 7) // 8) | |
| state["provider_page"] = max(0, min(int(page), total - 1)) | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_kind(self, call: InlineCall, session_id: str, kind: str): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| if not self._provider_supports_kind(state["provider"], kind): | |
| state["last_error"] = self.strings["amodels_provider_kind_unsupported"].format( | |
| utils.escape_html(self._provider_label(state["provider"])), | |
| utils.escape_html(kind), | |
| ) | |
| state["show_models"] = True | |
| state["models"] = [] | |
| await self._render_amodels(call, session_id) | |
| return | |
| state["kind"] = kind | |
| state["page"] = 0 | |
| state["models"] = [] | |
| state["show_models"] = False | |
| state["last_error"] = "" | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_input_token(self, call: InlineCall, query: str, session_id: str): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| token = str(query or "").strip() | |
| if token: | |
| self._store_provider_token(state["provider"], token) | |
| state["last_error"] = "" | |
| state["models"] = [] | |
| state["show_models"] = False | |
| state["validated"] = False | |
| else: | |
| state["last_error"] = "empty token" | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_clear_token(self, call: InlineCall, session_id: str): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| self._store_provider_token(state["provider"], "") | |
| state["models"] = [] | |
| state["show_models"] = False | |
| state["validated"] = False | |
| state["last_error"] = "" | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_validate(self, call: InlineCall, session_id: str): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| try: | |
| if not self._resolve_provider_token(state["provider"]) and state["provider"] != "perplexity": | |
| raise ValueError(self.strings["provider_token_missing"].format(self._provider_label(state["provider"]))) | |
| state["models"] = await self._fetch_provider_models(state["provider"], kind=state.get("kind")) | |
| state["validated"] = True | |
| state["last_error"] = self.strings["amodels_validation_ok"] | |
| kind = state.get("kind") or "chat" | |
| if state["models"] and state["selected_models"].get(kind) not in state["models"]: | |
| state["selected_models"][kind] = state["models"][0] | |
| if state["selected_models"].get(kind): | |
| self._set_provider_model(state["provider"], kind, state["selected_models"][kind]) | |
| except Exception as e: | |
| state["validated"] = False | |
| state["last_error"] = self._handle_error(e) | |
| state["models"] = [] | |
| state["show_models"] = True | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_models(self, call: InlineCall, session_id: str, force: bool = False): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| kind = state.get("kind") or "chat" | |
| if not self._provider_supports_kind(state["provider"], kind): | |
| state["models"] = [] | |
| state["show_models"] = True | |
| state["last_error"] = self.strings["amodels_provider_kind_unsupported"].format( | |
| utils.escape_html(self._provider_label(state["provider"])), | |
| utils.escape_html(kind), | |
| ) | |
| await self._render_amodels(call, session_id) | |
| return | |
| if force or not state["models"]: | |
| try: | |
| state["models"] = await self._fetch_provider_models(state["provider"], kind=state.get("kind")) | |
| state["validated"] = True | |
| state["last_error"] = self.strings["amodels_fetch_ok"] | |
| if state["models"] and state["selected_models"].get(kind) not in state["models"]: | |
| state["selected_models"][kind] = state["models"][0] | |
| if state["selected_models"].get(kind): | |
| self._set_provider_model(state["provider"], kind, state["selected_models"][kind]) | |
| except Exception as e: | |
| state["last_error"] = self._handle_error(e) | |
| state["models"] = [] | |
| state["show_models"] = True | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_page(self, call: InlineCall, session_id: str, page: int): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| total = max(1, (len(state.get("models") or []) + 9) // 10) | |
| state["page"] = max(0, min(int(page), total - 1)) | |
| state["show_models"] = True | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_set_model(self, call: InlineCall, session_id: str, kind: str, model_name: str): | |
| state = self._amodel_sessions.get(session_id) | |
| if not state: | |
| return | |
| provider = state["provider"] | |
| state["selected_models"][kind] = model_name | |
| self._set_provider_model(provider, kind, model_name) | |
| state["last_error"] = self.strings["amodels_apply_ok"].format(self._provider_label(provider), kind, utils.escape_html(model_name)) | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_refresh(self, call: InlineCall, session_id: str): | |
| await self._render_amodels(call, session_id) | |
| async def _amodels_cb_close(self, call: InlineCall, session_id: str): | |
| self._amodel_sessions.pop(session_id, None) | |
| with contextlib.suppress(Exception): | |
| await call.delete() | |
| def _get_inline_buttons(self, chat_id, base_message_id): | |
| return [[self._mk_inline_button(self.strings["btn_clear"], callback=self._clear_callback, args=(chat_id,), style="danger"), | |
| self._mk_inline_button(self.strings["btn_regenerate"], data=f"gemini:regen:{chat_id}:{base_message_id}", style="primary")] | |
| ] | |
| async def _safe_del_msg(self, msg, delay=1): | |
| await asyncio.sleep(delay) | |
| try: await self.client.delete_messages(msg.chat_id, msg.id) | |
| except Exception as e: logger.warning(f"Ошибка удаления сообщения: {e}") | |
| async def _clear_callback(self, call: InlineCall, chat_id: int): | |
| self._clear_history(chat_id, gauto=False) | |
| await self._edit(call, self.strings["memory_cleared"], reply_markup=None) | |
| async def _scan_keys(self, force=False): | |
| """ | |
| Сканирует ключи на валидность. | |
| """ | |
| self._sync_api_keys_from_config() | |
| if not GOOGLE_AVAILABLE: return "Library missing", [] | |
| current_map_keys = list(self.key_model_map.keys()) | |
| for k in current_map_keys: | |
| if k not in self.api_keys: del self.key_model_map[k] | |
| if not force and all(k in self.key_model_map for k in self.api_keys): | |
| return "Loaded from cache", [] | |
| if force: self.key_model_map = {} | |
| proxy_config = self._get_proxy_config() | |
| http_opts = types.HttpOptions(async_client_args={"proxies": proxy_config, "timeout": 10.0}) if proxy_config else None | |
| active_keys = [] | |
| invalid_keys = [] | |
| minimal_config = types.GenerateContentConfig( | |
| response_mime_type="text/plain", | |
| max_output_tokens=1, | |
| candidate_count=1, | |
| safety_settings=[types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE")] | |
| ) | |
| for i, key in enumerate(self.api_keys): | |
| if i > 0: await asyncio.sleep(1.2) | |
| try: | |
| client = genai.Client(api_key=key, http_options=http_opts) | |
| response = await client.aio.models.generate_content( | |
| model=CHECK_MODEL, contents="test", config=minimal_config | |
| ) | |
| active_keys.append(key) | |
| self.key_model_map[key] = 1 | |
| except Exception as e: | |
| err = str(e).lower() | |
| if "invalid_argument" in err or "api_key_invalid" in err or "400" in err or "blocked" in err: | |
| invalid_keys.append(key) | |
| else: | |
| self.key_model_map[key] = 0 | |
| self.db.set(self.strings["name"], DB_KEY_MAP_KEY, self.key_model_map) | |
| short_report = ( | |
| f"✅ <b>Скан завершен.</b>\n" | |
| f"💎 <b>Active:</b> {len(active_keys)}\n" | |
| f"🗑 <b>Invalid:</b> {len(invalid_keys)}\n" | |
| f"👻 <b>RateLimited/Other:</b> {len(self.api_keys) - len(active_keys) - len(invalid_keys)}" | |
| ) | |
| return short_report, invalid_keys | |
| def _get_sorted_keys(self): | |
| self._sync_api_keys_from_config() | |
| valid_keys = [] | |
| for key in self.api_keys: | |
| if key not in self.key_model_map: | |
| if not self.key_model_map: valid_keys.append((key, 0, random.random())) | |
| continue | |
| tier = self.key_model_map[key] | |
| valid_keys.append((key, tier, random.random())) | |
| valid_keys.sort(key=lambda x: (x[1], x[2])) | |
| return [item[0] for item in valid_keys] | |
| async def _call_google_rest(self, model_name: str, prompt: str, input_image_bytes=None): | |
| keys = self._get_sorted_keys() | |
| if not keys: return {"error": {"message": "Нет доступных API ключей"}} | |
| parts = [{"text": prompt}] | |
| if input_image_bytes: | |
| resized = await utils.run_sync(self._resize_image_ig, input_image_bytes) | |
| b64_img = base64.b64encode(resized).decode('utf-8') | |
| parts.insert(0, {"inlineData": {"mimeType": "image/jpeg", "data": b64_img}}) | |
| payload = { | |
| "contents": [{"parts": parts}], | |
| "safetySettings": [ | |
| {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, | |
| {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, | |
| {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, | |
| {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"} | |
| ], | |
| "generationConfig": {"candidateCount": 1, "temperature": 1.0} | |
| } | |
| proxy = self.config['proxy'] if self.config['proxy'] else None | |
| last_error = None | |
| async with aiohttp.ClientSession() as session: | |
| for i, api_key in enumerate(keys): | |
| url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={api_key}" | |
| try: | |
| if i > 0: await asyncio.sleep(1) | |
| async with session.post(url, json=payload, proxy=proxy, timeout=60) as resp: | |
| if resp.status == 200: | |
| return await resp.json() | |
| elif resp.status in [429, 503, 403]: | |
| last_error = f"HTTP {resp.status}" | |
| continue | |
| else: | |
| text = await resp.text() | |
| return {"error": {"message": f"HTTP {resp.status}: {text}"}} | |
| except Exception as e: | |
| last_error = str(e) | |
| continue | |
| return {"error": {"message": f"All keys exhausted. Last error: {last_error}"}} | |
| def _resize_image_ig(self, img_bytes): | |
| try: | |
| img = Image.open(io.BytesIO(img_bytes)) | |
| img.thumbnail((1024, 1024)) | |
| out = io.BytesIO() | |
| if img.mode in ("RGBA", "P"): img = img.convert("RGB") | |
| img.save(out, format='JPEG', quality=85) | |
| return out.getvalue() | |
| except: return img_bytes | |
| async def _send_to_Openrouter_api(self, model, messages, temperature): | |
| """Отправка запроса в OpenRouter (OpenAI format)""" | |
| api_key = self.config["Openrouter_api_key"] | |
| if not api_key: | |
| raise ValueError("Не указан OpenRouter API Key! Установите его в .cfg") | |
| url = "https://openrouter.ai/api/v1/chat/completions" | |
| headers = { | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json", | |
| "HTTP-Referer": "https://github.com/SenkoGuardian", | |
| "X-Title": "Gemini Module for Heroku Telegram-userbot", | |
| } | |
| payload = { | |
| "model": model, | |
| "messages": messages, | |
| "temperature": min(temperature, 1.0) | |
| } | |
| async with aiohttp.ClientSession() as session: | |
| async with session.post(url, headers=headers, json=payload, timeout=GEMINI_TIMEOUT) as resp: | |
| text = await resp.text() | |
| if resp.status != 200: | |
| try: | |
| err_json = json.loads(text) | |
| err_msg = err_json.get('error', {}).get('message', text) | |
| except: | |
| err_msg = text | |
| raise ConnectionError(f"OpenRouter API Error {resp.status}: {err_msg}") | |
| try: | |
| result = json.loads(text) | |
| except json.JSONDecodeError: | |
| raise ValueError(f"OpenRouter вернул не JSON: {text[:100]}...") | |
| if "choices" not in result or not result["choices"]: | |
| if "error" in result: | |
| raise ValueError(f"OpenRouter Logic Error: {result['error']}") | |
| raise ValueError(f"Пустой ответ (нет 'choices'). Raw: {text}") | |
| return result["choices"][0]["message"]["content"] | |
| def _convert_google_history_to_openai(self, history: list, system_prompt: str) -> list: | |
| """Конвертирует историю из формата Google в формат OpenAI.""" | |
| messages = [] | |
| if system_prompt: | |
| messages.append({"role": "system", "content": system_prompt}) | |
| try: | |
| user_tz = pytz.timezone(self.config["timezone"]) | |
| except: | |
| user_tz = pytz.utc | |
| for item in history: | |
| role = "assistant" if item['role'] == "model" else "user" | |
| content = item.get("content", "") | |
| if 'date' in item and item['date']: | |
| dt = datetime.fromtimestamp(item['date'], user_tz) | |
| content = f"[{dt.strftime('%d.%m.%Y %H:%M')}] {content}" | |
| messages.append({"role": role, "content": content}) | |
| return messages | |
| def _normalize_limit(self, value, default=20, maximum=100): | |
| try: | |
| val = int(value) | |
| except Exception: | |
| val = int(default) | |
| if val < 1: | |
| val = int(default) | |
| return min(val, int(maximum)) | |
| def _normalize_tool_path(self, path, *, default_dir="tg_tools"): | |
| raw = str(path or "").strip() | |
| if not raw: | |
| raw = os.path.join(default_dir, "artifact") | |
| raw = os.path.expanduser(raw) | |
| if not os.path.isabs(raw): | |
| raw = os.path.join(os.getcwd(), raw) | |
| parent = raw if raw.endswith(os.sep) else os.path.dirname(raw) | |
| if parent: | |
| os.makedirs(parent, exist_ok=True) | |
| return raw | |
| def _get_os_tools_root(self): | |
| root = str(self.config["os_tools_root"] or "").strip() | |
| if not root: | |
| root = os.getcwd() | |
| root = os.path.abspath(os.path.expanduser(root)) | |
| os.makedirs(root, exist_ok=True) | |
| return root | |
| def _resolve_os_path(self, path=None, *, default_name=None, create_parent=False, chat_id=None): | |
| root = self._get_os_tools_root() | |
| base_dir = self._get_session_workspace(chat_id) or root | |
| raw = str(path or "").strip() | |
| if not raw: | |
| raw = str(default_name or "artifact") | |
| raw = os.path.expanduser(raw) | |
| if os.path.isabs(raw): | |
| candidate = os.path.abspath(raw) | |
| else: | |
| candidate = os.path.abspath(os.path.join(base_dir, raw)) | |
| if os.path.commonpath([root, candidate]) != root: | |
| raise ValueError("path escapes os_tools_root") | |
| if create_parent: | |
| os.makedirs(os.path.dirname(candidate) or root, exist_ok=True) | |
| return candidate | |
| def _serialize_os_path(self, path): | |
| abs_path = os.path.abspath(path) | |
| root = self._get_os_tools_root() | |
| rel_path = os.path.relpath(abs_path, root) | |
| return { | |
| "path": abs_path, | |
| "relative_path": "." if rel_path == "." else rel_path, | |
| "exists": os.path.exists(abs_path), | |
| } | |
| def _session_workspace_dir(self, chat_id, message_id): | |
| safe_chat = str(chat_id).replace("-", "m") | |
| safe_msg = str(message_id or int(time.time())) | |
| return self._resolve_os_path( | |
| os.path.join("_gemini_agent", f"chat_{safe_chat}", f"req_{safe_msg}"), | |
| default_name=".", | |
| ) | |
| def _get_session_workspace(self, chat_id): | |
| session = self._get_request_session(chat_id) | |
| workspace = session.get("workspace") | |
| if isinstance(workspace, str) and workspace: | |
| return workspace | |
| return None | |
| def _sanitize_artifact_name(self, name, default="artifact.bin"): | |
| cleaned = os.path.basename(str(name or "").strip()) or default | |
| cleaned = re.sub(r"[^\w.\-]+", "_", cleaned).strip("._") or default | |
| return cleaned[:180] | |
| def _hash_file(self, path, *, max_bytes=None): | |
| hasher = hashlib.sha256() | |
| total = 0 | |
| with open(path, "rb") as file_obj: | |
| while True: | |
| chunk = file_obj.read(1024 * 1024) | |
| if not chunk: | |
| break | |
| total += len(chunk) | |
| if max_bytes is not None and total > max_bytes: | |
| hasher.update(chunk[: max(0, len(chunk) - (total - max_bytes))]) | |
| break | |
| hasher.update(chunk) | |
| return hasher.hexdigest() | |
| def _snapshot_workspace_files(self, workspace, *, max_files=200): | |
| snapshot = {} | |
| if not workspace or not os.path.isdir(workspace): | |
| return snapshot | |
| count = 0 | |
| for current, dirnames, filenames in os.walk(workspace): | |
| dirnames[:] = [item for item in dirnames if not item.startswith(".")] | |
| for filename in sorted(filenames): | |
| if filename.startswith("."): | |
| continue | |
| full = os.path.abspath(os.path.join(current, filename)) | |
| rel = os.path.relpath(full, workspace) | |
| try: | |
| stat_res = os.stat(full) | |
| snapshot[full] = { | |
| "relative_path": rel, | |
| "size": stat_res.st_size, | |
| "mtime": stat_res.st_mtime, | |
| "sha256": self._hash_file(full, max_bytes=8 * 1024 * 1024), | |
| } | |
| except Exception: | |
| continue | |
| count += 1 | |
| if count >= max_files: | |
| return snapshot | |
| return snapshot | |
| def _register_session_file(self, chat_id, path, *, role="output", label=None, source=None): | |
| if not path: | |
| return | |
| session = self._get_request_session(chat_id) | |
| files = session.get("session_files") | |
| if not isinstance(files, list): | |
| files = [] | |
| session["session_files"] = files | |
| abs_path = os.path.abspath(path) | |
| item = { | |
| "path": abs_path, | |
| "role": role, | |
| "label": label or os.path.basename(abs_path), | |
| "source": source or role, | |
| "exists": os.path.exists(abs_path), | |
| } | |
| files.append(item) | |
| if role == "output": | |
| session["artifact_send_requested"] = True | |
| async def _import_message_artifact_to_workspace(self, chat_id, workspace, message, *, source_label): | |
| if not message: | |
| return None | |
| media = getattr(message, "media", None) | |
| sticker = getattr(message, "sticker", None) | |
| file_obj = getattr(message, "file", None) | |
| if not (media or sticker or getattr(message, "photo", None) or getattr(message, "document", None)): | |
| return None | |
| filename = None | |
| mime = None | |
| if getattr(message, "photo", None): | |
| filename = "photo_{}.jpg".format(getattr(message, "id", "x")) | |
| mime = "image/jpeg" | |
| elif file_obj: | |
| filename = getattr(file_obj, "name", None) | |
| mime = getattr(getattr(message, "document", None), "mime_type", None) or getattr(file_obj, "mime_type", None) | |
| if not filename: | |
| ext = mimetypes.guess_extension(mime or "") or ".bin" | |
| filename = "artifact_{}{}".format(getattr(message, "id", "x"), ext) | |
| filename = self._sanitize_artifact_name(filename) | |
| incoming_dir = os.path.join(workspace, "incoming") | |
| os.makedirs(incoming_dir, exist_ok=True) | |
| target = os.path.join(incoming_dir, filename) | |
| saved = await self.client.download_media(message, file=target) | |
| final_path = os.path.abspath(saved or target) | |
| info = { | |
| "label": source_label, | |
| "message_id": getattr(message, "id", None), | |
| "chat_id": getattr(message, "chat_id", None), | |
| "sender_id": getattr(message, "sender_id", None), | |
| "path": final_path, | |
| "relative_path": os.path.relpath(final_path, workspace), | |
| "name": os.path.basename(final_path), | |
| "mime_type": mime or mimetypes.guess_type(final_path)[0], | |
| "size": os.path.getsize(final_path) if os.path.exists(final_path) else None, | |
| } | |
| self._register_session_file(chat_id, final_path, role="input", label=source_label, source=source_label) | |
| return info | |
| async def _bootstrap_request_session(self, chat_id, message, reply): | |
| session = self._get_request_session(chat_id) | |
| if session.get("bootstrapped"): | |
| return session.get("artifact_context_text") or "" | |
| workspace = self._session_workspace_dir(chat_id, getattr(message, "id", None)) | |
| os.makedirs(workspace, exist_ok=True) | |
| os.makedirs(os.path.join(workspace, "incoming"), exist_ok=True) | |
| os.makedirs(os.path.join(workspace, "generated"), exist_ok=True) | |
| os.makedirs(os.path.join(workspace, "exports"), exist_ok=True) | |
| session["workspace"] = workspace | |
| session["artifact_send_requested"] = False | |
| session["result_artifacts"] = [] | |
| imported = [] | |
| for src_message, label in ((message, "request_media"), (reply, "reply_media")): | |
| with contextlib.suppress(Exception): | |
| artifact = await self._import_message_artifact_to_workspace(chat_id, workspace, src_message, source_label=label) | |
| if artifact: | |
| imported.append(artifact) | |
| session["input_artifacts"] = imported | |
| session["workspace_initial_snapshot"] = self._snapshot_workspace_files(workspace) | |
| lines = [ | |
| "[Agent workspace]", | |
| f"workspace_path={workspace}", | |
| f"os_tools_root={self._get_os_tools_root()}", | |
| "Relative OS paths resolve from workspace_path for this request.", | |
| "If you edit code or create deliverable files, prefer keeping outputs inside workspace_path and call set_result_file for the final artifact when possible.", | |
| ] | |
| if imported: | |
| lines.append("[Input artifacts]") | |
| for item in imported[:8]: | |
| lines.append( | |
| "- {}: path={} mime={} size={}".format( | |
| item.get("label"), | |
| item.get("path"), | |
| item.get("mime_type") or "?", | |
| item.get("size") if item.get("size") is not None else "?", | |
| ) | |
| ) | |
| lines.append("If the user asked to edit the attached artifact, work on these local paths and return the updated file.") | |
| session["artifact_context_text"] = "\n".join(lines) | |
| session["bootstrapped"] = True | |
| return session["artifact_context_text"] | |
| def _collect_changed_session_artifacts(self, chat_id): | |
| session = self._get_request_session(chat_id) | |
| workspace = session.get("workspace") | |
| if not workspace: | |
| return [] | |
| final_snapshot = self._snapshot_workspace_files(workspace) | |
| session["workspace_final_snapshot"] = final_snapshot | |
| initial = session.get("workspace_initial_snapshot") or {} | |
| explicit_paths = [] | |
| for item in session.get("result_artifacts") or []: | |
| if isinstance(item, dict) and item.get("path"): | |
| explicit_paths.append(os.path.abspath(item["path"])) | |
| elif isinstance(item, str) and item: | |
| explicit_paths.append(os.path.abspath(item)) | |
| changed = [] | |
| seen = set() | |
| for path, meta in final_snapshot.items(): | |
| old = initial.get(path) | |
| if not old or old.get("sha256") != meta.get("sha256") or old.get("size") != meta.get("size"): | |
| changed.append({ | |
| "path": path, | |
| "relative_path": meta.get("relative_path"), | |
| "size": meta.get("size"), | |
| "kind": "created" if not old else "modified", | |
| "explicit": path in explicit_paths, | |
| }) | |
| seen.add(path) | |
| for path in explicit_paths: | |
| if path in seen or not os.path.isfile(path): | |
| continue | |
| changed.append({ | |
| "path": path, | |
| "relative_path": os.path.relpath(path, workspace), | |
| "size": os.path.getsize(path), | |
| "kind": "explicit", | |
| "explicit": True, | |
| }) | |
| changed.sort(key=lambda item: (0 if item.get("explicit") else 1, item.get("relative_path") or item.get("path") or "")) | |
| return changed | |
| async def _maybe_return_session_artifacts(self, chat_id, *, reply_to=None): | |
| if not self.config["auto_return_artifacts"]: | |
| return [] | |
| session = self._get_request_session(chat_id) | |
| if session.get("artifacts_already_sent"): | |
| return [] | |
| candidates = self._collect_changed_session_artifacts(chat_id) | |
| if not candidates: | |
| return [] | |
| max_items = int(self.config["max_return_artifacts"] or 4) | |
| outgoing = [] | |
| for item in candidates: | |
| path = item.get("path") | |
| if not path or not os.path.isfile(path): | |
| continue | |
| outgoing.append(item) | |
| if len(outgoing) >= max_items: | |
| break | |
| if not outgoing: | |
| return [] | |
| sent = [] | |
| for index, item in enumerate(outgoing, start=1): | |
| caption = "📎 <b>Артефакт {}</b>\n<code>{}</code>".format(index, utils.escape_html(item.get("relative_path") or os.path.basename(item["path"]))) | |
| msg = await self._send_file(chat_id, item["path"], caption=caption, reply_to=reply_to) | |
| if msg: | |
| sent.append(msg) | |
| session["artifacts_already_sent"] = True | |
| session["returned_artifacts"] = outgoing | |
| return sent | |
| def _normalize_aspect_ratio_value(self, value, *, allow_square=False, default="16:9"): | |
| raw = str(value or "").strip().lower().replace(" ", "") | |
| aliases = { | |
| "landscape": "16:9", | |
| "portrait": "9:16", | |
| "vertical": "9:16", | |
| "horizontal": "16:9", | |
| "wide": "16:9", | |
| "tall": "9:16", | |
| "square": "1:1", | |
| "1x1": "1:1", | |
| "16x9": "16:9", | |
| "9x16": "9:16", | |
| } | |
| raw = aliases.get(raw, raw) | |
| allowed = {"16:9", "9:16"} | |
| if allow_square: | |
| allowed.add("1:1") | |
| return raw if raw in allowed else default | |
| def _tool_get_path_value(self, data, path, default=None): | |
| cur = data | |
| for part in str(path or "").split("."): | |
| if part == "": | |
| continue | |
| if isinstance(cur, dict): | |
| cur = cur.get(part, default) | |
| elif isinstance(cur, list) and part.isdigit(): | |
| idx = int(part) | |
| if 0 <= idx < len(cur): | |
| cur = cur[idx] | |
| else: | |
| return default | |
| else: | |
| return default | |
| return cur | |
| def _coerce_int_list(self, value): | |
| if value in (None, "", []): | |
| return [] | |
| if isinstance(value, (int, str)): | |
| value = [value] | |
| result = [] | |
| if isinstance(value, list): | |
| for item in value: | |
| with contextlib.suppress(Exception): | |
| result.append(int(item)) | |
| return result | |
| def _serialize_dialog(self, dialog): | |
| entity = getattr(dialog, "entity", None) | |
| return { | |
| "id": getattr(entity, "id", None), | |
| "title": getattr(dialog, "title", None) or (get_display_name(entity) if entity else None), | |
| "username": getattr(entity, "username", None), | |
| "unread_count": int(getattr(dialog, "unread_count", 0) or 0), | |
| "unread_mentions_count": int(getattr(dialog, "unread_mentions_count", 0) or 0), | |
| "pinned": bool(getattr(dialog, "pinned", False)), | |
| "archived": int(getattr(dialog, "folder_id", 0) or 0) == 1, | |
| "folder_id": int(getattr(dialog, "folder_id", 0) or 0), | |
| "message": self._serialize_message(getattr(dialog, "message", None)), | |
| } | |
| def _extract_message_file_info(self, message): | |
| if not message: | |
| return None | |
| document = getattr(message, "document", None) | |
| file_obj = getattr(message, "file", None) | |
| attrs = [] | |
| for attr in getattr(document, "attributes", []) or []: | |
| item = {"type": attr.__class__.__name__} | |
| with contextlib.suppress(Exception): | |
| item["string"] = str(attr)[:500] | |
| attrs.append(item) | |
| return { | |
| "id": getattr(document, "id", None) if document else None, | |
| "name": getattr(file_obj, "name", None), | |
| "ext": getattr(file_obj, "ext", None), | |
| "mime_type": getattr(document, "mime_type", None) or getattr(file_obj, "mime_type", None), | |
| "size": getattr(file_obj, "size", None), | |
| "width": getattr(file_obj, "width", None), | |
| "height": getattr(file_obj, "height", None), | |
| "duration": getattr(file_obj, "duration", None), | |
| "performer": getattr(file_obj, "performer", None), | |
| "title": getattr(file_obj, "title", None), | |
| "emoji": getattr(file_obj, "emoji", None), | |
| "attributes": attrs, | |
| } | |
| def _parse_tool_datetime(self, value, *, seconds_fallback=None): | |
| if value in (None, ""): | |
| if seconds_fallback: | |
| return datetime.now(pytz.utc) + timedelta(seconds=max(1, int(seconds_fallback))) | |
| return None | |
| if isinstance(value, (int, float)): | |
| return datetime.fromtimestamp(float(value), pytz.utc) | |
| text = str(value).strip() | |
| if text.isdigit(): | |
| return datetime.fromtimestamp(int(text), pytz.utc) | |
| if re.fullmatch(r"\d+[smhd]", text.lower()): | |
| amount = int(text[:-1]) | |
| mul = {"s": 1, "m": 60, "h": 3600, "d": 86400}[text[-1].lower()] | |
| return datetime.now(pytz.utc) + timedelta(seconds=amount * mul) | |
| with contextlib.suppress(Exception): | |
| dt = datetime.fromisoformat(text.replace("Z", "+00:00")) | |
| if dt.tzinfo is None: | |
| dt = pytz.utc.localize(dt) | |
| return dt.astimezone(pytz.utc) | |
| for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M"): | |
| with contextlib.suppress(Exception): | |
| return pytz.utc.localize(datetime.strptime(text, fmt)) | |
| if seconds_fallback: | |
| return datetime.now(pytz.utc) + timedelta(seconds=max(1, int(seconds_fallback))) | |
| return None | |
| async def _find_recent_message(self, entity, *, limit=1, scan_limit=200, outgoing=None, incoming=None, from_user=None, with_media=None): | |
| found = [] | |
| async for msg in self.client.iter_messages(entity, limit=max(limit, scan_limit)): | |
| if outgoing is True and not bool(getattr(msg, "out", False)): | |
| continue | |
| if incoming is True and bool(getattr(msg, "out", False)): | |
| continue | |
| if with_media is True and not bool(getattr(msg, "media", None)): | |
| continue | |
| if with_media is False and bool(getattr(msg, "media", None)): | |
| continue | |
| if from_user is not None and getattr(msg, "sender_id", None) != getattr(from_user, "id", from_user): | |
| continue | |
| found.append(msg) | |
| if len(found) >= limit: | |
| break | |
| return found | |
| async def _save_profile_photo_file(self, entity, path, *, download_big=True): | |
| target = self._normalize_tool_path(path) | |
| saved = await self.client.download_profile_photo(entity, file=target, download_big=download_big) | |
| return saved or target | |
| async def _download_profile_photo_bytes(self, entity, photo=None, *, photo_index=1, download_big=True): | |
| if photo is not None: | |
| with contextlib.suppress(Exception): | |
| data = await self.client.download_media(photo, bytes) | |
| if data: | |
| return data | |
| with tempfile.TemporaryDirectory(prefix="gemini_avatar_") as temp_dir: | |
| temp_path = os.path.join(temp_dir, "avatar") | |
| with contextlib.suppress(Exception): | |
| saved = await self.client.download_media(photo, file=temp_path) | |
| if saved and os.path.isfile(saved): | |
| with open(saved, "rb") as file_obj: | |
| data = file_obj.read() | |
| if data: | |
| return data | |
| if int(photo_index or 1) == 1: | |
| with tempfile.TemporaryDirectory(prefix="gemini_profile_") as temp_dir: | |
| with contextlib.suppress(Exception): | |
| saved = await self.client.download_profile_photo(entity, file=temp_dir, download_big=download_big) | |
| candidate = saved if isinstance(saved, str) and os.path.isfile(saved) else None | |
| if candidate is None: | |
| for name in os.listdir(temp_dir): | |
| path = os.path.join(temp_dir, name) | |
| if os.path.isfile(path): | |
| candidate = path | |
| break | |
| if candidate and os.path.isfile(candidate): | |
| with open(candidate, "rb") as file_obj: | |
| data = file_obj.read() | |
| if data: | |
| return data | |
| return None | |
| async def _pick_profile_photo(self, entity, *, photo_id=None, photo_index=1, limit=None): | |
| photo_index = max(1, int(photo_index or 1)) | |
| fetch_limit = max( | |
| photo_index, | |
| int(limit or 0), | |
| 20 if photo_id not in (None, "") else photo_index, | |
| ) | |
| photos = await self.client.get_profile_photos(entity, limit=fetch_limit) | |
| chosen = None | |
| resolved_index = photo_index | |
| if photo_id not in (None, ""): | |
| photo_id = int(photo_id) | |
| for idx, photo in enumerate(photos, start=1): | |
| if getattr(photo, "id", None) == photo_id: | |
| chosen = photo | |
| resolved_index = idx | |
| break | |
| elif photos and photo_index <= len(photos): | |
| chosen = photos[photo_index - 1] | |
| return chosen, resolved_index, list(photos) | |
| async def _get_full_user_profile_payload(self, user): | |
| info = self._serialize_user_brief(user) | |
| with contextlib.suppress(Exception): | |
| full = await self.client(GetFullUserRequest(user)) | |
| full_user = getattr(full, "full_user", None) | |
| info["about"] = getattr(full_user, "about", None) | |
| info["common_chats_count"] = getattr(full_user, "common_chats_count", None) | |
| info["blocked"] = bool(getattr(full_user, "blocked", False)) | |
| info.setdefault("about", None) | |
| info.setdefault("common_chats_count", None) | |
| info.setdefault("blocked", False) | |
| info["phone"] = getattr(user, "phone", None) | |
| return info | |
| def _serialize_user_brief(self, user): | |
| info = self._serialize_entity_brief(user) or {} | |
| info.update({ | |
| "first_name": getattr(user, "first_name", None), | |
| "last_name": getattr(user, "last_name", None), | |
| "bot": bool(getattr(user, "bot", False)), | |
| "premium": bool(getattr(user, "premium", False)), | |
| }) | |
| return info | |
| def _get_participant_role(self, participant): | |
| if participant is None: | |
| return "member" | |
| if isinstance(participant, (tg_types.ChatParticipantCreator, tg_types.ChannelParticipantCreator)): | |
| return "creator" | |
| if isinstance(participant, (tg_types.ChatParticipantAdmin, tg_types.ChannelParticipantAdmin)): | |
| return "admin" | |
| return "member" | |
| async def _build_chat_info_payload(self, entity, *, include_admins=False, admins_limit=20): | |
| info = self._serialize_entity_brief(entity) | |
| info["participants_count"] = None | |
| info["about"] = None | |
| info["owner"] = None | |
| info["owner_user_id"] = None | |
| if isinstance(entity, User): | |
| info["participants_count"] = 2 | |
| info["owner"] = self._serialize_user_brief(entity) | |
| info["owner_user_id"] = getattr(entity, "id", None) | |
| return info | |
| if isinstance(entity, Channel): | |
| full = None | |
| with contextlib.suppress(Exception): | |
| full = await self.client(GetFullChannelRequest(entity)) | |
| full_chat = getattr(full, "full_chat", None) if full else None | |
| info.update({ | |
| "about": getattr(full_chat, "about", None), | |
| "participants_count": ( | |
| getattr(full_chat, "participants_count", None) | |
| or getattr(entity, "participants_count", None) | |
| ), | |
| "admins_count": getattr(full_chat, "admins_count", None), | |
| "kicked_count": getattr(full_chat, "kicked_count", None), | |
| "banned_count": getattr(full_chat, "banned_count", None), | |
| "online_count": getattr(full_chat, "online_count", None), | |
| "megagroup": bool(getattr(entity, "megagroup", False)), | |
| "broadcast": bool(getattr(entity, "broadcast", False)), | |
| "forum": bool(getattr(entity, "forum", False)), | |
| "verified": bool(getattr(entity, "verified", False)), | |
| }) | |
| admins = [] | |
| with contextlib.suppress(Exception): | |
| async for admin in self.client.iter_participants( | |
| entity, | |
| filter=tg_types.ChannelParticipantsAdmins(), | |
| limit=max(1, admins_limit), | |
| ): | |
| participant = getattr(admin, "participant", None) | |
| role = self._get_participant_role(participant) | |
| item = self._serialize_user_brief(admin) | |
| item["role"] = role | |
| item["rank"] = getattr(participant, "rank", None) | |
| admins.append(item) | |
| if role == "creator" and not info.get("owner"): | |
| info["owner"] = item | |
| info["owner_user_id"] = item.get("id") | |
| if include_admins: | |
| info["admins"] = admins[:admins_limit] | |
| return info | |
| if isinstance(entity, Chat): | |
| full = None | |
| with contextlib.suppress(Exception): | |
| full = await self.client(GetFullChatRequest(entity.id)) | |
| full_chat = getattr(full, "full_chat", None) if full else None | |
| participants_obj = getattr(full_chat, "participants", None) | |
| participant_items = list(getattr(participants_obj, "participants", None) or []) | |
| users_by_id = {getattr(user, "id", None): user for user in getattr(full, "users", None) or []} | |
| info["about"] = getattr(full_chat, "about", None) | |
| info["participants_count"] = ( | |
| getattr(full_chat, "participants_count", None) | |
| or len(participant_items) | |
| or len(getattr(full, "users", None) or []) | |
| ) | |
| admins = [] | |
| for participant in participant_items: | |
| user = users_by_id.get(getattr(participant, "user_id", None)) | |
| if not user: | |
| continue | |
| role = self._get_participant_role(participant) | |
| if role == "member": | |
| continue | |
| item = self._serialize_user_brief(user) | |
| item["role"] = role | |
| admins.append(item) | |
| if role == "creator" and not info.get("owner"): | |
| info["owner"] = item | |
| info["owner_user_id"] = item.get("id") | |
| if include_admins: | |
| info["admins"] = admins[:admins_limit] | |
| return info | |
| return info | |
| def _tool_bool(self, value): | |
| if isinstance(value, bool): | |
| return value | |
| return str(value or "").strip().lower() in {"1", "true", "yes", "ok", "confirm"} | |
| def _tool_ok(self, details): | |
| return json.dumps({"status": "success", "details": details}, ensure_ascii=False) | |
| def _tool_err(self, error, details=None): | |
| payload = {"status": "error", "error": str(error)} | |
| if details is not None: | |
| payload["details"] = details | |
| return json.dumps(payload, ensure_ascii=False) | |
| def _get_request_session(self, chat_id): | |
| session = self._request_sessions.get(chat_id) | |
| if not isinstance(session, dict): | |
| session = {} | |
| self._request_sessions[chat_id] = session | |
| return session | |
| def _append_tool_visual_context(self, chat_id, media_items): | |
| if not media_items: | |
| return | |
| session = self._get_request_session(chat_id) | |
| visuals = session.get("tool_visual_context") | |
| if not isinstance(visuals, list): | |
| visuals = [] | |
| session["tool_visual_context"] = visuals | |
| for item in media_items: | |
| if not isinstance(item, dict) or not item.get("data"): | |
| continue | |
| visuals.append({ | |
| "mime_type": str(item.get("mime_type") or "image/jpeg"), | |
| "data": item.get("data"), | |
| "label": str(item.get("label") or "tool image"), | |
| }) | |
| if len(visuals) > 12: | |
| del visuals[12:] | |
| def _consume_tool_visual_context(self, chat_id): | |
| session = self._get_request_session(chat_id) | |
| visuals = session.pop("tool_visual_context", []) | |
| if isinstance(visuals, list) and visuals: | |
| session["last_tool_visual_context"] = [ | |
| { | |
| "mime_type": str(item.get("mime_type") or "image/jpeg"), | |
| "data": item.get("data"), | |
| "label": str(item.get("label") or "tool image"), | |
| } | |
| for item in visuals | |
| if isinstance(item, dict) and item.get("data") | |
| ][:12] | |
| return visuals if isinstance(visuals, list) else [] | |
| def _peek_recent_tool_visual_context(self, chat_id): | |
| session = self._get_request_session(chat_id) | |
| visuals = session.get("last_tool_visual_context") or session.get("tool_visual_context") or [] | |
| return visuals if isinstance(visuals, list) else [] | |
| def _get_recent_tool_image_bytes(self, chat_id): | |
| for item in self._peek_recent_tool_visual_context(chat_id): | |
| if not isinstance(item, dict): | |
| continue | |
| mime = str(item.get("mime_type") or "image/jpeg") | |
| data = item.get("data") | |
| if data and mime.startswith("image/"): | |
| return data, mime | |
| return None, None | |
| def _prepare_tool_image_bytes(self, image_bytes, max_side=1280, quality=88): | |
| if not image_bytes: | |
| return None, None, None | |
| try: | |
| src = io.BytesIO(image_bytes) | |
| with Image.open(src) as img: | |
| with contextlib.suppress(Exception): | |
| if getattr(img, "is_animated", False): | |
| img.seek(0) | |
| img = ImageOps.exif_transpose(img) | |
| img.load() | |
| if img.mode in {"RGBA", "LA"} or ( | |
| img.mode == "P" and "transparency" in getattr(img, "info", {}) | |
| ): | |
| rgba = img.convert("RGBA") | |
| background = Image.new("RGB", rgba.size, (255, 255, 255)) | |
| background.paste(rgba, mask=rgba.getchannel("A")) | |
| img = background | |
| elif img.mode != "RGB": | |
| img = img.convert("RGB") | |
| if max_side: | |
| resampling = getattr(Image, "Resampling", Image) | |
| img.thumbnail((max_side, max_side), resampling.LANCZOS) | |
| out = io.BytesIO() | |
| img.save(out, format="JPEG", quality=quality, optimize=True) | |
| return out.getvalue(), "image/jpeg", img.size | |
| except Exception: | |
| return image_bytes, "image/jpeg", None | |
| def _build_workspace_output_path(self, chat_id, requested_path, fallback_name, *, subdir="exports"): | |
| fallback_name = self._sanitize_artifact_name(fallback_name) | |
| if requested_path not in (None, ""): | |
| requested_path = str(requested_path) | |
| candidate = self._resolve_os_path(requested_path, default_name=fallback_name, create_parent=True, chat_id=chat_id) | |
| if requested_path.endswith(os.sep) or not os.path.splitext(candidate)[1]: | |
| os.makedirs(candidate, exist_ok=True) | |
| return os.path.join(candidate, fallback_name) | |
| os.makedirs(os.path.dirname(candidate) or self._get_os_tools_root(), exist_ok=True) | |
| return candidate | |
| workspace = self._get_session_workspace(chat_id) or self._get_os_tools_root() | |
| target_dir = os.path.join(workspace, subdir) | |
| os.makedirs(target_dir, exist_ok=True) | |
| candidate = os.path.join(target_dir, fallback_name) | |
| if not os.path.exists(candidate): | |
| return candidate | |
| stem, ext = os.path.splitext(fallback_name) | |
| return os.path.join(target_dir, "{}_{}{}".format(stem or "file", uuid.uuid4().hex[:8], ext)) | |
| def _extract_filename_from_headers(self, headers, fallback="download.bin"): | |
| header = str((headers or {}).get("Content-Disposition") or "").strip() | |
| if not header: | |
| return self._sanitize_artifact_name(fallback) | |
| match = re.search(r"filename\\*=UTF-8''([^;]+)", header, re.IGNORECASE) | |
| if match: | |
| return self._sanitize_artifact_name(unquote(match.group(1))) | |
| match = re.search(r'filename="?([^";]+)"?', header, re.IGNORECASE) | |
| if match: | |
| return self._sanitize_artifact_name(match.group(1)) | |
| return self._sanitize_artifact_name(fallback) | |
| def _guess_filename_from_url(self, url, fallback="download.bin"): | |
| with contextlib.suppress(Exception): | |
| path = unquote(urlparse(str(url)).path) | |
| base = os.path.basename(path) | |
| if base and "." in base: | |
| return self._sanitize_artifact_name(base) | |
| return self._sanitize_artifact_name(fallback) | |
| def _guess_media_filename_from_message(self, message, default_prefix="message_media"): | |
| file_obj = getattr(message, "file", None) | |
| document = getattr(message, "document", None) | |
| if getattr(message, "photo", None): | |
| return "photo_{}.jpg".format(getattr(message, "id", "x")) | |
| if file_obj and getattr(file_obj, "name", None): | |
| return self._sanitize_artifact_name(file_obj.name) | |
| mime = getattr(document, "mime_type", None) or getattr(file_obj, "mime_type", None) or "" | |
| ext = mimetypes.guess_extension(mime) or ".bin" | |
| return self._sanitize_artifact_name("{}_{}{}".format(default_prefix, getattr(message, "id", "x"), ext)) | |
| def _guess_mime_from_filename(self, filename, fallback="application/octet-stream"): | |
| mime = mimetypes.guess_type(str(filename or ""))[0] | |
| return mime or fallback | |
| async def _download_url_to_file(self, url, *, chat_id, requested_path=None, fallback_name=None, max_bytes=None): | |
| target_limit = max(1, min(int(max_bytes or MAX_REMOTE_FETCH_SIZE), MAX_REMOTE_FETCH_SIZE)) | |
| timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=300) | |
| async with aiohttp.ClientSession(timeout=timeout, headers={"User-Agent": "HerokuGeminiModule/6.6.3"}) as session: | |
| async with session.get(str(url).strip(), allow_redirects=True) as response: | |
| if response.status >= 400: | |
| raise ValueError("remote server returned HTTP {}".format(response.status)) | |
| content_length = response.headers.get("Content-Length") | |
| if content_length: | |
| try: | |
| if int(content_length) > target_limit: | |
| raise ValueError("remote file too large: {} bytes".format(content_length)) | |
| except ValueError: | |
| raise | |
| except Exception: | |
| pass | |
| fallback = fallback_name or self._guess_filename_from_url(str(response.url), fallback="download.bin") | |
| header_name = self._extract_filename_from_headers(response.headers, fallback=fallback) | |
| final_name = header_name or fallback | |
| final_path = self._build_workspace_output_path(chat_id, requested_path, final_name, subdir="incoming") | |
| total = 0 | |
| with open(final_path, "wb") as file_obj: | |
| async for chunk in response.content.iter_chunked(65536): | |
| if not chunk: | |
| continue | |
| total += len(chunk) | |
| if total > target_limit: | |
| raise ValueError("remote file exceeded size limit of {} bytes".format(target_limit)) | |
| file_obj.write(chunk) | |
| mime_type = str(response.headers.get("Content-Type") or "").split(";")[0].strip() or self._guess_mime_from_filename(final_path) | |
| return { | |
| "path": os.path.abspath(final_path), | |
| "name": os.path.basename(final_path), | |
| "size": total, | |
| "mime_type": mime_type, | |
| "source": { | |
| "kind": "url", | |
| "url": str(response.url), | |
| }, | |
| } | |
| async def _materialize_message_media_to_file(self, message, *, chat_id, requested_path=None): | |
| if not message or not ( | |
| getattr(message, "media", None) | |
| or getattr(message, "photo", None) | |
| or getattr(message, "document", None) | |
| or getattr(message, "sticker", None) | |
| ): | |
| return None | |
| filename = self._guess_media_filename_from_message(message) | |
| final_path = self._build_workspace_output_path(chat_id, requested_path, filename, subdir="incoming") | |
| saved = await self.client.download_media(message, file=final_path) | |
| actual_path = os.path.abspath(saved or final_path) | |
| mime_type = ( | |
| getattr(getattr(message, "document", None), "mime_type", None) | |
| or getattr(getattr(message, "file", None), "mime_type", None) | |
| or ("image/jpeg" if getattr(message, "photo", None) else self._guess_mime_from_filename(actual_path)) | |
| ) | |
| return { | |
| "path": actual_path, | |
| "name": os.path.basename(actual_path), | |
| "size": os.path.getsize(actual_path) if os.path.exists(actual_path) else None, | |
| "mime_type": mime_type, | |
| "source": { | |
| "kind": "message_media", | |
| "chat_id": getattr(message, "chat_id", None), | |
| "message_id": getattr(message, "id", None), | |
| }, | |
| } | |
| async def _materialize_profile_photo_to_file(self, entity, *, chat_id, requested_path=None, photo_id=None, photo_index=1): | |
| chosen, resolved_index, _photos = await self._pick_profile_photo( | |
| entity, | |
| photo_id=photo_id, | |
| photo_index=photo_index, | |
| ) | |
| if not chosen: | |
| raise ValueError("profile photo not found") | |
| image_bytes = await self._download_profile_photo_bytes(entity, chosen, photo_index=resolved_index) | |
| prepared_bytes, _mime, size = self._prepare_tool_image_bytes(image_bytes, max_side=2048, quality=92) | |
| if not prepared_bytes: | |
| raise ValueError("failed to prepare profile photo bytes") | |
| filename = "avatar_{}_{}.jpg".format(getattr(entity, "id", "me"), resolved_index) | |
| final_path = self._build_workspace_output_path(chat_id, requested_path, filename, subdir="incoming") | |
| with open(final_path, "wb") as file_obj: | |
| file_obj.write(prepared_bytes) | |
| return { | |
| "path": os.path.abspath(final_path), | |
| "name": os.path.basename(final_path), | |
| "size": os.path.getsize(final_path), | |
| "mime_type": "image/jpeg", | |
| "source": { | |
| "kind": "profile_photo", | |
| "target": self._serialize_entity_brief(entity), | |
| "photo_id": getattr(chosen, "id", None), | |
| "photo_index": resolved_index, | |
| "size": {"w": size[0], "h": size[1]} if size else None, | |
| }, | |
| } | |
| async def _resolve_transfer_source_to_file(self, chat_id, raw, *, req_message=None, req_reply_id=None, requested_path=None): | |
| path = str(raw.get("path") or raw.get("file") or raw.get("file_path") or raw.get("local_path") or "").strip() | |
| remote_url = str(raw.get("url") or raw.get("source_url") or raw.get("file_url") or "").strip() | |
| if path: | |
| with contextlib.suppress(Exception): | |
| resolved = self._resolve_os_path(path, default_name="artifact", chat_id=chat_id) | |
| if os.path.isfile(resolved): | |
| path = resolved | |
| if not os.path.isfile(path): | |
| if not remote_url: | |
| raise FileNotFoundError("path does not exist: {}".format(path)) | |
| else: | |
| return { | |
| "path": os.path.abspath(path), | |
| "name": os.path.basename(path), | |
| "size": os.path.getsize(path), | |
| "mime_type": self._guess_mime_from_filename(path), | |
| "source": {"kind": "path", "path": os.path.abspath(path)}, | |
| } | |
| if remote_url: | |
| return await self._download_url_to_file( | |
| remote_url, | |
| chat_id=chat_id, | |
| requested_path=requested_path, | |
| fallback_name=str(raw.get("filename") or raw.get("name") or "").strip() or None, | |
| max_bytes=raw.get("max_bytes"), | |
| ) | |
| source_chat = raw.get("from_chat") or raw.get("source_chat") | |
| source_message_id = raw.get("message_id") or raw.get("source_message_id") | |
| if source_message_id in (None, "") and self._tool_bool(raw.get("use_reply", True)): | |
| source_message_id = req_reply_id | |
| if source_message_id not in (None, ""): | |
| source_entity = await self._resolve_target_entity(source_chat, chat_id if source_chat in (None, "") else None) | |
| source_msg = await self.client.get_messages(source_entity, ids=int(source_message_id)) | |
| item = await self._materialize_message_media_to_file( | |
| source_msg, | |
| chat_id=chat_id, | |
| requested_path=requested_path, | |
| ) | |
| if item: | |
| return item | |
| raise ValueError("source message has no media") | |
| requested_source = str(raw.get("source") or "").strip().lower() | |
| wants_avatar = ( | |
| requested_source in {"profile", "profile_photo", "avatar", "reply_avatar", "user_avatar", "owner_avatar", "owner_profile_photo", "chat_owner_avatar"} | |
| or self._tool_bool(raw.get("use_avatar")) | |
| or self._tool_bool(raw.get("use_owner_avatar")) | |
| or raw.get("target") not in (None, "") | |
| or raw.get("target_user") not in (None, "") | |
| or raw.get("user") not in (None, "") | |
| ) | |
| if wants_avatar: | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") | |
| if target in (None, "") and requested_source in {"owner_avatar", "owner_profile_photo", "chat_owner_avatar"}: | |
| owner_chat = raw.get("owner_chat") or raw.get("chat") or raw.get("target_chat") | |
| owner_entity = await self._resolve_target_entity(owner_chat, chat_id if owner_chat in (None, "") else None) | |
| owner_info = await self._build_chat_info_payload(owner_entity, include_admins=False) | |
| target = owner_info.get("owner_user_id") | |
| if target in (None, "") and req_message is not None: | |
| with contextlib.suppress(Exception): | |
| reply = await req_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, ""): | |
| target = "me" | |
| entity = await self._resolve_target_entity(target, self.me.id if target == "me" else None) | |
| return await self._materialize_profile_photo_to_file( | |
| entity, | |
| chat_id=chat_id, | |
| requested_path=requested_path, | |
| photo_id=raw.get("photo_id"), | |
| photo_index=self._normalize_limit(raw.get("photo_index", raw.get("index", 1)), default=1, maximum=50), | |
| ) | |
| raise ValueError("no transferable media source found") | |
| def _normalize_hosting_targets(self, hosts_raw): | |
| if isinstance(hosts_raw, list): | |
| items = hosts_raw | |
| else: | |
| items = re.split(r"[\s,;]+", str(hosts_raw or "0x0").strip()) | |
| canonical = { | |
| "0x0": "0x0", | |
| "0x0.st": "0x0", | |
| "zero": "0x0", | |
| "tmpfiles": "tmpfiles", | |
| "tmpfiles.org": "tmpfiles", | |
| "tempsh": "temp_sh", | |
| "temp.sh": "temp_sh", | |
| "tempsh": "temp_sh", | |
| "uguu": "uguu", | |
| "uguu.se": "uguu", | |
| "bashupload": "bashupload", | |
| "bashupload.com": "bashupload", | |
| } | |
| if any(str(item).strip().lower() in {"all", "*"} for item in items if str(item).strip()): | |
| return ["0x0", "tmpfiles", "temp_sh", "uguu", "bashupload"] | |
| resolved = [] | |
| for item in items: | |
| key = canonical.get(str(item).strip().lower()) | |
| if key and key not in resolved: | |
| resolved.append(key) | |
| return resolved or ["0x0"] | |
| def _normalize_hosting_returned_url(self, host, url): | |
| value = str(url or "").strip() | |
| if not value: | |
| return value | |
| value = value.splitlines()[0].strip() | |
| value = value.strip(" \t\r\n<>\"'`()[]{}") | |
| value = value.rstrip(".,;") | |
| parsed = urlparse(value) | |
| if host == "tmpfiles" and parsed.scheme in {"http", "https"} and parsed.netloc.lower().endswith("tmpfiles.org"): | |
| path = parsed.path or "" | |
| if path and not path.startswith("/dl/"): | |
| value = "{}://{}/dl{}".format(parsed.scheme, parsed.netloc, path if path.startswith("/") else "/" + path) | |
| return value | |
| def _mime_is_textual(self, mime_type): | |
| mime = str(mime_type or "").split(";")[0].strip().lower() | |
| return ( | |
| mime.startswith("text/") | |
| or mime in { | |
| "application/json", | |
| "application/javascript", | |
| "application/xml", | |
| "application/x-sh", | |
| "application/x-python", | |
| "text/x-python", | |
| } | |
| ) | |
| def _looks_like_broken_hosting_body(self, content_type, snippet, *, expected_mime=None): | |
| text = str(snippet or "").strip().lower() | |
| if not text: | |
| return False | |
| bad_markers = ( | |
| "invalid address", | |
| "stop reason", | |
| "lldb", | |
| "traceback", | |
| "segmentation fault", | |
| "process ", | |
| "fhostget(", | |
| "core dumped", | |
| "<!doctype html", | |
| "<html", | |
| ) | |
| if any(marker in text for marker in bad_markers): | |
| if self._mime_is_textual(expected_mime) and all(marker not in text for marker in ("<!doctype html", "<html", "traceback", "segmentation fault", "invalid address", "fhostget(")): | |
| return False | |
| return True | |
| mime = str(content_type or "").split(";")[0].strip().lower() | |
| if self._mime_is_textual(expected_mime): | |
| return False | |
| if mime in {"text/plain", "text/html"} and len(text) >= 24: | |
| if text.startswith("error") or text.startswith("{\"error\"") or text.startswith("process "): | |
| return True | |
| return False | |
| async def _verify_hosted_url(self, host, url, *, expected_mime=None): | |
| checked_url = self._normalize_hosting_returned_url(host, url) | |
| if not re.match(r"^https?://", checked_url, re.IGNORECASE): | |
| raise ValueError("upload returned invalid url: {}".format(checked_url or "<empty>")) | |
| timeout = aiohttp.ClientTimeout(total=45, connect=15, sock_read=20) | |
| headers = { | |
| "User-Agent": "HerokuGeminiModule/6.6.3", | |
| "Range": "bytes=0-511", | |
| } | |
| last_error = None | |
| async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session: | |
| for attempt in range(2): | |
| for method in ("HEAD", "GET"): | |
| try: | |
| requester = session.head if method == "HEAD" else session.get | |
| async with requester(checked_url, allow_redirects=True) as response: | |
| status = int(getattr(response, "status", 0) or 0) | |
| if status >= 400: | |
| raise ValueError("HTTP {}".format(status)) | |
| snippet = "" | |
| content_type = str(response.headers.get("Content-Type") or "").split(";")[0].strip().lower() | |
| if method == "GET": | |
| chunk = await response.content.read(512) | |
| snippet = chunk.decode("utf-8", "replace") | |
| if self._looks_like_broken_hosting_body(content_type, snippet, expected_mime=expected_mime): | |
| raise ValueError("host returned broken body") | |
| content_length = response.headers.get("Content-Length") | |
| length_value = None | |
| with contextlib.suppress(Exception): | |
| length_value = int(content_length) | |
| return { | |
| "url": str(response.url), | |
| "status": status, | |
| "content_type": content_type or None, | |
| "content_length": length_value, | |
| "checked_via": method.lower(), | |
| } | |
| except Exception as exc: | |
| last_error = exc | |
| if attempt == 0: | |
| await asyncio.sleep(1.2) | |
| raise ValueError("hosted url probe failed: {}".format(last_error or "unknown error")) | |
| async def _upload_file_to_hosting(self, host, file_path, *, source_url=None, expires=None, secret=False): | |
| filename = self._sanitize_artifact_name(os.path.basename(file_path)) | |
| mime_type = self._guess_mime_from_filename(filename) | |
| timeout = aiohttp.ClientTimeout(total=900, connect=30, sock_read=300) | |
| headers = {"User-Agent": "HerokuGeminiModule/6.6.3"} | |
| host = str(host).strip().lower() | |
| async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session: | |
| if host == "0x0": | |
| form = aiohttp.FormData() | |
| if source_url: | |
| form.add_field("url", str(source_url)) | |
| else: | |
| with open(file_path, "rb") as file_obj: | |
| form.add_field("file", file_obj, filename=filename, content_type=mime_type) | |
| if secret: | |
| form.add_field("secret", "") | |
| if expires not in (None, ""): | |
| form.add_field("expires", str(expires)) | |
| async with session.post("https://0x0.st", data=form) as response: | |
| text = (await response.text()).strip() | |
| if response.status >= 400: | |
| raise ValueError("0x0 upload failed: HTTP {} {}".format(response.status, text[:180])) | |
| probe = await self._verify_hosted_url("0x0", text.splitlines()[0].strip(), expected_mime=mime_type) | |
| return { | |
| "host": "0x0", | |
| "url": probe["url"], | |
| "probe": probe, | |
| "delete_token": response.headers.get("X-Token"), | |
| } | |
| if host == "tmpfiles": | |
| form = aiohttp.FormData() | |
| with open(file_path, "rb") as file_obj: | |
| form.add_field("file", file_obj, filename=filename, content_type=mime_type) | |
| async with session.post("https://tmpfiles.org/api/v1/upload", data=form) as response: | |
| payload = await response.json(content_type=None) | |
| if response.status >= 400: | |
| raise ValueError("tmpfiles upload failed: HTTP {} {}".format(response.status, str(payload)[:180])) | |
| url = (((payload or {}).get("data") or {}).get("url") or "").strip() | |
| if not url: | |
| raise ValueError("tmpfiles upload returned no url") | |
| probe = await self._verify_hosted_url("tmpfiles", url, expected_mime=mime_type) | |
| return {"host": "tmpfiles", "url": probe["url"], "probe": probe, "response": payload} | |
| if host == "temp_sh": | |
| form = aiohttp.FormData() | |
| with open(file_path, "rb") as file_obj: | |
| form.add_field("file", file_obj, filename=filename, content_type=mime_type) | |
| async with session.post("https://temp.sh/upload", data=form) as response: | |
| text = (await response.text()).strip() | |
| if response.status >= 400: | |
| raise ValueError("temp.sh upload failed: HTTP {} {}".format(response.status, text[:180])) | |
| match = re.search(r"https?://\\S+", text) | |
| if not match: | |
| raise ValueError("temp.sh upload returned no url") | |
| probe = await self._verify_hosted_url("temp_sh", match.group(0), expected_mime=mime_type) | |
| return {"host": "temp_sh", "url": probe["url"], "probe": probe, "response_text": text[:500]} | |
| if host == "uguu": | |
| form = aiohttp.FormData() | |
| with open(file_path, "rb") as file_obj: | |
| form.add_field("files[]", file_obj, filename=filename, content_type=mime_type) | |
| async with session.post("https://uguu.se/upload", data=form) as response: | |
| payload = await response.json(content_type=None) | |
| if response.status >= 400: | |
| raise ValueError("uguu upload failed: HTTP {} {}".format(response.status, str(payload)[:180])) | |
| files = (payload or {}).get("files") or [] | |
| first = files[0] if files else {} | |
| url = str(first.get("url") or "").strip() | |
| if not url: | |
| raise ValueError("uguu upload returned no url") | |
| probe = await self._verify_hosted_url("uguu", url, expected_mime=mime_type) | |
| return {"host": "uguu", "url": probe["url"], "probe": probe, "response": payload} | |
| if host == "bashupload": | |
| target_url = "https://bashupload.com/{}".format(filename) | |
| with open(file_path, "rb") as file_obj: | |
| async with session.put(target_url, data=file_obj) as response: | |
| text = (await response.text()).strip() | |
| if response.status >= 400: | |
| raise ValueError("bashupload upload failed: HTTP {} {}".format(response.status, text[:180])) | |
| match = re.search(r"https?://bashupload\\.com/\\S+", text) | |
| if not match: | |
| match = re.search(r"https?://\\S+", text) | |
| if not match: | |
| raise ValueError("bashupload upload returned no url") | |
| probe = await self._verify_hosted_url("bashupload", match.group(0), expected_mime=mime_type) | |
| return {"host": "bashupload", "url": probe["url"], "probe": probe, "response_text": text[:500]} | |
| raise ValueError("unsupported hosting provider: {}".format(host)) | |
| def _build_tool_result_prompt(self, tool_result, tool_name="execute_telegram_action"): | |
| return ( | |
| "Результат {}:\n{}\n\n" | |
| "Если задача уже выполнена, дай финальный ответ пользователю. " | |
| "Если нужен следующий шаг, верни новый tool_call JSON." | |
| ).format(tool_name, tool_result) | |
| def _build_openrouter_tool_result_content(self, chat_id, tool_result, tool_name="execute_telegram_action"): | |
| visuals = self._consume_tool_visual_context(chat_id) | |
| content = [{"type": "text", "text": self._build_tool_result_prompt(tool_result, tool_name=tool_name)}] | |
| for idx, item in enumerate(visuals[:8], start=1): | |
| mime = str(item.get("mime_type") or "image/jpeg") | |
| data = item.get("data") | |
| if not data: | |
| continue | |
| label = str(item.get("label") or f"tool image #{idx}") | |
| content.append({"type": "text", "text": f"[Tool image {idx}: {label}]"}) | |
| content.append({ | |
| "type": "image_url", | |
| "image_url": {"url": f"data:{mime};base64,{base64.b64encode(data).decode('utf-8')}"}, | |
| }) | |
| return content | |
| def _build_google_tool_result_parts(self, chat_id, tool_result, tool_name="execute_telegram_action"): | |
| visuals = self._consume_tool_visual_context(chat_id) | |
| parts = [types.Part(text=self._build_tool_result_prompt(tool_result, tool_name=tool_name))] | |
| for idx, item in enumerate(visuals[:8], start=1): | |
| mime = str(item.get("mime_type") or "image/jpeg") | |
| data = item.get("data") | |
| if not data: | |
| continue | |
| label = str(item.get("label") or f"tool image #{idx}") | |
| parts.append(types.Part(text=f"[Tool image {idx}: {label}]")) | |
| parts.append(types.Part(inline_data=types.Blob(mime_type=mime, data=data))) | |
| return parts | |
| async def _extract_image_bytes_from_message(self, message): | |
| if not message: | |
| return None, None | |
| if getattr(message, "photo", None): | |
| data = await self.client.download_media(message, bytes) | |
| return data, "reply_photo" | |
| document = getattr(message, "document", None) | |
| mime = getattr(document, "mime_type", "") if document else "" | |
| if document and mime.startswith("image/"): | |
| data = await self.client.download_media(message, bytes) | |
| name = getattr(getattr(message, "file", None), "name", None) or "reply_image" | |
| return data, name | |
| return None, None | |
| def _guess_mime_from_path(self, path, default="image/jpeg"): | |
| ext = os.path.splitext(str(path or "").lower())[1] | |
| return { | |
| ".jpg": "image/jpeg", | |
| ".jpeg": "image/jpeg", | |
| ".png": "image/png", | |
| ".webp": "image/webp", | |
| ".bmp": "image/bmp", | |
| ".gif": "image/gif", | |
| }.get(ext, default) | |
| async def _resolve_generation_image_source( | |
| self, | |
| *, | |
| chat_id, | |
| raw, | |
| req_message=None, | |
| req_reply_id=None, | |
| fallback_image_bytes=None, | |
| fallback_image_mime=None, | |
| ): | |
| image_bytes = fallback_image_bytes | |
| image_mime = fallback_image_mime or "image/jpeg" | |
| source = {} | |
| if image_bytes: | |
| source = {"kind": "inline_image"} | |
| else: | |
| recent_image_bytes, recent_image_mime = self._get_recent_tool_image_bytes(chat_id) | |
| if recent_image_bytes: | |
| image_bytes = recent_image_bytes | |
| image_mime = recent_image_mime or image_mime | |
| source = {"kind": "tool_visual_context"} | |
| source_path = str(raw.get("source_path") or raw.get("image_path") or raw.get("path") or "").strip() | |
| if image_bytes is None and source_path: | |
| with contextlib.suppress(Exception): | |
| resolved = self._resolve_os_path(source_path, default_name="artifact", chat_id=chat_id) | |
| if os.path.isfile(resolved): | |
| source_path = resolved | |
| if not os.path.isfile(source_path): | |
| raise FileNotFoundError("source path does not exist: {}".format(source_path)) | |
| with open(source_path, "rb") as file_obj: | |
| image_bytes = file_obj.read() | |
| image_mime = self._guess_mime_from_path(source_path) | |
| source = {"kind": "path", "path": source_path} | |
| source_chat = raw.get("from_chat") or raw.get("source_chat") | |
| source_message_id = raw.get("source_message_id") | |
| if source_message_id in (None, "") and self._tool_bool(raw.get("use_reply", True)): | |
| source_message_id = req_reply_id | |
| if image_bytes is None and source_message_id not in (None, ""): | |
| source_entity = await self._resolve_target_entity(source_chat, chat_id if source_chat in (None, "") else None) | |
| source_msg = await self.client.get_messages(source_entity, ids=int(source_message_id)) | |
| image_bytes, source_name = await self._extract_image_bytes_from_message(source_msg) | |
| if not image_bytes: | |
| raise ValueError("source message does not contain an image") | |
| if image_bytes: | |
| image_mime = self._guess_mime_from_path(source_name) | |
| source = { | |
| "kind": "message", | |
| "chat_id": getattr(source_entity, "id", None), | |
| "message_id": int(source_message_id), | |
| "name": source_name, | |
| } | |
| requested_source = str(raw.get("source") or raw.get("mode_source") or "").strip().lower() | |
| wants_profile = image_bytes is None and ( | |
| requested_source in {"profile", "profile_photo", "avatar", "user_avatar", "reply_avatar"} | |
| or requested_source in {"chat_owner_avatar", "owner_avatar", "owner_profile_photo"} | |
| or self._tool_bool(raw.get("use_avatar")) | |
| or self._tool_bool(raw.get("use_owner_avatar")) | |
| or raw.get("target") not in (None, "") | |
| or raw.get("target_user") not in (None, "") | |
| or raw.get("user") not in (None, "") | |
| ) | |
| if wants_profile: | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") | |
| if target in (None, "") and requested_source in {"chat_owner_avatar", "owner_avatar", "owner_profile_photo"}: | |
| owner_chat = raw.get("owner_chat") or raw.get("chat") or raw.get("target_chat") | |
| owner_entity = await self._resolve_target_entity(owner_chat, chat_id if owner_chat in (None, "") else None) | |
| owner_info = await self._build_chat_info_payload(owner_entity, include_admins=False) | |
| target = owner_info.get("owner_user_id") | |
| if target in (None, "") and req_message is not None: | |
| with contextlib.suppress(Exception): | |
| reply = await req_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, "") and requested_source in {"profile", "profile_photo", "avatar", "user_avatar", "reply_avatar"}: | |
| target = "me" | |
| if target not in (None, ""): | |
| entity = await self._resolve_target_entity(target, self.me.id if target == "me" else None) | |
| photo_id = raw.get("photo_id") | |
| photo_index = self._normalize_limit(raw.get("photo_index", raw.get("index", 1)), default=1, maximum=20) | |
| fetch_limit = max(photo_index, 20 if photo_id not in (None, "") else photo_index) | |
| photos = await self.client.get_profile_photos(entity, limit=fetch_limit) | |
| chosen = None | |
| if photo_id not in (None, ""): | |
| photo_id = int(photo_id) | |
| chosen = next((photo for photo in photos if getattr(photo, "id", None) == photo_id), None) | |
| elif photos and photo_index <= len(photos): | |
| chosen = photos[photo_index - 1] | |
| if not chosen: | |
| raise ValueError("profile photo not found") | |
| if chosen: | |
| image_bytes = await self._download_profile_photo_bytes(entity, chosen, photo_index=photo_index) | |
| if not image_bytes: | |
| raise ValueError("failed to download profile photo bytes") | |
| image_mime = "image/jpeg" | |
| source = { | |
| "kind": "profile_photo", | |
| "target": self._serialize_entity_brief(entity), | |
| "photo_id": getattr(chosen, "id", None), | |
| "photo_index": photo_index, | |
| } | |
| if not image_bytes: | |
| return None, None, source | |
| prepared_bytes, prepared_mime, size = self._prepare_tool_image_bytes(image_bytes, max_side=2048, quality=92) | |
| if size: | |
| source["size"] = {"w": size[0], "h": size[1]} | |
| return prepared_bytes or image_bytes, prepared_mime or image_mime or "image/jpeg", source | |
| async def _generate_image_asset(self, prompt, input_image_bytes=None): | |
| model = self._get_provider_model("google", "image") | |
| res = await self._call_google_rest(model, prompt, input_image_bytes) | |
| if "error" in res: | |
| err_msg = res["error"].get("message", "Unknown error") | |
| raise ValueError(err_msg) | |
| if "candidates" not in res or not res["candidates"]: | |
| raise ValueError("API вернул пустой ответ (нет candidates).") | |
| candidate = res["candidates"][0] | |
| if "content" not in candidate: | |
| reason = candidate.get("finishReason", "Unknown") | |
| raise ValueError(f"Модель отказалась генерировать. Причина: {reason}") | |
| img_bytes = None | |
| for part in candidate["content"].get("parts", []): | |
| if "inlineData" in part: | |
| img_bytes = base64.b64decode(part["inlineData"]["data"]) | |
| break | |
| if not img_bytes: | |
| raise ValueError("Модель не вернула изображение.") | |
| return img_bytes, model | |
| async def _generate_video_asset( | |
| self, | |
| prompt, | |
| *, | |
| model=None, | |
| seconds=None, | |
| aspect_ratio=None, | |
| resolution=None, | |
| image_bytes=None, | |
| image_mime=None, | |
| progress_callback=None, | |
| ): | |
| session = self._veo_normalize({ | |
| "model": str(model or self._get_provider_model("google", "video") or "").strip(), | |
| "seconds": self._normalize_veo_seconds_value( | |
| seconds if seconds is not None else self.config["veo_seconds"], | |
| default=self.config["veo_seconds"], | |
| ), | |
| "aspect_ratio": self._normalize_aspect_ratio_value( | |
| aspect_ratio if aspect_ratio is not None else self.config["veo_aspect_ratio"], | |
| allow_square=False, | |
| default="16:9", | |
| ), | |
| "resolution": resolution or self.config["veo_resolution"], | |
| "image_bytes": image_bytes, | |
| }) | |
| video_bytes, elapsed = await self._veo_generate( | |
| prompt=prompt, | |
| model=session["model"], | |
| seconds=session["seconds"], | |
| aspect_ratio=session["aspect_ratio"], | |
| resolution=session["resolution"], | |
| image_bytes=image_bytes, | |
| image_mime=image_mime, | |
| progress_callback=progress_callback, | |
| ) | |
| return video_bytes, elapsed, session | |
| def _build_generated_output_path(self, kind, explicit_path=None, chat_id=None): | |
| ext = ".mp4" if kind == "video" else ".jpg" | |
| if explicit_path: | |
| return self._resolve_os_path(explicit_path, default_name=f"{kind}{ext}", create_parent=True, chat_id=chat_id) | |
| workspace = self._get_session_workspace(chat_id) | |
| if workspace: | |
| target_dir = os.path.join(workspace, "generated") | |
| os.makedirs(target_dir, exist_ok=True) | |
| return os.path.join(target_dir, f"{kind}_{int(time.time())}_{uuid.uuid4().hex[:8]}{ext}") | |
| path = os.path.join("tg_tools", "generated", f"{kind}_{int(time.time())}_{uuid.uuid4().hex[:8]}{ext}") | |
| return self._normalize_tool_path(path, default_dir="tg_tools/generated") | |
| async def _fast_resolve_entity(self, target, fallback_chat=None): | |
| candidate = fallback_chat if target in (None, "") else target | |
| if isinstance(candidate, str): | |
| candidate = candidate.strip() | |
| if re.fullmatch(r"-?\d+", candidate): | |
| candidate = int(candidate) | |
| try: | |
| return await self.client.get_input_entity(candidate) | |
| except Exception: | |
| return await self.client.get_entity(candidate) | |
| async def _lookup_dialog_entity(self, query_text: str): | |
| query = (query_text or "").strip().lower().lstrip("@") | |
| if not query: | |
| return None, 0.0, "" | |
| best_entity, best_score, best_name = None, 0.0, "" | |
| async for dialog in self.client.iter_dialogs(): | |
| entity = dialog.entity | |
| candidates = [ | |
| getattr(dialog, "title", None) or "", | |
| get_display_name(entity) if entity else "", | |
| getattr(entity, "username", None) or "", | |
| str(getattr(entity, "id", None) or ""), | |
| ] | |
| score = 0.0 | |
| for candidate in candidates: | |
| cand = (candidate or "").strip().lower() | |
| if not cand: | |
| continue | |
| if query == cand: | |
| score = max(score, 1.0) | |
| elif query in cand: | |
| score = max(score, 0.92) | |
| else: | |
| try: | |
| from difflib import SequenceMatcher | |
| score = max(score, SequenceMatcher(None, query, cand).ratio()) | |
| except Exception: | |
| pass | |
| if score > best_score: | |
| best_entity, best_score, best_name = entity, score, candidates[0] or candidates[1] or candidates[2] or "Unknown" | |
| return best_entity, best_score, best_name | |
| async def _resolve_target_entity(self, target, fallback_chat=None): | |
| if target in (None, ""): | |
| return await self._fast_resolve_entity(fallback_chat) | |
| if isinstance(target, str): | |
| cleaned = target.strip() | |
| if re.fullmatch(r"-?\d+", cleaned): | |
| target = int(cleaned) | |
| else: | |
| with contextlib.suppress(Exception): | |
| return await self._fast_resolve_entity(cleaned) | |
| entity, score, _ = await self._lookup_dialog_entity(cleaned) | |
| if entity and score >= 0.35: | |
| return entity | |
| return await self._fast_resolve_entity(target) | |
| async def _resolve_target_user(self, chat_entity, tool_data, fallback_message=None): | |
| target = ( | |
| tool_data.get("target_user") | |
| or tool_data.get("user") | |
| or tool_data.get("target") | |
| or tool_data.get("username") | |
| or tool_data.get("user_id") | |
| ) | |
| if target in (None, "") and fallback_message is not None: | |
| with contextlib.suppress(Exception): | |
| reply = await fallback_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, ""): | |
| raise ValueError("target user not provided") | |
| if isinstance(target, str): | |
| target = target.strip() | |
| if re.fullmatch(r"-?\d+", target): | |
| target = int(target) | |
| with contextlib.suppress(Exception): | |
| return await self.client.get_entity(target) | |
| username = str(target).strip().lower().lstrip("@") | |
| async for participant in self.client.iter_participants(chat_entity, limit=400): | |
| pu = (getattr(participant, "username", None) or "").lower().lstrip("@") | |
| if pu == username or str(getattr(participant, "id", "")) == str(target): | |
| return participant | |
| raise ValueError("user not found") | |
| def _serialize_entity_brief(self, entity): | |
| if entity is None: | |
| return None | |
| return { | |
| "id": getattr(entity, "id", None), | |
| "title": getattr(entity, "title", None), | |
| "name": get_display_name(entity) if entity else None, | |
| "username": getattr(entity, "username", None), | |
| "type": entity.__class__.__name__.lower(), | |
| } | |
| def _serialize_message(self, message): | |
| if not message: | |
| return None | |
| text = getattr(message, "message", None) or getattr(message, "text", None) or "" | |
| media = getattr(message, "media", None) | |
| document = getattr(message, "document", None) | |
| mime = getattr(document, "mime_type", None) if document else None | |
| return { | |
| "id": getattr(message, "id", None), | |
| "chat_id": getattr(message, "chat_id", None), | |
| "sender_id": getattr(message, "sender_id", None), | |
| "date": str(getattr(message, "date", None) or ""), | |
| "text": text[:4000], | |
| "reply_to": getattr(getattr(message, "reply_to", None), "reply_to_msg_id", None), | |
| "out": bool(getattr(message, "out", False)), | |
| "has_media": bool(media), | |
| "photo": bool(getattr(message, "photo", None)), | |
| "document": bool(document), | |
| "mime_type": mime, | |
| } | |
| def _build_message_link(self, chat_entity, message_id): | |
| username = getattr(chat_entity, "username", None) | |
| if username: | |
| return "https://t.me/{}/{}".format(username, message_id) | |
| chat_id = int(getattr(chat_entity, "id", 0) or 0) | |
| if str(chat_id).startswith("-100"): | |
| chat_id = int(str(chat_id)[4:]) | |
| return "https://t.me/c/{}/{}".format(abs(chat_id), message_id) | |
| async def _prepare_outbound_text(self, tool_data, fallback_text=""): | |
| text = str(tool_data.get("text") or fallback_text or "") | |
| parse_mode = str(tool_data.get("parse_mode") or tool_data.get("mode") or "").strip().lower() | |
| if parse_mode in {"plain", "text"}: | |
| parse_mode = None | |
| elif parse_mode not in {"html", "markdown", "md"}: | |
| parse_mode = None | |
| elif parse_mode == "md": | |
| parse_mode = "markdown" | |
| return text, parse_mode | |
| async def _search_messages_core(self, entity, query=None, limit=20, from_user=None, filter_obj=None): | |
| results = [] | |
| async for msg in self.client.iter_messages(entity, limit=limit * 4, search=query, from_user=from_user, filter=filter_obj): | |
| results.append(self._serialize_message(msg)) | |
| if len(results) >= limit: | |
| break | |
| return results | |
| async def _execute_os_tool(self, chat_id: int, tool_payload) -> str: | |
| if not self.config["allow_os_tools"]: | |
| return self._tool_err("os tools disabled") | |
| session = self._get_request_session(chat_id) | |
| raw = tool_payload if isinstance(tool_payload, dict) else self._extract_json_object(tool_payload) | |
| if not isinstance(raw, dict): | |
| return self._tool_err("tool payload must be a JSON object") | |
| if isinstance(raw.get("arguments"), dict) and str(raw.get("tool_call") or "").strip().lower() == "execute_os_action": | |
| raw = raw["arguments"] | |
| aliases = self._tool_action_aliases() | |
| action = aliases.get(str(raw.get("action") or "").strip().lower(), str(raw.get("action") or "").strip().lower()) | |
| if action not in self._os_action_names(): | |
| return self._tool_err("unsupported os action: {}".format(action)) | |
| used = int(session.get("os_tool_actions_count") or 0) | |
| budget = int(self.config["os_tool_action_budget"] or 8) | |
| if used >= budget: | |
| return self._tool_err("os tool action budget exceeded: {}/{}".format(used, budget)) | |
| session["os_tool_actions_count"] = used + 1 | |
| destructive = {"delete_path"} | |
| if self.config["tool_destructive_guard"] and action in destructive and not self._tool_bool(raw.get("confirm")): | |
| return self._tool_err("destructive os action '{}' requires confirm=true".format(action)) | |
| def read_text_file(path, encoding=None, errors="replace", max_bytes=250000): | |
| with open(path, "rb") as file_obj: | |
| data = file_obj.read(max_bytes + 1) | |
| truncated = len(data) > max_bytes | |
| if truncated: | |
| data = data[:max_bytes] | |
| enc = encoding or "utf-8" | |
| text = data.decode(enc, errors=errors) | |
| return text, truncated, len(data) | |
| def resolve_path(path=None, *, default_name=None, create_parent=False): | |
| return self._resolve_os_path(path, default_name=default_name, create_parent=create_parent, chat_id=chat_id) | |
| try: | |
| if action == "get_session_workspace": | |
| workspace = session.get("workspace") or self._get_os_tools_root() | |
| return self._tool_ok({ | |
| "action": action, | |
| "workspace": self._serialize_os_path(workspace), | |
| "input_artifacts_count": len(session.get("input_artifacts") or []), | |
| }) | |
| if action == "get_input_artifacts": | |
| return self._tool_ok({ | |
| "action": action, | |
| "workspace": self._serialize_os_path(session.get("workspace") or self._get_os_tools_root()), | |
| "artifacts": list(session.get("input_artifacts") or []), | |
| }) | |
| if action == "run_shell": | |
| command = str(raw.get("command") or raw.get("cmd") or raw.get("shell") or "").strip() | |
| if not command: | |
| return self._tool_err("missing command") | |
| workdir = resolve_path(raw.get("workdir") or raw.get("cwd") or ".", default_name=".") | |
| timeout = self._normalize_limit(raw.get("timeout") or raw.get("seconds") or 60, default=60, maximum=600) | |
| env = os.environ.copy() | |
| if isinstance(raw.get("env"), dict): | |
| for key, value in raw["env"].items(): | |
| env[str(key)] = str(value) | |
| proc = await asyncio.create_subprocess_shell( | |
| command, | |
| cwd=workdir, | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.PIPE, | |
| env=env, | |
| ) | |
| try: | |
| stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) | |
| timed_out = False | |
| except asyncio.TimeoutError: | |
| proc.kill() | |
| stdout, stderr = await proc.communicate() | |
| timed_out = True | |
| stdout_text = stdout.decode("utf-8", "replace") | |
| stderr_text = stderr.decode("utf-8", "replace") | |
| max_output = self._normalize_limit(raw.get("max_output") or raw.get("max_chars") or 12000, default=12000, maximum=50000) | |
| return self._tool_ok({ | |
| "action": action, | |
| "command": command, | |
| "cwd": workdir, | |
| "timed_out": timed_out, | |
| "returncode": proc.returncode, | |
| "stdout": stdout_text[:max_output], | |
| "stderr": stderr_text[:max_output], | |
| }) | |
| if action == "list_directory": | |
| path = resolve_path(raw.get("path") or raw.get("dir") or ".", default_name=".") | |
| if not os.path.isdir(path): | |
| return self._tool_err("directory not found: {}".format(path)) | |
| include_hidden = self._tool_bool(raw.get("hidden")) | |
| limit = self._normalize_limit(raw.get("limit") or 100, default=100, maximum=500) | |
| entries = [] | |
| for name in sorted(os.listdir(path)): | |
| if not include_hidden and name.startswith("."): | |
| continue | |
| full = os.path.join(path, name) | |
| item = self._serialize_os_path(full) | |
| item.update({ | |
| "name": name, | |
| "is_dir": os.path.isdir(full), | |
| "size": os.path.getsize(full) if os.path.isfile(full) else None, | |
| }) | |
| entries.append(item) | |
| if len(entries) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "directory": self._serialize_os_path(path), "entries": entries, "count": len(entries)}) | |
| if action == "get_path_tree": | |
| path = resolve_path(raw.get("path") or raw.get("dir") or ".", default_name=".") | |
| max_depth = self._normalize_limit(raw.get("depth") or 3, default=3, maximum=8) | |
| max_nodes = self._normalize_limit(raw.get("limit") or 150, default=150, maximum=800) | |
| if not os.path.exists(path): | |
| return self._tool_err("path not found: {}".format(path)) | |
| root = path | |
| nodes = [] | |
| for current, dirnames, filenames in os.walk(root): | |
| rel = os.path.relpath(current, root) | |
| depth = 0 if rel == "." else rel.count(os.sep) + 1 | |
| if depth > max_depth: | |
| dirnames[:] = [] | |
| continue | |
| nodes.append({"path": current, "relative_path": rel, "type": "dir"}) | |
| if len(nodes) >= max_nodes: | |
| break | |
| for fname in sorted(filenames): | |
| full = os.path.join(current, fname) | |
| nodes.append({"path": full, "relative_path": os.path.relpath(full, root), "type": "file"}) | |
| if len(nodes) >= max_nodes: | |
| break | |
| if len(nodes) >= max_nodes: | |
| break | |
| return self._tool_ok({"action": action, "root": self._serialize_os_path(path), "nodes": nodes, "count": len(nodes)}) | |
| if action == "stat_path": | |
| path = resolve_path(raw.get("path"), default_name="artifact") | |
| exists = os.path.exists(path) | |
| info = self._serialize_os_path(path) | |
| if exists: | |
| stat_res = os.stat(path) | |
| info.update({ | |
| "is_file": os.path.isfile(path), | |
| "is_dir": os.path.isdir(path), | |
| "size": stat_res.st_size, | |
| "mtime": stat_res.st_mtime, | |
| "ctime": stat_res.st_ctime, | |
| "mime_type": mimetypes.guess_type(path)[0], | |
| }) | |
| return self._tool_ok({"action": action, "item": info}) | |
| if action in {"read_file", "read_json"}: | |
| path = resolve_path(raw.get("path"), default_name="artifact") | |
| if not os.path.isfile(path): | |
| return self._tool_err("file not found: {}".format(path)) | |
| if action == "read_json": | |
| with open(path, "r", encoding=str(raw.get("encoding") or "utf-8")) as file_obj: | |
| data = json.load(file_obj) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(path), "data": data}) | |
| text, truncated, read_bytes = read_text_file(path, encoding=str(raw.get("encoding") or "utf-8")) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(path), "text": text, "truncated": truncated, "read_bytes": read_bytes}) | |
| if action == "read_file_lines": | |
| path = resolve_path(raw.get("path"), default_name="artifact") | |
| if not os.path.isfile(path): | |
| return self._tool_err("file not found: {}".format(path)) | |
| start = max(1, int(raw.get("start") or raw.get("from_line") or 1)) | |
| end = max(start, int(raw.get("end") or raw.get("to_line") or start + 199)) | |
| limit = min(end - start + 1, 500) | |
| with open(path, "r", encoding=str(raw.get("encoding") or "utf-8"), errors="replace") as file_obj: | |
| lines = file_obj.readlines() | |
| selected = [] | |
| for lineno in range(start, min(len(lines), start + limit - 1) + 1): | |
| selected.append({"line": lineno, "text": lines[lineno - 1].rstrip("\n")}) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(path), "start": start, "end": start + len(selected) - 1, "lines": selected}) | |
| if action in {"write_file", "append_file", "write_json"}: | |
| path = resolve_path(raw.get("path"), default_name="artifact", create_parent=True) | |
| encoding = str(raw.get("encoding") or "utf-8") | |
| if action == "write_json": | |
| data = raw.get("data") | |
| with open(path, "w", encoding=encoding) as file_obj: | |
| json.dump(data, file_obj, ensure_ascii=False, indent=2) | |
| file_obj.write("\n") | |
| else: | |
| text = str(raw.get("text") or raw.get("content") or "") | |
| mode = "a" if action == "append_file" else "w" | |
| with open(path, mode, encoding=encoding) as file_obj: | |
| file_obj.write(text) | |
| self._register_session_file(chat_id, path, role="output", label=os.path.basename(path), source=action) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(path), "size": os.path.getsize(path)}) | |
| if action == "replace_in_file": | |
| path = resolve_path(raw.get("path"), default_name="artifact") | |
| if not os.path.isfile(path): | |
| return self._tool_err("file not found: {}".format(path)) | |
| old = raw.get("old") | |
| new = str(raw.get("new") or "") | |
| if old is None: | |
| return self._tool_err("missing old") | |
| use_regex = self._tool_bool(raw.get("regex")) | |
| count = int(raw.get("count") or 0) | |
| encoding = str(raw.get("encoding") or "utf-8") | |
| with open(path, "r", encoding=encoding, errors="replace") as file_obj: | |
| text = file_obj.read() | |
| if use_regex: | |
| updated, replaced = re.subn(str(old), new, text, count=count if count > 0 else 0) | |
| else: | |
| replaced = text.count(str(old)) if count <= 0 else min(text.count(str(old)), count) | |
| updated = text.replace(str(old), new, count) if count > 0 else text.replace(str(old), new) | |
| if replaced <= 0: | |
| return self._tool_err("pattern not found") | |
| with open(path, "w", encoding=encoding) as file_obj: | |
| file_obj.write(updated) | |
| self._register_session_file(chat_id, path, role="output", label=os.path.basename(path), source=action) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(path), "replacements": replaced}) | |
| if action == "create_directory": | |
| path = resolve_path(raw.get("path") or raw.get("dir"), default_name="new_dir") | |
| os.makedirs(path, exist_ok=True) | |
| return self._tool_ok({"action": action, "directory": self._serialize_os_path(path)}) | |
| if action == "touch_file": | |
| path = resolve_path(raw.get("path"), default_name="artifact", create_parent=True) | |
| with open(path, "a", encoding="utf-8"): | |
| os.utime(path, None) | |
| self._register_session_file(chat_id, path, role="output", label=os.path.basename(path), source=action) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(path)}) | |
| if action in {"move_path", "copy_path"}: | |
| src = resolve_path(raw.get("path") or raw.get("src"), default_name="artifact") | |
| dst = resolve_path(raw.get("to") or raw.get("dest") or raw.get("dst"), default_name="artifact", create_parent=True) | |
| if not os.path.exists(src): | |
| return self._tool_err("source path not found: {}".format(src)) | |
| overwrite = self._tool_bool(raw.get("overwrite")) | |
| if os.path.exists(dst) and not overwrite: | |
| return self._tool_err("destination exists; pass overwrite=true") | |
| if os.path.exists(dst) and overwrite: | |
| if os.path.isdir(dst) and not os.path.islink(dst): | |
| shutil.rmtree(dst) | |
| else: | |
| os.remove(dst) | |
| if action == "move_path": | |
| shutil.move(src, dst) | |
| else: | |
| if os.path.isdir(src): | |
| shutil.copytree(src, dst) | |
| else: | |
| shutil.copy2(src, dst) | |
| self._register_session_file(chat_id, dst, role="output", label=os.path.basename(dst), source=action) | |
| return self._tool_ok({"action": action, "source": self._serialize_os_path(src), "target": self._serialize_os_path(dst)}) | |
| if action == "delete_path": | |
| path = resolve_path(raw.get("path"), default_name="artifact") | |
| if not os.path.exists(path): | |
| return self._tool_ok({"action": action, "deleted": False, "path": self._serialize_os_path(path)}) | |
| if os.path.isdir(path) and not os.path.islink(path): | |
| shutil.rmtree(path) | |
| else: | |
| os.remove(path) | |
| result = self._serialize_os_path(path) | |
| result["deleted"] = True | |
| return self._tool_ok({"action": action, "item": result}) | |
| if action == "find_files": | |
| base = resolve_path(raw.get("path") or raw.get("dir") or ".", default_name=".") | |
| if not os.path.isdir(base): | |
| return self._tool_err("directory not found: {}".format(base)) | |
| query = str(raw.get("query") or raw.get("pattern") or "").strip().lower() | |
| mode = str(raw.get("mode") or "name").strip().lower() | |
| limit = self._normalize_limit(raw.get("limit") or 50, default=50, maximum=500) | |
| matches = [] | |
| for current, _, filenames in os.walk(base): | |
| for fname in filenames: | |
| full = os.path.join(current, fname) | |
| hay = fname.lower() if mode == "name" else os.path.relpath(full, base).lower() | |
| if query and query not in hay: | |
| continue | |
| info = self._serialize_os_path(full) | |
| info["size"] = os.path.getsize(full) | |
| matches.append(info) | |
| if len(matches) >= limit: | |
| break | |
| if len(matches) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "base": self._serialize_os_path(base), "query": query, "count": len(matches), "matches": matches}) | |
| if action == "read_multiple_files": | |
| raw_paths = raw.get("paths") or raw.get("files") or [] | |
| if isinstance(raw_paths, (str, bytes)): | |
| raw_paths = [raw_paths] | |
| if not isinstance(raw_paths, list) or not raw_paths: | |
| return self._tool_err("missing paths") | |
| items = [] | |
| limit = self._normalize_limit(raw.get("limit") or len(raw_paths), default=len(raw_paths), maximum=25) | |
| for item in raw_paths[:limit]: | |
| path = resolve_path(item, default_name="artifact") | |
| if not os.path.isfile(path): | |
| items.append({"path": path, "error": "file not found"}) | |
| continue | |
| text, truncated, read_bytes = read_text_file(path, encoding=str(raw.get("encoding") or "utf-8")) | |
| items.append({ | |
| "file": self._serialize_os_path(path), | |
| "text": text, | |
| "truncated": truncated, | |
| "read_bytes": read_bytes, | |
| }) | |
| return self._tool_ok({"action": action, "count": len(items), "items": items}) | |
| if action == "replace_many_in_file": | |
| path = resolve_path(raw.get("path"), default_name="artifact") | |
| if not os.path.isfile(path): | |
| return self._tool_err("file not found: {}".format(path)) | |
| replacements = raw.get("replacements") | |
| if not isinstance(replacements, list) or not replacements: | |
| return self._tool_err("missing replacements") | |
| encoding = str(raw.get("encoding") or "utf-8") | |
| with open(path, "r", encoding=encoding, errors="replace") as file_obj: | |
| text = file_obj.read() | |
| total = 0 | |
| for repl in replacements: | |
| if not isinstance(repl, dict) or repl.get("old") is None: | |
| continue | |
| old = str(repl.get("old")) | |
| new = str(repl.get("new") or "") | |
| count = int(repl.get("count") or 0) | |
| if self._tool_bool(repl.get("regex")): | |
| text, changed = re.subn(old, new, text, count=count if count > 0 else 0) | |
| else: | |
| changed = text.count(old) if count <= 0 else min(text.count(old), count) | |
| text = text.replace(old, new, count) if count > 0 else text.replace(old, new) | |
| total += max(0, changed) | |
| if total <= 0: | |
| return self._tool_err("no replacements applied") | |
| with open(path, "w", encoding=encoding) as file_obj: | |
| file_obj.write(text) | |
| self._register_session_file(chat_id, path, role="output", label=os.path.basename(path), source=action) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(path), "replacements": total}) | |
| if action == "insert_in_file": | |
| path = resolve_path(raw.get("path"), default_name="artifact") | |
| if not os.path.isfile(path): | |
| return self._tool_err("file not found: {}".format(path)) | |
| text_to_insert = str(raw.get("text") or raw.get("content") or "") | |
| if not text_to_insert: | |
| return self._tool_err("missing text") | |
| encoding = str(raw.get("encoding") or "utf-8") | |
| mode = str(raw.get("position") or raw.get("where") or "after").strip().lower() | |
| anchor = raw.get("anchor") | |
| with open(path, "r", encoding=encoding, errors="replace") as file_obj: | |
| content = file_obj.read() | |
| if mode == "start": | |
| updated = text_to_insert + content | |
| elif mode == "end": | |
| updated = content + text_to_insert | |
| else: | |
| if anchor in (None, ""): | |
| return self._tool_err("missing anchor") | |
| anchor = str(anchor) | |
| if anchor not in content: | |
| return self._tool_err("anchor not found") | |
| if mode == "before": | |
| updated = content.replace(anchor, text_to_insert + anchor, 1) | |
| else: | |
| updated = content.replace(anchor, anchor + text_to_insert, 1) | |
| with open(path, "w", encoding=encoding) as file_obj: | |
| file_obj.write(updated) | |
| self._register_session_file(chat_id, path, role="output", label=os.path.basename(path), source=action) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(path), "position": mode}) | |
| if action == "set_result_file": | |
| raw_paths = raw.get("paths") or raw.get("files") or raw.get("path") | |
| if isinstance(raw_paths, (str, bytes)): | |
| raw_paths = [raw_paths] | |
| if not isinstance(raw_paths, list) or not raw_paths: | |
| return self._tool_err("missing path") | |
| stored = [] | |
| session.setdefault("result_artifacts", []) | |
| for item in raw_paths[:12]: | |
| path = resolve_path(item, default_name="artifact") | |
| if not os.path.exists(path): | |
| continue | |
| meta = { | |
| "path": path, | |
| "relative_path": os.path.relpath(path, session.get("workspace") or self._get_os_tools_root()), | |
| } | |
| session["result_artifacts"].append(meta) | |
| self._register_session_file(chat_id, path, role="output", label=os.path.basename(path), source=action) | |
| stored.append(meta) | |
| if not stored: | |
| return self._tool_err("no existing files registered") | |
| session["artifact_send_requested"] = True | |
| return self._tool_ok({"action": action, "files": stored, "count": len(stored)}) | |
| if action == "create_zip": | |
| zip_path = resolve_path(raw.get("output_path") or raw.get("path") or "exports/result.zip", default_name="exports/result.zip", create_parent=True) | |
| sources = raw.get("sources") or raw.get("paths") or raw.get("files") or [] | |
| if isinstance(sources, (str, bytes)): | |
| sources = [sources] | |
| if not isinstance(sources, list) or not sources: | |
| return self._tool_err("missing sources") | |
| added = [] | |
| with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file: | |
| for item in sources[:64]: | |
| path = resolve_path(item, default_name="artifact") | |
| if not os.path.exists(path): | |
| continue | |
| if os.path.isdir(path): | |
| for current, _, filenames in os.walk(path): | |
| for filename in filenames: | |
| full = os.path.join(current, filename) | |
| arcname = os.path.relpath(full, os.path.dirname(path)) | |
| zip_file.write(full, arcname=arcname) | |
| added.append(full) | |
| else: | |
| zip_file.write(path, arcname=os.path.basename(path)) | |
| added.append(path) | |
| self._register_session_file(chat_id, zip_path, role="output", label=os.path.basename(zip_path), source=action) | |
| return self._tool_ok({"action": action, "file": self._serialize_os_path(zip_path), "sources_count": len(added)}) | |
| # ── New extended actions ────────────────────────────────────────── | |
| if action in {"create_poll", "send_poll"}: | |
| try: | |
| question = str(raw.get("question") or raw.get("text") or "").strip() | |
| options = raw.get("options") or raw.get("answers") or [] | |
| if isinstance(options, str): | |
| options = [o.strip() for o in options.split(",") if o.strip()] | |
| if not question or len(options) < 2: | |
| return self._tool_err("question and at least 2 options are required") | |
| from telethon.tl.types import InputMediaPoll, Poll, PollAnswer | |
| poll = InputMediaPoll(poll=Poll( | |
| id=0, | |
| question=tg_types.TextWithEntities(text=question, entities=[]), | |
| answers=[PollAnswer(text=tg_types.TextWithEntities(text=str(o), entities=[]), option=str(i).encode()) for i, o in enumerate(options)], | |
| multiple_choice=self._tool_bool(raw.get("multiple_choice")), | |
| public_voters=self._tool_bool(raw.get("public_voters")), | |
| quiz=self._tool_bool(raw.get("quiz")), | |
| )) | |
| out = await self.client.send_file(target_entity, poll, reply_to=raw.get("reply_to") or req_reply_id) | |
| return self._tool_ok({"action": action, "question": question, "options": options, "message": self._serialize_message(out)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action in {"create_invite_link", "get_invite_links", "revoke_invite_link"}: | |
| try: | |
| if action == "get_invite_links": | |
| from telethon.tl.functions.messages import GetExportedChatInvitesRequest | |
| result = await self.client(GetExportedChatInvitesRequest(peer=target_entity, admin_id=self.me, revoked=False, limit=10)) | |
| links = [getattr(inv, "link", None) for inv in getattr(result, "invites", [])] | |
| return self._tool_ok({"action": action, "links": links}) | |
| if action == "create_invite_link": | |
| from telethon.tl.functions.messages import ExportChatInviteRequest | |
| expire_date = None | |
| if raw.get("expire_seconds"): | |
| import time as _time | |
| expire_date = int(_time.time()) + int(raw["expire_seconds"]) | |
| inv = await self.client(ExportChatInviteRequest( | |
| peer=target_entity, | |
| expire_date=expire_date, | |
| usage_limit=int(raw.get("usage_limit") or 0) or None, | |
| )) | |
| return self._tool_ok({"action": action, "link": getattr(inv, "link", str(inv))}) | |
| if action == "revoke_invite_link": | |
| link = str(raw.get("link") or "").strip() | |
| if not link: | |
| return self._tool_err("link is required") | |
| from telethon.tl.functions.messages import EditExportedChatInviteRequest | |
| await self.client(EditExportedChatInviteRequest(peer=target_entity, link=link, revoked=True)) | |
| return self._tool_ok({"action": action, "link": link, "revoked": True}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action in {"pin_chat_message", "unpin_chat_message", "unpin_all_messages"}: | |
| try: | |
| if action == "unpin_all_messages": | |
| from telethon.tl.functions.messages import UnpinAllMessagesRequest | |
| await self.client(UnpinAllMessagesRequest(peer=target_entity)) | |
| return self._tool_ok({"action": action}) | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("message_id is required") | |
| from telethon.tl.functions.messages import UpdatePinnedMessageRequest | |
| await self.client(UpdatePinnedMessageRequest( | |
| peer=target_entity, | |
| id=message_id, | |
| silent=self._tool_bool(raw.get("silent", True)), | |
| unpin=(action == "unpin_chat_message"), | |
| pm_oneside=self._tool_bool(raw.get("pm_oneside")), | |
| )) | |
| return self._tool_ok({"action": action, "message_id": message_id}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "set_slow_mode": | |
| try: | |
| from telethon.tl.functions.channels import ToggleSlowModeRequest | |
| seconds = int(raw.get("seconds") or 0) | |
| await self.client(ToggleSlowModeRequest(channel=target_entity, seconds=seconds)) | |
| return self._tool_ok({"action": action, "seconds": seconds}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action in {"get_bot_commands", "set_bot_commands"}: | |
| try: | |
| from telethon.tl.functions.bots import GetBotCommandsRequest, SetBotCommandsRequest | |
| from telethon.tl.types import BotCommandScopeDefault, BotCommand | |
| scope = BotCommandScopeDefault() | |
| lang = str(raw.get("lang") or "") | |
| if action == "get_bot_commands": | |
| cmds = await self.client(GetBotCommandsRequest(scope=scope, lang_code=lang)) | |
| return self._tool_ok({"action": action, "commands": [{"command": c.command, "description": c.description} for c in cmds]}) | |
| commands_raw = raw.get("commands") or [] | |
| commands = [BotCommand(command=str(c.get("command", "")), description=str(c.get("description", ""))) for c in commands_raw if isinstance(c, dict)] | |
| await self.client(SetBotCommandsRequest(scope=scope, lang_code=lang, commands=commands)) | |
| return self._tool_ok({"action": action, "set": len(commands)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "get_online_count": | |
| try: | |
| from telethon.tl.functions.messages import GetOnlinesRequest | |
| result = await self.client(GetOnlinesRequest(peer=target_entity)) | |
| return self._tool_ok({"action": action, "online": getattr(result, "onlines", None)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "send_contact": | |
| try: | |
| from telethon.tl.types import InputMediaContact | |
| phone = str(raw.get("phone") or "").strip() | |
| first_name = str(raw.get("first_name") or raw.get("name") or "").strip() | |
| last_name = str(raw.get("last_name") or "").strip() | |
| if not phone or not first_name: | |
| return self._tool_err("phone and first_name are required") | |
| contact = InputMediaContact(phone_number=phone, first_name=first_name, last_name=last_name, vcard="") | |
| out = await self.client.send_file(target_entity, contact, reply_to=raw.get("reply_to") or req_reply_id) | |
| return self._tool_ok({"action": action, "phone": phone, "message": self._serialize_message(out)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "send_location": | |
| try: | |
| from telethon.tl.types import InputMediaGeoPoint, InputGeoPoint | |
| lat = float(raw.get("latitude") or raw.get("lat") or 0) | |
| lon = float(raw.get("longitude") or raw.get("lon") or 0) | |
| if not lat and not lon: | |
| return self._tool_err("latitude and longitude are required") | |
| geo = InputMediaGeoPoint(geo_point=InputGeoPoint(lat=lat, long=lon)) | |
| out = await self.client.send_file(target_entity, geo, reply_to=raw.get("reply_to") or req_reply_id) | |
| return self._tool_ok({"action": action, "lat": lat, "lon": lon, "message": self._serialize_message(out)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "send_dice": | |
| try: | |
| from telethon.tl.types import InputMediaDice | |
| emoji = str(raw.get("emoji") or "🎲").strip() | |
| out = await self.client.send_file(target_entity, InputMediaDice(emoticon=emoji), reply_to=raw.get("reply_to") or req_reply_id) | |
| return self._tool_ok({"action": action, "emoji": emoji, "message": self._serialize_message(out)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "get_chat_member": | |
| try: | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| perms = await self.client.get_permissions(target_entity, user) | |
| return self._tool_ok({"action": action, "user_id": user.id, "permissions": str(perms)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "get_top_peers": | |
| try: | |
| from telethon.tl.functions.contacts import GetTopPeersRequest | |
| from telethon.tl.types import TopPeerCategoryCorrespondents | |
| result = await self.client(GetTopPeersRequest( | |
| correspondents=True, bots_pm=False, bots_inline=False, | |
| phone_calls=False, forward_users=False, forward_chats=False, groups=False, channels=False, | |
| limit=int(raw.get("limit") or 10), hash=0, | |
| )) | |
| peers = [] | |
| for cat in getattr(result, "categories", []): | |
| for tp in getattr(cat, "peers", []): | |
| peers.append({"rating": getattr(tp, "rating", None), "peer": str(getattr(tp, "peer", None))}) | |
| return self._tool_ok({"action": action, "peers": peers}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "get_sticker_set": | |
| try: | |
| short_name = str(raw.get("short_name") or raw.get("sticker_set") or "").strip() | |
| if not short_name: | |
| return self._tool_err("short_name is required") | |
| result = await self.client(GetStickerSetRequest(stickerset=InputStickerSetShortName(short_name=short_name), hash=0)) | |
| stickers = [{"id": str(doc.id), "emoji": next((attr.alt for attr in doc.attributes if isinstance(attr, DocumentAttributeSticker)), "")} for doc in (getattr(result, "documents", []) or [])] | |
| return self._tool_ok({"action": action, "short_name": short_name, "count": len(stickers), "stickers": stickers[:30]}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| return self._tool_err("action is declared but not implemented: {}".format(action)) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| async def _execute_model_tool(self, chat_id: int, tool_info): | |
| tool_name = str((tool_info or {}).get("tool_call") or "").strip().lower() | |
| arguments = (tool_info or {}).get("arguments") or {} | |
| if tool_name == "execute_telegram_action": | |
| return tool_name, await self._execute_telegram_tool(chat_id, arguments) | |
| if tool_name == "execute_os_action": | |
| return tool_name, await self._execute_os_tool(chat_id, arguments) | |
| return tool_name, self._tool_err("unsupported tool call: {}".format(tool_name)) | |
| # ── Dispatch table for Telegram tool actions ── | |
| _TG_TOOL_HANDLERS = { | |
| "send_message": "_tool_messaging", | |
| "send_message_last": "_tool_messaging", | |
| "send_bulk_messages": "_tool_messaging", | |
| "edit_message": "_tool_messaging", | |
| "delete_messages": "_tool_messaging", | |
| "delete_last_message": "_tool_messaging", | |
| "reply_messages": "_tool_messaging", | |
| "reply_to_message": "_tool_messaging", | |
| "reply_with_sticker": "_tool_messaging", | |
| "forward_message": "_tool_messaging", | |
| "forward_last_messages": "_tool_messaging", | |
| "copy_message_to_chat": "_tool_messaging", | |
| "copy_text_to_chat": "_tool_messaging", | |
| "quote_message": "_tool_messaging", | |
| "resend_message": "_tool_messaging", | |
| "schedule_message": "_tool_messaging", | |
| "find_and_send_message": "_tool_messaging", | |
| "send_typing": "_tool_messaging", | |
| "send_upload_photo": "_tool_messaging", | |
| "send_upload_video": "_tool_messaging", | |
| "send_upload_document": "_tool_messaging", | |
| "send_upload_audio": "_tool_messaging", | |
| "send_upload_voice": "_tool_messaging", | |
| "send_file": "_tool_media_send", | |
| "send_photo": "_tool_media_send", | |
| "send_video": "_tool_media_send", | |
| "send_audio": "_tool_media_send", | |
| "send_voice_note": "_tool_media_send", | |
| "send_document": "_tool_media_send", | |
| "send_animation": "_tool_media_send", | |
| "send_sticker": "_tool_media_send", | |
| "generate_image": "_tool_media_generate", | |
| "generate_video": "_tool_media_generate", | |
| "ban_user": "_tool_moderation", | |
| "kick_user": "_tool_moderation", | |
| "mute_user": "_tool_moderation", | |
| "unmute_user": "_tool_moderation", | |
| "unban_user": "_tool_moderation", | |
| "restrict_user_media": "_tool_moderation", | |
| "unrestrict_user_media": "_tool_moderation", | |
| "promote_user": "_tool_moderation", | |
| "demote_user": "_tool_moderation", | |
| "warn_user": "_tool_moderation", | |
| "block_user": "_tool_moderation", | |
| "unblock_user": "_tool_moderation", | |
| "delete_user_messages": "_tool_moderation", | |
| "report_spam_user": "_tool_moderation", | |
| "purge_chat_messages": "_tool_moderation", | |
| "mention_user": "_tool_moderation", | |
| "react_messages": "_tool_reactions", | |
| "send_reaction_last": "_tool_reactions", | |
| "get_dialogs": "_tool_dialogs", | |
| "get_dialog_by_name": "_tool_dialogs", | |
| "get_pinned_dialogs": "_tool_dialogs", | |
| "get_archived_dialogs": "_tool_dialogs", | |
| "get_dialog_folders": "_tool_dialogs", | |
| "get_dialogs_count": "_tool_dialogs", | |
| "get_unread_overview": "_tool_dialogs", | |
| "get_dialog_messages_count": "_tool_dialogs", | |
| "mark_dialog_unread": "_tool_dialogs", | |
| "pin_dialog": "_tool_dialogs", | |
| "unpin_dialog": "_tool_dialogs", | |
| "mute_dialog": "_tool_dialogs", | |
| "unmute_dialog": "_tool_dialogs", | |
| "clear_dialog": "_tool_dialogs", | |
| "delete_dialog": "_tool_dialogs", | |
| "archive_dialog": "_tool_dialogs", | |
| "unarchive_dialog": "_tool_dialogs", | |
| "get_chat_info": "_tool_chat_info", | |
| "get_full_chat": "_tool_chat_info", | |
| "get_chat_stats": "_tool_chat_info", | |
| "get_chat_history": "_tool_chat_info", | |
| "get_chat_active_users": "_tool_chat_info", | |
| "get_chat_membership": "_tool_chat_info", | |
| "get_member_role": "_tool_chat_info", | |
| "get_moderation_capabilities": "_tool_chat_info", | |
| "get_permissions": "_tool_chat_info", | |
| "get_participants": "_tool_participants", | |
| "get_chat_participants": "_tool_participants", | |
| "search_participants": "_tool_participants", | |
| "get_chat_admins": "_tool_participants", | |
| "get_online_count": "_tool_participants", | |
| "get_message_by_id": "_tool_messages_info", | |
| "get_last_message": "_tool_messages_info", | |
| "get_last_outgoing_message": "_tool_messages_info", | |
| "get_last_incoming_message": "_tool_messages_info", | |
| "get_messages_by_ids": "_tool_messages_info", | |
| "get_messages_range": "_tool_messages_info", | |
| "get_message_context": "_tool_messages_info", | |
| "get_message_thread": "_tool_messages_info", | |
| "get_message_replies": "_tool_messages_info", | |
| "get_message_file_info": "_tool_messages_info", | |
| "get_message_sender": "_tool_messages_info", | |
| "get_message_stats": "_tool_messages_info", | |
| "get_reply_info": "_tool_messages_info", | |
| "get_message_link": "_tool_messages_info", | |
| "get_current_chat_context": "_tool_messages_info", | |
| "get_pinned_messages": "_tool_messages_info", | |
| "pin_message": "_tool_messages_info", | |
| "unpin_message": "_tool_messages_info", | |
| "read_history": "_tool_messages_info", | |
| "mark_chat_read": "_tool_messages_info", | |
| "search_messages": "_tool_search", | |
| "search_recent_messages": "_tool_search", | |
| "search_messages_from_user": "_tool_search", | |
| "search_contacts": "_tool_search", | |
| "search_links": "_tool_search", | |
| "search_photos": "_tool_search", | |
| "search_videos": "_tool_search", | |
| "search_documents": "_tool_search", | |
| "search_audio": "_tool_search", | |
| "search_voice": "_tool_search", | |
| "search_gifs": "_tool_search", | |
| "get_user_info": "_tool_user_info", | |
| "get_full_user": "_tool_user_info", | |
| "get_common_chats_with_user": "_tool_user_info", | |
| "get_user_media": "_tool_user_info", | |
| "get_user_last_messages": "_tool_user_info", | |
| "get_users_chats": "_tool_user_info", | |
| "get_peer_stories": "_tool_user_info", | |
| "read_peer_stories": "_tool_user_info", | |
| "get_recent_media": "_tool_media_history", | |
| "get_recent_links": "_tool_media_history", | |
| "get_recent_photos": "_tool_media_history", | |
| "get_recent_videos": "_tool_media_history", | |
| "get_recent_documents": "_tool_media_history", | |
| "get_recent_audio": "_tool_media_history", | |
| "get_recent_voice": "_tool_media_history", | |
| "get_recent_gifs": "_tool_media_history", | |
| "get_saved_messages": "_tool_media_history", | |
| "get_saved_messages_count": "_tool_media_history", | |
| "save_message_media": "_tool_media_history", | |
| "save_recent_media": "_tool_media_history", | |
| "get_profile_photos": "_tool_profile", | |
| "get_user_profile_photos": "_tool_profile", | |
| "delete_profile_photos": "_tool_profile", | |
| "get_self_profile": "_tool_profile", | |
| "get_self_profile_full": "_tool_profile", | |
| "set_profile_name": "_tool_profile", | |
| "set_profile_bio": "_tool_profile", | |
| "set_profile_first_name": "_tool_profile", | |
| "set_profile_last_name": "_tool_profile", | |
| "set_profile_about": "_tool_profile", | |
| "clear_profile_bio": "_tool_profile", | |
| "set_profile_photo": "_tool_profile", | |
| "send_profile_photo": "_tool_profile", | |
| "save_profile_photo": "_tool_profile", | |
| "save_profile_text": "_tool_profile", | |
| "save_profile_bundle": "_tool_profile", | |
| "clone_profile_text": "_tool_profile", | |
| "clone_profile": "_tool_profile", | |
| "set_random_profile_photo": "_tool_profile", | |
| "cycle_profile_photos": "_tool_profile", | |
| "set_profile_username": "_tool_profile", | |
| "add_contact": "_tool_contacts", | |
| "delete_contact": "_tool_contacts", | |
| "get_contacts": "_tool_contacts", | |
| "get_contacts_count": "_tool_contacts", | |
| "get_blocked_users": "_tool_contacts", | |
| "join_chat": "_tool_chat_management", | |
| "leave_chat": "_tool_chat_management", | |
| "invite_user_to_chat": "_tool_chat_management", | |
| "set_chat_title": "_tool_chat_management", | |
| "set_chat_about": "_tool_chat_management", | |
| "set_chat_photo": "_tool_chat_management", | |
| "delete_chat_photo": "_tool_chat_management", | |
| "export_chat_invite": "_tool_chat_management", | |
| "get_drafts": "_tool_chat_management", | |
| "set_draft": "_tool_chat_management", | |
| "clear_draft": "_tool_chat_management", | |
| "save_url_file": "_tool_file_ops", | |
| "send_url_file": "_tool_file_ops", | |
| "upload_to_hosting": "_tool_file_ops", | |
| "mirror_to_hosting": "_tool_file_ops", | |
| "sleep": "_tool_utility", | |
| "set_context": "_tool_utility", | |
| "merge_objects": "_tool_utility", | |
| "extract_field": "_tool_utility", | |
| "pick_random_item": "_tool_utility", | |
| "slice_items": "_tool_utility", | |
| "sort_items": "_tool_utility", | |
| "dedupe_items": "_tool_utility", | |
| "count_items": "_tool_utility", | |
| "coalesce_values": "_tool_utility", | |
| "build_text": "_tool_utility", | |
| "resolve_target": "_tool_utility", | |
| "smart_flow": "_tool_advanced", | |
| "batch_actions": "_tool_advanced", | |
| "send_contact": "_tool_extended", | |
| "send_location": "_tool_extended", | |
| "send_dice": "_tool_extended", | |
| "send_poll": "_tool_extended", | |
| "get_chat_member": "_tool_extended", | |
| "get_top_peers": "_tool_extended", | |
| "get_sticker_set": "_tool_extended", | |
| "get_invite_links": "_tool_extended", | |
| "create_invite_link": "_tool_extended", | |
| "revoke_invite_link": "_tool_extended", | |
| "unpin_all_messages": "_tool_extended", | |
| "set_slow_mode": "_tool_extended", | |
| "get_bot_commands": "_tool_extended", | |
| "set_bot_commands": "_tool_extended", | |
| # ── CLI tools (shell, fs, web, workspace) ── | |
| "shell_run": "_tool_cli", | |
| "shell_stream": "_tool_cli", | |
| "shell_status": "_tool_cli", | |
| "shell_kill": "_tool_cli", | |
| "fs_list": "_tool_cli", | |
| "fs_tree": "_tool_cli", | |
| "fs_search": "_tool_cli", | |
| "fs_grep": "_tool_cli", | |
| "fs_read": "_tool_cli", | |
| "fs_write": "_tool_cli", | |
| "fs_edit": "_tool_cli", | |
| "fs_append": "_tool_cli", | |
| "fs_insert": "_tool_cli", | |
| "fs_mkdir": "_tool_cli", | |
| "fs_move": "_tool_cli", | |
| "fs_copy": "_tool_cli", | |
| "fs_delete": "_tool_cli", | |
| "fs_info": "_tool_cli", | |
| "fs_zip": "_tool_cli", | |
| "fs_unzip": "_tool_cli", | |
| "web_fetch": "_tool_cli", | |
| "web_download": "_tool_cli", | |
| "workspace_info": "_tool_cli", | |
| "workspace_clean": "_tool_cli", | |
| "web_search": "_tool_cli", | |
| "memory_write": "_tool_cli", | |
| "memory_read": "_tool_cli", | |
| "memory_search": "_tool_cli", | |
| "memory_list": "_tool_cli", | |
| "memory_semantic": "_tool_cli", | |
| "git_status": "_tool_cli", | |
| "git_diff": "_tool_cli", | |
| "git_log": "_tool_cli", | |
| "git_add": "_tool_cli", | |
| "git_commit": "_tool_cli", | |
| "git_push": "_tool_cli", | |
| "git_pull": "_tool_cli", | |
| "git_branch": "_tool_cli", | |
| "git_checkout": "_tool_cli", | |
| "git_clone": "_tool_cli", | |
| "git_remote": "_tool_cli", | |
| "shell_log": "_tool_cli", | |
| "shell_list": "_tool_cli", | |
| "shell_clean": "_tool_cli", | |
| "image_analyze": "_tool_cli", | |
| "schedule_add": "_tool_cli", | |
| "schedule_list": "_tool_cli", | |
| "schedule_remove": "_tool_cli", | |
| "agent_spawn": "_tool_cli", | |
| "fs_diff": "_tool_cli", | |
| "code_run": "_tool_cli", | |
| "json_parse": "_tool_cli", | |
| "csv_parse": "_tool_cli", | |
| # ── Telethon TL methods (auto-generated, 582 actions) ── | |
| "accept_contact": "_tool_telethon", | |
| "accept_encryption": "_tool_telethon", | |
| "accept_url_auth": "_tool_telethon", | |
| "activate_stealth_mode": "_tool_telethon", | |
| "add_chat_user": "_tool_telethon", | |
| "add_contact": "_tool_telethon", | |
| "add_poll_answer": "_tool_telethon", | |
| "add_sticker_to_set": "_tool_telethon", | |
| "allow_send_message": "_tool_telethon", | |
| "app_web_view": "_tool_telethon", | |
| "append_todo_list": "_tool_telethon", | |
| "apply_boost": "_tool_telethon", | |
| "apply_gift_code": "_tool_telethon", | |
| "assign_app_store_transaction": "_tool_telethon", | |
| "assign_play_market_transaction": "_tool_telethon", | |
| "block": "_tool_telethon", | |
| "block_from_replies": "_tool_telethon", | |
| "bot_cancel_stars_subscription": "_tool_telethon", | |
| "can_purchase_store": "_tool_telethon", | |
| "can_send_message": "_tool_telethon", | |
| "can_send_story": "_tool_telethon", | |
| "change_authorization_settings": "_tool_telethon", | |
| "change_stars_subscription": "_tool_telethon", | |
| "change_sticker": "_tool_telethon", | |
| "change_sticker_position": "_tool_telethon", | |
| "channels_update_color": "_tool_telethon", | |
| "check_can_send_gift": "_tool_telethon", | |
| "check_chat_invite": "_tool_telethon", | |
| "check_chatlist_invite": "_tool_telethon", | |
| "check_gift_code": "_tool_telethon", | |
| "check_group_call": "_tool_telethon", | |
| "check_history_import": "_tool_telethon", | |
| "check_history_import_peer": "_tool_telethon", | |
| "check_quick_reply_shortcut": "_tool_telethon", | |
| "check_search_posts_flood": "_tool_telethon", | |
| "check_short_name": "_tool_telethon", | |
| "check_url_auth_match_code": "_tool_telethon", | |
| "check_username": "_tool_telethon", | |
| "clear_all_drafts": "_tool_telethon", | |
| "clear_recent_emoji_statuses": "_tool_telethon", | |
| "clear_recent_reactions": "_tool_telethon", | |
| "clear_recent_stickers": "_tool_telethon", | |
| "clear_saved_info": "_tool_telethon", | |
| "click_sponsored_message": "_tool_telethon", | |
| "compose_message_with_a_i": "_tool_telethon", | |
| "connect_star_ref_bot": "_tool_telethon", | |
| "convert_star_gift": "_tool_telethon", | |
| "convert_to_gigagroup": "_tool_telethon", | |
| "craft_star_gift": "_tool_telethon", | |
| "create_album": "_tool_telethon", | |
| "create_business_chat_link": "_tool_telethon", | |
| "create_channel": "_tool_telethon", | |
| "create_chat": "_tool_telethon", | |
| "create_forum_topic": "_tool_telethon", | |
| "create_group_call": "_tool_telethon", | |
| "create_star_gift_collection": "_tool_telethon", | |
| "create_sticker_set": "_tool_telethon", | |
| "create_theme": "_tool_telethon", | |
| "deactivate_all_usernames": "_tool_telethon", | |
| "decline_url_auth": "_tool_telethon", | |
| "delete_album": "_tool_telethon", | |
| "delete_auto_save_exceptions": "_tool_telethon", | |
| "delete_business_chat_link": "_tool_telethon", | |
| "delete_by_phones": "_tool_telethon", | |
| "delete_chat": "_tool_telethon", | |
| "delete_chat_user": "_tool_telethon", | |
| "delete_contacts": "_tool_telethon", | |
| "delete_exported_chat_invite": "_tool_telethon", | |
| "delete_exported_invite": "_tool_telethon", | |
| "delete_fact_check": "_tool_telethon", | |
| "delete_messages": "_tool_telethon", | |
| "delete_participant_history": "_tool_telethon", | |
| "delete_phone_call_history": "_tool_telethon", | |
| "delete_photos": "_tool_telethon", | |
| "delete_poll_answer": "_tool_telethon", | |
| "delete_quick_reply_messages": "_tool_telethon", | |
| "delete_quick_reply_shortcut": "_tool_telethon", | |
| "delete_revoked_exported_chat_invites": "_tool_telethon", | |
| "delete_saved_history": "_tool_telethon", | |
| "delete_scheduled_messages": "_tool_telethon", | |
| "delete_star_gift_collection": "_tool_telethon", | |
| "delete_sticker_set": "_tool_telethon", | |
| "delete_stories": "_tool_telethon", | |
| "delete_topic_history": "_tool_telethon", | |
| "disable_peer_connected_bot": "_tool_telethon", | |
| "discard_encryption": "_tool_telethon", | |
| "discard_group_call": "_tool_telethon", | |
| "edit_admin": "_tool_telethon", | |
| "edit_banned": "_tool_telethon", | |
| "edit_business_chat_link": "_tool_telethon", | |
| "edit_chat_about": "_tool_telethon", | |
| "edit_chat_admin": "_tool_telethon", | |
| "edit_chat_creator": "_tool_telethon", | |
| "edit_chat_default_banned_rights": "_tool_telethon", | |
| "edit_chat_participant_rank": "_tool_telethon", | |
| "edit_chat_photo": "_tool_telethon", | |
| "edit_chat_title": "_tool_telethon", | |
| "edit_close_friends": "_tool_telethon", | |
| "edit_connected_star_ref_bot": "_tool_telethon", | |
| "edit_exported_chat_invite": "_tool_telethon", | |
| "edit_exported_invite": "_tool_telethon", | |
| "edit_fact_check": "_tool_telethon", | |
| "edit_forum_topic": "_tool_telethon", | |
| "edit_group_call_participant": "_tool_telethon", | |
| "edit_group_call_title": "_tool_telethon", | |
| "edit_location": "_tool_telethon", | |
| "edit_message": "_tool_telethon", | |
| "edit_peer_folders": "_tool_telethon", | |
| "edit_photo": "_tool_telethon", | |
| "edit_quick_reply_shortcut": "_tool_telethon", | |
| "edit_story": "_tool_telethon", | |
| "edit_title": "_tool_telethon", | |
| "encryption": "_tool_telethon", | |
| "export_chat_invite": "_tool_telethon", | |
| "export_chatlist_invite": "_tool_telethon", | |
| "export_contact_token": "_tool_telethon", | |
| "export_group_call_invite": "_tool_telethon", | |
| "export_invoice": "_tool_telethon", | |
| "export_message_link": "_tool_telethon", | |
| "export_story_link": "_tool_telethon", | |
| "fave_sticker": "_tool_telethon", | |
| "finish_takeout_session": "_tool_telethon", | |
| "forward_messages": "_tool_telethon", | |
| "fulfill_stars_subscription": "_tool_telethon", | |
| "get_account_t_t_l": "_tool_telethon", | |
| "get_admin_log": "_tool_telethon", | |
| "get_admined_bots": "_tool_telethon", | |
| "get_admined_public_channels": "_tool_telethon", | |
| "get_admins_with_invites": "_tool_telethon", | |
| "get_album_stories": "_tool_telethon", | |
| "get_albums": "_tool_telethon", | |
| "get_all_drafts": "_tool_telethon", | |
| "get_all_read_peer_stories": "_tool_telethon", | |
| "get_all_stickers": "_tool_telethon", | |
| "get_all_stories": "_tool_telethon", | |
| "get_archived_stickers": "_tool_telethon", | |
| "get_attach_menu_bot": "_tool_telethon", | |
| "get_attach_menu_bots": "_tool_telethon", | |
| "get_attached_stickers": "_tool_telethon", | |
| "get_authorizations": "_tool_telethon", | |
| "get_auto_download_settings": "_tool_telethon", | |
| "get_auto_save_settings": "_tool_telethon", | |
| "get_available_effects": "_tool_telethon", | |
| "get_available_reactions": "_tool_telethon", | |
| "get_bank_card_data": "_tool_telethon", | |
| "get_birthdays": "_tool_telethon", | |
| "get_blocked": "_tool_telethon", | |
| "get_boosts_list": "_tool_telethon", | |
| "get_boosts_status": "_tool_telethon", | |
| "get_bot_app": "_tool_telethon", | |
| "get_bot_business_connection": "_tool_telethon", | |
| "get_bot_callback_answer": "_tool_telethon", | |
| "get_bot_commands": "_tool_telethon", | |
| "get_bot_info": "_tool_telethon", | |
| "get_bot_menu_button": "_tool_telethon", | |
| "get_broadcast_stats": "_tool_telethon", | |
| "get_business_chat_links": "_tool_telethon", | |
| "get_channel_default_emoji_statuses": "_tool_telethon", | |
| "get_channel_recommendations": "_tool_telethon", | |
| "get_channel_restricted_status_emojis": "_tool_telethon", | |
| "get_channels": "_tool_telethon", | |
| "get_chat_invite_importers": "_tool_telethon", | |
| "get_chat_themes": "_tool_telethon", | |
| "get_chatlist_updates": "_tool_telethon", | |
| "get_chats": "_tool_telethon", | |
| "get_chats_to_send": "_tool_telethon", | |
| "get_collectible_emoji_statuses": "_tool_telethon", | |
| "get_collectible_info": "_tool_telethon", | |
| "get_common_chats": "_tool_telethon", | |
| "get_connected_bots": "_tool_telethon", | |
| "get_connected_star_ref_bot": "_tool_telethon", | |
| "get_connected_star_ref_bots": "_tool_telethon", | |
| "get_contact_i_ds": "_tool_telethon", | |
| "get_contact_sign_up_notification": "_tool_telethon", | |
| "get_contacts": "_tool_telethon", | |
| "get_content_settings": "_tool_telethon", | |
| "get_craft_star_gifts": "_tool_telethon", | |
| "get_custom_emoji_documents": "_tool_telethon", | |
| "get_default_background_emojis": "_tool_telethon", | |
| "get_default_emoji_statuses": "_tool_telethon", | |
| "get_default_group_photo_emojis": "_tool_telethon", | |
| "get_default_history_t_t_l": "_tool_telethon", | |
| "get_default_profile_photo_emojis": "_tool_telethon", | |
| "get_default_tag_reactions": "_tool_telethon", | |
| "get_dialog_filters": "_tool_telethon", | |
| "get_dialog_unread_marks": "_tool_telethon", | |
| "get_dialogs": "_tool_telethon", | |
| "get_discussion_message": "_tool_telethon", | |
| "get_document_by_hash": "_tool_telethon", | |
| "get_emoji_groups": "_tool_telethon", | |
| "get_emoji_keywords": "_tool_telethon", | |
| "get_emoji_profile_photo_groups": "_tool_telethon", | |
| "get_emoji_status_groups": "_tool_telethon", | |
| "get_emoji_sticker_groups": "_tool_telethon", | |
| "get_emoji_stickers": "_tool_telethon", | |
| "get_emoji_u_r_l": "_tool_telethon", | |
| "get_exported_chat_invite": "_tool_telethon", | |
| "get_exported_chat_invites": "_tool_telethon", | |
| "get_exported_invites": "_tool_telethon", | |
| "get_extended_media": "_tool_telethon", | |
| "get_fact_check": "_tool_telethon", | |
| "get_faved_stickers": "_tool_telethon", | |
| "get_featured_emoji_stickers": "_tool_telethon", | |
| "get_featured_stickers": "_tool_telethon", | |
| "get_forum_topics": "_tool_telethon", | |
| "get_forum_topics_by_i_d": "_tool_telethon", | |
| "get_full_channel": "_tool_telethon", | |
| "get_full_chat": "_tool_telethon", | |
| "get_full_user": "_tool_telethon", | |
| "get_game_high_scores": "_tool_telethon", | |
| "get_giveaway_info": "_tool_telethon", | |
| "get_global_privacy_settings": "_tool_telethon", | |
| "get_group_call": "_tool_telethon", | |
| "get_group_call_join_as": "_tool_telethon", | |
| "get_group_call_stream_channels": "_tool_telethon", | |
| "get_group_call_stream_rtmp_url": "_tool_telethon", | |
| "get_group_participants": "_tool_telethon", | |
| "get_groups_for_discussion": "_tool_telethon", | |
| "get_history": "_tool_telethon", | |
| "get_inactive_channels": "_tool_telethon", | |
| "get_inline_bot_results": "_tool_telethon", | |
| "get_inline_game_high_scores": "_tool_telethon", | |
| "get_leave_chatlist_suggestions": "_tool_telethon", | |
| "get_left_channels": "_tool_telethon", | |
| "get_located": "_tool_telethon", | |
| "get_mask_stickers": "_tool_telethon", | |
| "get_megagroup_stats": "_tool_telethon", | |
| "get_message_author": "_tool_telethon", | |
| "get_message_edit_data": "_tool_telethon", | |
| "get_message_public_forwards": "_tool_telethon", | |
| "get_message_reactions_list": "_tool_telethon", | |
| "get_message_read_participants": "_tool_telethon", | |
| "get_message_stats": "_tool_telethon", | |
| "get_messages": "_tool_telethon", | |
| "get_messages_reactions": "_tool_telethon", | |
| "get_messages_views": "_tool_telethon", | |
| "get_multi_wall_papers": "_tool_telethon", | |
| "get_my_boosts": "_tool_telethon", | |
| "get_my_stickers": "_tool_telethon", | |
| "get_notify_exceptions": "_tool_telethon", | |
| "get_notify_settings": "_tool_telethon", | |
| "get_old_featured_stickers": "_tool_telethon", | |
| "get_onlines": "_tool_telethon", | |
| "get_outbox_read_date": "_tool_telethon", | |
| "get_paid_reaction_privacy": "_tool_telethon", | |
| "get_participant": "_tool_telethon", | |
| "get_participants": "_tool_telethon", | |
| "get_password": "_tool_telethon", | |
| "get_payment_form": "_tool_telethon", | |
| "get_payment_receipt": "_tool_telethon", | |
| "get_peer_dialogs": "_tool_telethon", | |
| "get_peer_max_i_ds": "_tool_telethon", | |
| "get_peer_settings": "_tool_telethon", | |
| "get_peer_stories": "_tool_telethon", | |
| "get_pinned_dialogs": "_tool_telethon", | |
| "get_pinned_saved_dialogs": "_tool_telethon", | |
| "get_pinned_stories": "_tool_telethon", | |
| "get_poll_results": "_tool_telethon", | |
| "get_premium_gift_code_options": "_tool_telethon", | |
| "get_prepared_inline_message": "_tool_telethon", | |
| "get_privacy": "_tool_telethon", | |
| "get_quick_replies": "_tool_telethon", | |
| "get_quick_reply_messages": "_tool_telethon", | |
| "get_reactions_notify_settings": "_tool_telethon", | |
| "get_recent_emoji_statuses": "_tool_telethon", | |
| "get_recent_locations": "_tool_telethon", | |
| "get_recent_reactions": "_tool_telethon", | |
| "get_recent_stickers": "_tool_telethon", | |
| "get_replies": "_tool_telethon", | |
| "get_requirements_to_contact": "_tool_telethon", | |
| "get_resale_star_gifts": "_tool_telethon", | |
| "get_saved": "_tool_telethon", | |
| "get_saved_dialogs": "_tool_telethon", | |
| "get_saved_dialogs_by_i_d": "_tool_telethon", | |
| "get_saved_gifs": "_tool_telethon", | |
| "get_saved_history": "_tool_telethon", | |
| "get_saved_info": "_tool_telethon", | |
| "get_saved_music": "_tool_telethon", | |
| "get_saved_music_by_i_d": "_tool_telethon", | |
| "get_saved_music_ids": "_tool_telethon", | |
| "get_saved_reaction_tags": "_tool_telethon", | |
| "get_saved_ringtones": "_tool_telethon", | |
| "get_saved_star_gift": "_tool_telethon", | |
| "get_saved_star_gifts": "_tool_telethon", | |
| "get_scheduled_history": "_tool_telethon", | |
| "get_scheduled_messages": "_tool_telethon", | |
| "get_search_counters": "_tool_telethon", | |
| "get_search_results_calendar": "_tool_telethon", | |
| "get_search_results_positions": "_tool_telethon", | |
| "get_send_as": "_tool_telethon", | |
| "get_split_ranges": "_tool_telethon", | |
| "get_sponsored_messages": "_tool_telethon", | |
| "get_sponsored_peers": "_tool_telethon", | |
| "get_star_gift_active_auctions": "_tool_telethon", | |
| "get_star_gift_auction_acquired_gifts": "_tool_telethon", | |
| "get_star_gift_auction_state": "_tool_telethon", | |
| "get_star_gift_collections": "_tool_telethon", | |
| "get_star_gift_upgrade_attributes": "_tool_telethon", | |
| "get_star_gift_upgrade_preview": "_tool_telethon", | |
| "get_star_gift_withdrawal_url": "_tool_telethon", | |
| "get_star_gifts": "_tool_telethon", | |
| "get_stars_gift_options": "_tool_telethon", | |
| "get_stars_giveaway_options": "_tool_telethon", | |
| "get_stars_revenue_ads_account_url": "_tool_telethon", | |
| "get_stars_revenue_stats": "_tool_telethon", | |
| "get_stars_revenue_withdrawal_url": "_tool_telethon", | |
| "get_stars_status": "_tool_telethon", | |
| "get_stars_subscriptions": "_tool_telethon", | |
| "get_stars_topup_options": "_tool_telethon", | |
| "get_stars_transactions": "_tool_telethon", | |
| "get_stars_transactions_by_i_d": "_tool_telethon", | |
| "get_status": "_tool_telethon", | |
| "get_statuses": "_tool_telethon", | |
| "get_sticker_set": "_tool_telethon", | |
| "get_stories_archive": "_tool_telethon", | |
| "get_stories_by_i_d": "_tool_telethon", | |
| "get_stories_views": "_tool_telethon", | |
| "get_story_public_forwards": "_tool_telethon", | |
| "get_story_reactions_list": "_tool_telethon", | |
| "get_story_stats": "_tool_telethon", | |
| "get_story_views_list": "_tool_telethon", | |
| "get_suggested_dialog_filters": "_tool_telethon", | |
| "get_suggested_star_ref_bots": "_tool_telethon", | |
| "get_theme": "_tool_telethon", | |
| "get_themes": "_tool_telethon", | |
| "get_tmp_password": "_tool_telethon", | |
| "get_top_peers": "_tool_telethon", | |
| "get_top_reactions": "_tool_telethon", | |
| "get_unique_star_gift": "_tool_telethon", | |
| "get_unread_mentions": "_tool_telethon", | |
| "get_unread_poll_votes": "_tool_telethon", | |
| "get_unread_reactions": "_tool_telethon", | |
| "get_user_boosts": "_tool_telethon", | |
| "get_user_photos": "_tool_telethon", | |
| "get_users": "_tool_telethon", | |
| "get_wall_paper": "_tool_telethon", | |
| "get_wall_papers": "_tool_telethon", | |
| "get_web_authorizations": "_tool_telethon", | |
| "get_web_page": "_tool_telethon", | |
| "get_web_page_preview": "_tool_telethon", | |
| "geted_web_view_button": "_tool_telethon", | |
| "hide_all_chat_joins": "_tool_telethon", | |
| "hide_chat_join": "_tool_telethon", | |
| "hide_chatlist_updates": "_tool_telethon", | |
| "hide_peer_settings_bar": "_tool_telethon", | |
| "import_chat_invite": "_tool_telethon", | |
| "import_contact_token": "_tool_telethon", | |
| "import_contacts": "_tool_telethon", | |
| "increment_story_views": "_tool_telethon", | |
| "init_history_import": "_tool_telethon", | |
| "init_takeout_session": "_tool_telethon", | |
| "install_sticker_set": "_tool_telethon", | |
| "install_theme": "_tool_telethon", | |
| "install_wall_paper": "_tool_telethon", | |
| "invalidate_sign_in_codes": "_tool_telethon", | |
| "invite_to_channel": "_tool_telethon", | |
| "invite_to_group_call": "_tool_telethon", | |
| "invoke_web_view_custom_method": "_tool_telethon", | |
| "is_eligible_to_join": "_tool_telethon", | |
| "join_channel": "_tool_telethon", | |
| "join_chatlist_invite": "_tool_telethon", | |
| "join_chatlist_updates": "_tool_telethon", | |
| "join_group_call": "_tool_telethon", | |
| "launch_prepaid_giveaway": "_tool_telethon", | |
| "leave_channel": "_tool_telethon", | |
| "leave_chatlist": "_tool_telethon", | |
| "leave_group_call": "_tool_telethon", | |
| "load_async_graph": "_tool_telethon", | |
| "main_web_view": "_tool_telethon", | |
| "mark_dialog_unread": "_tool_telethon", | |
| "messages_read_poll_votes": "_tool_telethon", | |
| "migrate_chat": "_tool_telethon", | |
| "prolong_web_view": "_tool_telethon", | |
| "rate_transcribed_audio": "_tool_telethon", | |
| "read_discussion": "_tool_telethon", | |
| "read_encrypted_history": "_tool_telethon", | |
| "read_featured_stickers": "_tool_telethon", | |
| "read_history": "_tool_telethon", | |
| "read_mentions": "_tool_telethon", | |
| "read_message_contents": "_tool_telethon", | |
| "read_poll_votes": "_tool_telethon", | |
| "read_reactions": "_tool_telethon", | |
| "read_saved_history": "_tool_telethon", | |
| "read_stories": "_tool_telethon", | |
| "refund_stars_charge": "_tool_telethon", | |
| "remove_sticker_from_set": "_tool_telethon", | |
| "rename_sticker_set": "_tool_telethon", | |
| "reorder_albums": "_tool_telethon", | |
| "reorder_pinned_dialogs": "_tool_telethon", | |
| "reorder_pinned_forum_topics": "_tool_telethon", | |
| "reorder_pinned_saved_dialogs": "_tool_telethon", | |
| "reorder_quick_replies": "_tool_telethon", | |
| "reorder_star_gift_collections": "_tool_telethon", | |
| "reorder_sticker_sets": "_tool_telethon", | |
| "reorder_usernames": "_tool_telethon", | |
| "replace_sticker": "_tool_telethon", | |
| "report": "_tool_telethon", | |
| "report_anti_spam_false_positive": "_tool_telethon", | |
| "report_encrypted_spam": "_tool_telethon", | |
| "report_messages_delivery": "_tool_telethon", | |
| "report_music_listen": "_tool_telethon", | |
| "report_peer": "_tool_telethon", | |
| "report_profile_photo": "_tool_telethon", | |
| "report_reaction": "_tool_telethon", | |
| "report_read_metrics": "_tool_telethon", | |
| "report_spam": "_tool_telethon", | |
| "report_sponsored_message": "_tool_telethon", | |
| "reset_authorization": "_tool_telethon", | |
| "reset_bot_commands": "_tool_telethon", | |
| "reset_notify_settings": "_tool_telethon", | |
| "reset_saved": "_tool_telethon", | |
| "reset_top_peer_rating": "_tool_telethon", | |
| "reset_wall_papers": "_tool_telethon", | |
| "reset_web_authorization": "_tool_telethon", | |
| "reset_web_authorizations": "_tool_telethon", | |
| "resolve_business_chat_link": "_tool_telethon", | |
| "resolve_phone": "_tool_telethon", | |
| "resolve_star_gift_offer": "_tool_telethon", | |
| "resolve_username": "_tool_telethon", | |
| "restrict_sponsored_messages": "_tool_telethon", | |
| "save_auto_download_settings": "_tool_telethon", | |
| "save_auto_save_settings": "_tool_telethon", | |
| "save_call_debug": "_tool_telethon", | |
| "save_call_log": "_tool_telethon", | |
| "save_default_group_call_join_as": "_tool_telethon", | |
| "save_default_send_as": "_tool_telethon", | |
| "save_draft": "_tool_telethon", | |
| "save_gif": "_tool_telethon", | |
| "save_music": "_tool_telethon", | |
| "save_prepared_inline_message": "_tool_telethon", | |
| "save_recent_sticker": "_tool_telethon", | |
| "save_ringtone": "_tool_telethon", | |
| "save_star_gift": "_tool_telethon", | |
| "save_theme": "_tool_telethon", | |
| "save_wall_paper": "_tool_telethon", | |
| "search": "_tool_telethon", | |
| "search_custom_emoji": "_tool_telethon", | |
| "search_emoji_sticker_sets": "_tool_telethon", | |
| "search_global": "_tool_telethon", | |
| "search_sticker_sets": "_tool_telethon", | |
| "send_boted_peer": "_tool_telethon", | |
| "send_custom": "_tool_telethon", | |
| "send_encrypted": "_tool_telethon", | |
| "send_encrypted_file": "_tool_telethon", | |
| "send_encrypted_service": "_tool_telethon", | |
| "send_inline_bot_result": "_tool_telethon", | |
| "send_media": "_tool_telethon", | |
| "send_message": "_tool_telethon", | |
| "send_multi_media": "_tool_telethon", | |
| "send_paid_reaction": "_tool_telethon", | |
| "send_payment_form": "_tool_telethon", | |
| "send_quick_reply_messages": "_tool_telethon", | |
| "send_reaction": "_tool_telethon", | |
| "send_scheduled_messages": "_tool_telethon", | |
| "send_screenshot_notification": "_tool_telethon", | |
| "send_star_gift_offer": "_tool_telethon", | |
| "send_stars_form": "_tool_telethon", | |
| "send_story": "_tool_telethon", | |
| "send_vote": "_tool_telethon", | |
| "send_web_view_data": "_tool_telethon", | |
| "send_web_view_result_message": "_tool_telethon", | |
| "set_account_t_t_l": "_tool_telethon", | |
| "set_boosts_to_unblock_restrictions": "_tool_telethon", | |
| "set_bot_broadcast_default_admin_rights": "_tool_telethon", | |
| "set_bot_callback_answer": "_tool_telethon", | |
| "set_bot_commands": "_tool_telethon", | |
| "set_bot_group_default_admin_rights": "_tool_telethon", | |
| "set_bot_info": "_tool_telethon", | |
| "set_bot_menu_button": "_tool_telethon", | |
| "set_bot_precheckout_results": "_tool_telethon", | |
| "set_bot_shipping_results": "_tool_telethon", | |
| "set_call_rating": "_tool_telethon", | |
| "set_chat_available_reactions": "_tool_telethon", | |
| "set_chat_theme": "_tool_telethon", | |
| "set_chat_wall_paper": "_tool_telethon", | |
| "set_contact_sign_up_notification": "_tool_telethon", | |
| "set_content_settings": "_tool_telethon", | |
| "set_custom_verification": "_tool_telethon", | |
| "set_default_history_t_t_l": "_tool_telethon", | |
| "set_default_reaction": "_tool_telethon", | |
| "set_discussion_group": "_tool_telethon", | |
| "set_emoji_stickers": "_tool_telethon", | |
| "set_encrypted_typing": "_tool_telethon", | |
| "set_game_score": "_tool_telethon", | |
| "set_global_privacy_settings": "_tool_telethon", | |
| "set_history_t_t_l": "_tool_telethon", | |
| "set_inline_game_score": "_tool_telethon", | |
| "set_main_profile_tab": "_tool_telethon", | |
| "set_privacy": "_tool_telethon", | |
| "set_reactions_notify_settings": "_tool_telethon", | |
| "set_secure_value_errors": "_tool_telethon", | |
| "set_sticker_set_thumb": "_tool_telethon", | |
| "set_stickers": "_tool_telethon", | |
| "set_typing": "_tool_telethon", | |
| "simple_web_view": "_tool_telethon", | |
| "start_bot": "_tool_telethon", | |
| "start_history_import": "_tool_telethon", | |
| "start_live": "_tool_telethon", | |
| "start_scheduled_group_call": "_tool_telethon", | |
| "suggest_birthday": "_tool_telethon", | |
| "suggest_short_name": "_tool_telethon", | |
| "summarize_text": "_tool_telethon", | |
| "toggle_all_stories_hidden": "_tool_telethon", | |
| "toggle_anti_spam": "_tool_telethon", | |
| "toggle_autotranslation": "_tool_telethon", | |
| "toggle_bot_in_attach_menu": "_tool_telethon", | |
| "toggle_chat_star_gift_notifications": "_tool_telethon", | |
| "toggle_connected_bot_paused": "_tool_telethon", | |
| "toggle_dialog_filter_tags": "_tool_telethon", | |
| "toggle_dialog_pin": "_tool_telethon", | |
| "toggle_forum": "_tool_telethon", | |
| "toggle_group_call_record": "_tool_telethon", | |
| "toggle_group_call_settings": "_tool_telethon", | |
| "toggle_group_call_start_subscription": "_tool_telethon", | |
| "toggle_join": "_tool_telethon", | |
| "toggle_join_to_send": "_tool_telethon", | |
| "toggle_no_forwards": "_tool_telethon", | |
| "toggle_paid_reaction_privacy": "_tool_telethon", | |
| "toggle_participants_hidden": "_tool_telethon", | |
| "toggle_peer_stories_hidden": "_tool_telethon", | |
| "toggle_peer_translations": "_tool_telethon", | |
| "toggle_pinned": "_tool_telethon", | |
| "toggle_pinned_to_top": "_tool_telethon", | |
| "toggle_pre_history_hidden": "_tool_telethon", | |
| "toggle_saved_dialog_pin": "_tool_telethon", | |
| "toggle_signatures": "_tool_telethon", | |
| "toggle_slow_mode": "_tool_telethon", | |
| "toggle_sponsored_messages": "_tool_telethon", | |
| "toggle_star_gifts_pinned_to_top": "_tool_telethon", | |
| "toggle_sticker_sets": "_tool_telethon", | |
| "toggle_suggested_post_approval": "_tool_telethon", | |
| "toggle_todo_completed": "_tool_telethon", | |
| "toggle_top_peers": "_tool_telethon", | |
| "toggle_user_emoji_status_permission": "_tool_telethon", | |
| "toggle_username": "_tool_telethon", | |
| "toggle_view_forum_as_messages": "_tool_telethon", | |
| "transcribe_audio": "_tool_telethon", | |
| "transfer_star_gift": "_tool_telethon", | |
| "translate_text": "_tool_telethon", | |
| "unblock": "_tool_telethon", | |
| "uninstall_sticker_set": "_tool_telethon", | |
| "update_album": "_tool_telethon", | |
| "update_birthday": "_tool_telethon", | |
| "update_business_away_message": "_tool_telethon", | |
| "update_business_greeting_message": "_tool_telethon", | |
| "update_business_intro": "_tool_telethon", | |
| "update_business_location": "_tool_telethon", | |
| "update_business_work_hours": "_tool_telethon", | |
| "update_color": "_tool_telethon", | |
| "update_connected_bot": "_tool_telethon", | |
| "update_contact_note": "_tool_telethon", | |
| "update_device_locked": "_tool_telethon", | |
| "update_dialog_filter": "_tool_telethon", | |
| "update_dialog_filters_order": "_tool_telethon", | |
| "update_emoji_status": "_tool_telethon", | |
| "update_notify_settings": "_tool_telethon", | |
| "update_paid_messages_price": "_tool_telethon", | |
| "update_password_settings": "_tool_telethon", | |
| "update_personal_channel": "_tool_telethon", | |
| "update_pinned_forum_topic": "_tool_telethon", | |
| "update_pinned_message": "_tool_telethon", | |
| "update_profile": "_tool_telethon", | |
| "update_profile_photo": "_tool_telethon", | |
| "update_saved_reaction_tag": "_tool_telethon", | |
| "update_star_gift_collection": "_tool_telethon", | |
| "update_star_gift_price": "_tool_telethon", | |
| "update_star_ref_program": "_tool_telethon", | |
| "update_status": "_tool_telethon", | |
| "update_theme": "_tool_telethon", | |
| "update_user_emoji_status": "_tool_telethon", | |
| "update_username": "_tool_telethon", | |
| "upgrade_star_gift": "_tool_telethon", | |
| "upload_contact_profile_photo": "_tool_telethon", | |
| "upload_encrypted_file": "_tool_telethon", | |
| "upload_imported_media": "_tool_telethon", | |
| "upload_profile_photo": "_tool_telethon", | |
| "upload_ringtone": "_tool_telethon", | |
| "url_auth": "_tool_telethon", | |
| "validateed_info": "_tool_telethon", | |
| "view_sponsored_message": "_tool_telethon", | |
| "web_view": "_tool_telethon", | |
| "web_view_button": "_tool_telethon", | |
| } | |
| # ── Telethon TL dispatch map (auto-generated from telethon.tl.functions) ──(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| _TL_DISPATCH = { | |
| "accept_contact": ("contacts", "AcceptContactRequest", ["id"]), | |
| "accept_encryption": ("messages", "AcceptEncryptionRequest", ["peer", "g_b", "key_fingerprint"]), | |
| "accept_url_auth": ("messages", "AcceptUrlAuthRequest", ["write_allowed", "share_phone_number", "peer", "msg_id", "button_id", "url", "match_code"]), | |
| "activate_stealth_mode": ("stories", "ActivateStealthModeRequest", ["past", "future"]), | |
| "add_chat_user": ("messages", "AddChatUserRequest", ["chat_id", "user_id", "fwd_limit"]), | |
| "add_contact": ("contacts", "AddContactRequest", ["id", "first_name", "last_name", "phone", "add_phone_privacy_exception", "note"]), | |
| "add_poll_answer": ("messages", "AddPollAnswerRequest", ["peer", "msg_id", "answer"]), | |
| "add_sticker_to_set": ("stickers", "AddStickerToSetRequest", ["stickerset", "sticker"]), | |
| "allow_send_message": ("bots", "AllowSendMessageRequest", ["bot"]), | |
| "app_web_view": ("messages", "RequestAppWebViewRequest", ["peer", "app", "platform", "write_allowed", "compact", "fullscreen", "start_param", "theme_params"]), | |
| "append_todo_list": ("messages", "AppendTodoListRequest", ["peer", "msg_id", "list"]), | |
| "apply_boost": ("premium", "ApplyBoostRequest", ["peer", "slots"]), | |
| "apply_gift_code": ("payments", "ApplyGiftCodeRequest", ["slug"]), | |
| "assign_app_store_transaction": ("payments", "AssignAppStoreTransactionRequest", ["receipt", "purpose"]), | |
| "assign_play_market_transaction": ("payments", "AssignPlayMarketTransactionRequest", ["receipt", "purpose"]), | |
| "block": ("contacts", "BlockRequest", ["id", "my_stories_from"]), | |
| "block_from_replies": ("contacts", "BlockFromRepliesRequest", ["msg_id", "delete_message", "delete_history", "report_spam"]), | |
| "bot_cancel_stars_subscription": ("payments", "BotCancelStarsSubscriptionRequest", ["user_id", "charge_id", "restore"]), | |
| "can_purchase_store": ("payments", "CanPurchaseStoreRequest", ["purpose"]), | |
| "can_send_message": ("bots", "CanSendMessageRequest", ["bot"]), | |
| "can_send_story": ("stories", "CanSendStoryRequest", ["peer"]), | |
| "change_authorization_settings": ("account", "ChangeAuthorizationSettingsRequest", ["hash", "confirmed", "encrypted_requests_disabled", "call_requests_disabled"]), | |
| "change_stars_subscription": ("payments", "ChangeStarsSubscriptionRequest", ["peer", "subscription_id", "canceled"]), | |
| "change_sticker": ("stickers", "ChangeStickerRequest", ["sticker", "emoji", "mask_coords", "keywords"]), | |
| "change_sticker_position": ("stickers", "ChangeStickerPositionRequest", ["sticker", "position"]), | |
| "channels_update_color": ("channels", "UpdateColorRequest", ["channel", "for_profile", "color", "background_emoji_id"]), | |
| "check_can_send_gift": ("payments", "CheckCanSendGiftRequest", ["gift_id"]), | |
| "check_chat_invite": ("messages", "CheckChatInviteRequest", ["hash"]), | |
| "check_chatlist_invite": ("chatlists", "CheckChatlistInviteRequest", ["slug"]), | |
| "check_gift_code": ("payments", "CheckGiftCodeRequest", ["slug"]), | |
| "check_group_call": ("phone", "CheckGroupCallRequest", ["call", "sources"]), | |
| "check_history_import": ("messages", "CheckHistoryImportRequest", ["import_head"]), | |
| "check_history_import_peer": ("messages", "CheckHistoryImportPeerRequest", ["peer"]), | |
| "check_quick_reply_shortcut": ("messages", "CheckQuickReplyShortcutRequest", ["shortcut"]), | |
| "check_search_posts_flood": ("channels", "CheckSearchPostsFloodRequest", ["query"]), | |
| "check_short_name": ("stickers", "CheckShortNameRequest", ["short_name"]), | |
| "check_url_auth_match_code": ("messages", "CheckUrlAuthMatchCodeRequest", ["url", "match_code"]), | |
| "check_username": ("account", "CheckUsernameRequest", ["username"]), | |
| "clear_all_drafts": ("messages", "ClearAllDraftsRequest", []), | |
| "clear_recent_emoji_statuses": ("account", "ClearRecentEmojiStatusesRequest", []), | |
| "clear_recent_reactions": ("messages", "ClearRecentReactionsRequest", []), | |
| "clear_recent_stickers": ("messages", "ClearRecentStickersRequest", ["attached"]), | |
| "clear_saved_info": ("payments", "ClearSavedInfoRequest", ["credentials", "info"]), | |
| "click_sponsored_message": ("messages", "ClickSponsoredMessageRequest", ["media", "fullscreen", "random_id"]), | |
| "compose_message_with_a_i": ("messages", "ComposeMessageWithAIRequest", ["text", "proofread", "emojify", "translate_to_lang", "change_tone"]), | |
| "connect_star_ref_bot": ("payments", "ConnectStarRefBotRequest", ["peer", "bot"]), | |
| "convert_star_gift": ("payments", "ConvertStarGiftRequest", ["stargift"]), | |
| "convert_to_gigagroup": ("channels", "ConvertToGigagroupRequest", ["channel"]), | |
| "craft_star_gift": ("payments", "CraftStarGiftRequest", ["stargift"]), | |
| "create_album": ("stories", "CreateAlbumRequest", ["peer", "title", "stories"]), | |
| "create_business_chat_link": ("account", "CreateBusinessChatLinkRequest", ["link"]), | |
| "create_channel": ("channels", "CreateChannelRequest", ["title", "about", "broadcast", "megagroup", "for_import", "forum", "geo_point", "address", "ttl_period"]), | |
| "create_chat": ("messages", "CreateChatRequest", ["users", "title", "ttl_period"]), | |
| "create_forum_topic": ("messages", "CreateForumTopicRequest", ["peer", "title", "title_missing", "icon_color", "icon_emoji_id", "random_id", "send_as"]), | |
| "create_group_call": ("phone", "CreateGroupCallRequest", ["peer", "rtmp_stream", "random_id", "title", "schedule_date"]), | |
| "create_star_gift_collection": ("payments", "CreateStarGiftCollectionRequest", ["peer", "title", "stargift"]), | |
| "create_sticker_set": ("stickers", "CreateStickerSetRequest", ["user_id", "title", "short_name", "stickers", "masks", "emojis", "text_color", "thumb", "software"]), | |
| "create_theme": ("account", "CreateThemeRequest", ["slug", "title", "document", "settings"]), | |
| "deactivate_all_usernames": ("channels", "DeactivateAllUsernamesRequest", ["channel"]), | |
| "decline_url_auth": ("messages", "DeclineUrlAuthRequest", ["url"]), | |
| "delete_album": ("stories", "DeleteAlbumRequest", ["peer", "album_id"]), | |
| "delete_auto_save_exceptions": ("account", "DeleteAutoSaveExceptionsRequest", []), | |
| "delete_business_chat_link": ("account", "DeleteBusinessChatLinkRequest", ["slug"]), | |
| "delete_by_phones": ("contacts", "DeleteByPhonesRequest", ["phones"]), | |
| "delete_chat": ("messages", "DeleteChatRequest", ["chat_id"]), | |
| "delete_chat_user": ("messages", "DeleteChatUserRequest", ["chat_id", "user_id", "revoke_history"]), | |
| "delete_contacts": ("contacts", "DeleteContactsRequest", ["id"]), | |
| "delete_exported_chat_invite": ("messages", "DeleteExportedChatInviteRequest", ["peer", "link"]), | |
| "delete_exported_invite": ("chatlists", "DeleteExportedInviteRequest", ["chatlist", "slug"]), | |
| "delete_fact_check": ("messages", "DeleteFactCheckRequest", ["peer", "msg_id"]), | |
| "delete_messages": ("messages", "DeleteMessagesRequest", ["id", "revoke"]), | |
| "delete_participant_history": ("channels", "DeleteParticipantHistoryRequest", ["channel", "participant"]), | |
| "delete_phone_call_history": ("messages", "DeletePhoneCallHistoryRequest", ["revoke"]), | |
| "delete_photos": ("photos", "DeletePhotosRequest", ["id"]), | |
| "delete_poll_answer": ("messages", "DeletePollAnswerRequest", ["peer", "msg_id", "option"]), | |
| "delete_quick_reply_messages": ("messages", "DeleteQuickReplyMessagesRequest", ["shortcut_id", "id"]), | |
| "delete_quick_reply_shortcut": ("messages", "DeleteQuickReplyShortcutRequest", ["shortcut_id"]), | |
| "delete_revoked_exported_chat_invites": ("messages", "DeleteRevokedExportedChatInvitesRequest", ["peer", "admin_id"]), | |
| "delete_saved_history": ("messages", "DeleteSavedHistoryRequest", ["peer", "max_id", "parent_peer", "min_date", "max_date"]), | |
| "delete_scheduled_messages": ("messages", "DeleteScheduledMessagesRequest", ["peer", "id"]), | |
| "delete_star_gift_collection": ("payments", "DeleteStarGiftCollectionRequest", ["peer", "collection_id"]), | |
| "delete_sticker_set": ("stickers", "DeleteStickerSetRequest", ["stickerset"]), | |
| "delete_stories": ("stories", "DeleteStoriesRequest", ["peer", "id"]), | |
| "delete_topic_history": ("messages", "DeleteTopicHistoryRequest", ["peer", "top_msg_id"]), | |
| "disable_peer_connected_bot": ("account", "DisablePeerConnectedBotRequest", ["peer"]), | |
| "discard_encryption": ("messages", "DiscardEncryptionRequest", ["chat_id", "delete_history"]), | |
| "discard_group_call": ("phone", "DiscardGroupCallRequest", ["call"]), | |
| "edit_admin": ("channels", "EditAdminRequest", ["channel", "user_id", "admin_rights", "rank"]), | |
| "edit_banned": ("channels", "EditBannedRequest", ["channel", "participant", "banned_rights"]), | |
| "edit_business_chat_link": ("account", "EditBusinessChatLinkRequest", ["slug", "link"]), | |
| "edit_chat_about": ("messages", "EditChatAboutRequest", ["peer", "about"]), | |
| "edit_chat_admin": ("messages", "EditChatAdminRequest", ["chat_id", "user_id", "is_admin"]), | |
| "edit_chat_creator": ("messages", "EditChatCreatorRequest", ["peer", "user_id", "password"]), | |
| "edit_chat_default_banned_rights": ("messages", "EditChatDefaultBannedRightsRequest", ["peer", "banned_rights"]), | |
| "edit_chat_participant_rank": ("messages", "EditChatParticipantRankRequest", ["peer", "participant", "rank"]), | |
| "edit_chat_photo": ("messages", "EditChatPhotoRequest", ["chat_id", "photo"]), | |
| "edit_chat_title": ("messages", "EditChatTitleRequest", ["chat_id", "title"]), | |
| "edit_close_friends": ("contacts", "EditCloseFriendsRequest", ["id"]), | |
| "edit_connected_star_ref_bot": ("payments", "EditConnectedStarRefBotRequest", ["peer", "link", "revoked"]), | |
| "edit_exported_chat_invite": ("messages", "EditExportedChatInviteRequest", ["peer", "link", "revoked", "expire_date", "usage_limit", "request_needed", "title"]), | |
| "edit_exported_invite": ("chatlists", "EditExportedInviteRequest", ["chatlist", "slug", "title", "peers"]), | |
| "edit_fact_check": ("messages", "EditFactCheckRequest", ["peer", "msg_id", "text"]), | |
| "edit_forum_topic": ("messages", "EditForumTopicRequest", ["peer", "topic_id", "title", "icon_emoji_id", "closed", "hidden"]), | |
| "edit_group_call_participant": ("phone", "EditGroupCallParticipantRequest", ["call", "participant", "muted", "volume", "raise_hand", "video_stopped", "video_paused", "presentation_paused"]), | |
| "edit_group_call_title": ("phone", "EditGroupCallTitleRequest", ["call", "title"]), | |
| "edit_location": ("channels", "EditLocationRequest", ["channel", "geo_point", "address"]), | |
| "edit_message": ("messages", "EditMessageRequest", ["peer", "id", "no_webpage", "invert_media", "message", "media", "reply_markup", "entities", "schedule_date", "schedule_repeat_period", "quick_reply_shortcut_id"]), | |
| "edit_peer_folders": ("folders", "EditPeerFoldersRequest", ["folder_peers"]), | |
| "edit_photo": ("channels", "EditPhotoRequest", ["channel", "photo"]), | |
| "edit_quick_reply_shortcut": ("messages", "EditQuickReplyShortcutRequest", ["shortcut_id", "shortcut"]), | |
| "edit_story": ("stories", "EditStoryRequest", ["peer", "id", "media", "media_areas", "caption", "entities", "privacy_rules", "music"]), | |
| "edit_title": ("channels", "EditTitleRequest", ["channel", "title"]), | |
| "encryption": ("messages", "RequestEncryptionRequest", ["user_id", "g_a", "random_id"]), | |
| "export_chat_invite": ("messages", "ExportChatInviteRequest", ["peer", "legacy_revoke_permanent", "request_needed", "expire_date", "usage_limit", "title", "subscription_pricing"]), | |
| "export_chatlist_invite": ("chatlists", "ExportChatlistInviteRequest", ["chatlist", "title", "peers"]), | |
| "export_contact_token": ("contacts", "ExportContactTokenRequest", []), | |
| "export_group_call_invite": ("phone", "ExportGroupCallInviteRequest", ["call", "can_self_unmute"]), | |
| "export_invoice": ("payments", "ExportInvoiceRequest", ["invoice_media"]), | |
| "export_message_link": ("channels", "ExportMessageLinkRequest", ["channel", "id", "grouped", "thread"]), | |
| "export_story_link": ("stories", "ExportStoryLinkRequest", ["peer", "id"]), | |
| "fave_sticker": ("messages", "FaveStickerRequest", ["id", "unfave"]), | |
| "finish_takeout_session": ("account", "FinishTakeoutSessionRequest", ["success"]), | |
| "forward_messages": ("messages", "ForwardMessagesRequest", ["from_peer", "id", "to_peer", "silent", "background", "with_my_score", "drop_author", "drop_media_captions", "noforwards", "allow_paid_floodskip", "random_id", "top_msg_id", "reply_to", "schedule_date", "schedule_repeat_period", "send_as", "quick_reply_shortcut", "effect", "video_timestamp", "allow_paid_stars", "suggested_post"]), | |
| "fulfill_stars_subscription": ("payments", "FulfillStarsSubscriptionRequest", ["peer", "subscription_id"]), | |
| "get_account_t_t_l": ("account", "GetAccountTTLRequest", []), | |
| "get_admin_log": ("channels", "GetAdminLogRequest", ["channel", "q", "max_id", "min_id", "limit", "events_filter", "admins"]), | |
| "get_admined_bots": ("bots", "GetAdminedBotsRequest", []), | |
| "get_admined_public_channels": ("channels", "GetAdminedPublicChannelsRequest", ["by_location", "check_limit", "for_personal"]), | |
| "get_admins_with_invites": ("messages", "GetAdminsWithInvitesRequest", ["peer"]), | |
| "get_album_stories": ("stories", "GetAlbumStoriesRequest", ["peer", "album_id", "offset", "limit"]), | |
| "get_albums": ("stories", "GetAlbumsRequest", ["peer", "hash"]), | |
| "get_all_drafts": ("messages", "GetAllDraftsRequest", []), | |
| "get_all_read_peer_stories": ("stories", "GetAllReadPeerStoriesRequest", []), | |
| "get_all_stickers": ("messages", "GetAllStickersRequest", ["hash"]), | |
| "get_all_stories": ("stories", "GetAllStoriesRequest", ["next", "hidden", "state"]), | |
| "get_archived_stickers": ("messages", "GetArchivedStickersRequest", ["offset_id", "limit", "masks", "emojis"]), | |
| "get_attach_menu_bot": ("messages", "GetAttachMenuBotRequest", ["bot"]), | |
| "get_attach_menu_bots": ("messages", "GetAttachMenuBotsRequest", ["hash"]), | |
| "get_attached_stickers": ("messages", "GetAttachedStickersRequest", ["media"]), | |
| "get_authorizations": ("account", "GetAuthorizationsRequest", []), | |
| "get_auto_download_settings": ("account", "GetAutoDownloadSettingsRequest", []), | |
| "get_auto_save_settings": ("account", "GetAutoSaveSettingsRequest", []), | |
| "get_available_effects": ("messages", "GetAvailableEffectsRequest", ["hash"]), | |
| "get_available_reactions": ("messages", "GetAvailableReactionsRequest", ["hash"]), | |
| "get_bank_card_data": ("payments", "GetBankCardDataRequest", ["number"]), | |
| "get_birthdays": ("contacts", "GetBirthdaysRequest", []), | |
| "get_blocked": ("contacts", "GetBlockedRequest", ["offset", "limit", "my_stories_from"]), | |
| "get_boosts_list": ("premium", "GetBoostsListRequest", ["peer", "offset", "limit", "gifts"]), | |
| "get_boosts_status": ("premium", "GetBoostsStatusRequest", ["peer"]), | |
| "get_bot_app": ("messages", "GetBotAppRequest", ["app", "hash"]), | |
| "get_bot_business_connection": ("account", "GetBotBusinessConnectionRequest", ["connection_id"]), | |
| "get_bot_callback_answer": ("messages", "GetBotCallbackAnswerRequest", ["peer", "msg_id", "game", "data", "password"]), | |
| "get_bot_commands": ("bots", "GetBotCommandsRequest", ["scope", "lang_code"]), | |
| "get_bot_info": ("bots", "GetBotInfoRequest", ["lang_code", "bot"]), | |
| "get_bot_menu_button": ("bots", "GetBotMenuButtonRequest", ["user_id"]), | |
| "get_broadcast_stats": ("stats", "GetBroadcastStatsRequest", ["channel", "dark"]), | |
| "get_business_chat_links": ("account", "GetBusinessChatLinksRequest", []), | |
| "get_channel_default_emoji_statuses": ("account", "GetChannelDefaultEmojiStatusesRequest", ["hash"]), | |
| "get_channel_recommendations": ("channels", "GetChannelRecommendationsRequest", ["channel"]), | |
| "get_channel_restricted_status_emojis": ("account", "GetChannelRestrictedStatusEmojisRequest", ["hash"]), | |
| "get_channels": ("channels", "GetChannelsRequest", ["id"]), | |
| "get_chat_invite_importers": ("messages", "GetChatInviteImportersRequest", ["peer", "offset_date", "offset_user", "limit", "requested", "subscription_expired", "link", "q"]), | |
| "get_chat_themes": ("account", "GetChatThemesRequest", ["hash"]), | |
| "get_chatlist_updates": ("chatlists", "GetChatlistUpdatesRequest", ["chatlist"]), | |
| "get_chats": ("messages", "GetChatsRequest", ["id"]), | |
| "get_chats_to_send": ("stories", "GetChatsToSendRequest", []), | |
| "get_collectible_emoji_statuses": ("account", "GetCollectibleEmojiStatusesRequest", ["hash"]), | |
| "get_collectible_info": ("fragment", "GetCollectibleInfoRequest", ["collectible"]), | |
| "get_common_chats": ("messages", "GetCommonChatsRequest", ["user_id", "max_id", "limit"]), | |
| "get_connected_bots": ("account", "GetConnectedBotsRequest", []), | |
| "get_connected_star_ref_bot": ("payments", "GetConnectedStarRefBotRequest", ["peer", "bot"]), | |
| "get_connected_star_ref_bots": ("payments", "GetConnectedStarRefBotsRequest", ["peer", "limit", "offset_date", "offset_link"]), | |
| "get_contact_i_ds": ("contacts", "GetContactIDsRequest", ["hash"]), | |
| "get_contact_sign_up_notification": ("account", "GetContactSignUpNotificationRequest", []), | |
| "get_contacts": ("contacts", "GetContactsRequest", ["hash"]), | |
| "get_content_settings": ("account", "GetContentSettingsRequest", []), | |
| "get_craft_star_gifts": ("payments", "GetCraftStarGiftsRequest", ["gift_id", "offset", "limit"]), | |
| "get_custom_emoji_documents": ("messages", "GetCustomEmojiDocumentsRequest", ["document_id"]), | |
| "get_default_background_emojis": ("account", "GetDefaultBackgroundEmojisRequest", ["hash"]), | |
| "get_default_emoji_statuses": ("account", "GetDefaultEmojiStatusesRequest", ["hash"]), | |
| "get_default_group_photo_emojis": ("account", "GetDefaultGroupPhotoEmojisRequest", ["hash"]), | |
| "get_default_history_t_t_l": ("messages", "GetDefaultHistoryTTLRequest", []), | |
| "get_default_profile_photo_emojis": ("account", "GetDefaultProfilePhotoEmojisRequest", ["hash"]), | |
| "get_default_tag_reactions": ("messages", "GetDefaultTagReactionsRequest", ["hash"]), | |
| "get_dialog_filters": ("messages", "GetDialogFiltersRequest", []), | |
| "get_dialog_unread_marks": ("messages", "GetDialogUnreadMarksRequest", ["parent_peer"]), | |
| "get_dialogs": ("messages", "GetDialogsRequest", ["offset_date", "offset_id", "offset_peer", "limit", "hash", "exclude_pinned", "folder_id"]), | |
| "get_discussion_message": ("messages", "GetDiscussionMessageRequest", ["peer", "msg_id"]), | |
| "get_document_by_hash": ("messages", "GetDocumentByHashRequest", ["sha256", "size", "mime_type"]), | |
| "get_emoji_groups": ("messages", "GetEmojiGroupsRequest", ["hash"]), | |
| "get_emoji_keywords": ("messages", "GetEmojiKeywordsRequest", ["lang_code"]), | |
| "get_emoji_profile_photo_groups": ("messages", "GetEmojiProfilePhotoGroupsRequest", ["hash"]), | |
| "get_emoji_status_groups": ("messages", "GetEmojiStatusGroupsRequest", ["hash"]), | |
| "get_emoji_sticker_groups": ("messages", "GetEmojiStickerGroupsRequest", ["hash"]), | |
| "get_emoji_stickers": ("messages", "GetEmojiStickersRequest", ["hash"]), | |
| "get_emoji_u_r_l": ("messages", "GetEmojiURLRequest", ["lang_code"]), | |
| "get_exported_chat_invite": ("messages", "GetExportedChatInviteRequest", ["peer", "link"]), | |
| "get_exported_chat_invites": ("messages", "GetExportedChatInvitesRequest", ["peer", "admin_id", "limit", "revoked", "offset_date", "offset_link"]), | |
| "get_exported_invites": ("chatlists", "GetExportedInvitesRequest", ["chatlist"]), | |
| "get_extended_media": ("messages", "GetExtendedMediaRequest", ["peer", "id"]), | |
| "get_fact_check": ("messages", "GetFactCheckRequest", ["peer", "msg_id"]), | |
| "get_faved_stickers": ("messages", "GetFavedStickersRequest", ["hash"]), | |
| "get_featured_emoji_stickers": ("messages", "GetFeaturedEmojiStickersRequest", ["hash"]), | |
| "get_featured_stickers": ("messages", "GetFeaturedStickersRequest", ["hash"]), | |
| "get_forum_topics": ("messages", "GetForumTopicsRequest", ["peer", "offset_date", "offset_id", "offset_topic", "limit", "q"]), | |
| "get_forum_topics_by_i_d": ("messages", "GetForumTopicsByIDRequest", ["peer", "topics"]), | |
| "get_full_channel": ("channels", "GetFullChannelRequest", ["channel"]), | |
| "get_full_chat": ("messages", "GetFullChatRequest", ["chat_id"]), | |
| "get_full_user": ("users", "GetFullUserRequest", ["id"]), | |
| "get_game_high_scores": ("messages", "GetGameHighScoresRequest", ["peer", "id", "user_id"]), | |
| "get_giveaway_info": ("payments", "GetGiveawayInfoRequest", ["peer", "msg_id"]), | |
| "get_global_privacy_settings": ("account", "GetGlobalPrivacySettingsRequest", []), | |
| "get_group_call": ("phone", "GetGroupCallRequest", ["call", "limit"]), | |
| "get_group_call_join_as": ("phone", "GetGroupCallJoinAsRequest", ["peer"]), | |
| "get_group_call_stream_channels": ("phone", "GetGroupCallStreamChannelsRequest", ["call"]), | |
| "get_group_call_stream_rtmp_url": ("phone", "GetGroupCallStreamRtmpUrlRequest", ["peer", "revoke", "live_story"]), | |
| "get_group_participants": ("phone", "GetGroupParticipantsRequest", ["call", "ids", "sources", "offset", "limit"]), | |
| "get_groups_for_discussion": ("channels", "GetGroupsForDiscussionRequest", []), | |
| "get_history": ("messages", "GetHistoryRequest", ["peer", "offset_id", "offset_date", "add_offset", "limit", "max_id", "min_id", "hash"]), | |
| "get_inactive_channels": ("channels", "GetInactiveChannelsRequest", []), | |
| "get_inline_bot_results": ("messages", "GetInlineBotResultsRequest", ["bot", "peer", "query", "offset", "geo_point"]), | |
| "get_inline_game_high_scores": ("messages", "GetInlineGameHighScoresRequest", ["id", "user_id"]), | |
| "get_leave_chatlist_suggestions": ("chatlists", "GetLeaveChatlistSuggestionsRequest", ["chatlist"]), | |
| "get_left_channels": ("channels", "GetLeftChannelsRequest", ["offset"]), | |
| "get_located": ("contacts", "GetLocatedRequest", ["geo_point", "background", "self_expires"]), | |
| "get_mask_stickers": ("messages", "GetMaskStickersRequest", ["hash"]), | |
| "get_megagroup_stats": ("stats", "GetMegagroupStatsRequest", ["channel", "dark"]), | |
| "get_message_author": ("channels", "GetMessageAuthorRequest", ["channel", "id"]), | |
| "get_message_edit_data": ("messages", "GetMessageEditDataRequest", ["peer", "id"]), | |
| "get_message_public_forwards": ("stats", "GetMessagePublicForwardsRequest", ["channel", "msg_id", "offset", "limit"]), | |
| "get_message_reactions_list": ("messages", "GetMessageReactionsListRequest", ["peer", "id", "limit", "reaction", "offset"]), | |
| "get_message_read_participants": ("messages", "GetMessageReadParticipantsRequest", ["peer", "msg_id"]), | |
| "get_message_stats": ("stats", "GetMessageStatsRequest", ["channel", "msg_id", "dark"]), | |
| "get_messages": ("messages", "GetMessagesRequest", ["id"]), | |
| "get_messages_reactions": ("messages", "GetMessagesReactionsRequest", ["peer", "id"]), | |
| "get_messages_views": ("messages", "GetMessagesViewsRequest", ["peer", "id", "increment"]), | |
| "get_multi_wall_papers": ("account", "GetMultiWallPapersRequest", ["wallpapers"]), | |
| "get_my_boosts": ("premium", "GetMyBoostsRequest", []), | |
| "get_my_stickers": ("messages", "GetMyStickersRequest", ["offset_id", "limit"]), | |
| "get_notify_exceptions": ("account", "GetNotifyExceptionsRequest", ["compare_sound", "compare_stories", "peer"]), | |
| "get_notify_settings": ("account", "GetNotifySettingsRequest", ["peer"]), | |
| "get_old_featured_stickers": ("messages", "GetOldFeaturedStickersRequest", ["offset", "limit", "hash"]), | |
| "get_onlines": ("messages", "GetOnlinesRequest", ["peer"]), | |
| "get_outbox_read_date": ("messages", "GetOutboxReadDateRequest", ["peer", "msg_id"]), | |
| "get_paid_reaction_privacy": ("messages", "GetPaidReactionPrivacyRequest", []), | |
| "get_participant": ("channels", "GetParticipantRequest", ["channel", "participant"]), | |
| "get_participants": ("channels", "GetParticipantsRequest", ["channel", "filter", "offset", "limit", "hash"]), | |
| "get_password": ("account", "GetPasswordRequest", []), | |
| "get_payment_form": ("payments", "GetPaymentFormRequest", ["invoice", "theme_params"]), | |
| "get_payment_receipt": ("payments", "GetPaymentReceiptRequest", ["peer", "msg_id"]), | |
| "get_peer_dialogs": ("messages", "GetPeerDialogsRequest", ["peers"]), | |
| "get_peer_max_i_ds": ("stories", "GetPeerMaxIDsRequest", ["id"]), | |
| "get_peer_settings": ("messages", "GetPeerSettingsRequest", ["peer"]), | |
| "get_peer_stories": ("stories", "GetPeerStoriesRequest", ["peer"]), | |
| "get_pinned_dialogs": ("messages", "GetPinnedDialogsRequest", ["folder_id"]), | |
| "get_pinned_saved_dialogs": ("messages", "GetPinnedSavedDialogsRequest", []), | |
| "get_pinned_stories": ("stories", "GetPinnedStoriesRequest", ["peer", "offset_id", "limit"]), | |
| "get_poll_results": ("messages", "GetPollResultsRequest", ["peer", "msg_id", "poll_hash"]), | |
| "get_premium_gift_code_options": ("payments", "GetPremiumGiftCodeOptionsRequest", ["boost_peer"]), | |
| "get_prepared_inline_message": ("messages", "GetPreparedInlineMessageRequest", ["bot", "id"]), | |
| "get_privacy": ("account", "GetPrivacyRequest", ["key"]), | |
| "get_quick_replies": ("messages", "GetQuickRepliesRequest", ["hash"]), | |
| "get_quick_reply_messages": ("messages", "GetQuickReplyMessagesRequest", ["shortcut_id", "hash", "id"]), | |
| "get_reactions_notify_settings": ("account", "GetReactionsNotifySettingsRequest", []), | |
| "get_recent_emoji_statuses": ("account", "GetRecentEmojiStatusesRequest", ["hash"]), | |
| "get_recent_locations": ("messages", "GetRecentLocationsRequest", ["peer", "limit", "hash"]), | |
| "get_recent_reactions": ("messages", "GetRecentReactionsRequest", ["limit", "hash"]), | |
| "get_recent_stickers": ("messages", "GetRecentStickersRequest", ["hash", "attached"]), | |
| "get_replies": ("messages", "GetRepliesRequest", ["peer", "msg_id", "offset_id", "offset_date", "add_offset", "limit", "max_id", "min_id", "hash"]), | |
| "get_requirements_to_contact": ("users", "GetRequirementsToContactRequest", ["id"]), | |
| "get_resale_star_gifts": ("payments", "GetResaleStarGiftsRequest", ["gift_id", "offset", "limit", "sort_by_price", "sort_by_num", "for_craft", "stars_only", "attributes_hash", "attributes"]), | |
| "get_saved": ("contacts", "GetSavedRequest", []), | |
| "get_saved_dialogs": ("messages", "GetSavedDialogsRequest", ["offset_date", "offset_id", "offset_peer", "limit", "hash", "exclude_pinned", "parent_peer"]), | |
| "get_saved_dialogs_by_i_d": ("messages", "GetSavedDialogsByIDRequest", ["ids", "parent_peer"]), | |
| "get_saved_gifs": ("messages", "GetSavedGifsRequest", ["hash"]), | |
| "get_saved_history": ("messages", "GetSavedHistoryRequest", ["peer", "offset_id", "offset_date", "add_offset", "limit", "max_id", "min_id", "hash", "parent_peer"]), | |
| "get_saved_info": ("payments", "GetSavedInfoRequest", []), | |
| "get_saved_music": ("users", "GetSavedMusicRequest", ["id", "offset", "limit", "hash"]), | |
| "get_saved_music_by_i_d": ("users", "GetSavedMusicByIDRequest", ["id", "documents"]), | |
| "get_saved_music_ids": ("account", "GetSavedMusicIdsRequest", ["hash"]), | |
| "get_saved_reaction_tags": ("messages", "GetSavedReactionTagsRequest", ["hash", "peer"]), | |
| "get_saved_ringtones": ("account", "GetSavedRingtonesRequest", ["hash"]), | |
| "get_saved_star_gift": ("payments", "GetSavedStarGiftRequest", ["stargift"]), | |
| "get_saved_star_gifts": ("payments", "GetSavedStarGiftsRequest", ["peer", "offset", "limit", "exclude_unsaved", "exclude_saved", "exclude_unlimited", "exclude_unique", "sort_by_value", "exclude_upgradable", "exclude_unupgradable", "peer_color_available", "exclude_hosted", "collection_id"]), | |
| "get_scheduled_history": ("messages", "GetScheduledHistoryRequest", ["peer", "hash"]), | |
| "get_scheduled_messages": ("messages", "GetScheduledMessagesRequest", ["peer", "id"]), | |
| "get_search_counters": ("messages", "GetSearchCountersRequest", ["peer", "filters", "saved_peer_id", "top_msg_id"]), | |
| "get_search_results_calendar": ("messages", "GetSearchResultsCalendarRequest", ["peer", "filter", "offset_id", "offset_date", "saved_peer_id"]), | |
| "get_search_results_positions": ("messages", "GetSearchResultsPositionsRequest", ["peer", "filter", "offset_id", "limit", "saved_peer_id"]), | |
| "get_send_as": ("channels", "GetSendAsRequest", ["peer", "for_paid_reactions", "for_live_stories"]), | |
| "get_split_ranges": ("messages", "GetSplitRangesRequest", []), | |
| "get_sponsored_messages": ("messages", "GetSponsoredMessagesRequest", ["peer", "msg_id"]), | |
| "get_sponsored_peers": ("contacts", "GetSponsoredPeersRequest", ["q"]), | |
| "get_star_gift_active_auctions": ("payments", "GetStarGiftActiveAuctionsRequest", ["hash"]), | |
| "get_star_gift_auction_acquired_gifts": ("payments", "GetStarGiftAuctionAcquiredGiftsRequest", ["gift_id"]), | |
| "get_star_gift_auction_state": ("payments", "GetStarGiftAuctionStateRequest", ["auction", "version"]), | |
| "get_star_gift_collections": ("payments", "GetStarGiftCollectionsRequest", ["peer", "hash"]), | |
| "get_star_gift_upgrade_attributes": ("payments", "GetStarGiftUpgradeAttributesRequest", ["gift_id"]), | |
| "get_star_gift_upgrade_preview": ("payments", "GetStarGiftUpgradePreviewRequest", ["gift_id"]), | |
| "get_star_gift_withdrawal_url": ("payments", "GetStarGiftWithdrawalUrlRequest", ["stargift", "password"]), | |
| "get_star_gifts": ("payments", "GetStarGiftsRequest", ["hash"]), | |
| "get_stars_gift_options": ("payments", "GetStarsGiftOptionsRequest", ["user_id"]), | |
| "get_stars_giveaway_options": ("payments", "GetStarsGiveawayOptionsRequest", []), | |
| "get_stars_revenue_ads_account_url": ("payments", "GetStarsRevenueAdsAccountUrlRequest", ["peer"]), | |
| "get_stars_revenue_stats": ("payments", "GetStarsRevenueStatsRequest", ["peer", "dark", "ton"]), | |
| "get_stars_revenue_withdrawal_url": ("payments", "GetStarsRevenueWithdrawalUrlRequest", ["peer", "password", "ton", "amount"]), | |
| "get_stars_status": ("payments", "GetStarsStatusRequest", ["peer", "ton"]), | |
| "get_stars_subscriptions": ("payments", "GetStarsSubscriptionsRequest", ["peer", "offset", "missing_balance"]), | |
| "get_stars_topup_options": ("payments", "GetStarsTopupOptionsRequest", []), | |
| "get_stars_transactions": ("payments", "GetStarsTransactionsRequest", ["peer", "offset", "limit", "inbound", "outbound", "ascending", "ton", "subscription_id"]), | |
| "get_stars_transactions_by_i_d": ("payments", "GetStarsTransactionsByIDRequest", ["peer", "id", "ton"]), | |
| "get_status": ("smsjobs", "GetStatusRequest", []), | |
| "get_statuses": ("contacts", "GetStatusesRequest", []), | |
| "get_sticker_set": ("messages", "GetStickerSetRequest", ["stickerset", "hash"]), | |
| "get_stories_archive": ("stories", "GetStoriesArchiveRequest", ["peer", "offset_id", "limit"]), | |
| "get_stories_by_i_d": ("stories", "GetStoriesByIDRequest", ["peer", "id"]), | |
| "get_stories_views": ("stories", "GetStoriesViewsRequest", ["peer", "id"]), | |
| "get_story_public_forwards": ("stats", "GetStoryPublicForwardsRequest", ["peer", "id", "offset", "limit"]), | |
| "get_story_reactions_list": ("stories", "GetStoryReactionsListRequest", ["peer", "id", "limit", "forwards_first", "reaction", "offset"]), | |
| "get_story_stats": ("stats", "GetStoryStatsRequest", ["peer", "id", "dark"]), | |
| "get_story_views_list": ("stories", "GetStoryViewsListRequest", ["peer", "id", "offset", "limit", "just_contacts", "reactions_first", "forwards_first", "q"]), | |
| "get_suggested_dialog_filters": ("messages", "GetSuggestedDialogFiltersRequest", []), | |
| "get_suggested_star_ref_bots": ("payments", "GetSuggestedStarRefBotsRequest", ["peer", "offset", "limit", "order_by_revenue", "order_by_date"]), | |
| "get_theme": ("account", "GetThemeRequest", ["format", "theme"]), | |
| "get_themes": ("account", "GetThemesRequest", ["format", "hash"]), | |
| "get_tmp_password": ("account", "GetTmpPasswordRequest", ["password", "period"]), | |
| "get_top_peers": ("contacts", "GetTopPeersRequest", ["offset", "limit", "hash", "correspondents", "bots_pm", "bots_inline", "phone_calls", "forward_users", "forward_chats", "groups", "channels", "bots_app"]), | |
| "get_top_reactions": ("messages", "GetTopReactionsRequest", ["limit", "hash"]), | |
| "get_unique_star_gift": ("payments", "GetUniqueStarGiftRequest", ["slug"]), | |
| "get_unread_mentions": ("messages", "GetUnreadMentionsRequest", ["peer", "offset_id", "add_offset", "limit", "max_id", "min_id", "top_msg_id"]), | |
| "get_unread_poll_votes": ("messages", "GetUnreadPollVotesRequest", ["peer", "offset_id", "add_offset", "limit", "max_id", "min_id", "top_msg_id"]), | |
| "get_unread_reactions": ("messages", "GetUnreadReactionsRequest", ["peer", "offset_id", "add_offset", "limit", "max_id", "min_id", "top_msg_id", "saved_peer_id"]), | |
| "get_user_boosts": ("premium", "GetUserBoostsRequest", ["peer", "user_id"]), | |
| "get_user_photos": ("photos", "GetUserPhotosRequest", ["user_id", "offset", "max_id", "limit"]), | |
| "get_users": ("users", "GetUsersRequest", ["id"]), | |
| "get_wall_paper": ("account", "GetWallPaperRequest", ["wallpaper"]), | |
| "get_wall_papers": ("account", "GetWallPapersRequest", ["hash"]), | |
| "get_web_authorizations": ("account", "GetWebAuthorizationsRequest", []), | |
| "get_web_page": ("messages", "GetWebPageRequest", ["url", "hash"]), | |
| "get_web_page_preview": ("messages", "GetWebPagePreviewRequest", ["message", "entities"]), | |
| "geted_web_view_button": ("bots", "GetRequestedWebViewButtonRequest", ["bot", "webapp_req_id"]), | |
| "hide_all_chat_joins": ("messages", "HideAllChatJoinRequestsRequest", ["peer", "approved", "link"]), | |
| "hide_chat_join": ("messages", "HideChatJoinRequestRequest", ["peer", "user_id", "approved"]), | |
| "hide_chatlist_updates": ("chatlists", "HideChatlistUpdatesRequest", ["chatlist"]), | |
| "hide_peer_settings_bar": ("messages", "HidePeerSettingsBarRequest", ["peer"]), | |
| "import_chat_invite": ("messages", "ImportChatInviteRequest", ["hash"]), | |
| "import_contact_token": ("contacts", "ImportContactTokenRequest", ["token"]), | |
| "import_contacts": ("contacts", "ImportContactsRequest", ["contacts"]), | |
| "increment_story_views": ("stories", "IncrementStoryViewsRequest", ["peer", "id"]), | |
| "init_history_import": ("messages", "InitHistoryImportRequest", ["peer", "file", "media_count"]), | |
| "init_takeout_session": ("account", "InitTakeoutSessionRequest", ["contacts", "message_users", "message_chats", "message_megagroups", "message_channels", "files", "file_max_size"]), | |
| "install_sticker_set": ("messages", "InstallStickerSetRequest", ["stickerset", "archived"]), | |
| "install_theme": ("account", "InstallThemeRequest", ["dark", "theme", "format", "base_theme"]), | |
| "install_wall_paper": ("account", "InstallWallPaperRequest", ["wallpaper", "settings"]), | |
| "invalidate_sign_in_codes": ("account", "InvalidateSignInCodesRequest", ["codes"]), | |
| "invite_to_channel": ("channels", "InviteToChannelRequest", ["channel", "users"]), | |
| "invite_to_group_call": ("phone", "InviteToGroupCallRequest", ["call", "users"]), | |
| "invoke_web_view_custom_method": ("bots", "InvokeWebViewCustomMethodRequest", ["bot", "custom_method", "params"]), | |
| "is_eligible_to_join": ("smsjobs", "IsEligibleToJoinRequest", []), | |
| "join_channel": ("channels", "JoinChannelRequest", ["channel"]), | |
| "join_chatlist_invite": ("chatlists", "JoinChatlistInviteRequest", ["slug", "peers"]), | |
| "join_chatlist_updates": ("chatlists", "JoinChatlistUpdatesRequest", ["chatlist", "peers"]), | |
| "join_group_call": ("phone", "JoinGroupCallRequest", ["call", "join_as", "params", "muted", "video_stopped", "invite_hash", "public_key", "block"]), | |
| "launch_prepaid_giveaway": ("payments", "LaunchPrepaidGiveawayRequest", ["peer", "giveaway_id", "purpose"]), | |
| "leave_channel": ("channels", "LeaveChannelRequest", ["channel"]), | |
| "leave_chatlist": ("chatlists", "LeaveChatlistRequest", ["chatlist", "peers"]), | |
| "leave_group_call": ("phone", "LeaveGroupCallRequest", ["call", "source"]), | |
| "load_async_graph": ("stats", "LoadAsyncGraphRequest", ["token", "x"]), | |
| "main_web_view": ("messages", "RequestMainWebViewRequest", ["peer", "bot", "platform", "compact", "fullscreen", "start_param", "theme_params"]), | |
| "mark_dialog_unread": ("messages", "MarkDialogUnreadRequest", ["peer", "unread", "parent_peer"]), | |
| "messages_read_poll_votes": ("messages", "ReadPollVotesRequest", ["peer", "top_msg_id"]), | |
| "migrate_chat": ("messages", "MigrateChatRequest", ["chat_id"]), | |
| "prolong_web_view": ("messages", "ProlongWebViewRequest", ["peer", "bot", "query_id", "silent", "reply_to", "send_as"]), | |
| "rate_transcribed_audio": ("messages", "RateTranscribedAudioRequest", ["peer", "msg_id", "transcription_id", "good"]), | |
| "read_discussion": ("messages", "ReadDiscussionRequest", ["peer", "msg_id", "read_max_id"]), | |
| "read_encrypted_history": ("messages", "ReadEncryptedHistoryRequest", ["peer", "max_date"]), | |
| "read_featured_stickers": ("messages", "ReadFeaturedStickersRequest", ["id"]), | |
| "read_history": ("messages", "ReadHistoryRequest", ["peer", "max_id"]), | |
| "read_mentions": ("messages", "ReadMentionsRequest", ["peer", "top_msg_id"]), | |
| "read_message_contents": ("messages", "ReadMessageContentsRequest", ["id"]), | |
| "read_poll_votes": ("messages", "ReadPollVotesRequest", ["peer", "top_msg_id"]), | |
| "read_reactions": ("messages", "ReadReactionsRequest", ["peer", "top_msg_id", "saved_peer_id"]), | |
| "read_saved_history": ("messages", "ReadSavedHistoryRequest", ["parent_peer", "peer", "max_id"]), | |
| "read_stories": ("stories", "ReadStoriesRequest", ["peer", "max_id"]), | |
| "refund_stars_charge": ("payments", "RefundStarsChargeRequest", ["user_id", "charge_id"]), | |
| "remove_sticker_from_set": ("stickers", "RemoveStickerFromSetRequest", ["sticker"]), | |
| "rename_sticker_set": ("stickers", "RenameStickerSetRequest", ["stickerset", "title"]), | |
| "reorder_albums": ("stories", "ReorderAlbumsRequest", ["peer", "order"]), | |
| "reorder_pinned_dialogs": ("messages", "ReorderPinnedDialogsRequest", ["folder_id", "order", "force"]), | |
| "reorder_pinned_forum_topics": ("messages", "ReorderPinnedForumTopicsRequest", ["peer", "order", "force"]), | |
| "reorder_pinned_saved_dialogs": ("messages", "ReorderPinnedSavedDialogsRequest", ["order", "force"]), | |
| "reorder_quick_replies": ("messages", "ReorderQuickRepliesRequest", ["order"]), | |
| "reorder_star_gift_collections": ("payments", "ReorderStarGiftCollectionsRequest", ["peer", "order"]), | |
| "reorder_sticker_sets": ("messages", "ReorderStickerSetsRequest", ["order", "masks", "emojis"]), | |
| "reorder_usernames": ("channels", "ReorderUsernamesRequest", ["channel", "order"]), | |
| "replace_sticker": ("stickers", "ReplaceStickerRequest", ["sticker", "new_sticker"]), | |
| "report": ("messages", "ReportRequest", ["peer", "id", "option", "message"]), | |
| "report_anti_spam_false_positive": ("channels", "ReportAntiSpamFalsePositiveRequest", ["channel", "msg_id"]), | |
| "report_encrypted_spam": ("messages", "ReportEncryptedSpamRequest", ["peer"]), | |
| "report_messages_delivery": ("messages", "ReportMessagesDeliveryRequest", ["peer", "id", "push"]), | |
| "report_music_listen": ("messages", "ReportMusicListenRequest", ["id", "listened_duration"]), | |
| "report_peer": ("account", "ReportPeerRequest", ["peer", "reason", "message"]), | |
| "report_profile_photo": ("account", "ReportProfilePhotoRequest", ["peer", "photo_id", "reason", "message"]), | |
| "report_reaction": ("messages", "ReportReactionRequest", ["peer", "id", "reaction_peer"]), | |
| "report_read_metrics": ("messages", "ReportReadMetricsRequest", ["peer", "metrics"]), | |
| "report_spam": ("messages", "ReportSpamRequest", ["peer"]), | |
| "report_sponsored_message": ("messages", "ReportSponsoredMessageRequest", ["option", "random_id"]), | |
| "reset_authorization": ("account", "ResetAuthorizationRequest", ["hash"]), | |
| "reset_bot_commands": ("bots", "ResetBotCommandsRequest", ["scope", "lang_code"]), | |
| "reset_notify_settings": ("account", "ResetNotifySettingsRequest", []), | |
| "reset_saved": ("contacts", "ResetSavedRequest", []), | |
| "reset_top_peer_rating": ("contacts", "ResetTopPeerRatingRequest", ["category", "peer"]), | |
| "reset_wall_papers": ("account", "ResetWallPapersRequest", []), | |
| "reset_web_authorization": ("account", "ResetWebAuthorizationRequest", ["hash"]), | |
| "reset_web_authorizations": ("account", "ResetWebAuthorizationsRequest", []), | |
| "resolve_business_chat_link": ("account", "ResolveBusinessChatLinkRequest", ["slug"]), | |
| "resolve_phone": ("contacts", "ResolvePhoneRequest", ["phone"]), | |
| "resolve_star_gift_offer": ("payments", "ResolveStarGiftOfferRequest", ["offer_msg_id", "decline"]), | |
| "resolve_username": ("contacts", "ResolveUsernameRequest", ["username", "referer"]), | |
| "restrict_sponsored_messages": ("channels", "RestrictSponsoredMessagesRequest", ["channel", "restricted"]), | |
| "save_auto_download_settings": ("account", "SaveAutoDownloadSettingsRequest", ["settings", "low", "high"]), | |
| "save_auto_save_settings": ("account", "SaveAutoSaveSettingsRequest", ["settings", "users", "chats", "broadcasts", "peer"]), | |
| "save_call_debug": ("phone", "SaveCallDebugRequest", ["peer", "debug"]), | |
| "save_call_log": ("phone", "SaveCallLogRequest", ["peer", "file"]), | |
| "save_default_group_call_join_as": ("phone", "SaveDefaultGroupCallJoinAsRequest", ["peer", "join_as"]), | |
| "save_default_send_as": ("messages", "SaveDefaultSendAsRequest", ["peer", "send_as"]), | |
| "save_draft": ("messages", "SaveDraftRequest", ["peer", "message", "no_webpage", "invert_media", "reply_to", "entities", "media", "effect", "suggested_post"]), | |
| "save_gif": ("messages", "SaveGifRequest", ["id", "unsave"]), | |
| "save_music": ("account", "SaveMusicRequest", ["id", "unsave", "after_id"]), | |
| "save_prepared_inline_message": ("messages", "SavePreparedInlineMessageRequest", ["result", "user_id", "peer_types"]), | |
| "save_recent_sticker": ("messages", "SaveRecentStickerRequest", ["id", "unsave", "attached"]), | |
| "save_ringtone": ("account", "SaveRingtoneRequest", ["id", "unsave"]), | |
| "save_star_gift": ("payments", "SaveStarGiftRequest", ["stargift", "unsave"]), | |
| "save_theme": ("account", "SaveThemeRequest", ["theme", "unsave"]), | |
| "save_wall_paper": ("account", "SaveWallPaperRequest", ["wallpaper", "unsave", "settings"]), | |
| "search": ("messages", "SearchRequest", ["peer", "q", "filter", "min_date", "max_date", "offset_id", "add_offset", "limit", "max_id", "min_id", "hash", "from_id", "saved_peer_id", "saved_reaction", "top_msg_id"]), | |
| "search_custom_emoji": ("messages", "SearchCustomEmojiRequest", ["emoticon", "hash"]), | |
| "search_emoji_sticker_sets": ("messages", "SearchEmojiStickerSetsRequest", ["q", "hash", "exclude_featured"]), | |
| "search_global": ("messages", "SearchGlobalRequest", ["q", "filter", "min_date", "max_date", "offset_rate", "offset_peer", "offset_id", "limit", "broadcasts_only", "groups_only", "users_only", "folder_id"]), | |
| "search_sticker_sets": ("messages", "SearchStickerSetsRequest", ["q", "hash", "exclude_featured"]), | |
| "send_boted_peer": ("messages", "SendBotRequestedPeerRequest", ["peer", "button_id", "requested_peers", "msg_id", "webapp_req_id"]), | |
| "send_custom": ("bots", "SendCustomRequestRequest", ["custom_method", "params"]), | |
| "send_encrypted": ("messages", "SendEncryptedRequest", ["peer", "data", "silent", "random_id"]), | |
| "send_encrypted_file": ("messages", "SendEncryptedFileRequest", ["peer", "data", "file", "silent", "random_id"]), | |
| "send_encrypted_service": ("messages", "SendEncryptedServiceRequest", ["peer", "data", "random_id"]), | |
| "send_inline_bot_result": ("messages", "SendInlineBotResultRequest", ["peer", "query_id", "id", "silent", "background", "clear_draft", "hide_via", "reply_to", "random_id", "schedule_date", "send_as", "quick_reply_shortcut", "allow_paid_stars"]), | |
| "send_media": ("messages", "SendMediaRequest", ["peer", "media", "message", "silent", "background", "clear_draft", "noforwards", "update_stickersets_order", "invert_media", "allow_paid_floodskip", "reply_to", "random_id", "reply_markup", "entities", "schedule_date", "schedule_repeat_period", "send_as", "quick_reply_shortcut", "effect", "allow_paid_stars", "suggested_post"]), | |
| "send_message": ("messages", "SendMessageRequest", ["peer", "message", "no_webpage", "silent", "background", "clear_draft", "noforwards", "update_stickersets_order", "invert_media", "allow_paid_floodskip", "reply_to", "random_id", "reply_markup", "entities", "schedule_date", "schedule_repeat_period", "send_as", "quick_reply_shortcut", "effect", "allow_paid_stars", "suggested_post"]), | |
| "send_multi_media": ("messages", "SendMultiMediaRequest", ["peer", "multi_media", "silent", "background", "clear_draft", "noforwards", "update_stickersets_order", "invert_media", "allow_paid_floodskip", "reply_to", "schedule_date", "send_as", "quick_reply_shortcut", "effect", "allow_paid_stars"]), | |
| "send_paid_reaction": ("messages", "SendPaidReactionRequest", ["peer", "msg_id", "count", "random_id", "private"]), | |
| "send_payment_form": ("payments", "SendPaymentFormRequest", ["form_id", "invoice", "credentials", "requested_info_id", "shipping_option_id", "tip_amount"]), | |
| "send_quick_reply_messages": ("messages", "SendQuickReplyMessagesRequest", ["peer", "shortcut_id", "id", "random_id"]), | |
| "send_reaction": ("messages", "SendReactionRequest", ["peer", "msg_id", "big", "add_to_recent", "reaction"]), | |
| "send_scheduled_messages": ("messages", "SendScheduledMessagesRequest", ["peer", "id"]), | |
| "send_screenshot_notification": ("messages", "SendScreenshotNotificationRequest", ["peer", "reply_to", "random_id"]), | |
| "send_star_gift_offer": ("payments", "SendStarGiftOfferRequest", ["peer", "slug", "price", "duration", "random_id", "allow_paid_stars"]), | |
| "send_stars_form": ("payments", "SendStarsFormRequest", ["form_id", "invoice"]), | |
| "send_story": ("stories", "SendStoryRequest", ["peer", "media", "privacy_rules", "pinned", "noforwards", "fwd_modified", "media_areas", "caption", "entities", "random_id", "period", "fwd_from_id", "fwd_from_story", "albums", "music"]), | |
| "send_vote": ("messages", "SendVoteRequest", ["peer", "msg_id", "options"]), | |
| "send_web_view_data": ("messages", "SendWebViewDataRequest", ["bot", "button_text", "data", "random_id"]), | |
| "send_web_view_result_message": ("messages", "SendWebViewResultMessageRequest", ["bot_query_id", "result"]), | |
| "set_account_t_t_l": ("account", "SetAccountTTLRequest", ["ttl"]), | |
| "set_boosts_to_unblock_restrictions": ("channels", "SetBoostsToUnblockRestrictionsRequest", ["channel", "boosts"]), | |
| "set_bot_broadcast_default_admin_rights": ("bots", "SetBotBroadcastDefaultAdminRightsRequest", ["admin_rights"]), | |
| "set_bot_callback_answer": ("messages", "SetBotCallbackAnswerRequest", ["query_id", "cache_time", "alert", "message", "url"]), | |
| "set_bot_commands": ("bots", "SetBotCommandsRequest", ["scope", "lang_code", "commands"]), | |
| "set_bot_group_default_admin_rights": ("bots", "SetBotGroupDefaultAdminRightsRequest", ["admin_rights"]), | |
| "set_bot_info": ("bots", "SetBotInfoRequest", ["lang_code", "bot", "name", "about", "description"]), | |
| "set_bot_menu_button": ("bots", "SetBotMenuButtonRequest", ["user_id", "button"]), | |
| "set_bot_precheckout_results": ("messages", "SetBotPrecheckoutResultsRequest", ["query_id", "success", "error"]), | |
| "set_bot_shipping_results": ("messages", "SetBotShippingResultsRequest", ["query_id", "error", "shipping_options"]), | |
| "set_call_rating": ("phone", "SetCallRatingRequest", ["peer", "rating", "comment", "user_initiative"]), | |
| "set_chat_available_reactions": ("messages", "SetChatAvailableReactionsRequest", ["peer", "available_reactions", "reactions_limit", "paid_enabled"]), | |
| "set_chat_theme": ("messages", "SetChatThemeRequest", ["peer", "theme"]), | |
| "set_chat_wall_paper": ("messages", "SetChatWallPaperRequest", ["peer", "for_both", "revert", "wallpaper", "settings", "id"]), | |
| "set_contact_sign_up_notification": ("account", "SetContactSignUpNotificationRequest", ["silent"]), | |
| "set_content_settings": ("account", "SetContentSettingsRequest", ["sensitive_enabled"]), | |
| "set_custom_verification": ("bots", "SetCustomVerificationRequest", ["peer", "enabled", "bot", "custom_description"]), | |
| "set_default_history_t_t_l": ("messages", "SetDefaultHistoryTTLRequest", ["period"]), | |
| "set_default_reaction": ("messages", "SetDefaultReactionRequest", ["reaction"]), | |
| "set_discussion_group": ("channels", "SetDiscussionGroupRequest", ["broadcast", "group"]), | |
| "set_emoji_stickers": ("channels", "SetEmojiStickersRequest", ["channel", "stickerset"]), | |
| "set_encrypted_typing": ("messages", "SetEncryptedTypingRequest", ["peer", "typing"]), | |
| "set_game_score": ("messages", "SetGameScoreRequest", ["peer", "id", "user_id", "score", "edit_message", "force"]), | |
| "set_global_privacy_settings": ("account", "SetGlobalPrivacySettingsRequest", ["settings"]), | |
| "set_history_t_t_l": ("messages", "SetHistoryTTLRequest", ["peer", "period"]), | |
| "set_inline_game_score": ("messages", "SetInlineGameScoreRequest", ["id", "user_id", "score", "edit_message", "force"]), | |
| "set_main_profile_tab": ("channels", "SetMainProfileTabRequest", ["channel", "tab"]), | |
| "set_privacy": ("account", "SetPrivacyRequest", ["key", "rules"]), | |
| "set_reactions_notify_settings": ("account", "SetReactionsNotifySettingsRequest", ["settings"]), | |
| "set_secure_value_errors": ("users", "SetSecureValueErrorsRequest", ["id", "errors"]), | |
| "set_sticker_set_thumb": ("stickers", "SetStickerSetThumbRequest", ["stickerset", "thumb", "thumb_document_id"]), | |
| "set_stickers": ("channels", "SetStickersRequest", ["channel", "stickerset"]), | |
| "set_typing": ("messages", "SetTypingRequest", ["peer", "action", "top_msg_id"]), | |
| "simple_web_view": ("messages", "RequestSimpleWebViewRequest", ["bot", "platform", "from_switch_webview", "from_side_menu", "compact", "fullscreen", "url", "start_param", "theme_params"]), | |
| "start_bot": ("messages", "StartBotRequest", ["bot", "peer", "start_param", "random_id"]), | |
| "start_history_import": ("messages", "StartHistoryImportRequest", ["peer", "import_id"]), | |
| "start_live": ("stories", "StartLiveRequest", ["peer", "privacy_rules", "pinned", "noforwards", "rtmp_stream", "caption", "entities", "random_id", "messages_enabled", "send_paid_messages_stars"]), | |
| "start_scheduled_group_call": ("phone", "StartScheduledGroupCallRequest", ["call"]), | |
| "suggest_birthday": ("users", "SuggestBirthdayRequest", ["id", "birthday"]), | |
| "suggest_short_name": ("stickers", "SuggestShortNameRequest", ["title"]), | |
| "summarize_text": ("messages", "SummarizeTextRequest", ["peer", "id", "to_lang", "tone"]), | |
| "toggle_all_stories_hidden": ("stories", "ToggleAllStoriesHiddenRequest", ["hidden"]), | |
| "toggle_anti_spam": ("channels", "ToggleAntiSpamRequest", ["channel", "enabled"]), | |
| "toggle_autotranslation": ("channels", "ToggleAutotranslationRequest", ["channel", "enabled"]), | |
| "toggle_bot_in_attach_menu": ("messages", "ToggleBotInAttachMenuRequest", ["bot", "enabled", "write_allowed"]), | |
| "toggle_chat_star_gift_notifications": ("payments", "ToggleChatStarGiftNotificationsRequest", ["peer", "enabled"]), | |
| "toggle_connected_bot_paused": ("account", "ToggleConnectedBotPausedRequest", ["peer", "paused"]), | |
| "toggle_dialog_filter_tags": ("messages", "ToggleDialogFilterTagsRequest", ["enabled"]), | |
| "toggle_dialog_pin": ("messages", "ToggleDialogPinRequest", ["peer", "pinned"]), | |
| "toggle_forum": ("channels", "ToggleForumRequest", ["channel", "enabled", "tabs"]), | |
| "toggle_group_call_record": ("phone", "ToggleGroupCallRecordRequest", ["call", "start", "video", "title", "video_portrait"]), | |
| "toggle_group_call_settings": ("phone", "ToggleGroupCallSettingsRequest", ["call", "reset_invite_hash", "join_muted", "messages_enabled", "send_paid_messages_stars"]), | |
| "toggle_group_call_start_subscription": ("phone", "ToggleGroupCallStartSubscriptionRequest", ["call", "subscribed"]), | |
| "toggle_join": ("channels", "ToggleJoinRequestRequest", ["channel", "enabled"]), | |
| "toggle_join_to_send": ("channels", "ToggleJoinToSendRequest", ["channel", "enabled"]), | |
| "toggle_no_forwards": ("messages", "ToggleNoForwardsRequest", ["peer", "enabled", "request_msg_id"]), | |
| "toggle_paid_reaction_privacy": ("messages", "TogglePaidReactionPrivacyRequest", ["peer", "msg_id", "private"]), | |
| "toggle_participants_hidden": ("channels", "ToggleParticipantsHiddenRequest", ["channel", "enabled"]), | |
| "toggle_peer_stories_hidden": ("stories", "TogglePeerStoriesHiddenRequest", ["peer", "hidden"]), | |
| "toggle_peer_translations": ("messages", "TogglePeerTranslationsRequest", ["peer", "disabled"]), | |
| "toggle_pinned": ("stories", "TogglePinnedRequest", ["peer", "id", "pinned"]), | |
| "toggle_pinned_to_top": ("stories", "TogglePinnedToTopRequest", ["peer", "id"]), | |
| "toggle_pre_history_hidden": ("channels", "TogglePreHistoryHiddenRequest", ["channel", "enabled"]), | |
| "toggle_saved_dialog_pin": ("messages", "ToggleSavedDialogPinRequest", ["peer", "pinned"]), | |
| "toggle_signatures": ("channels", "ToggleSignaturesRequest", ["channel", "signatures_enabled", "profiles_enabled"]), | |
| "toggle_slow_mode": ("channels", "ToggleSlowModeRequest", ["channel", "seconds"]), | |
| "toggle_sponsored_messages": ("account", "ToggleSponsoredMessagesRequest", ["enabled"]), | |
| "toggle_star_gifts_pinned_to_top": ("payments", "ToggleStarGiftsPinnedToTopRequest", ["peer", "stargift"]), | |
| "toggle_sticker_sets": ("messages", "ToggleStickerSetsRequest", ["stickersets", "uninstall", "archive", "unarchive"]), | |
| "toggle_suggested_post_approval": ("messages", "ToggleSuggestedPostApprovalRequest", ["peer", "msg_id", "reject", "schedule_date", "reject_comment"]), | |
| "toggle_todo_completed": ("messages", "ToggleTodoCompletedRequest", ["peer", "msg_id", "completed", "incompleted"]), | |
| "toggle_top_peers": ("contacts", "ToggleTopPeersRequest", ["enabled"]), | |
| "toggle_user_emoji_status_permission": ("bots", "ToggleUserEmojiStatusPermissionRequest", ["bot", "enabled"]), | |
| "toggle_username": ("account", "ToggleUsernameRequest", ["username", "active"]), | |
| "toggle_view_forum_as_messages": ("channels", "ToggleViewForumAsMessagesRequest", ["channel", "enabled"]), | |
| "transcribe_audio": ("messages", "TranscribeAudioRequest", ["peer", "msg_id"]), | |
| "transfer_star_gift": ("payments", "TransferStarGiftRequest", ["stargift", "to_id"]), | |
| "translate_text": ("messages", "TranslateTextRequest", ["to_lang", "peer", "id", "text", "tone"]), | |
| "unblock": ("contacts", "UnblockRequest", ["id", "my_stories_from"]), | |
| "uninstall_sticker_set": ("messages", "UninstallStickerSetRequest", ["stickerset"]), | |
| "update_album": ("stories", "UpdateAlbumRequest", ["peer", "album_id", "title", "delete_stories", "add_stories", "order"]), | |
| "update_birthday": ("account", "UpdateBirthdayRequest", ["birthday"]), | |
| "update_business_away_message": ("account", "UpdateBusinessAwayMessageRequest", ["message"]), | |
| "update_business_greeting_message": ("account", "UpdateBusinessGreetingMessageRequest", ["message"]), | |
| "update_business_intro": ("account", "UpdateBusinessIntroRequest", ["intro"]), | |
| "update_business_location": ("account", "UpdateBusinessLocationRequest", ["geo_point", "address"]), | |
| "update_business_work_hours": ("account", "UpdateBusinessWorkHoursRequest", ["business_work_hours"]), | |
| "update_color": ("account", "UpdateColorRequest", ["for_profile", "color"]), | |
| "update_connected_bot": ("account", "UpdateConnectedBotRequest", ["bot", "recipients", "deleted", "rights"]), | |
| "update_contact_note": ("contacts", "UpdateContactNoteRequest", ["id", "note"]), | |
| "update_device_locked": ("account", "UpdateDeviceLockedRequest", ["period"]), | |
| "update_dialog_filter": ("messages", "UpdateDialogFilterRequest", ["id", "filter"]), | |
| "update_dialog_filters_order": ("messages", "UpdateDialogFiltersOrderRequest", ["order"]), | |
| "update_emoji_status": ("account", "UpdateEmojiStatusRequest", ["emoji_status"]), | |
| "update_notify_settings": ("account", "UpdateNotifySettingsRequest", ["peer", "settings"]), | |
| "update_paid_messages_price": ("channels", "UpdatePaidMessagesPriceRequest", ["channel", "send_paid_messages_stars", "broadcast_messages_allowed"]), | |
| "update_password_settings": ("account", "UpdatePasswordSettingsRequest", ["password", "new_settings"]), | |
| "update_personal_channel": ("account", "UpdatePersonalChannelRequest", ["channel"]), | |
| "update_pinned_forum_topic": ("messages", "UpdatePinnedForumTopicRequest", ["peer", "topic_id", "pinned"]), | |
| "update_pinned_message": ("messages", "UpdatePinnedMessageRequest", ["peer", "id", "silent", "unpin", "pm_oneside"]), | |
| "update_profile": ("account", "UpdateProfileRequest", ["first_name", "last_name", "about"]), | |
| "update_profile_photo": ("photos", "UpdateProfilePhotoRequest", ["id", "fallback", "bot"]), | |
| "update_saved_reaction_tag": ("messages", "UpdateSavedReactionTagRequest", ["reaction", "title"]), | |
| "update_star_gift_collection": ("payments", "UpdateStarGiftCollectionRequest", ["peer", "collection_id", "title", "delete_stargift", "add_stargift", "order"]), | |
| "update_star_gift_price": ("payments", "UpdateStarGiftPriceRequest", ["stargift", "resell_amount"]), | |
| "update_star_ref_program": ("bots", "UpdateStarRefProgramRequest", ["bot", "commission_permille", "duration_months"]), | |
| "update_status": ("account", "UpdateStatusRequest", ["offline"]), | |
| "update_theme": ("account", "UpdateThemeRequest", ["format", "theme", "slug", "title", "document", "settings"]), | |
| "update_user_emoji_status": ("bots", "UpdateUserEmojiStatusRequest", ["user_id", "emoji_status"]), | |
| "update_username": ("channels", "UpdateUsernameRequest", ["channel", "username"]), | |
| "upgrade_star_gift": ("payments", "UpgradeStarGiftRequest", ["stargift", "keep_original_details"]), | |
| "upload_contact_profile_photo": ("photos", "UploadContactProfilePhotoRequest", ["user_id", "suggest", "save", "file", "video", "video_start_ts", "video_emoji_markup"]), | |
| "upload_encrypted_file": ("messages", "UploadEncryptedFileRequest", ["peer", "file"]), | |
| "upload_imported_media": ("messages", "UploadImportedMediaRequest", ["peer", "import_id", "file_name", "media"]), | |
| "upload_profile_photo": ("photos", "UploadProfilePhotoRequest", ["fallback", "bot", "file", "video", "video_start_ts", "video_emoji_markup"]), | |
| "upload_ringtone": ("account", "UploadRingtoneRequest", ["file", "file_name", "mime_type"]), | |
| "url_auth": ("messages", "RequestUrlAuthRequest", ["peer", "msg_id", "button_id", "url", "in_app_origin"]), | |
| "validateed_info": ("payments", "ValidateRequestedInfoRequest", ["invoice", "info", "save"]), | |
| "view_sponsored_message": ("messages", "ViewSponsoredMessageRequest", ["random_id"]), | |
| "web_view": ("messages", "RequestWebViewRequest", ["peer", "bot", "platform", "from_bot_menu", "silent", "compact", "fullscreen", "url", "start_param", "theme_params", "reply_to", "send_as"]), | |
| "web_view_button": ("bots", "RequestWebViewButtonRequest", ["user_id", "button"]), | |
| } | |
| _TL_DISPATCH_ALIASES = { | |
| "read_poll_votes": "messages_read_poll_votes", | |
| "update_color": "channels_update_color", | |
| } | |
| async def _tool_telethon(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Generic Telethon TL method dispatcher — 582 Telegram API methods.""" | |
| entry = self._TL_DISPATCH.get(action) | |
| if entry is None: | |
| alias = self._TL_DISPATCH_ALIASES.get(action) | |
| if alias: | |
| entry = self._TL_DISPATCH.get(alias) | |
| if entry is None: | |
| return self._tool_err("unknown TL action: {}".format(action)) | |
| action = alias | |
| mod_name, cls_name, param_names = entry | |
| try: | |
| kwargs = {} | |
| for pname in param_names: | |
| val = raw.get(pname) | |
| if val is None: | |
| continue | |
| if pname in ("peer", "channel", "chat_id", "from_peer", "to_peer", "participant", "user_id", "bot", "send_as", "id", "join_as"): | |
| try: | |
| kwargs[pname] = await self._resolve_target_entity(val, chat_id) | |
| except Exception: | |
| kwargs[pname] = val | |
| elif pname == "users": | |
| if isinstance(val, list): | |
| kwargs[pname] = [await self._resolve_target_entity(v, chat_id) for v in val] | |
| else: | |
| kwargs[pname] = [await self._resolve_target_entity(val, chat_id)] | |
| elif pname == "contacts" and isinstance(val, list): | |
| kwargs[pname] = val | |
| else: | |
| kwargs[pname] = val | |
| from telethon.tl import functions as tl_funcs | |
| mod = getattr(tl_funcs, mod_name) | |
| cls = getattr(mod, cls_name) | |
| req = cls(**kwargs) if kwargs else cls() | |
| result = await self.client(req) | |
| return self._tool_ok({"action": action, "result": self._serialize_tl_result(result)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| @staticmethod | |
| def _serialize_tl_result(obj): | |
| """Serialize Telethon TL result to JSON-safe dict.""" | |
| if obj is None: | |
| return None | |
| if isinstance(obj, (bool, int, float, str)): | |
| return obj | |
| if isinstance(obj, bytes): | |
| return "<bytes len={}>".format(len(obj)) | |
| if isinstance(obj, list): | |
| return [Gemini._serialize_tl_result(i) for i in obj[:50]] | |
| if isinstance(obj, dict): | |
| return {str(k): Gemini._serialize_tl_result(v) for k, v in list(obj.items())[:50]} | |
| result = {} | |
| for attr in dir(obj): | |
| if attr.startswith('_'): | |
| continue | |
| try: | |
| val = getattr(obj, attr) | |
| if callable(val): | |
| continue | |
| result[attr] = Gemini._serialize_tl_result(val) | |
| except Exception: | |
| pass | |
| return result if result else str(obj)[:200] | |
| return result if result else str(obj)[:200] | |
| # ═══════════════════════════════════════════════════════════ | |
| # CLI TOOLS — Shell, Filesystem, Web, Workspace | |
| # ═══════════════════════════════════════════════════════════ | |
| async def _tool_cli(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Unified handler for shell, filesystem, web fetch, and workspace actions.""" | |
| root = self._get_os_tools_root() | |
| ws_path = self._get_session_workspace(chat_id) or self._resolve_os_path("_cli", default_name=".") | |
| def _resolve(p, default_name="file"): | |
| if not p: | |
| p = default_name | |
| candidate = os.path.join(ws_path, str(p).lstrip("/")) | |
| candidate = os.path.abspath(os.path.expanduser(candidate)) | |
| if os.path.commonpath([root, candidate]) != root: | |
| raise ValueError("path escapes sandbox: {}".format(p)) | |
| return candidate | |
| # ── Shell ── | |
| if action == "shell_run": | |
| cmd = str(raw.get("command") or raw.get("cmd") or "").strip() | |
| if not cmd: | |
| return self._tool_err("missing command") | |
| cwd = _resolve(raw.get("cwd") or raw.get("workdir") or ws_path, default_name=".") | |
| timeout = min(int(raw.get("timeout") or 60), 300) | |
| env = os.environ.copy() | |
| if isinstance(raw.get("env"), dict): | |
| for k, v in raw["env"].items(): | |
| env[str(k)] = str(v) | |
| try: | |
| proc = await asyncio.create_subprocess_shell( | |
| cmd, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env) | |
| stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) | |
| stdout_s = stdout.decode("utf-8", "replace")[:50000] | |
| stderr_s = stderr.decode("utf-8", "replace")[:50000] | |
| return self._tool_ok({"action": action, "exit_code": proc.returncode or 0, "stdout": stdout_s, "stderr": stderr_s, "truncated": len(stdout) > 50000 or len(stderr) > 50000}) | |
| except asyncio.TimeoutError: | |
| proc.kill() | |
| return self._tool_ok({"action": action, "exit_code": -1, "stdout": "", "stderr": "timeout after {}s".format(timeout), "timed_out": True}) | |
| if action == "shell_stream": | |
| cmd = str(raw.get("command") or raw.get("cmd") or "").strip() | |
| if not cmd: | |
| return self._tool_err("missing command") | |
| cwd = _resolve(raw.get("cwd") or raw.get("workdir") or ws_path, default_name=".") | |
| job_id = uuid.uuid4().hex[:8] | |
| outfile = os.path.join(ws_path, f".shell_{job_id}.log") | |
| env = os.environ.copy() | |
| if isinstance(raw.get("env"), dict): | |
| for k, v in raw["env"].items(): | |
| env[str(k)] = str(v) | |
| proc = await asyncio.create_subprocess_shell( | |
| cmd, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env) | |
| self._cli_shell_jobs[job_id] = {"proc": proc, "outfile": outfile, "cwd": cwd, "cmd": cmd, "started": time.time()} | |
| return self._tool_ok({"action": action, "job_id": job_id, "cmd": cmd, "cwd": cwd, "hint": "use shell_status to check progress"}) | |
| if action == "shell_status": | |
| job_id = str(raw.get("job_id") or "").strip() | |
| if not job_id or job_id not in self._cli_shell_jobs: | |
| return self._tool_err("unknown job_id: use shell_status without job_id to list all" if job_id else "no job_id — active: {}".format(list(self._cli_shell_jobs.keys())[:10])) | |
| job = self._cli_shell_jobs[job_id] | |
| proc = job["proc"] | |
| if proc.returncode is not None: | |
| try: | |
| with open(job["outfile"], "r") as f: | |
| output = f.read()[-8000:] | |
| except Exception: | |
| output = "" | |
| del self._cli_shell_jobs[job_id] | |
| return self._tool_ok({"action": action, "job_id": job_id, "done": True, "exit_code": proc.returncode, "output": output, "runtime": time.time() - job["started"]}) | |
| return self._tool_ok({"action": action, "job_id": job_id, "done": False, "cmd": job["cmd"], "runtime": time.time() - job["started"]}) | |
| if action == "shell_kill": | |
| job_id = str(raw.get("job_id") or "").strip() | |
| if not job_id or job_id not in self._cli_shell_jobs: | |
| return self._tool_err("unknown job_id: {}".format(list(self._cli_shell_jobs.keys())[:10])) | |
| job = self._cli_shell_jobs.pop(job_id) | |
| with contextlib.suppress(Exception): | |
| job["proc"].kill() | |
| return self._tool_ok({"action": action, "job_id": job_id, "killed": True}) | |
| # ── Filesystem ── | |
| if action == "fs_read": | |
| path = _resolve(raw.get("path")) | |
| offset = max(0, int(raw.get("offset") or 0)) | |
| limit = min(int(raw.get("limit") or 500), 5000) | |
| if not os.path.isfile(path): | |
| return self._tool_err("not a file: {}".format(path)) | |
| with open(path, "rb") as f: | |
| data = f.read() | |
| try: | |
| text = data.decode("utf-8") | |
| lines = text.split("\n") | |
| total = len(lines) | |
| chunk = "\n".join(lines[offset:offset + limit]) | |
| return self._tool_ok({"action": action, "path": path, "total_lines": total, "content": chunk, "offset": offset, "limit": limit}) | |
| except UnicodeDecodeError: | |
| return self._tool_ok({"action": action, "path": path, "binary": True, "size": len(data), "hex_preview": data[:200].hex()}) | |
| if action == "fs_write": | |
| path = _resolve(raw.get("path")) | |
| content = str(raw.get("content") or "") | |
| os.makedirs(os.path.dirname(path) or ".", exist_ok=True) | |
| with open(path, "w", encoding="utf-8") as f: | |
| f.write(content) | |
| return self._tool_ok({"action": action, "path": path, "written": len(content), "size": os.path.getsize(path)}) | |
| if action == "fs_edit": | |
| path = _resolve(raw.get("path")) | |
| old_text = str(raw.get("old_text") or raw.get("old") or "") | |
| new_text = str(raw.get("new_text") or raw.get("new") or "") | |
| if not old_text: | |
| return self._tool_err("missing old_text to replace") | |
| if not os.path.isfile(path): | |
| return self._tool_err("not a file: {}".format(path)) | |
| with open(path, "r", encoding="utf-8") as f: | |
| original = f.read() | |
| count = original.count(old_text) | |
| if count == 0: | |
| return self._tool_err("old_text not found in file") | |
| if count > 1: | |
| return self._tool_err("old_text found {} times — must be unique".format(count)) | |
| with open(path, "w", encoding="utf-8") as f: | |
| f.write(original.replace(old_text, new_text)) | |
| return self._tool_ok({"action": action, "path": path, "replaced": True}) | |
| if action == "fs_append": | |
| path = _resolve(raw.get("path")) | |
| content = str(raw.get("content") or "") | |
| os.makedirs(os.path.dirname(path) or ".", exist_ok=True) | |
| with open(path, "a", encoding="utf-8") as f: | |
| f.write(content) | |
| return self._tool_ok({"action": action, "path": path, "appended": len(content)}) | |
| if action == "fs_insert": | |
| path = _resolve(raw.get("path")) | |
| line_num = int(raw.get("line") or raw.get("at_line") or 0) | |
| content = str(raw.get("content") or "") | |
| if not os.path.isfile(path): | |
| return self._tool_err("not a file: {}".format(path)) | |
| with open(path, "r", encoding="utf-8") as f: | |
| lines = f.readlines() | |
| lines.insert(line_num, content.rstrip("\n") + "\n") | |
| with open(path, "w", encoding="utf-8") as f: | |
| f.writelines(lines) | |
| return self._tool_ok({"action": action, "path": path, "inserted_at": line_num}) | |
| if action == "fs_list": | |
| path = _resolve(raw.get("path") or ".") | |
| if not os.path.isdir(path): | |
| return self._tool_err("not a directory: {}".format(path)) | |
| items = [] | |
| for name in sorted(os.listdir(path)): | |
| fp = os.path.join(path, name) | |
| try: | |
| st = os.stat(fp) | |
| items.append({"name": name, "type": "dir" if os.path.isdir(fp) else "file", "size": st.st_size, "mtime": st.st_mtime}) | |
| except OSError: | |
| pass | |
| return self._tool_ok({"action": action, "path": path, "items": items, "count": len(items)}) | |
| if action == "fs_tree": | |
| path = _resolve(raw.get("path") or ".") | |
| max_depth = min(int(raw.get("depth") or 3), 5) | |
| lines = [] | |
| for current, dirs, files in os.walk(path): | |
| depth = current.replace(path, "").count(os.sep) | |
| if depth > max_depth: | |
| dirs[:] = [] | |
| continue | |
| indent = " " * depth | |
| lines.append("{}{}/".format(indent, os.path.basename(current) or ".")) | |
| for fname in sorted(files)[:50]: | |
| lines.append("{} {}".format(indent, fname)) | |
| dirs[:] = sorted(d for d in dirs if not d.startswith(".")) | |
| return self._tool_ok({"action": action, "path": path, "tree": "\n".join(lines[:200])}) | |
| if action == "fs_search": | |
| path = _resolve(raw.get("path") or ".") | |
| pattern = str(raw.get("pattern") or raw.get("query") or "*").strip() | |
| limit = min(int(raw.get("limit") or 50), 200) | |
| matches = [] | |
| regex = re.compile(re.escape(pattern).replace(r"\*", ".*").replace(r"\?", ".")) | |
| for current, dirs, files in os.walk(path): | |
| dirs[:] = [d for d in dirs if not d.startswith(".")] | |
| for fname in files: | |
| if fname.startswith("."): | |
| continue | |
| if regex.search(fname): | |
| matches.append(os.path.relpath(os.path.join(current, fname), path)) | |
| if len(matches) >= limit: | |
| break | |
| if len(matches) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "pattern": pattern, "matches": matches, "count": len(matches)}) | |
| if action == "fs_grep": | |
| path = _resolve(raw.get("path") or ".") | |
| query = str(raw.get("query") or raw.get("pattern") or "").strip() | |
| if not query: | |
| return self._tool_err("missing query") | |
| limit = min(int(raw.get("limit") or 30), 100) | |
| case = raw.get("ignore_case") != False | |
| flags = re.IGNORECASE if case else 0 | |
| results = [] | |
| for current, dirs, files in os.walk(path): | |
| dirs[:] = [d for d in dirs if not d.startswith(".")] | |
| for fname in files: | |
| if fname.startswith("."): | |
| continue | |
| fp = os.path.join(current, fname) | |
| try: | |
| with open(fp, "r", encoding="utf-8", errors="replace") as f: | |
| for lineno, line in enumerate(f, 1): | |
| if re.search(re.escape(query), line, flags): | |
| results.append({"file": os.path.relpath(fp, path), "line": lineno, "text": line.strip()[:200]}) | |
| if len(results) >= limit: | |
| break | |
| except Exception: | |
| pass | |
| if len(results) >= limit: | |
| break | |
| if len(results) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "query": query, "results": results, "count": len(results)}) | |
| if action == "fs_info": | |
| path = _resolve(raw.get("path")) | |
| st = os.stat(path) | |
| info = {"path": path, "exists": True, "size": st.st_size, "mtime": st.st_mtime, "is_dir": os.path.isdir(path), "is_file": os.path.isfile(path)} | |
| if os.path.isfile(path): | |
| info["mime"] = mimetypes.guess_type(path)[0] or "application/octet-stream" | |
| with open(path, "rb") as f: | |
| info["sha256"] = hashlib.sha256(f.read(1024 * 1024)).hexdigest() | |
| return self._tool_ok({"action": action, "info": info}) | |
| if action == "fs_mkdir": | |
| path = _resolve(raw.get("path")) | |
| os.makedirs(path, exist_ok=True) | |
| return self._tool_ok({"action": action, "path": path, "created": True}) | |
| if action == "fs_move": | |
| src = _resolve(raw.get("source") or raw.get("src")) | |
| dst = _resolve(raw.get("target") or raw.get("dst")) | |
| os.rename(src, dst) | |
| return self._tool_ok({"action": action, "from": src, "to": dst}) | |
| if action == "fs_copy": | |
| src = _resolve(raw.get("source") or raw.get("src")) | |
| dst = _resolve(raw.get("target") or raw.get("dst")) | |
| if os.path.isdir(src): | |
| shutil.copytree(src, dst) | |
| else: | |
| os.makedirs(os.path.dirname(dst) or ".", exist_ok=True) | |
| shutil.copy2(src, dst) | |
| return self._tool_ok({"action": action, "from": src, "to": dst}) | |
| if action == "fs_delete": | |
| if not self._tool_bool(raw.get("confirm")): | |
| return self._tool_err("fs_delete requires confirm=true") | |
| path = _resolve(raw.get("path")) | |
| if os.path.isdir(path): | |
| shutil.rmtree(path) | |
| else: | |
| os.remove(path) | |
| return self._tool_ok({"action": action, "path": path, "deleted": True}) | |
| if action == "fs_zip": | |
| src = _resolve(raw.get("source") or raw.get("src") or ".") | |
| dst = _resolve(raw.get("target") or raw.get("dst"), default_name="archive.zip") | |
| with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf: | |
| if os.path.isdir(src): | |
| for current, dirs, files in os.walk(src): | |
| for fname in files: | |
| fp = os.path.join(current, fname) | |
| zf.write(fp, os.path.relpath(fp, src)) | |
| else: | |
| zf.write(src, os.path.basename(src)) | |
| return self._tool_ok({"action": action, "archive": dst, "size": os.path.getsize(dst)}) | |
| if action == "fs_unzip": | |
| src = _resolve(raw.get("source") or raw.get("src")) | |
| dst = _resolve(raw.get("target") or raw.get("dst") or ".") | |
| os.makedirs(dst, exist_ok=True) | |
| with zipfile.ZipFile(src, "r") as zf: | |
| zf.extractall(dst) | |
| return self._tool_ok({"action": action, "extracted_to": dst, "files": len(zf.namelist())}) | |
| # ── Web ── | |
| if action == "web_fetch": | |
| url = str(raw.get("url") or "").strip() | |
| if not url: | |
| return self._tool_err("missing url") | |
| timeout = min(int(raw.get("timeout") or 30), 60) | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp: | |
| data = await resp.read() | |
| ct = resp.headers.get("Content-Type", "") | |
| text = data.decode("utf-8", "replace") | |
| if "text/html" in ct or "text/plain" in ct: | |
| text = re.sub(r"<[^>]+>", " ", text) | |
| text = re.sub(r"\s+", " ", text) | |
| return self._tool_ok({"action": action, "url": url, "status": resp.status, "content_type": ct, "text": text[:30000], "truncated": len(text) > 30000}) | |
| except Exception as e: | |
| return self._tool_err("fetch failed: {}".format(str(e))) | |
| if action == "web_download": | |
| url = str(raw.get("url") or "").strip() | |
| if not url: | |
| return self._tool_err("missing url") | |
| filename = str(raw.get("filename") or os.path.basename(urlparse(url).path) or "download") | |
| path = _resolve(filename) | |
| timeout = min(int(raw.get("timeout") or 60), 120) | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp: | |
| data = await resp.read() | |
| os.makedirs(os.path.dirname(path) or ".", exist_ok=True) | |
| with open(path, "wb") as f: | |
| f.write(data) | |
| return self._tool_ok({"action": action, "url": url, "path": path, "size": len(data)}) | |
| except Exception as e: | |
| return self._tool_err("download failed: {}".format(str(e))) | |
| # ── Workspace ── | |
| if action == "workspace_info": | |
| files = [] | |
| total_size = 0 | |
| for current, dirs, fnames in os.walk(ws_path): | |
| dirs[:] = [d for d in dirs if not d.startswith(".")] | |
| for fn in sorted(fnames): | |
| if fn.startswith("."): | |
| continue | |
| fp = os.path.join(current, fn) | |
| try: | |
| sz = os.path.getsize(fp) | |
| total_size += sz | |
| files.append({"path": os.path.relpath(fp, ws_path), "size": sz}) | |
| except OSError: | |
| pass | |
| return self._tool_ok({"action": action, "workspace": ws_path, "files": files[:100], "file_count": len(files), "total_size": total_size}) | |
| if action == "workspace_clean": | |
| if not self._tool_bool(raw.get("confirm")): | |
| return self._tool_err("workspace_clean requires confirm=true") | |
| for item in os.listdir(ws_path): | |
| fp = os.path.join(ws_path, item) | |
| try: | |
| if os.path.isdir(fp): | |
| shutil.rmtree(fp) | |
| else: | |
| os.remove(fp) | |
| except OSError: | |
| pass | |
| return self._tool_ok({"action": action, "workspace": ws_path, "cleaned": True}) | |
| # ── Web Search (via SearXNG/DuckDuckGo) ── | |
| if action == "web_search": | |
| query = str(raw.get("query") or "").strip() | |
| if not query: | |
| return self._tool_err("missing query") | |
| count = min(int(raw.get("count") or 5), 10) | |
| results = [] | |
| ua = "Mozilla/5.0 (compatible; AetherAI/1.0; +https://github.com)" | |
| # ── Engine 1: DuckDuckGo HTML ── | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get("https://html.duckduckgo.com/html/", params={"q": query}, headers={"User-Agent": ua}, timeout=aiohttp.ClientTimeout(total=8)) as resp: | |
| html = await resp.text() | |
| for m in re.finditer(r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)</a>', html): | |
| results.append({"title": m.group(2).strip(), "url": m.group(1).strip(), "snippet": ""}) | |
| if results: | |
| for i, r in enumerate(results): | |
| sm = re.search(r'<a[^>]*class="result__snippet"[^>]*>(.*?)</a>', html[html.find(r["url"]):], re.DOTALL) | |
| if sm: | |
| results[i]["snippet"] = re.sub(r'<[^>]+>', '', sm.group(1)).strip()[:300] | |
| except Exception: | |
| pass | |
| # ── Engine 2: Wikipedia (always works, no blocks) ── | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get("https://en.wikipedia.org/w/api.php", params={"action":"query","list":"search","srsearch":query,"format":"json","srlimit":count}, headers={"User-Agent": ua}, timeout=aiohttp.ClientTimeout(total=8)) as resp: | |
| data = await resp.json() | |
| for r in data.get("query",{}).get("search",[]): | |
| results.append({"title": r["title"], "url": f"https://en.wikipedia.org/wiki/{r['title'].replace(' ','_')}", "snippet": re.sub(r'<[^>]+>','',r.get("snippet",""))[:300], "source": "wikipedia"}) | |
| except Exception: | |
| pass | |
| # ── Engine 3: StackExchange (tech queries) ── | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get("https://api.stackexchange.com/2.3/search", params={"site":"stackoverflow","order":"desc","sort":"relevance","intitle":query,"pagesize":max(3,count//2)}, headers={"User-Agent": ua}, timeout=aiohttp.ClientTimeout(total=8)) as resp: | |
| data = await resp.json() | |
| for r in data.get("items",[]): | |
| results.append({"title": r["title"], "url": r.get("link",""), "snippet": "Score:{} | Tags:{}".format(r.get("score",0), ",".join(r.get("tags",[])[:5])), "source": "stackoverflow"}) | |
| except Exception: | |
| pass | |
| # ── Engine 4: Local SearXNG proxy (if running) ── | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get("http://127.0.0.1:18798/search", params={"q": query, "format": "json"}, timeout=aiohttp.ClientTimeout(total=6)) as resp: | |
| data = await resp.json() | |
| for r in (data.get("results") or [])[:count]: | |
| results.append({"title": r.get("title",""), "url": r.get("url",""), "snippet": r.get("content","") or r.get("snippet",""), "source": "searxng"}) | |
| except Exception: | |
| pass | |
| # ── Engine 5: SearXNG public pool (rotating) ── | |
| if not results: | |
| searx_pool = ["https://searx.be", "https://search.sapti.me", "https://searx.tuxcloud.net", "https://search.bus-hit.me"] | |
| import random as _random | |
| for sx_url in _random.sample(searx_pool, min(3, len(searx_pool))): | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get(f"{sx_url}/search", params={"q": query, "format": "json"}, headers={"User-Agent": ua}, timeout=aiohttp.ClientTimeout(total=6)) as resp: | |
| data = await resp.json() | |
| for r in (data.get("results") or [])[:count]: | |
| results.append({"title": r.get("title",""), "url": r.get("url",""), "snippet": r.get("content","") or r.get("snippet",""), "source": sx_url.split("//")[1].split("/")[0]}) | |
| if results: | |
| break | |
| except Exception: | |
| continue | |
| # ── Engine 6: DDG Lite (last resort) ── | |
| if not results: | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get("https://lite.duckduckgo.com/lite/", params={"q": query}, headers={"User-Agent": "curl/8.0"}, timeout=aiohttp.ClientTimeout(total=8)) as resp: | |
| html = await resp.text() | |
| for m in re.finditer(r'<a[^>]*href="(https?://[^"]+)"[^>]*class="result-link"[^>]*>([^<]+)</a>', html): | |
| url = m.group(1).strip() | |
| if "duckduckgo" not in url: | |
| results.append({"title": m.group(2).strip(), "url": url, "snippet": ""}) | |
| if not results: | |
| for m in re.finditer(r'<a[^>]*href="(https?://[^"]+)"[^>]*>([^<]+)</a>', html): | |
| url = m.group(1).strip() | |
| if "duckduckgo.com" not in url and "spreadprivacy" not in url and not url.startswith("//"): | |
| results.append({"title": m.group(2).strip(), "url": url, "snippet": ""}) | |
| except Exception: | |
| pass | |
| # ── Deduplicate by URL ── | |
| seen = set() | |
| deduped = [] | |
| for r in results: | |
| if r["url"] not in seen: | |
| seen.add(r["url"]) | |
| deduped.append(r) | |
| results = deduped[:count] | |
| if not results: | |
| return self._tool_err("all {} search engines failed — try Google provider with google_search enabled".format(6)) | |
| return self._tool_ok({"action": action, "query": query, "results": results, "count": len(results)}) | |
| # ── Memory System ── | |
| if action == "memory_write": | |
| text = str(raw.get("text") or raw.get("content") or "").strip() | |
| if not text: | |
| return self._tool_err("missing text/content") | |
| tag = str(raw.get("tag") or raw.get("category") or "general").strip().lower() | |
| mem_root = os.path.join(root, "_aether_memory") | |
| os.makedirs(mem_root, exist_ok=True) | |
| today = datetime.now().strftime("%Y-%m-%d") | |
| fpath = os.path.join(mem_root, f"{today}.md") | |
| entry = "\n## {}\n{}\n".format(tag, text.strip()) | |
| with open(fpath, "a", encoding="utf-8") as f: | |
| f.write(entry) | |
| # Also update MEMORY.md for long-term | |
| ltm = os.path.join(mem_root, "MEMORY.md") | |
| with open(ltm, "a", encoding="utf-8") as f: | |
| f.write("\n### [{}] {}\n{}\n".format(today, tag, text.strip())) | |
| return self._tool_ok({"action": action, "tag": tag, "date": today, "written": len(text)}) | |
| if action == "memory_read": | |
| date = str(raw.get("date") or datetime.now().strftime("%Y-%m-%d")).strip() | |
| mem_root = os.path.join(root, "_aether_memory") | |
| fpath = os.path.join(mem_root, f"{date}.md") | |
| if os.path.isfile(fpath): | |
| with open(fpath, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| return self._tool_ok({"action": action, "date": date, "content": content[:20000]}) | |
| return self._tool_ok({"action": action, "date": date, "content": "(no entries for this date)"}) | |
| if action == "memory_list": | |
| mem_root = os.path.join(root, "_aether_memory") | |
| if not os.path.isdir(mem_root): | |
| return self._tool_ok({"action": action, "files": []}) | |
| files = sorted([f for f in os.listdir(mem_root) if f.endswith(".md")], reverse=True)[:30] | |
| return self._tool_ok({"action": action, "files": files}) | |
| if action == "memory_search": | |
| query = str(raw.get("query") or "").strip() | |
| if not query: | |
| return self._tool_err("missing query") | |
| mem_root = os.path.join(root, "_aether_memory") | |
| results = [] | |
| if os.path.isdir(mem_root): | |
| for fname in sorted(os.listdir(mem_root), reverse=True): | |
| if not fname.endswith(".md"): | |
| continue | |
| fp = os.path.join(mem_root, fname) | |
| try: | |
| with open(fp, "r", encoding="utf-8") as f: | |
| for lineno, line in enumerate(f, 1): | |
| if query.lower() in line.lower(): | |
| results.append({"file": fname, "line": lineno, "text": line.strip()[:300]}) | |
| if len(results) >= 30: | |
| break | |
| except Exception: | |
| pass | |
| if len(results) >= 30: | |
| break | |
| return self._tool_ok({"action": action, "query": query, "results": results, "count": len(results)}) | |
| if action == "memory_semantic": | |
| query = str(raw.get("query") or "").strip() | |
| if not query: | |
| return self._tool_err("missing query") | |
| import math | |
| mem_root = os.path.join(root, "_aether_memory") | |
| # Simple TF-IDF-like scoring | |
| query_words = set(re.findall(r"\w+", query.lower())) | |
| scored = [] | |
| if os.path.isdir(mem_root): | |
| for fname in sorted(os.listdir(mem_root), reverse=True): | |
| if not fname.endswith(".md"): | |
| continue | |
| fp = os.path.join(mem_root, fname) | |
| try: | |
| with open(fp, "r", encoding="utf-8") as f: | |
| text = f.read() | |
| words = set(re.findall(r"\w+", text.lower())) | |
| score = len(query_words & words) / max(1, math.log(len(words) + 1)) | |
| if score > 0: | |
| lines = text.split("\n") | |
| snippets = [l.strip()[:200] for l in lines if any(qw in l.lower() for qw in query_words)][:5] | |
| scored.append({"file": fname, "score": round(score, 4), "snippets": snippets}) | |
| except Exception: | |
| pass | |
| scored.sort(key=lambda x: x["score"], reverse=True) | |
| return self._tool_ok({"action": action, "query": query, "results": scored[:10]}) | |
| # ── Git Operations ── | |
| if action.startswith("git_"): | |
| git_action = action[4:] | |
| cwd = _resolve(raw.get("cwd") or ws_path) | |
| async def _git(*args): | |
| proc = await asyncio.create_subprocess_exec("git", *args, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) | |
| stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) | |
| return proc.returncode, stdout.decode("utf-8", "replace")[:10000], stderr.decode("utf-8", "replace")[:5000] | |
| if git_action == "status": | |
| rc, out, err = await _git("status", "--porcelain") | |
| return self._tool_ok({"action": action, "cwd": cwd, "output": out or "(clean)"}) | |
| if git_action == "diff": | |
| staged = self._tool_bool(raw.get("staged")) | |
| args = ["diff", "--staged"] if staged else ["diff"] | |
| rc, out, err = await _git(*args) | |
| return self._tool_ok({"action": action, "output": out[:8000] or "(no changes)"}) | |
| if git_action == "log": | |
| n = int(raw.get("n") or 20) | |
| rc, out, err = await _git("log", "--oneline", "-n", str(n)) | |
| return self._tool_ok({"action": action, "output": out}) | |
| if git_action == "add": | |
| paths = raw.get("paths") or raw.get("path") or "-A" | |
| if isinstance(paths, list): | |
| paths = " ".join(str(p) for p in paths) | |
| rc, out, err = await _git("add", *str(paths).split()) | |
| return self._tool_ok({"action": action, "added": str(paths), "exit": rc}) | |
| if git_action == "commit": | |
| msg = str(raw.get("message") or "").strip() | |
| if not msg: | |
| return self._tool_err("missing commit message") | |
| rc, out, err = await _git("commit", "-m", msg) | |
| return self._tool_ok({"action": action, "message": msg, "exit": rc, "output": out or err}) | |
| if git_action == "push": | |
| rc, out, err = await _git("push") | |
| return self._tool_ok({"action": action, "exit": rc, "output": out or err}) | |
| if git_action == "pull": | |
| rc, out, err = await _git("pull") | |
| return self._tool_ok({"action": action, "exit": rc, "output": out or err}) | |
| if git_action == "branch": | |
| name = str(raw.get("name") or "").strip() | |
| if name: | |
| rc, out, err = await _git("checkout", "-b", name) | |
| return self._tool_ok({"action": action, "created": name, "exit": rc, "output": out or err}) | |
| rc, out, err = await _git("branch", "-a") | |
| return self._tool_ok({"action": action, "branches": [b.strip().lstrip("*") for b in out.strip().split("\n") if b.strip()]}) | |
| if git_action == "checkout": | |
| ref = str(raw.get("ref") or raw.get("branch") or "").strip() | |
| if not ref: | |
| return self._tool_err("missing ref/branch") | |
| rc, out, err = await _git("checkout", ref) | |
| return self._tool_ok({"action": action, "ref": ref, "exit": rc, "output": out or err}) | |
| if git_action == "clone": | |
| url = str(raw.get("url") or "").strip() | |
| if not url: | |
| return self._tool_err("missing url") | |
| target = _resolve(raw.get("target") or os.path.basename(url).replace(".git", "")) | |
| rc, out, err = await _git("clone", url, target) | |
| return self._tool_ok({"action": action, "url": url, "target": target, "exit": rc, "output": out or err}) | |
| if git_action == "remote": | |
| rc, out, err = await _git("remote", "-v") | |
| return self._tool_ok({"action": action, "remotes": out.strip()}) | |
| return self._tool_err("unknown git action: {}".format(git_action)) | |
| # ── Enhanced Shell ── | |
| if action == "shell_log": | |
| job_id = str(raw.get("job_id") or "").strip() | |
| lines_n = min(int(raw.get("lines") or 50), 200) | |
| if not job_id or job_id not in self._cli_shell_jobs: | |
| return self._tool_err("unknown job_id") | |
| job = self._cli_shell_jobs[job_id] | |
| try: | |
| with open(job["outfile"], "r") as f: | |
| content = f.read() | |
| log_lines = content.split("\n") | |
| return self._tool_ok({"action": action, "job_id": job_id, "done": job["proc"].returncode is not None, "lines": log_lines[-lines_n:], "total_lines": len(log_lines)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "shell_list": | |
| jobs = [] | |
| for jid, job in list(self._cli_shell_jobs.items()): | |
| jobs.append({"job_id": jid, "cmd": job["cmd"], "running": job["proc"].returncode is None, "started": job["started"], "runtime": time.time() - job["started"]}) | |
| return self._tool_ok({"action": action, "jobs": jobs, "count": len(jobs)}) | |
| if action == "shell_clean": | |
| for jid in list(self._cli_shell_jobs.keys()): | |
| job = self._cli_shell_jobs[jid] | |
| if job["proc"].returncode is not None: | |
| with contextlib.suppress(Exception): | |
| job["proc"].kill() | |
| del self._cli_shell_jobs[jid] | |
| return self._tool_ok({"action": action, "cleaned": True, "remaining": len(self._cli_shell_jobs)}) | |
| # ── Image Analysis ── | |
| if action == "image_analyze": | |
| prompt = str(raw.get("prompt") or raw.get("query") or "Describe this image").strip() | |
| url = str(raw.get("url") or "").strip() | |
| img_bytes = None | |
| if url: | |
| try: | |
| async with aiohttp.ClientSession() as sess: | |
| async with sess.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp: | |
| img_bytes = await resp.read() | |
| except Exception as e: | |
| return self._tool_err("failed to download image: {}".format(str(e))) | |
| else: | |
| try: | |
| if req_message: | |
| reply = await req_message.get_reply_message() | |
| if reply and (reply.photo or reply.document): | |
| img_bytes = await self.client.download_media(reply, bytes) | |
| except Exception: | |
| pass | |
| if not img_bytes: | |
| return self._tool_err("no image — provide url or reply to an image") | |
| mime = "image/jpeg" | |
| try: | |
| from PIL import Image as PILImage | |
| with PILImage.open(io.BytesIO(img_bytes)) as im: | |
| mime = f"image/{im.format.lower()}" if im.format else mime | |
| except Exception: | |
| pass | |
| try: | |
| resp = await self._call_google_rest(self._get_provider_model("google", "chat"), prompt, img_bytes) | |
| result_text = "" | |
| if "candidates" in resp and resp["candidates"]: | |
| for part in resp["candidates"][0].get("content", {}).get("parts", []): | |
| if "text" in part: | |
| result_text += part["text"] | |
| return self._tool_ok({"action": action, "analysis": result_text.strip() or "(no analysis returned)"}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| # ── Scheduler ── | |
| if action == "schedule_add": | |
| when = raw.get("at") or raw.get("in_seconds") | |
| if not when: | |
| return self._tool_err("missing at (ISO timestamp) or in_seconds") | |
| if isinstance(when, (int, float)): | |
| trigger = time.time() + float(when) | |
| else: | |
| try: | |
| trigger = datetime.fromisoformat(str(when).replace("Z", "+00:00")).timestamp() | |
| except Exception: | |
| return self._tool_err("invalid time format") | |
| task_id = uuid.uuid4().hex[:8] | |
| self._scheduled_tasks[task_id] = { | |
| "trigger": trigger, | |
| "target_chat": chat_id, | |
| "message": str(raw.get("message") or raw.get("text") or "")[:500], | |
| "repeat": int(raw.get("repeat_seconds") or 0), | |
| } | |
| return self._tool_ok({"action": action, "task_id": task_id, "trigger": datetime.fromtimestamp(trigger).isoformat()}) | |
| if action == "schedule_list": | |
| tasks = [] | |
| now = time.time() | |
| for tid, t in self._scheduled_tasks.items(): | |
| tasks.append({"task_id": tid, "trigger": datetime.fromtimestamp(t["trigger"]).isoformat(), "due_in": max(0, t["trigger"] - now), "repeat": t["repeat"], "chat": t["target_chat"]}) | |
| return self._tool_ok({"action": action, "tasks": tasks, "count": len(tasks)}) | |
| if action == "schedule_remove": | |
| tid = str(raw.get("task_id") or "").strip() | |
| if tid in self._scheduled_tasks: | |
| del self._scheduled_tasks[tid] | |
| return self._tool_ok({"action": action, "removed": tid}) | |
| return self._tool_err("unknown task_id: {}".format(tid)) | |
| # ── Sub-agent Spawn ── | |
| if action == "agent_spawn": | |
| task = str(raw.get("task") or raw.get("prompt") or "").strip() | |
| if not task: | |
| return self._tool_err("missing task/prompt") | |
| # Spawn as an independent asyncio task — result sent back when done | |
| async def _agent_work(): | |
| try: | |
| m = await self.client.send_message(chat_id, "🤖 Суб-агент начал: {}".format(task[:100])) | |
| parts = [types.Part(text=task)] | |
| result = await self._send_to_gemini(m, parts, impersonation_mode=True, display_prompt=task) | |
| if result: | |
| await self._reply(m, "✅ Суб-агент завершил: {}".format(result[:1500])) | |
| except Exception as e: | |
| with contextlib.suppress(Exception): | |
| await self.client.send_message(chat_id, "❌ Суб-агент ошибка: {}".format(str(e)[:500])) | |
| asyncio.ensure_future(_agent_work()) | |
| return self._tool_ok({"action": action, "task": task[:200], "status": "spawned"}) | |
| # ── Diff Viewer ── | |
| if action == "fs_diff": | |
| import difflib | |
| a_path = _resolve(raw.get("path_a") or raw.get("path")) | |
| b_path = _resolve(raw.get("path_b") or raw.get("path")) | |
| if not self._tool_bool(raw.get("compare_paths")): | |
| # Compare file against itself (staged changes?) — use git | |
| cwd = _resolve(raw.get("cwd") or ws_path) | |
| proc = await asyncio.create_subprocess_exec("git", "diff", a_path, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) | |
| stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15) | |
| return self._tool_ok({"action": action, "path": a_path, "diff": stdout.decode("utf-8", "replace")[:8000] or "(no changes)"}) | |
| if not os.path.isfile(a_path): | |
| return self._tool_err("path_a not a file") | |
| with open(a_path, "r", encoding="utf-8") as f: | |
| a_lines = f.readlines() | |
| with open(b_path, "r", encoding="utf-8") as f: | |
| b_lines = f.readlines() | |
| diff = "\n".join(difflib.unified_diff(a_lines, b_lines, fromfile=a_path, tofile=b_path)) | |
| return self._tool_ok({"action": action, "diff": diff[:8000] or "(no differences)"}) | |
| # ── Code Runner ── | |
| if action == "code_run": | |
| code = str(raw.get("code") or raw.get("source") or raw.get("script") or "").strip() | |
| if not code: | |
| return self._tool_err("missing code") | |
| timeout_s = min(int(raw.get("timeout") or 30), 60) | |
| lang = str(raw.get("language") or "python").strip().lower() | |
| if lang == "python": | |
| tmp = _resolve(f"_code_{uuid.uuid4().hex[:6]}.py") | |
| with open(tmp, "w", encoding="utf-8") as f: | |
| f.write(code) | |
| try: | |
| proc = await asyncio.create_subprocess_exec("python3", tmp, cwd=ws_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) | |
| stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s) | |
| return self._tool_ok({"action": action, "exit": proc.returncode, "stdout": stdout.decode("utf-8", "replace")[:10000], "stderr": stderr.decode("utf-8", "replace")[:5000]}) | |
| except asyncio.TimeoutError: | |
| proc.kill() | |
| return self._tool_ok({"action": action, "exit": -1, "stdout": "", "stderr": "timeout"}) | |
| finally: | |
| with contextlib.suppress(Exception): | |
| os.remove(tmp) | |
| return self._tool_err("unsupported language: {} (only python for now)".format(lang)) | |
| # ── JSON/CSV ── | |
| if action == "json_parse": | |
| text = str(raw.get("text") or raw.get("content") or "").strip() | |
| if not text: | |
| return self._tool_err("missing text") | |
| try: | |
| parsed = json.loads(text) | |
| return self._tool_ok({"action": action, "parsed": parsed if isinstance(parsed, (dict, list)) else str(parsed)}) | |
| except json.JSONDecodeError as e: | |
| return self._tool_err("invalid JSON: {}".format(str(e))) | |
| if action == "csv_parse": | |
| text = str(raw.get("text") or raw.get("content") or "").strip() | |
| if not text: | |
| path = _resolve(raw.get("path") or "") | |
| if os.path.isfile(path): | |
| with open(path, "r", encoding="utf-8") as f: | |
| text = f.read() | |
| else: | |
| return self._tool_err("missing text or path") | |
| try: | |
| import csv as csv_mod | |
| reader = csv_mod.DictReader(io.StringIO(text)) | |
| rows = list(reader)[:100] | |
| return self._tool_ok({"action": action, "rows": rows, "count": len(rows), "columns": list(rows[0].keys()) if rows else []}) | |
| except Exception as e: | |
| return self._tool_err("CSV parse failed: {}".format(str(e))) | |
| # ── Tool Result Cache ── | |
| cache_actions = {"fs_read", "fs_list", "fs_tree", "fs_info", "web_fetch", "web_search"} | |
| result = None # will be set below | |
| return self._tool_err("unknown cli action: {}".format(action)) | |
| async def _tool_messaging(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| if action == "schedule_message": | |
| text, parse_mode = await self._prepare_outbound_text(raw) | |
| if not text: | |
| return self._tool_err("missing text") | |
| when = self._parse_tool_datetime(raw.get("schedule_at"), seconds_fallback=raw.get("schedule_in") or raw.get("seconds")) | |
| if when is None: | |
| return self._tool_err("missing schedule_at or schedule_in") | |
| sent = await self._send_message(target_entity, text, parse_mode=parse_mode, reply_to=raw.get("message_id") or req_reply_id, schedule=when) | |
| return self._tool_ok({"action": action, "scheduled_for": str(when), "message": self._serialize_message(sent)}) | |
| if action in {"send_typing", "send_upload_photo", "send_upload_video", "send_upload_document", "send_upload_audio", "send_upload_voice"}: | |
| action_map = { | |
| "send_typing": "typing", | |
| "send_upload_photo": "photo", | |
| "send_upload_video": "video", | |
| "send_upload_document": "document", | |
| "send_upload_audio": "audio", | |
| "send_upload_voice": "record-audio", | |
| } | |
| seconds = max(1, min(int(raw.get("seconds") or raw.get("duration") or 2), 20)) | |
| async with self.client.action(target_entity, action_map[action]): | |
| await asyncio.sleep(seconds) | |
| return self._tool_ok({"action": action, "duration": seconds}) | |
| if action == "send_message": | |
| text, parse_mode = await self._prepare_outbound_text(raw) | |
| if not text: | |
| return self._tool_err("missing text") | |
| sent = await self._send_message(target_entity, text, parse_mode=parse_mode, reply_to=raw.get("message_id") or req_reply_id) | |
| return self._tool_ok({"action": action, "target_chat": getattr(target_entity, "id", None), "message": self._serialize_message(sent), "parse_mode": parse_mode}) | |
| if action == "send_message_last": | |
| text, parse_mode = await self._prepare_outbound_text(raw) | |
| if not text: | |
| return self._tool_err("missing text") | |
| recent = await self.client.get_messages(target_entity, limit=1) | |
| reply_to = recent[0].id if recent else None | |
| sent = await self._send_message(target_entity, text, parse_mode=parse_mode, reply_to=reply_to) | |
| return self._tool_ok({"action": action, "reply_to": reply_to, "message": self._serialize_message(sent)}) | |
| if action == "send_bulk_messages": | |
| text, parse_mode = await self._prepare_outbound_text(raw) | |
| if not text: | |
| return self._tool_err("missing text") | |
| count = self._normalize_limit(raw.get("count", raw.get("limit", 1)), default=1, maximum=30) | |
| pause = max(0, min(int(raw.get("pause_ms") or 0), 5000)) | |
| ids = [] | |
| for idx in range(count): | |
| sent = await self._send_message(target_entity, text, parse_mode=parse_mode) | |
| ids.append(getattr(sent, "id", None)) | |
| if pause and idx < count - 1: | |
| await asyncio.sleep(pause / 1000.0) | |
| return self._tool_ok({"action": action, "target_chat": getattr(target_entity, "id", None), "sent": len(ids), "message_ids": ids}) | |
| if action == "edit_message": | |
| message_id = raw.get("message_id") or req_reply_id | |
| text, parse_mode = await self._prepare_outbound_text(raw) | |
| if not message_id or not text: | |
| return self._tool_err("message_id and text are required") | |
| edited = await self.client.edit_message(target_entity, int(message_id), text, parse_mode=parse_mode) | |
| return self._tool_ok({"action": action, "message": self._serialize_message(edited)}) | |
| if action == "delete_messages": | |
| ids = raw.get("message_ids") or raw.get("ids") or raw.get("message_id") | |
| if isinstance(ids, (int, str)): | |
| ids = [int(ids)] | |
| if not isinstance(ids, list) or not ids: | |
| return self._tool_err("missing message_ids") | |
| deleted = await self.client.delete_messages(target_entity, ids, revoke=self._tool_bool(raw.get("revoke"))) | |
| return self._tool_ok({"action": action, "deleted_count": len(ids), "deleted": str(deleted)}) | |
| if action == "delete_last_message": | |
| message_id = raw.get("message_id") | |
| if message_id: | |
| ids = [int(message_id)] | |
| else: | |
| recent = await self.client.get_messages(target_entity, limit=1) | |
| if not recent: | |
| return self._tool_err("no messages found") | |
| ids = [recent[0].id] | |
| await self.client.delete_messages(target_entity, ids, revoke=self._tool_bool(raw.get("revoke"))) | |
| return self._tool_ok({"action": action, "deleted_ids": ids}) | |
| if action in {"reply_messages", "reply_to_message"}: | |
| text, parse_mode = await self._prepare_outbound_text(raw) | |
| ids = raw.get("message_ids") or raw.get("ids") or raw.get("message_id") or req_reply_id | |
| if isinstance(ids, (int, str)): | |
| ids = [int(ids)] | |
| if not text or not ids: | |
| return self._tool_err("text and message ids are required") | |
| sent = [] | |
| for mid in ids: | |
| out = await self._send_message(target_entity, text, parse_mode=parse_mode, reply_to=int(mid)) | |
| sent.append(self._serialize_message(out)) | |
| return self._tool_ok({"action": action, "sent": sent}) | |
| if action == "reply_with_sticker": | |
| sticker = raw.get("sticker") | |
| message_id = raw.get("message_id") or req_reply_id | |
| if not sticker or not message_id: | |
| return self._tool_err("sticker and message_id are required") | |
| out = await self._send_file(target_entity, sticker, reply_to=int(message_id)) | |
| return self._tool_ok({"action": action, "message": self._serialize_message(out)}) | |
| if action == "forward_message": | |
| from_chat = await self._resolve_target_entity(raw.get("from_chat"), chat_id) | |
| to_chat = await self._resolve_target_entity(raw.get("to_chat") or raw.get("target_chat"), chat_id) | |
| ids = raw.get("message_ids") or raw.get("ids") or raw.get("message_id") | |
| if isinstance(ids, (int, str)): | |
| ids = [int(ids)] | |
| if not ids: | |
| return self._tool_err("missing message ids") | |
| forwarded = await self.client.forward_messages(to_chat, ids, from_chat) | |
| items = forwarded if isinstance(forwarded, list) else [forwarded] | |
| return self._tool_ok({"action": action, "to_chat": getattr(to_chat, "id", None), "messages": [self._serialize_message(x) for x in items if x]}) | |
| if action == "forward_last_messages": | |
| count = self._normalize_limit(raw.get("count", 1), default=1, maximum=30) | |
| from_chat = await self._resolve_target_entity(raw.get("from_chat"), chat_id) | |
| to_chat = await self._resolve_target_entity(raw.get("to_chat") or raw.get("target_chat") or "me", chat_id) | |
| messages = await self.client.get_messages(from_chat, limit=count) | |
| ids = [m.id for m in messages if m] | |
| if not ids: | |
| return self._tool_err("no messages found") | |
| forwarded = await self.client.forward_messages(to_chat, ids, from_chat) | |
| items = forwarded if isinstance(forwarded, list) else [forwarded] | |
| return self._tool_ok({"action": action, "from_chat": getattr(from_chat, "id", None), "to_chat": getattr(to_chat, "id", None), "count": len(items), "messages": [self._serialize_message(x) for x in items if x]}) | |
| if action == "copy_message_to_chat": | |
| from_chat = await self._resolve_target_entity(raw.get("from_chat"), chat_id) | |
| to_chat = await self._resolve_target_entity(raw.get("to_chat") or raw.get("target_chat"), chat_id) | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| copied = await self.client.forward_messages(to_chat, message_id, from_chat, drop_author=True) | |
| return self._tool_ok({"action": action, "message": self._serialize_message(copied if not isinstance(copied, list) else copied[0])}) | |
| if action == "copy_text_to_chat": | |
| from_chat = await self._resolve_target_entity(raw.get("from_chat"), chat_id) | |
| to_chat = await self._resolve_target_entity(raw.get("to_chat") or raw.get("target_chat"), chat_id) | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| source_msg = await self.client.get_messages(from_chat, ids=message_id) | |
| text = str(getattr(source_msg, "message", None) or getattr(source_msg, "text", None) or "") | |
| if not text: | |
| return self._tool_err("source message has no text") | |
| sent = await self._send_message(to_chat, text, parse_mode=None) | |
| return self._tool_ok({"action": action, "source_message_id": message_id, "message": self._serialize_message(sent)}) | |
| if action == "quote_message": | |
| source_chat = await self._resolve_target_entity(raw.get("from_chat"), chat_id) | |
| to_chat = await self._resolve_target_entity(raw.get("to_chat") or raw.get("target_chat") or source_chat, chat_id) | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| source_msg = await self.client.get_messages(source_chat, ids=message_id) | |
| source_text = str(getattr(source_msg, "message", None) or getattr(source_msg, "text", None) or "").strip() | |
| if not source_text: | |
| return self._tool_err("source message has no text") | |
| comment = str(raw.get("text") or "").strip() | |
| quoted = "<blockquote>{}</blockquote>".format(utils.escape_html(source_text[:3500])) | |
| full_text = "{}\n\n{}".format(comment, quoted).strip() if comment else quoted | |
| sent = await self._send_message(to_chat, full_text, parse_mode="html") | |
| return self._tool_ok({"action": action, "message": self._serialize_message(sent)}) | |
| if action == "resend_message": | |
| source_chat = await self._resolve_target_entity(raw.get("from_chat"), chat_id) | |
| to_chat = await self._resolve_target_entity(raw.get("to_chat") or raw.get("target_chat") or source_chat, chat_id) | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| source_msg = await self.client.get_messages(source_chat, ids=message_id) | |
| text = str(getattr(source_msg, "message", None) or getattr(source_msg, "text", None) or "") | |
| if getattr(source_msg, "media", None): | |
| blob = await self.client.download_media(source_msg, bytes) | |
| if not blob: | |
| return self._tool_err("failed to download media") | |
| upload = io.BytesIO(blob) | |
| upload.name = getattr(getattr(source_msg, "file", None), "name", None) or f"resend_{message_id}" | |
| sent = await self._send_file(to_chat, upload, caption=text or None) | |
| else: | |
| if not text: | |
| return self._tool_err("source message is empty") | |
| sent = await self._send_message(to_chat, text) | |
| return self._tool_ok({"action": action, "message": self._serialize_message(sent if not isinstance(sent, list) else sent[0])}) | |
| if action == "find_and_send_message": | |
| query = str(raw.get("query") or "").strip() | |
| text, parse_mode = await self._prepare_outbound_text(raw) | |
| if not query or not text: | |
| return self._tool_err("query and text are required") | |
| found = await self._search_messages_core(target_entity, query=query, limit=1) | |
| if not found: | |
| return self._tool_err("no messages found") | |
| reply_to = found[0].get("id") | |
| out = await self._send_message(target_entity, text, parse_mode=parse_mode, reply_to=reply_to) | |
| return self._tool_ok({"action": action, "query": query, "reply_to": reply_to, "message": self._serialize_message(out)}) | |
| return self._tool_err("action is declared but not implemented: {}".format(action)) | |
| async def _tool_media_send(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: send_file, send_photo, send_video, send_audio, send_voice_note, send_document, send_animation, send_sticker""" | |
| if action in {"send_file", "send_photo", "send_video", "send_audio", "send_voice_note", "send_document", "send_animation", "send_sticker"}: | |
| file_ref = raw.get("path") or raw.get("file") or raw.get("sticker") | |
| if not file_ref: | |
| return self._tool_err("missing file path") | |
| if isinstance(file_ref, str): | |
| with contextlib.suppress(Exception): | |
| candidate = resolve_path(file_ref, default_name="artifact") | |
| if os.path.exists(candidate): | |
| file_ref = candidate | |
| caption, parse_mode = await self._prepare_outbound_text(raw) | |
| kwargs = { | |
| "caption": caption or None, | |
| "parse_mode": parse_mode, | |
| "reply_to": raw.get("message_id") or req_reply_id, | |
| "force_document": action == "send_document", | |
| "supports_streaming": action == "send_video" or self._tool_bool(raw.get("supports_streaming")), | |
| "voice_note": action == "send_voice_note" or self._tool_bool(raw.get("voice_note")), | |
| "video_note": self._tool_bool(raw.get("video_note")), | |
| "nosound_video": action == "send_animation" or self._tool_bool(raw.get("nosound_video")), | |
| "schedule": self._parse_tool_datetime(raw.get("schedule_at"), seconds_fallback=raw.get("schedule_in")), | |
| } | |
| kwargs = {k: v for k, v in kwargs.items() if v not in (None, False)} | |
| sent = await self._send_file(target_entity, file_ref, **kwargs) | |
| items = sent if isinstance(sent, list) else [sent] | |
| return self._tool_ok({"action": action, "count": len(items), "messages": [self._serialize_message(item) for item in items if item]}) | |
| async def _tool_media_generate(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: generate_image, generate_video""" | |
| if action == "generate_image": | |
| prompt = str(raw.get("prompt") or raw.get("text") or raw.get("query") or "").strip() | |
| if not prompt: | |
| return self._tool_err("missing prompt") | |
| image_bytes, _image_mime, source = await self._resolve_generation_image_source( | |
| chat_id=chat_id, | |
| raw=raw, | |
| req_message=req_message, | |
| req_reply_id=req_reply_id, | |
| ) | |
| generated_bytes, model = await self._generate_image_asset(prompt, input_image_bytes=image_bytes) | |
| prepared_bytes, mime_type, size = self._prepare_tool_image_bytes(generated_bytes, max_side=2048, quality=92) | |
| generated_bytes = prepared_bytes or generated_bytes | |
| save_path = raw.get("output_path") or raw.get("save_path") | |
| send_result = None | |
| local_path = None | |
| should_send = not str(raw.get("send", "true")).strip().lower() in {"0", "false", "no"} | |
| if save_path or not should_send: | |
| local_path = self._build_generated_output_path("image", explicit_path=save_path, chat_id=chat_id) | |
| with open(local_path, "wb") as file_obj: | |
| file_obj.write(generated_bytes) | |
| self._register_session_file(chat_id, local_path, role="output", label="generated_image", source="generate_image") | |
| self._append_tool_visual_context(chat_id, [{ | |
| "mime_type": mime_type or "image/jpeg", | |
| "data": generated_bytes, | |
| "label": "generated image", | |
| }]) | |
| if should_send: | |
| out = io.BytesIO(generated_bytes) | |
| out.name = "generated_image.jpg" | |
| caption = str(raw.get("caption") or raw.get("comment") or "").strip() or None | |
| send_result = await self._send_file( | |
| target_entity, | |
| out, | |
| caption=caption, | |
| reply_to=raw.get("reply_to") or raw.get("reply_to_message_id") or req_reply_id, | |
| ) | |
| result = { | |
| "action": action, | |
| "mode": "i2i" if image_bytes else "t2i", | |
| "model": model, | |
| "prompt": prompt, | |
| "source": source or None, | |
| "mime_type": mime_type or "image/jpeg", | |
| } | |
| if size: | |
| result["size"] = {"w": size[0], "h": size[1]} | |
| if local_path: | |
| result["path"] = local_path | |
| if send_result is not None: | |
| items = send_result if isinstance(send_result, list) else [send_result] | |
| result["messages"] = [self._serialize_message(item) for item in items if item] | |
| result["count"] = len(result["messages"]) | |
| return self._tool_ok(result) | |
| if action == "generate_video": | |
| prompt = str(raw.get("prompt") or raw.get("text") or raw.get("query") or "").strip() | |
| if not prompt: | |
| return self._tool_err("missing prompt") | |
| image_bytes, image_mime, source = await self._resolve_generation_image_source( | |
| chat_id=chat_id, | |
| raw=raw, | |
| req_message=req_message, | |
| req_reply_id=req_reply_id, | |
| ) | |
| video_bytes, elapsed, session = await self._generate_video_asset( | |
| prompt, | |
| model=raw.get("model"), | |
| seconds=raw.get("seconds") or raw.get("duration") or raw.get("duration_seconds"), | |
| aspect_ratio=raw.get("aspect_ratio") or raw.get("aspect"), | |
| resolution=raw.get("resolution"), | |
| image_bytes=image_bytes, | |
| image_mime=image_mime, | |
| progress_callback=None, | |
| ) | |
| save_path = raw.get("output_path") or raw.get("save_path") | |
| send_result = None | |
| local_path = None | |
| should_send = not str(raw.get("send", "true")).strip().lower() in {"0", "false", "no"} | |
| if save_path or not should_send: | |
| local_path = self._build_generated_output_path("video", explicit_path=save_path, chat_id=chat_id) | |
| with open(local_path, "wb") as file_obj: | |
| file_obj.write(video_bytes) | |
| self._register_session_file(chat_id, local_path, role="output", label="generated_video", source="generate_video") | |
| if should_send: | |
| out = io.BytesIO(video_bytes) | |
| out.name = "generated_video.mp4" | |
| caption = str(raw.get("caption") or raw.get("comment") or "").strip() or None | |
| send_result = await self._send_file( | |
| target_entity, | |
| out, | |
| caption=caption, | |
| reply_to=raw.get("reply_to") or raw.get("reply_to_message_id") or req_reply_id, | |
| supports_streaming=True, | |
| ) | |
| result = { | |
| "action": action, | |
| "mode": "i2v" if image_bytes else "t2v", | |
| "model": session["model"], | |
| "prompt": prompt, | |
| "source": source or None, | |
| "seconds": session["seconds"], | |
| "aspect_ratio": session["aspect_ratio"], | |
| "resolution": session["resolution"], | |
| "elapsed_seconds": elapsed, | |
| } | |
| if local_path: | |
| result["path"] = local_path | |
| if send_result is not None: | |
| items = send_result if isinstance(send_result, list) else [send_result] | |
| result["messages"] = [self._serialize_message(item) for item in items if item] | |
| result["count"] = len(result["messages"]) | |
| return self._tool_ok(result) | |
| async def _tool_moderation(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: ban_user, kick_user, mute_user, unmute_user, unban_user, restrict_user_media, unrestrict_user_media, promote_user, demote_user, warn_user, block_user, unblock_user, delete_user_messages, report_spam_user, purge_chat_messages, mention_user""" | |
| if action in {"mention_user", "warn_user"}: | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| text = str(raw.get("text") or "") | |
| mention = '<a href="tg://user?id={}">{}</a>'.format(user.id, utils.escape_html(get_display_name(user))) | |
| if action == "warn_user" and not text: | |
| text = "{} предупреждение.".format(mention) | |
| elif action == "mention_user" and not text: | |
| text = mention | |
| out = await self._send_message(target_entity, text, parse_mode="html") | |
| return self._tool_ok({"action": action, "user_id": user.id, "message": self._serialize_message(out)}) | |
| if action in {"ban_user", "kick_user", "mute_user", "unmute_user", "restrict_user_media", "unrestrict_user_media"}: | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| kwargs = {} | |
| if action == "ban_user": | |
| kwargs["view_messages"] = False | |
| elif action == "kick_user": | |
| kwargs["view_messages"] = False | |
| elif action == "mute_user": | |
| kwargs["send_messages"] = False | |
| elif action == "unmute_user": | |
| kwargs["send_messages"] = True | |
| elif action == "restrict_user_media": | |
| kwargs["send_media"] = False | |
| elif action == "unrestrict_user_media": | |
| kwargs["send_media"] = True | |
| await self.client.edit_permissions(target_entity, user, **kwargs) | |
| return self._tool_ok({"action": action, "user_id": user.id, "chat_id": getattr(target_entity, "id", None)}) | |
| if action == "unban_user": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| await self.client.edit_permissions(target_entity, user, view_messages=True) | |
| return self._tool_ok({"action": action, "user_id": user.id}) | |
| if action in {"promote_user", "demote_user"}: | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| is_admin = action == "promote_user" | |
| await self.client.edit_admin(target_entity, user, is_admin=is_admin, change_info=is_admin, delete_messages=is_admin, ban_users=is_admin, invite_users=is_admin, pin_messages=is_admin, add_admins=False) | |
| return self._tool_ok({"action": action, "user_id": user.id}) | |
| if action == "delete_user_messages": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| ids = [] | |
| async for msg in self.client.iter_messages(target_entity, limit=limit * 5, from_user=user): | |
| ids.append(msg.id) | |
| if len(ids) >= limit: | |
| break | |
| if ids: | |
| await self.client.delete_messages(target_entity, ids) | |
| return self._tool_ok({"action": action, "user_id": user.id, "deleted_count": len(ids), "message_ids": ids}) | |
| if action == "block_user": | |
| user = await self._resolve_target_user(current_entity, raw, req_message) | |
| await self.client(BlockRequest(user)) | |
| return self._tool_ok({"action": action, "user_id": user.id}) | |
| if action == "unblock_user": | |
| user = await self._resolve_target_user(current_entity, raw, req_message) | |
| await self.client(UnblockRequest(user)) | |
| return self._tool_ok({"action": action, "user_id": user.id}) | |
| if action == "purge_chat_messages": | |
| limit = self._normalize_limit(raw.get("limit", 100), default=100, maximum=500) | |
| ids = [] | |
| async for msg in self.client.iter_messages(target_entity, limit=limit): | |
| ids.append(msg.id) | |
| if ids: | |
| await self.client.delete_messages(target_entity, ids) | |
| return self._tool_ok({"action": action, "deleted_count": len(ids)}) | |
| if action == "report_spam_user": | |
| try: | |
| from telethon.tl.functions.messages import ReportSpamRequest | |
| user = await self._resolve_target_user(current_entity, raw, req_message) | |
| await self.client(ReportSpamRequest(peer=user)) | |
| return self._tool_ok({"action": action, "user_id": user.id}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| async def _tool_reactions(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: react_messages, send_reaction_last""" | |
| if action in {"react_messages", "send_reaction_last"}: | |
| emoji = str(raw.get("emoji") or "").strip() | |
| if not emoji: | |
| return self._tool_err("missing emoji") | |
| ids = raw.get("message_ids") or raw.get("ids") or raw.get("message_id") | |
| if action == "send_reaction_last" and not ids: | |
| recent = await self.client.get_messages(target_entity, limit=1) | |
| ids = [recent[0].id] if recent else [] | |
| elif isinstance(ids, (int, str)): | |
| ids = [int(ids)] | |
| if not ids: | |
| return self._tool_err("missing message ids") | |
| for mid in ids: | |
| await self.client(SendReactionRequest(peer=target_entity, msg_id=int(mid), reaction=[ReactionEmoji(emoticon=emoji)], add_to_recent=True)) | |
| return self._tool_ok({"action": action, "emoji": emoji, "message_ids": ids}) | |
| async def _tool_dialogs(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: get_dialogs, get_dialog_by_name, get_pinned_dialogs, get_archived_dialogs, get_dialog_folders, get_dialogs_count, get_unread_overview, get_dialog_messages_count, mark_dialog_unread, pin_dialog, unpin_dialog, mute_dialog, unmute_dialog, clear_dialog, delete_dialog, archive_dialog, unarchive_dialog""" | |
| if action == "get_dialogs": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| dialogs = [] | |
| async for dialog in self.client.iter_dialogs(limit=limit): | |
| dialogs.append(self._serialize_dialog(dialog)) | |
| return self._tool_ok({"action": action, "count": len(dialogs), "dialogs": dialogs}) | |
| if action == "get_dialog_by_name": | |
| query = str(raw.get("query") or raw.get("target") or raw.get("name") or "").strip() | |
| if not query: | |
| return self._tool_err("missing query") | |
| entity, score, matched = await self._lookup_dialog_entity(query) | |
| if not entity: | |
| return self._tool_err("dialog not found") | |
| return self._tool_ok({"action": action, "query": query, "score": round(score, 4), "matched": matched, "entity": self._serialize_entity_brief(entity)}) | |
| if action in {"get_pinned_dialogs", "get_archived_dialogs"}: | |
| limit = self._normalize_limit(raw.get("limit", 30), default=30, maximum=100) | |
| dialogs = [] | |
| async for dialog in self.client.iter_dialogs(limit=limit * 4): | |
| if action == "get_pinned_dialogs" and not bool(getattr(dialog, "pinned", False)): | |
| continue | |
| if action == "get_archived_dialogs" and int(getattr(dialog, "folder_id", 0) or 0) != 1: | |
| continue | |
| dialogs.append(self._serialize_dialog(dialog)) | |
| if len(dialogs) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "count": len(dialogs), "dialogs": dialogs}) | |
| if action == "get_dialog_folders": | |
| limit = self._normalize_limit(raw.get("limit", 100), default=100, maximum=200) | |
| folders = {} | |
| async for dialog in self.client.iter_dialogs(limit=limit): | |
| folder_id = int(getattr(dialog, "folder_id", 0) or 0) | |
| folders.setdefault(str(folder_id), []).append(self._serialize_dialog(dialog)) | |
| return self._tool_ok({"action": action, "folders": folders}) | |
| if action == "get_dialogs_count": | |
| count = 0 | |
| async for _dialog in self.client.iter_dialogs(): | |
| count += 1 | |
| return self._tool_ok({"action": action, "count": count}) | |
| if action == "get_unread_overview": | |
| limit = self._normalize_limit(raw.get("limit", 30), default=30, maximum=100) | |
| dialogs = [] | |
| async for dialog in self.client.iter_dialogs(): | |
| unread = int(getattr(dialog, "unread_count", 0) or 0) | |
| if unread <= 0: | |
| continue | |
| entity = dialog.entity | |
| dialogs.append({"id": getattr(entity, "id", None), "title": getattr(dialog, "title", None) or get_display_name(entity), "unread_count": unread}) | |
| if len(dialogs) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "count": len(dialogs), "dialogs": dialogs}) | |
| if action == "get_dialog_messages_count": | |
| scan_limit = self._normalize_limit(raw.get("scan_limit", raw.get("limit", 200)), default=200, maximum=5000) | |
| count = 0 | |
| async for _msg in self.client.iter_messages(target_entity, limit=scan_limit): | |
| count += 1 | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "scanned_messages": count, "scan_limit": scan_limit}) | |
| if action in {"mark_dialog_unread", "pin_dialog", "unpin_dialog", "mute_dialog", "unmute_dialog"}: | |
| try: | |
| if action == "mark_dialog_unread": | |
| from telethon.tl.functions.messages import MarkDialogUnreadRequest | |
| await self.client(MarkDialogUnreadRequest(peer=target_entity, unread=self._tool_bool(raw.get("unread", True)))) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None)}) | |
| if action in {"pin_dialog", "unpin_dialog"}: | |
| from telethon.tl.functions.messages import ToggleDialogPinRequest | |
| await self.client(ToggleDialogPinRequest(peer=target_entity, pinned=(action == "pin_dialog"))) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None)}) | |
| from telethon.tl.functions.account import UpdateNotifySettingsRequest | |
| from telethon.tl.types import InputNotifyPeer, InputPeerNotifySettings | |
| mute_until = int(time.time()) + (365 * 24 * 60 * 60) if action == "mute_dialog" else 0 | |
| await self.client(UpdateNotifySettingsRequest( | |
| peer=InputNotifyPeer(peer=await self.client.get_input_entity(target_entity)), | |
| settings=InputPeerNotifySettings(mute_until=mute_until), | |
| )) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "mute_until": mute_until}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action in {"clear_dialog", "delete_dialog"}: | |
| await self.client.delete_dialog(target_entity, revoke=(action == "delete_dialog")) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None)}) | |
| if action in {"archive_dialog", "unarchive_dialog"}: | |
| try: | |
| from telethon.tl.functions.folders import EditPeerFoldersRequest | |
| from telethon.tl.types import InputFolderPeer | |
| folder_id = 1 if action == "archive_dialog" else 0 | |
| await self.client(EditPeerFoldersRequest(folder_peers=[InputFolderPeer(peer=target_entity, folder_id=folder_id)])) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "folder_id": folder_id}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| async def _tool_chat_info(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: get_chat_info, get_full_chat, get_chat_stats, get_chat_history, get_chat_active_users, get_chat_membership, get_member_role, get_moderation_capabilities, get_permissions""" | |
| if action == "get_chat_info": | |
| entity = target_entity | |
| info = await self._build_chat_info_payload(entity, include_admins=True) | |
| return self._tool_ok({"action": action, "chat": info}) | |
| if action == "get_full_chat": | |
| entity = target_entity | |
| info = await self._build_chat_info_payload(entity, include_admins=True, admins_limit=50) | |
| return self._tool_ok({"action": action, "chat": info}) | |
| if action == "get_chat_stats": | |
| count = 0 | |
| async for _user in self.client.iter_participants(target_entity): | |
| count += 1 | |
| if count >= 5000: | |
| break | |
| recent = await self.client.get_messages(target_entity, limit=20) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "participants_estimate": count, "recent_messages": len(recent or [])}) | |
| if action == "get_chat_history": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=200) | |
| reverse = self._tool_bool(raw.get("reverse")) | |
| items = [] | |
| async for msg in self.client.iter_messages(target_entity, limit=limit, reverse=reverse): | |
| items.append(self._serialize_message(msg)) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "get_chat_active_users": | |
| limit = self._normalize_limit(raw.get("count", raw.get("limit", 20)), default=20, maximum=50) | |
| scan_limit = self._normalize_limit(raw.get("scan_limit", limit * 10), default=limit * 10, maximum=500) | |
| counters = {} | |
| async for msg in self.client.iter_messages(target_entity, limit=scan_limit): | |
| sender_id = getattr(msg, "sender_id", None) | |
| if not sender_id: | |
| continue | |
| counters[sender_id] = counters.get(sender_id, 0) + 1 | |
| top_ids = sorted(counters.items(), key=lambda x: x[1], reverse=True)[:limit] | |
| users = [] | |
| for user_id, hits in top_ids: | |
| with contextlib.suppress(Exception): | |
| entity = await self.client.get_entity(user_id) | |
| users.append({"id": user_id, "name": get_display_name(entity), "username": getattr(entity, "username", None), "messages": hits}) | |
| return self._tool_ok({"action": action, "count": len(users), "users": users}) | |
| if action == "get_chat_membership": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| try: | |
| perms = await self.client.get_permissions(target_entity, user) | |
| role = "member" | |
| if getattr(perms, "is_admin", False): | |
| role = "admin" | |
| if getattr(perms, "is_creator", False): | |
| role = "creator" | |
| return self._tool_ok({"action": action, "user_id": user.id, "chat_id": getattr(target_entity, "id", None), "member": True, "role": role, "permissions": str(perms)}) | |
| except UserNotParticipantError: | |
| return self._tool_ok({"action": action, "user_id": user.id, "chat_id": getattr(target_entity, "id", None), "member": False, "role": None}) | |
| if action == "get_member_role": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| perms = await self.client.get_permissions(target_entity, user) | |
| role = "member" | |
| if getattr(perms, "is_admin", False): | |
| role = "admin" | |
| if getattr(perms, "is_creator", False): | |
| role = "creator" | |
| return self._tool_ok({"action": action, "user_id": user.id, "chat_id": getattr(target_entity, "id", None), "role": role, "permissions": str(perms)}) | |
| if action == "get_moderation_capabilities": | |
| capabilities = [] | |
| with contextlib.suppress(Exception): | |
| perms = await self.client.get_permissions(target_entity, self.me) | |
| if getattr(perms, "is_admin", False): | |
| capabilities = ["ban_user", "kick_user", "mute_user", "unmute_user", "promote_user", "demote_user", "delete_messages", "delete_user_messages", "pin_message", "set_chat_title", "set_chat_about"] | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "capabilities": capabilities}) | |
| if action == "get_permissions": | |
| target_user = None | |
| with contextlib.suppress(Exception): | |
| target_user = await self._resolve_target_user(target_entity, raw, req_message) | |
| perms = await self.client.get_permissions(target_entity, target_user) if target_user else await self.client.get_permissions(target_entity, self.me) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "user_id": getattr(target_user, "id", None) if target_user else self.me.id, "permissions": str(perms)}) | |
| async def _tool_participants(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: get_participants, get_chat_participants, search_participants, get_chat_admins, get_online_count""" | |
| if action in {"get_participants", "get_chat_participants"}: | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=200) | |
| participants = [] | |
| async for user in self.client.iter_participants(target_entity, limit=limit): | |
| participants.append({"id": user.id, "name": get_display_name(user), "username": getattr(user, "username", None), "bot": bool(getattr(user, "bot", False))}) | |
| chat_info = await self._build_chat_info_payload(target_entity, include_admins=False) | |
| return self._tool_ok({ | |
| "action": action, | |
| "chat_id": getattr(target_entity, "id", None), | |
| "count": len(participants), | |
| "participants_count": chat_info.get("participants_count"), | |
| "owner": chat_info.get("owner"), | |
| "participants": participants, | |
| }) | |
| if action == "search_participants": | |
| query = str(raw.get("query") or "").strip().lower().lstrip("@") | |
| if not query: | |
| return self._tool_err("missing query") | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| participants = [] | |
| async for user in self.client.iter_participants(target_entity, search=query, limit=limit): | |
| participants.append({"id": user.id, "name": get_display_name(user), "username": getattr(user, "username", None)}) | |
| return self._tool_ok({"action": action, "query": query, "count": len(participants), "participants": participants}) | |
| if action == "get_chat_admins": | |
| limit = self._normalize_limit(raw.get("limit", 50), default=50, maximum=200) | |
| admins = [] | |
| async for user in self.client.iter_participants(target_entity, limit=limit): | |
| participant = getattr(user, "participant", None) | |
| is_creator = self._get_participant_role(participant) == "creator" or bool(getattr(user, "creator", False)) | |
| is_admin = is_creator or self._get_participant_role(participant) == "admin" or bool(getattr(user, "admin_rights", None)) | |
| if is_admin: | |
| admins.append({ | |
| "id": user.id, | |
| "name": get_display_name(user), | |
| "username": getattr(user, "username", None), | |
| "creator": is_creator, | |
| "role": "creator" if is_creator else "admin", | |
| "rank": getattr(participant, "rank", None), | |
| }) | |
| return self._tool_ok({"action": action, "count": len(admins), "admins": admins}) | |
| async def _tool_messages_info(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: get_message_by_id, get_last_message, get_last_outgoing_message, get_last_incoming_message, get_messages_by_ids, get_messages_range, get_message_context, get_message_thread, get_message_replies, get_message_file_info, get_message_sender, get_message_stats, get_reply_info, get_message_link, get_current_chat_context, get_pinned_messages, pin_message, unpin_message, read_history, mark_chat_read""" | |
| if action in {"pin_message", "unpin_message"}: | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| if action == "pin_message": | |
| await self.client.pin_message(target_entity, message_id, notify=self._tool_bool(raw.get("notify"))) | |
| else: | |
| await self.client.unpin_message(target_entity, message=message_id) | |
| return self._tool_ok({"action": action, "message_id": message_id}) | |
| if action in {"read_history", "mark_chat_read"}: | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| messages = await self.client.get_messages(target_entity, limit=limit) | |
| if messages: | |
| await self.client.send_read_acknowledge(target_entity, message=messages[0]) | |
| return self._tool_ok({"action": action, "target_chat": getattr(target_entity, "id", None), "read_upto": messages[0].id if messages else None}) | |
| if action == "get_message_by_id": | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| msg = await self.client.get_messages(target_entity, ids=message_id) | |
| return self._tool_ok({"action": action, "message": self._serialize_message(msg)}) | |
| if action in {"get_last_message", "get_last_outgoing_message", "get_last_incoming_message"}: | |
| found = await self._find_recent_message( | |
| target_entity, | |
| limit=1, | |
| scan_limit=self._normalize_limit(raw.get("scan_limit", 200), default=200, maximum=1000), | |
| outgoing=(action == "get_last_outgoing_message"), | |
| incoming=(action == "get_last_incoming_message"), | |
| ) | |
| if not found: | |
| return self._tool_err("no messages found") | |
| return self._tool_ok({"action": action, "message": self._serialize_message(found[0])}) | |
| if action == "get_messages_by_ids": | |
| ids = raw.get("message_ids") or raw.get("ids") | |
| if isinstance(ids, (int, str)): | |
| ids = [int(ids)] | |
| if not ids: | |
| return self._tool_err("missing ids") | |
| messages = await self.client.get_messages(target_entity, ids=ids) | |
| items = messages if isinstance(messages, list) else [messages] | |
| return self._tool_ok({"action": action, "messages": [self._serialize_message(x) for x in items if x]}) | |
| if action == "get_messages_range": | |
| start_id = int(raw.get("start_id") or raw.get("from_id") or 0) | |
| end_id = int(raw.get("end_id") or raw.get("to_id") or 0) | |
| if not start_id or not end_id: | |
| return self._tool_err("start_id and end_id are required") | |
| if end_id < start_id: | |
| start_id, end_id = end_id, start_id | |
| ids = list(range(start_id, min(end_id, start_id + 199) + 1)) | |
| messages = await self.client.get_messages(target_entity, ids=ids) | |
| items = messages if isinstance(messages, list) else [messages] | |
| return self._tool_ok({"action": action, "count": len([x for x in items if x]), "messages": [self._serialize_message(x) for x in items if x]}) | |
| if action == "get_pinned_messages": | |
| try: | |
| from telethon.tl.types import InputMessagesFilterPinned | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=InputMessagesFilterPinned()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "get_message_context": | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| prev_msgs = await self.client.get_messages(target_entity, ids=[max(1, message_id - 1), message_id, message_id + 1]) | |
| items = prev_msgs if isinstance(prev_msgs, list) else [prev_msgs] | |
| return self._tool_ok({"action": action, "messages": [self._serialize_message(x) for x in items if x]}) | |
| if action == "get_message_thread": | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| before = self._normalize_limit(raw.get("before", 3), default=3, maximum=25) | |
| after = self._normalize_limit(raw.get("after", 3), default=3, maximum=25) | |
| ids = list(range(max(1, message_id - before), message_id + after + 1)) | |
| items = await self.client.get_messages(target_entity, ids=ids) | |
| items = items if isinstance(items, list) else [items] | |
| return self._tool_ok({"action": action, "message_id": message_id, "messages": [self._serialize_message(x) for x in items if x]}) | |
| if action == "get_message_replies": | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = [] | |
| async for msg in self.client.iter_messages(target_entity, reply_to=message_id, limit=limit): | |
| items.append(self._serialize_message(msg)) | |
| return self._tool_ok({"action": action, "message_id": message_id, "count": len(items), "messages": items}) | |
| if action == "get_message_file_info": | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| msg = await self.client.get_messages(target_entity, ids=message_id) | |
| return self._tool_ok({"action": action, "message": self._serialize_message(msg), "file": self._extract_message_file_info(msg)}) | |
| if action == "get_message_sender": | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| msg = await self.client.get_messages(target_entity, ids=message_id) | |
| sender = None | |
| with contextlib.suppress(Exception): | |
| sender = await msg.get_sender() | |
| return self._tool_ok({"action": action, "message_id": message_id, "sender": self._serialize_entity_brief(sender)}) | |
| if action == "get_message_stats": | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| msg = await self.client.get_messages(target_entity, ids=message_id) | |
| text = str(getattr(msg, "message", None) or getattr(msg, "text", None) or "") | |
| return self._tool_ok({ | |
| "action": action, | |
| "message_id": message_id, | |
| "length": len(text), | |
| "words": len([x for x in re.split(r"\s+", text.strip()) if x]), | |
| "has_media": bool(getattr(msg, "media", None)), | |
| "reply_to": getattr(getattr(msg, "reply_to", None), "reply_to_msg_id", None), | |
| "sender_id": getattr(msg, "sender_id", None), | |
| "out": bool(getattr(msg, "out", False)), | |
| }) | |
| if action == "get_reply_info": | |
| if not req_reply_id: | |
| return self._tool_err("current request has no reply context") | |
| reply_msg = await self.client.get_messages(current_entity, ids=req_reply_id) | |
| return self._tool_ok({"action": action, "reply_message": self._serialize_message(reply_msg)}) | |
| if action == "get_message_link": | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| return self._tool_ok({"action": action, "message_id": message_id, "link": self._build_message_link(target_entity, message_id)}) | |
| if action == "get_current_chat_context": | |
| info = await self._build_chat_info_payload(current_entity, include_admins=True) | |
| info["request_message_id"] = session.get("message_id") | |
| info["reply_message_id"] = session.get("reply_message_id") | |
| if req_reply_id: | |
| with contextlib.suppress(Exception): | |
| reply_msg = await self.client.get_messages(current_entity, ids=req_reply_id) | |
| info["reply_message"] = self._serialize_message(reply_msg) | |
| sender = await reply_msg.get_sender() | |
| info["reply_sender"] = self._serialize_user_brief(sender) if sender else None | |
| return self._tool_ok({"action": action, "chat": info}) | |
| async def _tool_search(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: search_messages, search_recent_messages, search_messages_from_user, search_contacts, search_links, search_photos, search_videos, search_documents, search_audio, search_voice, search_gifs""" | |
| if action == "search_messages": | |
| query = str(raw.get("query") or "").strip() | |
| if not query: | |
| return self._tool_err("missing query") | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, query=query, limit=limit) | |
| return self._tool_ok({"action": action, "query": query, "count": len(items), "messages": items}) | |
| if action == "search_recent_messages": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| scan_limit = self._normalize_limit(raw.get("scan_limit", limit * 4), default=limit * 4, maximum=500) | |
| query = str(raw.get("query") or "").strip().lower() | |
| if not query: | |
| return self._tool_err("missing query") | |
| items = [] | |
| async for msg in self.client.iter_messages(target_entity, limit=scan_limit): | |
| text = str(getattr(msg, "message", None) or getattr(msg, "text", None) or "") | |
| if query in text.lower(): | |
| items.append(self._serialize_message(msg)) | |
| if len(items) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "query": query, "count": len(items), "messages": items}) | |
| if action == "search_messages_from_user": | |
| query = str(raw.get("query") or "").strip() | |
| if not query: | |
| return self._tool_err("missing query") | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, query=query, limit=limit, from_user=user) | |
| return self._tool_ok({"action": action, "user_id": user.id, "query": query, "count": len(items), "messages": items}) | |
| if action == "search_contacts": | |
| query = str(raw.get("query") or "").strip().lower().lstrip("@") | |
| if not query: | |
| return self._tool_err("missing query") | |
| limit = self._normalize_limit(raw.get("limit", 30), default=30, maximum=200) | |
| items = [] | |
| async for dialog in self.client.iter_dialogs(): | |
| entity = dialog.entity | |
| if not isinstance(entity, User) or getattr(entity, "bot", False): | |
| continue | |
| hay = " ".join(filter(None, [get_display_name(entity), getattr(entity, "username", None), getattr(entity, "phone", None)])).lower() | |
| if query in hay: | |
| items.append({"id": entity.id, "name": get_display_name(entity), "username": getattr(entity, "username", None), "phone": getattr(entity, "phone", None)}) | |
| if len(items) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "query": query, "count": len(items), "contacts": items}) | |
| if action == "search_links": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=InputMessagesFilterUrl()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "search_photos": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=InputMessagesFilterPhotos()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "search_videos": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=InputMessagesFilterVideo()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "search_documents": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=InputMessagesFilterDocument()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "search_audio": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=InputMessagesFilterMusic()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "search_voice": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=InputMessagesFilterRoundVoice()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "search_gifs": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=InputMessagesFilterGif()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| async def _tool_user_info(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: get_user_info, get_full_user, get_common_chats_with_user, get_user_media, get_user_last_messages, get_users_chats, get_peer_stories, read_peer_stories""" | |
| if action == "get_user_info": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| info = await self._get_full_user_profile_payload(user) | |
| return self._tool_ok({"action": action, "user": info}) | |
| if action == "get_full_user": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| info = await self._get_full_user_profile_payload(user) | |
| return self._tool_ok({"action": action, "user": info}) | |
| if action == "get_common_chats_with_user": | |
| user = await self._resolve_target_user(current_entity, raw, req_message) | |
| limit = self._normalize_limit(raw.get("limit", 50), default=50, maximum=200) | |
| chats = [] | |
| async for dialog in self.client.iter_dialogs(): | |
| entity = dialog.entity | |
| if len(chats) >= limit: | |
| break | |
| with contextlib.suppress(Exception): | |
| async for participant in self.client.iter_participants(entity, search=str(user.id), limit=1): | |
| if getattr(participant, "id", None) == user.id: | |
| chats.append(self._serialize_dialog(dialog)) | |
| break | |
| return self._tool_ok({"action": action, "user_id": user.id, "count": len(chats), "dialogs": chats}) | |
| if action == "get_user_media": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| items = [] | |
| async for msg in self.client.iter_messages(target_entity, limit=limit * 8, from_user=user): | |
| if not getattr(msg, "media", None): | |
| continue | |
| items.append(self._serialize_message(msg)) | |
| if len(items) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "user_id": user.id, "count": len(items), "messages": items}) | |
| if action == "get_user_last_messages": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| limit = self._normalize_limit(raw.get("limit", 10), default=10, maximum=50) | |
| messages = [] | |
| async for msg in self.client.iter_messages(target_entity, limit=limit * 5, from_user=user): | |
| messages.append(self._serialize_message(msg)) | |
| if len(messages) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "user_id": user.id, "count": len(messages), "messages": messages}) | |
| if action == "get_users_chats": | |
| user = await self._resolve_target_user(current_entity, raw, req_message) | |
| limit = self._normalize_limit(raw.get("limit", 50), default=50, maximum=200) | |
| chats = [] | |
| async for dialog in self.client.iter_dialogs(): | |
| entity = dialog.entity | |
| if len(chats) >= limit: | |
| break | |
| with contextlib.suppress(Exception): | |
| async for p in self.client.iter_participants(entity, search=str(user.id), limit=1): | |
| if getattr(p, "id", None) == user.id: | |
| chats.append({"id": getattr(entity, "id", None), "title": getattr(dialog, "title", None) or get_display_name(entity), "username": getattr(entity, "username", None)}) | |
| break | |
| return self._tool_ok({"action": action, "user_id": user.id, "count": len(chats), "chats": chats}) | |
| if action == "get_peer_stories": | |
| try: | |
| from telethon.tl.functions.stories import GetPeerStoriesRequest | |
| stories = await self.client(GetPeerStoriesRequest(peer=target_entity)) | |
| return self._tool_ok({"action": action, "stories": str(stories)[:4000]}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "read_peer_stories": | |
| try: | |
| from telethon.tl.functions.stories import ReadStoriesRequest | |
| max_id = int(raw.get("max_id") or raw.get("story_id") or 0) | |
| await self.client(ReadStoriesRequest(peer=target_entity, max_id=max_id)) | |
| return self._tool_ok({"action": action, "peer_id": getattr(target_entity, "id", None), "max_id": max_id}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| async def _tool_media_history(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: get_recent_media, get_recent_links, get_recent_photos, get_recent_videos, get_recent_documents, get_recent_audio, get_recent_voice, get_recent_gifs, get_saved_messages, get_saved_messages_count, save_message_media, save_recent_media""" | |
| if action == "save_message_media": | |
| source_chat = await self._resolve_target_entity(raw.get("from_chat"), chat_id) | |
| message_id = int(raw.get("message_id") or req_reply_id or 0) | |
| if not message_id: | |
| return self._tool_err("missing message_id") | |
| source_msg = await self.client.get_messages(source_chat, ids=message_id) | |
| if not getattr(source_msg, "media", None): | |
| return self._tool_err("message has no media") | |
| base_path = resolve_path(raw.get("path") or raw.get("dir") or f"exports/message_{message_id}", default_name=f"exports/message_{message_id}", create_parent=True) | |
| saved = await self.client.download_media(source_msg, file=base_path) | |
| final_path = os.path.abspath(saved or base_path) | |
| self._register_session_file(chat_id, final_path, role="output", label=os.path.basename(final_path), source=action) | |
| return self._tool_ok({"action": action, "message_id": message_id, "path": final_path}) | |
| if action == "save_recent_media": | |
| limit = self._normalize_limit(raw.get("limit", 5), default=5, maximum=20) | |
| base_dir = resolve_path(raw.get("dir") or "exports/recent_media", default_name="exports/recent_media", create_parent=True) | |
| os.makedirs(base_dir, exist_ok=True) | |
| saved = [] | |
| async for msg in self.client.iter_messages(target_entity, limit=limit * 8): | |
| if not getattr(msg, "media", None): | |
| continue | |
| target_dir = base_dir if os.path.isdir(base_dir) else os.path.dirname(base_dir) | |
| path = await self.client.download_media(msg, file=target_dir or base_dir) | |
| if path: | |
| self._register_session_file(chat_id, path, role="output", label=os.path.basename(path), source=action) | |
| saved.append({"message_id": msg.id, "path": path}) | |
| if len(saved) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "count": len(saved), "files": saved}) | |
| if action == "get_recent_media": | |
| limit = self._normalize_limit(raw.get("limit", 10), default=10, maximum=50) | |
| items = [] | |
| async for msg in self.client.iter_messages(target_entity, limit=limit * 5): | |
| if not getattr(msg, "media", None): | |
| continue | |
| items.append(self._serialize_message(msg)) | |
| if len(items) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action in {"get_recent_links", "get_recent_photos", "get_recent_videos", "get_recent_documents", "get_recent_audio", "get_recent_voice", "get_recent_gifs"}: | |
| filter_map = { | |
| "get_recent_links": InputMessagesFilterUrl, | |
| "get_recent_photos": InputMessagesFilterPhotos, | |
| "get_recent_videos": InputMessagesFilterVideo, | |
| "get_recent_documents": InputMessagesFilterDocument, | |
| "get_recent_audio": InputMessagesFilterMusic, | |
| "get_recent_voice": InputMessagesFilterRoundVoice, | |
| "get_recent_gifs": InputMessagesFilterGif, | |
| } | |
| limit = self._normalize_limit(raw.get("limit", 10), default=10, maximum=50) | |
| items = await self._search_messages_core(target_entity, limit=limit, filter_obj=filter_map[action]()) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "get_saved_messages": | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=200) | |
| items = [] | |
| async for msg in self.client.iter_messages("me", limit=limit): | |
| items.append(self._serialize_message(msg)) | |
| return self._tool_ok({"action": action, "count": len(items), "messages": items}) | |
| if action == "get_saved_messages_count": | |
| scan_limit = self._normalize_limit(raw.get("scan_limit", 500), default=500, maximum=5000) | |
| count = 0 | |
| async for _msg in self.client.iter_messages("me", limit=scan_limit): | |
| count += 1 | |
| return self._tool_ok({"action": action, "scanned_messages": count, "scan_limit": scan_limit}) | |
| async def _tool_profile(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: get_profile_photos, get_user_profile_photos, delete_profile_photos, get_self_profile, get_self_profile_full, set_profile_name, set_profile_bio, set_profile_first_name, set_profile_last_name, set_profile_about, clear_profile_bio, set_profile_photo, send_profile_photo, save_profile_photo, save_profile_text, save_profile_bundle, clone_profile_text, clone_profile, set_random_profile_photo, cycle_profile_photos, set_profile_username""" | |
| if action == "get_profile_photos": | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") or "me" | |
| entity = await self._resolve_target_entity(target, self.me.id) | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=100) | |
| photos = await self.client.get_profile_photos(entity, limit=limit) | |
| media_limit = self._normalize_limit(raw.get("media_limit", min(limit, 5)), default=min(limit, 5), maximum=8) | |
| attach_media = self._tool_bool(raw.get("include_media", True)) | |
| entity_info = self._serialize_entity_brief(entity) | |
| visuals = [] | |
| items = [] | |
| display_name = ( | |
| entity_info.get("name") | |
| or entity_info.get("title") | |
| or entity_info.get("username") | |
| or str(entity_info.get("id") or target) | |
| ) | |
| for idx, photo in enumerate(photos, start=1): | |
| item = { | |
| "index": idx, | |
| "id": getattr(photo, "id", None), | |
| "date": str(getattr(photo, "date", None) or ""), | |
| "has_video": bool(getattr(photo, "video_sizes", None)), | |
| } | |
| if attach_media and len(visuals) < media_limit: | |
| with contextlib.suppress(Exception): | |
| raw_bytes = await self._download_profile_photo_bytes(entity, photo, photo_index=idx) | |
| prepared_bytes, mime, size = self._prepare_tool_image_bytes(raw_bytes) | |
| if prepared_bytes: | |
| item["preview_attached"] = True | |
| if size: | |
| item["preview_size"] = {"w": size[0], "h": size[1]} | |
| visuals.append({ | |
| "mime_type": mime, | |
| "data": prepared_bytes, | |
| "label": f"{display_name} avatar #{idx}", | |
| }) | |
| items.append(item) | |
| self._append_tool_visual_context(chat_id, visuals) | |
| return self._tool_ok({ | |
| "action": action, | |
| "target": entity_info, | |
| "count": len(items), | |
| "media_attached": len(visuals), | |
| "photos": items, | |
| }) | |
| if action == "get_user_profile_photos": | |
| prepared = dict(raw) | |
| prepared.setdefault("target", raw.get("target_user") or raw.get("user") or raw.get("target")) | |
| prepared["action"] = "get_profile_photos" | |
| return await self._execute_telegram_tool(chat_id, prepared) | |
| if action == "delete_profile_photos": | |
| try: | |
| from telethon.tl.functions.photos import DeletePhotosRequest | |
| limit = self._normalize_limit(raw.get("limit", 100), default=100, maximum=100) | |
| photos = await self.client.get_profile_photos("me", limit=limit) | |
| if photos: | |
| await self.client(DeletePhotosRequest(id=photos)) | |
| return self._tool_ok({"action": action, "deleted_count": len(photos)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "get_self_profile": | |
| me = await self.client.get_me() | |
| return self._tool_ok({"action": action, "profile": {"id": me.id, "name": get_display_name(me), "username": getattr(me, "username", None), "premium": bool(getattr(me, "premium", False))}}) | |
| if action == "get_self_profile_full": | |
| me = await self.client.get_me() | |
| full = await self.client(GetFullUserRequest(me)) | |
| full_user = getattr(full, "full_user", None) | |
| return self._tool_ok({"action": action, "profile": { | |
| "id": me.id, | |
| "name": get_display_name(me), | |
| "first_name": getattr(me, "first_name", None), | |
| "last_name": getattr(me, "last_name", None), | |
| "username": getattr(me, "username", None), | |
| "premium": bool(getattr(me, "premium", False)), | |
| "phone": getattr(me, "phone", None), | |
| "about": getattr(full_user, "about", None), | |
| }}) | |
| if action == "set_profile_name": | |
| try: | |
| from telethon.tl.functions.account import UpdateProfileRequest | |
| first_name = str(raw.get("first_name") or raw.get("text") or "").strip() | |
| last_name = str(raw.get("last_name") or "").strip() | |
| await self.client(UpdateProfileRequest(first_name=first_name or None, last_name=last_name or None)) | |
| return self._tool_ok({"action": action, "first_name": first_name, "last_name": last_name}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "set_profile_bio": | |
| try: | |
| from telethon.tl.functions.account import UpdateProfileRequest | |
| bio = str(raw.get("bio") or raw.get("about") or raw.get("text") or "").strip() | |
| await self.client(UpdateProfileRequest(about=bio or None)) | |
| return self._tool_ok({"action": action, "bio": bio}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action in {"set_profile_first_name", "set_profile_last_name", "set_profile_about", "clear_profile_bio"}: | |
| try: | |
| from telethon.tl.functions.account import UpdateProfileRequest | |
| kwargs = {} | |
| if action == "set_profile_first_name": | |
| kwargs["first_name"] = str(raw.get("first_name") or raw.get("text") or "").strip() or None | |
| elif action == "set_profile_last_name": | |
| kwargs["last_name"] = str(raw.get("last_name") or raw.get("text") or "").strip() or None | |
| elif action == "set_profile_about": | |
| kwargs["about"] = str(raw.get("about") or raw.get("bio") or raw.get("text") or "").strip() or None | |
| else: | |
| kwargs["about"] = None | |
| await self.client(UpdateProfileRequest(**kwargs)) | |
| return self._tool_ok({"action": action, **{k: v for k, v in kwargs.items() if v is not None}}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "set_profile_photo": | |
| try: | |
| from telethon.tl.functions.photos import UploadProfilePhotoRequest | |
| image_bytes = None | |
| source = {} | |
| path = str(raw.get("path") or raw.get("file_path") or "").strip() | |
| if path: | |
| with contextlib.suppress(Exception): | |
| resolved = self._resolve_os_path(path, default_name="artifact", chat_id=chat_id) | |
| if os.path.isfile(resolved): | |
| path = resolved | |
| if not os.path.isfile(path): | |
| return self._tool_err("path does not exist: {}".format(path)) | |
| with open(path, "rb") as file_obj: | |
| image_bytes = file_obj.read() | |
| source = {"kind": "path", "path": path} | |
| source_chat = raw.get("from_chat") or raw.get("source_chat") | |
| message_id = raw.get("message_id") or req_reply_id | |
| if image_bytes is None and message_id: | |
| source_entity = await self._resolve_target_entity(source_chat, chat_id if source_chat in (None, "") else None) | |
| source_msg = await self.client.get_messages(source_entity, ids=int(message_id)) | |
| image_bytes, source_name = await self._extract_image_bytes_from_message(source_msg) | |
| if image_bytes: | |
| source = {"kind": "message", "message_id": int(message_id), "name": source_name} | |
| if image_bytes is None: | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") | |
| if target in (None, "") and req_message is not None and req_reply_id: | |
| with contextlib.suppress(Exception): | |
| reply = await req_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, ""): | |
| target = "me" | |
| entity = await self._resolve_target_entity(target, self.me.id if target == "me" else None) | |
| photo_id = raw.get("photo_id") | |
| photo_index = self._normalize_limit(raw.get("photo_index", raw.get("index", 1)), default=1, maximum=20) | |
| fetch_limit = max(photo_index, 20 if photo_id else photo_index) | |
| photos = await self.client.get_profile_photos(entity, limit=fetch_limit) | |
| chosen = None | |
| if photo_id not in (None, ""): | |
| photo_id = int(photo_id) | |
| chosen = next((photo for photo in photos if getattr(photo, "id", None) == photo_id), None) | |
| elif photos and photo_index <= len(photos): | |
| chosen = photos[photo_index - 1] | |
| if not chosen: | |
| return self._tool_err("profile photo not found") | |
| image_bytes = await self._download_profile_photo_bytes(entity, chosen, photo_index=photo_index) | |
| if not image_bytes: | |
| return self._tool_err("failed to download profile photo bytes") | |
| source = { | |
| "kind": "profile_photo", | |
| "target": self._serialize_entity_brief(entity), | |
| "photo_id": getattr(chosen, "id", None), | |
| "photo_index": photo_index, | |
| } | |
| prepared_bytes, mime, size = self._prepare_tool_image_bytes(image_bytes, max_side=2048, quality=92) | |
| if not prepared_bytes: | |
| return self._tool_err("failed to prepare profile photo bytes") | |
| upload = io.BytesIO(prepared_bytes) | |
| upload.name = "profile_photo.jpg" | |
| uploaded = await self.client.upload_file(upload) | |
| photo = await self.client(UploadProfilePhotoRequest(file=uploaded)) | |
| result = { | |
| "action": action, | |
| "source": source, | |
| "photo_id": getattr(photo, "photo_id", None) or getattr(photo, "id", None), | |
| "mime_type": mime, | |
| } | |
| if size: | |
| result["size"] = {"w": size[0], "h": size[1]} | |
| return self._tool_ok(result) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "send_profile_photo": | |
| try: | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") | |
| if target in (None, "") and req_message is not None and req_reply_id: | |
| with contextlib.suppress(Exception): | |
| reply = await req_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, ""): | |
| target = "me" | |
| entity = await self._resolve_target_entity(target, self.me.id if target == "me" else None) | |
| photo_id = raw.get("photo_id") | |
| photo_index = self._normalize_limit(raw.get("photo_index", raw.get("index", 1)), default=1, maximum=50) | |
| chosen, resolved_index, _photos = await self._pick_profile_photo( | |
| entity, | |
| photo_id=photo_id, | |
| photo_index=photo_index, | |
| ) | |
| if not chosen: | |
| return self._tool_err("profile photo not found") | |
| image_bytes = await self._download_profile_photo_bytes(entity, chosen, photo_index=resolved_index) | |
| prepared_bytes, _mime, size = self._prepare_tool_image_bytes(image_bytes, max_side=2048, quality=92) | |
| if not prepared_bytes: | |
| return self._tool_err("failed to prepare profile photo bytes") | |
| out = io.BytesIO(prepared_bytes) | |
| out.name = "profile_photo_{}.jpg".format(resolved_index) | |
| caption, parse_mode = await self._prepare_outbound_text(raw) | |
| sent = await self._send_file( | |
| target_entity, | |
| out, | |
| caption=caption or None, | |
| parse_mode=parse_mode, | |
| reply_to=raw.get("message_id") or req_reply_id, | |
| force_document=self._tool_bool(raw.get("as_document")), | |
| ) | |
| items = sent if isinstance(sent, list) else [sent] | |
| result = { | |
| "action": action, | |
| "target": self._serialize_entity_brief(entity), | |
| "photo_id": getattr(chosen, "id", None), | |
| "photo_index": resolved_index, | |
| "count": len(items), | |
| "messages": [self._serialize_message(item) for item in items if item], | |
| } | |
| if size: | |
| result["size"] = {"w": size[0], "h": size[1]} | |
| return self._tool_ok(result) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "save_profile_photo": | |
| try: | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") or "me" | |
| entity = await self._resolve_target_entity(target, self.me.id if target == "me" else None) | |
| photo_id = raw.get("photo_id") | |
| photo_index = self._normalize_limit(raw.get("photo_index", raw.get("index", 1)), default=1, maximum=50) | |
| chosen, resolved_index, _photos = await self._pick_profile_photo( | |
| entity, | |
| photo_id=photo_id, | |
| photo_index=photo_index, | |
| ) | |
| if not chosen: | |
| return self._tool_err("profile photo not found") | |
| image_bytes = await self._download_profile_photo_bytes( | |
| entity, | |
| chosen, | |
| photo_index=resolved_index, | |
| download_big=not self._tool_bool(raw.get("small")), | |
| ) | |
| prepared_bytes, _mime, size = self._prepare_tool_image_bytes(image_bytes, max_side=2048, quality=92) | |
| if not prepared_bytes: | |
| return self._tool_err("failed to prepare profile photo bytes") | |
| base_path = resolve_path(raw.get("path") or raw.get("dir") or f"exports/profile_photos/{getattr(entity, 'id', 'me')}", default_name=f"exports/profile_photos/{getattr(entity, 'id', 'me')}", create_parent=True) | |
| if os.path.splitext(base_path)[1]: | |
| final_path = base_path | |
| os.makedirs(os.path.dirname(final_path) or ".", exist_ok=True) | |
| else: | |
| os.makedirs(base_path, exist_ok=True) | |
| final_path = os.path.join(base_path, "avatar_{}.jpg".format(resolved_index)) | |
| with open(final_path, "wb") as file_obj: | |
| file_obj.write(prepared_bytes) | |
| final_path = os.path.abspath(final_path) | |
| self._register_session_file(chat_id, final_path, role="output", label=os.path.basename(final_path), source=action) | |
| result = { | |
| "action": action, | |
| "target": self._serialize_entity_brief(entity), | |
| "path": final_path, | |
| "photo_id": getattr(chosen, "id", None), | |
| "photo_index": resolved_index, | |
| } | |
| if size: | |
| result["size"] = {"w": size[0], "h": size[1]} | |
| return self._tool_ok(result) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "save_profile_text": | |
| try: | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") | |
| if target in (None, "") and req_message is not None and req_reply_id: | |
| with contextlib.suppress(Exception): | |
| reply = await req_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, ""): | |
| target = "me" | |
| entity = await self._resolve_target_entity(target, self.me.id if target == "me" else None) | |
| if isinstance(entity, User): | |
| payload = await self._get_full_user_profile_payload(entity) | |
| else: | |
| payload = await self._build_chat_info_payload(entity, include_admins=True) | |
| path = resolve_path( | |
| raw.get("path") or f"exports/profile_text/{getattr(entity, 'id', 'me')}.json", | |
| default_name=f"exports/profile_text/{getattr(entity, 'id', 'me')}.json", | |
| create_parent=True, | |
| ) | |
| if not os.path.splitext(path)[1]: | |
| os.makedirs(path, exist_ok=True) | |
| path = os.path.join(path, "profile.json") | |
| with open(path, "w", encoding="utf-8") as file_obj: | |
| json.dump(payload, file_obj, ensure_ascii=False, indent=2) | |
| final_path = os.path.abspath(path) | |
| self._register_session_file(chat_id, final_path, role="output", label=os.path.basename(final_path), source=action) | |
| return self._tool_ok({ | |
| "action": action, | |
| "target": self._serialize_entity_brief(entity), | |
| "path": final_path, | |
| "profile": payload, | |
| }) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "save_profile_bundle": | |
| try: | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") | |
| if target in (None, "") and req_message is not None and req_reply_id: | |
| with contextlib.suppress(Exception): | |
| reply = await req_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, ""): | |
| target = "me" | |
| entity = await self._resolve_target_entity(target, self.me.id if target == "me" else None) | |
| if isinstance(entity, User): | |
| payload = await self._get_full_user_profile_payload(entity) | |
| else: | |
| payload = await self._build_chat_info_payload(entity, include_admins=True) | |
| bundle_root = resolve_path( | |
| raw.get("dir") or raw.get("path") or f"exports/profile_bundle/{getattr(entity, 'id', 'me')}", | |
| default_name=f"exports/profile_bundle/{getattr(entity, 'id', 'me')}", | |
| create_parent=True, | |
| ) | |
| if os.path.splitext(bundle_root)[1]: | |
| zip_path = bundle_root | |
| bundle_dir = os.path.splitext(bundle_root)[0] | |
| else: | |
| bundle_dir = bundle_root | |
| zip_path = bundle_root.rstrip("/\\") + ".zip" | |
| os.makedirs(bundle_dir, exist_ok=True) | |
| profile_json_path = os.path.join(bundle_dir, "profile.json") | |
| with open(profile_json_path, "w", encoding="utf-8") as file_obj: | |
| json.dump(payload, file_obj, ensure_ascii=False, indent=2) | |
| saved_paths = [os.path.abspath(profile_json_path)] | |
| avatar_limit = self._normalize_limit(raw.get("avatars_limit", raw.get("limit", 5)), default=5, maximum=20) | |
| include_avatars = not str(raw.get("include_avatars", "true")).strip().lower() in {"0", "false", "no"} | |
| avatar_count = 0 | |
| if include_avatars: | |
| photos = await self.client.get_profile_photos(entity, limit=avatar_limit) | |
| for idx, photo in enumerate(photos, start=1): | |
| image_bytes = await self._download_profile_photo_bytes(entity, photo, photo_index=idx) | |
| prepared_bytes, _mime, _size = self._prepare_tool_image_bytes(image_bytes, max_side=2048, quality=92) | |
| if not prepared_bytes: | |
| continue | |
| avatar_path = os.path.join(bundle_dir, "avatar_{}.jpg".format(idx)) | |
| with open(avatar_path, "wb") as file_obj: | |
| file_obj.write(prepared_bytes) | |
| saved_paths.append(os.path.abspath(avatar_path)) | |
| avatar_count += 1 | |
| zip_created = False | |
| if self._tool_bool(raw.get("zip")) or str(raw.get("format") or "").strip().lower() == "zip": | |
| with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: | |
| for item in saved_paths: | |
| archive.write(item, arcname=os.path.relpath(item, bundle_dir)) | |
| zip_path = os.path.abspath(zip_path) | |
| self._register_session_file(chat_id, zip_path, role="output", label=os.path.basename(zip_path), source=action) | |
| zip_created = True | |
| for item in saved_paths: | |
| self._register_session_file(chat_id, item, role="output", label=os.path.basename(item), source=action) | |
| result = { | |
| "action": action, | |
| "target": self._serialize_entity_brief(entity), | |
| "dir": os.path.abspath(bundle_dir), | |
| "profile_path": os.path.abspath(profile_json_path), | |
| "avatar_count": avatar_count, | |
| "files": saved_paths, | |
| } | |
| if zip_created: | |
| result["zip_path"] = zip_path | |
| return self._tool_ok(result) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "clone_profile_text": | |
| try: | |
| from telethon.tl.functions.account import UpdateProfileRequest, UpdateUsernameRequest | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") | |
| if target in (None, "") and req_message is not None and req_reply_id: | |
| with contextlib.suppress(Exception): | |
| reply = await req_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, ""): | |
| return self._tool_err("missing target user") | |
| user = await self._resolve_target_user(target_entity, {**raw, "target": target}, req_message) | |
| profile = await self._get_full_user_profile_payload(user) | |
| copy_name = self._tool_bool(raw.get("copy_name", True)) | |
| copy_bio = self._tool_bool(raw.get("copy_bio", True)) | |
| copy_username = self._tool_bool(raw.get("copy_username")) | |
| kwargs = {} | |
| if copy_name: | |
| kwargs["first_name"] = profile.get("first_name") or None | |
| kwargs["last_name"] = profile.get("last_name") or None | |
| if copy_bio: | |
| kwargs["about"] = profile.get("about") or None | |
| result = { | |
| "action": action, | |
| "source_user": self._serialize_user_brief(user), | |
| "applied": {}, | |
| } | |
| if kwargs: | |
| await self.client(UpdateProfileRequest(**kwargs)) | |
| result["applied"].update(kwargs) | |
| if copy_username and profile.get("username"): | |
| try: | |
| await self.client(UpdateUsernameRequest(username=profile["username"])) | |
| result["applied"]["username"] = profile["username"] | |
| except Exception as username_error: | |
| result["username_error"] = str(username_error) | |
| return self._tool_ok(result) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "clone_profile": | |
| try: | |
| from telethon.tl.functions.account import UpdateProfileRequest, UpdateUsernameRequest | |
| from telethon.tl.functions.photos import UploadProfilePhotoRequest | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") | |
| if target in (None, "") and req_message is not None and req_reply_id: | |
| with contextlib.suppress(Exception): | |
| reply = await req_message.get_reply_message() | |
| if reply and getattr(reply, "sender_id", None): | |
| target = reply.sender_id | |
| if target in (None, ""): | |
| return self._tool_err("missing target user") | |
| user = await self._resolve_target_user(target_entity, {**raw, "target": target}, req_message) | |
| profile = await self._get_full_user_profile_payload(user) | |
| result = { | |
| "action": action, | |
| "source_user": self._serialize_user_brief(user), | |
| "applied": {}, | |
| } | |
| if not str(raw.get("copy_avatar", "true")).strip().lower() in {"0", "false", "no"}: | |
| photo_id = raw.get("photo_id") | |
| photo_index = self._normalize_limit(raw.get("photo_index", raw.get("index", 1)), default=1, maximum=50) | |
| chosen, resolved_index, _photos = await self._pick_profile_photo( | |
| user, | |
| photo_id=photo_id, | |
| photo_index=photo_index, | |
| ) | |
| if not chosen: | |
| return self._tool_err("profile photo not found") | |
| image_bytes = await self._download_profile_photo_bytes(user, chosen, photo_index=resolved_index) | |
| prepared_bytes, _mime, size = self._prepare_tool_image_bytes(image_bytes, max_side=2048, quality=92) | |
| if not prepared_bytes: | |
| return self._tool_err("failed to prepare profile photo bytes") | |
| upload = io.BytesIO(prepared_bytes) | |
| upload.name = "profile_photo.jpg" | |
| uploaded = await self.client.upload_file(upload) | |
| photo = await self.client(UploadProfilePhotoRequest(file=uploaded)) | |
| result["applied"]["photo_id"] = getattr(photo, "photo_id", None) or getattr(photo, "id", None) | |
| result["applied"]["source_photo_index"] = resolved_index | |
| if size: | |
| result["applied"]["photo_size"] = {"w": size[0], "h": size[1]} | |
| kwargs = {} | |
| if not str(raw.get("copy_name", "true")).strip().lower() in {"0", "false", "no"}: | |
| kwargs["first_name"] = profile.get("first_name") or None | |
| kwargs["last_name"] = profile.get("last_name") or None | |
| if not str(raw.get("copy_bio", "true")).strip().lower() in {"0", "false", "no"}: | |
| kwargs["about"] = profile.get("about") or None | |
| if kwargs: | |
| await self.client(UpdateProfileRequest(**kwargs)) | |
| result["applied"].update(kwargs) | |
| if self._tool_bool(raw.get("copy_username")) and profile.get("username"): | |
| try: | |
| await self.client(UpdateUsernameRequest(username=profile["username"])) | |
| result["applied"]["username"] = profile["username"] | |
| except Exception as username_error: | |
| result["username_error"] = str(username_error) | |
| return self._tool_ok(result) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action in {"set_random_profile_photo", "cycle_profile_photos"}: | |
| try: | |
| target = raw.get("target") or raw.get("target_user") or raw.get("user") or "me" | |
| entity = await self._resolve_target_entity(target, self.me.id if target == "me" else None) | |
| limit = self._normalize_limit(raw.get("limit", 20), default=20, maximum=50) | |
| photos = await self.client.get_profile_photos(entity, limit=limit) | |
| if not photos: | |
| return self._tool_err("no profile photos found") | |
| if action == "set_random_profile_photo": | |
| chosen = random.choice(list(photos)) | |
| index = next((idx for idx, photo in enumerate(photos, start=1) if getattr(photo, "id", None) == getattr(chosen, "id", None)), 1) | |
| else: | |
| current = self._normalize_limit(raw.get("current_index", 1), default=1, maximum=max(1, len(photos))) | |
| next_index = current + 1 if current < len(photos) else 1 | |
| chosen = photos[next_index - 1] | |
| index = next_index | |
| image_bytes = await self._download_profile_photo_bytes(entity, chosen, photo_index=index) | |
| if not image_bytes: | |
| return self._tool_err("failed to download profile photo bytes") | |
| prepared_bytes, _, _ = self._prepare_tool_image_bytes(image_bytes, max_side=2048, quality=92) | |
| upload = io.BytesIO(prepared_bytes) | |
| upload.name = "profile_photo.jpg" | |
| uploaded = await self.client.upload_file(upload) | |
| from telethon.tl.functions.photos import UploadProfilePhotoRequest | |
| photo = await self.client(UploadProfilePhotoRequest(file=uploaded)) | |
| return self._tool_ok({ | |
| "action": action, | |
| "source_target": self._serialize_entity_brief(entity), | |
| "photo_id": getattr(photo, "photo_id", None) or getattr(photo, "id", None), | |
| "source_photo_index": index, | |
| }) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "set_profile_username": | |
| try: | |
| from telethon.tl.functions.account import UpdateUsernameRequest | |
| username = str(raw.get("username") or raw.get("text") or "").strip().lstrip("@") | |
| await self.client(UpdateUsernameRequest(username=username)) | |
| return self._tool_ok({"action": action, "username": username}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| async def _tool_contacts(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: add_contact, delete_contact, get_contacts, get_contacts_count, get_blocked_users""" | |
| if action == "add_contact": | |
| try: | |
| from telethon.tl.functions.contacts import AddContactRequest | |
| user = await self._resolve_target_user(current_entity, raw, req_message) | |
| first_name = str(raw.get("first_name") or get_display_name(user) or "Contact") | |
| last_name = str(raw.get("last_name") or "") | |
| phone = str(raw.get("phone") or "") | |
| await self.client(AddContactRequest(id=user, first_name=first_name, last_name=last_name, phone=phone, add_phone_privacy_exception=False)) | |
| return self._tool_ok({"action": action, "user_id": user.id}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "delete_contact": | |
| try: | |
| from telethon.tl.functions.contacts import DeleteContactsRequest | |
| user = await self._resolve_target_user(current_entity, raw, req_message) | |
| await self.client(DeleteContactsRequest(id=[user])) | |
| return self._tool_ok({"action": action, "user_id": user.id}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "get_contacts": | |
| limit = self._normalize_limit(raw.get("limit", 50), default=50, maximum=200) | |
| contacts = [] | |
| async for dialog in self.client.iter_dialogs(): | |
| entity = dialog.entity | |
| if isinstance(entity, User) and not getattr(entity, "bot", False): | |
| contacts.append({"id": entity.id, "name": get_display_name(entity), "username": getattr(entity, "username", None)}) | |
| if len(contacts) >= limit: | |
| break | |
| return self._tool_ok({"action": action, "count": len(contacts), "contacts": contacts}) | |
| if action == "get_contacts_count": | |
| count = 0 | |
| async for dialog in self.client.iter_dialogs(): | |
| if isinstance(dialog.entity, User) and not getattr(dialog.entity, "bot", False): | |
| count += 1 | |
| return self._tool_ok({"action": action, "count": count}) | |
| if action == "get_blocked_users": | |
| try: | |
| from telethon.tl.functions.contacts import GetBlockedRequest | |
| offset = 0 | |
| limit = self._normalize_limit(raw.get("limit", 50), default=50, maximum=200) | |
| response = await self.client(GetBlockedRequest(offset=offset, limit=limit)) | |
| users = [] | |
| for user in getattr(response, "users", [])[:limit]: | |
| users.append({"id": user.id, "name": get_display_name(user), "username": getattr(user, "username", None)}) | |
| return self._tool_ok({"action": action, "count": len(users), "users": users}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| async def _tool_chat_management(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: join_chat, leave_chat, invite_user_to_chat, set_chat_title, set_chat_about, set_chat_photo, delete_chat_photo, export_chat_invite, get_drafts, set_draft, clear_draft""" | |
| if action == "join_chat": | |
| target = raw.get("target_chat") or raw.get("target") or raw.get("query") | |
| if not target: | |
| return self._tool_err("missing target_chat") | |
| cleaned = str(target).strip() | |
| if cleaned.startswith("https://t.me/+") or cleaned.startswith("tg://join"): | |
| entity = await self._resolve_target_entity(cleaned, None) | |
| else: | |
| entity = await self._resolve_target_entity(cleaned, None) | |
| with contextlib.suppress(Exception): | |
| await self.client(JoinChannelRequest(entity)) | |
| return self._tool_ok({"action": action, "chat": self._serialize_entity_brief(entity)}) | |
| if action == "leave_chat": | |
| await self.client(LeaveChannelRequest(target_entity)) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None)}) | |
| if action == "invite_user_to_chat": | |
| user = await self._resolve_target_user(target_entity, raw, req_message) | |
| await self.client(InviteToChannelRequest(target_entity, [user])) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "user_id": user.id}) | |
| if action == "set_chat_title": | |
| title = str(raw.get("title") or raw.get("text") or "").strip() | |
| if not title: | |
| return self._tool_err("missing title") | |
| await self.client.edit_title(target_entity, title) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "title": title}) | |
| if action == "set_chat_about": | |
| try: | |
| from telethon.tl.functions.messages import EditChatAboutRequest | |
| about = str(raw.get("about") or raw.get("text") or "").strip() | |
| if not about: | |
| return self._tool_err("missing about") | |
| await self.client(EditChatAboutRequest(peer=target_entity, about=about)) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "about": about}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "set_chat_photo": | |
| try: | |
| from telethon.tl.functions.channels import EditPhotoRequest | |
| from telethon.tl.functions.messages import EditChatPhotoRequest | |
| from telethon.tl.types import InputChatUploadedPhoto | |
| path = str(raw.get("path") or raw.get("file") or "").strip() | |
| if path: | |
| with contextlib.suppress(Exception): | |
| resolved = self._resolve_os_path(path, default_name="artifact", chat_id=chat_id) | |
| if os.path.isfile(resolved): | |
| path = resolved | |
| if not path or not os.path.isfile(path): | |
| return self._tool_err("valid path is required") | |
| uploaded = await self.client.upload_file(path) | |
| photo = InputChatUploadedPhoto(file=uploaded) | |
| if isinstance(target_entity, Channel): | |
| await self.client(EditPhotoRequest(channel=target_entity, photo=photo)) | |
| else: | |
| await self.client(EditChatPhotoRequest(chat_id=getattr(target_entity, "id", None), photo=photo)) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "path": path}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "delete_chat_photo": | |
| try: | |
| from telethon.tl.functions.channels import EditPhotoRequest | |
| from telethon.tl.functions.messages import EditChatPhotoRequest | |
| from telethon.tl.types import InputPhotoEmpty | |
| if isinstance(target_entity, Channel): | |
| await self.client(EditPhotoRequest(channel=target_entity, photo=InputPhotoEmpty())) | |
| else: | |
| await self.client(EditChatPhotoRequest(chat_id=getattr(target_entity, "id", None), photo=InputPhotoEmpty())) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None)}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "export_chat_invite": | |
| try: | |
| from telethon.tl.functions.messages import ExportChatInviteRequest | |
| invite = await self.client(ExportChatInviteRequest(peer=target_entity)) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "link": invite}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "get_drafts": | |
| try: | |
| from telethon.tl.functions.messages import GetAllDraftsRequest | |
| drafts = await self.client(GetAllDraftsRequest()) | |
| items = [] | |
| for upd in getattr(drafts, "updates", [])[: self._normalize_limit(raw.get("limit", 20), default=20, maximum=100)]: | |
| draft = getattr(upd, "draft", None) | |
| peer = getattr(upd, "peer", None) | |
| items.append({"peer_id": get_peer_id(peer) if peer else None, "text": getattr(draft, "message", None)}) | |
| return self._tool_ok({"action": action, "count": len(items), "drafts": items}) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "set_draft": | |
| text = str(raw.get("text") or "").strip() | |
| await self.client(SaveDraftRequest(peer=target_entity, message=text, no_webpage=not self._tool_bool(raw.get("link_preview")))) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None), "text": text}) | |
| if action == "clear_draft": | |
| await self.client(SaveDraftRequest(peer=target_entity, message="", no_webpage=True)) | |
| return self._tool_ok({"action": action, "chat_id": getattr(target_entity, "id", None)}) | |
| async def _tool_file_ops(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: save_url_file, send_url_file, upload_to_hosting, mirror_to_hosting""" | |
| if action == "save_url_file": | |
| try: | |
| url = str(raw.get("url") or raw.get("source_url") or raw.get("file_url") or "").strip() | |
| if not url: | |
| return self._tool_err("missing url") | |
| item = await self._download_url_to_file( | |
| url, | |
| chat_id=chat_id, | |
| requested_path=raw.get("path") or raw.get("save_path") or raw.get("output_path"), | |
| fallback_name=str(raw.get("filename") or raw.get("name") or "").strip() or None, | |
| max_bytes=raw.get("max_bytes"), | |
| ) | |
| self._register_session_file(chat_id, item["path"], role="output", label=item["name"], source=action) | |
| return self._tool_ok({ | |
| "action": action, | |
| "url": url, | |
| "path": item["path"], | |
| "name": item["name"], | |
| "size": item["size"], | |
| "mime_type": item["mime_type"], | |
| }) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action == "send_url_file": | |
| try: | |
| url = str(raw.get("url") or raw.get("source_url") or raw.get("file_url") or "").strip() | |
| if not url: | |
| return self._tool_err("missing url") | |
| item = await self._download_url_to_file( | |
| url, | |
| chat_id=chat_id, | |
| requested_path=raw.get("path") or raw.get("save_path"), | |
| fallback_name=str(raw.get("filename") or raw.get("name") or "").strip() or None, | |
| max_bytes=raw.get("max_bytes"), | |
| ) | |
| self._register_session_file(chat_id, item["path"], role="output", label=item["name"], source=action) | |
| caption, parse_mode = await self._prepare_outbound_text(raw) | |
| sent = await self._send_file( | |
| target_entity, | |
| item["path"], | |
| caption=caption or None, | |
| parse_mode=parse_mode, | |
| reply_to=raw.get("reply_to") or raw.get("reply_to_message_id") or raw.get("message_id") or req_reply_id, | |
| force_document=self._tool_bool(raw.get("as_document")), | |
| supports_streaming=self._tool_bool(raw.get("supports_streaming")), | |
| voice_note=self._tool_bool(raw.get("voice_note")), | |
| video_note=self._tool_bool(raw.get("video_note")), | |
| nosound_video=self._tool_bool(raw.get("nosound_video")), | |
| ) | |
| items = sent if isinstance(sent, list) else [sent] | |
| return self._tool_ok({ | |
| "action": action, | |
| "url": url, | |
| "path": item["path"], | |
| "count": len(items), | |
| "messages": [self._serialize_message(entry) for entry in items if entry], | |
| }) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| if action in {"upload_to_hosting", "mirror_to_hosting"}: | |
| try: | |
| requested_save_path = raw.get("download_path") if action == "mirror_to_hosting" else raw.get("path") | |
| source_item = await self._resolve_transfer_source_to_file( | |
| chat_id, | |
| raw, | |
| req_message=req_message, | |
| req_reply_id=req_reply_id, | |
| requested_path=requested_save_path, | |
| ) | |
| source_path = source_item["path"] | |
| requested_hosts_raw = raw.get("hosts") or raw.get("host") or raw.get("hosting") or raw.get("service") or raw.get("target_host") | |
| hosts = self._normalize_hosting_targets(requested_hosts_raw or "0x0") | |
| request_text = "" | |
| if req_message is not None: | |
| request_text = str(getattr(req_message, "raw_text", None) or getattr(req_message, "message", None) or getattr(req_message, "text", None) or "") | |
| inferred_hosts = self._extract_requested_hostings(request_text) | |
| if inferred_hosts == ["all"] and (requested_hosts_raw in (None, "") or hosts == ["0x0"]): | |
| hosts = self._normalize_hosting_targets(inferred_hosts) | |
| elif len(inferred_hosts) > 1 and (requested_hosts_raw in (None, "") or hosts == ["0x0"]): | |
| hosts = self._normalize_hosting_targets(inferred_hosts) | |
| expires = raw.get("expires") or raw.get("expire") or raw.get("ttl") | |
| links = [] | |
| failures = [] | |
| for host_name in hosts: | |
| try: | |
| result = await self._upload_file_to_hosting( | |
| host_name, | |
| source_path, | |
| source_url=(source_item.get("source") or {}).get("url"), | |
| expires=expires, | |
| secret=self._tool_bool(raw.get("secret")), | |
| ) | |
| links.append(result) | |
| except Exception as host_error: | |
| failures.append({ | |
| "host": host_name, | |
| "error": str(host_error), | |
| }) | |
| if not links: | |
| return self._tool_err( | |
| "all hosting uploads failed", | |
| { | |
| "action": action, | |
| "source": source_item.get("source"), | |
| "source_path": source_path, | |
| "source_name": source_item.get("name"), | |
| "hosts_requested": hosts, | |
| "failures": failures, | |
| }, | |
| ) | |
| json_path = None | |
| if self._tool_bool(raw.get("save_json")) or raw.get("result_path") not in (None, ""): | |
| result_name = "hosting_links_{}.json".format(uuid.uuid4().hex[:8]) | |
| json_path = self._build_workspace_output_path( | |
| chat_id, | |
| raw.get("result_path"), | |
| result_name, | |
| subdir="exports", | |
| ) | |
| with open(json_path, "w", encoding="utf-8") as file_obj: | |
| json.dump({ | |
| "source": source_item, | |
| "links": links, | |
| }, file_obj, ensure_ascii=False, indent=2) | |
| self._register_session_file(chat_id, json_path, role="output", label=os.path.basename(json_path), source=action) | |
| sent_message = None | |
| if self._tool_bool(raw.get("send_links")): | |
| body = "\n".join( | |
| "• {} -> {}".format(item.get("host"), item.get("url")) | |
| for item in links | |
| if item.get("url") | |
| ) | |
| if body: | |
| sent_message = await self._send_message( | |
| target_entity, | |
| body, | |
| parse_mode=None, | |
| reply_to=raw.get("message_id") or req_reply_id, | |
| ) | |
| return self._tool_ok({ | |
| "action": action, | |
| "source": source_item.get("source"), | |
| "source_path": source_path, | |
| "source_name": source_item.get("name"), | |
| "size": source_item.get("size"), | |
| "mime_type": source_item.get("mime_type"), | |
| "hosts": links, | |
| "failures": failures, | |
| "links_count": len(links), | |
| "failed_count": len(failures), | |
| "result_path": json_path, | |
| "message": self._serialize_message(sent_message) if sent_message else None, | |
| }) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| async def _tool_utility(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: sleep, set_context, merge_objects, extract_field, pick_random_item, slice_items, sort_items, dedupe_items, count_items, coalesce_values, build_text, resolve_target""" | |
| if action == "sleep": | |
| seconds = max(0.0, min(float(raw.get("seconds") or raw.get("duration") or 1), 30.0)) | |
| await asyncio.sleep(seconds) | |
| return self._tool_ok({"action": action, "slept_seconds": seconds}) | |
| if action == "set_context": | |
| payload = raw.get("data") | |
| if payload is None: | |
| payload = {k: v for k, v in raw.items() if k not in {"action"}} | |
| return self._tool_ok({"action": action, "data": payload}) | |
| if action == "merge_objects": | |
| objects = raw.get("objects") | |
| if not isinstance(objects, list): | |
| objects = [raw.get("left"), raw.get("right")] | |
| merged = {} | |
| for item in objects: | |
| if isinstance(item, dict): | |
| merged.update(item) | |
| return self._tool_ok({"action": action, "object": merged}) | |
| if action == "extract_field": | |
| source = raw.get("source") | |
| path = raw.get("path") | |
| if path in (None, ""): | |
| return self._tool_err("missing path") | |
| return self._tool_ok({"action": action, "path": path, "value": self._tool_get_path_value(source, path)}) | |
| if action == "pick_random_item": | |
| source = raw.get("items") | |
| if not isinstance(source, list) or not source: | |
| return self._tool_err("items must be non-empty list") | |
| idx = random.randrange(len(source)) | |
| return self._tool_ok({"action": action, "index": idx, "item": source[idx]}) | |
| if action == "slice_items": | |
| items = raw.get("items") | |
| if not isinstance(items, list): | |
| return self._tool_err("items must be list") | |
| start = int(raw.get("start") or 0) | |
| end = raw.get("end") | |
| end = int(end) if end not in (None, "") else None | |
| step = int(raw.get("step") or 1) | |
| sliced = items[start:end:step] | |
| return self._tool_ok({"action": action, "count": len(sliced), "items": sliced}) | |
| if action == "sort_items": | |
| items = raw.get("items") | |
| if not isinstance(items, list): | |
| return self._tool_err("items must be list") | |
| reverse = self._tool_bool(raw.get("reverse")) | |
| path = raw.get("path") | |
| if path: | |
| sorted_items = sorted(items, key=lambda item: str(self._tool_get_path_value(item, path, "")), reverse=reverse) | |
| else: | |
| sorted_items = sorted(items, key=lambda item: str(item), reverse=reverse) | |
| return self._tool_ok({"action": action, "count": len(sorted_items), "items": sorted_items}) | |
| if action == "dedupe_items": | |
| items = raw.get("items") | |
| if not isinstance(items, list): | |
| return self._tool_err("items must be list") | |
| path = raw.get("path") | |
| seen = set() | |
| out = [] | |
| for item in items: | |
| key = self._tool_get_path_value(item, path) if path else item | |
| marker = json.dumps(key, ensure_ascii=False, sort_keys=True) if isinstance(key, (dict, list)) else str(key) | |
| if marker in seen: | |
| continue | |
| seen.add(marker) | |
| out.append(item) | |
| return self._tool_ok({"action": action, "count": len(out), "items": out}) | |
| if action == "count_items": | |
| items = raw.get("items") | |
| count = len(items) if isinstance(items, list) else 0 | |
| return self._tool_ok({"action": action, "count": count}) | |
| if action == "coalesce_values": | |
| values = raw.get("values") | |
| if not isinstance(values, list): | |
| values = [raw.get("value"), raw.get("fallback"), raw.get("default")] | |
| chosen = next((item for item in values if item not in (None, "", [], {})), None) | |
| return self._tool_ok({"action": action, "value": chosen}) | |
| if action == "build_text": | |
| pieces = raw.get("parts") | |
| if not isinstance(pieces, list): | |
| pieces = [raw.get("prefix"), raw.get("text"), raw.get("suffix")] | |
| separator = str(raw.get("separator") or "") | |
| text = separator.join([str(item) for item in pieces if item not in (None, "")]) | |
| return self._tool_ok({"action": action, "text": text}) | |
| if action == "resolve_target": | |
| target = raw.get("target") or raw.get("target_chat") or raw.get("query") or raw.get("user") | |
| entity = await self._resolve_target_entity(target, chat_id) | |
| return self._tool_ok({"action": action, "entity": self._serialize_entity_brief(entity)}) | |
| async def _tool_advanced(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: smart_flow, batch_actions""" | |
| if action == "smart_flow": | |
| flow = raw.get("flow") if isinstance(raw.get("flow"), dict) else raw | |
| steps = flow.get("steps") | |
| if isinstance(steps, list) and steps: | |
| if len(steps) > 100: | |
| return self._tool_err("smart_flow: too many steps (max 100)") | |
| context = {"input": dict(flow), "chat_id": chat_id, "results": {}} | |
| trace = [] | |
| def ctx_get(path, default=None): | |
| cur = context | |
| for part in str(path or "").split("."): | |
| if not part: | |
| continue | |
| if isinstance(cur, dict): | |
| cur = cur.get(part) | |
| elif isinstance(cur, list) and part.isdigit(): | |
| idx = int(part) | |
| cur = cur[idx] if 0 <= idx < len(cur) else default | |
| else: | |
| return default | |
| return cur if cur is not None else default | |
| def render_templates(value): | |
| if isinstance(value, str): | |
| def sub(match): | |
| ref = (match.group(1) or "").strip() | |
| resolved = ctx_get(ref, "") | |
| if isinstance(resolved, (dict, list)): | |
| return json.dumps(resolved, ensure_ascii=False) | |
| return str(resolved) | |
| return re.sub(r"\{\{\s*([^}]+)\s*\}\}", sub, value) | |
| if isinstance(value, dict): | |
| return {k: render_templates(v) for k, v in value.items()} | |
| if isinstance(value, list): | |
| return [render_templates(v) for v in value] | |
| return value | |
| def check_condition(cond): | |
| if not isinstance(cond, dict): | |
| return True | |
| left = ctx_get(cond.get("path")) | |
| if "exists" in cond: | |
| return (left is not None) == bool(cond.get("exists")) | |
| if "eq" in cond: | |
| return str(left) == str(render_templates(cond.get("eq"))) | |
| if "ne" in cond: | |
| return str(left) != str(render_templates(cond.get("ne"))) | |
| if "contains" in cond: | |
| return str(render_templates(cond.get("contains"))).lower() in str(left).lower() | |
| return True | |
| async def run_step(payload): | |
| if not isinstance(payload, dict): | |
| return {"status": "error", "error": "step must be object"} | |
| if str(payload.get("action") or "").strip().lower() == "smart_flow": | |
| return {"status": "error", "error": "nested smart_flow is not allowed"} | |
| raw_result = await self._execute_telegram_tool(chat_id, payload) | |
| with contextlib.suppress(Exception): | |
| parsed = json.loads(raw_result) | |
| if isinstance(parsed, dict): | |
| return parsed | |
| return {"status": "error", "error": raw_result[:500]} | |
| for idx, step in enumerate(steps, start=1): | |
| if not check_condition(step.get("if")): | |
| trace.append({"step": idx, "status": "skipped", "reason": "condition_failed"}) | |
| continue | |
| save_as = str(step.get("save_as") or step.get("var") or "").strip() | |
| foreach = step.get("foreach") | |
| if foreach: | |
| items = ctx_get(foreach, []) | |
| if not isinstance(items, list): | |
| trace.append({"step": idx, "status": "error", "error": "foreach path is not a list"}) | |
| break | |
| loop_results = [] | |
| template = step.get("do") or {} | |
| for loop_idx, item in enumerate(items[:50]): | |
| context["_item"] = item | |
| context["_index"] = loop_idx | |
| payload = render_templates(template) | |
| if not payload.get("action") and step.get("action"): | |
| payload["action"] = step.get("action") | |
| loop_results.append(await run_step(payload)) | |
| context.pop("_item", None) | |
| context.pop("_index", None) | |
| if save_as: | |
| context["results"][save_as] = loop_results | |
| trace.append({"step": idx, "status": "success", "foreach": len(loop_results), "save_as": save_as or None}) | |
| continue | |
| payload = render_templates({k: v for k, v in step.items() if k not in {"if", "save_as", "var", "foreach", "do"}}) | |
| if "action" not in payload and flow.get("default_action"): | |
| payload["action"] = flow.get("default_action") | |
| result = await run_step(payload) | |
| if save_as: | |
| context["results"][save_as] = result | |
| trace.append({"step": idx, "status": result.get("status", "unknown"), "action": payload.get("action"), "save_as": save_as or None, "error": result.get("error")}) | |
| if result.get("status") == "error" and not bool(step.get("continue_on_error", flow.get("continue_on_error", False))): | |
| break | |
| return self._tool_ok({"action": "smart_flow", "steps_total": len(steps), "trace": trace, "results": context.get("results", {})}) | |
| return self._tool_err("smart_flow requires steps") | |
| if action == "batch_actions": | |
| actions = raw.get("actions") | |
| if not isinstance(actions, list) or not actions: | |
| return self._tool_err("missing actions list") | |
| if len(actions) > 100: | |
| return self._tool_err("too many actions; maximum is 100") | |
| blocked = {"read_history", "get_dialogs", "find_and_send_message", "batch_actions"} | |
| parallel = bool(raw.get("parallel")) | |
| continue_on_error = bool(raw.get("continue_on_error", True)) | |
| retries = self._normalize_limit(raw.get("retries", 0), default=0, maximum=6) - 1 | |
| concurrency = self._normalize_limit(raw.get("concurrency", 3), default=3, maximum=20) if parallel else 1 | |
| semaphore = asyncio.Semaphore(concurrency) | |
| results = [None] * len(actions) | |
| stop_after_error = {"value": False} | |
| async def execute_one(idx, payload): | |
| if not isinstance(payload, dict): | |
| return {"index": idx, "status": "error", "error": "action item must be object"} | |
| one_action = aliases.get(str(payload.get("action") or "").strip().lower(), str(payload.get("action") or "").strip().lower()) | |
| if one_action in blocked: | |
| return {"index": idx, "status": "error", "error": "action not allowed in batch: {}".format(one_action)} | |
| prepared = dict(payload) | |
| prepared["action"] = one_action | |
| if "target_chat" not in prepared and raw.get("target_chat") not in (None, ""): | |
| prepared["target_chat"] = raw.get("target_chat") | |
| attempt = 0 | |
| last_result = None | |
| while attempt <= retries: | |
| result_raw = await self._execute_telegram_tool(chat_id, prepared) | |
| with contextlib.suppress(Exception): | |
| parsed = json.loads(result_raw) | |
| if isinstance(parsed, dict): | |
| parsed["index"] = idx | |
| parsed["attempt"] = attempt + 1 | |
| if parsed.get("status") == "success" or attempt >= retries: | |
| return parsed | |
| last_result = result_raw | |
| attempt += 1 | |
| return {"index": idx, "status": "error", "error": last_result or "unknown batch error"} | |
| async def runner(idx, payload): | |
| async with semaphore: | |
| if stop_after_error["value"]: | |
| return | |
| result = await execute_one(idx, payload) | |
| results[idx - 1] = result | |
| if not continue_on_error and isinstance(result, dict) and result.get("status") == "error": | |
| stop_after_error["value"] = True | |
| await asyncio.gather(*[runner(idx, payload) for idx, payload in enumerate(actions, start=1)]) | |
| filtered = [item for item in results if isinstance(item, dict)] | |
| ok_count = len([item for item in filtered if item.get("status") == "success"]) | |
| return self._tool_ok({"action": "batch_actions", "count": len(filtered), "parallel": parallel, "concurrency": concurrency, "retries": max(0, retries), "success": ok_count, "errors": max(0, len(filtered) - ok_count), "results": filtered}) | |
| async def _tool_extended(self, action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity): | |
| """Handler for: send_contact, send_location, send_dice, send_poll, get_chat_member, get_top_peers, get_sticker_set, get_invite_links, create_invite_link, revoke_invite_link, unpin_all_messages, set_slow_mode, get_bot_commands, set_bot_commands""" | |
| return self._tool_err("{}: handler not yet migrated".format(action)) | |
| async def _execute_telegram_tool(self, chat_id: int, tool_payload) -> str: | |
| if not self.config["allow_tg_tools"]: | |
| return self._tool_err("telegram tools disabled") | |
| session = self._get_request_session(chat_id) | |
| depth = int(session.get("_tool_call_depth") or 0) | |
| if depth <= 0: | |
| session["tool_visual_context"] = [] | |
| session["_tool_call_depth"] = 1 | |
| else: | |
| session["_tool_call_depth"] = depth + 1 | |
| try: | |
| raw = tool_payload if isinstance(tool_payload, dict) else self._extract_json_object(tool_payload) | |
| if not isinstance(raw, dict): | |
| return self._tool_err("tool payload must be a JSON object") | |
| if isinstance(raw.get("arguments"), dict) and str(raw.get("tool_call") or "").strip().lower() == "execute_telegram_action": | |
| raw = raw["arguments"] | |
| aliases = self._tool_action_aliases() | |
| action = str(raw.get("action") or "").strip().lower() | |
| action = aliases.get(action, action) | |
| if not action: | |
| return self._tool_err("missing action") | |
| if action == "telegram_tool": | |
| nested = raw.get("telegram_tool") or raw.get("arguments") or {} | |
| if not isinstance(nested, dict): | |
| return self._tool_err("telegram_tool wrapper missing nested action") | |
| raw = dict(nested) | |
| action = aliases.get(str(raw.get("action") or "").strip().lower(), "") | |
| if not action: | |
| return self._tool_err("telegram_tool wrapper missing nested action") | |
| used = int(session.get("tool_actions_count") or 0) | |
| budget = int(self.config["tool_action_budget"] or 12) | |
| if used >= budget: | |
| return self._tool_err("tool action budget exceeded: {}/{}".format(used, budget)) | |
| session["tool_actions_count"] = used + 1 | |
| destructive_actions = { | |
| "ban_user", "kick_user", "mute_user", "delete_messages", "delete_last_message", | |
| "delete_user_messages", "purge_chat_messages", "block_user", "clear_dialog", | |
| "delete_dialog", "delete_contact", "delete_profile_photos", "delete_chat_photo", | |
| "set_chat_photo", "set_profile_photo", "set_random_profile_photo", "cycle_profile_photos", | |
| } | |
| if self.config["tool_destructive_guard"] and action in destructive_actions and not self._tool_bool(raw.get("confirm")): | |
| return self._tool_err("destructive action '{}' requires confirm=true".format(action)) | |
| if action not in self._tool_action_names(): | |
| return self._tool_err("unsupported action: {}".format(action)) | |
| req_message = session.get("message_obj") | |
| req_reply_id = session.get("reply_message_id") | |
| current_entity = await self._resolve_target_entity(chat_id, chat_id) | |
| target_chat = raw.get("target_chat") | |
| if target_chat in (None, "") and action not in {"join_chat", "resolve_target"}: | |
| target_chat = raw.get("chat_id") | |
| target_entity = await self._resolve_target_entity(target_chat, chat_id if target_chat in (None, "") else None) | |
| handler_name = self._TG_TOOL_HANDLERS.get(action) | |
| if handler_name is not None: | |
| handler = getattr(self, handler_name) | |
| return await handler(action, raw, target_entity, chat_id, session, req_message, req_reply_id, current_entity) | |
| return self._tool_err("action is declared but not implemented: {}".format(action)) | |
| except FloodWaitError as e: | |
| return self._tool_err("FloodWait {}".format(getattr(e, "seconds", "?"))) | |
| except Exception as e: | |
| return self._tool_err(str(e)) | |
| finally: | |
| session["_tool_call_depth"] = max(0, int(session.get("_tool_call_depth") or 1) - 1) | |
| async def _maybe_run_telegram_tool_cycle_openrouter(self, *, chat_id, openai_messages, target_model, temperature, initial_text, status_msg=None): | |
| result_text = initial_text | |
| max_steps = int(self.config["tool_action_budget"] or 12) + int(self.config["os_tool_action_budget"] or 8) | |
| for step in range(1, max_steps + 1): | |
| tool_info = self._extract_function_tool_call_info(result_text) | |
| if not tool_info: | |
| return result_text | |
| if status_msg: | |
| with contextlib.suppress(Exception): | |
| await self._edit(status_msg, self.strings["tg_tool_exec"].format(utils.escape_html(str(tool_info["arguments"].get("action") or "?")), step, max_steps)) | |
| tool_name, tool_result = await self._execute_model_tool(chat_id, tool_info) | |
| openai_messages.append({"role": "assistant", "content": json.dumps({"tool_call": tool_name, "arguments": tool_info["arguments"]}, ensure_ascii=False)}) | |
| openai_messages.append({"role": "user", "content": self._build_openrouter_tool_result_content(chat_id, tool_result, tool_name=tool_name)}) | |
| result_text = await self._send_to_Openrouter_api(target_model, openai_messages, temperature) | |
| result_text = (result_text or "").strip() | |
| result_text = re.sub(r"^\[System Info:.*?\]\s*", "", result_text, flags=re.IGNORECASE) | |
| return result_text | |
| async def _maybe_run_telegram_tool_cycle_google(self, *, chat_id, contents, gen_config, target_model, api_key, proxy_config, initial_text, status_msg=None): | |
| result_text = initial_text | |
| max_steps = int(self.config["tool_action_budget"] or 12) + int(self.config["os_tool_action_budget"] or 8) | |
| http_opts = types.HttpOptions(async_client_args={"proxies": proxy_config}) if proxy_config else None | |
| client = genai.Client(api_key=api_key, http_options=http_opts) | |
| for step in range(1, max_steps + 1): | |
| tool_info = self._extract_function_tool_call_info(result_text) | |
| if not tool_info: | |
| return result_text | |
| if status_msg: | |
| with contextlib.suppress(Exception): | |
| await self._edit(status_msg, self.strings["tg_tool_exec"].format(utils.escape_html(str(tool_info["arguments"].get("action") or "?")), step, max_steps)) | |
| tool_name, tool_result = await self._execute_model_tool(chat_id, tool_info) | |
| contents.append(types.Content(role="model", parts=[types.Part(text=json.dumps({"tool_call": tool_name, "arguments": tool_info["arguments"]}, ensure_ascii=False))])) | |
| contents.append(types.Content(role="user", parts=self._build_google_tool_result_parts(chat_id, tool_result, tool_name=tool_name))) | |
| response = await client.aio.models.generate_content(model=target_model, contents=contents, config=gen_config) | |
| result_text = (response.text or "").strip() | |
| result_text = re.sub(r"^\[System Info:.*?\]\s*", "", result_text, flags=re.IGNORECASE) | |
| return result_text | |
| async def _maybe_run_telegram_tool_cycle_provider(self, *, chat_id, provider, messages, target_model, temperature, initial_text, status_msg=None): | |
| result_text = initial_text | |
| max_steps = int(self.config["tool_action_budget"] or 12) + int(self.config["os_tool_action_budget"] or 8) | |
| prompt_messages = list(messages or []) | |
| for step in range(1, max_steps + 1): | |
| tool_info = self._extract_function_tool_call_info(result_text) | |
| if not tool_info: | |
| return result_text | |
| if status_msg: | |
| with contextlib.suppress(Exception): | |
| await self._edit(status_msg, self.strings["tg_tool_exec"].format(utils.escape_html(str(tool_info["arguments"].get("action") or "?")), step, max_steps)) | |
| tool_name, tool_result = await self._execute_model_tool(chat_id, tool_info) | |
| prompt_messages.append({"role": "assistant", "content": json.dumps({"tool_call": tool_name, "arguments": tool_info["arguments"]}, ensure_ascii=False)}) | |
| prompt_messages.append({"role": "user", "content": self._build_openrouter_tool_result_content(chat_id, tool_result, tool_name=tool_name)}) | |
| result_text = await self._send_to_provider_api(provider, target_model, prompt_messages, temperature) | |
| result_text = re.sub(r"^\[System Info:.*?\]\s*", "", str(result_text or "").strip(), flags=re.IGNORECASE) | |
| return result_text | |
| def _is_memory_enabled(self, chat_id: str) -> bool: return chat_id not in self.memory_disabled_chats | |
| def _disable_memory(self, chat_id: int): self.memory_disabled_chats.add(str(chat_id)) | |
| def _enable_memory(self, chat_id: int): self.memory_disabled_chats.discard(str(chat_id)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment