Files
chat-summaries/references/message_analyzer.plugin
T
2026-09-10 12:47:54 +02:00

1068 lines
47 KiB
Plaintext

"""
_____ _____
( ___ )-----------------------------------------------------------( ___ )
| | | |
| | _ _ _ _ ___ _ _ _ ____ _ _ ____ ___ _ _ | |
| | | | | | | |__| | | | | | |___ |__] \_/ | |
| | |_|_| | | | | |___ |__| \/ |___ |__] | | |
| | | |
| | _ _ _ _ _ ____ _ _ _ _ ____ ___ ____ _ _ ____ _ _ _ | |
| | |\/| | |__| |__| | | |_/ | | | | | | | [__ |_/ | | |
| | | | | | | | | | |___ | \_ |__| | |__| \/ ___] | \_ | | |
|___| |___|
(_____)-----------------------------------------------------------(_____)
"""
""" Все права защищены. Любое копирование кода запрещено. Имейте уважение к автору. """
import os
import time
import json
import requests
import threading
import traceback
from typing import Any, Dict, Optional, List
from datetime import datetime
from base_plugin import BasePlugin, HookResult, HookStrategy, MenuItemData, MenuItemType
from client_utils import (
get_messages_controller, run_on_queue, send_message, get_last_fragment,
get_user_config, send_request, RequestCallback, get_connections_manager
)
from markdown_utils import parse_markdown
from ui.settings import Header, Input, Divider, Switch, Selector, Text
from ui.bulletin import BulletinHelper
from ui.alert import AlertDialogBuilder
from android_utils import run_on_ui_thread, log
from java.util import Locale
from org.telegram.tgnet import TLRPC
from org.telegram.messenger import MessageObject, UserObject, ChatObject
__id__ = "MessageAnalyzer"
__name__ = "Message Analyzer"
__description__ = "Анализирует последние сообщения пользователей (без ограничений) и создает сводки с помощью Gemini AI [.analyze, .summary, .report]"
__author__ = "@mihailkotovski & @mishabotov"
__version__ = "1.0.0 [beta]"
__min_version__ = "11.12.1"
__icon__ = "DateRegBot_by_MoiStikiBot/9"
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models/"
MODEL_DISPLAY_NAMES = [
"Gemini 2.5 Pro",
"Gemini 2.5 Flash",
"Gemini 2.5 Flash Lite"
]
MODEL_API_NAMES = [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite-preview-06-17"
]
DEFAULT_ANALYSIS_PROMPT = """Ты - аналитик сообщений в Telegram. Проанализируй следующие сообщения и создай краткую сводку.
Инструкции:
1. Определи основные темы обсуждения
2. Выдели ключевые моменты и важную информацию
3. Отметь настроение и тон общения
4. Укажи активных участников
5. Создай краткое резюме (не более 200 слов)
ВАЖНО: Используй только простой markdown без сложных конструкций. Используй **жирный текст** для заголовков и обычный текст для содержимого.
Формат ответа:
📊 **Анализ сообщений**
🔍 **Основные темы:**
- тема 1
- тема 2
💬 **Ключевые моменты:**
- момент 1
- момент 2
😊 **Настроение:** описание
👥 **Активные участники:** список
📝 **Резюме:**
краткое резюме
Сообщения для анализа:
{messages}
"""
DEFAULT_SUMMARY_PROMPT = """Создай очень краткую сводку (максимум 100 слов) следующих сообщений.
ВАЖНО: Используй только простой markdown. Используй **жирный текст** для заголовка и обычный текст для содержимого.
Формат ответа:
**Краткая сводка:**
сводка в 2-3 предложениях
Сообщения для анализа:
{messages}
"""
IRONIC_REPORT_PROMPT = """Ты - ироничный хроникер чатов, мастер сарказма и тонкого троллинга. Твоя задача - создать язвительный отчет о происходящем в чате в стиле "светской хроники", где каждый участник получает свое ироничное прозвище и характеристику.
СТИЛЬ НАПИСАНИЯ:
- Максимальный сарказм и ирония
- Каждый участник получает ироничное прозвище ("наш местный гений", "эксперт по всему", "вечно недопонятый")
- Обычные события подаются как эпические драмы
- Используй фразы типа "видимо", "похоже", "наш", "местный", "вечно"
- Высмеивай глупость, но остроумно и изящно
СТРУКТУРА:
Каждый абзац начинается с # и описывает одну ситуацию/конфликт/момент из чата.
ТРЕБОВАНИЯ:
- Не используй реальные имена, только ироничные прозвища
- Высмеивай ситуации, но не переходи на личности
- Будь остроумным, но не злобным
- Максимум 8-10 абзацев
- Каждый абзац - законченная ироничная зарисовка
ПРИМЕР СТИЛЯ:
"# Наш вечно недопонятый гений снова ляпнул что-то революционное, но вместо овации получил лишь коллективное недоумение от местных экспертов по всему на свете."
Сообщения для анализа:
{messages}
"""
class LocalizationManager:
strings = {
"ru": {
"SETTINGS_HEADER": "Настройки Message Analyzer",
"API_KEY_INPUT": "API Key",
"API_KEY_SUBTEXT": "Получите ключ в Google AI Studio",
"GET_API_KEY_BUTTON": "Получить API ключ",
"MODEL_SELECTOR": "Модель Gemini",
"ENABLE_SWITCH": "Включить анализатор",
"MESSAGE_COUNT_INPUT": "Количество сообщений",
"MESSAGE_COUNT_SUBTEXT": "Сколько последних сообщений анализировать (от 50). Больше сообщений = более точный анализ, но дольше обработка.",
"MAX_MESSAGE_LIMIT_INPUT": "Лимит сообщений",
"MAX_MESSAGE_LIMIT_SUBTEXT": "Максимальное количество сообщений для анализа (без ограничений). Ограничивает команды .analyze и .summary.",
"ANALYSIS_PROMPT_INPUT": "Промпт для анализа",
"SUMMARY_PROMPT_INPUT": "Промпт для сводки",
"REPORT_PROMPT_INPUT": "Промпт для отчета",
"TEMPERATURE_INPUT": "Температура",
"TEMPERATURE_SUBTEXT": "0.0-2.0. Контролирует креативность ответа",
"MAX_TOKENS_INPUT": "Максимум токенов",
"MAX_TOKENS_SUBTEXT": "Максимальная длина ответа",
"AUTO_BLOCKQUOTE_TITLE": "Автоматические цитаты",
"AUTO_BLOCKQUOTE_SUBTEXT": "Автоматически сворачивать длинные результаты анализа в цитаты",
"API_KEY_MISSING": "❌ API ключ Gemini не найден. Укажите его в настройках.",
"ANALYZING_MESSAGE": "🔍 Анализирую сообщения...",
"API_ERROR": "⚠️ Ошибка Gemini API: {error}",
"NO_MESSAGES": "❌ Не найдено сообщений для анализа.",
"UNEXPECTED_ERROR": "❗ Произошла ошибка: {error}",
"USAGE_INFO_TITLE": "Как использовать",
"USAGE_INFO_TEXT": (
"Команды плагина:\n\n"
".analyze - Подробный анализ последних сообщений\n"
".summary - Краткая сводка сообщений\n"
".report - Ироничный отчет в стиле 'хроники чата'\n"
".analyze 5000 - Анализ определенного количества сообщений (от 50)\n\n"
"Плагин анализирует сообщения в текущем чате и создает сводку с помощью Gemini AI."
)
},
"en": {
"SETTINGS_HEADER": "Message Analyzer Settings",
"API_KEY_INPUT": "API Key",
"API_KEY_SUBTEXT": "Get your key from Google AI Studio",
"GET_API_KEY_BUTTON": "Get API Key",
"MODEL_SELECTOR": "Gemini Model",
"ENABLE_SWITCH": "Enable Analyzer",
"MESSAGE_COUNT_INPUT": "Message Count",
"MESSAGE_COUNT_SUBTEXT": "How many recent messages to analyze (from 50). More messages = better analysis, but longer processing.",
"MAX_MESSAGE_LIMIT_INPUT": "Message Limit",
"MAX_MESSAGE_LIMIT_SUBTEXT": "Maximum number of messages for analysis (no limits). Limits .analyze and .summary commands.",
"ANALYSIS_PROMPT_INPUT": "Analysis Prompt",
"SUMMARY_PROMPT_INPUT": "Summary Prompt",
"REPORT_PROMPT_INPUT": "Report Prompt",
"TEMPERATURE_INPUT": "Temperature",
"TEMPERATURE_SUBTEXT": "0.0-2.0. Controls response creativity",
"MAX_TOKENS_INPUT": "Max Tokens",
"MAX_TOKENS_SUBTEXT": "Maximum response length",
"AUTO_BLOCKQUOTE_TITLE": "Auto Blockquotes",
"AUTO_BLOCKQUOTE_SUBTEXT": "Automatically collapse long analysis results into blockquotes",
"API_KEY_MISSING": "❌ Gemini API key not found. Set it in settings.",
"ANALYZING_MESSAGE": "🔍 Analyzing messages...",
"API_ERROR": "⚠️ Gemini API Error: {error}",
"NO_MESSAGES": "❌ No messages found for analysis.",
"UNEXPECTED_ERROR": "❗ An error occurred: {error}",
"USAGE_INFO_TITLE": "How to use",
"USAGE_INFO_TEXT": (
"Plugin commands:\n\n"
".analyze - Detailed analysis of recent messages\n"
".summary - Brief summary of messages\n"
".report - Ironic report in 'chat chronicles' style\n"
".analyze 5000 - Analyze specific number of messages (from 50)\n\n"
"The plugin analyzes messages in current chat and creates summary using Gemini AI."
)
}
}
def __init__(self):
self.language = Locale.getDefault().getLanguage()
self.language = self.language if self.language in self.strings else "en"
def get_string(self, key: str, **kwargs) -> str:
string = self.strings[self.language].get(key, self.strings["en"].get(key, key))
if kwargs:
try:
return string.format(**kwargs)
except (KeyError, ValueError):
return string
return string
locali = LocalizationManager()
class GeminiAPIHandler:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
"Content-Type": "application/json",
"User-Agent": f"ExteraPlugin/{__id__}/{__version__}"
})
def analyze_messages(self, api_key: str, model_name: str, prompt: str, temperature: float, max_tokens: int) -> Dict[str, Any]:
url = f"{GEMINI_BASE_URL}{model_name}:generateContent?key={api_key}"
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"temperature": temperature,
"maxOutputTokens": max_tokens,
}
}
prompt_size = len(prompt.encode('utf-8'))
log(f"Sending request to Gemini API: {prompt_size} bytes, model: {model_name}")
try:
response = self.session.post(url, json=payload, timeout=90)
response.raise_for_status()
data = response.json()
log(f"Gemini API response keys: {list(data.keys())}")
if "candidates" not in data:
log(f"No 'candidates' in response: {data}")
error_msg = data.get("error", {}).get("message", "No candidates in API response")
return {"success": False, "error": f"API Error: {error_msg}"}
candidates = data["candidates"]
if not candidates or len(candidates) == 0:
log(f"Empty candidates array: {data}")
return {"success": False, "error": "Empty candidates array in API response"}
first_candidate = candidates[0]
log(f"First candidate keys: {list(first_candidate.keys())}")
finish_reason = first_candidate.get("finishReason", "")
if finish_reason:
log(f"Finish reason: {finish_reason}")
if finish_reason == "SAFETY":
return {"success": False, "error": "Content blocked by safety filters"}
elif finish_reason == "MAX_TOKENS":
return {"success": False, "error": "Response truncated due to token limit"}
elif finish_reason not in ["STOP", ""]:
return {"success": False, "error": f"Generation stopped: {finish_reason}"}
content = first_candidate.get("content", {})
if not content:
log(f"No content in first candidate: {first_candidate}")
return {"success": False, "error": "No content in API response"}
parts = content.get("parts", [])
if not parts or len(parts) == 0:
log(f"No parts in content: {content}")
return {"success": False, "error": "No parts in content"}
text = parts[0].get("text", "")
if not text or not text.strip():
log(f"Empty text in first part: {parts[0]}")
return {"success": False, "error": "Empty text in API response"}
log(f"Successfully received {len(text)} characters from Gemini API")
return {"success": True, "text": text}
except requests.exceptions.HTTPError as e:
error_text = f"HTTP {e.response.status_code}"
try:
error_json = e.response.json()
log(f"HTTP Error response: {error_json}")
error_text += f": {error_json.get('error',{}).get('message', e.response.text)}"
except:
error_text += f": {e.response.text}"
return {"success": False, "error": error_text}
except requests.exceptions.RequestException as e:
log(f"Network error: {str(e)}")
return {"success": False, "error": f"Network error: {str(e)}"}
except Exception as e:
log(f"Unexpected error in analyze_messages: {str(e)}")
return {"success": False, "error": f"Unexpected error: {str(e)}"}
class MessageAnalyzerPlugin(BasePlugin):
def __init__(self):
super().__init__()
self.api_handler = GeminiAPIHandler()
self.progress_dialog: Optional[AlertDialogBuilder] = None
def on_plugin_load(self):
self.add_on_send_message_hook()
self.log("Message Analyzer plugin loaded")
def on_plugin_unload(self):
if self.progress_dialog:
run_on_ui_thread(lambda: self.progress_dialog.dismiss())
self.log("Message Analyzer plugin unloaded")
def _show_error_bulletin(self, key: str, **kwargs):
message = locali.get_string(key).format(**kwargs)
run_on_ui_thread(lambda: BulletinHelper.show_error(message))
def _get_current_dialog_id(self) -> Optional[int]:
try:
fragment = get_last_fragment()
if fragment and hasattr(fragment, 'getDialogId'):
return fragment.getDialogId()
elif fragment and hasattr(fragment, 'dialog_id'):
return getattr(fragment, 'dialog_id')
return None
except Exception as e:
self.log(f"Error getting dialog ID: {e}")
return None
def _get_topic_id_from_fragment(self) -> int:
try:
fragment = get_last_fragment()
if fragment and hasattr(fragment, 'threadMessageId'):
return getattr(fragment, 'threadMessageId', 0)
return 0
except Exception as e:
self.log(f"Error getting topic ID: {e}")
return 0
def _fetch_message_history(self, dialog_id: int, limit: int, callback):
try:
self.log(f"Starting to fetch {limit} messages")
self._fetch_messages_paginated(dialog_id, limit, 0, [], {}, {}, callback)
except Exception as e:
self.log(f"Error in _fetch_message_history: {e}")
callback(None, f"Ошибка: {str(e)}")
def _fetch_messages_paginated(self, dialog_id: int, total_limit: int, offset_id: int,
accumulated_messages: List, users: Dict, chats: Dict, callback):
try:
remaining = total_limit - len(accumulated_messages)
if remaining <= 0:
self.log(f"Reached target limit, returning {len(accumulated_messages)} messages")
callback(accumulated_messages, None)
return
current_limit = min(100, remaining)
req = TLRPC.TL_messages_getHistory()
req.peer = get_messages_controller().getInputPeer(dialog_id)
req.offset_id = offset_id
req.limit = current_limit
req.add_offset = 0
req.max_id = 0
req.min_id = 0
req.hash = 0
def handle_response(response, error):
try:
if error:
error_msg = error.text if hasattr(error, 'text') else str(error)
self.log(f"Error fetching messages: {error_msg}")
if accumulated_messages:
callback(accumulated_messages, None)
else:
callback(None, f"Ошибка получения сообщений: {error_msg}")
return
if not response or not hasattr(response, 'messages'):
if accumulated_messages:
callback(accumulated_messages, None)
else:
callback(None, "Пустой ответ от сервера")
return
messages_count = response.messages.size()
self.log(f"Received {messages_count} messages in this batch (offset_id: {offset_id})")
if messages_count == 0:
self.log(f"No more messages available, returning {len(accumulated_messages)} messages (requested: {total_limit})")
callback(accumulated_messages, None)
return
if hasattr(response, 'users') and response.users and response.users.size() > 0:
for i in range(response.users.size()):
try:
user = response.users.get(i)
if hasattr(user, 'id'):
users[user.id] = user
except Exception as user_error:
self.log(f"Error processing user {i}: {user_error}")
continue
if hasattr(response, 'chats') and response.chats and response.chats.size() > 0:
for i in range(response.chats.size()):
try:
chat = response.chats.get(i)
if hasattr(chat, 'id'):
chats[chat.id] = chat
except Exception as chat_error:
self.log(f"Error processing chat {i}: {chat_error}")
continue
batch_messages = []
last_message_id = offset_id
for i in range(messages_count):
msg = response.messages.get(i)
try:
if not hasattr(msg, 'message') or not msg.message or not msg.message.strip():
continue
if hasattr(msg, 'action') and msg.action:
continue
sender_name = self._get_sender_name(msg, users, chats)
msg_time = self._format_message_time(msg)
message_text = msg.message
batch_messages.append({
'sender': sender_name,
'text': message_text,
'time': msg_time,
'id': msg.id if hasattr(msg, 'id') else 0
})
if hasattr(msg, 'id'):
last_message_id = msg.id
except Exception as msg_error:
self.log(f"Error processing message: {msg_error}")
continue
accumulated_messages.extend(batch_messages)
self.log(f"Processed {len(batch_messages)} messages in this batch, total: {len(accumulated_messages)}")
if len(accumulated_messages) >= total_limit or len(batch_messages) == 0:
final_messages = accumulated_messages[:total_limit]
self.log(f"Finished fetching, returning {len(final_messages)} messages (requested: {total_limit}, available: {len(accumulated_messages)})")
callback(final_messages, None)
else:
self.log(f"Fetching next batch with offset_id: {last_message_id}")
self._fetch_messages_paginated(dialog_id, total_limit, last_message_id,
accumulated_messages, users, chats, callback)
except Exception as response_error:
self.log(f"Error in handle_response: {response_error}")
if accumulated_messages:
callback(accumulated_messages, None)
else:
callback(None, f"Ошибка обработки ответа: {str(response_error)}")
request_callback = RequestCallback(handle_response)
send_request(req, request_callback)
except Exception as e:
self.log(f"Error in _fetch_messages_paginated: {e}")
if accumulated_messages:
callback(accumulated_messages, None)
else:
callback(None, f"Ошибка: {str(e)}")
def _get_sender_name(self, msg, users: Dict, chats: Dict) -> str:
try:
if not hasattr(msg, 'from_id') or not msg.from_id:
return "Unknown"
if hasattr(msg.from_id, 'user_id') and msg.from_id.user_id in users:
user = users[msg.from_id.user_id]
return self._get_user_display_name(user)
elif hasattr(msg.from_id, 'chat_id') and msg.from_id.chat_id in chats:
chat = chats[msg.from_id.chat_id]
return chat.title if hasattr(chat, 'title') else f"Chat {chat.id}"
elif hasattr(msg.from_id, 'channel_id') and msg.from_id.channel_id in chats:
chat = chats[msg.from_id.channel_id]
return chat.title if hasattr(chat, 'title') else f"Channel {chat.id}"
else:
return "Unknown"
except Exception as e:
self.log(f"Error getting sender name: {e}")
return "Unknown"
def _format_message_time(self, msg) -> str:
try:
if hasattr(msg, 'date') and msg.date:
return datetime.fromtimestamp(msg.date).strftime("%H:%M")
return ""
except Exception as e:
self.log(f"Error formatting message time: {e}")
return ""
def _get_user_display_name(self, user) -> str:
try:
if not user:
return "Unknown"
name_parts = []
if hasattr(user, 'first_name') and user.first_name:
name_parts.append(user.first_name)
if hasattr(user, 'last_name') and user.last_name:
name_parts.append(user.last_name)
if name_parts:
return " ".join(name_parts)
elif hasattr(user, 'username') and user.username:
return f"@{user.username}"
else:
return f"User {user.id}"
except Exception as e:
self.log(f"Error getting user display name: {e}")
return "Unknown"
def _format_messages_for_analysis(self, messages: List[Dict]) -> str:
if not messages:
return ""
formatted_messages = []
for msg in messages:
formatted_msg = f"[{msg['time']}] {msg['sender']}: {msg['text']}"
formatted_messages.append(formatted_msg)
return "\n".join(formatted_messages)
def _truncate_messages_to_fit(self, messages: List[Dict], max_chars: int) -> List[Dict]:
if not messages:
return messages
truncated = []
current_chars = 0
for msg in messages:
estimated_size = len(msg['sender']) + len(msg['text']) + len(msg['time']) + 20
if current_chars + estimated_size > max_chars:
break
truncated.append(msg)
current_chars += estimated_size
self.log(f"Truncated from {len(messages)} to {len(truncated)} messages to fit {max_chars} char limit")
return truncated
def on_send_message_hook(self, account: int, params: Any) -> HookResult:
if not isinstance(params.message, str):
return HookResult()
message = params.message.strip()
if message.startswith('.analyze') or message.startswith('.summary') or message.startswith('.report'):
if not self.get_setting("enabled", True):
params.message = "❌ Плагин отключен в настройках"
return HookResult(strategy=HookStrategy.MODIFY, params=params)
api_key = self.get_setting("gemini_api_key", "")
if not api_key:
params.message = locali.get_string("API_KEY_MISSING")
return HookResult(strategy=HookStrategy.MODIFY, params=params)
dialog_id = self._get_current_dialog_id()
if not dialog_id:
params.message = "❌ Не удалось определить текущий чат"
return HookResult(strategy=HookStrategy.MODIFY, params=params)
parts = message.split()
message_count = None
if len(parts) > 1 and parts[1].isdigit():
try:
requested_count = int(parts[1])
max_limit = self._get_max_message_limit()
message_count = max(50, min(max_limit, requested_count))
self.log(f"Using message count from command: {message_count} (requested: {requested_count}, max_limit: {max_limit})")
except ValueError:
pass
if message_count is None:
try:
config_count = int(self.get_setting("message_count", "200"))
max_limit = self._get_max_message_limit()
message_count = max(50, min(max_limit, config_count))
self.log(f"Using message count from settings: {message_count}")
except (ValueError, TypeError):
message_count = 200
self.log(f"Using default message count: {message_count}")
is_summary = message.startswith('.summary')
is_report = message.startswith('.report')
BulletinHelper.show_info(locali.get_string("ANALYZING_MESSAGE"))
analysis_params = self._prepare_analysis_params(params)
run_on_queue(lambda: self._process_analysis(analysis_params, dialog_id, message_count, is_summary, is_report))
return HookResult(strategy=HookStrategy.CANCEL)
return HookResult()
def _prepare_analysis_params(self, params: Any) -> Any:
try:
analysis_params = type('AnalysisParams', (), {})()
analysis_params.peer = params.peer
if hasattr(params, 'replyToMsg') and params.replyToMsg:
analysis_params.replyToMsg = params.replyToMsg
topic_id = self._get_topic_id_from_fragment()
if topic_id > 0:
analysis_params.replyToTopMsg = self._create_reply_to_top_message(topic_id, params.peer)
elif hasattr(params, 'replyToTopMsg') and params.replyToTopMsg:
analysis_params.replyToTopMsg = params.replyToTopMsg
return analysis_params
except Exception as e:
self.log(f"Error preparing analysis params: {e}")
return params
def _create_reply_to_top_message(self, topic_id: int, peer_id: Any):
try:
if topic_id <= 0:
return None
reply_message = TLRPC.TL_message()
reply_message.message = ""
reply_message.id = topic_id
reply_message.peer_id = get_messages_controller().getPeer(peer_id)
account = get_user_config().selectedAccount
reply_to_top_msg = MessageObject(account, reply_message, False, False)
return reply_to_top_msg
except Exception as e:
self.log(f"Error creating replyToTopMsg: {e}")
return None
def _process_analysis(self, params: Any, dialog_id: int, message_count: int, is_summary: bool, is_report: bool = False):
try:
def handle_messages(messages, error):
try:
if error:
self._send_error_message(params, error)
return
if not messages:
self._send_error_message(params, locali.get_string("NO_MESSAGES"))
return
if len(messages) < 5:
self._send_error_message(params, "❌ Слишком мало сообщений для анализа (минимум 5)")
return
formatted_messages = self._format_messages_for_analysis(messages)
api_key = self.get_setting("gemini_api_key", "").strip()
if not api_key:
self._send_error_message(params, locali.get_string("API_KEY_MISSING"))
return
model_idx = self._validate_model_index(self.get_setting("model_selection", 1))
model_name = MODEL_API_NAMES[model_idx]
temperature = self._validate_temperature(self.get_setting("temperature", "0.7"))
max_tokens = self._validate_max_tokens(self.get_setting("max_tokens", "2048"))
if is_summary:
prompt_template = self.get_setting("summary_prompt", DEFAULT_SUMMARY_PROMPT)
elif is_report:
prompt_template = self.get_setting("report_prompt", IRONIC_REPORT_PROMPT)
else:
prompt_template = self.get_setting("analysis_prompt", DEFAULT_ANALYSIS_PROMPT)
final_prompt = prompt_template.format(messages=formatted_messages)
was_truncated = False
self.log(f"Sending to Gemini: {len(final_prompt)} chars, {len(messages)} messages, model: {model_name}")
result = self.api_handler.analyze_messages(api_key, model_name, final_prompt, temperature, max_tokens)
if result.get("success"):
self.log(f"Gemini API success: received {len(result['text'])} characters")
self._send_analysis_result(params, result["text"], len(messages), is_summary, was_truncated)
else:
error_msg = result.get("error", "Unknown")
self.log(f"Gemini API error: {error_msg}")
self._send_error_message(params, locali.get_string("API_ERROR").format(error=error_msg))
except Exception as handle_error:
self.log(f"Error in handle_messages: {handle_error}")
self._send_error_message(params, f"Ошибка обработки: {str(handle_error)}")
self._fetch_message_history(dialog_id, message_count, handle_messages)
except Exception as e:
self.log(f"Error in _process_analysis: {e}")
self._send_error_message(params, locali.get_string("UNEXPECTED_ERROR").format(error=str(e)))
def _validate_model_index(self, model_idx) -> int:
try:
idx = int(model_idx)
return max(0, min(len(MODEL_API_NAMES) - 1, idx))
except (ValueError, TypeError):
return 1
def _validate_temperature(self, temp_str) -> float:
try:
temp = float(temp_str)
return max(0.0, min(2.0, temp))
except (ValueError, TypeError):
return 0.7
def _validate_max_tokens(self, tokens_str) -> int:
try:
tokens = int(tokens_str)
return max(100, min(32768, tokens))
except (ValueError, TypeError):
return 4096
def _get_max_message_limit(self) -> int:
try:
limit = int(self.get_setting("max_message_limit", "50000"))
return max(50, limit)
except (ValueError, TypeError):
return 50000
def _validate_message_count(self, count_str) -> int:
try:
count = int(count_str)
max_limit = self._get_max_message_limit()
return max(50, min(max_limit, count))
except (ValueError, TypeError):
return 200
def _split_long_text(self, text: str, max_length: int = 3800) -> List[str]:
if len(text) <= max_length:
return [text]
parts = []
current_pos = 0
while current_pos < len(text):
end_pos = current_pos + max_length
if end_pos >= len(text):
parts.append(text[current_pos:])
break
chunk = text[current_pos:end_pos]
sentence_breaks = ['. ', '! ', '? ', '.\n', '!\n', '?\n']
best_break = -1
for break_char in sentence_breaks:
last_break = chunk.rfind(break_char)
if last_break > len(chunk) * 0.7:
best_break = max(best_break, last_break + len(break_char))
if best_break == -1:
paragraph_break = chunk.rfind('\n\n')
if paragraph_break > len(chunk) * 0.5:
best_break = paragraph_break + 2
if best_break == -1:
line_break = chunk.rfind('\n')
if line_break > len(chunk) * 0.5:
best_break = line_break + 1
if best_break == -1:
space_break = chunk.rfind(' ')
if space_break > len(chunk) * 0.5:
best_break = space_break + 1
if best_break == -1:
best_break = max_length
parts.append(text[current_pos:current_pos + best_break].rstrip())
current_pos += best_break
return parts
def _send_analysis_result(self, params: Any, analysis_text: str, message_count: int, is_summary: bool, was_truncated: bool = False):
try:
analysis_type = "Краткая сводка" if is_summary else "Подробный анализ"
truncated_note = " (обрезано)" if was_truncated else ""
header = f"🤖 **{analysis_type}** ({message_count} сообщений{truncated_note})\n\n"
full_text = header + analysis_text
auto_blockquote_enabled = self.get_setting("auto_blockquote", True)
max_message_length = 3900
if len(full_text) <= max_message_length:
use_blockquote = auto_blockquote_enabled and len(full_text) > 2000
self._send_single_message(params, full_text, use_blockquote)
else:
self._send_split_messages(params, header, analysis_text, auto_blockquote_enabled)
success_msg = "✅ Анализ завершен"
run_on_ui_thread(lambda: BulletinHelper.show_success(success_msg))
except Exception as e:
self.log(f"Error sending analysis result: {e}")
self._send_error_message(params, f"Ошибка отправки результата: {str(e)}")
def _send_single_message(self, params: Any, text: str, use_blockquote: bool = False):
try:
try:
parsed = parse_markdown(text)
entities = []
if use_blockquote and parsed.text and len(parsed.text.strip()) > 0:
blockquote_entity = TLRPC.TL_messageEntityBlockquote()
blockquote_entity.collapsed = True
blockquote_entity.offset = 0
try:
blockquote_entity.length = len(parsed.text.encode('utf-16le')) // 2
except:
blockquote_entity.length = len(parsed.text)
entities.append(blockquote_entity)
self.log(f"Added collapsible blockquote for message ({len(parsed.text)} chars)")
if hasattr(parsed, 'entities') and parsed.entities:
for entity in parsed.entities:
try:
tlrpc_entity = entity.to_tlrpc_object()
if tlrpc_entity is not None:
entities.append(tlrpc_entity)
except Exception as entity_error:
self.log(f"Error converting entity: {entity_error}")
continue
message_payload = {
"peer": params.peer,
"message": parsed.text,
"entities": entities if entities else None
}
except Exception as parse_error:
self.log(f"Error parsing markdown: {parse_error}")
clean_text = text.replace("**", "").replace("*", "")
message_payload = {
"peer": params.peer,
"message": clean_text
}
if use_blockquote:
try:
blockquote_entity = TLRPC.TL_messageEntityBlockquote()
blockquote_entity.collapsed = True
blockquote_entity.offset = 0
blockquote_entity.length = len(clean_text)
message_payload["entities"] = [blockquote_entity]
self.log("Added fallback blockquote for message")
except Exception as blockquote_error:
self.log(f"Error adding fallback blockquote: {blockquote_error}")
if hasattr(params, 'replyToMsg') and params.replyToMsg:
message_payload["replyToMsg"] = params.replyToMsg
if hasattr(params, 'replyToTopMsg') and params.replyToTopMsg:
message_payload["replyToTopMsg"] = params.replyToTopMsg
send_message(message_payload)
except Exception as e:
self.log(f"Error sending single message: {e}")
raise e
def _send_split_messages(self, params: Any, header: str, analysis_text: str, auto_blockquote_enabled: bool):
try:
max_content_length = 3800 - len(header) - 50
text_parts = self._split_long_text(analysis_text, max_content_length)
total_parts = len(text_parts)
self.log(f"Splitting analysis into {total_parts} parts")
for i, part in enumerate(text_parts, 1):
if i == 1:
part_header = header + f"**(Часть {i}/{total_parts})**\n\n"
else:
part_header = f"**(Часть {i}/{total_parts})**\n\n"
full_part_text = part_header + part
use_blockquote = auto_blockquote_enabled
self._send_single_message(params, full_part_text, use_blockquote)
if i < total_parts:
import time
time.sleep(0.5)
except Exception as e:
self.log(f"Error sending split messages: {e}")
raise e
def _send_error_message(self, params: Any, error_text: str):
try:
message_payload = {
"peer": params.peer,
"message": error_text
}
if hasattr(params, 'replyToMsg') and params.replyToMsg:
message_payload["replyToMsg"] = params.replyToMsg
if hasattr(params, 'replyToTopMsg') and params.replyToTopMsg:
message_payload["replyToTopMsg"] = params.replyToTopMsg
send_message(message_payload)
except Exception as e:
self.log(f"Error sending error message: {e}")
run_on_ui_thread(lambda: BulletinHelper.show_error(error_text))
def _open_link(self, url: str):
try:
from android.content import Intent
from android.net import Uri
fragment = get_last_fragment()
if not fragment:
return
context = fragment.getParentActivity()
if not context:
return
intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
except Exception as e:
self.log(f"Error opening link: {e}")
def _handle_show_info_alert_click(self, view):
try:
title = locali.get_string("USAGE_INFO_TITLE")
max_limit = self._get_max_message_limit()
text = locali.get_string("USAGE_INFO_TEXT", max_limit=max_limit)
fragment = get_last_fragment()
if not fragment or not fragment.getParentActivity():
return
context = fragment.getParentActivity()
builder = AlertDialogBuilder(context, AlertDialogBuilder.ALERT_TYPE_MESSAGE)
builder.set_title(title)
builder.set_message(text)
builder.set_positive_button("Закрыть", lambda d, w: builder.dismiss())
builder.set_cancelable(True)
run_on_ui_thread(builder.show)
except Exception as e:
self.log(f"Error showing info alert: {e}")
def create_settings(self) -> List[Any]:
max_limit = self._get_max_message_limit()
return [
Header(text=locali.get_string("SETTINGS_HEADER")),
Switch(
key="enabled",
text=locali.get_string("ENABLE_SWITCH"),
icon="ai_chat",
default=True
),
Input(
key="gemini_api_key",
text=locali.get_string("API_KEY_INPUT"),
icon="msg_pin_code",
default="",
subtext=locali.get_string("API_KEY_SUBTEXT")
),
Text(
text=locali.get_string("GET_API_KEY_BUTTON"),
icon="msg_link",
accent=True,
on_click=lambda view: self._open_link("https://aistudio.google.com/app/apikey")
),
Divider(),
Header(text="Настройки анализа"),
Input(
key="message_count",
text=locali.get_string("MESSAGE_COUNT_INPUT"),
icon="msg_voicechat_solar",
default="200",
subtext=locali.get_string("MESSAGE_COUNT_SUBTEXT", max_limit=max_limit)
),
Input(
key="max_message_limit",
text=locali.get_string("MAX_MESSAGE_LIMIT_INPUT"),
icon="msg_premium_limits",
default="50000",
subtext="Максимальное количество сообщений для анализа (без ограничений)"
),
Selector(
key="model_selection",
text=locali.get_string("MODEL_SELECTOR"),
icon="msg_language_solar",
default=1,
items=MODEL_DISPLAY_NAMES
),
Divider(),
Header(text="Промпты"),
Input(
key="analysis_prompt",
text=locali.get_string("ANALYSIS_PROMPT_INPUT"),
icon="msg_edit",
default=DEFAULT_ANALYSIS_PROMPT
),
Input(
key="summary_prompt",
text=locali.get_string("SUMMARY_PROMPT_INPUT"),
icon="msg_message",
default=DEFAULT_SUMMARY_PROMPT
),
Input(
key="report_prompt",
text=locali.get_string("REPORT_PROMPT_INPUT"),
icon="msg_report",
default=IRONIC_REPORT_PROMPT
),
Divider(),
Header(text="Внешний вид"),
Switch(
key="auto_blockquote",
text=locali.get_string("AUTO_BLOCKQUOTE_TITLE"),
subtext=locali.get_string("AUTO_BLOCKQUOTE_SUBTEXT"),
icon="header_goinline_solar",
default=True
),
Divider(),
Header(text="Параметры генерации"),
Input(
key="temperature",
text=locali.get_string("TEMPERATURE_INPUT"),
icon="msg_settings",
default="0.7",
subtext=locali.get_string("TEMPERATURE_SUBTEXT")
),
Input(
key="max_tokens",
text=locali.get_string("MAX_TOKENS_INPUT"),
icon="msg_data",
default="4096",
subtext=locali.get_string("MAX_TOKENS_SUBTEXT")
),
Divider(),
Text(
text=locali.get_string("USAGE_INFO_TITLE"),
icon="msg_info",
on_click=self._handle_show_info_alert_click
),
]