diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80d20ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +references/ +__pycache__/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/README.md b/README.md index f3febc2..1a750e4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,66 @@ -# chat-summaries +# AI Chat Summaries Plugin for exteraGram +Advanced AI-powered Telegram chat summarization plugin for [exteraGram](https://exteragram.app). + +## Features + +- **ChatGPT OAuth Integration**: Secure browser authorization with automatic tier detection (Free: `luna`, `gpt-4o-mini`; Plus/Pro/Business: `terra`, `sol`, `gpt-4o`, `o1`, `o3-mini`). +- **Custom AI Provider**: OpenAI-compatible custom endpoints (OpenRouter, DeepSeek, Together AI, local vLLM/Ollama) with **Multi-Stage Verification**: + - Stage 1: URL reachability test. + - Stage 2: Lightweight probe test (`max_tokens: 10`, thinking disabled). + - Stage 3: Error logs with one-click full sanitized request log copier. + - Stage 4: Context window token budgeting (8k to 1M) preserving up to 8,000 characters per message. +- **Built-in Connectors**: Direct API key connectors for OpenAI, Anthropic Claude (with Claude 3.7 reasoning tokens), Google Gemini, and Ollama. +- **Dual Triggers**: + 1. **Pinned Header AI Button**: Top-right AI action button next to the pinned message bar. + 2. **Unread Badge Long-Press**: Long-press on floating unread badge or unread divider in chat with auto-filled message count. + 3. **Chat Action Bar Menu**: Fallback action item for universal accessibility. +- **Animated Progress Widget**: Dynamic in-header pulsing progress indicator that shrinks `PinnedMessageView` to prevent text overlap. +- **Live Thinking Stream Preview**: Real-time streaming inspector for reasoning and thought process tokens (`reasoning_content`, `...`, `delta.thought`). Can be toggled off in settings to save mobile bandwidth. +- **Per-Chat Customization**: Pre-request bottom sheet with message count slider, style chips (Brief, Detailed, Key Highlights, Custom), and per-chat prompt override. +- **Rich Result Dialog**: Formatted Markdown rendering with Share, Copy Summary, Copy Full Prompt & Data, Regenerate, and Insert into Chat Composer. +- **Localization**: Full English (`en`) and Russian (`ru`) translations. + +## Project Structure + +``` +exteragram-chat-summaries/ +├── ai_chat_summary.plugin # Main entrypoint & metadata header +├── build.py # Standalone bundler & syntax validator +├── src/ +│ ├── config.py # Configuration constants & model tiers +│ ├── localization.py # LocalizationManager (EN/RU dictionaries) +│ ├── providers/ +│ │ ├── base.py # Base provider interface, debug logger, prompt builder +│ │ ├── oauth.py # ChatGPT OAuth handler & tier mapper +│ │ ├── custom.py # Custom provider with multi-stage verification +│ │ ├── builtin.py # OpenAI, Anthropic, Gemini, Ollama direct handlers +│ │ └── dispatcher.py # Unified dispatcher & live streaming state manager +│ ├── services/ +│ │ └── message_fetcher.py # TLRPC history pagination & media formatting +│ ├── ui/ +│ │ ├── settings.py # Dynamic settings layout & verification dialogs +│ │ ├── pre_request.py # Pre-request bottom sheet & prompt customizer +│ │ ├── progress_widget.py # In-header progress widget & pinned view shrinker +│ │ ├── thinking_sheet.py # Live streaming reasoning viewer +│ │ └── summary_dialog.py # Markdown result presentation & error log viewer +│ └── hooks/ +│ ├── pinned_hook.py # PinnedMessageView top bar hook +│ └── unread_hook.py # ChatUnreadCell & floating badge long-press hook +├── tests/ +│ └── test_plugin.py # Unit & simulation test suite +└── dist/ + └── ai_chat_summary.plugin # Bundled single-file distributable artifact +``` + +## Bundling & Testing + +To bundle into a single-file distributable artifact: +```bash +python3 build.py +``` + +To run the unit and simulation test suite: +```bash +python3 -m unittest discover -s tests -v +``` diff --git a/references/2026-09-10 10.20.58.jpg b/references/2026-09-10 10.20.58.jpg new file mode 100644 index 0000000..72b3893 Binary files /dev/null and b/references/2026-09-10 10.20.58.jpg differ diff --git a/references/chat_summary.plugin b/references/chat_summary.plugin new file mode 100644 index 0000000..3562a4a --- /dev/null +++ b/references/chat_summary.plugin @@ -0,0 +1,884 @@ +from typing import Any, List + +from base_plugin import BasePlugin, MenuItemData, MenuItemType +from client_utils import ( + get_last_fragment, get_messages_controller, + run_on_queue, send_request, PLUGINS_QUEUE, +) +from android_utils import run_on_ui_thread, log, OnClickListener, copy_to_clipboard +from ui.settings import Header, Divider, Selector, Input, Text +from ui.bulletin import BulletinHelper +from org.telegram.messenger import AndroidUtilities +from org.telegram.ui.ActionBar import Theme + +__id__ = "chat_summary" +__name__ = "Chat Summary" +__description__ = "Саммари любого чата через AI — OpenAI, Anthropic, Gemini, Ollama или свой endpoint." +__author__ = "@aaxnet" +__version__ = "2.2.0" # Обновили версию в связи с добавлением дебага и ретраев +__icon__ = "KOSHAKIEBANIYE/44" +__app_version__ = ">=12.5.1" +__sdk_version__ = ">=1.4.3.3" +__requirements__ = ["requests"] + +PROVIDER_OPENAI = 0 +PROVIDER_ANTHROPIC = 1 +PROVIDER_GEMINI = 2 +PROVIDER_OLLAMA = 3 +PROVIDER_CUSTOM = 4 + +PROVIDERS = [ + "OpenAI (GPT-4o)", + "Anthropic (Claude)", + "Gemini", + "Ollama (local)", + "Custom endpoint", +] + +DEFAULT_MODELS = [ + "gpt-4o", + "claude-sonnet-4-5", + "gemini-2.0-flash", + "llama3", + "", +] + +STYLE_BRIEF = 0 +STYLE_DETAILED = 1 +STYLE_BULLETS = 2 + +STYLES = ["Краткий", "Подробный", "По пунктам"] +STYLE_HINTS = [ + "2–3 sentences о главном", + "Все темы и детали", + "Список ключевых моментов", +] + +LANG_AUTO = 0 +LANG_RU = 1 +LANG_EN = 2 + +LANGS = ["Авто", "Русский", "English"] + +MSG_COUNTS = [50, 100, 200, 500] + +MAX_CONTENT_CHARS = 48000 + +KEY_PREFIXES = { + PROVIDER_OPENAI: "sk-", + PROVIDER_ANTHROPIC: "sk-ant-", + PROVIDER_GEMINI: "AIza", + PROVIDER_CUSTOM: "sk-", +} + +PROVIDER_MODEL_LABELS = { + PROVIDER_OPENAI: "OpenAI", + PROVIDER_ANTHROPIC: "Anthropic", + PROVIDER_GEMINI: "Gemini", + PROVIDER_OLLAMA: "Ollama", + PROVIDER_CUSTOM: "Custom", +} + + +def check_key_format(provider, api_key): + """Returns (is_valid, hint_text) for a quick format check, not a real auth check.""" + api_key = (api_key or "").strip() + if provider == PROVIDER_OLLAMA: + return True, "Ключ не требуется" + if not api_key: + return False, "Ключ не указан" + prefix = KEY_PREFIXES.get(provider) + if prefix and not api_key.startswith(prefix): + return False, "Похоже на неверный формат (ожидается " + prefix + "...)" + if len(api_key) < 16: + return False, "Слишком короткий ключ" + return True, "Формат похож на правильный" + + +def get_theme_color(key, fallback): + try: + return Theme.getColor(getattr(Theme, key)) + except Exception: + return fallback + + +def build_system_prompt(style, lang): + lang_instructions = { + LANG_AUTO: "Respond in the same language as the conversation.", + LANG_RU: "Respond in Russian.", + LANG_EN: "Respond in English.", + } + style_instructions = { + STYLE_BRIEF: "Write a concise 2-3 sentence summary of the key points.", + STYLE_DETAILED: "Write a detailed summary covering all important topics discussed.", + STYLE_BULLETS: "Write a bullet-point list of the main topics and decisions.", + } + lang_part = lang_instructions.get(lang, "") + style_part = style_instructions.get(style, "") + return ( + "You are a helpful assistant that summarizes Telegram chat conversations. " + + style_part + " " + lang_part + " " + "Focus only on content. Do not include meta-commentary." + ) + + +def parse_java_list(java_list): + result = {} + if not java_list: + return result + try: + size = java_list.size() + for i in range(size): + item = java_list.get(i) + try: + result[int(item.id)] = item + except Exception: + pass + except Exception: + pass + return result + + +def get_sender_name(raw_msg, user_map, chat_map): + fid = getattr(raw_msg, "from_id", None) + if fid is None: + fid = getattr(raw_msg, "peer_id", None) + if fid is None: + return "Unknown" + + uid = getattr(fid, "user_id", None) + channel_id = getattr(fid, "channel_id", None) + chat_id = getattr(fid, "chat_id", None) + + if uid: + uid = int(uid) + user = user_map.get(uid) + if user: + first = getattr(user, "first_name", "") or "" + last = getattr(user, "last_name", "") or "" + name = (first + " " + last).strip() + return name if name else "User" + str(uid) + return "User" + str(uid) + + if channel_id: + channel_id = int(channel_id) + chat = chat_map.get(channel_id) + if chat: + return getattr(chat, "title", None) or "Channel" + str(channel_id) + return "Channel" + str(channel_id) + + if chat_id: + chat_id = int(chat_id) + chat = chat_map.get(chat_id) + if chat: + return getattr(chat, "title", None) or "Chat" + str(chat_id) + return "Chat" + str(chat_id) + + return "Unknown" + + +def get_message_text(msg, raw_msg): + mt = getattr(msg, "messageText", None) + if mt: + return str(mt) + raw_text = getattr(raw_msg, "message", None) + if raw_text: + return str(raw_text) + return "" + + +def build_transcript(messages, users=None, chats=None): + user_map = parse_java_list(users) + chat_map = parse_java_list(chats) + + size = messages.size() if hasattr(messages, "size") else len(messages) + lines = [] + used = 0 + + for i in range(size - 1, -1, -1): + try: + msg = messages.get(i) if hasattr(messages, "get") else messages[i] + raw = getattr(msg, "messageOwner", msg) + + cls = type(raw).__name__ + if "Service" in cls or "Empty" in cls: + continue + + sender = get_sender_name(raw, user_map, chat_map) + text = get_message_text(msg, raw) + + if text.strip(): + lines.append(sender + ": " + text) + used += 1 + except Exception: + continue + + full_text = "\n".join(lines) + if len(full_text) > MAX_CONTENT_CHARS: + full_text = "...[обрезано]\n" + full_text[-MAX_CONTENT_CHARS:] + + return full_text, used + + +PROVIDER_EMOJIS = ["🤖", "🧠", "✨", "💻", "🔗"] + +LONG_SUMMARY_THRESHOLD = 600 # chars, above this we add a scroll + "via model" footer is still shown either way + + +def show_result(act, summary, msg_count, provider, model): + try: + from ui.alert import AlertDialogBuilder + from android.widget import LinearLayout, TextView, ScrollView + from android.view import Gravity + from android.util import TypedValue + + dp = AndroidUtilities.dp + + provider_label = PROVIDER_MODEL_LABELS.get(provider, "AI") + model_label = model.strip() if model and model.strip() else ( + DEFAULT_MODELS[provider] if 0 <= provider < len(DEFAULT_MODELS) else "" + ) + footer_text = "via " + provider_label + (" · " + model_label if model_label else "") + + def on_copy(b, w): + copy_to_clipboard(summary) + BulletinHelper.show_info("Скопировано ✓") + b.dismiss() + + def on_share(b, w): + try: + from android.content import Intent + intent = Intent(Intent.ACTION_SEND) + intent.setType("text/plain") + intent.putExtra(Intent.EXTRA_TEXT, summary) + act.startActivity(Intent.createChooser(intent, "Поделиться саммари")) + except Exception as ex: + log("chat_summary: share error: " + str(ex)) + b.dismiss() + + root = LinearLayout(act) + root.setOrientation(LinearLayout.VERTICAL) + root.setPadding(dp(24), dp(8), dp(24), dp(4)) + + scroll = ScrollView(act) + max_height_px = dp(360) + + body = TextView(act) + body.setText(summary) + body.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15) + body.setTextColor(get_theme_color("key_dialogTextBlack", 0xFF212121)) + body.setLineSpacing(dp(2), 1.0) + scroll.addView(body) + + lp_scroll = LinearLayout.LayoutParams(-1, -2) + if len(summary) > LONG_SUMMARY_THRESHOLD: + lp_scroll = LinearLayout.LayoutParams(-1, max_height_px) + root.addView(scroll, lp_scroll) + + if len(summary) > LONG_SUMMARY_THRESHOLD: + hint = TextView(act) + hint.setText("↕ прокрути для остального текста") + hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11) + hint.setGravity(Gravity.CENTER) + hint.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888)) + lp_hint = LinearLayout.LayoutParams(-1, -2) + lp_hint.topMargin = dp(6) + root.addView(hint, lp_hint) + + footer = TextView(act) + footer.setText(footer_text) + footer.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11) + footer.setGravity(Gravity.CENTER) + footer.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF999999)) + lp_footer = LinearLayout.LayoutParams(-1, -2) + lp_footer.topMargin = dp(14) + lp_footer.bottomMargin = dp(4) + root.addView(footer, lp_footer) + + builder = AlertDialogBuilder(act) + builder.set_title("✨ Саммари · " + str(msg_count) + " сообщений") + builder.set_view(root) + builder.set_positive_button("Закрыть", lambda b, w: b.dismiss()) + builder.set_negative_button("Копировать", on_copy) + builder.set_neutral_button("Поделиться", on_share) + builder.show() + except Exception as e: + log("chat_summary: show_result error: " + str(e)) + try: + from ui.alert import AlertDialogBuilder + builder = AlertDialogBuilder(act) + builder.set_title("✨ Саммари · " + str(msg_count) + " сообщений") + builder.set_message(summary) + builder.set_positive_button("Закрыть", lambda b, w: b.dismiss()) + builder.set_negative_button("Копировать", lambda b, w: (copy_to_clipboard(summary), b.dismiss())) + builder.show() + except Exception as e2: + log("chat_summary: fallback dialog error: " + str(e2)) + + +def show_spinner(act, title="Анализирую"): + try: + from ui.alert import AlertDialogBuilder + from android.os import Handler, Looper + + d = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_SPINNER) + d.set_title(title + "…") + d.set_cancelable(False) + d.show() + + dots = ["", ".", "..", "..."] + state = [0, True] + handler = Handler(Looper.getMainLooper()) + + def tick(): + if not state[1]: + return + state[0] = (state[0] + 1) % len(dots) + try: + d.set_title(title + dots[state[0]]) + except Exception: + state[1] = False + return + handler.postDelayed(tick, 500) + + handler.postDelayed(tick, 500) + + original_dismiss = d.dismiss + def dismiss_and_stop(): + state[1] = False + try: + original_dismiss() + except Exception: + pass + d.dismiss = dismiss_and_stop + + return d + except Exception as e: + log("chat_summary: spinner error: " + str(e)) + return None + + +def dismiss_dialog(dlg): + if dlg is None: + return + try: + dlg.dismiss() + except Exception: + pass + + +def call_openai(api_key, model, system, content, url=None): + import requests + model = model or "gpt-4o" + url = url or "https://api.openai.com/v1/chat/completions" + resp = requests.post( + url, + headers={ + "Authorization": "Bearer " + api_key, + "Content-Type": "application/json", + }, + json={ + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": content}, + ], + }, + timeout=60, + ) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"] + + +def call_anthropic(api_key, model, system, content): + import requests + model = model or "claude-sonnet-4-5" + resp = requests.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + json={ + "model": model, + "max_tokens": 1024, + "system": system, + "messages": [{"role": "user", "content": content}], + }, + timeout=60, + ) + resp.raise_for_status() + return resp.json()["content"][0]["text"] + + +def call_gemini(api_key, model, system, content): + import requests + model = model or "gemini-2.0-flash" + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + + model + + ":generateContent?key=" + + api_key + ) + resp = requests.post( + url, + json={"contents": [{"parts": [{"text": system + "\n\n" + content}]}]}, + timeout=60, + ) + resp.raise_for_status() + return resp.json()["candidates"][0]["content"]["parts"][0]["text"] + + +def call_ollama(model, system, content): + import requests + model = model or "llama3" + resp = requests.post( + "http://localhost:11434/api/chat", + json={ + "model": model, + "stream": False, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": content}, + ], + }, + timeout=120, + ) + resp.raise_for_status() + return resp.json()["message"]["content"] + + +# ОБНОВЛЕНО: Добавлен алгоритм Exponential Backoff для обработки ошибки 429 +def call_ai(provider, api_key, model, system, content, custom_url): + import time + import random + + max_retries = 3 # Количество попыток автоматического перезапуска + delay = 2 # Начальная задержка в секундах + + for attempt in range(max_retries): + try: + if provider == PROVIDER_OPENAI: + return call_openai(api_key, model, system, content) + if provider == PROVIDER_ANTHROPIC: + return call_anthropic(api_key, model, system, content) + if provider == PROVIDER_GEMINI: + return call_gemini(api_key, model, system, content) + if provider == PROVIDER_OLLAMA: + return call_ollama(model, system, content) + if provider == PROVIDER_CUSTOM: + return call_openai(api_key, model, system, content, url=custom_url) + raise ValueError("Unknown provider: " + str(provider)) + except Exception as e: + err_msg = str(e) + # Если словили 429 (Превышение лимитов) и лимит попыток не исчерпан + if "429" in err_msg and attempt < max_retries - 1: + sleep_time = delay + random.uniform(0, 1) + log("chat_summary: Hit 429 Rate Limit. Retrying in " + str(round(sleep_time, 2)) + "s...") + time.sleep(sleep_time) + delay *= 2 # Удваиваем время ожидания для следующего шага + continue + # Во всех остальных случаях (или если попытки кончились) выбрасываем ошибку дальше + raise e + + +# ОБНОВЛЕНО: Добавлено явное и красивое описание ошибки 429 при тесте связи +def test_connection(provider, api_key, model, custom_url): + """Sends a minimal ping request, returns (ok, message).""" + try: + system = "Reply with exactly one word: OK." + content = "ping" + result = call_ai(provider, api_key, model, system, content, custom_url) + if result and result.strip(): + return True, "Подключение работает ✓" + return False, "Пустой ответ от сервера" + except Exception as e: + msg = str(e) + if "429" in msg: + return False, "Превышен лимит запросов (ошибка 429). Подожди немного." + if "401" in msg or "Unauthorized" in msg: + return False, "Неверный API ключ" + if "404" in msg: + return False, "Модель не найдена" + if "timeout" in msg.lower() or "timed out" in msg.lower(): + return False, "Таймаут — сервер не отвечает" + return False, "Ошибка: " + msg[:80] + + +class ChatSummaryPlugin(BasePlugin): + + def on_plugin_load(self): + self.last_error = "Ошибок пока не зафиксировано" # Трекер для Debug-кнопки + self.add_menu_item(MenuItemData( + menu_type=MenuItemType.CHAT_ACTION_MENU, + text="Summarize Chat", + subtext="Краткое AI-саммари", + icon="msg_info", + on_click=self.on_menu_click, + )) + + def on_plugin_unload(self): + pass + + def on_menu_click(self, context): + dialog_id = context.get("dialog_id") + if not dialog_id: + return + fragment = context.get("fragment") or get_last_fragment() + if not fragment: + return + act = fragment.getParentActivity() + if not act: + return + + provider = self.get_setting("provider", PROVIDER_OPENAI) + api_key = self.get_setting("api_key", "") or "" + if provider != PROVIDER_OLLAMA and not api_key.strip(): + BulletinHelper.show_error("Сначала укажи API ключ в настройках плагина") + return + + run_on_ui_thread(lambda: self.show_count_picker(act, dialog_id)) + + def show_count_picker(self, act, dialog_id): + try: + from android.widget import LinearLayout, TextView + from android.view import Gravity + from android.util import TypedValue + from android.graphics.drawable import GradientDrawable + from org.telegram.ui.ActionBar import BottomSheet + + dp = AndroidUtilities.dp + sheet_holder = [None] + + provider = self.get_setting("provider", PROVIDER_OPENAI) + emoji = PROVIDER_EMOJIS[provider] if 0 <= provider < len(PROVIDER_EMOJIS) else "🤖" + provider_name = PROVIDERS[provider] if 0 <= provider < len(PROVIDERS) else "AI" + + def pick(count): + if sheet_holder[0]: + sheet_holder[0].dismiss() + BulletinHelper.show_info("Загружаю " + str(count) + " сообщений…") + run_on_queue( + lambda: self.fetch_and_summarize(act, dialog_id, count), + PLUGINS_QUEUE, 0, + ) + + root = LinearLayout(act) + root.setOrientation(LinearLayout.VERTICAL) + root.setPadding(dp(20), dp(20), dp(20), dp(28)) + try: + root.setBackgroundColor(Theme.getColor(Theme.key_dialogBackground)) + except Exception: + pass + + icon_tv = TextView(act) + icon_tv.setText(emoji) + icon_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 36) + icon_tv.setGravity(Gravity.CENTER) + lp = LinearLayout.LayoutParams(-1, -2) + lp.bottomMargin = dp(8) + root.addView(icon_tv, lp) + + title = TextView(act) + title.setText("Сколько сообщений?") + title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18) + title.setGravity(Gravity.CENTER) + title.setTextColor(get_theme_color("key_dialogTextBlack", 0xFF212121)) + try: + title.setTypeface(AndroidUtilities.bold()) + except Exception: + pass + lp = LinearLayout.LayoutParams(-1, -2) + lp.bottomMargin = dp(4) + root.addView(title, lp) + + sub = TextView(act) + sub.setText("Провайдер: " + provider_name) + sub.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13) + sub.setGravity(Gravity.CENTER) + sub.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888)) + lp = LinearLayout.LayoutParams(-1, -2) + lp.bottomMargin = dp(20) + root.addView(sub, lp) + + count_hints = ["быстро", "оптимально", "подробно", "полный анализ"] + + for i, count in enumerate(MSG_COUNTS): + row = LinearLayout(act) + row.setOrientation(LinearLayout.HORIZONTAL) + row.setGravity(Gravity.CENTER_VERTICAL) + row.setPadding(dp(16), dp(13), dp(16), dp(13)) + row.setClickable(True) + row.setFocusable(True) + + bg = GradientDrawable() + bg.setCornerRadius(dp(14)) + try: + accent = Theme.getColor(Theme.key_windowBackgroundWhiteBlueHeader) + if i == 1: + bg.setColor(accent & 0x22FFFFFF | 0x15000000) + else: + bg.setColor(get_theme_color("key_windowBackgroundWhite", 0xFFF5F5F5)) + except Exception: + bg.setColor(0xFFF5F5F5) + row.setBackground(bg) + + label = TextView(act) + label.setText(str(count) + " сообщений") + label.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15) + label.setTextColor(get_theme_color("key_windowBackgroundWhiteBlackText", 0xFF212121)) + try: + if i == 1: + label.setTypeface(AndroidUtilities.bold()) + except Exception: + pass + lp_label = LinearLayout.LayoutParams(0, -2, 1.0) + row.addView(label, lp_label) + + hint = TextView(act) + hint_text = count_hints[i] if i < len(count_hints) else "" + if i == 1: + hint_text = "⭐ " + hint_text + hint.setText(hint_text) + hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12) + hint.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888)) + hint.setGravity(Gravity.END) + row.addView(hint) + + n = count + row.setOnClickListener(OnClickListener(lambda v, c=n: pick(c))) + + lp = LinearLayout.LayoutParams(-1, -2) + lp.bottomMargin = dp(8) + root.addView(row, lp) + + sheet = BottomSheet.Builder(act).setCustomView(root).create() + sheet_holder[0] = sheet + sheet.show() + + except Exception as e: + log("chat_summary: count picker error: " + str(e)) + + def fetch_and_summarize(self, act, dialog_id, count): + try: + from org.telegram.tgnet import TLRPC + + req = TLRPC.TL_messages_getHistory() + req.peer = get_messages_controller().getInputPeer(int(dialog_id)) + req.offset_id = 0 + req.offset_date = 0 + req.add_offset = 0 + req.limit = count + req.max_id = 0 + req.min_id = 0 + req.hash = 0 + + self_ref = [self] + act_ref = [act] + + def on_response(response, error): + if error: + err = getattr(error, "text", "unknown") + run_on_ui_thread(lambda: BulletinHelper.show_error("Ошибка загрузки: " + err)) + return + try: + msgs = getattr(response, "messages", None) + if not msgs: + run_on_ui_thread(lambda: BulletinHelper.show_error("Нет сообщений.")) + return + + users = getattr(response, "users", None) + chats = getattr(response, "chats", None) + text, used = build_transcript(msgs, users, chats) + + if not text.strip(): + run_on_ui_thread(lambda: BulletinHelper.show_error("Текстовых сообщений не найдено.")) + return + + spinner = [None] + + def start_spinner(): + spinner[0] = show_spinner(act_ref[0]) + + run_on_ui_thread(start_spinner) + run_on_queue( + lambda: self_ref[0].run_ai(act_ref[0], text, used, spinner), + PLUGINS_QUEUE, 0, + ) + except Exception as ex: + log("chat_summary: parse error: " + str(ex)) + run_on_ui_thread(lambda: BulletinHelper.show_error("Ошибка парсинга сообщений.")) + + send_request(req, on_response) + + except Exception as e: + log("chat_summary: fetch error: " + str(e)) + run_on_ui_thread(lambda: BulletinHelper.show_error("Не удалось начать загрузку.")) + + def run_ai(self, act, text, used, spinner): + try: + provider = self.get_setting("provider", PROVIDER_OPENAI) + api_key = self.get_setting("api_key", "") or "" + model = self.get_setting("model", "") or "" + style = self.get_setting("style", STYLE_BRIEF) + lang = self.get_setting("lang", LANG_AUTO) + custom_url = self.get_setting("custom_endpoint", "") or "" + + system = build_system_prompt(style, lang) + content = "Chat conversation to summarize:\n\n" + text + + result = call_ai(provider, api_key, model, system, content, custom_url) + + def on_done(): + dismiss_dialog(spinner[0]) + show_result(act, result, used, provider, model) + + run_on_ui_thread(on_done) + + except Exception as e: + err = str(e) + self.last_error = "Ошибка в run_ai: " + err # ОБНОВЛЕНО: запись лога + log("chat_summary: ai error: " + err) + + def on_error(): + dismiss_dialog(spinner[0]) + BulletinHelper.show_error("Ошибка AI: " + err[:120]) + + run_on_ui_thread(on_error) + + def create_settings(self): + provider = self.get_setting("provider", PROVIDER_OPENAI) + style = self.get_setting("style", STYLE_BRIEF) + api_key = self.get_setting("api_key", "") or "" + default_model = DEFAULT_MODELS[provider] if 0 <= provider < len(DEFAULT_MODELS) else "" + style_hint = STYLE_HINTS[style] if 0 <= style < len(STYLE_HINTS) else "" + + key_valid, key_hint = check_key_format(provider, api_key) + key_icon = "msg_invite" if key_valid else "msg_secret" + key_subtext = ("✓ " if key_valid else "✗ ") + key_hint + + items = [ + Header(text="AI провайдер"), + Selector( + key="provider", + text="Провайдер", + default=PROVIDER_OPENAI, + items=PROVIDERS, + icon="msg_robot", + on_change=lambda idx: self.set_setting("provider", idx, reload_settings=True), + ), + Input( + key="api_key", + text="API ключ", + subtext=key_subtext, + icon=key_icon, + default="", + on_change=lambda val: self.set_setting("api_key", val, reload_settings=True), + ), + Input( + key="model", + text="Модель", + subtext="По умолчанию: " + default_model if default_model else "Укажи название модели", + icon="msg_topic", + default="", + ), + ] + + if provider == PROVIDER_CUSTOM: + items.append(Input( + key="custom_endpoint", + text="URL endpoint", + subtext="OpenAI-совместимый адрес", + icon="msg_link", + default="", + )) + + items.append(Text( + text="Проверить подключение", + subtext="Отправит тестовый запрос к API", + icon="msg_pin_code", + on_click=lambda v: self.run_connection_test(), + )) + + # ОБНОВЛЕНО: Добавлена кнопка сохранения отладочного лога + def action_copy_debug(): + provider_str = PROVIDERS[provider] if 0 <= provider < len(PROVIDERS) else str(provider) + key_valid, key_hint = check_key_format(provider, api_key) + + debug_text = ( + "=== CHAT SUMMARY DEBUG LOG ===\n" + "Plugin Version: " + str(__version__) + "\n" + "SDK Version: " + str(__sdk_version__) + "\n" + "Provider: " + str(provider_str) + "\n" + "Model: " + str(self.get_setting("model", "")) + "\n" + "Has Custom Endpoint: " + str(bool(self.get_setting("custom_endpoint", ""))) + "\n" + "API Key Valid Format: " + str(key_valid) + " (" + str(key_hint) + ")\n" + "Style: " + str(self.get_setting("style", 0)) + "\n" + "Lang: " + str(self.get_setting("lang", 0)) + "\n" + "Last Recorded Error: " + str(getattr(self, "last_error", "None")) + "\n" + "==============================" + ) + copy_to_clipboard(debug_text) + BulletinHelper.show_success("Дебаг-лог скопирован в буфер!") + + items.append(Text( + text="Скопировать дебаг-лог", + subtext="Скопирует статус плагина и последнюю ошибку для отправки разработчику", + icon="msg_edit", + on_click=lambda v: action_copy_debug(), + )) + + items += [ + Divider(), + Header(text="Саммари"), + Selector( + key="style", + text="Стиль", + default=STYLE_BRIEF, + items=STYLES, + icon="msg_list", + on_change=lambda idx: self.set_setting("style", idx, reload_settings=True), + ), + Text( + text=style_hint, + icon="msg_info", + ), + Selector( + key="lang", + text="Язык ответа", + default=LANG_AUTO, + items=LANGS, + icon="msg_translate", + ), + ] + + return items + + def run_connection_test(self): + provider = self.get_setting("provider", PROVIDER_OPENAI) + api_key = self.get_setting("api_key", "") or "" + model = self.get_setting("model", "") or "" + custom_url = self.get_setting("custom_endpoint", "") or "" + + if provider != PROVIDER_OLLAMA and not api_key.strip(): + BulletinHelper.show_error("Сначала укажи API ключ") + return + + BulletinHelper.show_info("Проверяю подключение…") + + def worker(): + ok, message = test_connection(provider, api_key, model, custom_url) + # ОБНОВЛЕНО: Если тест провалился, записываем сообщение в лог + if not ok: + self.last_error = "Ошибка теста связи: " + message + + def show(): + if ok: + BulletinHelper.show_success(message) + else: + BulletinHelper.show_error(message) + + run_on_ui_thread(show) + + run_on_queue(worker, PLINS_QUEUE if 'PLINS_QUEUE' in globals() else PLUGINS_QUEUE, 0) \ No newline at end of file diff --git a/references/message_analyzer.plugin b/references/message_analyzer.plugin new file mode 100644 index 0000000..7b92778 --- /dev/null +++ b/references/message_analyzer.plugin @@ -0,0 +1,1067 @@ +""" + _____ _____ +( ___ )-----------------------------------------------------------( ___ ) + | | | | + | | _ _ _ _ ___ _ _ _ ____ _ _ ____ ___ _ _ | | + | | | | | | | |__| | | | | | |___ |__] \_/ | | + | | |_|_| | | | | |___ |__| \/ |___ |__] | | | + | | | | + | | _ _ _ _ _ ____ _ _ _ _ ____ ___ ____ _ _ ____ _ _ _ | | + | | |\/| | |__| |__| | | |_/ | | | | | | | [__ |_/ | | | + | | | | | | | | | | |___ | \_ |__| | |__| \/ ___] | \_ | | | + |___| |___| +(_____)-----------------------------------------------------------(_____) +""" + +""" Все права защищены. Любое копирование кода запрещено. Имейте уважение к автору. """ + +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 + ), + ] diff --git a/references/unread_summary.plugin b/references/unread_summary.plugin new file mode 100644 index 0000000..a6ad077 --- /dev/null +++ b/references/unread_summary.plugin @@ -0,0 +1,2125 @@ +""" +⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⡿⠟⠋⠉⠉⠉⠉⠛⠿⣿⣿⣿⣿⡿⠛⠉⠉⠄⠈⠉⠙⠿⣿⣿⣿⣿ +⣿⣿⡿⠋⠄⣠⣶⣿⣿⣿⣷⣦⣄⠈⠛⢟⢁⣠⣤⣴⣶⣤⣄⠄⠄⠄⠈⢿⣿⣿ +⣿⡿⠁⢠⣾⣿⣿⣿⣿⣿⣿⣿⡿⣿⣦⣀⠈⠛⠛⠋⣸⣿⣿⣷⡄⠄⠄⠄⢻⣿ +⣿⠁⢀⣿⣿⣿⣿⣿⣿⣿⠋⠄⠄⣿⣿⣿⣿⣶⣶⣾⣿⣿⣿⣿⣧⠄⠄⠄⠄⣿ +⣿⠄⢸⣿⣿⣿⣿⣿⠟⠁⠄⠄⠄⠄⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⠄⣿ +⣿⠄⠘⣿⣿⣿⣿⡏⠄⠄⠄⠄⠄⠄⠄⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⠄⣿ +⣿⠄⠄⢻⣿⣿⣿⠁⠄⠄⠄⠄⠄⠄⠄⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⢀⣿ +⣿⡆⠄⠈⠿⠿⠋⠄⠄⠄⠄⠄⠄⢰⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠄⠄⠄⣸⣿ +⣿⣿⡀⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠄⠄⣰⣿⣿ +⣿⣿⣷⡄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠄⠄⣰⣿⣿⣿ +⣿⣿⣿⣿⣄⠄⠄⠄⠄⠄⠄⠄⠄⣰⣿⣿⣿⣿⣿⣿⣿⣿⠏⠄⢀⣴⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣷⣄⠄⠄⠄⠄⠄⣰⣿⣿⣿⣿⣿⣿⣿⡿⠃⠄⣠⣾⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣷⣄⠄⠄⠄⢿⣿⣿⣿⣿⣿⡿⠋⢀⣠⣾⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⠄⠙⢿⣿⠟⠋⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⣨⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ +⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ + +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 \ No newline at end of file