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/search.py ADDED
@@ -0,0 +1,521 @@
1
+ """The `search` group: finding messages outside one chat.
2
+
3
+ Global search does not paginate on a message id. `messages.searchGlobal` walks
4
+ a **triple** — `(offset_rate, offset_peer, offset_id)` — where `offset_rate`
5
+ comes back as `messagesSlice.next_rate` and is meaningless anywhere else.
6
+ v1-style "remember the last id" paging silently restarts the walk at the top
7
+ of the first chat, so the whole triple goes into the cursor and comes back out
8
+ of it.
9
+
10
+ `search post` spends Stars once the free quota is gone. It never does so
11
+ implicitly: `--quota` reports the price for free, and paying takes `--pay-stars`
12
+ plus the ordinary destructive confirmation.
13
+
14
+ Telethon is imported inside functions, never at module scope (§2.2).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from typing import Annotated, Any
21
+
22
+ from tlgr.core.errors import NotSupportedError, UsageError
23
+ from tlgr.core.pagination import PageKind, build_page
24
+ from tlgr.core.timefmt import parse_dt
25
+ from tlgr.models.base import Request
26
+ from tlgr.models.message import Message
27
+ from tlgr.models.page import Page
28
+ from tlgr.models.peer import PeerRef
29
+ from tlgr.ops import _send
30
+ from tlgr.ops._common import client, window
31
+ from tlgr.ops._params import arg, choice, opt
32
+ from tlgr.ops._serialize import message_to_model
33
+ from tlgr.ops._spec import OpContext, OperationSpec
34
+
35
+ #: `--type` names the same `inputMessagesFilter*` values `message search`
36
+ #: takes: one media vocabulary across the surface, not two that drift.
37
+ from tlgr.ops.message import FILTERS
38
+
39
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
40
+
41
+ _EXAMPLE_HIT: dict[str, Any] = {
42
+ "id": 12345,
43
+ "chat_id": -1001234567890,
44
+ "date": "2026-09-03T09:14:07Z",
45
+ "date_unix": 1788340447,
46
+ "text": "the release is out",
47
+ }
48
+
49
+ #: Where the local hashtag history lives, per account.
50
+ _HASHTAG_FILE = "hashtags.json"
51
+ _HASHTAG_MAX = 50
52
+
53
+
54
+ def _filter(name: str | None, *, missed: bool = False) -> Any:
55
+ """`--type photo` → `InputMessagesFilterPhotos()`; empty means everything."""
56
+ from telethon.tl import types
57
+
58
+ if not name:
59
+ return types.InputMessagesFilterEmpty()
60
+ class_name = FILTERS.get(name)
61
+ if class_name is None:
62
+ raise UsageError(
63
+ f"--type {name!r} is not a media filter; expected one of {', '.join(FILTERS)}",
64
+ field="type",
65
+ )
66
+ if class_name == "InputMessagesFilterPhoneCalls":
67
+ return types.InputMessagesFilterPhoneCalls(missed=missed or None)
68
+ return getattr(types, class_name)()
69
+
70
+
71
+ async def _empty_peer(ctx: OpContext, state: dict[str, Any]) -> Any:
72
+ """The `offset_peer` half of the search triple, resolved from the cursor."""
73
+ from telethon.tl import types
74
+
75
+ marked = state.get("offset_peer")
76
+ if not marked:
77
+ return types.InputPeerEmpty()
78
+ try:
79
+ return await client(ctx).get_input_entity(int(marked))
80
+ except (ValueError, TypeError):
81
+ return types.InputPeerEmpty()
82
+
83
+
84
+ def _hits(result: Any, ctx: OpContext) -> list[Message]:
85
+ """`messages.Messages*` → models, with the chat each hit came from filled in."""
86
+ from tlgr.ops._serialize import entity_to_peer
87
+
88
+ chats = {
89
+ int(getattr(entity, "id", 0)): entity
90
+ for entity in (
91
+ *(getattr(result, "chats", None) or []),
92
+ *(getattr(result, "users", None) or []),
93
+ )
94
+ }
95
+ out: list[Message] = []
96
+ for raw in getattr(result, "messages", None) or []:
97
+ model = message_to_model(raw)
98
+ peer = getattr(raw, "peer_id", None)
99
+ for attribute in ("channel_id", "chat_id", "user_id"):
100
+ value = getattr(peer, attribute, None)
101
+ entity = chats.get(int(value)) if value is not None else None
102
+ if entity is not None:
103
+ model.chat = entity_to_peer(entity)
104
+ break
105
+ out.append(model)
106
+ return out
107
+
108
+
109
+ def _next_state(result: Any, items: list[Message]) -> dict[str, Any]:
110
+ """The triple to resume from: rate, peer and id of the last hit."""
111
+ last = items[-1] if items else None
112
+ return {
113
+ "offset_rate": int(getattr(result, "next_rate", 0) or 0),
114
+ "offset_peer": last.chat_id if last is not None else 0,
115
+ "offset_id": last.id if last is not None else 0,
116
+ }
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # search global
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ class GlobalReq(Request):
125
+ query: Annotated[str, arg(0, metavar="QUERY", required=False, help="What to look for.")] = ""
126
+ type: Annotated[
127
+ str | None, opt("--type", metavar="FILTER", help="Media filter, as `message search`.")
128
+ ] = None
129
+ only: Annotated[
130
+ str | None, choice("user", "group", "channel", help="Scope to one kind of chat.")
131
+ ] = None
132
+ community: Annotated[
133
+ PeerRef | None,
134
+ opt("--community", metavar="CHAT", kind="peer", help="Scope to one community (layer 229)."),
135
+ ] = None
136
+ folder: Annotated[int | None, opt("--folder", metavar="ID", help="Scope to a chat folder.")] = (
137
+ None
138
+ )
139
+ archived: Annotated[bool, opt("--archived", help="Search the archive folder.")] = False
140
+ since: Annotated[
141
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Only after this time.")
142
+ ] = None
143
+ until: Annotated[
144
+ str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
145
+ ] = None
146
+ missed: Annotated[bool, opt("--missed", help="With --type call: only missed calls.")] = False
147
+ sent_media: Annotated[
148
+ bool, opt("--sent-media", help="Search my own recently sent documents instead.")
149
+ ] = False
150
+
151
+
152
+ async def search_global(ctx: OpContext, req: GlobalReq) -> Page[Message]:
153
+ """Search messages across every chat this account is in.
154
+
155
+ Global search never reaches chats you have left or secret chats; that is
156
+ the server's rule, not tlgr's, and it is why an empty answer here is not
157
+ proof a message does not exist.
158
+ """
159
+ from telethon.tl.functions import messages as fn
160
+
161
+ limit, state = window(ctx, "search.global", PageKind.RATE)
162
+ if req.community is not None:
163
+ raise NotSupportedError(
164
+ "--community is a layer-229 flag on messages.searchGlobal and the pinned "
165
+ "Telethon speaks 227; search inside the community's chats instead"
166
+ )
167
+
168
+ if req.sent_media:
169
+ # A different method with no pagination at all: it answers with the
170
+ # most recent N and nothing else, so the page is honest about that.
171
+ result = await client(ctx)(
172
+ fn.SearchSentMediaRequest(q=req.query, filter=_filter(req.type or "file"), limit=limit)
173
+ )
174
+ items = _hits(result, ctx)
175
+ return build_page(items, op="search.global", kind=PageKind.RATE, has_more=False)
176
+
177
+ folder = req.folder
178
+ if req.archived and folder is None:
179
+ folder = 1
180
+
181
+ result = await client(ctx)(
182
+ fn.SearchGlobalRequest(
183
+ q=req.query,
184
+ filter=_filter(req.type, missed=req.missed),
185
+ min_date=parse_dt(req.since),
186
+ max_date=parse_dt(req.until),
187
+ offset_rate=int(state.get("offset_rate") or 0),
188
+ offset_peer=await _empty_peer(ctx, state),
189
+ offset_id=int(state.get("offset_id") or 0),
190
+ limit=limit,
191
+ broadcasts_only=(req.only == "channel") or None,
192
+ groups_only=(req.only == "group") or None,
193
+ users_only=(req.only == "user") or None,
194
+ folder_id=folder,
195
+ )
196
+ )
197
+ items = _hits(result, ctx)
198
+ return build_page(
199
+ items,
200
+ op="search.global",
201
+ kind=PageKind.RATE,
202
+ state=_next_state(result, items),
203
+ account=ctx.account,
204
+ limit=limit,
205
+ total=getattr(result, "count", None),
206
+ )
207
+
208
+
209
+ SPEC_GLOBAL = OperationSpec(
210
+ id="search.global",
211
+ request=GlobalReq,
212
+ response=Page[Message],
213
+ impl=search_global,
214
+ summary="Search messages across every chat",
215
+ description=(
216
+ "Pagination is the `(offset_rate, offset_peer, offset_id)` triple, "
217
+ "carried whole in `--cursor`; a message id alone would restart the "
218
+ "walk at the top. Global search never covers chats you have left or "
219
+ "secret chats, so an empty answer is not proof of absence."
220
+ ),
221
+ aliases=("search.message",),
222
+ paginated=PageKind.RATE,
223
+ columns=("id", "chat_id", "date", "text"),
224
+ example={"items": [_EXAMPLE_HIT], "has_more": False},
225
+ example_args="search global 'release notes'",
226
+ covers=(
227
+ "messages-core.search-calls-log",
228
+ "messages-core.search-global",
229
+ "messages-core.search-global-media-tabs",
230
+ "messages-core.search-global-scope",
231
+ "messages-core.search-sent-media",
232
+ ),
233
+ coverage_note=(
234
+ "`--community` (layer 229) is refused with NOT_SUPPORTED; every other "
235
+ "scope — users / groups / broadcasts / folder — works on layer 227."
236
+ ),
237
+ )
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # search hashtag
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ def _hashtag_path(ctx: OpContext) -> Any:
246
+ from pathlib import Path
247
+
248
+ paths = getattr(ctx, "paths", None)
249
+ if paths is None:
250
+ return None
251
+ return Path(paths.ensure_account_dir(ctx.account)) / _HASHTAG_FILE
252
+
253
+
254
+ def _recent_hashtags(ctx: OpContext) -> list[str]:
255
+ """The local hashtag history.
256
+
257
+ There is no `messages.getRecentHashtags` on this layer — the official
258
+ clients keep the list themselves — so tlgr keeps it in the account's own
259
+ directory rather than pretending the server remembers.
260
+ """
261
+ path = _hashtag_path(ctx)
262
+ if path is None or not path.exists():
263
+ return []
264
+ try:
265
+ value = json.loads(path.read_text(encoding="utf-8"))
266
+ except (OSError, ValueError):
267
+ return []
268
+ return [str(item) for item in value] if isinstance(value, list) else []
269
+
270
+
271
+ def _remember_hashtag(ctx: OpContext, tag: str) -> None:
272
+ from tlgr.core.paths import write_private
273
+
274
+ path = _hashtag_path(ctx)
275
+ if path is None or not tag:
276
+ return
277
+ history = [item for item in _recent_hashtags(ctx) if item != tag]
278
+ history.insert(0, tag)
279
+ write_private(path, json.dumps(history[:_HASHTAG_MAX]))
280
+
281
+
282
+ def _forget_hashtag(ctx: OpContext, tag: str) -> None:
283
+ from tlgr.core.paths import write_private
284
+
285
+ path = _hashtag_path(ctx)
286
+ if path is None:
287
+ return
288
+ history = [] if tag == "*" else [item for item in _recent_hashtags(ctx) if item != tag]
289
+ write_private(path, json.dumps(history))
290
+
291
+
292
+ class HashtagReq(Request):
293
+ tag: Annotated[str, arg(0, metavar="TAG", required=False, help="The hashtag or cashtag.")] = ""
294
+ chat: Annotated[
295
+ PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Search this chat only.")
296
+ ] = None
297
+ public: Annotated[
298
+ bool, opt("--public", help="Global public-post search (channels.searchPosts).")
299
+ ] = False
300
+ stories: Annotated[bool, opt("--stories", help="Search public stories instead.")] = False
301
+ recent: Annotated[
302
+ bool, opt("--recent", help="List recently searched hashtags instead of searching.")
303
+ ] = False
304
+ forget: Annotated[
305
+ str | None, opt("--forget", metavar="TAG|*", help="Remove a recent hashtag, or all.")
306
+ ] = None
307
+
308
+
309
+ async def search_hashtag(ctx: OpContext, req: HashtagReq) -> Page[Message]:
310
+ """Search a hashtag in a chat, or across public channel posts.
311
+
312
+ In-chat hashtag search is `messages.search` with `q="#tag"`; the public
313
+ form is `channels.searchPosts`, which pages on the same rate triple as
314
+ global search. The recently-searched list is local state — this layer has
315
+ no `getRecentHashtags` — kept in the account's own directory.
316
+ """
317
+ from telethon.tl import types
318
+ from telethon.tl.functions import channels as ch
319
+ from telethon.tl.functions import messages as fn
320
+
321
+ limit, state = window(ctx, "search.hashtag", PageKind.RATE)
322
+
323
+ if req.forget is not None:
324
+ _forget_hashtag(ctx, req.forget)
325
+ if req.recent:
326
+ # A local list of strings, reported as message stubs would be a lie;
327
+ # the tags come back as warnings-free `text` rows.
328
+ rows = [
329
+ Message(id=index, chat_id=0, date="", date_unix=0, text=tag)
330
+ for index, tag in enumerate(_recent_hashtags(ctx), start=1)
331
+ ]
332
+ return build_page(rows, op="search.hashtag", kind=PageKind.RATE, has_more=False)
333
+
334
+ if req.stories:
335
+ raise NotSupportedError(
336
+ "hashtag search over public stories answers with stories, not messages; "
337
+ "it belongs to the `story` group and lands with it"
338
+ )
339
+ tag = req.tag.strip()
340
+ if not tag:
341
+ raise UsageError("a hashtag is required", field="tag")
342
+ if not tag.startswith(("#", "$")):
343
+ tag = f"#{tag}"
344
+ _remember_hashtag(ctx, tag)
345
+
346
+ if req.chat is not None:
347
+ peer = await _send.resolve(ctx, req.chat)
348
+ chat_id = _send.peer_id_of(peer)
349
+ result = await client(ctx)(
350
+ fn.SearchRequest(
351
+ peer=peer,
352
+ q=tag,
353
+ filter=types.InputMessagesFilterEmpty(),
354
+ min_date=None,
355
+ max_date=None,
356
+ offset_id=int(state.get("offset_id") or 0),
357
+ add_offset=0,
358
+ limit=limit,
359
+ max_id=0,
360
+ min_id=0,
361
+ hash=0,
362
+ )
363
+ )
364
+ items = [
365
+ message_to_model(raw, chat_id=chat_id)
366
+ for raw in (getattr(result, "messages", None) or [])
367
+ ]
368
+ return build_page(
369
+ items,
370
+ op="search.hashtag",
371
+ kind=PageKind.RATE,
372
+ state={"offset_id": items[-1].id if items else 0},
373
+ account=ctx.account,
374
+ limit=limit,
375
+ total=getattr(result, "count", None),
376
+ )
377
+
378
+ result = await client(ctx)(
379
+ ch.SearchPostsRequest(
380
+ offset_rate=int(state.get("offset_rate") or 0),
381
+ offset_peer=await _empty_peer(ctx, state),
382
+ offset_id=int(state.get("offset_id") or 0),
383
+ limit=limit,
384
+ hashtag=tag.lstrip("#$"),
385
+ )
386
+ )
387
+ items = _hits(result, ctx)
388
+ return build_page(
389
+ items,
390
+ op="search.hashtag",
391
+ kind=PageKind.RATE,
392
+ state=_next_state(result, items),
393
+ account=ctx.account,
394
+ limit=limit,
395
+ total=getattr(result, "count", None),
396
+ )
397
+
398
+
399
+ SPEC_HASHTAG = OperationSpec(
400
+ id="search.hashtag",
401
+ request=HashtagReq,
402
+ response=Page[Message],
403
+ impl=search_hashtag,
404
+ summary="Search a hashtag or cashtag, in a chat or across public posts",
405
+ description=(
406
+ "Without `--chat` this is the global public-post search. The "
407
+ "recently-searched list is tlgr's own state: this layer has no "
408
+ "`getRecentHashtags`, and the official clients keep it locally too."
409
+ ),
410
+ paginated=PageKind.RATE,
411
+ tags=frozenset({"mutating-checked"}),
412
+ columns=("id", "chat_id", "date", "text"),
413
+ example={"items": [_EXAMPLE_HIT], "has_more": False},
414
+ example_args="search hashtag telegram",
415
+ covers=(
416
+ "contacts-users.search-hashtag-history",
417
+ "messages-core.search-hashtag-in-chat",
418
+ "messages-core.search-hashtag-public-posts",
419
+ "messages-core.search-recent-hashtags",
420
+ ),
421
+ coverage_note=(
422
+ "The two recent-hashtag ids have no MTProto method; they are tlgr's "
423
+ "own per-account state, which is what the official clients do too. "
424
+ "Story hashtag search is refused here and belongs to the story group."
425
+ ),
426
+ )
427
+
428
+
429
+ # ---------------------------------------------------------------------------
430
+ # search post
431
+ # ---------------------------------------------------------------------------
432
+
433
+
434
+ class PostReq(Request):
435
+ query: Annotated[str, arg(0, metavar="QUERY", help="What to look for.")]
436
+ quota: Annotated[
437
+ bool, opt("--quota", help="Report the free searches left and the Star price.")
438
+ ] = False
439
+ pay_stars: Annotated[
440
+ int | None,
441
+ opt("--pay-stars", metavar="N", help="Agree to spend N Stars on this search."),
442
+ ] = None
443
+
444
+
445
+ async def search_post(ctx: OpContext, req: PostReq) -> Page[Message]:
446
+ """Full-text search across all public channel posts.
447
+
448
+ Free while the quota lasts, then priced in Stars. Price discovery is free
449
+ and happens first; spending is never implicit, which is why `--pay-stars`
450
+ exists and why the command confirms like any other destructive one.
451
+ """
452
+ from telethon.tl.functions import channels as ch
453
+
454
+ limit, state = window(ctx, "search.post", PageKind.RATE)
455
+ flood = await client(ctx)(ch.CheckSearchPostsFloodRequest(query=req.query))
456
+ remaining = int(getattr(flood, "remains", 0) or 0)
457
+ price = int(getattr(flood, "stars_amount", 0) or 0)
458
+ free = bool(getattr(flood, "query_is_free", False)) or remaining > 0
459
+
460
+ if req.quota:
461
+ page: Page[Message] = Page(items=[], has_more=False)
462
+ ctx.warn(f"{remaining} free searches left; beyond that this query costs {price} Stars")
463
+ return page
464
+
465
+ if not free and req.pay_stars is None:
466
+ raise UsageError(
467
+ f"the free quota is exhausted and this search costs {price} Stars; "
468
+ f"pass --pay-stars {price} to agree to spend them",
469
+ field="pay-stars",
470
+ )
471
+ if req.pay_stars is not None and price and req.pay_stars < price:
472
+ raise UsageError(
473
+ f"--pay-stars {req.pay_stars} is below the {price} Stars this search costs",
474
+ field="pay-stars",
475
+ )
476
+
477
+ result = await client(ctx)(
478
+ ch.SearchPostsRequest(
479
+ offset_rate=int(state.get("offset_rate") or 0),
480
+ offset_peer=await _empty_peer(ctx, state),
481
+ offset_id=int(state.get("offset_id") or 0),
482
+ limit=limit,
483
+ query=req.query,
484
+ allow_paid_stars=req.pay_stars if not free else None,
485
+ )
486
+ )
487
+ items = _hits(result, ctx)
488
+ return build_page(
489
+ items,
490
+ op="search.post",
491
+ kind=PageKind.RATE,
492
+ state=_next_state(result, items),
493
+ account=ctx.account,
494
+ limit=limit,
495
+ total=getattr(result, "count", None),
496
+ )
497
+
498
+
499
+ SPEC_POST = OperationSpec(
500
+ id="search.post",
501
+ request=PostReq,
502
+ response=Page[Message],
503
+ impl=search_post,
504
+ summary="Full-text search across all public channel posts",
505
+ description=(
506
+ "Free while the quota lasts. `--quota` reports what is left and what "
507
+ "the query would cost, without spending anything; paying needs an "
508
+ "explicit `--pay-stars N`, because spending Stars is a human decision."
509
+ ),
510
+ paginated=PageKind.RATE,
511
+ mutating=True,
512
+ destructive=True,
513
+ rate_class="resolve",
514
+ columns=("id", "chat_id", "date", "text"),
515
+ example={"items": [_EXAMPLE_HIT], "has_more": False},
516
+ example_args="search post 'release notes'",
517
+ covers=(
518
+ "contacts-users.search-public-posts",
519
+ "messages-core.search-public-posts-fulltext",
520
+ ),
521
+ )