Compare commits

...

11 Commits

Author SHA1 Message Date
Flaminel
cf330987f7 added tests 2025-12-31 04:04:00 +02:00
Flaminel
17a30dc07b merged numeric input directives into one 2025-12-31 03:10:19 +02:00
Flaminel
8599034ff4 removed redundant code 2025-12-31 03:05:05 +02:00
Flaminel
17643af180 fixed indentation 2025-12-31 03:04:57 +02:00
Flaminel
22fb83b715 fixed numeric input directives 2025-12-31 03:04:46 +02:00
Flaminel
1e390ef888 updated docs 2025-12-30 00:54:06 +02:00
Flaminel
4bb3e24fc0 added images to payload 2025-12-29 23:05:24 +02:00
Flaminel
b0fdf21f2b added docs 2025-12-29 22:35:40 +02:00
Flaminel
3af8cfcc5c added migration 2025-12-29 22:26:39 +02:00
Flaminel
55c4d269d8 fixed numeric inputs key combos 2025-12-29 22:26:34 +02:00
Flaminel
453acc4dda added Telegram provider 2025-12-29 22:26:13 +02:00
40 changed files with 3670 additions and 43 deletions

View File

@@ -3,6 +3,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
namespace Cleanuparr.Api.DependencyInjection;
@@ -16,6 +17,7 @@ public static class NotificationsDI
.AddSingleton<IAppriseCliDetector, AppriseCliDetector>()
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<ITelegramProxy, TelegramProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public sealed record CreateTelegramProviderRequest : CreateNotificationProviderRequestBase
{
public string BotToken { get; init; } = string.Empty;
public string ChatId { get; init; } = string.Empty;
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public sealed record TestTelegramProviderRequest
{
public string BotToken { get; init; } = string.Empty;
public string ChatId { get; init; } = string.Empty;
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public sealed record UpdateTelegramProviderRequest : CreateNotificationProviderRequestBase
{
public string BotToken { get; init; } = string.Empty;
public string ChatId { get; init; } = string.Empty;
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
}

View File

@@ -6,6 +6,7 @@ using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.AspNetCore.Mvc;
@@ -48,6 +49,7 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -73,6 +75,7 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => p.TelegramConfiguration ?? new object(),
_ => new object()
}
})
@@ -289,6 +292,69 @@ public sealed class NotificationProvidersController : ControllerBase
}
}
[HttpPost("telegram")]
public async Task<IActionResult> CreateTelegramProvider([FromBody] CreateTelegramProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var telegramConfig = new TelegramConfig
{
BotToken = newProvider.BotToken,
ChatId = newProvider.ChatId,
TopicId = newProvider.TopicId,
SendSilently = newProvider.SendSilently
};
telegramConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Telegram,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
TelegramConfiguration = telegramConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Telegram provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("notifiarr/{id:guid}")]
public async Task<IActionResult> UpdateNotifiarrProvider(Guid id, [FromBody] UpdateNotifiarrProviderRequest updatedProvider)
{
@@ -535,6 +601,87 @@ public sealed class NotificationProvidersController : ControllerBase
}
}
[HttpPut("telegram/{id:guid}")]
public async Task<IActionResult> UpdateTelegramProvider(Guid id, [FromBody] UpdateTelegramProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.TelegramConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Telegram);
if (existingProvider == null)
{
return NotFound($"Telegram provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var telegramConfig = new TelegramConfig
{
BotToken = updatedProvider.BotToken,
ChatId = updatedProvider.ChatId,
TopicId = updatedProvider.TopicId,
SendSilently = updatedProvider.SendSilently
};
if (existingProvider.TelegramConfiguration != null)
{
telegramConfig = telegramConfig with { Id = existingProvider.TelegramConfiguration.Id };
}
telegramConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
TelegramConfiguration = telegramConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Telegram provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteNotificationProvider(Guid id)
{
@@ -546,6 +693,7 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
@@ -707,6 +855,53 @@ public sealed class NotificationProvidersController : ControllerBase
}
}
[HttpPost("telegram/test")]
public async Task<IActionResult> TestTelegramProvider([FromBody] TestTelegramProviderRequest testRequest)
{
try
{
var telegramConfig = new TelegramConfig
{
BotToken = testRequest.BotToken,
ChatId = testRequest.ChatId,
TopicId = testRequest.TopicId,
SendSilently = testRequest.SendSilently
};
telegramConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Telegram,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = telegramConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (TelegramException ex)
{
_logger.LogWarning(ex, "Failed to test Telegram provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Telegram provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
private static NotificationProviderResponse MapProvider(NotificationConfig provider)
{
return new NotificationProviderResponse
@@ -730,6 +925,7 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(),
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => provider.TelegramConfiguration ?? new object(),
_ => new object()
}
};

View File

@@ -5,5 +5,6 @@ public enum NotificationProviderType
Notifiarr,
Apprise,
Ntfy,
Pushover
Pushover,
Telegram,
}

View File

@@ -363,6 +363,7 @@ public class NotificationConfigurationServiceTests : IDisposable
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
{
// Arrange
@@ -386,6 +387,7 @@ public class NotificationConfigurationServiceTests : IDisposable
[InlineData(NotificationProviderType.Apprise)]
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
{
// Arrange
@@ -417,6 +419,7 @@ public class NotificationConfigurationServiceTests : IDisposable
NotificationProviderType.Apprise => CreateAppriseConfig(name, isEnabled),
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
NotificationProviderType.Telegram => CreateTelegramConfig(name, isEnabled),
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
};
}
@@ -520,6 +523,31 @@ public class NotificationConfigurationServiceTests : IDisposable
}
};
}
private static NotificationConfig CreateTelegramConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Telegram,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
TelegramConfiguration = new TelegramConfig()
{
Id = Guid.NewGuid(),
BotToken = "test_bot_token_1234567890abcd",
ChatId = "1234567890",
TopicId = "-1234567890",
SendSilently = true
}
};
}
#endregion
}

View File

@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
using Moq;
@@ -19,6 +20,7 @@ public class NotificationProviderFactoryTests
private readonly Mock<INtfyProxy> _ntfyProxyMock;
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
private readonly Mock<ITelegramProxy> _telegramProxyMock;
private readonly IServiceProvider _serviceProvider;
private readonly NotificationProviderFactory _factory;
@@ -29,6 +31,7 @@ public class NotificationProviderFactoryTests
_ntfyProxyMock = new Mock<INtfyProxy>();
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
_pushoverProxyMock = new Mock<IPushoverProxy>();
_telegramProxyMock = new Mock<ITelegramProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
@@ -36,6 +39,7 @@ public class NotificationProviderFactoryTests
services.AddSingleton(_ntfyProxyMock.Object);
services.AddSingleton(_notifiarrProxyMock.Object);
services.AddSingleton(_pushoverProxyMock.Object);
services.AddSingleton(_telegramProxyMock.Object);
_serviceProvider = services.BuildServiceProvider();
_factory = new NotificationProviderFactory(_serviceProvider);
@@ -161,6 +165,35 @@ public class NotificationProviderFactoryTests
Assert.Equal(NotificationProviderType.Pushover, provider.Type);
}
[Fact]
public void CreateProvider_TelegramType_CreatesTelegramProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestTelegram",
Type = NotificationProviderType.Telegram,
IsEnabled = true,
Configuration = new TelegramConfig
{
Id = Guid.NewGuid(),
BotToken = "test-bot-token",
ChatId = "123456789",
SendSilently = false
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<TelegramProvider>(provider);
Assert.Equal("TestTelegram", provider.Name);
Assert.Equal(NotificationProviderType.Telegram, provider.Type);
}
[Fact]
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
{
@@ -241,7 +274,8 @@ public class NotificationProviderFactoryTests
(Type: NotificationProviderType.Apprise, Config: (object)new AppriseConfig { Id = Guid.NewGuid(), Url = "http://test.com", Key = "key" }),
(Type: NotificationProviderType.Ntfy, Config: (object)new NtfyConfig { Id = Guid.NewGuid(), ServerUrl = "http://test.com", Topics = new List<string> { "t" }, AuthenticationType = NtfyAuthenticationType.None, Priority = NtfyPriority.Default }),
(Type: NotificationProviderType.Notifiarr, Config: (object)new NotifiarrConfig { Id = Guid.NewGuid(), ApiKey = "1234567890", ChannelId = "12345" }),
(Type: NotificationProviderType.Pushover, Config: (object)new PushoverConfig { Id = Guid.NewGuid(), ApiToken = "token", UserKey = "user", Devices = new List<string>(), Priority = PushoverPriority.Normal, Sound = "", Tags = new List<string>() })
(Type: NotificationProviderType.Pushover, Config: (object)new PushoverConfig { Id = Guid.NewGuid(), ApiToken = "token", UserKey = "user", Devices = new List<string>(), Priority = PushoverPriority.Normal, Sound = "", Tags = new List<string>() }),
(Type: NotificationProviderType.Telegram, Config: (object)new TelegramConfig { Id = Guid.NewGuid(), BotToken = "token", ChatId = "123456789", SendSilently = false })
};
foreach (var (type, configObj) in configs)

