diff --git a/.dockerignore b/.dockerignore index 16b8590..6df6654 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,3 +20,7 @@ SECURITY.md # Project TwitchBot.csproj TwitchBot.sln + +# Configuration +twitch-bot.json +config.json diff --git a/.gitignore b/.gitignore index 478dfaf..4b12071 100644 --- a/.gitignore +++ b/.gitignore @@ -399,3 +399,4 @@ FodyWeavers.xsd # Configuration file twitch-bot.json +config.json diff --git a/Source/Config.cs b/Source/Config.cs deleted file mode 100644 index ebff634..0000000 --- a/Source/Config.cs +++ /dev/null @@ -1,223 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text.Json.Nodes; - -using viral32111.JsonExtensions; - -namespace TwitchBot; - -public static class Config { - - // Directories - public static readonly string DataDirectory; - public static readonly string CacheDirectory; - - // Twitch OAuth - public static readonly string TwitchOAuthBaseURL; - public static readonly string TwitchOAuthIdentifier; - public static readonly string? TwitchOAuthSecret; - public static readonly string TwitchOAuthRedirectURL; - public static readonly string[] TwitchOAuthScopes; - - // Twitch Chat IRC - public static readonly string TwitchChatBaseURL; - public static readonly int TwitchChatPrimaryChannelIdentifier; - - // Twitch API - public static readonly string TwitchAPIBaseURL; - - // Twitch EventSub - public static readonly string TwitchEventSubWebSocketURL; - - // Cloudflare Tunnel client - public static readonly string CloudflareTunnelVersion; - public static readonly string CloudflareTunnelChecksum; - - // Database - public static readonly string DatabaseName; - public static readonly string DatabaseServerAddress; - public static readonly int DatabaseServerPort; - public static readonly string DatabaseUserName; - public static readonly string DatabaseUserPassword; - - // Redis - public static readonly int RedisDatabase; - public static readonly string RedisKeyPrefix; - public static readonly string RedisServerAddress; - public static readonly int RedisServerPort; - public static readonly string RedisUserName; - public static readonly string RedisUserPassword; - - // Loads the configuration when the program is started - static Config() { - - // Get the command-line arguments (excluding flags & executable path) - string[] arguments = Environment.GetCommandLineArgs().ToList().FindAll( value => !value.StartsWith( "--" ) ).Skip( 1 ).ToArray(); - - // Use the first argument as the configuration file path, or default to a file in the current working directory - string configFilePath = arguments.Length > 0 ? arguments[ 0 ] : Path.Combine( Directory.GetCurrentDirectory(), "twitch-bot.json" ); - - // Will hold the loaded (or newly created) configuration - JsonObject? configuration; - - // Try to load the configuration from the above file path - try { - configuration = JsonExtensions.ReadFromFile( configFilePath ); - Log.Info( "Loaded configuration from file: '{0}'.", configFilePath ); - - // Ensure that no properties are missing (this adds new properties to older configuration files) - try { - EnsurePropertiesExist( configuration, defaultConfiguration ); - } catch ( Exception exception ) { - Log.Error( exception.Message ); - Environment.Exit( 1 ); - return; - } - - // Resave the file to save any new properties that might have been added - configuration.SaveToFile(); - - // Otherwise, create it with default values in the above file path - } catch ( FileNotFoundException ) { - configuration = JsonExtensions.CreateNewFile( configFilePath, defaultConfiguration ); - Log.Info( "Created default configuration in file: '{0}'.", configFilePath ); - } - - // Fail if the configuration is invalid - if ( configuration == null ) throw new Exception( "Configuration is invalid" ); - - // Try to populate the configuration properties - try { - - // Directories - // NOTE: Environment variables such as %APPDATA% on Windows are parsed here - DataDirectory = Environment.ExpandEnvironmentVariables( configuration.NestedGet( "directory.data" ) ); - CacheDirectory = Environment.ExpandEnvironmentVariables( configuration.NestedGet( "directory.cache" ) ); - - // Twitch OAuth, except from the client secret - TwitchOAuthBaseURL = configuration.NestedGet( "twitch.oauth.url" ); - TwitchOAuthIdentifier = configuration.NestedGet( "twitch.oauth.identifier" ); - TwitchOAuthRedirectURL = configuration.NestedGet( "twitch.oauth.redirect" ); - TwitchOAuthScopes = configuration.NestedGet( "twitch.oauth.scopes" )!.AsArray(); - - // Twitch Chat IRC - TwitchChatBaseURL = configuration.NestedGet( "twitch.chat.url" ); - TwitchChatPrimaryChannelIdentifier = configuration.NestedGet( "twitch.chat.channel" ); // This changed from a string (channel name) to an integer (channel id) in 0.5.0 - - // Twitch API - TwitchAPIBaseURL = configuration.NestedGet( "twitch.api.url" ); - - // Twitch EventSub - TwitchEventSubWebSocketURL = configuration.NestedGet( "twitch.eventsub.websocket.url" ); - - // Cloudflare Tunnel client - CloudflareTunnelVersion = configuration.NestedGet( "cloudflare.tunnel.version" ); - CloudflareTunnelChecksum = configuration.NestedGet( "cloudflare.tunnel.checksum" ); - - // Database - DatabaseName = configuration.NestedGet( "database.name" ); - DatabaseServerAddress = configuration.NestedGet( "database.server.address" ); - DatabaseServerPort = configuration.NestedGet( "database.server.port" ); - DatabaseUserName = configuration.NestedGet( "database.user.name" ); - DatabaseUserPassword = configuration.NestedGet( "database.user.password" ); - - // Redis - RedisDatabase = configuration.NestedGet( "redis.database" ); - RedisKeyPrefix = configuration.NestedGet( "redis.prefix" ); - RedisServerAddress = configuration.NestedGet( "redis.server.address" ); - RedisServerPort = configuration.NestedGet( "redis.server.port" ); - RedisUserName = configuration.NestedGet( "redis.user.name" ); - RedisUserPassword = configuration.NestedGet( "redis.user.password" ); - - // Fallback to the user secrets store if the Twitch OAuth secret is not in the configuration file - string? twitchOAuthSecretConfig = configuration.NestedGet( "twitch.oauth.secret" )?.AsValue().ToString(); - TwitchOAuthSecret = !string.IsNullOrEmpty( twitchOAuthSecretConfig ) ? twitchOAuthSecretConfig : UserSecrets.TwitchOAuthSecret; - - // Fail if any errors happen while attempting to populate the configuration properties - } catch ( Exception exception ) { - Log.Error( exception.Message ); - Environment.Exit( 1 ); - } - - } - - // Ensures all properties in the second object exist in the first object - private static void EnsurePropertiesExist( JsonObject badObject, JsonObject goodObject ) { - - // Loop through a list of all nested properties in the second object - foreach ( string propertyPath in goodObject.NestedList() ) { - - // Skip properties which exist in the first object - if ( badObject.NestedHas( propertyPath ) ) continue; - - // Add the property to the first object with the value from the second object - badObject.NestedSet( propertyPath, goodObject.NestedGet( propertyPath ).Clone() ); - Log.Info( "Added missing property '{0}' to configuration file.", propertyPath ); - - } - - } - - // The default configuration structure - private static readonly JsonObject defaultConfiguration = new() { - [ "directory" ] = new JsonObject() { - [ "data" ] = Shared.IsWindows() ? "%LOCALAPPDATA%\\TwitchBot" : "/var/lib/twitch-bot", - [ "cache" ] = Shared.IsWindows() ? "%TEMP%\\TwitchBot" : "/var/cache/twitch-bot", - }, - [ "twitch" ] = new JsonObject() { - [ "oauth" ] = new JsonObject() { - [ "url" ] = "id.twitch.tv/oauth2", - [ "identifier" ] = "", - [ "secret" ] = "", - [ "redirect" ] = "", - [ "scopes" ] = new JsonArray( new JsonNode[] { - JsonValue.Create( "chat:read" )!, - JsonValue.Create( "chat:edit" )! - } ), - }, - [ "chat" ] = new JsonObject() { - [ "url" ] = "irc.chat.twitch.tv", - [ "channel" ] = 0, - }, - [ "api" ] = new JsonObject() { - [ "url" ] = "api.twitch.tv/helix" - }, - [ "eventsub" ] = new JsonObject() { - [ "websocket" ] = new JsonObject() { - [ "url" ] = "eventsub-beta.wss.twitch.tv/ws" - } - } - }, - [ "cloudflare" ] = new JsonObject() { - [ "tunnel" ] = new JsonObject() { - [ "version" ] = "2022.8.2", - [ "checksum" ] = Shared.IsWindows() ? "61ed94712c1bfbf585c06de5fea82588662daeeb290727140cf2b199ca9f9c53" : "c971d24ae2f133b2579ac6fa3b1af34847e0f3e332766fbdc5f36521f410271a" - } - }, - [ "database" ] = new JsonObject() { - [ "name" ] = "", - [ "server" ] = new JsonObject() { - [ "address" ] = "", - [ "port" ] = 3306 - }, - [ "user" ] = new JsonObject() { - [ "name" ] = "", - [ "password" ] = "" - } - }, - [ "redis" ] = new JsonObject() { - [ "database" ] = 0, - [ "prefix" ] = "twitchbot:", - [ "server" ] = new JsonObject() { - [ "address" ] = "", - [ "port" ] = 6379 - }, - [ "user" ] = new JsonObject() { - [ "name" ] = "default", - [ "password" ] = "" - } - } - }; - -} diff --git a/Source/Configuration.cs b/Source/Configuration.cs new file mode 100644 index 0000000..37515e3 --- /dev/null +++ b/Source/Configuration.cs @@ -0,0 +1,246 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text.Json.Serialization; + +using Microsoft.Extensions.Configuration; + +namespace TwitchBot; + +/// +/// Represents the configuration. +/// +public class Configuration { + + // Defaults + private const string fileName = "config.json"; + private const string windowsDirectoryName = "TwitchBot"; + private const string linuxDirectoryName = "twitch-bot"; + private const string environmentVariablePrefix = "TWITCH_BOT_"; + + /// + /// Indicates whether the configuration has been loaded. + /// + [ JsonIgnore ] + public bool IsLoaded { get; private set; } = false; + + /// + /// Gets the path to the user's configuration file. + /// On Windows, it is %LOCALAPPDATA%\TwitchBot\config.json. + /// On Linux, it is ~/.config/twitch-bot/config.json. + /// + /// The name of the configuration file. + /// The name of the directory on Windows. + /// The name of the directory on Linux. + /// The path to the user's configuration file. + /// Thrown if the operating system is not handled. + [ SupportedOSPlatform( "windows" ) ] + [ SupportedOSPlatform( "linux" ) ] + public static string GetUserPath( string fileName, string windowsDirectoryName, string linuxDirectoryName ) { + if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { + return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData ), windowsDirectoryName, fileName ); + } else if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), linuxDirectoryName, fileName ); + } else throw new PlatformNotSupportedException( "Unhandled operating system" ); + } + + /// + /// Gets the path to the system configuration file. + /// On Windows, it is: C:\ProgramData\TwitchBot\config.json. + /// On Linux, it is: /etc/twitch-bot/config.json. + /// + /// The name of the configuration file. + /// The name of the directory on Windows. + /// The name of the directory on Linux. + /// The path to the system configuration file. + /// Thrown if the operating system is not handled. + [ SupportedOSPlatform( "windows" ) ] + [ SupportedOSPlatform( "linux" ) ] + public static string GetSystemPath( string fileName, string windowsDirectoryName, string linuxDirectoryName ) { + if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { + return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.CommonApplicationData ), windowsDirectoryName, fileName ); + } else if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + return Path.Combine( "/etc", linuxDirectoryName, fileName ); // No enumeration for /etc + } else throw new PlatformNotSupportedException( "Unsupported operating system" ); + } + + /// + /// Loads the configuration from all sources. + /// The priority is: System Configuration File -> User Configuration File -> Project Configuration File -> Environment Variables. + /// + /// The name of the configuration file. + /// The name of the directory on Windows. + /// The name of the directory on Linux. + /// The prefix of the environment variables. + /// + public static Configuration Load( + string fileName = fileName, + string windowsDirectoryName = windowsDirectoryName, + string linuxDirectoryName = linuxDirectoryName, + string environmentVariablePrefix = environmentVariablePrefix + ) { + IConfigurationRoot root = new ConfigurationBuilder() + .AddJsonFile( GetSystemPath( fileName, windowsDirectoryName, linuxDirectoryName ), true, false ) + .AddJsonFile( GetUserPath( fileName, windowsDirectoryName, linuxDirectoryName ), true, false ) + .AddEnvironmentVariables( environmentVariablePrefix ) + .Build(); + + Configuration configuration = root.Get() ?? throw new LoadException( "Failed to load configuration, are there malformed/missing properties?" ); + configuration.IsLoaded = true; + + return configuration; + } + + /// + /// Thrown when loading the configuration fails. + /// + public class LoadException : Exception { + public LoadException( string? message ) : base( message ) {} + public LoadException( string? message, Exception? innerException ) : base( message, innerException ) {} + } + + /****************************/ + /* Configuration Properties */ + /****************************/ + + /// + /// The directory where persistent data is stored, such as OAuth tokens. + /// On Windows, it should be: C:\ProgramData\TwitchBot\data, or: %LOCALAPPDATA%\TwitchBot\data. + /// On Linux, it is: /var/lib/twitch-bot, or: ~/.local/twitch-bot. + /// + [ JsonPropertyName( "data-directory" ) ] + public string DataDirectory { + get { + if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { + return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.CommonApplicationData ), windowsDirectoryName, "data" ); + } else if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), linuxDirectoryName, "data" ); + } else return Path.Combine( Environment.CurrentDirectory, "data" ); + } + + private set { + DataDirectory = value; + } + } + + /// + /// The directory where volatile cache is stored, such as bot state. + /// On Windows, it should be: %TEMP%\TwitchBot. + /// On Linux, it should: /tmp/twitch-bot. + /// + [ JsonPropertyName( "cache-directory" ) ] + public string CacheDirectory { + get { + if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { + return Path.Combine( Path.GetTempPath(), windowsDirectoryName ); + } else if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + return Path.Combine( "/tmp", linuxDirectoryName ); // No enumeration for /tmp + } else return Path.Combine( Environment.CurrentDirectory, "cache" ); + } + + private set { + DataDirectory = value; + } + } + + /// + /// The base URL of the Twitch OAuth API. + /// https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow + /// + [ JsonPropertyName( "twitch-oauth-base-url" ) ] + public static readonly string TwitchOAuthBaseURL = "https://id.twitch.tv/oauth2"; + + /// + /// The client identifier of the Twitch OAuth application. + /// https://dev.twitch.tv/docs/authentication/register-app/ + /// + [ JsonPropertyName( "twitch-oauth-client-identifier" ) ] + public static readonly string TwitchOAuthClientIdentifier = ""; + + /// + /// The client secret of the Twitch OAuth application. + /// https://dev.twitch.tv/docs/authentication/register-app/ + /// + [ JsonPropertyName( "twitch-oauth-client-secret" ) ] + public static readonly string TwitchOAuthClientSecret = ""; + + /// + /// The redirect URL of the Twitch OAuth application. + /// https://dev.twitch.tv/docs/authentication/register-app/ + /// + [ JsonPropertyName( "twitch-oauth-redirect-url" ) ] + public static readonly string TwitchOAuthRedirectURL = "https://example.com/my-redirect-handler"; + + /// + /// The scopes to request on behalf of the Twitch OAuth application. + /// https://dev.twitch.tv/docs/authentication/scopes/ + /// + [ JsonPropertyName( "twitch-oauth-scopes" ) ] + public static readonly string[] TwitchOAuthScopes = new[] { "chat:read", "chat:edit" }; + + /// + /// The IP address of the Twitch chat IRC server. + /// https://dev.twitch.tv/docs/irc/#connecting-to-the-twitch-irc-server + /// + [ JsonPropertyName( "twitch-chat-address" ) ] + public static readonly string TwitchChatAddress = "irc.chat.twitch.tv"; + + /// + /// The port number of the Twitch chat IRC server. + /// https://dev.twitch.tv/docs/irc/#connecting-to-the-twitch-irc-server + /// + [ JsonPropertyName( "twitch-chat-port" ) ] + public static readonly int TwitchChatPort = 6697; + + /// + /// The identifier of the primary Twitch channel. + /// + [ JsonPropertyName( "twitch-primary-channel-identifier" ) ] + public static readonly int TwitchPrimaryChannelIdentifier = 127154290; + + /// + /// The base URL of the Twitch API. + /// https://dev.twitch.tv/docs/api/ + /// + [ JsonPropertyName( "twitch-api-base-url" ) ] + public static readonly string TwitchAPIBaseURL = "https://api.twitch.tv/helix"; + + /// + /// The URL of the Twitch EventSub WebSocket. + /// https://dev.twitch.tv/docs/eventsub/handling-websocket-events/ + /// + [ JsonPropertyName( "twitch-events-websocket-url" ) ] + public static readonly string TwitchEventsWebSocketURL = "wss://eventsub.wss.twitch.tv/ws"; + + /// + /// The IP address of the MongoDB server. + /// + [ JsonPropertyName( "mongodb-server-address" ) ] + public static readonly string MongoDBServerAddress = "127.0.0.1"; + + /// + /// The port number of the MongoDB server. + /// + [ JsonPropertyName( "mongodb-server-port" ) ] + public static readonly int MongoDBServerPort = 27017; + + /// + /// The username of the MongoDB user. + /// + [ JsonPropertyName( "mongodb-user-name" ) ] + public static readonly string MongoDBUserName = ""; + + /// + /// The password of the MongoDB user. + /// + [ JsonPropertyName( "mongodb-user-password" ) ] + public static readonly string MongoDBUserPassword = ""; + + /// + /// The name of the MongoDB database. + /// + [ JsonPropertyName( "mongodb-database-name" ) ] + public static readonly string MongoDBDatabaseName = "twitch-bot"; + +} diff --git a/Source/Program.cs b/Source/Program.cs index 6c9a264..2cb410f 100644 --- a/Source/Program.cs +++ b/Source/Program.cs @@ -8,15 +8,21 @@ using MongoDB.Driver; +using viral32111.InternetRelayChat; + using TwitchBot.Database; using TwitchBot.Twitch; using TwitchBot.Twitch.OAuth; -using viral32111.InternetRelayChat; namespace TwitchBot; public class Program { + /// + /// Global instance of the configuration. + /// + public static Configuration Configuration { get; private set; } = new(); + // Windows-only [ DllImport( "Kernel32" ) ] private static extern bool SetConsoleCtrlHandler( EventHandler handler, bool add ); @@ -31,17 +37,20 @@ public static async Task Main( string[] arguments ) { // Display application name and version AssemblyName assemblyName = Assembly.GetExecutingAssembly().GetName(); - Log.Info( "This is {0}, version {1}.{2}.{3}.", assemblyName.Name, assemblyName.Version?.Major, assemblyName.Version?.Minor, assemblyName.Version?.Build ); + Log.Info( "Running version {1}.{2}.{3}.", assemblyName.Version?.Major, assemblyName.Version?.Minor, assemblyName.Version?.Build ); // Display directory paths for convenience - Log.Info( "Data directory is: '{0}'.", Config.DataDirectory ); - Log.Info( "Cache directory is: '{0}'.", Config.CacheDirectory ); + Log.Info( "Data directory is: '{0}'.", Configuration.DataDirectory ); + Log.Info( "Cache directory is: '{0}'.", Configuration.CacheDirectory ); // Create required directories Shared.CreateDirectories(); + Environment.Exit( 1 ); + return; + // Deprecation notice for the stream history file - string streamHistoryFile = Path.Combine( Config.DataDirectory, "stream-history.json" ); + string streamHistoryFile = Path.Combine( Configuration.DataDirectory, "stream-history.json" ); if ( File.Exists( streamHistoryFile ) ) Log.Warn( "The stream history file ({0}) is deprecated, it can safely be deleted.", streamHistoryFile ); // Exit now if this launch was only to initialize files diff --git a/TwitchBot.csproj b/TwitchBot.csproj index f9916e9..5b6fe5e 100644 --- a/TwitchBot.csproj +++ b/TwitchBot.csproj @@ -28,11 +28,19 @@ 1fe12478-094f-4416-a790-9119fb6e522e + + + Always + config.json + + + +