Compare commits

..

6 Commits

Author SHA1 Message Date
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
56 changed files with 2905 additions and 487 deletions

View File

@@ -76,7 +76,7 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: 9.0.x
- name: Cache NuGet packages
uses: actions/cache@v4

View File

@@ -86,7 +86,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: 9.0.x
- name: Restore .NET dependencies
run: |

View File

@@ -70,7 +70,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: 9.0.x
- name: Restore .NET dependencies
run: |

View File

@@ -31,7 +31,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: 9.0.x
- name: Cache NuGet packages
uses: actions/cache@v4

View File

@@ -19,7 +19,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
- [Node.js 18+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations

View File

@@ -15,7 +15,7 @@ COPY frontend/ .
RUN npm run build
# Build .NET backend
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
ARG TARGETARCH
ARG VERSION=0.0.1
ARG PACKAGES_USERNAME
@@ -42,7 +42,7 @@ RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
/p:DebugSymbols=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
# Install required packages for user management, timezone support, and Python for Apprise CLI
RUN apt-get update && apt-get install -y \

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<AssemblyName>Cleanuparr</AssemblyName>
<Version Condition="'$(Version)' == ''">0.0.1</Version>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishReadyToRun>true</PublishReadyToRun>
@@ -24,14 +24,14 @@
<ItemGroup>
<PackageReference Include="MassTransit" Version="8.5.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />

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

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

View File

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

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -19,9 +19,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
@@ -31,7 +31,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -13,15 +13,15 @@
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.1" />
<PackageReference Include="System.Threading.RateLimiting" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

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 ? true : false
};
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

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -22,7 +22,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -11,9 +11,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.0-rc.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />

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,82 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
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]
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

@@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -112,7 +112,6 @@
"cli": {
"schematicCollections": [
"angular-eslint"
],
"analytics": false
]
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,14 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.17",
"@angular/cdk": "^19.2.17",
"@angular/common": "^19.2.17",
"@angular/compiler": "^19.2.17",
"@angular/core": "^19.2.17",
"@angular/forms": "^19.2.17",
"@angular/platform-browser": "^19.2.17",
"@angular/platform-browser-dynamic": "^19.2.17",
"@angular/router": "^19.2.17",
"@angular/service-worker": "^19.2.17",
"@angular/common": "^19.2.16",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.16",
"@angular/forms": "^19.2.16",
"@angular/platform-browser": "^19.2.16",
"@angular/platform-browser-dynamic": "^19.2.16",
"@angular/router": "^19.2.16",
"@angular/service-worker": "^19.2.16",
"@microsoft/signalr": "^8.0.7",
"@ngrx/signals": "^19.2.0",
"@primeng/themes": "^19.1.3",
@@ -32,9 +30,9 @@
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.17",
"@angular/cli": "^19.2.17",
"@angular/compiler-cli": "^19.2.17",
"@angular-devkit/build-angular": "^19.2.12",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "^19.2.16",
"@types/jasmine": "~5.1.0",
"angular-eslint": "19.6.0",
"eslint": "^9.27.0",

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
signedNumericInput
[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,122 @@
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, SignedNumericInputDirective } 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,
SignedNumericInputDirective,
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 +1,2 @@
export * from './numeric-input.directive';
export * from './signed-numeric-input.directive';

View File

@@ -38,11 +38,11 @@ export class NumericInputDirective {
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: Ctrl/Cmd+A,C,V,X
(event.keyCode === 65 && (event.ctrlKey === true || event.metaKey === true)) ||
(event.keyCode === 67 && (event.ctrlKey === true || event.metaKey === true)) ||
(event.keyCode === 86 && (event.ctrlKey === true || event.metaKey === true)) ||
(event.keyCode === 88 && (event.ctrlKey === true || event.metaKey === true)) ||
// Allow: home, end, left, right
(event.keyCode >= 35 && event.keyCode <= 39)) {
return;

View File

@@ -0,0 +1,87 @@
import { Directive, HostListener } from '@angular/core';
import { NgControl } from '@angular/forms';
/**
* Directive that restricts input to numeric characters with an optional leading minus sign.
* Useful for Telegram chat IDs which can be negative for groups/supergroups.
*/
@Directive({
selector: '[signedNumericInput]',
standalone: true
})
export class SignedNumericInputDirective {
constructor(private ngControl: NgControl) {}
@HostListener('input', ['$event'])
onInput(event: Event): void {
const input = event.target as HTMLInputElement;
const originalValue = input.value;
const sanitized = this.sanitize(originalValue);
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].includes(event.keyCode) ||
// Allow: Ctrl/Cmd+A,C,V,X
(event.keyCode === 65 && (event.ctrlKey || event.metaKey)) ||
(event.keyCode === 67 && (event.ctrlKey || event.metaKey)) ||
(event.keyCode === 86 && (event.ctrlKey || event.metaKey)) ||
(event.keyCode === 88 && (event.ctrlKey || event.metaKey)) ||
// Allow: home, end, left, right
(event.keyCode >= 35 && event.keyCode <= 39)) {
return;
}
// Allow minus only at the start and only if not already present
if ((event.key === '-' || event.keyCode === 189 || event.keyCode === 109)) {
const input = event.target as HTMLInputElement;
const hasMinus = input.value.includes('-');
const cursorAtStart = (input.selectionStart ?? 0) === 0;
if (!hasMinus && cursorAtStart) {
return;
}
event.preventDefault();
return;
}
// Block non-numeric keys
if ((event.shiftKey || (event.keyCode < 48 || event.keyCode > 57)) && (event.keyCode < 96 || event.keyCode > 105)) {
event.preventDefault();
}
}
@HostListener('paste', ['$event'])
onPaste(event: ClipboardEvent): void {
const pasted = event.clipboardData?.getData('text') || '';
const sanitized = this.sanitize(pasted);
if (sanitized !== pasted) {
event.preventDefault();
const input = event.target as HTMLInputElement;
const currentValue = input.value;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const newValue = currentValue.substring(0, start) + sanitized + currentValue.substring(end);
input.value = newValue;
this.ngControl.control?.setValue(newValue);
const cursor = start + sanitized.length;
setTimeout(() => input.setSelectionRange(cursor, cursor));
}
}
private sanitize(value: string): string {
if (!value) return '';
const hasMinus = value.startsWith('-');
const digits = value.replace(/\D/g, '');
return hasMinus ? `-${digits}` : digits;
}
}

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>