View File

@@ -0,0 +1,460 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Telegram;
public class TelegramProviderTests
{
private readonly Mock<ITelegramProxy> _proxyMock;
private readonly TelegramConfig _config;
private readonly TelegramProvider _provider;
public TelegramProviderTests()
{
_proxyMock = new Mock<ITelegramProxy>();
_config = new TelegramConfig
{
Id = Guid.NewGuid(),
BotToken = "test-bot-token",
ChatId = "123456789",
TopicId = null,
SendSilently = false
};
_provider = new TelegramProvider(
"TestTelegram",
NotificationProviderType.Telegram,
_config,
_proxyMock.Object);
}
#region Constructor Tests
[Fact]
public void Constructor_SetsNameCorrectly()
{
// Assert
Assert.Equal("TestTelegram", _provider.Name);
}
[Fact]
public void Constructor_SetsTypeCorrectly()
{
// Assert
Assert.Equal(NotificationProviderType.Telegram, _provider.Type);
}
#endregion
#region SendNotificationAsync Tests
[Fact]
public async Task SendNotificationAsync_CallsProxyWithCorrectBotToken()
{
// Arrange
var context = CreateTestContext();
string? capturedBotToken = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((_, token) => capturedBotToken = token)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.Equal("test-bot-token", capturedBotToken);
}
[Fact]
public async Task SendNotificationAsync_CallsProxyWithCorrectChatId()
{
// Arrange
var context = CreateTestContext();
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("123456789", capturedPayload.ChatId);
}
[Fact]
public async Task SendNotificationAsync_TrimsChatId()
{
// Arrange
var config = new TelegramConfig
{
Id = Guid.NewGuid(),
BotToken = "token",
ChatId = " 123456789 ",
SendSilently = false
};
var provider = new TelegramProvider("Test", NotificationProviderType.Telegram, config, _proxyMock.Object);
var context = CreateTestContext();
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("123456789", capturedPayload.ChatId);
}
[Fact]
public async Task SendNotificationAsync_IncludesTitleInMessage()
{
// Arrange
var context = CreateTestContext();
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("Test Notification", capturedPayload.Text);
}
[Fact]
public async Task SendNotificationAsync_IncludesDescriptionInMessage()
{
// Arrange
var context = CreateTestContext();
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("Test Description", capturedPayload.Text);
}
[Fact]
public async Task SendNotificationAsync_IncludesDataInMessage()
{
// Arrange
var context = CreateTestContext();
context.Data["TestKey"] = "TestValue";
context.Data["AnotherKey"] = "AnotherValue";
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("TestKey: TestValue", capturedPayload.Text);
Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Text);
}
[Fact]
public async Task SendNotificationAsync_HtmlEncodesSpecialCharacters()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = "Test <script>alert('xss')</script>",
Description = "Description with & and < and >",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("&lt;script&gt;", capturedPayload.Text);
Assert.Contains("&amp;", capturedPayload.Text);
}
[Fact]
public async Task SendNotificationAsync_WithTopicId_SetsMessageThreadId()
{
// Arrange
var config = new TelegramConfig
{
Id = Guid.NewGuid(),
BotToken = "token",
ChatId = "123456789",
TopicId = "42",
SendSilently = false
};
var provider = new TelegramProvider("Test", NotificationProviderType.Telegram, config, _proxyMock.Object);
var context = CreateTestContext();
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal(42, capturedPayload.MessageThreadId);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("invalid")]
public async Task SendNotificationAsync_WithInvalidTopicId_SetsMessageThreadIdToNull(string? topicId)
{
// Arrange
var config = new TelegramConfig
{
Id = Guid.NewGuid(),
BotToken = "token",
ChatId = "123456789",
TopicId = topicId,
SendSilently = false
};
var provider = new TelegramProvider("Test", NotificationProviderType.Telegram, config, _proxyMock.Object);
var context = CreateTestContext();
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Null(capturedPayload.MessageThreadId);
}
[Fact]
public async Task SendNotificationAsync_WithSendSilently_SetsDisableNotification()
{
// Arrange
var config = new TelegramConfig
{
Id = Guid.NewGuid(),
BotToken = "token",
ChatId = "123456789",
SendSilently = true
};
var provider = new TelegramProvider("Test", NotificationProviderType.Telegram, config, _proxyMock.Object);
var context = CreateTestContext();
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.True(capturedPayload.DisableNotification);
}
[Fact]
public async Task SendNotificationAsync_WithImage_SetsPhotoUrl()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = "Test Notification",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>(),
Image = new Uri("https://example.com/image.jpg")
};
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("https://example.com/image.jpg", capturedPayload.PhotoUrl);
}
[Fact]
public async Task SendNotificationAsync_WithoutImage_PhotoUrlIsNull()
{
// Arrange
var context = CreateTestContext();
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Null(capturedPayload.PhotoUrl);
}
[Fact]
public async Task SendNotificationAsync_WithEmptyData_MessageContainsOnlyTitleAndDescription()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = "Test Title",
Description = "Test Description Only",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Contains("Test Title", capturedPayload.Text);
Assert.Contains("Test Description Only", capturedPayload.Text);
Assert.DoesNotContain(":", capturedPayload.Text.Replace("Test Title", "").Replace("Test Description Only", "").Trim());
}
[Fact]
public async Task SendNotificationAsync_TrimsWhitespaceFromTitleAndDescription()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = " Trimmed Title ",
Description = " Trimmed Description ",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.DoesNotContain(" Trimmed", capturedPayload.Text);
Assert.Contains("Trimmed Title", capturedPayload.Text);
}
[Fact]
public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException()
{
// Arrange
var context = CreateTestContext();
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.ThrowsAsync(new TelegramException("Proxy error"));
// Act & Assert
await Assert.ThrowsAsync<TelegramException>(() => _provider.SendNotificationAsync(context));
}
[Fact]
public async Task SendNotificationAsync_WithEmptyTitle_DoesNotIncludeTitleInMessage()
{
// Arrange
var context = new NotificationContext
{
EventType = NotificationEventType.Test,
Title = " ",
Description = "Description without title",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
TelegramPayload? capturedPayload = null;
_proxyMock.Setup(p => p.SendNotification(It.IsAny<TelegramPayload>(), It.IsAny<string>()))
.Callback<TelegramPayload, string>((payload, _) => capturedPayload = payload)
.Returns(Task.CompletedTask);
// Act
await _provider.SendNotificationAsync(context);
// Assert
Assert.NotNull(capturedPayload);
Assert.Equal("Description without title", capturedPayload.Text);
}
#endregion
#region Helper Methods
private static NotificationContext CreateTestContext()
{
return new NotificationContext
{
EventType = NotificationEventType.QueueItemDeleted,
Title = "Test Notification",
Description = "Test Description",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>()
};
}
#endregion
}

