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