From bd34e7b8ba7f58b8c4c488539b9e8379016fc41b Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Thu, 10 Sep 2026 12:48:56 +0200 Subject: [PATCH] . --- .idea/betterCommentsSettings.xml | 31 + .idea/exteragram-chat-summaries.iml | 8 + .idea/inspectionProfiles/Project_Default.xml | 34 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 10 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + build.py | 490 ++++ references/2026-09-10 10.20.58.jpg | Bin 331216 -> 0 bytes references/chat_summary.plugin | 884 ------- references/message_analyzer.plugin | 1067 --------- references/unread_summary.plugin | 2125 ----------------- src/__init__.py | 5 + src/config.py | 178 ++ src/diagnostics.py | 447 ++++ src/hooks/__init__.py | 8 + src/hooks/pinned_hook.py | 312 +++ src/hooks/unread_hook.py | 536 +++++ src/localization.py | 339 +++ src/providers/__init__.py | 28 + src/providers/base.py | 258 ++ src/providers/builtin.py | 684 ++++++ src/providers/custom.py | 373 +++ src/providers/dispatcher.py | 170 ++ src/providers/oauth.py | 438 ++++ src/services/__init__.py | 7 + src/services/message_fetcher.py | 431 ++++ src/ui/__init__.py | 20 + src/ui/pre_request.py | 318 +++ src/ui/progress_widget.py | 164 ++ src/ui/settings.py | 631 +++++ src/ui/summary_dialog.py | 282 +++ src/ui/thinking_sheet.py | 140 ++ tests/__init__.py | 3 + tests/test_plugin.py | 694 ++++++ 35 files changed, 7059 insertions(+), 4076 deletions(-) create mode 100644 .idea/betterCommentsSettings.xml create mode 100644 .idea/exteragram-chat-summaries.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100755 build.py delete mode 100644 references/2026-09-10 10.20.58.jpg delete mode 100644 references/chat_summary.plugin delete mode 100644 references/message_analyzer.plugin delete mode 100644 references/unread_summary.plugin create mode 100644 src/__init__.py create mode 100644 src/config.py create mode 100644 src/diagnostics.py create mode 100644 src/hooks/__init__.py create mode 100644 src/hooks/pinned_hook.py create mode 100644 src/hooks/unread_hook.py create mode 100644 src/localization.py create mode 100644 src/providers/__init__.py create mode 100644 src/providers/base.py create mode 100644 src/providers/builtin.py create mode 100644 src/providers/custom.py create mode 100644 src/providers/dispatcher.py create mode 100644 src/providers/oauth.py create mode 100644 src/services/__init__.py create mode 100644 src/services/message_fetcher.py create mode 100644 src/ui/__init__.py create mode 100644 src/ui/pre_request.py create mode 100644 src/ui/progress_widget.py create mode 100644 src/ui/settings.py create mode 100644 src/ui/summary_dialog.py create mode 100644 src/ui/thinking_sheet.py create mode 100644 tests/__init__.py create mode 100644 tests/test_plugin.py 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 72b38930436f9ad6c79cad83e7b584c0f0946906..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 331216 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

M$?#)-&+h7wi~n%RG}Q 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

fBfJJBxqTelH1(pYdjs7qCc<6u>{=VT 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

WeSKToWQ?ibg&EF) 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

=6VXszG#LfCS(RAm+{D;U%|09J7DocF1)YZpKl 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 Zt@VU4Aw 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()