View File

@@ -0,0 +1,456 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Shared.Helpers;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Telegram;
public class TelegramProxyTests
{
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public TelegramProxyTests()
{
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private TelegramProxy CreateProxy()
{
return new TelegramProxy(_httpClientFactoryMock.Object);
}
private static TelegramPayload CreatePayload(string text = "Test message", string? photoUrl = null)
{
return new TelegramPayload
{
ChatId = "123456789",
Text = text,
PhotoUrl = photoUrl,
MessageThreadId = null,
DisableNotification = false
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidFactory_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), "test-bot-token");
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), "test-bot-token");
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_WithoutPhoto_UseSendMessageEndpoint()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), "my-bot-token");
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("/botmy-bot-token/sendMessage", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_WithPhotoAndShortCaption_UsesSendPhotoEndpoint()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var payload = CreatePayload(text: "Short caption", photoUrl: "https://example.com/image.jpg");
// Act
await proxy.SendNotification(payload, "my-bot-token");
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("/botmy-bot-token/sendPhoto", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_WithPhotoAndLongCaption_UsesSendMessageEndpoint()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Caption longer than 1024 characters
var payload = CreatePayload(text: new string('A', 1025), photoUrl: "https://example.com/image.jpg");
// Act
await proxy.SendNotification(payload, "my-bot-token");
// Assert
Assert.NotNull(capturedUri);
Assert.Contains("/botmy-bot-token/sendMessage", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), "test-bot-token");
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_WithPhotoAndLongCaption_IncludesInvisibleImageLink()
{
// Arrange
var proxy = CreateProxy();
string? capturedContent = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
capturedContent = await req.Content!.ReadAsStringAsync())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Caption longer than 1024 characters
var payload = CreatePayload(text: new string('A', 1025), photoUrl: "https://example.com/image.jpg");
// Act
await proxy.SendNotification(payload, "test-bot-token");
// Assert
Assert.NotNull(capturedContent);
Assert.Contains("&#8203;", capturedContent); // Zero-width space
Assert.Contains("example.com/image.jpg", capturedContent);
}
[Fact]
public async Task SendNotification_WithoutPhoto_DisablesWebPagePreview()
{
// Arrange
var proxy = CreateProxy();
string? capturedContent = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
capturedContent = await req.Content!.ReadAsStringAsync())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), "test-bot-token");
// Assert
Assert.NotNull(capturedContent);
Assert.Contains("disable_web_page_preview", capturedContent);
Assert.Contains("true", capturedContent);
}
[Fact]
public async Task SendNotification_WithMessageThreadId_IncludesThreadIdInBody()
{
// Arrange
var proxy = CreateProxy();
string? capturedContent = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
capturedContent = await req.Content!.ReadAsStringAsync())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var payload = new TelegramPayload
{
ChatId = "123456789",
Text = "Test message",
MessageThreadId = 42
};
// Act
await proxy.SendNotification(payload, "test-bot-token");
// Assert
Assert.NotNull(capturedContent);
Assert.Contains("message_thread_id", capturedContent);
Assert.Contains("42", capturedContent);
}
[Fact]
public async Task SendNotification_WithDisableNotification_IncludesDisableNotificationInBody()
{
// Arrange
var proxy = CreateProxy();
string? capturedContent = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>(async (req, _) =>
capturedContent = await req.Content!.ReadAsStringAsync())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var payload = new TelegramPayload
{
ChatId = "123456789",
Text = "Test message",
DisableNotification = true
};
// Act
await proxy.SendNotification(payload, "test-bot-token");
// Assert
Assert.NotNull(capturedContent);
Assert.Contains("disable_notification", capturedContent);
}
#endregion
#region SendNotification Error Tests
[Fact]
public async Task SendNotification_When400_ThrowsTelegramExceptionWithRejectedMessage()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.BadRequest, "Bad Request: chat not found");
// Act & Assert
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
proxy.SendNotification(CreatePayload(), "test-bot-token"));
Assert.Contains("rejected the request", ex.Message);
}
[Fact]
public async Task SendNotification_When401_ThrowsTelegramExceptionWithInvalidToken()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.Unauthorized, "Unauthorized");
// Act & Assert
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
proxy.SendNotification(CreatePayload(), "test-bot-token"));
Assert.Contains("bot token is invalid", ex.Message);
}
[Fact]
public async Task SendNotification_When403_ThrowsTelegramExceptionWithPermissionDenied()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.Forbidden, "Forbidden: bot was blocked by the user");
// Act & Assert
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
proxy.SendNotification(CreatePayload(), "test-bot-token"));
Assert.Contains("permission", ex.Message);
}
[Fact]
public async Task SendNotification_When429_ThrowsTelegramExceptionWithRateLimitMessage()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse((HttpStatusCode)429, "Too Many Requests");
// Act & Assert
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
proxy.SendNotification(CreatePayload(), "test-bot-token"));
Assert.Contains("Rate limited", ex.Message);
}
[Fact]
public async Task SendNotification_WhenOtherHttpError_ThrowsTelegramExceptionWithStatusCode()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError, "Internal Server Error");
// Act & Assert
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
proxy.SendNotification(CreatePayload(), "test-bot-token"));
Assert.Contains("500", ex.Message);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsTelegramException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
proxy.SendNotification(CreatePayload(), "test-bot-token"));
Assert.Contains("Unable to reach Telegram API", ex.Message);
}
[Fact]
public async Task SendNotification_WhenErrorResponseTruncatesLongBody()
{
// Arrange
var proxy = CreateProxy();
var longErrorBody = new string('X', 600);
SetupErrorResponse(HttpStatusCode.BadRequest, longErrorBody);
// Act & Assert
var ex = await Assert.ThrowsAsync<TelegramException>(() =>
proxy.SendNotification(CreatePayload(), "test-bot-token"));
Assert.True(ex.Message.Length < 600);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode, string body = "")
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(statusCode)
{
Content = new StringContent(body)
});
}
#endregion
}

View File

@@ -87,6 +87,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
.Include(p => p.AppriseConfiguration)
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -137,6 +138,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
NotificationProviderType.Apprise => config.AppriseConfiguration,
NotificationProviderType.Ntfy => config.NtfyConfiguration,
NotificationProviderType.Pushover => config.PushoverConfiguration,
NotificationProviderType.Telegram => config.TelegramConfiguration,
_ => throw new ArgumentOutOfRangeException(nameof(config), $"Config type for provider type {config.Type.ToString()} is not registered")
};

View File

@@ -1,9 +1,11 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Microsoft.Extensions.DependencyInjection;
@@ -26,6 +28,7 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
NotificationProviderType.Apprise => CreateAppriseProvider(config),
NotificationProviderType.Ntfy => CreateNtfyProvider(config),
NotificationProviderType.Pushover => CreatePushoverProvider(config),
NotificationProviderType.Telegram => CreateTelegramProvider(config),
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
};
}
@@ -62,4 +65,12 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
return new PushoverProvider(config.Name, config.Type, pushoverConfig, proxy);
}
private INotificationProvider CreateTelegramProvider(NotificationProviderDto config)
{
var telegramConfig = (TelegramConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<ITelegramProxy>();
return new TelegramProvider(config.Name, config.Type, telegramConfig, proxy);
}
}

