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
82 changes: 72 additions & 10 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ tlgr user dialog-status <user> [--max-dialogs N]
→ {"ref": ..., "id": ..., "username": ..., "resolved": true, "has_dialog": true,
"message_count": 12, "source": "peer_dialogs", "reason": null}

tlgr user hide-stories <user>... [--unhide] [--all on|off]
tlgr user hide-stories <user>... [--unhide] [--all] # v1's spelling of `story hide`
→ {"user_id": ..., "username": ..., "hidden": true, "already": false}
# More than one peer fills `peers`; a single peer answers with exactly the
# four keys above.
Expand Down Expand Up @@ -774,15 +774,11 @@ tlgr resolve cache get [--type KIND] [--stale 7d] [--refresh PEER] [--purge]
# `access_hash_cached`; they are per login session and worthless elsewhere.
```

`hide-stories` is Telegram's own "Hide Stories" menu item: the peer leaves the
main stories bar for the collapsed Hidden list. Per-account and purely local —
**the other side is never notified**, the chat, the contact entry and their
access to you are untouched — so it is safe to apply in bulk to everyone an
outreach campaign has contacted, which is what keeps a working account's story
bar readable. Idempotent: it reads the fresh `stories_hidden` flag first and
returns `already: true` without an RPC when there is nothing to do, so
repeating a pass over hundreds of peers is nearly free. `tlgr user get` reports
the same flag as `stories_hidden`, so the state can be audited without writing.
`hide-stories` is now `tlgr story hide <peer>` (and `--unhide` is
`tlgr story unhide <peer>`). The old path, the old flag and the four keys are
unchanged — see **Stories** below for what it does and why it is free to
repeat. `tlgr user get` still reports the current value as `stories_hidden`,
so the state can be audited without writing.

`dialog-status` is the ONLY correct way to ask "does this account have prior
history with this person?". Three outcomes, never conflated:
Expand Down Expand Up @@ -937,6 +933,72 @@ the current tip; tlgr has no block builder, accepts one from an external
implementation (`--block`, `--public-key`) and otherwise exits 2 naming exactly
what is missing rather than sending a request that will fail.

### Stories

```
tlgr story feed list # the stories bar; --hidden, --unread-only
→ {"items": [{"peer_id": …, "max_read_id": 41, "unread_count": 1,
"has_unread": true}], "has_more": false}

tlgr story list <chat> # active; --profile, --archive, --album ID
→ {"items": [{"id": 42, "date": "…Z", "expire_date": "…Z", "caption": "…",
"media": {…}, "pinned": false}], "has_more": false}

tlgr story get <chat> <id>... # --views, --link, --areas-out, --translate
tlgr story post <file>... # --caption, --privacy, --allow, --exclude,
# --period, --pin, --album, --area-*
tlgr story edit <chat> <id> # --caption, --file, --cover-ts, --privacy
tlgr story delete <chat> <id>... # irreversible; needs --yes off a TTY

tlgr story read <chat> [<id>...] # clears YOUR unread ring
→ {"peer": …, "max_id": 43, "ids": [42, 43], "ok": true}
tlgr story read <chat> <id> --register-view # …and appear in their viewer list

tlgr story react <chat> <id> 🔥 # --remove, --custom-emoji, --as-message
tlgr story reply <chat> <id> "text" # a private message carrying the story
tlgr story share <chat> <id> --until <chat> # sends a story card, not a copy

