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/webapp.py ADDED
@@ -0,0 +1,717 @@
1
+ """The `webapp` group: mini apps, from a CLI that has no browser.
2
+
3
+ The return contract is the whole design, and it is deliberately narrow.
4
+
5
+ `webapp open` prints the **signed URL** and stops. It never launches a
6
+ browser, and it never hosts the `window.Telegram.WebApp` bridge — a terminal
7
+ cannot run a mini app, and pretending otherwise would mean shipping a headless
8
+ browser inside a CLI. What tlgr *can* do is everything on the Telegram side of
9
+ the boundary: mint the session, keep it alive, answer the app's peer request,
10
+ carry its data back to the bot, and check a download it proposes.
11
+
12
+ That URL is a credential, not a link: it carries the user's signed init data,
13
+ and whoever holds it can act as that user inside the app until it expires. It
14
+ is printed once, with that warning in human output, and plainly under `--json`
15
+ where the caller asked for machine-readable output on purpose.
16
+
17
+ Telethon is imported inside functions, never at module scope (§2.2).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from pathlib import Path
24
+ from typing import Annotated, Any
25
+
26
+ from tlgr.core.errors import NotFoundError, PermissionError_, UsageError
27
+ from tlgr.core.timefmt import fmt_dt
28
+ from tlgr.models.base import Request
29
+ from tlgr.models.bot import BotApiResult
30
+ from tlgr.models.page import Page
31
+ from tlgr.models.peer import PeerRef
32
+ from tlgr.models.webapp import (
33
+ WebAppDownload,
34
+ WebAppInfo,
35
+ WebAppProlong,
36
+ WebAppSent,
37
+ WebAppSession,
38
+ )
39
+ from tlgr.ops import _bots, _send
40
+ from tlgr.ops._common import client
41
+ from tlgr.ops._params import arg, choice, opt
42
+ from tlgr.ops._spec import OpContext, OperationSpec
43
+
44
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
45
+
46
+ #: The platform reported to Telegram. It picks the app's own layout; there is
47
+ #: no value meaning "a terminal", and lying about it is what every other
48
+ #: third-party client does too.
49
+ PLATFORM = "web"
50
+
51
+ #: How often a session that returned a `query_id` has to be prolonged.
52
+ PROLONG_EVERY = 60
53
+
54
+
55
+ def _theme(path: str | None) -> Any:
56
+ """`--theme` as the `DataJSON` Telegram passes into the app.
57
+
58
+ The default is a minimal object rather than nothing: an app handed no
59
+ theme at all renders with browser defaults, which looks broken.
60
+ """
61
+ from telethon.tl import types
62
+
63
+ if path:
64
+ return _bots.data_json(path, field="theme")
65
+ return types.DataJSON(data='{"bg_color":"#ffffff","text_color":"#000000"}')
66
+
67
+
68
+ def _session(result: Any, *, bot: str | None, kind: str, write_allowed: bool) -> WebAppSession:
69
+ query_id = getattr(result, "query_id", None)
70
+ return WebAppSession(
71
+ bot=bot,
72
+ kind=kind,
73
+ url=str(getattr(result, "url", "") or ""),
74
+ query_id=str(query_id) if query_id else None,
75
+ fullsize=bool(getattr(result, "fullsize", False)),
76
+ fullscreen=bool(getattr(result, "fullscreen", False)),
77
+ same_origin=bool(getattr(result, "same_origin", False)),
78
+ needs_prolong=bool(query_id),
79
+ prolong_every=PROLONG_EVERY if query_id else None,
80
+ write_allowed=write_allowed,
81
+ )
82
+
83
+
84
+ async def _app(ctx: OpContext, bot: PeerRef, short_name: str) -> Any:
85
+ """`InputBotAppShortName` for a direct-link app."""
86
+ from telethon.tl import types
87
+
88
+ return types.InputBotAppShortName(
89
+ bot_id=await _bots.input_user(ctx, bot), short_name=short_name
90
+ )
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # webapp get
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ class GetReq(Request):
99
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot owning the app.")]
100
+ short_name: Annotated[
101
+ str, arg(1, metavar="SHORT_NAME", required=False, help="Direct-link app short name.")
102
+ ] = ""
103
+ button_request: Annotated[
104
+ str | None,
105
+ opt("--button-request", metavar="ID", help="Show the peer request behind this id."),
106
+ ] = None
107
+
108
+
109
+ async def get(ctx: OpContext, req: GetReq) -> WebAppInfo:
110
+ """A mini app's manifest.
111
+
112
+ This covers every entry on the app's panel menu except "Reload page",
113
+ which is a webview concern with no API behind it. The placeholder is an
114
+ SVG-like path blob: its *length* is reported, because nothing that reads
115
+ this output can render it and printing the bytes would bury the rest.
116
+ """
117
+ from telethon.tl.functions import bots as bots_fn
118
+ from telethon.tl.functions import messages as fn
119
+
120
+ handle = client(ctx)
121
+ info = WebAppInfo(bot=str(req.bot.raw), short_name=req.short_name or None)
122
+
123
+ if req.short_name:
124
+ result = await handle(
125
+ fn.GetBotAppRequest(app=await _app(ctx, req.bot, req.short_name), hash=0)
126
+ )
127
+ app = getattr(result, "app", None)
128
+ if app is None or type(app).__name__ == "BotAppNotModified":
129
+ raise NotFoundError(f"@{req.bot.raw} has no app called {req.short_name!r}")
130
+ info.title = getattr(app, "title", None)
131
+ info.description = getattr(app, "description", None)
132
+ info.photo = int(getattr(getattr(app, "photo", None), "id", 0) or 0) or None
133
+ info.document = int(getattr(getattr(app, "document", None), "id", 0) or 0) or None
134
+ info.inactive = bool(getattr(result, "inactive", False))
135
+ info.request_write_access = bool(getattr(result, "request_write_access", False))
136
+ info.has_settings = bool(getattr(result, "has_settings", False))
137
+ info.link = f"https://t.me/{str(req.bot.value or req.bot.raw).lstrip('@')}/{req.short_name}"
138
+
139
+ peer = await _send.resolve(ctx, req.bot)
140
+ from tlgr.ops.bot import _app_settings, _full
141
+
142
+ full, _user = await _full(ctx, peer)
143
+ bot_info = getattr(full, "bot_info", None)
144
+ settings = _app_settings(getattr(bot_info, "app_settings", None)) or {}
145
+ info.privacy_policy_url = getattr(bot_info, "privacy_policy_url", None)
146
+ info.placeholder_path = settings.get("placeholder_path")
147
+ info.bg_color = settings.get("bg_color")
148
+ info.bg_dark_color = settings.get("bg_dark_color")
149
+ info.header_color = settings.get("header_color")
150
+ info.header_dark_color = settings.get("header_dark_color")
151
+
152
+ attach = await handle(fn.GetAttachMenuBotRequest(bot=await _bots.input_user(ctx, req.bot)))
153
+ entry = getattr(attach, "bot", None)
154
+ info.installed_in_attach_menu = bool(getattr(entry, "show_in_attach_menu", False))
155
+ info.installed_in_side_menu = bool(getattr(entry, "show_in_side_menu", False))
156
+
157
+ if req.button_request:
158
+ button = await handle(
159
+ bots_fn.GetRequestedWebViewButtonRequest(
160
+ bot=await _bots.input_user(ctx, req.bot), webapp_req_id=req.button_request
161
+ )
162
+ )
163
+ info.button_request = {
164
+ "text": getattr(button, "text", None),
165
+ "button_id": getattr(button, "button_id", None),
166
+ "peer_type": type(getattr(button, "peer_type", None)).__name__,
167
+ }
168
+ return info
169
+
170
+
171
+ SPEC_GET = OperationSpec(
172
+ id="webapp.get",
173
+ request=GetReq,
174
+ response=WebAppInfo,
175
+ impl=get,
176
+ summary="Show a mini app's manifest",
177
+ aliases=("app.info", "app.get"),
178
+ columns=("short_name", "title", "installed_in_attach_menu"),
179
+ headers=("App", "Title", "Installed"),
180
+ example={"bot": "@my_helper_bot", "short_name": "shop", "title": "Shop"},
181
+ example_args="webapp get @my_helper_bot shop",
182
+ covers=(
183
+ "bots.button-request-peer-from-miniapp",
184
+ "bots.direct-link-app-open",
185
+ "bots.webapp-placeholder-and-close",
186
+ ),
187
+ covers_partial=("bots.miniapp-panel-menu",),
188
+ coverage_note=(
189
+ "Installing and removing the app is `bot attach toggle`; reporting it "
190
+ "is `bot report --app`."
191
+ ),
192
+ )
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # webapp open
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ class OpenReq(Request):
201
+ bot: Annotated[
202
+ PeerRef | None, arg(0, metavar="BOT", required=False, kind="user", help="The bot.")
203
+ ] = None
204
+ app: Annotated[str | None, opt("--app", metavar="NAME", help="Direct-link app short name.")] = (
205
+ None
206
+ )
207
+ main: Annotated[bool, opt("--main", help="The bot's Main Mini App.")] = False
208
+ attach: Annotated[bool, opt("--attach", help="Attachment-menu app in --chat.")] = False
209
+ menu: Annotated[bool, opt("--menu", help="The bot's menu-button app.")] = False
210
+ simple: Annotated[bool, opt("--simple", help="Simple web view.")] = False
211
+ side_menu: Annotated[bool, opt("--side-menu", help="Side-menu app (implies --simple).")] = False
212
+ from_switch_webview: Annotated[
213
+ bool, opt("--from-switch-webview", help="Inline-mode app behind a switch_webview button.")
214
+ ] = False
215
+ join_query_id: Annotated[
216
+ str | None, opt("--join-query-id", metavar="ID", help="Guard-bot chat-join app.")
217
+ ] = None
218
+ url: Annotated[str | None, opt("--url", metavar="URL", help="Button URL for the app.")] = None
219
+ chat: Annotated[
220
+ PeerRef | None,
221
+ opt("--chat", metavar="CHAT", kind="peer", help="Chat the app is opened from."),
222
+ ] = None
223
+ start_param: Annotated[
224
+ str | None, opt("--start-param", metavar="TEXT", help="startapp payload.")
225
+ ] = None
226
+ mode: Annotated[
227
+ str | None, choice("compact", "fullscreen", help="Requested presentation mode.")
228
+ ] = None
229
+ allow_write: Annotated[bool, opt("--allow-write", help="CONSENT: let the bot message me.")] = (
230
+ False
231
+ )
232
+ theme: Annotated[
233
+ str | None, opt("--theme", metavar="PATH", kind="path", help="JSON theme params.")
234
+ ] = None
235
+ open_inactive: Annotated[
236
+ bool, opt("--open-inactive", help="Open an app Telegram has marked inactive.")
237
+ ] = False
238
+
239
+
240
+ async def open_app(ctx: OpContext, req: OpenReq) -> WebAppSession:
241
+ """Open a mini app and print its signed URL.
242
+
243
+ Seven entry points reach one answer, and they are genuinely different
244
+ requests — a Main Mini App, a direct link, an attachment-menu entry, a
245
+ menu button, a simple view, a side-menu view, an inline switch. What comes
246
+ back is the same shape, plus one fact that matters operationally: whether
247
+ the session has a `query_id` and therefore dies in a minute unless
248
+ `webapp watch` keeps it alive.
249
+
250
+ tlgr never opens a browser. `--allow-write` is never implied: opening an
251
+ app and letting its bot message you afterwards are two decisions.
252
+ """
253
+ from telethon.tl.functions import messages as fn
254
+
255
+ if req.join_query_id:
256
+ _bots.unsupported(
257
+ "--join-query-id",
258
+ "messages.requestChatJoinWebView is absent from Telethon 1.44 and "
259
+ "hand-rolling it would mean guessing at an unpublished constructor id",
260
+ )
261
+ if req.bot is None:
262
+ raise UsageError("name the bot that owns the app", field="bot")
263
+
264
+ handle = client(ctx)
265
+ bot = await _bots.input_user(ctx, req.bot)
266
+ peer = (
267
+ await _send.resolve(ctx, req.chat)
268
+ if req.chat is not None
269
+ else await _send.resolve(ctx, req.bot)
270
+ )
271
+ compact = req.mode == "compact" or None
272
+ fullscreen = req.mode == "fullscreen" or None
273
+ theme = _theme(req.theme)
274
+
275
+ if req.app:
276
+ app = await _app(ctx, req.bot, req.app)
277
+ listing = await handle(fn.GetBotAppRequest(app=app, hash=0))
278
+ if bool(getattr(listing, "inactive", False)) and not req.open_inactive:
279
+ raise PermissionError_(
280
+ "Telegram marks this app inactive; pass --open-inactive to open it anyway"
281
+ )
282
+ result = await handle(
283
+ fn.RequestAppWebViewRequest(
284
+ peer=peer,
285
+ app=app,
286
+ platform=PLATFORM,
287
+ write_allowed=req.allow_write or None,
288
+ compact=compact,
289
+ fullscreen=fullscreen,
290
+ start_param=req.start_param,
291
+ theme_params=theme,
292
+ )
293
+ )
294
+ kind = "direct-link"
295
+ elif req.main:
296
+ result = await handle(
297
+ fn.RequestMainWebViewRequest(
298
+ peer=peer,
299
+ bot=bot,
300
+ platform=PLATFORM,
301
+ compact=compact,
302
+ fullscreen=fullscreen,
303
+ start_param=req.start_param,
304
+ theme_params=theme,
305
+ )
306
+ )
307
+ kind = "main"
308
+ elif req.simple or req.side_menu or req.from_switch_webview:
309
+ result = await handle(
310
+ fn.RequestSimpleWebViewRequest(
311
+ bot=bot,
312
+ platform=PLATFORM,
313
+ from_switch_webview=req.from_switch_webview or None,
314
+ from_side_menu=req.side_menu or None,
315
+ compact=compact,
316
+ fullscreen=fullscreen,
317
+ url=req.url,
318
+ start_param=req.start_param,
319
+ theme_params=theme,
320
+ )
321
+ )
322
+ kind = "side-menu" if req.side_menu else "simple"
323
+ else:
324
+ url = req.url
325
+ if req.menu and not url:
326
+ from tlgr.ops.bot import _full
327
+
328
+ full, _user = await _full(ctx, await _send.resolve(ctx, req.bot))
329
+ button = getattr(getattr(full, "bot_info", None), "menu_button", None)
330
+ url = getattr(button, "url", None)
331
+ if not url:
332
+ raise NotFoundError("that bot has no menu-button app")
333
+ result = await handle(
334
+ fn.RequestWebViewRequest(
335
+ peer=peer,
336
+ bot=bot,
337
+ platform=PLATFORM,
338
+ from_bot_menu=req.menu or None,
339
+ compact=compact,
340
+ fullscreen=fullscreen,
341
+ url=url,
342
+ start_param=req.start_param,
343
+ theme_params=theme,
344
+ )
345
+ )
346
+ kind = "menu" if req.menu else "attach" if req.attach else "button"
347
+
348
+ if req.allow_write and kind not in ("direct-link",):
349
+ # Only requestAppWebView carries write_allowed; everywhere else the
350
+ # grant is its own call, and doing it silently would be the implicit
351
+ # consent this command refuses to give.
352
+ from telethon.tl.functions import bots as bots_fn
353
+
354
+ await handle(bots_fn.AllowSendMessageRequest(bot=bot))
355
+
356
+ session = _session(result, bot=str(req.bot.raw), kind=kind, write_allowed=req.allow_write)
357
+ if session.query_id:
358
+ ctx.warn(
359
+ "this URL carries your signed init data — treat it as a credential, "
360
+ f"and keep the session alive with `tlgr webapp watch {req.bot.raw} "
361
+ f"--query-id {session.query_id}`"
362
+ )
363
+ else:
364
+ ctx.warn("this URL carries your signed init data — treat it as a credential")
365
+ return session
366
+
367
+
368
+ SPEC_OPEN = OperationSpec(
369
+ id="webapp.open",
370
+ request=OpenReq,
371
+ response=WebAppSession,
372
+ impl=open_app,
373
+ summary="Open a mini app and print its signed URL",
374
+ description=(
375
+ "Printing the URL is the only behaviour: there is no --open, because "
376
+ "a CLI cannot host the mini-app JS bridge and a browser launched from "
377
+ "here would carry a credential into a process tlgr does not control."
378
+ ),
379
+ aliases=("app.open",),
380
+ mutating=True,
381
+ columns=("kind", "url", "needs_prolong"),
382
+ headers=("Kind", "URL", "Prolong"),
383
+ example={
384
+ "bot": "@my_helper_bot",
385
+ "kind": "main",
386
+ "url": "https://example.org/app#tgWebAppData=…",
387
+ },
388
+ example_args="webapp open @my_helper_bot --main",
389
+ covers=(
390
+ "attach.open-mini-app",
391
+ "bots.attach-menu-deeplinks",
392
+ "bots.attach-webapp-open",
393
+ "bots.main-webapp-open",
394
+ "bots.simple-webapp-open",
395
+ "bots.webapp-modes",
396
+ ),
397
+ covers_partial=(
398
+ "bots.direct-link-app-open",
399
+ "bots.inline-switch-webview",
400
+ "bots.webapp-write-access",
401
+ ),
402
+ coverage_note=(
403
+ "The app's manifest is `webapp get`; the attachment-menu install is "
404
+ "`bot attach toggle`. The guard-bot chat-join view needs layer 229 "
405
+ "and exits 13."
406
+ ),
407
+ )
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # webapp watch
412
+ # ---------------------------------------------------------------------------
413
+
414
+
415
+ class WatchReq(Request):
416
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")]
417
+ query_id: Annotated[
418
+ str, opt("--query-id", metavar="ID", help="query_id from `webapp open`.")
419
+ ] = ""
420
+ chat: Annotated[
421
+ PeerRef | None,
422
+ opt("--chat", metavar="CHAT", kind="peer", help="Chat the app was opened from."),
423
+ ] = None
424
+ interval: Annotated[
425
+ str, opt("--interval", metavar="DURATION", help="Prolong interval, e.g. 55s.")
426
+ ] = "55s"
427
+ until: Annotated[
428
+ str | None, opt("--until", metavar="DURATION", help="Stop after this long.")
429
+ ] = None
430
+
431
+
432
+ async def watch(ctx: OpContext, req: WatchReq) -> Any:
433
+ """Keep an open mini-app session alive.
434
+
435
+ Only a session that came back with a `query_id` needs this, and
436
+ `QUERY_ID_INVALID` is how it ends normally — the session died, which is
437
+ information, not a failure. The stream therefore closes with
438
+ `alive: false` and exit 0 rather than raising.
439
+ """
440
+ import asyncio
441
+ import time
442
+
443
+ from telethon.tl.functions import messages as fn
444
+
445
+ from tlgr.core.timefmt import parse_duration
446
+
447
+ if not req.query_id:
448
+ raise UsageError("--query-id is required", field="query_id")
449
+ try:
450
+ query_id = int(req.query_id)
451
+ except ValueError as exc:
452
+ raise UsageError("--query-id must be numeric", field="query_id") from exc
453
+
454
+ interval = float(parse_duration(req.interval) or 55)
455
+ deadline = time.monotonic() + float(parse_duration(req.until) or 0) if req.until else None
456
+ handle = client(ctx)
457
+ bot = await _bots.input_user(ctx, req.bot)
458
+ peer = (
459
+ await _send.resolve(ctx, req.chat)
460
+ if req.chat is not None
461
+ else await _send.resolve(ctx, req.bot)
462
+ )
463
+
464
+ while True:
465
+ try:
466
+ await handle(fn.ProlongWebViewRequest(peer=peer, bot=bot, query_id=query_id))
467
+ except Exception as exc:
468
+ reason = f"{type(exc).__name__} {exc}".upper().replace("_", "")
469
+ if "QUERYIDINVALID" not in reason:
470
+ raise
471
+ yield Page(
472
+ items=[
473
+ WebAppProlong(
474
+ query_id=req.query_id, alive=False, reason="the session has expired"
475
+ )
476
+ ],
477
+ has_more=False,
478
+ )
479
+ return
480
+ last = deadline is not None and time.monotonic() >= deadline
481
+ yield Page(
482
+ items=[WebAppProlong(query_id=req.query_id, prolonged_at=fmt_dt(_now()), alive=True)],
483
+ has_more=not last,
484
+ )
485
+ if last:
486
+ return
487
+ await asyncio.sleep(interval)
488
+
489
+
490
+ def _now() -> Any:
491
+ from datetime import datetime, timezone
492
+
493
+ return datetime.now(timezone.utc)
494
+
495
+
496
+ SPEC_WATCH = OperationSpec(
497
+ id="webapp.watch",
498
+ request=WatchReq,
499
+ response=Page[WebAppProlong],
500
+ impl=watch,
501
+ summary="Keep an open mini-app session alive",
502
+ aliases=("app.session.prolong",),
503
+ mutating=True,
504
+ stream=True,
505
+ timeout_s=900,
506
+ columns=("query_id", "prolonged_at", "alive"),
507
+ headers=("Query", "At", "Alive"),
508
+ example={"items": [{"query_id": "987654321", "alive": True}], "has_more": False},
509
+ example_args="webapp watch @my_helper_bot --query-id 987654321",
510
+ covers=("bots.prolong-webview",),
511
+ )
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # webapp send
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ class SendReq(Request):
520
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")]
521
+ button_text: Annotated[
522
+ str, opt("--button-text", metavar="TEXT", help="Text of the button that opened the app.")
523
+ ] = ""
524
+ data: Annotated[str, opt("--data", metavar="PAYLOAD", help="Payload, max 4096 bytes.")] = ""
525
+
526
+
527
+ async def send(ctx: OpContext, req: SendReq) -> WebAppSent:
528
+ """Send data from a keyboard-button mini app back to its bot.
529
+
530
+ Valid exactly once per web-app session: a second `web_app_data_send` from
531
+ the same session is ignored by the server, so a caller that retries is
532
+ not doing anything.
533
+ """
534
+ from telethon.tl.functions import messages as fn
535
+
536
+ if not req.button_text or not req.data:
537
+ raise UsageError("--button-text and --data are both required", field="data")
538
+ if len(req.data.encode()) > 4096:
539
+ raise UsageError("--data is capped at 4096 bytes", field="data")
540
+
541
+ await client(ctx)(
542
+ fn.SendWebViewDataRequest(
543
+ bot=await _bots.input_user(ctx, req.bot),
544
+ button_text=req.button_text,
545
+ data=req.data,
546
+ random_id=_random_id(),
547
+ )
548
+ )
549
+ peer = await _send.resolve(ctx, req.bot)
550
+ return WebAppSent(bot_id=_send.peer_id_of(peer), sent=True)
551
+
552
+
553
+ def _random_id() -> int:
554
+ from tlgr.ops._common import random_id
555
+
556
+ return random_id()
557
+
558
+
559
+ SPEC_SEND = OperationSpec(
560
+ id="webapp.send",
561
+ request=SendReq,
562
+ response=WebAppSent,
563
+ impl=send,
564
+ summary="Send data from a keyboard-button mini app back to its bot",
565
+ aliases=("app.send-data", "webapp.send-data"),
566
+ mutating=True,
567
+ rate_class="send",
568
+ columns=("bot_id", "sent"),
569
+ headers=("Bot", "Sent"),
570
+ example={"bot_id": 5000001, "sent": True},
571
+ example_args='webapp send @my_helper_bot --button-text Order --data "{}"',
572
+ covers=("bots.send-webview-data",),
573
+ )
574
+
575
+
576
+ # ---------------------------------------------------------------------------
577
+ # webapp invoke
578
+ # ---------------------------------------------------------------------------
579
+
580
+
581
+ class InvokeReq(Request):
582
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")]
583
+ method: Annotated[str, arg(1, metavar="METHOD", help="Custom method name.")]
584
+ params: Annotated[
585
+ str, opt("--params", metavar="JSON", kind="json", help="JSON parameters.")
586
+ ] = "{}"
587
+
588
+
589
+ async def invoke(ctx: OpContext, req: InvokeReq) -> BotApiResult:
590
+ """Call a mini app's custom method. The result is opaque and passed through."""
591
+ from telethon.tl.functions import bots as fn
592
+
593
+ result = await client(ctx)(
594
+ fn.InvokeWebViewCustomMethodRequest(
595
+ bot=await _bots.input_user(ctx, req.bot),
596
+ custom_method=req.method,
597
+ params=_bots.data_json(req.params, field="params"),
598
+ )
599
+ )
600
+ from tlgr.ops.bot import _data_json
601
+
602
+ return BotApiResult(method=req.method, result=_data_json(result))
603
+
604
+
605
+ SPEC_INVOKE = OperationSpec(
606
+ id="webapp.invoke",
607
+ request=InvokeReq,
608
+ response=BotApiResult,
609
+ impl=invoke,
610
+ summary="Call a mini app's custom method",
611
+ aliases=("app.invoke",),
612
+ mutating=True,
613
+ columns=("method",),
614
+ headers=("Method",),
615
+ example={"method": "getOrders", "result": {"orders": []}},
616
+ example_args='webapp invoke @my_helper_bot getOrders --params "{}"',
617
+ covers=("bots.webapp-custom-method",),
618
+ )
619
+
620
+
621
+ # ---------------------------------------------------------------------------
622
+ # webapp download
623
+ # ---------------------------------------------------------------------------
624
+
625
+
626
+ class DownloadReq(Request):
627
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")]
628
+ file_name: Annotated[
629
+ str, opt("--file-name", metavar="NAME", help="File name the app proposed.")
630
+ ] = ""
631
+ url: Annotated[str, opt("--url", metavar="URL", help="URL the app proposed.")] = ""
632
+ out: Annotated[
633
+ str | None, opt("--out", metavar="PATH", kind="path", help="Where to write it.")
634
+ ] = None
635
+ fetch: Annotated[
636
+ bool, opt("--fetch", help="Actually download it; checking alone never does.")
637
+ ] = False
638
+
639
+
640
+ async def download(ctx: OpContext, req: DownloadReq) -> WebAppDownload:
641
+ """Check — and only on request, perform — a download a mini app asked for.
642
+
643
+ Check-only by default. `bots.checkDownloadFileParams` is Telegram saying
644
+ whether the app is allowed to offer this file at all, and a client that
645
+ fetched first and asked afterwards would have already run the risk. The
646
+ fetch itself is plain HTTPS, not MTProto, which is the other reason it is
647
+ opt-in: nothing about it goes through Telegram.
648
+ """
649
+ from telethon.tl.functions import bots as fn
650
+
651
+ if not req.file_name or not req.url:
652
+ raise UsageError("--file-name and --url are both required", field="url")
653
+
654
+ allowed = bool(
655
+ await client(ctx)(
656
+ fn.CheckDownloadFileParamsRequest(
657
+ bot=await _bots.input_user(ctx, req.bot),
658
+ file_name=req.file_name,
659
+ url=req.url,
660
+ )
661
+ )
662
+ )
663
+ result = WebAppDownload(allowed=allowed, file_name=req.file_name, url=req.url)
664
+ if not req.fetch:
665
+ return result
666
+ if not allowed:
667
+ raise PermissionError_(
668
+ "Telegram does not allow this mini app to offer that file; nothing was downloaded"
669
+ )
670
+ if not req.url.startswith("https://"):
671
+ raise PermissionError_("only https:// downloads are performed")
672
+ target = Path(os.path.expanduser(req.out or req.file_name))
673
+ ctx.warn(f"fetching {req.file_name} from {req.url} over plain HTTPS, outside Telegram")
674
+ result.path = str(target)
675
+ result.downloaded = await _fetch(req.url, target)
676
+ return result
677
+
678
+
679
+ #: A mini app names its own file size nowhere, so the fetch is capped here.
680
+ MAX_DOWNLOAD = 64 * 1024 * 1024
681
+
682
+
683
+ async def _fetch(url: str, target: Path) -> bool:
684
+ """Fetch *url* into *target*, capped. Plain HTTPS: no Telegram involved."""
685
+ import aiohttp
686
+
687
+ target.parent.mkdir(parents=True, exist_ok=True)
688
+ written = 0
689
+ async with aiohttp.ClientSession() as session, session.get(url) as response:
690
+ response.raise_for_status()
691
+ with target.open("wb") as handle:
692
+ async for chunk in response.content.iter_chunked(64 * 1024):
693
+ written += len(chunk)
694
+ if written > MAX_DOWNLOAD:
695
+ handle.close()
696
+ target.unlink(missing_ok=True)
697
+ raise PermissionError_(
698
+ f"the file exceeds tlgr's {MAX_DOWNLOAD // (1024 * 1024)} MB cap "
699
+ "for a mini-app download"
700
+ )
701
+ handle.write(chunk)
702
+ return True
703
+
704
+
705
+ SPEC_DOWNLOAD = OperationSpec(
706
+ id="webapp.download",
707
+ request=DownloadReq,
708
+ response=WebAppDownload,
709
+ impl=download,
710
+ summary="Check a file download a mini app asked for",
711
+ aliases=("app.check-download",),
712
+ columns=("allowed", "file_name", "downloaded"),
713
+ headers=("Allowed", "File", "Downloaded"),
714
+ example={"allowed": True, "file_name": "invoice.pdf", "url": "https://example.org/i.pdf"},
715
+ example_args="webapp download @my_helper_bot --file-name i.pdf --url https://example.org/i.pdf",
716
+ covers=("attach.file-download-check", "bots.webapp-file-download-check"),
717
+ )