Backend Intermediate 12 min

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.

By Victor Robin Updated:

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:

  1. Single source of truth - All authorization logic in the API
  2. Audit trail - Every operation logged through API middleware
  3. Simpler bot - Only needs an HTTP client, not database drivers
  4. 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

Further Reading

[Building Bots with Microsoft Bot Framework] — Microsoft , 2024 [NATS.io Documentation - Core Concepts] — Synadia , 2024