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/privacy.py ADDED
@@ -0,0 +1,794 @@
1
+ """The `privacy` group: Settings ▸ Privacy and Security, minus the sessions.
2
+
3
+ Fourteen privacy keys, eight global switches and two blocklists, and the
4
+ whole group exists to make two dangerous APIs safe to use from a script.
5
+
6
+ * **`account.setPrivacy` replaces the whole ordered rule vector.** Sending
7
+ "allow contacts" wipes every exception the user had. So `privacy set`
8
+ always GETs first, and the `--add-*`/`--remove` flags exist precisely so a
9
+ script never has to re-state a list it did not mean to touch.
10
+ * **`account.setGlobalPrivacySettings` replaces the whole constructor.** Same
11
+ hazard, same answer: read, patch the named fields, write. A switch nobody
12
+ mentioned is written back exactly as it was found.
13
+
14
+ The rule vocabulary — `everybody`, `contacts`, `close-friends`, `premium`,
15
+ `bots`, `nobody` — is the one the official clients show, not the one the TL
16
+ schema uses, because `privacyValueDisallowAll` plus `privacyValueAllowUsers`
17
+ is a *shape* rather than a setting anybody chose.
18
+
19
+ Telethon is imported inside functions, never at module scope (§2.2).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Annotated, Any
25
+
26
+ from tlgr.core.errors import UsageError
27
+ from tlgr.core.pagination import PageKind
28
+ from tlgr.models.base import Request
29
+ from tlgr.models.contact import BlockedPeer, BlockedSet
30
+ from tlgr.models.page import Page
31
+ from tlgr.models.peer import PeerRef
32
+ from tlgr.models.privacy import GlobalPrivacy, PaidMessageRevenue, PrivacyRule, PrivacySettings
33
+ from tlgr.ops import _settings
34
+ from tlgr.ops._common import client
35
+ from tlgr.ops._params import arg, opt
36
+ from tlgr.ops._spec import OpContext, OperationSpec
37
+
38
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
39
+
40
+ #: tlgr's key name → the `inputPrivacyKey*` class name. The tlgr spelling is
41
+ #: the GUI's row label; the TL name is an implementation detail nobody should
42
+ #: have to learn to mute their last-seen time.
43
+ KEYS: dict[str, str] = {
44
+ "last-seen": "InputPrivacyKeyStatusTimestamp",
45
+ "profile-photo": "InputPrivacyKeyProfilePhoto",
46
+ "phone-number": "InputPrivacyKeyPhoneNumber",
47
+ "forwards": "InputPrivacyKeyForwards",
48
+ "calls": "InputPrivacyKeyPhoneCall",
49
+ "phone-p2p": "InputPrivacyKeyPhoneP2P",
50
+ "chat-invite": "InputPrivacyKeyChatInvite",
51
+ "voice-messages": "InputPrivacyKeyVoiceMessages",
52
+ "bio": "InputPrivacyKeyAbout",
53
+ "birthday": "InputPrivacyKeyBirthday",
54
+ "gifts-auto-save": "InputPrivacyKeyStarGiftsAutoSave",
55
+ "no-paid-messages": "InputPrivacyKeyNoPaidMessages",
56
+ "saved-music": "InputPrivacyKeySavedMusic",
57
+ "added-by-phone": "InputPrivacyKeyAddedByPhone",
58
+ }
59
+
60
+ #: The base rules, in the order `account.setPrivacy` wants them written: the
61
+ #: broad allow/disallow first, exceptions after, because the server applies
62
+ #: the vector in order and a trailing `allowAll` would undo everything.
63
+ BASES = ("everybody", "contacts", "close-friends", "premium", "bots", "nobody")
64
+
65
+ #: `disallowedGiftsSettings` field ⇄ the keyword `--disallow-gifts` takes.
66
+ GIFT_KINDS: dict[str, str] = {
67
+ "unlimited": "disallow_unlimited_stargifts",
68
+ "limited": "disallow_limited_stargifts",
69
+ "unique": "disallow_unique_stargifts",
70
+ "premium": "disallow_premium_gifts",
71
+ "from-channels": "disallow_stargifts_from_channels",
72
+ }
73
+
74
+
75
+ def _key_tl(name: str) -> Any:
76
+ """The `inputPrivacyKey*` for a tlgr key name, or a usage error that lists them."""
77
+ from telethon.tl import types
78
+
79
+ wanted = name.strip().lower()
80
+ if wanted == "stories":
81
+ raise UsageError(
82
+ "story visibility is not a privacy key: the audience is chosen per "
83
+ "story (`story post --audience`) and the exclusion list is "
84
+ "`story blocklist set` / `privacy blocked set --stories`",
85
+ field="key",
86
+ )
87
+ if wanted not in KEYS:
88
+ raise UsageError(
89
+ f"unknown privacy key {name!r}; one of: {' '.join(sorted(KEYS))}", field="key"
90
+ )
91
+ return getattr(types, KEYS[wanted])()
92
+
93
+
94
+ def _rules_model(key: str, rules: Any) -> PrivacySettings:
95
+ """A `privacyValue*` vector split into the shape the GUI shows.
96
+
97
+ The base is whichever broad rule the vector carries; the four exception
98
+ lists are the user/chat rules beside it. `raw_rules` keeps the server's
99
+ own ordering so a later write can reproduce it exactly.
100
+ """
101
+ model = PrivacySettings(key=key, base="nobody")
102
+ for rule in rules or []:
103
+ name = type(rule).__name__.removeprefix("PrivacyValue")
104
+ action = "allow" if name.startswith("Allow") else "disallow"
105
+ scope = name.removeprefix("Allow").removeprefix("Disallow")
106
+ ids = [int(v) for v in (getattr(rule, "users", None) or getattr(rule, "chats", None) or [])]
107
+ model.raw_rules.append(
108
+ PrivacyRule(action=action, scope=_SCOPE_NAMES.get(scope, scope.lower()), ids=ids)
109
+ )
110
+ if scope == "Users":
111
+ (model.allow_users if action == "allow" else model.deny_users).extend(ids)
112
+ elif scope == "ChatParticipants":
113
+ (model.allow_chats if action == "allow" else model.deny_chats).extend(ids)
114
+ elif scope in _BASE_FOR:
115
+ model.base = _BASE_FOR[scope] if action == "allow" else _DENY_BASE[scope]
116
+ return model
117
+
118
+
119
+ _SCOPE_NAMES = {
120
+ "All": "all",
121
+ "Contacts": "contacts",
122
+ "CloseFriends": "close-friends",
123
+ "Premium": "premium",
124
+ "Bots": "bots",
125
+ "Users": "users",
126
+ "ChatParticipants": "chats",
127
+ }
128
+ _BASE_FOR = {
129
+ "All": "everybody",
130
+ "Contacts": "contacts",
131
+ "CloseFriends": "close-friends",
132
+ "Premium": "premium",
133
+ "Bots": "bots",
134
+ }
135
+ #: `disallowContacts` and friends are the *other* half of the same switch:
136
+ #: "not my contacts" is how "nobody" is spelled for some keys.
137
+ _DENY_BASE = {
138
+ "All": "nobody",
139
+ "Contacts": "nobody",
140
+ "CloseFriends": "nobody",
141
+ "Premium": "nobody",
142
+ "Bots": "nobody",
143
+ }
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # privacy get / set
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ class GetReq(Request):
152
+ key: Annotated[
153
+ str | None,
154
+ arg(0, metavar="KEY", required=False, help="One key; omit for every key."),
155
+ ] = None
156
+ resolve: Annotated[
157
+ bool, opt("--resolve/--no-resolve", help="Resolve exception ids to names.")
158
+ ] = True
159
+
160
+
161
+ async def get(ctx: OpContext, req: GetReq) -> Page[PrivacySettings]:
162
+ """Read one privacy setting, or all of them.
163
+
164
+ The output is exactly what `privacy set` accepts back, which is what
165
+ makes "copy this account's privacy to that one" a pipeline rather than a
166
+ reading exercise.
167
+ """
168
+ from telethon.tl.functions import account as fn
169
+
170
+ handle = client(ctx)
171
+ wanted = [req.key.strip().lower()] if req.key else sorted(KEYS)
172
+ rows: list[PrivacySettings] = []
173
+ for name in wanted:
174
+ answer = await handle(fn.GetPrivacyRequest(key=_key_tl(name)))
175
+ model = _rules_model(name, getattr(answer, "rules", None))
176
+ if req.resolve:
177
+ known = _settings.entity_map(answer)
178
+ model.peers = [
179
+ peer
180
+ for raw_id in (*model.allow_users, *model.deny_users)
181
+ if (peer := _settings.peer_model(known.get(raw_id))) is not None
182
+ ]
183
+ rows.append(model)
184
+ return Page(items=rows, has_more=False, total=len(rows))
185
+
186
+
187
+ SPEC_GET = OperationSpec(
188
+ id="privacy.get",
189
+ request=GetReq,
190
+ response=Page[PrivacySettings],
191
+ impl=get,
192
+ summary="Read one privacy setting, or all of them",
193
+ description=(
194
+ "`base` is the headline value the GUI shows and the four lists are "
195
+ "its exceptions; `raw_rules` keeps the server's own ordered vector so "
196
+ "nothing is lost in the translation."
197
+ ),
198
+ paginated=PageKind.LOCAL,
199
+ idempotent=True,
200
+ columns=("key", "base", "allow_users", "deny_users"),
201
+ headers=("Key", "Base", "Always allow", "Never allow"),
202
+ example={
203
+ "items": [{"key": "last-seen", "base": "contacts", "deny_users": [777123]}],
204
+ "has_more": False,
205
+ },
206
+ example_args="privacy get last-seen",
207
+ covers=(
208
+ "contacts-users.privacy-about",
209
+ "contacts-users.privacy-added-by-phone",
210
+ "contacts-users.privacy-chat-invite",
211
+ "contacts-users.privacy-forwards",
212
+ "contacts-users.privacy-gifts",
213
+ "contacts-users.privacy-no-paid-messages",
214
+ "contacts-users.privacy-phone-number",
215
+ "contacts-users.privacy-voice-messages",
216
+ "gift.auto-save-privacy",
217
+ "privacy.get-rules",
218
+ "privacy.key-birthday",
219
+ "privacy.key-calls",
220
+ "privacy.key-no-paid-messages",
221
+ "privacy.key-phone-number",
222
+ "privacy.key-voice-messages",
223
+ ),
224
+ covers_partial=(
225
+ "privacy.key-bio",
226
+ "privacy.key-chat-invite",
227
+ "privacy.key-forwards",
228
+ "privacy.key-gifts-auto-save",
229
+ "privacy.key-last-seen",
230
+ "privacy.key-profile-photo",
231
+ "privacy.key-saved-music",
232
+ ),
233
+ coverage_note="Writing any of these keys is `privacy set`.",
234
+ tags=frozenset({"agent-safe"}),
235
+ )
236
+
237
+
238
+ class SetReq(Request):
239
+ key: Annotated[str, arg(0, metavar="KEY", help="The privacy key to change.")]
240
+ rule: Annotated[
241
+ str | None,
242
+ arg(1, metavar="RULE", required=False, help="everybody|contacts|close-friends|nobody…"),
243
+ ] = None
244
+ allow: Annotated[
245
+ str | None, opt("--allow", metavar="LIST", help="Replace the 'always allow' list.")
246
+ ] = None
247
+ disallow: Annotated[
248
+ str | None, opt("--disallow", metavar="LIST", help="Replace the 'never allow' list.")
249
+ ] = None
250
+ add_allow: Annotated[
251
+ str | None, opt("--add-allow", metavar="LIST", help="Append to the allow list.")
252
+ ] = None
253
+ add_disallow: Annotated[
254
+ str | None, opt("--add-disallow", metavar="LIST", help="Append to the deny list.")
255
+ ] = None
256
+ remove: Annotated[
257
+ str | None, opt("--remove", metavar="LIST", help="Drop these from both lists.")
258
+ ] = None
259
+ clear_exceptions: Annotated[
260
+ bool, opt("--clear-exceptions", help="Send only the base rule.")
261
+ ] = False
262
+
263
+
264
+ async def _ids_of(ctx: OpContext, text: str | None) -> tuple[list[int], list[int]]:
265
+ """A comma-separated peer list, split into `(user ids, chat ids)`."""
266
+ users: list[int] = []
267
+ chats: list[int] = []
268
+ for entry in (text or "").split(","):
269
+ ref = entry.strip()
270
+ if not ref:
271
+ continue
272
+ peer = await _settings.resolve(ctx, ref)
273
+ marked = _settings.peer_of(peer)
274
+ (users if marked > 0 else chats).append(abs(marked) if marked < 0 else marked)
275
+ return users, chats
276
+
277
+
278
+ async def set_(ctx: OpContext, req: SetReq) -> PrivacySettings:
279
+ """Change a privacy key: a base rule plus optional exception lists.
280
+
281
+ Read-modify-write, always. `account.setPrivacy` takes the *complete*
282
+ ordered vector, so sending only what changed would delete everything
283
+ else — which is the whole reason `--add-allow` and `--remove` exist.
284
+ """
285
+ from telethon.tl import types
286
+ from telethon.tl.functions import account as fn
287
+
288
+ name = req.key.strip().lower()
289
+ key = _key_tl(name)
290
+ handle = client(ctx)
291
+ current = _rules_model(
292
+ name, getattr(await handle(fn.GetPrivacyRequest(key=key)), "rules", None)
293
+ )
294
+
295
+ base = current.base
296
+ if req.rule is not None:
297
+ base = req.rule.strip().lower()
298
+ if base not in BASES:
299
+ raise UsageError(f"RULE is one of: {' '.join(BASES)}", field="rule")
300
+
301
+ allow_users, allow_chats = list(current.allow_users), list(current.allow_chats)
302
+ deny_users, deny_chats = list(current.deny_users), list(current.deny_chats)
303
+
304
+ if req.clear_exceptions:
305
+ allow_users, allow_chats, deny_users, deny_chats = [], [], [], []
306
+ if req.allow is not None:
307
+ allow_users, allow_chats = await _ids_of(ctx, req.allow)
308
+ if req.disallow is not None:
309
+ deny_users, deny_chats = await _ids_of(ctx, req.disallow)
310
+ if req.add_allow:
311
+ more_users, more_chats = await _ids_of(ctx, req.add_allow)
312
+ allow_users += [i for i in more_users if i not in allow_users]
313
+ allow_chats += [i for i in more_chats if i not in allow_chats]
314
+ if req.add_disallow:
315
+ more_users, more_chats = await _ids_of(ctx, req.add_disallow)
316
+ deny_users += [i for i in more_users if i not in deny_users]
317
+ deny_chats += [i for i in more_chats if i not in deny_chats]
318
+ if req.remove:
319
+ gone_users, gone_chats = await _ids_of(ctx, req.remove)
320
+ allow_users = [i for i in allow_users if i not in gone_users]
321
+ deny_users = [i for i in deny_users if i not in gone_users]
322
+ allow_chats = [i for i in allow_chats if i not in gone_chats]
323
+ deny_chats = [i for i in deny_chats if i not in gone_chats]
324
+
325
+ rules: list[Any] = []
326
+ # The exceptions go first: the server evaluates the vector in order, so a
327
+ # broad rule written before them would decide every case on its own.
328
+ if allow_users:
329
+ rules.append(
330
+ types.InputPrivacyValueAllowUsers(
331
+ users=[await _settings.input_user(ctx, str(i)) for i in allow_users]
332
+ )
333
+ )
334
+ if deny_users:
335
+ rules.append(
336
+ types.InputPrivacyValueDisallowUsers(
337
+ users=[await _settings.input_user(ctx, str(i)) for i in deny_users]
338
+ )
339
+ )
340
+ if allow_chats:
341
+ rules.append(types.InputPrivacyValueAllowChatParticipants(chats=allow_chats))
342
+ if deny_chats:
343
+ rules.append(types.InputPrivacyValueDisallowChatParticipants(chats=deny_chats))
344
+ rules.append(_base_rule(base))
345
+
346
+ answer = await handle(fn.SetPrivacyRequest(key=key, rules=rules))
347
+ ctx.emit("privacy_set", {"key": name, "base": base})
348
+ return _rules_model(name, getattr(answer, "rules", None))
349
+
350
+
351
+ def _base_rule(base: str) -> Any:
352
+ from telethon.tl import types
353
+
354
+ return {
355
+ "everybody": types.InputPrivacyValueAllowAll,
356
+ "contacts": types.InputPrivacyValueAllowContacts,
357
+ "close-friends": types.InputPrivacyValueAllowCloseFriends,
358
+ "premium": types.InputPrivacyValueAllowPremium,
359
+ "bots": types.InputPrivacyValueAllowBots,
360
+ "nobody": types.InputPrivacyValueDisallowAll,
361
+ }[base]()
362
+
363
+
364
+ SPEC_SET = OperationSpec(
365
+ id="privacy.set",
366
+ request=SetReq,
367
+ response=PrivacySettings,
368
+ impl=set_,
369
+ summary="Change a privacy setting: a base rule plus optional exception lists",
370
+ description=(
371
+ "`account.setPrivacy` replaces the whole ordered vector, so this "
372
+ "always reads the current rules first. `--add-allow`/`--remove` edit "
373
+ "the lists in place; `--allow`/`--disallow` replace them."
374
+ ),
375
+ mutating=True,
376
+ idempotent=True,
377
+ rate_class="send",
378
+ columns=("key", "base", "allow_users", "deny_users"),
379
+ headers=("Key", "Base", "Always allow", "Never allow"),
380
+ example={"key": "last-seen", "base": "contacts", "deny_users": [777123]},
381
+ example_args="privacy set last-seen contacts --add-disallow @nosy",
382
+ covers=(
383
+ "bots.privacy-rule-bots",
384
+ "calls.privacy-p2p",
385
+ "calls.privacy-who-can-call",
386
+ "contacts-users.privacy-exception-lists",
387
+ "contacts-users.user-status-reveal",
388
+ "dialogs.new-chats-privacy",
389
+ "gift.privacy-disallowed",
390
+ "privacy.exceptions",
391
+ "privacy.key-bio",
392
+ "privacy.key-chat-invite",
393
+ "privacy.key-forwards",
394
+ "privacy.key-gifts-auto-save",
395
+ "privacy.key-last-seen",
396
+ "privacy.key-profile-photo",
397
+ "privacy.key-saved-music",
398
+ "privacy.set-rules",
399
+ ),
400
+ covers_partial=(
401
+ "privacy.key-birthday",
402
+ "privacy.key-calls",
403
+ "privacy.key-no-paid-messages",
404
+ "privacy.key-phone-number",
405
+ "privacy.key-voice-messages",
406
+ ),
407
+ coverage_note="Reading any of these keys back is `privacy get`.",
408
+ tags=frozenset({"visible-to-others"}),
409
+ )
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # privacy global get / set
414
+ # ---------------------------------------------------------------------------
415
+
416
+
417
+ def _global_model(raw: Any) -> GlobalPrivacy:
418
+ gifts = getattr(raw, "disallowed_gifts", None)
419
+ return GlobalPrivacy(
420
+ hide_read_marks=getattr(raw, "hide_read_marks", None),
421
+ archive_and_mute_new_noncontact_peers=getattr(
422
+ raw, "archive_and_mute_new_noncontact_peers", None
423
+ ),
424
+ new_noncontact_peers_require_premium=getattr(
425
+ raw, "new_noncontact_peers_require_premium", None
426
+ ),
427
+ noncontact_peers_paid_stars=getattr(raw, "noncontact_peers_paid_stars", None),
428
+ keep_archived_unmuted=getattr(raw, "keep_archived_unmuted", None),
429
+ keep_archived_folders=getattr(raw, "keep_archived_folders", None),
430
+ display_gifts_button=getattr(raw, "display_gifts_button", None),
431
+ disallowed_gifts=sorted(
432
+ word for word, field in GIFT_KINDS.items() if getattr(gifts, field, False)
433
+ ),
434
+ )
435
+
436
+
437
+ class GlobalGetReq(Request):
438
+ pass
439
+
440
+
441
+ async def global_get(ctx: OpContext, req: GlobalGetReq) -> GlobalPrivacy:
442
+ """The account-wide privacy switches: read marks, archiving, paid messages."""
443
+ from telethon.tl.functions import account as fn
444
+
445
+ return _global_model(await client(ctx)(fn.GetGlobalPrivacySettingsRequest()))
446
+
447
+
448
+ SPEC_GLOBAL_GET = OperationSpec(
449
+ id="privacy.global.get",
450
+ request=GlobalGetReq,
451
+ response=GlobalPrivacy,
452
+ impl=global_get,
453
+ summary="Read the global privacy settings (read time, archiving, paid messages, gifts)",
454
+ idempotent=True,
455
+ columns=(
456
+ "hide_read_marks",
457
+ "archive_and_mute_new_noncontact_peers",
458
+ "new_noncontact_peers_require_premium",
459
+ "noncontact_peers_paid_stars",
460
+ ),
461
+ headers=("Hide read marks", "Archive strangers", "Premium only", "Stars/message"),
462
+ example={"hide_read_marks": False, "new_noncontact_peers_require_premium": True},
463
+ example_args="privacy global get",
464
+ covers=(
465
+ "contacts-users.privacy-global",
466
+ "privacy.global-disallowed-gifts",
467
+ "privacy.global-keep-archived-unmuted",
468
+ "privacy.global-require-premium-to-message",
469
+ ),
470
+ covers_partial=(
471
+ "privacy.global-archive-new-noncontacts",
472
+ "privacy.global-display-gifts-button",
473
+ "privacy.global-hide-read-marks",
474
+ "privacy.global-keep-archived-folders",
475
+ "privacy.global-paid-messages-price",
476
+ ),
477
+ coverage_note="Writing any of these switches is `privacy global set`.",
478
+ tags=frozenset({"agent-safe"}),
479
+ )
480
+
481
+
482
+ class GlobalSetReq(Request):
483
+ hide_read_marks: Annotated[
484
+ str | None, opt("--hide-read-marks", metavar="ON|OFF", help="Hide when I read messages.")
485
+ ] = None
486
+ archive_new_noncontacts: Annotated[
487
+ str | None,
488
+ opt("--archive-new-noncontacts", metavar="ON|OFF", help="Archive+mute unknown senders."),
489
+ ] = None
490
+ require_premium_to_message: Annotated[
491
+ str | None,
492
+ opt("--require-premium-to-message", metavar="ON|OFF", help="Premium non-contacts only."),
493
+ ] = None
494
+ paid_messages_price: Annotated[
495
+ int | None,
496
+ opt("--paid-messages-price", metavar="STARS", help="Stars per message; 0 turns it off."),
497
+ ] = None
498
+ keep_archived_unmuted: Annotated[
499
+ str | None,
500
+ opt("--keep-archived-unmuted", metavar="ON|OFF", help="Unmuted archived chats stay put."),
501
+ ] = None
502
+ keep_archived_folders: Annotated[
503
+ str | None,
504
+ opt("--keep-archived-folders", metavar="ON|OFF", help="Folder chats stay archived."),
505
+ ] = None
506
+ display_gifts_button: Annotated[
507
+ str | None,
508
+ opt("--display-gifts-button", metavar="ON|OFF", help="Gift button in private chats."),
509
+ ] = None
510
+ disallow_gifts: Annotated[
511
+ str | None, opt("--disallow-gifts", metavar="LIST", help="Gift categories to refuse.")
512
+ ] = None
513
+ allow_gifts: Annotated[
514
+ str | None, opt("--allow-gifts", metavar="LIST", help="Gift categories to accept again.")
515
+ ] = None
516
+
517
+
518
+ async def global_set(ctx: OpContext, req: GlobalSetReq) -> GlobalPrivacy:
519
+ """Change one global privacy switch; tlgr does the read-modify-write.
520
+
521
+ `account.setGlobalPrivacySettings` replaces the whole constructor, so a
522
+ flag nobody passed would be written back as `false` unless the current
523
+ value is fetched first. It is, every time.
524
+ """
525
+ from telethon.tl import types
526
+ from telethon.tl.functions import account as fn
527
+
528
+ handle = client(ctx)
529
+ current = await handle(fn.GetGlobalPrivacySettingsRequest())
530
+ before = _global_model(current)
531
+
532
+ values: dict[str, Any] = {
533
+ "hide_read_marks": before.hide_read_marks,
534
+ "archive_and_mute_new_noncontact_peers": before.archive_and_mute_new_noncontact_peers,
535
+ "new_noncontact_peers_require_premium": before.new_noncontact_peers_require_premium,
536
+ "keep_archived_unmuted": before.keep_archived_unmuted,
537
+ "keep_archived_folders": before.keep_archived_folders,
538
+ "display_gifts_button": before.display_gifts_button,
539
+ }
540
+ changed: list[str] = []
541
+ for flag, field in (
542
+ ("hide_read_marks", "hide_read_marks"),
543
+ ("archive_new_noncontacts", "archive_and_mute_new_noncontact_peers"),
544
+ ("require_premium_to_message", "new_noncontact_peers_require_premium"),
545
+ ("keep_archived_unmuted", "keep_archived_unmuted"),
546
+ ("keep_archived_folders", "keep_archived_folders"),
547
+ ("display_gifts_button", "display_gifts_button"),
548
+ ):
549
+ value = _settings.on_off(getattr(req, flag), field=flag)
550
+ if value is not None:
551
+ values[field] = value
552
+ changed.append(field)
553
+
554
+ stars = before.noncontact_peers_paid_stars
555
+ if req.paid_messages_price is not None:
556
+ if req.paid_messages_price < 0:
557
+ raise UsageError(
558
+ "--paid-messages-price cannot be negative", field="paid_messages_price"
559
+ )
560
+ stars = req.paid_messages_price or None
561
+ changed.append("noncontact_peers_paid_stars")
562
+
563
+ kinds = set(before.disallowed_gifts)
564
+ for text, add in ((req.disallow_gifts, True), (req.allow_gifts, False)):
565
+ for word in (part.strip().lower() for part in (text or "").split(",") if part.strip()):
566
+ if word not in GIFT_KINDS:
567
+ raise UsageError(
568
+ f"unknown gift category {word!r}; one of: {' '.join(sorted(GIFT_KINDS))}",
569
+ field="disallow_gifts",
570
+ )
571
+ kinds.add(word) if add else kinds.discard(word)
572
+ changed.append("disallowed_gifts")
573
+
574
+ if not changed:
575
+ raise UsageError("nothing to change: pass at least one flag", field="hide_read_marks")
576
+
577
+ gifts = (
578
+ types.DisallowedGiftsSettings(**{GIFT_KINDS[word]: True for word in sorted(kinds)})
579
+ if kinds
580
+ else None
581
+ )
582
+ answer = await handle(
583
+ fn.SetGlobalPrivacySettingsRequest(
584
+ settings=types.GlobalPrivacySettings(
585
+ **{k: v or None for k, v in values.items()},
586
+ noncontact_peers_paid_stars=stars,
587
+ disallowed_gifts=gifts,
588
+ )
589
+ )
590
+ )
591
+ ctx.emit("privacy_global", {"changed": sorted(set(changed))})
592
+ result = _global_model(answer)
593
+ result.changed = sorted(set(changed))
594
+ return result
595
+
596
+
597
+ SPEC_GLOBAL_SET = OperationSpec(
598
+ id="privacy.global.set",
599
+ request=GlobalSetReq,
600
+ response=GlobalPrivacy,
601
+ impl=global_set,
602
+ summary="Change global privacy settings (one flag at a time; tlgr read-modify-writes)",
603
+ mutating=True,
604
+ idempotent=True,
605
+ rate_class="send",
606
+ columns=("hide_read_marks", "new_noncontact_peers_require_premium", "changed"),
607
+ headers=("Hide read marks", "Premium only", "Changed"),
608
+ example={"hide_read_marks": True, "changed": ["hide_read_marks"]},
609
+ example_args="privacy global set --hide-read-marks on",
610
+ covers=(
611
+ "privacy.global-archive-new-noncontacts",
612
+ "privacy.global-display-gifts-button",
613
+ "privacy.global-hide-read-marks",
614
+ "privacy.global-keep-archived-folders",
615
+ "privacy.global-paid-messages-price",
616
+ ),
617
+ covers_partial=(
618
+ "privacy.global-disallowed-gifts",
619
+ "privacy.global-keep-archived-unmuted",
620
+ "privacy.global-require-premium-to-message",
621
+ ),
622
+ coverage_note="Reading them back is `privacy global get`.",
623
+ tags=frozenset({"visible-to-others"}),
624
+ )
625
+
626
+
627
+ # ---------------------------------------------------------------------------
628
+ # privacy blocked list / set
629
+ # ---------------------------------------------------------------------------
630
+
631
+
632
+ class BlockedListReq(Request):
633
+ stories: Annotated[
634
+ bool, opt("--stories", help="The 'blocked from my stories' list instead.")
635
+ ] = False
636
+
637
+
638
+ async def blocked_list(ctx: OpContext, req: BlockedListReq) -> Page[BlockedPeer]:
639
+ """Blocked users and bots, or the separate story blocklist.
640
+
641
+ The same list `contact blocked list` answers with — reached from the
642
+ Privacy screen, which is where the GUI puts it, rather than from the
643
+ address book.
644
+ """
645
+ from tlgr.ops.contact import BlockedListReq as ContactBlockedListReq
646
+ from tlgr.ops.contact import blocked_list as contact_blocked_list
647
+
648
+ return await contact_blocked_list(ctx, ContactBlockedListReq(stories=req.stories))
649
+
650
+
651
+ SPEC_BLOCKED_LIST = OperationSpec(
652
+ id="privacy.blocked.list",
653
+ request=BlockedListReq,
654
+ response=Page[BlockedPeer],
655
+ impl=blocked_list,
656
+ summary="List blocked users (and the separate story-only block list)",
657
+ aliases=("block.list",),
658
+ paginated=PageKind.PARTICIPANTS,
659
+ idempotent=True,
660
+ columns=("peer.id", "peer.title", "date", "kind"),
661
+ headers=("Id", "Peer", "Blocked", "List"),
662
+ example={
663
+ "items": [{"peer": {"id": 777123, "raw_id": 777123, "kind": "user"}, "kind": "main"}],
664
+ "has_more": False,
665
+ },
666
+ example_args="privacy blocked list",
667
+ covers_partial=("privacy.blocked-list",),
668
+ coverage_note="The write half is `privacy blocked set`; `contact blocked list` is the same list.",
669
+ tags=frozenset({"agent-safe"}),
670
+ )
671
+
672
+
673
+ class BlockedSetReq(Request):
674
+ peer: Annotated[
675
+ tuple[PeerRef, ...],
676
+ arg(0, metavar="PEER", required=False, variadic=True, kind="peer", help="Who to block."),
677
+ ] = ()
678
+ unblock: Annotated[bool, opt("--unblock", help="Remove the block instead of adding it.")] = (
679
+ False
680
+ )
681
+ stories: Annotated[bool, opt("--stories", help="Only block them from seeing my stories.")] = (
682
+ False
683
+ )
684
+ replace_with: Annotated[
685
+ str | None,
686
+ opt("--replace-with", metavar="LIST", help="Replace the whole list at once."),
687
+ ] = None
688
+
689
+
690
+ async def blocked_set(ctx: OpContext, req: BlockedSetReq) -> BlockedSet:
691
+ """Block or unblock peers, or replace the whole blocklist.
692
+
693
+ The answer is always the diff — who this call blocked and who it
694
+ unblocked — because `--replace-with` is `contacts.setBlocked`, which
695
+ *replaces* the list: everyone not named is unblocked. One shape for both
696
+ paths means a script never has to branch on which flag it passed.
697
+ """
698
+ from telethon.tl.functions import contacts as fn
699
+
700
+ if req.replace_with is not None:
701
+ from tlgr.models.peer import parse_peer_ref
702
+ from tlgr.ops.contact import BlockedSetReq as ContactBlockedSetReq
703
+ from tlgr.ops.contact import blocked_set as contact_blocked_set
704
+
705
+ refs = [
706
+ parse_peer_ref(part.strip()) for part in req.replace_with.split(",") if part.strip()
707
+ ]
708
+ return await contact_blocked_set(ctx, ContactBlockedSetReq(user=refs, stories=req.stories))
709
+
710
+ if not req.peer:
711
+ raise UsageError("give one or more peers, or --replace-with", field="peer")
712
+
713
+ handle = client(ctx)
714
+ marked: list[int] = []
715
+ for ref in req.peer:
716
+ peer = await _settings.resolve(ctx, ref)
717
+ request = fn.UnblockRequest if req.unblock else fn.BlockRequest
718
+ await handle(request(id=peer, my_stories_from=req.stories or None))
719
+ marked.append(_settings.peer_of(peer))
720
+ ctx.emit("privacy_blocked", {"peer_ids": marked, "unblock": req.unblock})
721
+ return BlockedSet(
722
+ count=len(marked),
723
+ blocked=[] if req.unblock else marked,
724
+ unblocked=marked if req.unblock else [],
725
+ kind="stories" if req.stories else "main",
726
+ applied=True,
727
+ )
728
+
729
+
730
+ SPEC_BLOCKED_SET = OperationSpec(
731
+ id="privacy.blocked.set",
732
+ request=BlockedSetReq,
733
+ response=BlockedSet,
734
+ impl=blocked_set,
735
+ summary="Block or unblock a user or bot (also the story-only block list)",
736
+ description=(
737
+ "The same operation as `user block` / `user unblock`, reached from "
738
+ "the Privacy screen. `--replace-with` is the bulk form and answers "
739
+ "with the diff it applied."
740
+ ),
741
+ aliases=("block.set",),
742
+ mutating=True,
743
+ rate_class="send",
744
+ columns=("count", "blocked", "unblocked", "kind"),
745
+ headers=("Peers", "Blocked", "Unblocked", "List"),
746
+ example={"count": 1, "blocked": [777123], "unblocked": [], "kind": "main", "applied": True},
747
+ example_args="privacy blocked set @spammer",
748
+ covers=("privacy.blocked-list",),
749
+ tags=frozenset({"visible-to-others"}),
750
+ )
751
+
752
+
753
+ # ---------------------------------------------------------------------------
754
+ # privacy revenue get
755
+ # ---------------------------------------------------------------------------
756
+
757
+
758
+ class RevenueGetReq(Request):
759
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who paid me.")]
760
+ parent_peer: Annotated[
761
+ str | None,
762
+ opt("--parent-peer", metavar="CHAT", help="Business or channel parent peer."),
763
+ ] = None
764
+
765
+
766
+ async def revenue_get(ctx: OpContext, req: RevenueGetReq) -> PaidMessageRevenue:
767
+ """Stars earned from paid messages one user sent me."""
768
+ from telethon.tl.functions import account as fn
769
+
770
+ target = await _settings.input_user(ctx, req.user)
771
+ parent = await _settings.resolve(ctx, req.parent_peer) if req.parent_peer else None
772
+ result = await client(ctx)(fn.GetPaidMessagesRevenueRequest(user_id=target, parent_peer=parent))
773
+ return PaidMessageRevenue(
774
+ user_id=_settings.peer_of(await _settings.resolve(ctx, req.user)),
775
+ stars_amount=int(getattr(result, "stars_amount", 0) or 0),
776
+ )
777
+
778
+
779
+ SPEC_REVENUE_GET = OperationSpec(
780
+ id="privacy.revenue.get",
781
+ request=RevenueGetReq,
782
+ response=PaidMessageRevenue,
783
+ impl=revenue_get,
784
+ summary="Stars earned from paid messages sent by a user",
785
+ idempotent=True,
786
+ columns=("user_id", "stars_amount"),
787
+ headers=("User", "Stars"),
788
+ example={"user_id": 777123, "stars_amount": 25},
789
+ example_args="privacy revenue get @alice",
790
+ covers=("privacy.paid-message-revenue",),
791
+ tags=frozenset({"agent-safe"}),
792
+ )
793
+
794
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]