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()