generated from exteraGramPlugins/pluginBase
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af23d2e682 |
No files matched your search
+5
-2
@@ -3,9 +3,12 @@
|
||||
"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.0",
|
||||
"version": "1.1.1",
|
||||
"icon": "exteraPlugins/1",
|
||||
"app_version": ">=12.5.1",
|
||||
"sdk_version": ">=1.4.4.3",
|
||||
"requirements": ["requests", "debugpy"]
|
||||
"requirements": [
|
||||
"requests",
|
||||
"debugpy"
|
||||
]
|
||||
}
|
||||
+11
-3
@@ -57,6 +57,14 @@ SUMMARY_HASHTAG = "#aisummaries"
|
||||
|
||||
# A summary card can only be placed directly after its newest analyzed
|
||||
# message, which with an offset may not be loaded yet. Placement asks the chat
|
||||
# to load it, then retries this many times, this many ms apart.
|
||||
PLACEMENT_RETRIES = 25
|
||||
PLACEMENT_RETRY_DELAY_MS = 120
|
||||
# to load it, then retries this many times, this many ms apart (~10 s), asking
|
||||
# again every PLACEMENT_RELOAD_EVERY retries in case a request was dropped.
|
||||
PLACEMENT_RETRIES = 80
|
||||
PLACEMENT_RETRY_DELAY_MS = 125
|
||||
PLACEMENT_RELOAD_EVERY = 16
|
||||
|
||||
# After jumping to a fresh card, check at these delays (ms) that it is still on
|
||||
# screen and jump again if not. Loading a far anchor makes ChatActivity run its
|
||||
# own postponed scroll, and a cache-then-server reload can re-scroll it, either
|
||||
# of which can land after the jump and carry the view away from the card.
|
||||
JUMP_SETTLE_DELAYS_MS = (250, 700, 1500, 3000)
|
||||
+9
-1
@@ -274,6 +274,9 @@ class ChatSummariesPlugin(BasePlugin):
|
||||
bulletin and leaves the chat exactly as the user left it: no error
|
||||
bubble to dismiss, no jump away from where they were reading.
|
||||
"""
|
||||
diagnostics.note(
|
||||
f"summary: dialog={dialog_id} topic={topic_id} count={count} offset={offset}"
|
||||
)
|
||||
BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment)
|
||||
|
||||
def on_fetched(messages, error):
|
||||
@@ -292,6 +295,7 @@ class ChatSummariesPlugin(BasePlugin):
|
||||
self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched)
|
||||
|
||||
def _report_error(self, message: str, fragment: Any) -> None:
|
||||
diagnostics.note(f"summary failed: {message}")
|
||||
BulletinHelper.show_error(f"Chat Summaries: {message}", fragment)
|
||||
|
||||
def _stream_into(
|
||||
@@ -310,7 +314,11 @@ class ChatSummariesPlugin(BasePlugin):
|
||||
"""
|
||||
from services.rich_injector import SummaryInjection, virtual_id_for
|
||||
|
||||
anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages)
|
||||
ids = [int(getattr(m, "id", 0) or 0) for m in messages]
|
||||
anchor_msg_id = max(ids)
|
||||
diagnostics.note(
|
||||
f"summary: fetched {len(messages)} messages, ids {min(ids)}..{anchor_msg_id}"
|
||||
)
|
||||
db = self._get_summary_db()
|
||||
state = {
|
||||
"answer": "",
|
||||
|
||||
@@ -16,7 +16,13 @@ Verified against exteraGram 12.5.x (dex inspection):
|
||||
from typing import Any, Optional
|
||||
|
||||
import diagnostics
|
||||
from config import PLACEMENT_RETRIES, PLACEMENT_RETRY_DELAY_MS, SUMMARY_HASHTAG
|
||||
from config import (
|
||||
JUMP_SETTLE_DELAYS_MS,
|
||||
PLACEMENT_RELOAD_EVERY,
|
||||
PLACEMENT_RETRIES,
|
||||
PLACEMENT_RETRY_DELAY_MS,
|
||||
SUMMARY_HASHTAG,
|
||||
)
|
||||
from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain
|
||||
|
||||
# Above Telegram's issued id range, so this user exists only locally.
|
||||
@@ -379,10 +385,18 @@ class SummaryInjection:
|
||||
else:
|
||||
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||
if index < 0:
|
||||
if retries_left == PLACEMENT_RETRIES:
|
||||
attempt = PLACEMENT_RETRIES - retries_left
|
||||
if retries_left > 0 and attempt % PLACEMENT_RELOAD_EVERY == 0:
|
||||
# The analyzed range ends outside the loaded window
|
||||
# (an offset, or history scrolled away). Have the chat
|
||||
# load and show it, then place once it is in the list.
|
||||
# Asked again periodically: scrollToMessageId drops the
|
||||
# request outright while waitingForGetDifference, and
|
||||
# a load can be superseded by another one.
|
||||
diagnostics.note(
|
||||
f"injector: anchor {self.anchor_msg_id} not loaded "
|
||||
f"({_id_span(messages_list)}), requesting it (attempt {attempt})"
|
||||
)
|
||||
self._load_anchor()
|
||||
if retries_left > 0:
|
||||
_on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS)
|
||||
@@ -398,16 +412,87 @@ class SummaryInjection:
|
||||
messages_list.set(index, self.message_object)
|
||||
self.placed = True
|
||||
self._placing = False
|
||||
diagnostics.note(
|
||||
f"injector: card placed after anchor {self.anchor_msg_id} at index {index} "
|
||||
f"(retries left {retries_left})"
|
||||
)
|
||||
adapter = get_chat_adapter(self.chat_activity)
|
||||
if adapter is not None:
|
||||
adapter.notifyDataSetChanged()
|
||||
# Once, on placement only -- scrolling on every streaming update
|
||||
# would fight the user for control of the list.
|
||||
self.scroll_into_view()
|
||||
# On placement only -- scrolling on every streaming update would
|
||||
# fight the user for control of the list. The settle checks right
|
||||
# after only undo scrolls the app itself makes in that window.
|
||||
if self.scroll_into_view():
|
||||
self._schedule_settle(0)
|
||||
except Exception as exc:
|
||||
self._placing = False
|
||||
diagnostics.record_error("injector.place", exc)
|
||||
|
||||
def _schedule_settle(self, step: int) -> None:
|
||||
if step >= len(JUMP_SETTLE_DELAYS_MS):
|
||||
return
|
||||
previous = JUMP_SETTLE_DELAYS_MS[step - 1] if step else 0
|
||||
_on_ui(lambda: self._settle(step), JUMP_SETTLE_DELAYS_MS[step] - previous)
|
||||
|
||||
def _settle(self, step: int) -> None:
|
||||
"""Re-jumps if something scrolled the fresh card off screen.
|
||||
|
||||
Stops for good as soon as the user touches the list: from then on
|
||||
where the chat is scrolled is theirs to decide.
|
||||
"""
|
||||
try:
|
||||
list_view = _chat_list_view(self.chat_activity)
|
||||
if list_view is not None and int(list_view.getScrollState()) == 1: # SCROLL_STATE_DRAGGING
|
||||
diagnostics.note("injector: user is scrolling, jump settled")
|
||||
return
|
||||
span = self._visible_span()
|
||||
if span is not None:
|
||||
position, first, last = span
|
||||
if not first <= position <= last:
|
||||
diagnostics.note(
|
||||
f"injector: card row {position} scrolled away (visible {first}..{last}), "
|
||||
f"jumping back (check {step})"
|
||||
)
|
||||
self._jump_to_card()
|
||||
elif self.placed and find_existing(get_messages_list(self.chat_activity), self.virtual_id) < 0:
|
||||
# The list was reloaded under the card. Put it back.
|
||||
messages_list = get_messages_list(self.chat_activity)
|
||||
index = find_index_for_anchor(messages_list, self.anchor_msg_id)
|
||||
if index >= 0:
|
||||
diagnostics.note(f"injector: list reloaded, re-placing card (check {step})")
|
||||
messages_list.add(index, self.message_object)
|
||||
adapter = get_chat_adapter(self.chat_activity)
|
||||
if adapter is not None:
|
||||
adapter.notifyDataSetChanged()
|
||||
self._jump_to_card()
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("injector.settle", exc)
|
||||
self._schedule_settle(step + 1)
|
||||
|
||||
def _visible_span(self) -> Optional[tuple]:
|
||||
"""(card adapter position, first visible, last visible), or None."""
|
||||
try:
|
||||
from hook_utils import get_private_field
|
||||
|
||||
messages_list = get_messages_list(self.chat_activity)
|
||||
adapter = get_chat_adapter(self.chat_activity)
|
||||
layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field(
|
||||
self.chat_activity, "chatLayoutManager"
|
||||
)
|
||||
if messages_list is None or adapter is None or layout_manager is None:
|
||||
return None
|
||||
index = find_existing(messages_list, self.virtual_id)
|
||||
if index < 0:
|
||||
return None
|
||||
first = int(layout_manager.findFirstVisibleItemPosition())
|
||||
last = int(layout_manager.findLastVisibleItemPosition())
|
||||
if first < 0 or last < 0:
|
||||
return None
|
||||
return int(adapter.messagesStartRow) + index, min(first, last), max(first, last)
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("injector.visible_span", exc)
|
||||
return None
|
||||
|
||||
def _load_anchor(self) -> None:
|
||||
"""Asks the chat to load the anchor and scroll to it.
|
||||
|
||||
@@ -464,6 +549,7 @@ class SummaryInjection:
|
||||
# bottom=False: offset counts from the top edge, as in
|
||||
# ChatActivity.scrollToMessageId's own scrollToPosition call.
|
||||
layout_manager.scrollToPositionWithOffset(position, offset, False)
|
||||
diagnostics.note(f"injector: jump to row {position} offset {offset}")
|
||||
return True
|
||||
except Exception as exc:
|
||||
diagnostics.record_error("injector.jump_to_card", exc)
|
||||
@@ -583,6 +669,21 @@ def _chat_list_view(chat_activity: Any) -> Optional[Any]:
|
||||
return None
|
||||
|
||||
|
||||
def _id_span(messages_list: Any) -> str:
|
||||
"""'loaded ids A..B, N rows' for diagnostics."""
|
||||
try:
|
||||
ids = [
|
||||
int(getattr(getattr(messages_list.get(i), "messageOwner", None), "id", 0) or 0)
|
||||
for i in range(messages_list.size())
|
||||
]
|
||||
real = [i for i in ids if i > 0]
|
||||
if not real:
|
||||
return f"no messages, {len(ids)} rows"
|
||||
return f"loaded ids {min(real)}..{max(real)}, {len(ids)} rows"
|
||||
except Exception:
|
||||
return "loaded ids unknown"
|
||||
|
||||
|
||||
def remove_cards(chat_activity: Any, virtual_ids) -> None:
|
||||
"""Drops injected cards from an open chat's list.
|
||||
|
||||
|
||||
+88
-2
@@ -1845,6 +1845,10 @@ def main(argv=None) -> int:
|
||||
def __init__(self):
|
||||
self.cells = []
|
||||
self.stopped = 0
|
||||
self.scroll_state = 0
|
||||
|
||||
def getScrollState(self):
|
||||
return self.scroll_state
|
||||
|
||||
def getMeasuredHeight(self):
|
||||
return 900
|
||||
@@ -1859,11 +1863,30 @@ def main(argv=None) -> int:
|
||||
return self.cells[index]
|
||||
|
||||
class _FakeLayoutManager:
|
||||
"""Records jumps and reports the visible row range they produce.
|
||||
|
||||
`hijack` simulates ChatActivity's own postponed scroll landing
|
||||
right after a jump and carrying the view elsewhere.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.jumps = []
|
||||
self.visible = (-1, -1)
|
||||
self.hijack = 0
|
||||
|
||||
def scrollToPositionWithOffset(self, position, offset, bottom):
|
||||
self.jumps.append((position, offset, bottom))
|
||||
if self.hijack:
|
||||
self.hijack -= 1
|
||||
self.visible = (40, 46)
|
||||
else:
|
||||
self.visible = (max(0, position - 3), position + 3)
|
||||
|
||||
def findFirstVisibleItemPosition(self):
|
||||
return self.visible[0]
|
||||
|
||||
def findLastVisibleItemPosition(self):
|
||||
return self.visible[1]
|
||||
|
||||
class _FakeChatActivity:
|
||||
def __init__(self, ids):
|
||||
@@ -2088,6 +2111,64 @@ def main(argv=None) -> int:
|
||||
f"ids={far_ids}",
|
||||
)
|
||||
|
||||
# Loading a far anchor makes ChatActivity run its own postponed scroll,
|
||||
# which can land after the jump and carry the view away from the card.
|
||||
# The settle checks must bring it back.
|
||||
hijacked = _FakeChatActivity([900, 800])
|
||||
hijacked.loadable[300] = [310, 300, 290]
|
||||
hijacked.chatLayoutManager.hijack = 1
|
||||
hijacked_card = injector.virtual_id_for(7, 300)
|
||||
injector.SummaryInjection(hijacked, 0, -4242, 0, 300, hijacked_card).insert(
|
||||
thinking="chunk", streaming=True
|
||||
)
|
||||
card_row = 2 + hijacked.ids().index(hijacked_card)
|
||||
first, last = hijacked.chatLayoutManager.visible
|
||||
check(
|
||||
"a jump overridden by the app's own scroll is redone until the card is on screen",
|
||||
len(hijacked.chatLayoutManager.jumps) == 2 and first <= card_row <= last,
|
||||
f"jumps={hijacked.chatLayoutManager.jumps} visible={(first, last)} row={card_row}",
|
||||
)
|
||||
|
||||
# Once the user takes hold of the list, the plugin stops steering it.
|
||||
dragged = _FakeChatActivity([600, 555, 500])
|
||||
dragged.chatLayoutManager.hijack = 1
|
||||
dragged.chatListView.scroll_state = 1
|
||||
injector.SummaryInjection(dragged, 0, -4242, 0, 555, injector.virtual_id_for(8, 555)).insert(
|
||||
thinking="chunk", streaming=True
|
||||
)
|
||||
check(
|
||||
"no re-jump while the user is dragging the list",
|
||||
len(dragged.chatLayoutManager.jumps) == 1,
|
||||
f"jumps={dragged.chatLayoutManager.jumps}",
|
||||
)
|
||||
|
||||
# A cache-then-server reload can replace the list under a fresh card.
|
||||
reloaded = _FakeChatActivity([600, 555, 500])
|
||||
reloaded_card = injector.virtual_id_for(9, 555)
|
||||
reloaded_inj = injector.SummaryInjection(reloaded, 0, -4242, 0, 555, reloaded_card)
|
||||
real_on_ui_for_reload = injector._on_ui
|
||||
dropped = []
|
||||
|
||||
def _reloading_on_ui(fn, delay_ms=0):
|
||||
if delay_ms and not dropped:
|
||||
# First settle check: the chat reloads its window first.
|
||||
reloaded.messages[:] = [m for m in reloaded.messages if m.messageOwner.id > 0]
|
||||
dropped.append(True)
|
||||
fn()
|
||||
|
||||
injector._on_ui = _reloading_on_ui
|
||||
try:
|
||||
reloaded_inj.insert(thinking="chunk", streaming=True)
|
||||
finally:
|
||||
injector._on_ui = real_on_ui_for_reload
|
||||
reloaded_ids = reloaded.ids()
|
||||
check(
|
||||
"a card dropped by a list reload right after placement is put back",
|
||||
dropped and reloaded_ids.count(reloaded_card) == 1
|
||||
and reloaded_ids.index(reloaded_card) == reloaded_ids.index(555) - 1,
|
||||
f"ids={reloaded_ids}",
|
||||
)
|
||||
|
||||
# An anchor that never loads: no card anywhere, rather than a card
|
||||
# attached to messages it did not analyze.
|
||||
never = _FakeChatActivity([900, 800])
|
||||
@@ -2097,8 +2178,13 @@ def main(argv=None) -> int:
|
||||
never_inj.update(answer_markdown="more", streaming=True)
|
||||
check(
|
||||
"an anchor that never loads places no card at a wrong position",
|
||||
never_card not in never.ids() and not never_inj.placed and never.scrolled_to == [300],
|
||||
f"ids={never.ids()} scrolled_to={never.scrolled_to}",
|
||||
never_card not in never.ids() and not never_inj.placed,
|
||||
f"ids={never.ids()}",
|
||||
)
|
||||
check(
|
||||
"a far anchor that does not arrive is requested again, not just once",
|
||||
len(never.scrolled_to) >= 2 and set(never.scrolled_to) == {300},
|
||||
f"scrolled_to={never.scrolled_to}",
|
||||
)
|
||||
# ...and the card shows up once the user scrolls the anchor in.
|
||||
for mid in (310, 300):
|
||||
|
||||
Reference in new issue
Block a user