From 5c57b753358cbde87462d071ce077e2e9600bb36 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 24 Oct 2024 20:59:26 +0200 Subject: [PATCH] feat(api): add news to /api/meta response --- Catalogger.Backend/Api/ApiCache.cs | 34 ++++++- Catalogger.Backend/Api/GuildsController.cs | 1 - Catalogger.Backend/Api/MetaController.cs | 17 +++- Catalogger.Backend/Config.cs | 2 + .../Extensions/StartupExtensions.cs | 1 + Catalogger.Backend/Services/NewsService.cs | 97 +++++++++++++++++++ 6 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 Catalogger.Backend/Services/NewsService.cs diff --git a/Catalogger.Backend/Api/ApiCache.cs b/Catalogger.Backend/Api/ApiCache.cs index c4161ad..b790643 100644 --- a/Catalogger.Backend/Api/ApiCache.cs +++ b/Catalogger.Backend/Api/ApiCache.cs @@ -14,11 +14,43 @@ // along with this program. If not, see . using Catalogger.Backend.Database.Redis; +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; namespace Catalogger.Backend.Api; -public class ApiCache(RedisService redisService) +public class ApiCache(RedisService redisService, IDiscordRestChannelAPI channelApi, Config config) { + private List? _news; + private readonly SemaphoreSlim _newsSemaphore = new(1); + + public async Task> GetNewsAsync() + { + await _newsSemaphore.WaitAsync(); + try + { + if (_news != null) + return _news; + + if (config.Web.NewsChannel == null) + return []; + + var res = await channelApi.GetChannelMessagesAsync( + DiscordSnowflake.New(config.Web.NewsChannel.Value), + limit: 5 + ); + if (res.IsSuccess) + return _news = res.Entity.ToList(); + + return []; + } + finally + { + _newsSemaphore.Release(); + } + } + private static string UserKey(string id) => $"api-user:{id}"; private static string GuildsKey(string userId) => $"api-user-guilds:{userId}"; diff --git a/Catalogger.Backend/Api/GuildsController.cs b/Catalogger.Backend/Api/GuildsController.cs index 11acc42..f7210a7 100644 --- a/Catalogger.Backend/Api/GuildsController.cs +++ b/Catalogger.Backend/Api/GuildsController.cs @@ -31,7 +31,6 @@ namespace Catalogger.Backend.Api; [Route("/api/guilds/{id}")] public partial class GuildsController( - Config config, ILogger logger, DatabaseContext db, ChannelCache channelCache, diff --git a/Catalogger.Backend/Api/MetaController.cs b/Catalogger.Backend/Api/MetaController.cs index f5d9fe6..533bc6d 100644 --- a/Catalogger.Backend/Api/MetaController.cs +++ b/Catalogger.Backend/Api/MetaController.cs @@ -15,7 +15,10 @@ using Catalogger.Backend.Api.Middleware; using Catalogger.Backend.Cache.InMemoryCache; +using Catalogger.Backend.Extensions; +using Catalogger.Backend.Services; using Microsoft.AspNetCore.Mvc; +using Remora.Discord.API.Abstractions.Objects; namespace Catalogger.Backend.Api; @@ -23,20 +26,24 @@ namespace Catalogger.Backend.Api; public class MetaController( Config config, GuildCache guildCache, + NewsService newsService, DiscordRequestService discordRequestService ) : ApiControllerBase { [HttpGet("meta")] - public IActionResult GetMeta() + public async Task GetMetaAsync() { var inviteUrl = $"https://discord.com/oauth2/authorize?client_id={config.Discord.ApplicationId}" + "&permissions=537250993&scope=bot+applications.commands"; + var news = await newsService.GetNewsAsync(); + return Ok( new MetaResponse( Guilds: (int)CataloggerMetrics.GuildsCached.Value, - InviteUrl: inviteUrl + InviteUrl: inviteUrl, + News: news ) ); } @@ -60,7 +67,11 @@ public class MetaController( ); } - private record MetaResponse(int Guilds, string InviteUrl); + private record MetaResponse( + int Guilds, + string InviteUrl, + IEnumerable News + ); private record CurrentUserResponse(ApiUser User, IEnumerable Guilds); } diff --git a/Catalogger.Backend/Config.cs b/Catalogger.Backend/Config.cs index d3489bf..e8badc9 100644 --- a/Catalogger.Backend/Config.cs +++ b/Catalogger.Backend/Config.cs @@ -62,5 +62,7 @@ public class Config public int Port { get; init; } = 5000; public string BaseUrl { get; init; } = null!; public string Address => $"http://{Host}:{Port}"; + + public ulong? NewsChannel { get; init; } } } diff --git a/Catalogger.Backend/Extensions/StartupExtensions.cs b/Catalogger.Backend/Extensions/StartupExtensions.cs index 46cf25b..95c7626 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -110,6 +110,7 @@ public static class StartupExtensions .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddScoped() .AddSingleton() .AddScoped() diff --git a/Catalogger.Backend/Services/NewsService.cs b/Catalogger.Backend/Services/NewsService.cs new file mode 100644 index 0000000..e52c783 --- /dev/null +++ b/Catalogger.Backend/Services/NewsService.cs @@ -0,0 +1,97 @@ +// Copyright (C) 2021-present sam (starshines.gay) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using Catalogger.Backend.Extensions; +using NodaTime; +using NodaTime.Extensions; +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; + +namespace Catalogger.Backend.Services; + +public class NewsService( + Config config, + ILogger logger, + IDiscordRestChannelAPI channelApi, + IClock clock +) +{ + private static readonly Duration MaxNewsAge = Duration.FromDays(90); // 3 months + private static readonly Duration ExpiresAfter = Duration.FromHours(1); + + private readonly ILogger _logger = logger.ForContext(); + private List? _messages; + private readonly SemaphoreSlim _lock = new(1); + private bool _isExpired => clock.GetCurrentInstant() > clock.GetCurrentInstant() + ExpiresAfter; + + public async Task> GetNewsAsync() + { + if (_messages != null && !_isExpired) + return _messages.Select(FormatIMessage); + + var messages = await GetRawNewsAsync(); + return messages.Select(FormatIMessage); + } + + public void AddMessage(IMessage message) => + _messages = _messages?.Take(4).Prepend(message).ToList(); + + private async Task> GetRawNewsAsync() + { + await _lock.WaitAsync(); + try + { + if (config.Web.NewsChannel == null) + return []; + + _logger.Information("Fetching news from channel {ChannelId}", config.Web.NewsChannel); + + var res = await channelApi.GetChannelMessagesAsync( + DiscordSnowflake.New(config.Web.NewsChannel.Value), + limit: 5 + ); + if (res.IsSuccess) + return _messages = res + .Entity.Where(m => + m.ID.Timestamp.ToInstant() > clock.GetCurrentInstant() - MaxNewsAge + ) + .ToList(); + + return []; + } + finally + { + _lock.Release(); + } + } + + public record NewsMessage( + string Author, + string Content, + IEnumerable AttachmentUrls, + DateTimeOffset PostedAt, + DateTimeOffset? EditedAt + ); + + private NewsMessage FormatIMessage(IMessage msg) => + new( + msg.Author.Tag(), + msg.Content, + msg.Attachments.Select(a => a.Url), + msg.ID.Timestamp, + msg.EditedTimestamp + ); +}