Ozakboy.Notify.Telegram
0.1.0
dotnet add package Ozakboy.Notify.Telegram --version 0.1.0
NuGet\Install-Package Ozakboy.Notify.Telegram -Version 0.1.0
<PackageReference Include="Ozakboy.Notify.Telegram" Version="0.1.0" />
<PackageVersion Include="Ozakboy.Notify.Telegram" Version="0.1.0" />
<PackageReference Include="Ozakboy.Notify.Telegram" />
paket add Ozakboy.Notify.Telegram --version 0.1.0
#r "nuget: Ozakboy.Notify.Telegram, 0.1.0"
#:package Ozakboy.Notify.Telegram@0.1.0
#addin nuget:?package=Ozakboy.Notify.Telegram&version=0.1.0
#tool nuget:?package=Ozakboy.Notify.Telegram&version=0.1.0
Ozakboy.Notify.Telegram
Telegram Bot alerting for .NET 10: levelled messages, throttling, 429 handling, and a bot token that never ends up in your logs.
English | 繁體中文
dotnet add package Ozakboy.Notify.Telegram
Requires .NET 10. No dependencies beyond first-party Microsoft packages and Ozakboy.* (Ozakboy.Http, Ozakboy.Core.Abstractions, Ozakboy.Security).
Why this exists
An alert channel has one job: to get through when something has gone wrong. That rules out a few things people usually get wrong:
- A formatting mistake must not lose the alert. With MarkdownV2, one unescaped
_in an exception message gets the whole message rejected. This package sends plain text only. - Long content must not be rejected. Telegram caps a message at 4096 characters. This package splits along line breaks and never cuts inside an emoji or a combining sequence.
- A burst of alerts must not trigger a 429. Messages to one chat are spaced one second apart and capped per minute. When a 429 happens anyway, the package waits exactly as long as Telegram asked.
- The bot token must not leak. It sits in the URL path (
/bot<TOKEN>/sendMessage), where the usual masking cannot see it. See Keeping the token out of logs.
Quick start
using Ozakboy.Notify.Telegram;
// The token comes from the environment (or an encrypted file, below). Never from source or appsettings.json.
services.AddTelegramNotifier(options =>
{
options.BotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN");
options.ChatId = Environment.GetEnvironmentVariable("TELEGRAM_CHAT_ID");
});
public sealed class Watchdog(ITelegramNotifier telegram)
{
public async Task OnConnectionLostAsync(CancellationToken cancellationToken)
{
var result = await telegram.SendAsync(
NotificationLevel.Critical,
"Exchange connection lost. New orders are paused.",
cancellationToken);
if (result.IsFailure)
{
// Nothing here throws for expected failures. Branch on the code, e.g. fall back to e-mail.
Console.WriteLine(result.Error.Code); // telegram.forbidden, telegram.rate_limited, ...
}
}
}
The message arrives as [CRITICAL] Exchange connection lost. New orders are paused.
AddTelegramNotifier validates the options when you call it, so a mistyped token fails at start-up rather than when
the first alert goes missing. Validation messages never echo the token.
Keeping the token in an encrypted file
With Ozakboy.Security, the token can live on disk encrypted with Windows DPAPI:
using Ozakboy.Security.Protection;
ISecretProtector protector = new DpapiSecretProtector();
string stored = File.ReadAllText(tokenFilePath); // written earlier with protector.Protect(token)
if (!protector.TryUnprotect(stored, out string? token))
{
throw new InvalidOperationException("The Telegram token cannot be decrypted on this machine or account.");
}
services.AddTelegramNotifier(options =>
{
options.BotToken = token;
options.ChatId = chatId;
});
Options
| Option | Default | Notes |
|---|---|---|
BotToken |
none | Required. Format-checked (digits:secret), never echoed. |
ChatId |
none | Required. An integer (negative for groups and channels) or @channel. |
ApiBaseAddress |
https://api.telegram.org/ |
https only; http is accepted for loopback (a self-hosted Bot API server). |
RequestTimeout |
15 s | Per HTTP request. |
MinimumInterval |
1 s | Spacing between messages to the chat. |
MaxMessagesPerMinute |
20 | Telegram's group limit. A private chat can go higher. |
ThrottleWaitLimit |
60 s | How long a message may queue locally before giving up with telegram.throttled. |
MaxRateLimitRetries |
3 | Resends after a 429. |
MaxRetryAfter |
60 s | The longest wait honoured. If Telegram asks for longer, the send returns at once (max 1 hour). |
TransientRetry |
3 attempts, 1 s → 10 s | For timeouts, connection failures, and 5xx. RetryPolicy.NoRetry for at-most-once. |
MaxPartsPerMessage |
10 | Longer content is truncated, and the last part says how much was cut (max 50). |
Messages
- Levels.
Info,Warning, andCriticalappear first as[INFO],[WARNING], and[CRITICAL]. With three levels, each one calls for a different reaction: glance, handle today, handle now. - Critical goes first. A queued
Criticalis always ahead of every queuedInfoandWarning. It leaves on the next free throttling slot, at most oneMinimumIntervalaway, so a critical alert can arrive within five seconds. Priority changes the queue order only.Criticalstill pays for a slot under the per-chat and per-minute limits, and still waits out a 429 pause, because rushing earns a 429 and arrives later. The first message out after a pause is theCritical. A message already being sent is not interrupted.InfoandWarningkeep their arrival order. - Plain text. No
parse_mode. Every character shows as written. Do not escape anything. - Splitting. Content over 4096 UTF-16 code units is split on line breaks. Each part is marked
(1/3),(2/3), and so on. A single line that is too long is cut on a grapheme boundary, so emoji, ZWJ sequences, and combining marks stay whole. Parts go out in order. If one fails, the rest are not sent, and the error reportspartsSent/partsTotal. - Link previews are off. A preview would have Telegram's servers fetch any URL in your alert.
429s, retries, and duplicates
sendMessage is not idempotent, and Telegram has no deduplication key. The package treats each kind of failure
according to what it says about delivery:
| Failure | Delivered? | What happens |
|---|---|---|
| 429 | No, Telegram refused it | Wait parameters.retry_after (or the Retry-After header), then resend. This cannot duplicate. The pause applies to every send on the notifier. |
429 asking for more than MaxRetryAfter |
No | Return telegram.rate_limited at once with retryAfterSeconds. Later sends fail fast until the pause ends. |
| Timeout, connection failure, 5xx | Unknown | Resend per TransientRetry (at-least-once). |
| 400, 401, 403, 404 | No | Return at once. Resending changes nothing. |
2xx without "ok": true |
Unknown | Return telegram.unexpected_response without resending. It usually means a proxy answered. |
Why at-least-once by default. For alerts, the two mistakes do not cost the same. A duplicate costs the reader a
second glance. A lost alert can mean nobody knows the system has stopped trading. If duplicates cost you more, set
TransientRetry = RetryPolicy.NoRetry.
Failures the package has already retried come back as ErrorCategory.Exhausted, which is not transient. That keeps your own
retry layer from multiplying the attempts.
Known limitations. The throttle uses fixed time windows. Across a window boundary, two messages can occasionally go
out less than a second apart. Telegram's per-chat limit is soft, and an occasional 429 is absorbed by the resend
logic above. When the minute's quota is spent, a Critical also waits for the next minute.
Division of labour with Ozakboy.Http. The named client is built with AddOzakboyHttpPipeline (Ozakboy.Http
0.3.2), which supplies masked logging, the token masker, and the timeouts. HttpPipelineClient folds exceptions into
Result and tells timeouts from cancellation. Throttling uses WeightedRateLimiter, built on
System.Threading.RateLimiting and running on TimeProvider, inside the notifier's own priority gate. The pipeline's
rate limiting is off, and its RetryHandler is set to RetryPolicy.NoRetry. Retries stay in one loop in
TelegramNotifier, for three reasons. RetryHandler cannot read the body's retry_after. sendMessage would have to
be falsely labelled idempotent before RetryHandler retried it. And the pipeline's limiter admits in arrival order, so
it cannot put a Critical first or share a 429 pause across the notifier. Every send, resends included, queues at the
gate again and pays for its own slot, the same rule the 0.3.0 pipeline follows.
Keeping the token out of logs
The address is https://api.telegram.org/bot<TOKEN>/sendMessage. Anything that prints the request URI prints the
token. Query-parameter masking cannot help, because the token is a path segment. The package handles each
place the URI can surface:
| Where the URI can surface | What the package does |
|---|---|
IHttpClientFactory's default LogicalHandler / ClientHandler logging. It logs the full URI at Information level, puts it in a scope, and .NET redacts only the query. |
AddOzakboyHttpPipeline removes it on the named client. |
Ozakboy.Http's SanitizingLoggingHandler, part of the pipeline. It logs the URI, and the exception on failure. |
The token and its post-colon secret are registered in HttpPipelineOptions.KnownSecrets, so the logged URI and exception text are masked, and the logger gets a SanitizedException, never the original. Body logging stays off, because the body holds the chat id and the text. TelegramNotifier's own log lines carry only the level, part number, error code, and timing. |
Error messages built from transport exceptions, and Error.Exception. |
The facade comes from CreateOzakboyHttpPipelineClient and masks with the same masker. Every returned error is then redacted again at the notifier's exit: the code, the message, and every data entry. The exception is always replaced by RedactedException, which keeps a redacted ToString() and holds no inner exception. |
| Response bodies echoing content. The pipeline never masks these, because a 4xx is a successful response as far as it is concerned. | Errors carry only Telegram's description (truncated and redacted), never the body. This is why the notifier keeps its own exit redaction on top of the pipeline's masker. |
Redaction registers both the full token and the part after the colon. The colon may be percent-encoded, but the secret part never changes under encoding.
The test suite includes a leak test. It runs a recognisable fake token through the full injected pipeline in every scenario: success, 400/401/403, 429, 5xx, timeout, and transport exceptions whose messages contain the full URI. It then checks every log surface and every error field.
Not covered by the package. Keep these in mind:
- Handlers you add yourself. Do not attach URI-logging handlers to the
Ozakboy.Notify.Telegramclient, and watch for ones added globally throughConfigureHttpClientDefaults. - Distributed tracing. When an
ActivityListeneror OpenTelemetry is attached,System.Net.Httprecordsurl.full, which includes the path. - Event tracing. An
EventListeneror ETW session onSystem-Net-HttpseesRequestStartwith the path. - The
HttpClientconstructor.new TelegramNotifier(httpClient, …)uses your client as is. If it came fromIHttpClientFactory, remove its default loggers yourself.
Errors
Every expected failure is a Result failure. Nothing throws except programming errors, such as invalid options, null, or use after
dispose.
| Code | Category | Meaning |
|---|---|---|
telegram.unauthorized |
Unauthorized | 401: token invalid or revoked. |
telegram.forbidden |
Forbidden | 403: the bot was kicked, blocked, or cannot post. |
telegram.chat_not_found |
NotFound | 400 chat not found. |
telegram.chat_migrated |
NotFound | The group became a supergroup. The new id is in migrateToChatId. |
telegram.bad_request |
Validation | Any other 400. |
telegram.not_found |
NotFound | 404: usually a wrong ApiBaseAddress. |
telegram.rate_limited |
RateLimited / Exhausted | 429, or a pause still running. May carry retryAfterSeconds. |
telegram.unavailable |
Unavailable / Exhausted | 5xx. |
telegram.timeout |
Timeout / Exhausted | A request timed out. |
telegram.network |
Network / Exhausted | DNS, TCP, TLS, or a dropped connection. |
telegram.cancelled |
Cancelled | Your CancellationToken fired. |
telegram.throttled |
RateLimited | Local throttling queue full for longer than ThrottleWaitLimit. Nothing was sent. |
telegram.unexpected_response |
Unexpected | The response does not confirm delivery. |
Data keys are listed in TelegramErrorDataKeys: statusCode, telegramErrorCode, description, retryAfterSeconds,
migrateToChatId, attempts, partsSent, partsTotal, level, and transportErrorCode.
Testing
- Your code. Depend on
ITelegramNotifier, which has a single method, and substitute a fake. No token is needed. - This package. MSTest on Microsoft.Testing.Platform, with a hand-written
HttpMessageHandlerandFakeTimeProvider. No network, no mocking framework. - Live test.
[TestCategory("TelegramLive")]sends a real message only when bothTELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDare set. Otherwise it endsInconclusive. It reads nothing from configuration files.
dotnet test -c Release
Licence
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Http (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Ozakboy.Core.Abstractions (>= 0.3.0)
- Ozakboy.Http (>= 0.3.2)
- Ozakboy.Security (>= 0.1.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0 | 116 | 10/14/2026 |
0.1.0 (2026-10-15) — First release. ITelegramNotifier / TelegramNotifier returning Result; Info / Warning / Critical levels with a Critical priority lane; plain-text only; grapheme-safe splitting over 4096 characters; per-chat throttling on System.Threading.RateLimiting; 429 retry_after handling; at-least-once retry of transient failures; bot token registered as a known secret and redacted from every error. Full changelog: https://github.com/ozakboy/Ozakboy.Notify.Telegram/blob/main/CHANGELOG.md