diff --git a/src/Apis/Genius/GeniusSong.cs b/src/Apis/Genius/GeniusSong.cs index 66396a9..4cd202e 100644 --- a/src/Apis/Genius/GeniusSong.cs +++ b/src/Apis/Genius/GeniusSong.cs @@ -6,6 +6,7 @@ namespace Fergun.Apis.Genius; /// public record GeniusSong( [property: JsonPropertyName("artist_names")] string ArtistNames, + [property: JsonPropertyName("primary_artist_names")] string PrimaryArtistNames, [property: JsonPropertyName("id")] int Id, [property: JsonPropertyName("instrumental")] bool IsInstrumental, [property: JsonPropertyName("lyrics_state")] string LyricsState, diff --git a/src/Apis/Genius/IGeniusSong.cs b/src/Apis/Genius/IGeniusSong.cs index 052cb77..28b5227 100644 --- a/src/Apis/Genius/IGeniusSong.cs +++ b/src/Apis/Genius/IGeniusSong.cs @@ -9,6 +9,11 @@ public interface IGeniusSong /// Gets the artist names. /// string ArtistNames { get; } + + /// + /// Gets the primary artist names. + /// + string PrimaryArtistNames { get; } /// /// Gets the ID of this song. diff --git a/src/Modules/OtherModule.cs b/src/Modules/OtherModule.cs index 9b84113..e316a7f 100644 --- a/src/Modules/OtherModule.cs +++ b/src/Modules/OtherModule.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Net.Http; @@ -18,6 +19,7 @@ using Fergun.Interactive.Pagination; using Fergun.Modules.Handlers; using Fergun.Preconditions; +using Fergun.Services; using Humanizer; using Humanizer.Localisation; using Microsoft.EntityFrameworkCore; @@ -39,9 +41,10 @@ public class OtherModule : InteractionModuleBase private readonly IGeniusClient _geniusClient; private readonly HttpClient _httpClient; private readonly FergunContext _db; + private readonly ApplicationCommandCache _commandCache; public OtherModule(ILogger logger, IFergunLocalizer localizer, IOptionsSnapshot fergunOptions, - InteractiveService interactive, IGeniusClient geniusClient, HttpClient httpClient, FergunContext db) + InteractiveService interactive, IGeniusClient geniusClient, HttpClient httpClient, FergunContext db, ApplicationCommandCache commandCache) { _logger = logger; _localizer = localizer; @@ -50,6 +53,7 @@ public OtherModule(ILogger logger, IFergunLocalizer lo _httpClient = httpClient; _interactive = interactive; _db = db; + _commandCache = commandCache; } public override void BeforeExecute(ICommandInfo command) => _localizer.CurrentCulture = CultureInfo.GetCultureInfo(Context.Interaction.GetLanguageCode()); @@ -147,6 +151,64 @@ public async Task LyricsAsync([MaxValue(int.MaxValue)] [Autocompl { await Context.Interaction.DeferAsync(); + return await LyricsInternalAsync(id); + } + + [Ratelimit(1, Constants.GlobalRatelimitPeriod)] + [SlashCommand("lyrics-spotify", "Gets the lyrics of the song you're listening to on Spotify.")] + public async Task LyricsSpotifyAsync() + { + var spotifyActivity = Context.User.Activities.OfType().FirstOrDefault(); + if (spotifyActivity is null) + { + return FergunResult.FromError(_localizer["NoSpotifyActivity"], true); + } + + await Context.Interaction.DeferAsync(); + + string artist = spotifyActivity.Artists.First(); + string title = RemoveTitleExtraInfo(spotifyActivity.TrackTitle); + string query = $"{artist} {title}"; + + _logger.LogInformation("Detected Spotify activity on user {User} ({UserId})", Context.User, Context.User.Id); + _logger.LogInformation("Searching for songs matching Spotify song \"{Song}\"", $"{artist} - {title}"); + + var results = await _geniusClient.SearchSongsAsync(query); + + var match = results.FirstOrDefault(x => + x.PrimaryArtistNames.Equals(artist, StringComparison.InvariantCultureIgnoreCase) && + x.Title.Equals(title, StringComparison.InvariantCultureIgnoreCase)); + + if (match is not null) + { + _logger.LogInformation("Found exact match for Spotify song \"{Song}\"", match); + if (match.IsInstrumental) + { + return FergunResult.FromError(_localizer["LyricsInstrumental", match]); + } + + if (match.LyricsState == "unreleased") + { + return FergunResult.FromError(_localizer["LyricsUnreleased", match]); + } + } + else + { + match = results.FirstOrDefault(x => !x.IsInstrumental && x.LyricsState != "unreleased" && + (x.PrimaryArtistNames.Equals(artist, StringComparison.InvariantCultureIgnoreCase) || + x.Title.Equals(title, StringComparison.InvariantCultureIgnoreCase))); + } + + if (match is null) + { + return FergunResult.FromError(_localizer["NoSongMatchFound", $"{artist} - {title}"]); + } + + return await LyricsInternalAsync(match.Id, false); + } + + private async Task LyricsInternalAsync(int id, bool checkSpotifyStatus = true) + { _logger.LogInformation("Requesting song from Genius with ID {Id}", id); var song = await _geniusClient.GetSongAsync(id); @@ -175,6 +237,9 @@ public async Task LyricsAsync([MaxValue(int.MaxValue)] [Autocompl return FergunResult.FromError(_localizer["LyricsEmpty", $"{song.ArtistNames} - {song.Title}"]); } + var spotifyLyricsCommand = _commandCache.CachedCommands.FirstOrDefault(x => x.Name == "lyrics-spotify"); + Debug.Assert(spotifyLyricsCommand != null, "Expected /lyrics-spotify to be present"); + var chunks = song.Lyrics.SplitForPagination(EmbedBuilder.MaxDescriptionLength).ToArray(); _logger.LogDebug("Split lyrics into {Chunks}", "chunk".ToQuantity(chunks.Length)); @@ -201,13 +266,36 @@ PageBuilder GeneratePage(int index) if (song.SpotifyTrackId is not null) links += $" | {Format.Url(_localizer["OpenInSpotify"], $"https://open.spotify.com/track/{song.SpotifyTrackId}?go=1")}"; - return new PageBuilder() + var builder = new PageBuilder() .WithTitle($"{song.ArtistNames} - {song.Title}".Truncate(EmbedBuilder.MaxTitleLength)) .WithThumbnailUrl(song.SongArtImageUrl) .WithDescription(chunks[index].ToString()) - .AddField(_localizer["Links"], links) .WithFooter(_localizer["GeniusPaginatorFooter", index + 1, chunks.Length], Constants.GeniusLogoUrl) .WithColor(Color.Orange); + + if (checkSpotifyStatus && IsSameSong()) + { + var mention = $""; + builder.AddField(_localizer["Note"], _localizer["UseSpotifyLyricsCommand", mention]); + } + + builder.AddField(_localizer["Links"], links); + + return builder; + } + + bool IsSameSong() + { + var spotifyActivity = Context.User.Activities.OfType().FirstOrDefault(); + if (spotifyActivity is null) + return false; + + string artist = spotifyActivity.Artists.First(); + string title = RemoveTitleExtraInfo(spotifyActivity.TrackTitle); + + return (song.SpotifyTrackId is not null && song.SpotifyTrackId == spotifyActivity.TrackId) || + (song.PrimaryArtistNames.Equals(artist, StringComparison.InvariantCultureIgnoreCase) && + song.Title.Equals(title, StringComparison.InvariantCultureIgnoreCase)); } } @@ -311,4 +399,14 @@ public async Task StatsAsync() return FergunResult.FromSuccess(); } + + [return: NotNullIfNotNull(nameof(input))] + private static string? RemoveTitleExtraInfo(string? input) + { + if (string.IsNullOrEmpty(input)) + return input; + + int index = input.IndexOf(" - ", StringComparison.Ordinal); + return index != -1 ? input[..index] : input; + } } \ No newline at end of file diff --git a/src/Modules/UtilityModule.cs b/src/Modules/UtilityModule.cs index 8da598b..3080328 100644 --- a/src/Modules/UtilityModule.cs +++ b/src/Modules/UtilityModule.cs @@ -9,7 +9,6 @@ using System.Threading.Tasks; using Discord; using Discord.Interactions; -using Discord.Rest; using Fergun.Apis.Dictionary; using Fergun.Apis.Wikipedia; using Fergun.Apis.WolframAlpha; @@ -20,6 +19,7 @@ using Fergun.Interactive.Selection; using Fergun.Modules.Handlers; using Fergun.Preconditions; +using Fergun.Services; using GTranslate; using GTranslate.Results; using Humanizer; @@ -41,7 +41,6 @@ namespace Fergun.Modules; [Ratelimit(Constants.GlobalCommandUsesPerPeriod, Constants.GlobalRatelimitPeriod)] public class UtilityModule : InteractionModuleBase { - private static IReadOnlyCollection? _cachedCommands; private static readonly DrawingOptions _cachedDrawingOptions = new(); private static readonly PngEncoder _cachedPngEncoder = new() { CompressionLevel = PngCompressionLevel.BestCompression, SkipMetadata = true }; private static readonly Lazy _lazyFilteredLanguages = new(() => Language.LanguageDictionary @@ -51,28 +50,28 @@ public class UtilityModule : InteractionModuleBase private readonly ILogger _logger; private readonly IFergunLocalizer _localizer; - private readonly StartupOptions _startupOptions; private readonly FergunOptions _fergunOptions; private readonly SharedModule _shared; private readonly InteractionService _commands; private readonly InteractiveService _interactive; + private readonly ApplicationCommandCache _commandCache; private readonly IFergunTranslator _translator; private readonly IDictionaryClient _dictionary; private readonly SearchClient _searchClient; private readonly IWikipediaClient _wikipediaClient; private readonly IWolframAlphaClient _wolframAlphaClient; - public UtilityModule(ILogger logger, IFergunLocalizer localizer, IOptions startupOptions, - IOptionsSnapshot fergunOptions, SharedModule shared, InteractionService commands, InteractiveService interactive, - IDictionaryClient dictionary, IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient, IWolframAlphaClient wolframAlphaClient) + public UtilityModule(ILogger logger, IFergunLocalizer localizer, IOptionsSnapshot fergunOptions, + SharedModule shared, InteractionService commands, InteractiveService interactive, ApplicationCommandCache commandCache, IDictionaryClient dictionary, + IFergunTranslator translator, SearchClient searchClient, IWikipediaClient wikipediaClient, IWolframAlphaClient wolframAlphaClient) { _logger = logger; _localizer = localizer; - _startupOptions = startupOptions.Value; _fergunOptions = fergunOptions.Value; _shared = shared; _commands = commands; _interactive = interactive; + _commandCache = commandCache; _dictionary = dictionary; _translator = translator; _searchClient = searchClient; @@ -366,25 +365,6 @@ public async Task DefineAsync( [SlashCommand("help", "Information about Fergun.")] public async Task HelpAsync() { - var responseType = InteractionResponseType.ChannelMessageWithSource; - - if (_cachedCommands is null) - { - responseType = InteractionResponseType.DeferredChannelMessageWithSource; - await Context.Interaction.DeferAsync(); - } - - if (_startupOptions.TestingGuildId == 0) - { - _logger.LogDebug("Requesting global commands from REST API"); - _cachedCommands ??= await ((ShardedInteractionContext)Context).Client.Rest.GetGlobalApplicationCommands(true); - } - else - { - _logger.LogDebug("Requesting testing guild commands ({TestingGuildId}) from REST API", _startupOptions.TestingGuildId); - _cachedCommands ??= await ((ShardedInteractionContext)Context).Client.Rest.GetGuildApplicationCommands(_startupOptions.TestingGuildId, true); - } - string description = _localizer["Fergun2Info"]; var links = new List(); @@ -437,6 +417,7 @@ public async Task HelpAsync() InteractiveMessageResult? result = null; var interaction = Context.Interaction; + var responseType = InteractionResponseType.ChannelMessageWithSource; _logger.LogInformation("Displaying help menu to user {User} ({Id})", Context.User, Context.User.Id); @@ -464,7 +445,7 @@ public async Task HelpAsync() string locale = Context.Interaction.UserLocale; if (module.IsSlashGroup) { - var group = _cachedCommands + var group = _commandCache.CachedCommands .First(globalCommand => module.SlashGroupName == globalCommand.Name); // Slash command mentions can't be localized @@ -474,7 +455,7 @@ public async Task HelpAsync() } else { - commandDescriptions = _cachedCommands + commandDescriptions = _commandCache.CachedCommands .Where(globalCommand => module.SlashCommands.Any(slashCommand => globalCommand.Name == slashCommand.Name)) .OrderBy(x => x.NameLocalized ?? x.Name) .Select(x => $" - {x.DescriptionLocalizations.GetValueOrDefault(locale, x.Description)}"); diff --git a/src/Program.cs b/src/Program.cs index 815ea9f..2f8dda7 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -57,7 +57,7 @@ config.SocketConfig = new DiscordSocketConfig { LogLevel = LogSeverity.Verbose, - GatewayIntents = GatewayIntents.Guilds, + GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildPresences, UseInteractionSnowflakeDate = false, LogGatewayIntentWarnings = false, FormatUsersInBidirectionalUnicode = false @@ -74,6 +74,7 @@ builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); builder.Services.AddTransient(typeof(IFergunLocalizer<>), typeof(FergunLocalizer<>)); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.ConfigureHttpClientDefaults(b => diff --git a/src/Resources/Modules.OtherModule.es.resx b/src/Resources/Modules.OtherModule.es.resx index 7889407..e880429 100644 --- a/src/Resources/Modules.OtherModule.es.resx +++ b/src/Resources/Modules.OtherModule.es.resx @@ -123,12 +123,6 @@ Versión del bot - - Muestra las estadísticas de uso de los comandos. - - - estadísticas-de-comandos - Estadísticas de Comandos @@ -141,18 +135,6 @@ Letra por Genius | Página {0} de {1} - - Envía una cita inspiradora. - - - inspirobot - - - Invita Fergun a tu servidor. - - - invitar - Haga click en el botón de abajo para invitar a Fergun a tu servidor. @@ -165,18 +147,6 @@ Enlaces - - Obtiene la letra de una canción. - - - letra - - - El nombre de la canción. - - - nombre - No es posible obtener la letra de "{0}". @@ -213,12 +183,6 @@ ID de shard - - Envía las estadísticas del bot. - - - estadísticas - Servidores Totales @@ -237,4 +201,58 @@ Ver en Musixmatch + + No se ha detectado la actividad de Spotify. + + + No es posible obtener la letra de la canción "{0}". + + + Obtiene la letra de la canción que estás escuchando en Spotify. + + + Muestra las estadísticas de uso de los comandos. + + + estadísticas-de-comandos + + + Envía una cita inspiradora. + + + inspirobot + + + Invita Fergun a tu servidor. + + + invitar + + + Obtiene la letra de una canción. + + + letra + + + El nombre de la canción. + + + nombre + + + Envía las estadísticas del bot. + + + estadísticas + + + letra-spotify + + + Nota + + + ℹ️ Usa {0} para obtener fácilmente la letra de la canción que estás escuchando. + \ No newline at end of file diff --git a/src/Resources/Modules.OtherModule.resx b/src/Resources/Modules.OtherModule.resx index 6b598ec..a607278 100644 --- a/src/Resources/Modules.OtherModule.resx +++ b/src/Resources/Modules.OtherModule.resx @@ -151,7 +151,7 @@ Unable to get the lyrics of "{0}". - "{0}" is instrumental. + "{0}" is an instrumental song. Unable to find a song with ID {0}. Use the autocomplete results. @@ -201,4 +201,19 @@ View on Musixmatch + + No Spotify activity detected. + + + Unable to get the lyrics of song "{0}". + + + + + + Note + + + ℹ️ Use {0} to easily get the lyrics of the song you're currently listening to. + \ No newline at end of file diff --git a/src/Services/ApplicationCommandCache.cs b/src/Services/ApplicationCommandCache.cs new file mode 100644 index 0000000..f2b0df0 --- /dev/null +++ b/src/Services/ApplicationCommandCache.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Discord; + +namespace Fergun.Services; + +/// +/// Stores the registered application commands. +/// +public class ApplicationCommandCache +{ + /// + /// Gets the registered application commands. + /// + public IReadOnlyCollection CachedCommands { get; internal set; } = []; +} \ No newline at end of file diff --git a/src/Services/InteractionHandlingService.cs b/src/Services/InteractionHandlingService.cs index 05a1265..f220fac 100644 --- a/src/Services/InteractionHandlingService.cs +++ b/src/Services/InteractionHandlingService.cs @@ -27,6 +27,7 @@ public sealed class InteractionHandlingService : IHostedService, IDisposable { private readonly DiscordShardedClient _shardedClient; private readonly InteractionService _interactionService; + private readonly ApplicationCommandCache _commandCache; private readonly FergunLocalizationManager _localizationManager; private readonly ILogger _logger; private readonly IServiceProvider _services; @@ -35,11 +36,12 @@ public sealed class InteractionHandlingService : IHostedService, IDisposable private readonly SemaphoreSlim _cmdStatsSemaphore = new(1, 1); private bool _disposed; - public InteractionHandlingService(DiscordShardedClient client, InteractionService interactionService, FergunLocalizationManager localizationManager, - ILogger logger, IServiceProvider services, IOptions options) + public InteractionHandlingService(DiscordShardedClient client, InteractionService interactionService, ApplicationCommandCache commandCache, + FergunLocalizationManager localizationManager, ILogger logger, IServiceProvider services, IOptions options) { _shardedClient = client; _interactionService = interactionService; + _commandCache = commandCache; _localizationManager = localizationManager; _logger = logger; _services = services; @@ -110,7 +112,7 @@ public async Task ReadyAsync() if (_testingGuildId == 0) { _logger.LogInformation("Registering commands globally"); - await _interactionService.AddModulesGloballyAsync(true, modules.ToArray()); + _commandCache.CachedCommands = await _interactionService.AddModulesGloballyAsync(true, modules.ToArray()); if (_ownerCommandsGuildId != 0) { @@ -124,11 +126,11 @@ public async Task ReadyAsync() if (_testingGuildId == _ownerCommandsGuildId) { - await _interactionService.RegisterCommandsToGuildAsync(_testingGuildId); + _commandCache.CachedCommands = await _interactionService.RegisterCommandsToGuildAsync(_testingGuildId); } else { - await _interactionService.AddModulesToGuildAsync(_testingGuildId, true, modules.ToArray()); + _commandCache.CachedCommands = await _interactionService.AddModulesToGuildAsync(_testingGuildId, true, modules.ToArray()); if (_ownerCommandsGuildId != 0) { diff --git a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs index 579906d..5d346a1 100644 --- a/tests/Fergun.Tests/Modules/UtilityModuleTests.cs +++ b/tests/Fergun.Tests/Modules/UtilityModuleTests.cs @@ -8,12 +8,11 @@ using Fergun.Apis.Dictionary; using Fergun.Apis.Wikipedia; using Fergun.Apis.WolframAlpha; -using Fergun.Configuration; using Fergun.Interactive; using Fergun.Modules; +using Fergun.Services; using GTranslate.Translators; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Moq; using Xunit; using YoutubeExplode.Search; @@ -34,14 +33,14 @@ public class UtilityModuleTests public UtilityModuleTests() { - var startupOptions = Mock.Of>(); + var commandCache = new ApplicationCommandCache(); var options = Utils.CreateMockedFergunOptions(); var client = new DiscordSocketClient(); SharedModule shared = new(Mock.Of>(), Utils.CreateMockedLocalizer(), Mock.Of(), _googleTranslator2); var interactionService = new InteractionService(client); var interactive = new InteractiveService(client, new InteractiveConfig { ReturnAfterSendingPaginator = true }); - _moduleMock = new Mock(() => new UtilityModule(Mock.Of>(), _localizer, startupOptions, options, shared, interactionService, - interactive, _dictionaryClient, Mock.Of(), _searchClient, _wikipediaClient, _wolframAlphaClient)) { CallBase = true }; + _moduleMock = new Mock(() => new UtilityModule(Mock.Of>(), _localizer, options, shared, interactionService, + interactive, commandCache, _dictionaryClient, Mock.Of(), _searchClient, _wikipediaClient, _wolframAlphaClient)) { CallBase = true }; _contextMock.SetupGet(x => x.Interaction).Returns(_interactionMock.Object); ((IInteractionModuleBase)_moduleMock.Object).SetContext(_contextMock.Object); }