View File

@@ -0,0 +1,6 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Telegram;
public interface ITelegramProxy
{
Task SendNotification(TelegramPayload payload, string botToken);
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Telegram;
public sealed class TelegramException : Exception
{
public TelegramException(string message) : base(message)
{
}
public TelegramException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,21 @@
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Notifications.Telegram;
public sealed class TelegramPayload
{
[JsonProperty("chat_id")]
public string ChatId { get; init; } = string.Empty;
[JsonProperty("text")]
public string Text { get; init; } = string.Empty;
[JsonProperty("photo")]
public string? PhotoUrl { get; init; }
[JsonProperty("message_thread_id")]
public int? MessageThreadId { get; init; }
[JsonProperty("disable_notification")]
public bool DisableNotification { get; init; }
}

View File

@@ -0,0 +1,74 @@
using System.Net;
using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Telegram;
public sealed class TelegramProvider : NotificationProviderBase<TelegramConfig>
{
private readonly ITelegramProxy _proxy;
public TelegramProvider(
string name,
NotificationProviderType type,
TelegramConfig config,
ITelegramProxy proxy
) : base(name, type, config)
{
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config.BotToken);
}
private TelegramPayload BuildPayload(NotificationContext context)
{
return new TelegramPayload
{
ChatId = Config.ChatId.Trim(),
MessageThreadId = ParseTopicId(Config.TopicId),
DisableNotification = Config.SendSilently,
Text = BuildMessage(context),
PhotoUrl = context.Image?.ToString()
};
}
private static string BuildMessage(NotificationContext context)
{
var builder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(context.Title))
{
builder.AppendLine(HtmlEncode(context.Title.Trim()));
builder.AppendLine();
}
if (!string.IsNullOrWhiteSpace(context.Description))
{
builder.AppendLine(HtmlEncode(context.Description.Trim()));
}
if (context.Data.Any())
{
builder.AppendLine();
foreach ((string key, string value) in context.Data)
{
builder.AppendLine($"{HtmlEncode(key)}: {HtmlEncode(value)}");
}
}
return builder.ToString().Trim();
}
private static string HtmlEncode(string value) => WebUtility.HtmlEncode(value);
private static int? ParseTopicId(string? topicId)
{
return int.TryParse(topicId, out int parsed) ? parsed : null;
}
}

View File

@@ -0,0 +1,109 @@
using System.Text;
using Cleanuparr.Shared.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using System.Net;
namespace Cleanuparr.Infrastructure.Features.Notifications.Telegram;
public sealed class TelegramProxy : ITelegramProxy
{
private readonly HttpClient _httpClient;
public TelegramProxy(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task SendNotification(TelegramPayload payload, string botToken)
{
bool hasImage = !string.IsNullOrWhiteSpace(payload.PhotoUrl);
bool captionFits = payload.Text.Length <= 1024;
bool usePhoto = hasImage && captionFits;
string endpoint = usePhoto ? "sendPhoto" : "sendMessage";
string url = $"https://api.telegram.org/bot{botToken}/{endpoint}";
string text = payload.Text;
if (hasImage && !usePhoto)
{
text = $"{payload.Text}\n{BuildInvisibleImageLink(payload.PhotoUrl!)}";
}
object body = usePhoto
? new
{
chat_id = payload.ChatId,
message_thread_id = payload.MessageThreadId,
disable_notification = payload.DisableNotification,
photo = payload.PhotoUrl,
caption = text,
parse_mode = "HTML"
}
: new
{
chat_id = payload.ChatId,
message_thread_id = payload.MessageThreadId,
disable_notification = payload.DisableNotification,
text,
parse_mode = "HTML",
disable_web_page_preview = !hasImage
};
try
{
string content = JsonConvert.SerializeObject(body, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return;
}
string bodyContent = await response.Content.ReadAsStringAsync();
throw MapToException(response.StatusCode, bodyContent);
}
catch (HttpRequestException ex)
{
throw new TelegramException("Unable to reach Telegram API", ex);
}
}
private static TelegramException MapToException(HttpStatusCode statusCode, string responseBody)
{
return statusCode switch
{
HttpStatusCode.BadRequest => new TelegramException($"Telegram rejected the request: {Truncate(responseBody)}"),
HttpStatusCode.Unauthorized => new TelegramException("Telegram bot token is invalid"),
HttpStatusCode.Forbidden => new TelegramException("Bot does not have permission to message the chat"),
HttpStatusCode.TooManyRequests => new TelegramException("Rate limited by Telegram"),
_ => new TelegramException($"Telegram API error ({(int)statusCode}): {Truncate(responseBody)}")
};
}
private static string BuildInvisibleImageLink(string imageUrl)
{
// Zero-width space to force a preview without visible text as described in https://stackoverflow.com/a/55126912
return $"<a href=\"{WebUtility.HtmlEncode(imageUrl)}\">&#8203;</a>";
}
private static string Truncate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
const int limit = 500;
return value.Length <= limit ? value : value[..limit];
}
}

View File

@@ -0,0 +1,296 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification;
public sealed class TelegramConfigTests
{
#region IsValid Tests
[Fact]
public void IsValid_WithValidBotTokenAndChatId_ReturnsTrue()
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789"
};
config.IsValid().ShouldBeTrue();
}
[Fact]
public void IsValid_WithNegativeChatId_ReturnsTrue()
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "-1001234567890" // Group chat ID
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullBotToken_ReturnsFalse(string? botToken)
{
var config = new TelegramConfig
{
BotToken = botToken ?? string.Empty,
ChatId = "123456789"
};
config.IsValid().ShouldBeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullChatId_ReturnsFalse(string? chatId)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = chatId ?? string.Empty
};
config.IsValid().ShouldBeFalse();
}
[Theory]
[InlineData("not-a-number")]
[InlineData("abc123")]
[InlineData("12.34")]
public void IsValid_WithInvalidChatId_ReturnsFalse(string chatId)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = chatId
};
config.IsValid().ShouldBeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullTopicId_ReturnsTrue(string? topicId)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789",
TopicId = topicId
};
config.IsValid().ShouldBeTrue();
}
[Fact]
public void IsValid_WithValidTopicId_ReturnsTrue()
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789",
TopicId = "42"
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData("not-a-number")]
[InlineData("abc")]
[InlineData("12.34")]
public void IsValid_WithInvalidTopicId_ReturnsFalse(string topicId)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789",
TopicId = topicId
};
config.IsValid().ShouldBeFalse();
}
#endregion
#region Validate Tests
[Fact]
public void Validate_WithValidConfig_DoesNotThrow()
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullBotToken_ThrowsValidationException(string? botToken)
{
var config = new TelegramConfig
{
BotToken = botToken ?? string.Empty,
ChatId = "123456789"
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Telegram bot token is required");
}
[Theory]
[InlineData("123456789")]
[InlineData("short")]
[InlineData("a")]
public void Validate_WithBotTokenTooShort_ThrowsValidationException(string botToken)
{
var config = new TelegramConfig
{
BotToken = botToken,
ChatId = "123456789"
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Telegram bot token must be at least 10 characters long");
}
[Fact]
public void Validate_WithBotTokenAtMinimumLength_DoesNotThrow()
{
var config = new TelegramConfig
{
BotToken = "1234567890", // Exactly 10 characters
ChatId = "123456789"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullChatId_ThrowsValidationException(string? chatId)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = chatId ?? string.Empty
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Telegram chat ID is required");
}
[Theory]
[InlineData("not-a-number")]
[InlineData("abc123")]
[InlineData("12.34")]
public void Validate_WithInvalidChatId_ThrowsValidationException(string chatId)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = chatId
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Telegram chat ID must be a valid integer (negative IDs allowed for groups)");
}
[Fact]
public void Validate_WithNegativeChatId_DoesNotThrow()
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "-1001234567890" // Group chat ID
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData("not-a-number")]
[InlineData("abc")]
[InlineData("12.34")]
public void Validate_WithInvalidTopicId_ThrowsValidationException(string topicId)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789",
TopicId = topicId
};
var exception = Should.Throw<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Telegram topic ID must be a valid integer when specified");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullTopicId_DoesNotThrow(string? topicId)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789",
TopicId = topicId
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WithValidTopicId_DoesNotThrow()
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789",
TopicId = "42"
};
Should.NotThrow(() => config.Validate());
}
#endregion
#region SendSilently Tests
[Theory]
[InlineData(true)]
[InlineData(false)]
public void IsValid_WithAnySendSilentlyValue_DoesNotAffectValidity(bool sendSilently)
{
var config = new TelegramConfig
{
BotToken = "1234567890:ABCDefgh_ijklmnop",
ChatId = "123456789",
SendSilently = sendSilently
};
config.IsValid().ShouldBeTrue();
}
#endregion
}

