Skip to content

Commit d0db2df

Browse files
authored
[Integration] Discord integration (#244)
* Fix updating the user preferences * Fix adding user properties to UserInfo context * Fix adding user properties to UserInfo context * Make the dictionaries merge code more imperative * Create Discord integration * Fix invalid status on Discord integration * Add tests for Discord integration * Change appSettings indentation to match the previous one * Update appSettings for tests * Add empty line at the end of the file to match the convention * Apply code review advice * Add integration image * Apply code refactoring * Add a new line at the end of file
1 parent ffb376c commit d0db2df

File tree

15 files changed

+594
-65
lines changed

15 files changed

+594
-65
lines changed

backend/src/Notifo.Domain.Integrations.Abstractions/MessagingMessage.cs

+11-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@ namespace Notifo.Domain.Integrations;
99

1010
public sealed class MessagingMessage : BaseMessage
1111
{
12+
public IReadOnlyDictionary<string, string> Targets { get; set; }
13+
1214
public string Text { get; set; }
1315

14-
public IReadOnlyDictionary<string, string> Targets { get; set; }
16+
public string? Body { get; init; }
17+
18+
public string? ImageLarge { get; init; }
19+
20+
public string? ImageSmall { get; init; }
21+
22+
public string? LinkText { get; init; }
23+
24+
public string? LinkUrl { get; init; }
1525
}

backend/src/Notifo.Domain.Integrations/CachePool.cs

+46-24
Original file line numberDiff line numberDiff line change
@@ -33,39 +33,61 @@ protected TItem GetOrCreate(object key, TimeSpan expiration, Func<TItem> factory
3333
entry.AbsoluteExpirationRelativeToNow = expiration;
3434

3535
var item = factory();
36+
HandleDispose(item, entry);
3637

37-
switch (item)
38-
{
39-
case IDisposable disposable:
38+
return item;
39+
})!;
40+
}
41+
42+
protected Task<TItem> GetOrCreateAsync(object key, Func<Task<TItem>> factory)
43+
{
44+
return GetOrCreateAsync(key, DefaultExpiration, factory);
45+
}
46+
47+
protected Task<TItem> GetOrCreateAsync(object key, TimeSpan expiration, Func<Task<TItem>> factory)
48+
{
49+
return memoryCache.GetOrCreateAsync(key, async entry =>
50+
{
51+
entry.AbsoluteExpirationRelativeToNow = expiration;
52+
53+
var item = await factory();
54+
HandleDispose(item, entry);
55+
56+
return item;
57+
})!;
58+
}
59+
60+
private void HandleDispose(TItem item, ICacheEntry entry)
61+
{
62+
switch (item)
63+
{
64+
case IDisposable disposable:
65+
{
66+
entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
4067
{
41-
entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
68+
EvictionCallback = (key, value, reason, state) =>
4269
{
43-
EvictionCallback = (key, value, reason, state) =>
44-
{
45-
disposable.Dispose();
46-
}
47-
});
48-
break;
49-
}
70+
disposable.Dispose();
71+
}
72+
});
73+
break;
74+
}
5075

