diff --git a/src/Mewdeko/Services/IBotCredentials.cs b/src/Mewdeko/Services/IBotCredentials.cs index 5609b82dc..d24a27a00 100644 --- a/src/Mewdeko/Services/IBotCredentials.cs +++ b/src/Mewdeko/Services/IBotCredentials.cs @@ -32,11 +32,6 @@ public interface IBotCredentials /// ImmutableArray OwnerIds { get; } - /// - /// Gets the bot's Genius API key. - /// - string GeniusKey { get; } - /// /// Gets the bot's Statcord key. /// @@ -62,11 +57,6 @@ public interface IBotCredentials /// string OsuApiKey { get; } - /// - /// Gets the port on which the client coordinator is running. - /// - string ShardRunPort { get; } - /// /// Gets the path where chat logs are saved. /// @@ -87,11 +77,6 @@ public interface IBotCredentials /// string TrovoClientId { get; } - /// - /// Gets the bot's Cleverbot API key. - /// - string CleverbotApiKey { get; } - /// /// Gets the command used to restart the bot. /// @@ -107,11 +92,6 @@ public interface IBotCredentials /// string TwitchClientId { get; } - /// - /// Gets the Redis options. - /// - string RedisOptions { get; } - /// /// Gets the LocationIQ API key. /// @@ -142,11 +122,6 @@ public interface IBotCredentials /// string CsrfToken { get; } - /// - /// Lavalink Url - /// - string LavalinkUrl { get; } - /// /// Last.fm API key /// diff --git a/src/Mewdeko/Services/Impl/BotCredentials.cs b/src/Mewdeko/Services/Impl/BotCredentials.cs index c4d4165a1..fcbafeb80 100644 --- a/src/Mewdeko/Services/Impl/BotCredentials.cs +++ b/src/Mewdeko/Services/Impl/BotCredentials.cs @@ -7,10 +7,6 @@ using Serilog; using StackExchange.Redis; -// ReSharper disable UnusedMember.Local -// ReSharper disable UnusedAutoPropertyAccessor.Local -// ReSharper disable UnassignedGetOnlyAutoProperty - namespace Mewdeko.Services.Impl; /// @@ -23,18 +19,23 @@ public class BotCredentials : IBotCredentials /// /// Initializes a new instance of the class. /// - /// public BotCredentials() { try { - File.WriteAllText("./credentials_example.json", - JsonConvert.SerializeObject(new CredentialsModel(), Formatting.Indented)); + var exampleCredentialsPath = "./credentials_example.json"; + if (!File.Exists(exampleCredentialsPath)) + { + File.WriteAllText(exampleCredentialsPath, + JsonConvert.SerializeObject(new CredentialsModel(), Formatting.Indented)); + } } - catch + catch (Exception ex) { - // ignored + Log.Error("Failed to write the credentials example file."); + Log.Error(ex.Message); } + if (!File.Exists(credsFileName)) { Log.Information("credentials.json is missing. Which of the following do you want to do?"); @@ -46,44 +47,99 @@ public BotCredentials() switch (choice) { case "1": - Log.Information( - "Please enter your bot's token. You can get it from https://discord.com/developers/applications"); - var token = Console.ReadLine(); - Log.Information( - "Please enter your ID and and any other IDs seperated by a space to mark them as owners. You can get your ID by enabling developer mode in discord and right clicking your name"); - var owners = Console.ReadLine(); - var ownersList = string.IsNullOrWhiteSpace(owners) - ? [] - : owners.Split(' ').Select(ulong.Parse).ToList(); - Log.Information("Please input your PostgreSQL Connection String."); - var model = new CredentialsModel - { - Token = token, OwnerIds = ownersList - }; - File.WriteAllText(credsFileName, JsonConvert.SerializeObject(model, Formatting.Indented)); + CreateCredentialsFileInteractively(); break; case "2": + // No action needed as it will load from environment variables break; case "3": Environment.Exit(0); break; - default: throw new ArgumentOutOfRangeException(); + default: + Log.Error("Invalid choice. Please restart the program and select a valid option."); + Environment.Exit(0); + break; } } UpdateCredentials(null, null); - if (MigrateToPsql) return; - var watcher = new FileSystemWatcher(Directory.GetCurrentDirectory()); - watcher.NotifyFilter = NotifyFilters.LastWrite; - watcher.Filter = "*.json"; - watcher.EnableRaisingEvents = true; - watcher.Changed += UpdateCredentials; + + if (!MigrateToPsql) + { + var watcher = new FileSystemWatcher(Directory.GetCurrentDirectory()) + { + NotifyFilter = NotifyFilters.LastWrite, + Filter = "*.json", + EnableRaisingEvents = true + }; + watcher.Changed += UpdateCredentials; + } } - /// - /// Gets or sets the bot's Carbon key. - /// - public string CarbonKey { get; set; } + private void CreateCredentialsFileInteractively() + { + Log.Information( + "Please enter your bot's token. You can get it from https://discord.com/developers/applications"); + var token = Console.ReadLine(); + + while (string.IsNullOrWhiteSpace(token)) + { + Log.Error("Bot token cannot be empty. Please enter a valid token:"); + token = Console.ReadLine(); + } + + Log.Information( + "Please enter your ID and any other IDs separated by a space to mark them as owners. You can get your ID by enabling developer mode in Discord and right-clicking your name"); + var ownersInput = Console.ReadLine(); + var ownersList = new List(); + + if (!string.IsNullOrWhiteSpace(ownersInput)) + { + var ownerIds = ownersInput.Split(' ', StringSplitOptions.RemoveEmptyEntries); + foreach (var ownerId in ownerIds) + { + if (ulong.TryParse(ownerId, out var parsedId)) + { + ownersList.Add(parsedId); + } + else + { + Log.Warning($"'{ownerId}' is not a valid ID and will be ignored."); + } + } + } + + Log.Information("Please input your PostgreSQL Connection String."); + var psqlConnectionString = Console.ReadLine(); + + while (string.IsNullOrWhiteSpace(psqlConnectionString)) + { + Log.Error("PostgreSQL Connection String cannot be empty. Please enter a valid connection string:"); + psqlConnectionString = Console.ReadLine(); + } + + var model = new CredentialsModel + { + Token = token, + OwnerIds = ownersList, + PsqlConnectionString = psqlConnectionString + }; + + try + { + File.WriteAllText(credsFileName, JsonConvert.SerializeObject(model, Formatting.Indented)); + Log.Information("credentials.json has been created successfully."); + } + catch (Exception ex) + { + Log.Error("Failed to write credentials.json file."); + Log.Error(ex.Message); + Environment.Exit(1); + } + } + + // Properties (same as before) + /// /// Gets or sets the command used to run a shard. @@ -91,7 +147,7 @@ public BotCredentials() public string ShardRunCommand { get; set; } /// - /// Gets or sets the arguments used to run a shard. + /// Gets or sets the arguments used with the shard run command. /// public string ShardRunArguments { get; set; } @@ -99,41 +155,27 @@ public BotCredentials() /// Gets or sets the PostgreSQL connection string. /// public string PsqlConnectionString { get; set; } + /// - /// Gets or sets a value indicating whether the bot should use global currency. + /// Gets or sets a value indicating whether to use global currency. /// public bool UseGlobalCurrency { get; set; } /// - /// Used for Mewdekos Api for dashboard requests. + /// Gets or sets the API key used for the bot's API. /// - public string? ApiKey { get; set; } = ""; + public string ApiKey { get; set; } = ""; /// - /// Used for turnstile captcha on the dashboard for giveaways, may be used for other stuff, who knows + /// Gets or sets the Turnstile key used for captcha verification. /// public string TurnstileKey { get; set; } = ""; /// - /// Gets or sets the URL for votes. - /// - public string VotesUrl { get; set; } - - /// - /// Gets or sets the token for bot lists. - /// - public string BotListToken { get; set; } - - /// - /// The IPs to use with redis, use a ; seperated list for multiple + /// Gets or sets the Redis connection strings, separated by semicolons for multiple connections. /// public string RedisConnections { get; set; } - /// - /// Gets or sets the API key for Coinmarketcap. - /// - public string CoinmarketcapApiKey { get; set; } - /// /// Gets or sets the ID of the debug guild. /// @@ -155,7 +197,7 @@ public BotCredentials() public ulong PronounAbuseReportChannelId { get; set; } /// - /// Gets or sets whether the bot should migrate to PostgreSQL. + /// Gets or sets a value indicating whether to migrate to PostgreSQL. /// public bool MigrateToPsql { get; set; } @@ -170,97 +212,92 @@ public BotCredentials() public string ClientSecret { get; set; } /// - /// Gets or sets the port the client coordinator is running on. - /// - public string ShardRunPort { get; set; } - - /// - /// Gets or sets the bot's Google API key. + /// Gets or sets the Google API key. /// public string GoogleApiKey { get; set; } /// - /// Gets or sets the bot's Spotify client ID. + /// Gets or sets the Spotify client ID. /// public string SpotifyClientId { get; set; } /// - /// Gets or sets the bot's Spotify client secret. + /// Gets or sets the Spotify client secret. /// public string SpotifyClientSecret { get; set; } /// - /// Gets or sets the bot's Mashape key. + /// Gets or sets the Mashape (now RapidAPI) key. /// public string MashapeKey { get; set; } /// - /// Gets or sets the bot's Statcord key. + /// Gets or sets the Statcord key used for bot statistics. /// public string StatcordKey { get; set; } /// - /// Gets or sets the bot's clearance cookie for Cloudflare. + /// Gets or sets the Cloudflare clearance token. /// public string CfClearance { get; set; } /// - /// Gets or sets the bot's user agent used for bypassing Cloudflare. + /// Gets or sets the user agent string used for web requests. /// public string UserAgent { get; set; } /// - /// Gets or sets the bot's CSRF token used for bypassing Cloudflare. + /// Gets or sets the CSRF token. /// public string CsrfToken { get; set; } /// - /// Gets or sets the url for the Lavalink server. + /// Gets or sets the URL of the Lavalink server. /// public string LavalinkUrl { get; set; } /// - /// Gets or sets last.fm API key. + /// Gets or sets the Last.fm API key. /// public string LastFmApiKey { get; set; } /// - /// Gets or sets last.fm API secret. + /// Gets or sets the Last.fm API secret. /// public string LastFmApiSecret { get; set; } /// - /// Gets or sets the bot's owner IDs. + /// Gets or sets the list of owner IDs. /// public ImmutableArray OwnerIds { get; set; } /// - /// Gets or sets the bot's osu! API key. + /// Gets or sets the osu! API key. /// public string OsuApiKey { get; set; } /// - /// Gets or sets the key used for the bot's Cleverbot integration. + /// Gets or sets the Cleverbot API key. /// public string CleverbotApiKey { get; set; } /// - /// Gets or sets the command used to restart the bot. + /// Gets or sets the configuration for the restart command. /// public RestartConfig RestartCommand { get; set; } /// - /// Gets or sets the bot's total number of shards. + /// Gets or sets the total number of shards. /// public int TotalShards { get; set; } /// - /// Gets or sets where the bot should save chat logs. + /// Gets or sets the path where chat logs are saved. /// public string ChatSavePath { get; set; } /// - /// The url used for giveaway captchas + /// Gets or sets the URL used for giveaway entries. /// public string GiveawayEntryUrl { get; set; } @@ -280,7 +317,7 @@ public BotCredentials() public string TrovoClientId { get; set; } /// - /// Gets or sets the token for votes. + /// Gets or sets the token used for votes. /// public string VotesToken { get; set; } @@ -290,29 +327,24 @@ public BotCredentials() public string RedisOptions { get; set; } /// - /// Gets or sets the API key for LocationIQ. + /// Gets or sets the LocationIQ API key. /// public string LocationIqApiKey { get; set; } /// - /// Gets or sets the API key for TimezoneDB. + /// Gets or sets the TimezoneDB API key. /// public string TimezoneDbApiKey { get; set; } /// - /// Gets or sets the Genius API key. - /// - public string GeniusKey { get; set; } - - /// - /// The http port for the api + /// Gets or sets the port used for the API. /// public int ApiPort { get; set; } = 5001; /// - /// Used for debugging the mewdeko api and not needing a key every time + /// Gets or sets a value indicating whether to skip API key verification. /// - public bool SkipApiKey { get; set; } + public bool SkipApiKey { get; set; } = false; /// /// Gets or sets the ID of the channel where confession reports are sent. @@ -320,13 +352,13 @@ public BotCredentials() public ulong ConfessionReportChannelId { get; set; } /// - /// Checks if the specified user is an owner of the bot. + /// Checks if the specified user is an owner. /// /// The user to check. - /// True if the user is an owner; otherwise, false. + /// true if the user is an owner; otherwise, false. public bool IsOwner(IUser u) => OwnerIds.Contains(u.Id); - private void UpdateCredentials(object ae, FileSystemEventArgs _) + private void UpdateCredentials(object sender, FileSystemEventArgs e) { try { @@ -337,7 +369,9 @@ private void UpdateCredentials(object ae, FileSystemEventArgs _) var data = configBuilder.Build(); Token = data[nameof(Token)]; - OwnerIds = [..data.GetSection("OwnerIds").GetChildren().Select(c => ulong.Parse(c.Value))]; + OwnerIds = data.GetSection(nameof(OwnerIds)).GetChildren() + .Select(c => ulong.Parse(c.Value)) + .ToImmutableArray(); TurnstileKey = data[nameof(TurnstileKey)]; GiveawayEntryUrl = data[nameof(GiveawayEntryUrl)]; GoogleApiKey = data[nameof(GoogleApiKey)]; @@ -347,39 +381,31 @@ private void UpdateCredentials(object ae, FileSystemEventArgs _) ApiKey = data[nameof(ApiKey)]; UserAgent = data[nameof(UserAgent)]; CfClearance = data[nameof(CfClearance)]; - ApiPort = int.TryParse(data[nameof(ApiPort)], out var port) ? port : 0; + ApiPort = int.TryParse(data[nameof(ApiPort)], out var port) ? port : 5001; LastFmApiKey = data[nameof(LastFmApiKey)]; LastFmApiSecret = data[nameof(LastFmApiSecret)]; MashapeKey = data[nameof(MashapeKey)]; OsuApiKey = data[nameof(OsuApiKey)]; TwitchClientId = data[nameof(TwitchClientId)]; TwitchClientSecret = data[nameof(TwitchClientSecret)]; - SkipApiKey = bool.Parse(data[nameof(SkipApiKey)]); + SkipApiKey = bool.Parse(data[nameof(SkipApiKey)] ?? "false"); LavalinkUrl = data[nameof(LavalinkUrl)]; TrovoClientId = data[nameof(TrovoClientId)]; ShardRunCommand = data[nameof(ShardRunCommand)]; ShardRunArguments = data[nameof(ShardRunArguments)]; - ShardRunPort = data[nameof(ShardRunPort)] ?? "3444"; CleverbotApiKey = data[nameof(CleverbotApiKey)]; LocationIqApiKey = data[nameof(LocationIqApiKey)]; - TimezoneDbApiKey = data[nameof(TimezoneDbApiKey)]; - CoinmarketcapApiKey = data[nameof(CoinmarketcapApiKey)]; SpotifyClientId = data[nameof(SpotifyClientId)]; SpotifyClientSecret = data[nameof(SpotifyClientSecret)]; StatcordKey = data[nameof(StatcordKey)]; ChatSavePath = data[nameof(ChatSavePath)]; ClientSecret = data[nameof(ClientSecret)]; - if (string.IsNullOrWhiteSpace(CoinmarketcapApiKey)) - CoinmarketcapApiKey = "e79ec505-0913-439d-ae07-069e296a6079"; - GeniusKey = data[nameof(GeniusKey)]; RedisOptions = !string.IsNullOrWhiteSpace(data[nameof(RedisOptions)]) ? data[nameof(RedisOptions)] : "127.0.0.1,syncTimeout=3000"; VotesToken = data[nameof(VotesToken)]; - VotesUrl = data[nameof(VotesUrl)]; - BotListToken = data[nameof(BotListToken)]; var restartSection = data.GetSection(nameof(RestartCommand)); var cmd = restartSection["cmd"]; @@ -389,27 +415,18 @@ private void UpdateCredentials(object ae, FileSystemEventArgs _) if (Environment.OSVersion.Platform == PlatformID.Unix) { - if (string.IsNullOrWhiteSpace(ShardRunCommand)) - ShardRunCommand = "dotnet"; - if (string.IsNullOrWhiteSpace(ShardRunArguments)) - ShardRunArguments = "run -c Release --no-build -- {0} {1}"; + ShardRunCommand ??= "dotnet"; + ShardRunArguments ??= "run -c Release --no-build -- {0} {1}"; } - else //windows + else // Windows { - if (string.IsNullOrWhiteSpace(ShardRunCommand)) - ShardRunCommand = "Mewdeko.exe"; - if (string.IsNullOrWhiteSpace(ShardRunArguments)) - ShardRunArguments = "{0} {1}"; + ShardRunCommand ??= "Mewdeko.exe"; + ShardRunArguments ??= "{0} {1}"; } - if (!int.TryParse(data[nameof(TotalShards)], out var ts)) - ts = 0; - TotalShards = ts < 1 ? 1 : ts; + TotalShards = int.TryParse(data[nameof(TotalShards)], out var ts) && ts > 0 ? ts : 1; - CarbonKey = data[nameof(CarbonKey)]; - - TwitchClientId = data[nameof(TwitchClientId)]; - if (string.IsNullOrWhiteSpace(TwitchClientId)) TwitchClientId = "67w6z9i09xv2uoojdm9l0wsyph4hxo6"; + TwitchClientId = data[nameof(TwitchClientId)] ?? "67w6z9i09xv2uoojdm9l0wsyph4hxo6"; RedisConnections = data[nameof(RedisConnections)]; DebugGuildId = ulong.TryParse(data[nameof(DebugGuildId)], out var dgid) ? dgid : 843489716674494475; @@ -429,48 +446,63 @@ private void UpdateCredentials(object ae, FileSystemEventArgs _) if (string.IsNullOrWhiteSpace(Token)) { - Log.Error( - "Token is missing from credentials.json or Environment variables. Add it and restart the program"); + Log.Error("Token is missing from credentials.json or Environment variables. Add it and restart the program"); Helpers.ReadErrorAndExit(5); } if (string.IsNullOrWhiteSpace(PsqlConnectionString)) { - Log.Error("Postgres connection string is missing. Please add and restart."); + Log.Error("PostgreSQL connection string is missing. Please add it and restart."); Helpers.ReadErrorAndExit(5); } + else + { + // Check if PostgreSQL connection string is valid + try + { + using var conn = new NpgsqlConnection(PsqlConnectionString); + conn.Open(); + conn.Close(); + Log.Information("Successfully connected to PostgreSQL database."); + } + catch (Exception ex) + { + Log.Error("Failed to connect to PostgreSQL database with the provided connection string."); + Log.Error(ex.Message); + Helpers.ReadErrorAndExit(6); + } + } if (string.IsNullOrWhiteSpace(RedisConnections)) { - Log.Error("Redis connection string is missing. Please add and restart."); + Log.Error("Redis connection string is missing. Please add it and restart."); Helpers.ReadErrorAndExit(5); } - - switch (ApiPort) + else { - case 0 or < 0: - Log.Error("Invalid Api Port specified, Please change and restart."); - - Helpers.ReadErrorAndExit(5); - break; - case > 65535: - Log.Error("Maximum port number is 65535. Lower your port value and restart."); - break; + // Check if Redis is running + try + { + var connect = ConnectionMultiplexer.Connect(RedisConnections.Split(";")[0]); + connect.Close(); + Log.Information("Successfully connected to Redis."); + } + catch + { + Log.Error("Redis is not running! Make sure it's installed and running, then restart the bot."); + Helpers.ReadErrorAndExit(6); + } } - try - { - var connect = ConnectionMultiplexer.Connect(RedisConnections.Split(";")[0]); - connect.Close(); - } - catch + if (ApiPort <= 0 || ApiPort > 65535) { - Log.Error("Redis is not running! Make sure its installed and running then restart the bot"); + Log.Error("Invalid API Port specified. Please change it to a value between 1 and 65535 and restart."); + Helpers.ReadErrorAndExit(5); } } catch (Exception ex) { - Log.Error("JSON serialization has failed. Fix your credentials file and restart the bot"); + Log.Error("An error occurred while loading the credentials. Please fix your credentials file and restart the bot."); Log.Fatal(ex.ToString()); Helpers.ReadErrorAndExit(6); } @@ -481,79 +513,51 @@ private void UpdateCredentials(object ae, FileSystemEventArgs _) /// private class CredentialsModel : IBotCredentials { - public List OwnerIds { get; set; } = [280835732728184843, 786375627892064257]; - - public ulong[] OfficialMods { get; set; } = - [ - 280835732728184843, 786375627892064257 - ]; - + public List OwnerIds { get; set; } = new List { 280835732728184843, 786375627892064257 }; public bool UseGlobalCurrency { get; set; } = false; - - public string SoundCloudClientId { get; set; } = ""; - public string RestartCommand { get; set; } = null; - - public string CarbonKey { get; } = ""; - public string PatreonAccessToken { get; } = ""; - public string PatreonCampaignId { get; } = "334038"; - public string RedisConnections { get; } = "127.0.0.1:6379"; - - public string ShardRunCommand { get; } = ""; - public string ShardRunArguments { get; } = ""; - public string TurnstileKey { get; } = ""; - public string GiveawayEntryUrl { get; } = ""; - - public string BotListToken { get; set; } - public string VotesUrl { get; set; } - public string PsqlConnectionString { get; set; } = + public RestartConfig RestartCommand { get; set; } = null; + public string RedisConnections { get; set; } = "127.0.0.1:6379"; + public string TurnstileKey { get; set; } = ""; + public string GiveawayEntryUrl { get; set; } = ""; + public string PsqlConnectionString { get; set; } = "Server=ServerIp;Database=DatabaseName;Port=PsqlPort;UID=PsqlUser;Password=UserPassword"; - - public string ApiKey { get; set; } - - public string CoinmarketcapApiKey { get; set; } - + public string ApiKey { get; set; } = StringExtensions.GenerateSecureString(90); public ulong DebugGuildId { get; set; } = 843489716674494475; public ulong GuildJoinsChannelId { get; set; } = 892789588739891250; public ulong GlobalBanReportChannelId { get; set; } = 905109141620682782; public ulong PronounAbuseReportChannelId { get; set; } = 970086914826858547; public bool MigrateToPsql { get; set; } = false; - - public string LastFmApiKey { get; set; } - public string LastFmApiSecret { get; set; } - + public string LastFmApiKey { get; set; } = ""; + public string LastFmApiSecret { get; set; } = ""; public string Token { get; set; } = ""; - public string ClientSecret { get; } = ""; - public string GeniusKey { get; set; } - public string CfClearance { get; set; } - public string UserAgent { get; set; } - public string CsrfToken { get; set; } + public string ClientSecret { get; set; } = ""; + public string CfClearance { get; set; } = ""; + public string UserAgent { get; set; } = ""; + public string CsrfToken { get; set; } = ""; public string LavalinkUrl { get; set; } = "http://localhost:2333"; public string SpotifyClientId { get; set; } = ""; public string SpotifyClientSecret { get; set; } = ""; public string StatcordKey { get; set; } = ""; - public string ShardRunPort { get; set; } = "3444"; - - public string GoogleApiKey { get; } = ""; - public string MashapeKey { get; } = ""; - public string OsuApiKey { get; } = ""; - public string TrovoClientId { get; } = ""; - public string TwitchClientId { get; } = ""; - public string CleverbotApiKey { get; } = ""; - public int TotalShards { get; } = 1; - public string TwitchClientSecret { get; set; } - public string VotesToken { get; set; } - public string RedisOptions { get; set; } - public string LocationIqApiKey { get; set; } - public string TimezoneDbApiKey { get; set; } + public string GoogleApiKey { get; set; } = ""; + public string MashapeKey { get; set; } = ""; + public string OsuApiKey { get; set; } = ""; + public string TrovoClientId { get; set; } = ""; + public string TwitchClientId { get; set; } = ""; + public string CleverbotApiKey { get; set; } = ""; + public int TotalShards { get; set; } = 1; + public string TwitchClientSecret { get; set; } = ""; + public string VotesToken { get; set; } = ""; + public string RedisOptions { get; set; } = "127.0.0.1,syncTimeout=3000"; + public string LocationIqApiKey { get; set; } = ""; + public string TimezoneDbApiKey { get; set; } = ""; public ulong ConfessionReportChannelId { get; set; } = 942825117820530709; public string ChatSavePath { get; set; } = "/usr/share/nginx/cdn/chatlogs/"; + public int ApiPort { get; set; } = 5001; + public bool SkipApiKey { get; set; } = false; [JsonIgnore] - ImmutableArray IBotCredentials.OwnerIds { get; } - - [JsonIgnore] - RestartConfig IBotCredentials.RestartCommand { get; } + ImmutableArray IBotCredentials.OwnerIds => OwnerIds.ToImmutableArray(); - public bool IsOwner(IUser u) => throw new NotImplementedException(); + public bool IsOwner(IUser u) => OwnerIds.Contains(u.Id); } -} \ No newline at end of file +}