Files
2026-09-10 15:02:25 +02:00

485 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", "summary_db.py"),
os.path.join(SRC_DIR, "services", "summary_injector.py"),
os.path.join(SRC_DIR, "services", "message_fetcher.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, "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,
dual triggers.
"""
__id__ = "ai_chat_summaries"
__name__ = "AI Chat Summaries"
__description__ = "Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, 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.pinned_hook = PinnedHeaderHook(self)
self.unread_hook = UnreadBadgeHook(self)
self.summary_injector = ChatSummaryInjector(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,
)
)
except Exception as exc:
record_error("on_plugin_load:add_menu_item", exc)
self.pinned_hook.install_hook()
self.unread_hook.install_hook()
self.summary_injector.install_hook()
record_fact(
"plugin.hooks_installed",
"pinned=%d unread=%d injector=%d" % (
len(self.pinned_hook.unhook_refs),
len(self.unread_hook.unhook_refs),
len(self.summary_injector.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.summary_injector.uninstall_hook()
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()
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:
latest_id = messages[-1].id if messages else 0
# 1. Save in local SQLite DB and inject rich client-side message directly into chat
try:
if latest_id:
self.summary_injector.save_and_inject_summary(
dialog_id=dialog_id,
anchor_msg_id=latest_id,
count=len(messages),
offset=offset,
summary_text=response.text,
provider_name=response.provider_name,
model_name=response.model,
topic_id=topic_id,
chat_activity=chat_act,
)
except Exception as exc:
record_error("pipeline.inject_summary", exc)
# 2. Show native success bulletin notification and jump to latest analyzed message
try:
BulletinHelper.show_success(
locali.get("RESULT_HEADER_INFO", provider=response.provider_name, model=response.model, count=len(messages), time=elapsed),
chat_act,
)
except Exception:
pass
# Scroll to the latest message in summary
try:
if latest_id and chat_act:
scroll_to_message_in_chat(chat_act, latest_id, topic_id)
except Exception:
pass
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, sender cache, and pinned header layout."""
self.is_processing = False
try:
self.message_fetcher.clear_sender_cache()
except Exception as exc:
record_error("pipeline.clear_sender_cache", exc)
'''
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(DIST_PLUGIN_FILE, "w", encoding="utf-8") as out:
out.write(final_content)
print(f"Bundled successfully -> {DIST_PLUGIN_FILE}")
# Validate syntax with py_compile
py_compile.compile(DIST_PLUGIN_FILE, doraise=True)
print("Compiled and validated bundled artifact syntax successfully!")
if __name__ == "__main__":
bundle_plugin()