View File

@@ -39,16 +39,16 @@ Download and configure the .NET SDK for FreeBSD:
cd ~
# Set up variables for cleaner commands
DOTNET_VERSION="v10.0.101-amd64-freebsd-14"
DOTNET_VERSION="v9.0.104-amd64-freebsd-14"
DOTNET_BASE_URL="https://github.com/Thefrank/dotnet-freebsd-crossbuild/releases/download"
# Download .NET SDK
wget -q "${DOTNET_BASE_URL}/${DOTNET_VERSION}/dotnet-sdk-10.0.101-freebsd-x64.tar.gz"
wget -q "${DOTNET_BASE_URL}/${DOTNET_VERSION}/dotnet-sdk-9.0.104-freebsd-x64.tar.gz"
# Set up .NET environment
export DOTNET_ROOT=$(pwd)/.dotnet
mkdir -p "$DOTNET_ROOT"
tar zxf dotnet-sdk-10.0.101-freebsd-x64.tar.gz -C "$DOTNET_ROOT"
tar zxf dotnet-sdk-9.0.104-freebsd-x64.tar.gz -C "$DOTNET_ROOT"
export PATH=$PATH:$DOTNET_ROOT:$DOTNET_ROOT/tools
```
</Step>
@@ -70,7 +70,7 @@ mkdir -p /tmp/nuget
# Set up variables for package URLs
NUGET_BASE_URL="${DOTNET_BASE_URL}/${DOTNET_VERSION}"
RUNTIME_VERSION="10.0.1"
RUNTIME_VERSION="9.0.3"
# Download required packages
wget -q -P /tmp/nuget/ \

37
docs/package-lock.json generated
View File

@@ -220,7 +220,6 @@
"version": "5.41.0",
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.41.0.tgz",
"integrity": "sha512-G9I2atg1ShtFp0t7zwleP6aPS4DcZvsV4uoQOripp16aR6VJzbEnKFPLW4OFXzX7avgZSpYeBAS+Zx4FOgmpPw==",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.41.0",
"@algolia/requester-browser-xhr": "5.41.0",
@@ -336,7 +335,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2017,7 +2015,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2039,7 +2036,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2144,7 +2140,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2550,7 +2545,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -3395,7 +3389,6 @@
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz",
"integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==",
"peer": true,
"dependencies": {
"@docusaurus/core": "3.9.2",
"@docusaurus/logger": "3.9.2",
@@ -4112,7 +4105,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz",
"integrity": "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==",
"peer": true,
"dependencies": {
"@types/mdx": "^2.0.0"
},
@@ -4405,7 +4397,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -4739,7 +4730,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -5052,7 +5042,6 @@
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5120,7 +5109,6 @@
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -5163,7 +5151,6 @@
"version": "5.41.0",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.41.0.tgz",
"integrity": "sha512-9E4b3rJmYbBkn7e3aAPt1as+VVnRhsR4qwRRgOzpeyz4PAOuwKh0HI4AN6mTrqK0S0M9fCCSTOUnuJ8gPY/tvA==",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.7.0",
"@algolia/client-abtesting": "5.41.0",
@@ -5704,7 +5691,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6627,7 +6613,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -7930,7 +7915,6 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -10047,10 +10031,9 @@
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
@@ -12147,7 +12130,6 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -12628,7 +12610,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13484,7 +13465,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -14281,7 +14261,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14290,7 +14269,6 @@
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -14341,7 +14319,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
"peer": true,
"dependencies": {
"@types/react": "*"
},
@@ -14394,7 +14371,6 @@
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@@ -16165,8 +16141,7 @@
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"peer": true
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
@@ -16234,7 +16209,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"devOptional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16552,7 +16526,6 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -16740,7 +16713,6 @@
"version": "5.99.5",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.5.tgz",
"integrity": "sha512-q+vHBa6H9qwBLUlHL4Y7L0L1/LlyBKZtS9FHNCQmtayxjI5RKC9yD8gpvLeqGv5lCQp1Re04yi0MF40pf30Pvg==",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@@ -17315,7 +17287,6 @@
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -4668,11 +4668,6 @@ fs-extra@^11.1.1, fs-extra@^11.2.0:
jsonfile "^6.0.1"
universalify "^2.0.0"
fsevents@~2.3.2:
version "2.3.3"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -5901,9 +5896,9 @@ mdast-util-phrasing@^4.0.0:
unist-util-is "^6.0.0"
mdast-util-to-hast@^13.0.0:
version "13.2.1"
resolved "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz"
integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==
version "13.2.0"
resolved "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz"
integrity sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==
dependencies:
"@types/hast" "^3.0.0"
"@types/mdast" "^4.0.0"