[broken work lowk]
This commit is contained in:
Generated
-3
@@ -3,8 +3,5 @@
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.14" />
|
||||
</component>
|
||||
<component name="GithubDefaultAccount">
|
||||
<option name="defaultAccountId" value="14736ef7-23b9-4442-8686-78ec985aa54f" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
@@ -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 '<none>'}\\n\\n"
|
||||
f"=== System Prompt ===\\n{global_prompt or '<standard>'}\\n"
|
||||
)
|
||||
latest_id = messages[-1].id if messages else 0
|
||||
show_summary_result_dialog(
|
||||
plugin=self,
|
||||
chat_activity=chat_act,
|
||||
|
||||
# 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,
|
||||
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,
|
||||
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()
|
||||
|
||||
+2
-3
@@ -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"
|
||||
|
||||
@@ -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"):
|
||||
|
||||
+14
-42
@@ -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}с",
|
||||
|
||||
@@ -156,6 +156,9 @@ def build_system_prompt(
|
||||
"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. "
|
||||
"When referring to a participant, preserve the exact sender placeholder "
|
||||
"<user:ID> from the transcript; never replace it with a name. "
|
||||
"The client will convert placeholders into Telegram mentions after completion."
|
||||
)
|
||||
|
||||
# Style instructions
|
||||
|
||||
@@ -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"):
|
||||
|
||||
+113
-49
@@ -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"<user:{self.sender_id}>" if self.sender_id is not None else "<user:unknown>"
|
||||
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)
|
||||
|
||||
raw_messages = getattr(response, "messages", None)
|
||||
msg_list = []
|
||||
if raw_messages:
|
||||
self._sender_cache.update(new_users)
|
||||
self._chat_cache.update(new_chats)
|
||||
try:
|
||||
size = raw_messages.size()
|
||||
for i in range(size):
|
||||
msg_list.append(raw_messages.get(i))
|
||||
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:
|
||||
if isinstance(raw_messages, list):
|
||||
msg_list = raw_messages
|
||||
|
||||
pass
|
||||
raw_messages = getattr(response, "messages", None)
|
||||
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,22 +226,67 @@ 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:
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"<user:(-?\d+)>")
|
||||
|
||||
|
||||
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<MessageObject>
|
||||
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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -140,6 +140,13 @@ class PinnedProgressManager:
|
||||
self.is_showing = False
|
||||
|
||||
try:
|
||||
from android_utils import run_on_ui_thread
|
||||
except ImportError:
|
||||
def run_on_ui_thread(fn): fn()
|
||||
|
||||
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:
|
||||
@@ -152,13 +159,19 @@ class PinnedProgressManager:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Remove widget view via direct ref
|
||||
if self.active_widget_ref:
|
||||
widget = self.active_widget_ref()
|
||||
if widget and hasattr(widget, "getParent") and widget.getParent():
|
||||
if widget:
|
||||
try:
|
||||
widget.getParent().removeView(widget)
|
||||
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)
|
||||
|
||||
+28
-21
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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 <user:42> (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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user