telegram-sendmail 1.0.0__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,13 @@
1
+ """
2
+ Telegram Sendmail Bridge
3
+
4
+ A drop-in `sendmail` replacement that forwards system emails to Telegram.
5
+
6
+ This package is the sole source of truth for the project version.
7
+ Hatchling reads `__version__` from this file via the `[tool.hatch.version]`
8
+ path defined in `pyproject.toml`.
9
+ """
10
+
11
+ __version__ = "1.0.0"
12
+ __author__ = "Theodosios Divolis"
13
+ __license__ = "MIT"
@@ -0,0 +1,480 @@
1
+ """
2
+ CLI entry point for telegram-sendmail.
3
+
4
+ This module is the sole owner of:
5
+ - `argparse` argument parsing
6
+ - Logging infrastructure bootstrap (syslog or console)
7
+ - Top-level exception-to-exit-code mapping
8
+ - Pipeline wiring (config -> spool -> parse -> send)
9
+
10
+ It is the only module in the package that calls `sys.exit`.
11
+
12
+ Entry point
13
+ -----------
14
+ The `main` function is registered in `pyproject.toml` under
15
+ `[project.scripts]` and is invoked by the `telegram-sendmail`
16
+ executable generated by the build backend.
17
+
18
+ Exit codes
19
+ ----------
20
+ - `0` (EX_OK) — message delivered successfully.
21
+ - `1` (EX_ERROR) — operational failure (parse error, Telegram API error,
22
+ interactive invocation, or unexpected exception).
23
+ - `75` (EX_TEMPFAIL) — transient network failure (HTTP 429 or 5xx); the
24
+ calling daemon should re-queue and retry.
25
+ - `78` (EX_CONFIG) — configuration error (missing/invalid config file).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import logging
32
+ import logging.handlers
33
+ import sys
34
+ from collections.abc import Callable
35
+
36
+ from telegram_sendmail import __version__
37
+ from telegram_sendmail.client import _RETRY_STATUS_CODES, TelegramClient
38
+ from telegram_sendmail.config import AppConfig, ConfigLoader
39
+ from telegram_sendmail.exceptions import (
40
+ ConfigurationError,
41
+ ParsingError,
42
+ SpoolError,
43
+ TelegramAPIError,
44
+ TelegramSendmailError,
45
+ )
46
+ from telegram_sendmail.parser import EmailParser
47
+ from telegram_sendmail.smtp import SMTPServer
48
+ from telegram_sendmail.spool import MailSpooler
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ # EX_TEMPFAIL is defined in OS on POSIX; hardcode the value for portability
53
+ _EX_OK = 0
54
+ _EX_ERROR = 1
55
+ _EX_TEMPFAIL = 75
56
+ _EX_CONFIG = 78
57
+
58
+ # Pipe-mode read cap matches the SMTP SIZE limit advertised in smtp.py so that
59
+ # both entry points enforce the same ceiling consistently.
60
+ _MAX_PIPE_SIZE: int = 10_485_760 # 10 MiB
61
+ _PIPE_READ_CHUNK: int = 65_536
62
+
63
+
64
+ # --------------------------------------------------------------------------
65
+ # Delivery pipeline
66
+ # --------------------------------------------------------------------------
67
+
68
+
69
+ def _deliver(
70
+ raw_email: str,
71
+ sender_override: str | None,
72
+ config: AppConfig,
73
+ ) -> None:
74
+ """
75
+ Run the full email delivery pipeline for a single message.
76
+
77
+ Pipeline order:
78
+ 1. **Spool** — archive the raw email. Non-fatal: a `SpoolError` is
79
+ logged at WARNING level and delivery continues.
80
+ 2. **Parse** — decode MIME structure and convert body to Telegram markup.
81
+ 3. **Format** — wrap in the Telegram message envelope with truncation.
82
+ 4. **Send** — deliver via the Telegram Bot API.
83
+
84
+ Args:
85
+ raw_email: Raw RFC 2822 email string.
86
+ sender_override: Envelope sender from `-f`/`-r` flag or SMTP
87
+ `MAIL FROM`. Overrides the `From` header.
88
+ config: Resolved `AppConfig` instance.
89
+
90
+ Raises:
91
+ ParsingError: If the email body cannot be decoded.
92
+ TelegramAPIError: If Telegram delivery fails after all retries.
93
+ """
94
+ spooler = MailSpooler(config)
95
+ try:
96
+ spooler.write(raw_email)
97
+ except SpoolError:
98
+ pass # Warning already emitted by MailSpooler.write
99
+
100
+ parser = EmailParser(config)
101
+ parsed = parser.parse(raw_email, sender_override=sender_override)
102
+ text = parser.format_for_telegram(parsed)
103
+
104
+ with TelegramClient(config) as client:
105
+ client.send(text)
106
+
107
+
108
+ def _make_smtp_handler(config: AppConfig) -> Callable[[str, str | None], None]:
109
+ """
110
+ Return a closure that binds `config` to `_deliver` for use as the
111
+ `SMTPServer.on_message` callback.
112
+ """
113
+
114
+ def handler(raw_email: str, envelope_sender: str | None) -> None:
115
+ _deliver(raw_email, envelope_sender, config)
116
+
117
+ return handler
118
+
119
+
120
+ def _bounded_stdin_read() -> str:
121
+ """
122
+ Read up to `_MAX_PIPE_SIZE` characters from `sys.stdin` in chunks.
123
+
124
+ Reading in fixed-size chunks prevents a single `sys.stdin.read()` call
125
+ from loading an arbitrarily large payload into memory before any
126
+ downstream truncation occurs. Once the accumulated size exceeds
127
+ `_MAX_PIPE_SIZE`, the overflow is drained without buffering and a
128
+ `WARNING` is emitted so syslog records the event; delivery continues
129
+ with the collected portion.
130
+
131
+ Returns:
132
+ The email content, at most `_MAX_PIPE_SIZE` characters long.
133
+ """
134
+ chunks: list[str] = []
135
+ total = 0
136
+ oversized = False
137
+
138
+ while True:
139
+ chunk = sys.stdin.read(_PIPE_READ_CHUNK)
140
+ if not chunk:
141
+ break
142
+
143
+ if oversized:
144
+ # Drain without buffering so the feeding process is not blocked.
145
+ continue
146
+
147
+ if total + len(chunk) > _MAX_PIPE_SIZE:
148
+ chunks.append(chunk[: _MAX_PIPE_SIZE - total])
149
+ total = _MAX_PIPE_SIZE
150
+ oversized = True
151
+ else:
152
+ chunks.append(chunk)
153
+ total += len(chunk)
154
+
155
+ if oversized:
156
+ logger.warning(
157
+ "Pipe-mode stdin exceeded %d bytes; input truncated to limit",
158
+ _MAX_PIPE_SIZE,
159
+ )
160
+
161
+ return "".join(chunks)
162
+
163
+
164
+ # --------------------------------------------------------------------------
165
+ # Logging bootstrap
166
+ # --------------------------------------------------------------------------
167
+
168
+ _SYSLOG_ADDRESS = "/dev/log"
169
+ _SYSLOG_SOCKET_FALLBACK = "/var/run/syslog"
170
+
171
+
172
+ class _TokenRedactFilter(logging.Filter):
173
+ """
174
+ Logging filter that replaces the bot token with a safe placeholder.
175
+
176
+ Installed on every root-logger handler after the token is known so that
177
+ third-party loggers (`urllib3`, `requests`) cannot leak the token at
178
+ DEBUG level.
179
+ """
180
+
181
+ def __init__(self, token: str) -> None:
182
+ super().__init__()
183
+ self._token = token
184
+ self._placeholder = f"{token[:8]}[…REDACTED…]"
185
+
186
+ def filter(self, record: logging.LogRecord) -> bool:
187
+ """Redact the token from the fully formatted message."""
188
+ formatted = record.getMessage()
189
+ if self._token in formatted:
190
+ record.msg = formatted.replace(self._token, self._placeholder)
191
+ record.args = None
192
+ return True
193
+
194
+
195
+ def _setup_logging(console: bool, debug: bool = False) -> None:
196
+ """
197
+ Configure the root logger before any module-level logger fires.
198
+
199
+ In production (`console=False`) log output goes to syslog under the
200
+ `LOG_MAIL` facility with the process identifier `telegram-sendmail`.
201
+ In console mode (`--console` flag) log output goes to `stderr` with
202
+ a human-readable format, which is useful for interactive debugging and
203
+ CI environments.
204
+
205
+ This function must be called before `ConfigLoader.load()` so that
206
+ configuration warnings are captured by the configured handler.
207
+
208
+ Args:
209
+ console: Route logs to `stderr` instead of syslog.
210
+ debug: Set root log level to `DEBUG`; default is `INFO`.
211
+ """
212
+ level = logging.DEBUG if debug else logging.INFO
213
+
214
+ if console:
215
+ handler: logging.Handler = logging.StreamHandler(sys.stderr)
216
+ msgfmt = "[%(levelname)s] %(name)s: %(message)s"
217
+ handler.setFormatter(
218
+ logging.Formatter(msgfmt, datefmt="%Y-%m-%d %H:%M:%S"),
219
+ )
220
+ else:
221
+ syslog_address: str | tuple[str, int]
222
+ for candidate in (_SYSLOG_ADDRESS, _SYSLOG_SOCKET_FALLBACK):
223
+ try:
224
+ syslog_address = candidate
225
+ handler = logging.handlers.SysLogHandler(
226
+ address=syslog_address,
227
+ facility=logging.handlers.SysLogHandler.LOG_MAIL,
228
+ )
229
+ break
230
+ except OSError:
231
+ continue
232
+ else:
233
+ # Neither syslog socket is accessible; fall back to stderr so
234
+ # we do not silently swallow errors.
235
+ handler = logging.StreamHandler(sys.stderr)
236
+
237
+ msgfmt = "telegram-sendmail[%(process)d]: %(levelname)s %(name)s: %(message)s"
238
+ handler.setFormatter(
239
+ logging.Formatter(msgfmt, datefmt="%Y-%m-%d %H:%M:%S"),
240
+ )
241
+
242
+ logging.root.setLevel(level)
243
+ logging.root.addHandler(handler)
244
+
245
+
246
+ # --------------------------------------------------------------------------
247
+ # Argument parser
248
+ # --------------------------------------------------------------------------
249
+
250
+
251
+ def _build_parser() -> argparse.ArgumentParser:
252
+ """
253
+ Construct the argument parser.
254
+
255
+ Sendmail-compatible flags are accepted and honoured or silently ignored
256
+ as documented. Positional recipient arguments (e.g.
257
+ `sendmail root@localhost`) are consumed via `nargs='*'` and discarded
258
+ so that system daemons calling `sendmail <recipient>` do not cause
259
+ argument parsing to fail.
260
+ """
261
+ parser = argparse.ArgumentParser(
262
+ prog="telegram-sendmail",
263
+ description="Forward system emails to Telegram.",
264
+ epilog="See https://github.com/theodiv/telegram-sendmail for documentation.",
265
+ add_help=False,
266
+ )
267
+
268
+ # Sendmail-compatible flags
269
+ parser.add_argument(
270
+ "-f",
271
+ "-r",
272
+ dest="sender",
273
+ metavar="ADDRESS",
274
+ help="set the envelope sender address (overrides the From header)",
275
+ )
276
+ parser.add_argument(
277
+ "-s",
278
+ dest="subject",
279
+ metavar="SUBJECT",
280
+ help="set the email subject (overrides the Subject header)",
281
+ )
282
+ parser.add_argument(
283
+ "-bs",
284
+ action="store_true",
285
+ help="run in SMTP server mode (read SMTP commands from stdin)",
286
+ )
287
+ parser.add_argument(
288
+ "-t",
289
+ action="store_true",
290
+ help=("extract recipients from message headers (ignored)"),
291
+ )
292
+ parser.add_argument(
293
+ "-i",
294
+ "-oi",
295
+ action="store_true",
296
+ dest="ignore_dots",
297
+ help="do not treat a line with only a dot as end-of-message (ignored)",
298
+ )
299
+
300
+ # Positional recipients consumed and discarded for drop-in compatibility.
301
+ # System tools like cron call: sendmail -oi -t root@localhost
302
+ parser.add_argument(
303
+ "recipients",
304
+ nargs="*",
305
+ metavar="RECIPIENT",
306
+ help="recipient addresses (ignored; all email is forwarded to Telegram chat)",
307
+ )
308
+
309
+ # Custom flags
310
+ parser.add_argument(
311
+ "--console",
312
+ action="store_true",
313
+ help="log to stderr instead of syslog (useful for debugging)",
314
+ )
315
+ parser.add_argument(
316
+ "--debug",
317
+ action="store_true",
318
+ help="set log level to DEBUG",
319
+ )
320
+ parser.add_argument(
321
+ "--help",
322
+ action="help",
323
+ help="display this help message and exit",
324
+ )
325
+ parser.add_argument(
326
+ "--version",
327
+ action="version",
328
+ version=f"telegram-sendmail {__version__}",
329
+ help="output version information and exit",
330
+ )
331
+
332
+ return parser
333
+
334
+
335
+ # --------------------------------------------------------------------------
336
+ # Main entry point
337
+ # --------------------------------------------------------------------------
338
+
339
+
340
+ def main() -> None:
341
+ """
342
+ CLI entry point registered under `[project.scripts]` in `pyproject.toml`.
343
+
344
+ Dispatches to one of three operating modes:
345
+
346
+ - **SMTP mode** (`-bs`): runs `SMTPServer.run()` which speaks the
347
+ SMTP protocol on `stdin`/`stdout`.
348
+ - **Pipe mode** (stdin is not a TTY): reads a raw email from `stdin`
349
+ and delivers it via `_deliver()`.
350
+ - **Interactive mode** (stdin is a TTY): prints usage information and
351
+ exits with code `1`. This is not an error condition in the strict
352
+ sense; it just means the tool was invoked directly from a terminal.
353
+ """
354
+ arg_parser = _build_parser()
355
+ args, unknown = arg_parser.parse_known_args()
356
+
357
+ _setup_logging(console=args.console, debug=args.debug)
358
+
359
+ if unknown:
360
+ logger.debug("Ignoring unrecognised arguments: %s", " ".join(unknown))
361
+
362
+ if args.recipients:
363
+ logger.debug(
364
+ "Recipient argument(s) received and ignored (all mail goes to "
365
+ "configured Telegram chat_id): %s",
366
+ ", ".join(args.recipients),
367
+ )
368
+
369
+ # Loading configuration must happen after logging is set up so that
370
+ # permission warnings from ConfigLoader are captured.
371
+ try:
372
+ config = ConfigLoader.load()
373
+ except ConfigurationError as exc:
374
+ logger.error("Configuration error: %s", exc)
375
+ sys.exit(_EX_CONFIG)
376
+
377
+ token_filter = _TokenRedactFilter(config.token)
378
+ for handler in logging.root.handlers:
379
+ handler.addFilter(token_filter)
380
+
381
+ if args.bs:
382
+ sys.exit(_run_smtp_mode(config))
383
+ elif not sys.stdin.isatty():
384
+ sys.exit(_run_pipe_mode(args.sender, args.subject, config))
385
+ else:
386
+ sys.exit(_run_interactive_mode())
387
+
388
+
389
+ def _run_smtp_mode(config: AppConfig) -> int:
390
+ """Launch the SMTP state machine and map exceptions to exit codes."""
391
+ logger.info("Starting SMTP server mode")
392
+ try:
393
+ server = SMTPServer(config, on_message=_make_smtp_handler(config))
394
+ server.run()
395
+ return _EX_OK
396
+ except KeyboardInterrupt:
397
+ logger.info("SMTP session interrupted by user")
398
+ return _EX_OK
399
+ except TelegramSendmailError as exc:
400
+ logger.error("%s", exc)
401
+ return _EX_ERROR
402
+ except Exception as exc:
403
+ logger.error("Unexpected error in SMTP mode: %s", exc)
404
+ return _EX_ERROR
405
+
406
+
407
+ def _run_pipe_mode(
408
+ sender_override: str | None,
409
+ subject_override: str | None,
410
+ config: AppConfig,
411
+ ) -> int:
412
+ """Read email from stdin and deliver it; return an exit code."""
413
+ try:
414
+ raw_email = _bounded_stdin_read()
415
+ except Exception as exc:
416
+ logger.error("Failed to read email from stdin: %s", exc)
417
+ return _EX_ERROR
418
+
419
+ # The parser accepts sender_override; subject must be injected as a
420
+ # synthetic header when passed via the CLI flag so that the existing
421
+ # parser interface is not widened unnecessarily.
422
+ #
423
+ # RFC 2822 §2.2: field names are case-insensitive, so we must perform
424
+ # a case-insensitive search to avoid injecting a duplicate header when
425
+ # the original uses a non-canonical casing (e.g. "subject:" instead of
426
+ # "Subject:").
427
+ if subject_override and "subject:" not in raw_email[:500].lower():
428
+ raw_email = f"Subject: {subject_override}\n{raw_email}"
429
+
430
+ try:
431
+ _deliver(raw_email, sender_override, config)
432
+ return _EX_OK
433
+ except ParsingError as exc:
434
+ logger.error("Failed to parse email: %s", exc)
435
+ return _EX_ERROR
436
+ except TelegramAPIError as exc:
437
+ status = getattr(exc, "status_code", None)
438
+ if status is not None and status in _RETRY_STATUS_CODES:
439
+ # Transient HTTP failure: signal the caller to retry later.
440
+ logger.warning(
441
+ "Telegram API transient failure (HTTP %d); signalling EX_TEMPFAIL",
442
+ status,
443
+ )
444
+ return _EX_TEMPFAIL
445
+ logger.error("Telegram delivery failed: %s", exc)
446
+ return _EX_ERROR
447
+ except TelegramSendmailError as exc:
448
+ logger.error("%s", exc)
449
+ return _EX_ERROR
450
+ except Exception as exc:
451
+ logger.error("Unexpected error in pipe mode: %s", exc)
452
+ return _EX_ERROR
453
+
454
+
455
+ def _run_interactive_mode() -> int:
456
+ """
457
+ Emit a helpful message when the tool is invoked directly from a terminal.
458
+
459
+ This is not a hard error, but the tool is not designed for interactive
460
+ use. Returns exit code 1 to signal to the shell that nothing was
461
+ delivered.
462
+ """
463
+ print(
464
+ f"telegram-sendmail {__version__}\n"
465
+ "\n"
466
+ "This tool is designed to be invoked by system daemons as a sendmail\n"
467
+ "replacement, not run interactively from a terminal.\n"
468
+ "\n"
469
+ "Pipe mode: echo 'Subject: Test\\n\\nHello' | telegram-sendmail\n"
470
+ "SMTP mode: telegram-sendmail -bs\n"
471
+ "Debug mode: echo 'test' | telegram-sendmail --console --debug\n"
472
+ "\n"
473
+ "For full documentation: telegram-sendmail --help",
474
+ file=sys.stderr,
475
+ )
476
+ return _EX_ERROR
477
+
478
+
479
+ if __name__ == "__main__":
480
+ main()
@@ -0,0 +1,201 @@
1
+ """
2
+ Telegram Bot API client for telegram-sendmail.
3
+
4
+ Public surface
5
+ --------------
6
+ - `TelegramClient` — sends formatted messages to a Telegram chat via the
7
+ Bot API, with configurable retry and timeout behaviour.
8
+
9
+ The client owns a `requests.Session` that is configured once at construction
10
+ time and reused for all requests within a delivery pipeline invocation. It
11
+ implements the context manager protocol so callers can use it with a `with`
12
+ statement for deterministic session cleanup.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from http import HTTPStatus
19
+
20
+ import requests
21
+ from requests.adapters import HTTPAdapter
22
+ from urllib3.util.retry import Retry
23
+
24
+ from telegram_sendmail.config import AppConfig
25
+ from telegram_sendmail.exceptions import TelegramAPIError
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _TELEGRAM_API_BASE = "https://api.telegram.org"
30
+
31
+ # HTTP status codes that warrant an automatic retry. 429 (Too Many Requests)
32
+ # is the most common transient failure from the Telegram API.
33
+ _RETRY_STATUS_CODES: frozenset[int] = frozenset({
34
+ HTTPStatus.TOO_MANY_REQUESTS,
35
+ HTTPStatus.INTERNAL_SERVER_ERROR,
36
+ HTTPStatus.BAD_GATEWAY,
37
+ HTTPStatus.SERVICE_UNAVAILABLE,
38
+ HTTPStatus.GATEWAY_TIMEOUT,
39
+ }) # fmt: skip
40
+
41
+
42
+ class TelegramClient:
43
+ """
44
+ Sends messages to the Telegram Bot API.
45
+
46
+ The underlying `requests.Session` is configured with an `HTTPAdapter`
47
+ backed by a `urllib3.Retry` strategy. Retry behaviour (attempt count,
48
+ backoff factor) is driven entirely by the values in `AppConfig`, so
49
+ operators can tune it for their network environment without touching code.
50
+
51
+ The class implements the context manager protocol. Although the session
52
+ is closed automatically when the object is garbage-collected, using the
53
+ context manager form is strongly preferred for production code paths
54
+ where deterministic cleanup matters::
55
+
56
+ with TelegramClient(config) as client:
57
+ client.send(message_text)
58
+
59
+ Args:
60
+ config: A fully resolved `AppConfig` instance.
61
+ """
62
+
63
+ def __init__(self, config: AppConfig) -> None:
64
+ self._token: str = config.token
65
+ self._chat_id: str = config.chat_id
66
+ self._timeout: int = config.telegram_timeout
67
+ self._disable_notification: bool = config.disable_notification
68
+ self._session: requests.Session = self._build_session(
69
+ max_retries=config.max_retries,
70
+ backoff_factor=config.backoff_factor,
71
+ )
72
+
73
+ def __enter__(self) -> TelegramClient:
74
+ return self
75
+
76
+ def __exit__(
77
+ self,
78
+ exc_type: type[BaseException] | None,
79
+ exc_val: BaseException | None,
80
+ exc_tb: object,
81
+ ) -> None:
82
+ self.close()
83
+
84
+ def close(self) -> None:
85
+ """Release the underlying `requests.Session` and its connections."""
86
+ self._session.close()
87
+
88
+ # ------------------------------------------------------------------
89
+ # Internal helpers
90
+ # ------------------------------------------------------------------
91
+
92
+ @staticmethod
93
+ def _build_session(max_retries: int, backoff_factor: float) -> requests.Session:
94
+ """
95
+ Construct a `requests.Session` with a retry-enabled HTTPS adapter.
96
+
97
+ Retries are attempted only on the HTTP methods and status codes that
98
+ are safe to repeat (POST on 429/5xx). The `raise_on_status` flag is
99
+ left `False` here; response status is checked explicitly in `send()`
100
+ so error messages can be extracted from the body.
101
+ """
102
+ retry_strategy = Retry(
103
+ total=max_retries,
104
+ backoff_factor=backoff_factor,
105
+ status_forcelist=list(_RETRY_STATUS_CODES),
106
+ allowed_methods=["POST"],
107
+ raise_on_status=False,
108
+ )
109
+ adapter = HTTPAdapter(max_retries=retry_strategy)
110
+ session = requests.Session()
111
+ session.mount("https://", adapter)
112
+
113
+ return session
114
+
115
+ def _api_url(self, method: str) -> str:
116
+ return f"{_TELEGRAM_API_BASE}/bot{self._token}/{method}"
117
+
118
+ # ------------------------------------------------------------------
119
+ # Public interface
120
+ # ------------------------------------------------------------------
121
+
122
+ def send(self, text: str) -> None:
123
+ """
124
+ Send `text` to the configured Telegram chat.
125
+
126
+ Args:
127
+ text: Fully formatted message string with Telegram HTML markup.
128
+ Must not exceed 4096 characters (Telegram hard limit).
129
+
130
+ Raises:
131
+ TelegramAPIError: If the API returns a non-OK response after all
132
+ retry attempts are exhausted, or if the network
133
+ request itself fails.
134
+ """
135
+ payload: dict[str, object] = {
136
+ "chat_id": self._chat_id,
137
+ "text": text,
138
+ "parse_mode": "HTML",
139
+ "disable_notification": self._disable_notification,
140
+ "link_preview_options": {"is_disabled": True},
141
+ }
142
+
143
+ logger.debug(
144
+ "Sending message to chat_id=%s (length=%d chars)",
145
+ self._chat_id,
146
+ len(text),
147
+ )
148
+
149
+ try:
150
+ response = self._session.post(
151
+ url=self._api_url("sendMessage"),
152
+ json=payload,
153
+ timeout=self._timeout,
154
+ )
155
+ except requests.exceptions.Timeout as exc:
156
+ raise TelegramAPIError(
157
+ f"Telegram API request timed out after {self._timeout}s: {exc}",
158
+ ) from exc
159
+ except requests.exceptions.ConnectionError as exc:
160
+ raise TelegramAPIError(
161
+ f"Failed to connect to Telegram API: {exc}",
162
+ ) from exc
163
+ except requests.exceptions.RequestException as exc:
164
+ raise TelegramAPIError(
165
+ f"Telegram API request failed: {exc}",
166
+ ) from exc
167
+
168
+ self._check_response(response)
169
+ logger.info("Message delivered to Telegram (chat_id=%s)", self._chat_id)
170
+
171
+ def _check_response(self, response: requests.Response) -> None:
172
+ """
173
+ Validate the Telegram API JSON response.
174
+
175
+ The Telegram API always returns a JSON body with an `ok` boolean
176
+ field, even for error responses. We check that field in preference
177
+ to the HTTP status code because the API occasionally returns `200`
178
+ with `ok: false` for application-level errors (e.g. bad chat ID).
179
+
180
+ Raises:
181
+ TelegramAPIError: If `ok` is `false` or the response body
182
+ cannot be decoded as JSON.
183
+ """
184
+ try:
185
+ body: dict[str, object] = response.json()
186
+ except ValueError as exc:
187
+ raise TelegramAPIError(
188
+ f"Telegram API returned non-JSON response "
189
+ f"(HTTP {response.status_code}): {response.text[:200]}",
190
+ status_code=response.status_code,
191
+ ) from exc
192
+
193
+ if not body.get("ok"):
194
+ description = body.get("description", "no description provided")
195
+ error_code = body.get("error_code", response.status_code)
196
+ raise TelegramAPIError(
197
+ f"Telegram API error {error_code}: {description}",
198
+ status_code=(
199
+ int(error_code) if isinstance(error_code, (int, float)) else None
200
+ ),
201
+ )