51-
case IAsyncDisposable asyncDisposable:
76+
case IAsyncDisposable asyncDisposable:
77+
{
78+
entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
5279
{
53-
entry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
80+
EvictionCallback = (key, value, reason, state) =>
5481
{
55-
EvictionCallback = (key, value, reason, state) =>
56-
{
5782
#pragma warning disable CA2012 // Use ValueTasks correctly
5883
#pragma warning disable MA0134 // Observe result of async calls
59-
asyncDisposable.DisposeAsync();
84+
asyncDisposable.DisposeAsync();
6085
#pragma warning restore MA0134 // Observe result of async calls
6186
#pragma warning restore CA2012 // Use ValueTasks correctly
62-
}
63-
});
64-
break;
65-
}
66-
}
67-
68-
return item;
69-
})!;
87+
}
88+
});
89+
break;
90+
}
91+
}
7092
}
7193
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// ==========================================================================
2+
// Notifo.io
3+
// ==========================================================================
4+
// Copyright (c) Sebastian Stehle
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using Discord;
9+
using Microsoft.Extensions.Caching.Memory;
10+
11+
namespace Notifo.Domain.Integrations.Discord;
12+
13+
public class DiscordBotClientPool : CachePool<IDiscordClient>
14+
{
15+
public DiscordBotClientPool(IMemoryCache memoryCache)
16+
: base(memoryCache)
17+
{
18+
}
19+
20+
public async Task<IDiscordClient> GetDiscordClient(string botToken, CancellationToken ct)
21+
{
22+
var cacheKey = $"{nameof(IDiscordClient)}_{botToken}";
23+
24+
var found = await GetOrCreateAsync(cacheKey, TimeSpan.FromMinutes(5), async () =>
25+
{
26+
var client = new DiscordClient();
27+
28+
// Method provides no option to pass CancellationToken
29+
await client.LoginAsync(TokenType.Bot, botToken);
30+
31+
return client;
32+
});
33+
34+
return found;
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// ==========================================================================
2+
// Notifo.io
3+
// ==========================================================================
4+
// Copyright (c) Sebastian Stehle
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using Discord.Rest;
9+
10+
namespace Notifo.Domain.Integrations.Discord;
11+
12+
public class DiscordClient : DiscordRestClient, IAsyncDisposable
13+
{
14+
public async new ValueTask DisposeAsync()
15+
{
16+
await LogoutAsync();
17+
await base.DisposeAsync();
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// ==========================================================================
2+
// Notifo.io
3+
// ==========================================================================
4+
// Copyright (c) Sebastian Stehle
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using Discord;
9+
using Discord.Net;
10+
11+
namespace Notifo.Domain.Integrations.Discord;
12+
13+
public sealed partial class DiscordIntegration : IMessagingSender
14+
{
15+
private const int Attempts = 5;
16+
public const string DiscordChatId = nameof(DiscordChatId);
17+
18+
public void AddTargets(IDictionary<string, string> targets, UserInfo user)
19+
{
20+
var userId = GetUserId(user);
21+
22+
if (!string.IsNullOrWhiteSpace(userId))
23+
{
24+
targets[DiscordChatId] = userId;
25+
}
26+
}
27+
28+
public async Task<DeliveryResult> SendAsync(IntegrationContext context, MessagingMessage message,
29+
CancellationToken ct)
30+
{
31+
if (!message.Targets.TryGetValue(DiscordChatId, out var chatId))
32+
{
33+
return DeliveryResult.Skipped();
34+
}
35+
36+
return await SendMessageAsync(context, message, chatId, ct);
37+
}
38+
39+
private async Task<DeliveryResult> SendMessageAsync(IntegrationContext context, MessagingMessage message, string chatId,
40+
CancellationToken ct)
41+
{
42+
var botToken = BotToken.GetString(context.Properties);
43+
44+
for (var i = 1; i <= Attempts; i++)
45+
{
46+
try
47+
{
48+
var client = await discordBotClientPool.GetDiscordClient(botToken, ct);
49+
var requestOptions = new RequestOptions { CancelToken = ct };
50+
51+
if (!ulong.TryParse(chatId, out var chatIdParsed))
52+
{
53+
throw new InvalidOperationException("Invalid Discord DM chat ID.");
54+
}
55+
56+
var user = await client.GetUserAsync(chatIdParsed, CacheMode.AllowDownload, requestOptions);
57+
if (user is null)
58+
{
59+
throw new InvalidOperationException("User not found.");
60+
}
61+
62+
EmbedBuilder builder = new EmbedBuilder();
63+
64+
builder.WithTitle(message.Text);
65+
builder.WithDescription(message.Body);
66+
67+
if (!string.IsNullOrWhiteSpace(message.ImageSmall))
68+
{
69+
builder.WithThumbnailUrl(message.ImageSmall);
70+
}
71+
72+
if (!string.IsNullOrWhiteSpace(message.ImageLarge))
73+
{
74+
builder.WithImageUrl(message.ImageLarge);
75+
}
76+
77+
if (!string.IsNullOrWhiteSpace(message.LinkUrl))
78+
{
79+
builder.WithFields(new EmbedFieldBuilder().WithName(message.LinkText ?? message.LinkUrl).WithValue(message.LinkUrl));
80+
}
81+
82+
builder.WithFooter("Sent with Notifo");
83+
84+
// Throws HttpException if the user has some privacy settings that make it impossible to text them.
85+
await user.SendMessageAsync(string.Empty, false, builder.Build(), requestOptions);
86+
break;
87+
}
88+
catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.CannotSendMessageToUser)
89+
{
90+
return DeliveryResult.Failed("User has privacy settings that prevent sending them DMs on Discord.");
91+
}
92+
catch
93+
{
94+
if (i == Attempts)
95+
{
96+
return DeliveryResult.Failed("Unknown error when sending Discord DM to user.");
97+
}
98+
}
99+
}
100+
101+
return DeliveryResult.Handled;
102+
}
103+
104+
private static string? GetUserId(UserInfo user)
105+
{
106+
return UserId.GetString(user.Properties);
107+
}
108+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// ==========================================================================
2+
// Notifo.io
3+
// ==========================================================================
4+
// Copyright (c) Sebastian Stehle
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using Discord;
9+
using Notifo.Domain.Integrations.Resources;
10+
using Notifo.Infrastructure.Validation;
11+
12+
namespace Notifo.Domain.Integrations.Discord;
13+
14+
public sealed partial class DiscordIntegration : IIntegration
15+
{
16+
private readonly DiscordBotClientPool discordBotClientPool;
17+
18+
public static readonly IntegrationProperty UserId = new IntegrationProperty("discordUserId", PropertyType.Text)
19+
{
20+
EditorLabel = Texts.Discord_UserIdLabel,
21+
EditorDescription = Texts.Discord_UserIdDescription
22+
};
23+
24+
public static readonly IntegrationProperty BotToken = new IntegrationProperty("discordBotToken", PropertyType.Text)
25+
{
26+
EditorLabel = Texts.Discord_BotTokenLabel,
27+
EditorDescription = Texts.Discord_BotTokenDescription,
28+
IsRequired = true
29+
};
30+
31+
public IntegrationDefinition Definition { get; } =
32+
new IntegrationDefinition(
33+
"Discord",
34+
Texts.Discord_Name,
35+
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 127.14 96.36\"><path fill=\"#5865f2\" d=\"M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z\"/></svg>",
36+
new List<IntegrationProperty>
37+
{
38+
BotToken
39+
},
40+
new List<IntegrationProperty>
41+
{
42+
UserId
43+
},
44+
new HashSet<string>
45+
{
46+
Providers.Messaging,
47+
})
48+
{
49+
Description = Texts.Discord_Description
50+
};
51+
52+
public DiscordIntegration(DiscordBotClientPool discordBotClientPool)
53+
{
54+
this.discordBotClientPool = discordBotClientPool;
55+
}
56+
57+
public Task<IntegrationStatus> OnConfiguredAsync(IntegrationContext context, IntegrationConfiguration? previous,
58+
CancellationToken ct)
59+
{
60+
var botToken = BotToken.GetString(context.Properties);
61+
62+
try
63+
{
64+
TokenUtils.ValidateToken(TokenType.Bot, botToken);
65+
}
66+
catch
67+
{
68+
throw new ValidationException("The Discord bot token is invalid.");
69+
}
70+
71+
return Task.FromResult(IntegrationStatus.Verified);
72+
}
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// ==========================================================================
2+
// Notifo.io
3+
// ==========================================================================
4+
// Copyright (c) Sebastian Stehle
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using Notifo.Domain.Integrations;
9+
using Notifo.Domain.Integrations.Discord;
10+
11+
namespace Microsoft.Extensions.DependencyInjection;
12+
13+
public static class DiscordServiceExtensions
14+
{
15+
public static IServiceCollection AddIntegrationDiscord(this IServiceCollection services)
16+
{
17+
services.AddSingletonAs<DiscordIntegration>()
18+
.As<IIntegration>();
19+
20+
services.AddSingletonAs<DiscordBotClientPool>()
21+
.AsSelf();
22+
23+
return services;
24+
}
25+
}

backend/src/Notifo.Domain.Integrations/Notifo.Domain.Integrations.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<ItemGroup>
1212
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.300.60" />
1313
<PackageReference Include="Confluent.Kafka" Version="2.3.0" />
14+
<PackageReference Include="Discord.Net" Version="3.15.2" />
1415
<PackageReference Include="FirebaseAdmin" Version="2.4.0" />
1516
<PackageReference Include="FluentValidation" Version="11.9.0" />
1617
<PackageReference Include="Fluid.Core" Version="2.7.0" />

0 commit comments

Comments
 (0)