1068 lines
47 KiB
Plaintext
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
|
|
),
|
|
]
|