#!/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()