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/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
|
+
)
|