generated from exteraGramPlugins/pluginBase
Correct the unread badge after jumping away from the newest messages
Publish plugin / publish (push) Successful in 3s
Publish plugin / publish (push) Successful in 3s
This commit is contained in:
1 parent
f58e157b70
commit
7b3a4bece3
3 files changed
+284
-1
No files matched your search
+1
-1
@@ -3,7 +3,7 @@
|
||||
"name": "Chat Summaries",
|
||||
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
||||
"author": "@lyuksovannyy",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"icon": "exteraGramPluginIcons/2",
|
||||
"app_version": ">=12.5.1",
|
||||
"sdk_version": ">=1.4.4.3",
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Keeps the page-down button's unread counter right away from the newest messages.
|
||||
|
||||
ChatActivity shows `newUnreadMessageCount` on the page-down badge. It seeds it
|
||||
from the dialog's unread count (everything after the read marker) and only
|
||||
decrements it as messages are marked read. Away from the newest messages that
|
||||
number is wrong: after a jump into history (a summary anchor, a reply, a
|
||||
pinned message, search) messages between the read marker and the jump target
|
||||
stay counted, so the badge can say 5k while only 3k messages lie below what is
|
||||
on screen.
|
||||
|
||||
What the badge should show is how many messages are newer than the newest
|
||||
visible one. The server answers that exactly: messages.getHistory with
|
||||
offset_id=<that id>, add_offset=-1, limit=1 returns `offset_id_offset`, the
|
||||
number of messages newer than offset_id. The hook asks while the chat is not
|
||||
showing the newest messages, at most once per REFRESH_SECONDS per chat and only
|
||||
after the visible position moved, then writes the result through the same
|
||||
setter the app uses. At the bottom of the chat it never interferes.
|
||||
"""
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import diagnostics
|
||||
from hooks.restore_hook import _find_methods_by_name
|
||||
|
||||
# The badge only needs to be right, not instant: one tiny history request per
|
||||
# chat every few seconds while scrolling history is negligible next to what
|
||||
# scrolling itself loads.
|
||||
REFRESH_SECONDS = 3.0
|
||||
|
||||
|
||||
def _get(obj: Any, name: str) -> Any:
|
||||
try:
|
||||
from hook_utils import get_private_field
|
||||
|
||||
return get_private_field(obj, name)
|
||||
except Exception:
|
||||
return getattr(obj, name, None)
|
||||
|
||||
|
||||
def _set_int(obj: Any, name: str, value: int) -> bool:
|
||||
"""Writes a Java int field through reflection.
|
||||
|
||||
hook_utils.set_private_field returns without error here but leaves the
|
||||
field unchanged (verified on device), so the field is written directly.
|
||||
"""
|
||||
cls = obj.getClass()
|
||||
while cls is not None:
|
||||
try:
|
||||
field = cls.getDeclaredField(name)
|
||||
field.setAccessible(True)
|
||||
field.setInt(obj, int(value))
|
||||
return True
|
||||
except Exception:
|
||||
cls = cls.getSuperclass()
|
||||
return False
|
||||
|
||||
|
||||
def _first(value: Any) -> Any:
|
||||
"""First element of a Java array exposed through Chaquopy, or None."""
|
||||
try:
|
||||
return value[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _newest_visible_id(chat_activity: Any) -> int:
|
||||
"""Largest real message id among the rows on screen, or 0."""
|
||||
try:
|
||||
from services.rich_injector import get_chat_adapter, get_messages_list
|
||||
|
||||
layout_manager = _get(chat_activity, "chatLayoutManager")
|
||||
adapter = get_chat_adapter(chat_activity)
|
||||
messages = get_messages_list(chat_activity)
|
||||
if layout_manager is None or adapter is None or messages is None:
|
||||
return 0
|
||||
first = int(layout_manager.findFirstVisibleItemPosition())
|
||||
last = int(layout_manager.findLastVisibleItemPosition())
|
||||
if first < 0 or last < 0:
|
||||
return 0
|
||||
start = int(adapter.messagesStartRow)
|
||||
size = int(messages.size())
|
||||
best = 0
|
||||
for position in range(min(first, last), max(first, last) + 1):
|
||||
index = position - start
|
||||
if 0 <= index < size:
|
||||
owner = getattr(messages.get(index), "messageOwner", None)
|
||||
msg_id = int(getattr(owner, "id", 0) or 0) if owner is not None else 0
|
||||
# Injected summary cards are negative, date rows are 0.
|
||||
if msg_id > best:
|
||||
best = msg_id
|
||||
return best
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("badge_hook.visible_id", exc)
|
||||
return 0
|
||||
|
||||
|
||||
def _read_inbox_max_id(dialog_id: int) -> int:
|
||||
try:
|
||||
from client_utils import get_messages_controller
|
||||
|
||||
controller = get_messages_controller()
|
||||
dialog = controller.dialogs_dict.get(dialog_id) if controller else None
|
||||
return int(getattr(dialog, "read_inbox_max_id", 0) or 0) if dialog is not None else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class BadgeHook:
|
||||
def __init__(self, plugin):
|
||||
self.plugin = plugin
|
||||
self._unhooks: List[Any] = []
|
||||
# dialog_id -> (monotonic time, visible id) of the last server query.
|
||||
self._asked_at: Dict[int, tuple] = {}
|
||||
# dialog_id -> True while a query is in flight.
|
||||
self._in_flight: Dict[int, bool] = {}
|
||||
# Strong references to RequestCallback wrappers until they fire.
|
||||
self._pending: List[Any] = []
|
||||
|
||||
def install(self) -> None:
|
||||
try:
|
||||
from base_plugin import MethodHook
|
||||
from hook_utils import find_class
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("badge_hook.install:imports", exc)
|
||||
return
|
||||
|
||||
chat_activity_class = find_class("org.telegram.ui.ChatActivity")
|
||||
if not chat_activity_class:
|
||||
diagnostics.note("badge_hook.install: ChatActivity not found")
|
||||
return
|
||||
methods = _find_methods_by_name(chat_activity_class, "updateMessagesVisiblePart")
|
||||
diagnostics.note(f"badge_hook.install: updateMessagesVisiblePart methods found={len(methods)}")
|
||||
|
||||
outer = self
|
||||
|
||||
class _VisiblePartHook(MethodHook):
|
||||
def after_hooked_method(self, param):
|
||||
try:
|
||||
outer._check(param.thisObject)
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("badge_hook.check", exc)
|
||||
|
||||
for method in methods:
|
||||
try:
|
||||
ref = self.plugin.hook_method(method, _VisiblePartHook())
|
||||
if ref:
|
||||
self._unhooks.append(ref)
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("badge_hook.install:hook_method", exc)
|
||||
|
||||
def uninstall(self) -> None:
|
||||
for ref in self._unhooks:
|
||||
try:
|
||||
self.plugin.unhook_method(ref)
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("badge_hook.uninstall", exc)
|
||||
self._unhooks = []
|
||||
self._pending = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check(self, chat_activity: Any) -> None:
|
||||
if chat_activity is None:
|
||||
return
|
||||
# Topics, threads, scheduled and saved views keep their own counters.
|
||||
for flag in ("isTopic", "isThreadChat", "isComments"):
|
||||
if bool(_get(chat_activity, flag) or False):
|
||||
return
|
||||
if int(_get(chat_activity, "chatMode") or 0) != 0:
|
||||
return
|
||||
if int(_get(chat_activity, "newUnreadMessageCount") or 0) <= 0:
|
||||
return
|
||||
# Showing the newest messages: the app's own count is accurate.
|
||||
if bool(_first(_get(chat_activity, "forwardEndReached")) or False):
|
||||
return
|
||||
|
||||
dialog_id = int(chat_activity.getDialogId())
|
||||
if self._in_flight.get(dialog_id):
|
||||
return
|
||||
visible_id = _newest_visible_id(chat_activity)
|
||||
if visible_id <= 0:
|
||||
return
|
||||
# Viewing a message marks everything up to it read, so the backlog
|
||||
# starts after whichever is newer: the read marker or the screen.
|
||||
visible_id = max(visible_id, _read_inbox_max_id(dialog_id))
|
||||
now = time.monotonic()
|
||||
last_at, last_id = self._asked_at.get(dialog_id, (0.0, 0))
|
||||
if visible_id == last_id or now - last_at < REFRESH_SECONDS:
|
||||
return
|
||||
if len(self._asked_at) > 64:
|
||||
self._asked_at.clear()
|
||||
self._asked_at[dialog_id] = (now, visible_id)
|
||||
self._query(chat_activity, dialog_id, visible_id)
|
||||
|
||||
def _query(self, chat_activity: Any, dialog_id: int, visible_id: int) -> None:
|
||||
try:
|
||||
from client_utils import RequestCallback, get_messages_controller, send_request
|
||||
from org.telegram.tgnet import TLRPC
|
||||
from services.message_fetcher import MessageFetcher
|
||||
|
||||
controller = get_messages_controller()
|
||||
peer = MessageFetcher._resolve_input_peer(controller, TLRPC, dialog_id) if controller else None
|
||||
if peer is None:
|
||||
return
|
||||
req = TLRPC.TL_messages_getHistory()
|
||||
req.peer = peer
|
||||
req.offset_id = visible_id
|
||||
req.offset_date = 0
|
||||
# -1 returns the anchor itself; offset_id_offset then counts the
|
||||
# messages strictly newer than it.
|
||||
req.add_offset = -1
|
||||
req.limit = 1
|
||||
req.max_id = 0
|
||||
req.min_id = 0
|
||||
req.hash = 0
|
||||
|
||||
box: List[Any] = []
|
||||
|
||||
def on_complete(response, error):
|
||||
try:
|
||||
self._in_flight.pop(dialog_id, None)
|
||||
for proxy in box:
|
||||
if proxy in self._pending:
|
||||
self._pending.remove(proxy)
|
||||
if error is not None or response is None:
|
||||
return
|
||||
newer = getattr(response, "offset_id_offset", None)
|
||||
if newer is None:
|
||||
return
|
||||
self._apply(chat_activity, dialog_id, int(newer), visible_id)
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("badge_hook.on_complete", exc)
|
||||
|
||||
proxy = RequestCallback(on_complete)
|
||||
box.append(proxy)
|
||||
self._pending.append(proxy)
|
||||
self._in_flight[dialog_id] = True
|
||||
send_request(req, proxy)
|
||||
except Exception as exc:
|
||||
self._in_flight.pop(dialog_id, None)
|
||||
diagnostics.record_error("badge_hook.query", exc)
|
||||
|
||||
def _apply(self, chat_activity: Any, dialog_id: int, newer: int, visible_id: int) -> None:
|
||||
def run():
|
||||
try:
|
||||
if int(chat_activity.getDialogId()) != dialog_id:
|
||||
return
|
||||
# Never raise the badge: when the user already read past what
|
||||
# is on screen, the app's lower count is the right one.
|
||||
current = int(_get(chat_activity, "newUnreadMessageCount") or 0)
|
||||
if current <= 0:
|
||||
return
|
||||
value = max(0, min(newer, current))
|
||||
if value == current:
|
||||
return
|
||||
if not _set_int(chat_activity, "newUnreadMessageCount", value):
|
||||
return
|
||||
_set_int(chat_activity, "prevSetUnreadCount", value)
|
||||
layout = _get(chat_activity, "sideControlsButtonsLayout")
|
||||
if layout is not None:
|
||||
# Button 1 is the page-down button; the app animates only
|
||||
# when the button is already on screen.
|
||||
layout.setButtonCount(1, value, True)
|
||||
diagnostics.note(
|
||||
f"badge_hook: dialog {dialog_id} badge {current} -> {value} "
|
||||
f"({newer} messages newer than visible {visible_id})"
|
||||
)
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("badge_hook.apply", exc)
|
||||
|
||||
try:
|
||||
from android_utils import run_on_ui_thread
|
||||
|
||||
run_on_ui_thread(run)
|
||||
except Exception:
|
||||
run()
|
||||
@@ -24,6 +24,7 @@ from config import (
|
||||
SETTING_STORAGE_REVISION,
|
||||
STREAM_UPDATE_INTERVAL,
|
||||
)
|
||||
from hooks.badge_hook import BadgeHook
|
||||
from hooks.delete_hook import DeleteHook
|
||||
from hooks.restore_hook import RestoreHook
|
||||
from hooks.unread_hook import UnreadHook
|
||||
@@ -40,6 +41,7 @@ class ChatSummariesPlugin(BasePlugin):
|
||||
self.unread_hook = UnreadHook(self)
|
||||
self.restore_hook = RestoreHook(self)
|
||||
self.delete_hook = DeleteHook(self)
|
||||
self.badge_hook = BadgeHook(self)
|
||||
self.summary_db = None
|
||||
|
||||
def _get_summary_db(self):
|
||||
@@ -90,11 +92,16 @@ class ChatSummariesPlugin(BasePlugin):
|
||||
self.delete_hook.install()
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("on_plugin_load:delete_hook.install", exc)
|
||||
try:
|
||||
self.badge_hook.install()
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("on_plugin_load:badge_hook.install", exc)
|
||||
|
||||
def on_plugin_unload(self) -> None:
|
||||
self.unread_hook.uninstall()
|
||||
self.restore_hook.uninstall()
|
||||
self.delete_hook.uninstall()
|
||||
self.badge_hook.uninstall()
|
||||
log("chat_summaries unloaded")
|
||||
|
||||
def create_settings(self):
|
||||
|
||||
Reference in new issue
Block a user