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/identity.py ADDED
@@ -0,0 +1,127 @@
1
+ """The strings tlgr sends in `initConnection`, and why they are honest.
2
+
3
+ Telegram shows `device_model` / `system_version` / `app_version` in
4
+ Settings → Devices. Two rules govern what goes there:
5
+
6
+ * **Never an official app's identity.** Borrowing an official `api_id` or
7
+ spoofing `device_model` to obtain official-app behaviour is a ToS violation
8
+ that gets accounts banned (§1.2). tlgr says what it is.
9
+ * **Stable across restarts.** Telethon's defaults are derived from
10
+ `platform.uname()` at each start; anything that varies (a container
11
+ hostname, a kernel patch level) makes the Devices entry churn, and a user
12
+ who checks their sessions sees a new "device" every reboot. The resolved
13
+ strings are therefore written once to `identity.json` and reused.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import json
20
+ import locale
21
+ import platform
22
+ import time
23
+ from dataclasses import asdict, dataclass
24
+ from pathlib import Path
25
+
26
+ from tlgr.core.paths import TlgrPaths, write_private
27
+
28
+ __all__ = ["Identity", "load_identity"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Identity:
33
+ device_model: str
34
+ system_version: str
35
+ app_version: str
36
+ lang_code: str
37
+ system_lang_code: str
38
+
39
+ def params(self) -> dict[str, int]:
40
+ """`initConnection.params` — the tz offset official clients send."""
41
+ return {"tz_offset": -time.timezone if not time.daylight else -time.altzone}
42
+
43
+
44
+ def _derive_device_model() -> str:
45
+ node = platform.node().split(".")[0] or "unknown-host"
46
+ machine = platform.machine() or "unknown"
47
+ return f"{node} ({machine})"[:64]
48
+
49
+
50
+ def _derive_system_version() -> str:
51
+ system = platform.system()
52
+ if system == "Darwin":
53
+ release = platform.mac_ver()[0] or platform.release()
54
+ return f"macOS {release}"[:64]
55
+ if system == "Linux":
56
+ # `platform.freedesktop_os_release()` needs 3.10+ and is absent in a
57
+ # bare container; the kernel release is always there.
58
+ try:
59
+ info = platform.freedesktop_os_release()
60
+ name = info.get("PRETTY_NAME") or info.get("NAME") or "Linux"
61
+ except (OSError, AttributeError):
62
+ name = "Linux"
63
+ return f"{name} ({platform.release()})"[:64]
64
+ return f"{system} {platform.release()}"[:64]
65
+
66
+
67
+ def _derive_lang() -> tuple[str, str]:
68
+ try:
69
+ code = (locale.getlocale()[0] or "").split("_")[0].lower()
70
+ except (ValueError, TypeError): # pragma: no cover - locale is odd on some CI images
71
+ code = ""
72
+ lang = code if len(code) == 2 else "en"
73
+ return lang, lang
74
+
75
+
76
+ def load_identity(
77
+ base: Path | None = None,
78
+ *,
79
+ device_model: str = "",
80
+ system_version: str = "",
81
+ lang_code: str = "",
82
+ system_lang_code: str = "",
83
+ app_version: str = "",
84
+ ) -> Identity:
85
+ """Resolve the identity, preferring config, then the cache, then the OS.
86
+
87
+ The cache is written the first time so that the value survives an OS
88
+ upgrade: what matters to the Devices list is that the string does not
89
+ change, not that it stays accurate to the patch level.
90
+ """
91
+ from tlgr import __version__
92
+
93
+ paths = TlgrPaths(base)
94
+ cached: dict[str, str] = {}
95
+ if paths.identity.exists():
96
+ try:
97
+ loaded = json.loads(paths.identity.read_text())
98
+ if isinstance(loaded, dict):
99
+ cached = {str(k): str(v) for k, v in loaded.items()}
100
+ except (OSError, json.JSONDecodeError):
101
+ cached = {}
102
+
103
+ derived_lang, derived_system_lang = _derive_lang()
104
+ if not app_version:
105
+ try:
106
+ import telethon
107
+
108
+ app_version = f"tlgr {__version__} (Telethon {telethon.__version__})"
109
+ except Exception: # pragma: no cover - telethon is a hard dependency
110
+ app_version = f"tlgr {__version__}"
111
+
112
+ identity = Identity(
113
+ device_model=device_model or cached.get("device_model") or _derive_device_model(),
114
+ system_version=system_version or cached.get("system_version") or _derive_system_version(),
115
+ app_version=app_version,
116
+ lang_code=lang_code or cached.get("lang_code") or derived_lang,
117
+ system_lang_code=(
118
+ system_lang_code or cached.get("system_lang_code") or derived_system_lang
119
+ ),
120
+ )
121
+
122
+ if cached != asdict(identity):
123
+ # A read-only home must not stop a login; the identity is then
124
+ # re-derived next time, which is stable anyway on a stable machine.
125
+ with contextlib.suppress(OSError):
126
+ write_private(paths.identity, json.dumps(asdict(identity), indent=2))
127
+ return identity
tlgr/core/launchd.py ADDED
@@ -0,0 +1,122 @@
1
+ """macOS LaunchAgent management for the tlgr daemon (§6.12).
2
+
3
+ `KeepAlive.SuccessfulExit = false` means "restart it unless it exited 0",
4
+ which is only safe if the daemon never exits 0 on its own. It did: the idle
5
+ monitor stopped it after thirty minutes, launchd saw a clean exit, and the
6
+ daemon never came back — jobs silently stopped and the webhook silently
7
+ unsubscribed until someone noticed. That is COR-39.
8
+
9
+ Two changes close it. The plist passes `--base`, and the daemon forces
10
+ `idle_timeout` to 0 whenever a supervisor owns it, so the clean exit that
11
+ launchd will not restart cannot happen. And a manually started second daemon
12
+ exits 0 with "already running" rather than 1, so `KeepAlive` does not turn a
13
+ duplicate start into a respawn loop.
14
+
15
+ The `ExecStart` also runs `tlgr.daemon.main`, not `tlgr.daemon.server`; the
16
+ latter still works as an alias, and an already-installed v1 plist keeps
17
+ running until it is reinstalled.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import plistlib
24
+ import subprocess
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ SERVICE_LABEL = "dev.tlgr.daemon"
29
+ PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{SERVICE_LABEL}.plist"
30
+
31
+
32
+ def _python_executable() -> str:
33
+ """Return the absolute path to the current Python interpreter."""
34
+ return sys.executable
35
+
36
+
37
+ def _build_plist(base: Path, log_dir: Path) -> dict:
38
+ log_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
39
+ return {
40
+ "Label": SERVICE_LABEL,
41
+ "ProgramArguments": [
42
+ _python_executable(),
43
+ "-m",
44
+ "tlgr.daemon.main",
45
+ "--base",
46
+ str(base),
47
+ "--foreground",
48
+ ],
49
+ "RunAtLoad": True,
50
+ "KeepAlive": {"SuccessfulExit": False},
51
+ "ThrottleInterval": 30,
52
+ # Tells the daemon a supervisor owns it, which forces idle_timeout to
53
+ # 0 — the other half of the COR-39 fix.
54
+ "EnvironmentVariables": {"XPC_SERVICE_NAME": SERVICE_LABEL},
55
+ "StandardOutPath": str(log_dir / "daemon.log"),
56
+ "StandardErrorPath": str(log_dir / "daemon.log"),
57
+ }
58
+
59
+
60
+ def is_installed() -> bool:
61
+ return PLIST_PATH.exists()
62
+
63
+
64
+ def is_loaded() -> bool:
65
+ result = subprocess.run(
66
+ ["launchctl", "print", f"gui/{os.getuid()}/{SERVICE_LABEL}"],
67
+ capture_output=True,
68
+ )
69
+ return result.returncode == 0
70
+
71
+
72
+ def install(base: Path, log_dir: Path) -> Path:
73
+ """Write the plist and load it into launchd. Returns the plist path."""
74
+ if is_loaded():
75
+ unload()
76
+
77
+ PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
78
+ plist_data = _build_plist(base, log_dir)
79
+ with open(PLIST_PATH, "wb") as f:
80
+ plistlib.dump(plist_data, f)
81
+
82
+ _load()
83
+ return PLIST_PATH
84
+
85
+
86
+ def uninstall() -> bool:
87
+ """Unload and remove the plist. Returns True if anything was removed."""
88
+ removed = False
89
+ if is_loaded():
90
+ unload()
91
+ removed = True
92
+ if PLIST_PATH.exists():
93
+ PLIST_PATH.unlink()
94
+ removed = True
95
+ return removed
96
+
97
+
98
+ def _load() -> None:
99
+ subprocess.run(
100
+ ["launchctl", "bootstrap", f"gui/{os.getuid()}", str(PLIST_PATH)],
101
+ check=True,
102
+ )
103
+
104
+
105
+ def unload() -> None:
106
+ subprocess.run(
107
+ ["launchctl", "bootout", f"gui/{os.getuid()}/{SERVICE_LABEL}"],
108
+ capture_output=True,
109
+ )
110
+
111
+
112
+ def kickstart() -> None:
113
+ """Force-(re)start the service via launchctl."""
114
+ subprocess.run(
115
+ [
116
+ "launchctl",
117
+ "kickstart",
118
+ "-k",
119
+ f"gui/{os.getuid()}/{SERVICE_LABEL}",
120
+ ],
121
+ check=True,
122
+ )
tlgr/core/logging.py ADDED
@@ -0,0 +1,194 @@
1
+ """Structured, rotating, redacted logs — configured exactly once.
2
+
3
+ Three v1 problems die here:
4
+
5
+ * **SEC-05/06.** The daemon logged message text, phone numbers and webhook
6
+ tokens at INFO into a 0644 file that grew without bound. Logs are now JSON
7
+ lines in a 0600 rotating file, and every record passes a redaction filter.
8
+ * **COR-40.** `logging.basicConfig` was called with two handlers and then
9
+ called again by another entry point, so lines were duplicated and the file
10
+ handler was attached twice. `setup_logging()` is idempotent: it owns the
11
+ root logger's handlers and replaces them.
12
+ * Redaction is an **allow-list**. A blocklist of patterns over free-form
13
+ message text is not a control — one Persian message with a phone number
14
+ spelled in Eastern Arabic digits defeats it. Only the fields named in
15
+ `SAFE_FIELDS` survive into the record's `extra`; everything else is dropped,
16
+ and the message itself is scrubbed of the few high-value literals that do
17
+ have a reliable shape (tokens, auth keys, `access_hash`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import json
24
+ import logging
25
+ import logging.handlers
26
+ import os
27
+ import re
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ __all__ = [
33
+ "SAFE_FIELDS",
34
+ "PrivateRotatingFileHandler",
35
+ "RedactionFilter",
36
+ "setup_logging",
37
+ ]
38
+
39
+ #: The only keys allowed out of a log record's structured payload.
40
+ SAFE_FIELDS = frozenset(
41
+ {
42
+ "account",
43
+ "op",
44
+ "request_id",
45
+ "elapsed_ms",
46
+ "status",
47
+ "code",
48
+ "exit_code",
49
+ "state",
50
+ "reason",
51
+ "seq",
52
+ "chat_id",
53
+ "peer_id",
54
+ "count",
55
+ "attempt",
56
+ "pid",
57
+ "uid",
58
+ "path",
59
+ "method",
60
+ "rate_class",
61
+ "wait_seconds",
62
+ "event_type",
63
+ "delivery_id",
64
+ "alias",
65
+ "version",
66
+ "protocol",
67
+ }
68
+ )
69
+
70
+ _REDACTIONS: tuple[tuple[re.Pattern[str], str], ...] = (
71
+ (
72
+ re.compile(r"(?i)\b(auth_?key|api_?hash|access_?hash|file_?reference)\b\s*[=:]\s*\S+"),
73
+ r"\1=<redacted>",
74
+ ),
75
+ (re.compile(r"(?i)\b(token|secret|password|passwd)\b\s*[=:]\s*\S+"), r"\1=<redacted>"),
76
+ (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]+"), "Bearer <redacted>"),
77
+ (re.compile(r"\+\d[\d\s\-()]{6,}\d"), "<phone>"),
78
+ )
79
+
80
+ _PLACEHOLDER = "<redacted>"
81
+
82
+
83
+ class RedactionFilter(logging.Filter):
84
+ """Scrub a record in place. Never disabled by `--verbose`."""
85
+
86
+ def __init__(self, enabled: bool = True) -> None:
87
+ super().__init__()
88
+ self.enabled = enabled
89
+
90
+ def filter(self, record: logging.LogRecord) -> bool:
91
+ if not self.enabled:
92
+ return True
93
+ try:
94
+ message = record.getMessage()
95
+ except Exception: # pragma: no cover - a broken %-format is not our problem
96
+ return True
97
+ for pattern, replacement in _REDACTIONS:
98
+ message = pattern.sub(replacement, message)
99
+ record.msg = message
100
+ record.args = ()
101
+ return True
102
+
103
+
104
+ class PrivateRotatingFileHandler(logging.handlers.RotatingFileHandler):
105
+ """A rotating handler whose files are 0600 — including after a rollover.
106
+
107
+ `RotatingFileHandler` reopens the log through `open()`, which applies the
108
+ process umask. A daemon started from a shell with a permissive umask, or
109
+ one whose parent widened it, therefore ends up with a world-readable log
110
+ the first time it rotates — and a tlgr log names accounts, chats and
111
+ peers. Chmod-ing on every open is cheap and closes the window.
112
+ """
113
+
114
+ def _open(self) -> Any:
115
+ stream = super()._open()
116
+ with contextlib.suppress(OSError):
117
+ os.chmod(self.baseFilename, 0o600)
118
+ return stream
119
+
120
+ def doRollover(self) -> None:
121
+ super().doRollover()
122
+ for index in range(1, self.backupCount + 1):
123
+ with contextlib.suppress(OSError):
124
+ os.chmod(f"{self.baseFilename}.{index}", 0o600)
125
+
126
+
127
+ class JsonFormatter(logging.Formatter):
128
+ """One JSON object per line, with only allow-listed extras."""
129
+
130
+ def format(self, record: logging.LogRecord) -> str:
131
+ payload: dict[str, Any] = {
132
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
133
+ "level": record.levelname.lower(),
134
+ "logger": record.name,
135
+ "msg": record.getMessage(),
136
+ }
137
+ for key, value in record.__dict__.items():
138
+ if key in SAFE_FIELDS and value is not None:
139
+ payload[key] = value
140
+ if record.exc_info:
141
+ payload["exc"] = self.formatException(record.exc_info)
142
+ return json.dumps(payload, ensure_ascii=False, default=str)
143
+
144
+
145
+ def setup_logging(
146
+ log_file: Path | None,
147
+ *,
148
+ level: str = "info",
149
+ stderr: bool = False,
150
+ redact: bool = True,
151
+ max_bytes: int = 8 * 1024 * 1024,
152
+ backups: int = 5,
153
+ ) -> logging.Logger:
154
+ """Install the daemon's logging configuration, replacing anything present.
155
+
156
+ Returns the root logger. Passing `log_file=None` gives stderr only, which
157
+ is what `--foreground` in a test wants.
158
+ """
159
+ root = logging.getLogger()
160
+ for handler in list(root.handlers):
161
+ root.removeHandler(handler)
162
+ with contextlib.suppress(Exception): # pragma: no cover
163
+ handler.close()
164
+
165
+ root.setLevel(getattr(logging, level.upper(), logging.INFO))
166
+ redaction = RedactionFilter(redact)
167
+
168
+ if log_file is not None:
169
+ log_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
170
+ # Create the file privately *before* the handler opens it: the handler
171
+ # would otherwise create it with the process umask, and a daemon whose
172
+ # umask was widened by its parent would leave a readable log.
173
+ if not log_file.exists():
174
+ log_file.touch(mode=0o600)
175
+ else:
176
+ with contextlib.suppress(OSError): # pragma: no cover
177
+ os.chmod(log_file, 0o600)
178
+ file_handler = PrivateRotatingFileHandler(
179
+ str(log_file), maxBytes=max_bytes, backupCount=backups, encoding="utf-8"
180
+ )
181
+ file_handler.setFormatter(JsonFormatter())
182
+ file_handler.addFilter(redaction)
183
+ root.addHandler(file_handler)
184
+
185
+ if stderr or log_file is None:
186
+ stream = logging.StreamHandler(sys.stderr)
187
+ stream.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
188
+ stream.addFilter(redaction)
189
+ root.addHandler(stream)
190
+
191
+ # aiohttp's access log is off (SEC-05); its client session warnings are not
192
+ # interesting at INFO either.
193
+ logging.getLogger("aiohttp.access").disabled = True
194
+ return root
tlgr/core/media.py ADDED
@@ -0,0 +1,134 @@
1
+ """Reading a media file's own metadata, without sending it anywhere.
2
+
3
+ Lives in `core/` rather than in the daemon's file pipeline because the send
4
+ path needs it too and `ops/` may not import `daemon/` (§2.2). It is also the
5
+ one place that knows the difference between "this file has no duration" and
6
+ "nothing on this machine can read a duration": a video sent with
7
+ `duration=0, w=1, h=1` renders in every Telegram client as a 1x1 black
8
+ rectangle, which is worse than sending it as a plain document.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ __all__ = ["infer_attributes", "probe", "probe_warnings"]
18
+
19
+
20
+ def infer_attributes(path: Path) -> tuple[dict[str, Any], list[str]]:
21
+ """Best-effort media metadata, and the warnings for what could not be read.
22
+
23
+ A video sent with `duration=0, w=1, h=1` renders in every Telegram client
24
+ as a 1×1 black rectangle. That is worse than a plain document, so when
25
+ nothing can read the file the caller is *told* — the warning is the
26
+ feature, not the fallback.
27
+ """
28
+ facts: dict[str, Any] = {}
29
+ warnings: list[str] = []
30
+ suffix = path.suffix.lower()
31
+
32
+ if suffix in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"):
33
+ try:
34
+ from PIL import Image
35
+
36
+ with Image.open(path) as image:
37
+ facts["width"], facts["height"] = image.size
38
+ except Exception:
39
+ warnings.append(
40
+ "install the [media] extra (pillow) for image dimensions; sending without them"
41
+ )
42
+ return facts, warnings
43
+
44
+ if suffix in (".mp4", ".mkv", ".mov", ".webm", ".mp3", ".m4a", ".ogg", ".oga", ".opus"):
45
+ probed = _ffprobe(path)
46
+ if probed:
47
+ facts.update(probed)
48
+ return facts, warnings
49
+ try:
50
+ from hachoir.metadata import extractMetadata
51
+ from hachoir.parser import createParser
52
+
53
+ parser = createParser(str(path))
54
+ if parser is not None:
55
+ with parser:
56
+ meta = extractMetadata(parser)
57
+ if meta is not None:
58
+ if meta.has("duration"):
59
+ facts["duration"] = int(meta.get("duration").total_seconds())
60
+ if meta.has("width"):
61
+ facts["width"] = int(meta.get("width"))
62
+ if meta.has("height"):
63
+ facts["height"] = int(meta.get("height"))
64
+ except Exception:
65
+ pass
66
+ if not facts:
67
+ warnings.append(
68
+ "no ffprobe and no [media] extra: this file is sent without "
69
+ "duration or dimensions, which some clients render as a 1x1 "
70
+ "placeholder — pass --duration/--width/--height, or install them"
71
+ )
72
+ return facts, warnings
73
+
74
+
75
+ def _ffprobe(path: Path) -> dict[str, Any]:
76
+ import json
77
+ import shutil
78
+ import subprocess
79
+
80
+ binary = shutil.which("ffprobe")
81
+ if not binary:
82
+ return {}
83
+ try:
84
+ output = subprocess.run(
85
+ [
86
+ binary,
87
+ "-v",
88
+ "quiet",
89
+ "-print_format",
90
+ "json",
91
+ "-show_format",
92
+ "-show_streams",
93
+ str(path),
94
+ ],
95
+ capture_output=True,
96
+ timeout=20,
97
+ check=False,
98
+ )
99
+ data = json.loads(output.stdout or b"{}")
100
+ except (OSError, ValueError, subprocess.SubprocessError):
101
+ return {}
102
+ facts: dict[str, Any] = {}
103
+ duration = (data.get("format") or {}).get("duration")
104
+ if duration:
105
+ with contextlib.suppress(ValueError, TypeError):
106
+ facts["duration"] = int(float(duration))
107
+ for stream in data.get("streams") or ():
108
+ if stream.get("codec_type") == "video":
109
+ facts["width"] = int(stream.get("width") or 0) or None
110
+ facts["height"] = int(stream.get("height") or 0) or None
111
+ break
112
+ return {k: v for k, v in facts.items() if v}
113
+
114
+
115
+ def probe(path: Path) -> dict[str, Any]:
116
+ """The facts only, for a caller that reports its own warnings."""
117
+ return infer_attributes(path)[0]
118
+
119
+
120
+ def probe_warnings(path: Path, *, voice: bool = False, video_note: bool = False) -> list[str]:
121
+ """The warnings only.
122
+
123
+ A voice note or a round video with no duration is the case that actually
124
+ hurts — the client draws an empty waveform and the message looks broken —
125
+ so it is called out separately rather than folded into the generic
126
+ "install ffprobe" line.
127
+ """
128
+ facts, warnings = infer_attributes(path)
129
+ if (voice or video_note) and not facts.get("duration"):
130
+ warnings.append(
131
+ "no duration could be read for this file; a voice note or round video "
132
+ "without one shows an empty waveform in every client"
133
+ )
134
+ return warnings