feat(api): add news to /api/meta response

This commit is contained in:
sam 2024-10-24 20:59:26 +02:00
parent 31b6ac2cac
commit 5c57b75335
Signed by: sam
GPG key ID: 5F3C3C1B3166639D
6 changed files with 147 additions and 5 deletions

View file

@ -14,11 +14,43 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Catalogger.Backend.Database.Redis; 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; namespace Catalogger.Backend.Api;
public class ApiCache(RedisService redisService) public class ApiCache(RedisService redisService, IDiscordRestChannelAPI channelApi, Config config)
{ {
private List<IMessage>? _news;
private readonly SemaphoreSlim _newsSemaphore = new(1);
public async Task<List<IMessage>> 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 UserKey(string id) => $"api-user:{id}";
private static string GuildsKey(string userId) => $"api-user-guilds:{userId}"; private static string GuildsKey(string userId) => $"api-user-guilds:{userId}";

View file

@ -31,7 +31,6 @@ namespace Catalogger.Backend.Api;
[Route("/api/guilds/{id}")] [Route("/api/guilds/{id}")]
public partial class GuildsController( public partial class GuildsController(
Config config,
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
ChannelCache channelCache, ChannelCache channelCache,

View file

@ -15,7 +15,10 @@
using Catalogger.Backend.Api.Middleware; using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Remora.Discord.API.Abstractions.Objects;
namespace Catalogger.Backend.Api; namespace Catalogger.Backend.Api;
@ -23,20 +26,24 @@ namespace Catalogger.Backend.Api;
public class MetaController( public class MetaController(
Config config, Config config,
GuildCache guildCache, GuildCache guildCache,
NewsService newsService,
DiscordRequestService discordRequestService DiscordRequestService discordRequestService
) : ApiControllerBase ) : ApiControllerBase
{ {
[HttpGet("meta")] [HttpGet("meta")]
public IActionResult GetMeta() public async Task<IActionResult> GetMetaAsync()
{ {
var inviteUrl = var inviteUrl =
$"https://discord.com/oauth2/authorize?client_id={config.Discord.ApplicationId}" $"https://discord.com/oauth2/authorize?client_id={config.Discord.ApplicationId}"
+ "&permissions=537250993&scope=bot+applications.commands"; + "&permissions=537250993&scope=bot+applications.commands";
var news = await newsService.GetNewsAsync();
return Ok( return Ok(
new MetaResponse( new MetaResponse(
Guilds: (int)CataloggerMetrics.GuildsCached.Value, 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<NewsService.NewsMessage> News
);
private record CurrentUserResponse(ApiUser User, IEnumerable<ApiGuild> Guilds); private record CurrentUserResponse(ApiUser User, IEnumerable<ApiGuild> Guilds);
} }

View file

@ -62,5 +62,7 @@ public class Config
public int Port { get; init; } = 5000; public int Port { get; init; } = 5000;
public string BaseUrl { get; init; } = null!; public string BaseUrl { get; init; } = null!;
public string Address => $"http://{Host}:{Port}"; public string Address => $"http://{Host}:{Port}";
public ulong? NewsChannel { get; init; }
} }
} }

View file

@ -110,6 +110,7 @@ public static class StartupExtensions
.AddSingleton<AuditLogCache>() .AddSingleton<AuditLogCache>()
.AddSingleton<EmojiCache>() .AddSingleton<EmojiCache>()
.AddSingleton<PluralkitApiService>() .AddSingleton<PluralkitApiService>()
.AddSingleton<NewsService>()
.AddScoped<IEncryptionService, EncryptionService>() .AddScoped<IEncryptionService, EncryptionService>()
.AddSingleton<MetricsCollectionService>() .AddSingleton<MetricsCollectionService>()
.AddScoped<MessageRepository>() .AddScoped<MessageRepository>()

View file

@ -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 <https://www.gnu.org/licenses/>.
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<NewsService>();
private List<IMessage>? _messages;
private readonly SemaphoreSlim _lock = new(1);
private bool _isExpired => clock.GetCurrentInstant() > clock.GetCurrentInstant() + ExpiresAfter;
public async Task<IEnumerable<NewsMessage>> 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<List<IMessage>> 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<string> 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
);
}