diff --git a/AGENT.md b/AGENT.md index 33d5f17..76b3112 100644 --- a/AGENT.md +++ b/AGENT.md @@ -736,7 +736,8 @@ tlgr user birthday set # sends a visible message tlgr user dialog-status [--max-dialogs N] → {"ref": ..., "id": ..., "username": ..., "resolved": true, "has_dialog": true, - "message_count": 12, "source": "peer_dialogs", "reason": null} + "message_count": 12, "read_outbox_max_id": 893, "unread_count": 0, + "top_message": 893, "source": "peer_dialogs", "reason": null} tlgr user hide-stories ... [--unhide] [--all] # v1's spelling of `story hide` → {"user_id": ..., "username": ..., "hidden": true, "already": false} @@ -805,6 +806,24 @@ the bug this command exists to remove; exit 13 must be treated as a refusal. how far it got. - `unknown` — cap hit, FloodWait, or RPC failure. `resolved` is `false`. +It also reports the peer's **read state**, because `messages.GetPeerDialogs` +already hands back the whole dialog object: + +| key | meaning | +|---|---| +| `read_outbox_max_id` | the highest message of **ours** the peer has read | +| `unread_count` | messages of theirs this account has not read | +| `top_message` | the id of the newest message in the dialog | + +All three are on **every** return path — `null` on the indeterminate one, `0` +on the definitive negative — because an absent key reads back as `null`, which +is indistinguishable from "they have not read it". `chat list` reports the same +field, so the two routes cannot disagree about the same peer. + +"Did they see our last message?" is `read_outbox_max_id >= top_message` **and** +the last message being ours. There is deliberately no derived `they_read_it` +boolean: the second half of that condition is only knowable by the caller. + Note: there is no MTProto call that resolves a bare user id to an access hash for a non-bot account (`users.GetUsers` with `access_hash=0` returns `UserEmpty` for non-contacts). The dialog list, not entity resolution, is what diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9cfe8..d4176c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,19 @@ registered and exit 13 `NOT_SUPPORTED`: they need API layer 229, which the pinned Telethon does not speak. They exist so that "unavailable in this build" is a different answer from "no such command". +`user dialog-status` gained the peer's read state — `read_outbox_max_id` (the +highest message of *ours* they have read), `unread_count` and `top_message` — +on every return path, `null` when nothing could be established and `0` on the +definitive negative. The server's dialog object always carried these and the op +threw them away, so the command could not answer "have they read our message?" +and answered it *wrongly by omission*: the key was simply absent, so a caller +reading it back got `null`, which is indistinguishable from "not read". `chat +list` has always reported the same field, so the two routes disagreed about the +same peer. No derived `they_read_it` boolean, on purpose: the comparison only +means "they saw our last message" when the last message is in fact ours, which +only the caller knows. The three keys are additions — nothing moved or was +renamed, and the three-valued contract and exit 13 are untouched. + One bug fix rides along: `message get --json` now actually prints `reply_markup`. PR-1 declared the shape and nothing ever populated it, which made its two keyboard-rendering P0 ids true only on paper — a caller could see diff --git a/docs/reference/user.md b/docs/reference/user.md index d14fe81..cadb213 100644 --- a/docs/reference/user.md +++ b/docs/reference/user.md @@ -148,7 +148,7 @@ Full: `contacts-users.user-leave-common-groups` Does this account have prior history with this user? (three-valued, never guessed). -resolved=true/has_dialog=true — a dialog exists, message_count is the server's exact total. resolved=true/has_dialog=false — definitively none, because the COMPLETE dialog list was enumerated. resolved=false/has_dialog=null — exit 13, and `reason` says why. Exit 13 means UNKNOWN: a caller gating a cold first message must treat it as a refusal, never as a green light. +resolved=true/has_dialog=true — a dialog exists, message_count is the server's exact total. resolved=true/has_dialog=false — definitively none, because the COMPLETE dialog list was enumerated. resolved=false/has_dialog=null — exit 13, and `reason` says why. Exit 13 means UNKNOWN: a caller gating a cold first message must treat it as a refusal, never as a green light. The peer's read state — read_outbox_max_id (the highest message of OURS they have read), unread_count, top_message — is on every return path, null when nothing could be established. 'Did they see our last message?' is read_outbox_max_id >= top_message AND the last message being ours; this op does not guess at the second half, because only the caller knows it. ``` tlgr user dialog-status [OPTIONS] diff --git a/tests/test_agentmd_compat.py b/tests/test_agentmd_compat.py index fb5f1ef..c41f3b7 100644 --- a/tests/test_agentmd_compat.py +++ b/tests/test_agentmd_compat.py @@ -147,6 +147,9 @@ "resolved", "has_dialog", "message_count", + "read_outbox_max_id", + "unread_count", + "top_message", "source", "reason", }, diff --git a/tests/test_ops_contacts.py b/tests/test_ops_contacts.py index a6d048b..f810a26 100644 --- a/tests/test_ops_contacts.py +++ b/tests/test_ops_contacts.py @@ -886,6 +886,9 @@ async def test_the_documented_keys_are_all_there(self, live_daemon, client, in_t "resolved", "has_dialog", "message_count", + "read_outbox_max_id", + "unread_count", + "top_message", "source", } <= set(answer) @@ -956,6 +959,65 @@ def fake_dispatch(spec, request, state): assert outcome.exit_code == EXIT_INDETERMINATE assert '"has_dialog": null' in outcome.output + # -- the peer's read state, which must always be PRESENT --------------- + # + # An ABSENT key reads back as `null`, which is indistinguishable from + # "they have not read it". A real 0 must be a real 0, and every other + # outcome must still carry the key. `/chat/list` has always reported this + # field, so without it the two routes disagreed about the same peer. + + async def test_read_state_is_reported_when_the_peer_read_our_message( + self, live_daemon, client, in_thread, book + ): + book.add_message(ALICE, "hello", message_id=893) + book.add_dialog(ALICE, top_message=893, unread_count=0) + book.read_outbox[ALICE] = 893 + answer = await result(client, in_thread, "user.dialog-status", {"user": "@alice"}) + assert answer["read_outbox_max_id"] == 893 + assert answer["top_message"] == 893 + assert answer["unread_count"] == 0 + + async def test_an_unread_outgoing_message_is_zero_not_missing( + self, live_daemon, client, in_thread, book + ): + """The trap this exists to close: 0 is an answer, absence is not.""" + book.add_message(ALICE, "hello", message_id=852) + book.add_dialog(ALICE, top_message=852) + book.read_outbox[ALICE] = 0 + answer = await result(client, in_thread, "user.dialog-status", {"user": "@alice"}) + assert answer["read_outbox_max_id"] == 0 + assert answer["top_message"] == 852 + + async def test_every_return_path_carries_the_read_state_keys( + self, live_daemon, client, in_thread, book + ): + """Including the ones that answer nothing — that is the whole point.""" + keys = {"read_outbox_max_id", "unread_count", "top_message"} + + indeterminate = await result( + client, in_thread, "user.dialog-status", {"user": str(NOBODY), "max_dialogs": 1} + ) + assert keys <= set(indeterminate) + assert all(indeterminate[key] is None for key in keys) + + negative = await result(client, in_thread, "user.dialog-status", {"user": str(NOBODY)}) + assert keys <= set(negative) + assert all(negative[key] == 0 for key in keys) + + async def test_the_read_state_does_not_become_a_they_read_it_boolean( + self, live_daemon, client, in_thread, book + ): + """The comparison is only meaningful when the last message is ours, + which only the caller knows — so the raw fields are echoed and no + derived verdict is invented.""" + book.add_message(ALICE, "hi", message_id=849) + book.add_dialog(ALICE, top_message=849, unread_count=3) + book.read_outbox[ALICE] = 846 + answer = await result(client, in_thread, "user.dialog-status", {"user": "@alice"}) + assert (answer["read_outbox_max_id"], answer["top_message"]) == (846, 849) + assert answer["unread_count"] == 3 + assert "they_read_it" not in answer + # --------------------------------------------------------------------------- # user hide-stories — the frozen contract, now owned by `story hide` diff --git a/tlgr/models/contact.py b/tlgr/models/contact.py index 5051154..f72dc8f 100644 --- a/tlgr/models/contact.py +++ b/tlgr/models/contact.py @@ -317,6 +317,17 @@ class DialogStatus(ContactModel): `has_dialog` is deliberately `bool | None`: there is no third boolean, and a caller that reads `null` as `false` re-introduces the cold-contact bug this command exists to remove. + + The server's dialog object carries more than "does it exist", and the + three read-state fields are echoed verbatim rather than reduced to a + convenience boolean: `read_outbox_max_id` (the highest message of OURS + the peer has read), `unread_count` and `top_message`. They are always + present — a *missing* key is what makes an unanswerable question look + answered, since a caller reading it back gets `null` and cannot tell + "not read" from "never reported". `read_outbox_max_id >= top_message` + only means "they saw our last message" when the last message is in fact + ours, so that comparison is left to the caller, which is the only party + that knows. """ ref: str = "" @@ -325,6 +336,9 @@ class DialogStatus(ContactModel): resolved: bool = False has_dialog: bool | None = None message_count: int | None = None + read_outbox_max_id: int | None = None + unread_count: int | None = None + top_message: int | None = None source: str = "unknown" reason: str | None = None scanned_dialogs: int | None = None diff --git a/tlgr/ops/user.py b/tlgr/ops/user.py index 376ae2b..847c95a 100644 --- a/tlgr/ops/user.py +++ b/tlgr/ops/user.py @@ -536,6 +536,16 @@ async def dialog_status(ctx: OpContext, req: DialogStatusReq) -> DialogStatus: It reports on the dialog list: a conversation this account itself deleted is gone server-side too and correctly reads as no dialog. + + `messages.getPeerDialogs` already hands back the whole dialog object, so + the peer's read state rides along rather than being thrown away: + `read_outbox_max_id` (the highest message of OURS they have read), + `unread_count` and `top_message`. All three are on EVERY return path, + including the indeterminate one (null) and the definitive negative (0) — + an absent key reads back as `null`, which is indistinguishable from "they + have not read it". There is deliberately no derived `they_read_it` + boolean: that comparison is only meaningful when the last message is + ours, which only the caller knows. """ from telethon import utils from telethon.tl import types @@ -609,6 +619,9 @@ def unknown(reason: str) -> DialogStatus: out.resolved = True out.has_dialog = False out.message_count = 0 + out.read_outbox_max_id = 0 + out.unread_count = 0 + out.top_message = 0 out.source = "dialog_scan" out.reason = "absent from the account's complete dialog list" out.id = target_id @@ -621,7 +634,11 @@ def unknown(reason: str) -> DialogStatus: mfn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer=input_peer)]) ) dialogs = list(getattr(answer, "dialogs", None) or []) - top = max((int(getattr(d, "top_message", 0) or 0) for d in dialogs), default=0) + # The peer's own dialog object — the one with the newest message when + # the server hands back more than one — carries the read state as + # well as the top message. + dlg = max(dialogs, key=lambda d: int(getattr(d, "top_message", 0) or 0), default=None) + top = int(getattr(dlg, "top_message", 0) or 0) if dlg is not None else 0 messages = await client.get_messages(input_peer, limit=1) total = getattr(messages, "total", None) total = int(total if total is not None else len(messages or [])) @@ -638,6 +655,12 @@ def unknown(reason: str) -> DialogStatus: # history: presence in the dialog list *is* the dialog. out.has_dialog = out.source == "dialog_scan" or bool(top) or total > 0 out.message_count = total + if dlg is not None: + read = getattr(dlg, "read_outbox_max_id", None) + unread = getattr(dlg, "unread_count", None) + out.read_outbox_max_id = None if read is None else int(read) + out.unread_count = None if unread is None else int(unread) + out.top_message = top if out.source != "dialog_scan": out.source = "peer_dialogs" return out @@ -655,12 +678,28 @@ def unknown(reason: str) -> DialogStatus: "definitively none, because the COMPLETE dialog list was enumerated. " "resolved=false/has_dialog=null — exit 13, and `reason` says why. " "Exit 13 means UNKNOWN: a caller gating a cold first message must " - "treat it as a refusal, never as a green light." + "treat it as a refusal, never as a green light. The peer's read " + "state — read_outbox_max_id (the highest message of OURS they have " + "read), unread_count, top_message — is on every return path, null " + "when nothing could be established. 'Did they see our last message?' " + "is read_outbox_max_id >= top_message AND the last message being " + "ours; this op does not guess at the second half, because only the " + "caller knows it." ), legacy_paths=("user dialog-status",), rate_class="bulk", timeout_s=600, - columns=("id", "username", "resolved", "has_dialog", "message_count", "source"), + columns=( + "id", + "username", + "resolved", + "has_dialog", + "message_count", + "read_outbox_max_id", + "top_message", + "unread_count", + "source", + ), example={ "ref": "@alice", "id": 777123, @@ -668,6 +707,9 @@ def unknown(reason: str) -> DialogStatus: "resolved": True, "has_dialog": True, "message_count": 12, + "read_outbox_max_id": 893, + "unread_count": 0, + "top_message": 893, "source": "peer_dialogs", "reason": None, },