using System.Net;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Args;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Polling;
using Telegram.Bot.Requests;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace Volcanic.AgenticCore.Telegram.Polling;
/// Receives one update. Must return quickly: the polling loop waits for it before the next update.
public interface IUpdateRouter
{
Task RouteAsync(Update update, CancellationToken cancellationToken);
}
///
/// Runs Telegram.Bot's built-in long-polling loop and makes it survive connection loss.
///
/// The built-in loop polls again right after a failed getUpdates (a busy loop while offline) and ends for good
/// if the error handler throws. So the error handler here never throws and waits out a backoff delay, which is what
/// holds the loop back; a successful getUpdates response resets the backoff. 401 is the only fatal error.
/// If the loop still ends while the host runs, it is restarted from the last offset, so no update is lost or repeated.
///
///
public sealed class PollingService(
ITelegramBotClient bot,
IUpdateRouter router,
ReconnectPolicy policy,
IHostApplicationLifetime lifetime,
ILogger logger) : BackgroundService, IUpdateHandler
{
internal static readonly UpdateType[] AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery, UpdateType.MyChatMember];
private int _offset;
/// The next update id to request; everything below it has been handed to the router.
public int Offset => Volatile.Read(ref _offset);
/// Set when polling stopped the application because retrying cannot help (a rejected token).
public Exception? FatalError { get; private set; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
bot.OnApiResponseReceived += OnApiResponseReceived;
try
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
logger.LogInformation("Polling Telegram for updates");
await bot.ReceiveAsync(this, new ReceiverOptions { Offset = Offset, AllowedUpdates = AllowedUpdates }, stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (FatalPollingException ex)
{
logger.LogCritical(ex.InnerException, "{Reason}. Stopping.", ex.Message);
FatalError = ex;
lifetime.StopApplication();
return;
}
catch (Exception ex)
{
logger.LogError(ex, "The Telegram polling loop failed; restarting it");
}
if (stoppingToken.IsCancellationRequested) return;
if (!await DelayAsync(policy.NextDelay(), stoppingToken).ConfigureAwait(false)) return;
}
}
finally
{
bot.OnApiResponseReceived -= OnApiResponseReceived;
}
}
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
{
Volatile.Write(ref _offset, update.Id + 1);
MarkConnected();
try
{
await router.RouteAsync(update, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Handling update {UpdateId} failed", update.Id);
}
}
public async Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken)
{
if (source != HandleErrorSource.PollingError)
{
logger.LogError(exception, "Telegram update handling failed ({Source})", source);
return;
}
if (exception is ApiRequestException { ErrorCode: (int)HttpStatusCode.Unauthorized })
throw new FatalPollingException("Telegram rejected the bot token (401 Unauthorized)", exception);
var delay = policy.NextDelay();
var failures = policy.Failures;
if (exception is ApiRequestException { ErrorCode: (int)HttpStatusCode.Conflict })
logger.LogWarning("Telegram reports another poller or a webhook for this bot (409): {Message}. Retrying in {Delay:0.#} s",
exception.Message, delay.TotalSeconds);
else if (failures == 1)
// A single failure is usually the long-poll timeout racing the HTTP timeout; only repeated ones matter.
logger.LogDebug(exception, "Polling Telegram failed; retrying in {Delay:0.#} s", delay.TotalSeconds);
else
logger.LogWarning("Telegram unreachable (attempt {Attempt}): {Message}. Retrying in {Delay:0.#} s",
failures, exception.Message, delay.TotalSeconds);
await DelayAsync(delay, cancellationToken).ConfigureAwait(false);
}
private ValueTask OnApiResponseReceived(ITelegramBotClient botClient, ApiResponseEventArgs args, CancellationToken cancellationToken)
{
if (args.ApiRequestEventArgs.Request is GetUpdatesRequest && args.ResponseMessage.IsSuccessStatusCode) MarkConnected();
return ValueTask.CompletedTask;
}
private void MarkConnected()
{
var failures = policy.Reset();
if (failures > 1) logger.LogInformation("Telegram connection restored after {Failures} failed attempts", failures);
}
/// False when the host is stopping.
private static async Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
return false;
}
}
}
/// A polling error retrying cannot fix.
public sealed class FatalPollingException(string message, Exception inner) : Exception(message, inner);