.
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