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/core/peers.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""Turning what a human typed into an `InputPeer` (§6.6).
|
|
2
|
+
|
|
3
|
+
Access hashes are **per account**. A resolver cache shared between accounts
|
|
4
|
+
hands account B a hash minted for account A, and the server answers
|
|
5
|
+
`PEER_ID_INVALID` for a peer that plainly exists — so there is one resolver
|
|
6
|
+
per account and the cache file lives under that account's directory.
|
|
7
|
+
|
|
8
|
+
The strategy order is cheapest-first, and each step exists because the one
|
|
9
|
+
before it cannot answer:
|
|
10
|
+
|
|
11
|
+
1. `me`/`saved` — free, and the only correct answer for `InputPeerSelf`.
|
|
12
|
+
2. the client's own entity cache and session table — free, no network.
|
|
13
|
+
3. `contacts.resolveUsername` — one round trip, cached for 24 h. Rate class
|
|
14
|
+
`resolve`, because this method floods at roughly fifty calls in a short
|
|
15
|
+
period and a chat list that resolves every `@handle` will hit it.
|
|
16
|
+
4. `contacts.resolvePhone` — works for a non-contact only if their privacy
|
|
17
|
+
allows it; falls back to scanning contacts.
|
|
18
|
+
5. `messages.checkChatInvite` for `t.me/+hash` — read-only, reports the chat
|
|
19
|
+
*without joining it*. Joining is `chat join`, which is a different verb
|
|
20
|
+
for a reason.
|
|
21
|
+
6. `t.me/c/<id>/<msg>` — pure arithmetic, no network.
|
|
22
|
+
7. a bare int — `getPeerDialogs` when a hash is cached, otherwise a bounded
|
|
23
|
+
dialog scan.
|
|
24
|
+
8. **min entities** — a user seen only inside a channel message has no usable
|
|
25
|
+
access hash, and Telethon gives up. Remembering `(chat_id, msg_id)` lets
|
|
26
|
+
the resolver build `InputPeerUserFromMessage` by hand, which is what makes
|
|
27
|
+
`chat posters` → `user get` work for a stranger.
|
|
28
|
+
|
|
29
|
+
The failure modes are deliberately two, not one. A peer the strategies
|
|
30
|
+
*exhausted* is `NOT_FOUND` (exit 5). A peer whose search was truncated,
|
|
31
|
+
flooded or errored is `INDETERMINATE` (exit 13) — "we could not establish
|
|
32
|
+
this" must never be reported as "no".
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import contextlib
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import re
|
|
41
|
+
import time
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from tlgr.core.errors import IndeterminateError, NotFoundError, UsageError
|
|
47
|
+
from tlgr.core.paths import write_private
|
|
48
|
+
from tlgr.models.peer import PeerRef, parse_peer_ref
|
|
49
|
+
|
|
50
|
+
log = logging.getLogger("tlgr.core.peers")
|
|
51
|
+
|
|
52
|
+
__all__ = ["PeerCache", "PeerResolver", "channel_id_from_link"]
|
|
53
|
+
|
|
54
|
+
#: A resolved username is stable for a day; an access hash is stable until the
|
|
55
|
+
#: account is re-logged-in, which is what makes this cache worth having.
|
|
56
|
+
USERNAME_TTL = 24 * 3600
|
|
57
|
+
|
|
58
|
+
_CHANNEL_MARK = -1000000000000
|
|
59
|
+
_LINK_C = re.compile(r"(?:t\.me|telegram\.me)/c/(\d+)(?:/(\d+))?")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def channel_id_from_link(text: str) -> tuple[int, int | None] | None:
|
|
63
|
+
"""`t.me/c/1234/56` → `(-1000000001234, 56)`. No network involved."""
|
|
64
|
+
match = _LINK_C.search(text)
|
|
65
|
+
if not match:
|
|
66
|
+
return None
|
|
67
|
+
raw = int(match.group(1))
|
|
68
|
+
message_id = int(match.group(2)) if match.group(2) else None
|
|
69
|
+
return _CHANNEL_MARK - raw, message_id
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class CachedPeer:
|
|
74
|
+
peer_id: int
|
|
75
|
+
kind: str
|
|
76
|
+
access_hash: int = 0
|
|
77
|
+
username: str = ""
|
|
78
|
+
resolved_at: float = 0.0
|
|
79
|
+
#: Where this user was seen, for `InputPeerUserFromMessage` (§6.6 step 8).
|
|
80
|
+
from_chat: int = 0
|
|
81
|
+
from_message: int = 0
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def fresh(self) -> bool:
|
|
85
|
+
return not self.username or (time.time() - self.resolved_at) < USERNAME_TTL
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PeerCache:
|
|
89
|
+
"""The per-account resolver cache, persisted as JSON.
|
|
90
|
+
|
|
91
|
+
JSON rather than SQLite because it is a few thousand rows read at start
|
|
92
|
+
and written on change; a second database file to keep consistent with the
|
|
93
|
+
session's is a cost with no benefit at this size.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
97
|
+
self.path = path
|
|
98
|
+
self.by_username: dict[str, CachedPeer] = {}
|
|
99
|
+
self.by_id: dict[int, CachedPeer] = {}
|
|
100
|
+
self._dirty = False
|
|
101
|
+
self.load()
|
|
102
|
+
|
|
103
|
+
def load(self) -> None:
|
|
104
|
+
if self.path is None or not self.path.exists():
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
raw = json.loads(self.path.read_text())
|
|
108
|
+
except (OSError, json.JSONDecodeError):
|
|
109
|
+
return
|
|
110
|
+
for entry in raw.get("peers", []) if isinstance(raw, dict) else []:
|
|
111
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
112
|
+
peer = CachedPeer(**entry)
|
|
113
|
+
self.by_id[peer.peer_id] = peer
|
|
114
|
+
if peer.username:
|
|
115
|
+
self.by_username[peer.username.lower()] = peer
|
|
116
|
+
|
|
117
|
+
def save(self) -> None:
|
|
118
|
+
if self.path is None or not self._dirty:
|
|
119
|
+
return
|
|
120
|
+
payload = {"peers": [vars(peer) for peer in self.by_id.values()]}
|
|
121
|
+
with contextlib.suppress(OSError):
|
|
122
|
+
write_private(self.path, json.dumps(payload))
|
|
123
|
+
self._dirty = False
|
|
124
|
+
|
|
125
|
+
def put(self, peer: CachedPeer) -> CachedPeer:
|
|
126
|
+
self.by_id[peer.peer_id] = peer
|
|
127
|
+
if peer.username:
|
|
128
|
+
self.by_username[peer.username.lower()] = peer
|
|
129
|
+
self._dirty = True
|
|
130
|
+
return peer
|
|
131
|
+
|
|
132
|
+
def get_username(self, username: str) -> CachedPeer | None:
|
|
133
|
+
peer = self.by_username.get(username.lower())
|
|
134
|
+
return peer if peer is not None and peer.fresh else None
|
|
135
|
+
|
|
136
|
+
def get_id(self, peer_id: int) -> CachedPeer | None:
|
|
137
|
+
return self.by_id.get(peer_id)
|
|
138
|
+
|
|
139
|
+
def remember_min(self, user_id: int, chat_id: int, message_id: int) -> None:
|
|
140
|
+
"""Record where a `min` user was seen so it can be addressed later."""
|
|
141
|
+
peer = self.by_id.get(user_id) or CachedPeer(peer_id=user_id, kind="user")
|
|
142
|
+
peer.from_chat = chat_id
|
|
143
|
+
peer.from_message = message_id
|
|
144
|
+
self.put(peer)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class PeerResolver:
|
|
149
|
+
"""One resolver per account. Never share one between accounts."""
|
|
150
|
+
|
|
151
|
+
client: Any
|
|
152
|
+
account: str = ""
|
|
153
|
+
cache: PeerCache = field(default_factory=PeerCache)
|
|
154
|
+
dialog_scan_max: int = 5000
|
|
155
|
+
#: Set by the daemon so a resolve is paced like the expensive call it is.
|
|
156
|
+
limiter: Any = None
|
|
157
|
+
|
|
158
|
+
async def resolve(
|
|
159
|
+
self,
|
|
160
|
+
ref: PeerRef | str,
|
|
161
|
+
*,
|
|
162
|
+
allow_network: bool = True,
|
|
163
|
+
want: str = "peer",
|
|
164
|
+
) -> Any:
|
|
165
|
+
"""The `InputPeer` for *ref*, or a classified failure."""
|
|
166
|
+
# An int is accepted as well as a string because ids come back from
|
|
167
|
+
# the models already parsed, and re-stringifying at each call site is
|
|
168
|
+
# a chance to drop the sign of a channel id.
|
|
169
|
+
parsed = parse_peer_ref(str(ref)) if isinstance(ref, (str, int)) else ref
|
|
170
|
+
kind = parsed.kind
|
|
171
|
+
|
|
172
|
+
if kind in ("self", "saved"):
|
|
173
|
+
from telethon.tl.types import InputPeerSelf
|
|
174
|
+
|
|
175
|
+
# Saved Messages *is* the self peer; there is no separate entity,
|
|
176
|
+
# which is why `me` and `saved` cannot be told apart downstream.
|
|
177
|
+
return InputPeerSelf()
|
|
178
|
+
|
|
179
|
+
if kind == "link":
|
|
180
|
+
# A `t.me/c/<id>/<msg>` link is arithmetic, not a lookup.
|
|
181
|
+
found = channel_id_from_link(parsed.raw)
|
|
182
|
+
if found is not None:
|
|
183
|
+
return await self._by_id(found[0], allow_network=allow_network)
|
|
184
|
+
|
|
185
|
+
if kind == "username":
|
|
186
|
+
return await self._by_username(
|
|
187
|
+
str(parsed.value), allow_network=allow_network, want=want
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if kind == "phone":
|
|
191
|
+
return await self._by_phone(str(parsed.value), allow_network=allow_network)
|
|
192
|
+
|
|
193
|
+
if kind == "id":
|
|
194
|
+
return await self._by_id(int(parsed.value), allow_network=allow_network)
|
|
195
|
+
|
|
196
|
+
if kind == "invite":
|
|
197
|
+
return await self._resolve_invite(parsed, allow_network=allow_network)
|
|
198
|
+
|
|
199
|
+
raise UsageError(f"cannot interpret {parsed.raw!r} as a peer", field="chat")
|
|
200
|
+
|
|
201
|
+
# -- strategies --------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
async def _cached_input(self, ref: Any) -> Any | None:
|
|
204
|
+
"""Telethon's own caches: the entity cache, then the session table."""
|
|
205
|
+
try:
|
|
206
|
+
return await self.client.get_input_entity(ref)
|
|
207
|
+
except (ValueError, TypeError):
|
|
208
|
+
return None
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
if _is_transport(exc):
|
|
211
|
+
raise
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
async def _by_username(self, username: str, *, allow_network: bool, want: str = "peer") -> Any:
|
|
215
|
+
handle = username.lstrip("@")
|
|
216
|
+
cached = self.cache.get_username(handle)
|
|
217
|
+
if cached is not None:
|
|
218
|
+
built = self._build(cached)
|
|
219
|
+
if built is not None:
|
|
220
|
+
return built
|
|
221
|
+
found = await self._cached_input(f"@{handle}")
|
|
222
|
+
if found is not None:
|
|
223
|
+
return found
|
|
224
|
+
if not allow_network:
|
|
225
|
+
raise IndeterminateError(
|
|
226
|
+
f"@{handle} is not in the local cache and network resolution was not allowed"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
from telethon.tl.functions.contacts import ResolveUsernameRequest
|
|
230
|
+
|
|
231
|
+
await self._pace("resolve")
|
|
232
|
+
try:
|
|
233
|
+
result = await self.client(ResolveUsernameRequest(handle))
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
if type(exc).__name__ in ("UsernameNotOccupiedError", "UsernameInvalidError"):
|
|
236
|
+
raise NotFoundError(f"no Telegram account or chat named @{handle}") from exc
|
|
237
|
+
raise
|
|
238
|
+
entity = _first_entity(result)
|
|
239
|
+
if entity is None:
|
|
240
|
+
raise NotFoundError(f"no Telegram account or chat named @{handle}")
|
|
241
|
+
self._remember(entity, username=handle)
|
|
242
|
+
return await self._cached_input(entity) or entity
|
|
243
|
+
|
|
244
|
+
async def _by_phone(self, phone: str, *, allow_network: bool) -> Any:
|
|
245
|
+
digits = "+" + "".join(c for c in phone if c.isdigit())
|
|
246
|
+
found = await self._cached_input(digits)
|
|
247
|
+
if found is not None:
|
|
248
|
+
return found
|
|
249
|
+
if not allow_network:
|
|
250
|
+
raise IndeterminateError(f"{_mask(digits)} is not cached and network was not allowed")
|
|
251
|
+
|
|
252
|
+
from telethon.tl.functions.contacts import ResolvePhoneRequest
|
|
253
|
+
|
|
254
|
+
await self._pace("resolve")
|
|
255
|
+
try:
|
|
256
|
+
result = await self.client(ResolvePhoneRequest(digits))
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
name = type(exc).__name__
|
|
259
|
+
if name in ("PhoneNotOccupiedError", "PhoneNumberInvalidError"):
|
|
260
|
+
raise NotFoundError(f"no Telegram account for {_mask(digits)}") from exc
|
|
261
|
+
# A privacy refusal is *not* proof the number has no account: the
|
|
262
|
+
# honest answer is "we could not establish it" (exit 13).
|
|
263
|
+
raise IndeterminateError(
|
|
264
|
+
f"could not resolve {_mask(digits)}: {exc}. "
|
|
265
|
+
"The number may exist but hide itself from lookups."
|
|
266
|
+
) from exc
|
|
267
|
+
entity = _first_entity(result)
|
|
268
|
+
if entity is None:
|
|
269
|
+
raise NotFoundError(f"no Telegram account for {_mask(digits)}")
|
|
270
|
+
self._remember(entity)
|
|
271
|
+
return await self._cached_input(entity) or entity
|
|
272
|
+
|
|
273
|
+
async def _by_id(self, peer_id: int, *, allow_network: bool) -> Any:
|
|
274
|
+
found = await self._cached_input(peer_id)
|
|
275
|
+
if found is not None:
|
|
276
|
+
return found
|
|
277
|
+
cached = self.cache.get_id(peer_id)
|
|
278
|
+
if cached is not None:
|
|
279
|
+
built = self._build(cached)
|
|
280
|
+
if built is not None:
|
|
281
|
+
return built
|
|
282
|
+
if not allow_network:
|
|
283
|
+
raise IndeterminateError(f"{peer_id} is not cached and network was not allowed")
|
|
284
|
+
return await self._scan_dialogs(peer_id)
|
|
285
|
+
|
|
286
|
+
async def _scan_dialogs(self, peer_id: int) -> Any:
|
|
287
|
+
"""The last resort, and the only thing that licenses a negative answer.
|
|
288
|
+
|
|
289
|
+
A scan that was cut short by the cap, a flood or an RPC error has not
|
|
290
|
+
proved anything, so it raises INDETERMINATE rather than NOT_FOUND.
|
|
291
|
+
"""
|
|
292
|
+
from telethon import utils
|
|
293
|
+
|
|
294
|
+
seen = 0
|
|
295
|
+
truncated = False
|
|
296
|
+
try:
|
|
297
|
+
async for dialog in self.client.iter_dialogs(limit=self.dialog_scan_max):
|
|
298
|
+
seen += 1
|
|
299
|
+
entity = getattr(dialog, "entity", None)
|
|
300
|
+
if entity is None:
|
|
301
|
+
continue
|
|
302
|
+
if utils.get_peer_id(entity) == peer_id:
|
|
303
|
+
self._remember(entity)
|
|
304
|
+
return await self._cached_input(entity) or entity
|
|
305
|
+
truncated = seen >= self.dialog_scan_max
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
raise IndeterminateError(
|
|
308
|
+
f"the dialog scan for {peer_id} failed after {seen} dialogs: {exc}"
|
|
309
|
+
) from exc
|
|
310
|
+
if truncated:
|
|
311
|
+
raise IndeterminateError(
|
|
312
|
+
f"{peer_id} was not among the first {self.dialog_scan_max} dialogs; "
|
|
313
|
+
"raise [limits] dialog_scan_max to search further"
|
|
314
|
+
)
|
|
315
|
+
raise NotFoundError(f"no chat or user with id {peer_id} is reachable from this account")
|
|
316
|
+
|
|
317
|
+
async def _resolve_invite(self, ref: PeerRef, *, allow_network: bool) -> Any:
|
|
318
|
+
"""Read an invite link without joining it."""
|
|
319
|
+
if not allow_network:
|
|
320
|
+
raise IndeterminateError("an invite link cannot be resolved without the network")
|
|
321
|
+
|
|
322
|
+
from telethon.tl.functions.messages import CheckChatInviteRequest
|
|
323
|
+
|
|
324
|
+
await self._pace("resolve")
|
|
325
|
+
try:
|
|
326
|
+
result = await self.client(CheckChatInviteRequest(ref.value))
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
if type(exc).__name__ == "InviteHashExpiredError":
|
|
329
|
+
raise NotFoundError("that invite link has expired") from exc
|
|
330
|
+
if type(exc).__name__ == "InviteHashInvalidError":
|
|
331
|
+
raise NotFoundError("that invite link is not valid") from exc
|
|
332
|
+
raise
|
|
333
|
+
chat = getattr(result, "chat", None)
|
|
334
|
+
if chat is None:
|
|
335
|
+
# `ChatInvite` (not `ChatInviteAlready`) describes a chat we are
|
|
336
|
+
# not in: there is nothing to address, and saying so beats
|
|
337
|
+
# inventing an InputPeer that every later call rejects.
|
|
338
|
+
raise NotFoundError(
|
|
339
|
+
"that invite is for a chat this account has not joined; run 'tlgr chat join' first"
|
|
340
|
+
)
|
|
341
|
+
self._remember(chat)
|
|
342
|
+
return await self._cached_input(chat) or chat
|
|
343
|
+
|
|
344
|
+
# -- min entities ------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def remember_from_message(self, user_id: int, chat_id: int, message_id: int) -> None:
|
|
347
|
+
self.cache.remember_min(user_id, chat_id, message_id)
|
|
348
|
+
self.cache.save()
|
|
349
|
+
|
|
350
|
+
def _build(self, cached: CachedPeer) -> Any | None:
|
|
351
|
+
"""An `InputPeer` from the cache, including the `*FromMessage` forms."""
|
|
352
|
+
from telethon.tl.types import (
|
|
353
|
+
InputPeerChannel,
|
|
354
|
+
InputPeerChat,
|
|
355
|
+
InputPeerUser,
|
|
356
|
+
InputPeerUserFromMessage,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if cached.access_hash:
|
|
360
|
+
if cached.kind == "user":
|
|
361
|
+
return InputPeerUser(_raw_id(cached.peer_id), cached.access_hash)
|
|
362
|
+
if cached.kind in ("channel", "supergroup"):
|
|
363
|
+
return InputPeerChannel(_raw_id(cached.peer_id), cached.access_hash)
|
|
364
|
+
if cached.kind == "group":
|
|
365
|
+
return InputPeerChat(_raw_id(cached.peer_id))
|
|
366
|
+
if cached.kind == "user" and cached.from_chat and cached.from_message:
|
|
367
|
+
# The whole point of remembering where a `min` user was seen:
|
|
368
|
+
# Telethon never builds this, so a stranger who posted in a
|
|
369
|
+
# channel is unaddressable without it.
|
|
370
|
+
peer = self.cache.get_id(cached.from_chat)
|
|
371
|
+
container = self._build(peer) if peer else None
|
|
372
|
+
if container is not None:
|
|
373
|
+
return InputPeerUserFromMessage(
|
|
374
|
+
peer=container,
|
|
375
|
+
msg_id=cached.from_message,
|
|
376
|
+
user_id=_raw_id(cached.peer_id),
|
|
377
|
+
)
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
def _remember(self, entity: Any, *, username: str = "") -> CachedPeer:
|
|
381
|
+
from telethon import utils
|
|
382
|
+
|
|
383
|
+
kind = type(entity).__name__.lower()
|
|
384
|
+
if kind == "channel":
|
|
385
|
+
kind = "supergroup" if getattr(entity, "megagroup", False) else "channel"
|
|
386
|
+
elif kind == "chat":
|
|
387
|
+
kind = "group"
|
|
388
|
+
peer = CachedPeer(
|
|
389
|
+
peer_id=utils.get_peer_id(entity),
|
|
390
|
+
kind=kind,
|
|
391
|
+
access_hash=int(getattr(entity, "access_hash", 0) or 0),
|
|
392
|
+
username=username or (getattr(entity, "username", "") or ""),
|
|
393
|
+
resolved_at=time.time(),
|
|
394
|
+
)
|
|
395
|
+
self.cache.put(peer)
|
|
396
|
+
self.cache.save()
|
|
397
|
+
return peer
|
|
398
|
+
|
|
399
|
+
async def _pace(self, rate_class: str) -> None:
|
|
400
|
+
if self.limiter is not None:
|
|
401
|
+
await self.limiter.acquire(rate_class)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _raw_id(marked: int) -> int:
|
|
405
|
+
"""The unmarked id Telethon's `Input*` constructors want."""
|
|
406
|
+
if marked < _CHANNEL_MARK:
|
|
407
|
+
return _CHANNEL_MARK - marked
|
|
408
|
+
if marked < 0:
|
|
409
|
+
return -marked
|
|
410
|
+
return marked
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _first_entity(result: Any) -> Any | None:
|
|
414
|
+
for attribute in ("users", "chats"):
|
|
415
|
+
found = getattr(result, attribute, None) or ()
|
|
416
|
+
if found:
|
|
417
|
+
return found[0]
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _is_transport(exc: BaseException) -> bool:
|
|
422
|
+
return isinstance(exc, ConnectionError) or "disconnected" in str(exc).lower()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _mask(phone: str) -> str:
|
|
426
|
+
digits = "".join(c for c in phone if c.isdigit())
|
|
427
|
+
return f"+{digits[:3]}…{digits[-2:]}" if len(digits) >= 6 else "+***"
|
tlgr/core/process.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Daemonising, pid files, signals — the parts that must happen in order.
|
|
2
|
+
|
|
3
|
+
Three v1 bugs are fixed by ordering alone:
|
|
4
|
+
|
|
5
|
+
* **`os.umask(0)` inside `daemonize()`** made every file the daemon created
|
|
6
|
+
afterwards world-writable, including the log and the socket. The umask is
|
|
7
|
+
now `0o077`, set in `main()` *before* anything creates a file (SEC-01).
|
|
8
|
+
* **the log was opened 0644** and `basicConfig` was called with two handlers
|
|
9
|
+
from two entry points, so lines were duplicated (COR-40). Logging is now
|
|
10
|
+
configured once, by `core/logging.py`, into a 0600 rotating file.
|
|
11
|
+
* **`atexit` unlinked the socket and pid file unconditionally**, so a second
|
|
12
|
+
daemon that exited because one was already running deleted the *running*
|
|
13
|
+
one's files on the way out. Cleanup now only removes files this process
|
|
14
|
+
wrote (COR-14).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import atexit
|
|
20
|
+
import contextlib
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import signal
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from tlgr.core.logging import setup_logging as _setup_logging
|
|
28
|
+
from tlgr.core.paths import TlgrPaths, write_private
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("tlgr.daemon")
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"daemonize",
|
|
34
|
+
"read_pid",
|
|
35
|
+
"remove_pid",
|
|
36
|
+
"setup_logging",
|
|
37
|
+
"stop_daemon",
|
|
38
|
+
"write_pid",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_pid(base: Path | None = None) -> None:
|
|
43
|
+
"""Write our pid, and arrange to remove *only our own* file at exit."""
|
|
44
|
+
paths = TlgrPaths(base)
|
|
45
|
+
pid = os.getpid()
|
|
46
|
+
write_private(paths.pid, f"{pid}\n")
|
|
47
|
+
|
|
48
|
+
def cleanup() -> None:
|
|
49
|
+
# Re-read before deleting: if another daemon took over the file, the
|
|
50
|
+
# pid in it is not ours and the file is not ours to remove.
|
|
51
|
+
with contextlib.suppress(OSError, ValueError):
|
|
52
|
+
if int(paths.pid.read_text().strip()) == pid:
|
|
53
|
+
paths.pid.unlink(missing_ok=True)
|
|
54
|
+
|
|
55
|
+
atexit.register(cleanup)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def remove_pid(base: Path | None = None) -> None:
|
|
59
|
+
paths = TlgrPaths(base)
|
|
60
|
+
with contextlib.suppress(OSError, ValueError):
|
|
61
|
+
if int(paths.pid.read_text().strip()) == os.getpid():
|
|
62
|
+
paths.pid.unlink(missing_ok=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_pid(base: Path | None = None) -> int | None:
|
|
66
|
+
"""The pid of a live daemon, or None. Never deletes anything.
|
|
67
|
+
|
|
68
|
+
v1 deleted the pid file whenever `kill(pid, 0)` failed — including on
|
|
69
|
+
`PermissionError`, which means the process exists and belongs to someone
|
|
70
|
+
else. Reading is now a read (COR-14c).
|
|
71
|
+
"""
|
|
72
|
+
paths = TlgrPaths(base)
|
|
73
|
+
try:
|
|
74
|
+
pid = int(paths.pid.read_text().strip())
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
if pid <= 0:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
os.kill(pid, 0)
|
|
81
|
+
except ProcessLookupError:
|
|
82
|
+
return None
|
|
83
|
+
except PermissionError:
|
|
84
|
+
return pid
|
|
85
|
+
return pid
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def daemonize(base: Path | None = None) -> None:
|
|
89
|
+
"""Double-fork into the background, with stdio pointed at the log.
|
|
90
|
+
|
|
91
|
+
The umask is *not* touched here: `main()` sets `0o077` before anything is
|
|
92
|
+
created, and resetting it in the middle of daemonising is how v1 ended up
|
|
93
|
+
with a world-writable socket.
|
|
94
|
+
"""
|
|
95
|
+
paths = TlgrPaths(base)
|
|
96
|
+
if os.fork() > 0:
|
|
97
|
+
os._exit(0)
|
|
98
|
+
os.setsid()
|
|
99
|
+
if os.fork() > 0:
|
|
100
|
+
os._exit(0)
|
|
101
|
+
|
|
102
|
+
paths.ensure_logs()
|
|
103
|
+
log_file = paths.log_file
|
|
104
|
+
if not log_file.exists():
|
|
105
|
+
log_file.touch(mode=0o600)
|
|
106
|
+
|
|
107
|
+
with contextlib.suppress(OSError):
|
|
108
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
109
|
+
os.dup2(devnull, sys.stdin.fileno())
|
|
110
|
+
os.close(devnull)
|
|
111
|
+
|
|
112
|
+
fd = os.open(str(log_file), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
113
|
+
os.dup2(fd, sys.stdout.fileno())
|
|
114
|
+
os.dup2(fd, sys.stderr.fileno())
|
|
115
|
+
os.close(fd)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def setup_logging(
|
|
119
|
+
base: Path | None = None, level: str = "info", *, foreground: bool = False
|
|
120
|
+
) -> None:
|
|
121
|
+
paths = TlgrPaths(base)
|
|
122
|
+
paths.ensure_logs()
|
|
123
|
+
_setup_logging(paths.log_file, level=level, stderr=foreground)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def stop_daemon(base: Path | None = None) -> bool:
|
|
127
|
+
"""SIGTERM the daemon. True when the signal was delivered."""
|
|
128
|
+
pid = read_pid(base)
|
|
129
|
+
if pid is None:
|
|
130
|
+
return False
|
|
131
|
+
try:
|
|
132
|
+
os.kill(pid, signal.SIGTERM)
|
|
133
|
+
return True
|
|
134
|
+
except ProcessLookupError:
|
|
135
|
+
return False
|
|
136
|
+
except PermissionError:
|
|
137
|
+
log.warning("the daemon (pid %s) belongs to another user; not signalling it", pid)
|
|
138
|
+
return False
|
tlgr/core/signing.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The webhook signature, in one place.
|
|
2
|
+
|
|
3
|
+
Part of the wire contract rather than of the pusher: `daemon/webhook.py` signs
|
|
4
|
+
deliveries with it and `ops/webhook.py` shows a receiver what a signature will
|
|
5
|
+
look like, and `ops/` may not import `daemon/` (§2.2).
|
|
6
|
+
|
|
7
|
+
SEC-08 is what it fixes. v1 sent events with no signature at all, so any
|
|
8
|
+
process that learned the URL could forge them; a bearer token would only have
|
|
9
|
+
proved the sender knew a string, not that the body was unmodified.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
|
|
17
|
+
__all__ = ["sign_body", "verify_body"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sign_body(secret: str, body: bytes) -> str:
|
|
21
|
+
"""`sha256=<hex>` over the exact bytes that go on the wire.
|
|
22
|
+
|
|
23
|
+
Over the *bytes*, not over a re-encoded dict: a receiver verifies what it
|
|
24
|
+
received, and any re-encoding — key order, whitespace, escaping — makes an
|
|
25
|
+
honest signature fail.
|
|
26
|
+
"""
|
|
27
|
+
digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
28
|
+
return f"sha256={digest}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def verify_body(secret: str, body: bytes, signature: str) -> bool:
|
|
32
|
+
"""Constant-time check, for a receiver written in Python.
|
|
33
|
+
|
|
34
|
+
Exported because "verify the signature" is advice everybody follows
|
|
35
|
+
slightly differently, and `==` on two hex strings is the difference
|
|
36
|
+
between a check and a timing oracle.
|
|
37
|
+
"""
|
|
38
|
+
return hmac.compare_digest(sign_body(secret, body), signature.strip())
|
tlgr/core/systemd.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""A systemd **user** unit for the daemon (§6.12).
|
|
2
|
+
|
|
3
|
+
User, not system: the daemon holds session files under `$HOME` and must run as
|
|
4
|
+
the person who owns them. A system unit would need `User=`, a home directory
|
|
5
|
+
override and a login session for the keyring, and would still be the wrong
|
|
6
|
+
security boundary.
|
|
7
|
+
|
|
8
|
+
`Type=simple` with `--foreground`, not `Type=notify`: notify needs
|
|
9
|
+
`sd_notify`, which means either a C extension or hand-rolling the socket
|
|
10
|
+
protocol, and buys nothing here — systemd's own restart logic is driven by the
|
|
11
|
+
process exiting, which is what `Restart=on-failure` already watches.
|
|
12
|
+
|
|
13
|
+
`idle_timeout` is forced to 0 under a supervisor (see `idle.py`): an idle exit
|
|
14
|
+
is either a respawn loop or, with the wrong `Restart=` policy, a daemon that
|
|
15
|
+
never comes back. That is COR-39, and the unit and the config agree about it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
SERVICE_NAME = "tlgr.service"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"SERVICE_NAME",
|
|
29
|
+
"install",
|
|
30
|
+
"is_installed",
|
|
31
|
+
"restart",
|
|
32
|
+
"uninstall",
|
|
33
|
+
"unit_path",
|
|
34
|
+
"unit_text",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def unit_path() -> Path:
|
|
39
|
+
config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
40
|
+
return Path(config_home) / "systemd" / "user" / SERVICE_NAME
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def unit_text(base: Path, *, python: str | None = None) -> str:
|
|
44
|
+
executable = python or sys.executable
|
|
45
|
+
return f"""[Unit]
|
|
46
|
+
Description=tlgr Telegram daemon
|
|
47
|
+
Documentation=https://github.com/tlgrcli/tlgr
|
|
48
|
+
After=network-online.target
|
|
49
|
+
|
|
50
|
+
[Service]
|
|
51
|
+
Type=simple
|
|
52
|
+
ExecStart={executable} -m tlgr.daemon.main --base {base} --foreground
|
|
53
|
+
Restart=on-failure
|
|
54
|
+
RestartSec=5
|
|
55
|
+
# The daemon sets its own umask before creating anything; this is the
|
|
56
|
+
# belt-and-braces copy for the files systemd itself creates.
|
|
57
|
+
UMask=0077
|
|
58
|
+
KillSignal=SIGTERM
|
|
59
|
+
TimeoutStopSec=45
|
|
60
|
+
|
|
61
|
+
[Install]
|
|
62
|
+
WantedBy=default.target
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_installed() -> bool:
|
|
67
|
+
return unit_path().exists()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def install(base: Path, *, python: str | None = None, enable: bool = True) -> Path:
|
|
71
|
+
path = unit_path()
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
path.write_text(unit_text(base, python=python))
|
|
74
|
+
path.chmod(0o644)
|
|
75
|
+
_systemctl("daemon-reload")
|
|
76
|
+
if enable:
|
|
77
|
+
_systemctl("enable", "--now", SERVICE_NAME)
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def uninstall() -> bool:
|
|
82
|
+
path = unit_path()
|
|
83
|
+
if not path.exists():
|
|
84
|
+
return False
|
|
85
|
+
_systemctl("disable", "--now", SERVICE_NAME)
|
|
86
|
+
path.unlink()
|
|
87
|
+
_systemctl("daemon-reload")
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def restart() -> None:
|
|
92
|
+
_systemctl("restart", SERVICE_NAME)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _systemctl(*args: str) -> subprocess.CompletedProcess[bytes]:
|
|
96
|
+
return subprocess.run(["systemctl", "--user", *args], capture_output=True, check=False)
|