""" ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⡿⠟⠋⠉⠉⠉⠉⠛⠿⣿⣿⣿⣿⡿⠛⠉⠉⠄⠈⠉⠙⠿⣿⣿⣿⣿ ⣿⣿⡿⠋⠄⣠⣶⣿⣿⣿⣷⣦⣄⠈⠛⢟⢁⣠⣤⣴⣶⣤⣄⠄⠄⠄⠈⢿⣿⣿ ⣿⡿⠁⢠⣾⣿⣿⣿⣿⣿⣿⣿⡿⣿⣦⣀⠈⠛⠛⠋⣸⣿⣿⣷⡄⠄⠄⠄⢻⣿ ⣿⠁⢀⣿⣿⣿⣿⣿⣿⣿⠋⠄⠄⣿⣿⣿⣿⣶⣶⣾⣿⣿⣿⣿⣧⠄⠄⠄⠄⣿ ⣿⠄⢸⣿⣿⣿⣿⣿⠟⠁⠄⠄⠄⠄⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⠄⣿ ⣿⠄⠘⣿⣿⣿⣿⡏⠄⠄⠄⠄⠄⠄⠄⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⠄⣿ ⣿⠄⠄⢻⣿⣿⣿⠁⠄⠄⠄⠄⠄⠄⠄⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⢀⣿ ⣿⡆⠄⠈⠿⠿⠋⠄⠄⠄⠄⠄⠄⢰⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠄⠄⠄⣸⣿ ⣿⣿⡀⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠄⠄⣰⣿⣿ ⣿⣿⣷⡄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠄⠄⣰⣿⣿⣿ ⣿⣿⣿⣿⣄⠄⠄⠄⠄⠄⠄⠄⠄⣰⣿⣿⣿⣿⣿⣿⣿⣿⠏⠄⢀⣴⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣷⣄⠄⠄⠄⠄⠄⣰⣿⣿⣿⣿⣿⣿⣿⡿⠃⠄⣠⣾⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣷⣄⠄⠄⠄⢿⣿⣿⣿⣿⣿⡿⠋⢀⣠⣾⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⠄⠙⢿⣿⠟⠋⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⣨⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ by @mihailkotovski Перед копированием/изменением кода уведомите @mihailkotovski """ import requests import traceback from typing import Any, Dict, Optional, List from datetime import datetime import weakref from base_plugin import BasePlugin, XposedHook from android_utils import OnClickListener, run_on_ui_thread from client_utils import ( get_messages_controller, run_on_queue, send_request, RequestCallback, get_last_fragment, get_user_config ) from markdown_utils import parse_markdown from ui.settings import Header, Input, Selector, Switch, Text, Divider from ui.bulletin import BulletinHelper from ui.alert import AlertDialogBuilder from hook_utils import find_class, get_private_field from java.util import Locale from org.telegram.tgnet import TLRPC from org.telegram.messenger import R, MessageObject from org.telegram.ui import LaunchActivity from org.telegram.ui.Components import EditTextBoldCursor from org.telegram.ui.ActionBar import Theme from android.text import InputType __id__ = "unread_summary" __name__ = "Unread Summary" __description__ = "Summarize unread messages with AI providers by clicking the unread badge" __author__ = "@mihailkotovski & @mishabotov" __version__ = "1.0.0" __icon__ = "pk_4377080_by_Ctikerubot/24" __min_version__ = "12.1.1" 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-flash-latest", "gemini-flash-lite-latest" ] PROVIDER_TYPES = [ "Gemini", "OpenAI-Compatible" ] DEFAULT_UNREAD_SUMMARY_PROMPT_RU = """Проанализируй следующие непрочитанные сообщения и создай краткую сводку. Сосредоточься на: 1. Основных темах обсуждения 2. Важной информации или решениях 3. Вопросах или задачах, адресованных пользователю Сводка должна быть краткой (максимум 150 слов) и написана простым текстом без какого либо форматирования. Сообщения для анализа: {messages} """ DEFAULT_UNREAD_SUMMARY_PROMPT_EN = """Analyze the following unread messages and create a brief summary. Focus on: 1. Main discussion topics 2. Important information or decisions 3. Questions or tasks addressed to the user The summary should be concise (maximum 150 words) and written in plain text without any formatting. Messages to analyze: {messages} """ class LocalizationManager: strings = { "ru": { "SETTINGS_HEADER": "Настройки суммаризации", "API_KEY_INPUT": "API", "API_KEY_SUBTEXT": "Получите ключ в Google AI Studio", "GET_API_KEY_BUTTON": "Получить API ключ", "MODEL_SELECTOR": "Модель Gemini", "ENABLE_SWITCH": "Включить плагин", "SUMMARY_PROMPT_INPUT": "Промпт для суммаризации", "TEMPERATURE_INPUT": "Температура", "TEMPERATURE_SUBTEXT": "0.0-2.0. Контролирует креативность", "MAX_TOKENS_INPUT": "Максимум токенов", "MAX_TOKENS_SUBTEXT": "Максимальная длина ответа", "API_KEY_MISSING": "API ключ Gemini не найден. Укажите его в настройках.", "ANALYZING_MESSAGE": "Анализирую непрочитанные сообщения...", "API_ERROR": "Ошибка API: {error}", "NO_UNREAD_MESSAGES": "Нет непрочитанных сообщений.", "UNEXPECTED_ERROR": "Произошла ошибка: {error}", "MENU_SUMMARIZE": "Суммаризовать непрочитанные", "MENU_CANCEL": "Отмена", "DIALOG_TITLE": "Сводка непрочитанных сообщений", "CLOSE_BUTTON": "Закрыть", "PLUGIN_DISABLED": "Плагин отключен в настройках", "CANNOT_GET_DIALOG": "Не удалось определить текущий чат", "FETCHING_MESSAGES": "Загружаю сообщения...", "MENU_PROVIDER": "Провайдер", "MENU_LANGUAGE": "Язык", "MENU_AI_SETTINGS": "Настройки AI", "MENU_SHARE": "Поделиться", "SELECT_PROVIDER_TITLE": "Выбор провайдера", "SELECT_LANGUAGE_TITLE": "Выбор языка", "SELECTOR_ERROR": "Не удалось открыть селектор", "AI_SETTINGS_HEADER": "Настройки AI", "PROMPT_HEADER": "Промпт", "PROVIDER_SELECTOR": "Провайдер", "OPENAI_SECTION": "OpenAI-совместимый", "OPENAI_API_KEY_INPUT": "API Key", "OPENAI_BASE_URL_INPUT": "URL", "OPENAI_MODEL_INPUT": "Модель", "OPENAI_API_KEY_MISSING": "API ключ OpenAI не найден. Укажите его в настройках.", "OPENAI_HEADER_NAME_INPUT": "Заголовок API-ключа", "OPENAI_HEADER_PREFIX_INPUT": "Префикс заголовка", "MESSAGE_LIMIT_INPUT": "Лимит сообщений", "MESSAGE_LIMIT_SUBTEXT": "0 — без лимита. Сколько непрочитанных анализировать", }, "en": { "SETTINGS_HEADER": "Summary 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 Plugin", "SUMMARY_PROMPT_INPUT": "Summary Prompt", "TEMPERATURE_INPUT": "Temperature", "TEMPERATURE_SUBTEXT": "0.0-2.0. Controls response creativity", "MAX_TOKENS_INPUT": "Max Tokens", "MAX_TOKENS_SUBTEXT": "Maximum response length", "API_KEY_MISSING": "Gemini API key not found. Set it in settings.", "ANALYZING_MESSAGE": "Analyzing unread messages...", "API_ERROR": "AI API Error: {error}", "NO_UNREAD_MESSAGES": "No unread messages found.", "UNEXPECTED_ERROR": "An error occurred: {error}", "MENU_SUMMARIZE": "Summarize Unread Messages", "MENU_CANCEL": "Cancel", "DIALOG_TITLE": "Unread Messages Summary", "CLOSE_BUTTON": "Close", "PLUGIN_DISABLED": "Plugin is disabled in settings", "CANNOT_GET_DIALOG": "Cannot determine current chat", "FETCHING_MESSAGES": "Fetching messages...", "MENU_PROVIDER": "Provider", "MENU_LANGUAGE": "Language", "MENU_AI_SETTINGS": "AI Settings", "MENU_SHARE": "Share", "SELECT_PROVIDER_TITLE": "Select Provider", "SELECT_LANGUAGE_TITLE": "Select Language", "SELECTOR_ERROR": "Failed to open selector", "AI_SETTINGS_HEADER": "AI Settings", "PROMPT_HEADER": "Prompt", "PROVIDER_SELECTOR": "Provider", "OPENAI_SECTION": "OpenAI-compatible", "OPENAI_API_KEY_INPUT": "API Key", "OPENAI_BASE_URL_INPUT": "URL", "OPENAI_MODEL_INPUT": "Model", "OPENAI_API_KEY_MISSING": "OpenAI API key not found. Set it in settings.", "OPENAI_HEADER_NAME_INPUT": "Header Name", "OPENAI_HEADER_PREFIX_INPUT": "Header Prefix", "MESSAGE_LIMIT_INPUT": "Message limit", "MESSAGE_LIMIT_SUBTEXT": "0 = unlimited. How many unread messages to analyze", } } 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')) try: response = self.session.post(url, json=payload, timeout=90) response.raise_for_status() data = response.json() if "candidates" not in 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: return {"success": False, "error": "Empty candidates array in API response"} first_candidate = candidates[0] finish_reason = first_candidate.get("finishReason", "") if 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: return {"success": False, "error": "No content in API response"} parts = content.get("parts", []) if not parts or len(parts) == 0: return {"success": False, "error": "No parts in content"} text = parts[0].get("text", "") if not text or not text.strip(): return {"success": False, "error": "Empty text in API response"} 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() 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: return {"success": False, "error": f"Network error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} class OpenAICompatibleAPIHandler: 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, base_url: str, api_key: str, model_name: str, prompt: str, temperature: float, max_tokens: int, api_key_header: str = "Authorization", api_key_prefix: str = "Bearer") -> Dict[str, Any]: try: bu = (base_url or "https://api.openai.com").rstrip('/') if bu.endswith("/v1") or bu.endswith("/v1/"): url = bu.rstrip('/') + "/chat/completions" else: url = bu + "/v1/chat/completions" headers = {} if api_key_header: if api_key_prefix: headers[api_key_header] = f"{api_key_prefix} {api_key}" else: headers[api_key_header] = api_key payload = { "model": model_name, "messages": [ {"role": "user", "content": prompt} ], "temperature": float(temperature), "max_tokens": int(max_tokens), "stream": False } prompt_size = len(prompt.encode('utf-8')) response = self.session.post(url, json=payload, headers=headers, timeout=90) response.raise_for_status() data = response.json() if "choices" not in data or not data["choices"]: err = data.get("error", {}).get("message", "No choices in API response") return {"success": False, "error": err} first_choice = data["choices"][0] finish_reason = first_choice.get("finish_reason") or first_choice.get("finishReason", "") if finish_reason and finish_reason not in ["stop", ""]: if finish_reason == "length": return {"success": False, "error": "Response truncated due to token limit"} if finish_reason == "content_filter": return {"success": False, "error": "Blocked by content filter"} message = first_choice.get("message") or {} text = (message.get("content") if isinstance(message, dict) else None) or first_choice.get("text", "") if not text or not str(text).strip(): return {"success": False, "error": "Empty content in API response"} 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() error_text += f": {error_json.get('error',{}).get('message', e.response.text)}" except Exception: error_text += f": {e.response.text}" return {"success": False, "error": error_text} except requests.exceptions.RequestException as e: return {"success": False, "error": f"Network error: {str(e)}"} except Exception as e: return {"success": False, "error": f"Unexpected error: {str(e)}"} class ChatUnreadCellConstructorHook(XposedHook): def __init__(self, plugin: 'UnreadSummaryPlugin'): super().__init__() self.plugin = plugin def after_hooked_method(self, param): try: unread_cell = param.thisObject try: from java.lang.ref import WeakReference as JWeakReference _cell_wr = JWeakReference(unread_cell) unread_cell_ref = (lambda ref=_cell_wr: ref.get()) except Exception: try: import weakref as _pyweakref _cell_wr_py = _pyweakref.ref(unread_cell) unread_cell_ref = (lambda ref=_cell_wr_py: ref()) except Exception: unread_cell_ref = (lambda obj=unread_cell: obj) def setup_click_listener(): try: background_layout = None cell_obj = unread_cell_ref() if cell_obj is None: return background_layout = get_private_field(cell_obj, "backgroundLayout") if not background_layout: if cell_obj.getChildCount() > 0: background_layout = cell_obj.getChildAt(0) if not background_layout: try: field = cell_obj.getClass().getDeclaredField("backgroundLayout") field.setAccessible(True) background_layout = field.get(cell_obj) except Exception: pass if background_layout: from android.view import View background_layout.setClickable(True) background_layout.setFocusable(True) def on_unread_cell_click(*_args, **_kwargs): try: if not self.plugin.is_analyzing: try: view_location = [0, 0] background_layout.getLocationOnScreen(view_location) center_x = view_location[0] + background_layout.getWidth() / 2.0 center_y = view_location[1] + background_layout.getHeight() / 2.0 LaunchActivity.makeRipple(center_x, center_y, 1.5) try: view_obj = _args[0] if (_args and _args[0] is not None) else background_layout self.plugin._perform_click_vibration(view_obj) except Exception: pass except Exception: pass cell_for_action = unread_cell_ref() if cell_for_action is None: return text_view = get_private_field(cell_for_action, "textView") if text_view: current_text = str(text_view.getText()) if ("•" in current_text and ("сообщений" in current_text or "message" in current_text)) and self.plugin.last_summary: self.plugin._show_summary_bottom_sheet(self.plugin.last_summary, self.plugin.last_message_count) return self.plugin._start_summarization(cell_for_action) except Exception as e: pass background_layout.setOnClickListener(OnClickListener(on_unread_cell_click)) else: pass except Exception: pass from android_utils import R unread_cell.post(R(setup_click_listener)) except Exception: pass class UnreadSummaryPlugin(BasePlugin): def __init__(self): super().__init__() self.api_handler = GeminiAPIHandler() self.openai_handler = OpenAICompatibleAPIHandler() self.progress_dialog: Optional[AlertDialogBuilder] = None self.hook_ref = None self.last_summary = None self.last_message_count = 0 self.locale_unhook = None self.is_analyzing = False def on_plugin_load(self): self._apply_hook() self._apply_locale_hook() def on_plugin_unload(self): self._remove_hook() if self.progress_dialog: run_on_ui_thread(lambda: self.progress_dialog.dismiss()) if self.locale_unhook: try: self.unhook_method(self.locale_unhook) except Exception: pass finally: self.locale_unhook = None self.is_analyzing = False def _apply_hook(self): try: self._remove_hook() cls = find_class("org.telegram.ui.Cells.ChatUnreadCell") if cls: constructors = cls.getClass().getDeclaredConstructors() if constructors and len(constructors) > 0: self.hook_ref = self.hook_method(constructors[0], ChatUnreadCellConstructorHook(self)) else: pass else: pass except Exception: pass def _apply_locale_hook(self): try: LocaleController = find_class("org.telegram.messenger.LocaleController") if not LocaleController: return proceed_id = None try: proceed_id = R.string.Proceed except Exception: proceed_id = None from java.lang import Integer try: getStringMethod = LocaleController.getClass().getDeclaredMethod("getString", Integer.TYPE) except Exception as e: return class _LocaleHook(XposedHook): def __init__(self, proceed_res_id): super().__init__() self.proceed_res_id = proceed_res_id def after_hooked_method(self, param): try: if not param or not hasattr(param, 'args') or not param.args: return res_id = param.args[0] if self.proceed_res_id is not None and res_id == self.proceed_res_id: param.setResult("Summarize") except Exception: pass self.locale_unhook = self.hook_method(getStringMethod, _LocaleHook(proceed_id)) if self.locale_unhook: pass except Exception: pass def _remove_hook(self): if self.hook_ref: try: self.unhook_method(self.hook_ref) self.hook_ref = None except Exception: pass def _show_summary_menu(self, unread_cell): try: self._start_summarization(unread_cell) except Exception as e: BulletinHelper.show_error(locali.get_string("UNEXPECTED_ERROR").format(error=str(e))) def _start_summarization(self, unread_cell): try: if self.is_analyzing: return api_key = self.get_setting("gemini_api_key", "") if not api_key: BulletinHelper.show_error(locali.get_string("API_KEY_MISSING")) return dialog_id = self._get_current_dialog_id() if not dialog_id: BulletinHelper.show_error(locali.get_string("CANNOT_GET_DIALOG")) return self.is_analyzing = True text_view = get_private_field(unread_cell, "textView") if text_view: run_on_ui_thread(lambda: text_view.setText("Анализирую...")) run_on_queue(lambda: self._fetch_and_summarize(dialog_id, unread_cell)) except Exception as e: self.is_analyzing = False BulletinHelper.show_error(locali.get_string("UNEXPECTED_ERROR").format(error=str(e))) 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: return None def _get_current_topic_id(self) -> int: try: fragment = get_last_fragment() if fragment and hasattr(fragment, 'getTopicId'): topic_id = fragment.getTopicId() return int(topic_id) if topic_id else 0 return 0 except Exception: return 0 def _resolve_read_bounds(self, dialog_id: int, topic_id: int) -> Dict[str, int]: in_max = 0 out_max = 0 unread_count = 0 try: mc = get_messages_controller() dialog = None try: dialog = mc.getDialog(dialog_id) except Exception: pass if dialog: try: in_max = getattr(dialog, 'read_inbox_max_id', 0) or 0 out_max = getattr(dialog, 'read_outbox_max_id', 0) or 0 unread_count = getattr(dialog, 'unread_count', 0) or 0 except Exception: pass if topic_id and dialog_id < 0: try: chat_id = -int(dialog_id) topics_controller = mc.getTopicsController() if topics_controller: topic = topics_controller.findTopic(chat_id, topic_id) if topic: in_max = getattr(topic, 'read_inbox_max_id', in_max) or in_max out_max = getattr(topic, 'read_outbox_max_id', out_max) or out_max unread_count = getattr(topic, 'unread_count', unread_count) or unread_count except Exception: pass except Exception: pass return {"in_max": int(in_max or 0), "out_max": int(out_max or 0), "unread_count": int(unread_count or 0)} def _fetch_and_summarize_for_dialog(self, dialog_id: int): try: if self.is_analyzing: BulletinHelper.show_info("Анализ уже выполняется...") return self.is_analyzing = True topic_id = self._get_current_topic_id() read_bounds = self._resolve_read_bounds(dialog_id, topic_id) def handle_messages(messages, error): try: if error: BulletinHelper.show_error(f"Ошибка: {error}") return if not messages or len(messages) == 0: BulletinHelper.show_error(locali.get_string("NO_UNREAD_MESSAGES")) return formatted_messages = self._format_messages_for_analysis(messages) provider_type = self.get_setting("provider_type", 0) try: provider_type = int(provider_type) except (ValueError, TypeError): provider_type = 0 temperature = self._validate_temperature(self.get_setting("temperature", "0.7")) max_tokens = self._validate_max_tokens(self.get_setting("max_tokens", "256000")) default_prompt = self._get_default_prompt() prompt_template = self.get_setting("summary_prompt", default_prompt) final_prompt = prompt_template.format(messages=formatted_messages) if provider_type == 1: base_url = self.get_setting("openai_base_url", "https://api.openai.com").strip() or "https://api.openai.com" api_key = self.get_setting("openai_api_key", "").strip() if not api_key: BulletinHelper.show_error(locali.get_string("OPENAI_API_KEY_MISSING")) return model_name = self.get_setting("openai_model", "gpt-5-chat").strip() or "gpt-5-chat" header_name = self.get_setting("openai_api_key_header", "Authorization").strip() or "Authorization" header_prefix = self.get_setting("openai_api_key_prefix", "Bearer").strip() result = self.openai_handler.analyze_messages(base_url, api_key, model_name, final_prompt, temperature, max_tokens, header_name, header_prefix) else: api_key = self.get_setting("gemini_api_key", "").strip() model_idx = self._validate_model_index(self.get_setting("model_selection", 1)) model_name = MODEL_API_NAMES[model_idx] result = self.api_handler.analyze_messages(api_key, model_name, final_prompt, temperature, max_tokens) if result.get("success"): self.last_summary = result["text"] self.last_message_count = len(messages) run_on_ui_thread(lambda: self._show_summary_bottom_sheet(result["text"], len(messages))) else: error_msg = result.get("error", "Unknown") BulletinHelper.show_error(locali.get_string("API_ERROR").format(error=error_msg)) except Exception as handle_error: BulletinHelper.show_error(locali.get_string("UNEXPECTED_ERROR").format(error=str(handle_error))) finally: self.is_analyzing = False self._fetch_unread_messages(dialog_id, handle_messages, read_bounds, topic_id) except Exception as e: self.is_analyzing = False BulletinHelper.show_error(locali.get_string("UNEXPECTED_ERROR").format(error=str(e))) def _fetch_and_summarize(self, dialog_id: int, unread_cell): try: topic_id = self._get_current_topic_id() read_bounds = self._resolve_read_bounds(dialog_id, topic_id) def handle_messages(messages, error): try: if error: self._show_error_in_cell(unread_cell, error) return if not messages or len(messages) == 0: self._show_error_in_cell(unread_cell, locali.get_string("NO_UNREAD_MESSAGES")) return formatted_messages = self._format_messages_for_analysis(messages) provider_type = self.get_setting("provider_type", 0) try: provider_type = int(provider_type) except (ValueError, TypeError): provider_type = 0 temperature = self._validate_temperature(self.get_setting("temperature", "0.7")) max_tokens = self._validate_max_tokens(self.get_setting("max_tokens", "256000")) default_prompt = self._get_default_prompt() prompt_template = self.get_setting("summary_prompt", default_prompt) final_prompt = prompt_template.format(messages=formatted_messages) if provider_type == 1: base_url = self.get_setting("openai_base_url", "https://api.openai.com").strip() or "https://api.openai.com" api_key = self.get_setting("openai_api_key", "").strip() if not api_key: self._show_error_in_cell(unread_cell, locali.get_string("OPENAI_API_KEY_MISSING")) return model_name = self.get_setting("openai_model", "gpt-5-chat").strip() or "gpt-5-chat" header_name = self.get_setting("openai_api_key_header", "Authorization").strip() or "Authorization" header_prefix = self.get_setting("openai_api_key_prefix", "Bearer").strip() result = self.openai_handler.analyze_messages(base_url, api_key, model_name, final_prompt, temperature, max_tokens, header_name, header_prefix) else: api_key = self.get_setting("gemini_api_key", "").strip() model_idx = self._validate_model_index(self.get_setting("model_selection", 1)) model_name = MODEL_API_NAMES[model_idx] result = self.api_handler.analyze_messages(api_key, model_name, final_prompt, temperature, max_tokens) if result.get("success"): self._show_summary_in_cell(unread_cell, result["text"], len(messages)) else: error_msg = result.get("error", "Unknown") self._show_error_in_cell(unread_cell, locali.get_string("API_ERROR").format(error=error_msg)) except Exception as handle_error: self._show_error_in_cell(unread_cell, locali.get_string("UNEXPECTED_ERROR").format(error=str(handle_error))) finally: self.is_analyzing = False self._fetch_unread_messages(dialog_id, handle_messages, read_bounds, topic_id) except Exception as e: self.is_analyzing = False self._show_error_in_cell(unread_cell, locali.get_string("UNEXPECTED_ERROR").format(error=str(e))) def _fetch_unread_messages(self, dialog_id: int, callback, read_bounds: Dict[str, int], topic_id: int): try: message_limit = self._validate_message_limit(self.get_setting("message_limit", "0")) self._fetch_messages_paginated(dialog_id, 0, [], {}, {}, callback, read_bounds, topic_id, message_limit) except Exception as e: callback(None, f"Error: {str(e)}") def _fetch_messages_paginated(self, dialog_id: int, offset_id: int, accumulated_messages: List, users: Dict, chats: Dict, callback, read_bounds: Dict[str, int], topic_id: int, message_limit: int): try: current_limit = 100 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) if accumulated_messages: callback(accumulated_messages, None) else: callback(None, f"Error fetching messages: {error_msg}") return if not response or not hasattr(response, 'messages'): if accumulated_messages: callback(accumulated_messages, None) else: callback(None, "Empty response from server") return messages_count = response.messages.size() if messages_count == 0: 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: 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: continue batch_messages = [] last_message_id = offset_id found_read_message = False in_max = int(read_bounds.get("in_max", 0) or 0) out_max = int(read_bounds.get("out_max", 0) or 0) unread_expected = int(read_bounds.get("unread_count", 0) or 0) current_account = 0 try: current_account = int(get_user_config().getCurrentAccount()) except Exception: current_account = 0 for i in range(messages_count): msg = response.messages.get(i) try: if hasattr(msg, 'action') and msg.action: continue if topic_id: try: msg_topic_id = MessageObject.getTopicId(current_account, msg, True) if int(msg_topic_id) != int(topic_id): continue except Exception as e: continue try: is_out = bool(getattr(msg, 'out', False)) msg_id = int(getattr(msg, 'id', 0) or 0) unread_by_id = (msg_id > in_max) and (not is_out) unread_by_flag = bool(getattr(msg, 'unread', False)) is_unread = unread_by_flag or unread_by_id except Exception: is_unread = False if hasattr(msg, 'id') and msg.id <= in_max: found_read_message = True break message_text = self._format_message_text_or_media(msg) if not message_text: continue if not is_unread: continue sender_name = self._get_sender_name(msg, users, chats) msg_time = self._format_message_time(msg) 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: continue accumulated_messages.extend(batch_messages) reached_limit = (message_limit > 0 and len(accumulated_messages) >= message_limit) should_finish = (unread_expected > 0 and len(accumulated_messages) >= unread_expected) or found_read_message or len(batch_messages) == 0 or messages_count < current_limit or reached_limit if should_finish: result_messages = accumulated_messages if unread_expected > 0 and len(result_messages) > unread_expected: try: try: result_messages = sorted(result_messages, key=lambda m: m.get('id', 0)) except Exception: pass result_messages = result_messages[:unread_expected] except Exception: pass if message_limit > 0 and len(result_messages) > message_limit: try: try: result_messages = sorted(result_messages, key=lambda m: m.get('id', 0)) except Exception: pass result_messages = result_messages[:message_limit] except Exception: pass callback(result_messages, None) else: self._fetch_messages_paginated(dialog_id, last_message_id, accumulated_messages, users, chats, callback, read_bounds, topic_id, message_limit) except Exception as response_error: if accumulated_messages: callback(accumulated_messages, None) else: callback(None, f"Error processing response: {str(response_error)}") request_callback = RequestCallback(handle_response) send_request(req, request_callback) except Exception as e: if accumulated_messages: callback(accumulated_messages, None) else: callback(None, f"Error: {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: 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: 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: 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 _get_media_label(self, msg) -> str: try: if not hasattr(msg, 'media') or msg.media is None: return "" media = msg.media cls = None try: cls = media.getClass().getSimpleName() except Exception: cls = str(media.__class__.__name__) if hasattr(media, 'photo') and media.photo is not None: return "[Photo]" if hasattr(media, 'document') and media.document is not None: try: mime = getattr(media.document, 'mime_type', None) or "" if 'gif' in mime.lower(): return "[GIF]" if mime.startswith('video/'): return "[Video]" if mime.startswith('audio/'): return "[Audio]" return "[File]" except Exception: return "[Document]" if 'WebPage' in cls: return "[Link]" if 'Geo' in cls or 'Venue' in cls: return "[Location]" if 'Contact' in cls: return "[Contact]" if 'Poll' in cls: return "[Poll]" return "[Media]" except Exception: return "[Media]" def _format_message_text_or_media(self, msg) -> str: try: text = getattr(msg, 'message', None) if text and str(text).strip(): return str(text) label = self._get_media_label(msg) return label except Exception: return "" def _get_default_prompt(self) -> str: lang_index = self.get_setting("summary_language", 0) try: lang_index = int(lang_index) except (ValueError, TypeError): lang_index = 0 return DEFAULT_UNREAD_SUMMARY_PROMPT_RU if lang_index == 0 else DEFAULT_UNREAD_SUMMARY_PROMPT_EN 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 2048 def _validate_message_limit(self, limit_str) -> int: try: value = int(limit_str) if value <= 0: return 0 return max(1, min(100000, value)) except (ValueError, TypeError): return 0 def _show_summary_in_cell(self, unread_cell, summary_text: str, message_count: int): self.last_summary = summary_text self.last_message_count = message_count def update_and_show(): try: text_view = get_private_field(unread_cell, "textView") if text_view: preview = self._make_summary_preview(summary_text, message_count) text_view.setText(preview) BulletinHelper.show_success(f"Проанализировано {message_count} сообщений") except Exception as e: BulletinHelper.show_error(f"Ошибка: {str(e)}") run_on_ui_thread(update_and_show) def _make_summary_preview(self, summary_text: str, message_count: int) -> str: try: if locali.language == "ru": msg_word = "сообщений" else: msg_word = "messages" if message_count != 1 else "message" return f"{message_count} • {msg_word}" except Exception: return str(message_count) def _show_dots_menu(self, anchor_view, summary_text: str): try: from org.telegram.ui.ActionBar import ActionBarPopupWindow, Theme from org.telegram.ui.Components import LayoutHelper from org.telegram.messenger import AndroidUtilities, R from android.view import View, Gravity from android.widget import FrameLayout, LinearLayout, TextView, ImageView from android.util import TypedValue from androidx.core.content import ContextCompat context = anchor_view.getContext() popup_layout = ActionBarPopupWindow.ActionBarPopupWindowLayout(context) popup_layout.setBackgroundColor(Theme.getColor(Theme.key_actionBarDefaultSubmenuBackground)) popup_layout.setFitItems(True) def create_menu_item(icon_res: int, title: str, on_click_action): item_frame = FrameLayout(context) item_frame.setMinimumWidth(AndroidUtilities.dp(200)) item_frame.setClickable(True) item_frame.setFocusable(True) item_frame.setBackground(Theme.createSelectorDrawable(Theme.getColor(Theme.key_listSelector), 2)) item_content = LinearLayout(context) item_content.setOrientation(LinearLayout.HORIZONTAL) item_content.setGravity(Gravity.CENTER_VERTICAL) item_content.setPadding(AndroidUtilities.dp(16), AndroidUtilities.dp(12), AndroidUtilities.dp(16), AndroidUtilities.dp(12)) icon = ImageView(context) icon.setScaleType(ImageView.ScaleType.CENTER) try: icon_drawable = ContextCompat.getDrawable(context, icon_res) from android.graphics import PorterDuff icon_drawable.setColorFilter(Theme.getColor(Theme.key_actionBarDefaultSubmenuItem), PorterDuff.Mode.SRC_IN) icon.setImageDrawable(icon_drawable) except Exception: icon.setImageResource(icon_res) item_content.addView(icon, LayoutHelper.createLinear(24, 24, Gravity.CENTER_VERTICAL, 0, 0, 12, 0)) title_tv = TextView(context) title_tv.setText(title) title_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16) try: title_tv.setTextColor(Theme.getColor(Theme.key_actionBarDefaultSubmenuItem)) except Exception: pass item_content.addView(title_tv, LayoutHelper.createLinear(-1, -2, 1.0, Gravity.CENTER_VERTICAL)) item_frame.addView(item_content) return item_frame, on_click_action menu_items = [ (R.drawable.msg_bot, locali.get_string("MENU_PROVIDER"), lambda: self._show_provider_selector()), (R.drawable.msg_translate, locali.get_string("MENU_LANGUAGE"), lambda: self._show_language_selector()), (R.drawable.msg_share, locali.get_string("MENU_SHARE"), lambda: self._share_summary(summary_text)), ] popup_window_ref = [None] for icon_res, title, action in menu_items: item_view, on_click_action = create_menu_item(icon_res, title, action) item_view.setOnClickListener(OnClickListener(lambda *_args, act=on_click_action, pw_ref=popup_window_ref: ( pw_ref[0].dismiss() if pw_ref[0] else None, act() ))) popup_layout.addView(item_view, LayoutHelper.createLinear(-1, -2)) popup_window = ActionBarPopupWindow( popup_layout, -2, -2 ) popup_window_ref[0] = popup_window popup_window.setOutsideTouchable(True) popup_window.setClippingEnabled(True) popup_window.setAnimationStyle(R.style.PopupContextAnimation) popup_window.setFocusable(True) popup_layout.measure( View.MeasureSpec.makeMeasureSpec(AndroidUtilities.dp(1000), View.MeasureSpec.AT_MOST), View.MeasureSpec.makeMeasureSpec(AndroidUtilities.dp(1000), View.MeasureSpec.AT_MOST) ) location = [0, 0] anchor_view.getLocationInWindow(location) popup_x = location[0] + anchor_view.getWidth() - popup_layout.getMeasuredWidth() popup_y = location[1] - popup_layout.getMeasuredHeight() popup_window.showAtLocation( anchor_view, Gravity.TOP | Gravity.LEFT, popup_x, popup_y ) popup_window.dimBehind() except Exception as e: BulletinHelper.show_error(f"Ошибка: {str(e)}") def _select_provider(self, index: int): try: self.set_setting("provider_type", index) provider_name = PROVIDER_TYPES[index] if 0 <= index < len(PROVIDER_TYPES) else str(index) msg = f"Выбран провайдер: {provider_name}" if locali.language == "ru" else f"Provider selected: {provider_name}" BulletinHelper.show_success(msg) except Exception as e: pass def _select_language(self, index: int): try: current_prompt = self.get_setting("summary_prompt", "") old_lang_index = self.get_setting("summary_language", 0) try: old_lang_index = int(old_lang_index) except (ValueError, TypeError): old_lang_index = 0 old_default = DEFAULT_UNREAD_SUMMARY_PROMPT_RU if old_lang_index == 0 else DEFAULT_UNREAD_SUMMARY_PROMPT_EN new_default = DEFAULT_UNREAD_SUMMARY_PROMPT_RU if index == 0 else DEFAULT_UNREAD_SUMMARY_PROMPT_EN self.set_setting("summary_language", index) if not current_prompt or current_prompt.strip() == old_default.strip(): self.set_setting("summary_prompt", new_default) lang_text = "Русский" if index == 0 else "English" BulletinHelper.show_success(f"Язык изменен: {lang_text}") except Exception as e: pass def _share_summary(self, summary_text: str): try: fragment = get_last_fragment() if not fragment: BulletinHelper.show_error("Не удалось открыть диалог выбора") return from org.telegram.ui import LaunchActivity from android.content import Intent try: activity = fragment.getParentActivity() if activity and isinstance(activity, LaunchActivity): share_text = f"📝 Сводка непрочитанных сообщений:\n\n{summary_text}" intent = Intent(Intent.ACTION_SEND) intent.setType("text/plain") intent.putExtra(Intent.EXTRA_TEXT, share_text) chooser = Intent.createChooser(intent, "Поделиться сводкой") activity.startActivity(chooser) else: from android.content import ClipData, ClipboardManager, Context clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) clip = ClipData.newPlainText("Summary", summary_text) clipboard.setPrimaryClip(clip) BulletinHelper.show_success("Сводка скопирована в буфер обмена") except Exception as share_error: from android.content import ClipData, ClipboardManager, Context context = fragment.getParentActivity() clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) clip = ClipData.newPlainText("Summary", summary_text) clipboard.setPrimaryClip(clip) BulletinHelper.show_success("📋 Сводка скопирована в буфер обмена") except Exception as e: BulletinHelper.show_error("Ошибка при попытке поделиться") def _show_provider_selector(self): try: fragment = get_last_fragment() if not fragment or not hasattr(fragment, 'getParentActivity'): BulletinHelper.show_error(locali.get_string("SELECTOR_ERROR")) return context = fragment.getParentActivity() if not context: BulletinHelper.show_error(locali.get_string("SELECTOR_ERROR")) return builder = AlertDialogBuilder(context, AlertDialogBuilder.ALERT_TYPE_MESSAGE) builder.set_title(locali.get_string("SELECT_PROVIDER_TITLE")) def on_provider_selected(dialog_builder, which): self._select_provider(which) dialog_builder.dismiss() builder.set_items(PROVIDER_TYPES, on_provider_selected) builder.show() except Exception as e: BulletinHelper.show_error(locali.get_string("SELECTOR_ERROR")) def _show_language_selector(self): try: fragment = get_last_fragment() if not fragment or not hasattr(fragment, 'getParentActivity'): BulletinHelper.show_error(locali.get_string("SELECTOR_ERROR")) return context = fragment.getParentActivity() if not context: BulletinHelper.show_error(locali.get_string("SELECTOR_ERROR")) return current_lang_index = self.get_setting("summary_language", 0) try: current_lang_index = int(current_lang_index) except (ValueError, TypeError): current_lang_index = 0 lang_items = ["🇷🇺 Русский", "🇬🇧 English"] builder = AlertDialogBuilder(context, AlertDialogBuilder.ALERT_TYPE_MESSAGE) builder.set_title(locali.get_string("SELECT_LANGUAGE_TITLE")) def on_language_selected(dialog_builder, which): self._select_language(which) dialog_builder.dismiss() builder.set_items(lang_items, on_language_selected) builder.show() except Exception as e: BulletinHelper.show_error(locali.get_string("SELECTOR_ERROR")) def _show_summary_bottom_sheet(self, summary_text: str, message_count: int): try: fragment = get_last_fragment() if not fragment or not hasattr(fragment, 'getParentActivity'): BulletinHelper.show_error("Не удалось показать сводку") return context = fragment.getParentActivity() if not context: BulletinHelper.show_error("Не удалось показать сводку") return from org.telegram.ui.ActionBar import BottomSheet, Theme from android.widget import LinearLayout, TextView, ScrollView, FrameLayout from android.view import Gravity, View from android.util import TypedValue from org.telegram.ui.Components import LayoutHelper from org.telegram.messenger import AndroidUtilities from android.graphics.drawable import GradientDrawable from android.graphics import Color sheet = BottomSheet(context, False) root_layout = LinearLayout(context) root_layout.setOrientation(LinearLayout.VERTICAL) root_layout.setPadding(AndroidUtilities.dp(20), AndroidUtilities.dp(16), AndroidUtilities.dp(20), AndroidUtilities.dp(20)) try: root_layout.setBackgroundColor(Theme.getColor(Theme.key_dialogBackground)) except Exception: pass title_view = TextView(context) title_view.setTypeface(AndroidUtilities.bold()) title_view.setGravity(Gravity.LEFT) title_view.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 20) title_view.setText(locali.get_string('DIALOG_TITLE')) try: title_view.setTextColor(Theme.getColor(Theme.key_dialogTextBlack)) except Exception: pass root_layout.addView(title_view, LayoutHelper.createLinear(-1, -2, Gravity.LEFT, 0, 0, 0, 12)) tags_container = LinearLayout(context) tags_container.setOrientation(LinearLayout.HORIZONTAL) tags_container.setGravity(Gravity.LEFT) def create_tag(text: str, bg_color: int): tag_frame = FrameLayout(context) tag_bg = GradientDrawable() tag_bg.setCornerRadius(AndroidUtilities.dp(12)) tag_bg.setColor(bg_color) tag_frame.setBackground(tag_bg) tag_text = TextView(context) tag_text.setText(text) tag_text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) tag_text.setTypeface(AndroidUtilities.bold()) try: tag_text.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlueText)) except Exception: tag_text.setTextColor(Color.parseColor("#3390EC")) tag_text.setPadding(AndroidUtilities.dp(10), AndroidUtilities.dp(4), AndroidUtilities.dp(10), AndroidUtilities.dp(4)) tag_frame.addView(tag_text) return tag_frame lang_index = self.get_setting("summary_language", 0) try: lang_index = int(lang_index) except (ValueError, TypeError): lang_index = 0 lang_display = "RU" if lang_index == 0 else "EN" try: tag_bg_color = Theme.getColor(Theme.key_chat_inLoader) & 0x30FFFFFF | 0x20000000 except Exception: tag_bg_color = Color.parseColor("#E8F4FC") lang_tag = create_tag(lang_display, tag_bg_color) tags_container.addView(lang_tag, LayoutHelper.createLinear(-2, -2, 0, 0, 8, 0)) provider_type = self.get_setting("provider_type", 0) try: provider_type = int(provider_type) except (ValueError, TypeError): provider_type = 0 if provider_type == 1: provider_display = "OpenAI" else: model_idx = self._validate_model_index(self.get_setting("model_selection", 1)) provider_display = MODEL_DISPLAY_NAMES[model_idx].split()[0] model_tag = create_tag(provider_display, tag_bg_color) tags_container.addView(model_tag, LayoutHelper.createLinear(-2, -2)) root_layout.addView(tags_container, LayoutHelper.createLinear(-1, -2, 0, 0, 0, 8)) message_info = TextView(context) message_info.setText(f"Проанализировано сообщений: {message_count}") message_info.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) try: message_info.setTextColor(Theme.getColor(Theme.key_windowBackgroundWhiteGrayText)) except Exception: message_info.setTextColor(Color.parseColor("#808080")) root_layout.addView(message_info, LayoutHelper.createLinear(-1, -2, 0, 0, 0, 12)) body_scroll = ScrollView(context) body_scroll.setVerticalScrollBarEnabled(False) body_scroll.setPadding(AndroidUtilities.dp(4), 0, AndroidUtilities.dp(4), 0) body_tv = TextView(context) try: parsed = parse_markdown(summary_text) body_tv.setText(parsed.text) except Exception: body_tv.setText(summary_text) body_tv.setTextIsSelectable(True) body_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15) try: body_tv.setTextColor(Theme.getColor(Theme.key_dialogTextBlack)) except Exception: pass try: body_tv.setLineSpacing(AndroidUtilities.dp(4), 1.15) except Exception: pass body_scroll.addView(body_tv) root_layout.addView(body_scroll, LayoutHelper.createLinear(-1, 0, 1.0)) divider = View(context) try: divider_color = Theme.getColor(Theme.key_divider) except Exception: divider_color = Color.parseColor("#E0E0E0") divider.setBackgroundColor(divider_color) root_layout.addView(divider, LayoutHelper.createLinear(-1, 1, 0, 16, 0, 12)) def create_action_button(icon_res: str, on_click): btn_frame = FrameLayout(context) btn_bg = GradientDrawable() btn_bg.setCornerRadius(AndroidUtilities.dp(20)) try: btn_bg.setColor(Theme.getColor(Theme.key_chat_inLoader) & 0x15FFFFFF | 0x0A000000) except Exception: btn_bg.setColor(Color.parseColor("#F5F5F5")) btn_frame.setBackground(btn_bg) btn_frame.setPadding(AndroidUtilities.dp(16), AndroidUtilities.dp(10), AndroidUtilities.dp(16), AndroidUtilities.dp(10)) btn_frame.setClickable(True) btn_frame.setFocusable(True) btn_content = LinearLayout(context) btn_content.setOrientation(LinearLayout.HORIZONTAL) btn_content.setGravity(Gravity.CENTER) try: icon_id = R.drawable.__getattribute__(R.drawable, icon_res) icon_view = TextView(context) icon_view.setCompoundDrawablesWithIntrinsicBounds(icon_id, 0, 0, 0) icon_view.setCompoundDrawablePadding(AndroidUtilities.dp(4)) except Exception: icon_view = TextView(context) btn_content.addView(icon_view) btn_frame.addView(btn_content) btn_frame.setOnClickListener(OnClickListener(lambda *_: on_click(btn_frame))) return btn_frame actions_row = LinearLayout(context) actions_row.setOrientation(LinearLayout.HORIZONTAL) actions_row.setGravity(Gravity.CENTER_VERTICAL) def on_repeat(v): try: sheet.dismiss() dialog_id = self._get_current_dialog_id() if dialog_id: BulletinHelper.show_info("Обновляю сводку...") self.last_summary = None self.last_message_count = 0 run_on_queue(lambda: self._fetch_and_summarize_for_dialog(dialog_id)) else: BulletinHelper.show_error("Не удалось определить текущий чат") except Exception as e: pass def on_copy(v): try: from android.content import ClipData, ClipboardManager, Context clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) clip = ClipData.newPlainText("Unread summary", summary_text) clipboard.setPrimaryClip(clip) BulletinHelper.show_success("Скопировано в буфер обмена") except Exception as copy_error: BulletinHelper.show_error("Ошибка копирования") def on_more(v): try: self._show_dots_menu(v, summary_text) except Exception as e: BulletinHelper.show_error("Ошибка открытия меню") def create_rounded_button(icon_res: int, text: str, on_click): from androidx.core.content import ContextCompat btn_frame = FrameLayout(context) btn_bg = GradientDrawable() btn_bg.setCornerRadius(AndroidUtilities.dp(18)) try: bg_color = Theme.getColor(Theme.key_chat_inLoader) & 0x20FFFFFF | 0x10000000 except Exception: bg_color = Color.parseColor("#F0F0F0") btn_bg.setColor(bg_color) try: from android.graphics.drawable import RippleDrawable from android.content.res import ColorStateList ripple_color = ColorStateList.valueOf(Color.parseColor("#40000000")) ripple_drawable = RippleDrawable(ripple_color, btn_bg, None) btn_frame.setBackground(ripple_drawable) except Exception: btn_frame.setBackground(btn_bg) btn_layout = LinearLayout(context) btn_layout.setOrientation(LinearLayout.HORIZONTAL) btn_layout.setGravity(Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL) btn_layout.setPadding(AndroidUtilities.dp(14), AndroidUtilities.dp(10), AndroidUtilities.dp(14), AndroidUtilities.dp(10)) btn_layout.setMinimumHeight(AndroidUtilities.dp(40)) if icon_res: from android.widget import ImageView icon_view = ImageView(context) icon_view.setScaleType(ImageView.ScaleType.FIT_CENTER) try: from android.graphics import PorterDuff icon_drawable = ContextCompat.getDrawable(context, icon_res) icon_drawable.setColorFilter(Theme.getColor(Theme.key_dialogTextBlack), PorterDuff.Mode.SRC_IN) icon_view.setImageDrawable(icon_drawable) except Exception: icon_view.setImageResource(icon_res) btn_layout.addView(icon_view, LayoutHelper.createLinear(16, 16, Gravity.CENTER_VERTICAL)) if text: spacer = View(context) btn_layout.addView(spacer, LayoutHelper.createLinear(6, 0)) if text: label_text = TextView(context) label_text.setText(text) label_text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14) label_text.setGravity(Gravity.CENTER_VERTICAL) try: label_text.setTextColor(Theme.getColor(Theme.key_dialogTextBlack)) except Exception: label_text.setTextColor(Color.parseColor("#000000")) btn_layout.addView(label_text, LayoutHelper.createLinear(-2, -2, Gravity.CENTER_VERTICAL)) btn_frame.addView(btn_layout) btn_frame.setClickable(True) btn_frame.setFocusable(True) btn_frame.setOnClickListener(OnClickListener(lambda *_: on_click(btn_frame))) return btn_frame repeat_btn_frame = create_rounded_button(R.drawable.msg_retry, "Повторить", on_repeat) copy_btn_frame = create_rounded_button(R.drawable.msg_copy, "Копировать", on_copy) more_btn_frame = create_rounded_button(R.drawable.ic_ab_other, "", on_more) actions_row.addView(repeat_btn_frame, LayoutHelper.createLinear(-2, -2, Gravity.CENTER_VERTICAL, 0, 0, 6, 0)) actions_row.addView(copy_btn_frame, LayoutHelper.createLinear(-2, -2, Gravity.CENTER_VERTICAL, 0, 0, 6, 0)) actions_row.addView(more_btn_frame, LayoutHelper.createLinear(-2, -2, Gravity.CENTER_VERTICAL, 0, 0, 0, 0)) root_layout.addView(actions_row, LayoutHelper.createLinear(-1, -2, 0, 0, 0, 12)) close_btn_frame = FrameLayout(context) close_btn_bg = GradientDrawable() close_btn_bg.setCornerRadius(AndroidUtilities.dp(10)) try: btn_color = Theme.getColor(Theme.key_windowBackgroundWhiteBlueText) except Exception: btn_color = Color.parseColor("#3390EC") close_btn_bg.setColor(btn_color) try: from android.graphics.drawable import RippleDrawable from android.content.res import ColorStateList ripple_color = ColorStateList.valueOf(Color.parseColor("#30FFFFFF")) ripple_drawable = RippleDrawable(ripple_color, close_btn_bg, None) close_btn_frame.setBackground(ripple_drawable) except Exception: close_btn_frame.setBackground(close_btn_bg) close_btn_frame.setPadding(0, AndroidUtilities.dp(14), 0, AndroidUtilities.dp(14)) close_btn_frame.setClickable(True) close_btn_frame.setFocusable(True) close_btn_text = TextView(context) close_btn_text.setText("Закрыть сводку") close_btn_text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16) close_btn_text.setTypeface(AndroidUtilities.bold()) close_btn_text.setGravity(Gravity.CENTER) close_btn_text.setTextColor(Color.WHITE) close_btn_frame.addView(close_btn_text, FrameLayout.LayoutParams(-1, -2)) close_btn_frame.setOnClickListener(OnClickListener(lambda *_: sheet.dismiss())) root_layout.addView(close_btn_frame, LayoutHelper.createLinear(-1, -2, 0, 0, 0, 0)) sheet.setCustomView(root_layout) sheet.show() except Exception as e: pass def _show_summary_dialog(self, summary_text: str, message_count: int): try: fragment = get_last_fragment() if not fragment or not hasattr(fragment, 'getParentActivity'): BulletinHelper.show_error("Не удалось показать сводку") return context = fragment.getParentActivity() if not context: BulletinHelper.show_error("Не удалось показать сводку") return builder = AlertDialogBuilder(context, AlertDialogBuilder.ALERT_TYPE_MESSAGE) title = f"📝 Сводка ({message_count} сообщений)" builder.set_title(title) builder.set_message(summary_text) builder.set_message_text_view_clickable(True) def copy_to_clipboard(dialog_builder, which): try: from android.content import ClipData, ClipboardManager, Context clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) clip = ClipData.newPlainText("Сводка непрочитанных", summary_text) clipboard.setPrimaryClip(clip) BulletinHelper.show_success("Скопировано в буфер обмена") except Exception as copy_error: BulletinHelper.show_error("Ошибка копирования") builder.set_neutral_button("Закрыть", lambda b, w: b.dismiss()) builder.set_positive_button("Копировать", copy_to_clipboard) builder.set_cancelable(True) builder.show() BulletinHelper.show_success(f"Проанализировано {message_count} сообщений") except Exception as e: BulletinHelper.show_error(f"Ошибка: {str(e)}") def _show_error_in_cell(self, unread_cell, error_text: str): def update_cell(): try: text_view = get_private_field(unread_cell, "textView") if text_view: text_view.setText(error_text) else: BulletinHelper.show_error(error_text) except Exception as e: BulletinHelper.show_error(error_text) run_on_ui_thread(update_cell) def _perform_click_vibration(self, view): try: context = None try: context = view.getContext() while context and not hasattr(context, 'getSystemService'): if hasattr(context, 'getBaseContext'): context = context.getBaseContext() else: break except Exception: pass if not context: fragment = get_last_fragment() if fragment and hasattr(fragment, 'getParentActivity'): context = fragment.getParentActivity() if not context: return try: from java.lang import Class as JClass HFC = JClass.forName("android.view.HapticFeedbackConstants") haptic_constant = getattr(HFC, "KEYBOARD_TAP", getattr(HFC, "VIRTUAL_KEY", 1)) if hasattr(view, 'performHapticFeedback'): view.performHapticFeedback(haptic_constant) return except Exception as haptic_error: pass try: vibrator = context.getSystemService("vibrator") if vibrator: try: from java.lang import Class as JClass VibrationEffect = JClass.forName("android.os.VibrationEffect") effect = VibrationEffect.createOneShot(20, 120) vibrator.vibrate(effect) except Exception: vibrator.vibrate(20) except Exception as vib_error: pass except Exception as e: pass 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: pass def _show_api_key_dialog(self, view): try: fragment = get_last_fragment() if not fragment or not hasattr(fragment, 'getParentActivity'): BulletinHelper.show_error("Cannot get current context") return context = fragment.getParentActivity() if not context: BulletinHelper.show_error("Cannot get current context") return from org.telegram.messenger import AndroidUtilities current_value = self.get_setting("gemini_api_key", "") edit_text = EditTextBoldCursor(context) edit_text.setText(current_value) edit_text.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) edit_text.setHint(locali.get_string("API_KEY_SUBTEXT")) edit_text.setSingleLine(True) try: from android.graphics.drawable import ColorDrawable from android.content.res import ColorStateList from android.graphics import Color edit_text.setTextColor(Theme.getColor(Theme.key_dialogTextBlack)) edit_text.setHintTextColor(Theme.getColor(Theme.key_dialogTextGray3)) edit_text.setLineColors( Theme.getColor(Theme.key_dialogInputField), Theme.getColor(Theme.key_dialogInputFieldActivated), Theme.getColor(Theme.key_text_RedBold) ) edit_text.setCursorColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlackText)) edit_text.setBackground(ColorDrawable(0)) edit_text.setBackgroundTintList(ColorStateList.valueOf(Color.TRANSPARENT)) except Exception: pass padding_h = AndroidUtilities.dp(20) padding_v = AndroidUtilities.dp(12) edit_text.setPadding(padding_h, padding_v, padding_h, padding_v) builder = AlertDialogBuilder(context, AlertDialogBuilder.ALERT_TYPE_MESSAGE) builder.set_title(locali.get_string("API_KEY_INPUT")) builder.set_view(edit_text) def on_ok_click(dialog_builder, which): new_value = str(edit_text.getText()).strip() self.set_setting("gemini_api_key", new_value) dialog_builder.dismiss() BulletinHelper.show_success("API ключ сохранен" if locali.language == "ru" else "API key saved") builder.set_positive_button("OK", on_ok_click) builder.set_negative_button(locali.get_string("MENU_CANCEL") if locali.language == "ru" else "Cancel", lambda b, w: b.dismiss()) builder.show() def focus_edit(): edit_text.requestFocus() from android.view.inputmethod import InputMethodManager from android.content import Context imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) if imm: imm.showSoftInput(edit_text, InputMethodManager.SHOW_IMPLICIT) from android_utils import R run_on_ui_thread(lambda: edit_text.post(R(focus_edit))) except Exception as e: BulletinHelper.show_error(f"Error: {str(e)}") def _show_openai_api_key_dialog(self, view): try: fragment = get_last_fragment() if not fragment or not hasattr(fragment, 'getParentActivity'): BulletinHelper.show_error("Cannot get current context") return context = fragment.getParentActivity() if not context: BulletinHelper.show_error("Cannot get current context") return from org.telegram.messenger import AndroidUtilities current_value = self.get_setting("openai_api_key", "") edit_text = EditTextBoldCursor(context) edit_text.setText(current_value) edit_text.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) edit_text.setHint(locali.get_string("OPENAI_API_KEY_INPUT")) edit_text.setSingleLine(True) try: from android.graphics.drawable import ColorDrawable from android.content.res import ColorStateList from android.graphics import Color edit_text.setTextColor(Theme.getColor(Theme.key_dialogTextBlack)) edit_text.setHintTextColor(Theme.getColor(Theme.key_dialogTextGray3)) edit_text.setLineColors( Theme.getColor(Theme.key_dialogInputField), Theme.getColor(Theme.key_dialogInputFieldActivated), Theme.getColor(Theme.key_text_RedBold) ) edit_text.setCursorColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlackText)) edit_text.setBackground(ColorDrawable(0)) edit_text.setBackgroundTintList(ColorStateList.valueOf(Color.TRANSPARENT)) except Exception: pass padding_h = AndroidUtilities.dp(20) padding_v = AndroidUtilities.dp(12) edit_text.setPadding(padding_h, padding_v, padding_h, padding_v) builder = AlertDialogBuilder(context, AlertDialogBuilder.ALERT_TYPE_MESSAGE) builder.set_title(locali.get_string("OPENAI_API_KEY_INPUT")) builder.set_view(edit_text) def on_ok_click(dialog_builder, which): new_value = str(edit_text.getText()).strip() self.set_setting("openai_api_key", new_value) dialog_builder.dismiss() BulletinHelper.show_success("API ключ сохранен" if locali.language == "ru" else "API key saved") builder.set_positive_button("OK", on_ok_click) builder.set_negative_button(locali.get_string("MENU_CANCEL") if locali.language == "ru" else "Cancel", lambda b, w: b.dismiss()) builder.show() def focus_edit(): edit_text.requestFocus() from android.view.inputmethod import InputMethodManager from android.content import Context imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) if imm: imm.showSoftInput(edit_text, InputMethodManager.SHOW_IMPLICIT) from android_utils import R run_on_ui_thread(lambda: edit_text.post(R(focus_edit))) except Exception as e: BulletinHelper.show_error(f"Error: {str(e)}") def _show_prompt_dialog(self, view): try: fragment = get_last_fragment() if not fragment or not hasattr(fragment, 'getParentActivity'): BulletinHelper.show_error("Cannot get current context") return context = fragment.getParentActivity() if not context: BulletinHelper.show_error("Cannot get current context") return from org.telegram.messenger import AndroidUtilities from android.widget import ScrollView from android.util import TypedValue default_prompt = self._get_default_prompt() current_value = self.get_setting("summary_prompt", default_prompt) scroll_view = ScrollView(context) edit_text = EditTextBoldCursor(context) edit_text.setText(current_value) edit_text.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) edit_text.setHint("Введите промпт для суммаризации..." if locali.language == "ru" else "Enter summarization prompt...") edit_text.setMaxLines(15) edit_text.setSingleLine(False) edit_text.setVerticalScrollBarEnabled(True) edit_text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14) try: from android.graphics.drawable import ColorDrawable from android.content.res import ColorStateList from android.graphics import Color edit_text.setTextColor(Theme.getColor(Theme.key_dialogTextBlack)) edit_text.setHintTextColor(Theme.getColor(Theme.key_dialogTextGray3)) edit_text.setLineColors( Theme.getColor(Theme.key_dialogInputField), Theme.getColor(Theme.key_dialogInputFieldActivated), Theme.getColor(Theme.key_text_RedBold) ) edit_text.setCursorColor(Theme.getColor(Theme.key_windowBackgroundWhiteBlackText)) edit_text.setBackground(ColorDrawable(0)) edit_text.setBackgroundTintList(ColorStateList.valueOf(Color.TRANSPARENT)) except Exception: pass padding = AndroidUtilities.dp(20) edit_text.setPadding(padding, padding, padding, padding) scroll_view.addView(edit_text) builder = AlertDialogBuilder(context, AlertDialogBuilder.ALERT_TYPE_MESSAGE) builder.set_title(locali.get_string("SUMMARY_PROMPT_INPUT")) builder.set_view(scroll_view) def on_ok_click(dialog_builder, which): new_value = str(edit_text.getText()).strip() if new_value: self.set_setting("summary_prompt", new_value) dialog_builder.dismiss() BulletinHelper.show_success("Промпт сохранен" if locali.language == "ru" else "Prompt saved") else: BulletinHelper.show_error("Промпт не может быть пустым" if locali.language == "ru" else "Prompt cannot be empty") builder.set_positive_button("OK", on_ok_click) builder.set_negative_button(locali.get_string("MENU_CANCEL") if locali.language == "ru" else "Cancel", lambda b, w: b.dismiss()) def on_reset_click(dialog_builder, which): edit_text.setText(default_prompt) BulletinHelper.show_info("Промпт сброшен" if locali.language == "ru" else "Prompt reset") builder.set_neutral_button("Сброс" if locali.language == "ru" else "Reset", on_reset_click) builder.show() def focus_edit(): edit_text.requestFocus() edit_text.setSelection(len(str(edit_text.getText()))) from android.view.inputmethod import InputMethodManager from android.content import Context imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) if imm: imm.showSoftInput(edit_text, InputMethodManager.SHOW_IMPLICIT) from android_utils import R run_on_ui_thread(lambda: edit_text.post(R(focus_edit))) except Exception as e: BulletinHelper.show_error(f"Error: {str(e)}") def create_settings(self): provider_type = self.get_setting("provider_type", 0) try: provider_type = int(provider_type) except (ValueError, TypeError): provider_type = 0 items = [ Header(text=locali.get_string("AI_SETTINGS_HEADER")), Selector( key="provider_type", text=locali.get_string("PROVIDER_SELECTOR"), icon="msg_bot", default=provider_type, items=PROVIDER_TYPES ), ] if provider_type == 0: items.extend([ Text( text=locali.get_string("API_KEY_INPUT"), icon="msg_pin_code", on_click=self._show_api_key_dialog ), 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(), Selector( key="model_selection", text=locali.get_string("MODEL_SELECTOR"), icon="msg_language_solar", default=1, items=MODEL_DISPLAY_NAMES ), ]) else: items.extend([ Header(text=locali.get_string("OPENAI_SECTION")), Text( text=locali.get_string("OPENAI_API_KEY_INPUT"), icon="msg_pin_code", on_click=self._show_openai_api_key_dialog ), Input( key="openai_base_url", text=locali.get_string("OPENAI_BASE_URL_INPUT"), icon="msg_link", default="https://api.openai.com" ), Input( key="openai_model", text=locali.get_string("OPENAI_MODEL_INPUT"), icon="msg_language_solar", default="gpt-5-chat" ), Input( key="openai_api_key_header", text=locali.get_string("OPENAI_HEADER_NAME_INPUT"), icon="msg_settings", default="Authorization" ), Input( key="openai_api_key_prefix", text=locali.get_string("OPENAI_HEADER_PREFIX_INPUT"), icon="msg_settings", default="Bearer" ), Divider(), ]) items.extend([ Selector( key="summary_language", text="Summary Language" if locali.language == "en" else "Язык сводки", icon="msg_translate", default=0, items=["🇷🇺 Русский", "🇬🇧 English"] ), 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="256000", subtext=locali.get_string("MAX_TOKENS_SUBTEXT") ), Input( key="message_limit", text=locali.get_string("MESSAGE_LIMIT_INPUT"), icon="msg_data", default="0", subtext=locali.get_string("MESSAGE_LIMIT_SUBTEXT") ), Divider(), Header(text=locali.get_string("PROMPT_HEADER")), Text( text=locali.get_string("SUMMARY_PROMPT_INPUT"), icon="msg_edit", on_click=self._show_prompt_dialog ), ]) return items