[broken work lowk]

This commit is contained in:
lyuksovannyy
2026-09-10 15:02:25 +02:00
parent bc103d069d
commit d7f8170c7e
15 changed files with 1044 additions and 284 deletions
-3
View File
@@ -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>
+48 -50
View File
@@ -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
View File
@@ -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"
-2
View File
@@ -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
View File
@@ -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}с",
+4 -1
View File
@@ -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
+6 -83
View File
@@ -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
View File
@@ -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)
+227
View File
@@ -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()
+451
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+111
View File
@@ -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()
+5 -4
View File
@@ -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)