View File

@@ -53,6 +53,8 @@ public class DataContext : DbContext
public DbSet<NtfyConfig> NtfyConfigs { get; set; }
public DbSet<PushoverConfig> PushoverConfigs { get; set; }
public DbSet<TelegramConfig> TelegramConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
@@ -149,6 +151,11 @@ public class DataContext : DbContext
.HasForeignKey<PushoverConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.TelegramConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<TelegramConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => p.Name).IsUnique();
});

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddTelegram : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "telegram_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
bot_token = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
chat_id = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
topic_id = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
send_silently = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_telegram_configs", x => x.id);
table.ForeignKey(
name: "fk_telegram_configs_notification_configs_notification_config_id",
column: x => x.notification_config_id,
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_telegram_configs_notification_config_id",
table: "telegram_configs",
column: "notification_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "telegram_configs");
}
}
}

View File

@@ -738,6 +738,48 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("pushover_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("BotToken")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT")
.HasColumnName("bot_token");
b.Property<string>("ChatId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("chat_id");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<bool>("SendSilently")
.HasColumnType("INTEGER")
.HasColumnName("send_silently");
b.Property<string>("TopicId")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("topic_id");
b.HasKey("Id")
.HasName("pk_telegram_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_telegram_configs_notification_config_id");
b.ToTable("telegram_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>
{
b.Property<Guid>("Id")
@@ -1033,6 +1075,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("TelegramConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig")
@@ -1088,6 +1142,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("NtfyConfiguration");
b.Navigation("PushoverConfiguration");
b.Navigation("TelegramConfiguration");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b =>

View File

@@ -43,6 +43,8 @@ public sealed record NotificationConfig
public PushoverConfig? PushoverConfiguration { get; init; }
public TelegramConfig? TelegramConfiguration { get; init; }
[NotMapped]
public bool IsConfigured => Type switch
{
@@ -50,6 +52,7 @@ public sealed record NotificationConfig
NotificationProviderType.Apprise => AppriseConfiguration?.IsValid() == true,
NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true,
NotificationProviderType.Pushover => PushoverConfiguration?.IsValid() == true,
NotificationProviderType.Telegram => TelegramConfiguration?.IsValid() == true,
_ => throw new ArgumentOutOfRangeException(nameof(Type), $"Invalid notification provider type {Type}")
};

View File

@@ -0,0 +1,84 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
using Cleanuparr.Persistence.Models.Configuration;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record TelegramConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
[ExcludeFromCodeCoverage]
public Guid NotificationConfigId { get; init; }
public NotificationConfig NotificationConfig { get; init; } = null!;
[Required]
[MaxLength(255)]
public string BotToken { get; init; } = string.Empty;
[Required]
[MaxLength(100)]
public string ChatId { get; init; } = string.Empty;
[MaxLength(100)]
public string? TopicId { get; init; }
public bool SendSilently { get; init; }
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(BotToken)
&& !string.IsNullOrWhiteSpace(ChatId)
&& IsChatIdValid(ChatId)
&& IsTopicValid(TopicId);
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(BotToken))
{
throw new ValidationException("Telegram bot token is required");
}
if (BotToken.Length < 10)
{
throw new ValidationException("Telegram bot token must be at least 10 characters long");
}
if (string.IsNullOrWhiteSpace(ChatId))
{
throw new ValidationException("Telegram chat ID is required");
}
if (!IsChatIdValid(ChatId))
{
throw new ValidationException("Telegram chat ID must be a valid integer (negative IDs allowed for groups)");
}
if (!IsTopicValid(TopicId))
{
throw new ValidationException("Telegram topic ID must be a valid integer when specified");
}
}
private static bool IsChatIdValid(string chatId)
{
return long.TryParse(chatId, out _);
}
private static bool IsTopicValid(string? topicId)
{
if (string.IsNullOrWhiteSpace(topicId))
{
return true;
}
return int.TryParse(topicId, out _);
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256 256-114.6 256-256S397.4 0 256 0m-46.1 291.2c-.7.8-2.8 3.4-2.5 6.7l-4 42.5-21.3-59c2.1-1.3 5-3.2 8.7-5.5 51-32.1 88.1-55.3 111.1-69.4-22.2 21.6-58 54.4-91.6 84.3zm4 66.3 4.5-48.3c6.6 4.4 16 10.8 26.5 18-17.4 17.7-26.4 26.2-31 30.3m163-202.7v.3c0 .9-.1 1.9-.2 3.2-.1.5-.1 1.1-.2 1.7v.1c-1.5 23-45.1 198.8-45.5 200.6-.1.3-1.8 6.5-7 6.7-3.2.1-6.3-1.1-8.5-3.3l-.3-.3c-17.6-15.1-74.7-53.8-94.4-66.9 7.8-7 30.7-27.5 53.5-48.6 57.8-53.3 59.3-58.5 60.1-61.3l.1-.2c.5-2.2-.1-4.4-1.6-5.9-1.7-1.7-4.2-2.3-6.9-1.6l-.5.2c-4.9 1.8-47 27.7-140.7 86.8-4.3 2.7-7.6 4.8-9.7 6.1l-61.7-20.1c-1.7-.8-2.3-1.7-1.9-2.9 0-.1.4-.6 2.5-2 9.8-6.7 157.8-61.1 255-96 2.4-.8 5.9-1.3 7.2-.8l.3.1c.1 0 .2.1.2.2v.2c.1 1.1.3 2.5.2 3.7" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 864 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><linearGradient id="telegram_svg__a" x1="256" x2="256" y1="790" y2="278" gradientTransform="translate(0 -278)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#1d93d2"/><stop offset="1" style="stop-color:#38b0e3"/></linearGradient><circle cx="256" cy="256" r="256" style="fill:url(#telegram_svg__a)"/><path d="m173.3 274.7 30.4 84.1s3.8 7.9 7.9 7.9 64.5-62.9 64.5-62.9l67.3-129.9-169 79.1z" style="fill:#c8daea"/><path d="m213.6 296.3-5.8 62s-2.4 19 16.5 0c19-19 37.2-33.6 37.2-33.6" style="fill:#a9c6d8"/><path d="m173.8 277.7-62.5-20.4s-7.5-3-5.1-9.9c.5-1.4 1.5-2.6 4.5-4.7C124.6 233.1 367 146 367 146s6.8-2.3 10.9-.8c2 .6 3.6 2.3 4 4.4.4 1.8.6 3.7.5 5.5 0 1.6-.2 3.1-.4 5.4-1.5 23.8-45.7 201.6-45.7 201.6s-2.6 10.4-12.1 10.8c-4.7.2-9.3-1.6-12.6-4.9-18.6-16-82.8-59.2-97-68.6-.6-.4-1.1-1.1-1.2-1.9-.2-1 .9-2.2.9-2.2s111.8-99.4 114.8-109.8c.2-.8-.6-1.2-1.8-.9-7.4 2.7-136.2 84.1-150.4 93-.9.2-2 .3-3.1.1" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -146,6 +146,12 @@ export class DocumentationService {
'pushover.sound': 'pushover.sound',
'pushover.tags': 'pushover.tags'
},
'notifications/telegram': {
'telegram.botToken': 'bot-token',
'telegram.chatId': 'chat-id',
'telegram.topicId': 'topic-id',
'telegram.sendSilently': 'send-silently'
},
};
constructor(private applicationPathService: ApplicationPathService) {}

