telegram-kit 0.1.2__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.
@@ -0,0 +1,366 @@
1
+ """Secure Telegram notifications for any Python project. Stdlib only.
2
+
3
+ ``pip``/``uv`` install this repository and ``import telegram_kit`` — it has no
4
+ dependency on the ``codex_reset_watch`` CLI, so importing it is cheap.
5
+
6
+ import telegram_kit
7
+
8
+ telegram_kit.notify("build finished", service="my-app", chat_id="123456789")
9
+
10
+ store = telegram_kit.CredentialStore("my-app")
11
+ token = telegram_kit.read_hidden("Telegram bot token (hidden): ")
12
+ if token and store.set("telegram_bot_token", token):
13
+ print("stored as", telegram_kit.mask_secret(token))
14
+
15
+ What it guarantees, whichever project uses it:
16
+
17
+ * The bot token lives only in the OS credential store, namespaced by
18
+ *service*: macOS Keychain (``security``), Linux Secret Service
19
+ (``secret-tool``) or Windows DPAPI (PowerShell). With none available,
20
+ nothing is stored; there is no plaintext or obfuscated fallback.
21
+ * The secret never enters an argument vector (``ps``, shell history): every
22
+ helper receives it on stdin.
23
+ * Reads never raise. A locked keyring or missing helper just means "no secret".
24
+ * ``TG_BOT_TOKEN`` / ``TG_CHAT_ID`` are fallbacks for values the caller did
25
+ not configure, each on its own.
26
+
27
+ Passing ``service="codex-reset-watch"`` reuses the token that ``crw config``
28
+ stored. The chat id is ordinary configuration, so each project keeps its own.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import contextlib
33
+ import functools
34
+ import getpass
35
+ import http.client
36
+ import json
37
+ import os
38
+ import pathlib
39
+ import platform
40
+ import re
41
+ import shutil
42
+ import subprocess
43
+ import urllib.error
44
+ import urllib.parse
45
+ import urllib.request
46
+ import uuid
47
+ import warnings
48
+ from collections.abc import Callable, Mapping
49
+
50
+ IS_MACOS = platform.system() == "Darwin"
51
+ IS_WINDOWS = platform.system() == "Windows"
52
+
53
+ #: How long a credential helper may take before we give up on it.
54
+ TIMEOUT_SECONDS = 10
55
+
56
+ #: Environment variables that stand in for credentials the caller did not set.
57
+ TOKEN_ENV, CHAT_ID_ENV = "TG_BOT_TOKEN", "TG_CHAT_ID"
58
+ TOKEN_KEY = "telegram_bot_token"
59
+
60
+ BACKEND_LABELS = {
61
+ "keychain": "macOS Keychain",
62
+ "libsecret": "Secret Service (libsecret)",
63
+ "dpapi": "Windows DPAPI",
64
+ }
65
+
66
+ _API = "https://api.telegram.org/bot{token}/sendMessage"
67
+
68
+
69
+ # ── owner-only files ─────────────────────────────────────────────────────────
70
+
71
+ def write_private(target: pathlib.Path, content: str) -> None:
72
+ """Atomically write owner-only text; fail before writing if protection fails."""
73
+ target = pathlib.Path(target)
74
+ target.parent.mkdir(parents=True, exist_ok=True)
75
+ if target.is_symlink():
76
+ raise OSError("Refusing to overwrite a symlink")
77
+ temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
78
+ created = False
79
+ try:
80
+ if os.name == "nt":
81
+ # The ACL is attached at creation, before content is written.
82
+ # The path and content travel on stdin, never the command line.
83
+ script = (
84
+ "$ErrorActionPreference='Stop'; "
85
+ "$data=[Console]::In.ReadToEnd() | ConvertFrom-Json; "
86
+ "$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; "
87
+ "$acl=[Security.AccessControl.FileSecurity]::new(); "
88
+ "$acl.SetAccessRuleProtection($true,$false); $acl.SetOwner($sid); "
89
+ "$rule=[Security.AccessControl.FileSystemAccessRule]::new($sid,"
90
+ "[Security.AccessControl.FileSystemRights]::FullControl,"
91
+ "[Security.AccessControl.AccessControlType]::Allow); "
92
+ "$acl.AddAccessRule($rule); "
93
+ "$file=[IO.FileStream]::new($data.path,[IO.FileMode]::CreateNew,"
94
+ "[Security.AccessControl.FileSystemRights]::FullControl,[IO.FileShare]::None,4096,"
95
+ "[IO.FileOptions]::None,$acl); "
96
+ "$writer=[IO.StreamWriter]::new($file,[Text.UTF8Encoding]::new($false)); "
97
+ "try { $writer.Write($data.content) } finally { $writer.Dispose() }"
98
+ )
99
+ created = True # PowerShell may create it before failing or timing out
100
+ try:
101
+ result = subprocess.run(
102
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
103
+ input=json.dumps({"path": str(temporary), "content": content}, ensure_ascii=True),
104
+ capture_output=True, text=True, timeout=15, check=False,
105
+ )
106
+ except subprocess.SubprocessError as exc:
107
+ raise OSError("Unable to create an owner-only file") from exc
108
+ if result.returncode:
109
+ raise OSError("Unable to create an owner-only file")
110
+ else:
111
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
112
+ created = True
113
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
114
+ stream.write(content)
115
+ os.replace(temporary, target)
116
+ created = False
117
+ finally:
118
+ if created:
119
+ temporary.unlink(missing_ok=True)
120
+
121
+
122
+ # ── credential store ─────────────────────────────────────────────────────────
123
+
124
+ def _run(argv, stdin: str | None = None) -> tuple[int, str]:
125
+ """The single subprocess funnel: ``(returncode, stdout)``.
126
+
127
+ Tests replace this whole function, so nothing above it needs a keychain.
128
+ """
129
+ completed = subprocess.run(
130
+ list(argv), input=stdin, capture_output=True, text=True,
131
+ timeout=TIMEOUT_SECONDS, check=False,
132
+ )
133
+ return completed.returncode, completed.stdout
134
+
135
+
136
+ def _unhex(secret: str) -> str:
137
+ """Undo the hex encoding ``security -w`` applies to "non-clean" secrets.
138
+
139
+ It decides that per item, so the shape of the output is the only signal.
140
+ A secret that is itself pure hex stays as-is unless it also decodes to
141
+ valid UTF-8 — Telegram tokens contain ``:`` so they never take that path.
142
+ """
143
+ if not re.fullmatch(r"(?:[0-9a-fA-F]{2})+", secret):
144
+ return secret
145
+ try:
146
+ return bytes.fromhex(secret).decode("utf-8")
147
+ except (ValueError, UnicodeDecodeError):
148
+ return secret
149
+
150
+
151
+ def _batch_quote(value: str) -> str:
152
+ """Escape a value for a double-quoted argument in ``security -i`` batch mode.
153
+
154
+ Rejects newlines outright: batch mode is line-oriented, so an embedded one
155
+ would end the command and let the rest be read as a second one.
156
+ """
157
+ if "\n" in value or "\r" in value:
158
+ raise ValueError("security batch argument contains a newline")
159
+ return value.replace("\\", "\\\\").replace('"', '\\"')
160
+
161
+
162
+ def _detect_backend() -> str | None:
163
+ if IS_MACOS and shutil.which("security"):
164
+ return "keychain"
165
+ if IS_WINDOWS and shutil.which("powershell.exe"):
166
+ return "dpapi"
167
+ if shutil.which("secret-tool"):
168
+ return "libsecret"
169
+ return None
170
+
171
+
172
+ @functools.lru_cache(maxsize=1)
173
+ def backend() -> str | None:
174
+ """Which credential store this machine has, or ``None``. Probed once."""
175
+ return _detect_backend()
176
+
177
+
178
+ def available() -> bool:
179
+ return backend() is not None
180
+
181
+
182
+ def backend_label() -> str:
183
+ """A human name for the active store, for ``doctor`` and the config menu."""
184
+ return BACKEND_LABELS.get(backend() or "", "none")
185
+
186
+
187
+ def legacy_windows_env_token_present() -> bool:
188
+ """Detect a token left by older `setx` installs without reading it aloud."""
189
+ if not IS_WINDOWS:
190
+ return False
191
+ try:
192
+ import winreg
193
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
194
+ value, _ = winreg.QueryValueEx(key, "TG_BOT_TOKEN")
195
+ return bool(value)
196
+ except OSError:
197
+ return False
198
+
199
+
200
+ def _default_dpapi_dir(service: str) -> pathlib.Path:
201
+ base = os.environ.get("APPDATA") or str(pathlib.Path.home() / "AppData" / "Roaming")
202
+ return pathlib.Path(base) / service
203
+
204
+
205
+ class CredentialStore:
206
+ """The OS credential store, scoped to one *service* name.
207
+
208
+ *dpapi_dir* returns the folder for Windows DPAPI ciphertext files. It is a
209
+ callable so a caller whose config folder can move (an env override) is
210
+ asked each time rather than once.
211
+ """
212
+
213
+ def __init__(self, service: str,
214
+ dpapi_dir: Callable[[], pathlib.Path] | None = None) -> None:
215
+ self.service = service
216
+ self._dpapi_dir = dpapi_dir or (lambda: _default_dpapi_dir(service))
217
+
218
+ def _dpapi_path(self, key: str) -> pathlib.Path:
219
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", key):
220
+ raise ValueError("Invalid credential identifier")
221
+ return pathlib.Path(self._dpapi_dir()) / f"{key}.dpapi"
222
+
223
+ def get(self, key: str) -> str:
224
+ """The stored secret for *key*, or ``""`` when there is none.
225
+
226
+ Never raises: a locked keyring, a missing helper or a denied prompt all
227
+ mean "no secret", which the caller already handles.
228
+ """
229
+ active = backend()
230
+ if active is None:
231
+ return ""
232
+ with contextlib.suppress(Exception):
233
+ if active == "keychain":
234
+ code, out = _run(["security", "find-generic-password",
235
+ "-s", self.service, "-a", key, "-w"])
236
+ if code == 0:
237
+ return _unhex(out.strip())
238
+ elif active == "libsecret":
239
+ code, out = _run(["secret-tool", "lookup", "service", self.service, "account", key])
240
+ else: # dpapi
241
+ path = self._dpapi_path(key)
242
+ if not path.exists():
243
+ return ""
244
+ code, out = _run([
245
+ "powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
246
+ ("$s = [Console]::In.ReadToEnd().Trim() | ConvertTo-SecureString; "
247
+ "[Runtime.InteropServices.Marshal]::PtrToStringAuto("
248
+ "[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))"),
249
+ ], stdin=path.read_text(encoding="utf-8"))
250
+ if code == 0:
251
+ return out.strip()
252
+ return ""
253
+
254
+ def set(self, key: str, value: str) -> bool:
255
+ """Store *value* for *key*. ``False`` when it could not be stored securely.
256
+
257
+ An empty *value* deletes the item instead of storing a blank — that is how
258
+ the menu clears a token.
259
+ """
260
+ if not value:
261
+ return self.delete(key)
262
+ active = backend()
263
+ if active is None:
264
+ return False # refuse rather than write plaintext; see the module docstring
265
+ with contextlib.suppress(Exception):
266
+ if active == "keychain":
267
+ # `add-generic-password -w` with no argument does NOT read stdin — it
268
+ # opens /dev/tty and prompts, so piping the secret there stored an
269
+ # empty item and still exited 0. Batch mode (`security -i`) takes the
270
+ # whole command on stdin, which keeps the secret out of every argv
271
+ # (i.e. out of `ps`), and -X hex-encodes it past the tokenizer's
272
+ # quoting and newline rules. -U updates in place rather than stacking
273
+ # duplicate items.
274
+ command = 'add-generic-password -U -s "{}" -a "{}" -X {}\n'.format(
275
+ _batch_quote(self.service), _batch_quote(key), value.encode("utf-8").hex())
276
+ code, _ = _run(["security", "-i"], stdin=command)
277
+ elif active == "libsecret":
278
+ code, _ = _run(["secret-tool", "store", "--label", f"{self.service} {key}",
279
+ "service", self.service, "account", key], stdin=value)
280
+ else: # dpapi
281
+ path = self._dpapi_path(key)
282
+ code, encrypted = _run([
283
+ "powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
284
+ ("$in = [Console]::In.ReadToEnd().Trim(); "
285
+ "ConvertTo-SecureString $in -AsPlainText -Force | ConvertFrom-SecureString"),
286
+ ], stdin=value)
287
+ if code != 0 or not encrypted.strip():
288
+ return False
289
+ write_private(path, encrypted.strip())
290
+ return code == 0
291
+ return False
292
+
293
+ def delete(self, key: str) -> bool:
294
+ """Remove the stored secret for *key*. ``False`` when there was nothing to remove."""
295
+ active = backend()
296
+ if active is None:
297
+ return False
298
+ with contextlib.suppress(Exception):
299
+ if active == "keychain":
300
+ code, _ = _run(["security", "delete-generic-password", "-s", self.service, "-a", key])
301
+ elif active == "libsecret":
302
+ code, _ = _run(["secret-tool", "clear", "service", self.service, "account", key])
303
+ else: # dpapi
304
+ path = self._dpapi_path(key)
305
+ path.unlink(missing_ok=True)
306
+ return True
307
+ return code == 0
308
+ return False
309
+
310
+
311
+ # ── resolving, sending, entering, showing ────────────────────────────────────
312
+
313
+ def resolve_credentials(token: str = "", chat_id: str = "", *,
314
+ environ: Mapping[str, str] | None = None) -> tuple[str, str]:
315
+ """``(token, chat_id)``: what the caller configured, else the environment.
316
+
317
+ Each value falls back on its own, and blank counts as unset. Configured
318
+ values win so a stale ``TG_BOT_TOKEN`` in a shell profile cannot keep
319
+ notifying through a bot the user already replaced.
320
+ """
321
+ env = os.environ if environ is None else environ
322
+ return (str(token or "").strip() or env.get(TOKEN_ENV, "").strip(),
323
+ str(chat_id or "").strip() or env.get(CHAT_ID_ENV, "").strip())
324
+
325
+
326
+ def send_message(token: str, chat_id: str, text: str, *, timeout: float = 10) -> bool:
327
+ """POST one sendMessage to the Bot API. False on missing credentials or any failure."""
328
+ if not token or not chat_id:
329
+ return False
330
+ data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode("utf-8")
331
+ request = urllib.request.Request(_API.format(token=token), data=data, method="POST")
332
+ try:
333
+ with urllib.request.urlopen(request, timeout=timeout) as response:
334
+ response.read()
335
+ except (urllib.error.URLError, OSError, ValueError, http.client.HTTPException):
336
+ return False # incl. InvalidURL from a hand-corrupted token
337
+ return True
338
+
339
+
340
+ def notify(text: str, *, service: str, chat_id: str = "", token_key: str = TOKEN_KEY,
341
+ store: CredentialStore | None = None) -> bool:
342
+ """Send *text* with *service*'s stored token. Never raises; False on failure."""
343
+ store = store or CredentialStore(service)
344
+ token, chat = resolve_credentials(store.get(token_key), chat_id)
345
+ return send_message(token, chat, text)
346
+
347
+
348
+ def read_hidden(prompt: str, *, ask: Callable[[str], str] | None = None) -> str | None:
349
+ """Read a secret without echo. ``None`` when echo cannot be disabled or input ends.
350
+
351
+ ``getpass`` silently falls back to an echoing prompt when it has no
352
+ terminal; that warning is treated as a refusal, never as consent.
353
+ """
354
+ try:
355
+ with warnings.catch_warnings():
356
+ warnings.simplefilter("error", getpass.GetPassWarning)
357
+ return (ask or getpass.getpass)(prompt).strip()
358
+ except (EOFError, OSError, getpass.GetPassWarning):
359
+ return None
360
+
361
+
362
+ def mask_secret(secret: str) -> str:
363
+ """Stars, plus at most the last 4 characters — never enough to reuse."""
364
+ if not secret:
365
+ return ""
366
+ return "*" * 8 + secret[-4:] if len(secret) > 12 else "*" * 8
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: telegram-kit
3
+ Version: 0.1.2
4
+ Summary: Secure Telegram Bot API notifications for any Python project. Stdlib only.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # telegram-kit
11
+
12
+ Secure Telegram Bot API notifications for any Python project. Stdlib only —
13
+ no dependencies, no vendoring.
14
+
15
+ ```python
16
+ import telegram_kit
17
+
18
+ telegram_kit.notify("build finished", service="my-app", chat_id="123456789")
19
+
20
+ store = telegram_kit.CredentialStore("my-app")
21
+ token = telegram_kit.read_hidden("Telegram bot token (hidden): ")
22
+ if token and store.set("telegram_bot_token", token):
23
+ print("stored as", telegram_kit.mask_secret(token))
24
+ ```
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ uv add "telegram-kit>=0.1.2,<0.2"
30
+ ```
31
+
32
+ Published to PyPI on every `v*` tag (`.github/workflows/publish.yml`).
33
+ `uv lock --upgrade-package telegram-kit` is how every project using this kit
34
+ picks up a fix — no file to copy, no diff to reapply. A project that is never
35
+ published to PyPI can pin the git tag instead:
36
+ `uv add "telegram-kit @ git+https://github.com/weskao/telegram-kit@v0.1.2"`.
37
+
38
+ ## What it guarantees, whichever project uses it
39
+
40
+ - **No plaintext fallback.** `CredentialStore` writes to the OS credential
41
+ store (macOS Keychain, Linux Secret Service, Windows DPAPI). With none
42
+ available, it refuses to store rather than falling back to a plaintext
43
+ file or home-rolled obfuscation.
44
+ - **Each caller gets its own namespace.** `CredentialStore(service)` keys
45
+ every item under that service name, so two projects on the same machine
46
+ never collide.
47
+ - **Credentials never touch argv or the process list** — batch-mode/stdin
48
+ paths are used for every backend.
49
+ - **Owner-only atomic writes** (`write_private`) for anything that must live
50
+ on disk, on POSIX and Windows alike.
51
+
52
+ ## API
53
+
54
+ | Function | Purpose |
55
+ |---|---|
56
+ | `CredentialStore(service, dpapi_dir=None)` | Get/set/delete a secret in the OS store. |
57
+ | `resolve_credentials(token, chat_id, environ=None)` | Configured value, else `TG_BOT_TOKEN`/`TG_CHAT_ID`. |
58
+ | `send_message(token, chat_id, text, timeout=10)` | One `sendMessage` call. `False` on any failure. |
59
+ | `notify(text, service, chat_id="", token_key=..., store=None)` | Resolve + send in one call. |
60
+ | `read_hidden(prompt, ask=None)` | Hidden input; `None` if the terminal can't hide it. |
61
+ | `mask_secret(secret)` | `********` plus at most the last 4 characters. |
62
+ | `write_private(target, content)` | Atomic, owner-only file write. |
63
+
64
+ ## Develop
65
+
66
+ ```bash
67
+ uv run python -m unittest discover -s tests -t . -v
68
+ uv run ruff check .
69
+ ```
70
+
71
+ ## CI notifications
72
+
73
+ `.github/workflows/ci.yml` runs the test matrix (macOS/Linux/Windows) on every push and PR,
74
+ then a separate `notify-telegram` job sends a Telegram message only when the test job fails on
75
+ a `push` (never on green runs, never on `pull_request`, to avoid pinging on forks/external PRs).
76
+ Configure it once per repo:
77
+
78
+ ```bash
79
+ gh secret set TELEGRAM_BOT_TOKEN
80
+ gh secret set TELEGRAM_CHAT_ID
81
+ ```
82
+
83
+ Unconfigured secrets are a valid state — the job skips quietly instead of failing a second time
84
+ on top of the real failure.
@@ -0,0 +1,5 @@
1
+ telegram_kit/__init__.py,sha256=N1Ld11sdlKvKNhKCBUynRzQiJkuTIPQX4A8j87SfAHs,15730
2
+ telegram_kit-0.1.2.dist-info/METADATA,sha256=fBLsaN16jUV-ioC73FWbJY-YZj4A3Y1WR-jjmKUPxtI,3125
3
+ telegram_kit-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
4
+ telegram_kit-0.1.2.dist-info/licenses/LICENSE,sha256=hbegGfnKh0keOqgPtmGw5K4EYue_BwPemF07Uw2eFDs,1047
5
+ telegram_kit-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wes Kao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.