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