View File

@@ -193,6 +193,43 @@ export interface TestPushoverProviderRequest {
tags: string[];
}
export interface CreateTelegramProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
botToken: string;
chatId: string;
topicId: string;
sendSilently: boolean;
}
export interface UpdateTelegramProviderRequest {
name: string;
isEnabled: boolean;
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
botToken: string;
chatId: string;
topicId: string;
sendSilently: boolean;
}
export interface TestTelegramProviderRequest {
botToken: string;
chatId: string;
topicId: string;
sendSilently: boolean;
}
@Injectable({
providedIn: 'root'
})
@@ -243,6 +280,13 @@ export class NotificationProviderService {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/pushover`, provider);
}
/**
* Create a new Telegram provider
*/
createTelegramProvider(provider: CreateTelegramProviderRequest): Observable<NotificationProviderDto> {
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/telegram`, provider);
}
/**
* Update an existing Notifiarr provider
*/
@@ -271,6 +315,13 @@ export class NotificationProviderService {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/pushover/${id}`, provider);
}
/**
* Update an existing Telegram provider
*/
updateTelegramProvider(id: string, provider: UpdateTelegramProviderRequest): Observable<NotificationProviderDto> {
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/telegram/${id}`, provider);
}
/**
* Delete a notification provider
*/
@@ -306,6 +357,13 @@ export class NotificationProviderService {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/pushover/test`, testRequest);
}
/**
* Test a Telegram provider (without ID - for testing configuration before saving)
*/
testTelegramProvider(testRequest: TestTelegramProviderRequest): Observable<TestNotificationResult> {
return this.http.post<TestNotificationResult>(`${this.baseUrl}/telegram/test`, testRequest);
}
/**
* Generic create method that delegates to provider-specific methods
*/
@@ -319,6 +377,8 @@ export class NotificationProviderService {
return this.createNtfyProvider(provider as CreateNtfyProviderRequest);
case NotificationProviderType.Pushover:
return this.createPushoverProvider(provider as CreatePushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.createTelegramProvider(provider as CreateTelegramProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
@@ -337,6 +397,8 @@ export class NotificationProviderService {
return this.updateNtfyProvider(id, provider as UpdateNtfyProviderRequest);
case NotificationProviderType.Pushover:
return this.updatePushoverProvider(id, provider as UpdatePushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.updateTelegramProvider(id, provider as UpdateTelegramProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}
@@ -355,6 +417,8 @@ export class NotificationProviderService {
return this.testNtfyProvider(testRequest as TestNtfyProviderRequest);
case NotificationProviderType.Pushover:
return this.testPushoverProvider(testRequest as TestPushoverProviderRequest);
case NotificationProviderType.Telegram:
return this.testTelegramProvider(testRequest as TestTelegramProviderRequest);
default:
throw new Error(`Unsupported provider type: ${type}`);
}

View File

@@ -43,8 +43,8 @@
placeholder="My Notification Provider"
class="w-full"
/>
<small class="form-helper-text">A unique name to identify this provider</small>
<small *ngIf="hasError('name', 'required')" class="form-error-text"> Provider name is required </small>
<small class="form-helper-text">A unique name to identify this provider</small>
</div>
<!-- Provider-Specific Configuration (Content Projection) -->

View File

@@ -50,6 +50,13 @@ export class ProviderTypeSelectionComponent {
iconUrl: 'icons/ext/pushover-light.svg',
iconUrlHover: 'icons/ext/pushover.svg',
description: 'https://pushover.net/'
},
{
type: NotificationProviderType.Telegram,
name: 'Telegram',
iconUrl: 'icons/ext/telegram-light.svg',
iconUrlHover: 'icons/ext/telegram.svg',
description: 'https://core.telegram.org/bots'
}
];

View File

@@ -0,0 +1,92 @@
<app-notification-provider-base
[visible]="visible"
modalTitle="Configure Telegram Provider"
[saving]="saving"
[testing]="testing"
[editingProvider]="editingProvider"
(save)="onSave($event)"
(cancel)="onCancel()"
(test)="onTest($event)">
<div slot="provider-config">
<div class="field">
<label for="bot-token">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('telegram.botToken')"
></i>
Bot Token *
</label>
<input
id="bot-token"
type="password"
pInputText
[formControl]="botTokenControl"
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
class="w-full"
/>
<small *ngIf="hasFieldError(botTokenControl, 'required')" class="form-error-text">Bot token is required</small>
<small *ngIf="hasFieldError(botTokenControl, 'minlength')" class="form-error-text">Bot token looks too short</small>
<small class="form-helper-text">Create a bot with BotFather and paste the API token</small>
</div>
<div class="field">
<label for="chat-id">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('telegram.chatId')"
></i>
Chat ID *
</label>
<input
id="chat-id"
type="text"
pInputText
numericInput signed
[formControl]="chatIdControl"
placeholder="e.g. 123456789 or -100123456789"
class="w-full"
/>
<small *ngIf="hasFieldError(chatIdControl, 'required')" class="form-error-text">Chat ID is required</small>
<small class="form-helper-text">Start a conversation with the bot or add it to your group to get the chat ID</small>
</div>
<div class="field">
<label for="topic-id">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('telegram.topicId')"
></i>
Topic ID (optional)
</label>
<input
id="topic-id"
type="text"
pInputText
numericInput
[formControl]="topicIdControl"
placeholder="Enter topic ID for supergroup"
class="w-full"
/>
<small class="form-helper-text">Specify a Topic ID to send to a specific thread (supergroups only)</small>
</div>
<div class="field flex flex-row">
<label class="field-label">
<i
class="pi pi-question-circle field-info-icon"
title="Click for documentation"
(click)="openFieldDocs('telegram.sendSilently')"
></i>
Send Silently
</label>
<div class="field-input">
<p-checkbox [binary]="true" [formControl]="sendSilentlyControl"></p-checkbox>
<small class="form-helper-text">Deliver without sound for recipients</small>
</div>
</div>
</div>
</app-notification-provider-base>

View File

@@ -0,0 +1 @@
@use '../../../styles/settings-shared.scss';

View File

@@ -0,0 +1,121 @@
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { InputTextModule } from 'primeng/inputtext';
import { CheckboxModule } from 'primeng/checkbox';
import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component';
import { NumericInputDirective } from '../../../../shared/directives';
import { TelegramFormData, BaseProviderFormData } from '../../models/provider-modal.model';
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
import { DocumentationService } from '../../../../core/services/documentation.service';
@Component({
selector: 'app-telegram-provider',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
InputTextModule,
CheckboxModule,
NumericInputDirective,
NotificationProviderBaseComponent
],
templateUrl: './telegram-provider.component.html',
styleUrls: ['./telegram-provider.component.scss']
})
export class TelegramProviderComponent implements OnInit, OnChanges {
@Input() visible = false;
@Input() editingProvider: NotificationProviderDto | null = null;
@Input() saving = false;
@Input() testing = false;
@Output() save = new EventEmitter<TelegramFormData>();
@Output() cancel = new EventEmitter<void>();
@Output() test = new EventEmitter<TelegramFormData>();
botTokenControl = new FormControl('', [Validators.required, Validators.minLength(10)]);
chatIdControl = new FormControl('', [Validators.required]);
topicIdControl = new FormControl('');
sendSilentlyControl = new FormControl(false);
private documentationService = inject(DocumentationService);
openFieldDocs(fieldName: string): void {
this.documentationService.openFieldDocumentation('notifications/telegram', fieldName);
}
ngOnInit(): void {
// initialization handled in ngOnChanges
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['editingProvider']) {
if (this.editingProvider) {
this.populateProviderFields();
} else {
this.resetProviderFields();
}
}
}
private populateProviderFields(): void {
if (!this.editingProvider) return;
const config = this.editingProvider.configuration as any;
this.botTokenControl.setValue(config?.botToken || '');
this.chatIdControl.setValue(config?.chatId || '');
this.topicIdControl.setValue(config?.topicId || '');
this.sendSilentlyControl.setValue(!!config?.sendSilently);
}
private resetProviderFields(): void {
this.botTokenControl.setValue('');
this.chatIdControl.setValue('');
this.topicIdControl.setValue('');
this.sendSilentlyControl.setValue(false);
}
protected hasFieldError(control: FormControl, errorType: string): boolean {
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
}
private isFormValid(): boolean {
return this.botTokenControl.valid && this.chatIdControl.valid;
}
onSave(baseData: BaseProviderFormData): void {
if (this.isFormValid()) {
const telegramData: TelegramFormData = {
...baseData,
botToken: this.botTokenControl.value || '',
chatId: this.chatIdControl.value || '',
topicId: this.topicIdControl.value || '',
sendSilently: this.sendSilentlyControl.value || false,
};
this.save.emit(telegramData);
} else {
this.botTokenControl.markAsTouched();
this.chatIdControl.markAsTouched();
}
}
onCancel(): void {
this.cancel.emit();
}
onTest(baseData: BaseProviderFormData): void {
if (this.isFormValid()) {
const telegramData: TelegramFormData = {
...baseData,
botToken: this.botTokenControl.value || '',
chatId: this.chatIdControl.value || '',
topicId: this.topicIdControl.value || '',
sendSilently: this.sendSilentlyControl.value || false,
};
this.test.emit(telegramData);
} else {
this.botTokenControl.markAsTouched();
this.chatIdControl.markAsTouched();
}
}
}

View File

@@ -65,6 +65,13 @@ export interface PushoverFormData extends BaseProviderFormData {
tags: string[];
}
export interface TelegramFormData extends BaseProviderFormData {
botToken: string;
chatId: string;
topicId: string;
sendSilently: boolean;
}
// Events for modal communication
export interface ProviderModalEvents {
save: (data: any) => void;

View File

@@ -198,6 +198,17 @@
(test)="onPushoverTest($event)"
></app-pushover-provider>
<!-- Telegram Provider Modal -->
<app-telegram-provider
[visible]="showTelegramModal"
[editingProvider]="editingProvider"
[saving]="saving()"
[testing]="testing()"
(save)="onTelegramSave($event)"
(cancel)="onProviderCancel()"
(test)="onTelegramTest($event)"
></app-telegram-provider>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -8,7 +8,7 @@ import {
} from "../../shared/models/notification-provider.model";
import { NotificationProviderType } from "../../shared/models/enums";
import { DocumentationService } from "../../core/services/documentation.service";
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData } from "./models/provider-modal.model";
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData } from "./models/provider-modal.model";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
// New modal components
@@ -17,6 +17,7 @@ import { NotifiarrProviderComponent } from "./modals/notifiarr-provider/notifiar
import { AppriseProviderComponent } from "./modals/apprise-provider/apprise-provider.component";
import { NtfyProviderComponent } from "./modals/ntfy-provider/ntfy-provider.component";
import { PushoverProviderComponent } from "./modals/pushover-provider/pushover-provider.component";
import { TelegramProviderComponent } from "./modals/telegram-provider/telegram-provider.component";
// PrimeNG Components
import { CardModule } from "primeng/card";
@@ -53,6 +54,7 @@ import { NotificationService } from "../../core/services/notification.service";
AppriseProviderComponent,
NtfyProviderComponent,
PushoverProviderComponent,
TelegramProviderComponent,
],
providers: [NotificationProviderConfigStore, ConfirmationService, MessageService],
templateUrl: "./notification-settings.component.html",
@@ -69,6 +71,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
showAppriseModal = false; // New: Apprise provider modal
showNtfyModal = false; // New: Ntfy provider modal
showPushoverModal = false; // New: Pushover provider modal
showTelegramModal = false; // New: Telegram provider modal
modalMode: 'add' | 'edit' = 'add';
editingProvider: NotificationProviderDto | null = null;
@@ -180,6 +183,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Pushover:
this.showPushoverModal = true;
break;
case NotificationProviderType.Telegram:
this.showTelegramModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
@@ -233,6 +239,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
case NotificationProviderType.Pushover:
this.showPushoverModal = true;
break;
case NotificationProviderType.Telegram:
this.showTelegramModal = true;
break;
default:
// For unsupported types, show the legacy modal with info message
this.showProviderModal = true;
@@ -309,6 +318,15 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
tags: pushoverConfig.tags || [],
};
break;
case NotificationProviderType.Telegram:
const telegramConfig = provider.configuration as any;
testRequest = {
botToken: telegramConfig.botToken,
chatId: telegramConfig.chatId,
topicId: telegramConfig.topicId || "",
sendSilently: telegramConfig.sendSilently || false,
};
break;
default:
this.notificationService.showError("Testing not supported for this provider type");
return;
@@ -349,6 +367,8 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
return "ntfy";
case NotificationProviderType.Pushover:
return "Pushover";
case NotificationProviderType.Telegram:
return "Telegram";
default:
return "Unknown";
}
@@ -489,6 +509,34 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
});
}
/**
* Handle Telegram provider save
*/
onTelegramSave(data: TelegramFormData): void {
if (this.modalMode === "edit" && this.editingProvider) {
this.updateTelegramProvider(data);
} else {
this.createTelegramProvider(data);
}
}
/**
* Handle Telegram provider test
*/
onTelegramTest(data: TelegramFormData): void {
const testRequest = {
botToken: data.botToken,
chatId: data.chatId,
topicId: data.topicId,
sendSilently: data.sendSilently,
};
this.notificationProviderStore.testProvider({
testRequest,
type: NotificationProviderType.Telegram,
});
}
/**
* Handle provider modal cancel
*/
@@ -505,6 +553,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
this.showAppriseModal = false;
this.showNtfyModal = false;
this.showPushoverModal = false;
this.showTelegramModal = false;
this.showProviderModal = false;
this.editingProvider = null;
this.notificationProviderStore.clearTestResult();
@@ -744,6 +793,61 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
this.monitorProviderOperation("updated");
}
/**
* Create new Telegram provider
*/
private createTelegramProvider(data: TelegramFormData): void {
const createDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
botToken: data.botToken,
chatId: data.chatId,
topicId: data.topicId,
sendSilently: data.sendSilently,
};
this.notificationProviderStore.createProvider({
provider: createDto,
type: NotificationProviderType.Telegram,
});
this.monitorProviderOperation("created");
}
/**
* Update existing Telegram provider
*/
private updateTelegramProvider(data: TelegramFormData): void {
if (!this.editingProvider) return;
const updateDto = {
name: data.name,
isEnabled: data.enabled,
onFailedImportStrike: data.onFailedImportStrike,
onStalledStrike: data.onStalledStrike,
onSlowStrike: data.onSlowStrike,
onQueueItemDeleted: data.onQueueItemDeleted,
onDownloadCleaned: data.onDownloadCleaned,
onCategoryChanged: data.onCategoryChanged,
botToken: data.botToken,
chatId: data.chatId,
topicId: data.topicId,
sendSilently: data.sendSilently,
};
this.notificationProviderStore.updateProvider({
id: this.editingProvider.id,
provider: updateDto,
type: NotificationProviderType.Telegram,
});
this.monitorProviderOperation("updated");
}
/**
* Monitor provider operation completion and close modals
*/

View File

@@ -1,19 +1,21 @@
import { Directive, HostListener } from '@angular/core';
import { booleanAttribute, Directive, HostListener, Input } from '@angular/core';
import { NgControl } from '@angular/forms';
/**
* Directive that restricts input to numeric characters only.
* Useful for fields that need to accept very long numeric values like Discord channel IDs
* that exceed JavaScript's safe integer limits.
*
* Usage: <input type="text" numericInput formControlName="channelId" />
*
* Usage:
* <input type="text" numericInput formControlName="channelId" />
* <input type="text" numericInput signed formControlName="chatId" />
*/
@Directive({
selector: '[numericInput]',
standalone: true
})
export class NumericInputDirective {
private regex = /^\d*$/; // Only allow positive integers (no decimals or negative numbers)
@Input({ transform: booleanAttribute }) signed = false;
constructor(private ngControl: NgControl) {}
@@ -21,65 +23,84 @@ export class NumericInputDirective {
onInput(event: Event): void {
const input = event.target as HTMLInputElement;
const originalValue = input.value;
const sanitized = this.sanitize(originalValue);
if (!this.regex.test(originalValue)) {
// Strip all non-numeric characters
const sanitized = originalValue.replace(/[^\d]/g, '');
// Update the form control value
this.ngControl.control?.setValue(sanitized);
// Update the input display value
if (sanitized !== originalValue) {
input.value = sanitized;
this.ngControl.control?.setValue(sanitized);
}
}
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent): void {
// Allow: backspace, delete, tab, escape, enter
if ([8, 9, 27, 13, 46].indexOf(event.keyCode) !== -1 ||
// Allow: Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
(event.keyCode === 65 && event.ctrlKey === true) ||
(event.keyCode === 67 && event.ctrlKey === true) ||
(event.keyCode === 86 && event.ctrlKey === true) ||
(event.keyCode === 88 && event.ctrlKey === true) ||
// Allow: home, end, left, right
(event.keyCode >= 35 && event.keyCode <= 39)) {
const allowedKeys = ['Backspace', 'Delete', 'Tab', 'Escape', 'Enter', 'Home', 'End', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
// Allow navigation and control keys
if (allowedKeys.includes(event.key)) {
return;
}
// Ensure that it is a number and stop the keypress
if ((event.shiftKey || (event.keyCode < 48 || event.keyCode > 57)) && (event.keyCode < 96 || event.keyCode > 105)) {
event.preventDefault();
// Allow: Ctrl/Cmd+A,C,V,X
if ((event.ctrlKey || event.metaKey) && ['a', 'c', 'v', 'x'].includes(event.key.toLowerCase())) {
return;
}
// Allow minus only at the start and only if not already present (when signed mode is enabled)
if (this.signed && event.key === '-') {
const input = event.target as HTMLInputElement;
const hasMinus = input.value.includes('-');
const cursorAtStart = (input.selectionStart ?? 0) === 0;
if (!hasMinus && cursorAtStart) {
return;
}
event.preventDefault();
return;
}
// Allow digits (0-9)
if (/^[0-9]$/.test(event.key)) {
return;
}
// Block all other keys
event.preventDefault();
}
@HostListener('paste', ['$event'])
onPaste(event: ClipboardEvent): void {
const paste = event.clipboardData?.getData('text') || '';
const sanitized = paste.replace(/[^\d]/g, '');
// If the paste content has non-numeric characters, prevent default and handle manually
const sanitized = this.sanitize(paste);
// If the paste content has invalid characters, prevent default and handle manually
if (sanitized !== paste) {
event.preventDefault();
const input = event.target as HTMLInputElement;
const currentValue = input.value;
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const newValue = currentValue.substring(0, start) + sanitized + currentValue.substring(end);
// Update both the input value and form control
input.value = newValue;
this.ngControl.control?.setValue(newValue);
// Set cursor position after pasted content
setTimeout(() => {
input.setSelectionRange(start + sanitized.length, start + sanitized.length);
});
const cursor = start + sanitized.length;
setTimeout(() => input.setSelectionRange(cursor, cursor));
}
// If paste content is all numeric, allow normal paste behavior
// The input event will handle form control synchronization
}
private sanitize(value: string): string {
if (!value) return '';
if (this.signed) {
const hasMinus = value.startsWith('-');
const digits = value.replace(/\D/g, '');
return hasMinus ? `-${digits}` : digits;
}
return value.replace(/\D/g, '');
}
}

View File

@@ -15,6 +15,7 @@ export enum NotificationProviderType {
Apprise = "Apprise",
Ntfy = "Ntfy",
Pushover = "Pushover",
Telegram = "Telegram",
}
export enum AppriseMode {

View File

@@ -63,3 +63,10 @@ export interface AppriseConfiguration {
export interface TestNotificationResult {
message: string;
}
export interface TelegramConfiguration {
botToken: string;
chatId: string;
topicId?: string;
sendSilently: boolean;
}

View File

@@ -0,0 +1,78 @@
---
sidebar_position: 5
---
import {
ConfigSection,
ElementNavigator,
SectionTitle,
styles
} from '@site/src/components/documentation';
# Telegram
Telegram can deliver notifications to users, groups, or supergroups via bots.
<ElementNavigator />
<div className={styles.documentationPage}>
<div className={styles.section}>
<SectionTitle icon="🤖">Configuration</SectionTitle>
<p className={styles.sectionDescription}>
Configure a Telegram bot to send notifications to a chat (user, group, or supergroup). Chat IDs can be negative for groups/supergroups; topic IDs are for threads in supergroups.
</p>
<ConfigSection
title="Bot Token"
icon="🔑"
id="bot-token"
>
Create a bot with [@BotFather](https://t.me/BotFather) and paste the generated token. Tokens look like `123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`.
<br/>
Reference: https://core.telegram.org/bots#how-do-i-create-a-bot
</ConfigSection>
<ConfigSection
title="Chat ID"
icon="💬"
id="chat-id"
>
The destination chat. Examples:
- Direct chat with your bot: positive integer (e.g., `123456789`)
- Group/supergroup: negative integer starting with `-100` (e.g., `-100123456789`)
One way to find the chat id: https://stackoverflow.com/a/75954034
</ConfigSection>
<ConfigSection
title="Topic ID (optional)"
icon="🧵"
id="topic-id"
>
For supergroups with topics enabled, specify the thread/topic ID to target a specific thread. Leave empty to post to the main chat.
<br/>
One way to get the topic id: https://stackoverflow.com/a/75178418
</ConfigSection>
<ConfigSection
title="Send Silently"
icon="🔕"
id="send-silently"
>
When enabled, Telegram delivers the message without sound.
</ConfigSection>
</div>
</div>