diff --git a/.idea/misc.xml b/.idea/misc.xml index 06fdc9d..590a59e 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,8 +3,5 @@ - - \ No newline at end of file diff --git a/build.py b/build.py index 871dbd4..d87c613 100755 --- a/build.py +++ b/build.py @@ -24,12 +24,12 @@ MODULE_ORDER = [ 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", "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"), ] @@ -38,12 +38,12 @@ 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. +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." +__description__ = "Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, and dual triggers." __author__ = "@exteraGramDev" __version__ = "3.0.1" __icon__ = "msg_bot" @@ -161,9 +161,9 @@ class AIChatSummariesPlugin(BasePlugin): 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.summary_injector = ChatSummaryInjector(self) self.is_processing = False def on_plugin_load(self) -> None: @@ -181,29 +181,18 @@ class AIChatSummariesPlugin(BasePlugin): 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() + self.summary_injector.install_hook() record_fact( "plugin.hooks_installed", - "pinned=%d unread=%d" % ( + "pinned=%d unread=%d injector=%d" % ( len(self.pinned_hook.unhook_refs), len(self.unread_hook.unhook_refs), + len(self.summary_injector.unhook_refs), ), ) @@ -211,8 +200,7 @@ class AIChatSummariesPlugin(BasePlugin): """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 + self.summary_injector.uninstall_hook() uninstall_uncaught_exception_hooks() # Aliases for alternate SDK lifecycle naming @@ -306,9 +294,6 @@ class AIChatSummariesPlugin(BasePlugin): 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: @@ -372,27 +357,40 @@ class AIChatSummariesPlugin(BasePlugin): 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 ''}\\n\\n" - f"=== System Prompt ===\\n{global_prompt or ''}\\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, - ) + + # 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"), @@ -416,14 +414,14 @@ class AIChatSummariesPlugin(BasePlugin): self._finish_pipeline() record_error("pipeline.fetch_history", exc) def _finish_pipeline(self) -> None: - """Cleans up in-progress state and resets pinned header layout.""" + """Cleans up in-progress state, sender cache, and pinned header layout.""" self.is_processing = False - def _ui_clean(): - self.progress_manager.hide_progress() - run_on_ui_thread(_ui_clean) + 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() diff --git a/src/config.py b/src/config.py index cfd588f..f2a2896 100644 --- a/src/config.py +++ b/src/config.py @@ -9,7 +9,7 @@ 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." + "and dual triggers." ) PLUGIN_AUTHOR = "@exteraGramDev" PLUGIN_VERSION = "3.0.1" @@ -139,11 +139,10 @@ KEY_PREFIXES = { 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_ENABLE_THINKING_STREAM = "enable_thinking_stream" SETTING_OAUTH_ACCESS_TOKEN = "oauth_access_token" SETTING_OAUTH_REFRESH_TOKEN = "oauth_refresh_token" diff --git a/src/hooks/pinned_hook.py b/src/hooks/pinned_hook.py index 70b824f..b5b924a 100644 --- a/src/hooks/pinned_hook.py +++ b/src/hooks/pinned_hook.py @@ -205,8 +205,6 @@ class PinnedHeaderHook: 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"): diff --git a/src/localization.py b/src/localization.py index 0fecaa9..9d091bd 100644 --- a/src/localization.py +++ b/src/localization.py @@ -11,8 +11,7 @@ class LocalizationManager: _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.", + "PLUGIN_DESC": "Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, and dual triggers.", # Settings - General & Triggers "SETTINGS_HEADER_GENERAL": "General & Triggers", @@ -20,8 +19,6 @@ class LocalizationManager: "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", @@ -86,17 +83,16 @@ class LocalizationManager: "SETTINGS_GLOBAL_PROMPT": "Global System Prompt Override", "SETTINGS_GLOBAL_PROMPT_SUB": "Leave blank to use built-in style prompt instructions.", - # Diagnostics + # Diagnostics and local summary storage + "SETTINGS_HEADER_SUMMARIES": "Local Summaries", + "SETTINGS_CLEAR_SUMMARIES": "Clear Saved Summaries", + "SETTINGS_CLEAR_SUMMARIES_SUB": "Deletes client-side summary messages saved on this device.", + "SETTINGS_SUMMARIES_CLEARED": "Cleared {count} saved summaries.", "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_ERRORS_SUB": "Copies errors with timestamps 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!", + "SETTINGS_COPY_LOGS_SUB": "Copies the latest timestamped plugin log lines.", # Pre-Request Bottom Sheet "PRE_REQ_TITLE": "AI Chat Summary", "PRE_REQ_SUBTITLE": "Chat: {title}", @@ -115,16 +111,6 @@ class LocalizationManager: "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", @@ -164,8 +150,6 @@ class LocalizationManager: "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 Провайдера", @@ -233,15 +217,13 @@ class LocalizationManager: # Diagnostics "SETTINGS_HEADER_DIAGNOSTICS": "Диагностика", "SETTINGS_COPY_ERRORS": "Скопировать последние ошибки", - "SETTINGS_COPY_ERRORS_SUB": "Копирует ошибки с временем, потоком, местом, сообщением и полным traceback.", + "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 + "SETTINGS_COPY_LOGS_SUB": "Копирует последние строки логов плагина.", + "SETTINGS_HEADER_SUMMARIES": "Локальные саммари", + "SETTINGS_CLEAR_SUMMARIES": "Очистить сохранённые саммари", + "SETTINGS_CLEAR_SUMMARIES_SUB": "Удаляет клиентские AI-сообщения, сохранённые на устройстве.", + "SETTINGS_SUMMARIES_CLEARED": "Удалено сохранённых саммари: {count}.", "PRE_REQ_TITLE": "AI Саммари Чата", "PRE_REQ_SUBTITLE": "Чат: {title}", "PRE_REQ_COUNT_LABEL": "Сообщений для анализа: {count}", @@ -260,16 +242,6 @@ class LocalizationManager: "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}с", diff --git a/src/providers/base.py b/src/providers/base.py index cee500f..d88a251 100644 --- a/src/providers/base.py +++ b/src/providers/base.py @@ -155,7 +155,10 @@ def build_system_prompt( 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." + "and objective summary. " + "When referring to a participant, preserve the exact sender placeholder " + " from the transcript; never replace it with a name. " + "The client will convert placeholders into Telegram mentions after completion." ) # Style instructions diff --git a/src/providers/dispatcher.py b/src/providers/dispatcher.py index facd19b..18a15e4 100644 --- a/src/providers/dispatcher.py +++ b/src/providers/dispatcher.py @@ -12,8 +12,6 @@ from ..config import ( PROVIDER_CUSTOM, PROVIDER_GEMINI, PROVIDER_OLLAMA, - PROVIDER_OPENAI, - SETTING_ENABLE_THINKING_STREAM, SETTING_PROVIDER, SETTING_SUMMARY_LANG, SETTING_SUMMARY_STYLE, @@ -42,18 +40,6 @@ class UnifiedDispatcher: 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.""" @@ -72,46 +58,7 @@ class UnifiedDispatcher: 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, @@ -122,47 +69,23 @@ class UnifiedDispatcher: lang: Optional[int] = None, global_prompt: str = "", ) -> ProviderResponse: - """Executes full summarization request with live state management.""" + """Executes the summarization request without exposing live thinking state.""" 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( + return 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, + enable_stream=False, + stream_callback=None, ) + except Exception as exc: + return ProviderResponse(success=False, error=str(exc)) def _get_setting(self, key: str, default: Any = None) -> Any: if self.plugin and hasattr(self.plugin, "get_setting"): diff --git a/src/services/message_fetcher.py b/src/services/message_fetcher.py index eaaec16..cf9d9c9 100644 --- a/src/services/message_fetcher.py +++ b/src/services/message_fetcher.py @@ -11,7 +11,7 @@ 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.""" + """Represents a sanitized Telegram message and stable sender identity.""" id: int date: int time_str: str @@ -21,9 +21,10 @@ class FormattedMessage: reply_to_id: Optional[int] = None forward_from: Optional[str] = None media_info: Optional[str] = None + sender_id: Optional[int] = None def to_transcript_line(self) -> str: - """Formats into a clean transcript line for AI summarization.""" + """Formats a transcript using IDs, never mutable account display names.""" meta_parts = [] if self.forward_from: meta_parts.append(f"fwd: {self.forward_from}") @@ -31,23 +32,24 @@ class FormattedMessage: 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}" + content = f"{self.media_info}\n{content}" if content else self.media_info + sender = f"" if self.sender_id is not None else "" + return f"[{self.time_str}] #{self.id} {sender}{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 + self._sender_cache: Dict[int, Any] = {} + self._chat_cache: Dict[int, Any] = {} + def clear_sender_cache(self) -> None: + """Drops the per-summarization entity cache after the pipeline completes.""" + self._sender_cache.clear() + self._chat_cache.clear() def fetch_history( self, dialog_id: int, @@ -56,10 +58,10 @@ class MessageFetcher: 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) - + self._sender_cache.clear() + self._chat_cache.clear() try: from client_utils import PLUGINS_QUEUE, run_on_queue run_on_queue( @@ -114,10 +116,12 @@ class MessageFetcher: 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 messages_controller is None: + raise ValueError("Telegram messages controller is unavailable") + peer = self._resolve_input_peer(messages_controller, TLRPC, dialog_id) + if peer is None: + raise ValueError("Unable to resolve Telegram input peer for dialog %s" % dialog_id) if topic_id and topic_id != 0: # Forum thread / replies req = TLRPC.TL_messages_getReplies() @@ -156,35 +160,43 @@ class MessageFetcher: return if not response: + record_fact("fetch.response", "empty response dialog=%s offset=%s" % (dialog_id, offset_id)) formatted = self._process_messages(accumulated_raw, user_map, chat_map) if callback: callback(formatted, None) return - - # Update users and chats dictionaries + # Update users and chats dictionaries and retain them for this run. 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) - + self._sender_cache.update(new_users) + self._chat_cache.update(new_chats) + try: + from org.telegram.messenger import MessagesController + controller = MessagesController.getInstance(0) + for sender in new_users.values(): + controller.putUser(sender, False) + for chat in new_chats.values(): + controller.putChat(chat, False) + except Exception: + pass 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 - + msg_list = self._to_list(raw_messages) + if not msg_list and raw_messages is not None: + record_fact("fetch.response", "zero messages dialog=%s response=%s container=%s" % (dialog_id, type(response).__name__, type(raw_messages).__name__ if raw_messages is not None else "None")) if not msg_list: - # End of chat history reached + # A first empty page can occur when the dialog cache is stale. + if not accumulated_raw and offset_id == 0 and add_offset: + self._fetch_messages_paginated( + dialog_id, target_count, topic_id, 0, 0, + accumulated_raw, user_map, chat_map, callback + ) + return 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) @@ -214,25 +226,70 @@ class MessageFetcher: except Exception as e: if callback: callback([], f"Fetch setup failed: {str(e)}") + return + @staticmethod + def _resolve_input_peer(controller: Any, tlrpc: Any, dialog_id: int) -> Any: + """Use Telegram's canonical dialog resolver before manual peer fallback.""" + value = int(dialog_id) + try: + peer = controller.getInputPeer(value) + if peer is not None: + return peer + except Exception: + pass + if value > 0: + try: + user = controller.getUser(value) + except Exception: + user = None + if user is None: + return None + peer = tlrpc.TL_inputPeerUser() + peer.user_id = value + peer.access_hash = int(getattr(user, "access_hash", 0) or 0) + return peer + chat_id = abs(value) + try: + chat = controller.getChat(chat_id) + except Exception: + chat = None + if chat is None: + return None + is_channel = bool(getattr(chat, "megagroup", False) or getattr(chat, "broadcast", False)) + if value <= -1000000000000 or is_channel: + peer = tlrpc.TL_inputPeerChannel() + peer.channel_id = int(getattr(chat, "id", chat_id) or chat_id) + peer.access_hash = int(getattr(chat, "access_hash", 0) or 0) + return peer + peer = tlrpc.TL_inputPeerChat() + peer.chat_id = chat_id + return peer + + + @staticmethod + def _to_list(value: Any) -> List[Any]: + """Converts Java lists, Python lists, and iterable Telegram vectors.""" + if value is None: + return [] + try: + size = value.size() + return [value.get(i) for i in range(size)] + except Exception: + pass + if isinstance(value, (list, tuple)): + return list(value) + try: + return list(value) + except Exception: + return [] def _parse_java_list(self, java_list: Any) -> Dict[int, Any]: - """Converts TLRPC ArrayList of objects with .id into a python dictionary.""" + """Converts Java, Python, or iterable Telegram entity vectors.""" 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 + for item in self._to_list(java_list): + item_id = getattr(item, "id", None) + if item_id is not None: + result[int(item_id)] = item return result def _process_messages( @@ -261,7 +318,6 @@ class MessageFetcher: 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]" @@ -277,13 +333,10 @@ class MessageFetcher: reply_to_id=reply_id, forward_from=fwd_info, media_info=media_info, + sender_id=self._sender_id(raw), ) ) - # 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, @@ -300,10 +353,9 @@ class MessageFetcher: 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) + user = user_map.get(uid) or self._sender_cache.get(uid) if user: first = getattr(user, "first_name", "") or "" last = getattr(user, "last_name", "") or "" @@ -335,6 +387,18 @@ class MessageFetcher: return "Unknown", "" + @staticmethod + def _sender_id(raw_msg: Any) -> Optional[int]: + """Returns the stable Telegram peer ID for transcript placeholders.""" + peer = getattr(raw_msg, "from_id", None) or getattr(raw_msg, "peer_id", None) + if peer is None: + return None + for field in ("user_id", "channel_id", "chat_id"): + value = getattr(peer, field, None) + if value is not None: + return int(value) + return None + 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) diff --git a/src/services/summary_db.py b/src/services/summary_db.py new file mode 100644 index 0000000..87b8f3a --- /dev/null +++ b/src/services/summary_db.py @@ -0,0 +1,227 @@ +""" +Local SQLite database service for storing and querying AI Chat Summaries. +""" + +import json +import os +import sqlite3 +import time +from typing import Any, Dict, List, Optional +from dataclasses import dataclass + + +@dataclass +class StoredSummary: + id: int + dialog_id: int + topic_id: int + anchor_msg_id: int + count: int + offset: int + summary_text: str + provider_name: str + model_name: str + created_at: float + meta_json: str + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "dialog_id": self.dialog_id, + "topic_id": self.topic_id, + "anchor_msg_id": self.anchor_msg_id, + "count": self.count, + "offset": self.offset, + "summary_text": self.summary_text, + "provider_name": self.provider_name, + "model_name": self.model_name, + "created_at": self.created_at, + "meta_json": self.meta_json, + } + + +class SummaryDatabase: + """Manages SQLite storage for local client-side AI chat summaries.""" + + def __init__(self, db_path: Optional[str] = None) -> None: + if not db_path: + db_path = self._resolve_default_db_path() + self.db_path = db_path + self._init_db() + + def _resolve_default_db_path(self) -> str: + try: + from file_utils import get_files_dir + base_dir = get_files_dir() + target_dir = os.path.join(base_dir, "ai_chat_summaries") + os.makedirs(target_dir, exist_ok=True) + return os.path.join(target_dir, "summaries.db") + except Exception: + # Fallback to local user or session directory + local_dir = os.path.join(os.path.expanduser("~"), ".exteragram_ai_summaries") + os.makedirs(local_dir, exist_ok=True) + return os.path.join(local_dir, "summaries.db") + + def _get_connection(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, timeout=10.0) + conn.row_factory = sqlite3.Row + return conn + + def _init_db(self) -> None: + try: + with self._get_connection() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS chat_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dialog_id INTEGER NOT NULL, + topic_id INTEGER NOT NULL DEFAULT 0, + anchor_msg_id INTEGER NOT NULL, + count INTEGER NOT NULL, + offset INTEGER NOT NULL DEFAULT 0, + summary_text TEXT NOT NULL, + provider_name TEXT NOT NULL, + model_name TEXT NOT NULL, + created_at REAL NOT NULL, + meta_json TEXT NOT NULL DEFAULT '{}' + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_summaries_dialog_msg + ON chat_summaries(dialog_id, anchor_msg_id) + """) + conn.commit() + except Exception: + pass + + def insert_summary( + self, + dialog_id: int, + anchor_msg_id: int, + count: int, + summary_text: str, + provider_name: str, + model_name: str, + offset: int = 0, + topic_id: int = 0, + meta: Optional[Dict[str, Any]] = None, + ) -> int: + """Saves generated summary into SQLite db and returns inserted record ID.""" + now = time.time() + meta_str = json.dumps(meta or {}, ensure_ascii=False) + with self._get_connection() as conn: + cursor = conn.execute( + """ + INSERT INTO chat_summaries ( + dialog_id, topic_id, anchor_msg_id, count, offset, + summary_text, provider_name, model_name, created_at, meta_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + dialog_id, + topic_id, + anchor_msg_id, + count, + offset, + summary_text, + provider_name, + model_name, + now, + meta_str, + ), + ) + conn.commit() + return cursor.lastrowid + + def get_summaries_for_dialog( + self, + dialog_id: int, + topic_id: int = 0, + ) -> List[StoredSummary]: + """Retrieves all stored summaries for a given chat/dialog.""" + with self._get_connection() as conn: + cursor = conn.execute( + """ + SELECT id, dialog_id, topic_id, anchor_msg_id, count, offset, + summary_text, provider_name, model_name, created_at, meta_json + FROM chat_summaries + WHERE dialog_id = ? AND topic_id = ? + ORDER BY created_at ASC + """, + (dialog_id, topic_id), + ) + rows = cursor.fetchall() + return [ + StoredSummary( + id=row["id"], + dialog_id=row["dialog_id"], + topic_id=row["topic_id"], + anchor_msg_id=row["anchor_msg_id"], + count=row["count"], + offset=row["offset"], + summary_text=row["summary_text"], + provider_name=row["provider_name"], + model_name=row["model_name"], + created_at=row["created_at"], + meta_json=row["meta_json"], + ) + for row in rows + ] + + def get_summary_by_anchor( + self, + dialog_id: int, + anchor_msg_id: int, + ) -> Optional[StoredSummary]: + """Finds summary attached to a specific anchor message ID.""" + with self._get_connection() as conn: + cursor = conn.execute( + """ + SELECT id, dialog_id, topic_id, anchor_msg_id, count, offset, + summary_text, provider_name, model_name, created_at, meta_json + FROM chat_summaries + WHERE dialog_id = ? AND anchor_msg_id = ? + ORDER BY id DESC LIMIT 1 + """, + (dialog_id, anchor_msg_id), + ) + row = cursor.fetchone() + if not row: + return None + return StoredSummary( + id=row["id"], + dialog_id=row["dialog_id"], + topic_id=row["topic_id"], + anchor_msg_id=row["anchor_msg_id"], + count=row["count"], + offset=row["offset"], + summary_text=row["summary_text"], + provider_name=row["provider_name"], + model_name=row["model_name"], + created_at=row["created_at"], + meta_json=row["meta_json"], + ) + + def delete_summary(self, summary_id: int) -> bool: + """Deletes a summary record by ID.""" + with self._get_connection() as conn: + cursor = conn.execute("DELETE FROM chat_summaries WHERE id = ?", (summary_id,)) + conn.commit() + return cursor.rowcount > 0 + def clear_summaries(self, dialog_id: Optional[int] = None, topic_id: Optional[int] = None) -> int: + """Deletes stored client summaries, optionally limited to one dialog/topic.""" + with self._get_connection() as conn: + if dialog_id is None: + cursor = conn.execute("DELETE FROM chat_summaries") + elif topic_id is None: + cursor = conn.execute("DELETE FROM chat_summaries WHERE dialog_id = ?", (dialog_id,)) + else: + cursor = conn.execute( + "DELETE FROM chat_summaries WHERE dialog_id = ? AND topic_id = ?", + (dialog_id, topic_id), + ) + conn.commit() + return cursor.rowcount + + +# Singleton database instance +summary_db = SummaryDatabase() diff --git a/src/services/summary_injector.py b/src/services/summary_injector.py new file mode 100644 index 0000000..154a650 --- /dev/null +++ b/src/services/summary_injector.py @@ -0,0 +1,451 @@ +""" +Client-side Chat Summary message injector. +Injects formatted AI summaries directly into the message list after the latest analyzed message +as a distinct client-side rich message card (with bot avatar, custom badge, and local indicator). +""" + +import time +from typing import Any, Dict, List, Optional +import weakref + +from ..services.summary_db import summary_db, StoredSummary +from ..diagnostics import record_error, record_fact + +import re + +_SENDER_PLACEHOLDER_RE = re.compile(r"") + + +def replace_sender_ids_with_mentions(text: str, account: int, tlrpc: Any) -> tuple: + """Replaces AI sender placeholders with Telegram Markdown mention links.""" + try: + from org.telegram.messenger import MessagesController + controller = MessagesController.getInstance(account) + except Exception as exc: + record_error("injector.resolve_mentions", exc) + return text, [] + + def resolve_name(sender_id: int) -> Optional[str]: + try: + user = controller.getUser(sender_id) + try: + cached = controller.getUser(sender_id) + if cached is not None: + controller.putUser(cached, False) + except Exception as exc: + record_error("injector.cache_mention_user", exc) + if user is None: + return None + first = getattr(user, "first_name", "") or "" + last = getattr(user, "last_name", "") or "" + return (f"{first} {last}").strip() or f"User {sender_id}" + except Exception as exc: + record_error("injector.resolve_mention", exc) + return None + + def replace(match: Any) -> str: + sender_id = int(match.group(1)) + if sender_id <= 0: + return match.group(0) + display_name = resolve_name(sender_id) + if not display_name: + return match.group(0) + # This is intentionally Markdown, not a MessageEntity. The summary is + # parsed after this step, so Telegram receives a clickable mention link. + safe_name = display_name.replace("\\", "\\\\").replace("]", "\\]") + return f"[{safe_name}](tg://user?id={sender_id})" + + return _SENDER_PLACEHOLDER_RE.sub(replace, text), [] + + + +def summary_virtual_id(summary_id: int, anchor_msg_id: int) -> int: + """Returns the stable negative ID used for a local summary message.""" + return -abs(int(anchor_msg_id) * 1000 + int(summary_id % 1000)) + + +def build_summary_message_text(summary: StoredSummary) -> str: + """Formats the summary header and Markdown body for Telegram rendering.""" + return "🤖 AI CHAT SUMMARY\nSummary for %s messages:\n%s · %s\n🔒 Client-Side Only · Local AI Summary\n━━━━━━━━━━━━━━━━━━━━\n\n%s" % ( + summary.count, + summary.provider_name, + summary.model_name, + summary.summary_text, + ) + + +def parse_summary_markdown(text: str) -> tuple: + """Parses summary Markdown, including tg:// user mention links.""" + try: + from markdown_utils import parse_markdown + parsed = parse_markdown(text) + if isinstance(parsed, dict): + return parsed.get("message", parsed.get("text", text)), parsed.get("entities", []) + parsed_text = getattr(parsed, "text", None) + if parsed_text is not None: + return str(parsed_text), list(getattr(parsed, "entities", ()) or ()) + return str(parsed), [] + except Exception as exc: + record_error("injector.parse_markdown", exc) + return text, [] + +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): + 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 [] + + + +class ChatSummaryInjector: + """Manages local in-chat injection of AI summaries after anchor messages.""" + + LIFECYCLE_METHODS = frozenset(( + "createView", + "onResume", + "processLoadedMessages", + "updateChatList", + "updateMessagesVisiblePart", + )) + + 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: + """Hooks ChatActivity to interleave client-side summaries.""" + 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 + injector_self = self + + chat_act_class = find_class("org.telegram.ui.ChatActivity") + record_fact("injector.ChatActivity_found", bool(chat_act_class)) + if chat_act_class: + class _ChatActMessagesHook(XposedHook): + def after_hooked_method(self, param): + try: + chat_act = getattr(param, "thisObject", None) + if not chat_act: + return + injector_self._inject_into_chat_activity(chat_act) + except Exception as exc: + record_error("injector.chat_act_hook", exc) + + hook = _ChatActMessagesHook() + can_hook = bool(self.plugin and hasattr(self.plugin, "hook_method")) + hooked_count = 0 + for m in find_methods_by_name(chat_act_class, self.LIFECYCLE_METHODS): + if not can_hook: + break + try: + ref = self.plugin.hook_method(m, hook) + if ref: + self.unhook_refs.append(ref) + hooked_count += 1 + except Exception as exc: + record_error("injector.hook_method", exc) + record_fact("injector.lifecycle_hooked", hooked_count) + + except Exception as exc: + record_error("injector.install_hook", exc) + + def uninstall_hook(self) -> None: + 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() + self._installed = False + + def save_and_inject_summary( + self, + dialog_id: int, + anchor_msg_id: int, + count: int, + offset: int, + summary_text: str, + provider_name: str, + model_name: str, + topic_id: int = 0, + chat_activity: Optional[Any] = None, + ) -> int: + """Saves summary in SQLite DB, immediately renders rich client message, and navigates to it.""" + summary_id = summary_db.insert_summary( + dialog_id=dialog_id, + anchor_msg_id=anchor_msg_id, + count=count, + offset=offset, + summary_text=summary_text, + provider_name=provider_name, + model_name=model_name, + topic_id=topic_id, + meta={"is_client_side": True, "version": "3.0.1"}, + ) + record_fact("injector.summary_saved", f"id={summary_id} dialog={dialog_id} anchor={anchor_msg_id}") + + # Inject into chat view and scroll to anchor + try: + from client_utils import get_last_fragment + from android_utils import run_on_ui_thread + + act = chat_activity or get_last_fragment() + if act: + def _do_inject_and_scroll(): + self._inject_into_chat_activity(act) + try: + from ..ui.summary_dialog import scroll_to_message_in_chat + scroll_to_message_in_chat(act, anchor_msg_id, topic_id) + except Exception: + pass + + run_on_ui_thread(_do_inject_and_scroll) + except Exception as exc: + record_error("injector.save_and_inject", exc) + + return summary_id + + def build_client_side_summary_message( + self, + summary: StoredSummary, + current_account: int = 0, + ) -> Any: + """Build a native Telegram message object for a locally rendered summary.""" + try: + from org.telegram.tgnet import TLRPC + from org.telegram.messenger import MessageObject, UserConfig + + account = current_account or getattr(UserConfig, "selectedAccount", 0) + virtual_id = summary_virtual_id(summary.id, summary.anchor_msg_id) + full_text = build_summary_message_text(summary) + + # Create a local-only message whose reply header points to the + # latest message included in this summary. Telegram renders this + # as the normal reply preview and keeps navigation local. + tl_msg = TLRPC.TL_message() + tl_msg.id = virtual_id + tl_msg.dialog_id = int(summary.dialog_id) + tl_msg.message = full_text + tl_msg.date = int(summary.created_at) + rendered_text, mention_entities = replace_sender_ids_with_mentions(full_text, account, TLRPC) + parsed_text, parsed_entities = parse_summary_markdown(rendered_text) + tl_msg.message = parsed_text + try: + tl_msg.entities = list(parsed_entities or []) + mention_entities + except Exception: + pass + try: + reply_header = TLRPC.TL_messageReplyHeader() + reply_header.reply_to_msg_id = int(summary.anchor_msg_id) + if summary.topic_id: + reply_header.reply_to_top_id = int(summary.topic_id) + tl_msg.reply_to = reply_header + except Exception as exc: + record_error("injector.reply_header", exc) + tl_msg.out = False + tl_msg.unread = False + tl_msg.flags = 0 + + if summary.dialog_id > 0: + peer = TLRPC.TL_peerUser() + peer.user_id = int(summary.dialog_id) + else: + peer = TLRPC.TL_peerChat() + peer.chat_id = abs(int(summary.dialog_id)) + tl_msg.peer_id = peer + + mock_user_id = -2000000000 - (abs(int(summary.dialog_id)) % 100000000) + sender = TLRPC.TL_peerUser() + sender.user_id = mock_user_id + tl_msg.from_id = sender + + try: + from org.telegram.messenger import MessagesController + mock_user = TLRPC.TL_user() + mock_user.id = mock_user_id + mock_user.first_name = "Ai Summary" + mock_user.last_name = "" + mock_user.username = "ai_summary" + mock_user.bot = True + mock_user.verified = True + self._set_robot_animated_avatar(mock_user, account, TLRPC) + MessagesController.getInstance(account).putUser(mock_user, False) + except Exception as exc: + record_error("injector.register_mock_user", exc) + + return MessageObject(account, tl_msg, False, False) + except Exception as exc: + record_error("injector.build_message", exc) + return None + + @staticmethod + def _set_robot_animated_avatar(mock_user: Any, account: int, tlrpc: Any) -> None: + """Attach Telegram's animated robot custom emoji as the user avatar/status.""" + try: + from org.telegram.messenger import AnimatedEmojiDrawable + robot_document_id = 5397634513026043777 + document = AnimatedEmojiDrawable.findDocument(account, robot_document_id) + if document is not None: + status = tlrpc.TL_emojiStatus() + status.document_id = robot_document_id + mock_user.emoji_status = status + return + except Exception as exc: + record_error("injector.robot_avatar", exc) + try: + mock_user.photo = tlrpc.TL_userProfilePhotoEmpty() + except Exception: + pass + + def _inject_into_chat_activity(self, chat_activity: Any) -> None: + """Injects stored summaries for this chat into the active ChatActivity message adapter.""" + try: + from hook_utils import get_private_field + from ..diagnostics import resolve_chat_context + + dialog_id, _, topic_id = resolve_chat_context(chat_activity) + if not dialog_id: + return + + stored_list = summary_db.get_summaries_for_dialog(dialog_id, topic_id) + if not stored_list: + return + + # Comprehensive search for messages list in ChatActivity and its ChatActivityAdapter + messages_list = None + chat_adapter = ( + getattr(chat_activity, "chatAdapter", None) + or get_private_field(chat_activity, "chatAdapter") + ) + + # Check ChatActivity and ChatActivityAdapter fields for ArrayList + sources_to_check = [chat_activity] + if chat_adapter: + sources_to_check.append(chat_adapter) + + for src in sources_to_check: + for attr_name in ("messages", "chatMessages", "loadedMessages", "items"): + try: + candidate = getattr(src, attr_name, None) or get_private_field(src, attr_name) + if candidate is not None and (hasattr(candidate, "size") or isinstance(candidate, list)): + # Confirm this contains MessageObject items + size = candidate.size() if hasattr(candidate, "size") else len(candidate) + if size > 0: + first_item = candidate.get(0) if hasattr(candidate, "get") else candidate[0] + if hasattr(first_item, "messageOwner") or hasattr(first_item, "getId") or hasattr(first_item, "id"): + messages_list = candidate + break + else: + messages_list = candidate + break + except Exception: + pass + if messages_list is not None: + break + + if messages_list is None: + record_fact("injector.messages_list", "NOT FOUND in ChatActivity/chatAdapter") + return + + # Existing virtual summary messages are recognized by their exact + # deterministic negative ID. Do not compare an anchor ID to a + # virtual ID; that was the source of repeated reinjection. + existing_virtual_ids = set() + try: + size = messages_list.size() if hasattr(messages_list, "size") else len(messages_list) + for i in range(size): + m = messages_list.get(i) if hasattr(messages_list, "get") else messages_list[i] + owner = getattr(m, "messageOwner", None) + virtual_id = int(getattr(owner, "id", 0) or 0) + if virtual_id < 0: + existing_virtual_ids.add(virtual_id) + except Exception as exc: + record_error("injector.scan_existing", exc) + + injected_count = 0 + for s in stored_list: + virtual_id = -abs(int(s.anchor_msg_id) * 1000 + int(s.id % 1000)) + if virtual_id in existing_virtual_ids: + continue + + msg_obj = self.build_client_side_summary_message(s) + if not msg_obj: + continue + + # Locate the actual anchor in the MessageObject list. Telegram + # stores the server id on messageOwner; relying only on a Python + # ``id`` attribute misses Java MessageObject proxies. + target_idx = -1 + try: + size = messages_list.size() if hasattr(messages_list, "size") else len(messages_list) + for i in range(size): + item = messages_list.get(i) if hasattr(messages_list, "get") else messages_list[i] + owner = getattr(item, "messageOwner", None) + item_id = getattr(owner, "id", None) + if item_id is None: + try: + item_id = item.getId() + except Exception: + item_id = getattr(item, "id", None) + if item_id is not None and int(item_id) == int(s.anchor_msg_id): + # In Telegram's message array the inserted item is + # rendered immediately adjacent to the anchor. + target_idx = i + break + except Exception as exc: + record_error("injector.find_anchor", exc) + + # Never place a summary at index 0 when the anchor is absent: + # that silently moves it to the end of the conversation. The + # current history page may not contain the anchor yet; retry on + # the next ChatActivity lifecycle/data callback instead. + if target_idx < 0: + record_fact("injector.anchor_not_loaded", s.anchor_msg_id) + continue + + try: + if hasattr(messages_list, "add"): + messages_list.add(target_idx, msg_obj) + elif isinstance(messages_list, list): + messages_list.insert(target_idx, msg_obj) + existing_virtual_ids.add(virtual_id) + injected_count += 1 + except Exception as exc: + record_error("injector.insert_item", exc) + + if injected_count > 0: + record_fact("injector.injected_count", injected_count) + # Refresh chat adapter view + if chat_adapter and hasattr(chat_adapter, "notifyDataSetChanged"): + try: + chat_adapter.notifyDataSetChanged() + except Exception as exc: + record_error("injector.notifyDataSetChanged", exc) + + except Exception as exc: + record_error("injector._inject_into_chat_activity", exc) diff --git a/src/ui/__init__.py b/src/ui/__init__.py index 1ed613a..2b7b6ff 100644 --- a/src/ui/__init__.py +++ b/src/ui/__init__.py @@ -4,8 +4,6 @@ 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__ = [ @@ -13,8 +11,6 @@ __all__ = [ "show_oauth_login_dialog", "show_custom_verification_dialog", "show_pre_request_sheet", - "PinnedProgressManager", - "show_thinking_bottom_sheet", "show_summary_result_dialog", "show_error_dialog", ] diff --git a/src/ui/progress_widget.py b/src/ui/progress_widget.py index 6493d2d..f951a15 100644 --- a/src/ui/progress_widget.py +++ b/src/ui/progress_widget.py @@ -140,25 +140,38 @@ class PinnedProgressManager: 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 + from android_utils import run_on_ui_thread + except ImportError: + def run_on_ui_thread(fn): fn() - if self.active_widget_ref: - widget = self.active_widget_ref() - if widget and hasattr(widget, "getParent") and widget.getParent(): + def _do_hide(): + try: + # 1. Restore PinnedMessageView padding + pinned_view = self.pinned_view_ref() if self.pinned_view_ref else None + if pinned_view: try: - widget.getParent().removeView(widget) + pinned_view.setPadding( + pinned_view.getPaddingLeft(), + pinned_view.getPaddingTop(), + self.original_right_padding, + pinned_view.getPaddingBottom(), + ) except Exception: pass - self.active_widget_ref = None - except Exception: - pass + + # 2. Remove widget view via direct ref + if self.active_widget_ref: + widget = self.active_widget_ref() + if widget: + try: + widget.setVisibility(8) # View.GONE + parent = getattr(widget, "getParent", lambda: None)() + if parent and hasattr(parent, "removeView"): + parent.removeView(widget) + except Exception: + pass + self.active_widget_ref = None + except Exception: + pass + + run_on_ui_thread(_do_hide) diff --git a/src/ui/settings.py b/src/ui/settings.py index be20e26..331f1d8 100644 --- a/src/ui/settings.py +++ b/src/ui/settings.py @@ -36,8 +36,6 @@ from ..config import ( 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, @@ -54,6 +52,7 @@ from ..config import ( SETTING_SUMMARY_STYLE, STYLES, ) +from ..services.summary_db import summary_db from ..localization import locali @@ -106,16 +105,6 @@ def build_settings_layout(plugin: Any) -> List[Any]: 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()) @@ -379,8 +368,19 @@ def build_settings_layout(plugin: Any) -> List[Any]: on_change=lambda val: _set(SETTING_GLOBAL_PROMPT, val), ) ) + # 5. Stored local summaries + items.append(Divider()) + items.append(Header(text=locali.get("SETTINGS_HEADER_SUMMARIES"))) + items.append( + Text( + text=locali.get("SETTINGS_CLEAR_SUMMARIES"), + subtext=locali.get("SETTINGS_CLEAR_SUMMARIES_SUB"), + icon="msg_delete", + on_click=lambda _: _clear_all_summaries(), + ) + ) - # 5. Diagnostics: makes on-device hook failures inspectable instead of silent. + # 6. Diagnostics: only errors and logs remain in plugin settings. items.append(Divider()) items.append(Header(text=locali.get("SETTINGS_HEADER_DIAGNOSTICS"))) items.append( @@ -399,18 +399,25 @@ def build_settings_layout(plugin: Any) -> List[Any]: 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 _clear_all_summaries() -> None: + """Clear all locally persisted summaries and confirm through Telegram UI.""" + try: + removed = summary_db.clear_summaries() + from android_utils import run_on_ui_thread + from ui.bulletin import BulletinHelper + run_on_ui_thread(lambda: BulletinHelper.show_success(locali.get("SETTINGS_SUMMARIES_CLEARED", count=removed))) + except Exception as exc: + try: + from ..diagnostics import record_error + record_error("settings.clear_summaries", exc) + except Exception: + pass + + def _copy_diagnostic_payload(kind: str) -> None: """Copies latest errors, latest logs, or the complete diagnostics report.""" try: diff --git a/tests/test_db_and_injection.py b/tests/test_db_and_injection.py new file mode 100644 index 0000000..884341b --- /dev/null +++ b/tests/test_db_and_injection.py @@ -0,0 +1,111 @@ +""" +Database and Chat Injection tests for AI Chat Summaries. +""" + +import os +import sys +import tempfile +import unittest + +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) + +from src.services.summary_db import SummaryDatabase, StoredSummary +from src.services.summary_injector import ChatSummaryInjector + + +class TestSummaryDatabase(unittest.TestCase): + def setUp(self): + self.temp_file = tempfile.NamedTemporaryFile(delete=False) + self.temp_file.close() + self.db = SummaryDatabase(self.temp_file.name) + + def tearDown(self): + try: + os.unlink(self.temp_file.name) + except Exception: + pass + + def test_insert_and_get_summaries(self): + summary_id = self.db.insert_summary( + dialog_id=-1001234567, + anchor_msg_id=42, + count=100, + summary_text="Discussion about system architecture and DB migrations.", + provider_name="ChatGPT (OAuth)", + model_name="terra", + offset=0, + topic_id=0, + ) + self.assertTrue(summary_id > 0) + + results = self.db.get_summaries_for_dialog(-1001234567, 0) + self.assertEqual(len(results), 1) + item = results[0] + self.assertEqual(item.anchor_msg_id, 42) + self.assertEqual(item.provider_name, "ChatGPT (OAuth)") + self.assertEqual(item.model_name, "terra") + self.assertIn("architecture", item.summary_text) + + by_anchor = self.db.get_summary_by_anchor(-1001234567, 42) + self.assertIsNotNone(by_anchor) + self.assertEqual(by_anchor.id, summary_id) + + def test_delete_summary(self): + summary_id = self.db.insert_summary( + dialog_id=555, + anchor_msg_id=10, + count=20, + summary_text="Short summary", + provider_name="Custom", + model_name="deepseek-chat", + ) + self.assertTrue(self.db.delete_summary(summary_id)) + self.assertIsNone(self.db.get_summary_by_anchor(555, 10)) + + +class TestSummaryInjector(unittest.TestCase): + def test_summary_helpers_are_stable_and_plain_text(self): + from src.services.summary_injector import build_summary_message_text, summary_virtual_id + + stored = StoredSummary( + id=1001, + dialog_id=-100123, + topic_id=7, + anchor_msg_id=42, + count=50, + offset=0, + summary_text="Project milestones achieved.", + provider_name="Anthropic", + model_name="claude-3-7-sonnet", + created_at=1700000000.0, + meta_json="{}", + ) + self.assertEqual(summary_virtual_id(stored.id, stored.anchor_msg_id), -42001) + text = build_summary_message_text(stored) + self.assertIn("AI CHAT SUMMARY", text) + self.assertIn("Anthropic · claude-3-7-sonnet", text) + self.assertIn("Project milestones achieved.", text) + self.assertNotIn("**", text) + + def test_build_client_side_message_is_safe_without_android_runtime(self): + injector = ChatSummaryInjector() + stored = StoredSummary( + id=1, + dialog_id=98765, + topic_id=0, + anchor_msg_id=100, + count=50, + offset=0, + summary_text="Project milestones achieved.", + provider_name="Anthropic", + model_name="claude-3-7-sonnet", + created_at=1700000000.0, + meta_json="{}", + ) + self.assertIsNone(injector.build_client_side_summary_message(stored)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 1ac7dbd..84fcc5e 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -59,7 +59,6 @@ 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: @@ -98,7 +97,7 @@ class TestPluginMetadata(unittest.TestCase): def test_plugin_entrypoint_metadata(self): from importlib.machinery import SourceFileLoader - plugin_path = os.path.join(BASE_DIR, "ai_chat_summary.plugin") + plugin_path = os.path.join(BASE_DIR, "dist", "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))) @@ -109,7 +108,7 @@ class TestPluginMetadata(unittest.TestCase): def test_plugin_lifecycle_and_settings(self): from importlib.machinery import SourceFileLoader - plugin_path = os.path.join(BASE_DIR, "ai_chat_summary.plugin") + plugin_path = os.path.join(BASE_DIR, "dist", "ai_chat_summary.plugin") mod = SourceFileLoader("ai_chat_summaries_bundle", plugin_path).load_module() plugin = mod.AIChatSummariesPlugin() @@ -409,13 +408,15 @@ class TestMessageFetcher(unittest.TestCase): time_str="2023-11-14 22:13", sender_name="Alice Wonderland", sender_username="@alice", + sender_id=42, 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("[2023-11-14 22:13] #101 (fwd: Bob, reply-to #98):", line) + self.assertNotIn("Alice Wonderland", line) self.assertIn("[Photo: System architecture]", line) self.assertIn("Let's schedule our release for Monday 10am UTC.", line)