Skip to content

Commit e646e38

Browse files
authored
feat: add slash commands for feed management and runtime feed updates (#2)
* feat(nuget): Add AutoCommand dependency * feat(git): Add package registry setup * fix: Change default interval to 60 * feat: Add commands file path option * feat: Add command registration * feat: Add command files * feat: Register commands * feat(commands): Add command implementations * feat(command): Add option safeguards * feat(docker): Add command filepath environment variable and mount * feat: Switch to options monitor and refactoring * feat: Add sorting of the feed items * feat: Add file path option configuration * feat: Add command resources * feat: Add json writer utility * feat(git): Add global.json * fix: Fix async method naming * fix(formatting): Fix formatting * feat(branding): Change embed color to brand primary color * fix: Fix time span * feat: Add feeds file to solution items * fix: Fix command structure * feat(extension): Add slash command extensions * refactor: Use slash command extension * fix: Fix sequence empty exception for optional options * refactor: Minor refactoring * feat: Add alternate response for empty feeds * fix: Fixed channel ID property name and added formatting * feat!: Added equality and overrides * fix: Fix non IConvertible exception when validating channel * fix: HTTP scheme validation and URI conversion * feat: Add duplicate feed detection * feat: Add Guilds gateway intent * fix: Fix Timer event handler memory leak by properly disposing of resources * feat: Add chunk dispatching of embeds * style: Add punctuation
1 parent 49a2245 commit e646e38

22 files changed

Lines changed: 651 additions & 38 deletions

.github/workflows/dotnet.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ jobs:
2020
uses: actions/setup-dotnet@v4
2121
with:
2222
dotnet-version: 8.0.x
23+
- name: Setup package registry
24+
run: dotnet nuget add source --username moeux --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/moeux/index.json"
2325
- name: Restore dependencies
2426
run: dotnet restore
2527
- name: Build

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
global.json
12
feeds.json
23
bin/
34
obj/

Courier.slnx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<Solution>
22
<Folder Name="/Solution Items/">
33
<File Path="compose.yaml"/>
4+
<File Path="commands/add.json"/>
5+
<File Path="commands/remove.json"/>
6+
<File Path="commands/list.json"/>
7+
<File Path="feeds.json"/>
48
</Folder>
59
<Project Path="Courier/Courier.csproj"/>
610
</Solution>

Courier/Commands/AddCommand.cs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using AutoCommand.Handler;
2+
using Courier.Configuration;
3+
using Courier.Extensions;
4+
using Courier.Models;
5+
using Courier.Utilities;
6+
using Discord;
7+
using Discord.WebSocket;
8+
using Microsoft.Extensions.Options;
9+
using Serilog;
10+
11+
namespace Courier.Commands;
12+
13+
public class AddCommand(IOptionsMonitor<FeedOptions> optionsMonitor, DiscordSocketClient client) : ICommandHandler
14+
{
15+
public async Task HandleAsync(
16+
ILogger logger,
17+
SocketSlashCommand command,
18+
CancellationToken cancellationToken = new())
19+
{
20+
var commandOptions = command.Data.Options;
21+
var interval = command.Data.Options
22+
.Where(option => option.Name == "interval")
23+
.Select(option => option.Value)
24+
.Cast<long>()
25+
.FirstOrDefault(Feed.DefaultInterval);
26+
var channel = await command.Data.Options
27+
.Where(option => option.Name == "channel")
28+
.Select(option => option.Value)
29+
.OfType<ITextChannel>()
30+
.ToAsyncEnumerable()
31+
.FirstOrDefaultAwaitAsync(async textChannel =>
32+
{
33+
var user = await textChannel.GetUserAsync(
34+
client.CurrentUser.Id,
35+
options: new RequestOptions { CancelToken = cancellationToken });
36+
var permissions = user?.GetPermissions(textChannel);
37+
38+
return permissions is { SendMessages: true, EmbedLinks: true };
39+
}, cancellationToken);
40+
41+
if (!Validate<string>(commandOptions.First(option => option.Name == "name"), out var name) ||
42+
name is null)
43+
{
44+
await command.RespondEphemeralAsync(
45+
Resources.AddCommandNameOptionRequired,
46+
cancellationToken: cancellationToken);
47+
return;
48+
}
49+
50+
if (!Validate<Uri>(commandOptions.First(option => option.Name == "uri"), out var uri) ||
51+
uri is null)
52+
{
53+
await command.RespondEphemeralAsync(
54+
Resources.AddCommandUriOptionRequired,
55+
cancellationToken: cancellationToken);
56+
return;
57+
}
58+
59+
if (channel is null)
60+
{
61+
await command.RespondEphemeralAsync(
62+
Resources.AddCommandChannelOptionRequired,
63+
cancellationToken: cancellationToken);
64+
return;
65+
}
66+
67+
var feeds = new HashSet<Feed>(optionsMonitor.CurrentValue.Feeds);
68+
var newFeed = new Feed
69+
{
70+
Name = name,
71+
Uri = uri.AbsoluteUri,
72+
ChannelId = channel.Id,
73+
Interval = interval
74+
};
75+
76+
if (!feeds.Add(newFeed))
77+
{
78+
await command.RespondEphemeralAsync(
79+
Resources.AddCommandFeedAlreadyExists,
80+
cancellationToken: cancellationToken);
81+
return;
82+
}
83+
84+
await JsonWriter.UpdateFeedsAsync(feeds, optionsMonitor.CurrentValue.FilePath, cancellationToken);
85+
await command.RespondEphemeralAsync(
86+
Resources.AddCommandFeedAdded,
87+
cancellationToken: cancellationToken);
88+
}
89+
90+
public string CommandName => "add";
91+
92+
private static bool Validate<T>(SocketSlashCommandDataOption option, out T? value) where T : class
93+
{
94+
switch (option.Value)
95+
{
96+
case string s when typeof(T) == typeof(string):
97+
value = Convert.ChangeType(s, typeof(T)) as T;
98+
return !string.IsNullOrWhiteSpace(s);
99+
case string u when typeof(T) == typeof(Uri):
100+
u = u.Trim();
101+
u = u.StartsWith("https://") || u.StartsWith("http://") ? u : "https://" + u;
102+
var isValid = Uri.TryCreate(u, UriKind.Absolute, out var uri);
103+
value = Convert.ChangeType(uri, typeof(T)) as T;
104+
return isValid && (uri?.Scheme == Uri.UriSchemeHttp || uri?.Scheme == Uri.UriSchemeHttps);
105+
case long l when typeof(T) == typeof(long):
106+
value = Convert.ChangeType(l, typeof(T)) as T;
107+
return l > 0;
108+
}
109+
110+
value = null;
111+
return false;
112+
}
113+
}

Courier/Commands/ListCommand.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using AutoCommand.Handler;
2+
using Courier.Configuration;
3+
using Courier.Extensions;
4+
using Courier.Models;
5+
using Discord;
6+
using Discord.WebSocket;
7+
using Microsoft.Extensions.Options;
8+
using Serilog;
9+
10+
namespace Courier.Commands;
11+
12+
public class ListCommand(IOptionsMonitor<FeedOptions> optionsMonitor) : ICommandHandler
13+
{
14+
public async Task HandleAsync(
15+
ILogger logger,
16+
SocketSlashCommand command,
17+
CancellationToken cancellationToken = new())
18+
{
19+
var feeds = optionsMonitor.CurrentValue.Feeds;
20+
var page = command.Data.Options
21+
.Where(option => option.Name == "page")
22+
.Select(option => option.Value)
23+
.Cast<long>()
24+
.FirstOrDefault(1);
25+
26+
if (command.Data.Options.First(option => option.Name == "channel")?.Value is not IGuildChannel channel)
27+
{
28+
await command.RespondEphemeralAsync(
29+
Resources.ListCommandChannelOptionRequired,
30+
cancellationToken: cancellationToken);
31+
return;
32+
}
33+
34+
var fields = feeds
35+
.Where(feed => feed.ChannelId == channel.Id)
36+
.SelectMany(CreateEmbedFields)
37+
.Chunk(EmbedBuilder.MaxFieldCount)
38+
.Skip((int)(page - 1))
39+
.Take(1)
40+
.SelectMany(f => f);
41+
var embed = new EmbedBuilder()
42+
.WithTitle("Feeds")
43+
.WithColor(new Color(242, 125, 22))
44+
.WithFields(fields)
45+
.Build();
46+
47+
if (embed.Fields.IsDefaultOrEmpty)
48+
{
49+
await command.RespondEphemeralAsync(
50+
Resources.ListCommandNoFeedsFound,
51+
cancellationToken: cancellationToken);
52+
return;
53+
}
54+
55+
await command.RespondEphemeralAsync(embed: embed, cancellationToken: cancellationToken);
56+
}
57+
58+
public string CommandName => "list";
59+
60+
private static EmbedFieldBuilder[] CreateEmbedFields(Feed feed)
61+
{
62+
return
63+
[
64+
new EmbedFieldBuilder()
65+
.WithName("Name")
66+
.WithValue(feed.Name.Truncate(EmbedFieldBuilder.MaxFieldValueLength))
67+
.WithIsInline(true),
68+
new EmbedFieldBuilder()
69+
.WithName("URI")
70+
.WithValue(feed.Uri.Truncate(EmbedFieldBuilder.MaxFieldValueLength))
71+
.WithIsInline(true),
72+
new EmbedFieldBuilder()
73+
.WithName("Interval")
74+
.WithValue(feed.Interval)
75+
.WithIsInline(true)
76+
];
77+
}
78+
}

Courier/Commands/RemoveCommand.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using AutoCommand.Handler;
2+
using Courier.Configuration;
3+
using Courier.Extensions;
4+
using Courier.Models;
5+
using Courier.Utilities;
6+
using Discord;
7+
using Discord.WebSocket;
8+
using Microsoft.Extensions.Options;
9+
using Serilog;
10+
11+
namespace Courier.Commands;
12+
13+
public class RemoveCommand(IOptionsMonitor<FeedOptions> optionsMonitor) : ICommandHandler
14+
{
15+
public async Task HandleAsync(
16+
ILogger logger,
17+
SocketSlashCommand command,
18+
CancellationToken cancellationToken = new())
19+
{
20+
if (command.Data.Options.First(option => option.Name == "channel")?.Value is not IGuildChannel channel)
21+
{
22+
await command.RespondEphemeralAsync(
23+
Resources.RemoveCommandChannelOptionRequired,
24+
cancellationToken: cancellationToken);
25+
return;
26+
}
27+
28+
if (command.Data.Options.First(option => option.Name == "name")?.Value is not string name ||
29+
string.IsNullOrWhiteSpace(name))
30+
{
31+
await command.RespondEphemeralAsync(
32+
Resources.RemoveCommandNameOptionRequired,
33+
cancellationToken: cancellationToken);
34+
return;
35+
}
36+
37+
var feeds = new List<Feed>(optionsMonitor.CurrentValue.Feeds);
38+
var removedFeeds = feeds.RemoveAll(feed => feed.Name == name && feed.ChannelId == channel.Id);
39+
40+
if (removedFeeds == 0)
41+
{
42+
await command.RespondEphemeralAsync(
43+
Resources.RemoveCommandNoFeedsFound,
44+
cancellationToken: cancellationToken);
45+
return;
46+
}
47+
48+
await JsonWriter.UpdateFeedsAsync(feeds, optionsMonitor.CurrentValue.FilePath, cancellationToken);
49+
await command.RespondEphemeralAsync(
50+
Resources.RemoveCommandFeedRemoved,
51+
cancellationToken: cancellationToken);
52+
}
53+
54+
public string CommandName => "remove";
55+
}

Courier/Configuration/BotOptions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ namespace Courier.Configuration;
33
public class BotOptions
44
{
55
public required string Token { get; set; }
6+
public required string FilePath { get; set; }
67
}

Courier/Configuration/ConfKeys.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public static class ConfKeys
77
public static class Bot
88
{
99
public const string Token = $"{Prefix}:{nameof(Bot)}:{nameof(Token)}";
10+
public const string FilePath = $"{Prefix}:{nameof(Bot)}:{nameof(FilePath)}";
1011
}
1112

1213
public static class Feed

Courier/Configuration/FeedOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ namespace Courier.Configuration;
55
public class FeedOptions
66
{
77
public required string FilePath { get; set; }
8-
public required Feed[] Feeds { get; set; }
8+
public required IList<Feed> Feeds { get; set; }
99
}

Courier/Courier.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
</ItemGroup>
1616

1717
<ItemGroup>
18+
<PackageReference Include="AutoCommand" Version="1.0.3"/>
1819
<PackageReference Include="Discord.Net" Version="3.18.0"/>
1920
<PackageReference Include="Html2Markdown" Version="7.1.2.20"/>
2021
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3"/>

0 commit comments

Comments
 (0)