diff --git a/.idea/betterCommentsSettings.xml b/.idea/betterCommentsSettings.xml
new file mode 100644
index 0000000..4f152ed
--- /dev/null
+++ b/.idea/betterCommentsSettings.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/exteragram-chat-summaries.iml b/.idea/exteragram-chat-summaries.iml
new file mode 100644
index 0000000..c03f621
--- /dev/null
+++ b/.idea/exteragram-chat-summaries.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..348ee26
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..06fdc9d
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..ea57e99
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.py b/build.py
new file mode 100755
index 0000000..98894c0
--- /dev/null
+++ b/build.py
@@ -0,0 +1,490 @@
+#!/usr/bin/env python3
+"""
+Standalone Bundler Script for AI Chat Summaries Plugin.
+Generates a 100% self-contained single-file plugin (with ZERO 'src' imports)
+for Chaquopy / exteraGram runtime, and verifies Python syntax.
+"""
+
+import os
+import py_compile
+import re
+import sys
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+SRC_DIR = os.path.join(BASE_DIR, "src")
+DIST_DIR = os.path.join(BASE_DIR, "dist")
+DIST_PLUGIN_FILE = os.path.join(DIST_DIR, "ai_chat_summary.plugin")
+
+MODULE_ORDER = [
+ os.path.join(SRC_DIR, "config.py"),
+ os.path.join(SRC_DIR, "diagnostics.py"),
+ os.path.join(SRC_DIR, "localization.py"),
+ os.path.join(SRC_DIR, "providers", "base.py"),
+ os.path.join(SRC_DIR, "providers", "oauth.py"),
+ os.path.join(SRC_DIR, "providers", "custom.py"),
+ os.path.join(SRC_DIR, "providers", "builtin.py"),
+ os.path.join(SRC_DIR, "providers", "dispatcher.py"),
+ os.path.join(SRC_DIR, "services", "message_fetcher.py"),
+ os.path.join(SRC_DIR, "ui", "thinking_sheet.py"),
+ os.path.join(SRC_DIR, "ui", "summary_dialog.py"),
+ os.path.join(SRC_DIR, "ui", "settings.py"),
+ os.path.join(SRC_DIR, "ui", "pre_request.py"),
+ os.path.join(SRC_DIR, "ui", "progress_widget.py"),
+ os.path.join(SRC_DIR, "hooks", "pinned_hook.py"),
+ os.path.join(SRC_DIR, "hooks", "unread_hook.py"),
+]
+
+HEADER_CODE = '''"""
+AI Chat Summaries Plugin for exteraGram (v3.0.1)
+================================================
+Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers,
+animated progress widget, live thinking stream viewer, and dual triggers.
+"""
+
+__id__ = "ai_chat_summaries"
+__name__ = "AI Chat Summaries"
+__description__ = "Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, animated progress, thinking stream preview, and dual triggers."
+__author__ = "@exteraGramDev"
+__version__ = "3.0.1"
+__icon__ = "msg_bot"
+__app_version__ = ">=12.1.1"
+__sdk_version__ = ">=1.4.3.3"
+__requirements__ = ["requests"]
+
+# Direct metadata aliases for scanners
+id = __id__
+name = __name__
+description = __description__
+author = __author__
+version = __version__
+icon = __icon__
+
+import os
+import sys
+import time
+import json
+import re
+import random
+import secrets
+import hashlib
+import urllib.parse
+import base64
+import weakref
+import threading
+from typing import Any, Callable, Dict, List, Optional, Tuple
+from datetime import datetime, timezone
+from dataclasses import dataclass
+
+try:
+ import requests
+except ImportError:
+ requests = None
+
+try:
+ from base_plugin import BasePlugin, MenuItemData, MenuItemType, XposedHook
+ from client_utils import (
+ PLUGINS_QUEUE,
+ get_last_fragment,
+ get_messages_controller,
+ run_on_queue,
+ send_request,
+ RequestCallback,
+ )
+ from android_utils import run_on_ui_thread, copy_to_clipboard, OnClickListener
+ from markdown_utils import parse_markdown
+ from ui.settings import Header, Divider, Selector, Input, Switch, Text
+ from ui.bulletin import BulletinHelper
+ from ui.alert import AlertDialogBuilder
+ from hook_utils import find_class, get_private_field
+ from org.telegram.tgnet import TLRPC
+ from org.telegram.messenger import AndroidUtilities, R
+ from org.telegram.ui import LaunchActivity, ChatActivity
+ from org.telegram.ui.ActionBar import Theme
+ from org.telegram.ui.Components import EditTextBoldCursor
+ from android.widget import LinearLayout, TextView, ScrollView, FrameLayout, Button, SeekBar, ProgressBar, ImageView
+ from android.view import Gravity, View
+except ImportError:
+ class BasePlugin:
+ def __init__(self): self._settings = {}
+ def get_setting(self, k, d=None): return self._settings.get(k, d)
+ def set_setting(self, k, v, reload_settings=False): self._settings[k] = v
+ def add_menu_item(self, item): pass
+ def hook_method(self, m, h): return "hook_ref"
+ def unhook_method(self, r): pass
+
+ class MenuItemType:
+ CHAT_ACTION_MENU = 1
+
+ class MenuItemData:
+ def __init__(self, menu_type=None, text="", subtext="", icon=None, on_click=None):
+ self.menu_type = menu_type
+ self.text = text
+ self.subtext = subtext
+ self.icon = icon
+ self.on_click = on_click
+
+ class XposedHook:
+ def before_hooked_method(self, p): pass
+ def after_hooked_method(self, p): pass
+
+ PLUGINS_QUEUE = "plugins_queue"
+ def run_on_queue(fn, q=None, delay=0): fn()
+ def run_on_ui_thread(fn, delay=0): fn()
+ def get_last_fragment(): return None
+ def get_messages_controller(): return None
+ def send_request(req, cb): pass
+ class RequestCallback:
+ def __init__(self, cb): self.cb = cb
+ def copy_to_clipboard(t): pass
+ def parse_markdown(t): return t
+ def find_class(c): return None
+ def get_private_field(o, f): return None
+
+ class BulletinHelper:
+ @staticmethod
+ def show_info(msg): pass
+ @staticmethod
+ def show_success(msg): pass
+ @staticmethod
+ def show_error(msg): pass
+'''
+
+PLUGIN_CLASS_CODE = '''
+# ==================== Main Plugin Class ====================
+
+class AIChatSummariesPlugin(BasePlugin):
+ """Next-generation AI Chat Summaries plugin for exteraGram."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.oauth_handler = ChatGPTOAuthHandler(self)
+ self.custom_handler = CustomAIHandler(self)
+ self.dispatcher = UnifiedDispatcher(self)
+ self.message_fetcher = MessageFetcher(self)
+ self.progress_manager = PinnedProgressManager(self)
+ self.pinned_hook = PinnedHeaderHook(self)
+ self.unread_hook = UnreadBadgeHook(self)
+ self.is_processing = False
+
+ def on_plugin_load(self) -> None:
+ """Called when plugin is loaded into exteraGram."""
+ clear()
+ install_uncaught_exception_hooks()
+ record_fact("plugin.version", PLUGIN_VERSION)
+ locali.set_language("auto")
+ try:
+ self.add_menu_item(
+ MenuItemData(
+ menu_type=MenuItemType.CHAT_ACTION_MENU,
+ text=locali.get("PLUGIN_NAME"),
+ icon="msg_bot",
+ on_click=self.on_menu_click,
+ )
+ )
+ record_fact("plugin.menu_item", "registered")
+
+ # Second entry so diagnostics can be copied immediately after a
+ # failed trigger, without leaving the chat.
+ self.add_menu_item(
+ MenuItemData(
+ menu_type=MenuItemType.CHAT_ACTION_MENU,
+ text=locali.get("SETTINGS_COPY_DIAGNOSTICS"),
+ icon="msg_data",
+ on_click=self.on_diagnostics_menu_click,
+ )
+ )
+ record_fact("plugin.diagnostics_menu_item", "registered")
+ except Exception as exc:
+ record_error("on_plugin_load:add_menu_item", exc)
+
+ self.pinned_hook.install_hook()
+ self.unread_hook.install_hook()
+ record_fact(
+ "plugin.hooks_installed",
+ "pinned=%d unread=%d" % (
+ len(self.pinned_hook.unhook_refs),
+ len(self.unread_hook.unhook_refs),
+ ),
+ )
+
+ def on_plugin_unload(self) -> None:
+ """Called when plugin is disabled or uninstalled."""
+ self.pinned_hook.uninstall_hook()
+ self.unread_hook.uninstall_hook()
+ self.progress_manager.hide_progress()
+ self.is_processing = False
+ uninstall_uncaught_exception_hooks()
+
+ # Aliases for alternate SDK lifecycle naming
+ def on_load(self) -> None:
+ self.on_plugin_load()
+
+ def on_unload(self) -> None:
+ self.on_plugin_unload()
+
+ def has_settings(self) -> bool:
+ """Indicates to exteraGram plugin list that this plugin has configurable settings."""
+ return True
+
+ def create_settings(self) -> List[Any]:
+ """Builds settings page layout."""
+ return build_settings_layout(self)
+
+ def on_menu_click(self, context: Any) -> None:
+ """Handles chat action bar menu click from exteraGram context dict."""
+ try:
+ fragment = None
+ if isinstance(context, dict):
+ fragment = context.get("fragment")
+ fragment = fragment or get_last_fragment()
+ if not fragment:
+ record_fact("menu_click.abort", "no fragment")
+ return
+
+ dialog_id, chat_title, topic_id = resolve_chat_context(fragment)
+ if not dialog_id and isinstance(context, dict):
+ try:
+ dialog_id = int(context.get("dialog_id") or 0)
+ except Exception:
+ dialog_id = 0
+
+ record_fact("menu_click.chat", "id=%s title=%r topic=%s" % (dialog_id, chat_title, topic_id))
+
+ if not dialog_id:
+ try:
+ BulletinHelper.show_error("AI Summary: cannot resolve current chat")
+ except Exception:
+ pass
+ return
+
+ show_pre_request_sheet(
+ plugin=self,
+ dialog_id=dialog_id,
+ chat_title=chat_title,
+ topic_id=topic_id,
+ )
+ except Exception as exc:
+ record_error("on_menu_click", exc)
+ try:
+ BulletinHelper.show_error(f"AI Summary menu failed: {type(exc).__name__}: {exc}")
+ except Exception:
+ pass
+
+ def on_diagnostics_menu_click(self, context: Any) -> None:
+ """Copies the diagnostics report straight from the chat menu."""
+ try:
+ report = build_report()
+ except Exception as exc:
+ report = f"Failed to build diagnostics report: {type(exc).__name__}: {exc}"
+ try:
+ copy_to_clipboard(report)
+ BulletinHelper.show_success(locali.get("SETTINGS_DIAGNOSTICS_COPIED"))
+ except Exception as exc:
+ record_error("on_diagnostics_menu_click", exc)
+
+ # ==================== End-to-End Summarization Pipeline ====================
+
+ def start_summarization_pipeline(
+ self,
+ dialog_id: int,
+ chat_title: str = "",
+ count: int = 100,
+ offset: int = 0,
+ style: Optional[int] = None,
+ custom_prompt: str = "",
+ topic_id: int = 0,
+ auto_jump_latest: bool = False,
+ ) -> None:
+ """Executes full message fetch, context formatting, and AI summarization with offset and auto-jump."""
+ if self.is_processing:
+ try:
+ BulletinHelper.show_info("Summarization is already in progress...")
+ except Exception:
+ pass
+ return
+
+ self.is_processing = True
+ chat_act = get_last_fragment()
+
+ if chat_act:
+ self.progress_manager.show_progress(chat_act, dialog_id, chat_title)
+
+ try:
+ BulletinHelper.show_info(locali.get("PROGRESS_FETCHING"))
+ except Exception:
+ pass
+
+ def on_messages_fetched(messages: List[Any], fetch_error: Optional[str]) -> None:
+ if fetch_error or not messages:
+ self._finish_pipeline()
+ err_msg = fetch_error or locali.get("ERROR_NO_MESSAGES")
+ record_error_message(
+ "pipeline.message_fetch",
+ err_msg,
+ f"Dialog ID: {dialog_id}\\nTopic ID: {topic_id}\\nCount: {count}\\nOffset: {offset}\\nError: {err_msg}",
+ )
+ def _show_fetch_err():
+ show_error_dialog(
+ title=locali.get("ERROR_TITLE"),
+ error_text=err_msg,
+ debug_log=f"Message Fetch Failed\\nDialog ID: {dialog_id}\\nTopic ID: {topic_id}\\nError: {err_msg}",
+ )
+ run_on_ui_thread(guarded("pipeline.show_fetch_error", _show_fetch_err))
+ return
+
+ raw_lines = [m.to_transcript_line() for m in messages]
+ budgeted_transcript = self.custom_handler.budget_and_chunk_transcript(raw_lines)
+
+ try:
+ provider_name = self.dispatcher.get_active_provider().name
+ BulletinHelper.show_info(locali.get("PROGRESS_PROCESSING", provider=provider_name))
+ except Exception:
+ pass
+
+ def _execute_ai():
+ start_ts = time.time()
+ try:
+ global_prompt = self.get_setting(SETTING_GLOBAL_PROMPT, "")
+ target_style = style if style is not None else int(self.get_setting(SETTING_SUMMARY_STYLE, STYLE_BRIEF) or STYLE_BRIEF)
+ target_lang = int(self.get_setting(SETTING_SUMMARY_LANG, 0) or 0)
+
+ response = self.dispatcher.run_summary(
+ messages_transcript=budgeted_transcript,
+ chat_title=chat_title,
+ custom_prompt=custom_prompt,
+ style=target_style,
+ lang=target_lang,
+ global_prompt=global_prompt,
+ )
+ elapsed = time.time() - start_ts
+ except Exception as exc:
+ self._finish_pipeline()
+ record_error("pipeline.execute_ai", exc)
+ return
+
+ self._finish_pipeline()
+ if not response.success:
+ record_error_message(
+ "provider.%s" % (response.provider_name or "unknown"),
+ response.error or "Unknown generation error",
+ response.debug_log or response.error or "Unknown generation error",
+ )
+
+ def _render_result():
+ if response.success:
+ full_debug_data = (
+ f"=== Assembled Chat Transcript ({len(messages)} messages) ===\\n\\n"
+ f"{budgeted_transcript}\\n\\n"
+ f"=== Custom Prompt ===\\n{custom_prompt or ''}\\n\\n"
+ f"=== System Prompt ===\\n{global_prompt or ''}\\n"
+ )
+ latest_id = messages[-1].id if messages else 0
+ show_summary_result_dialog(
+ plugin=self,
+ chat_activity=chat_act,
+ summary_text=response.text,
+ provider_name=response.provider_name,
+ model_name=response.model,
+ message_count=len(messages),
+ elapsed_time=elapsed,
+ dialog_id=dialog_id,
+ topic_id=topic_id,
+ full_debug_data=full_debug_data,
+ latest_msg_id=latest_id,
+ auto_jump=auto_jump_latest,
+ )
+ else:
+ show_error_dialog(
+ title=locali.get("ERROR_TITLE"),
+ error_text=response.error or "Unknown generation error",
+ debug_log=response.debug_log or response.error,
+ )
+
+ run_on_ui_thread(guarded("pipeline.render_result", _render_result))
+
+ run_on_queue(guarded("pipeline.worker", _execute_ai), PLUGINS_QUEUE)
+
+ try:
+ self.message_fetcher.fetch_history(
+ dialog_id=dialog_id,
+ count=count,
+ offset=offset,
+ topic_id=topic_id,
+ callback=guarded("pipeline.messages_callback", on_messages_fetched),
+ )
+ except Exception as exc:
+ self._finish_pipeline()
+ record_error("pipeline.fetch_history", exc)
+ def _finish_pipeline(self) -> None:
+ """Cleans up in-progress state and resets pinned header layout."""
+ self.is_processing = False
+ def _ui_clean():
+ self.progress_manager.hide_progress()
+ run_on_ui_thread(_ui_clean)
+'''
+
+
+def clean_module_code(code: str) -> str:
+ """Strips package imports, __all__, and docstrings for single-file bundling."""
+ lines = code.splitlines()
+ output = []
+ skip_import_block = False
+
+ for line in lines:
+ stripped = line.strip()
+ if skip_import_block:
+ if ")" in stripped:
+ skip_import_block = False
+ continue
+
+ if re.match(r"^\s*from\s+(\.|\.\.|src\.)", line):
+ if "(" in line and ")" not in line:
+ skip_import_block = True
+ continue
+
+ if re.match(r"^\s*import\s+src\.", line):
+ continue
+
+ if line.startswith("__all__ ="):
+ continue
+
+ output.append(line)
+
+ return "\n".join(output)
+
+
+def bundle_plugin():
+ os.makedirs(DIST_DIR, exist_ok=True)
+ print("Bundling self-contained AI Chat Summaries plugin...")
+
+ bundled_parts = [HEADER_CODE]
+
+ for mod_path in MODULE_ORDER:
+ rel_name = os.path.relpath(mod_path, BASE_DIR)
+ print(f" + Packing {rel_name}")
+ with open(mod_path, "r", encoding="utf-8") as mf:
+ content = mf.read()
+ cleaned = clean_module_code(content)
+ bundled_parts.append(f"\n# {'='*20} {rel_name} {'='*20}\n")
+ bundled_parts.append(cleaned)
+
+ bundled_parts.append(PLUGIN_CLASS_CODE)
+ final_content = "\n".join(bundled_parts)
+
+ # Write to both root plugin file and dist plugin file
+ with open(ROOT_PLUGIN_FILE, "w", encoding="utf-8") as out:
+ out.write(final_content)
+ with open(DIST_PLUGIN_FILE, "w", encoding="utf-8") as out:
+ out.write(final_content)
+
+ print(f"Bundled successfully -> {ROOT_PLUGIN_FILE}")
+ print(f"Bundled successfully -> {DIST_PLUGIN_FILE}")
+
+ # Validate syntax with py_compile
+ py_compile.compile(ROOT_PLUGIN_FILE, doraise=True)
+ py_compile.compile(DIST_PLUGIN_FILE, doraise=True)
+ print("Compiled and validated bundled artifact syntax successfully!")
+
+
+if __name__ == "__main__":
+ bundle_plugin()
diff --git a/references/2026-09-10 10.20.58.jpg b/references/2026-09-10 10.20.58.jpg
deleted file mode 100644
index 72b3893..0000000
Binary files a/references/2026-09-10 10.20.58.jpg and /dev/null differ
diff --git a/references/chat_summary.plugin b/references/chat_summary.plugin
deleted file mode 100644
index 3562a4a..0000000
--- a/references/chat_summary.plugin
+++ /dev/null
@@ -1,884 +0,0 @@
-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
deleted file mode 100644
index 7b92778..0000000
--- a/references/message_analyzer.plugin
+++ /dev/null
@@ -1,1067 +0,0 @@
-"""
- _____ _____
-( ___ )-----------------------------------------------------------( ___ )
- | | | |
- | | _ _ _ _ ___ _ _ _ ____ _ _ ____ ___ _ _ | |
- | | | | | | | |__| | | | | | |___ |__] \_/ | |
- | | |_|_| | | | | |___ |__| \/ |___ |__] | | |
- | | | |
- | | _ _ _ _ _ ____ _ _ _ _ ____ ___ ____ _ _ ____ _ _ _ | |
- | | |\/| | |__| |__| | | |_/ | | | | | | | [__ |_/ | | |
- | | | | | | | | | | |___ | \_ |__| | |__| \/ ___] | \_ | | |
- |___| |___|
-(_____)-----------------------------------------------------------(_____)
-"""
-
-""" Все права защищены. Любое копирование кода запрещено. Имейте уважение к автору. """
-
-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
deleted file mode 100644
index a6ad077..0000000
--- a/references/unread_summary.plugin
+++ /dev/null
@@ -1,2125 +0,0 @@
-"""
-⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
-⣿⣿⣿⣿⡿⠟⠋⠉⠉⠉⠉⠛⠿⣿⣿⣿⣿⡿⠛⠉⠉⠄⠈⠉⠙⠿⣿⣿⣿⣿
-⣿⣿⡿⠋⠄⣠⣶⣿⣿⣿⣷⣦⣄⠈⠛⢟⢁⣠⣤⣴⣶⣤⣄⠄⠄⠄⠈⢿⣿⣿
-⣿⡿⠁⢠⣾⣿⣿⣿⣿⣿⣿⣿⡿⣿⣦⣀⠈⠛⠛⠋⣸⣿⣿⣷⡄⠄⠄⠄⢻⣿
-⣿⠁⢀⣿⣿⣿⣿⣿⣿⣿⠋⠄⠄⣿⣿⣿⣿⣶⣶⣾⣿⣿⣿⣿⣧⠄⠄⠄⠄⣿
-⣿⠄⢸⣿⣿⣿⣿⣿⠟⠁⠄⠄⠄⠄⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⠄⣿
-⣿⠄⠘⣿⣿⣿⣿⡏⠄⠄⠄⠄⠄⠄⠄⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⠄⣿
-⣿⠄⠄⢻⣿⣿⣿⠁⠄⠄⠄⠄⠄⠄⠄⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠄⠄⢀⣿
-⣿⡆⠄⠈⠿⠿⠋⠄⠄⠄⠄⠄⠄⢰⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠄⠄⠄⣸⣿
-⣿⣿⡀⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠄⠄⣰⣿⣿
-⣿⣿⣷⡄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠄⠄⣰⣿⣿⣿
-⣿⣿⣿⣿⣄⠄⠄⠄⠄⠄⠄⠄⠄⣰⣿⣿⣿⣿⣿⣿⣿⣿⠏⠄⢀⣴⣿⣿⣿⣿
-⣿⣿⣿⣿⣿⣷⣄⠄⠄⠄⠄⠄⣰⣿⣿⣿⣿⣿⣿⣿⡿⠃⠄⣠⣾⣿⣿⣿⣿⣿
-⣿⣿⣿⣿⣿⣿⣿⣷⣄⠄⠄⠄⢿⣿⣿⣿⣿⣿⡿⠋⢀⣠⣾⣿⣿⣿⣿⣿⣿⣿
-⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⠄⠙⢿⣿⠟⠋⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
-⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⣨⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
-⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
-
-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
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..7c28bd0
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1,5 @@
+"""
+AI Chat Summaries Plugin for exteraGram
+"""
+
+__all__ = ["config", "localization"]
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..cfd588f
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,178 @@
+"""
+Configuration constants, metadata, defaults, and model tiers for AI Chat Summaries.
+"""
+
+from typing import Dict, List
+
+# Plugin Metadata
+PLUGIN_ID = "ai_chat_summaries"
+PLUGIN_NAME = "AI Chat Summaries"
+PLUGIN_DESCRIPTION = (
+ "Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, "
+ "animated progress, thinking stream preview, and dual triggers."
+)
+PLUGIN_AUTHOR = "@exteraGramDev"
+PLUGIN_VERSION = "3.0.1"
+PLUGIN_ICON = "msg_bot"
+PLUGIN_APP_VERSION = ">=12.1.1"
+PLUGIN_SDK_VERSION = ">=1.4.3.3"
+PLUGIN_REQUIREMENTS = ["requests"]
+
+__id__ = PLUGIN_ID
+__name__ = PLUGIN_NAME
+__description__ = PLUGIN_DESCRIPTION
+__author__ = PLUGIN_AUTHOR
+__version__ = PLUGIN_VERSION
+__icon__ = PLUGIN_ICON
+__app_version__ = PLUGIN_APP_VERSION
+__sdk_version__ = PLUGIN_SDK_VERSION
+__requirements__ = PLUGIN_REQUIREMENTS
+
+id = PLUGIN_ID
+name = PLUGIN_NAME
+# Provider IDs
+PROVIDER_CHATGPT_OAUTH = 0
+PROVIDER_CUSTOM = 1
+PROVIDER_OPENAI = 2
+PROVIDER_ANTHROPIC = 3
+PROVIDER_GEMINI = 4
+PROVIDER_OLLAMA = 5
+
+PROVIDERS = [
+ "ChatGPT (OAuth)",
+ "Custom endpoint",
+ "OpenAI (Direct Key)",
+ "Anthropic (Claude)",
+ "Gemini",
+ "Ollama (local)",
+]
+
+# ChatGPT OAuth Subscription Tiers and Models
+OAUTH_TIER_FREE = "free"
+OAUTH_TIER_PLUS = "plus"
+OAUTH_TIER_PRO = "pro"
+OAUTH_TIER_TEAM = "team"
+OAUTH_TIER_ENTERPRISE = "enterprise"
+
+OAUTH_FREE_MODELS = [
+ "luna",
+ "gpt-4o-mini",
+]
+
+OAUTH_PAID_MODELS = [
+ "terra",
+ "sol",
+ "gpt-4o",
+ "o1",
+ "o3-mini",
+]
+
+# Default Models
+DEFAULT_MODEL_OAUTH_FREE = "luna"
+DEFAULT_MODEL_OAUTH_PAID = "terra"
+DEFAULT_MODEL_CUSTOM = "deepseek-chat"
+DEFAULT_MODEL_OPENAI = "gpt-4o"
+DEFAULT_MODEL_ANTHROPIC = "claude-3-7-sonnet-latest"
+DEFAULT_MODEL_GEMINI = "gemini-2.0-flash"
+DEFAULT_MODEL_OLLAMA = "llama3.3"
+
+# Built-in direct provider model lists
+OPENAI_MODELS = ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini", "gpt-4.5-preview"]
+ANTHROPIC_MODELS = ["claude-3-7-sonnet-latest", "claude-3-5-haiku-latest", "claude-3-5-sonnet-latest"]
+GEMINI_MODELS = ["gemini-2.0-flash", "gemini-2.5-pro", "gemini-1.5-pro", "gemini-1.5-flash"]
+OLLAMA_MODELS = ["llama3.3", "qwen2.5", "deepseek-r1", "mistral"]
+
+# Summary Styles
+STYLE_BRIEF = 0
+STYLE_DETAILED = 1
+STYLE_BULLETS = 2
+STYLE_CUSTOM = 3
+
+STYLES = ["Brief", "Detailed", "Key Highlights & Actions", "Custom"]
+STYLES_RU = ["Краткий", "Подробный", "Главное и задачи", "Свой"]
+
+# Language Options
+LANG_AUTO = 0
+LANG_RU = 1
+LANG_EN = 2
+
+LANGS = ["Auto", "Russian", "English"]
+
+# Context Window Options & Character Budgets
+CONTEXT_WINDOWS = ["8k", "32k", "64k", "128k", "200k", "1M"]
+CONTEXT_WINDOW_BUDGETS: Dict[str, int] = {
+ "8k": 28_000,
+ "32k": 110_000,
+ "64k": 220_000,
+ "128k": 450_000,
+ "200k": 700_000,
+ "1M": 3_500_000,
+}
+DEFAULT_CONTEXT_WINDOW = "128k"
+
+# Max characters per individual Telegram message (preserves rich content without arbitrary cutoff)
+MAX_SINGLE_MESSAGE_CHARS = 8000
+
+# Default Message Limits
+DEFAULT_MSG_COUNT = 100
+MIN_MSG_COUNT = 1
+MAX_MSG_COUNT = 1000
+
+# Endpoint URLs
+OAUTH_AUTH_URL = "https://auth0.openai.com/authorize"
+OAUTH_TOKEN_URL = "https://auth0.openai.com/oauth/token"
+OAUTH_USER_INFO_URL = "https://api.openai.com/v1/me"
+OAUTH_CLIENT_ID = "exteragram_ai_summaries"
+OAUTH_REDIRECT_URI = "exteragram://oauth/chatgpt"
+OAUTH_SCOPE = "openid profile email model.request offline_access"
+
+OPENAI_ENDPOINT = "https://api.openai.com/v1/chat/completions"
+ANTHROPIC_ENDPOINT = "https://api.anthropic.com/v1/messages"
+GEMINI_ENDPOINT_TEMPLATE = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
+OLLAMA_DEFAULT_ENDPOINT = "http://localhost:11434/api/chat"
+
+# Key Prefixes
+KEY_PREFIXES = {
+ PROVIDER_OPENAI: "sk-",
+ PROVIDER_ANTHROPIC: "sk-ant-",
+ PROVIDER_GEMINI: "AIza",
+ PROVIDER_CUSTOM: "",
+}
+
+# Settings Keys
+SETTING_PROVIDER = "ai_provider"
+SETTING_ENABLE_THINKING_STREAM = "enable_thinking_stream"
+SETTING_ENABLE_PINNED_TRIGGER = "enable_pinned_trigger"
+SETTING_ENABLE_UNREAD_LONGPRESS = "enable_unread_longpress"
+
+SETTING_OAUTH_ACCESS_TOKEN = "oauth_access_token"
+SETTING_OAUTH_REFRESH_TOKEN = "oauth_refresh_token"
+SETTING_OAUTH_EXPIRES_AT = "oauth_expires_at"
+SETTING_OAUTH_USER_EMAIL = "oauth_user_email"
+SETTING_OAUTH_USER_TIER = "oauth_user_tier"
+SETTING_OAUTH_MODEL = "oauth_model"
+
+SETTING_CUSTOM_BASE_URL = "custom_base_url"
+SETTING_CUSTOM_API_KEY = "custom_api_key"
+SETTING_CUSTOM_MODEL = "custom_model"
+SETTING_CUSTOM_CONTEXT_WINDOW = "custom_context_window"
+
+SETTING_OPENAI_API_KEY = "openai_api_key"
+SETTING_OPENAI_MODEL = "openai_model"
+
+SETTING_ANTHROPIC_API_KEY = "anthropic_api_key"
+SETTING_ANTHROPIC_MODEL = "anthropic_model"
+
+SETTING_GEMINI_API_KEY = "gemini_api_key"
+SETTING_GEMINI_MODEL = "gemini_model"
+
+SETTING_OLLAMA_ENDPOINT = "ollama_endpoint"
+SETTING_OLLAMA_MODEL = "ollama_model"
+
+SETTING_SUMMARY_STYLE = "summary_style"
+SETTING_SUMMARY_LANG = "summary_lang"
+SETTING_DEFAULT_COUNT = "default_count"
+SETTING_GLOBAL_PROMPT = "global_prompt"
+
+# Long summary threshold for scrollview expansion
+LONG_SUMMARY_THRESHOLD = 600
diff --git a/src/diagnostics.py b/src/diagnostics.py
new file mode 100644
index 0000000..155faa1
--- /dev/null
+++ b/src/diagnostics.py
@@ -0,0 +1,447 @@
+"""
+Diagnostics: real error surfacing for the exteraGram runtime.
+
+Bare `except Exception: pass` makes on-device failures invisible and forces
+blind guessing. Every swallowed exception in this plugin routes here instead,
+so the cause is recoverable from the exteraGram log and from the in-app
+diagnostics report.
+"""
+
+import sys
+import threading
+import traceback
+from typing import Any, Dict, List, Optional, Tuple
+
+LOG_PREFIX = "[ai_chat_summaries]"
+
+# Structured, bounded error records. Every entry includes timestamp, location,
+# type, message, full traceback, and thread name so "Copy Latest Errors" is
+# sufficient for remote diagnosis.
+_ERRORS: List[Dict[str, str]] = []
+_MAX_ERRORS = 100
+
+# Bounded ring buffer of (key, value) facts about what hooks actually resolved.
+_FACTS: List[Tuple[str, str]] = []
+_MAX_FACTS = 100
+
+# Bounded ring buffer of every log line this plugin emitted, newest last.
+_LOGS: List[str] = []
+_MAX_LOGS = 500
+
+
+def _timestamp() -> str:
+ try:
+ from datetime import datetime
+ return datetime.now().strftime("%H:%M:%S")
+ except Exception:
+ return "--:--:--"
+
+
+def log(message: str) -> None:
+ """Writes a line to the exteraGram plugin log and retains it for the report."""
+ stamped = f"[{_timestamp()}] {message}"
+ if len(_LOGS) >= _MAX_LOGS:
+ _LOGS.pop(0)
+ _LOGS.append(stamped)
+
+ text = f"{LOG_PREFIX} {message}"
+ try:
+ from android_utils import log as android_log
+ android_log(text)
+ except Exception:
+ try:
+ print(text)
+ except Exception:
+ pass
+
+
+def get_logs() -> List[str]:
+ """Returns retained log lines, newest last."""
+ return list(_LOGS)
+
+
+def record_error(
+ where: str,
+ exc: BaseException,
+ traceback_text: Optional[str] = None,
+) -> None:
+ """Records and logs an exception with its complete traceback."""
+ detail = f"{type(exc).__name__}: {exc}"
+ if traceback_text is None:
+ try:
+ traceback_text = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
+ except Exception:
+ traceback_text = traceback.format_exc()
+ if not traceback_text or traceback_text.strip() == "NoneType: None":
+ traceback_text = detail
+
+ record = {
+ "timestamp": _timestamp(),
+ "where": str(where),
+ "type": type(exc).__name__,
+ "message": str(exc),
+ "thread": threading.current_thread().name,
+ "traceback": traceback_text.strip(),
+ }
+ if len(_ERRORS) >= _MAX_ERRORS:
+ _ERRORS.pop(0)
+ _ERRORS.append(record)
+ log(f"ERROR in {where}: {detail}")
+ log(record["traceback"])
+
+
+def record_error_message(where: str, message: str, traceback_text: str = "") -> None:
+ """Records an error reported as data rather than a raised exception."""
+ exc = RuntimeError(message)
+ record_error(where, exc, traceback_text or message)
+
+
+_HOOKS_INSTALLED = False
+_PREVIOUS_SYS_EXCEPTHOOK = None
+_PREVIOUS_THREAD_EXCEPTHOOK = None
+
+
+def install_uncaught_exception_hooks() -> None:
+ """Captures uncaught Python exceptions on the main and worker threads."""
+ global _HOOKS_INSTALLED, _PREVIOUS_SYS_EXCEPTHOOK, _PREVIOUS_THREAD_EXCEPTHOOK
+ if _HOOKS_INSTALLED:
+ return
+ _HOOKS_INSTALLED = True
+
+ _PREVIOUS_SYS_EXCEPTHOOK = sys.excepthook
+
+ def _sys_hook(exc_type, exc_value, exc_traceback):
+ text = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
+ record_error("uncaught.main", exc_value, text)
+ previous = _PREVIOUS_SYS_EXCEPTHOOK
+ if previous and previous is not _sys_hook:
+ try:
+ previous(exc_type, exc_value, exc_traceback)
+ except Exception:
+ pass
+
+ sys.excepthook = _sys_hook
+
+ if hasattr(threading, "excepthook"):
+ _PREVIOUS_THREAD_EXCEPTHOOK = threading.excepthook
+
+ def _thread_hook(args):
+ text = "".join(traceback.format_exception(
+ args.exc_type,
+ args.exc_value,
+ args.exc_traceback,
+ ))
+ name = getattr(getattr(args, "thread", None), "name", "unknown")
+ record_error(f"uncaught.thread:{name}", args.exc_value, text)
+ previous = _PREVIOUS_THREAD_EXCEPTHOOK
+ if previous and previous is not _thread_hook:
+ try:
+ previous(args)
+ except Exception:
+ pass
+
+ threading.excepthook = _thread_hook
+
+
+def uninstall_uncaught_exception_hooks() -> None:
+ """Restores exception hooks installed before the plugin loaded."""
+ global _HOOKS_INSTALLED
+ if not _HOOKS_INSTALLED:
+ return
+ if _PREVIOUS_SYS_EXCEPTHOOK is not None:
+ sys.excepthook = _PREVIOUS_SYS_EXCEPTHOOK
+ if _PREVIOUS_THREAD_EXCEPTHOOK is not None and hasattr(threading, "excepthook"):
+ threading.excepthook = _PREVIOUS_THREAD_EXCEPTHOOK
+ _HOOKS_INSTALLED = False
+
+
+def guarded(where: str, callback: Any):
+ """Wraps a UI/queue callback so failures are recorded before propagation stops."""
+ def _wrapped(*args, **kwargs):
+ try:
+ return callback(*args, **kwargs)
+ except Exception as exc:
+ record_error(where, exc)
+ raise
+ return _wrapped
+
+
+def record_fact(key: str, value: Any) -> None:
+ """Records a resolved runtime fact (hook installed, view found, etc.)."""
+ text = str(value)
+ if len(_FACTS) >= _MAX_FACTS:
+ _FACTS.pop(0)
+ _FACTS.append((key, text))
+ log(f"{key} = {text}")
+
+
+def get_errors() -> List[Dict[str, str]]:
+ """Returns structured recorded errors, newest last."""
+ return [dict(item) for item in _ERRORS]
+
+
+def get_facts() -> List[Tuple[str, str]]:
+ """Returns recorded runtime facts, newest last."""
+ return list(_FACTS)
+
+
+
+def build_errors_report() -> str:
+ """Builds a copyable report containing only the latest structured errors."""
+ lines = [f"=== AI Chat Summaries Latest Errors ({len(_ERRORS)}) ==="]
+ if not _ERRORS:
+ lines.append("")
+ else:
+ for index, item in enumerate(reversed(_ERRORS), 1):
+ lines.extend((
+ "",
+ f"--- Error {index} (newest first) ---",
+ f"Timestamp: {item['timestamp']}",
+ f"Thread: {item['thread']}",
+ f"Location: {item['where']}",
+ f"Type: {item['type']}",
+ f"Message: {item['message']}",
+ "Traceback:",
+ item["traceback"],
+ ))
+ lines.append("===================================")
+ return "\n".join(lines)
+
+
+def build_logs_report() -> str:
+ """Builds a copyable report containing retained plugin logs only."""
+ lines = [f"=== AI Chat Summaries Latest Logs ({len(_LOGS)}) ==="]
+ lines.extend(_LOGS[-300:] if _LOGS else [""])
+ lines.append("===================================")
+ return "\n".join(lines)
+
+def clear() -> None:
+ """Clears recorded diagnostics and retained logs."""
+ _ERRORS.clear()
+ _FACTS.clear()
+ _LOGS.clear()
+
+def probe_runtime() -> List[str]:
+ """Inspects the live exteraGram runtime and reports what this build actually exposes.
+
+ This replaces guesswork about class/field/method names: instead of assuming
+ `pagedownButton` or `scrollToLastMessage` exist, ask the running app.
+ """
+ lines: List[str] = []
+
+ # 1. Which SDK helpers exist (proxy support decides if long-press can work at all).
+ try:
+ import android_utils
+ helpers = [n for n in ("OnClickListener", "OnLongClickListener", "OnTouchListener", "R", "log")
+ if hasattr(android_utils, n)]
+ lines.append("android_utils exports: " + (", ".join(helpers) or ""))
+ except Exception as exc:
+ lines.append(f"android_utils: UNAVAILABLE ({type(exc).__name__})")
+
+ try:
+ import java
+ lines.append("java.dynamic_proxy: " + ("yes" if hasattr(java, "dynamic_proxy") else "NO"))
+ except Exception:
+ lines.append("java.dynamic_proxy: java module unavailable")
+
+ # 2. ChatActivity: confirm the real method names for scroll + lifecycle.
+ try:
+ from hook_utils import find_class
+ cls = find_class("org.telegram.ui.ChatActivity")
+ lines.append("ChatActivity found: " + str(bool(cls)))
+ if cls:
+ names = set()
+ for source in (getattr(cls, "getClass", lambda: None)(), cls):
+ if source is None or not hasattr(source, "getDeclaredMethods"):
+ continue
+ try:
+ for m in source.getDeclaredMethods():
+ try:
+ names.add(m.getName())
+ except Exception:
+ continue
+ except Exception:
+ continue
+ if names:
+ break
+ lines.append(f"ChatActivity declared methods: {len(names)}")
+ interesting = sorted(n for n in names
+ if "scroll" in n.lower() or "pagedown" in n.lower() or "unread" in n.lower())
+ lines.append("scroll/pagedown/unread methods: " + (", ".join(interesting) or ""))
+
+ fields = set()
+ for source in (getattr(cls, "getClass", lambda: None)(), cls):
+ if source is None or not hasattr(source, "getDeclaredFields"):
+ continue
+ try:
+ for f in source.getDeclaredFields():
+ try:
+ fields.add(f.getName())
+ except Exception:
+ continue
+ except Exception:
+ continue
+ if fields:
+ break
+ cand = sorted(n for n in fields
+ if "pagedown" in n.lower() or "unread" in n.lower() or "pinned" in n.lower())
+ lines.append("pagedown/unread/pinned fields: " + (", ".join(cand) or ""))
+ except Exception as exc:
+ lines.append(f"ChatActivity probe failed: {type(exc).__name__}: {exc}")
+
+ # 3. Other classes we depend on.
+ for cls_name in (
+ "org.telegram.ui.Cells.ChatUnreadCell",
+ "org.telegram.ui.Components.PinnedMessageView",
+ ):
+ try:
+ from hook_utils import find_class
+ lines.append(f"{cls_name.rsplit('.', 1)[-1]}: " + ("found" if find_class(cls_name) else "NOT FOUND"))
+ except Exception as exc:
+ lines.append(f"{cls_name}: probe failed ({type(exc).__name__})")
+
+ return lines
+
+
+def build_report() -> str:
+ """Builds a copyable diagnostics report describing what resolved and what failed."""
+ lines = ["=== AI Chat Summaries Diagnostics ==="]
+
+ lines.append("")
+ lines.append(f"--- Runtime Facts ({len(_FACTS)}) ---")
+ if _FACTS:
+ for key, value in _FACTS:
+ lines.append(f"{key} = {value}")
+ else:
+ lines.append("")
+
+ lines.append("")
+ lines.append(f"--- Errors ({len(_ERRORS)}) ---")
+ if _ERRORS:
+ for index, item in enumerate(reversed(_ERRORS), 1):
+ lines.extend((
+ f"[{index}] {item['timestamp']} [{item['thread']}] {item['where']}",
+ f"{item['type']}: {item['message']}",
+ item["traceback"],
+ "",
+ ))
+ else:
+ lines.append("")
+
+ lines.append("")
+ lines.append(f"--- Recent Log ({len(_LOGS)} lines, newest last) ---")
+ if _LOGS:
+ # Cap the tail so the clipboard payload stays pasteable.
+ lines.extend(_LOGS[-120:])
+ else:
+ lines.append("")
+
+ lines.append("")
+ lines.append("--- Live Runtime Probe ---")
+ try:
+ lines.extend(probe_runtime())
+ except Exception as exc:
+ lines.append(f"probe failed: {type(exc).__name__}: {exc}")
+
+ lines.append("")
+ lines.append("--- Environment ---")
+ for module_name in (
+ "base_plugin",
+ "client_utils",
+ "android_utils",
+ "hook_utils",
+ "ui.alert",
+ "ui.settings",
+ "ui.bulletin",
+ "markdown_utils",
+ ):
+ try:
+ __import__(module_name)
+ lines.append(f"{module_name}: available")
+ except Exception as exc:
+ lines.append(f"{module_name}: MISSING ({type(exc).__name__})")
+
+ lines.append("===================================")
+ return "\n".join(lines)
+
+
+
+def safe_text(value: Any) -> str:
+ """Converts a possibly-null Java value to a string, never the literal "None"."""
+ if value is None:
+ return ""
+ try:
+ text = str(value)
+ except Exception:
+ return ""
+ # A Java null crossing JNI stringifies to "None"/"null"; useless as a title.
+ if text in ("None", "null", ""):
+ return ""
+ return text
+
+
+def resolve_chat_context(fragment: Any):
+ """Resolves (dialog_id, chat_title, topic_id) from a ChatActivity fragment.
+
+ Mirrors the ordering used by the working reference plugins: getDialogId()
+ is authoritative and is checked before the dialog_id attribute.
+ """
+ dialog_id = 0
+ chat_title = ""
+ topic_id = 0
+
+ if fragment is None:
+ return dialog_id, chat_title, topic_id
+
+ # 1. Dialog id: getDialogId() first, attribute as fallback.
+ try:
+ if hasattr(fragment, "getDialogId"):
+ dialog_id = int(fragment.getDialogId() or 0)
+ except Exception as exc:
+ record_error("resolve_chat_context:getDialogId", exc)
+ if not dialog_id:
+ try:
+ dialog_id = int(getattr(fragment, "dialog_id", 0) or 0)
+ except Exception:
+ dialog_id = 0
+
+ # 2. Title: prefer the real chat/user record, fall back to the action bar.
+ if dialog_id:
+ try:
+ from client_utils import get_messages_controller
+ controller = get_messages_controller()
+ if controller is not None:
+ if dialog_id < 0:
+ chat = controller.getChat(abs(dialog_id))
+ chat_title = safe_text(getattr(chat, "title", None))
+ else:
+ user = controller.getUser(dialog_id)
+ first = safe_text(getattr(user, "first_name", None))
+ last = safe_text(getattr(user, "last_name", None))
+ chat_title = (first + " " + last).strip()
+ if not chat_title:
+ chat_title = safe_text(getattr(user, "username", None))
+ except Exception as exc:
+ record_error("resolve_chat_context:title", exc)
+
+ if not chat_title:
+ try:
+ bar = getattr(fragment, "actionBar", None)
+ if bar is not None:
+ chat_title = safe_text(bar.getTitle())
+ except Exception:
+ chat_title = ""
+
+ if not chat_title and dialog_id:
+ chat_title = "Chat %d" % dialog_id
+
+ # 3. Topic / forum thread id.
+ try:
+ if hasattr(fragment, "getTopicId"):
+ topic_id = int(fragment.getTopicId() or 0)
+ elif hasattr(fragment, "topicId"):
+ topic_id = int(getattr(fragment, "topicId", 0) or 0)
+ except Exception:
+ topic_id = 0
+
+ return dialog_id, chat_title, topic_id
\ No newline at end of file
diff --git a/src/hooks/__init__.py b/src/hooks/__init__.py
new file mode 100644
index 0000000..cfcb4da
--- /dev/null
+++ b/src/hooks/__init__.py
@@ -0,0 +1,8 @@
+"""
+Hooks module exports.
+"""
+
+from .pinned_hook import PinnedHeaderHook
+from .unread_hook import UnreadBadgeHook
+
+__all__ = ["PinnedHeaderHook", "UnreadBadgeHook"]
diff --git a/src/hooks/pinned_hook.py b/src/hooks/pinned_hook.py
new file mode 100644
index 0000000..70b824f
--- /dev/null
+++ b/src/hooks/pinned_hook.py
@@ -0,0 +1,312 @@
+"""
+Hook for PinnedMessageView / ChatActivity top pinned section to inject AI Action button and progress widget.
+"""
+
+from typing import Any, List, Optional
+import weakref
+
+from ..config import SETTING_ENABLE_PINNED_TRIGGER
+from ..diagnostics import record_error, record_fact
+from ..localization import locali
+
+
+def get_java_class(cls: Any) -> Any:
+ """Resolves the actual java.lang.Class object from a Chaquopy class reference."""
+ if not cls:
+ return None
+ if hasattr(cls, "getDeclaredMethods"):
+ return cls
+ try:
+ inner = cls.getClass()
+ if inner and hasattr(inner, "getDeclaredMethods"):
+ return inner
+ except Exception:
+ pass
+ return None
+
+
+def find_methods_by_name(cls: Any, wanted: frozenset) -> List[Any]:
+ """Single-pass lookup of declared methods whose name is in `wanted`.
+
+ Compares cheap Java strings rather than Method objects: comparing Method
+ objects crosses JNI into equals() and is O(n^2) over ~1000 declared methods,
+ which blocks the main thread long enough to trigger an ANR.
+ """
+ found: List[Any] = []
+ java_cls = get_java_class(cls)
+ if not java_cls:
+ return found
+ try:
+ for m in java_cls.getDeclaredMethods():
+ try:
+ if m.getName() in wanted:
+ found.append(m)
+ except Exception:
+ continue
+ except Exception:
+ pass
+ return found
+
+
+def get_class_constructors(cls: Any) -> List[Any]:
+ """Single-pass retrieval of declared constructors from a Java class."""
+ java_cls = get_java_class(cls)
+ if not java_cls:
+ return []
+ try:
+ return list(java_cls.getDeclaredConstructors())
+ except Exception:
+ return []
+
+
+class PinnedHeaderHook:
+ """Hooks ChatActivity / PinnedMessageView to inject AI button in top pinned message bar."""
+
+ LIFECYCLE_METHODS = frozenset((
+ "createView",
+ "onResume",
+ "updatePinnedMessageView",
+ "showPinnedMessageView",
+ ))
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ self.plugin = plugin_instance
+ self.unhook_refs: list[Any] = []
+ self._installed = False
+
+ def install_hook(self) -> None:
+ """Installs XposedHook on ChatActivity and PinnedMessageView."""
+ if self._installed:
+ return
+ self._installed = True
+ try:
+ from base_plugin import XposedHook
+ from hook_utils import find_class
+
+ plugin_ref = weakref.ref(self.plugin) if self.plugin else None
+ hook_self = self
+
+ # 1. Hook ChatActivity lifecycle methods
+ chat_activity_class = find_class("org.telegram.ui.ChatActivity")
+ if chat_activity_class:
+ class _PinnedLifecycleHook(XposedHook):
+ def after_hooked_method(self, param):
+ try:
+ if not param or not hasattr(param, "thisObject"):
+ return
+ chat_act = param.thisObject
+ if not chat_act:
+ return
+
+ p = plugin_ref() if plugin_ref else None
+ if not p or not bool(p.get_setting(SETTING_ENABLE_PINNED_TRIGGER, True)):
+ return
+
+ hook_self._inject_pinned_button(chat_act)
+ except Exception as exc:
+ record_error("pinned.lifecycle.after", exc)
+
+ lifecycle_hook = _PinnedLifecycleHook()
+ can_hook = bool(self.plugin and hasattr(self.plugin, "hook_method"))
+ record_fact("pinned.ChatActivity_found", True)
+ record_fact("pinned.can_hook", can_hook)
+ hooked = 0
+ for m in find_methods_by_name(chat_activity_class, self.LIFECYCLE_METHODS):
+ if not can_hook:
+ break
+ try:
+ ref = self.plugin.hook_method(m, lifecycle_hook)
+ if ref:
+ self.unhook_refs.append(ref)
+ hooked += 1
+ except Exception as exc:
+ record_error("pinned.hook_method", exc)
+ record_fact("pinned.lifecycle_methods_hooked", hooked)
+
+ # 2. Hook PinnedMessageView constructors
+ pinned_class = find_class("org.telegram.ui.Components.PinnedMessageView")
+ if pinned_class:
+ class _PinnedViewCtorHook(XposedHook):
+ def after_hooked_method(self, param):
+ try:
+ if not param or not hasattr(param, "thisObject"):
+ return
+ pinned_v = param.thisObject
+ from client_utils import get_last_fragment
+ act = get_last_fragment()
+ if act:
+ hook_self._inject_pinned_button(act, pinned_v)
+ except Exception:
+ pass
+
+ ctors = get_class_constructors(pinned_class)
+ for ctor in ctors:
+ try:
+ if self.plugin and hasattr(self.plugin, "hook_method"):
+ ref = self.plugin.hook_method(ctor, _PinnedViewCtorHook())
+ if ref:
+ self.unhook_refs.append(ref)
+ except Exception:
+ pass
+
+ except Exception:
+ pass
+
+ def uninstall_hook(self) -> None:
+ """Removes the hooks when plugin is unloaded."""
+ if self.plugin and hasattr(self.plugin, "unhook_method"):
+ for ref in self.unhook_refs:
+ try:
+ self.plugin.unhook_method(ref)
+ except Exception:
+ pass
+ self.unhook_refs.clear()
+
+ def _inject_pinned_button(self, chat_activity: Any, explicit_pinned_view: Optional[Any] = None) -> None:
+ """Adds or updates AI summary button next to pinned message view."""
+ # Lifecycle hooks fire repeatedly; only scan the view tree until the button exists.
+ if not explicit_pinned_view:
+ try:
+ if getattr(chat_activity, "_ai_summary_pinned_done", False):
+ return
+ except Exception:
+ pass
+
+ try:
+ from hook_utils import get_private_field
+ from org.telegram.messenger import AndroidUtilities, R
+ from org.telegram.ui.ActionBar import Theme
+ from android.widget import ImageView, FrameLayout
+ from android.view import Gravity, View
+ from android_utils import OnClickListener, run_on_ui_thread
+
+ hook_self = self
+
+ def _do_inject():
+ try:
+ pinned_view = explicit_pinned_view
+ if not pinned_view:
+ pinned_view = (
+ getattr(chat_activity, "pinnedMessageView", None)
+ or get_private_field(chat_activity, "pinnedMessageView")
+ )
+
+ # Scan child views in fragmentView if not directly found
+ if not pinned_view:
+ root_v = getattr(chat_activity, "fragmentView", None) or getattr(chat_activity, "contentView", None)
+ if root_v and hasattr(root_v, "getChildCount"):
+ for i in range(root_v.getChildCount()):
+ child = root_v.getChildAt(i)
+ c_name = str(type(child).__name__).lower() + " " + str(child)
+ if "pinnedmessageview" in c_name or "pinned" in c_name:
+ pinned_view = child
+ break
+
+ if not pinned_view:
+ return
+
+ if hook_self.plugin and hasattr(hook_self.plugin, "progress_manager"):
+ hook_self.plugin.progress_manager.attach_pinned_view(pinned_view)
+
+ parent = pinned_view.getParent() or pinned_view
+ if not parent or not hasattr(parent, "findViewWithTag"):
+ return
+
+ tag_name = "ai_chat_summary_pinned_btn"
+ existing_btn = parent.findViewWithTag(tag_name)
+ if existing_btn:
+ existing_btn.bringToFront()
+ existing_btn.setVisibility(View.VISIBLE)
+ try:
+ chat_activity._ai_summary_pinned_done = True
+ except Exception:
+ pass
+ return
+
+ def dp(val: float) -> int:
+ return AndroidUtilities.dp(val)
+
+ act = chat_activity.getParentActivity()
+ if not act:
+ return
+
+ ai_btn = ImageView(act)
+ ai_btn.setTag(tag_name)
+ ai_btn.setScaleType(ImageView.ScaleType.CENTER)
+ ai_btn.setPadding(dp(6), dp(6), dp(6), dp(6))
+
+ # Set icon (msg_bot / msg_robot / msg_settings)
+ icon_id = (
+ getattr(R.drawable, "msg_bot", None)
+ or getattr(R.drawable, "msg_robot", None)
+ or getattr(R.drawable, "msg_settings", None)
+ )
+ if icon_id:
+ ai_btn.setImageResource(icon_id)
+
+ # Color filter matching header theme
+ try:
+ icon_color = Theme.getColor(Theme.key_actionBarDefaultIcon)
+ ai_btn.setColorFilter(icon_color)
+ except Exception:
+ pass
+
+ # Clickable ripple background
+ try:
+ sel_color = Theme.getColor(Theme.key_actionBarDefaultSelector)
+ ai_btn.setBackground(Theme.createSelectorDrawable(sel_color, 1))
+ except Exception:
+ pass
+
+ ai_btn.setClickable(True)
+ ai_btn.setFocusable(True)
+
+ def on_ai_btn_click(*_):
+ dialog_id = getattr(chat_activity, "dialog_id", 0)
+ if not dialog_id and hasattr(chat_activity, "getDialogId"):
+ dialog_id = chat_activity.getDialogId()
+
+ chat_title = ""
+ try:
+ chat_title = str(getattr(chat_activity, "actionBar", None).getTitle())
+ except Exception:
+ pass
+
+ topic_id = 0
+ if hasattr(chat_activity, "topicId"):
+ topic_id = getattr(chat_activity, "topicId", 0)
+ elif hasattr(chat_activity, "getTopicId"):
+ topic_id = chat_activity.getTopicId()
+
+ from ..ui.pre_request import show_pre_request_sheet
+ show_pre_request_sheet(
+ plugin=hook_self.plugin,
+ dialog_id=dialog_id,
+ chat_title=chat_title,
+ topic_id=topic_id,
+ )
+
+ ai_btn.setOnClickListener(OnClickListener(on_ai_btn_click))
+
+ lp = FrameLayout.LayoutParams(dp(38), dp(38))
+ lp.gravity = Gravity.RIGHT | Gravity.CENTER_VERTICAL
+ lp.rightMargin = dp(44) # Left of the close/options X button
+ lp.topMargin = dp(2)
+
+ parent.addView(ai_btn, lp)
+ ai_btn.bringToFront()
+ ai_btn.setVisibility(View.VISIBLE)
+
+ # Button exists now: stop re-scanning on later lifecycle calls.
+ try:
+ chat_activity._ai_summary_pinned_done = True
+ except Exception:
+ pass
+
+ except Exception:
+ pass
+
+ run_on_ui_thread(_do_inject)
+
+ except Exception:
+ pass
diff --git a/src/hooks/unread_hook.py b/src/hooks/unread_hook.py
new file mode 100644
index 0000000..6d73054
--- /dev/null
+++ b/src/hooks/unread_hook.py
@@ -0,0 +1,536 @@
+"""
+Hook for ChatUnreadCell and the floating unread (pagedown) button.
+
+Long-pressing either one opens the AI summarization pre-request sheet and
+cancels Telegram's normal "scroll to newest message" behaviour.
+"""
+
+import time
+from typing import Any, List, Optional
+import weakref
+
+from ..config import SETTING_ENABLE_UNREAD_LONGPRESS
+from ..diagnostics import record_error, record_fact
+from ..localization import locali
+
+# Long-press threshold in seconds.
+LONG_PRESS_SECONDS = 0.4
+
+# Scroll suppression window: set while a long-press is being handled so the
+# ChatActivity scroll hook can abort Telegram's jump-to-latest.
+_SUPPRESS_UNTIL: List[float] = [0.0]
+
+# Gesture state observed at LaunchActivity.dispatchTouchEvent. This avoids any
+# dependency on a private pagedown button field; the runtime report confirms
+# this build has onPageDownClicked() but no field named pagedownButton.
+_GESTURE = {
+ "down_at": 0.0,
+ "released_at": 0.0,
+ "last_action": -1,
+ "consumed": False,
+}
+
+
+def _gesture_before_dispatch(event: Any) -> None:
+ """Records DOWN/UP timing before Android dispatches a touch event."""
+ try:
+ action = event.getActionMasked() if hasattr(event, "getActionMasked") else event.getAction()
+ except Exception:
+ return
+
+ now = time.monotonic()
+ _GESTURE["last_action"] = action
+ if action == 0: # MotionEvent.ACTION_DOWN
+ _GESTURE["down_at"] = now
+ _GESTURE["released_at"] = 0.0
+ _GESTURE["consumed"] = False
+ elif action == 1: # MotionEvent.ACTION_UP
+ _GESTURE["released_at"] = now
+ elif action == 3: # MotionEvent.ACTION_CANCEL
+ _GESTURE["down_at"] = 0.0
+ _GESTURE["released_at"] = 0.0
+ _GESTURE["consumed"] = False
+
+
+def _held_duration() -> float:
+ """Returns duration of the current/recent touch gesture in seconds."""
+ down_at = float(_GESTURE.get("down_at") or 0.0)
+ if not down_at:
+ return 0.0
+ released_at = float(_GESTURE.get("released_at") or 0.0)
+ end = released_at or time.monotonic()
+ # Reject stale gestures; click dispatch normally happens synchronously.
+ if released_at and (time.monotonic() - released_at) > 0.75:
+ return 0.0
+ return max(0.0, end - down_at)
+
+
+def suppress_scroll(duration: float = 1.5) -> None:
+ """Blocks scroll-to-newest for `duration` seconds."""
+ _SUPPRESS_UNTIL[0] = time.time() + duration
+
+
+def is_scroll_suppressed() -> bool:
+ """Returns True while scroll-to-newest should be blocked."""
+ return time.time() < _SUPPRESS_UNTIL[0]
+
+
+def _class_candidates(cls: Any) -> List[Any]:
+ """Returns every object that may expose the real declared members, best first."""
+ candidates: List[Any] = []
+ try:
+ inner = cls.getClass() if cls is not None else None
+ if inner is not None:
+ candidates.append(inner)
+ except Exception:
+ pass
+ if cls is not None:
+ # Identity check avoids Java Method/Class equality calls across JNI.
+ if not any(cls is candidate for candidate in candidates):
+ candidates.append(cls)
+ return candidates
+
+
+def find_methods_by_name(cls: Any, wanted: frozenset) -> List[Any]:
+ """Single-pass lookup of declared or inherited methods named in `wanted`."""
+ for source in _class_candidates(cls):
+ # getDeclaredMethods exposes private ChatActivity methods such as
+ # onPageDownClicked. getMethods is the fallback for inherited public
+ # methods such as Activity.dispatchTouchEvent.
+ for accessor in ("getDeclaredMethods", "getMethods"):
+ if not hasattr(source, accessor):
+ continue
+ found: List[Any] = []
+ try:
+ methods = getattr(source, accessor)()
+ except Exception as exc:
+ record_error("find_methods_by_name:%s" % accessor, exc)
+ continue
+ for method in methods:
+ try:
+ if method.getName() in wanted:
+ found.append(method)
+ except Exception:
+ continue
+ if found:
+ return found
+ return []
+
+def get_class_constructors(cls: Any) -> List[Any]:
+ """Single-pass retrieval of declared constructors from a Java class."""
+ for source in _class_candidates(cls):
+ if not hasattr(source, "getDeclaredConstructors"):
+ continue
+ try:
+ ctors = list(source.getDeclaredConstructors())
+ except Exception as exc:
+ record_error("get_class_constructors", exc)
+ continue
+ if ctors:
+ return ctors
+ return []
+
+
+def make_long_click_listener(callback: Any) -> Any:
+ """Creates a Java View.OnLongClickListener proxy, or None if unsupported."""
+ try:
+ from android_utils import OnLongClickListener
+ return OnLongClickListener(callback)
+ except Exception:
+ pass
+
+ try:
+ from java import dynamic_proxy
+ from android.view import View
+
+ class _LongClickProxy(dynamic_proxy(View.OnLongClickListener)):
+ def __init__(self, cb):
+ super().__init__()
+ self.cb = cb
+
+ def onLongClick(self, v):
+ return bool(self.cb(v))
+
+ return _LongClickProxy(callback)
+ except Exception as exc:
+ record_error("make_long_click_listener", exc)
+
+ return None
+
+
+def make_touch_listener(callback: Any) -> Any:
+ """Creates a Java View.OnTouchListener proxy, or None if unsupported."""
+ try:
+ from android_utils import OnTouchListener
+ return OnTouchListener(callback)
+ except Exception:
+ pass
+
+ try:
+ from java import dynamic_proxy
+ from android.view import View
+
+ class _TouchProxy(dynamic_proxy(View.OnTouchListener)):
+ def __init__(self, cb):
+ super().__init__()
+ self.cb = cb
+
+ def onTouch(self, v, event):
+ return bool(self.cb(v, event))
+
+ return _TouchProxy(callback)
+ except Exception as exc:
+ record_error("make_touch_listener", exc)
+
+ return None
+
+
+def describe_view(view: Any) -> str:
+ """Returns the Java class name of a view for diagnostics."""
+ if view is None:
+ return ""
+ try:
+ return str(view.getClass().getName())
+ except Exception:
+ return str(type(view).__name__)
+
+
+class UnreadBadgeHook:
+ """Hooks ChatUnreadCell and the ChatActivity pagedown button for long-press summarization."""
+
+ # Grounded by the on-device diagnostics report. The floating button has no
+ # exposed `pagedownButton` field in this exteraGram build. Its stable
+ # behavioral boundary is ChatActivity.onPageDownClicked().
+ PAGE_DOWN_METHODS = frozenset((
+ "onPageDownClicked",
+ ))
+
+ # Second defense: if onPageDownClicked delegates into either overload,
+ # abort that call while handling our long press.
+ SCROLL_METHODS = frozenset((
+ "scrollToLastMessage",
+ ))
+
+ # LaunchActivity sees the original DOWN/UP events before the pagedown click
+ # callback. Tracking duration there avoids any private button-field lookup.
+ TOUCH_DISPATCH_METHODS = frozenset((
+ "dispatchTouchEvent",
+ ))
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ self.plugin = plugin_instance
+ self.unhook_refs: List[Any] = []
+ self._installed = False
+ self._hooked_view_ids: set = set()
+
+ def _mark_hooked(self, view: Any) -> bool:
+ """Returns True the first time `view` is seen, False afterwards."""
+ try:
+ key = id(view)
+ except Exception:
+ return True
+ if key in self._hooked_view_ids:
+ return False
+ # Bound the set so a long session cannot grow it without limit.
+ if len(self._hooked_view_ids) > 64:
+ self._hooked_view_ids.clear()
+ self._hooked_view_ids.add(key)
+ return True
+
+ def install_hook(self) -> None:
+ """Installs hooks on ChatUnreadCell, ChatActivity lifecycle, and scroll suppression."""
+ if self._installed:
+ return
+ self._installed = True
+
+ try:
+ from base_plugin import XposedHook
+ from hook_utils import find_class
+ except Exception as exc:
+ record_error("unread.install_hook:imports", exc)
+ return
+
+ plugin_ref = weakref.ref(self.plugin) if self.plugin else None
+ hook_self = self
+ can_hook = bool(self.plugin and hasattr(self.plugin, "hook_method"))
+ record_fact("unread.can_hook", can_hook)
+ if not can_hook:
+ return
+
+ def enabled() -> bool:
+ p = plugin_ref() if plugin_ref else None
+ if not p:
+ return False
+ return bool(p.get_setting(SETTING_ENABLE_UNREAD_LONGPRESS, True))
+
+ # 1. ChatUnreadCell constructor -> attach listeners to the unread divider.
+ try:
+ unread_cell_class = find_class("org.telegram.ui.Cells.ChatUnreadCell")
+ record_fact("unread.ChatUnreadCell_found", bool(unread_cell_class))
+
+ if unread_cell_class:
+ class _UnreadCellHook(XposedHook):
+ def after_hooked_method(self, param):
+ try:
+ cell = getattr(param, "thisObject", None)
+ if cell and enabled():
+ hook_self._setup_unread_cell(cell)
+ except Exception as exc:
+ record_error("unread.ChatUnreadCell.after", exc)
+
+ cell_hook = _UnreadCellHook()
+ ctors = get_class_constructors(unread_cell_class)
+ attached = 0
+ for ctor in ctors:
+ try:
+ ref = self.plugin.hook_method(ctor, cell_hook)
+ if ref:
+ self.unhook_refs.append(ref)
+ attached += 1
+ except Exception as exc:
+ record_error("unread.hook_ctor", exc)
+ record_fact("unread.ChatUnreadCell_ctors_hooked", attached)
+ except Exception as exc:
+ record_error("unread.install_hook:cell", exc)
+
+ # 2. Hook the actual page-down click method and its scroll delegates.
+ # The on-device report confirms all three methods exist, while no
+ # `pagedownButton` field exists.
+ try:
+ chat_act_class = find_class("org.telegram.ui.ChatActivity")
+ record_fact("unread.ChatActivity_found", bool(chat_act_class))
+ if not chat_act_class:
+ return
+
+ class _PageDownHook(XposedHook):
+ def before_hooked_method(self, param):
+ try:
+ duration = _held_duration()
+ record_fact("unread.onPageDownClicked.duration", "%.3f" % duration)
+ if duration < LONG_PRESS_SECONDS or _GESTURE["consumed"]:
+ return
+
+ chat_act = getattr(param, "thisObject", None)
+ if chat_act is None or not enabled():
+ return
+
+ _GESTURE["consumed"] = True
+ suppress_scroll(1.5)
+ # Abort Telegram's normal click method before it can
+ # move to the newest message.
+ if hasattr(param, "setResult"):
+ param.setResult(None)
+ record_fact("unread.onPageDownClicked", "INTERCEPTED long press")
+ hook_self._trigger(chat_act, source="onPageDownClicked")
+ except Exception as exc:
+ record_error("unread.onPageDownClicked", exc)
+
+ class _ScrollSuppressHook(XposedHook):
+ def before_hooked_method(self, param):
+ try:
+ if is_scroll_suppressed() and hasattr(param, "setResult"):
+ param.setResult(None)
+ record_fact("unread.scroll_suppressed_at", time.time())
+ except Exception as exc:
+ record_error("unread.scroll_suppress", exc)
+
+ click_hook = _PageDownHook()
+ scroll_hook = _ScrollSuppressHook()
+ click_names: List[str] = []
+ scroll_names: List[str] = []
+
+ wanted = self.PAGE_DOWN_METHODS | self.SCROLL_METHODS
+ for m in find_methods_by_name(chat_act_class, wanted):
+ try:
+ name = m.getName()
+ is_click = name in self.PAGE_DOWN_METHODS
+ ref = self.plugin.hook_method(m, click_hook if is_click else scroll_hook)
+ if not ref:
+ continue
+ self.unhook_refs.append(ref)
+ (click_names if is_click else scroll_names).append(name)
+ except Exception as exc:
+ record_error("unread.hook_method", exc)
+
+ record_fact("unread.page_down_methods_hooked", click_names or "")
+ record_fact("unread.scroll_methods_hooked", scroll_names or "")
+ except Exception as exc:
+ record_error("unread.install_hook:activity", exc)
+
+ # 3. Track DOWN/UP timing at the public Activity dispatch boundary.
+ try:
+ launch_class = find_class("org.telegram.ui.LaunchActivity")
+ record_fact("unread.LaunchActivity_found", bool(launch_class))
+ touch_methods = find_methods_by_name(launch_class, self.TOUCH_DISPATCH_METHODS)
+
+ class _TouchDispatchHook(XposedHook):
+ def before_hooked_method(self, param):
+ try:
+ if param and getattr(param, "args", None):
+ _gesture_before_dispatch(param.args[0])
+ except Exception as exc:
+ record_error("unread.dispatchTouchEvent", exc)
+
+ touch_hook = _TouchDispatchHook()
+ touch_count = 0
+ for m in touch_methods:
+ try:
+ ref = self.plugin.hook_method(m, touch_hook)
+ if ref:
+ self.unhook_refs.append(ref)
+ touch_count += 1
+ except Exception as exc:
+ record_error("unread.hook_dispatchTouchEvent", exc)
+ record_fact("unread.touch_dispatch_hooked", touch_count)
+ except Exception as exc:
+ record_error("unread.install_hook:touch_dispatch", exc)
+
+ def uninstall_hook(self) -> None:
+ """Removes all installed hooks and resets local state."""
+ if self.plugin and hasattr(self.plugin, "unhook_method"):
+ for ref in self.unhook_refs:
+ try:
+ self.plugin.unhook_method(ref)
+ except Exception as exc:
+ record_error("unread.uninstall", exc)
+ self.unhook_refs.clear()
+ self._hooked_view_ids.clear()
+ self._installed = False
+
+ # ==================== ChatUnreadCell ====================
+
+ def _setup_unread_cell(self, unread_cell: Any) -> None:
+ """Attaches click and long-click listeners to the unread divider cell."""
+ try:
+ from android_utils import OnClickListener, run_on_ui_thread, R
+ from hook_utils import get_private_field
+ except Exception as exc:
+ record_error("unread.cell:imports", exc)
+ return
+
+ hook_self = self
+
+ def _init():
+ try:
+ target = None
+ try:
+ target = get_private_field(unread_cell, "backgroundLayout")
+ except Exception:
+ target = None
+
+ if not target:
+ try:
+ if unread_cell.getChildCount() > 0:
+ target = unread_cell.getChildAt(0)
+ except Exception:
+ target = None
+
+ target = target or unread_cell
+ if not hook_self._mark_hooked(target):
+ return
+
+ record_fact("unread.cell_target", describe_view(target))
+
+ target.setClickable(True)
+ target.setFocusable(True)
+ target.setLongClickable(True)
+
+ def on_action(v=None):
+ hook_self._trigger(None, source="unread_cell")
+ return True
+
+ try:
+ target.setOnClickListener(OnClickListener(lambda v=None: on_action(v)))
+ except Exception as exc:
+ record_error("unread.cell:setOnClickListener", exc)
+
+ proxy = make_long_click_listener(on_action)
+ if proxy:
+ target.setOnLongClickListener(proxy)
+ record_fact("unread.cell_longclick_attached", bool(proxy))
+ except Exception as exc:
+ record_error("unread.cell:init", exc)
+
+ try:
+ unread_cell.post(R(_init))
+ except Exception:
+ try:
+ run_on_ui_thread(_init)
+ except Exception as exc:
+ record_error("unread.cell:post", exc)
+
+ # ==================== Page-down Trigger ====================
+
+ def _trigger(self, chat_activity: Optional[Any] = None, source: str = "") -> None:
+ """Resolves unread count/offset and opens the pre-request sheet."""
+ record_fact("unread.trigger", source or "unknown")
+ try:
+ from client_utils import get_last_fragment, get_messages_controller
+ from android_utils import run_on_ui_thread
+
+ frag = chat_activity or get_last_fragment()
+ if not frag:
+ record_fact("unread.trigger_abort", "no fragment")
+ return
+
+ from ..diagnostics import resolve_chat_context
+ dialog_id, chat_title, topic_id = resolve_chat_context(frag)
+ record_fact("unread.chat", "id=%s title=%r topic=%s" % (dialog_id, chat_title, topic_id))
+
+ if not dialog_id:
+ record_fact("unread.trigger_abort", "no dialog_id")
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_error("AI Summary: cannot resolve current chat")
+ except Exception:
+ pass
+ return
+
+ raw_unread = 0
+ try:
+ mc = get_messages_controller()
+ dialog = mc.dialogs_dict.get(dialog_id) if (mc and hasattr(mc, "dialogs_dict")) else None
+ if dialog:
+ raw_unread = int(getattr(dialog, "unread_count", 0) or 0)
+ except Exception as exc:
+ record_error("unread.trigger:unread_count", exc)
+
+ target_count = min(1000, max(1, raw_unread)) if raw_unread > 0 else 100
+ target_offset = max(0, raw_unread - target_count) if raw_unread > 0 else 0
+ record_fact("unread.batch", f"count={target_count} offset={target_offset} unread={raw_unread}")
+
+ chat_title = ""
+ try:
+ bar = getattr(frag, "actionBar", None)
+ if bar is not None:
+ chat_title = str(bar.getTitle())
+ except Exception:
+ chat_title = ""
+
+ topic_id = 0
+ try:
+ if hasattr(frag, "topicId"):
+ topic_id = getattr(frag, "topicId", 0) or 0
+ elif hasattr(frag, "getTopicId"):
+ topic_id = frag.getTopicId() or 0
+ except Exception:
+ topic_id = 0
+
+ from ..ui.pre_request import show_pre_request_sheet
+
+ def _open():
+ show_pre_request_sheet(
+ plugin=self.plugin,
+ dialog_id=dialog_id,
+ chat_title=chat_title,
+ initial_count=target_count,
+ initial_offset=target_offset,
+ topic_id=topic_id,
+ is_unread_trigger=True,
+ total_unread_count=raw_unread,
+ auto_jump=True,
+ )
+
+ run_on_ui_thread(_open)
+ except Exception as exc:
+ record_error("unread.trigger", exc)
diff --git a/src/localization.py b/src/localization.py
new file mode 100644
index 0000000..0fecaa9
--- /dev/null
+++ b/src/localization.py
@@ -0,0 +1,339 @@
+"""
+Localization Manager providing comprehensive English and Russian strings for AI Chat Summaries.
+"""
+
+from typing import Any, Dict, Optional
+
+
+class LocalizationManager:
+ """Manages internationalization for UI strings with automatic fallback to English."""
+
+ _strings: Dict[str, Dict[str, str]] = {
+ "en": {
+ # Plugin Metadata
+ "PLUGIN_NAME": "AI Chat Summaries",
+ "PLUGIN_DESC": "Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, animated progress, and dual triggers.",
+
+ # Settings - General & Triggers
+ "SETTINGS_HEADER_GENERAL": "General & Triggers",
+ "SETTINGS_ENABLE_PINNED": "Pinned Header AI Button",
+ "SETTINGS_ENABLE_PINNED_SUB": "Display AI summary button next to pinned message bar in chats.",
+ "SETTINGS_ENABLE_UNREAD": "Long-Press Unread Badge",
+ "SETTINGS_ENABLE_UNREAD_SUB": "Hold the floating unread counter or unread bar to summarize unread messages.",
+ "SETTINGS_ENABLE_THINKING": "Thinking Stream Preview",
+ "SETTINGS_ENABLE_THINKING_SUB": "Stream live reasoning and thought process tokens. Disable to save mobile bandwidth.",
+
+ # Settings - Provider Selection
+ "SETTINGS_HEADER_PROVIDER": "AI Provider Configuration",
+ "SETTINGS_PROVIDER_SELECTOR": "Active Provider",
+
+ # Settings - ChatGPT OAuth
+ "OAUTH_HEADER": "ChatGPT Account (OAuth)",
+ "OAUTH_CONNECTED_STATUS": "Connected: {email} • Tier: {tier}",
+ "OAUTH_DISCONNECTED_STATUS": "No account connected. Log in to use ChatGPT models.",
+ "OAUTH_BTN_CONNECT": "Connect ChatGPT Account",
+ "OAUTH_BTN_DISCONNECT": "Disconnect Account",
+ "OAUTH_MODEL_SELECTOR": "ChatGPT Model",
+ "OAUTH_FREE_BADGE": "Free Tier",
+ "OAUTH_PLUS_BADGE": "Plus / Pro Tier",
+ "OAUTH_LOGIN_TITLE": "ChatGPT Login",
+ "OAUTH_LOGIN_MSG": "Authorize exteraGram in your browser, then copy and paste the redirect code below if not redirected automatically.",
+ "OAUTH_COPY_LINK": "Copy Login URL",
+ "OAUTH_PASTE_CODE_HINT": "Paste authorization code or redirect URL here",
+ "OAUTH_SUBMIT_CODE": "Complete Login",
+ "OAUTH_SUCCESS": "Successfully authenticated as {email} ({tier})!",
+ "OAUTH_ERROR": "ChatGPT authentication failed: {error}",
+
+ # Settings - Custom Provider
+ "CUSTOM_HEADER": "Custom AI Provider",
+ "CUSTOM_URL_INPUT": "Base URL",
+ "CUSTOM_URL_SUB": "e.g. https://openrouter.ai/api/v1 or https://api.deepseek.com/v1",
+ "CUSTOM_KEY_INPUT": "API Key",
+ "CUSTOM_KEY_SUB": "Bearer token / API key for custom endpoint.",
+ "CUSTOM_MODEL_INPUT": "Model Name",
+ "CUSTOM_MODEL_SUB": "e.g. deepseek-chat, meta-llama/llama-3.3-70b-instruct",
+ "CUSTOM_CONTEXT_SELECTOR": "Context Window Token Budget",
+ "CUSTOM_CONTEXT_SUB": "Tokens allowed for context history (handles up to 8,000 chars per message).",
+ "CUSTOM_TEST_BTN": "Verify Connection & Model",
+ "CUSTOM_TESTING_STAGE1": "Checking URL reachability...",
+ "CUSTOM_TESTING_STAGE2": "Testing model probe (thinking disabled)...",
+ "CUSTOM_TEST_SUCCESS": "Connection verified successfully! Model '{model}' responded in {time:.2f}s.",
+ "CUSTOM_TEST_FAIL_TITLE": "Verification Failed: {error}",
+
+ # Settings - Direct Built-in Providers
+ "OPENAI_HEADER": "OpenAI (Direct Key)",
+ "OPENAI_KEY_INPUT": "OpenAI API Key",
+ "OPENAI_MODEL_SELECTOR": "OpenAI Model",
+
+ "ANTHROPIC_HEADER": "Anthropic (Claude)",
+ "ANTHROPIC_KEY_INPUT": "Anthropic API Key",
+ "ANTHROPIC_MODEL_SELECTOR": "Claude Model",
+
+ "GEMINI_HEADER": "Google Gemini",
+ "GEMINI_KEY_INPUT": "Gemini API Key",
+ "GEMINI_MODEL_SELECTOR": "Gemini Model",
+
+ "OLLAMA_HEADER": "Ollama (Local Server)",
+ "OLLAMA_URL_INPUT": "Ollama Endpoint",
+ "OLLAMA_MODEL_INPUT": "Model Name",
+
+ # Settings - Summary Defaults & Prompts
+ "SETTINGS_HEADER_DEFAULTS": "Summary Preferences",
+ "SETTINGS_DEFAULT_STYLE": "Default Summary Style",
+ "SETTINGS_DEFAULT_LANG": "Summary Language",
+ "SETTINGS_DEFAULT_COUNT": "Default Message Count",
+ "SETTINGS_DEFAULT_COUNT_SUB": "Default number of messages to fetch (1 - 1000).",
+ "SETTINGS_GLOBAL_PROMPT": "Global System Prompt Override",
+ "SETTINGS_GLOBAL_PROMPT_SUB": "Leave blank to use built-in style prompt instructions.",
+
+ # Diagnostics
+ "SETTINGS_HEADER_DIAGNOSTICS": "Diagnostics",
+ "SETTINGS_COPY_ERRORS": "Copy Latest Errors",
+ "SETTINGS_COPY_ERRORS_SUB": "Copies errors with timestamps, thread names, locations, messages, and full tracebacks.",
+ "SETTINGS_COPY_LOGS": "Copy Latest Logs",
+ "SETTINGS_COPY_LOGS_SUB": "Copies the latest 300 timestamped plugin log lines.",
+ "SETTINGS_COPY_DIAGNOSTICS": "Copy Full Diagnostics Report",
+ "SETTINGS_COPY_DIAGNOSTICS_SUB": "Copies errors, logs, hook state, and a live runtime API probe.",
+ "SETTINGS_ERRORS_COPIED": "Latest errors copied to clipboard!",
+ "SETTINGS_LOGS_COPIED": "Latest logs copied to clipboard!",
+ "SETTINGS_DIAGNOSTICS_COPIED": "Diagnostics report copied to clipboard!",
+ # Pre-Request Bottom Sheet
+ "PRE_REQ_TITLE": "AI Chat Summary",
+ "PRE_REQ_SUBTITLE": "Chat: {title}",
+ "PRE_REQ_COUNT_LABEL": "Messages to Analyze: {count}",
+ "PRE_REQ_OFFSET_LABEL": "Offset (Skip recent messages): {offset}",
+ "PRE_REQ_OFFSET_SUB": "Enter any non-negative offset. The slider provides a practical range; the number field has no maximum.",
+ "PRE_REQ_STYLE_LABEL": "Summary Style",
+ "PRE_REQ_PROMPT_LABEL": "Prompt for this chat",
+ "PRE_REQ_PROMPT_HINT": "Customize summary focus or questions...",
+ "PRE_REQ_PROMPT_RESET": "Reset Prompt",
+ "PRE_REQ_BTN_START": "Summarize",
+ "PRE_REQ_BTN_CANCEL": "Cancel",
+ "PRE_REQ_UNREAD_BADGE": "Auto-filled from {count} unread messages",
+ "PRE_REQ_UNREAD_BATCH_BADGE": "Oldest unread batch: offset {offset}, count {count} (from {unread} unread)",
+ # In-Chat Progress Widget & Notifications
+ "PROGRESS_SUMMARIZING": "AI Summarizing...",
+ "PROGRESS_FETCHING": "Fetching chat messages...",
+ "PROGRESS_PROCESSING": "Generating summary with {provider}...",
+ "PROGRESS_CLICK_THINKING": "Tap to inspect live reasoning stream",
+ "PROGRESS_THINKING_DISABLED_ALERT": "Thinking preview is disabled in settings, but your request is still being processed in the background.",
+
+ # Live Thinking Sheet
+ "THINKING_TITLE": "Live AI Reasoning Stream",
+ "THINKING_SUBTITLE": "{provider} • {model}",
+ "THINKING_SECTION_THOUGHT": "Thinking Process:",
+ "THINKING_SECTION_OUTPUT": "Drafting Response:",
+ "THINKING_WAITING": "Waiting for model thoughts...",
+ "THINKING_BTN_CLOSE": "Minimize",
+
+ # Summary Result Dialog
+ "RESULT_TITLE": "Chat Summary",
+ "RESULT_HEADER_INFO": "{provider} ({model}) • {count} msgs in {time:.1f}s",
+ "RESULT_SCROLL_HINT": "↕ Scroll to read full summary",
+ "RESULT_BTN_JUMP": "Jump to Latest in Summary",
+ "RESULT_JUMPED_NOTICE": "Moved to message #{id} in chat",
+ "RESULT_BTN_COPY": "Copy Summary",
+ "RESULT_BTN_SHARE": "Share",
+ "RESULT_BTN_COPY_DATA": "Copy Full Prompt & Data",
+ "RESULT_BTN_REGEN": "Regenerate",
+ "RESULT_BTN_INSERT": "Insert in Chat",
+ "RESULT_COPIED_NOTICE": "Summary copied to clipboard!",
+ "RESULT_DATA_COPIED_NOTICE": "Full debug prompt and context copied to clipboard!",
+
+ # Error Dialog & Log Copier
+ "ERROR_TITLE": "Summary Generation Failed",
+ "ERROR_COPY_LOG_BTN": "Copy Full Request Log",
+ "ERROR_LOG_COPIED": "Debug error log copied to clipboard!",
+ "ERROR_NO_MESSAGES": "No messages found to summarize.",
+ "ERROR_FETCH_FAILED": "Failed to fetch chat history: {error}",
+ "ERROR_API_KEY_MISSING": "API key is missing for {provider}. Configure it in plugin settings.",
+ "ERROR_REACHABILITY_FAILED": "Endpoint URL is unreachable: {error}",
+ "ERROR_RATE_LIMIT": "Rate limit exceeded (HTTP 429). Please try again shortly.",
+ "ERROR_UNAUTHORIZED": "Unauthorized (HTTP 401). Invalid API key or expired token.",
+ "ERROR_MODEL_NOT_FOUND": "Model '{model}' not found (HTTP 404). Check model name in settings.",
+ "ERROR_CONTEXT_OVERFLOW": "Message history exceeds context window token budget ({budget} chars). Reduce message count.",
+ },
+ "ru": {
+ # Plugin Metadata
+ "PLUGIN_NAME": "AI Саммари Чатов",
+ "PLUGIN_DESC": "Продвинутый AI-анализатор чатов с ChatGPT OAuth, Custom endpoint, анимацией прогресса и быстрыми триггерами.",
+
+ # Settings - General & Triggers
+ "SETTINGS_HEADER_GENERAL": "Основные и триггеры",
+ "SETTINGS_ENABLE_PINNED": "Кнопка в закрепе",
+ "SETTINGS_ENABLE_PINNED_SUB": "Отображать кнопку AI-саммари рядом с плашкой закрепленного сообщения.",
+ "SETTINGS_ENABLE_UNREAD": "Удержание счетчика непрочитанных",
+ "SETTINGS_ENABLE_UNREAD_SUB": "Долгое нажатие на плавающий бейдж или разделитель непрочитанных запускает саммари.",
+ "SETTINGS_ENABLE_THINKING": "Предпросмотр потока рассуждений (Thinking)",
+ "SETTINGS_ENABLE_THINKING_SUB": "Отображать ход мыслей модели в реальном времени. Отключите для экономии мобильного трафика.",
+
+ # Settings - Provider Selection
+ "SETTINGS_HEADER_PROVIDER": "Настройка AI Провайдера",
+ "SETTINGS_PROVIDER_SELECTOR": "Активный провайдер",
+
+ # Settings - ChatGPT OAuth
+ "OAUTH_HEADER": "Аккаунт ChatGPT (OAuth)",
+ "OAUTH_CONNECTED_STATUS": "Подключен: {email} • Тариф: {tier}",
+ "OAUTH_DISCONNECTED_STATUS": "Аккаунт не подключен. Войдите для доступа к моделям ChatGPT.",
+ "OAUTH_BTN_CONNECT": "Подключить аккаунт ChatGPT",
+ "OAUTH_BTN_DISCONNECT": "Отключить аккаунт",
+ "OAUTH_MODEL_SELECTOR": "Модель ChatGPT",
+ "OAUTH_FREE_BADGE": "Free Тариф",
+ "OAUTH_PLUS_BADGE": "Plus / Pro Тариф",
+ "OAUTH_LOGIN_TITLE": "Вход в ChatGPT",
+ "OAUTH_LOGIN_MSG": "Авторизуйте exteraGram в браузере, затем скопируйте и вставьте код или ссылку редиректа ниже.",
+ "OAUTH_COPY_LINK": "Скопировать ссылку для входа",
+ "OAUTH_PASTE_CODE_HINT": "Вставьте код авторизации или ссылку редиректа сюда",
+ "OAUTH_SUBMIT_CODE": "Завершить вход",
+ "OAUTH_SUCCESS": "Успешная авторизация: {email} ({tier})!",
+ "OAUTH_ERROR": "Ошибка авторизации ChatGPT: {error}",
+
+ # Settings - Custom Provider
+ "CUSTOM_HEADER": "Свой AI Провайдер (Custom endpoint)",
+ "CUSTOM_URL_INPUT": "Base URL",
+ "CUSTOM_URL_SUB": "например https://openrouter.ai/api/v1 или https://api.deepseek.com/v1",
+ "CUSTOM_KEY_INPUT": "API Ключ",
+ "CUSTOM_KEY_SUB": "Bearer токен / ключ для доступа к API.",
+ "CUSTOM_MODEL_INPUT": "Имя модели",
+ "CUSTOM_MODEL_SUB": "например deepseek-chat, meta-llama/llama-3.3-70b-instruct",
+ "CUSTOM_CONTEXT_SELECTOR": "Контекстное окно (токены)",
+ "CUSTOM_CONTEXT_SUB": "Лимит символов истории (поддерживает до 8000 симв. на одно сообщение).",
+ "CUSTOM_TEST_BTN": "Проверить подключение и модель",
+ "CUSTOM_TESTING_STAGE1": "Проверка доступности URL...",
+ "CUSTOM_TESTING_STAGE2": "Тестовый опрос модели (без reasoning)...",
+ "CUSTOM_TEST_SUCCESS": "Подключение проверено! Модель '{model}' ответила за {time:.2f}с.",
+ "CUSTOM_TEST_FAIL_TITLE": "Ошибка проверки: {error}",
+
+ # Settings - Direct Built-in Providers
+ "OPENAI_HEADER": "OpenAI (Прямой ключ)",
+ "OPENAI_KEY_INPUT": "OpenAI API Key",
+ "OPENAI_MODEL_SELECTOR": "Модель OpenAI",
+
+ "ANTHROPIC_HEADER": "Anthropic (Claude)",
+ "ANTHROPIC_KEY_INPUT": "Anthropic API Key",
+ "ANTHROPIC_MODEL_SELECTOR": "Модель Claude",
+
+ "GEMINI_HEADER": "Google Gemini",
+ "GEMINI_KEY_INPUT": "Gemini API Key",
+ "GEMINI_MODEL_SELECTOR": "Модель Gemini",
+
+ "OLLAMA_HEADER": "Ollama (Локальный сервер)",
+ "OLLAMA_URL_INPUT": "Эндпоинт Ollama",
+ "OLLAMA_MODEL_INPUT": "Имя модели",
+
+ # Settings - Summary Defaults & Prompts
+ "SETTINGS_HEADER_DEFAULTS": "Предпочтения саммари",
+ "SETTINGS_DEFAULT_STYLE": "Стиль саммари по умолчанию",
+ "SETTINGS_DEFAULT_LANG": "Язык саммари",
+ "SETTINGS_DEFAULT_COUNT": "Количество сообщений по умолчанию",
+ "SETTINGS_DEFAULT_COUNT_SUB": "Сколько сообщений анализировать (1 - 1000).",
+ "SETTINGS_GLOBAL_PROMPT": "Глобальный системный промпт",
+ "SETTINGS_GLOBAL_PROMPT_SUB": "Оставьте пустым для использования стандартных инструкций стиля.",
+
+ # Diagnostics
+ "SETTINGS_HEADER_DIAGNOSTICS": "Диагностика",
+ "SETTINGS_COPY_ERRORS": "Скопировать последние ошибки",
+ "SETTINGS_COPY_ERRORS_SUB": "Копирует ошибки с временем, потоком, местом, сообщением и полным traceback.",
+ "SETTINGS_COPY_LOGS": "Скопировать последние логи",
+ "SETTINGS_COPY_LOGS_SUB": "Копирует последние 300 строк логов плагина с временем.",
+ "SETTINGS_COPY_DIAGNOSTICS": "Скопировать полный отчёт диагностики",
+ "SETTINGS_COPY_DIAGNOSTICS_SUB": "Копирует ошибки, логи, состояние хуков и проверку runtime API.",
+ "SETTINGS_ERRORS_COPIED": "Последние ошибки скопированы!",
+ "SETTINGS_LOGS_COPIED": "Последние логи скопированы!",
+ "SETTINGS_DIAGNOSTICS_COPIED": "Отчёт диагностики скопирован!",
+ # Pre-Request Bottom Sheet
+ "PRE_REQ_TITLE": "AI Саммари Чата",
+ "PRE_REQ_SUBTITLE": "Чат: {title}",
+ "PRE_REQ_COUNT_LABEL": "Сообщений для анализа: {count}",
+ "PRE_REQ_OFFSET_LABEL": "Смещение (пропуск новых): {offset}",
+ "PRE_REQ_OFFSET_SUB": "Введите любое неотрицательное смещение. Ползунок даёт практичный диапазон; поле числа не ограничено сверху.",
+ "PRE_REQ_STYLE_LABEL": "Стиль саммари",
+ "PRE_REQ_PROMPT_LABEL": "Промпт для этого чата",
+ "PRE_REQ_PROMPT_HINT": "Уточните акценты саммари или вопросы...",
+ "PRE_REQ_PROMPT_RESET": "Сбросить промпт",
+ "PRE_REQ_BTN_START": "Сделать саммари",
+ "PRE_REQ_BTN_CANCEL": "Отмена",
+ "PRE_REQ_UNREAD_BADGE": "Заполнено из {count} непрочитанных сообщений",
+ "PRE_REQ_UNREAD_BATCH_BADGE": "Партия старых непрочитанных: смещение {offset}, кол-во {count} (всего {unread})",
+ # In-Chat Progress Widget & Notifications
+ "PROGRESS_SUMMARIZING": "AI делает саммари...",
+ "PROGRESS_FETCHING": "Загрузка сообщений чата...",
+ "PROGRESS_PROCESSING": "Генерация ответа через {provider}...",
+ "PROGRESS_CLICK_THINKING": "Нажмите для просмотра хода мыслей AI",
+ "PROGRESS_THINKING_DISABLED_ALERT": "Предпросмотр хода мыслей отключен в настройках, но запрос выполняется в фоне.",
+
+ # Live Thinking Sheet
+ "THINKING_TITLE": "Ход мыслей AI в реальном времени",
+ "THINKING_SUBTITLE": "{provider} • {model}",
+ "THINKING_SECTION_THOUGHT": "Процесс рассуждения:",
+ "THINKING_SECTION_OUTPUT": "Формирование ответа:",
+ "THINKING_WAITING": "Ожидание мыслей модели...",
+ "THINKING_BTN_CLOSE": "Свернуть",
+
+ # Summary Result Dialog
+ "RESULT_TITLE": "Сводка чата",
+ "RESULT_HEADER_INFO": "{provider} ({model}) • {count} сообщ. за {time:.1f}с",
+ "RESULT_SCROLL_HINT": "↕ Прокрутите для чтения полного текста",
+ "RESULT_BTN_COPY": "Скопировать саммари",
+ "RESULT_BTN_SHARE": "Поделиться",
+ "RESULT_BTN_JUMP": "Перейти к последнему в сводке",
+ "RESULT_JUMPED_NOTICE": "Переход к сообщению #{id} в чате",
+ "RESULT_BTN_COPY_DATA": "Скопировать весь промпт и контекст",
+ "RESULT_BTN_REGEN": "Сгенерировать заново",
+ "RESULT_BTN_INSERT": "Вставить в чат",
+ "RESULT_COPIED_NOTICE": "Саммари скопировано в буфер обмена!",
+ "RESULT_DATA_COPIED_NOTICE": "Полный отладочный промпт и контекст скопированы!",
+
+ # Error Dialog & Log Copier
+ "ERROR_TITLE": "Ошибка генерации саммари",
+ "ERROR_COPY_LOG_BTN": "Скопировать полный лог запроса",
+ "ERROR_LOG_COPIED": "Отладочный лог скопирован в буфер обмена!",
+ "ERROR_NO_MESSAGES": "Не найдено сообщений для анализа.",
+ "ERROR_FETCH_FAILED": "Не удалось загрузить историю чата: {error}",
+ "ERROR_API_KEY_MISSING": "Отсутствует API ключ для {provider}. Укажите его в настройках плагина.",
+ "ERROR_REACHABILITY_FAILED": "URL недоступен: {error}",
+ "ERROR_RATE_LIMIT": "Превышен лимит запросов (HTTP 429). Повторите попытку через минуту.",
+ "ERROR_UNAUTHORIZED": "Ошибка авторизации (HTTP 401). Неверный ключ или истек токен.",
+ "ERROR_MODEL_NOT_FOUND": "Модель '{model}' не найдена (HTTP 404). Проверьте имя модели в настройках.",
+ "ERROR_CONTEXT_OVERFLOW": "История превышает лимит контекстного окна ({budget} симв.). Уменьшите количество сообщений.",
+ },
+ }
+
+ def __init__(self, default_lang: str = "auto") -> None:
+ self._current_lang = default_lang
+
+ def set_language(self, lang: str) -> None:
+ """Sets the active language code ('en', 'ru', or 'auto')."""
+ self._current_lang = lang
+
+ def _detect_system_lang(self) -> str:
+ try:
+ from java.util import Locale
+ lang = Locale.getDefault().getLanguage()
+ if lang and lang.lower().startswith("ru"):
+ return "ru"
+ except Exception:
+ pass
+ return "en"
+
+ def get(self, key: str, **kwargs: Any) -> str:
+ """Retrieves localized text with automatic string interpolation and fallback."""
+ target_lang = self._current_lang
+ if target_lang == "auto" or not target_lang:
+ target_lang = self._detect_system_lang()
+
+ lang_dict = self._strings.get(target_lang, self._strings["en"])
+ template = lang_dict.get(key)
+ if template is None:
+ template = self._strings["en"].get(key, key)
+
+ if kwargs:
+ try:
+ return template.format(**kwargs)
+ except Exception:
+ return template
+ return template
+
+
+# Singleton instance for direct access across modules
+locali = LocalizationManager()
diff --git a/src/providers/__init__.py b/src/providers/__init__.py
new file mode 100644
index 0000000..9914e92
--- /dev/null
+++ b/src/providers/__init__.py
@@ -0,0 +1,28 @@
+"""
+AI Provider module exports.
+"""
+
+from .base import BaseAIProvider, ProviderResponse, build_debug_log, build_system_prompt
+from .oauth import ChatGPTOAuthHandler
+from .custom import CustomAIHandler
+from .builtin import (
+ OpenAIDirectHandler,
+ AnthropicDirectHandler,
+ GeminiDirectHandler,
+ OllamaDirectHandler,
+)
+from .dispatcher import UnifiedDispatcher
+
+__all__ = [
+ "BaseAIProvider",
+ "ProviderResponse",
+ "build_debug_log",
+ "build_system_prompt",
+ "ChatGPTOAuthHandler",
+ "CustomAIHandler",
+ "OpenAIDirectHandler",
+ "AnthropicDirectHandler",
+ "GeminiDirectHandler",
+ "OllamaDirectHandler",
+ "UnifiedDispatcher",
+]
diff --git a/src/providers/base.py b/src/providers/base.py
new file mode 100644
index 0000000..cee500f
--- /dev/null
+++ b/src/providers/base.py
@@ -0,0 +1,258 @@
+"""
+Base provider abstractions, debug log builders, and prompt formatters.
+"""
+
+import json
+import random
+import re
+import time
+import traceback
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from ..config import (
+ LANG_AUTO,
+ LANG_EN,
+ LANG_RU,
+ STYLE_BRIEF,
+ STYLE_BULLETS,
+ STYLE_CUSTOM,
+ STYLE_DETAILED,
+)
+from ..localization import locali
+
+
+@dataclass
+class ProviderResponse:
+ """Standardized response from any AI provider backend."""
+ success: bool
+ text: str = ""
+ reasoning: str = ""
+ raw_response: Any = None
+ error: str = ""
+ debug_log: str = ""
+ latency: float = 0.0
+ model: str = ""
+ provider_name: str = ""
+
+
+def _mask_token_string(text: str, is_secret_field: bool = False) -> str:
+ if not text:
+ return ""
+ s = text
+ # Mask Bearer tokens
+ s = re.sub(
+ r"(Bearer\s+)([A-Za-z0-9_\-\.]{4})[A-Za-z0-9_\-\.]+",
+ r"\1\2...***",
+ s,
+ flags=re.IGNORECASE,
+ )
+ # Mask sk- keys
+ s = re.sub(
+ r"(sk-[A-Za-z0-9_\-]{4})[A-Za-z0-9_\-]+",
+ r"\1...***",
+ s,
+ )
+ # Mask AIza keys
+ s = re.sub(
+ r"(AIza[A-Za-z0-9_\-]{4})[A-Za-z0-9_\-]+",
+ r"\1...***",
+ s,
+ )
+ if is_secret_field and s == text and len(text) > 8:
+ s = text[:4] + "..." + text[-4:]
+ return s
+
+
+def mask_sensitive_data(text_or_obj: Any, is_secret_field: bool = False) -> Any:
+ """Masks secret keys and tokens in strings, headers, or JSON dicts."""
+ if isinstance(text_or_obj, dict):
+ masked_dict = {}
+ for k, v in text_or_obj.items():
+ key_lower = str(k).lower()
+ is_sensitive_key = any(s in key_lower for s in ("auth", "api_key", "apikey", "token", "secret", "password"))
+ if isinstance(v, str):
+ masked_dict[k] = _mask_token_string(v, is_secret_field=is_sensitive_key)
+ else:
+ masked_dict[k] = mask_sensitive_data(v, is_secret_field=is_sensitive_key)
+ return masked_dict
+ elif isinstance(text_or_obj, list):
+ return [mask_sensitive_data(item, is_secret_field) for item in text_or_obj]
+ elif isinstance(text_or_obj, str):
+ return _mask_token_string(text_or_obj, is_secret_field)
+ return text_or_obj
+
+
+def build_debug_log(
+ url: str,
+ method: str,
+ headers: Optional[Dict[str, Any]] = None,
+ payload: Optional[Any] = None,
+ status_code: Optional[int] = None,
+ response_body: Optional[Any] = None,
+ error_exc: Optional[Exception] = None,
+) -> str:
+ """Formats a comprehensive sanitized debug log for easy clipboard copying and troubleshooting."""
+ try:
+ from datetime import timezone
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
+ except Exception:
+ timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
+ masked_headers = mask_sensitive_data(headers or {})
+ masked_payload = mask_sensitive_data(payload)
+
+ log_lines = [
+ "=== AI Chat Summaries Debug Log ===",
+ f"Timestamp: {timestamp}",
+ f"Request URL: {url}",
+ f"HTTP Method: {method.upper()}",
+ f"Status Code: {status_code if status_code is not None else 'N/A'}",
+ "",
+ "--- Request Headers ---",
+ json.dumps(masked_headers, indent=2, ensure_ascii=False)
+ if isinstance(masked_headers, dict)
+ else str(masked_headers),
+ "",
+ "--- Request Body ---",
+ ]
+
+ if isinstance(masked_payload, (dict, list)):
+ log_lines.append(json.dumps(masked_payload, indent=2, ensure_ascii=False))
+ else:
+ log_lines.append(str(masked_payload))
+
+ log_lines.extend(["", "--- Response Body ---"])
+ if isinstance(response_body, (dict, list)):
+ log_lines.append(json.dumps(mask_sensitive_data(response_body), indent=2, ensure_ascii=False))
+ else:
+ log_lines.append(str(response_body)[:4000] if response_body else "")
+
+ if error_exc:
+ log_lines.extend([
+ "",
+ "--- Exception & Stack Trace ---",
+ f"Error: {type(error_exc).__name__}: {str(error_exc)}",
+ traceback.format_exc(),
+ ])
+
+ log_lines.append("===================================")
+ return "\n".join(log_lines)
+
+
+def build_system_prompt(
+ style: int,
+ lang: int,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ global_prompt: str = "",
+) -> str:
+ """Constructs a high quality system prompt based on style, language, and custom prompt inputs."""
+ if global_prompt and global_prompt.strip():
+ base = global_prompt.strip()
+ else:
+ # Base role
+ base = (
+ "You are an expert chat summarization assistant for Telegram conversations. "
+ "Your task is to analyze the provided chat messages and produce a clear, well-structured, "
+ "and objective summary."
+ )
+
+ # Style instructions
+ if style == STYLE_BRIEF:
+ style_instruction = (
+ "Format: Brief overview.\n"
+ "- Summarize the entire discussion in 2 to 4 concise paragraphs.\n"
+ "- Focus exclusively on major events, primary questions, and overall chat sentiment.\n"
+ "- Avoid listing every individual message or greeting."
+ )
+ elif style == STYLE_DETAILED:
+ style_instruction = (
+ "Format: Detailed comprehensive breakdown.\n"
+ "- Organize the summary with Markdown headings (`### Topic Name`).\n"
+ "- Under each heading, clearly explain who said what, the context, arguments, and conclusions.\n"
+ "- Note unresolved questions or disagreements."
+ )
+ elif style == STYLE_BULLETS:
+ style_instruction = (
+ "Format: Key highlights and action items.\n"
+ "- **Key Decisions**: Bullet points of decisions agreed upon.\n"
+ "- **Action Items / Tasks**: List tasks, assignments, and follow-ups with responsible persons if mentioned.\n"
+ "- **Notable Links & References**: Any important resources or facts shared."
+ )
+ elif style == STYLE_CUSTOM and custom_prompt.strip():
+ style_instruction = f"User Instructions:\n{custom_prompt.strip()}"
+ else:
+ style_instruction = (
+ "Provide a balanced summary highlighting key topics, main opinions, and decisions."
+ )
+
+ # Language instruction
+ if lang == LANG_RU:
+ lang_instruction = "Language: Output MUST be entirely in Russian (Русский язык)."
+ elif lang == LANG_EN:
+ lang_instruction = "Language: Output MUST be entirely in English."
+ else:
+ lang_instruction = (
+ "Language: Output MUST be in the primary language used in the chat messages "
+ "(e.g. if the conversation is in Russian, respond in Russian; if in English, respond in English)."
+ )
+
+ meta_rule = "Formatting: Use clean Telegram Markdown (bold `**`, lists `-`, code backticks, headers `###`). Do not output meta-commentary like 'Sure, here is the summary:'."
+
+ parts = [base, style_instruction, lang_instruction, meta_rule]
+ if chat_title:
+ parts.insert(1, f"Chat Title: \"{chat_title}\"")
+
+ if custom_prompt.strip() and style != STYLE_CUSTOM:
+ parts.append(f"Additional Specific Focus:\n{custom_prompt.strip()}")
+
+ return "\n\n".join(parts)
+
+
+class BaseAIProvider:
+ """Abstract base class for all AI provider connectors."""
+
+ def __init__(self, name: str) -> None:
+ self.name = name
+
+ def summarize(
+ self,
+ messages_transcript: str,
+ style: int,
+ lang: int,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ global_prompt: str = "",
+ enable_stream: bool = True,
+ stream_callback: Optional[Callable[[str, str], None]] = None,
+ ) -> ProviderResponse:
+ """Executes summarization request against the provider.
+
+ stream_callback(thought_delta, content_delta) is invoked if streaming is active.
+ """
+ raise NotImplementedError
+
+ def execute_with_retry(
+ self,
+ func: Callable[[], Any],
+ max_retries: int = 3,
+ base_delay: float = 1.0,
+ ) -> Any:
+ """Executes an API call with exponential backoff and jitter for transient errors (429, 502, 503)."""
+ retries = 0
+ last_exception = None
+ while retries <= max_retries:
+ try:
+ return func()
+ except Exception as e:
+ last_exception = e
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
+ # Retry on 429 (Rate Limit), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout)
+ if status_code in (429, 502, 503, 504) and retries < max_retries:
+ delay = base_delay * (2 ** retries) + random.uniform(0.1, 0.5)
+ time.sleep(delay)
+ retries += 1
+ else:
+ raise last_exception
+ raise last_exception
diff --git a/src/providers/builtin.py b/src/providers/builtin.py
new file mode 100644
index 0000000..13764f0
--- /dev/null
+++ b/src/providers/builtin.py
@@ -0,0 +1,684 @@
+"""
+Built-in Direct API providers: OpenAI, Anthropic Claude, Google Gemini, and Ollama.
+"""
+
+import json
+import re
+import time
+from typing import Any, Callable, Dict, List, Optional
+
+import requests
+
+from ..config import (
+ ANTHROPIC_ENDPOINT,
+ DEFAULT_MODEL_ANTHROPIC,
+ DEFAULT_MODEL_GEMINI,
+ DEFAULT_MODEL_OLLAMA,
+ DEFAULT_MODEL_OPENAI,
+ GEMINI_ENDPOINT_TEMPLATE,
+ OLLAMA_DEFAULT_ENDPOINT,
+ OPENAI_ENDPOINT,
+ SETTING_ANTHROPIC_API_KEY,
+ SETTING_ANTHROPIC_MODEL,
+ SETTING_GEMINI_API_KEY,
+ SETTING_GEMINI_MODEL,
+ SETTING_OLLAMA_ENDPOINT,
+ SETTING_OLLAMA_MODEL,
+ SETTING_OPENAI_API_KEY,
+ SETTING_OPENAI_MODEL,
+)
+from ..localization import locali
+from .base import BaseAIProvider, ProviderResponse, build_debug_log, build_system_prompt
+
+
+class OpenAIDirectHandler(BaseAIProvider):
+ """Direct OpenAI API key handler."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ super().__init__("OpenAI (Direct Key)")
+ self.plugin = plugin_instance
+
+ def summarize(
+ self,
+ messages_transcript: str,
+ style: int,
+ lang: int,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ global_prompt: str = "",
+ enable_stream: bool = True,
+ stream_callback: Optional[Callable[[str, str], None]] = None,
+ ) -> ProviderResponse:
+ start_time = time.time()
+ api_key = self._get_setting(SETTING_OPENAI_API_KEY, "").strip()
+ model_name = self._get_setting(SETTING_OPENAI_MODEL, DEFAULT_MODEL_OPENAI) or DEFAULT_MODEL_OPENAI
+
+ if not api_key:
+ return ProviderResponse(
+ success=False,
+ error=locali.get("ERROR_API_KEY_MISSING", provider="OpenAI"),
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ system_prompt = build_system_prompt(
+ style=style,
+ lang=lang,
+ chat_title=chat_title,
+ custom_prompt=custom_prompt,
+ global_prompt=global_prompt,
+ )
+
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ "User-Agent": "exteraGram-AI-Summaries/3.0",
+ }
+
+ payload: Dict[str, Any] = {
+ "model": model_name,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": f"Chat conversation history:\n\n{messages_transcript}"},
+ ],
+ "temperature": 0.3,
+ "stream": enable_stream,
+ }
+
+ if model_name.startswith("o1") or model_name.startswith("o3"):
+ payload["messages"] = [
+ {"role": "user", "content": f"{system_prompt}\n\nChat conversation history:\n\n{messages_transcript}"}
+ ]
+ payload.pop("temperature", None)
+
+ def _do_request():
+ return requests.post(
+ OPENAI_ENDPOINT,
+ headers=headers,
+ json=payload,
+ stream=enable_stream,
+ timeout=(10, 150),
+ )
+
+ try:
+ resp = self.execute_with_retry(_do_request)
+ if resp.status_code != 200:
+ raw_err = resp.text
+ err_title = f"HTTP {resp.status_code}"
+ try:
+ err_json = resp.json()
+ err_msg = err_json.get("error", {}).get("message")
+ if err_msg:
+ err_title = f"HTTP {resp.status_code}: {err_msg}"
+ except Exception:
+ pass
+
+ debug_log = build_debug_log(
+ url=OPENAI_ENDPOINT,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ status_code=resp.status_code,
+ response_body=raw_err,
+ )
+ return ProviderResponse(
+ success=False,
+ error=err_title,
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ full_thought = ""
+ full_content = ""
+
+ if enable_stream:
+ for line in resp.iter_lines(decode_unicode=True):
+ if not line:
+ continue
+ if line.startswith("data: "):
+ data_str = line[6:].strip()
+ if data_str == "[DONE]":
+ break
+ try:
+ chunk = json.loads(data_str)
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
+ thought_chunk = delta.get("reasoning_content") or ""
+ content_chunk = delta.get("content") or ""
+
+ if thought_chunk:
+ full_thought += thought_chunk
+ if content_chunk:
+ full_content += content_chunk
+
+ if stream_callback and (thought_chunk or content_chunk):
+ stream_callback(thought_chunk, content_chunk)
+ except Exception:
+ continue
+ else:
+ data = resp.json()
+ choice = data.get("choices", [{}])[0]
+ msg = choice.get("message", {})
+ full_content = msg.get("content", "")
+ full_thought = msg.get("reasoning_content", "")
+
+ return ProviderResponse(
+ success=True,
+ text=full_content.strip(),
+ reasoning=full_thought.strip(),
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ except Exception as e:
+ debug_log = build_debug_log(
+ url=OPENAI_ENDPOINT,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ error_exc=e,
+ )
+ return ProviderResponse(
+ success=False,
+ error=f"Request failed: {str(e)}",
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ def _get_setting(self, key: str, default: Any = None) -> Any:
+ if self.plugin and hasattr(self.plugin, "get_setting"):
+ return self.plugin.get_setting(key, default)
+ return default
+
+
+class AnthropicDirectHandler(BaseAIProvider):
+ """Anthropic Claude API direct handler."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ super().__init__("Anthropic (Claude)")
+ self.plugin = plugin_instance
+
+ def summarize(
+ self,
+ messages_transcript: str,
+ style: int,
+ lang: int,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ global_prompt: str = "",
+ enable_stream: bool = True,
+ stream_callback: Optional[Callable[[str, str], None]] = None,
+ ) -> ProviderResponse:
+ start_time = time.time()
+ api_key = self._get_setting(SETTING_ANTHROPIC_API_KEY, "").strip()
+ model_name = self._get_setting(SETTING_ANTHROPIC_MODEL, DEFAULT_MODEL_ANTHROPIC) or DEFAULT_MODEL_ANTHROPIC
+
+ if not api_key:
+ return ProviderResponse(
+ success=False,
+ error=locali.get("ERROR_API_KEY_MISSING", provider="Anthropic"),
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ system_prompt = build_system_prompt(
+ style=style,
+ lang=lang,
+ chat_title=chat_title,
+ custom_prompt=custom_prompt,
+ global_prompt=global_prompt,
+ )
+
+ headers = {
+ "x-api-key": api_key,
+ "anthropic-version": "2023-06-01",
+ "Content-Type": "application/json",
+ "User-Agent": "exteraGram-AI-Summaries/3.0",
+ }
+
+ payload: Dict[str, Any] = {
+ "model": model_name,
+ "max_tokens": 4096,
+ "system": system_prompt,
+ "messages": [
+ {"role": "user", "content": f"Chat conversation history to summarize:\n\n{messages_transcript}"}
+ ],
+ "stream": enable_stream,
+ }
+
+ # Thinking configuration for Claude 3.7 Sonnet
+ if "3-7" in model_name and enable_stream:
+ payload["thinking"] = {
+ "type": "enabled",
+ "budget_tokens": 2048,
+ }
+
+ def _do_request():
+ return requests.post(
+ ANTHROPIC_ENDPOINT,
+ headers=headers,
+ json=payload,
+ stream=enable_stream,
+ timeout=(10, 150),
+ )
+
+ try:
+ resp = self.execute_with_retry(_do_request)
+ if resp.status_code != 200:
+ raw_err = resp.text
+ err_title = f"HTTP {resp.status_code}"
+ try:
+ err_json = resp.json()
+ err_title = f"HTTP {resp.status_code}: {err_json.get('error', {}).get('message', raw_err)}"
+ except Exception:
+ pass
+
+ debug_log = build_debug_log(
+ url=ANTHROPIC_ENDPOINT,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ status_code=resp.status_code,
+ response_body=raw_err,
+ )
+ return ProviderResponse(
+ success=False,
+ error=err_title,
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ full_thought = ""
+ full_content = ""
+
+ if enable_stream:
+ for line in resp.iter_lines(decode_unicode=True):
+ if not line:
+ continue
+ if line.startswith("data: "):
+ data_str = line[6:].strip()
+ try:
+ event = json.loads(data_str)
+ event_type = event.get("type")
+ if event_type == "content_block_delta":
+ delta = event.get("delta", {})
+ d_type = delta.get("type")
+ if d_type == "text_delta":
+ c_chunk = delta.get("text", "")
+ full_content += c_chunk
+ if stream_callback:
+ stream_callback("", c_chunk)
+ elif d_type == "thinking_delta":
+ t_chunk = delta.get("thinking", "")
+ full_thought += t_chunk
+ if stream_callback:
+ stream_callback(t_chunk, "")
+ except Exception:
+ continue
+ else:
+ data = resp.json()
+ for block in data.get("content", []):
+ b_type = block.get("type")
+ if b_type == "text":
+ full_content += block.get("text", "")
+ elif b_type == "thinking":
+ full_thought += block.get("thinking", "")
+
+ return ProviderResponse(
+ success=True,
+ text=full_content.strip(),
+ reasoning=full_thought.strip(),
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ except Exception as e:
+ debug_log = build_debug_log(
+ url=ANTHROPIC_ENDPOINT,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ error_exc=e,
+ )
+ return ProviderResponse(
+ success=False,
+ error=f"Request failed: {str(e)}",
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ def _get_setting(self, key: str, default: Any = None) -> Any:
+ if self.plugin and hasattr(self.plugin, "get_setting"):
+ return self.plugin.get_setting(key, default)
+ return default
+
+
+class GeminiDirectHandler(BaseAIProvider):
+ """Google Gemini API direct handler."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ super().__init__("Gemini")
+ self.plugin = plugin_instance
+
+ def summarize(
+ self,
+ messages_transcript: str,
+ style: int,
+ lang: int,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ global_prompt: str = "",
+ enable_stream: bool = True,
+ stream_callback: Optional[Callable[[str, str], None]] = None,
+ ) -> ProviderResponse:
+ start_time = time.time()
+ api_key = self._get_setting(SETTING_GEMINI_API_KEY, "").strip()
+ model_name = self._get_setting(SETTING_GEMINI_MODEL, DEFAULT_MODEL_GEMINI) or DEFAULT_MODEL_GEMINI
+
+ if not api_key:
+ return ProviderResponse(
+ success=False,
+ error=locali.get("ERROR_API_KEY_MISSING", provider="Gemini"),
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ system_prompt = build_system_prompt(
+ style=style,
+ lang=lang,
+ chat_title=chat_title,
+ custom_prompt=custom_prompt,
+ global_prompt=global_prompt,
+ )
+
+ stream_suffix = "streamGenerateContent?alt=sse&" if enable_stream else "generateContent?"
+ endpoint = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:{stream_suffix}key={api_key}"
+
+ payload: Dict[str, Any] = {
+ "systemInstruction": {
+ "parts": [{"text": system_prompt}]
+ },
+ "contents": [
+ {
+ "role": "user",
+ "parts": [{"text": f"Chat conversation history to summarize:\n\n{messages_transcript}"}]
+ }
+ ],
+ "generationConfig": {
+ "temperature": 0.3,
+ "maxOutputTokens": 4096,
+ }
+ }
+
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "exteraGram-AI-Summaries/3.0",
+ }
+
+ def _do_request():
+ return requests.post(
+ endpoint,
+ headers=headers,
+ json=payload,
+ stream=enable_stream,
+ timeout=(10, 150),
+ )
+
+ try:
+ resp = self.execute_with_retry(_do_request)
+ if resp.status_code != 200:
+ raw_err = resp.text
+ err_title = f"HTTP {resp.status_code}"
+ try:
+ err_json = resp.json()
+ err_title = f"HTTP {resp.status_code}: {err_json.get('error', {}).get('message', raw_err)}"
+ except Exception:
+ pass
+
+ debug_log = build_debug_log(
+ url=endpoint,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ status_code=resp.status_code,
+ response_body=raw_err,
+ )
+ return ProviderResponse(
+ success=False,
+ error=err_title,
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ full_thought = ""
+ full_content = ""
+
+ if enable_stream:
+ for line in resp.iter_lines(decode_unicode=True):
+ if not line:
+ continue
+ if line.startswith("data: "):
+ data_str = line[6:].strip()
+ try:
+ chunk = json.loads(data_str)
+ candidates = chunk.get("candidates", [])
+ if candidates:
+ parts = candidates[0].get("content", {}).get("parts", [])
+ for p in parts:
+ # Check thought vs text
+ if "thought" in p:
+ t_chunk = p.get("text", "")
+ full_thought += t_chunk
+ if stream_callback:
+ stream_callback(t_chunk, "")
+ else:
+ c_chunk = p.get("text", "")
+ full_content += c_chunk
+ if stream_callback:
+ stream_callback("", c_chunk)
+ except Exception:
+ continue
+ else:
+ data = resp.json()
+ candidates = data.get("candidates", [])
+ if candidates:
+ parts = candidates[0].get("content", {}).get("parts", [])
+ for p in parts:
+ if "thought" in p:
+ full_thought += p.get("text", "")
+ else:
+ full_content += p.get("text", "")
+
+ return ProviderResponse(
+ success=True,
+ text=full_content.strip(),
+ reasoning=full_thought.strip(),
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ except Exception as e:
+ debug_log = build_debug_log(
+ url=endpoint,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ error_exc=e,
+ )
+ return ProviderResponse(
+ success=False,
+ error=f"Request failed: {str(e)}",
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ def _get_setting(self, key: str, default: Any = None) -> Any:
+ if self.plugin and hasattr(self.plugin, "get_setting"):
+ return self.plugin.get_setting(key, default)
+ return default
+
+
+class OllamaDirectHandler(BaseAIProvider):
+ """Ollama local server handler."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ super().__init__("Ollama (local)")
+ self.plugin = plugin_instance
+
+ def summarize(
+ self,
+ messages_transcript: str,
+ style: int,
+ lang: int,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ global_prompt: str = "",
+ enable_stream: bool = True,
+ stream_callback: Optional[Callable[[str, str], None]] = None,
+ ) -> ProviderResponse:
+ start_time = time.time()
+ endpoint = self._get_setting(SETTING_OLLAMA_ENDPOINT, OLLAMA_DEFAULT_ENDPOINT) or OLLAMA_DEFAULT_ENDPOINT
+ model_name = self._get_setting(SETTING_OLLAMA_MODEL, DEFAULT_MODEL_OLLAMA) or DEFAULT_MODEL_OLLAMA
+
+ system_prompt = build_system_prompt(
+ style=style,
+ lang=lang,
+ chat_title=chat_title,
+ custom_prompt=custom_prompt,
+ global_prompt=global_prompt,
+ )
+
+ payload = {
+ "model": model_name,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": f"Chat conversation history:\n\n{messages_transcript}"},
+ ],
+ "stream": enable_stream,
+ "options": {
+ "temperature": 0.3,
+ }
+ }
+
+ headers = {"Content-Type": "application/json"}
+
+ def _do_request():
+ return requests.post(
+ endpoint,
+ headers=headers,
+ json=payload,
+ stream=enable_stream,
+ timeout=(10, 180),
+ )
+
+ try:
+ resp = self.execute_with_retry(_do_request)
+ if resp.status_code != 200:
+ raw_err = resp.text
+ debug_log = build_debug_log(
+ url=endpoint,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ status_code=resp.status_code,
+ response_body=raw_err,
+ )
+ return ProviderResponse(
+ success=False,
+ error=f"HTTP {resp.status_code}: {raw_err[:120]}",
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ full_thought = ""
+ full_content = ""
+
+ if enable_stream:
+ in_think_tag = False
+ for line in resp.iter_lines(decode_unicode=True):
+ if not line:
+ continue
+ try:
+ chunk = json.loads(line)
+ msg = chunk.get("message", {})
+ c_chunk = msg.get("content", "")
+ t_chunk = msg.get("thought", "")
+
+ if "" in c_chunk:
+ in_think_tag = True
+ parts = c_chunk.split("", 1)
+ c_chunk = parts[0]
+ t_chunk += parts[1]
+ if "" in c_chunk:
+ in_think_tag = False
+ parts = c_chunk.split("", 1)
+ t_chunk += parts[0]
+ c_chunk = parts[1]
+ elif in_think_tag:
+ t_chunk += c_chunk
+ c_chunk = ""
+
+ if t_chunk:
+ full_thought += t_chunk
+ if c_chunk:
+ full_content += c_chunk
+
+ if stream_callback and (t_chunk or c_chunk):
+ stream_callback(t_chunk, c_chunk)
+ except Exception:
+ continue
+ else:
+ data = resp.json()
+ msg = data.get("message", {})
+ full_content = msg.get("content", "")
+ full_thought = msg.get("thought", "")
+
+ return ProviderResponse(
+ success=True,
+ text=full_content.strip(),
+ reasoning=full_thought.strip(),
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ except Exception as e:
+ debug_log = build_debug_log(
+ url=endpoint,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ error_exc=e,
+ )
+ return ProviderResponse(
+ success=False,
+ error=f"Request failed: {str(e)}",
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ def _get_setting(self, key: str, default: Any = None) -> Any:
+ if self.plugin and hasattr(self.plugin, "get_setting"):
+ return self.plugin.get_setting(key, default)
+ return default
diff --git a/src/providers/custom.py b/src/providers/custom.py
new file mode 100644
index 0000000..a1c3634
--- /dev/null
+++ b/src/providers/custom.py
@@ -0,0 +1,373 @@
+"""
+Custom AI Provider handler with multi-stage verification (reachability, lightweight probe, error log builder, context budgeting).
+"""
+
+import json
+import re
+import time
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+import requests
+
+from ..config import (
+ CONTEXT_WINDOW_BUDGETS,
+ DEFAULT_CONTEXT_WINDOW,
+ DEFAULT_MODEL_CUSTOM,
+ MAX_SINGLE_MESSAGE_CHARS,
+ SETTING_CUSTOM_API_KEY,
+ SETTING_CUSTOM_BASE_URL,
+ SETTING_CUSTOM_CONTEXT_WINDOW,
+ SETTING_CUSTOM_MODEL,
+)
+from ..localization import locali
+from .base import BaseAIProvider, ProviderResponse, build_debug_log, build_system_prompt
+
+
+class CustomAIHandler(BaseAIProvider):
+ """Custom OpenAI-compatible API provider with multi-stage verification and context budgeting."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ super().__init__("Custom endpoint")
+ self.plugin = plugin_instance
+
+ def _normalize_base_url(self, base_url: str) -> str:
+ """Ensures base URL is clean and points to the base API path without trailing slashes."""
+ url = (base_url or "").strip()
+ if not url:
+ url = "https://api.openai.com/v1"
+ url = url.rstrip("/")
+ # If user supplied full /chat/completions endpoint, strip it
+ if url.endswith("/chat/completions"):
+ url = url[:-17]
+ return url
+
+ def get_completions_url(self, base_url: Optional[str] = None) -> str:
+ """Returns the full /chat/completions URL for this custom endpoint."""
+ url = base_url or self._get_setting(SETTING_CUSTOM_BASE_URL, "")
+ norm = self._normalize_base_url(url)
+ return f"{norm}/chat/completions"
+
+ # ==================== Multi-Stage Verification ====================
+
+ def verify_stage1_reachability(self, base_url: str) -> Tuple[bool, str]:
+ """Stage 1: Validates server connectivity and DNS resolution."""
+ norm_url = self._normalize_base_url(base_url)
+ if not norm_url.startswith("http://") and not norm_url.startswith("https://"):
+ return False, "URL must start with http:// or https://"
+
+ try:
+ # First try lightweight HEAD request with 5s timeout
+ try:
+ resp = requests.head(norm_url, timeout=5, allow_redirects=True)
+ return True, f"Server responded with HTTP {resp.status_code}"
+ except (requests.exceptions.HTTPError, requests.exceptions.RequestException):
+ # Fallback to GET on base URL or models endpoint
+ resp = requests.get(norm_url, timeout=5, allow_redirects=True)
+ return True, f"Server responded with HTTP {resp.status_code}"
+ except requests.exceptions.ConnectionError as e:
+ return False, f"Connection refused or DNS lookup failed: {str(e)}"
+ except requests.exceptions.Timeout:
+ return False, "Connection timed out after 5.0s."
+ except Exception as e:
+ return False, f"Network error: {str(e)}"
+
+ def verify_stage2_probe_test(
+ self,
+ base_url: str,
+ api_key: str,
+ model_name: str,
+ ) -> Tuple[bool, str, Optional[str], float]:
+ """Stage 2: Sends lightweight probe completion with thinking disabled and 'Hey there' payload.
+
+ Returns (success, message_or_error_title, sanitized_debug_log, elapsed_time).
+ """
+ start_time = time.time()
+ endpoint = self.get_completions_url(base_url)
+ model = (model_name or DEFAULT_MODEL_CUSTOM).strip()
+
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "exteraGram-AI-Summaries/3.0",
+ }
+ if api_key and api_key.strip():
+ headers["Authorization"] = f"Bearer {api_key.strip()}"
+
+ # Lightweight probe with thinking disabled to minimize latency and token cost
+ payload: Dict[str, Any] = {
+ "model": model,
+ "messages": [
+ {"role": "user", "content": "Hey there"}
+ ],
+ "max_tokens": 10,
+ "temperature": 0.0,
+ "stream": False,
+ }
+
+ # Attempt to disable thinking if the provider supports Anthropic/DeepSeek/OpenRouter thinking parameters
+ payload["thinking"] = {"type": "disabled"}
+
+ try:
+ resp = requests.post(endpoint, headers=headers, json=payload, timeout=12)
+ elapsed = time.time() - start_time
+
+ # Some providers reject extra 'thinking' param with HTTP 400 - retry once without it
+ if resp.status_code == 400 and "thinking" in resp.text.lower():
+ payload.pop("thinking", None)
+ start_time = time.time()
+ resp = requests.post(endpoint, headers=headers, json=payload, timeout=12)
+ elapsed = time.time() - start_time
+
+ if resp.status_code == 200:
+ try:
+ data = resp.json()
+ choices = data.get("choices", [])
+ if choices:
+ return True, locali.get("CUSTOM_TEST_SUCCESS", model=model, time=elapsed), None, elapsed
+ return True, f"HTTP 200 OK (Model: {model})", None, elapsed
+ except Exception:
+ return True, f"HTTP 200 OK in {elapsed:.2f}s", None, elapsed
+ else:
+ raw_text = resp.text
+ error_title = f"HTTP {resp.status_code}"
+ try:
+ err_json = resp.json()
+ err_msg = err_json.get("error", {}).get("message") or err_json.get("message")
+ if err_msg:
+ error_title = f"HTTP {resp.status_code}: {err_msg}"
+ except Exception:
+ if len(raw_text) < 120:
+ error_title = f"HTTP {resp.status_code}: {raw_text}"
+
+ debug_log = build_debug_log(
+ url=endpoint,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ status_code=resp.status_code,
+ response_body=raw_text,
+ )
+ return False, error_title, debug_log, elapsed
+
+ except Exception as e:
+ elapsed = time.time() - start_time
+ debug_log = build_debug_log(
+ url=endpoint,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ error_exc=e,
+ )
+ return False, f"Probe error: {str(e)}", debug_log, elapsed
+
+ # ==================== Context Window Management ====================
+
+ def get_context_character_budget(self) -> int:
+ """Returns maximum allowed character length for the configured context window."""
+ window_size = self._get_setting(SETTING_CUSTOM_CONTEXT_WINDOW, DEFAULT_CONTEXT_WINDOW)
+ return CONTEXT_WINDOW_BUDGETS.get(window_size, CONTEXT_WINDOW_BUDGETS[DEFAULT_CONTEXT_WINDOW])
+
+ def budget_and_chunk_transcript(self, formatted_messages: List[str]) -> str:
+ """Ensures chat transcript fits within context budget while preserving up to 8,000 chars per message."""
+ budget = self.get_context_character_budget()
+
+ # Build from most recent messages back to older messages
+ selected: List[str] = []
+ current_len = 0
+
+ for msg_str in reversed(formatted_messages):
+ # Clamp individual message if greater than 8,000 chars
+ if len(msg_str) > MAX_SINGLE_MESSAGE_CHARS:
+ msg_str = msg_str[:MAX_SINGLE_MESSAGE_CHARS] + "\n[... truncated long message content ...]"
+
+ item_len = len(msg_str) + 2 # +2 for \n\n
+ if current_len + item_len > budget:
+ break
+ selected.append(msg_str)
+ current_len += item_len
+
+ selected.reverse()
+ return "\n\n".join(selected)
+
+ # ==================== Execution ====================
+
+ def summarize(
+ self,
+ messages_transcript: str,
+ style: int,
+ lang: int,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ global_prompt: str = "",
+ enable_stream: bool = True,
+ stream_callback: Optional[Callable[[str, str], None]] = None,
+ ) -> ProviderResponse:
+ """Executes summarization request against custom OpenAI-compatible endpoint."""
+ start_time = time.time()
+ base_url = self._get_setting(SETTING_CUSTOM_BASE_URL, "")
+ api_key = self._get_setting(SETTING_CUSTOM_API_KEY, "")
+ model_name = self._get_setting(SETTING_CUSTOM_MODEL, DEFAULT_MODEL_CUSTOM) or DEFAULT_MODEL_CUSTOM
+ endpoint = self.get_completions_url(base_url)
+
+ system_prompt = build_system_prompt(
+ style=style,
+ lang=lang,
+ chat_title=chat_title,
+ custom_prompt=custom_prompt,
+ global_prompt=global_prompt,
+ )
+
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "exteraGram-AI-Summaries/3.0",
+ }
+ if api_key and api_key.strip():
+ headers["Authorization"] = f"Bearer {api_key.strip()}"
+
+ payload: Dict[str, Any] = {
+ "model": model_name,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": f"Chat conversation history to summarize:\n\n{messages_transcript}"},
+ ],
+ "temperature": 0.3,
+ "stream": enable_stream,
+ }
+
+ def _do_request():
+ return requests.post(
+ endpoint,
+ headers=headers,
+ json=payload,
+ stream=enable_stream,
+ timeout=(10, 150),
+ )
+
+ try:
+ resp = self.execute_with_retry(_do_request)
+ if resp.status_code != 200:
+ raw_err = resp.text
+ err_title = f"HTTP {resp.status_code}"
+ try:
+ err_json = resp.json()
+ err_msg = err_json.get("error", {}).get("message") or err_json.get("message")
+ if err_msg:
+ err_title = f"HTTP {resp.status_code}: {err_msg}"
+ except Exception:
+ pass
+
+ debug_log = build_debug_log(
+ url=endpoint,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ status_code=resp.status_code,
+ response_body=raw_err,
+ )
+ return ProviderResponse(
+ success=False,
+ error=err_title,
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ full_thought = ""
+ full_content = ""
+
+ if enable_stream:
+ in_think_tag = False
+ for line in resp.iter_lines(decode_unicode=True):
+ if not line:
+ continue
+ if line.startswith("data: "):
+ data_str = line[6:].strip()
+ if data_str == "[DONE]":
+ break
+ try:
+ chunk = json.loads(data_str)
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
+
+ # Check reasoning tokens (DeepSeek, OpenRouter, Qwen)
+ thought_chunk = (
+ delta.get("reasoning_content")
+ or delta.get("reasoning")
+ or delta.get("thought")
+ or ""
+ )
+ content_chunk = delta.get("content") or ""
+
+ # Check embedded ... in content
+ if "" in content_chunk:
+ in_think_tag = True
+ parts = content_chunk.split("", 1)
+ content_chunk = parts[0]
+ thought_chunk += parts[1]
+ if "" in content_chunk:
+ in_think_tag = False
+ parts = content_chunk.split("", 1)
+ thought_chunk += parts[0]
+ content_chunk = parts[1]
+ elif in_think_tag:
+ thought_chunk += content_chunk
+ content_chunk = ""
+
+ if thought_chunk:
+ full_thought += thought_chunk
+ if content_chunk:
+ full_content += content_chunk
+
+ if stream_callback and (thought_chunk or content_chunk):
+ stream_callback(thought_chunk, content_chunk)
+ except Exception:
+ continue
+ else:
+ data = resp.json()
+ choice = data.get("choices", [{}])[0]
+ msg = choice.get("message", {})
+ full_content = msg.get("content", "")
+ full_thought = (
+ msg.get("reasoning_content")
+ or msg.get("reasoning")
+ or msg.get("thought")
+ or ""
+ )
+
+ # Parse tag if present in non-stream output
+ if "" in full_content and "" in full_content:
+ match = re.search(r"(.*?)", full_content, flags=re.DOTALL)
+ if match:
+ full_thought = match.group(1).strip()
+ full_content = re.sub(r".*?", "", full_content, flags=re.DOTALL).strip()
+
+ return ProviderResponse(
+ success=True,
+ text=full_content.strip(),
+ reasoning=full_thought.strip(),
+ raw_response=None,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ except Exception as e:
+ debug_log = build_debug_log(
+ url=endpoint,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ error_exc=e,
+ )
+ return ProviderResponse(
+ success=False,
+ error=f"Request failed: {str(e)}",
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ def _get_setting(self, key: str, default: Any = None) -> Any:
+ if self.plugin and hasattr(self.plugin, "get_setting"):
+ return self.plugin.get_setting(key, default)
+ return default
diff --git a/src/providers/dispatcher.py b/src/providers/dispatcher.py
new file mode 100644
index 0000000..facd19b
--- /dev/null
+++ b/src/providers/dispatcher.py
@@ -0,0 +1,170 @@
+"""
+Unified AI Provider Dispatcher, streaming engine, and thinking state manager.
+"""
+
+import threading
+import time
+from typing import Any, Callable, Dict, Optional
+
+from ..config import (
+ PROVIDER_ANTHROPIC,
+ PROVIDER_CHATGPT_OAUTH,
+ PROVIDER_CUSTOM,
+ PROVIDER_GEMINI,
+ PROVIDER_OLLAMA,
+ PROVIDER_OPENAI,
+ SETTING_ENABLE_THINKING_STREAM,
+ SETTING_PROVIDER,
+ SETTING_SUMMARY_LANG,
+ SETTING_SUMMARY_STYLE,
+)
+from ..localization import locali
+from .base import BaseAIProvider, ProviderResponse
+from .builtin import (
+ AnthropicDirectHandler,
+ GeminiDirectHandler,
+ OllamaDirectHandler,
+ OpenAIDirectHandler,
+)
+from .custom import CustomAIHandler
+from .oauth import ChatGPTOAuthHandler
+
+
+class UnifiedDispatcher:
+ """Dispatches summarization tasks to the active AI provider and coordinates live streaming state."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ self.plugin = plugin_instance
+ self.oauth_handler = ChatGPTOAuthHandler(plugin_instance)
+ self.custom_handler = CustomAIHandler(plugin_instance)
+ self.openai_handler = OpenAIDirectHandler(plugin_instance)
+ self.anthropic_handler = AnthropicDirectHandler(plugin_instance)
+ self.gemini_handler = GeminiDirectHandler(plugin_instance)
+ self.ollama_handler = OllamaDirectHandler(plugin_instance)
+
+ self._lock = threading.Lock()
+ self.active_stream_state: Dict[str, Any] = {
+ "thinking": "",
+ "content": "",
+ "is_running": False,
+ "is_finished": False,
+ "provider": "",
+ "model": "",
+ "chat_title": "",
+ "error": "",
+ }
+ self.stream_listeners: list[Callable[[str, str], None]] = []
+
+ def get_active_provider(self) -> BaseAIProvider:
+ """Returns the configured AI provider backend."""
+ provider_id = int(self._get_setting(SETTING_PROVIDER, PROVIDER_CHATGPT_OAUTH) or 0)
+ if provider_id == PROVIDER_CHATGPT_OAUTH:
+ return self.oauth_handler
+ elif provider_id == PROVIDER_CUSTOM:
+ return self.custom_handler
+ elif provider_id == PROVIDER_OPENAI:
+ return self.openai_handler
+ elif provider_id == PROVIDER_ANTHROPIC:
+ return self.anthropic_handler
+ elif provider_id == PROVIDER_GEMINI:
+ return self.gemini_handler
+ elif provider_id == PROVIDER_OLLAMA:
+ return self.ollama_handler
+ return self.oauth_handler
+
+ def add_stream_listener(self, listener: Callable[[str, str], None]) -> None:
+ """Registers a listener callback (thought_chunk, content_chunk) for live stream updates."""
+ with self._lock:
+ if listener not in self.stream_listeners:
+ self.stream_listeners.append(listener)
+
+ def remove_stream_listener(self, listener: Callable[[str, str], None]) -> None:
+ """Unregisters a stream listener callback."""
+ with self._lock:
+ if listener in self.stream_listeners:
+ self.stream_listeners.remove(listener)
+
+ def reset_stream_state(self, provider_name: str, model_name: str, chat_title: str) -> None:
+ """Resets streaming state before a new summary run."""
+ with self._lock:
+ self.active_stream_state = {
+ "thinking": "",
+ "content": "",
+ "is_running": True,
+ "is_finished": False,
+ "provider": provider_name,
+ "model": model_name,
+ "chat_title": chat_title,
+ "error": "",
+ }
+
+ def _on_stream_chunk(self, thought_delta: str, content_delta: str) -> None:
+ """Invoked on each token or thinking chunk received from the provider."""
+ with self._lock:
+ if thought_delta:
+ self.active_stream_state["thinking"] += thought_delta
+ if content_delta:
+ self.active_stream_state["content"] += content_delta
+ listeners = list(self.stream_listeners)
+
+ for listener in listeners:
+ try:
+ listener(thought_delta, content_delta)
+ except Exception:
+ pass
+
+ def run_summary(
+ self,
+ messages_transcript: str,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ style: Optional[int] = None,
+ lang: Optional[int] = None,
+ global_prompt: str = "",
+ ) -> ProviderResponse:
+ """Executes full summarization request with live state management."""
+ provider = self.get_active_provider()
+ enable_stream = bool(self._get_setting(SETTING_ENABLE_THINKING_STREAM, True))
+
+ target_style = style if style is not None else int(self._get_setting(SETTING_SUMMARY_STYLE, 0) or 0)
+ target_lang = lang if lang is not None else int(self._get_setting(SETTING_SUMMARY_LANG, 0) or 0)
+
+ # Retrieve model label for UI
+ model_name = getattr(provider, "get_active_model", lambda: getattr(provider, "name", "AI"))()
+ self.reset_stream_state(provider.name, str(model_name), chat_title)
+
+ try:
+ response = provider.summarize(
+ messages_transcript=messages_transcript,
+ style=target_style,
+ lang=target_lang,
+ chat_title=chat_title,
+ custom_prompt=custom_prompt,
+ global_prompt=global_prompt,
+ enable_stream=enable_stream,
+ stream_callback=self._on_stream_chunk if enable_stream else None,
+ )
+ with self._lock:
+ self.active_stream_state["is_running"] = False
+ self.active_stream_state["is_finished"] = True
+ if not response.success:
+ self.active_stream_state["error"] = response.error
+ return response
+
+ except Exception as e:
+ with self._lock:
+ self.active_stream_state["is_running"] = False
+ self.active_stream_state["is_finished"] = True
+ self.active_stream_state["error"] = str(e)
+ return ProviderResponse(
+ success=False,
+ error=f"Dispatcher error: {str(e)}",
+ latency=0.0,
+ model=str(model_name),
+ provider_name=provider.name,
+ )
+
+ def _get_setting(self, key: str, default: Any = None) -> Any:
+ if self.plugin and hasattr(self.plugin, "get_setting"):
+ return self.plugin.get_setting(key, default)
+ return default
diff --git a/src/providers/oauth.py b/src/providers/oauth.py
new file mode 100644
index 0000000..977d226
--- /dev/null
+++ b/src/providers/oauth.py
@@ -0,0 +1,438 @@
+"""
+ChatGPT OAuth flow, token exchange, tier-based model selection, and execution handler.
+"""
+
+import base64
+import hashlib
+import json
+import os
+import re
+import secrets
+import time
+import urllib.parse
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+import requests
+
+from ..config import (
+ DEFAULT_MODEL_OAUTH_FREE,
+ DEFAULT_MODEL_OAUTH_PAID,
+ OAUTH_AUTH_URL,
+ OAUTH_CLIENT_ID,
+ OAUTH_FREE_MODELS,
+ OAUTH_PAID_MODELS,
+ OAUTH_REDIRECT_URI,
+ OAUTH_SCOPE,
+ OAUTH_TIER_ENTERPRISE,
+ OAUTH_TIER_FREE,
+ OAUTH_TIER_PLUS,
+ OAUTH_TIER_PRO,
+ OAUTH_TIER_TEAM,
+ OAUTH_TOKEN_URL,
+ OAUTH_USER_INFO_URL,
+ OPENAI_ENDPOINT,
+ SETTING_OAUTH_ACCESS_TOKEN,
+ SETTING_OAUTH_EXPIRES_AT,
+ SETTING_OAUTH_MODEL,
+ SETTING_OAUTH_REFRESH_TOKEN,
+ SETTING_OAUTH_USER_EMAIL,
+ SETTING_OAUTH_USER_TIER,
+)
+from ..localization import locali
+from .base import BaseAIProvider, ProviderResponse, build_debug_log, build_system_prompt
+
+
+class ChatGPTOAuthHandler(BaseAIProvider):
+ """Handles ChatGPT OAuth login, PKCE exchange, subscription inspection, and summarization."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ super().__init__("ChatGPT (OAuth)")
+ self.plugin = plugin_instance
+ self._current_verifier: Optional[str] = None
+ self._current_state: Optional[str] = None
+
+ def generate_auth_url(self) -> Tuple[str, str, str]:
+ """Generates PKCE authorization URL, verifier, and state."""
+ verifier = secrets.token_urlsafe(64)
+ challenge_bytes = hashlib.sha256(verifier.encode("utf-8")).digest()
+ challenge = base64.urlsafe_b64encode(challenge_bytes).decode("utf-8").replace("=", "")
+ state = secrets.token_urlsafe(16)
+
+ self._current_verifier = verifier
+ self._current_state = state
+
+ params = {
+ "client_id": OAUTH_CLIENT_ID,
+ "response_type": "code",
+ "redirect_uri": OAUTH_REDIRECT_URI,
+ "scope": OAUTH_SCOPE,
+ "state": state,
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ }
+ url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
+ return url, verifier, state
+
+ def extract_code_from_input(self, raw_input: str) -> Optional[str]:
+ """Extracts authorization code from either a raw code string or a full redirect URL."""
+ raw_input = raw_input.strip()
+ if not raw_input:
+ return None
+
+ # Check if full URL
+ if "://" in raw_input or "code=" in raw_input:
+ try:
+ parsed = urllib.parse.urlparse(raw_input)
+ query_params = urllib.parse.parse_qs(parsed.query)
+ if "code" in query_params:
+ return query_params["code"][0]
+ # Check fragment if any
+ if parsed.fragment:
+ frag_params = urllib.parse.parse_qs(parsed.fragment)
+ if "code" in frag_params:
+ return frag_params["code"][0]
+ except Exception:
+ pass
+ return raw_input
+
+ def exchange_code_for_tokens(
+ self,
+ code_or_url: str,
+ code_verifier: Optional[str] = None,
+ ) -> Tuple[bool, Optional[Dict[str, Any]], str]:
+ """Exchanges authorization code for access and refresh tokens."""
+ code = self.extract_code_from_input(code_or_url)
+ if not code:
+ return False, None, "Invalid authorization code or URL."
+
+ verifier = code_verifier or self._current_verifier or ""
+ payload = {
+ "grant_type": "authorization_code",
+ "client_id": OAUTH_CLIENT_ID,
+ "code": code,
+ "redirect_uri": OAUTH_REDIRECT_URI,
+ "code_verifier": verifier,
+ }
+ headers = {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "User-Agent": "exteraGram-AI-Summaries/3.0",
+ }
+
+ try:
+ resp = requests.post(OAUTH_TOKEN_URL, data=payload, headers=headers, timeout=15)
+ if resp.status_code == 200:
+ data = resp.json()
+ access_token = data.get("access_token")
+ refresh_token = data.get("refresh_token")
+ expires_in = data.get("expires_in", 3600)
+ expires_at = time.time() + float(expires_in)
+
+ # Query account profile & tier
+ tier, email = self.fetch_user_account_info(access_token)
+
+ result = {
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "expires_at": expires_at,
+ "tier": tier,
+ "email": email,
+ }
+ self._save_oauth_data(result)
+ return True, result, ""
+ else:
+ err_msg = f"HTTP {resp.status_code}: {resp.text}"
+ return False, None, err_msg
+ except Exception as e:
+ return False, None, f"Network error during token exchange: {str(e)}"
+
+ def fetch_user_account_info(self, access_token: str) -> Tuple[str, str]:
+ """Fetches user profile and maps subscription tier (Free vs Plus/Pro/Team/Enterprise)."""
+ headers = {
+ "Authorization": f"Bearer {access_token}",
+ "User-Agent": "exteraGram-AI-Summaries/3.0",
+ }
+ tier = OAUTH_TIER_FREE
+ email = "chatgpt_user@openai.com"
+
+ try:
+ resp = requests.get(OAUTH_USER_INFO_URL, headers=headers, timeout=10)
+ if resp.status_code == 200:
+ data = resp.json()
+ email = data.get("email") or data.get("id", "User")
+ plan = str(data.get("plan") or data.get("subscription", "")).lower()
+ if any(p in plan for p in ("plus", "pro", "team", "enterprise", "business")):
+ tier = OAUTH_TIER_PLUS
+ else:
+ tier = OAUTH_TIER_FREE
+ except Exception:
+ # Default to free if user info request fails
+ tier = OAUTH_TIER_FREE
+ return tier, email
+
+ def refresh_access_token(self) -> bool:
+ """Refreshes the access token using stored refresh token."""
+ refresh_token = self._get_setting(SETTING_OAUTH_REFRESH_TOKEN, "")
+ if not refresh_token:
+ return False
+
+ payload = {
+ "grant_type": "refresh_token",
+ "client_id": OAUTH_CLIENT_ID,
+ "refresh_token": refresh_token,
+ }
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
+
+ try:
+ resp = requests.post(OAUTH_TOKEN_URL, data=payload, headers=headers, timeout=15)
+ if resp.status_code == 200:
+ data = resp.json()
+ access_token = data.get("access_token")
+ new_refresh_token = data.get("refresh_token", refresh_token)
+ expires_in = data.get("expires_in", 3600)
+ expires_at = time.time() + float(expires_in)
+
+ self._set_setting(SETTING_OAUTH_ACCESS_TOKEN, access_token)
+ self._set_setting(SETTING_OAUTH_REFRESH_TOKEN, new_refresh_token)
+ self._set_setting(SETTING_OAUTH_EXPIRES_AT, str(expires_at))
+ return True
+ except Exception:
+ pass
+ return False
+
+ def get_valid_access_token(self) -> Optional[str]:
+ """Returns active access token, refreshing it if expired or nearing expiration."""
+ access_token = self._get_setting(SETTING_OAUTH_ACCESS_TOKEN, "")
+ if not access_token:
+ return None
+
+ expires_at = float(self._get_setting(SETTING_OAUTH_EXPIRES_AT, "0") or "0")
+ # If token expires in less than 60 seconds, refresh it
+ if expires_at and time.time() > (expires_at - 60):
+ if not self.refresh_access_token():
+ return None
+ access_token = self._get_setting(SETTING_OAUTH_ACCESS_TOKEN, "")
+
+ return access_token
+
+ def get_tier_models(self) -> List[str]:
+ """Returns available models based on user subscription tier."""
+ tier = self._get_setting(SETTING_OAUTH_USER_TIER, OAUTH_TIER_FREE)
+ if tier in (OAUTH_TIER_PLUS, OAUTH_TIER_PRO, OAUTH_TIER_TEAM, OAUTH_TIER_ENTERPRISE):
+ return OAUTH_PAID_MODELS
+ return OAUTH_FREE_MODELS
+
+ def get_active_model(self) -> str:
+ """Returns currently selected model according to tier."""
+ tier = self._get_setting(SETTING_OAUTH_USER_TIER, OAUTH_TIER_FREE)
+ selected = self._get_setting(SETTING_OAUTH_MODEL, "")
+ available = self.get_tier_models()
+ if selected in available:
+ return selected
+ return DEFAULT_MODEL_OAUTH_PAID if tier != OAUTH_TIER_FREE else DEFAULT_MODEL_OAUTH_FREE
+
+ def disconnect_account(self) -> None:
+ """Wipes stored OAuth tokens and resets user tier."""
+ self._set_setting(SETTING_OAUTH_ACCESS_TOKEN, "")
+ self._set_setting(SETTING_OAUTH_REFRESH_TOKEN, "")
+ self._set_setting(SETTING_OAUTH_EXPIRES_AT, "0")
+ self._set_setting(SETTING_OAUTH_USER_EMAIL, "")
+ self._set_setting(SETTING_OAUTH_USER_TIER, "")
+ self._set_setting(SETTING_OAUTH_MODEL, "")
+
+ def is_connected(self) -> bool:
+ """Checks if a valid OAuth session is present."""
+ return bool(self._get_setting(SETTING_OAUTH_ACCESS_TOKEN, ""))
+
+ def summarize(
+ self,
+ messages_transcript: str,
+ style: int,
+ lang: int,
+ chat_title: str = "",
+ custom_prompt: str = "",
+ global_prompt: str = "",
+ enable_stream: bool = True,
+ stream_callback: Optional[Callable[[str, str], None]] = None,
+ ) -> ProviderResponse:
+ """Executes summarization using ChatGPT OAuth token."""
+ start_time = time.time()
+ token = self.get_valid_access_token()
+ model_name = self.get_active_model()
+
+ if not token:
+ return ProviderResponse(
+ success=False,
+ error=locali.get("ERROR_UNAUTHORIZED"),
+ debug_log=build_debug_log(
+ url=OPENAI_ENDPOINT,
+ method="POST",
+ payload={"error": "Missing access token"},
+ status_code=401,
+ ),
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ system_prompt = build_system_prompt(
+ style=style,
+ lang=lang,
+ chat_title=chat_title,
+ custom_prompt=custom_prompt,
+ global_prompt=global_prompt,
+ )
+
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ "User-Agent": "exteraGram-AI-Summaries/3.0",
+ }
+
+ payload: Dict[str, Any] = {
+ "model": model_name,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": f"Chat conversation history to summarize:\n\n{messages_transcript}"},
+ ],
+ "temperature": 0.3,
+ "stream": enable_stream,
+ }
+
+ # Model specific parameter tuning
+ if model_name.startswith("o1") or model_name.startswith("o3"):
+ # O-series models don't support temperature or system prompt in some API variants
+ payload["messages"] = [
+ {"role": "user", "content": f"{system_prompt}\n\nChat conversation history to summarize:\n\n{messages_transcript}"}
+ ]
+ payload.pop("temperature", None)
+
+ def _do_request():
+ return requests.post(
+ OPENAI_ENDPOINT,
+ headers=headers,
+ json=payload,
+ stream=enable_stream,
+ timeout=(10, 120),
+ )
+
+ try:
+ resp = self.execute_with_retry(_do_request)
+ if resp.status_code != 200:
+ raw_err = resp.text
+ err_title = f"HTTP {resp.status_code}"
+ try:
+ err_json = resp.json()
+ err_title = f"HTTP {resp.status_code}: {err_json.get('error', {}).get('message', raw_err)}"
+ except Exception:
+ pass
+
+ debug_log = build_debug_log(
+ url=OPENAI_ENDPOINT,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ status_code=resp.status_code,
+ response_body=raw_err,
+ )
+ return ProviderResponse(
+ success=False,
+ error=err_title,
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ # Process response
+ full_thought = ""
+ full_content = ""
+
+ if enable_stream:
+ for line in resp.iter_lines(decode_unicode=True):
+ if not line:
+ continue
+ if line.startswith("data: "):
+ data_str = line[6:].strip()
+ if data_str == "[DONE]":
+ break
+ try:
+ chunk = json.loads(data_str)
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
+ thought_chunk = (
+ delta.get("reasoning_content")
+ or delta.get("thought")
+ or ""
+ )
+ content_chunk = delta.get("content") or ""
+
+ if thought_chunk:
+ full_thought += thought_chunk
+ if content_chunk:
+ full_content += content_chunk
+
+ if stream_callback and (thought_chunk or content_chunk):
+ stream_callback(thought_chunk, content_chunk)
+ except Exception:
+ continue
+ else:
+ data = resp.json()
+ choice = data.get("choices", [{}])[0]
+ msg = choice.get("message", {})
+ full_content = msg.get("content", "")
+ full_thought = (
+ msg.get("reasoning_content")
+ or msg.get("thought")
+ or ""
+ )
+
+ return ProviderResponse(
+ success=True,
+ text=full_content.strip(),
+ reasoning=full_thought.strip(),
+ raw_response=None,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ except Exception as e:
+ debug_log = build_debug_log(
+ url=OPENAI_ENDPOINT,
+ method="POST",
+ headers=headers,
+ payload=payload,
+ error_exc=e,
+ )
+ return ProviderResponse(
+ success=False,
+ error=f"Request failed: {str(e)}",
+ debug_log=debug_log,
+ latency=time.time() - start_time,
+ model=model_name,
+ provider_name=self.name,
+ )
+
+ # Internal Settings access helpers
+ def _get_setting(self, key: str, default: Any = None) -> Any:
+ if self.plugin and hasattr(self.plugin, "get_setting"):
+ return self.plugin.get_setting(key, default)
+ return default
+
+ def _set_setting(self, key: str, value: Any) -> None:
+ if self.plugin and hasattr(self.plugin, "set_setting"):
+ self.plugin.set_setting(key, value)
+
+ def _save_oauth_data(self, data: Dict[str, Any]) -> None:
+ self._set_setting(SETTING_OAUTH_ACCESS_TOKEN, data.get("access_token", ""))
+ self._set_setting(SETTING_OAUTH_REFRESH_TOKEN, data.get("refresh_token", ""))
+ self._set_setting(SETTING_OAUTH_EXPIRES_AT, str(data.get("expires_at", 0)))
+ self._set_setting(SETTING_OAUTH_USER_EMAIL, data.get("email", ""))
+ self._set_setting(SETTING_OAUTH_USER_TIER, data.get("tier", OAUTH_TIER_FREE))
+
+ # Set default model based on tier
+ default_model = (
+ DEFAULT_MODEL_OAUTH_PAID
+ if data.get("tier") != OAUTH_TIER_FREE
+ else DEFAULT_MODEL_OAUTH_FREE
+ )
+ self._set_setting(SETTING_OAUTH_MODEL, default_model)
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 0000000..e295317
--- /dev/null
+++ b/src/services/__init__.py
@@ -0,0 +1,7 @@
+"""
+Services module exports.
+"""
+
+from .message_fetcher import MessageFetcher, FormattedMessage
+
+__all__ = ["MessageFetcher", "FormattedMessage"]
diff --git a/src/services/message_fetcher.py b/src/services/message_fetcher.py
new file mode 100644
index 0000000..eaaec16
--- /dev/null
+++ b/src/services/message_fetcher.py
@@ -0,0 +1,431 @@
+"""
+Message Fetcher service: TLRPC history pagination, forum topic support, media formatting, and sender mapping.
+"""
+
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from ..config import MAX_MSG_COUNT, MAX_SINGLE_MESSAGE_CHARS, MIN_MSG_COUNT
+
+
+@dataclass
+class FormattedMessage:
+ """Represents a sanitized, human-readable Telegram message for LLM context."""
+ id: int
+ date: int
+ time_str: str
+ sender_name: str
+ sender_username: str
+ text: str
+ reply_to_id: Optional[int] = None
+ forward_from: Optional[str] = None
+ media_info: Optional[str] = None
+
+ def to_transcript_line(self) -> str:
+ """Formats into a clean transcript line for AI summarization."""
+ meta_parts = []
+ if self.forward_from:
+ meta_parts.append(f"fwd: {self.forward_from}")
+ if self.reply_to_id:
+ meta_parts.append(f"reply-to #{self.reply_to_id}")
+
+ meta_str = f" ({', '.join(meta_parts)})" if meta_parts else ""
+
+ content = self.text
+ if self.media_info:
+ if content:
+ content = f"{self.media_info}\n{content}"
+ else:
+ content = self.media_info
+
+ return f"[{self.time_str}] #{self.id} {self.sender_name}{meta_str}:\n{content}"
+
+
+class MessageFetcher:
+ """Handles fetching and formatting Telegram chat history across chats, channels, and forum topics."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ self.plugin = plugin_instance
+
+ def fetch_history(
+ self,
+ dialog_id: int,
+ count: int,
+ offset: int = 0,
+ topic_id: int = 0,
+ callback: Optional[Callable[[List[FormattedMessage], Optional[str]], None]] = None,
+ ) -> None:
+ """Asynchronously fetches chat messages up to count with optional offset, then invokes callback(messages, error)."""
+ target_count = max(MIN_MSG_COUNT, min(MAX_MSG_COUNT, count))
+ target_offset = max(0, offset)
+
+ try:
+ from client_utils import PLUGINS_QUEUE, run_on_queue
+ run_on_queue(
+ lambda: self._fetch_messages_paginated(
+ dialog_id=dialog_id,
+ target_count=target_count,
+ topic_id=topic_id,
+ offset_id=0,
+ add_offset=target_offset,
+ accumulated_raw=[],
+ user_map={},
+ chat_map={},
+ callback=callback,
+ ),
+ PLUGINS_QUEUE,
+ )
+ except Exception:
+ # Running outside exteraGram runtime (e.g. testing / direct simulation)
+ self._fetch_messages_paginated(
+ dialog_id=dialog_id,
+ target_count=target_count,
+ topic_id=topic_id,
+ offset_id=0,
+ add_offset=target_offset,
+ accumulated_raw=[],
+ user_map={},
+ chat_map={},
+ callback=callback,
+ )
+ def _fetch_messages_paginated(
+ self,
+ dialog_id: int,
+ target_count: int,
+ topic_id: int,
+ offset_id: int,
+ add_offset: int,
+ accumulated_raw: List[Any],
+ user_map: Dict[int, Any],
+ chat_map: Dict[int, Any],
+ callback: Optional[Callable[[List[FormattedMessage], Optional[str]], None]],
+ ) -> None:
+ """Paginates in chunks of 100 messages until target count is satisfied or end of history reached."""
+ remaining = target_count - len(accumulated_raw)
+ if remaining <= 0:
+ formatted = self._process_messages(accumulated_raw, user_map, chat_map)
+ if callback:
+ callback(formatted, None)
+ return
+
+ chunk_limit = min(100, remaining)
+
+ try:
+ from client_utils import get_messages_controller, send_request, RequestCallback
+ from org.telegram.tgnet import TLRPC
+
+ messages_controller = get_messages_controller()
+ peer = messages_controller.getInputPeer(dialog_id)
+
+ if topic_id and topic_id != 0:
+ # Forum thread / replies
+ req = TLRPC.TL_messages_getReplies()
+ req.peer = peer
+ req.msg_id = topic_id
+ req.offset_id = offset_id
+ req.offset_date = 0
+ req.add_offset = add_offset
+ req.limit = chunk_limit
+ req.max_id = 0
+ req.min_id = 0
+ req.hash = 0
+ else:
+ # Standard chat history
+ req = TLRPC.TL_messages_getHistory()
+ req.peer = peer
+ req.offset_id = offset_id
+ req.offset_date = 0
+ req.add_offset = add_offset
+ req.limit = chunk_limit
+ req.max_id = 0
+ req.min_id = 0
+ req.hash = 0
+
+ def on_complete(response, error):
+ try:
+ if error:
+ err_text = getattr(error, "text", str(error))
+ if accumulated_raw:
+ formatted = self._process_messages(accumulated_raw, user_map, chat_map)
+ if callback:
+ callback(formatted, None)
+ else:
+ if callback:
+ callback([], f"TLRPC Error: {err_text}")
+ return
+
+ if not response:
+ formatted = self._process_messages(accumulated_raw, user_map, chat_map)
+ if callback:
+ callback(formatted, None)
+ return
+
+ # Update users and chats dictionaries
+ new_users = self._parse_java_list(getattr(response, "users", None))
+ new_chats = self._parse_java_list(getattr(response, "chats", None))
+ user_map.update(new_users)
+ chat_map.update(new_chats)
+
+ raw_messages = getattr(response, "messages", None)
+ msg_list = []
+ if raw_messages:
+ try:
+ size = raw_messages.size()
+ for i in range(size):
+ msg_list.append(raw_messages.get(i))
+ except Exception:
+ if isinstance(raw_messages, list):
+ msg_list = raw_messages
+
+ if not msg_list:
+ # End of chat history reached
+ formatted = self._process_messages(accumulated_raw, user_map, chat_map)
+ if callback:
+ callback(formatted, None)
+ return
+
+ accumulated_raw.extend(msg_list)
+ next_offset = getattr(msg_list[-1], "id", 0)
+
+ if len(msg_list) < chunk_limit or len(accumulated_raw) >= target_count:
+ formatted = self._process_messages(accumulated_raw, user_map, chat_map)
+ if callback:
+ callback(formatted, None)
+ else:
+ # Fetch next page
+ self._fetch_messages_paginated(
+ dialog_id=dialog_id,
+ target_count=target_count,
+ topic_id=topic_id,
+ offset_id=next_offset,
+ add_offset=0,
+ accumulated_raw=accumulated_raw,
+ user_map=user_map,
+ chat_map=chat_map,
+ callback=callback,
+ )
+ except Exception as e:
+ if callback:
+ callback([], f"Pagination processing error: {str(e)}")
+
+ send_request(req, RequestCallback(on_complete))
+
+ except Exception as e:
+ if callback:
+ callback([], f"Fetch setup failed: {str(e)}")
+
+ def _parse_java_list(self, java_list: Any) -> Dict[int, Any]:
+ """Converts TLRPC ArrayList of objects with .id into a python dictionary."""
+ result: Dict[int, Any] = {}
+ if not java_list:
+ return result
+ try:
+ size = java_list.size()
+ for i in range(size):
+ item = java_list.get(i)
+ item_id = getattr(item, "id", None)
+ if item_id is not None:
+ result[int(item_id)] = item
+ except Exception:
+ if isinstance(java_list, list):
+ for item in java_list:
+ item_id = getattr(item, "id", None)
+ if item_id is not None:
+ result[int(item_id)] = item
+ return result
+
+ def _process_messages(
+ self,
+ raw_messages: List[Any],
+ user_map: Dict[int, Any],
+ chat_map: Dict[int, Any],
+ ) -> List[FormattedMessage]:
+ """Converts raw TLRPC messages into clean FormattedMessage instances sorted chronologically."""
+ formatted_list: List[FormattedMessage] = []
+
+ for raw in raw_messages:
+ # Skip empty service messages with no text/media or action-only messages without content
+ msg_id = getattr(raw, "id", 0)
+ date_val = getattr(raw, "date", 0)
+ message_text = getattr(raw, "message", "") or ""
+
+ # Check if action message (e.g. TL_messageService)
+ action = getattr(raw, "action", None)
+ if action and not message_text:
+ continue
+
+ sender_name, sender_user = self._resolve_sender(raw, user_map, chat_map)
+ time_str = datetime.fromtimestamp(date_val).strftime("%Y-%m-%d %H:%M") if date_val else "00:00"
+ reply_id = self._resolve_reply_id(raw)
+ fwd_info = self._resolve_forward(raw, user_map, chat_map)
+ media_info = self._format_media(getattr(raw, "media", None))
+
+ # Preserve up to MAX_SINGLE_MESSAGE_CHARS per message
+ if len(message_text) > MAX_SINGLE_MESSAGE_CHARS:
+ message_text = message_text[:MAX_SINGLE_MESSAGE_CHARS] + " ...[truncated]"
+
+ if message_text or media_info:
+ formatted_list.append(
+ FormattedMessage(
+ id=int(msg_id),
+ date=int(date_val),
+ time_str=time_str,
+ sender_name=sender_name,
+ sender_username=sender_user,
+ text=message_text.strip(),
+ reply_to_id=reply_id,
+ forward_from=fwd_info,
+ media_info=media_info,
+ )
+ )
+
+ # Sort chronologically (oldest first) so conversational context flows naturally into the LLM
+ formatted_list.sort(key=lambda m: m.date if m.date else m.id)
+ return formatted_list
+
+ def _resolve_sender(
+ self,
+ raw_msg: Any,
+ user_map: Dict[int, Any],
+ chat_map: Dict[int, Any],
+ ) -> Tuple[str, str]:
+ """Resolves readable sender display name and @username."""
+ 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 is not None:
+ uid = int(uid)
+ user = user_map.get(uid)
+ if user:
+ first = getattr(user, "first_name", "") or ""
+ last = getattr(user, "last_name", "") or ""
+ username = getattr(user, "username", "") or ""
+ name = (first + " " + last).strip()
+ if not name and username:
+ name = f"@{username}"
+ elif not name:
+ name = f"User {uid}"
+ return name, f"@{username}" if username else ""
+ return f"User {uid}", ""
+
+ if channel_id is not None:
+ channel_id = int(channel_id)
+ chat = chat_map.get(channel_id)
+ if chat:
+ title = getattr(chat, "title", f"Channel {channel_id}")
+ username = getattr(chat, "username", "") or ""
+ return str(title), f"@{username}" if username else ""
+ return f"Channel {channel_id}", ""
+
+ if chat_id is not None:
+ chat_id = int(chat_id)
+ chat = chat_map.get(chat_id)
+ if chat:
+ title = getattr(chat, "title", f"Group {chat_id}")
+ return str(title), ""
+ return f"Group {chat_id}", ""
+
+ return "Unknown", ""
+
+ def _resolve_reply_id(self, raw_msg: Any) -> Optional[int]:
+ """Extracts replied-to message ID if present."""
+ reply_to = getattr(raw_msg, "reply_to", None)
+ if reply_to:
+ r_id = getattr(reply_to, "reply_to_msg_id", None)
+ if r_id:
+ return int(r_id)
+ reply_to_msg_id = getattr(raw_msg, "reply_to_msg_id", None)
+ if reply_to_msg_id:
+ return int(reply_to_msg_id)
+ return None
+
+ def _resolve_forward(
+ self,
+ raw_msg: Any,
+ user_map: Dict[int, Any],
+ chat_map: Dict[int, Any],
+ ) -> Optional[str]:
+ """Resolves forwarded message origin name and username."""
+ fwd_from = getattr(raw_msg, "fwd_from", None)
+ if not fwd_from:
+ return None
+
+ # Check from_name
+ from_name = getattr(fwd_from, "from_name", None)
+ if from_name:
+ return str(from_name)
+
+ # Check from_id
+ from_id = getattr(fwd_from, "from_id", None)
+ if from_id:
+ uid = getattr(from_id, "user_id", None)
+ if uid is not None and int(uid) in user_map:
+ u = user_map[int(uid)]
+ return getattr(u, "first_name", f"User {uid}")
+ cid = getattr(from_id, "channel_id", None)
+ if cid is not None and int(cid) in chat_map:
+ c = chat_map[int(cid)]
+ return getattr(c, "title", f"Channel {cid}")
+
+ return "Forwarded message"
+
+ def _format_media(self, media: Any) -> Optional[str]:
+ """Extracts human-readable metadata for attachments."""
+ if not media:
+ return None
+
+ media_type = type(media).__name__
+ caption = getattr(media, "caption", "") or getattr(media, "description", "") or ""
+
+ if "Photo" in media_type:
+ return f"[Photo{': ' + caption if caption else ''}]"
+ elif "Document" in media_type:
+ doc = getattr(media, "document", None)
+ file_name = ""
+ if doc:
+ attrs = getattr(doc, "attributes", [])
+ try:
+ size = attrs.size() if hasattr(attrs, "size") else len(attrs)
+ for i in range(size):
+ attr = attrs.get(i) if hasattr(attrs, "get") else attrs[i]
+ if "FileName" in type(attr).__name__:
+ file_name = getattr(attr, "file_name", "")
+ elif "Audio" in type(attr).__name__:
+ voice = getattr(attr, "voice", False)
+ duration = getattr(attr, "duration", 0)
+ if voice:
+ return f"[Voice message: {duration}s]"
+ title = getattr(attr, "title", "")
+ performer = getattr(attr, "performer", "")
+ return f"[Audio: {performer} - {title}]" if performer or title else f"[Audio: {duration}s]"
+ except Exception:
+ pass
+ return f"[Document: {file_name if file_name else 'file'}{': ' + caption if caption else ''}]"
+ elif "Poll" in media_type:
+ poll = getattr(media, "poll", None)
+ question = getattr(poll, "question", "Poll") if poll else "Poll"
+ return f"[Poll: {question}]"
+ elif "Geo" in media_type:
+ return "[Location]"
+ elif "Contact" in media_type:
+ first = getattr(media, "first_name", "")
+ return f"[Contact: {first}]"
+ elif "WebPage" in media_type:
+ webpage = getattr(media, "webpage", None)
+ title = getattr(webpage, "title", "") if webpage else ""
+ url = getattr(webpage, "url", "") if webpage else ""
+ return f"[Web Link: {title or url}]"
+ elif "Game" in media_type:
+ return "[Game]"
+ elif "Invoice" in media_type:
+ return "[Invoice]"
+
+ return "[Attachment]"
diff --git a/src/ui/__init__.py b/src/ui/__init__.py
new file mode 100644
index 0000000..1ed613a
--- /dev/null
+++ b/src/ui/__init__.py
@@ -0,0 +1,20 @@
+"""
+UI components module exports.
+"""
+
+from .settings import build_settings_layout, show_oauth_login_dialog, show_custom_verification_dialog
+from .pre_request import show_pre_request_sheet
+from .progress_widget import PinnedProgressManager
+from .thinking_sheet import show_thinking_bottom_sheet
+from .summary_dialog import show_summary_result_dialog, show_error_dialog
+
+__all__ = [
+ "build_settings_layout",
+ "show_oauth_login_dialog",
+ "show_custom_verification_dialog",
+ "show_pre_request_sheet",
+ "PinnedProgressManager",
+ "show_thinking_bottom_sheet",
+ "show_summary_result_dialog",
+ "show_error_dialog",
+]
diff --git a/src/ui/pre_request.py b/src/ui/pre_request.py
new file mode 100644
index 0000000..3a0649d
--- /dev/null
+++ b/src/ui/pre_request.py
@@ -0,0 +1,318 @@
+"""
+Pre-request dialog for count, offset, style, and per-chat prompt configuration.
+"""
+
+from typing import Any, Callable, Optional
+
+from ..config import (
+ DEFAULT_MSG_COUNT,
+ MAX_MSG_COUNT,
+ MIN_MSG_COUNT,
+ SETTING_DEFAULT_COUNT,
+ SETTING_GLOBAL_PROMPT,
+ SETTING_SUMMARY_STYLE,
+ STYLE_BRIEF,
+ STYLES,
+)
+from ..localization import locali
+
+
+def show_pre_request_sheet(
+ plugin: Any,
+ dialog_id: int,
+ chat_title: str = "",
+ initial_count: Optional[int] = None,
+ initial_offset: int = 0,
+ topic_id: int = 0,
+ is_unread_trigger: bool = False,
+ total_unread_count: int = 0,
+ auto_jump: bool = False,
+ on_start_callback: Optional[Callable[[int, int, int, str, int, bool], None]] = None,
+) -> None:
+ """Shows a theme-aware summary configuration dialog."""
+ saved_default = int(plugin.get_setting(SETTING_DEFAULT_COUNT, DEFAULT_MSG_COUNT) or DEFAULT_MSG_COUNT)
+ target_count = max(MIN_MSG_COUNT, min(MAX_MSG_COUNT, initial_count if initial_count is not None else saved_default))
+ target_offset = max(0, int(initial_offset or 0))
+ saved_style = int(plugin.get_setting(SETTING_SUMMARY_STYLE, STYLE_BRIEF) or STYLE_BRIEF)
+
+ per_chat_key = f"prompt_chat_{dialog_id}"
+ saved_prompt = plugin.get_setting(per_chat_key, "") or ""
+ global_prompt = plugin.get_setting(SETTING_GLOBAL_PROMPT, "") or ""
+ initial_prompt = saved_prompt or global_prompt
+
+ try:
+ from android.content.res import ColorStateList
+ from android.text import InputType
+ from android.util import TypedValue
+ from android.view import Gravity
+ from android.widget import Button, LinearLayout, SeekBar, TextView
+ from android_utils import OnClickListener
+ from client_utils import get_last_fragment
+ from java import dynamic_proxy
+ from org.telegram.messenger import AndroidUtilities
+ from org.telegram.ui.ActionBar import Theme
+ from org.telegram.ui.Components import EditTextBoldCursor
+ from ui.alert import AlertDialogBuilder
+ from ..diagnostics import record_error, record_fact
+
+ fragment = get_last_fragment()
+ activity = fragment.getParentActivity() if fragment else None
+ if not activity:
+ record_fact("pre_request.abort", "no parent activity")
+ return
+
+ def dp(value: float) -> int:
+ return AndroidUtilities.dp(value)
+
+ def color(key: str, fallback: int) -> int:
+ try:
+ return Theme.getColor(getattr(Theme, key))
+ except Exception:
+ return fallback
+
+ # All colors derive from the active Telegram/exteraGram theme.
+ dialog_bg = color("key_dialogBackground", -15395563)
+ primary = color("key_dialogTextBlack", -14606047)
+ secondary = color("key_dialogTextGray3", -7829368)
+ accent = color("key_dialogTextBlue2", color("key_windowBackgroundWhiteBlueText", -14575885))
+ input_text = color("key_windowBackgroundWhiteBlackText", primary)
+ input_hint = color("key_windowBackgroundWhiteHintText", secondary)
+ input_line = color("key_dialogInputField", secondary)
+ input_line_active = color("key_dialogInputFieldActivated", accent)
+ danger = color("key_text_RedBold", -2937041)
+ control_bg = color("key_dialogGrayLine", color("key_windowBackgroundWhite", dialog_bg))
+
+ builder = AlertDialogBuilder(activity, AlertDialogBuilder.ALERT_TYPE_MESSAGE)
+ builder.set_title(locali.get("PRE_REQ_TITLE"))
+
+ root = LinearLayout(activity)
+ root.setOrientation(LinearLayout.VERTICAL)
+ root.setPadding(dp(20), dp(8), dp(20), dp(8))
+ root.setBackgroundColor(dialog_bg)
+
+ subtitle = TextView(activity)
+ subtitle.setText(locali.get("PRE_REQ_SUBTITLE", title=chat_title or f"Chat {dialog_id}"))
+ subtitle.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14)
+ subtitle.setTextColor(secondary)
+ root.addView(subtitle)
+
+ if is_unread_trigger and total_unread_count > 0:
+ unread = TextView(activity)
+ if total_unread_count > target_count:
+ unread.setText(locali.get(
+ "PRE_REQ_UNREAD_BATCH_BADGE",
+ offset=target_offset,
+ count=target_count,
+ unread=total_unread_count,
+ ))
+ else:
+ unread.setText(locali.get("PRE_REQ_UNREAD_BADGE", count=total_unread_count))
+ unread.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
+ unread.setTextColor(accent)
+ unread.setPadding(0, dp(3), 0, 0)
+ root.addView(unread)
+
+ values = {"count": target_count, "offset": target_offset}
+ listener_refs = []
+
+ def parse_number(field, fallback: int, minimum: int, maximum: Optional[int]) -> int:
+ try:
+ text = str(field.getText()).strip()
+ result = int(text) if text else fallback
+ except Exception:
+ result = fallback
+ result = max(minimum, result)
+ return min(maximum, result) if maximum is not None else result
+
+ def make_seek_listener(callback):
+ class _SeekListener(dynamic_proxy(SeekBar.OnSeekBarChangeListener)):
+ def __init__(self, cb):
+ super().__init__()
+ self.cb = cb
+
+ def onProgressChanged(self, seekbar, progress, from_user):
+ if from_user:
+ self.cb(int(progress))
+
+ def onStartTrackingTouch(self, seekbar):
+ pass
+
+ def onStopTrackingTouch(self, seekbar):
+ pass
+
+ return _SeekListener(callback)
+
+ def configure_input(field) -> None:
+ field.setSingleLine(True)
+ field.setGravity(Gravity.CENTER)
+ field.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
+ field.setInputType(InputType.TYPE_CLASS_NUMBER)
+ field.setSelectAllOnFocus(True)
+ field.setTextColor(input_text)
+ field.setHintTextColor(input_hint)
+ field.setLineColors(input_line, input_line_active, danger)
+ field.setCursorColor(accent)
+
+ def build_slider(label_key: str, key: str, minimum: int, slider_maximum: int):
+ label = TextView(activity)
+ label.setText(locali.get(label_key, **{key: values[key]}))
+ label.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14)
+ label.setTextColor(primary)
+ label.setPadding(0, dp(12), 0, 0)
+ root.addView(label)
+
+ row = LinearLayout(activity)
+ row.setOrientation(LinearLayout.HORIZONTAL)
+ row.setGravity(Gravity.CENTER_VERTICAL)
+
+ slider = SeekBar(activity)
+ span = max(1, slider_maximum - minimum)
+ slider.setMax(span)
+ slider.setProgress(min(span, max(0, values[key] - minimum)))
+ try:
+ tint = ColorStateList.valueOf(accent)
+ slider.setProgressTintList(tint)
+ slider.setThumbTintList(tint)
+ except Exception:
+ pass
+ row.addView(slider, LinearLayout.LayoutParams(0, -2, 1.0))
+
+ number = EditTextBoldCursor(activity)
+ number.setText(str(values[key]))
+ configure_input(number)
+ row.addView(number, LinearLayout.LayoutParams(dp(84), dp(48)))
+ root.addView(row)
+
+ def changed(progress: int):
+ value = minimum + progress
+ values[key] = value
+ number.setText(str(value))
+ number.setSelection(len(str(value)))
+ label.setText(locali.get(label_key, **{key: value}))
+
+ listener = make_seek_listener(changed)
+ listener_refs.append(listener)
+ slider.setOnSeekBarChangeListener(listener)
+ return number
+
+ count_input = build_slider("PRE_REQ_COUNT_LABEL", "count", MIN_MSG_COUNT, MAX_MSG_COUNT)
+
+ # Offset number input has no maximum. Slider is only a convenient viewport.
+ offset_slider_max = max(10000, target_offset * 2, total_unread_count or 0)
+ offset_input = build_slider("PRE_REQ_OFFSET_LABEL", "offset", 0, offset_slider_max)
+
+ offset_help = TextView(activity)
+ offset_help.setText(locali.get("PRE_REQ_OFFSET_SUB"))
+ offset_help.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
+ offset_help.setTextColor(secondary)
+ root.addView(offset_help)
+
+ style_title = TextView(activity)
+ style_title.setText(locali.get("PRE_REQ_STYLE_LABEL"))
+ style_title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14)
+ style_title.setTextColor(primary)
+ style_title.setPadding(0, dp(12), 0, dp(4))
+ root.addView(style_title)
+
+ style_state = {"style": saved_style}
+ style_buttons = []
+
+ def refresh_styles():
+ for index, button in enumerate(style_buttons):
+ selected = index == style_state["style"]
+ button.setTextColor(dialog_bg if selected else primary)
+ button.setBackgroundTintList(ColorStateList.valueOf(accent if selected else control_bg))
+
+ # Two columns avoid the clipped 3-button row shown in the previous UI.
+ for row_start in range(0, len(STYLES), 2):
+ style_row = LinearLayout(activity)
+ style_row.setOrientation(LinearLayout.HORIZONTAL)
+ for index in range(row_start, min(row_start + 2, len(STYLES))):
+ button = Button(activity)
+ button.setText(STYLES[index])
+ button.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
+
+ def select(*_, idx=index):
+ style_state["style"] = idx
+ refresh_styles()
+
+ button.setOnClickListener(OnClickListener(select))
+ style_buttons.append(button)
+ style_row.addView(button, LinearLayout.LayoutParams(0, dp(44), 1.0))
+ root.addView(style_row)
+ refresh_styles()
+
+ prompt_title = TextView(activity)
+ prompt_title.setText(locali.get("PRE_REQ_PROMPT_LABEL"))
+ prompt_title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14)
+ prompt_title.setTextColor(primary)
+ prompt_title.setPadding(0, dp(12), 0, dp(2))
+ root.addView(prompt_title)
+
+ prompt_input = EditTextBoldCursor(activity)
+ prompt_input.setHint(locali.get("PRE_REQ_PROMPT_HINT"))
+ prompt_input.setText(initial_prompt)
+ prompt_input.setMinLines(2)
+ prompt_input.setMaxLines(4)
+ prompt_input.setTextColor(input_text)
+ prompt_input.setHintTextColor(input_hint)
+ prompt_input.setLineColors(input_line, input_line_active, danger)
+ prompt_input.setCursorColor(accent)
+ root.addView(prompt_input)
+
+ reset = Button(activity)
+ reset.setText(locali.get("PRE_REQ_PROMPT_RESET"))
+ reset.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
+ reset.setTextColor(primary)
+ reset.setBackgroundTintList(ColorStateList.valueOf(control_bg))
+
+ def reset_prompt(*_):
+ prompt_input.setText("")
+ plugin.set_setting(per_chat_key, "")
+
+ reset.setOnClickListener(OnClickListener(reset_prompt))
+ root.addView(reset, LinearLayout.LayoutParams(-1, dp(44)))
+
+ builder.set_view(root)
+
+ def start(dialog, _):
+ final_count = parse_number(count_input, values["count"], MIN_MSG_COUNT, MAX_MSG_COUNT)
+ final_offset = parse_number(offset_input, values["offset"], 0, None)
+ final_prompt = str(prompt_input.getText()).strip()
+ plugin.set_setting(per_chat_key, final_prompt)
+ dialog.dismiss()
+
+ if on_start_callback:
+ on_start_callback(final_count, final_offset, style_state["style"], final_prompt, topic_id, auto_jump)
+ else:
+ plugin.start_summarization_pipeline(
+ dialog_id=dialog_id,
+ chat_title=chat_title,
+ count=final_count,
+ offset=final_offset,
+ style=style_state["style"],
+ custom_prompt=final_prompt,
+ topic_id=topic_id,
+ auto_jump_latest=auto_jump,
+ )
+
+ builder.set_negative_button(locali.get("PRE_REQ_BTN_CANCEL"), lambda dialog, _: dialog.dismiss())
+ builder.set_positive_button(locali.get("PRE_REQ_BTN_START"), start)
+ builder.show()
+ record_fact(
+ "pre_request.shown",
+ f"dialog={dialog_id} count={target_count} offset={target_offset} unread_trigger={is_unread_trigger}",
+ )
+ except Exception as exc:
+ try:
+ from ..diagnostics import record_error
+ record_error("show_pre_request_sheet", exc)
+ except Exception:
+ pass
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_error(f"AI Summary dialog failed: {type(exc).__name__}: {exc}")
+ except Exception:
+ pass
+ if on_start_callback:
+ on_start_callback(target_count, target_offset, saved_style, initial_prompt, topic_id, auto_jump)
diff --git a/src/ui/progress_widget.py b/src/ui/progress_widget.py
new file mode 100644
index 0000000..6493d2d
--- /dev/null
+++ b/src/ui/progress_widget.py
@@ -0,0 +1,164 @@
+"""
+In-Chat Animated Progress Widget and Pinned Message View shrinker/manager.
+"""
+
+from typing import Any, Optional
+import weakref
+
+from ..config import SETTING_ENABLE_THINKING_STREAM
+from ..localization import locali
+
+
+class PinnedProgressManager:
+ """Manages the lifecycle of the in-header animated progress widget and adjusts PinnedMessageView layout."""
+
+ def __init__(self, plugin_instance: Optional[Any] = None) -> None:
+ self.plugin = plugin_instance
+ self.active_widget_ref: Optional[weakref.ref] = None
+ self.pinned_view_ref: Optional[weakref.ref] = None
+ self.original_right_padding: int = 0
+ self.original_right_margin: int = 0
+ self.is_showing: bool = False
+
+ def attach_pinned_view(self, pinned_view: Any) -> None:
+ """Stores reference to PinnedMessageView for dynamic layout adjustment."""
+ if pinned_view:
+ self.pinned_view_ref = weakref.ref(pinned_view)
+ try:
+ self.original_right_padding = pinned_view.getPaddingRight()
+ except Exception:
+ pass
+
+ def show_progress(self, chat_activity: Any, dialog_id: int, chat_title: str) -> None:
+ """Injects animated progress widget into the top bar and shrinks the pinned message view."""
+ self.is_showing = True
+
+ try:
+ from org.telegram.messenger import AndroidUtilities
+ from org.telegram.ui.ActionBar import Theme
+ from android.widget import LinearLayout, TextView, FrameLayout, ProgressBar
+ from android.view import Gravity
+ from android_utils import OnClickListener
+
+ def dp(val: float) -> int:
+ return AndroidUtilities.dp(val)
+
+ def color(key: str, fallback: int) -> int:
+ try:
+ return Theme.getColor(getattr(Theme, key))
+ except Exception:
+ return fallback
+
+ accent = color("key_windowBackgroundWhiteBlueText", -14575885)
+
+ pinned_view = self.pinned_view_ref() if self.pinned_view_ref else None
+ if pinned_view:
+ try:
+ pinned_view.setPadding(
+ pinned_view.getPaddingLeft(),
+ pinned_view.getPaddingTop(),
+ dp(110),
+ pinned_view.getPaddingBottom(),
+ )
+ except Exception:
+ pass
+
+ act = chat_activity.getParentActivity() if hasattr(chat_activity, "getParentActivity") else None
+ if not act:
+ return
+
+ parent_view = getattr(chat_activity, "pinnedMessageView", None) or pinned_view
+ if parent_view and hasattr(parent_view, "getParent"):
+ container = parent_view.getParent()
+ else:
+ container = getattr(chat_activity, "contentView", None) or getattr(chat_activity, "fragmentView", None)
+
+ if not container:
+ return
+
+ widget = LinearLayout(act)
+ widget.setOrientation(LinearLayout.HORIZONTAL)
+ widget.setGravity(Gravity.CENTER_VERTICAL)
+ widget.setPadding(dp(8), dp(4), dp(8), dp(4))
+
+ try:
+ from android.graphics.drawable import GradientDrawable
+ shape = GradientDrawable()
+ shape.setCornerRadius(dp(12))
+ shape.setColor((accent & 0x00FFFFFF) | 0x33000000) # 20% alpha of accent
+ widget.setBackground(shape)
+ except Exception:
+ pass
+
+ spinner = ProgressBar(act)
+ lp_sp = LinearLayout.LayoutParams(dp(16), dp(16))
+ lp_sp.rightMargin = dp(6)
+ widget.addView(spinner, lp_sp)
+
+ label = TextView(act)
+ label.setText(locali.get("PROGRESS_SUMMARIZING"))
+ label.setTextSize(11)
+ label.setTextColor(accent)
+ widget.addView(label)
+
+ def on_widget_click(*_):
+ enable_thinking = bool(
+ self.plugin.get_setting(SETTING_ENABLE_THINKING_STREAM, True) if self.plugin else True
+ )
+ if enable_thinking:
+ from .thinking_sheet import show_thinking_bottom_sheet
+ show_thinking_bottom_sheet(self.plugin, chat_activity)
+ else:
+ try:
+ from ui.alert import AlertDialogBuilder
+ b = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_MESSAGE)
+ b.set_title(locali.get("PLUGIN_NAME"))
+ b.set_message(locali.get("PROGRESS_THINKING_DISABLED_ALERT"))
+ b.set_positive_button("OK", lambda d, _: d.dismiss())
+ b.show()
+ except Exception:
+ pass
+
+ widget.setOnClickListener(OnClickListener(on_widget_click))
+
+ lp = FrameLayout.LayoutParams(-2, -2)
+ lp.gravity = Gravity.TOP | Gravity.RIGHT
+ lp.topMargin = dp(4)
+ lp.rightMargin = dp(36)
+
+ try:
+ container.addView(widget, lp)
+ self.active_widget_ref = weakref.ref(widget)
+ except Exception:
+ pass
+
+ except Exception:
+ pass
+
+ def hide_progress(self) -> None:
+ """Removes the progress widget and restores original PinnedMessageView layout."""
+ self.is_showing = False
+
+ try:
+ pinned_view = self.pinned_view_ref() if self.pinned_view_ref else None
+ if pinned_view:
+ try:
+ pinned_view.setPadding(
+ pinned_view.getPaddingLeft(),
+ pinned_view.getPaddingTop(),
+ self.original_right_padding,
+ pinned_view.getPaddingBottom(),
+ )
+ except Exception:
+ pass
+
+ if self.active_widget_ref:
+ widget = self.active_widget_ref()
+ if widget and hasattr(widget, "getParent") and widget.getParent():
+ try:
+ widget.getParent().removeView(widget)
+ except Exception:
+ pass
+ self.active_widget_ref = None
+ except Exception:
+ pass
diff --git a/src/ui/settings.py b/src/ui/settings.py
new file mode 100644
index 0000000..be20e26
--- /dev/null
+++ b/src/ui/settings.py
@@ -0,0 +1,631 @@
+"""
+Settings UI generator and verification dialogs for AI Chat Summaries.
+"""
+
+from typing import Any, Callable, Dict, List, Optional
+
+from ..config import (
+ ANTHROPIC_MODELS,
+ CONTEXT_WINDOWS,
+ DEFAULT_CONTEXT_WINDOW,
+ DEFAULT_MODEL_ANTHROPIC,
+ DEFAULT_MODEL_CUSTOM,
+ DEFAULT_MODEL_GEMINI,
+ DEFAULT_MODEL_OLLAMA,
+ DEFAULT_MODEL_OPENAI,
+ DEFAULT_MSG_COUNT,
+ GEMINI_MODELS,
+ LANGS,
+ OAUTH_FREE_MODELS,
+ OAUTH_PAID_MODELS,
+ OAUTH_TIER_FREE,
+ OLLAMA_DEFAULT_ENDPOINT,
+ OPENAI_MODELS,
+ PROVIDER_ANTHROPIC,
+ PROVIDER_CHATGPT_OAUTH,
+ PROVIDER_CUSTOM,
+ PROVIDER_GEMINI,
+ PROVIDER_OLLAMA,
+ PROVIDER_OPENAI,
+ PROVIDERS,
+ SETTING_ANTHROPIC_API_KEY,
+ SETTING_ANTHROPIC_MODEL,
+ SETTING_CUSTOM_API_KEY,
+ SETTING_CUSTOM_BASE_URL,
+ SETTING_CUSTOM_CONTEXT_WINDOW,
+ SETTING_CUSTOM_MODEL,
+ SETTING_DEFAULT_COUNT,
+ SETTING_ENABLE_PINNED_TRIGGER,
+ SETTING_ENABLE_THINKING_STREAM,
+ SETTING_ENABLE_UNREAD_LONGPRESS,
+ SETTING_GEMINI_API_KEY,
+ SETTING_GEMINI_MODEL,
+ SETTING_GLOBAL_PROMPT,
+ SETTING_OAUTH_ACCESS_TOKEN,
+ SETTING_OAUTH_MODEL,
+ SETTING_OAUTH_USER_EMAIL,
+ SETTING_OAUTH_USER_TIER,
+ SETTING_OLLAMA_ENDPOINT,
+ SETTING_OLLAMA_MODEL,
+ SETTING_OPENAI_API_KEY,
+ SETTING_OPENAI_MODEL,
+ SETTING_PROVIDER,
+ SETTING_SUMMARY_LANG,
+ SETTING_SUMMARY_STYLE,
+ STYLES,
+)
+from ..localization import locali
+
+
+def build_settings_layout(plugin: Any) -> List[Any]:
+ """Constructs dynamic exteraGram settings list with provider-specific configuration panels."""
+ try:
+ from ui.settings import Divider, Header, Input, Selector, Switch, Text
+ except ImportError:
+ # For mock / test environments
+ class Header:
+ def __init__(self, text: str = "", **kwargs: Any): self.text = text
+ class Divider:
+ def __init__(self, **kwargs: Any): pass
+ class Switch:
+ def __init__(self, key: str = "", text: str = "", subtext: str = "", default: bool = True, icon: str = "", on_change: Any = None, **kwargs: Any): pass
+ class Selector:
+ def __init__(self, key: str = "", text: str = "", items: Optional[List[str]] = None, default: int = 0, icon: str = "", on_change: Any = None, **kwargs: Any): pass
+ class Input:
+ def __init__(self, key: str = "", text: str = "", subtext: str = "", default: str = "", icon: str = "", on_change: Any = None, **kwargs: Any): pass
+ class Text:
+ def __init__(self, text: str = "", subtext: str = "", icon: str = "", on_click: Any = None, **kwargs: Any): pass
+
+ items: List[Any] = []
+
+ def _set(key: str, val: Any) -> None:
+ try:
+ plugin.set_setting(key, val, reload_settings=True)
+ except Exception:
+ plugin.set_setting(key, val)
+
+ # 1. General & Triggers Header
+ items.append(Header(text=locali.get("SETTINGS_HEADER_GENERAL")))
+ items.append(
+ Switch(
+ key=SETTING_ENABLE_PINNED_TRIGGER,
+ text=locali.get("SETTINGS_ENABLE_PINNED"),
+ subtext=locali.get("SETTINGS_ENABLE_PINNED_SUB"),
+ icon="msg_pin_code",
+ default=bool(plugin.get_setting(SETTING_ENABLE_PINNED_TRIGGER, True)),
+ on_change=lambda v: _set(SETTING_ENABLE_PINNED_TRIGGER, v),
+ )
+ )
+ items.append(
+ Switch(
+ key=SETTING_ENABLE_UNREAD_LONGPRESS,
+ text=locali.get("SETTINGS_ENABLE_UNREAD"),
+ subtext=locali.get("SETTINGS_ENABLE_UNREAD_SUB"),
+ icon="msg_message",
+ default=bool(plugin.get_setting(SETTING_ENABLE_UNREAD_LONGPRESS, True)),
+ on_change=lambda v: _set(SETTING_ENABLE_UNREAD_LONGPRESS, v),
+ )
+ )
+ items.append(
+ Switch(
+ key=SETTING_ENABLE_THINKING_STREAM,
+ text=locali.get("SETTINGS_ENABLE_THINKING"),
+ subtext=locali.get("SETTINGS_ENABLE_THINKING_SUB"),
+ icon="msg_bot",
+ default=bool(plugin.get_setting(SETTING_ENABLE_THINKING_STREAM, True)),
+ on_change=lambda v: _set(SETTING_ENABLE_THINKING_STREAM, v),
+ )
+ )
+
+ items.append(Divider())
+
+ # 2. Active Provider Selector
+ items.append(Header(text=locali.get("SETTINGS_HEADER_PROVIDER")))
+
+ current_provider = int(plugin.get_setting(SETTING_PROVIDER, PROVIDER_CHATGPT_OAUTH) or 0)
+ items.append(
+ Selector(
+ key=SETTING_PROVIDER,
+ text=locali.get("SETTINGS_PROVIDER_SELECTOR"),
+ items=PROVIDERS,
+ icon="msg_bot",
+ default=current_provider,
+ on_change=lambda idx: _set(SETTING_PROVIDER, idx),
+ )
+ )
+
+ # 3. Provider Specific Panels
+ if current_provider == PROVIDER_CHATGPT_OAUTH:
+ items.append(Header(text=locali.get("OAUTH_HEADER")))
+
+ is_connected = plugin.oauth_handler.is_connected()
+ email = plugin.get_setting(SETTING_OAUTH_USER_EMAIL, "") or "Account"
+ tier = plugin.get_setting(SETTING_OAUTH_USER_TIER, OAUTH_TIER_FREE) or OAUTH_TIER_FREE
+
+ if is_connected:
+ status_text = locali.get("OAUTH_CONNECTED_STATUS", email=email, tier=tier.upper())
+ items.append(Text(text=status_text, icon="msg_info"))
+
+ # Model selector depending on tier
+ available_models = plugin.oauth_handler.get_tier_models()
+ current_model = plugin.oauth_handler.get_active_model()
+ default_idx = available_models.index(current_model) if current_model in available_models else 0
+
+ items.append(
+ Selector(
+ key=SETTING_OAUTH_MODEL,
+ text=locali.get("OAUTH_MODEL_SELECTOR"),
+ items=available_models,
+ icon="msg_settings",
+ default=default_idx,
+ on_change=lambda idx: _set(SETTING_OAUTH_MODEL, available_models[idx]),
+ )
+ )
+ items.append(
+ Text(
+ text=locali.get("OAUTH_BTN_DISCONNECT"),
+ icon="msg_report",
+ on_click=lambda _: _handle_disconnect(plugin),
+ )
+ )
+ else:
+ items.append(Text(text=locali.get("OAUTH_DISCONNECTED_STATUS"), icon="msg_pin_code"))
+ items.append(
+ Text(
+ text=locali.get("OAUTH_BTN_CONNECT"),
+ icon="msg_link",
+ on_click=lambda _: show_oauth_login_dialog(plugin),
+ )
+ )
+
+ elif current_provider == PROVIDER_CUSTOM:
+ items.append(Header(text=locali.get("CUSTOM_HEADER")))
+ items.append(
+ Input(
+ key=SETTING_CUSTOM_BASE_URL,
+ text=locali.get("CUSTOM_URL_INPUT"),
+ subtext=locali.get("CUSTOM_URL_SUB"),
+ icon="msg_link",
+ default=plugin.get_setting(SETTING_CUSTOM_BASE_URL, "https://api.deepseek.com/v1"),
+ on_change=lambda val: _set(SETTING_CUSTOM_BASE_URL, val),
+ )
+ )
+ items.append(
+ Input(
+ key=SETTING_CUSTOM_API_KEY,
+ text=locali.get("CUSTOM_KEY_INPUT"),
+ subtext=locali.get("CUSTOM_KEY_SUB"),
+ icon="msg_pin_code",
+ default=plugin.get_setting(SETTING_CUSTOM_API_KEY, ""),
+ on_change=lambda val: _set(SETTING_CUSTOM_API_KEY, val),
+ )
+ )
+ items.append(
+ Input(
+ key=SETTING_CUSTOM_MODEL,
+ text=locali.get("CUSTOM_MODEL_INPUT"),
+ subtext=locali.get("CUSTOM_MODEL_SUB"),
+ icon="msg_settings",
+ default=plugin.get_setting(SETTING_CUSTOM_MODEL, DEFAULT_MODEL_CUSTOM),
+ on_change=lambda val: _set(SETTING_CUSTOM_MODEL, val),
+ )
+ )
+
+ current_cw = plugin.get_setting(SETTING_CUSTOM_CONTEXT_WINDOW, DEFAULT_CONTEXT_WINDOW)
+ cw_idx = CONTEXT_WINDOWS.index(current_cw) if current_cw in CONTEXT_WINDOWS else 3
+ items.append(
+ Selector(
+ key=SETTING_CUSTOM_CONTEXT_WINDOW,
+ text=locali.get("CUSTOM_CONTEXT_SELECTOR"),
+ items=CONTEXT_WINDOWS,
+ icon="msg_list",
+ default=cw_idx,
+ on_change=lambda idx: _set(SETTING_CUSTOM_CONTEXT_WINDOW, CONTEXT_WINDOWS[idx]),
+ )
+ )
+ items.append(
+ Text(
+ text=locali.get("CUSTOM_TEST_BTN"),
+ icon="msg_invite",
+ on_click=lambda _: show_custom_verification_dialog(plugin),
+ )
+ )
+
+ elif current_provider == PROVIDER_OPENAI:
+ items.append(Header(text=locali.get("OPENAI_HEADER")))
+ items.append(
+ Input(
+ key=SETTING_OPENAI_API_KEY,
+ text=locali.get("OPENAI_KEY_INPUT"),
+ subtext="sk-...",
+ icon="msg_pin_code",
+ default=plugin.get_setting(SETTING_OPENAI_API_KEY, ""),
+ on_change=lambda val: _set(SETTING_OPENAI_API_KEY, val),
+ )
+ )
+ cur_m = plugin.get_setting(SETTING_OPENAI_MODEL, DEFAULT_MODEL_OPENAI)
+ m_idx = OPENAI_MODELS.index(cur_m) if cur_m in OPENAI_MODELS else 0
+ items.append(
+ Selector(
+ key=SETTING_OPENAI_MODEL,
+ text=locali.get("OPENAI_MODEL_SELECTOR"),
+ items=OPENAI_MODELS,
+ icon="msg_settings",
+ default=m_idx,
+ on_change=lambda idx: _set(SETTING_OPENAI_MODEL, OPENAI_MODELS[idx]),
+ )
+ )
+
+ elif current_provider == PROVIDER_ANTHROPIC:
+ items.append(Header(text=locali.get("ANTHROPIC_HEADER")))
+ items.append(
+ Input(
+ key=SETTING_ANTHROPIC_API_KEY,
+ text=locali.get("ANTHROPIC_KEY_INPUT"),
+ subtext="sk-ant-...",
+ icon="msg_pin_code",
+ default=plugin.get_setting(SETTING_ANTHROPIC_API_KEY, ""),
+ on_change=lambda val: _set(SETTING_ANTHROPIC_API_KEY, val),
+ )
+ )
+ cur_m = plugin.get_setting(SETTING_ANTHROPIC_MODEL, DEFAULT_MODEL_ANTHROPIC)
+ m_idx = ANTHROPIC_MODELS.index(cur_m) if cur_m in ANTHROPIC_MODELS else 0
+ items.append(
+ Selector(
+ key=SETTING_ANTHROPIC_MODEL,
+ text=locali.get("ANTHROPIC_MODEL_SELECTOR"),
+ items=ANTHROPIC_MODELS,
+ icon="msg_settings",
+ default=m_idx,
+ on_change=lambda idx: _set(SETTING_ANTHROPIC_MODEL, ANTHROPIC_MODELS[idx]),
+ )
+ )
+
+ elif current_provider == PROVIDER_GEMINI:
+ items.append(Header(text=locali.get("GEMINI_HEADER")))
+ items.append(
+ Input(
+ key=SETTING_GEMINI_API_KEY,
+ text=locali.get("GEMINI_KEY_INPUT"),
+ subtext="AIza...",
+ icon="msg_pin_code",
+ default=plugin.get_setting(SETTING_GEMINI_API_KEY, ""),
+ on_change=lambda val: _set(SETTING_GEMINI_API_KEY, val),
+ )
+ )
+ cur_m = plugin.get_setting(SETTING_GEMINI_MODEL, DEFAULT_MODEL_GEMINI)
+ m_idx = GEMINI_MODELS.index(cur_m) if cur_m in GEMINI_MODELS else 0
+ items.append(
+ Selector(
+ key=SETTING_GEMINI_MODEL,
+ text=locali.get("GEMINI_MODEL_SELECTOR"),
+ items=GEMINI_MODELS,
+ icon="msg_settings",
+ default=m_idx,
+ on_change=lambda idx: _set(SETTING_GEMINI_MODEL, GEMINI_MODELS[idx]),
+ )
+ )
+
+ elif current_provider == PROVIDER_OLLAMA:
+ items.append(Header(text=locali.get("OLLAMA_HEADER")))
+ items.append(
+ Input(
+ key=SETTING_OLLAMA_ENDPOINT,
+ text=locali.get("OLLAMA_URL_INPUT"),
+ subtext="http://localhost:11434/api/chat",
+ icon="msg_link",
+ default=plugin.get_setting(SETTING_OLLAMA_ENDPOINT, OLLAMA_DEFAULT_ENDPOINT),
+ on_change=lambda val: _set(SETTING_OLLAMA_ENDPOINT, val),
+ )
+ )
+ items.append(
+ Input(
+ key=SETTING_OLLAMA_MODEL,
+ text=locali.get("OLLAMA_MODEL_INPUT"),
+ subtext="llama3.3, qwen2.5",
+ icon="msg_settings",
+ default=plugin.get_setting(SETTING_OLLAMA_MODEL, DEFAULT_MODEL_OLLAMA),
+ on_change=lambda val: _set(SETTING_OLLAMA_MODEL, val),
+ )
+ )
+
+ items.append(Divider())
+
+ # 4. Summary Preferences Header
+ items.append(Header(text=locali.get("SETTINGS_HEADER_DEFAULTS")))
+
+ cur_style = int(plugin.get_setting(SETTING_SUMMARY_STYLE, 0) or 0)
+ items.append(
+ Selector(
+ key=SETTING_SUMMARY_STYLE,
+ text=locali.get("SETTINGS_DEFAULT_STYLE"),
+ items=STYLES,
+ icon="msg_list",
+ default=cur_style,
+ on_change=lambda idx: _set(SETTING_SUMMARY_STYLE, idx),
+ )
+ )
+
+ cur_lang = int(plugin.get_setting(SETTING_SUMMARY_LANG, 0) or 0)
+ items.append(
+ Selector(
+ key=SETTING_SUMMARY_LANG,
+ text=locali.get("SETTINGS_DEFAULT_LANG"),
+ items=LANGS,
+ icon="msg_translate",
+ default=cur_lang,
+ on_change=lambda idx: _set(SETTING_SUMMARY_LANG, idx),
+ )
+ )
+
+ items.append(
+ Input(
+ key=SETTING_DEFAULT_COUNT,
+ text=locali.get("SETTINGS_DEFAULT_COUNT"),
+ subtext=locali.get("SETTINGS_DEFAULT_COUNT_SUB"),
+ icon="msg_list",
+ default=str(plugin.get_setting(SETTING_DEFAULT_COUNT, DEFAULT_MSG_COUNT)),
+ on_change=lambda val: _set(SETTING_DEFAULT_COUNT, val),
+ )
+ )
+
+ items.append(
+ Input(
+ key=SETTING_GLOBAL_PROMPT,
+ text=locali.get("SETTINGS_GLOBAL_PROMPT"),
+ subtext=locali.get("SETTINGS_GLOBAL_PROMPT_SUB"),
+ icon="msg_edit",
+ default=plugin.get_setting(SETTING_GLOBAL_PROMPT, ""),
+ on_change=lambda val: _set(SETTING_GLOBAL_PROMPT, val),
+ )
+ )
+
+ # 5. Diagnostics: makes on-device hook failures inspectable instead of silent.
+ items.append(Divider())
+ items.append(Header(text=locali.get("SETTINGS_HEADER_DIAGNOSTICS")))
+ items.append(
+ Text(
+ text=locali.get("SETTINGS_COPY_ERRORS"),
+ subtext=locali.get("SETTINGS_COPY_ERRORS_SUB"),
+ icon="msg_report",
+ on_click=lambda _: _copy_diagnostic_payload("errors"),
+ )
+ )
+ items.append(
+ Text(
+ text=locali.get("SETTINGS_COPY_LOGS"),
+ subtext=locali.get("SETTINGS_COPY_LOGS_SUB"),
+ icon="msg_data",
+ on_click=lambda _: _copy_diagnostic_payload("logs"),
+ )
+ )
+ items.append(
+ Text(
+ text=locali.get("SETTINGS_COPY_DIAGNOSTICS"),
+ subtext=locali.get("SETTINGS_COPY_DIAGNOSTICS_SUB"),
+ icon="msg_info",
+ on_click=lambda _: _copy_diagnostic_payload("full"),
+ )
+ )
+
+ return items
+
+
+def _copy_diagnostic_payload(kind: str) -> None:
+ """Copies latest errors, latest logs, or the complete diagnostics report."""
+ try:
+ from ..diagnostics import build_errors_report, build_logs_report, build_report, record_error
+ if kind == "errors":
+ payload = build_errors_report()
+ success_key = "SETTINGS_ERRORS_COPIED"
+ elif kind == "logs":
+ payload = build_logs_report()
+ success_key = "SETTINGS_LOGS_COPIED"
+ else:
+ payload = build_report()
+ success_key = "SETTINGS_DIAGNOSTICS_COPIED"
+ except Exception as exc:
+ payload = f"Failed to build {kind} report: {type(exc).__name__}: {exc}"
+
+ try:
+ from android_utils import copy_to_clipboard
+ from ui.bulletin import BulletinHelper
+ copy_to_clipboard(payload)
+ BulletinHelper.show_success(locali.get(success_key))
+ except Exception as exc:
+ try:
+ from ..diagnostics import record_error
+ record_error(f"settings.copy_{kind}", exc)
+ except Exception:
+ pass
+
+
+def _handle_disconnect(plugin: Any) -> None:
+ plugin.oauth_handler.disconnect_account()
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_success("ChatGPT account disconnected.")
+ except Exception:
+ pass
+ try:
+ plugin.set_setting(SETTING_PROVIDER, PROVIDER_CHATGPT_OAUTH, reload_settings=True)
+ except Exception:
+ pass
+
+
+# ==================== OAuth Login Dialog ====================
+
+def show_oauth_login_dialog(plugin: Any) -> None:
+ """Launches OAuth flow in browser and presents fallback code input dialog."""
+ url, verifier, state = plugin.oauth_handler.generate_auth_url()
+
+ launched = False
+ try:
+ from client_utils import get_last_fragment
+ from org.telegram.messenger import AndroidUtilities
+ frag = get_last_fragment()
+ act = frag.getParentActivity() if frag else None
+ if act:
+ AndroidUtilities.openUrlInExternalBrowser(act, url, False)
+ launched = True
+ except Exception:
+ pass
+
+ try:
+ from client_utils import get_last_fragment
+ from ui.alert import AlertDialogBuilder
+ from android_utils import copy_to_clipboard, OnClickListener, run_on_ui_thread
+ from org.telegram.ui.Components import EditTextBoldCursor
+ from android.widget import LinearLayout, TextView, Button
+ from org.telegram.messenger import AndroidUtilities
+
+ frag = get_last_fragment()
+ act = frag.getParentActivity() if frag else None
+ if not act:
+ return
+
+ def dp(val: float) -> int:
+ return AndroidUtilities.dp(val)
+
+ builder = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_MESSAGE)
+ builder.set_title(locali.get("OAUTH_LOGIN_TITLE"))
+
+ layout = LinearLayout(act)
+ layout.setOrientation(LinearLayout.VERTICAL)
+ layout.setPadding(dp(20), dp(10), dp(20), dp(10))
+
+ info_tv = TextView(act)
+ info_tv.setText(locali.get("OAUTH_LOGIN_MSG"))
+ layout.addView(info_tv)
+
+ copy_btn = Button(act)
+ copy_btn.setText(locali.get("OAUTH_COPY_LINK"))
+
+ def on_copy_click(*_):
+ copy_to_clipboard(url)
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_success("Login URL copied!")
+ except Exception:
+ pass
+
+ copy_btn.setOnClickListener(OnClickListener(on_copy_click))
+ layout.addView(copy_btn)
+
+ input_field = EditTextBoldCursor(act)
+ input_field.setHint(locali.get("OAUTH_PASTE_CODE_HINT"))
+ layout.addView(input_field)
+
+ builder.set_view(layout)
+
+ def on_submit(dialog, _):
+ code_text = str(input_field.getText()).strip()
+ if not code_text:
+ return
+
+ def _do_exchange():
+ success, data, err = plugin.oauth_handler.exchange_code_for_tokens(code_text, verifier)
+
+ def _ui_done():
+ if success and data:
+ try:
+ from ui.bulletin import BulletinHelper
+ email = data.get("email", "Account")
+ tier = data.get("tier", "FREE").upper()
+ BulletinHelper.show_success(locali.get("OAUTH_SUCCESS", email=email, tier=tier))
+ except Exception:
+ pass
+ try:
+ plugin.set_setting(SETTING_PROVIDER, PROVIDER_CHATGPT_OAUTH, reload_settings=True)
+ except Exception:
+ pass
+ else:
+ from ..ui.summary_dialog import show_error_dialog
+ show_error_dialog(
+ title=locali.get("OAUTH_ERROR", error="Exchange failed"),
+ error_text=err,
+ debug_log=err,
+ )
+ run_on_ui_thread(_ui_done)
+
+ from client_utils import PLUGINS_QUEUE, run_on_queue
+ run_on_queue(_do_exchange, PLUGINS_QUEUE)
+ dialog.dismiss()
+
+ builder.set_positive_button(locali.get("OAUTH_SUBMIT_CODE"), on_submit)
+ builder.set_negative_button(locali.get("PRE_REQ_BTN_CANCEL"), lambda d, _: d.dismiss())
+ builder.show()
+
+ except Exception as e:
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_error(f"Cannot open login dialog: {e}")
+ except Exception:
+ pass
+
+
+# ==================== Custom Provider Verification Dialog ====================
+
+def show_custom_verification_dialog(plugin: Any) -> None:
+ """Executes Stage 1 & Stage 2 verification on custom provider endpoint and shows detailed results."""
+ base_url = plugin.get_setting(SETTING_CUSTOM_BASE_URL, "")
+ api_key = plugin.get_setting(SETTING_CUSTOM_API_KEY, "")
+ model_name = plugin.get_setting(SETTING_CUSTOM_MODEL, DEFAULT_MODEL_CUSTOM)
+
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_info(locali.get("CUSTOM_TESTING_STAGE1"))
+ except Exception:
+ pass
+
+ def _run_verification():
+ # Stage 1: URL Reachability
+ ok1, msg1 = plugin.custom_handler.verify_stage1_reachability(base_url)
+ if not ok1:
+ def _show_fail1():
+ from .summary_dialog import show_error_dialog
+ show_error_dialog(
+ title=locali.get("CUSTOM_TEST_FAIL_TITLE", error="Stage 1 Reachability"),
+ error_text=msg1,
+ debug_log=f"Custom Endpoint Reachability Test\\nBase URL: {base_url}\\nError: {msg1}",
+ )
+ try:
+ from android_utils import run_on_ui_thread
+ run_on_ui_thread(_show_fail1)
+ except Exception:
+ pass
+ return
+
+ # Stage 2: Probe Test
+ ok2, msg2, debug_log, elapsed = plugin.custom_handler.verify_stage2_probe_test(
+ base_url=base_url,
+ api_key=api_key,
+ model_name=model_name,
+ )
+
+ def _show_result2():
+ if ok2:
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_success(msg2)
+ except Exception:
+ pass
+ else:
+ from .summary_dialog import show_error_dialog
+ show_error_dialog(
+ title=locali.get("CUSTOM_TEST_FAIL_TITLE", error="Stage 2 Probe"),
+ error_text=msg2,
+ debug_log=debug_log or msg2,
+ )
+
+ try:
+ from android_utils import run_on_ui_thread
+ run_on_ui_thread(_show_result2)
+ except Exception:
+ pass
+
+ try:
+ from client_utils import PLUGINS_QUEUE, run_on_queue
+ run_on_queue(_run_verification, PLUGINS_QUEUE)
+ except Exception:
+ _run_verification()
diff --git a/src/ui/summary_dialog.py b/src/ui/summary_dialog.py
new file mode 100644
index 0000000..2e57748
--- /dev/null
+++ b/src/ui/summary_dialog.py
@@ -0,0 +1,282 @@
+"""
+Summary Result Dialog and Error Dialog — fully theme-aware.
+"""
+
+from typing import Any, Optional
+
+from ..config import LONG_SUMMARY_THRESHOLD
+from ..localization import locali
+
+
+def _theme_color(key: str, fallback: int) -> int:
+ try:
+ from org.telegram.ui.ActionBar import Theme
+ return Theme.getColor(getattr(Theme, key))
+ except Exception:
+ return fallback
+
+
+def scroll_to_message_in_chat(chat_activity: Any, message_id: int, topic_id: int = 0) -> bool:
+ """Scrolls the chat view to a specific message ID."""
+ if not chat_activity or not message_id:
+ return False
+ try:
+ from android_utils import run_on_ui_thread
+ except ImportError:
+ def run_on_ui_thread(fn):
+ fn()
+
+ try:
+ def _do_scroll():
+ for method_name in ("scrollToMessageId", "jumpToMessageId", "scrollToMessage"):
+ if hasattr(chat_activity, method_name):
+ fn = getattr(chat_activity, method_name)
+ try:
+ fn(message_id, topic_id, True, 0, False, 0)
+ return
+ except Exception:
+ try:
+ fn(message_id, topic_id, True)
+ return
+ except Exception:
+ try:
+ fn(message_id, True)
+ return
+ except Exception:
+ pass
+
+ chat_list = getattr(chat_activity, "chatListView", None)
+ if chat_list and hasattr(chat_list, "scrollToPosition"):
+ try:
+ chat_list.scrollToPosition(0)
+ except Exception:
+ pass
+
+ run_on_ui_thread(_do_scroll)
+ return True
+ except Exception:
+ return False
+
+
+def show_summary_result_dialog(
+ plugin: Any,
+ chat_activity: Any,
+ summary_text: str,
+ provider_name: str,
+ model_name: str,
+ message_count: int,
+ elapsed_time: float,
+ dialog_id: int = 0,
+ topic_id: int = 0,
+ full_debug_data: str = "",
+ latest_msg_id: int = 0,
+ auto_jump: bool = False,
+) -> None:
+ """Renders formatted Markdown summary with theme-aware styling."""
+ try:
+ from android.content.res import ColorStateList
+ from android.util import TypedValue
+ from android.view import Gravity
+ from android.widget import Button, LinearLayout, ScrollView, TextView
+ from android_utils import OnClickListener, copy_to_clipboard, run_on_ui_thread
+ from client_utils import get_last_fragment
+ from markdown_utils import parse_markdown
+ from org.telegram.messenger import AndroidUtilities
+ from ui.alert import AlertDialogBuilder
+
+ frag = chat_activity or get_last_fragment()
+ act = frag.getParentActivity() if hasattr(frag, "getParentActivity") else None
+ if not act:
+ return
+
+ def dp(val: float) -> int:
+ return AndroidUtilities.dp(val)
+
+ dialog_bg = _theme_color("key_dialogBackground", -15395563)
+ primary = _theme_color("key_dialogTextBlack", -14606047)
+ secondary = _theme_color("key_dialogTextGray3", -7829368)
+ accent = _theme_color("key_dialogTextBlue2", _theme_color("key_windowBackgroundWhiteBlueText", -14575885))
+ control_bg = _theme_color("key_dialogGrayLine", _theme_color("key_windowBackgroundWhite", dialog_bg))
+
+ if auto_jump and latest_msg_id:
+ scroll_to_message_in_chat(frag, latest_msg_id, topic_id)
+
+ builder = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_MESSAGE)
+ builder.set_title(locali.get("RESULT_TITLE"))
+
+ root = LinearLayout(act)
+ root.setOrientation(LinearLayout.VERTICAL)
+ root.setPadding(dp(20), dp(10), dp(20), dp(10))
+ root.setBackgroundColor(dialog_bg)
+
+ header = TextView(act)
+ header.setText(locali.get(
+ "RESULT_HEADER_INFO",
+ provider=provider_name,
+ model=model_name,
+ count=message_count,
+ time=elapsed_time,
+ ))
+ header.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
+ header.setTextColor(secondary)
+ header.setPadding(0, 0, 0, dp(6))
+ root.addView(header)
+
+ scroll = ScrollView(act)
+ body = TextView(act)
+ try:
+ body.setText(parse_markdown(summary_text))
+ except Exception:
+ body.setText(summary_text)
+ body.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14)
+ body.setTextColor(primary)
+ body.setLineSpacing(dp(2), 1.0)
+ scroll.addView(body)
+
+ lp_scroll = LinearLayout.LayoutParams(-1, -2)
+ if len(summary_text) > LONG_SUMMARY_THRESHOLD:
+ lp_scroll = LinearLayout.LayoutParams(-1, dp(340))
+ root.addView(scroll, lp_scroll)
+
+ if len(summary_text) > LONG_SUMMARY_THRESHOLD:
+ hint = TextView(act)
+ hint.setText(locali.get("RESULT_SCROLL_HINT"))
+ hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
+ hint.setGravity(Gravity.CENTER)
+ hint.setTextColor(secondary)
+ hint.setPadding(0, dp(4), 0, dp(4))
+ root.addView(hint)
+
+ actions = LinearLayout(act)
+ actions.setOrientation(LinearLayout.HORIZONTAL)
+ actions.setPadding(0, dp(10), 0, 0)
+
+ def add_action(text: str, callback):
+ btn = Button(act)
+ btn.setText(text)
+ btn.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
+ btn.setTextColor(accent)
+ btn.setBackgroundTintList(ColorStateList.valueOf(control_bg))
+ btn.setOnClickListener(OnClickListener(callback))
+ actions.addView(btn, LinearLayout.LayoutParams(0, dp(40), 1.0))
+
+ add_action(locali.get("RESULT_BTN_SHARE"), lambda *_: _share(act, summary_text))
+
+ if latest_msg_id:
+ add_action(locali.get("RESULT_BTN_JUMP"), lambda *_: _jump(frag, latest_msg_id, topic_id))
+
+ if full_debug_data:
+ add_action(locali.get("RESULT_BTN_COPY_DATA"), lambda *_: _copy(full_debug_data, "RESULT_DATA_COPIED_NOTICE"))
+
+ root.addView(actions)
+ builder.set_view(root)
+
+ def on_copy(dialog, _):
+ copy_to_clipboard(summary_text)
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_success(locali.get("RESULT_COPIED_NOTICE"))
+ except Exception:
+ pass
+ dialog.dismiss()
+
+ builder.set_positive_button(locali.get("RESULT_BTN_COPY"), on_copy)
+ builder.set_negative_button("Close", lambda d, _: d.dismiss())
+ builder.show()
+
+ except Exception:
+ pass
+
+
+def _share(act: Any, text: str) -> None:
+ try:
+ from android.content import Intent
+ intent = Intent(Intent.ACTION_SEND)
+ intent.setType("text/plain")
+ intent.putExtra(Intent.EXTRA_TEXT, text)
+ act.startActivity(Intent.createChooser(intent, "Share Summary"))
+ except Exception:
+ pass
+
+
+def _jump(frag: Any, msg_id: int, topic_id: int) -> None:
+ scroll_to_message_in_chat(frag, msg_id, topic_id)
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_info(locali.get("RESULT_JUMPED_NOTICE", id=msg_id))
+ except Exception:
+ pass
+
+
+def _copy(text: str, notice_key: str) -> None:
+ try:
+ from android_utils import copy_to_clipboard
+ from ui.bulletin import BulletinHelper
+ copy_to_clipboard(text)
+ BulletinHelper.show_success(locali.get(notice_key))
+ except Exception:
+ pass
+
+
+def show_error_dialog(title: str, error_text: str, debug_log: str = "") -> None:
+ """Displays a theme-aware error dialog with a log-copy button."""
+ try:
+ from android.content.res import ColorStateList
+ from android.util import TypedValue
+ from android.widget import Button, LinearLayout, TextView
+ from android_utils import OnClickListener, copy_to_clipboard
+ from client_utils import get_last_fragment
+ from org.telegram.messenger import AndroidUtilities
+ from ui.alert import AlertDialogBuilder
+
+ frag = get_last_fragment()
+ act = frag.getParentActivity() if frag else None
+ if not act:
+ return
+
+ def dp(val: float) -> int:
+ return AndroidUtilities.dp(val)
+
+ dialog_bg = _theme_color("key_dialogBackground", -15395563)
+ primary = _theme_color("key_dialogTextBlack", -14606047)
+ danger = _theme_color("key_text_RedBold", -2937041)
+ control_bg = _theme_color("key_dialogGrayLine", _theme_color("key_windowBackgroundWhite", dialog_bg))
+
+ builder = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_MESSAGE)
+ builder.set_title(title or locali.get("ERROR_TITLE"))
+
+ root = LinearLayout(act)
+ root.setOrientation(LinearLayout.VERTICAL)
+ root.setPadding(dp(20), dp(10), dp(20), dp(10))
+ root.setBackgroundColor(dialog_bg)
+
+ err = TextView(act)
+ err.setText(error_text)
+ err.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14)
+ err.setTextColor(danger)
+ root.addView(err)
+
+ if debug_log:
+ btn = Button(act)
+ btn.setText(locali.get("ERROR_COPY_LOG_BTN"))
+ btn.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
+ btn.setTextColor(primary)
+ btn.setBackgroundTintList(ColorStateList.valueOf(control_bg))
+
+ def on_copy(*_):
+ copy_to_clipboard(debug_log)
+ try:
+ from ui.bulletin import BulletinHelper
+ BulletinHelper.show_success(locali.get("ERROR_LOG_COPIED"))
+ except Exception:
+ pass
+
+ btn.setOnClickListener(OnClickListener(on_copy))
+ root.addView(btn)
+
+ builder.set_view(root)
+ builder.set_positive_button("Close", lambda d, _: d.dismiss())
+ builder.show()
+
+ except Exception:
+ pass
diff --git a/src/ui/thinking_sheet.py b/src/ui/thinking_sheet.py
new file mode 100644
index 0000000..0b4dd92
--- /dev/null
+++ b/src/ui/thinking_sheet.py
@@ -0,0 +1,140 @@
+"""
+Real-time streaming Thinking & Reasoning Bottom Sheet viewer.
+"""
+
+from typing import Any
+
+from ..localization import locali
+
+
+def show_thinking_bottom_sheet(plugin: Any, chat_activity: Any) -> None:
+ """Displays a theme-aware live thinking and drafting sheet."""
+ try:
+ from org.telegram.messenger import AndroidUtilities
+ from org.telegram.ui.ActionBar import Theme
+ from ui.alert import AlertDialogBuilder
+ from android.widget import LinearLayout, TextView, ScrollView
+ from android.util import TypedValue
+ from android_utils import run_on_ui_thread
+
+ act = chat_activity.getParentActivity() if hasattr(chat_activity, "getParentActivity") else None
+ if not act:
+ return
+
+ def dp(val: float) -> int:
+ return AndroidUtilities.dp(val)
+
+ def color(key: str, fallback: int) -> int:
+ try:
+ return Theme.getColor(getattr(Theme, key))
+ except Exception:
+ return fallback
+
+ dialog_bg = color("key_dialogBackground", -15395563)
+ primary = color("key_dialogTextBlack", -14606047)
+ secondary = color("key_dialogTextGray3", -7829368)
+
+ builder = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_MESSAGE)
+ builder.set_title(locali.get("THINKING_TITLE"))
+
+ root = LinearLayout(act)
+ root.setOrientation(LinearLayout.VERTICAL)
+ root.setPadding(dp(20), dp(10), dp(20), dp(10))
+ root.setBackgroundColor(dialog_bg)
+
+ stream_state = plugin.dispatcher.active_stream_state
+ provider_name = stream_state.get("provider", "AI")
+ model_name = stream_state.get("model", "")
+
+ sub_tv = TextView(act)
+ sub_tv.setText(locali.get("THINKING_SUBTITLE", provider=provider_name, model=model_name))
+ sub_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
+ sub_tv.setTextColor(secondary)
+ root.addView(sub_tv)
+
+ sec_thought = TextView(act)
+ sec_thought.setText(locali.get("THINKING_SECTION_THOUGHT"))
+ sec_thought.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13)
+ sec_thought.setTextColor(primary)
+ sec_thought.setPadding(0, dp(10), 0, dp(4))
+ root.addView(sec_thought)
+
+ thought_scroll = ScrollView(act)
+ thought_tv = TextView(act)
+ thought_tv.setText(stream_state.get("thinking") or locali.get("THINKING_WAITING"))
+ thought_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
+ thought_tv.setTextColor(secondary)
+ thought_tv.setLineSpacing(dp(1), 1.0)
+
+ try:
+ from android.graphics.drawable import GradientDrawable
+ thought_bg = GradientDrawable()
+ thought_bg.setCornerRadius(dp(8))
+ # 15% alpha of accent color
+ accent = color("key_windowBackgroundWhiteBlueText", -14575885)
+ thought_bg.setColor((accent & 0x00FFFFFF) | 0x26000000)
+ thought_scroll.setBackground(thought_bg)
+ thought_scroll.setPadding(dp(8), dp(8), dp(8), dp(8))
+ except Exception:
+ pass
+
+ thought_scroll.addView(thought_tv)
+ lp_thought = LinearLayout.LayoutParams(-1, dp(120))
+ root.addView(thought_scroll, lp_thought)
+
+ sec_content = TextView(act)
+ sec_content.setText(locali.get("THINKING_SECTION_OUTPUT"))
+ sec_content.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13)
+ sec_content.setTextColor(primary)
+ sec_content.setPadding(0, dp(10), 0, dp(4))
+ root.addView(sec_content)
+
+ content_scroll = ScrollView(act)
+ content_tv = TextView(act)
+ content_tv.setText(stream_state.get("content") or "...")
+ content_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14)
+ content_tv.setTextColor(primary)
+ content_tv.setLineSpacing(dp(2), 1.0)
+ content_scroll.addView(content_tv)
+
+ lp_content = LinearLayout.LayoutParams(-1, dp(140))
+ root.addView(content_scroll, lp_content)
+
+ builder.set_view(root)
+ builder.set_positive_button(locali.get("THINKING_BTN_CLOSE"), lambda d, _: d.dismiss())
+
+ def on_stream_token(thought_delta: str, content_delta: str) -> None:
+ def _ui_update():
+ try:
+ cur_state = plugin.dispatcher.active_stream_state
+ t_text = cur_state.get("thinking", "")
+ c_text = cur_state.get("content", "")
+
+ if t_text:
+ thought_tv.setText(t_text)
+ thought_scroll.fullScroll(ScrollView.FOCUS_DOWN)
+ if c_text:
+ content_tv.setText(c_text)
+ content_scroll.fullScroll(ScrollView.FOCUS_DOWN)
+ except Exception:
+ pass
+ run_on_ui_thread(_ui_update)
+
+ plugin.dispatcher.add_stream_listener(on_stream_token)
+
+ dialog = builder.show()
+
+ try:
+ from android.content import DialogInterface
+
+ class _DismissListener(DialogInterface.OnDismissListener):
+ def onDismiss(self, d):
+ plugin.dispatcher.remove_stream_listener(on_stream_token)
+
+ if hasattr(dialog, "setOnDismissListener"):
+ dialog.setOnDismissListener(_DismissListener())
+ except Exception:
+ pass
+
+ except Exception:
+ pass
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..b24f456
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,3 @@
+"""
+Test suite package.
+"""
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
new file mode 100644
index 0000000..1ac7dbd
--- /dev/null
+++ b/tests/test_plugin.py
@@ -0,0 +1,694 @@
+"""
+Comprehensive simulation and unit test suite for AI Chat Summaries Plugin.
+"""
+
+import os
+import sys
+
+# Configure sys.path
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if BASE_DIR not in sys.path:
+ sys.path.insert(0, BASE_DIR)
+
+import json
+import unittest
+from unittest.mock import MagicMock, patch
+
+from src.config import (
+ CONTEXT_WINDOW_BUDGETS,
+ DEFAULT_CONTEXT_WINDOW,
+ DEFAULT_MODEL_CUSTOM,
+ DEFAULT_MODEL_OAUTH_FREE,
+ DEFAULT_MODEL_OAUTH_PAID,
+ MAX_SINGLE_MESSAGE_CHARS,
+ OAUTH_FREE_MODELS,
+ OAUTH_PAID_MODELS,
+ OAUTH_TIER_FREE,
+ OAUTH_TIER_PLUS,
+ OAUTH_TIER_PRO,
+ PROVIDER_ANTHROPIC,
+ PROVIDER_CHATGPT_OAUTH,
+ PROVIDER_CUSTOM,
+ PROVIDER_GEMINI,
+ PROVIDER_OLLAMA,
+ PROVIDER_OPENAI,
+ SETTING_ENABLE_THINKING_STREAM,
+ SETTING_OAUTH_ACCESS_TOKEN,
+ SETTING_OAUTH_MODEL,
+ SETTING_OAUTH_USER_EMAIL,
+ SETTING_OAUTH_USER_TIER,
+ SETTING_PROVIDER,
+ STYLE_BRIEF,
+ STYLE_BULLETS,
+ STYLE_CUSTOM,
+ STYLE_DETAILED,
+)
+from src.localization import LocalizationManager, locali
+from src.providers.base import (
+ build_debug_log,
+ build_system_prompt,
+ mask_sensitive_data,
+)
+from src.providers.builtin import (
+ AnthropicDirectHandler,
+ GeminiDirectHandler,
+ OllamaDirectHandler,
+ OpenAIDirectHandler,
+)
+from src.providers.custom import CustomAIHandler
+from src.providers.dispatcher import UnifiedDispatcher
+from src.providers.oauth import ChatGPTOAuthHandler
+from src.services.message_fetcher import FormattedMessage, MessageFetcher
+from src.ui.progress_widget import PinnedProgressManager
+
+
+class MockPlugin:
+ """Mock exteraGram plugin environment for testing."""
+ def __init__(self):
+ self._settings = {}
+ self.oauth_handler = ChatGPTOAuthHandler(self)
+ self.custom_handler = CustomAIHandler(self)
+ self.dispatcher = UnifiedDispatcher(self)
+ self.message_fetcher = MessageFetcher(self)
+ self.progress_manager = PinnedProgressManager(self)
+
+ def get_setting(self, key, default=None):
+ return self._settings.get(key, default)
+
+ def set_setting(self, key, value):
+ self._settings[key] = value
+
+ def hook_method(self, target, hook):
+ return "mock_hook_ref"
+
+ def unhook_method(self, ref):
+ pass
+
+class TestPluginMetadata(unittest.TestCase):
+ """Validates that plugin metadata contains non-empty id, name, and standard attributes."""
+
+ def test_config_metadata(self):
+ from src import config
+ self.assertTrue(bool(config.__id__))
+ self.assertTrue(bool(config.__name__))
+ self.assertTrue(bool(config.id))
+ self.assertTrue(bool(config.name))
+ self.assertEqual(config.__id__, "ai_chat_summaries")
+ self.assertEqual(config.__name__, "AI Chat Summaries")
+
+ def test_plugin_entrypoint_metadata(self):
+ from importlib.machinery import SourceFileLoader
+ plugin_path = os.path.join(BASE_DIR, "ai_chat_summary.plugin")
+ mod = SourceFileLoader("ai_chat_summaries_bundle", plugin_path).load_module()
+ self.assertTrue(bool(getattr(mod, "__id__", None)))
+ self.assertTrue(bool(getattr(mod, "__name__", None)))
+ self.assertTrue(bool(getattr(mod, "id", None)))
+ self.assertTrue(bool(getattr(mod, "name", None)))
+ self.assertEqual(mod.__id__, "ai_chat_summaries")
+ self.assertEqual(mod.__name__, "AI Chat Summaries")
+
+ def test_plugin_lifecycle_and_settings(self):
+ from importlib.machinery import SourceFileLoader
+ plugin_path = os.path.join(BASE_DIR, "ai_chat_summary.plugin")
+ mod = SourceFileLoader("ai_chat_summaries_bundle", plugin_path).load_module()
+ plugin = mod.AIChatSummariesPlugin()
+
+ # exteraGram invokes on_plugin_load / on_plugin_unload
+ self.assertTrue(hasattr(plugin, "on_plugin_load"))
+ self.assertTrue(hasattr(plugin, "on_plugin_unload"))
+ self.assertTrue(hasattr(plugin, "on_menu_click"))
+ self.assertTrue(plugin.has_settings())
+
+ plugin.on_plugin_load()
+ self.assertTrue(len(plugin.create_settings()) > 0)
+ plugin.on_plugin_unload()
+
+
+
+class TestDiagnostics(unittest.TestCase):
+ """Tests structured error retention and copyable reports."""
+
+ def setUp(self):
+ from src import diagnostics
+ self.diagnostics = diagnostics
+ diagnostics.clear()
+
+ def tearDown(self):
+ self.diagnostics.uninstall_uncaught_exception_hooks()
+ self.diagnostics.clear()
+
+ def test_structured_error_report_contains_full_context(self):
+ try:
+ raise ValueError("broken provider")
+ except ValueError as exc:
+ self.diagnostics.record_error("provider.test", exc)
+
+ errors = self.diagnostics.get_errors()
+ self.assertEqual(len(errors), 1)
+ self.assertEqual(errors[0]["where"], "provider.test")
+ self.assertEqual(errors[0]["type"], "ValueError")
+ self.assertEqual(errors[0]["message"], "broken provider")
+ self.assertIn("Traceback", errors[0]["traceback"])
+ self.assertTrue(errors[0]["thread"])
+
+ report = self.diagnostics.build_errors_report()
+ self.assertIn("provider.test", report)
+ self.assertIn("ValueError", report)
+ self.assertIn("broken provider", report)
+ self.assertIn("Traceback", report)
+
+ def test_logs_and_full_report_include_errors(self):
+ self.diagnostics.record_error_message(
+ "pipeline.fetch",
+ "history failed",
+ "TLRPC: HISTORY_FAILED",
+ )
+ logs = self.diagnostics.build_logs_report()
+ full = self.diagnostics.build_report()
+
+ self.assertIn("pipeline.fetch", logs)
+ self.assertIn("history failed", logs)
+ self.assertIn("pipeline.fetch", full)
+ self.assertIn("TLRPC: HISTORY_FAILED", full)
+
+ def test_uncaught_hooks_install_and_restore(self):
+ old_sys = sys.excepthook
+ old_thread = getattr(__import__("threading"), "excepthook", None)
+ self.diagnostics.install_uncaught_exception_hooks()
+ self.assertIsNot(sys.excepthook, old_sys)
+ self.diagnostics.uninstall_uncaught_exception_hooks()
+ self.assertIs(sys.excepthook, old_sys)
+ if old_thread is not None:
+ import threading
+ self.assertIs(threading.excepthook, old_thread)
+
+
+
+class TestLocalization(unittest.TestCase):
+ """Tests for internationalization dictionaries and fallback behavior."""
+
+ def test_english_strings(self):
+ loc = LocalizationManager("en")
+ title = loc.get("PLUGIN_NAME")
+ self.assertEqual(title, "AI Chat Summaries")
+ self.assertIn("ChatGPT", loc.get("OAUTH_HEADER"))
+
+ def test_russian_strings(self):
+ loc = LocalizationManager("ru")
+ title = loc.get("PLUGIN_NAME")
+ self.assertEqual(title, "AI Саммари Чатов")
+ self.assertIn("ChatGPT", loc.get("OAUTH_HEADER"))
+
+ def test_interpolation_and_fallback(self):
+ loc = LocalizationManager("en")
+ msg = loc.get("OAUTH_CONNECTED_STATUS", email="user@test.com", tier="PRO")
+ self.assertIn("user@test.com", msg)
+ self.assertIn("PRO", msg)
+
+ # Fallback to key itself if not found
+ non_existent = loc.get("NON_EXISTENT_KEY_12345")
+ self.assertEqual(non_existent, "NON_EXISTENT_KEY_12345")
+
+
+class TestBaseProviderUtilities(unittest.TestCase):
+ """Tests for prompt building, sensitive data masking, and debug log construction."""
+
+ def test_sensitive_masking(self):
+ raw_header = {"Authorization": "Bearer sk-1234567890abcdef12345678", "User-Agent": "test"}
+ masked = mask_sensitive_data(raw_header)
+ self.assertTrue("sk-" in str(masked["Authorization"]))
+ self.assertTrue("..." in str(masked["Authorization"]))
+ self.assertNotIn("abcdef12345678", str(masked["Authorization"]))
+
+ raw_str = "Key: AIzaSyD9876543210abcdef and sk-proj-1122334455667788"
+ masked_str = mask_sensitive_data(raw_str)
+ self.assertNotIn("9876543210abcdef", masked_str)
+ self.assertNotIn("1122334455667788", masked_str)
+
+ def test_debug_log_building(self):
+ log = build_debug_log(
+ url="https://api.openai.com/v1/chat/completions",
+ method="POST",
+ headers={"Authorization": "Bearer sk-testsecret12345"},
+ payload={"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello"}]},
+ status_code=401,
+ response_body={"error": {"message": "Invalid API key"}},
+ )
+ self.assertIn("=== AI Chat Summaries Debug Log ===", log)
+ self.assertIn("Status Code: 401", log)
+ self.assertIn("Invalid API key", log)
+ self.assertNotIn("sk-testsecret12345", log)
+
+ def test_system_prompt_builder(self):
+ prompt_brief = build_system_prompt(
+ style=STYLE_BRIEF,
+ lang=1, # Russian
+ chat_title="Team Alpha",
+ custom_prompt="Focus on deadlines",
+ )
+ self.assertIn("Team Alpha", prompt_brief)
+ self.assertIn("Russian", prompt_brief)
+ self.assertIn("Focus on deadlines", prompt_brief)
+ self.assertIn("Brief overview", prompt_brief)
+
+ prompt_custom = build_system_prompt(
+ style=STYLE_CUSTOM,
+ lang=2, # English
+ custom_prompt="Provide a 3-bullet list of blockers",
+ )
+ self.assertIn("Provide a 3-bullet list of blockers", prompt_custom)
+ self.assertIn("English", prompt_custom)
+
+
+class TestChatGPTOAuth(unittest.TestCase):
+ """Tests for OAuth PKCE generation, code parsing, and tier-based model selection."""
+
+ def setUp(self):
+ self.plugin = MockPlugin()
+ self.oauth = self.plugin.oauth_handler
+
+ def test_auth_url_generation(self):
+ url, verifier, state = self.oauth.generate_auth_url()
+ self.assertTrue(url.startswith("https://auth0.openai.com/authorize?"))
+ self.assertIn("code_challenge=", url)
+ self.assertIn("code_challenge_method=S256", url)
+ self.assertIn("client_id=exteragram_ai_summaries", url)
+ self.assertTrue(len(verifier) > 40)
+ self.assertTrue(len(state) > 10)
+
+ def test_code_extraction(self):
+ raw_code = "4/0AeanS0abcdef12345"
+ self.assertEqual(self.oauth.extract_code_from_input(raw_code), raw_code)
+
+ redirect_url = "exteragram://oauth/chatgpt?code=test_code_xyz123&state=state_abc"
+ self.assertEqual(self.oauth.extract_code_from_input(redirect_url), "test_code_xyz123")
+
+ frag_url = "https://example.com/oauth/callback#code=frag_code_999"
+ self.assertEqual(self.oauth.extract_code_from_input(frag_url), "frag_code_999")
+
+ def test_tier_model_mapping(self):
+ # Free tier
+ self.plugin.set_setting(SETTING_OAUTH_USER_TIER, OAUTH_TIER_FREE)
+ free_models = self.oauth.get_tier_models()
+ self.assertEqual(free_models, OAUTH_FREE_MODELS)
+ self.assertIn("luna", free_models)
+ self.assertEqual(self.oauth.get_active_model(), DEFAULT_MODEL_OAUTH_FREE)
+
+ # Plus / Pro tier
+ self.plugin.set_setting(SETTING_OAUTH_USER_TIER, OAUTH_TIER_PLUS)
+ paid_models = self.oauth.get_tier_models()
+ self.assertEqual(paid_models, OAUTH_PAID_MODELS)
+ self.assertIn("terra", paid_models)
+ self.assertIn("sol", paid_models)
+ self.assertEqual(self.oauth.get_active_model(), DEFAULT_MODEL_OAUTH_PAID)
+
+ @patch("requests.post")
+ @patch("requests.get")
+ def test_token_exchange_and_profile_inspection(self, mock_get, mock_post):
+ # Mock token exchange response
+ mock_post_resp = MagicMock()
+ mock_post_resp.status_code = 200
+ mock_post_resp.json.return_value = {
+ "access_token": "mock_access_token_123",
+ "refresh_token": "mock_refresh_token_456",
+ "expires_in": 3600,
+ }
+ mock_post.return_value = mock_post_resp
+
+ # Mock user info profile response (Pro tier)
+ mock_get_resp = MagicMock()
+ mock_get_resp.status_code = 200
+ mock_get_resp.json.return_value = {
+ "email": "dev@company.com",
+ "subscription": "pro_tier",
+ }
+ mock_get.return_value = mock_get_resp
+
+ success, data, err = self.oauth.exchange_code_for_tokens("code_test_123")
+ self.assertTrue(success)
+ self.assertIsNotNone(data)
+ self.assertEqual(data["email"], "dev@company.com")
+ self.assertEqual(data["tier"], OAUTH_TIER_PLUS)
+
+ # Verify saved in plugin settings
+ self.assertEqual(self.plugin.get_setting(SETTING_OAUTH_ACCESS_TOKEN), "mock_access_token_123")
+ self.assertEqual(self.plugin.get_setting(SETTING_OAUTH_USER_EMAIL), "dev@company.com")
+ self.assertEqual(self.plugin.get_setting(SETTING_OAUTH_MODEL), DEFAULT_MODEL_OAUTH_PAID)
+
+
+class TestCustomAIProvider(unittest.TestCase):
+ """Tests for Custom AI Provider multi-stage verification and context budgeting."""
+
+ def setUp(self):
+ self.plugin = MockPlugin()
+ self.custom = self.plugin.custom_handler
+
+ @patch("requests.head")
+ def test_stage1_reachability(self, mock_head):
+ # Invalid scheme
+ ok, msg = self.custom.verify_stage1_reachability("ftp://example.com")
+ self.assertFalse(ok)
+
+ # Reachable HTTP 200
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_head.return_value = mock_resp
+
+ ok, msg = self.custom.verify_stage1_reachability("https://api.deepseek.com/v1")
+ self.assertTrue(ok)
+ self.assertIn("HTTP 200", msg)
+
+ @patch("requests.post")
+ def test_stage2_probe_test(self, mock_post):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {
+ "choices": [{"message": {"role": "assistant", "content": "Hello!"}}]
+ }
+ mock_post.return_value = mock_resp
+
+ ok, msg, debug_log, elapsed = self.custom.verify_stage2_probe_test(
+ base_url="https://api.deepseek.com/v1",
+ api_key="sk-test-12345",
+ model_name="deepseek-chat",
+ )
+ self.assertTrue(ok)
+ self.assertIsNone(debug_log)
+
+ # Verify request payload has thinking disabled & 'Hey there'
+ call_kwargs = mock_post.call_args[1]
+ json_payload = call_kwargs["json"]
+ self.assertEqual(json_payload["model"], "deepseek-chat")
+ self.assertEqual(json_payload["messages"][0]["content"], "Hey there")
+ self.assertEqual(json_payload["max_tokens"], 10)
+ self.assertEqual(json_payload["thinking"], {"type": "disabled"})
+
+ def test_context_window_budgeting(self):
+ self.plugin.set_setting("custom_context_window", "8k")
+ budget_8k = self.custom.get_context_character_budget()
+ self.assertEqual(budget_8k, CONTEXT_WINDOW_BUDGETS["8k"])
+
+ # Generate long message (e.g. 10,000 chars) -> must clamp to 8,000 chars per message
+ long_msg = "X" * 10_000
+ short_msg = "Normal message 123"
+ messages = [long_msg, short_msg]
+
+ transcript = self.custom.budget_and_chunk_transcript(messages)
+ self.assertIn("Normal message 123", transcript)
+ self.assertIn("[... truncated long message content ...]", transcript)
+ self.assertTrue(len(transcript) <= budget_8k)
+
+
+class TestMessageFetcher(unittest.TestCase):
+ """Tests for message conversion, sender formatting, and chronological sorting."""
+
+ def test_formatted_message_transcript_line(self):
+ msg = FormattedMessage(
+ id=101,
+ date=1700000000,
+ time_str="2023-11-14 22:13",
+ sender_name="Alice Wonderland",
+ sender_username="@alice",
+ text="Let's schedule our release for Monday 10am UTC.",
+ reply_to_id=98,
+ forward_from="Bob",
+ media_info="[Photo: System architecture]",
+ )
+ line = msg.to_transcript_line()
+ self.assertIn("[2023-11-14 22:13] #101 Alice Wonderland (fwd: Bob, reply-to #98):", line)
+ self.assertIn("[Photo: System architecture]", line)
+ self.assertIn("Let's schedule our release for Monday 10am UTC.", line)
+
+ def test_chronological_ordering(self):
+ fetcher = MessageFetcher()
+ raw_msgs = [
+ MagicMock(id=3, date=1700000300, message="Third message", from_id=None, peer_id=None, reply_to=None, fwd_from=None, media=None, action=None),
+ MagicMock(id=1, date=1700000100, message="First message", from_id=None, peer_id=None, reply_to=None, fwd_from=None, media=None, action=None),
+ MagicMock(id=2, date=1700000200, message="Second message", from_id=None, peer_id=None, reply_to=None, fwd_from=None, media=None, action=None),
+ ]
+ processed = fetcher._process_messages(raw_msgs, {}, {})
+ self.assertEqual(len(processed), 3)
+ self.assertEqual(processed[0].id, 1)
+ self.assertEqual(processed[1].id, 2)
+ self.assertEqual(processed[2].id, 3)
+
+
+class TestThinkingStreamDispatcher(unittest.TestCase):
+ """Tests for UnifiedDispatcher streaming token accumulation and thinking parsing."""
+
+ def test_streaming_token_accumulator(self):
+ plugin = MockPlugin()
+ dispatcher = plugin.dispatcher
+ dispatcher.reset_stream_state("Custom endpoint", "deepseek-r1", "General Chat")
+
+ accumulated_thoughts = []
+ accumulated_content = []
+
+ def listener(t_delta, c_delta):
+ if t_delta:
+ accumulated_thoughts.append(t_delta)
+ if c_delta:
+ accumulated_content.append(c_delta)
+
+ dispatcher.add_stream_listener(listener)
+
+ # Simulate streaming chunk delivery
+ dispatcher._on_stream_chunk("Thinking step 1... ", "")
+ dispatcher._on_stream_chunk("Thinking step 2.", "")
+ dispatcher._on_stream_chunk("", "Summary result: ")
+ dispatcher._on_stream_chunk("", "Everything is completed successfully.")
+
+ self.assertEqual(dispatcher.active_stream_state["thinking"], "Thinking step 1... Thinking step 2.")
+ self.assertEqual(dispatcher.active_stream_state["content"], "Summary result: Everything is completed successfully.")
+ self.assertEqual(len(accumulated_thoughts), 2)
+
+class TestBuiltinDirectProviders(unittest.TestCase):
+ """Tests for OpenAI, Anthropic, Gemini, and Ollama direct API handlers and retry backoff."""
+
+ def setUp(self):
+ self.plugin = MockPlugin()
+
+ @patch("requests.post")
+ def test_openai_direct_summarize(self, mock_post):
+ self.plugin.set_setting("openai_api_key", "sk-proj-test12345")
+ self.plugin.set_setting("openai_model", "gpt-4o")
+ handler = OpenAIDirectHandler(self.plugin)
+
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {
+ "choices": [{"message": {"role": "assistant", "content": "Summary of chat"}}]
+ }
+ mock_post.return_value = mock_resp
+
+ resp = handler.summarize(
+ messages_transcript="Alice: Hi\nBob: Hello",
+ style=STYLE_BRIEF,
+ lang=0,
+ enable_stream=False,
+ )
+ self.assertTrue(resp.success)
+ self.assertEqual(resp.text, "Summary of chat")
+ self.assertEqual(resp.model, "gpt-4o")
+
+ @patch("requests.post")
+ def test_anthropic_direct_with_thinking(self, mock_post):
+ self.plugin.set_setting("anthropic_api_key", "sk-ant-test12345")
+ self.plugin.set_setting("anthropic_model", "claude-3-7-sonnet-latest")
+ handler = AnthropicDirectHandler(self.plugin)
+
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {
+ "content": [
+ {"type": "thinking", "thinking": "Analyzing topics..."},
+ {"type": "text", "text": "Claude Summary of chat"}
+ ]
+ }
+ mock_post.return_value = mock_resp
+
+ resp = handler.summarize(
+ messages_transcript="Alice: Project roadmap",
+ style=STYLE_DETAILED,
+ lang=2,
+ enable_stream=False,
+ )
+ self.assertTrue(resp.success)
+ self.assertEqual(resp.text, "Claude Summary of chat")
+ self.assertEqual(resp.reasoning, "Analyzing topics...")
+
+ @patch("requests.post")
+ def test_gemini_direct_summarize(self, mock_post):
+ self.plugin.set_setting("gemini_api_key", "AIzaSyTest12345")
+ self.plugin.set_setting("gemini_model", "gemini-2.0-flash")
+ handler = GeminiDirectHandler(self.plugin)
+
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {"thought": True, "text": "Gemini thought"},
+ {"text": "Gemini summary text"}
+ ]
+ }
+ }
+ ]
+ }
+ mock_post.return_value = mock_resp
+
+ resp = handler.summarize(
+ messages_transcript="User: Update?",
+ style=STYLE_BULLETS,
+ lang=1,
+ enable_stream=False,
+ )
+ self.assertTrue(resp.success)
+ self.assertEqual(resp.text, "Gemini summary text")
+ self.assertEqual(resp.reasoning, "Gemini thought")
+
+ @patch("time.sleep")
+ def test_exponential_backoff_retry(self, mock_sleep):
+ handler = OpenAIDirectHandler(self.plugin)
+ attempts = [0]
+
+ def flaky_call():
+ attempts[0] += 1
+ if attempts[0] < 3:
+ # Simulate 429 Rate Limit
+ resp = MagicMock()
+ resp.status_code = 429
+ import requests
+ err = requests.exceptions.HTTPError("429 Rate Limit")
+ err.response = resp
+ raise err
+ return "success_after_retry"
+
+ result = handler.execute_with_retry(flaky_call, max_retries=3, base_delay=0.1)
+ self.assertEqual(result, "success_after_retry")
+ self.assertEqual(attempts[0], 3)
+ self.assertEqual(mock_sleep.call_count, 2)
+
+
+class TestUIWidgetsAndLifecycle(unittest.TestCase):
+ """Tests for ProgressManager, PreRequest configuration, and unread count calculations."""
+
+ def test_pinned_view_padding_shrink_and_restore(self):
+ manager = PinnedProgressManager()
+ mock_pinned_view = MagicMock()
+ mock_pinned_view.getPaddingRight.return_value = 16
+ mock_pinned_view.getPaddingLeft.return_value = 16
+ mock_pinned_view.getPaddingTop.return_value = 8
+ mock_pinned_view.getPaddingBottom.return_value = 8
+
+ manager.attach_pinned_view(mock_pinned_view)
+ self.assertEqual(manager.original_right_padding, 16)
+
+ # Simulate show_progress
+ mock_activity = MagicMock()
+ manager.show_progress(mock_activity, 12345, "Tech Chat")
+ self.assertTrue(manager.is_showing)
+
+ # Hide progress -> must restore padding
+ manager.hide_progress()
+ self.assertFalse(manager.is_showing)
+ mock_pinned_view.setPadding.assert_called_with(16, 8, 16, 8)
+
+ def test_unread_count_and_offset_calculation(self):
+ from src.hooks.unread_hook import UnreadBadgeHook
+ hook = UnreadBadgeHook()
+
+ # Case 1: 0 or empty unread -> count 100, offset 0
+ raw_unread = 0
+ target_count = min(1000, max(1, raw_unread)) if raw_unread > 0 else 100
+ target_offset = max(0, raw_unread - target_count) if raw_unread > 0 else 0
+ self.assertEqual(target_count, 100)
+ self.assertEqual(target_offset, 0)
+
+ # Case 2: 250 unread messages -> count 250, offset 0 (all unread)
+ raw_unread = 250
+ target_count = min(1000, max(1, raw_unread)) if raw_unread > 0 else 100
+ target_offset = max(0, raw_unread - target_count) if raw_unread > 0 else 0
+ self.assertEqual(target_count, 250)
+ self.assertEqual(target_offset, 0)
+
+ # Case 3: 1500 unread messages -> count 1000, offset 500 (oldest 1000 unread batch)
+ raw_unread = 1500
+ target_count = min(1000, max(1, raw_unread)) if raw_unread > 0 else 100
+ target_offset = max(0, raw_unread - target_count) if raw_unread > 0 else 0
+ self.assertEqual(target_count, 1000)
+ self.assertEqual(target_offset, 500)
+
+ def test_scroll_to_latest_message_navigation(self):
+ from src.ui.summary_dialog import scroll_to_message_in_chat
+ mock_activity = MagicMock()
+ mock_activity.scrollToMessageId = MagicMock()
+
+ # Navigate to message #54321
+ success = scroll_to_message_in_chat(mock_activity, message_id=54321, topic_id=0)
+ self.assertTrue(success)
+ mock_activity.scrollToMessageId.assert_called_with(54321, 0, True, 0, False, 0)
+
+ def test_page_down_long_press_timing_contract(self):
+ from src.hooks import unread_hook
+
+ class Event:
+ def __init__(self, action):
+ self._action = action
+ def getAction(self):
+ return self._action
+
+ # Short press: below threshold, should remain native.
+ with patch.object(unread_hook.time, "monotonic", side_effect=[10.0, 10.2, 10.2]):
+ unread_hook._gesture_before_dispatch(Event(0))
+ unread_hook._gesture_before_dispatch(Event(1))
+ self.assertLess(unread_hook._held_duration(), unread_hook.LONG_PRESS_SECONDS)
+
+ # Long press: above threshold, should enter our interception path.
+ with patch.object(unread_hook.time, "monotonic", side_effect=[20.0, 20.6, 20.6]):
+ unread_hook._gesture_before_dispatch(Event(0))
+ unread_hook._gesture_before_dispatch(Event(1))
+ self.assertGreaterEqual(unread_hook._held_duration(), unread_hook.LONG_PRESS_SECONDS)
+
+class TestPluginEndToEndSimulation(unittest.TestCase):
+ """End-to-end simulation of summarization pipeline from trigger to result."""
+
+ @patch("requests.post")
+ def test_full_pipeline_run(self, mock_post):
+ plugin = MockPlugin()
+ plugin.set_setting(SETTING_PROVIDER, PROVIDER_CUSTOM)
+ plugin.set_setting("custom_base_url", "https://api.deepseek.com/v1")
+ plugin.set_setting("custom_api_key", "sk-deepseek-test")
+ plugin.set_setting("custom_model", "deepseek-chat")
+ plugin.set_setting(SETTING_ENABLE_THINKING_STREAM, False)
+
+ # Mock AI completion response
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {
+ "choices": [{"message": {"role": "assistant", "content": "Summary: Key topics discussed."}}]
+ }
+ mock_post.return_value = mock_resp
+
+ # Verify unified dispatcher routes properly
+ provider = plugin.dispatcher.get_active_provider()
+ self.assertEqual(provider.name, "Custom endpoint")
+
+ # Dispatch summarization
+ response = plugin.dispatcher.run_summary(
+ messages_transcript="Alice: Deployment ready.\nBob: Approved.",
+ chat_title="Release Channel",
+ style=STYLE_BRIEF,
+ lang=0,
+ )
+
+ self.assertTrue(response.success)
+ self.assertEqual(response.text, "Summary: Key topics discussed.")
+ self.assertEqual(response.model, "deepseek-chat")
+ self.assertEqual(response.provider_name, "Custom endpoint")
+
+
+if __name__ == "__main__":
+ unittest.main()