tlgr story pin|unpin <chat> <id>... # the profile page; --top for the top row
tlgr story hide|unhide <chat>... # the stories bar; --all for the whole bar
tlgr story viewer list <chat> <id> # --contacts, --q, --csv PATH, --hide-from
tlgr story blocklist list|set <user>... # "Hide my stories from"; --remove, --replace
tlgr story album create|edit|list|delete|reorder <chat> …
tlgr story can-post # free slots, limits, Premium gates, --chats
tlgr story stealth set --past --future # Premium; --status only reads
tlgr story search --hashtag berlin # public stories only
tlgr story stats get <chat> <id> # graphs; --forwards for public reposts
tlgr story export <chat> --out DIR # the bulk export the GUI has no button for
tlgr story live start --rtmp # prints the ingest URL and key
tlgr story watch # story.new / read / reaction / stealth
```

Four rules matter more than the flags:

- **Reading is not being seen.** `story read` sends `stories.readStories`,
which clears the ring on *your* side and tells the poster nothing. Appearing
in their viewer list is `--register-view`, and it is opt-in on purpose.
- **The audience is a base rule plus exceptions**, applied in that order:
`--privacy contacts --exclude @bob` is "contacts, except Bob". `--privacy
selected` with no `--allow` is refused, because it would post to nobody.
Channel stories ignore the vector entirely.
- **A placeholder is not a story.** A feed row can come back as
`{"id": 99, "skipped": true}` and a gone story as `{"id": 99,
"deleted": true}`. `story list` hydrates placeholders by default;
`--no-hydrate` gives you the raw shape.
- **Re-run `story can-post` immediately before posting.** The weekly and
monthly quotas move under you, and a refusal comes back as a named
`reason` (`STORY_SEND_FLOOD_WEEKLY`, `BOOSTS_REQUIRED`, …) with the
seconds or boosts still missing.

`story hide` is Telegram's own "Hide Stories" menu item: the peer leaves the
main stories bar for the collapsed Hidden list. Per-account and purely local —
**the other side is never notified**, the chat, the contact entry and their
access to you are untouched — so it is safe to apply in bulk to everyone an
outreach campaign has contacted. Idempotent: it reads the fresh
`stories_hidden` flag first and returns `already: true` without an RPC when
there is nothing to do, so repeating a pass over hundreds of peers is nearly
free.

### Agent Helpers

```
Expand Down
45 changes: 44 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,19 @@ freezes are unchanged: `user dialog-status` is still three-valued and still
exits 13 for "could not establish", and `user hide-stories` still reports
`already` and sends nothing when there is nothing to do.

`story` follows — 31 operations covering posting, the feed, viewers, albums,
the story blocklist, stealth mode and live stories, where v1 had exactly one
command (`user hide-stories`). That path still works, `--unhide` and bulk
peers included: `story hide` is now the single implementation of the toggle
and `user hide-stories` is a legacy path on it, so the two spellings cannot
drift apart.

### Breaking

Every change below applies **only to commands generated from the operation
registry** — in this release that is the `message`, `draft`, `chat`,
`folder`, `auth`, `account`, `passport`, `media`, `sticker`, `gif`, `emoji`,
`events`, `watch`, `daemon`, `sync`, `net`, `proxy`, `config`, `job`,
`story`, `events`, `watch`, `daemon`, `sync`, `net`, `proxy`, `config`, `job`,
`webhook`, `export`, `contact`, `user` and `resolve` groups,
`tlgr completion`, `tlgr status`, `tlgr schema` and the `agent` group. Commands still
hand-written under `tlgr/cli/legacy/` behave exactly as they did in v1 until
Expand Down Expand Up @@ -142,6 +149,37 @@ Two more, outside the documented output shapes:

### Added

- **The `story` group.** 31 operations covering the whole story surface:
posting (with the audience vector, media areas, albums, reposts and a
soundtrack), the stories bar, a peer's active/profile/archive/album grids,
reading, reacting, replying, sharing, pinning, hiding, viewers, the
story-only blocklist, stealth mode, hashtag and location search, statistics,
a bulk export and live stories. Four of Telegram's shapes drive the design
and are worth knowing before scripting against them:
- **Reading a story and being seen watching it are different calls.**
`story read` sends `stories.readStories`, which clears *your* unread ring
and tells the poster nothing; `--register-view` is what calls
`stories.incrementStoryViews` and puts you in their viewer list. It is
opt-in because an agent that silently appears there is a privacy bug.
- **The audience is an ordered vector**, `[base rule, allows…, disallows…]`,
which is the only way `--privacy contacts --exclude @bob` can mean
"contacts, except Bob". `--privacy selected` with no `--allow` is refused
rather than posted to nobody, and channel stories ignore the vector.
- **One id has three TL shapes.** A feed row can be a `storyItemSkipped`
placeholder and a gone story a `storyItemDeleted`; both come back with
`skipped: true` / `deleted: true` rather than as a story with no caption.
`story list` hydrates placeholders by default.
- **The feed pages on an opaque state, not an offset.**
`stories.getAllStories` returns a `state` that the next call sends back
with `next`; `--cursor` carries both, and `--refresh` re-sends the stored
state and reports `already: true` when nothing changed.
- **`tlgr user hide-stories` is now `tlgr story hide`.** The v1 path, its
`--unhide` flag and its four keys (`user_id`, `username`, `hidden`,
`already`) are unchanged; `story unhide` is the canonical inverse and
`--all` collapses the whole stories bar.
- **`story viewer list --csv PATH` and `story export`** are the two things the
official clients have no button for: the viewer list as a file, and every
story with its media on disk.
- **The content groups: `poll`, `reaction`, `todo`, `location` and `search`.**
43 operations covering polls and quizzes, the whole reaction surface,
checklists, places and live locations, and search outside a single chat.
Expand Down Expand Up @@ -552,6 +590,11 @@ Two more, outside the documented output shapes:
full set of reactions this account holds after the call, which is what the
next `sendReaction` has to resend.

