Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,8 @@ tlgr user birthday set <user> <date> # sends a visible message

tlgr user dialog-status <user> [--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 <user>... [--unhide] [--all] # v1's spelling of `story hide`
→ {"user_id": ..., "username": ..., "hidden": true, "already": false}
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <USER> [OPTIONS]
Expand Down
3 changes: 3 additions & 0 deletions tests/test_agentmd_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@
"resolved",
"has_dialog",
"message_count",
"read_outbox_max_id",
"unread_count",
"top_message",
"source",
"reason",
},
Expand Down
62 changes: 62 additions & 0 deletions tests/test_ops_contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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`
Expand Down
14 changes: 14 additions & 0 deletions tlgr/models/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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
Expand Down
48 changes: 45 additions & 3 deletions tlgr/ops/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 []))
Expand All @@ -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
Expand All @@ -655,19 +678,38 @@ 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,
"username": "alice",
"resolved": True,
"has_dialog": True,
"message_count": 12,
"read_outbox_max_id": 893,
"unread_count": 0,
"top_message": 893,
"source": "peer_dialogs",
"reason": None,
},
Expand Down
Loading