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/sticker.py ADDED
@@ -0,0 +1,1602 @@
1
+ """The `sticker` group — and, through its aliases, the `emoji set` one.
2
+
3
+ One distinction runs through the whole module and it is the API's, not a
4
+ naming preference:
5
+
6
+ * a **set** is somebody's collection you install, archive, reorder or
7
+ uninstall. Every verb here is in the `messages.*` namespace and works on
8
+ any set in the world.
9
+ * a **pack** is a set *you created*. Every verb is in the `stickers.*`
10
+ namespace and the server answers `STICKERSET_INVALID` to anyone else.
11
+
12
+ Uninstalling a set is therefore not deleting a pack, and `sticker set remove`
13
+ and `sticker pack delete` are deliberately different commands with different
14
+ blast radii.
15
+
16
+ Custom emoji sets take exactly the same calls — only the `emojis` flag on the
17
+ archive/reorder requests differs — which is why `emoji set list` is an alias
18
+ of `sticker set list --type emoji` rather than a second implementation.
19
+
20
+ Telethon is imported inside functions, never at module scope (§2.2).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import gzip
26
+ import os
27
+ from pathlib import Path
28
+ from typing import Annotated, Any
29
+
30
+ from tlgr.core.errors import EXIT_EMPTY, NotFoundError, NotSupportedError, UsageError
31
+ from tlgr.core.pagination import PageKind, build_page
32
+ from tlgr.models.base import Request
33
+ from tlgr.models.media import MediaFile
34
+ from tlgr.models.page import Page
35
+ from tlgr.models.sticker import (
36
+ FaveResult,
37
+ PackCreated,
38
+ PackDeleted,
39
+ PackEdited,
40
+ PackStickerAdded,
41
+ PackStickerRemoved,
42
+ RecentResult,
43
+ Sticker,
44
+ StickerSet,
45
+ StickerSetOrder,
46
+ StickerSetsChanged,
47
+ )
48
+ from tlgr.ops import _media, _send
49
+ from tlgr.ops._params import arg, choice, opt
50
+ from tlgr.ops._spec import OpContext, OperationSpec
51
+
52
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
53
+
54
+ #: The reserved emoticons behind the server's two special sticker lists: the
55
+ #: greeting sticker an empty chat offers, and the Premium promo strip.
56
+ SPECIAL_EMOTICONS = {"greeting": "👋⭐️", "premium": "📂⭐️"}
57
+
58
+ _EXAMPLE_SET: dict[str, Any] = {
59
+ "id": 1258816259751983,
60
+ "short_name": "AnimatedEmojies",
61
+ "title": "Animated Emoji",
62
+ "count": 120,
63
+ "type": "sticker",
64
+ "installed": True,
65
+ "link": "https://t.me/addstickers/AnimatedEmojies",
66
+ }
67
+
68
+ _EXAMPLE_STICKER: dict[str, Any] = {
69
+ "doc_id": 5312836234,
70
+ "emoji": "😀",
71
+ "index": 0,
72
+ "set_short_name": "AnimatedEmojies",
73
+ "mime": "application/x-tgsticker",
74
+ }
75
+
76
+
77
+ def _client(ctx: OpContext) -> Any:
78
+ return _media.client(ctx)
79
+
80
+
81
+ def _refs(values: list[str], field: str = "set") -> list[Any]:
82
+ if not values:
83
+ raise UsageError("name at least one sticker set", field=field)
84
+ return [_media.sticker_set_ref(value, field=field) for value in values]
85
+
86
+
87
+ def _names(values: list[str]) -> list[str]:
88
+ """The short names, as the caller spelled them, for the response."""
89
+ out: list[str] = []
90
+ for value in values:
91
+ text = str(value).strip()
92
+ for marker in ("addstickers/", "addemoji/"):
93
+ if marker in text.lower():
94
+ text = text[text.lower().index(marker) + len(marker) :].split("?")[0]
95
+ out.append(text)
96
+ return out
97
+
98
+
99
+ def _archived_names(result: Any) -> list[str]:
100
+ """`stickerSetInstallResultArchive` names the sets it pushed aside.
101
+
102
+ Reporting them is the whole point: an install that silently archived four
103
+ other sets to stay under the limit looks like a bug from the outside.
104
+ """
105
+ sets = getattr(result, "sets", None) or []
106
+ out: list[str] = []
107
+ for covered in sets:
108
+ inner = getattr(covered, "set", covered)
109
+ name = getattr(inner, "short_name", None)
110
+ if name:
111
+ out.append(str(name))
112
+ return out
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # sticker set get
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ class SetGetReq(Request):
121
+ set: Annotated[
122
+ str | None,
123
+ arg(0, metavar="SET", required=False, help="Short name, t.me link, or <id>:<hash>."),
124
+ ] = None
125
+ from_message: Annotated[
126
+ str | None,
127
+ opt("--from-message", metavar="CHAT:ID", help="Take the set from a message's sticker."),
128
+ ] = None
129
+ system: Annotated[
130
+ str | None,
131
+ opt(
132
+ "--system",
133
+ metavar="NAME",
134
+ help="A system set: dice:EMOJI, animated-emoji, topic-icons, default-statuses…",
135
+ ),
136
+ ] = None
137
+ download: Annotated[
138
+ str | None,
139
+ opt("--download", metavar="DIR", kind="path", help="Download every sticker into here."),
140
+ ] = None
141
+ thumb: Annotated[bool, opt("--thumb", help="Also download the set thumbnail.")] = False
142
+ effects: Annotated[bool, opt("--effects", help="Also download premium effect videos.")] = False
143
+ convert: Annotated[
144
+ str, choice("png", "json", "none", help="Post-process downloads: TGS to Lottie JSON.")
145
+ ] = "none"
146
+ link: Annotated[bool, opt("--link", help="Print only the shareable set link.")] = False
147
+
148
+
149
+ def _set_detail(result: Any) -> StickerSet:
150
+ """A `messages.stickerSet` with its documents, emoji map and keywords."""
151
+ model = _media.sticker_set_model(getattr(result, "set", result))
152
+ documents = list(getattr(result, "documents", None) or [])
153
+ keywords: dict[str, list[str]] = {}
154
+ for entry in getattr(result, "keywords", None) or []:
155
+ keywords[str(getattr(entry, "document_id", ""))] = list(
156
+ getattr(entry, "keywords", None) or []
157
+ )
158
+ model.stickers = [
159
+ _media.sticker_model(
160
+ document,
161
+ index=index,
162
+ set_short_name=model.short_name,
163
+ set_title=model.title,
164
+ keywords=keywords.get(str(getattr(document, "id", "")), []),
165
+ )
166
+ for index, document in enumerate(documents)
167
+ ]
168
+ model.packs = {
169
+ str(getattr(pack, "emoticon", "")): [int(i) for i in getattr(pack, "documents", None) or []]
170
+ for pack in getattr(result, "packs", None) or []
171
+ }
172
+ model.keywords = keywords
173
+ return model
174
+
175
+
176
+ async def set_get(ctx: OpContext, req: SetGetReq) -> StickerSet:
177
+ """One sticker set with all its stickers, the emoji map and the keywords.
178
+
179
+ `--system` reaches the sets a client holds without installing: `dice:🎲`
180
+ is how a received `messageMediaDice` value renders (`documents[value]`),
181
+ and the animated-emoji sets are the assets behind an emoji-only message.
182
+ """
183
+ reference = await _set_reference(ctx, req)
184
+ result = await _media.fetch_set(ctx, reference)
185
+ model = _set_detail(result)
186
+ if req.download:
187
+ await _download_set(ctx, req, result, model)
188
+ return model
189
+
190
+
191
+ async def _set_reference(ctx: OpContext, req: SetGetReq) -> Any:
192
+ if req.from_message:
193
+ peer_text, _, message_id = str(req.from_message).rpartition(":")
194
+ if not peer_text or not message_id.lstrip("-").isdigit():
195
+ raise UsageError("--from-message takes CHAT:ID", field="from_message")
196
+ from tlgr.models.peer import parse_peer_ref
197
+
198
+ peer = await _send.resolve(ctx, parse_peer_ref(peer_text))
199
+ message = await _media.fetch_message(ctx, peer, int(message_id))
200
+ document = _media.document_of(getattr(message, "media", None))
201
+ stickerset = _media.attributes_of(document).get("stickerset")
202
+ if stickerset is None:
203
+ raise NotFoundError(f"message {message_id} carries no sticker")
204
+ return stickerset
205
+ if req.system:
206
+ return _media.sticker_set_ref(req.system, field="system")
207
+ if req.set:
208
+ return _media.sticker_set_ref(req.set, field="set")
209
+ raise UsageError("name a set, or use --system/--from-message", field="set")
210
+
211
+
212
+ async def _download_set(ctx: OpContext, req: SetGetReq, result: Any, model: StickerSet) -> None:
213
+ """Fetch every sticker, and convert the ones that can be converted here."""
214
+ directory = Path(os.path.expanduser(req.download or "."))
215
+ directory.mkdir(parents=True, exist_ok=True)
216
+ download = getattr(ctx, "download_file", None)
217
+ if download is None: # pragma: no cover - the daemon always supplies one
218
+ raise UsageError("this context cannot download files")
219
+
220
+ for index, document in enumerate(getattr(result, "documents", None) or []):
221
+ suffix = {
222
+ "image/webp": ".webp",
223
+ "application/x-tgsticker": ".tgs",
224
+ "video/webm": ".webm",
225
+ }.get(getattr(document, "mime_type", "") or "", ".bin")
226
+ target = directory / f"{model.short_name or model.id}_{index}{suffix}"
227
+ written = await download(
228
+ document,
229
+ target,
230
+ size=int(getattr(document, "size", 0) or 0),
231
+ dc_id=int(getattr(document, "dc_id", 0) or 0),
232
+ )
233
+ path = Path(written)
234
+ if req.convert != "none":
235
+ path = _convert(path, req.convert)
236
+ model.stickers[index].path = str(path)
237
+
238
+
239
+ def _convert(path: Path, into: str) -> Path:
240
+ """TGS is gzipped Lottie, so `--convert json` is a gunzip and nothing more.
241
+
242
+ `--convert png` is not: rasterising WebP needs Pillow, and pretending to
243
+ do it without one would write a file that is not a PNG.
244
+ """
245
+ if into == "json":
246
+ if path.suffix != ".tgs":
247
+ return path
248
+ target = path.with_suffix(".json")
249
+ target.write_bytes(gzip.decompress(path.read_bytes()))
250
+ return target
251
+ try:
252
+ from PIL import Image
253
+ except ImportError as exc:
254
+ raise NotSupportedError(
255
+ "--convert png needs Pillow (install the [media] extra); the stickers were "
256
+ "downloaded in their original format"
257
+ ) from exc
258
+ target = path.with_suffix(".png")
259
+ with Image.open(path) as image:
260
+ image.save(target, "PNG")
261
+ return target
262
+
263
+
264
+ SPEC_SET_GET = OperationSpec(
265
+ id="sticker.set.get",
266
+ request=SetGetReq,
267
+ response=StickerSet,
268
+ impl=set_get,
269
+ summary="One sticker set with all its stickers, emoji map and keywords",
270
+ description=(
271
+ "Each sticker row carries the document id, its emoji, its keywords and "
272
+ "its index, which is what `sticker fave add` and `message send "
273
+ "--sticker <set>/<index>` consume. The share link is string "
274
+ "formatting, not a request: t.me/addstickers/<name>, or "
275
+ "t.me/addemoji/<name> for an emoji set."
276
+ ),
277
+ aliases=("emoji.set.get",),
278
+ columns=("short_name", "title", "count", "type"),
279
+ headers=("Short name", "Title", "Count", "Type"),
280
+ empty_exit=EXIT_EMPTY,
281
+ example={**_EXAMPLE_SET, "stickers": [_EXAMPLE_STICKER]},
282
+ example_args="sticker set get AnimatedEmojies",
283
+ covers=(
284
+ "media.animated-emoji",
285
+ "sticker.dice-and-system-sets",
286
+ "sticker.download-assets",
287
+ "sticker.set-view",
288
+ "sticker.share-link",
289
+ ),
290
+ covers_partial=("emoji.custom-sets",),
291
+ coverage_note="Custom-emoji sets take the same calls; `emoji set get` is this command.",
292
+ )
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # sticker set list
297
+ # ---------------------------------------------------------------------------
298
+
299
+
300
+ class SetListReq(Request):
301
+ type: Annotated[str, choice("sticker", "mask", "emoji", help="Which library.")] = "sticker"
302
+ archived: Annotated[bool, opt("--archived", help="The archived shelf.")] = False
303
+ featured: Annotated[bool, opt("--featured", help="The trending shelf.")] = False
304
+ old_featured: Annotated[
305
+ bool, opt("--old-featured", help="The long tail behind the trending shelf.")
306
+ ] = False
307
+ mark_read: Annotated[
308
+ bool, opt("--mark-read", help="With --featured: clear the unread badge.")
309
+ ] = False
310
+ unread_only: Annotated[
311
+ bool, opt("--unread-only", help="With --featured: only sets still badged.")
312
+ ] = False
313
+
314
+
315
+ async def set_list(ctx: OpContext, req: SetListReq) -> Page[StickerSet]:
316
+ """Installed, archived or featured sets.
317
+
318
+ The installed and featured lists are hash-cached rather than offset
319
+ paginated — the server answers `*NotModified` when nothing changed — so
320
+ `--limit`/`--cursor` only mean something for the two that genuinely walk:
321
+ `--archived` (offset_id) and `--old-featured` (offset).
322
+ """
323
+ from telethon.tl.functions import messages as fn
324
+
325
+ limit, state = _media.window(ctx, "sticker.set.list", PageKind.LOCAL)
326
+ if req.archived:
327
+ result = await _client(ctx)(
328
+ fn.GetArchivedStickersRequest(
329
+ offset_id=int(state.get("offset_id", 0)),
330
+ limit=limit,
331
+ masks=req.type == "mask" or None,
332
+ emojis=req.type == "emoji" or None,
333
+ )
334
+ )
335
+ items = [_media.covered_set(covered) for covered in getattr(result, "sets", None) or []]
336
+ for item in items:
337
+ item.archived = True
338
+ next_state = {"offset_id": items[-1].id} if items else {}
339
+ return build_page(
340
+ items,
341
+ op="sticker.set.list",
342
+ kind=PageKind.LOCAL,
343
+ state=next_state,
344
+ account=ctx.account,
345
+ limit=limit,
346
+ )
347
+
348
+ if req.old_featured:
349
+ result = await _client(ctx)(
350
+ fn.GetOldFeaturedStickersRequest(
351
+ offset=int(state.get("offset", 0)), limit=limit, hash=0
352
+ )
353
+ )
354
+ items = [_media.covered_set(covered) for covered in getattr(result, "sets", None) or []]
355
+ next_state = {"offset": int(state.get("offset", 0)) + len(items)}
356
+ return build_page(
357
+ items,
358
+ op="sticker.set.list",
359
+ kind=PageKind.LOCAL,
360
+ state=next_state,
361
+ account=ctx.account,
362
+ limit=limit,
363
+ )
364
+
365
+ if req.featured:
366
+ request: Any = (
367
+ fn.GetFeaturedEmojiStickersRequest(hash=0)
368
+ if req.type == "emoji"
369
+ else fn.GetFeaturedStickersRequest(hash=0)
370
+ )
371
+ result = await _client(ctx)(request)
372
+ unread = {int(i) for i in getattr(result, "unread", None) or []}
373
+ items = [_media.covered_set(covered) for covered in getattr(result, "sets", None) or []]
374
+ for item in items:
375
+ item.unread = item.id in unread
376
+ if req.unread_only:
377
+ items = [item for item in items if item.unread]
378
+ if req.mark_read and unread:
379
+ await _client(ctx)(fn.ReadFeaturedStickersRequest(id=sorted(unread)))
380
+ return Page(items=items[:limit], has_more=False, total=len(items))
381
+
382
+ installed: Any = {
383
+ "sticker": fn.GetAllStickersRequest(hash=0),
384
+ "mask": fn.GetMaskStickersRequest(hash=0),
385
+ "emoji": fn.GetEmojiStickersRequest(hash=0),
386
+ }[req.type]
387
+ result = await _client(ctx)(installed)
388
+ items = [_media.sticker_set_model(entry) for entry in getattr(result, "sets", None) or []]
389
+ return Page(items=items[:limit], has_more=False, total=len(items))
390
+
391
+
392
+ SPEC_SET_LIST = OperationSpec(
393
+ id="sticker.set.list",
394
+ request=SetListReq,
395
+ response=Page[StickerSet],
396
+ impl=set_list,
397
+ summary="Installed, archived or featured sticker, mask or emoji sets",
398
+ aliases=("emoji.set.list",),
399
+ paginated=PageKind.LOCAL,
400
+ columns=("short_name", "title", "count", "type"),
401
+ headers=("Short name", "Title", "Count", "Type"),
402
+ example={"items": [_EXAMPLE_SET], "has_more": False},
403
+ example_args="sticker set list --type emoji",
404
+ covers=("sticker.set-featured", "sticker.set-list-installed"),
405
+ covers_partial=("emoji.custom-sets", "sticker.set-archive"),
406
+ coverage_note=(
407
+ "Archiving is `sticker set archive`; this lists the shelf. `--mark-read` is the "
408
+ "only mutation the listing performs and it is opt-in."
409
+ ),
410
+ tags=frozenset({"mutating-checked"}),
411
+ )
412
+
413
+
414
+ # ---------------------------------------------------------------------------
415
+ # sticker set install / uninstall / archive
416
+ # ---------------------------------------------------------------------------
417
+
418
+
419
+ class SetAddReq(Request):
420
+ set: Annotated[
421
+ list[str], arg(0, metavar="SET", variadic=True, help="Short names, links or <id>:<hash>.")
422
+ ] = []
423
+ archived: Annotated[
424
+ bool, opt("--archived", help="Install straight into the archived shelf.")
425
+ ] = False
426
+
427
+
428
+ async def set_add(ctx: OpContext, req: SetAddReq) -> StickerSetsChanged:
429
+ """Install sets.
430
+
431
+ The reply can be `stickerSetInstallResultArchive`, meaning the install
432
+ pushed *other* sets into the archive to stay under the installed-sets
433
+ limit. Those names are reported rather than swallowed.
434
+ """
435
+ from telethon.tl.functions import messages as fn
436
+
437
+ names = _names(req.set)
438
+ changed = StickerSetsChanged(short_names=names)
439
+ for reference in _refs(req.set):
440
+ result = await _client(ctx)(
441
+ fn.InstallStickerSetRequest(stickerset=reference, archived=req.archived)
442
+ )
443
+ changed.installed += 1
444
+ changed.archived_sets.extend(_archived_names(result))
445
+ if changed.archived_sets:
446
+ ctx.warn(
447
+ "the installed-sets limit was reached; these were archived to make room: "
448
+ + ", ".join(changed.archived_sets)
449
+ )
450
+ return changed
451
+
452
+
453
+ SPEC_SET_ADD = OperationSpec(
454
+ id="sticker.set.add",
455
+ request=SetAddReq,
456
+ response=StickerSetsChanged,
457
+ impl=set_add,
458
+ summary="Install a sticker, mask or emoji set",
459
+ aliases=("emoji.set.add",),
460
+ mutating=True,
461
+ idempotent=True,
462
+ columns=("short_names", "installed", "archived_sets"),
463
+ headers=("Sets", "Installed", "Archived to fit"),
464
+ example={"short_names": ["AnimatedEmojies"], "installed": 1, "archived_sets": []},
465
+ example_args="sticker set add AnimatedEmojies",
466
+ covers_partial=("emoji.custom-sets", "sticker.set-install-uninstall"),
467
+ coverage_note="Uninstalling is `sticker set remove`; deleting an owned pack is `sticker pack delete`.",
468
+ )
469
+
470
+
471
+ class SetRemoveReq(Request):
472
+ set: Annotated[list[str], arg(0, metavar="SET", variadic=True, help="Sets to uninstall.")] = []
473
+
474
+
475
+ async def set_remove(ctx: OpContext, req: SetRemoveReq) -> StickerSetsChanged:
476
+ """Uninstall sets.
477
+
478
+ Uninstalling is not deleting: a pack you own still exists afterwards
479
+ (that is `sticker pack delete`), and any set can be reinstalled from its
480
+ link.
481
+ """
482
+ from telethon.tl.functions import messages as fn
483
+
484
+ names = _names(req.set)
485
+ for reference in _refs(req.set):
486
+ await _client(ctx)(fn.UninstallStickerSetRequest(stickerset=reference))
487
+ return StickerSetsChanged(short_names=names, removed=len(names))
488
+
489
+
490
+ SPEC_SET_REMOVE = OperationSpec(
491
+ id="sticker.set.remove",
492
+ request=SetRemoveReq,
493
+ response=StickerSetsChanged,
494
+ impl=set_remove,
495
+ summary="Uninstall a sticker, mask or emoji set",
496
+ aliases=("emoji.set.remove",),
497
+ mutating=True,
498
+ destructive=True,
499
+ columns=("short_names", "removed"),
500
+ headers=("Sets", "Removed"),
501
+ example={"short_names": ["AnimatedEmojies"], "removed": 1},
502
+ example_args="sticker set remove AnimatedEmojies",
503
+ covers=("sticker.set-install-uninstall",),
504
+ covers_partial=("emoji.custom-sets",),
505
+ coverage_note="Emoji sets uninstall through the same call; `emoji set remove` is this command.",
506
+ )
507
+
508
+
509
+ class SetArchiveReq(Request):
510
+ set: Annotated[list[str], arg(0, metavar="SET", variadic=True, help="Sets to archive.")] = []
511
+
512
+
513
+ async def set_archive(ctx: OpContext, req: SetArchiveReq) -> StickerSetsChanged:
514
+ """Move installed sets to the archived shelf.
515
+
516
+ Archiving keeps a set usable — it stays in `sticker set list --archived`
517
+ and its stickers still send — it only leaves the panel.
518
+ """
519
+ from telethon.tl.functions import messages as fn
520
+
521
+ names = _names(req.set)
522
+ await _client(ctx)(fn.ToggleStickerSetsRequest(stickersets=_refs(req.set), archive=True))
523
+ return StickerSetsChanged(short_names=names, archived=len(names))
524
+
525
+
526
+ SPEC_SET_ARCHIVE = OperationSpec(
527
+ id="sticker.set.archive",
528
+ request=SetArchiveReq,
529
+ response=StickerSetsChanged,
530
+ impl=set_archive,
531
+ summary="Move installed sets to the archived shelf",
532
+ aliases=("emoji.set.archive",),
533
+ mutating=True,
534
+ idempotent=True,
535
+ columns=("short_names", "archived"),
536
+ headers=("Sets", "Archived"),
537
+ example={"short_names": ["AnimatedEmojies"], "archived": 1},
538
+ example_args="sticker set archive AnimatedEmojies",
539
+ covers_partial=("sticker.set-archive",),
540
+ coverage_note="Restoring is `sticker set unarchive`; the shelf is `sticker set list --archived`.",
541
+ )
542
+
543
+
544
+ class SetUnarchiveReq(Request):
545
+ set: Annotated[list[str], arg(0, metavar="SET", variadic=True, help="Sets to restore.")] = []
546
+
547
+
548
+ async def set_unarchive(ctx: OpContext, req: SetUnarchiveReq) -> StickerSetsChanged:
549
+ """Restore archived sets to the panel.
550
+
551
+ This can itself push other sets out when the installed limit is reached,
552
+ so anything the server archived as a side effect is reported.
553
+ """
554
+ from telethon.tl.functions import messages as fn
555
+
556
+ names = _names(req.set)
557
+ result = await _client(ctx)(
558
+ fn.ToggleStickerSetsRequest(stickersets=_refs(req.set), unarchive=True)
559
+ )
560
+ changed = StickerSetsChanged(short_names=names, unarchived=len(names))
561
+ changed.archived_sets = _archived_names(result)
562
+ if changed.archived_sets:
563
+ ctx.warn(
564
+ "restoring these reached the installed-sets limit; archived instead: "
565
+ + ", ".join(changed.archived_sets)
566
+ )
567
+ return changed
568
+
569
+
570
+ SPEC_SET_UNARCHIVE = OperationSpec(
571
+ id="sticker.set.unarchive",
572
+ request=SetUnarchiveReq,
573
+ response=StickerSetsChanged,
574
+ impl=set_unarchive,
575
+ summary="Restore archived sets to the panel",
576
+ aliases=("emoji.set.unarchive",),
577
+ mutating=True,
578
+ idempotent=True,
579
+ columns=("short_names", "unarchived"),
580
+ headers=("Sets", "Unarchived"),
581
+ example={"short_names": ["AnimatedEmojies"], "unarchived": 1},
582
+ example_args="sticker set unarchive AnimatedEmojies",
583
+ covers=("sticker.set-archive",),
584
+ )
585
+
586
+
587
+ class SetReorderReq(Request):
588
+ set: Annotated[
589
+ list[str],
590
+ arg(0, metavar="SET", required=False, variadic=True, help="Full desired order."),
591
+ ] = []
592
+ type: Annotated[
593
+ str, choice("sticker", "mask", "emoji", help="Which library's order to write.")
594
+ ] = "sticker"
595
+ top: Annotated[
596
+ str | None, opt("--top", metavar="SET", help="Move just this set to the front.")
597
+ ] = None
598
+
599
+
600
+ async def set_reorder(ctx: OpContext, req: SetReorderReq) -> StickerSetOrder:
601
+ """Write the order of the installed sets.
602
+
603
+ The API writes the **full** order for one library, and a partial vector
604
+ silently drops the sets left out of it. `--top` is therefore
605
+ read-modify-write: fetch the current order, move one id to the front,
606
+ send the whole thing back.
607
+ """
608
+ from telethon.tl.functions import messages as fn
609
+
610
+ current = await set_list(ctx, SetListReq(type=req.type))
611
+ known = {item.short_name.lower(): item.id for item in current.items}
612
+ known.update({str(item.id): item.id for item in current.items})
613
+
614
+ def resolve(value: str) -> int:
615
+ found = known.get(_names([value])[0].lower()) or known.get(str(value))
616
+ if found is None:
617
+ raise NotFoundError(f"{value!r} is not an installed {req.type} set")
618
+ return found
619
+
620
+ if req.top:
621
+ head = resolve(req.top)
622
+ order = [head] + [item.id for item in current.items if item.id != head]
623
+ elif req.set:
624
+ order = [resolve(value) for value in req.set]
625
+ else:
626
+ raise UsageError("give the full order, or --top <set>", field="set")
627
+
628
+ await _client(ctx)(
629
+ fn.ReorderStickerSetsRequest(
630
+ order=order, masks=req.type == "mask" or None, emojis=req.type == "emoji" or None
631
+ )
632
+ )
633
+ return StickerSetOrder(type=req.type, order=order, ok=True)
634
+
635
+
636
+ SPEC_SET_REORDER = OperationSpec(
637
+ id="sticker.set.reorder",
638
+ request=SetReorderReq,
639
+ response=StickerSetOrder,
640
+ impl=set_reorder,
641
+ summary="Set the order of the installed sets",
642
+ aliases=("emoji.set.reorder",),
643
+ mutating=True,
644
+ columns=("type", "order"),
645
+ headers=("Library", "Order"),
646
+ example={"type": "sticker", "order": [1258816259751983, 1258816259751984], "ok": True},
647
+ example_args="sticker set reorder --top AnimatedEmojies",
648
+ covers=("sticker.set-reorder",),
649
+ )
650
+
651
+
652
+ class SetSearchReq(Request):
653
+ query: Annotated[str, arg(0, metavar="QUERY", help="Text to find.")]
654
+ type: Annotated[str, choice("sticker", "emoji", help="Which catalogue.")] = "sticker"
655
+ exclude_featured: Annotated[
656
+ bool, opt("--exclude-featured", help="Skip sets already on the trending shelf.")
657
+ ] = False
658
+
659
+
660
+ async def set_search(ctx: OpContext, req: SetSearchReq) -> Page[StickerSet]:
661
+ """Search public sets by name.
662
+
663
+ Results are set *previews* — metadata plus a couple of cover stickers,
664
+ not the document list. `sticker set get` is what fetches the contents.
665
+ """
666
+ from telethon.tl.functions import messages as fn
667
+
668
+ limit, _state = _media.window(ctx, "sticker.set.search", PageKind.LOCAL)
669
+ request: Any = (
670
+ fn.SearchEmojiStickerSetsRequest(
671
+ q=req.query, hash=0, exclude_featured=req.exclude_featured or None
672
+ )
673
+ if req.type == "emoji"
674
+ else fn.SearchStickerSetsRequest(
675
+ q=req.query, hash=0, exclude_featured=req.exclude_featured or None
676
+ )
677
+ )
678
+ result = await _client(ctx)(request)
679
+ items = [_media.covered_set(covered) for covered in getattr(result, "sets", None) or []]
680
+ return Page(items=items[:limit], has_more=False, total=len(items))
681
+
682
+
683
+ SPEC_SET_SEARCH = OperationSpec(
684
+ id="sticker.set.search",
685
+ request=SetSearchReq,
686
+ response=Page[StickerSet],
687
+ impl=set_search,
688
+ summary="Search public sticker or emoji sets by name",
689
+ aliases=("emoji.set.search",),
690
+ paginated=PageKind.LOCAL,
691
+ columns=("short_name", "title", "count", "official"),
692
+ headers=("Short name", "Title", "Count", "Official"),
693
+ example={"items": [_EXAMPLE_SET], "has_more": False},
694
+ example_args="sticker set search cats",
695
+ covers=("emoji.custom-sets", "sticker.set-search"),
696
+ )
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # sticker search
701
+ # ---------------------------------------------------------------------------
702
+
703
+
704
+ class SearchReq(Request):
705
+ query: Annotated[
706
+ str, arg(0, metavar="QUERY", required=False, help="Free text, or an emoji.")
707
+ ] = ""
708
+ emoji: Annotated[
709
+ list[str], opt("--emoji", metavar="EMOJI", help="Emoji to match (repeatable).")
710
+ ] = []
711
+ custom: Annotated[
712
+ bool, opt("--custom", help="Search custom emoji documents instead of stickers.")
713
+ ] = False
714
+ lang: Annotated[list[str], opt("--lang", metavar="CODE", help="Language codes.")] = []
715
+ source: Annotated[str, choice("server", "installed", "all", help="Where to look.")] = "all"
716
+ special: Annotated[
717
+ str | None, choice("greeting", "premium", help="Fetch a server special list.")
718
+ ] = None
719
+
720
+
721
+ async def search(ctx: OpContext, req: SearchReq) -> Page[Sticker]:
722
+ """Search individual stickers by words or emoji.
723
+
724
+ Two surfaces behind one command: `searchStickers` is the global search
725
+ box, and `getStickers(emoticon)` is the suggestion strip that appears
726
+ when you type an emoji. The app config's
727
+ `stickers_emoji_suggest_only_api` forbids answering emoji suggestions
728
+ purely from local sets, so the server is asked first and the installed
729
+ sets are the fallback.
730
+ """
731
+ from telethon.tl.functions import messages as fn
732
+
733
+ limit, state = _media.window(ctx, "sticker.search", PageKind.LOCAL)
734
+
735
+ if req.special:
736
+ result = await _client(ctx)(
737
+ fn.GetStickersRequest(emoticon=SPECIAL_EMOTICONS[req.special], hash=0)
738
+ )
739
+ items = [_media.sticker_model(doc) for doc in getattr(result, "stickers", None) or []]
740
+ return Page(items=items[:limit], has_more=False, total=len(items))
741
+
742
+ if req.emoji and not req.query:
743
+ items = []
744
+ for emoticon in req.emoji:
745
+ result = await _client(ctx)(fn.GetStickersRequest(emoticon=emoticon, hash=0))
746
+ items.extend(
747
+ _media.sticker_model(doc) for doc in getattr(result, "stickers", None) or []
748
+ )
749
+ if not items and req.source in ("installed", "all"):
750
+ items = await _installed_matches(ctx, req.emoji)
751
+ return Page(items=items[:limit], has_more=False, total=len(items))
752
+
753
+ if req.source == "installed":
754
+ return Page(items=await _installed_matches(ctx, req.emoji), has_more=False)
755
+
756
+ offset = int(state.get("offset", 0))
757
+ result = await _client(ctx)(
758
+ fn.SearchStickersRequest(
759
+ q=req.query,
760
+ emoticon="".join(req.emoji),
761
+ lang_code=list(req.lang) or [],
762
+ offset=offset,
763
+ limit=limit,
764
+ hash=0,
765
+ emojis=req.custom or None,
766
+ )
767
+ )
768
+ items = [_media.sticker_model(doc) for doc in getattr(result, "stickers", None) or []]
769
+ return build_page(
770
+ items,
771
+ op="sticker.search",
772
+ kind=PageKind.LOCAL,
773
+ state={"offset": offset + len(items)},
774
+ account=ctx.account,
775
+ limit=limit,
776
+ total=getattr(result, "count", None),
777
+ )
778
+
779
+
780
+ async def _installed_matches(ctx: OpContext, emojis: list[str]) -> list[Sticker]:
781
+ """The local half of the suggestion strip: installed sets, walked here."""
782
+ installed = await set_list(ctx, SetListReq())
783
+ out: list[Sticker] = []
784
+ for entry in installed.items[:20]:
785
+ if not entry.short_name:
786
+ continue
787
+ detail = _set_detail(await _media.fetch_set(ctx, entry.short_name))
788
+ for sticker in detail.stickers:
789
+ if not emojis or sticker.emoji in emojis:
790
+ out.append(sticker)
791
+ return out
792
+
793
+
794
+ SPEC_SEARCH = OperationSpec(
795
+ id="sticker.search",
796
+ request=SearchReq,
797
+ response=Page[Sticker],
798
+ impl=search,
799
+ summary="Search individual stickers by words or emoji",
800
+ paginated=PageKind.LOCAL,
801
+ columns=("doc_id", "emoji", "set_short_name", "mime"),
802
+ headers=("Doc", "Emoji", "Set", "MIME"),
803
+ example={"items": [_EXAMPLE_STICKER], "has_more": False},
804
+ example_args="sticker search 'happy cat'",
805
+ covers=("sticker.search", "sticker.special-lists", "sticker.suggestion-settings"),
806
+ covers_partial=("emoji.custom-search-by-emoticon",),
807
+ coverage_note="`emoji search --custom` is the custom-emoji half of the same suggestion popup.",
808
+ )
809
+
810
+
811
+ # ---------------------------------------------------------------------------
812
+ # sticker fave
813
+ # ---------------------------------------------------------------------------
814
+
815
+
816
+ class FaveListReq(Request):
817
+ pass
818
+
819
+
820
+ async def fave_list(ctx: OpContext, req: FaveListReq) -> Page[Sticker]:
821
+ """Favourite stickers.
822
+
823
+ The reply also carries the emoji→sticker packs, which is what lets a
824
+ caller resolve `fave:<emoji>` without a second round trip.
825
+ """
826
+ from telethon.tl.functions import messages as fn
827
+
828
+ result = await _client(ctx)(fn.GetFavedStickersRequest(hash=0))
829
+ items = [
830
+ _media.sticker_model(document, index=index)
831
+ for index, document in enumerate(getattr(result, "stickers", None) or [])
832
+ ]
833
+ return Page(items=items, has_more=False, total=len(items))
834
+
835
+
836
+ SPEC_FAVE_LIST = OperationSpec(
837
+ id="sticker.fave.list",
838
+ request=FaveListReq,
839
+ response=Page[Sticker],
840
+ impl=fave_list,
841
+ summary="Favourite stickers",
842
+ columns=("doc_id", "emoji", "set_short_name"),
843
+ headers=("Doc", "Emoji", "Set"),
844
+ example={"items": [_EXAMPLE_STICKER], "has_more": False},
845
+ example_args="sticker fave list",
846
+ covers_partial=("sticker.favorites",),
847
+ coverage_note="Adding and removing are `sticker fave add` and `sticker fave remove`.",
848
+ )
849
+
850
+
851
+ class FaveAddReq(Request):
852
+ sticker: Annotated[
853
+ list[str],
854
+ arg(
855
+ 0,
856
+ metavar="STICKER",
857
+ required=False,
858
+ variadic=True,
859
+ help="<set>/<index> or <set>/<emoji>.",
860
+ ),
861
+ ] = []
862
+ from_message: Annotated[
863
+ str | None,
864
+ opt("--from-message", metavar="CHAT:ID", help="Take the sticker from this message."),
865
+ ] = None
866
+
867
+
868
+ async def _from_message(ctx: OpContext, reference: str | None) -> tuple[Any, int] | None:
869
+ if not reference:
870
+ return None
871
+ peer_text, _, message_id = str(reference).rpartition(":")
872
+ if not peer_text or not message_id.lstrip("-").isdigit():
873
+ raise UsageError("--from-message takes CHAT:ID", field="from_message")
874
+ from tlgr.models.peer import parse_peer_ref
875
+
876
+ return await _send.resolve(ctx, parse_peer_ref(peer_text)), int(message_id)
877
+
878
+
879
+ async def fave_add(ctx: OpContext, req: FaveAddReq) -> FaveResult:
880
+ """Add stickers to favourites.
881
+
882
+ Every `InputDocument` is resolved from the set (or the message) here and
883
+ now: a document id copied out of an old listing carries a dead
884
+ `file_reference` and the call fails with `FILE_REFERENCE_EXPIRED`.
885
+ """
886
+ from telethon.tl.functions import messages as fn
887
+
888
+ documents = await _media.resolve_stickers(
889
+ ctx, list(req.sticker), from_message=await _from_message(ctx, req.from_message)
890
+ )
891
+ before = {item.doc_id for item in (await fave_list(ctx, FaveListReq())).items}
892
+ for document in documents:
893
+ await _client(ctx)(fn.FaveStickerRequest(id=_media.input_document(document), unfave=False))
894
+ ids = [int(getattr(document, "id", 0)) for document in documents]
895
+ after = {item.doc_id for item in (await fave_list(ctx, FaveListReq())).items}
896
+ evicted = sorted(before - after - set(ids))
897
+ if evicted:
898
+ ctx.warn(
899
+ "the favourites limit was reached; the server evicted the oldest entries: "
900
+ + ", ".join(str(i) for i in evicted)
901
+ )
902
+ return FaveResult(
903
+ doc_ids=ids,
904
+ faved=len(ids),
905
+ already=bool(ids) and set(ids) <= before,
906
+ evicted=evicted,
907
+ )
908
+
909
+
910
+ SPEC_FAVE_ADD = OperationSpec(
911
+ id="sticker.fave.add",
912
+ request=FaveAddReq,
913
+ response=FaveResult,
914
+ impl=fave_add,
915
+ summary="Add a sticker to favourites",
916
+ mutating=True,
917
+ idempotent=True,
918
+ columns=("doc_ids", "faved", "evicted"),
919
+ headers=("Docs", "Faved", "Evicted"),
920
+ example={"doc_ids": [5312836234], "faved": 1, "evicted": []},
921
+ example_args="sticker fave add AnimatedEmojies/0",
922
+ covers_partial=("sticker.favorites",),
923
+ coverage_note="Listing is `sticker fave list`; removing is `sticker fave remove`.",
924
+ )
925
+
926
+
927
+ class FaveRemoveReq(Request):
928
+ sticker: Annotated[
929
+ list[str], arg(0, metavar="STICKER", variadic=True, help="<set>/<index> or <set>/<emoji>.")
930
+ ] = []
931
+
932
+
933
+ async def fave_remove(ctx: OpContext, req: FaveRemoveReq) -> FaveResult:
934
+ """Remove stickers from favourites, with the same reference-freshness rule."""
935
+ from telethon.tl.functions import messages as fn
936
+
937
+ documents = await _media.resolve_stickers(ctx, list(req.sticker))
938
+ for document in documents:
939
+ await _client(ctx)(fn.FaveStickerRequest(id=_media.input_document(document), unfave=True))
940
+ ids = [int(getattr(document, "id", 0)) for document in documents]
941
+ return FaveResult(doc_ids=ids, unfaved=len(ids))
942
+
943
+
944
+ SPEC_FAVE_REMOVE = OperationSpec(
945
+ id="sticker.fave.remove",
946
+ request=FaveRemoveReq,
947
+ response=FaveResult,
948
+ impl=fave_remove,
949
+ summary="Remove a sticker from favourites",
950
+ mutating=True,
951
+ idempotent=True,
952
+ columns=("doc_ids", "unfaved"),
953
+ headers=("Docs", "Unfaved"),
954
+ example={"doc_ids": [5312836234], "unfaved": 1},
955
+ example_args="sticker fave remove AnimatedEmojies/0",
956
+ covers=("sticker.favorites",),
957
+ )
958
+
959
+
960
+ # ---------------------------------------------------------------------------
961
+ # sticker recent
962
+ # ---------------------------------------------------------------------------
963
+
964
+
965
+ class RecentListReq(Request):
966
+ masks: Annotated[bool, opt("--masks", help="The recently used masks list instead.")] = False
967
+
968
+
969
+ async def recent_list(ctx: OpContext, req: RecentListReq) -> Page[Sticker]:
970
+ """Recently used stickers — a hash-cached list the server appends to."""
971
+ from telethon.tl.functions import messages as fn
972
+
973
+ result = await _client(ctx)(fn.GetRecentStickersRequest(hash=0, attached=req.masks or None))
974
+ items = [
975
+ _media.sticker_model(document, index=index)
976
+ for index, document in enumerate(getattr(result, "stickers", None) or [])
977
+ ]
978
+ return Page(items=items, has_more=False, total=len(items))
979
+
980
+
981
+ SPEC_RECENT_LIST = OperationSpec(
982
+ id="sticker.recent.list",
983
+ request=RecentListReq,
984
+ response=Page[Sticker],
985
+ impl=recent_list,
986
+ summary="Recently used stickers",
987
+ columns=("doc_id", "emoji", "set_short_name"),
988
+ headers=("Doc", "Emoji", "Set"),
989
+ example={"items": [_EXAMPLE_STICKER], "has_more": False},
990
+ example_args="sticker recent list",
991
+ covers_partial=("sticker.recent",),
992
+ coverage_note="Forgetting one, or clearing the list, is `sticker recent remove`.",
993
+ )
994
+
995
+
996
+ class RecentRemoveReq(Request):
997
+ sticker: Annotated[
998
+ list[str],
999
+ arg(
1000
+ 0,
1001
+ metavar="STICKER",
1002
+ required=False,
1003
+ variadic=True,
1004
+ help="<set>/<index> or <set>/<emoji>.",
1005
+ ),
1006
+ ] = []
1007
+ masks: Annotated[bool, opt("--masks", help="Operate on the recent masks list.")] = False
1008
+ every: Annotated[bool, opt("--all", help="Clear the whole list.")] = False
1009
+
1010
+
1011
+ async def recent_remove(ctx: OpContext, req: RecentRemoveReq) -> RecentResult:
1012
+ """Forget one recent sticker, or clear the list."""
1013
+ from telethon.tl.functions import messages as fn
1014
+
1015
+ if req.every:
1016
+ await _client(ctx)(fn.ClearRecentStickersRequest(attached=req.masks or None))
1017
+ return RecentResult(cleared=True)
1018
+
1019
+ documents = await _media.resolve_stickers(ctx, list(req.sticker))
1020
+ for document in documents:
1021
+ await _client(ctx)(
1022
+ fn.SaveRecentStickerRequest(
1023
+ id=_media.input_document(document), unsave=True, attached=req.masks or None
1024
+ )
1025
+ )
1026
+ ids = [int(getattr(document, "id", 0)) for document in documents]
1027
+ return RecentResult(doc_ids=ids, removed=len(ids))
1028
+
1029
+
1030
+ SPEC_RECENT_REMOVE = OperationSpec(
1031
+ id="sticker.recent.remove",
1032
+ request=RecentRemoveReq,
1033
+ response=RecentResult,
1034
+ impl=recent_remove,
1035
+ summary="Forget one recent sticker, or clear the list",
1036
+ mutating=True,
1037
+ destructive=True,
1038
+ columns=("doc_ids", "removed", "cleared"),
1039
+ headers=("Docs", "Removed", "Cleared"),
1040
+ example={"doc_ids": [5312836234], "removed": 1, "cleared": False},
1041
+ example_args="sticker recent remove AnimatedEmojies/0",
1042
+ covers=("sticker.recent",),
1043
+ )
1044
+
1045
+
1046
+ # ---------------------------------------------------------------------------
1047
+ # sticker pack (the sets you created)
1048
+ # ---------------------------------------------------------------------------
1049
+
1050
+
1051
+ def _mask_coords(value: str | None) -> Any:
1052
+ """`eyes:0.5,0.5,1.2` → `MaskCoords(n, x, y, zoom)`."""
1053
+ if not value:
1054
+ return None
1055
+ from telethon.tl import types
1056
+
1057
+ places = {"forehead": 0, "eyes": 1, "mouth": 2, "chin": 3}
1058
+ where, _, numbers = value.partition(":")
1059
+ if where not in places:
1060
+ raise UsageError(
1061
+ f"--mask: expected one of {', '.join(places)}, optionally with :x,y,zoom",
1062
+ field="mask",
1063
+ )
1064
+ parts = [part for part in numbers.split(",") if part]
1065
+ try:
1066
+ x, y, zoom = (
1067
+ (float(parts[0]), float(parts[1]), float(parts[2])) if parts else (0.5, 0.5, 1.0)
1068
+ )
1069
+ except (IndexError, ValueError) as exc:
1070
+ raise UsageError("--mask coordinates are x,y,zoom", field="mask") from exc
1071
+ return types.MaskCoords(n=places[where], x=x, y=y, zoom=zoom)
1072
+
1073
+
1074
+ async def _sticker_document(ctx: OpContext, path: Path, emoji: str) -> Any:
1075
+ """Upload one sticker file and return the `InputDocument` it became."""
1076
+ from telethon.tl import types
1077
+ from telethon.tl.functions import messages as fn
1078
+
1079
+ upload = getattr(ctx, "upload_file", None)
1080
+ if upload is None: # pragma: no cover - the daemon always supplies one
1081
+ raise UsageError("this context cannot upload files")
1082
+ if not path.exists():
1083
+ raise UsageError(f"{path} does not exist", field="file")
1084
+ handle = await upload(path)
1085
+ mime = {
1086
+ ".webp": "image/webp",
1087
+ ".png": "image/png",
1088
+ ".tgs": "application/x-tgsticker",
1089
+ ".webm": "video/webm",
1090
+ }.get(path.suffix.lower(), "image/webp")
1091
+ result = await _client(ctx)(
1092
+ fn.UploadMediaRequest(
1093
+ peer=types.InputPeerSelf(),
1094
+ media=types.InputMediaUploadedDocument(
1095
+ file=handle,
1096
+ mime_type=mime,
1097
+ attributes=[
1098
+ types.DocumentAttributeSticker(
1099
+ alt=emoji, stickerset=types.InputStickerSetEmpty()
1100
+ )
1101
+ ],
1102
+ ),
1103
+ )
1104
+ )
1105
+ document = _media.document_of(result)
1106
+ if document is None:
1107
+ raise NotFoundError(f"the server did not accept {path.name} as a sticker")
1108
+ return _media.input_document(document)
1109
+
1110
+
1111
+ class PackCreateReq(Request):
1112
+ short_name: Annotated[
1113
+ str | None,
1114
+ arg(0, metavar="SHORT_NAME", required=False, help="URL name; omit to have one suggested."),
1115
+ ] = None
1116
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="Display title.")] = None
1117
+ type: Annotated[str, choice("sticker", "mask", "emoji", help="Pack kind.")] = "sticker"
1118
+ add: Annotated[
1119
+ list[str],
1120
+ opt("--add", metavar="FILE:EMOJI[:kw,kw]", help="A sticker to seed the pack with."),
1121
+ ] = []
1122
+ manifest: Annotated[
1123
+ str | None,
1124
+ opt("--manifest", metavar="PATH", kind="path", help="JSON manifest of files and emoji."),
1125
+ ] = None
1126
+ convert: Annotated[
1127
+ bool, opt("--convert/--no-convert", help="Convert inputs to the required format first.")
1128
+ ] = True
1129
+ thumb: Annotated[
1130
+ str | None, opt("--thumb", metavar="PATH", kind="path", help="Pack thumbnail.")
1131
+ ] = None
1132
+ text_color: Annotated[
1133
+ bool, opt("--text-color", help="Emoji packs: render in the message text colour.")
1134
+ ] = False
1135
+ software: Annotated[str, opt("--software", metavar="NAME", help="Creating-software tag.")] = (
1136
+ "tlgr"
1137
+ )
1138
+
1139
+
1140
+ def _seed_items(req: PackCreateReq) -> list[tuple[Path, str, list[str]]]:
1141
+ """`--add FILE:EMOJI[:kw,kw]` and `--manifest` as one ordered list."""
1142
+ import json
1143
+
1144
+ out: list[tuple[Path, str, list[str]]] = []
1145
+ for entry in req.add:
1146
+ parts = str(entry).split(":")
1147
+ if len(parts) < 2:
1148
+ raise UsageError("--add takes FILE:EMOJI[:kw,kw]", field="add")
1149
+ keywords = parts[2].split(",") if len(parts) > 2 and parts[2] else []
1150
+ out.append((Path(os.path.expanduser(parts[0])), parts[1], keywords))
1151
+ if req.manifest:
1152
+ raw = json.loads(Path(os.path.expanduser(req.manifest)).read_text(encoding="utf-8"))
1153
+ rows = raw.get("stickers", raw) if isinstance(raw, dict) else raw
1154
+ for row in rows:
1155
+ out.append(
1156
+ (
1157
+ Path(os.path.expanduser(str(row["file"]))),
1158
+ str(row.get("emoji") or ""),
1159
+ list(row.get("keywords") or []),
1160
+ )
1161
+ )
1162
+ for path, emoji, _ in out:
1163
+ if not emoji:
1164
+ raise UsageError(
1165
+ f"{path.name}: every sticker must carry at least one emoji", field="add"
1166
+ )
1167
+ return out
1168
+
1169
+
1170
+ async def pack_create(ctx: OpContext, req: PackCreateReq) -> PackCreated:
1171
+ """Create your own sticker, mask or custom-emoji pack.
1172
+
1173
+ Four steps and the order matters: suggest or check the short name, upload
1174
+ each file with `messages.uploadMedia` (bound to `me`) to get an
1175
+ `InputDocument`, then one `createStickerSet` carrying every item.
1176
+ `--manifest` is the third-party import path — a folder of images plus an
1177
+ emoji map becomes exactly the same call, which is why `tg://importStickers`
1178
+ needs no command of its own.
1179
+ """
1180
+ from telethon.tl import types
1181
+ from telethon.tl.functions import stickers as fn
1182
+
1183
+ short_name = req.short_name
1184
+ if not short_name:
1185
+ if not req.title:
1186
+ raise UsageError(
1187
+ "give a short name, or --title so one can be suggested", field="short_name"
1188
+ )
1189
+ suggestion = await _client(ctx)(fn.SuggestShortNameRequest(title=req.title))
1190
+ short_name = str(getattr(suggestion, "short_name", "") or "")
1191
+ available = bool(await _client(ctx)(fn.CheckShortNameRequest(short_name=short_name)))
1192
+ if getattr(ctx, "dry_run", False):
1193
+ return PackCreated(short_name=short_name, title=req.title or "", available=available)
1194
+ if not available:
1195
+ raise UsageError(f"the short name {short_name!r} is taken", field="short_name")
1196
+
1197
+ items = _seed_items(req)
1198
+ if not items:
1199
+ raise UsageError("a new pack needs at least one --add or a --manifest", field="add")
1200
+ if req.convert:
1201
+ ctx.warn(
1202
+ "tlgr uploads the bytes as given: converting to 512px WebP / TGS / VP9 WebM "
1203
+ "needs Pillow or ffmpeg, and the server rejects a wrong format by name"
1204
+ )
1205
+
1206
+ stickers = [
1207
+ types.InputStickerSetItem(
1208
+ document=await _sticker_document(ctx, path, emoji),
1209
+ emoji=emoji,
1210
+ keywords=",".join(keywords) or None,
1211
+ )
1212
+ for path, emoji, keywords in items
1213
+ ]
1214
+ thumb = None
1215
+ if req.thumb:
1216
+ thumb = await _sticker_document(ctx, Path(os.path.expanduser(req.thumb)), items[0][1])
1217
+
1218
+ result = await _client(ctx)(
1219
+ fn.CreateStickerSetRequest(
1220
+ user_id=types.InputUserSelf(),
1221
+ title=req.title or short_name,
1222
+ short_name=short_name,
1223
+ stickers=stickers,
1224
+ masks=req.type == "mask" or None,
1225
+ emojis=req.type == "emoji" or None,
1226
+ text_color=req.text_color or None,
1227
+ thumb=thumb,
1228
+ software=req.software or None,
1229
+ )
1230
+ )
1231
+ detail = _set_detail(result)
1232
+ return PackCreated(
1233
+ id=detail.id,
1234
+ short_name=detail.short_name or short_name,
1235
+ title=detail.title,
1236
+ type=req.type,
1237
+ count=detail.count or len(stickers),
1238
+ link=_media.set_link(detail.short_name or short_name, req.type),
1239
+ stickers=detail.stickers,
1240
+ available=True,
1241
+ )
1242
+
1243
+
1244
+ SPEC_PACK_CREATE = OperationSpec(
1245
+ id="sticker.pack.create",
1246
+ request=PackCreateReq,
1247
+ response=PackCreated,
1248
+ impl=pack_create,
1249
+ summary="Create your own sticker, mask or custom-emoji pack",
1250
+ mutating=True,
1251
+ rate_class="file",
1252
+ timeout_s=300,
1253
+ columns=("short_name", "title", "count", "link"),
1254
+ headers=("Short name", "Title", "Count", "Link"),
1255
+ example={
1256
+ "id": 1258816259751990,
1257
+ "short_name": "my_cats_by_tlgr",
1258
+ "title": "My cats",
1259
+ "type": "sticker",
1260
+ "count": 2,
1261
+ "link": "https://t.me/addstickers/my_cats_by_tlgr",
1262
+ },
1263
+ example_args="sticker pack create my_cats_by_tlgr --title 'My cats' --add cat.webp:🐱",
1264
+ covers=("sticker.import-third-party", "sticker.set-create"),
1265
+ covers_partial=("sticker.create-from-image",),
1266
+ coverage_note=(
1267
+ "Converting an arbitrary image to the required format needs Pillow/ffmpeg; tlgr "
1268
+ "uploads the bytes as given and says so rather than writing a file that is not a sticker."
1269
+ ),
1270
+ )
1271
+
1272
+
1273
+ class PackAddReq(Request):
1274
+ pack: Annotated[str, arg(0, metavar="PACK", help="A pack you created.")]
1275
+ file: Annotated[
1276
+ str | None, arg(1, metavar="FILE", required=False, kind="path", help="Sticker file.")
1277
+ ] = None
1278
+ emoji: Annotated[
1279
+ str | None, opt("--emoji", metavar="EMOJI", help="Emoji for the new sticker.")
1280
+ ] = None
1281
+ keywords: Annotated[list[str], opt("--keywords", metavar="WORD", help="Search keywords.")] = []
1282
+ mask: Annotated[
1283
+ str | None, opt("--mask", metavar="PLACE[:x,y,zoom]", help="Mask placement.")
1284
+ ] = None
1285
+ position: Annotated[
1286
+ int | None, opt("--position", metavar="N", help="Insert at this 0-based position.")
1287
+ ] = None
1288
+ replace: Annotated[
1289
+ str | None, opt("--replace", metavar="STICKER", help="Replace this sticker instead.")
1290
+ ] = None
1291
+ file_id: Annotated[
1292
+ str | None, opt("--file-id", metavar="ID", help="Use media already on Telegram.")
1293
+ ] = None
1294
+
1295
+
1296
+ async def pack_add(ctx: OpContext, req: PackAddReq) -> PackStickerAdded:
1297
+ """Add or replace a sticker in a pack you created.
1298
+
1299
+ `addStickerToSet` always appends, so `--position` is a second call; and
1300
+ `--replace` keeps the pack's ordering, which is what the GUI's "replace
1301
+ sticker" does.
1302
+ """
1303
+ from telethon.tl import types
1304
+ from telethon.tl.functions import stickers as fn
1305
+
1306
+ if not req.emoji:
1307
+ raise UsageError("--emoji is required: every sticker carries at least one", field="emoji")
1308
+ if req.file_id:
1309
+ from telethon import utils
1310
+
1311
+ resolved = utils.resolve_bot_file_id(req.file_id)
1312
+ if resolved is None:
1313
+ raise UsageError("--file-id: that is not a Telegram file id", field="file_id")
1314
+ document = _media.input_document(resolved)
1315
+ elif req.file:
1316
+ document = await _sticker_document(ctx, Path(os.path.expanduser(req.file)), req.emoji)
1317
+ else:
1318
+ raise UsageError("give a file, or --file-id", field="file")
1319
+
1320
+ item = types.InputStickerSetItem(
1321
+ document=document,
1322
+ emoji=req.emoji,
1323
+ keywords=",".join(req.keywords) or None,
1324
+ mask_coords=_mask_coords(req.mask),
1325
+ )
1326
+ replaced: int | None = None
1327
+ if req.replace:
1328
+ old = (await _media.resolve_stickers(ctx, [req.replace]))[0]
1329
+ replaced = int(getattr(old, "id", 0))
1330
+ result = await _client(ctx)(
1331
+ fn.ReplaceStickerRequest(sticker=_media.input_document(old), new_sticker=item)
1332
+ )
1333
+ else:
1334
+ result = await _client(ctx)(
1335
+ fn.AddStickerToSetRequest(
1336
+ stickerset=_media.sticker_set_ref(req.pack, field="pack"), sticker=item
1337
+ )
1338
+ )
1339
+ detail = _set_detail(result)
1340
+ if req.position is not None:
1341
+ await _client(ctx)(fn.ChangeStickerPositionRequest(sticker=document, position=req.position))
1342
+ return PackStickerAdded(
1343
+ short_name=detail.short_name or _names([req.pack])[0],
1344
+ doc_id=int(getattr(document, "id", 0)),
1345
+ position=req.position,
1346
+ count=detail.count or len(detail.stickers),
1347
+ replaced=replaced,
1348
+ )
1349
+
1350
+
1351
+ SPEC_PACK_ADD = OperationSpec(
1352
+ id="sticker.pack.add",
1353
+ request=PackAddReq,
1354
+ response=PackStickerAdded,
1355
+ impl=pack_add,
1356
+ summary="Add or replace a sticker in a pack you created",
1357
+ mutating=True,
1358
+ rate_class="file",
1359
+ timeout_s=300,
1360
+ columns=("short_name", "doc_id", "count"),
1361
+ headers=("Pack", "Doc", "Count"),
1362
+ example={"short_name": "my_cats_by_tlgr", "doc_id": 5312836299, "count": 3},
1363
+ example_args="sticker pack add my_cats_by_tlgr cat2.webp --emoji 🐱",
1364
+ covers=("sticker.create-from-image",),
1365
+ covers_partial=("sticker.set-edit-stickers",),
1366
+ coverage_note="Removing one is `sticker pack remove`; reordering is `sticker pack edit`.",
1367
+ )
1368
+
1369
+
1370
+ class PackRemoveReq(Request):
1371
+ pack: Annotated[str, arg(0, metavar="PACK", help="A pack you created.")]
1372
+ sticker: Annotated[
1373
+ list[str], arg(1, metavar="STICKER", variadic=True, help="Index, emoji or <set>/<index>.")
1374
+ ] = []
1375
+
1376
+
1377
+ async def pack_remove(ctx: OpContext, req: PackRemoveReq) -> PackStickerRemoved:
1378
+ """Remove stickers from a pack you created.
1379
+
1380
+ Removing the last sticker deletes the set server-side, which is worth
1381
+ knowing before you do it.
1382
+ """
1383
+ from telethon.tl.functions import stickers as fn
1384
+
1385
+ refs = [value if "/" in str(value) else f"{req.pack}/{value}" for value in req.sticker]
1386
+ documents = await _media.resolve_stickers(ctx, refs)
1387
+ for document in documents:
1388
+ await _client(ctx)(fn.RemoveStickerFromSetRequest(sticker=_media.input_document(document)))
1389
+ remaining = _set_detail(await _media.fetch_set(ctx, req.pack, field="pack"))
1390
+ return PackStickerRemoved(
1391
+ short_name=remaining.short_name or _names([req.pack])[0],
1392
+ removed=[int(getattr(document, "id", 0)) for document in documents],
1393
+ count=remaining.count or len(remaining.stickers),
1394
+ )
1395
+
1396
+
1397
+ SPEC_PACK_REMOVE = OperationSpec(
1398
+ id="sticker.pack.remove",
1399
+ request=PackRemoveReq,
1400
+ response=PackStickerRemoved,
1401
+ impl=pack_remove,
1402
+ summary="Remove a sticker from a pack you created",
1403
+ mutating=True,
1404
+ destructive=True,
1405
+ columns=("short_name", "removed", "count"),
1406
+ headers=("Pack", "Removed", "Left"),
1407
+ example={"short_name": "my_cats_by_tlgr", "removed": [5312836299], "count": 2},
1408
+ example_args="sticker pack remove my_cats_by_tlgr 0",
1409
+ covers=("sticker.set-edit-stickers",),
1410
+ )
1411
+
1412
+
1413
+ class PackEditReq(Request):
1414
+ pack: Annotated[str, arg(0, metavar="PACK", help="A pack you created.")]
1415
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="New display title.")] = None
1416
+ thumb: Annotated[
1417
+ str | None, opt("--thumb", metavar="PATH", kind="path", help="New pack thumbnail.")
1418
+ ] = None
1419
+ thumb_sticker: Annotated[
1420
+ str | None, opt("--thumb-sticker", metavar="STICKER", help="Use a pack sticker as thumb.")
1421
+ ] = None
1422
+ no_thumb: Annotated[bool, opt("--no-thumb", help="Drop the custom thumbnail.")] = False
1423
+ sticker: Annotated[
1424
+ str | None, opt("--sticker", metavar="STICKER", help="Select one sticker in the pack.")
1425
+ ] = None
1426
+ emoji: Annotated[
1427
+ str | None, opt("--emoji", metavar="EMOJI", help="New emoji for --sticker.")
1428
+ ] = None
1429
+ keywords: Annotated[
1430
+ list[str], opt("--keywords", metavar="WORD", help="New keywords for --sticker.")
1431
+ ] = []
1432
+ mask: Annotated[
1433
+ str | None, opt("--mask", metavar="PLACE[:x,y,zoom]", help="Mask placement for --sticker.")
1434
+ ] = None
1435
+ position: Annotated[
1436
+ int | None, opt("--position", metavar="N", help="Move --sticker to this position.")
1437
+ ] = None
1438
+
1439
+
1440
+ async def pack_edit(ctx: OpContext, req: PackEditReq) -> PackEdited:
1441
+ """The whole "edit pack" dialog, in one command.
1442
+
1443
+ Without `--sticker` the flags act on the set (rename, thumbnail); with it
1444
+ they act on that one document. There is no bulk reorder in the API, so a
1445
+ full re-order is N position writes — which is why `--position` moves one
1446
+ sticker rather than taking a list.
1447
+ """
1448
+ from telethon.tl.functions import stickers as fn
1449
+
1450
+ reference = _media.sticker_set_ref(req.pack, field="pack")
1451
+ changed: list[str] = []
1452
+ edited = PackEdited(short_name=_names([req.pack])[0])
1453
+
1454
+ if req.title:
1455
+ result = await _client(ctx)(
1456
+ fn.RenameStickerSetRequest(stickerset=reference, title=req.title)
1457
+ )
1458
+ edited.title = _set_detail(result).title
1459
+ changed.append("title")
1460
+
1461
+ if req.thumb or req.thumb_sticker or req.no_thumb:
1462
+ thumb = None
1463
+ document_id = None
1464
+ if req.thumb:
1465
+ thumb = await _sticker_document(ctx, Path(os.path.expanduser(req.thumb)), "🖼")
1466
+ elif req.thumb_sticker:
1467
+ picked = (await _media.resolve_stickers(ctx, [req.thumb_sticker]))[0]
1468
+ document_id = int(getattr(picked, "id", 0))
1469
+ await _client(ctx)(
1470
+ fn.SetStickerSetThumbRequest(
1471
+ stickerset=reference, thumb=thumb, thumb_document_id=document_id
1472
+ )
1473
+ )
1474
+ edited.thumb = MediaFile(doc_id=document_id or 0) if not req.no_thumb else None
1475
+ changed.append("thumb")
1476
+
1477
+ if req.sticker:
1478
+ picked = (
1479
+ await _media.resolve_stickers(
1480
+ ctx, [f"{req.pack}/{req.sticker}" if "/" not in req.sticker else req.sticker]
1481
+ )
1482
+ )[0]
1483
+ document = _media.input_document(picked)
1484
+ edited.sticker = int(getattr(picked, "id", 0))
1485
+ if req.emoji or req.keywords or req.mask:
1486
+ await _client(ctx)(
1487
+ fn.ChangeStickerRequest(
1488
+ sticker=document,
1489
+ emoji=req.emoji,
1490
+ keywords=",".join(req.keywords) or None,
1491
+ mask_coords=_mask_coords(req.mask),
1492
+ )
1493
+ )
1494
+ changed.append("sticker")
1495
+ if req.position is not None:
1496
+ await _client(ctx)(
1497
+ fn.ChangeStickerPositionRequest(sticker=document, position=req.position)
1498
+ )
1499
+ changed.append("position")
1500
+
1501
+ if not changed:
1502
+ raise UsageError("nothing to change; give --title, --thumb or --sticker", field="title")
1503
+ edited.changed = changed
1504
+ return edited
1505
+
1506
+
1507
+ SPEC_PACK_EDIT = OperationSpec(
1508
+ id="sticker.pack.edit",
1509
+ request=PackEditReq,
1510
+ response=PackEdited,
1511
+ impl=pack_edit,
1512
+ summary="Change a pack's title or thumbnail, or one sticker's emoji, keywords or position",
1513
+ mutating=True,
1514
+ columns=("short_name", "title", "changed"),
1515
+ headers=("Pack", "Title", "Changed"),
1516
+ example={"short_name": "my_cats_by_tlgr", "title": "My cats", "changed": ["title"]},
1517
+ example_args="sticker pack edit my_cats_by_tlgr --title 'My cats'",
1518
+ covers_partial=("sticker.set-edit-meta", "sticker.set-edit-stickers"),
1519
+ coverage_note="Listing the packs you own is `sticker pack list`; deleting one is `sticker pack delete`.",
1520
+ )
1521
+
1522
+
1523
+ class PackDeleteReq(Request):
1524
+ pack: Annotated[str, arg(0, metavar="PACK", help="A pack you created.")]
1525
+
1526
+
1527
+ async def pack_delete(ctx: OpContext, req: PackDeleteReq) -> PackDeleted:
1528
+ """Delete a pack you created.
1529
+
1530
+ Irreversible and global: everyone who installed it loses it, and stickers
1531
+ already sent stop resolving to a set.
1532
+ """
1533
+ from telethon.tl.functions import stickers as fn
1534
+
1535
+ ok = await _client(ctx)(
1536
+ fn.DeleteStickerSetRequest(stickerset=_media.sticker_set_ref(req.pack, field="pack"))
1537
+ )
1538
+ return PackDeleted(short_name=_names([req.pack])[0], deleted=bool(ok))
1539
+
1540
+
1541
+ SPEC_PACK_DELETE = OperationSpec(
1542
+ id="sticker.pack.delete",
1543
+ request=PackDeleteReq,
1544
+ response=PackDeleted,
1545
+ impl=pack_delete,
1546
+ summary="Delete a pack you created",
1547
+ mutating=True,
1548
+ destructive=True,
1549
+ columns=("short_name", "deleted"),
1550
+ headers=("Pack", "Deleted"),
1551
+ example={"short_name": "my_cats_by_tlgr", "deleted": True},
1552
+ example_args="sticker pack delete my_cats_by_tlgr",
1553
+ covers_partial=("sticker.set-edit-meta",),
1554
+ coverage_note="Renaming and re-thumbnailing are `sticker pack edit`.",
1555
+ )
1556
+
1557
+
1558
+ class PackListReq(Request):
1559
+ pass
1560
+
1561
+
1562
+ async def pack_list(ctx: OpContext, req: PackListReq) -> Page[StickerSet]:
1563
+ """The sets you created.
1564
+
1565
+ Genuinely offset-paginated, unlike the installed lists: only sets with
1566
+ the creator flag appear here, and only those accept the `stickers.*`
1567
+ editing calls.
1568
+ """
1569
+ from telethon.tl.functions import messages as fn
1570
+
1571
+ limit, state = _media.window(ctx, "sticker.pack.list", PageKind.LOCAL)
1572
+ result = await _client(ctx)(
1573
+ fn.GetMyStickersRequest(offset_id=int(state.get("offset_id", 0)), limit=limit)
1574
+ )
1575
+ items = [_media.covered_set(covered) for covered in getattr(result, "sets", None) or []]
1576
+ for item in items:
1577
+ item.creator = True
1578
+ next_state = {"offset_id": items[-1].id} if items else {}
1579
+ return build_page(
1580
+ items,
1581
+ op="sticker.pack.list",
1582
+ kind=PageKind.LOCAL,
1583
+ state=next_state,
1584
+ account=ctx.account,
1585
+ limit=limit,
1586
+ total=getattr(result, "count", None),
1587
+ )
1588
+
1589
+
1590
+ SPEC_PACK_LIST = OperationSpec(
1591
+ id="sticker.pack.list",
1592
+ request=PackListReq,
1593
+ response=Page[StickerSet],
1594
+ impl=pack_list,
1595
+ summary="Sticker sets you created",
1596
+ paginated=PageKind.LOCAL,
1597
+ columns=("short_name", "title", "count", "link"),
1598
+ headers=("Short name", "Title", "Count", "Link"),
1599
+ example={"items": [{**_EXAMPLE_SET, "creator": True}], "has_more": False},
1600
+ example_args="sticker pack list",
1601
+ covers=("sticker.set-edit-meta",),
1602
+ )