diff --git a/Librarian/Commands/BookmarkCommand.cs b/Librarian/Commands/BookmarkCommand.cs new file mode 100644 index 0000000..3fa5d93 --- /dev/null +++ b/Librarian/Commands/BookmarkCommand.cs @@ -0,0 +1,43 @@ +using Discord; +using Discord.Interactions; +using Librarian.Services; + +namespace Librarian.Commands; + +/// +/// Represents a class which implements the Bookmark context menu. +/// +internal sealed class BookmarkCommand : InteractionModuleBase +{ + private readonly BookmarkService _bookmarkService; + + /// + /// Initializes a new instance of the class. + /// + /// The bookmark service. + public BookmarkCommand(BookmarkService bookmarkService) + { + _bookmarkService = bookmarkService; + } + + [MessageCommand("Bookmark")] + [RequireContext(ContextType.Guild)] + public async Task BookmarkAsync(IMessage message) + { + if (message is not IUserMessage userMessage) + { + return; + } + + var member = (IGuildUser)Context.User; + IUserMessage? bookmark = await _bookmarkService.CreateBookmarkAsync(member, userMessage).ConfigureAwait(false); + if (bookmark is null) + { + await RespondAsync("Bookmark failed. Please make sure you have DMs enabled.", ephemeral: true).ConfigureAwait(false); + return; + } + + var response = $"Bookmark created. [Check your DMs]({bookmark.GetJumpUrl()}) to view your bookmarks."; + await RespondAsync(response, ephemeral: true).ConfigureAwait(false); + } +} diff --git a/Librarian/Commands/InfoCommand.cs b/Librarian/Commands/InfoCommand.cs new file mode 100644 index 0000000..93be485 --- /dev/null +++ b/Librarian/Commands/InfoCommand.cs @@ -0,0 +1,57 @@ +using System.Text; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Humanizer; +using Librarian.Services; + +namespace Librarian.Commands; + +/// +/// Represents a class which implements the info command. +/// +internal sealed class InfoCommand : InteractionModuleBase +{ + private readonly BotService _botService; + private readonly DiscordSocketClient _discordClient; + + /// + /// Initializes a new instance of the class. + /// + /// The bot service. + /// + public InfoCommand(BotService botService, DiscordSocketClient discordClient) + { + _botService = botService; + _discordClient = discordClient; + } + + [SlashCommand("info", "Displays information about the bot.")] + [RequireContext(ContextType.Guild)] + public async Task InfoAsync() + { + SocketGuildUser member = Context.Guild.GetUser(_discordClient.CurrentUser.Id); + string botVerison = _botService.Version; + + SocketRole? highestRole = member.Roles.Where(r => r.Color != Color.Default).MaxBy(r => r.Position); + + var embed = new EmbedBuilder(); + embed.WithAuthor(member); + embed.WithColor(highestRole?.Color ?? Color.Default); + embed.WithThumbnailUrl(member.GetAvatarUrl()); + embed.WithTitle($"Librarian v{botVerison}"); + embed.AddField("Ping", $"{_discordClient.Latency} ms", true); + embed.AddField("Uptime", (DateTimeOffset.UtcNow - _botService.StartedAt).Humanize(), true); + embed.AddField("View Source", "[View on GitHub](https://github.com/BrackeysBot/Librarian)", true); + + var builder = new StringBuilder(); + builder.AppendLine($"Librarian: {botVerison}"); + builder.AppendLine($"Discord.Net: {_botService.DiscordNetVersion}"); + builder.AppendLine($"CLR: {Environment.Version.ToString(3)}"); + builder.AppendLine($"Host: {Environment.OSVersion}"); + + embed.AddField("Version", $"```\n{builder}\n```"); + + await RespondAsync(embed: embed.Build(), ephemeral: true).ConfigureAwait(false); + } +} diff --git a/Librarian/Librarian.csproj b/Librarian/Librarian.csproj index 2827d32..8d8075d 100644 --- a/Librarian/Librarian.csproj +++ b/Librarian/Librarian.csproj @@ -6,12 +6,43 @@ enable enable Linux + 1.0.0 + + $(VersionPrefix)-$(VersionSuffix) + $(VersionPrefix).0 + $(VersionPrefix).0 + + + + $(VersionPrefix)-$(VersionSuffix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + + + + $(VersionPrefix) + $(VersionPrefix).0 + $(VersionPrefix).0 + + + + + .dockerignore + + + - - .dockerignore - + + + + + + + + + diff --git a/Librarian/Program.cs b/Librarian/Program.cs index 139ec4e..a5f56b1 100644 --- a/Librarian/Program.cs +++ b/Librarian/Program.cs @@ -1,3 +1,41 @@ -// See https://aka.ms/new-console-template for more information +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Librarian.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using X10D.Hosting.DependencyInjection; -Console.WriteLine("Hello, World!"); +Directory.CreateDirectory("data"); + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .WriteTo.File("logs/latest.log", rollingInterval: RollingInterval.Day) +#if DEBUG + .MinimumLevel.Debug() +#endif + .CreateLogger(); + + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Configuration.AddJsonFile("data/config.json", true, true); +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new DiscordSocketConfig +{ + GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.GuildMessages | GatewayIntents.MessageContent +}); + +builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); + +builder.Services.AddHostedSingleton(); + +IHost app = builder.Build(); +await app.RunAsync(); diff --git a/Librarian/Services/BookmarkEmoteService.cs b/Librarian/Services/BookmarkEmoteService.cs new file mode 100644 index 0000000..71930a9 --- /dev/null +++ b/Librarian/Services/BookmarkEmoteService.cs @@ -0,0 +1,51 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; + +namespace Librarian.Services; + +/// +/// Represents a service which listens for the bookmark emote. +/// +internal sealed class BookmarkEmoteService : BackgroundService +{ + private readonly DiscordSocketClient _discordClient; + private readonly BookmarkService _bookmarkService; + + /// + /// Initializes a new instance of the class. + /// + /// The Discord client. + /// The bookmark service. + public BookmarkEmoteService(DiscordSocketClient discordClient, BookmarkService bookmarkService) + { + _discordClient = discordClient; + _bookmarkService = bookmarkService; + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _discordClient.ReactionAdded += OnBookmarkAdded; + return Task.CompletedTask; + } + + private async Task OnBookmarkAdded(Cacheable message, + Cacheable channel, + SocketReaction reaction) + { + var theChannel = (ITextChannel)(channel.HasValue ? channel.Value : reaction.Channel); + var theMessage = (IUserMessage)(message.HasValue + ? message.Value + : await theChannel.GetMessageAsync(message.Id).ConfigureAwait(false) + ); + + if (theMessage.Channel is not ITextChannel) return; // ignore DMs + if (reaction.Emote.Name != "🔖") return; + + IUser user = reaction.User.Value; + + await theMessage.RemoveReactionAsync(reaction.Emote, user).ConfigureAwait(false); + await _bookmarkService.CreateBookmarkAsync((IGuildUser)user, theMessage).ConfigureAwait(false); + } +} diff --git a/Librarian/Services/BookmarkService.cs b/Librarian/Services/BookmarkService.cs new file mode 100644 index 0000000..aa3379a --- /dev/null +++ b/Librarian/Services/BookmarkService.cs @@ -0,0 +1,90 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; + +namespace Librarian.Services; + +internal sealed class BookmarkService : BackgroundService +{ + private readonly DiscordSocketClient _discordClient; + + /// + /// Initializes a new instance of the class. + /// + /// The Discord client. + public BookmarkService(DiscordSocketClient discordClient) + { + _discordClient = discordClient; + } + + /// + /// Creates a bookmark for the specified message. + /// + /// The user who bookmarked the message. + /// The message to bookmark. + /// The bookmark sent to the user. + /// + /// or is . + /// + public async Task CreateBookmarkAsync(IGuildUser user, IUserMessage message) + { + if (user is null) throw new ArgumentNullException(nameof(user)); + if (message is null) throw new ArgumentNullException(nameof(message)); + + IDMChannel dmChannel; + try + { + dmChannel = await user.CreateDMChannelAsync(); + } + catch + { + return null; + } + + IGuild guild = user.Guild; + + var embed = new EmbedBuilder(); + embed.WithTitle("🔖 Bookmarked Message"); + embed.WithDescription($"You bookmarked a message in **{guild.Name}**."); + embed.WithColor(Color.Green); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithThumbnailUrl(guild.IconUrl); + embed.AddField("Author", message.Author.Mention, true); + embed.AddField("Sent", $"", true); + embed.AddField("Channel", MentionUtils.MentionChannel(message.Channel.Id), true); + + var jumpButton = new ButtonBuilder(); + jumpButton.WithLabel("Jump to Message"); + jumpButton.WithStyle(ButtonStyle.Link); + jumpButton.WithUrl(message.GetJumpUrl()); + + var deleteButton = new ButtonBuilder(); + deleteButton.WithLabel("Delete Bookmark"); + deleteButton.WithStyle(ButtonStyle.Danger); + deleteButton.WithCustomId("delete_bookmark"); + deleteButton.WithEmote(new Emoji("🗑️")); + + var components = new ComponentBuilder(); + components.WithButton(jumpButton); + components.WithButton(deleteButton); + + return await dmChannel.SendMessageAsync(embed: embed.Build(), components: components.Build()).ConfigureAwait(false); + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _discordClient.ButtonExecuted += OnDeleteBookmark; + return Task.CompletedTask; + } + + private async Task OnDeleteBookmark(SocketMessageComponent component) + { + if (component.Data.CustomId != "delete_bookmark") + { + return; + } + + await component.Message.DeleteAsync().ConfigureAwait(false); + await component.RespondAsync("Bookmark deleted!", ephemeral: true).ConfigureAwait(false); + } +} diff --git a/Librarian/Services/BotService.cs b/Librarian/Services/BotService.cs new file mode 100644 index 0000000..e5fa069 --- /dev/null +++ b/Librarian/Services/BotService.cs @@ -0,0 +1,112 @@ +using System.Reflection; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Librarian.Commands; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Librarian.Services; + +/// +/// Represents a service which manages the bot's Discord connection. +/// +internal sealed class BotService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly DiscordSocketClient _discordClient; + private readonly InteractionService _interactionService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The service provider. + /// The Discord client. + /// The interaction service. + public BotService(ILogger logger, + IServiceProvider serviceProvider, + DiscordSocketClient discordClient, + InteractionService interactionService) + { + _logger = logger; + _serviceProvider = serviceProvider; + _discordClient = discordClient; + _interactionService = interactionService; + + var attribute = typeof(BotService).Assembly.GetCustomAttribute(); + Version = attribute?.InformationalVersion ?? "Unknown"; + + attribute = typeof(DiscordSocketClient).Assembly.GetCustomAttribute(); + DiscordNetVersion = attribute?.InformationalVersion ?? "Unknown"; + } + + /// + /// Gets the Discord.Net version. + /// + /// The Discord.Net version. + public string DiscordNetVersion { get; } + + /// + /// Gets the date and time at which the bot was started. + /// + /// The start timestamp. + public DateTimeOffset StartedAt { get; private set; } + + /// + /// Gets the bot version. + /// + /// The bot version. + public string Version { get; } + + /// + public override Task StopAsync(CancellationToken cancellationToken) + { + return Task.WhenAll(_discordClient.DisposeAsync().AsTask(), base.StopAsync(cancellationToken)); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + StartedAt = DateTimeOffset.UtcNow; + _logger.LogInformation("Librarian v{Version} is starting...", Version); + + await _interactionService.AddModuleAsync(_serviceProvider).ConfigureAwait(false); + await _interactionService.AddModuleAsync(_serviceProvider).ConfigureAwait(false); + + _logger.LogInformation("Connecting to Discord..."); + _discordClient.Ready += OnReady; + _discordClient.InteractionCreated += OnInteractionCreated; + + await _discordClient.LoginAsync(TokenType.Bot, Environment.GetEnvironmentVariable("DISCORD_TOKEN")).ConfigureAwait(false); + await _discordClient.StartAsync().ConfigureAwait(false); + } + + private async Task OnInteractionCreated(SocketInteraction interaction) + { + try + { + var context = new SocketInteractionContext(_discordClient, interaction); + IResult result = await _interactionService.ExecuteCommandAsync(context, _serviceProvider); + + if (!result.IsSuccess) + switch (result.Error) + { + case InteractionCommandError.UnmetPrecondition: + break; + } + } + catch + { + if (interaction.Type is InteractionType.ApplicationCommand) + await interaction.GetOriginalResponseAsync().ContinueWith(async msg => await msg.Result.DeleteAsync()); + } + } + + private Task OnReady() + { + _logger.LogInformation("Discord client ready"); + return _interactionService.RegisterCommandsGloballyAsync(); + } +}