This commit is contained in:
lyuksovannyy
2026-09-10 12:48:56 +02:00
parent 90678506c6
commit bd34e7b8ba
35 changed files with 7059 additions and 4076 deletions
+31
View File
@@ -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>
+8
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+10
View File
@@ -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>
+8
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
Executable
+490
View File
@@ -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

-884
View File
@@ -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 = [
"23 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
+5
View File
@@ -0,0 +1,5 @@
"""
AI Chat Summaries Plugin for exteraGram
"""
__all__ = ["config", "localization"]
+178
View File
@@ -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
+447
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
"""
Hooks module exports.
"""
from .pinned_hook import PinnedHeaderHook
from .unread_hook import UnreadBadgeHook
__all__ = ["PinnedHeaderHook", "UnreadBadgeHook"]
+312
View File
@@ -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
+536
View File
@@ -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)
+339
View File
@@ -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()
+28
View File
@@ -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",
]
+258
View File
@@ -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
+684
View File
@@ -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
+373
View File
@@ -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
+170
View File
@@ -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
+438
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
"""
Services module exports.
"""
from .message_fetcher import MessageFetcher, FormattedMessage
__all__ = ["MessageFetcher", "FormattedMessage"]
+431
View File
@@ -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]"
+20
View File
@@ -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",
]
+318
View File
@@ -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)
+164
View File
@@ -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
+631
View File
@@ -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()
+282
View File
@@ -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
+140
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""
Test suite package.
"""
+694
View File
@@ -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()