.
This commit is contained in:
Generated
+31
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="BetterCommentsSettings">
|
||||||
|
<option name="initialized" value="true" />
|
||||||
|
<option name="tags">
|
||||||
|
<list>
|
||||||
|
<CustomTag>
|
||||||
|
<option name="color" value="#FF2D00" />
|
||||||
|
<option name="type" value="!" />
|
||||||
|
</CustomTag>
|
||||||
|
<CustomTag>
|
||||||
|
<option name="color" value="#3498DB" />
|
||||||
|
<option name="type" value="?" />
|
||||||
|
</CustomTag>
|
||||||
|
<CustomTag>
|
||||||
|
<option name="color" value="#474747" />
|
||||||
|
<option name="hasStrikethrough" value="true" />
|
||||||
|
<option name="type" value="//" />
|
||||||
|
</CustomTag>
|
||||||
|
<CustomTag>
|
||||||
|
<option name="color" value="#FF8C00" />
|
||||||
|
<option name="type" value="todo" />
|
||||||
|
</CustomTag>
|
||||||
|
<CustomTag>
|
||||||
|
<option name="color" value="#98C379" />
|
||||||
|
<option name="type" value="*" />
|
||||||
|
</CustomTag>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.14" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="PyStringConversionWithoutDunderMethodInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredTypes">
|
||||||
|
<list>
|
||||||
|
<option value="types.NoneType" />
|
||||||
|
<option value="_io.TextIOWrapper" />
|
||||||
|
<option value="str" />
|
||||||
|
<option value="int" />
|
||||||
|
<option value="float" />
|
||||||
|
<option value="complex" />
|
||||||
|
<option value="set" />
|
||||||
|
<option value="frozenset" />
|
||||||
|
<option value="bytes" />
|
||||||
|
<option value="bytearray" />
|
||||||
|
<option value="memoryview" />
|
||||||
|
<option value="slice" />
|
||||||
|
<option value="list" />
|
||||||
|
<option value="dict" />
|
||||||
|
<option value="bool" />
|
||||||
|
<option value="range" />
|
||||||
|
<option value="tuple" />
|
||||||
|
<option value="pathlib.PurePath" />
|
||||||
|
<option value="uuid.UUID" />
|
||||||
|
<option value="decimal.Decimal" />
|
||||||
|
<option value="fractions.Fraction" />
|
||||||
|
<option value="schemas.token.JWTToken" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.14" />
|
||||||
|
</component>
|
||||||
|
<component name="GithubDefaultAccount">
|
||||||
|
<option name="defaultAccountId" value="14736ef7-23b9-4442-8686-78ec985aa54f" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/exteragram-chat-summaries.iml" filepath="$PROJECT_DIR$/.idea/exteragram-chat-summaries.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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 '<none>'}\\n\\n"
|
||||||
|
f"=== System Prompt ===\\n{global_prompt or '<standard>'}\\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()
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 324 KiB |
@@ -1,884 +0,0 @@
|
|||||||
from typing import Any, List
|
|
||||||
|
|
||||||
from base_plugin import BasePlugin, MenuItemData, MenuItemType
|
|
||||||
from client_utils import (
|
|
||||||
get_last_fragment, get_messages_controller,
|
|
||||||
run_on_queue, send_request, PLUGINS_QUEUE,
|
|
||||||
)
|
|
||||||
from android_utils import run_on_ui_thread, log, OnClickListener, copy_to_clipboard
|
|
||||||
from ui.settings import Header, Divider, Selector, Input, Text
|
|
||||||
from ui.bulletin import BulletinHelper
|
|
||||||
from org.telegram.messenger import AndroidUtilities
|
|
||||||
from org.telegram.ui.ActionBar import Theme
|
|
||||||
|
|
||||||
__id__ = "chat_summary"
|
|
||||||
__name__ = "Chat Summary"
|
|
||||||
__description__ = "Саммари любого чата через AI — OpenAI, Anthropic, Gemini, Ollama или свой endpoint."
|
|
||||||
__author__ = "@aaxnet"
|
|
||||||
__version__ = "2.2.0" # Обновили версию в связи с добавлением дебага и ретраев
|
|
||||||
__icon__ = "KOSHAKIEBANIYE/44"
|
|
||||||
__app_version__ = ">=12.5.1"
|
|
||||||
__sdk_version__ = ">=1.4.3.3"
|
|
||||||
__requirements__ = ["requests"]
|
|
||||||
|
|
||||||
PROVIDER_OPENAI = 0
|
|
||||||
PROVIDER_ANTHROPIC = 1
|
|
||||||
PROVIDER_GEMINI = 2
|
|
||||||
PROVIDER_OLLAMA = 3
|
|
||||||
PROVIDER_CUSTOM = 4
|
|
||||||
|
|
||||||
PROVIDERS = [
|
|
||||||
"OpenAI (GPT-4o)",
|
|
||||||
"Anthropic (Claude)",
|
|
||||||
"Gemini",
|
|
||||||
"Ollama (local)",
|
|
||||||
"Custom endpoint",
|
|
||||||
]
|
|
||||||
|
|
||||||
DEFAULT_MODELS = [
|
|
||||||
"gpt-4o",
|
|
||||||
"claude-sonnet-4-5",
|
|
||||||
"gemini-2.0-flash",
|
|
||||||
"llama3",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
STYLE_BRIEF = 0
|
|
||||||
STYLE_DETAILED = 1
|
|
||||||
STYLE_BULLETS = 2
|
|
||||||
|
|
||||||
STYLES = ["Краткий", "Подробный", "По пунктам"]
|
|
||||||
STYLE_HINTS = [
|
|
||||||
"2–3 sentences о главном",
|
|
||||||
"Все темы и детали",
|
|
||||||
"Список ключевых моментов",
|
|
||||||
]
|
|
||||||
|
|
||||||
LANG_AUTO = 0
|
|
||||||
LANG_RU = 1
|
|
||||||
LANG_EN = 2
|
|
||||||
|
|
||||||
LANGS = ["Авто", "Русский", "English"]
|
|
||||||
|
|
||||||
MSG_COUNTS = [50, 100, 200, 500]
|
|
||||||
|
|
||||||
MAX_CONTENT_CHARS = 48000
|
|
||||||
|
|
||||||
KEY_PREFIXES = {
|
|
||||||
PROVIDER_OPENAI: "sk-",
|
|
||||||
PROVIDER_ANTHROPIC: "sk-ant-",
|
|
||||||
PROVIDER_GEMINI: "AIza",
|
|
||||||
PROVIDER_CUSTOM: "sk-",
|
|
||||||
}
|
|
||||||
|
|
||||||
PROVIDER_MODEL_LABELS = {
|
|
||||||
PROVIDER_OPENAI: "OpenAI",
|
|
||||||
PROVIDER_ANTHROPIC: "Anthropic",
|
|
||||||
PROVIDER_GEMINI: "Gemini",
|
|
||||||
PROVIDER_OLLAMA: "Ollama",
|
|
||||||
PROVIDER_CUSTOM: "Custom",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def check_key_format(provider, api_key):
|
|
||||||
"""Returns (is_valid, hint_text) for a quick format check, not a real auth check."""
|
|
||||||
api_key = (api_key or "").strip()
|
|
||||||
if provider == PROVIDER_OLLAMA:
|
|
||||||
return True, "Ключ не требуется"
|
|
||||||
if not api_key:
|
|
||||||
return False, "Ключ не указан"
|
|
||||||
prefix = KEY_PREFIXES.get(provider)
|
|
||||||
if prefix and not api_key.startswith(prefix):
|
|
||||||
return False, "Похоже на неверный формат (ожидается " + prefix + "...)"
|
|
||||||
if len(api_key) < 16:
|
|
||||||
return False, "Слишком короткий ключ"
|
|
||||||
return True, "Формат похож на правильный"
|
|
||||||
|
|
||||||
|
|
||||||
def get_theme_color(key, fallback):
|
|
||||||
try:
|
|
||||||
return Theme.getColor(getattr(Theme, key))
|
|
||||||
except Exception:
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def build_system_prompt(style, lang):
|
|
||||||
lang_instructions = {
|
|
||||||
LANG_AUTO: "Respond in the same language as the conversation.",
|
|
||||||
LANG_RU: "Respond in Russian.",
|
|
||||||
LANG_EN: "Respond in English.",
|
|
||||||
}
|
|
||||||
style_instructions = {
|
|
||||||
STYLE_BRIEF: "Write a concise 2-3 sentence summary of the key points.",
|
|
||||||
STYLE_DETAILED: "Write a detailed summary covering all important topics discussed.",
|
|
||||||
STYLE_BULLETS: "Write a bullet-point list of the main topics and decisions.",
|
|
||||||
}
|
|
||||||
lang_part = lang_instructions.get(lang, "")
|
|
||||||
style_part = style_instructions.get(style, "")
|
|
||||||
return (
|
|
||||||
"You are a helpful assistant that summarizes Telegram chat conversations. "
|
|
||||||
+ style_part + " " + lang_part + " "
|
|
||||||
"Focus only on content. Do not include meta-commentary."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_java_list(java_list):
|
|
||||||
result = {}
|
|
||||||
if not java_list:
|
|
||||||
return result
|
|
||||||
try:
|
|
||||||
size = java_list.size()
|
|
||||||
for i in range(size):
|
|
||||||
item = java_list.get(i)
|
|
||||||
try:
|
|
||||||
result[int(item.id)] = item
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_sender_name(raw_msg, user_map, chat_map):
|
|
||||||
fid = getattr(raw_msg, "from_id", None)
|
|
||||||
if fid is None:
|
|
||||||
fid = getattr(raw_msg, "peer_id", None)
|
|
||||||
if fid is None:
|
|
||||||
return "Unknown"
|
|
||||||
|
|
||||||
uid = getattr(fid, "user_id", None)
|
|
||||||
channel_id = getattr(fid, "channel_id", None)
|
|
||||||
chat_id = getattr(fid, "chat_id", None)
|
|
||||||
|
|
||||||
if uid:
|
|
||||||
uid = int(uid)
|
|
||||||
user = user_map.get(uid)
|
|
||||||
if user:
|
|
||||||
first = getattr(user, "first_name", "") or ""
|
|
||||||
last = getattr(user, "last_name", "") or ""
|
|
||||||
name = (first + " " + last).strip()
|
|
||||||
return name if name else "User" + str(uid)
|
|
||||||
return "User" + str(uid)
|
|
||||||
|
|
||||||
if channel_id:
|
|
||||||
channel_id = int(channel_id)
|
|
||||||
chat = chat_map.get(channel_id)
|
|
||||||
if chat:
|
|
||||||
return getattr(chat, "title", None) or "Channel" + str(channel_id)
|
|
||||||
return "Channel" + str(channel_id)
|
|
||||||
|
|
||||||
if chat_id:
|
|
||||||
chat_id = int(chat_id)
|
|
||||||
chat = chat_map.get(chat_id)
|
|
||||||
if chat:
|
|
||||||
return getattr(chat, "title", None) or "Chat" + str(chat_id)
|
|
||||||
return "Chat" + str(chat_id)
|
|
||||||
|
|
||||||
return "Unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def get_message_text(msg, raw_msg):
|
|
||||||
mt = getattr(msg, "messageText", None)
|
|
||||||
if mt:
|
|
||||||
return str(mt)
|
|
||||||
raw_text = getattr(raw_msg, "message", None)
|
|
||||||
if raw_text:
|
|
||||||
return str(raw_text)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def build_transcript(messages, users=None, chats=None):
|
|
||||||
user_map = parse_java_list(users)
|
|
||||||
chat_map = parse_java_list(chats)
|
|
||||||
|
|
||||||
size = messages.size() if hasattr(messages, "size") else len(messages)
|
|
||||||
lines = []
|
|
||||||
used = 0
|
|
||||||
|
|
||||||
for i in range(size - 1, -1, -1):
|
|
||||||
try:
|
|
||||||
msg = messages.get(i) if hasattr(messages, "get") else messages[i]
|
|
||||||
raw = getattr(msg, "messageOwner", msg)
|
|
||||||
|
|
||||||
cls = type(raw).__name__
|
|
||||||
if "Service" in cls or "Empty" in cls:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sender = get_sender_name(raw, user_map, chat_map)
|
|
||||||
text = get_message_text(msg, raw)
|
|
||||||
|
|
||||||
if text.strip():
|
|
||||||
lines.append(sender + ": " + text)
|
|
||||||
used += 1
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
full_text = "\n".join(lines)
|
|
||||||
if len(full_text) > MAX_CONTENT_CHARS:
|
|
||||||
full_text = "...[обрезано]\n" + full_text[-MAX_CONTENT_CHARS:]
|
|
||||||
|
|
||||||
return full_text, used
|
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_EMOJIS = ["🤖", "🧠", "✨", "💻", "🔗"]
|
|
||||||
|
|
||||||
LONG_SUMMARY_THRESHOLD = 600 # chars, above this we add a scroll + "via model" footer is still shown either way
|
|
||||||
|
|
||||||
|
|
||||||
def show_result(act, summary, msg_count, provider, model):
|
|
||||||
try:
|
|
||||||
from ui.alert import AlertDialogBuilder
|
|
||||||
from android.widget import LinearLayout, TextView, ScrollView
|
|
||||||
from android.view import Gravity
|
|
||||||
from android.util import TypedValue
|
|
||||||
|
|
||||||
dp = AndroidUtilities.dp
|
|
||||||
|
|
||||||
provider_label = PROVIDER_MODEL_LABELS.get(provider, "AI")
|
|
||||||
model_label = model.strip() if model and model.strip() else (
|
|
||||||
DEFAULT_MODELS[provider] if 0 <= provider < len(DEFAULT_MODELS) else ""
|
|
||||||
)
|
|
||||||
footer_text = "via " + provider_label + (" · " + model_label if model_label else "")
|
|
||||||
|
|
||||||
def on_copy(b, w):
|
|
||||||
copy_to_clipboard(summary)
|
|
||||||
BulletinHelper.show_info("Скопировано ✓")
|
|
||||||
b.dismiss()
|
|
||||||
|
|
||||||
def on_share(b, w):
|
|
||||||
try:
|
|
||||||
from android.content import Intent
|
|
||||||
intent = Intent(Intent.ACTION_SEND)
|
|
||||||
intent.setType("text/plain")
|
|
||||||
intent.putExtra(Intent.EXTRA_TEXT, summary)
|
|
||||||
act.startActivity(Intent.createChooser(intent, "Поделиться саммари"))
|
|
||||||
except Exception as ex:
|
|
||||||
log("chat_summary: share error: " + str(ex))
|
|
||||||
b.dismiss()
|
|
||||||
|
|
||||||
root = LinearLayout(act)
|
|
||||||
root.setOrientation(LinearLayout.VERTICAL)
|
|
||||||
root.setPadding(dp(24), dp(8), dp(24), dp(4))
|
|
||||||
|
|
||||||
scroll = ScrollView(act)
|
|
||||||
max_height_px = dp(360)
|
|
||||||
|
|
||||||
body = TextView(act)
|
|
||||||
body.setText(summary)
|
|
||||||
body.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
|
|
||||||
body.setTextColor(get_theme_color("key_dialogTextBlack", 0xFF212121))
|
|
||||||
body.setLineSpacing(dp(2), 1.0)
|
|
||||||
scroll.addView(body)
|
|
||||||
|
|
||||||
lp_scroll = LinearLayout.LayoutParams(-1, -2)
|
|
||||||
if len(summary) > LONG_SUMMARY_THRESHOLD:
|
|
||||||
lp_scroll = LinearLayout.LayoutParams(-1, max_height_px)
|
|
||||||
root.addView(scroll, lp_scroll)
|
|
||||||
|
|
||||||
if len(summary) > LONG_SUMMARY_THRESHOLD:
|
|
||||||
hint = TextView(act)
|
|
||||||
hint.setText("↕ прокрути для остального текста")
|
|
||||||
hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
|
|
||||||
hint.setGravity(Gravity.CENTER)
|
|
||||||
hint.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
|
|
||||||
lp_hint = LinearLayout.LayoutParams(-1, -2)
|
|
||||||
lp_hint.topMargin = dp(6)
|
|
||||||
root.addView(hint, lp_hint)
|
|
||||||
|
|
||||||
footer = TextView(act)
|
|
||||||
footer.setText(footer_text)
|
|
||||||
footer.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 11)
|
|
||||||
footer.setGravity(Gravity.CENTER)
|
|
||||||
footer.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF999999))
|
|
||||||
lp_footer = LinearLayout.LayoutParams(-1, -2)
|
|
||||||
lp_footer.topMargin = dp(14)
|
|
||||||
lp_footer.bottomMargin = dp(4)
|
|
||||||
root.addView(footer, lp_footer)
|
|
||||||
|
|
||||||
builder = AlertDialogBuilder(act)
|
|
||||||
builder.set_title("✨ Саммари · " + str(msg_count) + " сообщений")
|
|
||||||
builder.set_view(root)
|
|
||||||
builder.set_positive_button("Закрыть", lambda b, w: b.dismiss())
|
|
||||||
builder.set_negative_button("Копировать", on_copy)
|
|
||||||
builder.set_neutral_button("Поделиться", on_share)
|
|
||||||
builder.show()
|
|
||||||
except Exception as e:
|
|
||||||
log("chat_summary: show_result error: " + str(e))
|
|
||||||
try:
|
|
||||||
from ui.alert import AlertDialogBuilder
|
|
||||||
builder = AlertDialogBuilder(act)
|
|
||||||
builder.set_title("✨ Саммари · " + str(msg_count) + " сообщений")
|
|
||||||
builder.set_message(summary)
|
|
||||||
builder.set_positive_button("Закрыть", lambda b, w: b.dismiss())
|
|
||||||
builder.set_negative_button("Копировать", lambda b, w: (copy_to_clipboard(summary), b.dismiss()))
|
|
||||||
builder.show()
|
|
||||||
except Exception as e2:
|
|
||||||
log("chat_summary: fallback dialog error: " + str(e2))
|
|
||||||
|
|
||||||
|
|
||||||
def show_spinner(act, title="Анализирую"):
|
|
||||||
try:
|
|
||||||
from ui.alert import AlertDialogBuilder
|
|
||||||
from android.os import Handler, Looper
|
|
||||||
|
|
||||||
d = AlertDialogBuilder(act, AlertDialogBuilder.ALERT_TYPE_SPINNER)
|
|
||||||
d.set_title(title + "…")
|
|
||||||
d.set_cancelable(False)
|
|
||||||
d.show()
|
|
||||||
|
|
||||||
dots = ["", ".", "..", "..."]
|
|
||||||
state = [0, True]
|
|
||||||
handler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
def tick():
|
|
||||||
if not state[1]:
|
|
||||||
return
|
|
||||||
state[0] = (state[0] + 1) % len(dots)
|
|
||||||
try:
|
|
||||||
d.set_title(title + dots[state[0]])
|
|
||||||
except Exception:
|
|
||||||
state[1] = False
|
|
||||||
return
|
|
||||||
handler.postDelayed(tick, 500)
|
|
||||||
|
|
||||||
handler.postDelayed(tick, 500)
|
|
||||||
|
|
||||||
original_dismiss = d.dismiss
|
|
||||||
def dismiss_and_stop():
|
|
||||||
state[1] = False
|
|
||||||
try:
|
|
||||||
original_dismiss()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
d.dismiss = dismiss_and_stop
|
|
||||||
|
|
||||||
return d
|
|
||||||
except Exception as e:
|
|
||||||
log("chat_summary: spinner error: " + str(e))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def dismiss_dialog(dlg):
|
|
||||||
if dlg is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
dlg.dismiss()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def call_openai(api_key, model, system, content, url=None):
|
|
||||||
import requests
|
|
||||||
model = model or "gpt-4o"
|
|
||||||
url = url or "https://api.openai.com/v1/chat/completions"
|
|
||||||
resp = requests.post(
|
|
||||||
url,
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer " + api_key,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
"model": model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system},
|
|
||||||
{"role": "user", "content": content},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()["choices"][0]["message"]["content"]
|
|
||||||
|
|
||||||
|
|
||||||
def call_anthropic(api_key, model, system, content):
|
|
||||||
import requests
|
|
||||||
model = model or "claude-sonnet-4-5"
|
|
||||||
resp = requests.post(
|
|
||||||
"https://api.anthropic.com/v1/messages",
|
|
||||||
headers={
|
|
||||||
"x-api-key": api_key,
|
|
||||||
"anthropic-version": "2023-06-01",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
"model": model,
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"system": system,
|
|
||||||
"messages": [{"role": "user", "content": content}],
|
|
||||||
},
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()["content"][0]["text"]
|
|
||||||
|
|
||||||
|
|
||||||
def call_gemini(api_key, model, system, content):
|
|
||||||
import requests
|
|
||||||
model = model or "gemini-2.0-flash"
|
|
||||||
url = (
|
|
||||||
"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
||||||
+ model
|
|
||||||
+ ":generateContent?key="
|
|
||||||
+ api_key
|
|
||||||
)
|
|
||||||
resp = requests.post(
|
|
||||||
url,
|
|
||||||
json={"contents": [{"parts": [{"text": system + "\n\n" + content}]}]},
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()["candidates"][0]["content"]["parts"][0]["text"]
|
|
||||||
|
|
||||||
|
|
||||||
def call_ollama(model, system, content):
|
|
||||||
import requests
|
|
||||||
model = model or "llama3"
|
|
||||||
resp = requests.post(
|
|
||||||
"http://localhost:11434/api/chat",
|
|
||||||
json={
|
|
||||||
"model": model,
|
|
||||||
"stream": False,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system},
|
|
||||||
{"role": "user", "content": content},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()["message"]["content"]
|
|
||||||
|
|
||||||
|
|
||||||
# ОБНОВЛЕНО: Добавлен алгоритм Exponential Backoff для обработки ошибки 429
|
|
||||||
def call_ai(provider, api_key, model, system, content, custom_url):
|
|
||||||
import time
|
|
||||||
import random
|
|
||||||
|
|
||||||
max_retries = 3 # Количество попыток автоматического перезапуска
|
|
||||||
delay = 2 # Начальная задержка в секундах
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
if provider == PROVIDER_OPENAI:
|
|
||||||
return call_openai(api_key, model, system, content)
|
|
||||||
if provider == PROVIDER_ANTHROPIC:
|
|
||||||
return call_anthropic(api_key, model, system, content)
|
|
||||||
if provider == PROVIDER_GEMINI:
|
|
||||||
return call_gemini(api_key, model, system, content)
|
|
||||||
if provider == PROVIDER_OLLAMA:
|
|
||||||
return call_ollama(model, system, content)
|
|
||||||
if provider == PROVIDER_CUSTOM:
|
|
||||||
return call_openai(api_key, model, system, content, url=custom_url)
|
|
||||||
raise ValueError("Unknown provider: " + str(provider))
|
|
||||||
except Exception as e:
|
|
||||||
err_msg = str(e)
|
|
||||||
# Если словили 429 (Превышение лимитов) и лимит попыток не исчерпан
|
|
||||||
if "429" in err_msg and attempt < max_retries - 1:
|
|
||||||
sleep_time = delay + random.uniform(0, 1)
|
|
||||||
log("chat_summary: Hit 429 Rate Limit. Retrying in " + str(round(sleep_time, 2)) + "s...")
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
delay *= 2 # Удваиваем время ожидания для следующего шага
|
|
||||||
continue
|
|
||||||
# Во всех остальных случаях (или если попытки кончились) выбрасываем ошибку дальше
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
# ОБНОВЛЕНО: Добавлено явное и красивое описание ошибки 429 при тесте связи
|
|
||||||
def test_connection(provider, api_key, model, custom_url):
|
|
||||||
"""Sends a minimal ping request, returns (ok, message)."""
|
|
||||||
try:
|
|
||||||
system = "Reply with exactly one word: OK."
|
|
||||||
content = "ping"
|
|
||||||
result = call_ai(provider, api_key, model, system, content, custom_url)
|
|
||||||
if result and result.strip():
|
|
||||||
return True, "Подключение работает ✓"
|
|
||||||
return False, "Пустой ответ от сервера"
|
|
||||||
except Exception as e:
|
|
||||||
msg = str(e)
|
|
||||||
if "429" in msg:
|
|
||||||
return False, "Превышен лимит запросов (ошибка 429). Подожди немного."
|
|
||||||
if "401" in msg or "Unauthorized" in msg:
|
|
||||||
return False, "Неверный API ключ"
|
|
||||||
if "404" in msg:
|
|
||||||
return False, "Модель не найдена"
|
|
||||||
if "timeout" in msg.lower() or "timed out" in msg.lower():
|
|
||||||
return False, "Таймаут — сервер не отвечает"
|
|
||||||
return False, "Ошибка: " + msg[:80]
|
|
||||||
|
|
||||||
|
|
||||||
class ChatSummaryPlugin(BasePlugin):
|
|
||||||
|
|
||||||
def on_plugin_load(self):
|
|
||||||
self.last_error = "Ошибок пока не зафиксировано" # Трекер для Debug-кнопки
|
|
||||||
self.add_menu_item(MenuItemData(
|
|
||||||
menu_type=MenuItemType.CHAT_ACTION_MENU,
|
|
||||||
text="Summarize Chat",
|
|
||||||
subtext="Краткое AI-саммари",
|
|
||||||
icon="msg_info",
|
|
||||||
on_click=self.on_menu_click,
|
|
||||||
))
|
|
||||||
|
|
||||||
def on_plugin_unload(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_menu_click(self, context):
|
|
||||||
dialog_id = context.get("dialog_id")
|
|
||||||
if not dialog_id:
|
|
||||||
return
|
|
||||||
fragment = context.get("fragment") or get_last_fragment()
|
|
||||||
if not fragment:
|
|
||||||
return
|
|
||||||
act = fragment.getParentActivity()
|
|
||||||
if not act:
|
|
||||||
return
|
|
||||||
|
|
||||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
|
||||||
api_key = self.get_setting("api_key", "") or ""
|
|
||||||
if provider != PROVIDER_OLLAMA and not api_key.strip():
|
|
||||||
BulletinHelper.show_error("Сначала укажи API ключ в настройках плагина")
|
|
||||||
return
|
|
||||||
|
|
||||||
run_on_ui_thread(lambda: self.show_count_picker(act, dialog_id))
|
|
||||||
|
|
||||||
def show_count_picker(self, act, dialog_id):
|
|
||||||
try:
|
|
||||||
from android.widget import LinearLayout, TextView
|
|
||||||
from android.view import Gravity
|
|
||||||
from android.util import TypedValue
|
|
||||||
from android.graphics.drawable import GradientDrawable
|
|
||||||
from org.telegram.ui.ActionBar import BottomSheet
|
|
||||||
|
|
||||||
dp = AndroidUtilities.dp
|
|
||||||
sheet_holder = [None]
|
|
||||||
|
|
||||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
|
||||||
emoji = PROVIDER_EMOJIS[provider] if 0 <= provider < len(PROVIDER_EMOJIS) else "🤖"
|
|
||||||
provider_name = PROVIDERS[provider] if 0 <= provider < len(PROVIDERS) else "AI"
|
|
||||||
|
|
||||||
def pick(count):
|
|
||||||
if sheet_holder[0]:
|
|
||||||
sheet_holder[0].dismiss()
|
|
||||||
BulletinHelper.show_info("Загружаю " + str(count) + " сообщений…")
|
|
||||||
run_on_queue(
|
|
||||||
lambda: self.fetch_and_summarize(act, dialog_id, count),
|
|
||||||
PLUGINS_QUEUE, 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
root = LinearLayout(act)
|
|
||||||
root.setOrientation(LinearLayout.VERTICAL)
|
|
||||||
root.setPadding(dp(20), dp(20), dp(20), dp(28))
|
|
||||||
try:
|
|
||||||
root.setBackgroundColor(Theme.getColor(Theme.key_dialogBackground))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
icon_tv = TextView(act)
|
|
||||||
icon_tv.setText(emoji)
|
|
||||||
icon_tv.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 36)
|
|
||||||
icon_tv.setGravity(Gravity.CENTER)
|
|
||||||
lp = LinearLayout.LayoutParams(-1, -2)
|
|
||||||
lp.bottomMargin = dp(8)
|
|
||||||
root.addView(icon_tv, lp)
|
|
||||||
|
|
||||||
title = TextView(act)
|
|
||||||
title.setText("Сколько сообщений?")
|
|
||||||
title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18)
|
|
||||||
title.setGravity(Gravity.CENTER)
|
|
||||||
title.setTextColor(get_theme_color("key_dialogTextBlack", 0xFF212121))
|
|
||||||
try:
|
|
||||||
title.setTypeface(AndroidUtilities.bold())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
lp = LinearLayout.LayoutParams(-1, -2)
|
|
||||||
lp.bottomMargin = dp(4)
|
|
||||||
root.addView(title, lp)
|
|
||||||
|
|
||||||
sub = TextView(act)
|
|
||||||
sub.setText("Провайдер: " + provider_name)
|
|
||||||
sub.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13)
|
|
||||||
sub.setGravity(Gravity.CENTER)
|
|
||||||
sub.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
|
|
||||||
lp = LinearLayout.LayoutParams(-1, -2)
|
|
||||||
lp.bottomMargin = dp(20)
|
|
||||||
root.addView(sub, lp)
|
|
||||||
|
|
||||||
count_hints = ["быстро", "оптимально", "подробно", "полный анализ"]
|
|
||||||
|
|
||||||
for i, count in enumerate(MSG_COUNTS):
|
|
||||||
row = LinearLayout(act)
|
|
||||||
row.setOrientation(LinearLayout.HORIZONTAL)
|
|
||||||
row.setGravity(Gravity.CENTER_VERTICAL)
|
|
||||||
row.setPadding(dp(16), dp(13), dp(16), dp(13))
|
|
||||||
row.setClickable(True)
|
|
||||||
row.setFocusable(True)
|
|
||||||
|
|
||||||
bg = GradientDrawable()
|
|
||||||
bg.setCornerRadius(dp(14))
|
|
||||||
try:
|
|
||||||
accent = Theme.getColor(Theme.key_windowBackgroundWhiteBlueHeader)
|
|
||||||
if i == 1:
|
|
||||||
bg.setColor(accent & 0x22FFFFFF | 0x15000000)
|
|
||||||
else:
|
|
||||||
bg.setColor(get_theme_color("key_windowBackgroundWhite", 0xFFF5F5F5))
|
|
||||||
except Exception:
|
|
||||||
bg.setColor(0xFFF5F5F5)
|
|
||||||
row.setBackground(bg)
|
|
||||||
|
|
||||||
label = TextView(act)
|
|
||||||
label.setText(str(count) + " сообщений")
|
|
||||||
label.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
|
|
||||||
label.setTextColor(get_theme_color("key_windowBackgroundWhiteBlackText", 0xFF212121))
|
|
||||||
try:
|
|
||||||
if i == 1:
|
|
||||||
label.setTypeface(AndroidUtilities.bold())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
lp_label = LinearLayout.LayoutParams(0, -2, 1.0)
|
|
||||||
row.addView(label, lp_label)
|
|
||||||
|
|
||||||
hint = TextView(act)
|
|
||||||
hint_text = count_hints[i] if i < len(count_hints) else ""
|
|
||||||
if i == 1:
|
|
||||||
hint_text = "⭐ " + hint_text
|
|
||||||
hint.setText(hint_text)
|
|
||||||
hint.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12)
|
|
||||||
hint.setTextColor(get_theme_color("key_windowBackgroundWhiteGrayText", 0xFF888888))
|
|
||||||
hint.setGravity(Gravity.END)
|
|
||||||
row.addView(hint)
|
|
||||||
|
|
||||||
n = count
|
|
||||||
row.setOnClickListener(OnClickListener(lambda v, c=n: pick(c)))
|
|
||||||
|
|
||||||
lp = LinearLayout.LayoutParams(-1, -2)
|
|
||||||
lp.bottomMargin = dp(8)
|
|
||||||
root.addView(row, lp)
|
|
||||||
|
|
||||||
sheet = BottomSheet.Builder(act).setCustomView(root).create()
|
|
||||||
sheet_holder[0] = sheet
|
|
||||||
sheet.show()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log("chat_summary: count picker error: " + str(e))
|
|
||||||
|
|
||||||
def fetch_and_summarize(self, act, dialog_id, count):
|
|
||||||
try:
|
|
||||||
from org.telegram.tgnet import TLRPC
|
|
||||||
|
|
||||||
req = TLRPC.TL_messages_getHistory()
|
|
||||||
req.peer = get_messages_controller().getInputPeer(int(dialog_id))
|
|
||||||
req.offset_id = 0
|
|
||||||
req.offset_date = 0
|
|
||||||
req.add_offset = 0
|
|
||||||
req.limit = count
|
|
||||||
req.max_id = 0
|
|
||||||
req.min_id = 0
|
|
||||||
req.hash = 0
|
|
||||||
|
|
||||||
self_ref = [self]
|
|
||||||
act_ref = [act]
|
|
||||||
|
|
||||||
def on_response(response, error):
|
|
||||||
if error:
|
|
||||||
err = getattr(error, "text", "unknown")
|
|
||||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Ошибка загрузки: " + err))
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
msgs = getattr(response, "messages", None)
|
|
||||||
if not msgs:
|
|
||||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Нет сообщений."))
|
|
||||||
return
|
|
||||||
|
|
||||||
users = getattr(response, "users", None)
|
|
||||||
chats = getattr(response, "chats", None)
|
|
||||||
text, used = build_transcript(msgs, users, chats)
|
|
||||||
|
|
||||||
if not text.strip():
|
|
||||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Текстовых сообщений не найдено."))
|
|
||||||
return
|
|
||||||
|
|
||||||
spinner = [None]
|
|
||||||
|
|
||||||
def start_spinner():
|
|
||||||
spinner[0] = show_spinner(act_ref[0])
|
|
||||||
|
|
||||||
run_on_ui_thread(start_spinner)
|
|
||||||
run_on_queue(
|
|
||||||
lambda: self_ref[0].run_ai(act_ref[0], text, used, spinner),
|
|
||||||
PLUGINS_QUEUE, 0,
|
|
||||||
)
|
|
||||||
except Exception as ex:
|
|
||||||
log("chat_summary: parse error: " + str(ex))
|
|
||||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Ошибка парсинга сообщений."))
|
|
||||||
|
|
||||||
send_request(req, on_response)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log("chat_summary: fetch error: " + str(e))
|
|
||||||
run_on_ui_thread(lambda: BulletinHelper.show_error("Не удалось начать загрузку."))
|
|
||||||
|
|
||||||
def run_ai(self, act, text, used, spinner):
|
|
||||||
try:
|
|
||||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
|
||||||
api_key = self.get_setting("api_key", "") or ""
|
|
||||||
model = self.get_setting("model", "") or ""
|
|
||||||
style = self.get_setting("style", STYLE_BRIEF)
|
|
||||||
lang = self.get_setting("lang", LANG_AUTO)
|
|
||||||
custom_url = self.get_setting("custom_endpoint", "") or ""
|
|
||||||
|
|
||||||
system = build_system_prompt(style, lang)
|
|
||||||
content = "Chat conversation to summarize:\n\n" + text
|
|
||||||
|
|
||||||
result = call_ai(provider, api_key, model, system, content, custom_url)
|
|
||||||
|
|
||||||
def on_done():
|
|
||||||
dismiss_dialog(spinner[0])
|
|
||||||
show_result(act, result, used, provider, model)
|
|
||||||
|
|
||||||
run_on_ui_thread(on_done)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
err = str(e)
|
|
||||||
self.last_error = "Ошибка в run_ai: " + err # ОБНОВЛЕНО: запись лога
|
|
||||||
log("chat_summary: ai error: " + err)
|
|
||||||
|
|
||||||
def on_error():
|
|
||||||
dismiss_dialog(spinner[0])
|
|
||||||
BulletinHelper.show_error("Ошибка AI: " + err[:120])
|
|
||||||
|
|
||||||
run_on_ui_thread(on_error)
|
|
||||||
|
|
||||||
def create_settings(self):
|
|
||||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
|
||||||
style = self.get_setting("style", STYLE_BRIEF)
|
|
||||||
api_key = self.get_setting("api_key", "") or ""
|
|
||||||
default_model = DEFAULT_MODELS[provider] if 0 <= provider < len(DEFAULT_MODELS) else ""
|
|
||||||
style_hint = STYLE_HINTS[style] if 0 <= style < len(STYLE_HINTS) else ""
|
|
||||||
|
|
||||||
key_valid, key_hint = check_key_format(provider, api_key)
|
|
||||||
key_icon = "msg_invite" if key_valid else "msg_secret"
|
|
||||||
key_subtext = ("✓ " if key_valid else "✗ ") + key_hint
|
|
||||||
|
|
||||||
items = [
|
|
||||||
Header(text="AI провайдер"),
|
|
||||||
Selector(
|
|
||||||
key="provider",
|
|
||||||
text="Провайдер",
|
|
||||||
default=PROVIDER_OPENAI,
|
|
||||||
items=PROVIDERS,
|
|
||||||
icon="msg_robot",
|
|
||||||
on_change=lambda idx: self.set_setting("provider", idx, reload_settings=True),
|
|
||||||
),
|
|
||||||
Input(
|
|
||||||
key="api_key",
|
|
||||||
text="API ключ",
|
|
||||||
subtext=key_subtext,
|
|
||||||
icon=key_icon,
|
|
||||||
default="",
|
|
||||||
on_change=lambda val: self.set_setting("api_key", val, reload_settings=True),
|
|
||||||
),
|
|
||||||
Input(
|
|
||||||
key="model",
|
|
||||||
text="Модель",
|
|
||||||
subtext="По умолчанию: " + default_model if default_model else "Укажи название модели",
|
|
||||||
icon="msg_topic",
|
|
||||||
default="",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
if provider == PROVIDER_CUSTOM:
|
|
||||||
items.append(Input(
|
|
||||||
key="custom_endpoint",
|
|
||||||
text="URL endpoint",
|
|
||||||
subtext="OpenAI-совместимый адрес",
|
|
||||||
icon="msg_link",
|
|
||||||
default="",
|
|
||||||
))
|
|
||||||
|
|
||||||
items.append(Text(
|
|
||||||
text="Проверить подключение",
|
|
||||||
subtext="Отправит тестовый запрос к API",
|
|
||||||
icon="msg_pin_code",
|
|
||||||
on_click=lambda v: self.run_connection_test(),
|
|
||||||
))
|
|
||||||
|
|
||||||
# ОБНОВЛЕНО: Добавлена кнопка сохранения отладочного лога
|
|
||||||
def action_copy_debug():
|
|
||||||
provider_str = PROVIDERS[provider] if 0 <= provider < len(PROVIDERS) else str(provider)
|
|
||||||
key_valid, key_hint = check_key_format(provider, api_key)
|
|
||||||
|
|
||||||
debug_text = (
|
|
||||||
"=== CHAT SUMMARY DEBUG LOG ===\n"
|
|
||||||
"Plugin Version: " + str(__version__) + "\n"
|
|
||||||
"SDK Version: " + str(__sdk_version__) + "\n"
|
|
||||||
"Provider: " + str(provider_str) + "\n"
|
|
||||||
"Model: " + str(self.get_setting("model", "")) + "\n"
|
|
||||||
"Has Custom Endpoint: " + str(bool(self.get_setting("custom_endpoint", ""))) + "\n"
|
|
||||||
"API Key Valid Format: " + str(key_valid) + " (" + str(key_hint) + ")\n"
|
|
||||||
"Style: " + str(self.get_setting("style", 0)) + "\n"
|
|
||||||
"Lang: " + str(self.get_setting("lang", 0)) + "\n"
|
|
||||||
"Last Recorded Error: " + str(getattr(self, "last_error", "None")) + "\n"
|
|
||||||
"=============================="
|
|
||||||
)
|
|
||||||
copy_to_clipboard(debug_text)
|
|
||||||
BulletinHelper.show_success("Дебаг-лог скопирован в буфер!")
|
|
||||||
|
|
||||||
items.append(Text(
|
|
||||||
text="Скопировать дебаг-лог",
|
|
||||||
subtext="Скопирует статус плагина и последнюю ошибку для отправки разработчику",
|
|
||||||
icon="msg_edit",
|
|
||||||
on_click=lambda v: action_copy_debug(),
|
|
||||||
))
|
|
||||||
|
|
||||||
items += [
|
|
||||||
Divider(),
|
|
||||||
Header(text="Саммари"),
|
|
||||||
Selector(
|
|
||||||
key="style",
|
|
||||||
text="Стиль",
|
|
||||||
default=STYLE_BRIEF,
|
|
||||||
items=STYLES,
|
|
||||||
icon="msg_list",
|
|
||||||
on_change=lambda idx: self.set_setting("style", idx, reload_settings=True),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
text=style_hint,
|
|
||||||
icon="msg_info",
|
|
||||||
),
|
|
||||||
Selector(
|
|
||||||
key="lang",
|
|
||||||
text="Язык ответа",
|
|
||||||
default=LANG_AUTO,
|
|
||||||
items=LANGS,
|
|
||||||
icon="msg_translate",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
def run_connection_test(self):
|
|
||||||
provider = self.get_setting("provider", PROVIDER_OPENAI)
|
|
||||||
api_key = self.get_setting("api_key", "") or ""
|
|
||||||
model = self.get_setting("model", "") or ""
|
|
||||||
custom_url = self.get_setting("custom_endpoint", "") or ""
|
|
||||||
|
|
||||||
if provider != PROVIDER_OLLAMA and not api_key.strip():
|
|
||||||
BulletinHelper.show_error("Сначала укажи API ключ")
|
|
||||||
return
|
|
||||||
|
|
||||||
BulletinHelper.show_info("Проверяю подключение…")
|
|
||||||
|
|
||||||
def worker():
|
|
||||||
ok, message = test_connection(provider, api_key, model, custom_url)
|
|
||||||
# ОБНОВЛЕНО: Если тест провалился, записываем сообщение в лог
|
|
||||||
if not ok:
|
|
||||||
self.last_error = "Ошибка теста связи: " + message
|
|
||||||
|
|
||||||
def show():
|
|
||||||
if ok:
|
|
||||||
BulletinHelper.show_success(message)
|
|
||||||
else:
|
|
||||||
BulletinHelper.show_error(message)
|
|
||||||
|
|
||||||
run_on_ui_thread(show)
|
|
||||||
|
|
||||||
run_on_queue(worker, PLINS_QUEUE if 'PLINS_QUEUE' in globals() else PLUGINS_QUEUE, 0)
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
AI Chat Summaries Plugin for exteraGram
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["config", "localization"]
|
||||||
+178
@@ -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
|
||||||
@@ -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("<none>")
|
||||||
|
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 ["<empty>"])
|
||||||
|
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 "<none>"))
|
||||||
|
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 "<NONE>"))
|
||||||
|
|
||||||
|
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 "<NONE>"))
|
||||||
|
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("<none recorded - hooks never ran>")
|
||||||
|
|
||||||
|
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("<none>")
|
||||||
|
|
||||||
|
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("<empty>")
|
||||||
|
|
||||||
|
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", "<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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Hooks module exports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .pinned_hook import PinnedHeaderHook
|
||||||
|
from .unread_hook import UnreadBadgeHook
|
||||||
|
|
||||||
|
__all__ = ["PinnedHeaderHook", "UnreadBadgeHook"]
|
||||||
@@ -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
|
||||||
@@ -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 "<none>"
|
||||||
|
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 "<NONE FOUND>")
|
||||||
|
record_fact("unread.scroll_methods_hooked", scroll_names or "<NONE FOUND>")
|
||||||
|
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)
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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 "<empty>")
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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 "<think>" in c_chunk:
|
||||||
|
in_think_tag = True
|
||||||
|
parts = c_chunk.split("<think>", 1)
|
||||||
|
c_chunk = parts[0]
|
||||||
|
t_chunk += parts[1]
|
||||||
|
if "</think>" in c_chunk:
|
||||||
|
in_think_tag = False
|
||||||
|
parts = c_chunk.split("</think>", 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
|
||||||
@@ -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 <think>...</think> in content
|
||||||
|
if "<think>" in content_chunk:
|
||||||
|
in_think_tag = True
|
||||||
|
parts = content_chunk.split("<think>", 1)
|
||||||
|
content_chunk = parts[0]
|
||||||
|
thought_chunk += parts[1]
|
||||||
|
if "</think>" in content_chunk:
|
||||||
|
in_think_tag = False
|
||||||
|
parts = content_chunk.split("</think>", 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 <think> tag if present in non-stream output
|
||||||
|
if "<think>" in full_content and "</think>" in full_content:
|
||||||
|
match = re.search(r"<think>(.*?)</think>", full_content, flags=re.DOTALL)
|
||||||
|
if match:
|
||||||
|
full_thought = match.group(1).strip()
|
||||||
|
full_content = re.sub(r"<think>.*?</think>", "", 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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Services module exports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .message_fetcher import MessageFetcher, FormattedMessage
|
||||||
|
|
||||||
|
__all__ = ["MessageFetcher", "FormattedMessage"]
|
||||||
@@ -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]"
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Test suite package.
|
||||||
|
"""
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user