- **The second implementation of the "Hide Stories" toggle.** `story hide`
owns it, and `tlgr user hide-stories` is declared as its legacy path rather
than kept as an operation of its own — one alias, one implementation.
`tlgr user get` still reports `stories_hidden`.

- The dead `jobs.toml` job engine in `core/config.py` (`load_jobs`,
`save_jobs`, `JobConfig`, `DestinationConfig`, …). It had no callers left;
jobs are `jobs.yaml`, parsed by `gateway/config.py` (MNT-04).
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ rather than the reply claiming "no such user".
```bash
tlgr user get <user> # --full --translate-bio LANG --from-chat/--from-message
tlgr user dialog-status <user> # does THIS account have prior history with them?
tlgr user hide-stories <user>... # archive their stories for this account (--unhide)
tlgr user hide-stories <user>... # v1's spelling of `story hide` (--unhide)
tlgr user block <user> # --stories --report-spam --delete-history
tlgr user unblock <user>
tlgr user can-message <user>... # free | premium | paid (and the Stars price)
Expand Down Expand Up @@ -265,6 +265,28 @@ tlgr resolve cache get # inspect the per-account peer database
that would follow it in `delegated_to`. A phone lookup that comes back empty
exits 13, never 5 — no account and a privacy refusal are indistinguishable.

### Stories

```bash
tlgr story feed list # the stories bar (--hidden, --unread-only)
tlgr story list <chat> # active; --profile, --archive, --album ID
tlgr story get <chat> <id> # --views, --link, --areas-out, --translate
tlgr story post <file>... # --caption, --privacy, --allow, --exclude,
# --period, --pin, --album, --area-url, …
tlgr story read <chat> # clears YOUR ring; --register-view to be seen
tlgr story react|reply|share <chat> <id>
tlgr story pin|unpin|hide|unhide <chat> [<id>...]
tlgr story viewer list <chat> <id> # --contacts, --q, --csv PATH
tlgr story blocklist set <user>... # "Hide my stories from"
tlgr story album create|edit|list|delete|reorder <chat> …
tlgr story can-post | stealth set | search | stats get | export | live start | watch
```

`story read` clears your own unread ring and tells the poster nothing;
`--register-view` is what puts you in their viewer list, and it is opt-in.
`--privacy` sets the base audience and `--allow`/`--exclude` layer exceptions
on top, in that order, so "contacts, except Bob" is expressible.

### Media, stickers, GIFs and emoji

```bash
Expand Down
107 changes: 107 additions & 0 deletions docs/design/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1128,3 +1128,110 @@ operations land in `chat.md` and `boost.md` rather than in a hand-named
`chat-admin.md`. Adding a second grouping rule to the generator would mean the
page a command lives on is no longer derivable from its id, which is the
property that makes the docs impossible to get out of sync.

## 2026-09-04 — one "Hide Stories" toggle, owned by `story hide`

PR-5 shipped `user hide-stories` as an operation of its own; `story hide`
declares the same path as a legacy path, and the registry refuses one alias
claimed by two ops — correctly, because a toggle with two implementations is
a toggle that will disagree with itself. `story hide` keeps the
implementation, because that is where the rest of the story surface reads the
same `stories_hidden` flag, and it absorbs what the `user` op had that it did
not: several peers in one pass, and `--all` for the whole bar. `user
hide-stories` is now purely the §12.4 path onto it, so v1's spelling and its
`--unhide` flag keep working with nothing behind them to drift.

## 2026-09-04 — `story feed list` does not carry the stealth mode

`stories.getAllStories` answers with the account's `stealth_mode` beside the
feed, and the work list asked for it as a top-level field. A paginated
operation must declare `response=Page[T]` (registry lint L6), and `Page` has
no room for a sidecar; putting the same object on every row would be worse
than not reporting it. `story stealth --status` reads it from the same call,
so the information is one command away and appears in exactly one shape.

