Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save sepiol026-wq/bf6ed8fd7f0723b8b8479a82ce4ab7b4 to your computer and use it in GitHub Desktop.

Select an option

Save sepiol026-wq/bf6ed8fd7f0723b8b8479a82ce4ab7b4 to your computer and use it in GitHub Desktop.
AetherAI: rich status, zero-setup search (6 engines), cache, memory, git, shell, code, scheduler, sub-agents, image analysis, 582 TL methods
# 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=5256079005731271025>📟</emoji> <b>{}</b> · <code>{}</code> · <code>{}с</code>",
"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": "<emoji document_id=5256079005731271025>📟</emoji> <b>{}</b> · <code>{}</code> · 🔧 <code>{}</code> <i>({}/{})</i> · <code>{}с</code>",
"tg_tool_tokens": "💳 in <code>{}</code> / out <code>{}</code> / total <code>{}</code>",
"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")
self._req_provider = self._provider_label(provider_name); self._req_model = target_model or "?"
if status_msg:
with contextlib.suppress(Exception):
await self._edit(status_msg, self.strings["processing"].format(self._req_provider, self._req_model, int(time.time() - self._req_start)))
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()
try:
um = response.usage_metadata
self._req_tokens_in = um.prompt_token_count
self._req_tokens_out = um.candidates_token_count
except Exception:
pass
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"].format("...", "...", 0))
self._req_start = time.time(); self._req_provider = "..."; self._req_model = "..."; self._req_tokens_in = 0; self._req_tokens_out = 0
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(self._req_provider or "?", self._req_model or "?", utils.escape_html(str(tool_info["arguments"].get("action") or "?")), step, max_steps, int(time.time() - self._req_start)))
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(self._req_provider or "?", self._req_model or "?", utils.escape_html(str(tool_info["arguments"].get("action") or "?")), step, max_steps, int(time.time() - self._req_start)))
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(self._req_provider or "?", self._req_model or "?", utils.escape_html(str(tool_info["arguments"].get("action") or "?")), step, max_steps, int(time.time() - self._req_start)))
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