.
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user