## 2026-09-04 — three two-segment story aliases are dropped

`story feed`, `story stats` and `story stealth` were proposed as shorthands
for `story feed list`, `story stats get` and `story stealth set`. Click has one
namespace per level, so registering them would replace the *group* of the same
name and take the canonical three-segment command with it — the same trap
`chat badge` hit on 2026-09-03. The canonical paths stand alone; `story view`
and `story forward` are registered because they cannot collide.

## 2026-09-04 — `story search --peer`, not `--since`

The work list spells the poster filter `--since`, which is the date flag the
generator injects into every `SEARCH`-paginated command. Two parameters with
one name is a Click warning and a silent shadow, so the flag is `--peer`; the
work list's spelling would have meant "restrict to one poster" on this command
and "only after this time" on every other one.

## 2026-09-04 — the viewer export is `--csv PATH`, not `--format csv`

`--format table|json|csv` would have added a third output switch beside the
global `--json`/`--plain` pair, and its `json` value would have shadowed a
global flag. `story viewer list --csv PATH` writes the file the GUI has no
button for and leaves rendering to the flags every other command uses.

## 2026-09-04 — `story live get` reports the story, not the call

Telethon 1.44 speaks layer 227, whose `storyItem` carries no group-call
reference; there is no accessor from a live story to the call that carries its
viewer count, publisher and stream settings. The operation is registered and
reports what the layer does expose — the story id, its dates, the live flag —
and warns that the call-side fields are unreachable, rather than returning
zeros that read as an empty broadcast. `stories.live-join` and
`livestory.streamer-info` are therefore `covers_partial`; the rest of the
live-story surface belongs to PR-11, which owns the call and landed ahead of
this one.

## 2026-09-04 — a refusal from `canSendStory` is an error, not a result

The work list describes `canSendStoryResult*` variants. Layer 227 has none:
`stories.canSendStory` returns a count or the server raises
`PREMIUM_ACCOUNT_REQUIRED` / `BOOSTS_REQUIRED` / `STORIES_TOO_MUCH` /
`STORY_SEND_FLOOD_WEEKLY_%d`. `story can-post` catches the error and reports
the reason with the number the message carries, so a caller still gets
`{"can_post": false, "reason": …, "retry_after": …}` rather than a raw
exception — and `story post` turns the same refusal into exit 6.

## 2026-09-04 — `story read --register-view` is opt-in

`stories.readStories` clears the reader's own unread ring and tells the poster
nothing; `stories.incrementStoryViews` is what puts the account in the
poster's viewer list. The work list folds both into one command, and the
default is the private half: an agent walking a feed must not silently appear
in strangers' viewer lists, so being seen costs an explicit flag.

## 2026-09-04 — story updates are shaped in `normalise_update`, not beside it

Telethon has no event builder for stories, so the six story updates only reach
the bus through the daemon's single `events.Raw()` handler. PR-4's taxonomy in
`core/eventtypes.py` already maps all six constructors onto the five
`story_*` types, so PR-8 adds a payload branch to `normalise_update` rather
than a second normaliser and a second Raw handler: one table names the
vocabulary, one function shapes it. The branch keeps the fine-grained `kind`
the story surface wants — `UpdateNewStoryReaction` and
`UpdateSentStoryReaction` share a type, and only `kind` says which side the
reaction came from.

## 2026-09-04 — `--music` takes a path, never a bare document id

A soundtrack is sent as an `inputDocument`, which needs an access hash and a
file reference. A bare document id has neither, so accepting one would produce
a request the server rejects minutes later with `FILE_REFERENCE_EXPIRED`. The
flag takes a file, uploads it through `messages.uploadMedia`, and refuses a
numeric argument with a usage error that says why.

## 2026-09-04 — the stories domain keeps 7 ids it does not own

Same shape as `media_files`: the catalog groups by subject, tlgr by command
group. Story notification settings belong to `notify`, saving a story's
soundtrack is a profile surface, and posting for a business account goes
through a bot connection. Each is waived to the PR that owns the command
rather than implemented here under a `story` noun where nobody would look for
it. The close-friends list and the live story's call — comments, RTMP key,
send-as identity — were on that list too until PR-5 and PR-11 landed ahead of
this one and covered them outright. `stories` is 94.2 % covered and 100 %
accounted.
Loading
Loading