[broken work lowk]
This commit is contained in:
Generated
-3
@@ -3,8 +3,5 @@
|
|||||||
<component name="Black">
|
<component name="Black">
|
||||||
<option name="sdkName" value="Python 3.14" />
|
<option name="sdkName" value="Python 3.14" />
|
||||||
</component>
|
</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" />
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14" project-jdk-type="Python SDK" />
|
||||||
</project>
|
</project>
|
||||||
@@ -24,12 +24,12 @@ MODULE_ORDER = [
|
|||||||
os.path.join(SRC_DIR, "providers", "custom.py"),
|
os.path.join(SRC_DIR, "providers", "custom.py"),
|
||||||
os.path.join(SRC_DIR, "providers", "builtin.py"),
|
os.path.join(SRC_DIR, "providers", "builtin.py"),
|
||||||
os.path.join(SRC_DIR, "providers", "dispatcher.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, "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", "summary_dialog.py"),
|
||||||
os.path.join(SRC_DIR, "ui", "settings.py"),
|
os.path.join(SRC_DIR, "ui", "settings.py"),
|
||||||
os.path.join(SRC_DIR, "ui", "pre_request.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", "pinned_hook.py"),
|
||||||
os.path.join(SRC_DIR, "hooks", "unread_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)
|
AI Chat Summaries Plugin for exteraGram (v3.0.1)
|
||||||
================================================
|
================================================
|
||||||
Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers,
|
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"
|
__id__ = "ai_chat_summaries"
|
||||||
__name__ = "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"
|
__author__ = "@exteraGramDev"
|
||||||
__version__ = "3.0.1"
|
__version__ = "3.0.1"
|
||||||
__icon__ = "msg_bot"
|
__icon__ = "msg_bot"
|
||||||
@@ -161,9 +161,9 @@ class AIChatSummariesPlugin(BasePlugin):
|
|||||||
self.custom_handler = CustomAIHandler(self)
|
self.custom_handler = CustomAIHandler(self)
|
||||||
self.dispatcher = UnifiedDispatcher(self)
|
self.dispatcher = UnifiedDispatcher(self)
|
||||||
self.message_fetcher = MessageFetcher(self)
|
self.message_fetcher = MessageFetcher(self)
|
||||||
self.progress_manager = PinnedProgressManager(self)
|
|
||||||
self.pinned_hook = PinnedHeaderHook(self)
|
self.pinned_hook = PinnedHeaderHook(self)
|
||||||
self.unread_hook = UnreadBadgeHook(self)
|
self.unread_hook = UnreadBadgeHook(self)
|
||||||
|
self.summary_injector = ChatSummaryInjector(self)
|
||||||
self.is_processing = False
|
self.is_processing = False
|
||||||
|
|
||||||
def on_plugin_load(self) -> None:
|
def on_plugin_load(self) -> None:
|
||||||
@@ -181,29 +181,18 @@ class AIChatSummariesPlugin(BasePlugin):
|
|||||||
on_click=self.on_menu_click,
|
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:
|
except Exception as exc:
|
||||||
record_error("on_plugin_load:add_menu_item", exc)
|
record_error("on_plugin_load:add_menu_item", exc)
|
||||||
|
|
||||||
self.pinned_hook.install_hook()
|
self.pinned_hook.install_hook()
|
||||||
self.unread_hook.install_hook()
|
self.unread_hook.install_hook()
|
||||||
|
self.summary_injector.install_hook()
|
||||||
record_fact(
|
record_fact(
|
||||||
"plugin.hooks_installed",
|
"plugin.hooks_installed",
|
||||||
"pinned=%d unread=%d" % (
|
"pinned=%d unread=%d injector=%d" % (
|
||||||
len(self.pinned_hook.unhook_refs),
|
len(self.pinned_hook.unhook_refs),
|
||||||
len(self.unread_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."""
|
"""Called when plugin is disabled or uninstalled."""
|
||||||
self.pinned_hook.uninstall_hook()
|
self.pinned_hook.uninstall_hook()
|
||||||
self.unread_hook.uninstall_hook()
|
self.unread_hook.uninstall_hook()
|
||||||
self.progress_manager.hide_progress()
|
self.summary_injector.uninstall_hook()
|
||||||
self.is_processing = False
|
|
||||||
uninstall_uncaught_exception_hooks()
|
uninstall_uncaught_exception_hooks()
|
||||||
|
|
||||||
# Aliases for alternate SDK lifecycle naming
|
# Aliases for alternate SDK lifecycle naming
|
||||||
@@ -306,9 +294,6 @@ class AIChatSummariesPlugin(BasePlugin):
|
|||||||
self.is_processing = True
|
self.is_processing = True
|
||||||
chat_act = get_last_fragment()
|
chat_act = get_last_fragment()
|
||||||
|
|
||||||
if chat_act:
|
|
||||||
self.progress_manager.show_progress(chat_act, dialog_id, chat_title)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
BulletinHelper.show_info(locali.get("PROGRESS_FETCHING"))
|
BulletinHelper.show_info(locali.get("PROGRESS_FETCHING"))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -372,27 +357,40 @@ class AIChatSummariesPlugin(BasePlugin):
|
|||||||
|
|
||||||
def _render_result():
|
def _render_result():
|
||||||
if response.success:
|
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
|
latest_id = messages[-1].id if messages else 0
|
||||||
show_summary_result_dialog(
|
|
||||||
plugin=self,
|
# 1. Save in local SQLite DB and inject rich client-side message directly into chat
|
||||||
chat_activity=chat_act,
|
try:
|
||||||
summary_text=response.text,
|
if latest_id:
|
||||||
provider_name=response.provider_name,
|
self.summary_injector.save_and_inject_summary(
|
||||||
model_name=response.model,
|
dialog_id=dialog_id,
|
||||||
message_count=len(messages),
|
anchor_msg_id=latest_id,
|
||||||
elapsed_time=elapsed,
|
count=len(messages),
|
||||||
dialog_id=dialog_id,
|
offset=offset,
|
||||||
topic_id=topic_id,
|
summary_text=response.text,
|
||||||
full_debug_data=full_debug_data,
|
provider_name=response.provider_name,
|
||||||
latest_msg_id=latest_id,
|
model_name=response.model,
|
||||||
auto_jump=auto_jump_latest,
|
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:
|
else:
|
||||||
show_error_dialog(
|
show_error_dialog(
|
||||||
title=locali.get("ERROR_TITLE"),
|
title=locali.get("ERROR_TITLE"),
|
||||||
@@ -416,14 +414,14 @@ class AIChatSummariesPlugin(BasePlugin):
|
|||||||
self._finish_pipeline()
|
self._finish_pipeline()
|
||||||
record_error("pipeline.fetch_history", exc)
|
record_error("pipeline.fetch_history", exc)
|
||||||
def _finish_pipeline(self) -> None:
|
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
|
self.is_processing = False
|
||||||
def _ui_clean():
|
try:
|
||||||
self.progress_manager.hide_progress()
|
self.message_fetcher.clear_sender_cache()
|
||||||
run_on_ui_thread(_ui_clean)
|
except Exception as exc:
|
||||||
|
record_error("pipeline.clear_sender_cache", exc)
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
def clean_module_code(code: str) -> str:
|
def clean_module_code(code: str) -> str:
|
||||||
"""Strips package imports, __all__, and docstrings for single-file bundling."""
|
"""Strips package imports, __all__, and docstrings for single-file bundling."""
|
||||||
lines = code.splitlines()
|
lines = code.splitlines()
|
||||||
|
|||||||
+2
-3
@@ -9,7 +9,7 @@ PLUGIN_ID = "ai_chat_summaries"
|
|||||||
PLUGIN_NAME = "AI Chat Summaries"
|
PLUGIN_NAME = "AI Chat Summaries"
|
||||||
PLUGIN_DESCRIPTION = (
|
PLUGIN_DESCRIPTION = (
|
||||||
"Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, "
|
"Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, "
|
||||||
"animated progress, thinking stream preview, and dual triggers."
|
"and dual triggers."
|
||||||
)
|
)
|
||||||
PLUGIN_AUTHOR = "@exteraGramDev"
|
PLUGIN_AUTHOR = "@exteraGramDev"
|
||||||
PLUGIN_VERSION = "3.0.1"
|
PLUGIN_VERSION = "3.0.1"
|
||||||
@@ -139,11 +139,10 @@ KEY_PREFIXES = {
|
|||||||
PROVIDER_CUSTOM: "",
|
PROVIDER_CUSTOM: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Settings Keys
|
|
||||||
SETTING_PROVIDER = "ai_provider"
|
SETTING_PROVIDER = "ai_provider"
|
||||||
SETTING_ENABLE_THINKING_STREAM = "enable_thinking_stream"
|
|
||||||
SETTING_ENABLE_PINNED_TRIGGER = "enable_pinned_trigger"
|
SETTING_ENABLE_PINNED_TRIGGER = "enable_pinned_trigger"
|
||||||
SETTING_ENABLE_UNREAD_LONGPRESS = "enable_unread_longpress"
|
SETTING_ENABLE_UNREAD_LONGPRESS = "enable_unread_longpress"
|
||||||
|
SETTING_ENABLE_THINKING_STREAM = "enable_thinking_stream"
|
||||||
|
|
||||||
SETTING_OAUTH_ACCESS_TOKEN = "oauth_access_token"
|
SETTING_OAUTH_ACCESS_TOKEN = "oauth_access_token"
|
||||||
SETTING_OAUTH_REFRESH_TOKEN = "oauth_refresh_token"
|
SETTING_OAUTH_REFRESH_TOKEN = "oauth_refresh_token"
|
||||||
|
|||||||
@@ -205,8 +205,6 @@ class PinnedHeaderHook:
|
|||||||
if not pinned_view:
|
if not pinned_view:
|
||||||
return
|
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
|
parent = pinned_view.getParent() or pinned_view
|
||||||
if not parent or not hasattr(parent, "findViewWithTag"):
|
if not parent or not hasattr(parent, "findViewWithTag"):
|
||||||
|
|||||||
+14
-42
@@ -11,8 +11,7 @@ class LocalizationManager:
|
|||||||
_strings: Dict[str, Dict[str, str]] = {
|
_strings: Dict[str, Dict[str, str]] = {
|
||||||
"en": {
|
"en": {
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
"PLUGIN_NAME": "AI Chat Summaries",
|
"PLUGIN_DESC": "Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, and dual triggers.",
|
||||||
"PLUGIN_DESC": "Advanced AI chat summarizer with ChatGPT OAuth, Custom Providers, animated progress, and dual triggers.",
|
|
||||||
|
|
||||||
# Settings - General & Triggers
|
# Settings - General & Triggers
|
||||||
"SETTINGS_HEADER_GENERAL": "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_PINNED_SUB": "Display AI summary button next to pinned message bar in chats.",
|
||||||
"SETTINGS_ENABLE_UNREAD": "Long-Press Unread Badge",
|
"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_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 - Provider Selection
|
||||||
"SETTINGS_HEADER_PROVIDER": "AI Provider Configuration",
|
"SETTINGS_HEADER_PROVIDER": "AI Provider Configuration",
|
||||||
@@ -86,17 +83,16 @@ class LocalizationManager:
|
|||||||
"SETTINGS_GLOBAL_PROMPT": "Global System Prompt Override",
|
"SETTINGS_GLOBAL_PROMPT": "Global System Prompt Override",
|
||||||
"SETTINGS_GLOBAL_PROMPT_SUB": "Leave blank to use built-in style prompt instructions.",
|
"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_HEADER_DIAGNOSTICS": "Diagnostics",
|
||||||
"SETTINGS_COPY_ERRORS": "Copy Latest Errors",
|
"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": "Copy Latest Logs",
|
||||||
"SETTINGS_COPY_LOGS_SUB": "Copies the latest 300 timestamped plugin log lines.",
|
"SETTINGS_COPY_LOGS_SUB": "Copies the latest 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!",
|
|
||||||
# Pre-Request Bottom Sheet
|
# Pre-Request Bottom Sheet
|
||||||
"PRE_REQ_TITLE": "AI Chat Summary",
|
"PRE_REQ_TITLE": "AI Chat Summary",
|
||||||
"PRE_REQ_SUBTITLE": "Chat: {title}",
|
"PRE_REQ_SUBTITLE": "Chat: {title}",
|
||||||
@@ -115,16 +111,6 @@ class LocalizationManager:
|
|||||||
"PROGRESS_SUMMARIZING": "AI Summarizing...",
|
"PROGRESS_SUMMARIZING": "AI Summarizing...",
|
||||||
"PROGRESS_FETCHING": "Fetching chat messages...",
|
"PROGRESS_FETCHING": "Fetching chat messages...",
|
||||||
"PROGRESS_PROCESSING": "Generating summary with {provider}...",
|
"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
|
# Summary Result Dialog
|
||||||
"RESULT_TITLE": "Chat Summary",
|
"RESULT_TITLE": "Chat Summary",
|
||||||
@@ -164,8 +150,6 @@ class LocalizationManager:
|
|||||||
"SETTINGS_ENABLE_PINNED_SUB": "Отображать кнопку AI-саммари рядом с плашкой закрепленного сообщения.",
|
"SETTINGS_ENABLE_PINNED_SUB": "Отображать кнопку AI-саммари рядом с плашкой закрепленного сообщения.",
|
||||||
"SETTINGS_ENABLE_UNREAD": "Удержание счетчика непрочитанных",
|
"SETTINGS_ENABLE_UNREAD": "Удержание счетчика непрочитанных",
|
||||||
"SETTINGS_ENABLE_UNREAD_SUB": "Долгое нажатие на плавающий бейдж или разделитель непрочитанных запускает саммари.",
|
"SETTINGS_ENABLE_UNREAD_SUB": "Долгое нажатие на плавающий бейдж или разделитель непрочитанных запускает саммари.",
|
||||||
"SETTINGS_ENABLE_THINKING": "Предпросмотр потока рассуждений (Thinking)",
|
|
||||||
"SETTINGS_ENABLE_THINKING_SUB": "Отображать ход мыслей модели в реальном времени. Отключите для экономии мобильного трафика.",
|
|
||||||
|
|
||||||
# Settings - Provider Selection
|
# Settings - Provider Selection
|
||||||
"SETTINGS_HEADER_PROVIDER": "Настройка AI Провайдера",
|
"SETTINGS_HEADER_PROVIDER": "Настройка AI Провайдера",
|
||||||
@@ -233,15 +217,13 @@ class LocalizationManager:
|
|||||||
# Diagnostics
|
# Diagnostics
|
||||||
"SETTINGS_HEADER_DIAGNOSTICS": "Диагностика",
|
"SETTINGS_HEADER_DIAGNOSTICS": "Диагностика",
|
||||||
"SETTINGS_COPY_ERRORS": "Скопировать последние ошибки",
|
"SETTINGS_COPY_ERRORS": "Скопировать последние ошибки",
|
||||||
"SETTINGS_COPY_ERRORS_SUB": "Копирует ошибки с временем, потоком, местом, сообщением и полным traceback.",
|
"SETTINGS_COPY_ERRORS_SUB": "Копирует ошибки с временем и полным traceback.",
|
||||||
"SETTINGS_COPY_LOGS": "Скопировать последние логи",
|
"SETTINGS_COPY_LOGS": "Скопировать последние логи",
|
||||||
"SETTINGS_COPY_LOGS_SUB": "Копирует последние 300 строк логов плагина с временем.",
|
"SETTINGS_COPY_LOGS_SUB": "Копирует последние строки логов плагина.",
|
||||||
"SETTINGS_COPY_DIAGNOSTICS": "Скопировать полный отчёт диагностики",
|
"SETTINGS_HEADER_SUMMARIES": "Локальные саммари",
|
||||||
"SETTINGS_COPY_DIAGNOSTICS_SUB": "Копирует ошибки, логи, состояние хуков и проверку runtime API.",
|
"SETTINGS_CLEAR_SUMMARIES": "Очистить сохранённые саммари",
|
||||||
"SETTINGS_ERRORS_COPIED": "Последние ошибки скопированы!",
|
"SETTINGS_CLEAR_SUMMARIES_SUB": "Удаляет клиентские AI-сообщения, сохранённые на устройстве.",
|
||||||
"SETTINGS_LOGS_COPIED": "Последние логи скопированы!",
|
"SETTINGS_SUMMARIES_CLEARED": "Удалено сохранённых саммари: {count}.",
|
||||||
"SETTINGS_DIAGNOSTICS_COPIED": "Отчёт диагностики скопирован!",
|
|
||||||
# Pre-Request Bottom Sheet
|
|
||||||
"PRE_REQ_TITLE": "AI Саммари Чата",
|
"PRE_REQ_TITLE": "AI Саммари Чата",
|
||||||
"PRE_REQ_SUBTITLE": "Чат: {title}",
|
"PRE_REQ_SUBTITLE": "Чат: {title}",
|
||||||
"PRE_REQ_COUNT_LABEL": "Сообщений для анализа: {count}",
|
"PRE_REQ_COUNT_LABEL": "Сообщений для анализа: {count}",
|
||||||
@@ -260,16 +242,6 @@ class LocalizationManager:
|
|||||||
"PROGRESS_FETCHING": "Загрузка сообщений чата...",
|
"PROGRESS_FETCHING": "Загрузка сообщений чата...",
|
||||||
"PROGRESS_PROCESSING": "Генерация ответа через {provider}...",
|
"PROGRESS_PROCESSING": "Генерация ответа через {provider}...",
|
||||||
"PROGRESS_CLICK_THINKING": "Нажмите для просмотра хода мыслей AI",
|
"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
|
# Summary Result Dialog
|
||||||
"RESULT_TITLE": "Сводка чата",
|
"RESULT_TITLE": "Сводка чата",
|
||||||
"RESULT_HEADER_INFO": "{provider} ({model}) • {count} сообщ. за {time:.1f}с",
|
"RESULT_HEADER_INFO": "{provider} ({model}) • {count} сообщ. за {time:.1f}с",
|
||||||
|
|||||||
@@ -155,7 +155,10 @@ def build_system_prompt(
|
|||||||
base = (
|
base = (
|
||||||
"You are an expert chat summarization assistant for Telegram conversations. "
|
"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, "
|
"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 "
|
||||||
|
"<user:ID> from the transcript; never replace it with a name. "
|
||||||
|
"The client will convert placeholders into Telegram mentions after completion."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Style instructions
|
# Style instructions
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ from ..config import (
|
|||||||
PROVIDER_CUSTOM,
|
PROVIDER_CUSTOM,
|
||||||
PROVIDER_GEMINI,
|
PROVIDER_GEMINI,
|
||||||
PROVIDER_OLLAMA,
|
PROVIDER_OLLAMA,
|
||||||
PROVIDER_OPENAI,
|
|
||||||
SETTING_ENABLE_THINKING_STREAM,
|
|
||||||
SETTING_PROVIDER,
|
SETTING_PROVIDER,
|
||||||
SETTING_SUMMARY_LANG,
|
SETTING_SUMMARY_LANG,
|
||||||
SETTING_SUMMARY_STYLE,
|
SETTING_SUMMARY_STYLE,
|
||||||
@@ -42,18 +40,6 @@ class UnifiedDispatcher:
|
|||||||
self.gemini_handler = GeminiDirectHandler(plugin_instance)
|
self.gemini_handler = GeminiDirectHandler(plugin_instance)
|
||||||
self.ollama_handler = OllamaDirectHandler(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:
|
def get_active_provider(self) -> BaseAIProvider:
|
||||||
"""Returns the configured AI provider backend."""
|
"""Returns the configured AI provider backend."""
|
||||||
@@ -72,46 +58,7 @@ class UnifiedDispatcher:
|
|||||||
return self.ollama_handler
|
return self.ollama_handler
|
||||||
return self.oauth_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(
|
def run_summary(
|
||||||
self,
|
self,
|
||||||
@@ -122,47 +69,23 @@ class UnifiedDispatcher:
|
|||||||
lang: Optional[int] = None,
|
lang: Optional[int] = None,
|
||||||
global_prompt: str = "",
|
global_prompt: str = "",
|
||||||
) -> ProviderResponse:
|
) -> ProviderResponse:
|
||||||
"""Executes full summarization request with live state management."""
|
"""Executes the summarization request without exposing live thinking state."""
|
||||||
provider = self.get_active_provider()
|
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_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)
|
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:
|
try:
|
||||||
response = provider.summarize(
|
return provider.summarize(
|
||||||
messages_transcript=messages_transcript,
|
messages_transcript=messages_transcript,
|
||||||
style=target_style,
|
style=target_style,
|
||||||
lang=target_lang,
|
lang=target_lang,
|
||||||
chat_title=chat_title,
|
chat_title=chat_title,
|
||||||
custom_prompt=custom_prompt,
|
custom_prompt=custom_prompt,
|
||||||
global_prompt=global_prompt,
|
global_prompt=global_prompt,
|
||||||
enable_stream=enable_stream,
|
enable_stream=False,
|
||||||
stream_callback=self._on_stream_chunk if enable_stream else None,
|
stream_callback=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,
|
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ProviderResponse(success=False, error=str(exc))
|
||||||
|
|
||||||
def _get_setting(self, key: str, default: Any = None) -> Any:
|
def _get_setting(self, key: str, default: Any = None) -> Any:
|
||||||
if self.plugin and hasattr(self.plugin, "get_setting"):
|
if self.plugin and hasattr(self.plugin, "get_setting"):
|
||||||
|
|||||||
+117
-53
@@ -11,7 +11,7 @@ from ..config import MAX_MSG_COUNT, MAX_SINGLE_MESSAGE_CHARS, MIN_MSG_COUNT
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FormattedMessage:
|
class FormattedMessage:
|
||||||
"""Represents a sanitized, human-readable Telegram message for LLM context."""
|
"""Represents a sanitized Telegram message and stable sender identity."""
|
||||||
id: int
|
id: int
|
||||||
date: int
|
date: int
|
||||||
time_str: str
|
time_str: str
|
||||||
@@ -21,9 +21,10 @@ class FormattedMessage:
|
|||||||
reply_to_id: Optional[int] = None
|
reply_to_id: Optional[int] = None
|
||||||
forward_from: Optional[str] = None
|
forward_from: Optional[str] = None
|
||||||
media_info: Optional[str] = None
|
media_info: Optional[str] = None
|
||||||
|
sender_id: Optional[int] = None
|
||||||
|
|
||||||
def to_transcript_line(self) -> str:
|
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 = []
|
meta_parts = []
|
||||||
if self.forward_from:
|
if self.forward_from:
|
||||||
meta_parts.append(f"fwd: {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_parts.append(f"reply-to #{self.reply_to_id}")
|
||||||
|
|
||||||
meta_str = f" ({', '.join(meta_parts)})" if meta_parts else ""
|
meta_str = f" ({', '.join(meta_parts)})" if meta_parts else ""
|
||||||
|
|
||||||
content = self.text
|
content = self.text
|
||||||
if self.media_info:
|
if self.media_info:
|
||||||
if content:
|
content = f"{self.media_info}\n{content}" if content else self.media_info
|
||||||
content = f"{self.media_info}\n{content}"
|
sender = f"<user:{self.sender_id}>" if self.sender_id is not None else "<user:unknown>"
|
||||||
else:
|
return f"[{self.time_str}] #{self.id} {sender}{meta_str}:\n{content}"
|
||||||
content = self.media_info
|
|
||||||
|
|
||||||
return f"[{self.time_str}] #{self.id} {self.sender_name}{meta_str}:\n{content}"
|
|
||||||
|
|
||||||
|
|
||||||
class MessageFetcher:
|
class MessageFetcher:
|
||||||
"""Handles fetching and formatting Telegram chat history across chats, channels, and forum topics."""
|
"""Handles fetching and formatting Telegram chat history across chats, channels, and forum topics."""
|
||||||
|
|
||||||
def __init__(self, plugin_instance: Optional[Any] = None) -> None:
|
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(
|
def fetch_history(
|
||||||
self,
|
self,
|
||||||
dialog_id: int,
|
dialog_id: int,
|
||||||
@@ -56,10 +58,10 @@ class MessageFetcher:
|
|||||||
topic_id: int = 0,
|
topic_id: int = 0,
|
||||||
callback: Optional[Callable[[List[FormattedMessage], Optional[str]], None]] = None,
|
callback: Optional[Callable[[List[FormattedMessage], Optional[str]], None]] = 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_count = max(MIN_MSG_COUNT, min(MAX_MSG_COUNT, count))
|
||||||
target_offset = max(0, offset)
|
target_offset = max(0, offset)
|
||||||
|
self._sender_cache.clear()
|
||||||
|
self._chat_cache.clear()
|
||||||
try:
|
try:
|
||||||
from client_utils import PLUGINS_QUEUE, run_on_queue
|
from client_utils import PLUGINS_QUEUE, run_on_queue
|
||||||
run_on_queue(
|
run_on_queue(
|
||||||
@@ -114,10 +116,12 @@ class MessageFetcher:
|
|||||||
try:
|
try:
|
||||||
from client_utils import get_messages_controller, send_request, RequestCallback
|
from client_utils import get_messages_controller, send_request, RequestCallback
|
||||||
from org.telegram.tgnet import TLRPC
|
from org.telegram.tgnet import TLRPC
|
||||||
|
|
||||||
messages_controller = get_messages_controller()
|
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:
|
if topic_id and topic_id != 0:
|
||||||
# Forum thread / replies
|
# Forum thread / replies
|
||||||
req = TLRPC.TL_messages_getReplies()
|
req = TLRPC.TL_messages_getReplies()
|
||||||
@@ -156,35 +160,43 @@ class MessageFetcher:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not response:
|
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)
|
formatted = self._process_messages(accumulated_raw, user_map, chat_map)
|
||||||
if callback:
|
if callback:
|
||||||
callback(formatted, None)
|
callback(formatted, None)
|
||||||
return
|
return
|
||||||
|
# Update users and chats dictionaries and retain them for this run.
|
||||||
# Update users and chats dictionaries
|
|
||||||
new_users = self._parse_java_list(getattr(response, "users", None))
|
new_users = self._parse_java_list(getattr(response, "users", None))
|
||||||
new_chats = self._parse_java_list(getattr(response, "chats", None))
|
new_chats = self._parse_java_list(getattr(response, "chats", None))
|
||||||
user_map.update(new_users)
|
user_map.update(new_users)
|
||||||
chat_map.update(new_chats)
|
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)
|
raw_messages = getattr(response, "messages", None)
|
||||||
msg_list = []
|
msg_list = self._to_list(raw_messages)
|
||||||
if raw_messages:
|
if not msg_list and raw_messages is not None:
|
||||||
try:
|
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"))
|
||||||
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
|
|
||||||
|
|
||||||
if not msg_list:
|
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)
|
formatted = self._process_messages(accumulated_raw, user_map, chat_map)
|
||||||
if callback:
|
if callback:
|
||||||
callback(formatted, None)
|
callback(formatted, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
accumulated_raw.extend(msg_list)
|
accumulated_raw.extend(msg_list)
|
||||||
next_offset = getattr(msg_list[-1], "id", 0)
|
next_offset = getattr(msg_list[-1], "id", 0)
|
||||||
|
|
||||||
@@ -214,25 +226,70 @@ class MessageFetcher:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if callback:
|
if callback:
|
||||||
callback([], f"Fetch setup failed: {str(e)}")
|
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]:
|
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] = {}
|
result: Dict[int, Any] = {}
|
||||||
if not java_list:
|
for item in self._to_list(java_list):
|
||||||
return result
|
item_id = getattr(item, "id", None)
|
||||||
try:
|
if item_id is not None:
|
||||||
size = java_list.size()
|
result[int(item_id)] = item
|
||||||
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
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _process_messages(
|
def _process_messages(
|
||||||
@@ -261,7 +318,6 @@ class MessageFetcher:
|
|||||||
fwd_info = self._resolve_forward(raw, user_map, chat_map)
|
fwd_info = self._resolve_forward(raw, user_map, chat_map)
|
||||||
media_info = self._format_media(getattr(raw, "media", None))
|
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:
|
if len(message_text) > MAX_SINGLE_MESSAGE_CHARS:
|
||||||
message_text = message_text[:MAX_SINGLE_MESSAGE_CHARS] + " ...[truncated]"
|
message_text = message_text[:MAX_SINGLE_MESSAGE_CHARS] + " ...[truncated]"
|
||||||
|
|
||||||
@@ -277,13 +333,10 @@ class MessageFetcher:
|
|||||||
reply_to_id=reply_id,
|
reply_to_id=reply_id,
|
||||||
forward_from=fwd_info,
|
forward_from=fwd_info,
|
||||||
media_info=media_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(
|
def _resolve_sender(
|
||||||
self,
|
self,
|
||||||
raw_msg: Any,
|
raw_msg: Any,
|
||||||
@@ -300,10 +353,9 @@ class MessageFetcher:
|
|||||||
uid = getattr(fid, "user_id", None)
|
uid = getattr(fid, "user_id", None)
|
||||||
channel_id = getattr(fid, "channel_id", None)
|
channel_id = getattr(fid, "channel_id", None)
|
||||||
chat_id = getattr(fid, "chat_id", None)
|
chat_id = getattr(fid, "chat_id", None)
|
||||||
|
|
||||||
if uid is not None:
|
if uid is not None:
|
||||||
uid = int(uid)
|
uid = int(uid)
|
||||||
user = user_map.get(uid)
|
user = user_map.get(uid) or self._sender_cache.get(uid)
|
||||||
if user:
|
if user:
|
||||||
first = getattr(user, "first_name", "") or ""
|
first = getattr(user, "first_name", "") or ""
|
||||||
last = getattr(user, "last_name", "") or ""
|
last = getattr(user, "last_name", "") or ""
|
||||||
@@ -335,6 +387,18 @@ class MessageFetcher:
|
|||||||
|
|
||||||
return "Unknown", ""
|
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]:
|
def _resolve_reply_id(self, raw_msg: Any) -> Optional[int]:
|
||||||
"""Extracts replied-to message ID if present."""
|
"""Extracts replied-to message ID if present."""
|
||||||
reply_to = getattr(raw_msg, "reply_to", None)
|
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 .settings import build_settings_layout, show_oauth_login_dialog, show_custom_verification_dialog
|
||||||
from .pre_request import show_pre_request_sheet
|
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
|
from .summary_dialog import show_summary_result_dialog, show_error_dialog
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -13,8 +11,6 @@ __all__ = [
|
|||||||
"show_oauth_login_dialog",
|
"show_oauth_login_dialog",
|
||||||
"show_custom_verification_dialog",
|
"show_custom_verification_dialog",
|
||||||
"show_pre_request_sheet",
|
"show_pre_request_sheet",
|
||||||
"PinnedProgressManager",
|
|
||||||
"show_thinking_bottom_sheet",
|
|
||||||
"show_summary_result_dialog",
|
"show_summary_result_dialog",
|
||||||
"show_error_dialog",
|
"show_error_dialog",
|
||||||
]
|
]
|
||||||
|
|||||||
+31
-18
@@ -140,25 +140,38 @@ class PinnedProgressManager:
|
|||||||
self.is_showing = False
|
self.is_showing = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pinned_view = self.pinned_view_ref() if self.pinned_view_ref else None
|
from android_utils import run_on_ui_thread
|
||||||
if pinned_view:
|
except ImportError:
|
||||||
try:
|
def run_on_ui_thread(fn): fn()
|
||||||
pinned_view.setPadding(
|
|
||||||
pinned_view.getPaddingLeft(),
|
|
||||||
pinned_view.getPaddingTop(),
|
|
||||||
self.original_right_padding,
|
|
||||||
pinned_view.getPaddingBottom(),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self.active_widget_ref:
|
def _do_hide():
|
||||||
widget = self.active_widget_ref()
|
try:
|
||||||
if widget and hasattr(widget, "getParent") and widget.getParent():
|
# 1. Restore PinnedMessageView padding
|
||||||
|
pinned_view = self.pinned_view_ref() if self.pinned_view_ref else None
|
||||||
|
if pinned_view:
|
||||||
try:
|
try:
|
||||||
widget.getParent().removeView(widget)
|
pinned_view.setPadding(
|
||||||
|
pinned_view.getPaddingLeft(),
|
||||||
|
pinned_view.getPaddingTop(),
|
||||||
|
self.original_right_padding,
|
||||||
|
pinned_view.getPaddingBottom(),
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.active_widget_ref = None
|
|
||||||
except Exception:
|
# 2. Remove widget view via direct ref
|
||||||
pass
|
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)
|
||||||
|
|||||||
+28
-21
@@ -36,8 +36,6 @@ from ..config import (
|
|||||||
SETTING_CUSTOM_MODEL,
|
SETTING_CUSTOM_MODEL,
|
||||||
SETTING_DEFAULT_COUNT,
|
SETTING_DEFAULT_COUNT,
|
||||||
SETTING_ENABLE_PINNED_TRIGGER,
|
SETTING_ENABLE_PINNED_TRIGGER,
|
||||||
SETTING_ENABLE_THINKING_STREAM,
|
|
||||||
SETTING_ENABLE_UNREAD_LONGPRESS,
|
|
||||||
SETTING_GEMINI_API_KEY,
|
SETTING_GEMINI_API_KEY,
|
||||||
SETTING_GEMINI_MODEL,
|
SETTING_GEMINI_MODEL,
|
||||||
SETTING_GLOBAL_PROMPT,
|
SETTING_GLOBAL_PROMPT,
|
||||||
@@ -54,6 +52,7 @@ from ..config import (
|
|||||||
SETTING_SUMMARY_STYLE,
|
SETTING_SUMMARY_STYLE,
|
||||||
STYLES,
|
STYLES,
|
||||||
)
|
)
|
||||||
|
from ..services.summary_db import summary_db
|
||||||
from ..localization import locali
|
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),
|
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())
|
items.append(Divider())
|
||||||
|
|
||||||
@@ -379,8 +368,19 @@ def build_settings_layout(plugin: Any) -> List[Any]:
|
|||||||
on_change=lambda val: _set(SETTING_GLOBAL_PROMPT, val),
|
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(Divider())
|
||||||
items.append(Header(text=locali.get("SETTINGS_HEADER_DIAGNOSTICS")))
|
items.append(Header(text=locali.get("SETTINGS_HEADER_DIAGNOSTICS")))
|
||||||
items.append(
|
items.append(
|
||||||
@@ -399,18 +399,25 @@ def build_settings_layout(plugin: Any) -> List[Any]:
|
|||||||
on_click=lambda _: _copy_diagnostic_payload("logs"),
|
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
|
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:
|
def _copy_diagnostic_payload(kind: str) -> None:
|
||||||
"""Copies latest errors, latest logs, or the complete diagnostics report."""
|
"""Copies latest errors, latest logs, or the complete diagnostics report."""
|
||||||
try:
|
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.dispatcher import UnifiedDispatcher
|
||||||
from src.providers.oauth import ChatGPTOAuthHandler
|
from src.providers.oauth import ChatGPTOAuthHandler
|
||||||
from src.services.message_fetcher import FormattedMessage, MessageFetcher
|
from src.services.message_fetcher import FormattedMessage, MessageFetcher
|
||||||
from src.ui.progress_widget import PinnedProgressManager
|
|
||||||
|
|
||||||
|
|
||||||
class MockPlugin:
|
class MockPlugin:
|
||||||
@@ -98,7 +97,7 @@ class TestPluginMetadata(unittest.TestCase):
|
|||||||
|
|
||||||
def test_plugin_entrypoint_metadata(self):
|
def test_plugin_entrypoint_metadata(self):
|
||||||
from importlib.machinery import SourceFileLoader
|
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()
|
mod = SourceFileLoader("ai_chat_summaries_bundle", plugin_path).load_module()
|
||||||
self.assertTrue(bool(getattr(mod, "__id__", None)))
|
self.assertTrue(bool(getattr(mod, "__id__", None)))
|
||||||
self.assertTrue(bool(getattr(mod, "__name__", None)))
|
self.assertTrue(bool(getattr(mod, "__name__", None)))
|
||||||
@@ -109,7 +108,7 @@ class TestPluginMetadata(unittest.TestCase):
|
|||||||
|
|
||||||
def test_plugin_lifecycle_and_settings(self):
|
def test_plugin_lifecycle_and_settings(self):
|
||||||
from importlib.machinery import SourceFileLoader
|
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()
|
mod = SourceFileLoader("ai_chat_summaries_bundle", plugin_path).load_module()
|
||||||
plugin = mod.AIChatSummariesPlugin()
|
plugin = mod.AIChatSummariesPlugin()
|
||||||
|
|
||||||
@@ -409,13 +408,15 @@ class TestMessageFetcher(unittest.TestCase):
|
|||||||
time_str="2023-11-14 22:13",
|
time_str="2023-11-14 22:13",
|
||||||
sender_name="Alice Wonderland",
|
sender_name="Alice Wonderland",
|
||||||
sender_username="@alice",
|
sender_username="@alice",
|
||||||
|
sender_id=42,
|
||||||
text="Let's schedule our release for Monday 10am UTC.",
|
text="Let's schedule our release for Monday 10am UTC.",
|
||||||
reply_to_id=98,
|
reply_to_id=98,
|
||||||
forward_from="Bob",
|
forward_from="Bob",
|
||||||
media_info="[Photo: System architecture]",
|
media_info="[Photo: System architecture]",
|
||||||
)
|
)
|
||||||
line = msg.to_transcript_line()
|
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("[Photo: System architecture]", line)
|
||||||
self.assertIn("Let's schedule our release for Monday 10am UTC.", line)
|
self.assertIn("Let's schedule our release for Monday 10am UTC.", line)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user