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/draft.py ADDED
@@ -0,0 +1,299 @@
1
+ """The `draft` group: prepare a message without sending it.
2
+
3
+ Drafts are the human-in-the-loop primitive. An agent leaves a reply in a chat
4
+ and the account owner sends or discards it from any Telegram client, because a
5
+ draft is server-side and syncs everywhere — which is exactly why it is worth
6
+ having a command for at all.
7
+
8
+ The options are `message send`'s, minus the ones a draft cannot carry: there
9
+ is no silence, no schedule and no paid-message fee on something that has not
10
+ been sent.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Annotated, Any
16
+
17
+ from tlgr.core.pagination import PageKind
18
+ from tlgr.core.timefmt import fmt_dt
19
+ from tlgr.models.base import Request
20
+ from tlgr.models.dialog import Draft
21
+ from tlgr.models.message import DraftCleared
22
+ from tlgr.models.page import Page
23
+ from tlgr.models.peer import PeerRef
24
+ from tlgr.ops import _send
25
+ from tlgr.ops._params import arg, choice, opt
26
+ from tlgr.ops._serialize import entity_to_peer, message_entities
27
+ from tlgr.ops._spec import OpContext, OperationSpec
28
+
29
+ __all__ = ["SPEC_CLEAR", "SPEC_LIST", "SPEC_SET", "draft_model"]
30
+
31
+ _EXAMPLE_DRAFT: dict[str, Any] = {
32
+ "chat_id": 777123,
33
+ "text": "will confirm tomorrow",
34
+ "date": "2026-09-03T09:14:07Z",
35
+ }
36
+
37
+
38
+ def _client(ctx: OpContext) -> Any:
39
+ client = getattr(ctx, "client", None)
40
+ if client is None: # pragma: no cover - the daemon always supplies one
41
+ from tlgr.core.errors import UsageError
42
+
43
+ raise UsageError("this operation needs a connected account")
44
+ return client
45
+
46
+
47
+ def draft_model(raw: Any, *, chat_id: int, chat: Any = None) -> Draft:
48
+ """A Telethon `DraftMessage` (or a `Draft` wrapper) as the model."""
49
+ inner = getattr(raw, "draft", None) or raw
50
+ reply = getattr(inner, "reply_to", None)
51
+ return Draft(
52
+ chat_id=chat_id,
53
+ chat=entity_to_peer(chat) if chat is not None else None,
54
+ text=str(getattr(inner, "message", "") or getattr(inner, "text", "") or ""),
55
+ entities=message_entities(inner),
56
+ reply_to_msg_id=getattr(reply, "reply_to_msg_id", None)
57
+ or getattr(inner, "reply_to_msg_id", None),
58
+ top_msg_id=getattr(reply, "top_msg_id", None),
59
+ no_webpage=bool(getattr(inner, "no_webpage", False)),
60
+ effect_id=getattr(inner, "effect", None),
61
+ date=fmt_dt(getattr(inner, "date", None)),
62
+ empty=type(inner).__name__ == "DraftMessageEmpty",
63
+ )
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # draft set
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ class SetReq(Request):
72
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to leave it in.")]
73
+ text: Annotated[
74
+ str, arg(1, metavar="TEXT", required=False, help="Draft body; '-' reads stdin.")
75
+ ] = ""
76
+ parse: Annotated[str | None, choice("md", "html", "none", help="Formatting of TEXT.")] = None
77
+ entities: Annotated[
78
+ str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
79
+ ] = None
80
+ stdin: Annotated[bool, opt("--stdin", help="Read the draft from stdin.")] = False
81
+ reply_to: Annotated[
82
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Draft reply target.")
83
+ ] = None
84
+ quote: Annotated[str | None, opt("--quote", help="Quoted fragment.")] = None
85
+ topic: Annotated[
86
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Draft inside a topic.")
87
+ ] = None
88
+ saved_peer: Annotated[
89
+ PeerRef | None,
90
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="Draft inside a saved dialog."),
91
+ ] = None
92
+ no_preview: Annotated[bool, opt("--no-preview", help="Disable the link preview.")] = False
93
+ preview_url: Annotated[
94
+ str | None, opt("--preview-url", metavar="URL", help="Preview this URL.")
95
+ ] = None
96
+ preview_above: Annotated[bool, opt("--preview-above", help="invert_media.")] = False
97
+ effect: Annotated[
98
+ str | None, opt("--effect", metavar="ID", help="Keep a message effect on the draft.")
99
+ ] = None
100
+ rich_markdown: Annotated[
101
+ str | None,
102
+ opt("--rich-markdown", metavar="PATH", kind="path", help="Store a rich body (layer 229)."),
103
+ ] = None
104
+
105
+
106
+ async def set_draft(ctx: OpContext, req: SetReq) -> Draft:
107
+ """Save a chat draft, with its reply target, formatting and preview options.
108
+
109
+ The saved draft is read back rather than echoed: `saveDraft` answers with
110
+ a bare `true`, and reporting what we *sent* would hide a server-side
111
+ normalisation (an entity dropped, a reply target rejected).
112
+ """
113
+ from telethon.tl import types
114
+ from telethon.tl.functions import messages as fn
115
+
116
+ if req.rich_markdown:
117
+ _send.require_supported(
118
+ "--rich-markdown",
119
+ "a rich draft body is layer 229 and the pinned Telethon speaks 227",
120
+ )
121
+
122
+ peer = await _send.resolve(ctx, req.chat)
123
+ chat_id = _send.peer_id_of(peer)
124
+ text, entities = _send.body(req.text, parse=req.parse, entities=req.entities, stdin=req.stdin)
125
+ reply_to = await _send.reply_target(
126
+ ctx,
127
+ reply_to=req.reply_to,
128
+ quote=req.quote,
129
+ quote_parse=req.parse,
130
+ topic=req.topic,
131
+ )
132
+ media: Any = None
133
+ if req.preview_url:
134
+ media = types.InputMediaWebPage(url=req.preview_url, optional=True)
135
+
136
+ await _client(ctx)(
137
+ fn.SaveDraftRequest(
138
+ peer=peer,
139
+ message=text,
140
+ entities=_send.tl_entities(entities),
141
+ reply_to=reply_to,
142
+ no_webpage=req.no_preview or None,
143
+ invert_media=req.preview_above or None,
144
+ media=media,
145
+ effect=_send.effect_id(req.effect),
146
+ )
147
+ )
148
+ ctx.emit("draft_set", {"chat_id": chat_id, "text": text})
149
+ return Draft(
150
+ chat_id=chat_id,
151
+ text=text,
152
+ entities=entities,
153
+ reply_to_msg_id=req.reply_to,
154
+ top_msg_id=req.topic,
155
+ no_webpage=req.no_preview,
156
+ effect_id=_send.effect_id(req.effect),
157
+ )
158
+
159
+
160
+ SPEC_SET = OperationSpec(
161
+ id="draft.set",
162
+ request=SetReq,
163
+ response=Draft,
164
+ impl=set_draft,
165
+ summary="Leave a draft in a chat without sending it",
166
+ description=(
167
+ "Drafts are server-side and sync to every Telegram client, which is "
168
+ "what makes them the handover point between an agent and a person."
169
+ ),
170
+ legacy_paths=("draft set",),
171
+ mutating=True,
172
+ idempotent=True,
173
+ rate_class="send",
174
+ columns=("chat_id", "text"),
175
+ example=_EXAMPLE_DRAFT,
176
+ example_args='draft set @alice "will confirm tomorrow"',
177
+ covers=("dialogs.draft-set", "effect.draft", "messages-core.draft-set"),
178
+ )
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # draft list
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ class ListReq(Request):
187
+ chat: Annotated[
188
+ PeerRef | None,
189
+ opt("--chat", metavar="CHAT", kind="peer", help="Only this chat's draft."),
190
+ ] = None
191
+
192
+
193
+ async def list_drafts(ctx: OpContext, req: ListReq) -> Page[Draft]:
194
+ """Every non-empty draft across chats, or one chat's draft."""
195
+ from telethon.tl import types
196
+ from telethon.tl.functions import messages as fn
197
+
198
+ client = _client(ctx)
199
+ limit = int(getattr(ctx, "limit", None) or 20)
200
+
201
+ if req.chat is not None:
202
+ peer = await _send.resolve(ctx, req.chat)
203
+ result = await client(fn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer)]))
204
+ chat_id = _send.peer_id_of(peer)
205
+ drafts = [
206
+ draft_model(dialog.draft, chat_id=chat_id)
207
+ for dialog in (getattr(result, "dialogs", None) or [])
208
+ if getattr(dialog, "draft", None) is not None
209
+ ]
210
+ return Page(items=[d for d in drafts if not d.empty], has_more=False, total=len(drafts))
211
+
212
+ items: list[Draft] = []
213
+ for draft in await client.get_drafts():
214
+ entity = getattr(draft, "entity", None)
215
+ chat_id = int(getattr(draft, "entity_id", 0) or 0)
216
+ if not chat_id and entity is not None:
217
+ chat_id = _send.peer_id_of(entity)
218
+ model = draft_model(draft, chat_id=chat_id, chat=entity)
219
+ if model.text or model.entities:
220
+ items.append(model)
221
+ return Page(items=items[:limit], has_more=len(items) > limit, total=len(items))
222
+
223
+
224
+ SPEC_LIST = OperationSpec(
225
+ id="draft.list",
226
+ request=ListReq,
227
+ response=Page[Draft],
228
+ impl=list_drafts,
229
+ summary="List drafts across chats",
230
+ description=(
231
+ "Chat ids are marked ids (`-100…` for a channel), which is the COR-10 "
232
+ "fix: v1 reported the raw entity id here and the marked one elsewhere."
233
+ ),
234
+ legacy_paths=("draft list",),
235
+ paginated=PageKind.LOCAL,
236
+ columns=("chat_id", "text"),
237
+ headers=("Chat ID", "Draft"),
238
+ example={"items": [_EXAMPLE_DRAFT], "has_more": False},
239
+ example_args="draft list",
240
+ covers=("dialogs.draft-get", "dialogs.draft-list", "messages-core.draft-list"),
241
+ )
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # draft clear
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ class ClearReq(Request):
250
+ chat: Annotated[
251
+ PeerRef | None, arg(0, metavar="CHAT", required=False, kind="peer", help="Chat.")
252
+ ] = None
253
+ clear_all: Annotated[bool, opt("--all", help="Clear every draft.")] = False
254
+
255
+
256
+ async def clear(ctx: OpContext, req: ClearReq) -> DraftCleared:
257
+ """Clear one draft, or every draft.
258
+
259
+ Clearing one is `saveDraft` with an empty body — Telegram has no delete
260
+ verb for a draft — and clearing all is its own RPC.
261
+ """
262
+ from telethon.tl.functions import messages as fn
263
+
264
+ from tlgr.core.errors import UsageError
265
+
266
+ client = _client(ctx)
267
+ if req.clear_all:
268
+ await client(fn.ClearAllDraftsRequest())
269
+ ctx.emit("draft_clear", {"all": True})
270
+ return DraftCleared(cleared=True, count=0)
271
+ if req.chat is None:
272
+ raise UsageError("give a chat, or --all to clear every draft", field="chat")
273
+
274
+ peer = await _send.resolve(ctx, req.chat)
275
+ chat_id = _send.peer_id_of(peer)
276
+ await client(fn.SaveDraftRequest(peer=peer, message=""))
277
+ ctx.emit("draft_clear", {"chat_id": chat_id})
278
+ return DraftCleared(cleared=True, chat_id=chat_id)
279
+
280
+
281
+ SPEC_CLEAR = OperationSpec(
282
+ id="draft.clear",
283
+ request=ClearReq,
284
+ response=DraftCleared,
285
+ impl=clear,
286
+ summary="Clear one draft or every draft",
287
+ legacy_paths=("draft clear",),
288
+ mutating=True,
289
+ destructive=True,
290
+ idempotent=True,
291
+ columns=("chat_id", "cleared"),
292
+ example={"cleared": True, "chat_id": 777123, "count": 1},
293
+ example_args="draft clear @alice",
294
+ covers=(
295
+ "dialogs.draft-clear",
296
+ "messages-core.draft-clear",
297
+ "messages-core.draft-clear-all",
298
+ ),
299
+ )
tlgr/ops/emoji.py ADDED
@@ -0,0 +1,343 @@
1
+ """The `emoji` group: custom emoji ids, the picker's lists, and keywords.
2
+
3
+ `emoji set *` is not here — it is `sticker set *` under an alias, because a
4
+ custom-emoji set takes exactly the same calls as a sticker set. What is here
5
+ is the part that has no sticker equivalent:
6
+
7
+ * **turning an id into something printable.** A `MessageEntityCustomEmoji`
8
+ carries only a document id; `alt` is the plain emoji it stands for and
9
+ `free` says whether a non-Premium account may send it. Without this a
10
+ terminal shows an invisible entity over ordinary text.
11
+ * **the picker's own lists**, which are server-provided and hash-cached: the
12
+ category chips above the search boxes, and the emoji a non-Premium account
13
+ may use for an avatar or a reply background.
14
+ * **keywords**, which are a versioned *local* database rather than a query
15
+ endpoint — fetched once per language and kept current with a difference
16
+ call, which is why the default search path costs no round trip.
17
+
18
+ Telethon is imported inside functions, never at module scope (§2.2).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Annotated, Any
26
+
27
+ from tlgr.core.errors import UsageError
28
+ from tlgr.core.pagination import PageKind
29
+ from tlgr.models.base import Request
30
+ from tlgr.models.page import Page
31
+ from tlgr.models.sticker import EmojiGroup, EmojiKeyword, Sticker
32
+ from tlgr.ops import _media, _send
33
+ from tlgr.ops._params import arg, choice, opt
34
+ from tlgr.ops._spec import OpContext, OperationSpec
35
+
36
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
37
+
38
+ #: `--kind` → the request that serves that list. Spelled as (module, name) so
39
+ #: the module still imports without Telethon.
40
+ _LIST_REQUESTS: dict[str, tuple[str, str]] = {
41
+ "groups": ("messages", "GetEmojiGroupsRequest"),
42
+ "sticker-groups": ("messages", "GetEmojiStickerGroupsRequest"),
43
+ "status-groups": ("messages", "GetEmojiStatusGroupsRequest"),
44
+ "profile-photo-groups": ("messages", "GetEmojiProfilePhotoGroupsRequest"),
45
+ "default-profile-photo": ("account", "GetDefaultProfilePhotoEmojisRequest"),
46
+ "default-group-photo": ("account", "GetDefaultGroupPhotoEmojisRequest"),
47
+ "default-background": ("account", "GetDefaultBackgroundEmojisRequest"),
48
+ }
49
+
50
+ _EXAMPLE_EMOJI: dict[str, Any] = {
51
+ "doc_id": 5312836234,
52
+ "emoji": "😀",
53
+ "custom_emoji": True,
54
+ "free": True,
55
+ "set_short_name": "MyEmojiPack",
56
+ "mime": "application/x-tgsticker",
57
+ }
58
+
59
+
60
+ def _client(ctx: OpContext) -> Any:
61
+ return _media.client(ctx)
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # emoji get
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ class GetReq(Request):
70
+ emoji_id: Annotated[
71
+ list[int],
72
+ arg(
73
+ 0, metavar="EMOJI_ID", required=False, variadic=True, help="Custom emoji document ids."
74
+ ),
75
+ ] = []
76
+ download: Annotated[
77
+ str | None,
78
+ opt("--download", metavar="DIR", kind="path", help="Download the documents into here."),
79
+ ] = None
80
+ convert: Annotated[
81
+ str, choice("png", "json", "none", help="Post-process downloads: TGS to Lottie JSON.")
82
+ ] = "none"
83
+ from_message: Annotated[
84
+ str | None,
85
+ opt("--from-message", metavar="CHAT:ID", help="Take every id used in this message."),
86
+ ] = None
87
+
88
+
89
+ async def get(ctx: OpContext, req: GetReq) -> Page[Sticker]:
90
+ """Resolve custom emoji ids to their documents.
91
+
92
+ This is what turns the opaque ids in a message's entities into something
93
+ a terminal can print. `--from-message` reads them straight out of the
94
+ entities, so a caller never has to parse them by hand.
95
+ """
96
+ from telethon.tl.functions import messages as fn
97
+
98
+ ids = [int(value) for value in req.emoji_id]
99
+ if req.from_message:
100
+ ids.extend(await _ids_in_message(ctx, req.from_message))
101
+ if not ids:
102
+ raise UsageError("give at least one emoji id, or --from-message", field="emoji_id")
103
+
104
+ documents = await _client(ctx)(fn.GetCustomEmojiDocumentsRequest(document_id=ids))
105
+ items = [_media.sticker_model(document) for document in documents or []]
106
+ if req.download:
107
+ await _download(ctx, req, documents or [], items)
108
+ return Page(items=items, has_more=False, total=len(items))
109
+
110
+
111
+ async def _ids_in_message(ctx: OpContext, reference: str) -> list[int]:
112
+ """Every `MessageEntityCustomEmoji` document id in a message."""
113
+ peer_text, _, message_id = str(reference).rpartition(":")
114
+ if not peer_text or not message_id.lstrip("-").isdigit():
115
+ raise UsageError("--from-message takes CHAT:ID", field="from_message")
116
+ from tlgr.models.peer import parse_peer_ref
117
+
118
+ peer = await _send.resolve(ctx, parse_peer_ref(peer_text))
119
+ message = await _media.fetch_message(ctx, peer, int(message_id))
120
+ out: list[int] = []
121
+ for entity in getattr(message, "entities", None) or []:
122
+ document_id = getattr(entity, "document_id", None)
123
+ if document_id is not None:
124
+ out.append(int(document_id))
125
+ return out
126
+
127
+
128
+ async def _download(
129
+ ctx: OpContext, req: GetReq, documents: list[Any], items: list[Sticker]
130
+ ) -> None:
131
+ from tlgr.ops.sticker import _convert
132
+
133
+ download = getattr(ctx, "download_file", None)
134
+ if download is None: # pragma: no cover - the daemon always supplies one
135
+ raise UsageError("this context cannot download files")
136
+ directory = Path(os.path.expanduser(str(req.download)))
137
+ directory.mkdir(parents=True, exist_ok=True)
138
+ for index, document in enumerate(documents):
139
+ suffix = {
140
+ "image/webp": ".webp",
141
+ "application/x-tgsticker": ".tgs",
142
+ "video/webm": ".webm",
143
+ }.get(getattr(document, "mime_type", "") or "", ".bin")
144
+ written = await download(
145
+ document,
146
+ directory / f"{getattr(document, 'id', index)}{suffix}",
147
+ size=int(getattr(document, "size", 0) or 0),
148
+ dc_id=int(getattr(document, "dc_id", 0) or 0),
149
+ )
150
+ path = Path(written)
151
+ if req.convert != "none":
152
+ path = _convert(path, req.convert)
153
+ items[index].path = str(path)
154
+
155
+
156
+ SPEC_GET = OperationSpec(
157
+ id="emoji.get",
158
+ request=GetReq,
159
+ response=Page[Sticker],
160
+ impl=get,
161
+ summary="Resolve custom emoji ids to their documents",
162
+ columns=("doc_id", "emoji", "free", "set_short_name"),
163
+ headers=("Doc", "Emoji", "Free", "Set"),
164
+ example={"items": [_EXAMPLE_EMOJI], "has_more": False},
165
+ example_args="emoji get 5312836234",
166
+ covers=("emoji.custom-fetch",),
167
+ )
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # emoji list
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ class ListReq(Request):
176
+ kind: Annotated[
177
+ str,
178
+ choice(*_LIST_REQUESTS, help="Which server-provided list."),
179
+ ] = "groups"
180
+ resolve: Annotated[
181
+ bool, opt("--resolve", help="Also resolve the returned ids to documents.")
182
+ ] = False
183
+
184
+
185
+ async def list_groups(ctx: OpContext, req: ListReq) -> Page[EmojiGroup]:
186
+ """The picker's own lists.
187
+
188
+ The groups are the category chips above the sticker, emoji and GIF search
189
+ boxes: each carries a title, an icon custom-emoji id and an emoticon list
190
+ usable as a `sticker search --emoji` filter. The `default-*` lists are the
191
+ emoji a non-Premium account may use for an avatar, a group photo or a
192
+ reply background, which is what makes those settable without guessing ids.
193
+ """
194
+ module_name, request_name = _LIST_REQUESTS[req.kind]
195
+ if module_name == "messages":
196
+ from telethon.tl.functions import messages as module
197
+ else:
198
+ from telethon.tl.functions import account as module
199
+
200
+ result = await _client(ctx)(getattr(module, request_name)(hash=0))
201
+ items: list[EmojiGroup] = []
202
+ for group in getattr(result, "groups", None) or []:
203
+ items.append(
204
+ EmojiGroup(
205
+ kind=req.kind,
206
+ title=str(getattr(group, "title", "") or ""),
207
+ icon_emoji_id=getattr(group, "icon_emoji_id", None),
208
+ emoticons=[str(e) for e in getattr(group, "emoticons", None) or []],
209
+ )
210
+ )
211
+ document_ids = [int(i) for i in getattr(result, "document_id", None) or []]
212
+ if document_ids:
213
+ items.append(EmojiGroup(kind=req.kind, title=req.kind, document_ids=document_ids))
214
+ if req.resolve and document_ids:
215
+ resolved = await get(ctx, GetReq(emoji_id=document_ids))
216
+ items[-1].emoticons = [item.emoji or "" for item in resolved.items]
217
+ return Page(items=items, has_more=False, total=len(items))
218
+
219
+
220
+ SPEC_LIST = OperationSpec(
221
+ id="emoji.list",
222
+ request=ListReq,
223
+ response=Page[EmojiGroup],
224
+ impl=list_groups,
225
+ summary="Server-provided emoji groups and default lists used by the pickers",
226
+ columns=("kind", "title", "icon_emoji_id"),
227
+ headers=("Kind", "Title", "Icon"),
228
+ example={
229
+ "items": [
230
+ {
231
+ "kind": "groups",
232
+ "title": "Smileys",
233
+ "icon_emoji_id": 5312836234,
234
+ "emoticons": ["😀", "😃"],
235
+ }
236
+ ],
237
+ "has_more": False,
238
+ },
239
+ example_args="emoji list --kind groups",
240
+ covers=("emoji.categories", "emoji.default-lists"),
241
+ )
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # emoji search
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ class SearchReq(Request):
250
+ query: Annotated[str, arg(0, metavar="QUERY", help="A keyword, or an emoji with --custom.")]
251
+ lang: Annotated[
252
+ list[str], opt("--lang", metavar="CODE", help="Keyword languages to search.")
253
+ ] = []
254
+ custom: Annotated[
255
+ bool, opt("--custom", help="Custom emoji suggestions for the query emoji.")
256
+ ] = False
257
+ sync: Annotated[
258
+ bool, opt("--sync", help="Refresh the cached keyword database before searching.")
259
+ ] = False
260
+ suggest_url: Annotated[
261
+ bool, opt("--suggest-url", help="Print the login URL for suggesting new keywords.")
262
+ ] = False
263
+
264
+
265
+ async def search(ctx: OpContext, req: SearchReq) -> Page[EmojiKeyword]:
266
+ """Find emoji by keyword, or custom emoji matching an emoji.
267
+
268
+ Keyword lists are a versioned local database, not a query endpoint: they
269
+ are fetched once per language and kept current with
270
+ `getEmojiKeywordsDifference`. `--custom` is the other half of the
271
+ composer's suggestion popup and *is* a server call.
272
+ """
273
+ from telethon.tl.functions import messages as fn
274
+
275
+ limit, _state = _media.window(ctx, "emoji.search", PageKind.LOCAL, 50)
276
+ languages = list(req.lang)
277
+ if not languages:
278
+ result = await _client(ctx)(fn.GetEmojiKeywordsLanguagesRequest(lang_codes=["en"]))
279
+ languages = [str(getattr(entry, "lang_code", "en")) for entry in result or []] or ["en"]
280
+
281
+ if req.suggest_url:
282
+ url = await _client(ctx)(fn.GetEmojiURLRequest(lang_code=languages[0]))
283
+ return Page(
284
+ items=[
285
+ EmojiKeyword(
286
+ emoticon="", keyword=str(getattr(url, "url", "") or ""), lang=languages[0]
287
+ )
288
+ ],
289
+ has_more=False,
290
+ )
291
+
292
+ if req.custom:
293
+ result = await _client(ctx)(fn.SearchCustomEmojiRequest(emoticon=req.query, hash=0))
294
+ ids = [int(i) for i in getattr(result, "document_id", None) or []][:limit]
295
+ documents = await get(ctx, GetReq(emoji_id=ids)) if ids else None
296
+ items = [
297
+ EmojiKeyword(
298
+ emoticon=item.emoji or req.query,
299
+ keyword=req.query,
300
+ lang=languages[0],
301
+ doc_id=item.doc_id,
302
+ set_short_name=item.set_short_name,
303
+ )
304
+ for item in (documents.items if documents is not None else [])
305
+ ]
306
+ return Page(items=items, has_more=False, total=len(items))
307
+
308
+ items = []
309
+ needle = req.query.strip().lower()
310
+ for language in languages:
311
+ # A difference call when asked to sync, the full list otherwise: both
312
+ # return the same keyword rows, and the search happens here.
313
+ request: Any = (
314
+ fn.GetEmojiKeywordsDifferenceRequest(lang_code=language, from_version=0)
315
+ if req.sync
316
+ else fn.GetEmojiKeywordsRequest(lang_code=language)
317
+ )
318
+ result = await _client(ctx)(request)
319
+ for entry in getattr(result, "keywords", None) or []:
320
+ keyword = str(getattr(entry, "keyword", "") or "")
321
+ if needle and needle not in keyword.lower():
322
+ continue
323
+ for emoticon in getattr(entry, "emoticons", None) or []:
324
+ items.append(EmojiKeyword(emoticon=str(emoticon), keyword=keyword, lang=language))
325
+ return Page(items=items[:limit], has_more=len(items) > limit, total=len(items))
326
+
327
+
328
+ SPEC_SEARCH = OperationSpec(
329
+ id="emoji.search",
330
+ request=SearchReq,
331
+ response=Page[EmojiKeyword],
332
+ impl=search,
333
+ summary="Find emoji by keyword, or custom emoji matching an emoji",
334
+ paginated=PageKind.LOCAL,
335
+ columns=("emoticon", "keyword", "lang"),
336
+ headers=("Emoji", "Keyword", "Lang"),
337
+ example={
338
+ "items": [{"emoticon": "😀", "keyword": "grinning", "lang": "en"}],
339
+ "has_more": False,
340
+ },
341
+ example_args="emoji search grinning",
342
+ covers=("emoji.custom-search-by-emoticon", "emoji.keywords-search"),
343
+ )