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)