491 lines
18 KiB
Python
Executable File
491 lines
18 KiB
Python
Executable File
#!/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()
|