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.
telegram_kit/__init__.py
ADDED
|
@@ -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,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.
|