diff --git a/src/EllieBot.Generators/EllieBot.Generators.csproj b/src/EllieBot.Generators/EllieBot.Generators.csproj index 1dbbc1d..4e94449 100644 --- a/src/EllieBot.Generators/EllieBot.Generators.csproj +++ b/src/EllieBot.Generators/EllieBot.Generators.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/EllieBot.Generators/GrpcApiPermGenerator.cs b/src/EllieBot.Generators/GrpcApiPermGenerator.cs new file mode 100644 index 0000000..545fa3a --- /dev/null +++ b/src/EllieBot.Generators/GrpcApiPermGenerator.cs @@ -0,0 +1,148 @@ +#nullable enable +using System.CodeDom.Compiler; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Newtonsoft.Json; + +namespace EllieBot.Generators +{ + public readonly record struct MethodPermData + { + public readonly string Name; + public readonly string Value; + + public MethodPermData(string name, string value) + { + Name = name; + Value = value; + } + } + + + [Generator] + public class GrpcApiPermGenerator : IIncrementalGenerator + { + public const string Attribute = + """ + namespace EllieBot.GrpcApi; + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class GrpcApiPermAttribute : System.Attribute + { + public GuildPerm Value { get; } + public GrpcApiPermAttribute(GuildPerm value) => Value = value; + } + """; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(ctx => ctx.AddSource("GrpcApiPermAttribute.cs", + SourceText.From(Attribute, Encoding.UTF8))); + + var enumsToGenerate = context.SyntaxProvider + .ForAttributeWithMetadataName( + "EllieBot.GrpcApi.GrpcApiPermAttribute", + predicate: static (s, _) => s is MethodDeclarationSyntax, + transform: static (ctx, _) => GetMethodSemanticTargets(ctx.SemanticModel, ctx.TargetNode)) + .Where(static m => m is not null) + .Select(static (x, _) => x.Value) + .Collect(); + + context.RegisterSourceOutput(enumsToGenerate, + static (spc, source) => Execute(source, spc)); + } + + private static MethodPermData? GetMethodSemanticTargets(SemanticModel model, SyntaxNode node) + { + var method = (MethodDeclarationSyntax)node; + + var name = method.Identifier.Text; + var attr = method.AttributeLists + .SelectMany(x => x.Attributes) + .FirstOrDefault(); + // .FirstOrDefault(x => x.Name.ToString() == "GrpcApiPermAttribute"); + + + if (attr is null) + return null; + + // if (model.GetSymbolInfo(attr).Symbol is not IMethodSymbol attrSymbol) + // return null; + + return new MethodPermData(name, attr.ArgumentList.Arguments[0].ToString()); + // return new MethodPermData(name, attrSymbol.Parameters[0].ContainingType.ToDisplayString() + "." + attrSymbol.Parameters[0].Name); + } + + private static void Execute(ImmutableArray fields, SourceProductionContext ctx) + { + using (var stringWriter = new StringWriter()) + using (var sw = new IndentedTextWriter(stringWriter)) + { + sw.WriteLine("using System.Collections.Frozen;"); + sw.WriteLine(); + sw.WriteLine("namespace EllieBot.GrpcApi;"); + sw.WriteLine(); + + sw.WriteLine("public partial class PermsInterceptor"); + sw.WriteLine("{"); + + sw.Indent++; + + sw.WriteLine("public static FrozenDictionary perms = new Dictionary()"); + sw.WriteLine("{"); + + sw.Indent++; + foreach (var field in fields) + { + sw.WriteLine("{{ \"{0}\", {1} }},", field!.Name, field!.Value); + } + + sw.Indent--; + sw.WriteLine("}.ToFrozenDictionary();"); + + sw.Indent--; + sw.WriteLine("}"); + + sw.Flush(); + ctx.AddSource("GrpcApiInterceptor.g.cs", stringWriter.ToString()); + } + } + + private List GetFields(string? dataText) + { + if (string.IsNullOrWhiteSpace(dataText)) + return new(); + + Dictionary data; + try + { + var output = JsonConvert.DeserializeObject>(dataText!); + if (output is null) + return new(); + + data = output; + } + catch + { + Debug.WriteLine("Failed parsing responses file."); + return new(); + } + + var list = new List(); + foreach (var entry in data) + { + list.Add(new( + entry.Key, + entry.Value + )); + } + + return list; + } + } +} \ No newline at end of file diff --git a/src/EllieBot.GrpcApiBase/protos/other.proto b/src/EllieBot.GrpcApiBase/protos/other.proto index 3a7aecf..9a5f9f8 100644 --- a/src/EllieBot.GrpcApiBase/protos/other.proto +++ b/src/EllieBot.GrpcApiBase/protos/other.proto @@ -8,13 +8,26 @@ import "google/protobuf/timestamp.proto"; package other; service GrpcOther { + + rpc GetGuilds(google.protobuf.Empty) returns (GetGuildsReply); rpc GetTextChannels(GetTextChannelsRequest) returns (GetTextChannelsReply); - + rpc GetCurrencyLb(GetLbRequest) returns (CurrencyLbReply); rpc GetXpLb(GetLbRequest) returns (XpLbReply); rpc GetWaifuLb(GetLbRequest) returns (WaifuLbReply); - + rpc GetShardStatuses(google.protobuf.Empty) returns (GetShardStatusesReply); + rpc GetServerInfo(ServerInfoRequest) returns (GetServerInfoReply); +} + +message GetGuildsReply { + repeated GuildReply guilds = 1; +} + +message GuildReply { + uint64 id = 1; + string name = 2; + string iconUrl = 3; } message GetShardStatusesReply { @@ -24,7 +37,7 @@ message GetShardStatusesReply { message ShardStatusReply { int32 id = 1; string status = 2; - + int32 guildCount = 3; google.protobuf.Timestamp lastUpdate = 4; } @@ -79,3 +92,46 @@ message WaifuLbEntry { int64 value = 3; bool isMutual = 4; } + +message ServerInfoRequest { + uint64 guildId = 1; +} + +message GetServerInfoReply { + uint64 id = 1; + string name = 2; + string iconUrl = 3; + uint64 ownerId = 4; + string ownerName = 5; + repeated RoleReply roles = 6; + repeated EmojiReply emojis = 7; + repeated string features = 8; + int32 textChannels = 9; + int32 voiceChannels = 10; + int32 memberCount = 11; + int64 createdAt = 12; +} + +message RoleReply { + uint64 id = 1; + string name = 2; + string iconUrl = 3; + string color = 4; +} + +message EmojiReply { + string name = 1; + string url = 2; + string code = 3; +} + +message ChannelReply { + uint64 id = 1; + string name = 2; + ChannelType type = 3; +} + +enum ChannelType { + Text = 0; + Voice = 1; +} diff --git a/src/EllieBot/Modules/Gambling/Gambling.cs b/src/EllieBot/Modules/Gambling/Gambling.cs index 64fedf5..427274f 100644 --- a/src/EllieBot/Modules/Gambling/Gambling.cs +++ b/src/EllieBot/Modules/Gambling/Gambling.cs @@ -1,6 +1,7 @@ #nullable disable using LinqToDB; using LinqToDB.EntityFrameworkCore; +using LinqToDB.Tools; using EllieBot.Db.Models; using EllieBot.Modules.Gambling.Bank; using EllieBot.Modules.Gambling.Common; @@ -625,8 +626,6 @@ public partial class Gambling : GamblingModule var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args); - // List cleanRichest; - // it's pointless to have clean on dm context if (ctx.Guild is null) { opts.Clean = false; @@ -640,10 +639,16 @@ public partial class Gambling : GamblingModule await ctx.Channel.TriggerTypingAsync(); await _tracker.EnsureUsersDownloadedAsync(ctx.Guild); - await using var uow = _db.GetDbContext(); + var users = ((SocketGuild)ctx.Guild).Users.Map(x => x.Id); + var perPage = 9; - var cleanRichest = await uow.Set() - .GetTopRichest(_client.CurrentUser.Id, 0, 1000); + await using var uow = _db.GetDbContext(); + var cleanRichest = await uow.GetTable() + .Where(x => x.UserId.In(users)) + .OrderByDescending(x => x.CurrencyAmount) + .Skip(page * perPage) + .Take(perPage) + .ToListAsync(); var sg = (SocketGuild)ctx.Guild!; return cleanRichest.Where(x => sg.GetUser(x.UserId) is not null).ToList(); @@ -661,7 +666,6 @@ public partial class Gambling : GamblingModule await Response() .Paginated() .PageItems(GetTopRichest) - .TotalElements(900) .PageSize(9) .CurrentPage(page) .Page((toSend, curPage) => diff --git a/src/EllieBot/Services/GrpcApi/ExprsSvc.cs b/src/EllieBot/Services/GrpcApi/ExprsSvc.cs index 57c49d9..970c3c3 100644 --- a/src/EllieBot/Services/GrpcApi/ExprsSvc.cs +++ b/src/EllieBot/Services/GrpcApi/ExprsSvc.cs @@ -14,6 +14,7 @@ public class ExprsSvc : GrpcExprs.GrpcExprsBase, IEService _svc = svc; } + [GrpcApiPerm(GuildPerm.Administrator)] public override async Task AddExpr(AddExprRequest request, ServerCallContext context) { EllieExpression expr; @@ -44,6 +45,7 @@ public class ExprsSvc : GrpcExprs.GrpcExprsBase, IEService }; } + [GrpcApiPerm(GuildPerm.Administrator)] public override async Task GetExprs(GetExprsRequest request, ServerCallContext context) { var (exprs, totalCount) = await _svc.FindExpressionsAsync(request.GuildId, request.Query, request.Page); @@ -64,6 +66,7 @@ public class ExprsSvc : GrpcExprs.GrpcExprsBase, IEService return reply; } + [GrpcApiPerm(GuildPerm.Administrator)] public override async Task DeleteExpr(DeleteExprRequest request, ServerCallContext context) { await _svc.DeleteAsync(request.GuildId, new kwum(request.Id)); diff --git a/src/EllieBot/Services/GrpcApi/GreetByeSvc.cs b/src/EllieBot/Services/GrpcApi/GreetByeSvc.cs index c0fec41..a617eea 100644 --- a/src/EllieBot/Services/GrpcApi/GreetByeSvc.cs +++ b/src/EllieBot/Services/GrpcApi/GreetByeSvc.cs @@ -30,10 +30,11 @@ public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, IEService Message = conf.MessageText, Type = (GrpcGreetType)conf.GreetType, ChannelId = conf.ChannelId ?? 0, - IsEnabled = conf.IsEnabled + IsEnabled = conf.IsEnabled, }; } + [GrpcApiPerm(GuildPerm.Administrator)] public override async Task GetGreetSettings(GetGreetRequest request, ServerCallContext context) { var guildId = request.GuildId; @@ -53,6 +54,7 @@ public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, IEService }; } + [GrpcApiPerm(GuildPerm.Administrator)] public override async Task UpdateGreet(UpdateGreetRequest request, ServerCallContext context) { var gid = request.GuildId; @@ -68,6 +70,7 @@ public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, IEService }; } + [GrpcApiPerm(GuildPerm.Administrator)] public override Task TestGreet(TestGreetRequest request, ServerCallContext context) => TestGreet(request.GuildId, request.ChannelId, request.UserId, request.Type); diff --git a/src/EllieBot/Services/GrpcApi/OtherSvc.cs b/src/EllieBot/Services/GrpcApi/OtherSvc.cs index f720803..12143ca 100644 --- a/src/EllieBot/Services/GrpcApi/OtherSvc.cs +++ b/src/EllieBot/Services/GrpcApi/OtherSvc.cs @@ -5,6 +5,12 @@ using EllieBot.Modules.Xp.Services; namespace EllieBot.GrpcApi; +public static class GrpcApiExtensions +{ + public static ulong GetUserId(this ServerCallContext context) + => ulong.Parse(context.RequestHeaders.FirstOrDefault(x => x.Key == "userid")!.Value); +} + public sealed class OtherSvc : GrpcOther.GrpcOtherBase, IEService { private readonly IDiscordClient _client; @@ -12,22 +18,54 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, IEService private readonly ICurrencyService _cur; private readonly WaifuService _waifus; private readonly ICoordinator _coord; + private readonly IStatsService _stats; public OtherSvc( DiscordSocketClient client, XpService xp, ICurrencyService cur, WaifuService waifus, - ICoordinator coord) + ICoordinator coord, + IStatsService stats) { _client = client; _xp = xp; _cur = cur; _waifus = waifus; _coord = coord; + _stats = stats; } - public override async Task GetTextChannels(GetTextChannelsRequest request, ServerCallContext context) + public override async Task GetGuilds(Empty request, ServerCallContext context) + { + var guilds = await _client.GetGuildsAsync(CacheMode.CacheOnly); + + var reply = new GetGuildsReply(); + var userId = context.GetUserId(); + + var toReturn = new List(); + foreach (var g in guilds) + { + var user = await g.GetUserAsync(userId, CacheMode.AllowDownload); + if (user.GuildPermissions.Has(GuildPermission.Administrator)) + toReturn.Add(g); + } + + reply.Guilds.AddRange(toReturn + .Select(x => new GuildReply() + { + Id = x.Id, + Name = x.Name, + IconUrl = x.IconUrl + })); + + return reply; + } + + [GrpcApiPerm(GuildPerm.Administrator)] + public override async Task GetTextChannels( + GetTextChannelsRequest request, + ServerCallContext context) { var g = await _client.GetGuildAsync(request.GuildId); var reply = new GetTextChannelsReply(); @@ -54,9 +92,9 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, IEService return new CurrencyLbEntryReply() { Amount = x.CurrencyAmount, - User = user.ToString(), + User = user?.ToString() ?? x.Username, UserId = x.UserId, - Avatar = user.RealAvatarUrl().ToString() + Avatar = user?.RealAvatarUrl().ToString() ?? x.RealAvatarUrl()?.ToString() }; }); @@ -96,7 +134,7 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, IEService var reply = new WaifuLbReply(); reply.Entries.AddRange(waifus.Select(x => new WaifuLbEntry() { - ClaimedBy = x.Claimer, + ClaimedBy = x.Claimer ?? string.Empty, IsMutual = x.Claimer == x.Affinity, Value = x.Price, User = x.Username, @@ -108,6 +146,7 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, IEService { var reply = new GetShardStatusesReply(); + // todo cache var shards = _coord.GetAllShardStatuses(); reply.Shards.AddRange(shards.Select(x => new ShardStatusReply() @@ -120,4 +159,41 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, IEService return Task.FromResult(reply); } -} + + [GrpcApiPerm(GuildPerm.Administrator)] + public override async Task GetServerInfo(ServerInfoRequest request, ServerCallContext context) + { + var info = await _stats.GetGuildInfoAsync(request.GuildId); + + var reply = new GetServerInfoReply() + { + Id = info.Id, + Name = info.Name, + IconUrl = info.IconUrl, + OwnerId = info.OwnerId, + OwnerName = info.Owner, + TextChannels = info.TextChannels, + VoiceChannels = info.VoiceChannels, + MemberCount = info.MemberCount, + CreatedAt = info.CreatedAt.Ticks, + }; + + reply.Features.AddRange(info.Features); + reply.Emojis.AddRange(info.Emojis.Select(x => new EmojiReply() + { + Name = x.Name, + Url = x.Url, + Code = x.ToString() + })); + + reply.Roles.AddRange(info.Roles.Select(x => new RoleReply() + { + Id = x.Id, + Name = x.Name, + IconUrl = x.GetIconUrl() ?? string.Empty, + Color = x.Color.ToString() + })); + + return reply; + } +} \ No newline at end of file diff --git a/src/EllieBot/Services/GrpcApiService.cs b/src/EllieBot/Services/GrpcApiService.cs index fe1f1e1..f16321a 100644 --- a/src/EllieBot/Services/GrpcApiService.cs +++ b/src/EllieBot/Services/GrpcApiService.cs @@ -1,5 +1,5 @@ -using Grpc; -using Grpc.Core; +using Grpc.Core; +using Grpc.Core.Interceptors; using EllieBot.Common.ModuleBehaviors; namespace EllieBot.GrpcApi; @@ -9,51 +9,61 @@ public class GrpcApiService : IEService, IReadyExecutor private Server? _app; private static readonly bool _isEnabled = true; - private readonly string _host = "localhost"; - private readonly int _port = 5030; - private readonly ServerCredentials _creds = ServerCredentials.Insecure; + private readonly DiscordSocketClient _client; private readonly OtherSvc _other; private readonly ExprsSvc _exprs; private readonly GreetByeSvc _greet; + private readonly IBotCredsProvider _creds; public GrpcApiService( + DiscordSocketClient client, OtherSvc other, ExprsSvc exprs, - GreetByeSvc greet) + GreetByeSvc greet, + IBotCredsProvider creds) { + _client = client; _other = other; _exprs = exprs; _greet = greet; + _creds = creds; } public async Task OnReadyAsync() { - if (!_isEnabled) + var creds = _creds.GetCreds(); + if (creds.GrpcApi is null || creds.GrpcApi.Enabled) return; try { - _app = new() + var host = creds.GrpcApi.Host; + var port = creds.GrpcApi.Port + _client.ShardId; + + var interceptor = new PermsInterceptor(_client); + + _app = new Server() { Services = { - GrpcOther.BindService(_other), - GrpcExprs.BindService(_exprs), - GrpcGreet.BindService(_greet) + GrpcOther.BindService(_other).Intercept(interceptor), + GrpcExprs.BindService(_exprs).Intercept(interceptor), + GrpcGreet.BindService(_greet).Intercept(interceptor), }, Ports = { - new(_host, _port, _creds), + new(host, port, ServerCredentials.Insecure), } }; + _app.Start(); + + Log.Information("Grpc Api Server started on port {Host}:{Port}", host, port); } - finally + catch { _app?.ShutdownAsync().GetAwaiter().GetResult(); } - - Log.Information("Grpc Api Server started on port {Host}:{Port}", _host, _port); } } \ No newline at end of file diff --git a/src/EllieBot/Services/PermsInterceptor.cs b/src/EllieBot/Services/PermsInterceptor.cs new file mode 100644 index 0000000..c066ae7 --- /dev/null +++ b/src/EllieBot/Services/PermsInterceptor.cs @@ -0,0 +1,67 @@ +using Grpc.Core; +using Grpc.Core.Interceptors; + +namespace EllieBot.GrpcApi; + +public sealed partial class PermsInterceptor : Interceptor +{ + private readonly DiscordSocketClient _client; + + public PermsInterceptor(DiscordSocketClient client) + { + _client = client; + Log.Information("interceptor created"); + } + + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + try + { + Log.Information("Starting receiving call. Type/Method: {Type} / {Method}", + MethodType.Unary, + context.Method); + + // get metadata + var metadata = context + .RequestHeaders + .ToDictionary(x => x.Key, x => x.Value); + + + var method = context.Method[(context.Method.LastIndexOf('/') + 1)..]; + + if (perms.TryGetValue(method, out var perm)) + { + Log.Information("Required permission for {Method} is {Perm}", + method, + perm); + + var userId = ulong.Parse(metadata["userid"]); + var guildId = ulong.Parse(metadata["guildid"]); + + IGuild guild = _client.GetGuild(guildId); + var user = guild is null ? null : await guild.GetUserAsync(userId); + + if (user is null) + throw new RpcException(new Status(StatusCode.NotFound, "User not found")); + + if (!user.GuildPermissions.Has(perm)) + throw new RpcException(new Status(StatusCode.PermissionDenied, + $"You need {perm} permission to use this method")); + } + else + { + Log.Information("No permission required for {Method}", method); + } + + return await continuation(request, context); + } + catch (Exception ex) + { + Log.Error(ex, "Error thrown by {ContextMethod}", context.Method); + throw; + } + } +} diff --git a/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs b/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs index faeae4f..7f98972 100644 --- a/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs +++ b/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs @@ -29,6 +29,7 @@ public interface IBotCredentials string TwitchClientSecret { get; set; } GoogleApiConfig Google { get; set; } BotCacheImplemenation BotCache { get; set; } + Creds.GrpcApiConfig GrpcApi { get; set; } } public interface IVotesSettings diff --git a/src/EllieBot/_common/Creds.cs b/src/EllieBot/_common/Creds.cs index 30ad6b7..d9139c9 100644 --- a/src/EllieBot/_common/Creds.cs +++ b/src/EllieBot/_common/Creds.cs @@ -162,7 +162,7 @@ public sealed class Creds : IBotCredentials We don't provide support for this. If you leave certPath empty, the api will run on http. """)] - public ApiConfig Api { get; set; } + public GrpcApiConfig GrpcApi { get; set; } public Creds() { @@ -189,7 +189,7 @@ public sealed class Creds : IBotCredentials RestartCommand = new RestartConfig(); Google = new GoogleApiConfig(); - Api = new ApiConfig(); + GrpcApi = new GrpcApiConfig(); } public class DbOptions @@ -284,7 +284,7 @@ public sealed class Creds : IBotCredentials } } - public sealed record ApiConfig + public sealed record GrpcApiConfig { public bool Enabled { get; set; } = false; public string CertPath { get; set; } = string.Empty; diff --git a/src/EllieBot/_common/Impl/RemoteGrpcCoordinator.cs b/src/EllieBot/_common/Impl/RemoteGrpcCoordinator.cs index de56a39..1fb2867 100644 --- a/src/EllieBot/_common/Impl/RemoteGrpcCoordinator.cs +++ b/src/EllieBot/_common/Impl/RemoteGrpcCoordinator.cs @@ -90,8 +90,7 @@ public class RemoteGrpcCoordinator : ICoordinator, IReadyExecutor { if (!gracefulImminent) { - Log.Warning(ex, - "Hearbeat failed and graceful shutdown was not expected: {Message}", + Log.Warning(ex, "Hearbeat failed and graceful shutdown was not expected: {Message}", ex.Message); break; } diff --git a/src/EllieBot/_common/Services/IStatsService.cs b/src/EllieBot/_common/Services/IStatsService.cs index 3dee0a6..3f73495 100644 --- a/src/EllieBot/_common/Services/IStatsService.cs +++ b/src/EllieBot/_common/Services/IStatsService.cs @@ -49,8 +49,8 @@ public interface IStatsService /// double GetPrivateMemoryMegabytes(); - GuildInfo GetGuildInfo(string name); - GuildInfo GetGuildInfo(ulong id); + GuildInfo GetGuildInfoAsync(string name); + Task GetGuildInfoAsync(ulong id); } public record struct GuildInfo diff --git a/src/EllieBot/_common/Services/Impl/StatsService.cs b/src/EllieBot/_common/Services/Impl/StatsService.cs index 9387e51..363f8a5 100644 --- a/src/EllieBot/_common/Services/Impl/StatsService.cs +++ b/src/EllieBot/_common/Services/Impl/StatsService.cs @@ -180,19 +180,20 @@ public sealed class StatsService : IStatsService, IReadyExecutor, IEService return _currentProcess.PrivateMemorySize64 / 1.Megabytes(); } - public GuildInfo GetGuildInfo(string name) + public GuildInfo GetGuildInfoAsync(string name) => throw new NotImplementedException(); - public GuildInfo GetGuildInfo(ulong id) + public async Task GetGuildInfoAsync(ulong id) { var g = _client.GetGuild(id); + var ig = (IGuild)g; return new GuildInfo() { Id = g.Id, IconUrl = g.IconUrl, Name = g.Name, - Owner = g.Owner.Username, + Owner = (await ig.GetUserAsync(g.OwnerId))?.Username ?? "Unknown", OwnerId = g.OwnerId, CreatedAt = g.CreatedAt.UtcDateTime, VoiceChannels = g.VoiceChannels.Count,