diff --git a/.github/workflows/deploy.sh b/.github/workflows/deploy.sh index 9d95cb57..aa8dd8ef 100644 --- a/.github/workflows/deploy.sh +++ b/.github/workflows/deploy.sh @@ -1,13 +1,21 @@ # Test that the new executable runs with the existing config. # Redirect any output just to make sure no json parsing errors or similar can leak secrets. # Then just replace the executable atomically using mv and restart the service. -if ./core_update testconfig >/dev/null 2>&1 ; then +testconfig_output=$(./core_update testconfig 2>&1) +testconfig_exitcode=$? +if [[ $testconfig_exitcode == 0 ]]; then \cp core core_deploybackup && \ mv core_update core && \ systemctl --user restart tpp-dualcore && \ echo "Successfully deployed!" + exit 0 +elif [[ $testconfig_exitcode == 42 ]]; then + # 42 = Arbitrary exit code to indicate a semantic error, see also Program.cs + echo "Failed to run 'testconfig' for new deployment, a semantic error occurred:" + echo testconfig_output + exit 1 else - echo "Failed to run 'testconfig' for new deployment." + echo "Failed to run 'testconfig' for new deployment, an uncaught exception occurred." echo "The output is suppressed to avoid leaking sensitive data, but this typically means the config file has syntactic or semantic errors." exit 1 fi diff --git a/TPP.Core/Chat/TwitchChat.cs b/TPP.Core/Chat/TwitchChat.cs index f176b271..4f8023db 100644 --- a/TPP.Core/Chat/TwitchChat.cs +++ b/TPP.Core/Chat/TwitchChat.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -10,7 +9,6 @@ using TPP.Core.Overlay; using TPP.Core.Utils; using TPP.Persistence; -using TwitchLib.Api.Auth; using User = TPP.Model.User; namespace TPP.Core.Chat @@ -25,7 +23,6 @@ public sealed class TwitchChat : IChat, IChatModeChanger, IExecutor public readonly TwitchApi TwitchApi; private readonly string _channelName; private readonly string _botUsername; - private readonly string _appClientId; private readonly TwitchChatSender _twitchChatSender; private readonly TwitchChatModeChanger _twitchChatModeChanger; private readonly TwitchChatExecutor _twitchChatExecutor; @@ -47,7 +44,6 @@ public TwitchChat( ChannelId = chatConfig.ChannelId; _channelName = chatConfig.Channel; _botUsername = chatConfig.Username; - _appClientId = chatConfig.AppClientId; TwitchApi = new TwitchApi( loggerFactory, @@ -76,78 +72,10 @@ private void MessageReceived(object? sender, MessageEventArgs args) IncomingMessage?.Invoke(this, args); } - private enum ScopeType { Bot, Channel, Both } - private record ScopeInfo(string Scope, string NeededFor, ScopeType ScopeType); - /// Mostly copied from TPP.Core's README.md - private static readonly List ScopeInfos = - [ - new ScopeInfo("chat:read", "Read messages from chat (via IRC/TMI)", ScopeType.Bot), - new ScopeInfo("chat:edit", "Send messages to chat (via IRC/TMI)", ScopeType.Bot), - new ScopeInfo("user:bot", "Appear in chat as bot", ScopeType.Bot), - new ScopeInfo("user:read:chat", "Read messages from chat. (via EventSub)", ScopeType.Bot), - new ScopeInfo("user:write:chat", "Send messages to chat. (via Twitch API)", ScopeType.Bot), - new ScopeInfo("user:manage:whispers", "Sending and receiving whispers", ScopeType.Bot), - new ScopeInfo("moderator:read:chatters", "Read the chatters list in the channel (e.g. for badge drops)", - ScopeType.Bot), - new ScopeInfo("moderator:read:followers", "Read the followers list (currently old core)", ScopeType.Bot), - new ScopeInfo("moderator:manage:banned_users", "Timeout, ban and unban users (tpp automod, mod commands)", - ScopeType.Bot), - new ScopeInfo("moderator:manage:chat_messages", "Delete chat messages (tpp automod, purge invalid bets)", - ScopeType.Bot), - new ScopeInfo("moderator:manage:chat_settings", "Change chat settings, e.g. emote-only mode (mod commands)", - ScopeType.Bot), - new ScopeInfo("channel:read:subscriptions", "Reacting to incoming subscriptions", ScopeType.Channel) - ]; - private static readonly Dictionary ScopeInfosPerScope = ScopeInfos - .ToDictionary(scopeInfo => scopeInfo.Scope, scopeInfo => scopeInfo); - - private async Task ValidateApiTokens() - { - _logger.LogDebug("Validating API access token..."); - ValidateAccessTokenResponse botTokenInfo = await TwitchApi.ValidateBot(); - _logger.LogInformation( - "Successfully got Twitch API bot access token info! Client-ID: {ClientID}, User-ID: {UserID}, " + - "Login: {Login}, Expires in: {Expires}s, Scopes: {Scopes}", botTokenInfo.ClientId, - botTokenInfo.UserId, botTokenInfo.Login, botTokenInfo.ExpiresIn, botTokenInfo.Scopes); - ValidateAccessTokenResponse channelTokenInfo = await TwitchApi.ValidateChannel(); - _logger.LogInformation( - "Successfully got Twitch API channel access token info! Client-ID: {ClientID}, User-ID: {UserID}, " + - "Login: {Login}, Expires in: {Expires}s, Scopes: {Scopes}", channelTokenInfo.ClientId, - channelTokenInfo.UserId, channelTokenInfo.Login, channelTokenInfo.ExpiresIn, channelTokenInfo.Scopes); - - // Validate correct usernames - if (!botTokenInfo.Login.Equals(_botUsername, StringComparison.InvariantCultureIgnoreCase)) - _logger.LogWarning("Bot token login '{Login}' does not match configured bot username '{Username}'", - botTokenInfo.Login, _botUsername); - if (!channelTokenInfo.Login.Equals(_channelName, StringComparison.InvariantCultureIgnoreCase)) - _logger.LogWarning("Channel token login '{Login}' does not match configured channel '{Channel}'", - botTokenInfo.Login, _channelName); - - // Validate correct Client-IDs - if (!botTokenInfo.ClientId.Equals(_appClientId, StringComparison.InvariantCultureIgnoreCase)) - _logger.LogWarning( - "Bot token Client-ID '{ClientID}' does not match configured App-Client-ID '{AppClientId}'. " + - "Did you create the token using the wrong App-Client-ID?", botTokenInfo.ClientId, _appClientId); - if (!channelTokenInfo.ClientId.Equals(_appClientId, StringComparison.InvariantCultureIgnoreCase)) - _logger.LogWarning( - "Channel token Client-ID '{ClientID}' does not match configured App-Client-ID '{AppClientId}'. " + - "Did you create the token using the wrong App-Client-ID?", channelTokenInfo.ClientId, _appClientId); - - // Validate Scopes - foreach ((string scope, ScopeInfo scopeInfo) in ScopeInfosPerScope) - { - if (scopeInfo.ScopeType == ScopeType.Bot && !botTokenInfo.Scopes.ToHashSet().Contains(scope)) - _logger.LogWarning("Missing Twitch-API scope '{Scope}' (bot), needed for: {NeededFor}", - scope, scopeInfo.NeededFor); - if (scopeInfo.ScopeType == ScopeType.Channel && !channelTokenInfo.Scopes.ToHashSet().Contains(scope)) - _logger.LogWarning("Missing Twitch-API scope '{Scope}' (channel), needed for: {NeededFor}", - scope, scopeInfo.NeededFor); - } - } - public async Task Start(CancellationToken cancellationToken) { - await ValidateApiTokens(); + foreach (string problem in await TwitchApi.DetectProblems(_botUsername, _channelName)) + _logger.LogWarning("TwitchAPI problem detected: {Problem}", problem); List tasks = []; tasks.Add(TwitchEventSubChat.Start(cancellationToken)); diff --git a/TPP.Core/Program.cs b/TPP.Core/Program.cs index 6b4c75b3..f369abbe 100644 --- a/TPP.Core/Program.cs +++ b/TPP.Core/Program.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -11,6 +12,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Schema.Generation; +using NodaTime; using Serilog; using Serilog.Events; using Serilog.Sinks.Discord; @@ -149,7 +151,8 @@ private static ILoggerFactory BuildLoggerFactory(BaseConfig baseConfig) => if (baseConfig.DiscordLoggingConfig != null) { builder.AddSerilog(new LoggerConfiguration() - .WriteTo.Discord(baseConfig.DiscordLoggingConfig.WebhookId, baseConfig.DiscordLoggingConfig.WebhookToken) + .WriteTo.Discord(baseConfig.DiscordLoggingConfig.WebhookId, + baseConfig.DiscordLoggingConfig.WebhookToken) .MinimumLevel.Is(baseConfig.DiscordLoggingConfig.MinLogLevel) .Filter.ByExcluding(logEvent => { @@ -183,8 +186,10 @@ private static void Mode(string modeName, string baseConfigFilename, string mode CancellationTokenSource cts = new(); IWithLifecycle mode = modeName switch { - "run" => new Runmode(loggerFactory, baseConfig, cts, () => ReadConfig(modeConfigFilename)), - "match" => new Matchmode(loggerFactory, baseConfig, cts, ReadConfig(modeConfigFilename)), + "run" => new Runmode(loggerFactory, baseConfig, cts, + () => ReadConfig(modeConfigFilename)), + "match" => new Matchmode(loggerFactory, baseConfig, cts, + ReadConfig(modeConfigFilename)), "dualcore" => new DualcoreMode(loggerFactory, baseConfig, cts), "dummy" => new DummyMode(loggerFactory, baseConfig), _ => throw new NotSupportedException($"Invalid mode '{modeName}'") @@ -227,20 +232,66 @@ private static void TestConfig( string? mode, string modeConfigFilename) { - // just try to read the configs, don't do anything with them + List errors = TestConfigCollectErrors(configFilename, mode, modeConfigFilename); + foreach (string error in errors) + Console.Error.WriteLine(error); + if (errors.Count > 0) + { + Environment.Exit(42); // Arbitrary exit code to indicate a semantic error, see also deploy.sh + } + } + + private static List TestConfigCollectErrors( + string configFilename, + string? mode, + string modeConfigFilename) + { + List errors = []; if (File.Exists(configFilename)) - ReadConfig(configFilename); + try + { + var baseConfig = ReadConfig(configFilename); + ILoggerFactory loggerFactory = BuildLoggerFactory(baseConfig); + foreach (var twitchConnection in baseConfig.Chat.Connections.OfType()) + { + var twitchApi = new TwitchApi(loggerFactory, SystemClock.Instance, + twitchConnection.InfiniteAccessToken, twitchConnection.RefreshToken, + twitchConnection.ChannelInfiniteAccessToken, twitchConnection.ChannelRefreshToken, + twitchConnection.AppClientId, twitchConnection.AppClientSecret); + List problems = twitchApi + .DetectProblems(twitchConnection.Username, twitchConnection.Channel).Result; + foreach (string problem in problems) + errors.Add($"TwitchAPI config issue for '{twitchConnection.Name}': {problem}"); + } + } + catch (JsonReaderException ex) + { + Console.Error.WriteLine(ex.Message); + errors.Add($"{configFilename}: JSON parsing failed at {ex.LineNumber}:{ex.LinePosition}. " + + $"Error message suppressed to avoid leaking any secrets, but printed to stderr"); + } else - Console.Error.WriteLine(MissingConfigErrorMessage(null, configFilename)); + errors.Add(MissingConfigErrorMessage(null, configFilename)); if (mode != null && ModeHasItsOwnConfig(mode)) { if (File.Exists(modeConfigFilename)) - ReadConfig(modeConfigFilename, DefaultConfigs[mode]!.GetType()); + try + { + ReadConfig(modeConfigFilename, DefaultConfigs[mode]!.GetType()); + } + catch (JsonReaderException ex) + { + Console.Error.WriteLine(ex.Message); + errors.Add($"{modeConfigFilename}: JSON parsing failed at {ex.LineNumber}:{ex.LinePosition}. " + + $"Error message suppressed to avoid leaking any secrets, but printed to stderr"); + } else - Console.Error.WriteLine(MissingConfigErrorMessage(mode, modeConfigFilename)); + errors.Add(MissingConfigErrorMessage(mode, modeConfigFilename)); } + + return errors; } private static void OutputDefaultConfig(string? modeName, ValueObject? outfileArgument) diff --git a/TPP.Core/TwitchApi.cs b/TPP.Core/TwitchApi.cs index c7207ff7..4d73eb7d 100644 --- a/TPP.Core/TwitchApi.cs +++ b/TPP.Core/TwitchApi.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using NodaTime; @@ -33,6 +34,7 @@ public class TwitchApi( loggerFactory, clock, botInfiniteAccessToken, botRefreshToken, appClientId, appClientSecret); private readonly TwitchApiProvider _channelTwitchApiProvider = new( loggerFactory, clock, channelInfiniteAccessToken, channelRefreshToken, appClientId, appClientSecret); + private readonly ILogger _logger = loggerFactory.CreateLogger(); private async Task Retrying(TwitchApiProvider apiProvider, Func> action) { @@ -73,9 +75,9 @@ await Retrying(apiProvider, async api => private Task RetryingChannel(Func action) => Retrying(_channelTwitchApiProvider, action); // Meta - public Task ValidateBot() => + public Task GetBotTokenInfo() => RetryingBot(api => api.Auth.ValidateAccessTokenAsync()); - public Task ValidateChannel() => + public Task GetChannelTokenInfo() => RetryingChannel(api => api.Auth.ValidateAccessTokenAsync()); // Chat (and whispers) @@ -86,8 +88,11 @@ public Task GetChatSettingsAsync(string broadcasterId, RetryingBot(api => api.Helix.Chat.GetChatSettingsAsync(broadcasterId, moderatorId)); public Task UpdateChatSettingsAsync(string broadcasterId, string moderatorId, ChatSettings settings) => RetryingBot(api => api.Helix.Chat.UpdateChatSettingsAsync(broadcasterId, moderatorId, settings)); - public Task SendChatMessage(string broadcasterId, string senderUserId, string message, string? replyParentMessageId = null) => - RetryingBot(api => api.Helix.Chat.SendChatMessage(broadcasterId, senderUserId, message, replyParentMessageId: replyParentMessageId)); + public Task SendChatMessage(string broadcasterId, string senderUserId, string message, + string? replyParentMessageId = null) => + RetryingBot(api => + api.Helix.Chat.SendChatMessage(broadcasterId, senderUserId, message, + replyParentMessageId: replyParentMessageId)); public Task SendWhisperAsync(string fromUserId, string toUserId, string message, bool newRecipient) => RetryingBot(api => api.Helix.Whispers.SendWhisperAsync(fromUserId, toUserId, message, newRecipient)); @@ -124,4 +129,74 @@ public Task SubscribeToEventSubChannel( EventSubTransportMethod.Websocket, websocketSessionId: sessionId)); public Task DeleteEventSubSubscriptionAsyncChannel(string subscriptionId) => RetryingChannel(api => api.Helix.EventSub.DeleteEventSubSubscriptionAsync(subscriptionId)); + + private enum ScopeType { Bot, Channel } + + private record ScopeInfo(string Scope, string NeededFor, ScopeType ScopeType) + { + public static ScopeInfo Bot(string scope, string neededFor) => new(scope, neededFor, ScopeType.Bot); + public static ScopeInfo Channel(string scope, string neededFor) => new(scope, neededFor, ScopeType.Channel); + } + + /// Mostly copied from TPP.Core's README.md + private static readonly List ScopeInfos = + [ + ScopeInfo.Bot("chat:read", "Read messages from chat (via IRC/TMI)"), + ScopeInfo.Bot("chat:edit", "Send messages to chat (via IRC/TMI)"), + ScopeInfo.Bot("user:bot", "Appear in chat as bot"), + ScopeInfo.Bot("user:read:chat", "Read messages from chat. (via EventSub)"), + ScopeInfo.Bot("user:write:chat", "Send messages to chat. (via Twitch API)"), + ScopeInfo.Bot("user:manage:whispers", "Sending and receiving whispers"), + ScopeInfo.Bot("moderator:read:chatters", "Read the chatters list in the channel (e.g. for badge drops)"), + ScopeInfo.Bot("moderator:read:followers", "Read the followers list (currently old core)"), + ScopeInfo.Bot("moderator:manage:banned_users", "Timeout, ban and unban users (tpp automod, mod commands)"), + ScopeInfo.Bot("moderator:manage:chat_messages", "Delete chat messages (tpp automod, purge invalid bets)"), + ScopeInfo.Bot("moderator:manage:chat_settings", "Change chat settings, e.g. emote-only mode (mod commands)"), + ScopeInfo.Channel("channel:read:subscriptions", "Reacting to incoming subscriptions") + ]; + private static readonly Dictionary ScopeInfosPerScope = ScopeInfos + .ToDictionary(scopeInfo => scopeInfo.Scope, scopeInfo => scopeInfo); + + public async Task> DetectProblems(string botUsername, string channelName) + { + _logger.LogDebug("Validating API access token..."); + ValidateAccessTokenResponse botTokenInfo = await GetBotTokenInfo(); + _logger.LogInformation( + "Successfully got Twitch API bot access token info! Client-ID: {ClientID}, User-ID: {UserID}, " + + "Login: {Login}, Expires in: {Expires}s, Scopes: {Scopes}", botTokenInfo.ClientId, + botTokenInfo.UserId, botTokenInfo.Login, botTokenInfo.ExpiresIn, botTokenInfo.Scopes); + ValidateAccessTokenResponse channelTokenInfo = await GetChannelTokenInfo(); + _logger.LogInformation( + "Successfully got Twitch API channel access token info! Client-ID: {ClientID}, User-ID: {UserID}, " + + "Login: {Login}, Expires in: {Expires}s, Scopes: {Scopes}", channelTokenInfo.ClientId, + channelTokenInfo.UserId, channelTokenInfo.Login, channelTokenInfo.ExpiresIn, channelTokenInfo.Scopes); + + List oofs = []; + + // Validate correct usernames + if (!botTokenInfo.Login.Equals(botUsername, StringComparison.InvariantCultureIgnoreCase)) + oofs.Add($"Bot token login '{botTokenInfo.Login}' does not match configured bot username '{botUsername}'"); + if (!channelTokenInfo.Login.Equals(channelName, StringComparison.InvariantCultureIgnoreCase)) + oofs.Add($"Channel token login '{botTokenInfo.Login}' does not match configured channel '{channelName}'"); + + // Validate correct Client-IDs + if (!botTokenInfo.ClientId.Equals(appClientId, StringComparison.InvariantCultureIgnoreCase)) + oofs.Add($"Bot token Client-ID '{botTokenInfo.ClientId}' does not match configured " + + $"App-Client-ID '{appClientId}'. Did you create the token using the wrong App-Client-ID?"); + if (!channelTokenInfo.ClientId.Equals(appClientId, StringComparison.InvariantCultureIgnoreCase)) + oofs.Add( + $"Channel token Client-ID '{channelTokenInfo.ClientId}' does not match configured " + + $"App-Client-ID '{appClientId}'. Did you create the token using the wrong App-Client-ID?"); + + // Validate Scopes + foreach ((string scope, ScopeInfo scopeInfo) in ScopeInfosPerScope) + { + if (scopeInfo.ScopeType == ScopeType.Bot && !botTokenInfo.Scopes.ToHashSet().Contains(scope)) + oofs.Add($"Missing Twitch-API scope '{scope}' (bot), needed for: {scopeInfo.NeededFor}"); + if (scopeInfo.ScopeType == ScopeType.Channel && !channelTokenInfo.Scopes.ToHashSet().Contains(scope)) + oofs.Add($"Missing Twitch-API scope '{scope}' (channel), needed for: {scopeInfo.NeededFor}"); + } + + return oofs; + } }