diff --git a/.gitignore b/.gitignore
index aa6c539..ea16193 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,5 +10,3 @@ logs/
*.conf
*.json
.env
-
-DesignTimeNumerousDbContextFactory.cs
diff --git a/src/Numerous.Bot/BotServiceConfiguration.cs b/src/Numerous.Bot/BotServiceConfiguration.cs
index 4de8dcf..b8c432c 100644
--- a/src/Numerous.Bot/BotServiceConfiguration.cs
+++ b/src/Numerous.Bot/BotServiceConfiguration.cs
@@ -4,9 +4,10 @@
// You should have received a copy of the GNU General Public License along with this program. If not, see .
using Microsoft.Extensions.DependencyInjection;
-using Numerous.Bot.Discord;
using Numerous.Bot.Discord.Events;
using Numerous.Bot.Discord.Interactions;
+using Numerous.Bot.Discord.Services;
+using Numerous.Bot.Discord.Services.Attachments;
using Numerous.Bot.Discord.Util;
using Numerous.Bot.Exclusive;
using Numerous.Bot.Osu;
@@ -28,6 +29,7 @@ public static void Configure(IServiceCollection services)
services.AddSingleton();
services.AddTransient();
services.AddSingleton();
+ services.AddTransient();
services.AddHostedService();
services.AddHostedService();
services.AddHostedService();
diff --git a/src/Numerous.Bot/Discord/Events/DiscordEventHandler.cs b/src/Numerous.Bot/Discord/Events/DiscordEventHandler.cs
index debc33d..4abcf03 100644
--- a/src/Numerous.Bot/Discord/Events/DiscordEventHandler.cs
+++ b/src/Numerous.Bot/Discord/Events/DiscordEventHandler.cs
@@ -4,6 +4,8 @@
// You should have received a copy of the GNU General Public License along with this program. If not, see .
using Discord.WebSocket;
+using Numerous.Bot.Discord.Services;
+using Numerous.Bot.Discord.Services.Attachments;
using Numerous.Bot.Util;
using Numerous.Bot.Web.Osu;
using Numerous.Common.Config;
diff --git a/src/Numerous.Bot/Discord/Events/EventHandler.Logger.cs b/src/Numerous.Bot/Discord/Events/EventHandler.Logger.cs
index ae2324e..7242f94 100644
--- a/src/Numerous.Bot/Discord/Events/EventHandler.Logger.cs
+++ b/src/Numerous.Bot/Discord/Events/EventHandler.Logger.cs
@@ -4,6 +4,7 @@
// You should have received a copy of the GNU General Public License along with this program. If not, see .
using Discord.WebSocket;
+using Numerous.Bot.Discord.Util;
using Numerous.Bot.Util;
using Serilog;
diff --git a/src/Numerous.Bot/Discord/Events/EventHandler.MessageCommands.cs b/src/Numerous.Bot/Discord/Events/EventHandler.MessageCommands.cs
index d53d293..a264623 100644
--- a/src/Numerous.Bot/Discord/Events/EventHandler.MessageCommands.cs
+++ b/src/Numerous.Bot/Discord/Events/EventHandler.MessageCommands.cs
@@ -5,6 +5,7 @@
using System.Net;
using Discord;
+using Numerous.Bot.Discord.Util;
using Numerous.Bot.Util;
using Refit;
diff --git a/src/Numerous.Bot/Discord/Events/MessageResponder.Ban.cs b/src/Numerous.Bot/Discord/Events/MessageResponder.Ban.cs
index 3bf2674..375cc71 100644
--- a/src/Numerous.Bot/Discord/Events/MessageResponder.Ban.cs
+++ b/src/Numerous.Bot/Discord/Events/MessageResponder.Ban.cs
@@ -7,6 +7,7 @@
using Discord;
using Discord.Net;
using Discord.WebSocket;
+using Numerous.Bot.Discord.Util;
using Numerous.Common.Util;
namespace Numerous.Bot.Discord.Events;
diff --git a/src/Numerous.Bot/Discord/Events/MessageResponder.cs b/src/Numerous.Bot/Discord/Events/MessageResponder.cs
index e8042f8..092b77b 100644
--- a/src/Numerous.Bot/Discord/Events/MessageResponder.cs
+++ b/src/Numerous.Bot/Discord/Events/MessageResponder.cs
@@ -4,7 +4,7 @@
// You should have received a copy of the GNU General Public License along with this program. If not, see .
using Discord.WebSocket;
-using Numerous.Common;
+using Numerous.Common.Services;
namespace Numerous.Bot.Discord.Events;
diff --git a/src/Numerous.Bot/Discord/Events/MudaeMessageHandler.cs b/src/Numerous.Bot/Discord/Events/MudaeMessageHandler.cs
index f29a818..76e5943 100644
--- a/src/Numerous.Bot/Discord/Events/MudaeMessageHandler.cs
+++ b/src/Numerous.Bot/Discord/Events/MudaeMessageHandler.cs
@@ -5,7 +5,8 @@
using Discord;
using Discord.WebSocket;
-using Numerous.Common;
+using Numerous.Bot.Discord.Util;
+using Numerous.Common.Services;
namespace Numerous.Bot.Discord.Events;
diff --git a/src/Numerous.Bot/Discord/Interactions/Commands/Admin/AdminCommandModule.Competition.End.cs b/src/Numerous.Bot/Discord/Interactions/Commands/Admin/AdminCommandModule.Competition.End.cs
index 6577369..6edf82c 100644
--- a/src/Numerous.Bot/Discord/Interactions/Commands/Admin/AdminCommandModule.Competition.End.cs
+++ b/src/Numerous.Bot/Discord/Interactions/Commands/Admin/AdminCommandModule.Competition.End.cs
@@ -6,6 +6,7 @@
using Discord;
using Discord.Interactions;
using JetBrains.Annotations;
+using Numerous.Bot.Discord.Util;
using Numerous.Common.Util;
namespace Numerous.Bot.Discord.Interactions.Commands.Admin;
diff --git a/src/Numerous.Bot/Discord/Interactions/Commands/Config/ConfigCommandModule.Mapfeed.cs b/src/Numerous.Bot/Discord/Interactions/Commands/Config/ConfigCommandModule.Mapfeed.cs
index 0c9751e..4217791 100644
--- a/src/Numerous.Bot/Discord/Interactions/Commands/Config/ConfigCommandModule.Mapfeed.cs
+++ b/src/Numerous.Bot/Discord/Interactions/Commands/Config/ConfigCommandModule.Mapfeed.cs
@@ -6,6 +6,7 @@
using Discord;
using Discord.Interactions;
using JetBrains.Annotations;
+using Numerous.Bot.Discord.Util;
using Numerous.Database.Context;
namespace Numerous.Bot.Discord.Interactions.Commands.Config;
diff --git a/src/Numerous.Bot/Discord/Interactions/Commands/Config/ConfigCommandModule.Role.cs b/src/Numerous.Bot/Discord/Interactions/Commands/Config/ConfigCommandModule.Role.cs
index b733d91..01c4092 100644
--- a/src/Numerous.Bot/Discord/Interactions/Commands/Config/ConfigCommandModule.Role.cs
+++ b/src/Numerous.Bot/Discord/Interactions/Commands/Config/ConfigCommandModule.Role.cs
@@ -6,6 +6,7 @@
using Discord;
using Discord.Interactions;
using JetBrains.Annotations;
+using Numerous.Bot.Discord.Services;
using Numerous.Common.Enums;
namespace Numerous.Bot.Discord.Interactions.Commands.Config;
diff --git a/src/Numerous.Bot/Discord/Interactions/Commands/ReminderCommandModule.cs b/src/Numerous.Bot/Discord/Interactions/Commands/ReminderCommandModule.cs
index 01508a9..cc9b319 100644
--- a/src/Numerous.Bot/Discord/Interactions/Commands/ReminderCommandModule.cs
+++ b/src/Numerous.Bot/Discord/Interactions/Commands/ReminderCommandModule.cs
@@ -6,6 +6,8 @@
using Discord;
using Discord.Interactions;
using JetBrains.Annotations;
+using Numerous.Bot.Discord.Services;
+using Numerous.Bot.Discord.Util;
using Numerous.Bot.Util;
using Numerous.Database.Context;
using Numerous.Database.Dtos;
diff --git a/src/Numerous.Bot/Discord/Interactions/Commands/SuperDeleteCommandModule.cs b/src/Numerous.Bot/Discord/Interactions/Commands/SuperDeleteCommandModule.cs
index 99aa54f..3eae5a1 100644
--- a/src/Numerous.Bot/Discord/Interactions/Commands/SuperDeleteCommandModule.cs
+++ b/src/Numerous.Bot/Discord/Interactions/Commands/SuperDeleteCommandModule.cs
@@ -6,6 +6,7 @@
using Discord;
using Discord.Interactions;
using JetBrains.Annotations;
+using Numerous.Bot.Discord.Util;
using Numerous.Database.Context;
namespace Numerous.Bot.Discord.Interactions.Commands;
diff --git a/src/Numerous.Bot/Discord/Interactions/Commands/UnDeleteCommandModule.cs b/src/Numerous.Bot/Discord/Interactions/Commands/UnDeleteCommandModule.cs
index 399ec09..5f86320 100644
--- a/src/Numerous.Bot/Discord/Interactions/Commands/UnDeleteCommandModule.cs
+++ b/src/Numerous.Bot/Discord/Interactions/Commands/UnDeleteCommandModule.cs
@@ -6,6 +6,7 @@
using Discord;
using Discord.Interactions;
using JetBrains.Annotations;
+using Numerous.Bot.Discord.Services.Attachments;
using Numerous.Bot.Util;
using Numerous.Common.Util;
using Numerous.Database.Context;
diff --git a/src/Numerous.Bot/Discord/Interactions/Commands/UnEditCommandModule.cs b/src/Numerous.Bot/Discord/Interactions/Commands/UnEditCommandModule.cs
index b293d21..147494d 100644
--- a/src/Numerous.Bot/Discord/Interactions/Commands/UnEditCommandModule.cs
+++ b/src/Numerous.Bot/Discord/Interactions/Commands/UnEditCommandModule.cs
@@ -7,6 +7,7 @@
using Discord.Interactions;
using Discord.WebSocket;
using JetBrains.Annotations;
+using Numerous.Bot.Discord.Util;
using Numerous.Bot.Util;
using Numerous.Common.Util;
using Numerous.Database.Context;
diff --git a/src/Numerous.Bot/Discord/Interactions/Commands/VerifyCommandModule.cs b/src/Numerous.Bot/Discord/Interactions/Commands/VerifyCommandModule.cs
index e80baa2..0670940 100644
--- a/src/Numerous.Bot/Discord/Interactions/Commands/VerifyCommandModule.cs
+++ b/src/Numerous.Bot/Discord/Interactions/Commands/VerifyCommandModule.cs
@@ -6,6 +6,7 @@
using Discord;
using Discord.Interactions;
using JetBrains.Annotations;
+using Numerous.Bot.Discord.Services;
using Numerous.Common.Config;
using Numerous.Common.Enums;
using Numerous.Database.Context;
diff --git a/src/Numerous.Bot/Discord/Interactions/InteractionHandler.cs b/src/Numerous.Bot/Discord/Interactions/InteractionHandler.cs
index e4b85a1..ec5d9c7 100644
--- a/src/Numerous.Bot/Discord/Interactions/InteractionHandler.cs
+++ b/src/Numerous.Bot/Discord/Interactions/InteractionHandler.cs
@@ -7,8 +7,9 @@
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
-using Numerous.Common;
+using Numerous.Bot.Discord.Util;
using Numerous.Common.Config;
+using Numerous.Common.Services;
using Numerous.Common.Util;
using Serilog;
diff --git a/src/Numerous.Bot/Discord/AttachmentService.cs b/src/Numerous.Bot/Discord/Services/Attachments/AttachmentService.cs
similarity index 97%
rename from src/Numerous.Bot/Discord/AttachmentService.cs
rename to src/Numerous.Bot/Discord/Services/Attachments/AttachmentService.cs
index 5472e2e..b73d170 100644
--- a/src/Numerous.Bot/Discord/AttachmentService.cs
+++ b/src/Numerous.Bot/Discord/Services/Attachments/AttachmentService.cs
@@ -7,7 +7,7 @@
using Numerous.Bot.Services;
using Numerous.Common.Config;
-namespace Numerous.Bot.Discord;
+namespace Numerous.Bot.Discord.Services.Attachments;
public sealed class AttachmentService(IConfigProvider cfgProvider, IFileService files)
{
diff --git a/src/Numerous.Bot/Discord/FileAttachmentInfo.cs b/src/Numerous.Bot/Discord/Services/Attachments/FileAttachmentInfo.cs
similarity index 94%
rename from src/Numerous.Bot/Discord/FileAttachmentInfo.cs
rename to src/Numerous.Bot/Discord/Services/Attachments/FileAttachmentInfo.cs
index cfc0dde..7005df0 100644
--- a/src/Numerous.Bot/Discord/FileAttachmentInfo.cs
+++ b/src/Numerous.Bot/Discord/Services/Attachments/FileAttachmentInfo.cs
@@ -5,7 +5,7 @@
using Discord;
-namespace Numerous.Bot.Discord;
+namespace Numerous.Bot.Discord.Services.Attachments;
public readonly record struct FileAttachmentInfo(string Path, string FileName)
{
diff --git a/src/Numerous.Bot/Discord/Services/GuildStatsService.cs b/src/Numerous.Bot/Discord/Services/GuildStatsService.cs
new file mode 100644
index 0000000..7e88280
--- /dev/null
+++ b/src/Numerous.Bot/Discord/Services/GuildStatsService.cs
@@ -0,0 +1,38 @@
+// Copyright (C) Pasi4K5
+// This program is free software: you can redistribute it and/or modify it under the terms of the GNU 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 General Public License for more details.
+// You should have received a copy of the GNU General Public License along with this program. If not, see .
+
+using Discord.WebSocket;
+using Numerous.Database.Context;
+
+namespace Numerous.Bot.Discord.Services;
+
+public sealed class GuildStatsService(
+ DiscordSocketClient client,
+ IUnitOfWorkFactory uowFactory
+)
+{
+ public void Start()
+ {
+ client.UserJoined += async gu => await UpdateStatsAsync(gu.Guild);
+ client.UserLeft += async (g, _) => await UpdateStatsAsync(g);
+ }
+
+ private async Task UpdateStatsAsync(SocketGuild guild)
+ {
+ var now = DateTimeOffset.UtcNow;
+ await guild.DownloadUsersAsync();
+
+ await using var uow = uowFactory.Create();
+
+ await uow.GuildStats.InsertAsync(new()
+ {
+ GuildId = guild.Id,
+ Timestamp = now,
+ MemberCount = guild.DownloadedMemberCount,
+ });
+
+ await uow.CommitAsync();
+ }
+}
diff --git a/src/Numerous.Bot/Discord/OsuVerifier.cs b/src/Numerous.Bot/Discord/Services/OsuVerifier.cs
similarity index 98%
rename from src/Numerous.Bot/Discord/OsuVerifier.cs
rename to src/Numerous.Bot/Discord/Services/OsuVerifier.cs
index b1e4b26..5e2e24d 100644
--- a/src/Numerous.Bot/Discord/OsuVerifier.cs
+++ b/src/Numerous.Bot/Discord/Services/OsuVerifier.cs
@@ -15,7 +15,7 @@
using osu.Game.Beatmaps;
using Serilog;
-namespace Numerous.Bot.Discord;
+namespace Numerous.Bot.Discord.Services;
public sealed class OsuVerifier(
IHost host,
@@ -24,7 +24,7 @@ public sealed class OsuVerifier(
IOsuApiRepository osuApi
)
{
- public Task StartAsync(CancellationToken ct)
+ public void Start(CancellationToken ct)
{
host.Services.UseScheduler(scheduler => scheduler.ScheduleAsync(async () => await AssignAllRolesAsync(ct))
.EveryMinute()
@@ -34,8 +34,6 @@ public Task StartAsync(CancellationToken ct)
await GetOsuUsersAsync(user, ct),
await GetGroupRoleMappingsAsync(user.Guild, ct)
);
-
- return Task.CompletedTask;
}
public async Task AssignAllRolesAsync(SocketGuild guild, CancellationToken ct = default)
diff --git a/src/Numerous.Bot/Discord/ReminderService.cs b/src/Numerous.Bot/Discord/Services/ReminderService.cs
similarity index 98%
rename from src/Numerous.Bot/Discord/ReminderService.cs
rename to src/Numerous.Bot/Discord/Services/ReminderService.cs
index 435a329..14cc52e 100644
--- a/src/Numerous.Bot/Discord/ReminderService.cs
+++ b/src/Numerous.Bot/Discord/Services/ReminderService.cs
@@ -12,7 +12,7 @@
using Numerous.Database.Dtos;
using Timer = System.Timers.Timer;
-namespace Numerous.Bot.Discord;
+namespace Numerous.Bot.Discord.Services;
public sealed class ReminderService(IHost host, IUnitOfWorkFactory uowFactory, DiscordSocketClient client)
{
@@ -20,7 +20,7 @@ public sealed class ReminderService(IHost host, IUnitOfWorkFactory uowFactory, D
private readonly Dictionary _timerCache = new();
- public void StartAsync(CancellationToken ct)
+ public void Start(CancellationToken ct)
{
host.Services.UseScheduler(s =>
s.ScheduleAsync(() => CacheRemindersAsync(ct)).Hourly().RunOnceAtStart().PreventOverlapping(nameof(ReminderService))
diff --git a/src/Numerous.Bot/Discord/Constants.cs b/src/Numerous.Bot/Discord/Util/Constants.cs
similarity index 95%
rename from src/Numerous.Bot/Discord/Constants.cs
rename to src/Numerous.Bot/Discord/Util/Constants.cs
index 964fa65..7edb288 100644
--- a/src/Numerous.Bot/Discord/Constants.cs
+++ b/src/Numerous.Bot/Discord/Util/Constants.cs
@@ -3,7 +3,7 @@
// 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 General Public License for more details.
// You should have received a copy of the GNU General Public License along with this program. If not, see .
-namespace Numerous.Bot.Discord;
+namespace Numerous.Bot.Discord.Util;
internal static class Constants
{
diff --git a/src/Numerous.Bot/Discord/DiscordExtensions.cs b/src/Numerous.Bot/Discord/Util/DiscordExtensions.cs
similarity index 98%
rename from src/Numerous.Bot/Discord/DiscordExtensions.cs
rename to src/Numerous.Bot/Discord/Util/DiscordExtensions.cs
index 01c1d63..3665725 100644
--- a/src/Numerous.Bot/Discord/DiscordExtensions.cs
+++ b/src/Numerous.Bot/Discord/Util/DiscordExtensions.cs
@@ -8,7 +8,7 @@
using Discord;
using Discord.WebSocket;
-namespace Numerous.Bot.Discord;
+namespace Numerous.Bot.Discord.Util;
public static partial class DiscordExtensions
{
diff --git a/src/Numerous.Bot/Exclusive/StarReactPreventionService.cs b/src/Numerous.Bot/Exclusive/StarReactPreventionService.cs
index 106a6f9..c5fef96 100644
--- a/src/Numerous.Bot/Exclusive/StarReactPreventionService.cs
+++ b/src/Numerous.Bot/Exclusive/StarReactPreventionService.cs
@@ -5,8 +5,8 @@
using Discord;
using Discord.WebSocket;
-using Numerous.Common;
using Numerous.Common.Config;
+using Numerous.Common.Services;
namespace Numerous.Bot.Exclusive;
diff --git a/src/Numerous.Bot/Osu/MapFeedService.cs b/src/Numerous.Bot/Osu/MapFeedService.cs
index 7755ada..40f261d 100644
--- a/src/Numerous.Bot/Osu/MapFeedService.cs
+++ b/src/Numerous.Bot/Osu/MapFeedService.cs
@@ -10,7 +10,7 @@
using Numerous.Bot.Discord.Util;
using Numerous.Bot.Util;
using Numerous.Bot.Web.Osu;
-using Numerous.Common;
+using Numerous.Common.Services;
using Numerous.Common.Util;
using Numerous.Database.Context;
using osu.Game.Beatmaps;
diff --git a/src/Numerous.Bot/Services/Startup.cs b/src/Numerous.Bot/Services/Startup.cs
index 0bfd56b..4b9be62 100644
--- a/src/Numerous.Bot/Services/Startup.cs
+++ b/src/Numerous.Bot/Services/Startup.cs
@@ -5,10 +5,10 @@
using Discord;
using Discord.WebSocket;
-using Numerous.Bot.Discord;
using Numerous.Bot.Discord.Events;
-using Numerous.Common;
+using Numerous.Bot.Discord.Services;
using Numerous.Common.Config;
+using Numerous.Common.Services;
using Numerous.Database.Context;
namespace Numerous.Bot.Services;
@@ -19,7 +19,8 @@ public sealed class Startup(
IUnitOfWorkFactory uowFactory,
ReminderService reminderService,
OsuVerifier verifier,
- DiscordEventHandler eventHandler
+ DiscordEventHandler eventHandler,
+ GuildStatsService guildStatsService
) : HostedService
{
private Config Cfg => cfgProvider.Get();
@@ -46,9 +47,10 @@ await uow.Guilds.InsertAsync(new()
await uow.CommitAsync(ct);
- reminderService.StartAsync(ct);
- await verifier.StartAsync(ct);
+ reminderService.Start(ct);
+ verifier.Start(ct);
eventHandler.Start();
+ guildStatsService.Start();
}
public override async Task StopAsync(CancellationToken cancellationToken)
diff --git a/src/Numerous.Common/HostedService.cs b/src/Numerous.Common/Services/HostedService.cs
similarity index 96%
rename from src/Numerous.Common/HostedService.cs
rename to src/Numerous.Common/Services/HostedService.cs
index ea13252..0fa30ad 100644
--- a/src/Numerous.Common/HostedService.cs
+++ b/src/Numerous.Common/Services/HostedService.cs
@@ -5,7 +5,7 @@
using Microsoft.Extensions.Hosting;
-namespace Numerous.Common;
+namespace Numerous.Common.Services;
public abstract class HostedService : IHostedService
{
diff --git a/src/Numerous.Database/Context/DesignTimeNumerousDbContextFactory.cs.template b/src/Numerous.Database/Context/DesignTimeNumerousDbContextFactory.cs
similarity index 77%
rename from src/Numerous.Database/Context/DesignTimeNumerousDbContextFactory.cs.template
rename to src/Numerous.Database/Context/DesignTimeNumerousDbContextFactory.cs
index a0ed22a..dd9a894 100644
--- a/src/Numerous.Database/Context/DesignTimeNumerousDbContextFactory.cs.template
+++ b/src/Numerous.Database/Context/DesignTimeNumerousDbContextFactory.cs
@@ -3,6 +3,7 @@
// 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 General Public License for more details.
// You should have received a copy of the GNU General Public License along with this program. If not, see .
+using DotNetEnv;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@@ -12,13 +13,18 @@ namespace Numerous.Database.Context;
[UsedImplicitly]
public sealed class DesignTimeNumerousDbContextFactory : IDesignTimeDbContextFactory
{
- private const string ConnectionString =
- "Host=YOUR_HOST;Username=YOUR_USERNAME;Password=YOUR_PASSWORD;Database=numerous";
-
public NumerousDbContext CreateDbContext(string[] args)
{
+ Env.TraversePath().Load(".env");
+
+ var connectionString =
+ $"Host=localhost;"
+ + $"Username={Env.GetString("POSTGRES_USER")};"
+ + $"Password={Env.GetString("POSTGRES_PASSWORD")};"
+ + $"Database={Env.GetString("POSTGRES_DB")}";
+
var optionsBuilder = new DbContextOptionsBuilder()
- .UseNpgsql(ConnectionString);
+ .UseNpgsql(connectionString);
return new NumerousDbContext(optionsBuilder.Options);
}
diff --git a/src/Numerous.Database/Context/UnitOfWork.cs b/src/Numerous.Database/Context/UnitOfWork.cs
index 29ebcfe..a09adfc 100644
--- a/src/Numerous.Database/Context/UnitOfWork.cs
+++ b/src/Numerous.Database/Context/UnitOfWork.cs
@@ -21,6 +21,7 @@ public interface IUnitOfWork : IDisposable, IAsyncDisposable
IDiscordUserRepository DiscordUsers { get; }
IGroupRoleMappingRepository GroupRoleMappings { get; }
IGuildRepository Guilds { get; }
+ IGuildStatsEntryRepository GuildStats { get; }
IIdRepository LocalBeatmaps { get; }
IIdRepository JoinMessages { get; }
IOnlineBeatmapRepository OnlineBeatmaps { get; }
@@ -45,6 +46,7 @@ public sealed class UnitOfWork(IDbContextFactory contextProvi
public IDiscordUserRepository DiscordUsers => new DiscordUserRepository(_context, mapper);
public IGroupRoleMappingRepository GroupRoleMappings => new GroupRoleMappingRepository(_context, mapper);
public IGuildRepository Guilds => new GuildRepository(_context, mapper);
+ public IGuildStatsEntryRepository GuildStats => new GuildStatsEntryRepository(_context, mapper);
public IIdRepository LocalBeatmaps => new IdRepository(_context, mapper);
public IIdRepository JoinMessages => new IdRepository(_context, mapper);
public IOnlineBeatmapRepository OnlineBeatmaps => new OnlineBeatmapRepository(_context, mapper);
diff --git a/src/Numerous.Database/DbMapperProfile.cs b/src/Numerous.Database/DbMapperProfile.cs
index d04c3b9..147dc4a 100644
--- a/src/Numerous.Database/DbMapperProfile.cs
+++ b/src/Numerous.Database/DbMapperProfile.cs
@@ -63,6 +63,9 @@ public DbMapperProfile()
CreateMap();
CreateMap();
+ CreateMap();
+ CreateMap();
+
CreateMap();
CreateMap();
diff --git a/src/Numerous.Database/Dtos/GuildStatsEntryDto.cs b/src/Numerous.Database/Dtos/GuildStatsEntryDto.cs
new file mode 100644
index 0000000..0dc0c45
--- /dev/null
+++ b/src/Numerous.Database/Dtos/GuildStatsEntryDto.cs
@@ -0,0 +1,16 @@
+// Copyright (C) Pasi4K5
+// This program is free software: you can redistribute it and/or modify it under the terms of the GNU 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 General Public License for more details.
+// You should have received a copy of the GNU General Public License along with this program. If not, see .
+
+namespace Numerous.Database.Dtos;
+
+public sealed class GuildStatsEntryDto
+{
+ public GuildDto Guild { get; set; } = null!;
+ public required ulong GuildId { get; set; }
+
+ public required DateTimeOffset Timestamp { get; set; }
+
+ public required int MemberCount { get; set; }
+}
diff --git a/src/Numerous.Database/Entities/DbGuild.cs b/src/Numerous.Database/Entities/DbGuild.cs
index d4be001..12023ca 100644
--- a/src/Numerous.Database/Entities/DbGuild.cs
+++ b/src/Numerous.Database/Entities/DbGuild.cs
@@ -31,4 +31,5 @@ public sealed class DbGuild : DbEntity
public ICollection Channels { get; set; } = [];
public ICollection GroupRoleMappings { get; set; } = [];
public ICollection BeatmapCompetitions { get; set; } = [];
+ public ICollection Stats { get; set; } = [];
}
diff --git a/src/Numerous.Database/Entities/DbGuildStatsEntry.cs b/src/Numerous.Database/Entities/DbGuildStatsEntry.cs
new file mode 100644
index 0000000..6fff9b6
--- /dev/null
+++ b/src/Numerous.Database/Entities/DbGuildStatsEntry.cs
@@ -0,0 +1,22 @@
+// Copyright (C) Pasi4K5
+// This program is free software: you can redistribute it and/or modify it under the terms of the GNU 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 General Public License for more details.
+// You should have received a copy of the GNU General Public License along with this program. If not, see .
+
+using System.ComponentModel.DataAnnotations.Schema;
+using Microsoft.EntityFrameworkCore;
+
+namespace Numerous.Database.Entities;
+
+[Table("guild_stats_entry")]
+[PrimaryKey(nameof(GuildId), nameof(Timestamp))]
+[Index(nameof(GuildId))]
+public sealed class DbGuildStatsEntry
+{
+ public DbGuild Guild { get; set; } = null!;
+ public ulong GuildId { get; set; }
+
+ public DateTimeOffset Timestamp { get; set; }
+
+ public int MemberCount { get; set; }
+}
diff --git a/src/Numerous.Database/Migrations/20241206220252_AddGuildStats.Designer.cs b/src/Numerous.Database/Migrations/20241206220252_AddGuildStats.Designer.cs
new file mode 100644
index 0000000..0c0bce5
--- /dev/null
+++ b/src/Numerous.Database/Migrations/20241206220252_AddGuildStats.Designer.cs
@@ -0,0 +1,910 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Numerous.Database.Context;
+
+#nullable disable
+
+namespace Numerous.Database.Migrations
+{
+ [DbContext(typeof(NumerousDbContext))]
+ [Migration("20241206220252_AddGuildStats")]
+ partial class AddGuildStats
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseSerialColumns(modelBuilder);
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbAutoPingMapping", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Id"));
+
+ b.Property("ChannelId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("channel_id");
+
+ b.Property("RoleId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("role_id");
+
+ b.Property("TagId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("tag_id");
+
+ b.HasKey("Id")
+ .HasName("pk_auto_ping_mapping");
+
+ b.HasIndex("ChannelId", "TagId", "RoleId")
+ .IsUnique()
+ .HasDatabaseName("ix_auto_ping_mapping_channel_id_tag_id_role_id");
+
+ b.ToTable("auto_ping_mapping", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbBeatmapCompetition", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("guild_id");
+
+ b.Property("StartTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_time");
+
+ b.Property("EndTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("end_time");
+
+ b.Property("LocalBeatmapId")
+ .HasColumnType("uuid")
+ .HasColumnName("local_beatmap_id");
+
+ b.HasKey("GuildId", "StartTime")
+ .HasName("pk_beatmap_competition");
+
+ b.HasIndex("LocalBeatmapId")
+ .HasDatabaseName("ix_beatmap_competition_local_beatmap_id");
+
+ b.ToTable("beatmap_competition", null, t =>
+ {
+ t.HasCheckConstraint("CK_BeatmapCompetition_ValidTime", "\"StartTime\" < \"EndTime\"");
+ });
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbBeatmapCompetitionScore", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("Md5Hash");
+
+ b.Property("Accuracy")
+ .HasColumnType("double precision")
+ .HasColumnName("accuracy");
+
+ b.Property("DateTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("date_time");
+
+ b.Property("GreatCount")
+ .HasColumnType("bigint")
+ .HasColumnName("great_count");
+
+ b.Property("GuildId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("guild_id");
+
+ b.Property("MaxCombo")
+ .HasColumnType("bigint")
+ .HasColumnName("max_combo");
+
+ b.Property("MehCount")
+ .HasColumnType("bigint")
+ .HasColumnName("meh_count");
+
+ b.Property("MissCount")
+ .HasColumnType("bigint")
+ .HasColumnName("miss_count");
+
+ b.Property("Mods")
+ .IsRequired()
+ .HasColumnType("char(2)[]")
+ .HasColumnName("mods");
+
+ b.Property("OkCount")
+ .HasColumnType("bigint")
+ .HasColumnName("ok_count");
+
+ b.Property("OnlineId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("online_id");
+
+ b.Property("PlayerId")
+ .HasColumnType("bigint")
+ .HasColumnName("player_id");
+
+ b.Property("StartTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_time");
+
+ b.Property("TotalScore")
+ .HasColumnType("bigint")
+ .HasColumnName("total_score");
+
+ b.HasKey("Id")
+ .HasName("pk_beatmap_competition_score");
+
+ b.HasIndex("OnlineId")
+ .IsUnique()
+ .HasDatabaseName("ix_beatmap_competition_score_online_id");
+
+ b.HasIndex("PlayerId")
+ .HasDatabaseName("ix_beatmap_competition_score_player_id");
+
+ b.HasIndex("GuildId", "StartTime")
+ .HasDatabaseName("ix_beatmap_competition_score_guild_id_start_time");
+
+ b.ToTable("beatmap_competition_score", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("id");
+
+ b.Property("GuildId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("guild_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId")
+ .HasDatabaseName("ix_channel_guild_id");
+
+ b.ToTable("channel");
+
+ b.UseTptMappingStrategy();
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbDiscordMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("author_id");
+
+ b.Property("ChannelId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("channel_id");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("IsHidden")
+ .HasColumnType("boolean")
+ .HasColumnName("is_hidden");
+
+ b.Property("ReferenceMessageId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("reference_message_id");
+
+ b.HasKey("Id")
+ .HasName("pk_discord_message");
+
+ b.HasIndex("AuthorId")
+ .HasDatabaseName("ix_discord_message_author_id");
+
+ b.HasIndex("ChannelId")
+ .HasDatabaseName("ix_discord_message_channel_id");
+
+ b.HasIndex("ReferenceMessageId")
+ .HasDatabaseName("ix_discord_message_reference_message_id");
+
+ b.ToTable("discord_message", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbDiscordMessageVersion", b =>
+ {
+ b.Property("MessageId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("message_id");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("timestamp");
+
+ b.Property("CleanContent")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)")
+ .HasColumnName("clean_content")
+ .HasComment("If NULL, the clean content is the same as the raw content.");
+
+ b.Property("RawContent")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)")
+ .HasColumnName("raw_content");
+
+ b.HasKey("MessageId", "Timestamp")
+ .HasName("pk_discord_message_version");
+
+ b.ToTable("discord_message_version", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbDiscordUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("id");
+
+ b.Property("TimeZoneId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("time_zone_id");
+
+ b.HasKey("Id")
+ .HasName("pk_discord_user");
+
+ b.ToTable("discord_user", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbGroupRoleMapping", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("guild_id");
+
+ b.Property("RoleId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("role_id");
+
+ b.Property("Group")
+ .HasColumnType("smallint")
+ .HasColumnName("group_id");
+
+ b.HasKey("GuildId", "RoleId", "Group")
+ .HasName("pk_group_role_mapping");
+
+ b.ToTable("group_role_mapping", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbGuild", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("id");
+
+ b.Property("GreetOnAdded")
+ .HasColumnType("boolean")
+ .HasColumnName("greet_on_added");
+
+ b.Property("MapfeedChannelId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("mapfeed_channel_id");
+
+ b.Property("TrackMessages")
+ .HasColumnType("boolean")
+ .HasColumnName("track_messages");
+
+ b.Property("UnverifiedRoleId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("unverified_role_id");
+
+ b.HasKey("Id")
+ .HasName("pk_guild");
+
+ b.HasIndex("MapfeedChannelId")
+ .IsUnique()
+ .HasDatabaseName("ix_guild_mapfeed_channel_id");
+
+ b.HasIndex("UnverifiedRoleId")
+ .IsUnique()
+ .HasDatabaseName("ix_guild_unverified_role_id");
+
+ b.ToTable("guild", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbGuildStatsEntry", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("guild_id");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("timestamp");
+
+ b.Property("MemberCount")
+ .HasColumnType("integer")
+ .HasColumnName("member_count");
+
+ b.HasKey("GuildId", "Timestamp")
+ .HasName("pk_guild_stats_entry");
+
+ b.HasIndex("GuildId")
+ .HasDatabaseName("ix_guild_stats_entry_guild_id");
+
+ b.ToTable("guild_stats_entry", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbJoinMessage", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("guild_id");
+
+ b.Property("ChannelId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("channel_id");
+
+ b.Property("Description")
+ .HasMaxLength(4096)
+ .HasColumnType("character varying(4096)")
+ .HasColumnName("description");
+
+ b.Property("Title")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("title");
+
+ b.HasKey("GuildId")
+ .HasName("pk_join_message");
+
+ b.HasIndex("ChannelId")
+ .HasDatabaseName("ix_join_message_channel_id");
+
+ b.ToTable("join_message", null, t =>
+ {
+ t.HasCheckConstraint("CK_JoinMessage_HasText", "\"Title\" IS NOT NULL OR \"Description\" IS NOT NULL");
+ });
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbLocalBeatmap", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("md5_hash");
+
+ b.Property("MaxCombo")
+ .HasColumnType("bigint")
+ .HasColumnName("max_combo");
+
+ b.Property("OnlineBeatmapId")
+ .HasColumnType("bigint")
+ .HasColumnName("online_beatmap_id");
+
+ b.Property("OsuText")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("osu_text");
+
+ b.Property("OszHash")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("osz_hash");
+
+ b.HasKey("Id")
+ .HasName("pk_local_beatmap");
+
+ b.HasIndex("OnlineBeatmapId")
+ .HasDatabaseName("ix_local_beatmap_online_beatmap_id");
+
+ b.ToTable("local_beatmap", null, t =>
+ {
+ t.HasCheckConstraint("CK_LocalBeatmap_ValidSha256", "length(\"OszHash\") = 256 / 8");
+ });
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOnlineBeatmap", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Id"));
+
+ b.Property("CreatorId")
+ .HasColumnType("bigint")
+ .HasColumnName("creator_id");
+
+ b.Property("OnlineBeatmapsetId")
+ .HasColumnType("bigint")
+ .HasColumnName("online_beatmapset_id");
+
+ b.HasKey("Id")
+ .HasName("pk_online_beatmap");
+
+ b.HasIndex("CreatorId")
+ .HasDatabaseName("ix_online_beatmap_creator_id");
+
+ b.HasIndex("OnlineBeatmapsetId")
+ .HasDatabaseName("ix_online_beatmap_online_beatmapset_id");
+
+ b.ToTable("online_beatmap", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOnlineBeatmapset", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Id"));
+
+ b.Property("CreatorId")
+ .HasColumnType("bigint")
+ .HasColumnName("creator_id");
+
+ b.HasKey("Id")
+ .HasName("pk_online_beatmapset");
+
+ b.HasIndex("CreatorId")
+ .HasDatabaseName("ix_online_beatmapset_creator_id");
+
+ b.ToTable("online_beatmapset", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOsuUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Id"));
+
+ b.Property("DiscordUserId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("discord_user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_osu_user");
+
+ b.HasIndex("DiscordUserId")
+ .IsUnique()
+ .HasDatabaseName("ix_osu_user_discord_user_id");
+
+ b.ToTable("osu_user", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbReminder", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Id"));
+
+ b.Property("ChannelId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("channel_id");
+
+ b.Property("Message")
+ .HasMaxLength(6000)
+ .HasColumnType("character varying(6000)")
+ .HasColumnName("message");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("timestamp");
+
+ b.Property("UserId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_reminder");
+
+ b.HasIndex("ChannelId")
+ .HasDatabaseName("ix_reminder_channel_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_reminder_user_id");
+
+ b.ToTable("reminder", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbReplay", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("md5_hash");
+
+ b.Property("Data")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("data");
+
+ b.HasKey("Id")
+ .HasName("pk_replay");
+
+ b.ToTable("replay", (string)null);
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbForumChannel", b =>
+ {
+ b.HasBaseType("Numerous.Database.Entities.DbChannel");
+
+ b.ToTable("forum_channel");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbMessageChannel", b =>
+ {
+ b.HasBaseType("Numerous.Database.Entities.DbChannel");
+
+ b.Property("IsReadOnly")
+ .HasColumnType("boolean")
+ .HasColumnName("is_read_only");
+
+ b.ToTable("message_channel");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbAutoPingMapping", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbForumChannel", "Channel")
+ .WithMany("AutoPingMappings")
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_auto_ping_mapping_forum_channel_channel_id");
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbBeatmapCompetition", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbGuild", "Guild")
+ .WithMany("BeatmapCompetitions")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_beatmap_competition_guild_guild_id");
+
+ b.HasOne("Numerous.Database.Entities.DbLocalBeatmap", "LocalBeatmap")
+ .WithMany("BeatmapCompetitions")
+ .HasForeignKey("LocalBeatmapId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_beatmap_competition_local_beatmap_local_beatmap_id");
+
+ b.Navigation("Guild");
+
+ b.Navigation("LocalBeatmap");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbBeatmapCompetitionScore", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbOsuUser", "Player")
+ .WithMany()
+ .HasForeignKey("PlayerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_beatmap_competition_score_osu_user_player_id");
+
+ b.HasOne("Numerous.Database.Entities.DbBeatmapCompetition", "Competition")
+ .WithMany("Scores")
+ .HasForeignKey("GuildId", "StartTime")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_beatmap_competition_score_beatmap_competition_guild_id_star");
+
+ b.Navigation("Competition");
+
+ b.Navigation("Player");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbChannel", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbGuild", "Guild")
+ .WithMany("Channels")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_channel_guild_guild_id");
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbDiscordMessage", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbDiscordUser", "Author")
+ .WithMany("Messages")
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_discord_message_discord_user_author_id");
+
+ b.HasOne("Numerous.Database.Entities.DbMessageChannel", "Channel")
+ .WithMany("Messages")
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_discord_message_message_channel_channel_id");
+
+ b.HasOne("Numerous.Database.Entities.DbDiscordMessage", "ReferenceMessage")
+ .WithMany("Replies")
+ .HasForeignKey("ReferenceMessageId")
+ .HasConstraintName("fk_discord_message_discord_message_reference_message_id");
+
+ b.Navigation("Author");
+
+ b.Navigation("Channel");
+
+ b.Navigation("ReferenceMessage");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbDiscordMessageVersion", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbDiscordMessage", "Message")
+ .WithMany("Versions")
+ .HasForeignKey("MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_discord_message_version_discord_message_message_id");
+
+ b.Navigation("Message");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbGroupRoleMapping", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbGuild", "Guild")
+ .WithMany("GroupRoleMappings")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_group_role_mapping_guild_guild_id");
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbGuild", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbChannel", "MapfeedChannel")
+ .WithOne()
+ .HasForeignKey("Numerous.Database.Entities.DbGuild", "MapfeedChannelId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_guild_channel_mapfeed_channel_id");
+
+ b.Navigation("MapfeedChannel");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbGuildStatsEntry", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbGuild", "Guild")
+ .WithMany("Stats")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guild_stats_entry_guild_guild_id");
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbJoinMessage", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbMessageChannel", "Channel")
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_join_message_message_channel_channel_id");
+
+ b.HasOne("Numerous.Database.Entities.DbGuild", "Guild")
+ .WithOne("JoinMessage")
+ .HasForeignKey("Numerous.Database.Entities.DbJoinMessage", "GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_join_message_guild_guild_id");
+
+ b.Navigation("Channel");
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbLocalBeatmap", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbOnlineBeatmap", "OnlineBeatmap")
+ .WithMany("LocalBeatmaps")
+ .HasForeignKey("OnlineBeatmapId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_local_beatmap_online_beatmap_online_beatmap_id");
+
+ b.Navigation("OnlineBeatmap");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOnlineBeatmap", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbOsuUser", "Creator")
+ .WithMany("OnlineBeatmaps")
+ .HasForeignKey("CreatorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_online_beatmap_osu_user_creator_id");
+
+ b.HasOne("Numerous.Database.Entities.DbOnlineBeatmapset", "OnlineBeatmapset")
+ .WithMany("Beatmaps")
+ .HasForeignKey("OnlineBeatmapsetId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_online_beatmap_online_beatmapset_online_beatmapset_id");
+
+ b.Navigation("Creator");
+
+ b.Navigation("OnlineBeatmapset");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOnlineBeatmapset", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbOsuUser", "Creator")
+ .WithMany("OnlineBeatmapsets")
+ .HasForeignKey("CreatorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_online_beatmapset_osu_user_creator_id");
+
+ b.Navigation("Creator");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOsuUser", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbDiscordUser", "DiscordUser")
+ .WithOne("OsuUser")
+ .HasForeignKey("Numerous.Database.Entities.DbOsuUser", "DiscordUserId")
+ .HasConstraintName("fk_osu_user_discord_user_discord_user_id");
+
+ b.Navigation("DiscordUser");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbReminder", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbMessageChannel", "Channel")
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .HasConstraintName("fk_reminder_message_channel_channel_id");
+
+ b.HasOne("Numerous.Database.Entities.DbDiscordUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_reminder_discord_user_user_id");
+
+ b.Navigation("Channel");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbReplay", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbBeatmapCompetitionScore", "Score")
+ .WithOne("Replay")
+ .HasForeignKey("Numerous.Database.Entities.DbReplay", "Id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_replay_beatmap_competition_score_md5_hash");
+
+ b.Navigation("Score");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbForumChannel", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbChannel", null)
+ .WithOne()
+ .HasForeignKey("Numerous.Database.Entities.DbForumChannel", "Id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forum_channel_channel_id");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbMessageChannel", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbChannel", null)
+ .WithOne()
+ .HasForeignKey("Numerous.Database.Entities.DbMessageChannel", "Id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_message_channel_channel_id");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbBeatmapCompetition", b =>
+ {
+ b.Navigation("Scores");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbBeatmapCompetitionScore", b =>
+ {
+ b.Navigation("Replay");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbDiscordMessage", b =>
+ {
+ b.Navigation("Replies");
+
+ b.Navigation("Versions");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbDiscordUser", b =>
+ {
+ b.Navigation("Messages");
+
+ b.Navigation("OsuUser")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbGuild", b =>
+ {
+ b.Navigation("BeatmapCompetitions");
+
+ b.Navigation("Channels");
+
+ b.Navigation("GroupRoleMappings");
+
+ b.Navigation("JoinMessage");
+
+ b.Navigation("Stats");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbLocalBeatmap", b =>
+ {
+ b.Navigation("BeatmapCompetitions");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOnlineBeatmap", b =>
+ {
+ b.Navigation("LocalBeatmaps");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOnlineBeatmapset", b =>
+ {
+ b.Navigation("Beatmaps");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbOsuUser", b =>
+ {
+ b.Navigation("OnlineBeatmaps");
+
+ b.Navigation("OnlineBeatmapsets");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbForumChannel", b =>
+ {
+ b.Navigation("AutoPingMappings");
+ });
+
+ modelBuilder.Entity("Numerous.Database.Entities.DbMessageChannel", b =>
+ {
+ b.Navigation("Messages");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Numerous.Database/Migrations/20241206220252_AddGuildStats.cs b/src/Numerous.Database/Migrations/20241206220252_AddGuildStats.cs
new file mode 100644
index 0000000..d51ebc8
--- /dev/null
+++ b/src/Numerous.Database/Migrations/20241206220252_AddGuildStats.cs
@@ -0,0 +1,46 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Numerous.Database.Migrations
+{
+ ///
+ public partial class AddGuildStats : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "guild_stats_entry",
+ columns: table => new
+ {
+ guild_id = table.Column(type: "numeric(20,0)", nullable: false),
+ timestamp = table.Column(type: "timestamp with time zone", nullable: false),
+ member_count = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_guild_stats_entry", x => new { x.guild_id, x.timestamp });
+ table.ForeignKey(
+ name: "fk_guild_stats_entry_guild_guild_id",
+ column: x => x.guild_id,
+ principalTable: "guild",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_guild_stats_entry_guild_id",
+ table: "guild_stats_entry",
+ column: "guild_id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "guild_stats_entry");
+ }
+ }
+}
diff --git a/src/Numerous.Database/Migrations/NumerousDbContextModelSnapshot.cs b/src/Numerous.Database/Migrations/NumerousDbContextModelSnapshot.cs
index f1e4263..699af26 100644
--- a/src/Numerous.Database/Migrations/NumerousDbContextModelSnapshot.cs
+++ b/src/Numerous.Database/Migrations/NumerousDbContextModelSnapshot.cs
@@ -325,6 +325,29 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("guild", (string)null);
});
+ modelBuilder.Entity("Numerous.Database.Entities.DbGuildStatsEntry", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("numeric(20,0)")
+ .HasColumnName("guild_id");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("timestamp");
+
+ b.Property("MemberCount")
+ .HasColumnType("integer")
+ .HasColumnName("member_count");
+
+ b.HasKey("GuildId", "Timestamp")
+ .HasName("pk_guild_stats_entry");
+
+ b.HasIndex("GuildId")
+ .HasDatabaseName("ix_guild_stats_entry_guild_id");
+
+ b.ToTable("guild_stats_entry", (string)null);
+ });
+
modelBuilder.Entity("Numerous.Database.Entities.DbJoinMessage", b =>
{
b.Property("GuildId")
@@ -670,6 +693,18 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("MapfeedChannel");
});
+ modelBuilder.Entity("Numerous.Database.Entities.DbGuildStatsEntry", b =>
+ {
+ b.HasOne("Numerous.Database.Entities.DbGuild", "Guild")
+ .WithMany("Stats")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guild_stats_entry_guild_guild_id");
+
+ b.Navigation("Guild");
+ });
+
modelBuilder.Entity("Numerous.Database.Entities.DbJoinMessage", b =>
{
b.HasOne("Numerous.Database.Entities.DbMessageChannel", "Channel")
@@ -831,6 +866,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("GroupRoleMappings");
b.Navigation("JoinMessage");
+
+ b.Navigation("Stats");
});
modelBuilder.Entity("Numerous.Database.Entities.DbLocalBeatmap", b =>
diff --git a/src/Numerous.Database/Repositories/GuildStatsEntryRepository.cs b/src/Numerous.Database/Repositories/GuildStatsEntryRepository.cs
new file mode 100644
index 0000000..ea089b6
--- /dev/null
+++ b/src/Numerous.Database/Repositories/GuildStatsEntryRepository.cs
@@ -0,0 +1,27 @@
+// Copyright (C) Pasi4K5
+// This program is free software: you can redistribute it and/or modify it under the terms of the GNU 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 General Public License for more details.
+// You should have received a copy of the GNU General Public License along with this program. If not, see .
+
+using AutoMapper;
+using Microsoft.EntityFrameworkCore;
+using Numerous.Database.Context;
+using Numerous.Database.Dtos;
+using Numerous.Database.Entities;
+
+namespace Numerous.Database.Repositories;
+
+public interface IGuildStatsEntryRepository : IRepository
+{
+ public Task> GetGuildStatsAsync(ulong guildId, CancellationToken ct = default);
+}
+
+public sealed class GuildStatsEntryRepository(NumerousDbContext context, IMapper mapper)
+ : Repository(context, mapper), IGuildStatsEntryRepository
+{
+ public async Task> GetGuildStatsAsync(ulong guildId, CancellationToken ct = default)
+ {
+ return await Set.Where(x => x.GuildId == guildId)
+ .ToDictionaryAsync(x => x.Timestamp, x => x.MemberCount, ct);
+ }
+}
diff --git a/src/Numerous.Tests/AttachmentServiceTests.cs b/src/Numerous.Tests/AttachmentServiceTests.cs
index 323a355..809ffd5 100644
--- a/src/Numerous.Tests/AttachmentServiceTests.cs
+++ b/src/Numerous.Tests/AttachmentServiceTests.cs
@@ -1,5 +1,5 @@
using Discord;
-using Numerous.Bot.Discord;
+using Numerous.Bot.Discord.Services.Attachments;
using Numerous.Bot.Services;
using Numerous.Common.Config;
using Numerous.Tests.Stubs;
diff --git a/src/Numerous.Web/Controllers/AuthController.cs b/src/Numerous.Web/Controllers/AuthController.cs
index 323a6d6..3895f8e 100644
--- a/src/Numerous.Web/Controllers/AuthController.cs
+++ b/src/Numerous.Web/Controllers/AuthController.cs
@@ -12,7 +12,7 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
-using Numerous.Bot.Discord;
+using Numerous.Bot.Discord.Services;
using Numerous.Common.Config;
using Numerous.Web.Auth;