tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/_params.py ADDED
@@ -0,0 +1,212 @@
1
+ """The annotation vocabulary a request field uses to describe its CLI shape.
2
+
3
+ Fields are declared as `Annotated[T, arg(...)]` or `Annotated[T, opt(...)]`.
4
+ Both produce a single `msgspec.Meta` whose `extra["cli"]` the generator reads
5
+ through `msgspec.inspect.type_info()`, so one declaration drives the Click
6
+ parameter, the JSON Schema and the reference docs.
7
+
8
+ **Exactly one Meta per field.** `type_info` surfaces only the first `Meta`'s
9
+ `extra` and `description`, so stacking `Annotated[int, opt(...), Meta(ge=1)]`
10
+ silently loses the second one's help text. `arg()`/`opt()` therefore take the
11
+ constraint keywords themselves and emit one merged `Meta`; registry lint L14
12
+ rejects a field that carries two.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Literal
18
+
19
+ import msgspec
20
+
21
+ from tlgr.core.errors import UsageError
22
+ from tlgr.core.timefmt import parse_dt, parse_duration
23
+ from tlgr.models.peer import parse_message_link, parse_peer_ref, parse_user_ref
24
+
25
+ __all__ = [
26
+ "CLI_KEY",
27
+ "ParamKind",
28
+ "arg",
29
+ "choice",
30
+ "cli_meta",
31
+ "opt",
32
+ "parse_dt",
33
+ "parse_duration",
34
+ "parse_message_link",
35
+ "parse_peer_ref",
36
+ "parse_user_ref",
37
+ "read_secret",
38
+ "secret_flags",
39
+ ]
40
+
41
+ CLI_KEY = "cli"
42
+
43
+ ParamKind = Literal["", "peer", "user", "msg_id", "duration", "datetime", "json", "path"]
44
+
45
+ _CONSTRAINTS = (
46
+ "ge",
47
+ "gt",
48
+ "le",
49
+ "lt",
50
+ "multiple_of",
51
+ "pattern",
52
+ "min_length",
53
+ "max_length",
54
+ "tz",
55
+ )
56
+
57
+
58
+ #: Keys whose falsy value carries meaning. `required=False` is the whole
59
+ #: point of an optional positional, and dropping it would default it back to
60
+ #: True; `pos=0` survives the filter only because 0 != "" and 0 is not False.
61
+ _ALWAYS = frozenset({"pos", "required"})
62
+
63
+
64
+ def _is_empty(value: Any) -> bool:
65
+ """Drop defaults from the stored metadata, without confusing 0 with False."""
66
+ if value is None or value == "":
67
+ return True
68
+ return value is False or value == []
69
+
70
+
71
+ def _meta(cli: dict[str, Any], help: str, constraints: dict[str, Any]) -> msgspec.Meta:
72
+ unknown = set(constraints) - set(_CONSTRAINTS)
73
+ if unknown:
74
+ raise TypeError(f"unknown constraint(s) for a request field: {sorted(unknown)}")
75
+ return msgspec.Meta(
76
+ description=help or None,
77
+ extra={CLI_KEY: {k: v for k, v in cli.items() if k in _ALWAYS or not _is_empty(v)}},
78
+ **constraints,
79
+ )
80
+
81
+
82
+ def arg(
83
+ pos: int,
84
+ *,
85
+ metavar: str = "",
86
+ required: bool = True,
87
+ variadic: bool = False,
88
+ help: str = "",
89
+ kind: ParamKind = "",
90
+ **constraints: Any,
91
+ ) -> msgspec.Meta:
92
+ """Declare the field as a positional argument at index *pos*."""
93
+ return _meta(
94
+ {
95
+ "role": "arg",
96
+ "pos": pos,
97
+ "metavar": metavar,
98
+ "required": required,
99
+ "variadic": variadic,
100
+ "kind": kind,
101
+ },
102
+ help,
103
+ constraints,
104
+ )
105
+
106
+
107
+ def opt(
108
+ *flags: str,
109
+ help: str = "",
110
+ metavar: str = "",
111
+ envvar: str = "",
112
+ hidden: bool = False,
113
+ secret: bool = False,
114
+ count: bool = False,
115
+ kind: ParamKind = "",
116
+ **constraints: Any,
117
+ ) -> msgspec.Meta:
118
+ """Declare the field as an option.
119
+
120
+ Without *flags* the name is derived from the field
121
+ (`reply_to` → `--reply-to`). Give flags to override, to add a short form,
122
+ or to spell the negative half of a paired boolean (`--keep/--no-keep`).
123
+ """
124
+ return _meta(
125
+ {
126
+ "role": "opt",
127
+ "flags": list(flags),
128
+ "metavar": metavar,
129
+ "envvar": envvar,
130
+ "hidden": hidden,
131
+ "secret": secret,
132
+ "count": count,
133
+ "kind": kind,
134
+ },
135
+ help,
136
+ constraints,
137
+ )
138
+
139
+
140
+ def choice(*values: str, help: str = "", metavar: str = "", **constraints: Any) -> msgspec.Meta:
141
+ """An option restricted to *values*, shown in --help and in the schema."""
142
+ return _meta(
143
+ {"role": "opt", "flags": [], "metavar": metavar, "choices": list(values)},
144
+ help,
145
+ constraints,
146
+ )
147
+
148
+
149
+ def cli_meta(node: Any) -> dict[str, Any]:
150
+ """The `cli` dict off a field, or `{}` when the field is unannotated.
151
+
152
+ Accepts either a `msgspec.Meta` or the `msgspec.inspect.Metadata` node the
153
+ generator actually walks; both carry `.extra`, and asking for one specific
154
+ type here would force every caller to convert.
155
+ """
156
+ extra = getattr(node, "extra", None)
157
+ if not isinstance(extra, dict):
158
+ return {}
159
+ value = extra.get(CLI_KEY)
160
+ return dict(value) if isinstance(value, dict) else {}
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Secrets
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ def secret_flags(base: str) -> tuple[str, str, str]:
169
+ """The three flags a secret field generates: `--x-env/--x-stdin/--x-file`.
170
+
171
+ A secret never gets a value-taking flag, because argv is world-readable
172
+ through `ps` and ends up in shell history (STYLE §3).
173
+ """
174
+ return (f"--{base}-env", f"--{base}-stdin", f"--{base}-file")
175
+
176
+
177
+ def read_secret(
178
+ name: str,
179
+ *,
180
+ env: str | None = None,
181
+ stdin: bool = False,
182
+ file: str | None = None,
183
+ default_env: str = "",
184
+ ) -> str | None:
185
+ """Read a secret from a file, stdin or the environment, in that order.
186
+
187
+ File first because it is the most deliberate, stdin next because it is
188
+ explicit, environment last because it is the easiest to leak.
189
+ """
190
+ import os
191
+ import sys
192
+
193
+ if file:
194
+ try:
195
+ with open(file, encoding="utf-8") as handle:
196
+ return handle.read().strip("\n")
197
+ except OSError as exc:
198
+ raise UsageError(f"--{name}-file: {exc.strerror or exc}", field=name) from exc
199
+
200
+ if stdin:
201
+ if sys.stdin is None or sys.stdin.isatty():
202
+ raise UsageError(f"--{name}-stdin was given but stdin is a terminal", field=name)
203
+ return sys.stdin.read().strip("\n")
204
+
205
+ variable = env or default_env
206
+ if variable:
207
+ value = os.environ.get(variable)
208
+ if value is not None:
209
+ return value
210
+ if env:
211
+ raise UsageError(f"--{name}-env names {variable}, which is not set", field=name)
212
+ return None
tlgr/ops/_rights.py ADDED
@@ -0,0 +1,402 @@
1
+ """The one canonical vocabulary for admin and member rights.
2
+
3
+ `chat admin promote --rights`, `chat member restrict --deny`, `chat permission
4
+ set --allow` and `chat permission list` all read this table, which is why a
5
+ right is spelled the same way in every one of them and why `chat permission
6
+ get` round-trips straight back into `chat permission set`.
7
+
8
+ Two conversions live here because getting either wrong is silent.
9
+
10
+ * **Polarity.** `ChatBannedRights` is inverted — `send_messages=True` means
11
+ *cannot* send. Every `Rights` model tlgr emits is allow-polarity, so the
12
+ inversion happens once, here, instead of once per caller.
13
+ * **Completeness.** `channels.editBanned` and
14
+ `messages.editChatDefaultBannedRights` replace the whole mask: a flag you
15
+ omit is a flag you cleared. Every writer therefore builds the *full* mask
16
+ from a name set rather than patching the request object.
17
+
18
+ Telethon 1.44 speaks layer 227. Two layer-229 rights (`manage-linked-peers`,
19
+ `manage-welcome-messages`) have no field to set, so they are listed with
20
+ `supported=false` and refused with NOT_SUPPORTED rather than dropped — a
21
+ right that vanishes quietly is a permission bug waiting to happen.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timezone
28
+ from typing import Any
29
+
30
+ from tlgr.core.errors import NotSupportedError, UsageError
31
+ from tlgr.core.timefmt import fmt_dt, parse_dt, parse_duration, to_unix
32
+ from tlgr.models.admin import RightInfo
33
+ from tlgr.models.peer import Rights
34
+
35
+ __all__ = [
36
+ "ADMIN_MASK",
37
+ "FOREVER",
38
+ "MEMBER_MASK",
39
+ "MaskEdit",
40
+ "all_allowed",
41
+ "build_admin_rights",
42
+ "build_banned_rights",
43
+ "catalog",
44
+ "denied_names",
45
+ "granted_names",
46
+ "model_from_admin",
47
+ "model_from_banned",
48
+ "parse_names",
49
+ "parse_until",
50
+ "read_only",
51
+ "require_supported",
52
+ "until_label",
53
+ ]
54
+
55
+ #: Telegram treats 0, anything under 30 s and anything over 366 d as "forever".
56
+ FOREVER = 0
57
+ _MIN_BAN = 30
58
+ _MAX_BAN = 366 * 86400
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class _Right:
63
+ name: str
64
+ mask: str
65
+ tl_flag: str
66
+ summary: str
67
+ peer_types: tuple[str, ...] = ()
68
+ supported: bool = True
69
+ since_layer: int | None = None
70
+
71
+
72
+ #: The admin mask, in the order `ChatAdminRights` declares its flags.
73
+ _ADMIN: tuple[_Right, ...] = (
74
+ _Right("change-info", "admin", "change_info", "Edit the title, photo and description"),
75
+ _Right("post-messages", "admin", "post_messages", "Post to the channel", ("channel",)),
76
+ _Right("edit-messages", "admin", "edit_messages", "Edit others' posts", ("channel",)),
77
+ _Right("delete-messages", "admin", "delete_messages", "Delete others' messages"),
78
+ _Right("ban-users", "admin", "ban_users", "Ban and restrict members", ("group", "supergroup")),
79
+ _Right("invite-users", "admin", "invite_users", "Add members and create invite links"),
80
+ _Right("pin-messages", "admin", "pin_messages", "Pin messages", ("group", "supergroup")),
81
+ _Right("add-admins", "admin", "add_admins", "Promote other admins"),
82
+ _Right("anonymous", "admin", "anonymous", "Post as the group", ("group", "supergroup")),
83
+ _Right("manage-call", "admin", "manage_call", "Start and manage video chats"),
84
+ _Right("other", "admin", "other", "The undocumented catch-all flag"),
85
+ _Right("manage-topics", "admin", "manage_topics", "Create and manage topics", ("forum",)),
86
+ _Right("post-stories", "admin", "post_stories", "Post stories", ("channel",)),
87
+ _Right("edit-stories", "admin", "edit_stories", "Edit others' stories", ("channel",)),
88
+ _Right("delete-stories", "admin", "delete_stories", "Delete others' stories", ("channel",)),
89
+ _Right(
90
+ "manage-direct-messages",
91
+ "admin",
92
+ "manage_direct_messages",
93
+ "Moderate the channel's direct messages",
94
+ ("channel",),
95
+ ),
96
+ _Right("manage-ranks", "admin", "manage_ranks", "Set other members' custom titles"),
97
+ _Right(
98
+ "manage-linked-peers",
99
+ "admin",
100
+ "",
101
+ "Manage a community's linked chats",
102
+ (),
103
+ supported=False,
104
+ since_layer=229,
105
+ ),
106
+ _Right(
107
+ "manage-welcome-messages",
108
+ "admin",
109
+ "",
110
+ "Write the chat's welcome messages",
111
+ (),
112
+ supported=False,
113
+ since_layer=229,
114
+ ),
115
+ )
116
+
117
+ #: The member mask. Stored inverted by Telegram; named in allow-polarity here.
118
+ _MEMBER: tuple[_Right, ...] = (
119
+ _Right("view-messages", "member", "view_messages", "Read the chat at all"),
120
+ _Right("send-messages", "member", "send_messages", "Send any message"),
121
+ _Right("send-media", "member", "send_media", "Send media of any kind"),
122
+ _Right("send-photos", "member", "send_photos", "Send photos"),
123
+ _Right("send-videos", "member", "send_videos", "Send videos"),
124
+ _Right("send-audios", "member", "send_audios", "Send music"),
125
+ _Right("send-docs", "member", "send_docs", "Send files"),
126
+ _Right("send-voices", "member", "send_voices", "Send voice notes"),
127
+ _Right("send-rounds", "member", "send_roundvideos", "Send video notes"),
128
+ _Right("send-stickers", "member", "send_stickers", "Send stickers"),
129
+ _Right("send-gifs", "member", "send_gifs", "Send GIFs"),
130
+ _Right("send-games", "member", "send_games", "Send games"),
131
+ _Right("send-inline", "member", "send_inline", "Use inline bots"),
132
+ _Right("send-polls", "member", "send_polls", "Send polls"),
133
+ _Right("send-plain", "member", "send_plain", "Send plain text"),
134
+ _Right("send-reactions", "member", "send_reactions", "React to messages"),
135
+ _Right("embed-links", "member", "embed_links", "Send links with a preview"),
136
+ _Right("change-info", "member", "change_info", "Edit the title, photo and description"),
137
+ _Right("invite-users", "member", "invite_users", "Add members"),
138
+ _Right("pin-messages", "member", "pin_messages", "Pin messages"),
139
+ _Right("manage-topics", "member", "manage_topics", "Create topics", ("forum",)),
140
+ _Right("edit-rank", "member", "edit_rank", "Change their own custom title"),
141
+ _Right(
142
+ "manage-linked-peers",
143
+ "member",
144
+ "",
145
+ "Manage a community's linked chats",
146
+ (),
147
+ supported=False,
148
+ since_layer=229,
149
+ ),
150
+ )
151
+
152
+ ADMIN_MASK: tuple[str, ...] = tuple(right.name for right in _ADMIN)
153
+ MEMBER_MASK: tuple[str, ...] = tuple(right.name for right in _MEMBER)
154
+
155
+ _BY_MASK: dict[str, dict[str, _Right]] = {
156
+ "admin": {right.name: right for right in _ADMIN},
157
+ "member": {right.name: right for right in _MEMBER},
158
+ }
159
+
160
+ #: The member rights `--none` leaves alone: taking view-messages away is a
161
+ #: ban, not a restriction, and `chat member ban` is the command that says so.
162
+ _READ_ONLY_KEEP = frozenset({"view-messages"})
163
+
164
+ #: Model field per TL flag. They line up one-to-one today; the mapping exists
165
+ #: so a future rename in either direction stays a one-line change.
166
+ _MODEL_FIELD = {right.tl_flag: right.tl_flag for right in (*_ADMIN, *_MEMBER) if right.tl_flag}
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Parsing
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def parse_names(value: str | None, *, mask: str, field: str = "rights") -> list[str]:
175
+ """`"ban-users, pin-messages"` → the canonical names, or a USAGE error.
176
+
177
+ Unknown names are refused rather than ignored: silently dropping a right
178
+ somebody asked for is how a member ends up with permissions nobody
179
+ intended.
180
+ """
181
+ if not value:
182
+ return []
183
+ known = _BY_MASK[mask]
184
+ out: list[str] = []
185
+ for raw in str(value).replace(",", " ").split():
186
+ name = raw.strip().lower().replace("_", "-")
187
+ if not name:
188
+ continue
189
+ if name in ("all", "*"):
190
+ out.extend(n for n in known if n not in out)
191
+ continue
192
+ if name not in known:
193
+ raise UsageError(
194
+ f"{name!r} is not a {mask} right; see `tlgr chat permission list --mask {mask}`",
195
+ field=field,
196
+ )
197
+ if name not in out:
198
+ out.append(name)
199
+ return out
200
+
201
+
202
+ def require_supported(names: list[str], *, mask: str) -> None:
203
+ """Refuse the layer-229 rights this Telethon cannot express (exit 13)."""
204
+ known = _BY_MASK[mask]
205
+ gaps = [name for name in names if not known[name].supported]
206
+ if gaps:
207
+ raise NotSupportedError(
208
+ f"{', '.join(gaps)} {'is a' if len(gaps) == 1 else 'are'} layer-229 "
209
+ f"{'right' if len(gaps) == 1 else 'rights'} and Telethon 1.44 speaks layer 227, "
210
+ "so tlgr has no field to set. `chat permission list` marks them supported=false"
211
+ )
212
+
213
+
214
+ def parse_until(value: str | None) -> int:
215
+ """`--until 7d` → an absolute unix timestamp, with Telegram's rounding.
216
+
217
+ 0, under 30 seconds and over 366 days all mean *forever* server-side, so
218
+ they are collapsed here rather than sent and silently reinterpreted.
219
+ """
220
+ if value is None:
221
+ return FOREVER
222
+ text = str(value).strip().lower()
223
+ if text in ("", "0", "forever", "never", "permanent"):
224
+ return FOREVER
225
+ seconds = parse_duration(text)
226
+ if seconds is not None:
227
+ if seconds < _MIN_BAN or seconds > _MAX_BAN:
228
+ return FOREVER
229
+ return int(datetime.now(timezone.utc).timestamp()) + int(seconds)
230
+ moment = parse_dt(text)
231
+ if moment is None:
232
+ raise UsageError(f"{value!r} is neither a duration nor a timestamp", field="until")
233
+ stamp = to_unix(moment) or 0
234
+ delta = stamp - int(datetime.now(timezone.utc).timestamp())
235
+ if delta < _MIN_BAN or delta > _MAX_BAN:
236
+ return FOREVER
237
+ return stamp
238
+
239
+
240
+ def until_label(stamp: int) -> tuple[str | None, int | None]:
241
+ """`(RFC-3339, unix)` for a ban expiry, or `(None, None)` for forever."""
242
+ if not stamp:
243
+ return None, None
244
+ moment = datetime.fromtimestamp(stamp, tz=timezone.utc)
245
+ return fmt_dt(moment), stamp
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # TL ⇄ model
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ def model_from_admin(raw: Any) -> Rights | None:
254
+ """`ChatAdminRights` → the allow-polarity `Rights` model."""
255
+ if raw is None:
256
+ return None
257
+ rights = Rights()
258
+ for right in _ADMIN:
259
+ if not right.tl_flag:
260
+ continue
261
+ setattr(rights, _MODEL_FIELD[right.tl_flag], bool(getattr(raw, right.tl_flag, False)))
262
+ return rights
263
+
264
+
265
+ def model_from_banned(raw: Any) -> Rights | None:
266
+ """`ChatBannedRights` → `Rights`, inverted so True still means *allowed*."""
267
+ if raw is None:
268
+ return None
269
+ rights = Rights()
270
+ for right in _MEMBER:
271
+ if not right.tl_flag:
272
+ continue
273
+ setattr(rights, _MODEL_FIELD[right.tl_flag], not bool(getattr(raw, right.tl_flag, False)))
274
+ # Telethon hands back a datetime, but a mask tlgr just built carries the
275
+ # int it will serialise; both have to read the same way in the response.
276
+ until = getattr(raw, "until_date", None)
277
+ if isinstance(until, int):
278
+ until = datetime.fromtimestamp(until, tz=timezone.utc) if until else None
279
+ if until is not None:
280
+ rights.until = fmt_dt(until)
281
+ rights.until_unix = to_unix(until)
282
+ return rights
283
+
284
+
285
+ def granted_names(rights: Rights | None, *, mask: str) -> list[str]:
286
+ """The names whose value is True, in table order."""
287
+ if rights is None:
288
+ return []
289
+ table = _ADMIN if mask == "admin" else _MEMBER
290
+ return [
291
+ right.name
292
+ for right in table
293
+ if right.tl_flag and getattr(rights, _MODEL_FIELD[right.tl_flag], None) is True
294
+ ]
295
+
296
+
297
+ def denied_names(rights: Rights | None, *, mask: str) -> list[str]:
298
+ if rights is None:
299
+ return []
300
+ table = _ADMIN if mask == "admin" else _MEMBER
301
+ return [
302
+ right.name
303
+ for right in table
304
+ if right.tl_flag and getattr(rights, _MODEL_FIELD[right.tl_flag], None) is False
305
+ ]
306
+
307
+
308
+ def build_admin_rights(names: list[str] | set[str]) -> Any:
309
+ """The `ChatAdminRights` for exactly *names*; everything else is False."""
310
+ from telethon.tl import types
311
+
312
+ wanted = set(names)
313
+ return types.ChatAdminRights(
314
+ **{right.tl_flag: right.name in wanted for right in _ADMIN if right.tl_flag}
315
+ )
316
+
317
+
318
+ def build_banned_rights(allowed: list[str] | set[str], *, until: int = FOREVER) -> Any:
319
+ """The `ChatBannedRights` allowing exactly *allowed*.
320
+
321
+ The mask is always complete, because the server replaces it wholesale.
322
+ """
323
+ from telethon.tl import types
324
+
325
+ wanted = set(allowed)
326
+ flags = {right.tl_flag: right.name not in wanted for right in _MEMBER if right.tl_flag}
327
+ return types.ChatBannedRights(until_date=until, **flags)
328
+
329
+
330
+ def all_allowed(*, mask: str = "member") -> list[str]:
331
+ """Every supported name in a mask — the `--all` shorthand."""
332
+ table = _ADMIN if mask == "admin" else _MEMBER
333
+ return [right.name for right in table if right.supported]
334
+
335
+
336
+ def read_only() -> list[str]:
337
+ """`--none`: nothing but reading."""
338
+ return sorted(_READ_ONLY_KEEP)
339
+
340
+
341
+ def catalog(*, mask: str = "all", grantable: dict[str, bool] | None = None) -> list[RightInfo]:
342
+ """The vocabulary as data, for `chat permission list`."""
343
+ tables: list[tuple[str, tuple[_Right, ...]]] = []
344
+ if mask in ("admin", "all"):
345
+ tables.append(("admin", _ADMIN))
346
+ if mask in ("member", "all"):
347
+ tables.append(("member", _MEMBER))
348
+ out: list[RightInfo] = []
349
+ for _label, table in tables:
350
+ for right in table:
351
+ out.append(
352
+ RightInfo(
353
+ name=right.name,
354
+ mask=right.mask,
355
+ tl_flag=right.tl_flag or "-",
356
+ polarity="allow" if right.mask == "admin" else "deny",
357
+ supported=right.supported,
358
+ since_layer=right.since_layer,
359
+ peer_types=list(right.peer_types),
360
+ grantable=(grantable or {}).get(f"{right.mask}:{right.name}"),
361
+ summary=right.summary,
362
+ )
363
+ )
364
+ return out
365
+
366
+
367
+ @dataclass
368
+ class MaskEdit:
369
+ """A `--rights/--grant/--revoke/--all/--none` request, resolved once.
370
+
371
+ Every rights-taking command has the same four shapes (set absolutely,
372
+ patch, everything, nothing) and v1 would have implemented them four
373
+ times. Resolving them here means `chat admin promote --grant ban-users`
374
+ and `chat member restrict --allow send-media` agree about what "patch"
375
+ means.
376
+ """
377
+
378
+ mask: str
379
+ current: set[str] = field(default_factory=set)
380
+
381
+ def resolve(
382
+ self,
383
+ *,
384
+ absolute: list[str] | set[str] | None = None,
385
+ add: list[str] | None = None,
386
+ remove: list[str] | None = None,
387
+ everything: bool = False,
388
+ nothing: bool = False,
389
+ exclude: list[str] | None = None,
390
+ ceiling: set[str] | None = None,
391
+ ) -> set[str]:
392
+ if everything:
393
+ base = set(ceiling) if ceiling is not None else set(all_allowed(mask=self.mask))
394
+ return base - set(exclude or ())
395
+ if nothing:
396
+ return set(read_only()) if self.mask == "member" else set()
397
+ if absolute is not None:
398
+ return set(absolute)
399
+ out = set(self.current)
400
+ out |= set(add or ())
401
+ out -= set(remove or ())
402
+ return out