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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|