Building a Telegram Bot for System Notifications
Going beyond simple webhooks. How to build a robust C# BackgroundService that listens to NATS events and manages a bidirectional Telegram Bot.
Introduction
I wanted a way to get notified when documents finished processing without staring at the web dashboard. A Telegram bot was the perfect solution---it’s always in my pocket, supports rich media, and the Bot API is surprisingly well-designed. What started as a simple notification channel evolved into a full conversational interface for managing my document archive.
While n8n is great for simple flows, sometimes you need a robust, code-first approach for system notifications. We needed a bot that could not only send alerts but also respond to commands (like /status or /restart). For this, we leveraged .NET’s BackgroundService and the NATS messaging system.
Why a Custom Bot?
- Two-way Communication: Receive commands from admins to query system state.
- Performance: High-throughput processing of system events directly from the message bus.
- Integration: Deep integration with our C# domain models and dependency injection container.
What We’ll Build
In this guide, we will implement TelegramBotService, a hosted service that connects to the Telegram Bot API and subscribes to alerts.> subjects on NATS.
[Telegram Bot API Documentation]
— Telegram , 2024
Architecture Overview
The bot acts as a bridge between the event bus and the chat application. [Designing Event-Driven Systems] — Ben Stopford , 2018
flowchart LR
Workers[AI Workers] -->|Publish| NATS[NATS JetStream]
subgraph Service["Notification Service"]
NatsListener[NATS Listener]
BotLogic[Bot Command Logic]
TelegramClient[Telegram Client]
NatsListener -->|Event| BotLogic
BotLogic -->|Message| TelegramClient
end
NATS --> NatsListener
TelegramClient -->|API| Telegram[Telegram Server]
classDef primary fill:#7c3aed,color:#fff
classDef secondary fill:#06b6d4,color:#fff
classDef warning fill:#fbbf24,color:#000
class Workers,NatsListener,BotLogic,TelegramClient primary
class NATS secondary
class Telegram warning
Section 1: The Telegram Client
We use the Telegram.Bot NuGet package. It facilitates typed access to the API.
[Telegram.Bot - .NET Client Library]
— TelegramBots , 2024
public class TelegramNotifier : ITelegramNotifier
{
private readonly ITelegramBotClient _botClient;
public TelegramNotifier(IOptions<TelegramOptions> options)
{
_botClient = new TelegramBotClient(options.Value.BotToken);
}
public async Task SendAlertAsync(string message)
{
await _botClient.SendTextMessageAsync(
chatId: _chatId,
text: message,
parseMode: ParseMode.Markdown
);
}
}
Section 2: Listening to NATS
The core of the service is the BackgroundService that maintains a subscription to NATS. We use the NATS.Client.Core library for modern, async-first usage.
[BackgroundService in .NET]
— Microsoft , 2024
public class NatsAlertListener : BackgroundService
{
private readonly INatsConnection _nats;
private readonly ITelegramNotifier _notifier;
// We listen to all alerts in the staging environment
private const string Subject = "staging.archives.alerts.>";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in _nats.SubscribeAsync<AlertEvent>(Subject, cancellationToken: stoppingToken))
{
var alert = msg.Data;
var formattedMessage = $"🚨 *{alert.Severity}*: {alert.Message}";
await _notifier.SendAlertAsync(formattedMessage);
}
}
}
Section 3: Handling Commands
To make the bot interactive, we implement a polling loop (or webhook) to listen for user messages. [Long Polling vs WebHooks] — Telegram , 2024
We implemented a simple /health command that checks the specialized health endpoints of our API and reports back to the chat. This allows us to check system status from our phones without logging into the VPN or opening a dashboard.
Section 4: API-Based Data Access
Instead of giving the bot direct database access, we created internal API endpoints and use a service token for authentication:
public class AppApiClient : IAppApiClient
{
private readonly HttpClient _httpClient;
private readonly TelegramBotOptions _options;
public AppApiClient(
IHttpClientFactory httpClientFactory,
IOptions<TelegramBotOptions> options)
{
_httpClient = httpClientFactory.CreateClient("MyAppApi");
_options = options.Value;
// Add service authentication headers
_httpClient.DefaultRequestHeaders.Add("X-Service-Token", _options.ServiceToken);
_httpClient.DefaultRequestHeaders.Add("X-Service-Name", "telegram-bot");
}
public async Task<UserLookupResponse?> GetUserByTelegramChatIdAsync(long chatId, CancellationToken ct)
{
var response = await _httpClient.GetAsync($"/internal/users/by-telegram/{chatId}", ct);
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<UserLookupResponse>(ct);
}
public async Task<RagQueryResponse> AskAsync(string userId, string question, CancellationToken ct)
{
// Include user ID header for authorization
var request = new HttpRequestMessage(HttpMethod.Post, "/api/rag/ask");
request.Headers.Add("X-App-UserId", userId);
request.Content = JsonContent.Create(new { question, maxChunks = 5 });
var response = await _httpClient.SendAsync(request, ct);
return await response.Content.ReadFromJsonAsync<RagQueryResponse>(ct);
}
}
The API validates the service token and enforces permissions:
// In API AuthenticationServiceExtensions
serviceTokens[telegramBotToken] = new ServiceTokenConfig
{
ExpectedServiceName = "telegram-bot",
Role = "service",
Permissions = ["users:read", "telegram:link", "documents:upload", "rag:query"]
};
Benefits of this pattern:
- Single source of truth - All authorization logic in the API
- Audit trail - Every operation logged through API middleware
- Simpler bot - Only needs an HTTP client, not database drivers
- Better security - Bot can’t bypass business rules
Conclusion
Combining .NET’s robust hosting model with NATS and Telegram creates a powerful ChatOps experience. We receive critical information where we already are (in our chat app) and can take basic actions immediately.
Looking back, the biggest lesson was keeping the bot thin. Early on I was tempted to put business logic directly in the command handlers---querying databases, triggering pipelines, managing state. That turned the bot into a second API with none of the safeguards. Once I moved everything behind the main API and made the bot a pure interface layer, things got dramatically simpler: easier to test, easier to secure, and easier to extend. If I were starting over, I would treat the bot as just another frontend from day one.
Next Steps
- See how we configure the infrastructure supporting this bot.
- Learn about the observability stack that generates the alerts.
- Explore the document processing pipeline that feeds events into NATS.
- Read about securing service-to-service communication in a homelab environment.