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",
|
"name": "Chat Summaries",
|
||||||
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
"description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.",
|
||||||
"author": "@lyuksovannyy",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"icon": "exteraGramPluginIcons/2",
|
"icon": "exteraGramPluginIcons/2",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"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,
|
SETTING_STORAGE_REVISION,
|
||||||
STREAM_UPDATE_INTERVAL,
|
STREAM_UPDATE_INTERVAL,
|
||||||
)
|
)
|
||||||
|
from hooks.badge_hook import BadgeHook
|
||||||
from hooks.delete_hook import DeleteHook
|
from hooks.delete_hook import DeleteHook
|
||||||
from hooks.restore_hook import RestoreHook
|
from hooks.restore_hook import RestoreHook
|
||||||
from hooks.unread_hook import UnreadHook
|
from hooks.unread_hook import UnreadHook
|
||||||
@@ -40,6 +41,7 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
self.unread_hook = UnreadHook(self)
|
self.unread_hook = UnreadHook(self)
|
||||||
self.restore_hook = RestoreHook(self)
|
self.restore_hook = RestoreHook(self)
|
||||||
self.delete_hook = DeleteHook(self)
|
self.delete_hook = DeleteHook(self)
|
||||||
|
self.badge_hook = BadgeHook(self)
|
||||||
self.summary_db = None
|
self.summary_db = None
|
||||||
|
|
||||||
def _get_summary_db(self):
|
def _get_summary_db(self):
|
||||||
@@ -90,11 +92,16 @@ class ChatSummariesPlugin(BasePlugin):
|
|||||||
self.delete_hook.install()
|
self.delete_hook.install()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostics.record_error("on_plugin_load:delete_hook.install", 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:
|
def on_plugin_unload(self) -> None:
|
||||||
self.unread_hook.uninstall()
|
self.unread_hook.uninstall()
|
||||||
self.restore_hook.uninstall()
|
self.restore_hook.uninstall()
|
||||||
self.delete_hook.uninstall()
|
self.delete_hook.uninstall()
|
||||||
|
self.badge_hook.uninstall()
|
||||||
log("chat_summaries unloaded")
|
log("chat_summaries unloaded")
|
||||||
|
|
||||||
def create_settings(self):
|
def create_settings(self):
|
||||||
|
|||||||
Reference in new issue
Block a user