diff --git a/backend/src/Notifo.Domain.Integrations.Abstractions/MessagingMessage.cs b/backend/src/Notifo.Domain.Integrations.Abstractions/MessagingMessage.cs index 56868784..7e4b3421 100644 --- a/backend/src/Notifo.Domain.Integrations.Abstractions/MessagingMessage.cs +++ b/backend/src/Notifo.Domain.Integrations.Abstractions/MessagingMessage.cs @@ -9,7 +9,17 @@ namespace Notifo.Domain.Integrations; public sealed class MessagingMessage : BaseMessage { + public IReadOnlyDictionary Targets { get; set; } + public string Text { get; set; } - public IReadOnlyDictionary Targets { get; set; } + public string? Body { get; init; } + + public string? ImageLarge { get; init; } + + public string? ImageSmall { get; init; } + + public string? LinkText { get; init; } + + public string? LinkUrl { get; init; } } diff --git a/backend/src/Notifo.Domain.Integrations/CachePool.cs b/backend/src/Notifo.Domain.Integrations/CachePool.cs index 9116bfa1..3fb33200 100644 --- a/backend/src/Notifo.Domain.Integrations/CachePool.cs +++ b/backend/src/Notifo.Domain.Integrations/CachePool.cs @@ -33,39 +33,61 @@ protected TItem GetOrCreate(object key, TimeSpan expiration, Func factory entry.AbsoluteExpirationRelativeToNow = expiration; var item = factory(); + HandleDispose(item, entry); - switch (item) - { - case IDisposable disposable: + return item; + })!; + } + + protected Task GetOrCreateAsync(object key, Func> factory) + { + return GetOrCreateAsync(key, DefaultExpiration, factory); + } + + protected Task GetOrCreateAsync(object key, TimeSpan expiration, Func> factory) + { + return memoryCache.GetOrCreateAsync(key, async entry => + { + entry.AbsoluteExpirationRelativeToNow = expiration; + + var item = await factory(); + HandleDispose(item, entry); + + return item; + })!; + } + + private void HandleDispose(TItem item, ICacheEntry entry) + { + switch (item) + { + case IDisposable disposable: + { + entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration { - entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration + EvictionCallback = (key, value, reason, state) => { - EvictionCallback = (key, value, reason, state) => - { - disposable.Dispose(); - } - }); - break; - } + disposable.Dispose(); + } + }); + break; + } - case IAsyncDisposable asyncDisposable: + case IAsyncDisposable asyncDisposable: + { + entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration { - entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration + EvictionCallback = (key, value, reason, state) => { - EvictionCallback = (key, value, reason, state) => - { #pragma warning disable CA2012 // Use ValueTasks correctly #pragma warning disable MA0134 // Observe result of async calls - asyncDisposable.DisposeAsync(); + asyncDisposable.DisposeAsync(); #pragma warning restore MA0134 // Observe result of async calls #pragma warning restore CA2012 // Use ValueTasks correctly - } - }); - break; - } - } - - return item; - })!; + } + }); + break; + } + } } } diff --git a/backend/src/Notifo.Domain.Integrations/Discord/DiscordBotClientPool.cs b/backend/src/Notifo.Domain.Integrations/Discord/DiscordBotClientPool.cs new file mode 100644 index 00000000..b78d59ab --- /dev/null +++ b/backend/src/Notifo.Domain.Integrations/Discord/DiscordBotClientPool.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Notifo.io +// ========================================================================== +// Copyright (c) Sebastian Stehle +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Discord; +using Microsoft.Extensions.Caching.Memory; + +namespace Notifo.Domain.Integrations.Discord; + +public class DiscordBotClientPool : CachePool +{ + public DiscordBotClientPool(IMemoryCache memoryCache) + : base(memoryCache) + { + } + + public async Task GetDiscordClient(string botToken, CancellationToken ct) + { + var cacheKey = $"{nameof(IDiscordClient)}_{botToken}"; + + var found = await GetOrCreateAsync(cacheKey, TimeSpan.FromMinutes(5), async () => + { + var client = new DiscordClient(); + + // Method provides no option to pass CancellationToken + await client.LoginAsync(TokenType.Bot, botToken); + + return client; + }); + + return found; + } +} diff --git a/backend/src/Notifo.Domain.Integrations/Discord/DiscordClient.cs b/backend/src/Notifo.Domain.Integrations/Discord/DiscordClient.cs new file mode 100644 index 00000000..67456f67 --- /dev/null +++ b/backend/src/Notifo.Domain.Integrations/Discord/DiscordClient.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Notifo.io +// ========================================================================== +// Copyright (c) Sebastian Stehle +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Discord.Rest; + +namespace Notifo.Domain.Integrations.Discord; + +public class DiscordClient : DiscordRestClient, IAsyncDisposable +{ + public async new ValueTask DisposeAsync() + { + await LogoutAsync(); + await base.DisposeAsync(); + } +} diff --git a/backend/src/Notifo.Domain.Integrations/Discord/DiscordIntegration.Messaging.cs b/backend/src/Notifo.Domain.Integrations/Discord/DiscordIntegration.Messaging.cs new file mode 100644 index 00000000..9203e9bd --- /dev/null +++ b/backend/src/Notifo.Domain.Integrations/Discord/DiscordIntegration.Messaging.cs @@ -0,0 +1,108 @@ +// ========================================================================== +// Notifo.io +// ========================================================================== +// Copyright (c) Sebastian Stehle +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Discord; +using Discord.Net; + +namespace Notifo.Domain.Integrations.Discord; + +public sealed partial class DiscordIntegration : IMessagingSender +{ + private const int Attempts = 5; + public const string DiscordChatId = nameof(DiscordChatId); + + public void AddTargets(IDictionary targets, UserInfo user) + { + var userId = GetUserId(user); + + if (!string.IsNullOrWhiteSpace(userId)) + { + targets[DiscordChatId] = userId; + } + } + + public async Task SendAsync(IntegrationContext context, MessagingMessage message, + CancellationToken ct) + { + if (!message.Targets.TryGetValue(DiscordChatId, out var chatId)) + { + return DeliveryResult.Skipped(); + } + + return await SendMessageAsync(context, message, chatId, ct); + } + + private async Task SendMessageAsync(IntegrationContext context, MessagingMessage message, string chatId, + CancellationToken ct) + { + var botToken = BotToken.GetString(context.Properties); + + for (var i = 1; i <= Attempts; i++) + { + try + { + var client = await discordBotClientPool.GetDiscordClient(botToken, ct); + var requestOptions = new RequestOptions { CancelToken = ct }; + + if (!ulong.TryParse(chatId, out var chatIdParsed)) + { + throw new InvalidOperationException("Invalid Discord DM chat ID."); + } + + var user = await client.GetUserAsync(chatIdParsed, CacheMode.AllowDownload, requestOptions); + if (user is null) + { + throw new InvalidOperationException("User not found."); + } + + EmbedBuilder builder = new EmbedBuilder(); + + builder.WithTitle(message.Text); + builder.WithDescription(message.Body); + + if (!string.IsNullOrWhiteSpace(message.ImageSmall)) + { + builder.WithThumbnailUrl(message.ImageSmall); + } + + if (!string.IsNullOrWhiteSpace(message.ImageLarge)) + { + builder.WithImageUrl(message.ImageLarge); + } + + if (!string.IsNullOrWhiteSpace(message.LinkUrl)) + { + builder.WithFields(new EmbedFieldBuilder().WithName(message.LinkText ?? message.LinkUrl).WithValue(message.LinkUrl)); + } + + builder.WithFooter("Sent with Notifo"); + + // Throws HttpException if the user has some privacy settings that make it impossible to text them. + await user.SendMessageAsync(string.Empty, false, builder.Build(), requestOptions); + break; + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.CannotSendMessageToUser) + { + return DeliveryResult.Failed("User has privacy settings that prevent sending them DMs on Discord."); + } + catch + { + if (i == Attempts) + { + return DeliveryResult.Failed("Unknown error when sending Discord DM to user."); + } + } + } + + return DeliveryResult.Handled; + } + + private static string? GetUserId(UserInfo user) + { + return UserId.GetString(user.Properties); + } +} diff --git a/backend/src/Notifo.Domain.Integrations/Discord/DiscordIntegration.cs b/backend/src/Notifo.Domain.Integrations/Discord/DiscordIntegration.cs new file mode 100644 index 00000000..09736b4d --- /dev/null +++ b/backend/src/Notifo.Domain.Integrations/Discord/DiscordIntegration.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// Notifo.io +// ========================================================================== +// Copyright (c) Sebastian Stehle +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Discord; +using Notifo.Domain.Integrations.Resources; +using Notifo.Infrastructure.Validation; + +namespace Notifo.Domain.Integrations.Discord; + +public sealed partial class DiscordIntegration : IIntegration +{ + private readonly DiscordBotClientPool discordBotClientPool; + + public static readonly IntegrationProperty UserId = new IntegrationProperty("discordUserId", PropertyType.Text) + { + EditorLabel = Texts.Discord_UserIdLabel, + EditorDescription = Texts.Discord_UserIdDescription + }; + + public static readonly IntegrationProperty BotToken = new IntegrationProperty("discordBotToken", PropertyType.Text) + { + EditorLabel = Texts.Discord_BotTokenLabel, + EditorDescription = Texts.Discord_BotTokenDescription, + IsRequired = true + }; + + public IntegrationDefinition Definition { get; } = + new IntegrationDefinition( + "Discord", + Texts.Discord_Name, + "", + new List + { + BotToken + }, + new List + { + UserId + }, + new HashSet + { + Providers.Messaging, + }) + { + Description = Texts.Discord_Description + }; + + public DiscordIntegration(DiscordBotClientPool discordBotClientPool) + { + this.discordBotClientPool = discordBotClientPool; + } + + public Task OnConfiguredAsync(IntegrationContext context, IntegrationConfiguration? previous, + CancellationToken ct) + { + var botToken = BotToken.GetString(context.Properties); + + try + { + TokenUtils.ValidateToken(TokenType.Bot, botToken); + } + catch + { + throw new ValidationException("The Discord bot token is invalid."); + } + + return Task.FromResult(IntegrationStatus.Verified); + } +} diff --git a/backend/src/Notifo.Domain.Integrations/Discord/DiscordServiceExtensions.cs b/backend/src/Notifo.Domain.Integrations/Discord/DiscordServiceExtensions.cs new file mode 100644 index 00000000..f9ea72fc --- /dev/null +++ b/backend/src/Notifo.Domain.Integrations/Discord/DiscordServiceExtensions.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Notifo.io +// ========================================================================== +// Copyright (c) Sebastian Stehle +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Notifo.Domain.Integrations; +using Notifo.Domain.Integrations.Discord; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DiscordServiceExtensions +{ + public static IServiceCollection AddIntegrationDiscord(this IServiceCollection services) + { + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .AsSelf(); + + return services; + } +} diff --git a/backend/src/Notifo.Domain.Integrations/Notifo.Domain.Integrations.csproj b/backend/src/Notifo.Domain.Integrations/Notifo.Domain.Integrations.csproj index f9c03620..935684a3 100644 --- a/backend/src/Notifo.Domain.Integrations/Notifo.Domain.Integrations.csproj +++ b/backend/src/Notifo.Domain.Integrations/Notifo.Domain.Integrations.csproj @@ -11,6 +11,7 @@ + diff --git a/backend/src/Notifo.Domain.Integrations/Resources/Texts.Designer.cs b/backend/src/Notifo.Domain.Integrations/Resources/Texts.Designer.cs index 70d845dc..e18d0bf9 100644 --- a/backend/src/Notifo.Domain.Integrations/Resources/Texts.Designer.cs +++ b/backend/src/Notifo.Domain.Integrations/Resources/Texts.Designer.cs @@ -87,6 +87,60 @@ internal static string AmazonSES_ReservedEmailAddress { } } + /// + /// Looks up a localized string similar to You will find it on your Discord Developer Platform. Go to your app and then to the "Bot" tab.. + /// + internal static string Discord_BotTokenDescription { + get { + return ResourceManager.GetString("Discord_BotTokenDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discord Bot token. + /// + internal static string Discord_BotTokenLabel { + get { + return ResourceManager.GetString("Discord_BotTokenLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Send Notifo's notifications via direct messages to your Discord account.. + /// + internal static string Discord_Description { + get { + return ResourceManager.GetString("Discord_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discord. + /// + internal static string Discord_Name { + get { + return ResourceManager.GetString("Discord_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The user_id of the user the bot will send the DMs to. User is required to install the Discord bot on their account.. + /// + internal static string Discord_UserIdDescription { + get { + return ResourceManager.GetString("Discord_UserIdDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discord user id. + /// + internal static string Discord_UserIdLabel { + get { + return ResourceManager.GetString("Discord_UserIdLabel", resourceCulture); + } + } + /// /// Looks up a localized string similar to Comma or line-separated list of additional email-addresses.. /// diff --git a/backend/src/Notifo.Domain.Integrations/Resources/Texts.resx b/backend/src/Notifo.Domain.Integrations/Resources/Texts.resx index 3be2d294..8a738434 100644 --- a/backend/src/Notifo.Domain.Integrations/Resources/Texts.resx +++ b/backend/src/Notifo.Domain.Integrations/Resources/Texts.resx @@ -126,6 +126,24 @@ Email address {0} is already used by another app. + + You will find it on your Discord Developer Platform. Go to your app and then to the "Bot" tab. + + + Discord Bot token + + + Send Notifo's notifications via direct messages to your Discord account. + + + Discord + + + The user_id of the user the bot will send the DMs to. User is required to install the Discord bot on their account. + + + Discord user id + Comma or line-separated list of additional email-addresses. diff --git a/backend/src/Notifo.Domain/Channels/Messaging/MessagingChannel.cs b/backend/src/Notifo.Domain/Channels/Messaging/MessagingChannel.cs index e527e922..b00d0862 100644 --- a/backend/src/Notifo.Domain/Channels/Messaging/MessagingChannel.cs +++ b/backend/src/Notifo.Domain/Channels/Messaging/MessagingChannel.cs @@ -262,6 +262,11 @@ private async Task SendCoreAsync(string appId, MessagingMessage Targets = lastJob.Configuration, // We might also format the text without the template if no primary template is defined. Text = text, + Body = lastJob.Notification.Formatting.Body, + ImageLarge = lastJob.Notification.Formatting.ImageLarge, + ImageSmall = lastJob.Notification.Formatting.ImageSmall, + LinkText = lastJob.Notification.Formatting.LinkText, + LinkUrl = lastJob.Notification.Formatting.LinkUrl, }; return (default, message.Enrich(lastJob, Name)); diff --git a/backend/src/Notifo/Startup.cs b/backend/src/Notifo/Startup.cs index bc71307f..00019577 100644 --- a/backend/src/Notifo/Startup.cs +++ b/backend/src/Notifo/Startup.cs @@ -5,18 +5,19 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Globalization; -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Notifo.Areas.Api.Controllers.Notifications; using Notifo.Areas.Frontend; using Notifo.Domain; +using Notifo.Domain.Integrations.Discord; using Notifo.Domain.Utils; using Notifo.Pipeline; +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Notifo; @@ -146,6 +147,7 @@ public void ConfigureServices(IServiceCollection services) services.AddMyWebPushChannel(config); services.AddIntegrationAmazonSES(config); + services.AddIntegrationDiscord(); services.AddIntegrationFirebase(); services.AddIntegrationHttp(); services.AddIntegrationMailchimp(); diff --git a/backend/tests/Notifo.Domain.Tests/Integrations/Discord/DiscordTests.cs b/backend/tests/Notifo.Domain.Tests/Integrations/Discord/DiscordTests.cs new file mode 100644 index 00000000..cfb102fd --- /dev/null +++ b/backend/tests/Notifo.Domain.Tests/Integrations/Discord/DiscordTests.cs @@ -0,0 +1,153 @@ +// ========================================================================== +// Notifo.io +// ========================================================================== +// Copyright (c) Sebastian Stehle +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Notifo.Domain.Integrations.Discord; + +[Trait("Category", "Dependencies")] +public sealed class DiscordTests +{ + private readonly ResolvedIntegration sut; + + public DiscordTests() + { + sut = CreateClient(); + } + + [Fact] + public void Should_get_integration() + { + Assert.NotNull(sut.System); + } + + [Fact] + public async Task Should_send_simple_message() + { + var userId = GetUserId(); + var message = new MessagingMessage + { + Text = "Test message", + Targets = new Dictionary() { { DiscordIntegration.DiscordChatId, userId } } + }; + + var result = await sut.System.SendAsync(sut.Context, message, default); + + Assert.Equal(DeliveryResult.Handled, result); + } + + [Fact] + public async Task Should_send_full_message() + { + var userId = GetUserId(); + var message = new MessagingMessage + { + Text = "Test message", + Body = "Detailed body text", + Targets = new Dictionary() { { DiscordIntegration.DiscordChatId, userId } } + }; + + var result = await sut.System.SendAsync(sut.Context, message, default); + + Assert.Equal(DeliveryResult.Handled, result); + } + + [Fact] + public async Task Should_fail_on_user() + { + // Random 18-digit number + var random = new Random(); + string userId = string.Join(string.Empty, Enumerable.Range(0, 18).Select(number => random.Next(0, 9))); + + var message = new MessagingMessage + { + Text = "Test message", + Targets = new Dictionary() { { DiscordIntegration.DiscordChatId, userId } } + }; + + var result = await sut.System.SendAsync(sut.Context, message, default); + + Assert.Equal(DeliveryStatus.Failed, result.Status); + } + + [Fact] + public async Task Should_accept_images() + { + var userId = GetUserId(); + var message = new MessagingMessage + { + Text = "Test message", + ImageSmall = "https://picsum.photos/200/300", + ImageLarge = "https://picsum.photos/400/600", + Targets = new Dictionary() { { DiscordIntegration.DiscordChatId, userId } } + }; + + var result = await sut.System.SendAsync(sut.Context, message, default); + + Assert.Equal(DeliveryResult.Handled, result); + } + + [Fact] + public async Task Should_accept_urls() + { + var userId = GetUserId(); + var message = new MessagingMessage + { + Text = "Test message", + LinkUrl = "https://notifo.io", + LinkText = "Notifo", + Targets = new Dictionary() { { DiscordIntegration.DiscordChatId, userId } } + }; + + var result = await sut.System.SendAsync(sut.Context, message, default); + + Assert.Equal(DeliveryResult.Handled, result); + } + + private static ResolvedIntegration CreateClient() + { + var botToken = TestHelpers.Configuration.GetValue("discord:botToken")!; + var context = BuildContext(new Dictionary + { + [DiscordIntegration.BotToken.Name] = botToken, + }); + + var integration = + new ServiceCollection() + .AddIntegrationDiscord() + .AddMemoryCache() + .BuildServiceProvider() + .GetRequiredService(); + + return new ResolvedIntegration(Guid.NewGuid().ToString(), context, integration); + } + + private string GetUserId() + { + var id = TestHelpers.Configuration.GetValue("discord:userId"); + ArgumentException.ThrowIfNullOrEmpty(id, nameof(id)); + + return id; + } + + private static IntegrationContext BuildContext(Dictionary properties) + { + return new IntegrationContext + { + UpdateStatusAsync = null!, + AppId = string.Empty, + AppName = string.Empty, + CallbackToken = string.Empty, + CallbackUrl = string.Empty, + IntegrationAdapter = null!, + IntegrationId = string.Empty, + Properties = properties, + WebhookUrl = string.Empty, + }; + } +} diff --git a/backend/tests/Notifo.Domain.Tests/appsettings.json b/backend/tests/Notifo.Domain.Tests/appsettings.json index 447ebed9..5c70276b 100644 --- a/backend/tests/Notifo.Domain.Tests/appsettings.json +++ b/backend/tests/Notifo.Domain.Tests/appsettings.json @@ -1,47 +1,50 @@ { - - "sms": { - "to": "", + "sms": { + "to": "", - "messageBird": { - "accessKey": "", - "phoneNumber": "", - "phoneNumbers": "" - }, + "messageBird": { + "accessKey": "", + "phoneNumber": "", + "phoneNumbers": "" + }, + + "telekom": { + "apiKey": "", + "phoneNumber": "" + }, - "telekom": { - "apiKey": "", - "phoneNumber": "" + "twilio": { + "authToken": "", + "accountSid": "", + "phoneNumber": "" + } }, + "email": { + "address": "sebastian@squidex.io", - "twilio": { - "authToken": "", - "accountSid": "", - "phoneNumber": "" - } - }, - "email": { - "address": "sebastian@squidex.io", + "amazonSES": { + "host": "", + "username": "", + "password": "" + }, - "amazonSES": { - "host": "", - "username": "", - "password": "" - }, + "mailchimp": { + "apiKey": "" + }, - "mailchimp": { - "apiKey": "" - }, + "mailjet": { + "apiKey": "", + "apiSecret": "" + }, - "mailjet": { - "apiKey": "", - "apiSecret": "" + "smtp": { + "host": "", + "username": "", + "password": "" + } }, - - "smtp": { - "host": "", - "username": "", - "password": "" + "discord": { + "botToken": "", + "userId": "" } - } } diff --git a/media/Integrations.png b/media/Integrations.png index 34d356a6..c104ab52 100644 Binary files a/media/Integrations.png and b/media/Integrations.png differ